diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..9ceba3a2 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,26 @@ +changelog: + exclude: + labels: + - ignore-for-release + - skip-changelog + categories: + - title: Breaking changes + labels: + - breaking-change + - breaking + - title: Features + labels: + - enhancement + - feature + - title: Bug fixes + labels: + - bug + - bugfix + - title: Maintenance and dependencies + labels: + - maintenance + - dependencies + - chore + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36968461..9cb3c4bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,39 +1,151 @@ -name: Build release tarball +name: Build and test multiACE packages on: push: + branches: + - '**' tags: - 'v*' + pull_request: + workflow_dispatch: + inputs: + source_ref: + description: Branch, tag, or commit SHA to build as a test artifact (no release is published) + required: true + default: main + type: string permissions: - contents: write + contents: read jobs: - release: + validate: + runs-on: ubuntu-latest + outputs: + source_sha: ${{ steps.source.outputs.sha }} + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.sha }} + + - name: Record exact source commit + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate managed package sources + run: | + python -m py_compile \ + multiace/klipper/extras/ace.py \ + multiace/klipper/extras/ace_gen1_tunnel.py \ + multiace/web/backend/i18n_path.py \ + multiace/web/backend/main.py \ + multiace/tools/post_process_virtual_toolheads.py \ + multiace/managed/build_package.py \ + multiace/managed/tests/test_package.py \ + multiace/managed/tests/test_i18n_path.py + node --check multiace/web/frontend/app.js + bash -n \ + multiace/install_multiace.sh \ + multiace/uninstall_multiace.sh \ + multiace/tools/multiace_update.sh \ + multiace/config/extended/multiace/ace_mode_switch.sh \ + multiace/web/deploy/S98multiace-web + bash multiace/managed/tests/test_managed_guards.sh + bash multiace/managed/tests/test_updater_asset_selection.sh + python -m unittest discover -s multiace/managed/tests -v + + python multiace/managed/build_package.py \ + --output "$RUNNER_TEMP/multiace-managed-check.tar.gz" + (cd "$RUNNER_TEMP" && \ + sha256sum --check multiace-managed-check.tar.gz.sha256) + + package: + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + needs: validate runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout validated source uses: actions/checkout@v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} - - name: Compose tarball + - name: Build packages and metadata + shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF_NAME}" OUT="dist/release" mkdir -p "$OUT" - # The whole multiace/ tree at its repo layout (same as the - # release recipe): the updater runs install_multiace.sh from - # inside, and the installer needs deploy/, config/, tools/, - # i18n/ and web/ next to it. - tar -czf "$OUT/multiace-${TAG}.tar.gz" multiace/ - (cd "$OUT" && sha256sum "multiace-${TAG}.tar.gz" \ - > "multiace-${TAG}.tar.gz.sha256") + VERSION="$(tr -d '\r\n' < multiace/VERSION)" + SOURCE_SHA="$(git rev-parse HEAD)" + test -n "$VERSION" + + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + BUILD_KIND="manual-test-artifact" + PACKAGE_TAG="v$VERSION-test-$GITHUB_RUN_ID" + else + TAG="$GITHUB_REF_NAME" + test "$TAG" = "v$VERSION" + BUILD_KIND="stable-tag" + PACKAGE_TAG="$TAG" + fi + + # Keep the existing standalone archive layout required by its installer. + tar -czf "$OUT/multiace-$PACKAGE_TAG.tar.gz" multiace/ + (cd "$OUT" && sha256sum "multiace-$PACKAGE_TAG.tar.gz" \ + > "multiace-$PACKAGE_TAG.tar.gz.sha256") + python multiace/managed/build_package.py \ + --output "$OUT/multiace-managed-$VERSION.tar.gz" + + { + printf 'Build kind: %s\n' "$BUILD_KIND" + printf 'Version: %s\n' "$VERSION" + printf 'Source commit: %s\n' "$SOURCE_SHA" + printf 'Workflow run: https://github.com/%s/actions/runs/%s\n' \ + "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" + } > "$OUT/build-info.txt" + + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + { + printf '## Release packages\n\n' + printf -- '- Standalone installer: multiace-%s.tar.gz and its SHA-256 checksum.\n' "$TAG" + printf -- '- Platform-managed payload: multiace-managed-%s.tar.gz and its SHA-256 checksum.\n\n' "$VERSION" + printf 'Built from source commit %s (https://github.com/%s/commit/%s).\n' \ + "$SOURCE_SHA" "$GITHUB_REPOSITORY" "$SOURCE_SHA" + } > "$OUT/release-prefix.md" + fi ls -la "$OUT/" - - name: Publish + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: multiace-packages-${{ github.run_id }} + path: dist/release/* + if-no-files-found: error + retention-days: 14 + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [validate, package] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download validated packages + uses: actions/download-artifact@v4 + with: + name: multiace-packages-${{ github.run_id }} + path: dist/release + + - name: Publish stable release uses: softprops/action-gh-release@v2 with: - files: | - dist/release/multiace-*.tar.gz - dist/release/multiace-*.tar.gz.sha256 + tag_name: ${{ github.ref_name }} + target_commitish: ${{ needs.validate.outputs.source_sha }} + name: ${{ github.ref_name }} + body_path: dist/release/release-prefix.md generate_release_notes: true + fail_on_unmatched_files: true + files: | + dist/release/*.tar.gz + dist/release/*.tar.gz.sha256 diff --git a/multiace/config/extended/multiace/ace_mode_switch.sh b/multiace/config/extended/multiace/ace_mode_switch.sh index 660858ec..4800d56e 100644 --- a/multiace/config/extended/multiace/ace_mode_switch.sh +++ b/multiace/config/extended/multiace/ace_mode_switch.sh @@ -1,5 +1,12 @@ #!/bin/bash set -e +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE mode switching is managed by the platform; refusing to copy Klipper files" >&2 + exit 2 +fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" HOME_DIR="/home/lava" EXTRAS_DIR="${HOME_DIR}/klipper/klippy/extras" diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 25cd2d86..5dd90e79 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -281,6 +281,7 @@ "update_failed": "Update fehlgeschlagen", "update_intro": "Holt die neueste multiACE-Release und installiert sie.", "update_latest": "Verfügbar", + "update_managed": "Updates werden von der Plattform verwaltet. Verwende die multiACE-Integration deines Druckers, um eine geprüfte Version zu installieren.", "update_not_checked": "noch nicht geprüft", "update_title": "multiACE Updates" }, diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index dfc313b9..a98d79ab 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -281,6 +281,7 @@ "update_failed": "Update failed", "update_intro": "Pulls the latest multiACE release and installs it.", "update_latest": "Available", + "update_managed": "Updates are managed by the platform. Use your printer's multiACE integration to install a tested release.", "update_not_checked": "not checked yet", "update_title": "multiACE updates" }, diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index 9f21726b..4bdfb204 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -107,6 +107,7 @@ "update_intro": "拉取最新的 multiACE 版本并安装。", "update_current": "已安装", "update_latest": "可用版本", + "update_managed": "更新由平台管理。请使用打印机的 multiACE 集成安装经过验证的版本。", "update_not_checked": "尚未检查", "update_check_btn": "检查更新", "update_checking": "检查中...", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 9a25cb7e..23f9ed0b 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -1,5 +1,13 @@ #!/bin/bash set -e + +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE is managed by the platform; use its integration instead of install_multiace.sh" >&2 + exit 2 +fi + INSTALL_WEB=0 KEEP_CONFIG=0 for arg in "$@"; do diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 5e7e9e36..a17ed6fd 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -27,6 +27,20 @@ MULTIACE_BUILD_TAG = "eed86c9b" MULTIACE_BUNDLE_SHA1 = "40a00eb" + +def _env_flag(name): + return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on') + + +_MULTIACE_CONFIG_DIR = os.environ.get( + 'MULTIACE_CONFIG_DIR', '/home/lava/printer_data/config') +MULTIACE_MANAGED_MARKER = os.environ.get( + 'MULTIACE_MANAGED_MARKER', '').strip() or os.path.join( + _MULTIACE_CONFIG_DIR, 'extended', 'multiace', '.multiace-managed') +MULTIACE_MANAGED = ( + _env_flag('MULTIACE_MANAGED') + or os.path.exists(MULTIACE_MANAGED_MARKER)) + def _load_i18n_catalog(i18n_dir, lang): """Read /.json overlaid on en.json. Returns a dict (possibly empty if the i18n dir is missing) - caller falls back to @@ -697,8 +711,9 @@ def __init__(self, config): self.paths = _resolve_multiace_paths(config) self.host = self.paths['host_type'] - # Derived, never a config option: the web backend edits the same - # file (MULTIACE_CFG_PATH), so the two must not be able to diverge. + self._managed_by_host = MULTIACE_MANAGED + # Derived from the shared config-directory contract so Klipper and + # the web UI always write through to the same ace.cfg. self.ACE_CFG_PATH = self.paths['ace_cfg'] self.gate_status = [GATE_UNKNOWN, GATE_UNKNOWN, GATE_UNKNOWN, GATE_UNKNOWN] @@ -1433,7 +1448,11 @@ def _parse_idx_list(key): 'inbox_max_mb', 256, minval=1, maxval=4096) self._i18n_primary = config.get('i18n_dir', self.paths['i18n_primary']) - self._i18n_fallback = os.path.join(self._web_dir, 'i18n') + self._i18n_fallbacks = [] + app_dir = os.environ.get('MULTIACE_APP_DIR', '').strip() + if app_dir: + self._i18n_fallbacks.append(os.path.join(app_dir, 'i18n')) + self._i18n_fallbacks.append(os.path.join(self._web_dir, 'i18n')) self._reload_i18n_catalog() self._head_source = {0: None, 1: None, 2: None, 3: None} @@ -2347,8 +2366,9 @@ def _t(self, key, **params): def _reload_i18n_catalog(self): """(Re)load self._i18n for the current self._language. Used at startup and live by MULTIACE_SET_LANGUAGE.""" - i18n_dir = self._i18n_primary if os.path.isdir(self._i18n_primary) \ - else self._i18n_fallback + candidates = [self._i18n_primary] + self._i18n_fallbacks + i18n_dir = next((path for path in candidates if os.path.isdir(path)), + self._i18n_primary) try: self._i18n = _load_i18n_catalog(i18n_dir, self._language) except Exception as e: @@ -20732,7 +20752,13 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): # multi<->head stay on the SAME ace files -> pure runtime flip, no file # swap / reboot. Only transitions involving 'normal' (stock files) run # the file switch script below. - if mode in ('multi', 'head') and current in ('multi', 'head'): + if self._managed_by_host and mode == 'normal': + raise gcmd.error( + '[multiACE] Normal mode is controlled by the host platform. ' + 'Disable the managed multiACE integration and reboot.') + + if mode in ('multi', 'head') and ( + current in ('multi', 'head') or self._managed_by_host): self.gcode.run_script_from_command( "SAVE_VARIABLE VARIABLE=ace__mode VALUE=\"'%s'\"" % mode) self._ace_mode = mode @@ -20766,6 +20792,14 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): pass return + if self._managed_by_host: + # The host has already selected and activated the ACE modules. + # Managed mode changes runtime state only; it never invokes the + # standalone helper that copies over stock Klipper files. + raise gcmd.error( + '[multiACE] This mode change is not supported by the managed ' + 'runtime; the host platform controls file activation.') + save_vars = self.printer.lookup_object('save_variables') vars_path = save_vars.filename script_dir = os.path.dirname(os.path.abspath(vars_path)) @@ -20818,6 +20852,9 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): _UPDATE_SCRIPT = '/home/lava/multiace_update.sh' if os.path.isfile('/home/lava/multiace_update.sh') else os.path.expanduser('~/multiace_update.sh') def _run_update_script(self, gcmd, sub_args, timeout): + if self._managed_by_host: + raise gcmd.error( + '[multiACE] Updates are managed by the platform.') if not os.path.isfile(self._UPDATE_SCRIPT): raise gcmd.error( '[multiACE] Updater script not found at %s - re-run ' diff --git a/multiace/managed/README.md b/multiace/managed/README.md new file mode 100644 index 00000000..2c2c1f37 --- /dev/null +++ b/multiace/managed/README.md @@ -0,0 +1,84 @@ +# multiACE managed package + +This package is the platform-neutral payload for host-managed multiACE +installations. It is separate from the standalone SSH installer so users on +stock firmware can continue using the existing standalone installation and +self-update process. + +## Ownership boundary + +- multiACE owns ACE behavior, Klipper modules, and the web interface. +- The host platform owns the selected multiACE version, package installation, + activation, persistent configuration, updates, rollback, and user-facing + controls. +- The package omits the standalone installer, uninstaller, updater, boot + service, and file-copy mode-switch helper. The source repository still + contains those standalone components. + +Managed deployments use a shared runtime contract. The host passes these +variables to both Klipper and the web service: + +- `MULTIACE_MANAGED=1` enables managed behavior. +- `MULTIACE_MANAGED_MARKER` points to the durable `.multiace-managed` marker + under the shared multiACE configuration/state directory. +- `MULTIACE_CONFIG_DIR` is the directory containing `printer.cfg` (for the + U1, `/home/lava/printer_data/config`). +- `MULTIACE_PRINTER_DATA` is the printer-data root (for the U1, + `/home/lava/printer_data`). +- `MULTIACE_APP_DIR` is the active package root, used to locate packaged + provider data such as translation catalogs. + +The two printer paths follow the shared contract in +[issue #142](https://github.com/decay71/multiACE/issues/142). multiACE derives +`extended/ace.cfg` and its persistent state from these roots; a host should not +provide a second config-file path that could diverge. + +In managed mode, install, uninstall, and self-update entry points refuse to +run. The web Update area reports that updates are managed by the platform +instead of invoking the updater. `SET_ACE_MODE MODE=normal` and the standalone +file-copy mode switch are refused; switching between multi and head remains a +runtime-only operation. + +The managed archive contains the provider's clean `ace.cfg` defaults and +macros, but omits the standalone `[save_variables]` path and self-update +wrapper macros. Each platform seeds its own persistent save-variable path and +preserves existing user values. The standalone source config remains +unchanged. + +## Archive + +The allowlisted archive is named `multiace-managed-.tar.gz` and is +published with a matching SHA256 file for each release. The host pins both the +release and checksum before installation. + +From the repository root, a package and checksum can be built with: + +```text +python3 multiace/managed/build_package.py +``` + +## Release and test-build flow + +Every branch push and pull request runs validation and builds a check package; +those runs do not publish release assets. + +After this workflow is present on the repository's default branch, a maintainer +can manually run **Build and test multiACE packages** with a `source_ref` +(branch, tag, or commit SHA). The workflow uploads both archives, their SHA-256 +sidecars, and build metadata as a 14-day Actions artifact; it does not create a +tag or GitHub Release. Anyone with repository read access can download the +artifact from its workflow run, but these files are not release assets and are +not offered by the standalone updater. This is intended for deliberate +compatibility testing, not as an update channel. + +Stable releases keep the existing tag-driven process: pushing +`v` runs validation, verifies that the tag matches `multiace/VERSION`, +and automatically publishes both package types with their checksums. GitHub +generates the change list using the categories in `.github/release.yml`; no +hand-maintained changelog is required. + +The standalone updater selects only the release's exact +`multiace-.tar.gz` archive and matching checksum. It will fail closed if +either asset is missing or if checksum verification fails. The managed archive +is never a candidate for the standalone installer. Package construction and +publishing do not replace or alter the standalone installation path. diff --git a/multiace/managed/build_package.py b/multiace/managed/build_package.py new file mode 100644 index 00000000..9622af75 --- /dev/null +++ b/multiace/managed/build_package.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Build the allowlisted host-managed multiACE archive. + +The regular multiACE repository contains an SSH installer and maintenance +scripts for standalone users. Those scripts must not be shipped in a managed +payload, because the host platform owns installation and update lifecycle. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +import tarfile +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = Path(__file__).with_name("manifest.json") +VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$") + + +def _load_manifest() -> dict: + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _read_version(manifest: dict) -> str: + path = ROOT / manifest["version_file"] + version = path.read_text(encoding="utf-8").strip() + if not version or not VERSION_RE.fullmatch(version): + raise ValueError(f"invalid version in {path}") + return version + + +def _safe_relative(value: str, field: str) -> str: + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"invalid {field} path: {value}") + return path.as_posix().rstrip("/") + + +def _validate_manifest(manifest: dict) -> None: + if manifest.get("schema") != 1: + raise ValueError("unsupported package manifest schema") + for relative in manifest.get("payload", []): + _safe_relative(relative, "payload") + for relative in manifest.get("excluded_from_package", []): + _safe_relative(relative, "excluded") + managed_config = manifest.get("managed_config", {}) + config_path = managed_config.get("path", "") + if not config_path: + raise ValueError("managed package has no config path contract") + _safe_relative(config_path, "managed config") + sections = managed_config.get("remove_sections", []) + if not sections: + raise ValueError("managed package has no config section contract") + if any(not isinstance(section, str) or not section.strip() + for section in sections): + raise ValueError("managed config section names must be non-empty strings") + + +def _remove_ini_sections(text: str, section_names: set[str]) -> str: + """Remove exact INI sections while preserving all other config text.""" + output: list[str] = [] + skipping = False + for line in text.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + section = stripped[1:-1] + skipping = section in section_names + if not skipping: + output.append(line) + return "".join(output) + + +def _managed_config(manifest: dict, relative: str, text: str) -> str: + managed_config = manifest["managed_config"] + if relative != managed_config["path"]: + return text + sections = set(managed_config["remove_sections"]) + return _remove_ini_sections(text, sections) + + +def _copy_payload(stage: Path, manifest: dict) -> None: + for relative in manifest["payload"]: + relative = _safe_relative(relative, "payload") + source = ROOT / relative + if not source.exists(): + raise FileNotFoundError(f"payload entry does not exist: {relative}") + destination = stage / relative + if source.is_dir(): + # Validation compiles/imports provider modules before packaging. + # Do not ship host-specific Python bytecode or make the archive + # depend on which tests ran first. + shutil.copytree( + source, + destination, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"), + ) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + if relative == manifest["managed_config"]["path"]: + destination.write_text( + _managed_config( + manifest, relative, source.read_text(encoding="utf-8")), + encoding="utf-8", + newline="", + ) + else: + shutil.copy2(source, destination) + + for relative in (manifest["version_file"], "LICENSE"): + relative = _safe_relative(relative, "metadata") + source = ROOT / relative + destination = stage / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + manifest_destination = stage / "managed" / "manifest.json" + manifest_destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(MANIFEST_PATH, manifest_destination) + + +def _assert_excluded(stage: Path, manifest: dict) -> None: + forbidden = tuple( + _safe_relative(item, "excluded") + for item in manifest["excluded_from_package"] + ) + for path in stage.rglob("*"): + relative = path.relative_to(stage).as_posix() + if any(relative == item or relative.startswith(item + "/") + for item in forbidden): + raise AssertionError(f"excluded path leaked into package: {relative}") + + +def _tar_filter(info: tarfile.TarInfo) -> tarfile.TarInfo: + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + return info + + +def build(output: Path) -> tuple[Path, str]: + manifest = _load_manifest() + _validate_manifest(manifest) + version = _read_version(manifest) + root_name = f"multiace-{version}" + output.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="multiace-managed-") as temporary: + stage = Path(temporary) / root_name + stage.mkdir() + _copy_payload(stage, manifest) + _assert_excluded(stage, manifest) + + with output.open("wb") as raw: + import gzip + + with gzip.GzipFile( + filename="", fileobj=raw, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as archive: + archive.add(stage, arcname=root_name, + filter=_tar_filter) + + digest = hashlib.sha256(output.read_bytes()).hexdigest() + checksum = output.with_name(output.name + ".sha256") + checksum.write_text(f"{digest} {output.name}\n", encoding="utf-8") + return output, digest + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output", + type=Path, + default=None, + help="output archive path", + ) + args = parser.parse_args(argv) + try: + if args.output is None: + manifest = _load_manifest() + version = _read_version(manifest) + prefix = manifest["release"]["asset_prefix"] + suffix = manifest["release"]["archive_suffix"] + args.output = Path("dist") / f"{prefix}{version}{suffix}" + output, digest = build(args.output) + except (OSError, ValueError, AssertionError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"created {output}") + print(f"sha256 {digest}") + print(f"checksum {output}.sha256") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/multiace/managed/manifest.json b/multiace/managed/manifest.json new file mode 100644 index 00000000..2336f8d8 --- /dev/null +++ b/multiace/managed/manifest.json @@ -0,0 +1,46 @@ +{ + "schema": 1, + "package": "multiace", + "version_file": "VERSION", + "release": { + "asset_prefix": "multiace-managed-", + "archive_suffix": ".tar.gz", + "checksum_suffix": ".sha256" + }, + "managed_config": { + "path": "config/extended/ace.cfg", + "remove_sections": [ + "save_variables", + "gcode_macro ACEH__Update_Check", + "gcode_macro ACEH__Update_Apply" + ] + }, + "payload": [ + "klipper/extras/ace.py", + "klipper/extras/ace_protocol.py", + "klipper/extras/ace_protocol_v1.py", + "klipper/extras/ace_protocol_v2.py", + "klipper/extras/ace_bg_swap.py", + "klipper/extras/ace_tipform.py", + "klipper/extras/ace_rc522.py", + "klipper/extras/ace_gen1_tunnel.py", + "klipper/extras/filament_feed_ace.py", + "klipper/extras/filament_switch_sensor_ace.py", + "klipper/kinematics/extruder_ace.py", + "config/extended/ace.cfg", + "config/extended/multiace/ace_vars.cfg", + "tools/post_process_virtual_toolheads.py", + "i18n/", + "web/backend/", + "web/frontend/" + ], + "excluded_from_package": [ + "install_multiace.sh", + "uninstall_multiace.sh", + "tools/multiace_update.sh", + "tools/merge_ace_cfg.py", + "deploy/", + "config/extended/multiace/ace_mode_switch.sh", + "web/deploy/" + ] +} diff --git a/multiace/managed/tests/test_i18n_path.py b/multiace/managed/tests/test_i18n_path.py new file mode 100644 index 00000000..2cd60e0d --- /dev/null +++ b/multiace/managed/tests/test_i18n_path.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +BACKEND_DIR = Path(__file__).resolve().parents[2] / "web" / "backend" +sys.path.insert(0, str(BACKEND_DIR)) +from i18n_path import resolve_i18n_dir # noqa: E402 + + +class I18nPathTests(unittest.TestCase): + @staticmethod + def _module_file(root: Path) -> Path: + path = root / "multiace" / "web" / "backend" / "main.py" + path.parent.mkdir(parents=True) + path.touch() + return path + + def test_managed_package_root_layout(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-root-") as tmp: + root = Path(tmp) / "multiace" + catalog = root / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + + def test_standalone_web_layout_fallback(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-web-") as tmp: + root = Path(tmp) / "multiace" + catalog = root / "web" / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + + def test_explicit_override_wins(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-override-") as tmp: + root = Path(tmp) / "multiace" + root_catalog = root / "i18n" + root_catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + override = Path(tmp) / "external-catalogs" + + with patch.dict(os.environ, {"MULTIACE_I18N_DIR": str(override)}): + self.assertEqual(resolve_i18n_dir(module_file), override) + + def test_managed_app_dir_override(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-app-override-") as tmp: + app_root = Path(tmp) / "active-package" + catalog = app_root / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp) / "fallback") + + with patch.dict(os.environ, {"MULTIACE_APP_DIR": str(app_root)}): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + + +if __name__ == "__main__": + unittest.main() diff --git a/multiace/managed/tests/test_managed_guards.sh b/multiace/managed/tests/test_managed_guards.sh new file mode 100644 index 00000000..105ecf9f --- /dev/null +++ b/multiace/managed/tests/test_managed_guards.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +MULTIACE_ROOT="$(CDPATH= cd -- "$TEST_DIR/../.." && pwd)" +MARKER="$(mktemp)" +trap 'rm -f "$MARKER"' EXIT + +GUARDED_SCRIPTS=( + "install_multiace.sh" + "uninstall_multiace.sh" + "tools/multiace_update.sh" + "config/extended/multiace/ace_mode_switch.sh" +) + +run_guard_test() { + local label="$1" + shift + local script output status + for script in "${GUARDED_SCRIPTS[@]}"; do + set +e + output="$(env "$@" bash "$MULTIACE_ROOT/$script" 2>&1)" + status=$? + set -e + if [ "$status" -ne 2 ]; then + printf 'FAIL: %s guard for %s returned %s\n%s\n' \ + "$label" "$script" "$status" "$output" >&2 + exit 1 + fi + case "$output" in + *managed*) ;; + *) + printf 'FAIL: %s guard for %s did not explain managed ownership\n' \ + "$label" "$script" >&2 + exit 1 + ;; + esac + done +} + +# The marker is the fallback used when a shell session does not inherit the +# activation hook's environment. +run_guard_test marker \ + -u MULTIACE_MANAGED \ + MULTIACE_MANAGED_MARKER="$MARKER" + +# The explicit environment contract remains supported as well. +run_guard_test environment \ + MULTIACE_MANAGED=1 \ + MULTIACE_MANAGED_MARKER="/path/that/does/not/exist" + +printf 'Managed-install guard tests passed\n' diff --git a/multiace/managed/tests/test_package.py b/multiace/managed/tests/test_package.py new file mode 100644 index 00000000..21df3344 --- /dev/null +++ b/multiace/managed/tests/test_package.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + + +MANAGED_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(MANAGED_DIR)) +import build_package # noqa: E402 + + +class PackageTests(unittest.TestCase): + def test_manifest_is_self_consistent(self) -> None: + manifest = json.loads( + (MANAGED_DIR / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["schema"], 1) + self.assertEqual(manifest["release"]["asset_prefix"], "multiace-managed-") + self.assertEqual( + manifest["managed_config"]["path"], + "config/extended/ace.cfg", + ) + self.assertEqual( + manifest["managed_config"]["remove_sections"], + [ + "save_variables", + "gcode_macro ACEH__Update_Check", + "gcode_macro ACEH__Update_Apply", + ], + ) + self.assertNotIn("runtime", manifest) + self.assertNotIn("persistent_files", manifest) + self.assertNotIn("klipper_mounts", manifest) + for relative in manifest["payload"]: + self.assertTrue( + (build_package.ROOT / relative.rstrip("/")).exists(), relative) + self.assertIn("install_multiace.sh", manifest["excluded_from_package"]) + self.assertIn("uninstall_multiace.sh", manifest["excluded_from_package"]) + self.assertIn("klipper/extras/ace_gen1_tunnel.py", manifest["payload"]) + self.assertIn("tools/post_process_virtual_toolheads.py", manifest["payload"]) + self.assertIn("tools/multiace_update.sh", manifest["excluded_from_package"]) + self.assertIn("tools/merge_ace_cfg.py", manifest["excluded_from_package"]) + + def test_package_contains_only_managed_payload(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-managed-test-") as tmp: + output = Path(tmp) / "multiace.tar.gz" + archive_path, digest = build_package.build(output) + checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest() + self.assertEqual(checksum, digest) + with tarfile.open(archive_path, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + config_member = next( + member for member in archive.getmembers() + if member.name.endswith("/config/extended/ace.cfg")) + config_text = archive.extractfile(config_member).read().decode() + self.assertTrue(any(name.endswith("/managed/manifest.json") for name in names)) + self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) + self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) + self.assertTrue(any(name.endswith("/klipper/extras/ace_gen1_tunnel.py") for name in names)) + self.assertTrue(any(name.endswith("/tools/post_process_virtual_toolheads.py") for name in names)) + self.assertFalse(any(name.endswith("/tools/multiace_update.sh") for name in names)) + self.assertFalse(any(name.endswith("/tools/merge_ace_cfg.py") for name in names)) + self.assertFalse(any("/deploy/" in name for name in names)) + self.assertFalse(any("/__pycache__/" in name for name in names)) + self.assertFalse(any(name.endswith((".pyc", ".pyo")) for name in names)) + self.assertNotIn("[gcode_macro ACEH__Update_Check]", config_text) + self.assertNotIn("[gcode_macro ACEH__Update_Apply]", config_text) + self.assertNotIn("[save_variables]", config_text) + self.assertIn("[ace]", config_text) + self.assertRegex(config_text, r"(?m)^ace_device_count:\s*1$") + self.assertRegex(config_text, r"(?m)^enable_ace_v2:\s*true$") + source_config = (build_package.ROOT / "config/extended/ace.cfg").read_text() + self.assertIn("[gcode_macro ACEH__Update_Check]", source_config) + self.assertIn("[gcode_macro ACEH__Update_Apply]", source_config) + self.assertIn("[save_variables]", source_config) + self.assertRegex(source_config, r"(?m)^ace_device_count\s*:") + + def test_package_is_deterministic(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-managed-deterministic-") as tmp: + first = Path(tmp) / "first.tar.gz" + second = Path(tmp) / "second.tar.gz" + _, first_digest = build_package.build(first) + _, second_digest = build_package.build(second) + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_digest, second_digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/multiace/managed/tests/test_updater_asset_selection.sh b/multiace/managed/tests/test_updater_asset_selection.sh new file mode 100644 index 00000000..3935fe7c --- /dev/null +++ b/multiace/managed/tests/test_updater_asset_selection.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +UPDATER="$TEST_DIR/../../tools/multiace_update.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +cat > "$TMP/bin/curl" <<'CURL' +#!/bin/sh +printf '%s\n' "${MULTIACE_TEST_RELEASE_JSON:?missing test release JSON}" +CURL +chmod +x "$TMP/bin/curl" + +run_assets() { + local fixture="$1" + env \ + PATH="$TMP/bin:$PATH" \ + MULTIACE_TEST_RELEASE_JSON="$fixture" \ + MULTIACE_UPDATE_REPO="fixture/multiACE" \ + MULTIACE_UPDATE_PRERELEASE=1 \ + MULTIACE_MANAGED=0 \ + MULTIACE_DISABLE_UPDATES=0 \ + MULTIACE_MANAGED_MARKER="$TMP/no-managed-marker" \ + MULTIACE_CONFIG_DIR="$TMP/config" \ + sh "$UPDATER" assets +} + +RELEASE_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-managed-1.11b.tar.gz", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz" + }, + { + "name": "multiace-managed-1.11b.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz.sha256" + }, + { + "name": "multiace-v1.11b-test.123456.tar.gz", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz" + }, + { + "name": "multiace-v1.11b-test.123456.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz.sha256" + } + ] + } +] +JSON +)" + +OUTPUT="$(run_assets "$RELEASE_JSON")" +case "$OUTPUT" in + *"TARBALL_URL=https://example.invalid/multiace-v1.11b-test.123456.tar.gz"*);; + *) + printf 'FAIL: updater did not select the exact standalone archive:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac +case "$OUTPUT" in + *"SHA_URL=https://example.invalid/multiace-v1.11b-test.123456.tar.gz.sha256"*);; + *) + printf 'FAIL: updater did not select the matching checksum:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + +MANAGED_ONLY_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-managed-1.11b.tar.gz", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz" + }, + { + "name": "multiace-managed-1.11b.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz.sha256" + } + ] + } +] +JSON +)" +if OUTPUT="$(run_assets "$MANAGED_ONLY_JSON" 2>&1)"; then + printf 'FAIL: updater accepted a managed archive without the standalone asset\n%s\n' "$OUTPUT" >&2 + exit 1 +fi +case "$OUTPUT" in + *"missing the exact standalone asset"*) ;; + *) + printf 'FAIL: missing-asset error was unclear:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + + + +NO_CHECKSUM_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-v1.11b-test.123456.tar.gz", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz" + } + ] + } +] +JSON +)" +if OUTPUT="$(run_assets "$NO_CHECKSUM_JSON" 2>&1)"; then + printf 'FAIL: updater accepted a standalone archive without its checksum\n%s\n' "$OUTPUT" >&2 + exit 1 +fi +case "$OUTPUT" in + *"missing the matching checksum"*) ;; + *) + printf 'FAIL: missing-checksum error was unclear:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + +printf 'Standalone release asset selection tests passed\n' diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index 416788ce..fb0ffc94 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -1,6 +1,7 @@ #!/bin/sh -# Usage: multiace_update.sh [check | apply [--force] [--keep-web] [--install-web] | --help] +# Usage: multiace_update.sh [check | assets | apply [--force] [--keep-web] [--install-web] | --help] # check compare the installed version with the latest release +# assets print the exact standalone archive and checksum selected # apply download and install the latest release # --force reinstall even when already on latest / older release # --keep-web leave the web UI untouched @@ -11,6 +12,16 @@ # MULTIACE_UPDATE_PRERELEASE 1 = consider prereleases / beta.txt set -e + +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ "${MULTIACE_DISABLE_UPDATES:-0}" = "1" ] || \ + [ "${MULTIACE_DISABLE_UPDATES:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE updates are managed by the platform; use its multiACE integration" >&2 + exit 2 +fi + REPO="${MULTIACE_UPDATE_REPO:-decay71/multiACE}" STATIC_BASE="${MULTIACE_UPDATE_URL_BASE:-}" USE_STATIC=0 @@ -134,8 +145,18 @@ resolve_latest() { echo "ERROR: could not parse latest tag from $API" >&2 return 1 fi - TARBALL_URL="$(echo "$JSON" | json_asset_urls | grep -E 'multiace-.*\.tar\.gz$' | head -1)" - SHA_URL="$(echo "$JSON" | json_asset_urls | grep -E 'multiace-.*\.tar\.gz\.sha256$' | head -1)" + TARBALL_NAME="multiace-${LATEST}.tar.gz" + SHA_NAME="${TARBALL_NAME}.sha256" + TARBALL_URL="$(printf '%s\n' "$JSON" | json_asset_url "$TARBALL_NAME" || true)" + SHA_URL="$(printf '%s\n' "$JSON" | json_asset_url "$SHA_NAME" || true)" + if [ -z "$TARBALL_URL" ]; then + echo "ERROR: release $LATEST is missing the exact standalone asset $TARBALL_NAME" >&2 + return 1 + fi + if [ -z "$SHA_URL" ]; then + echo "ERROR: release $LATEST is missing the matching checksum $SHA_NAME" >&2 + return 1 + fi return 0 } json_field() { @@ -144,6 +165,15 @@ json_field() { json_asset_urls() { sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*\)".*/\1/p' } +json_asset_url() { + expected="$1" + json_asset_urls | while IFS= read -r url; do + if [ "${url##*/}" = "$expected" ]; then + printf '%s\n' "$url" + break + fi + done +} normalize_version() { echo "${1:-}" | sed -n 's/^v\?\([0-9][0-9.]*[a-z]\?\).*/\1/p' } @@ -174,6 +204,12 @@ cmd_check() { fi return 0 } +cmd_assets() { + resolve_latest || return 1 + printf 'STATUS: release=%s\n' "$LATEST" + printf 'TARBALL_URL=%s\n' "$TARBALL_URL" + printf 'SHA_URL=%s\n' "$SHA_URL" +} cmd_apply() { FORCE=0 KEEP_WEB=0 @@ -236,7 +272,11 @@ cmd_apply() { fi fi if [ -z "$TARBALL_URL" ]; then - echo "ERROR: release $LATEST has no multiace-*.tar.gz asset" >&2 + echo "ERROR: release $LATEST has no exact standalone archive asset" >&2 + return 1 + fi + if [ -z "$SHA_URL" ]; then + echo "ERROR: release $LATEST has no matching SHA-256 checksum asset" >&2 return 1 fi echo "STATUS: downloading tarball=$TARBALL_URL" @@ -247,23 +287,18 @@ cmd_apply() { echo "ERROR: tarball download failed from $TARBALL_URL" >&2 return 1 } - if [ -n "$SHA_URL" ]; then - echo "STATUS: verifying sha256" - if fetch_url "$SHA_URL" > "$TARBALL.sha256"; then - EXPECTED="$(awk '{print $1}' "$TARBALL.sha256" | head -1)" - ACTUAL="$(sha256sum "$TARBALL" | awk '{print $1}')" - if [ -z "$EXPECTED" ] || [ "$EXPECTED" != "$ACTUAL" ]; then - echo "ERROR: sha256 mismatch - expected $EXPECTED got $ACTUAL" >&2 - return 1 - fi - echo "STATUS: sha256_ok" - else - echo "WARN: sha256 download failed - skipping verification (tarball came over TLS)" >&2 - echo "STATUS: sha256_skipped (sha256 download failed)" - fi - else - echo "STATUS: sha256_skipped (no .sha256 asset on release - trust GitHub TLS)" + echo "STATUS: verifying sha256" + if ! fetch_url "$SHA_URL" > "$TARBALL.sha256"; then + echo "ERROR: checksum download failed from $SHA_URL" >&2 + return 1 + fi + EXPECTED="$(awk '{print $1}' "$TARBALL.sha256" | head -1)" + ACTUAL="$(sha256sum "$TARBALL" | awk '{print $1}')" + if [ -z "$EXPECTED" ] || [ "$EXPECTED" != "$ACTUAL" ]; then + echo "ERROR: sha256 mismatch - expected $EXPECTED got $ACTUAL" >&2 + return 1 fi + echo "STATUS: sha256_ok" echo "STATUS: extracting" mkdir "$TMP/extracted" tar xzf "$TARBALL" -C "$TMP/extracted" @@ -288,6 +323,8 @@ cmd_apply() { case "${1:-check}" in check) shift; cmd_check "$@" ;; + assets) + shift; cmd_assets "$@" ;; apply) shift; cmd_apply "$@" ;; -h|--help|help) diff --git a/multiace/uninstall_multiace.sh b/multiace/uninstall_multiace.sh index 6abbce59..8893b501 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -1,4 +1,11 @@ #!/bin/bash +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE is managed by the platform; use its integration instead of uninstall_multiace.sh" >&2 + exit 2 +fi + sed -i 's/\r$//' "$0" 2>/dev/null set -e HOME_DIR="/home/lava" diff --git a/multiace/web/backend/i18n_path.py b/multiace/web/backend/i18n_path.py new file mode 100644 index 00000000..4bf830dc --- /dev/null +++ b/multiace/web/backend/i18n_path.py @@ -0,0 +1,34 @@ +"""Locate multiACE translation catalogs across supported install layouts.""" +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_i18n_dir(module_file: str | os.PathLike[str]) -> Path: + """Return the catalog directory for a backend module. + + Managed packages keep shared provider data at the application root while + the standalone installer historically copied catalogs into ``web/i18n``. + Prefer the application-root layout and retain the standalone layout as a + fallback so both installations use the same backend code. + """ + configured = os.environ.get("MULTIACE_I18N_DIR", "").strip() + if configured: + return Path(configured) + + app_dir = os.environ.get("MULTIACE_APP_DIR", "").strip() + if app_dir: + app_catalog = Path(app_dir).expanduser() / "i18n" + if app_catalog.is_dir(): + return app_catalog + + backend_dir = Path(module_file).resolve().parent + web_dir = backend_dir.parent + package_dir = web_dir.parent + candidates = (package_dir / "i18n", web_dir / "i18n") + + for candidate in candidates: + if candidate.is_dir(): + return candidate + return candidates[0] diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index bd87b991..55096172 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -8,8 +8,12 @@ Environment variables: MOONRAKER_URL default http://127.0.0.1:7125 - MULTIACE_CFG_PATH default /home/lava/printer_data/config/extended/ace.cfg + MULTIACE_CONFIG_DIR printer_data/config directory + MULTIACE_PRINTER_DATA printer data root + MULTIACE_CFG_PATH legacy explicit config-file override MULTIACE_FRONTEND_DIR default ../frontend (relative to this file) + MULTIACE_MANAGED set to 1 when the platform owns installation/updates + MULTIACE_MANAGED_MARKER durable neutral managed-install marker path MULTIACE_WEB_VERSION default "0.1.0" """ from __future__ import annotations @@ -43,10 +47,25 @@ from pydantic import BaseModel import preflight_core +from i18n_path import resolve_i18n_dir MOONRAKER_URL = os.environ.get("MOONRAKER_URL", "http://127.0.0.1:7125") +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ( + "1", "true", "yes", "on") + + +MULTIACE_MANAGED_MARKER = os.environ.get( + "MULTIACE_MANAGED_MARKER", "").strip() or os.path.join( + os.environ.get("MULTIACE_CONFIG_DIR", "/home/lava/printer_data/config"), + "extended", "multiace", ".multiace-managed") +MULTIACE_MANAGED = ( + _env_flag("MULTIACE_MANAGED") + or os.path.exists(MULTIACE_MANAGED_MARKER)) + + def _user_paths(rel: str) -> list[str]: """Ordered candidates for a path under the Klipper user's home. @@ -70,11 +89,18 @@ def _first_existing(candidates: list[str]) -> str: return candidates[0] -# Anchor on printer_data/config, which exists wherever Klipper runs. Probing -# for 'extended' or 'persistent' instead would fall back to the U1 path on -# any host that does not have those multiACE subfolders yet, which is every -# fresh generic install. -_CFG_DIR = _first_existing(_user_paths("printer_data/config")) +# These two roots are the shared host-path contract. Keep fallback discovery +# for standalone installs, but let managed platforms supply canonical paths. +_CONFIG_DIR_ENV = os.environ.get("MULTIACE_CONFIG_DIR", "").strip() +_PRINTER_DATA_ENV = os.environ.get("MULTIACE_PRINTER_DATA", "").strip() +if _PRINTER_DATA_ENV: + MULTIACE_PRINTER_DATA = os.path.abspath(_PRINTER_DATA_ENV) +elif _CONFIG_DIR_ENV: + MULTIACE_PRINTER_DATA = os.path.dirname(os.path.abspath(_CONFIG_DIR_ENV)) +else: + MULTIACE_PRINTER_DATA = _first_existing(_user_paths("printer_data")) +_CFG_DIR = os.path.abspath(_CONFIG_DIR_ENV) if _CONFIG_DIR_ENV else os.path.join( + MULTIACE_PRINTER_DATA, "config") _CFG_EXT_DIR = os.path.join(_CFG_DIR, "extended") def _resolve_cfg_path() -> str: @@ -128,10 +154,7 @@ def _resolve_cfg_path() -> str: "PC", "PC-ABS", "PVA", ] -I18N_DIR = os.environ.get( - "MULTIACE_I18N_DIR", - str((Path(__file__).resolve().parent.parent / "i18n")), -) +I18N_DIR = str(resolve_i18n_dir(__file__)) SCREEN_PROBE_URL = os.environ.get("SCREEN_PROBE_URL", "http://127.0.0.1:8092/snapshot") # 0003 mitigation: ace.py (the Klipper module) touches this tmpfs flag on @@ -1703,10 +1726,16 @@ def _read_update_cfg() -> dict[str, str]: async def _run_update_script(args: list[str], timeout: float) -> dict: """Exec the bundled multiace_update.sh and capture stdout+rc.""" - # Canonical install location first. The PAXX-baked - # /home/lava/multiace/tools/multiace_update.sh comes from the - # squashfs and never gets refreshed by online updates, so it - # serves only as a last-resort fallback. + if MULTIACE_MANAGED: + raise HTTPException( + status_code=409, + detail="multiACE updates are managed by the platform.", + ) + + # The installed updater is preferred. The legacy + # /home/lava/multiace/tools/multiace_update.sh path comes from the + # firmware image and is not refreshed by online updates, so it serves + # only as a last-resort standalone fallback. # The two U1 entries keep their exact order; the home-relative # pair is appended for a generic Klipper host and can never reorder them. update_script = None @@ -1835,9 +1864,22 @@ async def preflight_inbox_clear() -> dict: async def update_check() -> dict: return await _run_update_script(["check"], timeout=30.0) +@app.get("/api/update/status") +async def update_status() -> dict: + return { + "managed": MULTIACE_MANAGED, + "owner": "platform" if MULTIACE_MANAGED else "multiACE", + } + @app.post("/api/update/apply") async def update_apply(force: bool = False) -> dict: + if MULTIACE_MANAGED: + raise HTTPException( + status_code=409, + detail="multiACE updates are managed by the platform.", + ) + if not _DEBUG_FLAG_PATH.exists(): raise HTTPException( status_code=409, diff --git a/multiace/web/deploy/S98multiace-web b/multiace/web/deploy/S98multiace-web index 1a597d8d..95198254 100644 --- a/multiace/web/deploy/S98multiace-web +++ b/multiace/web/deploy/S98multiace-web @@ -10,7 +10,8 @@ # Either way the same script controls the lifecycle. NAME="multiace-web" -DAEMON_DIR="/home/lava/multiace_web/backend" +MULTIACE_WEB_DIR="${MULTIACE_WEB_DIR:-/home/lava/multiace_web}" +DAEMON_DIR="$MULTIACE_WEB_DIR/backend" DAEMON="/usr/bin/python3" ARGS="-m uvicorn main:app --host 127.0.0.1 --port 7126 --log-level warning" PIDFILE="/tmp/multiace_web.pid" @@ -22,8 +23,11 @@ LOGFILE="/home/lava/printer_data/logs/multiace_web.log" USER="root" export MOONRAKER_URL="${MOONRAKER_URL:-http://127.0.0.1:7125}" -export MULTIACE_CFG_PATH="${MULTIACE_CFG_PATH:-/home/lava/printer_data/config/extended/ace.cfg}" -export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-/home/lava/multiace_web/frontend}" +export MULTIACE_PRINTER_DATA="${MULTIACE_PRINTER_DATA:-/home/lava/printer_data}" +export MULTIACE_CONFIG_DIR="${MULTIACE_CONFIG_DIR:-$MULTIACE_PRINTER_DATA/config}" +export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-$MULTIACE_WEB_DIR/frontend}" +export MULTIACE_MANAGED="${MULTIACE_MANAGED:-0}" +export MULTIACE_MANAGED_MARKER="${MULTIACE_MANAGED_MARKER:-$MULTIACE_CONFIG_DIR/extended/multiace/.multiace-managed}" # Every uvicorn instance we ever start matches this cmdline pattern. # /proc scan instead of pgrep: busybox builds here don't guarantee pgrep. diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 27418570..9c589763 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -4833,6 +4833,7 @@ createApp({ latest: "", statusText: "", canApply: false, + managed: false, busy: null, log: "", }); @@ -4849,6 +4850,14 @@ createApp({ } catch (e) { } } + async function refreshUpdateStatus() { + try { + const r = await fetch(`${API}/update/status`); + const j = await r.json(); + if (r.ok) updateState.managed = !!j.managed; + } catch (e) { + } + } async function debugEnable() { if (debugState.busy) return; debugState.busy = true; @@ -6916,6 +6925,7 @@ createApp({ await loadMaterials(); await loadNotifications(); await refreshDebugState(); + await refreshUpdateStatus(); await refreshPlugins(); if (state.mode === "normal" && ["dashboard", "calibration"].includes(tab.value)) { tab.value = "config"; diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 9ac51b25..7ad44c55 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1921,7 +1921,10 @@

{{ t('ui.config.tipform_title') }}

{{ t('ui.config.tipform_unsupported') }}

{{ t('ui.config.update_title') }}

{{ t('ui.config.update_intro') }}

-
+

+ {{ t('ui.config.update_managed') }} +

+