Compare commits

..
1414 Commits
Author SHA1 Message Date
ProxMenuxBot 6324d41a1d chore(lang): auto-rebuild translation cache
Source: ef00a34
Triggered by: push
2026-11-04 18:05:56 +00:00
MacRimi ef00a34654 feat(oci): show new and installed application counts in the OCI menu entries, lined up in one column 2026-11-04 19:02:03 +01:00
ProxMenuxBot 143c7db610 chore(lang): auto-rebuild translation cache
Source: 17786e0
Triggered by: push
2026-11-04 17:20:16 +00:00
MacRimi 17786e0f54 perf(oci): read the Proxmox version from the package database when opening the OCI menu 2026-11-04 18:19:13 +01:00
MacRimi 5c91e1c661 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-04 18:18:17 +01:00
MacRimi f0871607fa i18n: translate the Selkies NVIDIA acceleration label 2026-11-04 18:16:33 +01:00
ProxMenuxBot 8e1b2ccc9d chore(lang): auto-rebuild translation cache
Source: b7167ac
Triggered by: push
2026-11-04 17:14:35 +00:00
MacRimi b7167ac498 feat(oci): offer GPU acceleration to the ArtCraft apps built on the LinuxServer Selkies base 2026-11-04 18:11:41 +01:00
MacRimi e916c3b8bb fix(oci): offer what the AMD GPU can run without warning about ROCm support 2026-11-04 17:25:08 +01:00
MacRimi 64e9e0e8dd fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 17:05:44 +01:00
MacRimi 7cbe92ad9f fix(monitor): read the error frequency for AI context from the real errors table columns 2026-11-04 16:57:51 +01:00
MacRimi 8ca445ff3b fix(oci): let AdGuard Home pass its update check on the web port once it is set up 2026-11-04 16:47:35 +01:00
github-actions[bot] a026ab5926 Update AppImage beta build (2026-11-03 22:24:19) 2026-11-03 22:24:20 +00:00
MacRimi 1dca76aaa5 fix(oci): check high availability in the preflight so tests without Proxmox keep working 2026-11-03 23:16:25 +01:00
MacRimi dcb1d65ec8 fix(monitor): keep the theme-aware logo list and add Tailscale 2026-11-03 23:08:30 +01:00
ProxMenuxBot 7bb99d30ff chore(lang): auto-rebuild translation cache
Source: a89eb92
Triggered by: push
2026-11-03 22:00:23 +00:00
MacRimi a89eb921cc fix(oci): refuse an HA-managed container before downloading the image and explain how to proceed 2026-11-03 22:49:47 +01:00
github-actions[bot] 97da4321c8 Update AppImage beta build (2026-11-03 20:27:40) 2026-11-03 20:27:40 +00:00
MacRimi 11c0c683d7 fix(monitor): run scheduled backup jobs detached and follow them by their task UPID 2026-11-03 21:15:40 +01:00
MacRimi 6628744900 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-03 21:02:17 +01:00
MacRimi 194a959492 fix(monitor): show the light variant of selfh.st logos in the dark theme 2026-11-03 21:02:06 +01:00
MacRimiandGitHub 74e5b7a4ff Merge pull request #587 from MaEscalanteHe/docs/drift-utils
docs(web): fix Utils drift
2026-11-03 20:58:55 +01:00
MacRimiandGitHub 244181f9df Merge pull request #586 from MaEscalanteHe/docs/drift-backup-restore
docs(web): fix Backup & Restore drift
2026-11-03 20:58:33 +01:00
Manuel Escalante e3f85b3e09 docs(web): translate utils drift fixes 2026-11-03 20:00:03 +01:00
Manuel Escalante c0067d35a6 docs(web): fix Utils drift found in docs audit 2026-11-03 19:59:52 +01:00
Manuel Escalante cd3d4061d0 docs(web): translate backup-restore drift fixes 2026-11-03 19:52:20 +01:00
Manuel Escalante e9272c90a0 docs(web): fix Backup & Restore drift found in docs audit 2026-11-03 19:52:20 +01:00
MacRimi e51553ada8 test: give the cached health check a safe margin before the cache expires 2026-11-03 19:50:03 +01:00
MacRimiandGitHub c07b328bb9 Merge pull request #582 from jcastro/feature/oci-catalog-refresh
Refresh the OCI application catalog automatically with a local cache
2026-11-03 19:46:54 +01:00
MacRimiandGitHub 9a9d0f2379 Merge pull request #576 from dodog/main
Refine Slovak translations in common.json
2026-11-03 19:42:30 +01:00
MacRimiandGitHub 389a07c265 Merge pull request #575 from MaEscalanteHe/docs/drift-monitor
docs(web): fix Monitor drift
2026-11-03 19:41:56 +01:00
MacRimiandGitHub c5df337db6 Merge pull request #574 from MaEscalanteHe/docs/drift-settings-intro
docs(web): fix Settings, Introduction and Installation drift
2026-11-03 19:41:29 +01:00
ProxMenuxBot 3e2b8d72d1 chore(lang): auto-rebuild translation cache
Source: 3bf4c0d
Triggered by: push
2026-11-03 18:14:42 +00:00
MacRimiandGitHub 3bf4c0d82f Merge pull request #585 from jcastro/feature/oci-artcraft-suite
feat(oci): add thirteen ArtCraft desktop applications
2026-11-03 19:00:47 +01:00
MacRimi c3024b8448 Merge develop into feature/oci-artcraft-suite 2026-11-03 18:52:37 +01:00
ProxMenuxBot ab76ab161b chore(lang): auto-rebuild translation cache
Source: 7f59ee5
Triggered by: push
2026-11-03 17:50:46 +00:00
MacRimi f61c59d093 Merge develop into feature/oci-artcraft-suite
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:43:52 +01:00
MacRimiandGitHub 7f59ee5103 Merge pull request #580 from jcastro/feature/oci-app-catalog-additions
Add KitchenOwl, Krusader, Spoolman, Unbound and Z-Wave JS UI
2026-11-03 18:30:15 +01:00
github-actions[bot] cef1dc7422 Update AppImage beta build (2026-11-03 17:25:45) 2026-11-03 17:25:45 +00:00
MacRimi 888b99330b Merge develop into feature/oci-app-catalog-additions 2026-11-03 18:20:08 +01:00
ProxMenuxBot af0dd74067 chore(lang): auto-rebuild translation cache
Source: 7ec022a
Triggered by: push
2026-11-03 17:13:55 +00:00
Jonatan Castro 75a96b2658 Complete Swedish Authelia label after syncing develop 2026-11-03 18:06:58 +01:00
MacRimi 114882fcba Merge develop into feature/oci-app-catalog-additions
Resolve the catalog conflicts and regenerate VALIDATION.md.
2026-11-03 18:06:57 +01:00
Jonatan Castro 79c0f268fd Merge develop and refresh the OCI validation summary 2026-11-03 18:05:19 +01:00
MacRimiandGitHub 7ec022af81 Merge pull request #584 from jcastro/feature/oci-airdcpp
feat(oci): add AirDC++ Web Client
2026-11-03 18:02:36 +01:00
Jonatan Castro 07f7f9a6a1 Apply profile compatibility gate using effective menu visibility 2026-11-03 17:58:52 +01:00
Jonatan Castro fec97c57d9 Prune catalog snapshots and defer recipes requiring newer profile keys 2026-11-03 17:56:15 +01:00
MacRimi 4ba0d6a236 Merge develop into feature/oci-airdcpp
Resolve the catalog index conflict and regenerate VALIDATION.md.
2026-11-03 17:47:35 +01:00
ProxMenuxBot 96b3a919ba chore(lang): auto-rebuild translation cache
Source: c628e65
Triggered by: push
2026-11-03 16:46:28 +00:00
MacRimiandGitHub c628e65e2f Merge pull request #581 from jcastro/feature/oci-authelia
Add Authelia to the OCI catalog
2026-11-03 17:42:30 +01:00
MacRimi 1e08a5939b ci: cancel superseded test runs on the same branch or pull request 2026-11-03 17:10:11 +01:00
MacRimiandGitHub 163c7c3505 Merge pull request #583 from jcastro/fix/oci-failed-update-recovery
fix(oci): recover automatically after failed image replacements
2026-11-03 16:50:36 +01:00
MacRimi eb46fffd45 fix(oci): apply the llama.cpp context size and stable Vulkan settings for integrated GPUs 2026-11-03 16:46:38 +01:00
Manuel Escalante a4c2c34597 fix(i18n): use localized UI labels in Monitor docs 2026-11-03 16:04:15 +01:00
Jonatan Castro aa19af66eb Add thirteen ArtCraft desktop applications to the OCI catalog 2026-11-03 14:50:20 +01:00
Jonatan Castro f19c300387 Restore the previous OCI installation after a failed image replacement 2026-11-03 13:32:16 +01:00
Jonatan Castro c1183bdeed Use the AirDC++ index page for startup health checks 2026-11-03 13:22:55 +01:00
Jonatan Castro 19e35a567f Add AirDC++ Web Client OCI recipe 2026-11-03 12:51:00 +01:00
Jonatan Castro f428dc76f0 Isolate catalog worker in offline Slovak menu regression 2026-11-03 12:38:57 +01:00
Jonatan Castro e412d3161d Harden OCI catalog refresh recovery and retries 2026-11-03 12:30:54 +01:00
Jonatan Castro 13a8ac1001 Refresh OCI catalog automatically with local cache and app counters 2026-11-03 12:20:23 +01:00
Jonatan Castro f0d54fca13 Leave lab verification to maintainers 2026-11-03 11:35:59 +01:00
Jonatan Castro d9973635fb Add Authelia to OCI catalog 2026-11-03 11:34:27 +01:00
Manuel Escalante 75b13d87da fix(i18n/de): correct the backups tab alt text in the guest detail dialog 2026-11-03 10:25:41 +01:00
Manuel Escalante ac97e2c5a6 docs(web): apply review feedback on Monitor docs 2026-11-03 09:54:33 +01:00
Manuel Escalante 700f72babd docs(web): fix release channel wording from review 2026-11-03 09:45:39 +01:00
Jonatan Castro 0ab40b4eea feat(oci): add five application recipes 2026-11-03 00:27:00 +01:00
MacRimi 9940f05adf fix(oci): llama.cpp lists the models in /models, with resources and notes to add one 2026-11-02 23:45:10 +01:00
Jozef GaalandJozef Gaal 8328d5d366 Refine 'onServer' explanation in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 7e6a942d16 Fix Slovak translations in common.json 2026-11-02 23:17:00 +01:00
Jozef GaalandJozef Gaal 03928c4277 Refine Slovak translations in common.json 2026-11-02 23:16:59 +01:00
MacRimiandGitHub 8aefd70e52 Merge pull request #578 from jcastro/feature/oci-paperclip
Add Paperclip to the OCI catalog
2026-11-02 23:02:03 +01:00
MacRimiandGitHub 6bee13114c Merge pull request #579 from f3rs3n/fix/log2ram-journal-transition
fix(log2ram): keep journal protection enabled until disk migration
2026-11-02 23:00:50 +01:00
martino 6a1513a014 fix(log2ram): keep journal protection enabled until disk migration 2026-11-02 22:32:28 +01:00
Jonatan Castro fb083e92cb feat(oci): add Paperclip application recipe 2026-11-02 20:37:29 +01:00
github-actions[bot] 781d2500ad Update AppImage beta build (2026-11-02 18:39:06) 2026-11-02 18:39:06 +00:00
MacRimi 98931fe4a7 New version 1.2.8 2026-11-02 19:22:47 +01:00
MacRimi 46337f162f New version 1.2.8 2026-11-02 19:20:30 +01:00
MacRimiandGitHub 064c2a6c39 Merge pull request #557 from MaEscalanteHe/feature/web-refresh
fix(web): dependency cleanup, HTML entity rendering and sidebar fixes
2026-11-02 19:10:00 +01:00
MacRimi 3feb5ca5cb fix(web): wrap inline code and scroll tables on narrow screens 2026-11-02 19:01:03 +01:00
MacRimi b0215d4bbb fix: OCI image data paths, LAN endpoints for OpenAI-compatible AI and lab-validated apps 2026-11-02 18:42:32 +01:00
MacRimi 9f3fffd249 fix: one source for the disk type and a config volume for SearXNG 2026-11-02 17:28:31 +01:00
Manuel Escalante 511211f10b fix(web): keep ICU quotes out of messages inserted as raw HTML 2026-11-02 10:32:41 +01:00
github-actions[bot] 41081121f8 Update AppImage beta build (2026-11-02 07:28:12) 2026-11-02 07:28:12 +00:00
MacRimi e83794b7e2 fix(web): send only the client translations to each page 2026-11-02 08:25:28 +01:00
MacRimi 1f56d6b1ce Restore the AppImage checksum for the merge 2026-11-02 08:06:15 +01:00
MacRimi 7df9fdf381 New version 1.2.7 2026-11-02 08:00:57 +01:00
MacRimi 9084df7ebd docs: set the 1.2.8 release date 2026-11-02 07:58:18 +01:00
MacRimiandGitHub 0ae3fd19f4 Merge pull request #572 from f3rs3n/fix/italian-1.2.8-review
fix(i18n): review Italian translations for 1.2.8
2026-11-02 07:51:53 +01:00
MacRimi 8d8ae53a75 fix: stop the NVIDIA install under Secure Boot and keep the 2FA field visible to Bitwarden 2026-11-02 07:47:35 +01:00
martino fa8f021ee9 fix(i18n): review Italian translations for 1.2.8 2026-11-02 01:09:21 +01:00
MacRimi d6f6b4c914 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-11-01 17:56:15 +01:00
MacRimi 28b23c8ffd i18n: use the Slovak term for application in two terminal strings 2026-11-01 17:56:11 +01:00
MacRimiandGitHub 9976183a54 Merge pull request #569 from Vaso73/slovak-translation-review-1.2.8
i18n: review Slovak translations for 1.2.8
2026-11-01 17:53:32 +01:00
VAIO73 f2328624ab i18n: finish consistent Slovak container lifecycle labels 2026-11-01 15:14:27 +00:00
VAIO73 93e2161355 i18n: explain moving Docker image tags in Slovak 2026-11-01 15:14:27 +00:00
VAIO73 fa58d2f7fe i18n: use consistent Slovak ZFS pool notification terminology 2026-11-01 15:14:27 +00:00
VAIO73 0251dd3c7a i18n: clarify Slovak index documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 253e71a85f i18n: clarify Slovak hardware documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 d4cc80cb4c i18n: clarify Slovak architecture documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 3c9dc45d68 i18n: clarify Slovak custom-image documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 b9ee0514c6 i18n: clarify Slovak monitor documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 537bb5a815 i18n: clarify Slovak storage-network documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 2e227a87c5 i18n: clarify Slovak lifecycle documentation terminology 2026-11-01 15:14:27 +00:00
VAIO73 6fe5c8e25c i18n: clarify Slovak multi-container application documentation 2026-11-01 15:14:27 +00:00
VAIO73 7fe03d96f5 i18n: correct Slovak backup audit guidance 2026-11-01 15:14:27 +00:00
VAIO73 2c59356e66 i18n: clarify Slovak wording for all application containers 2026-11-01 15:14:27 +00:00
VAIO73 c68d57feb5 i18n: preserve the full pct create command in Slovak release notes 2026-11-01 15:14:27 +00:00
VAIO73 5395dd488a i18n: preserve the executable example in the Slovak app editor 2026-11-01 15:14:27 +00:00
VAIO73 3048ed8e5b i18n: translate remaining Slovak documentation navigation labels 2026-11-01 15:14:27 +00:00
VAIO73 468f57ebed i18n: translate the OCI catalog contribution guide into Slovak 2026-11-01 15:14:27 +00:00
VAIO73 e42fca75ec i18n: add Slovak VLAN networking documentation 2026-11-01 15:14:27 +00:00
VAIO73 1a18432cee i18n: clarify Slovak custom Compose endpoint documentation 2026-11-01 15:14:27 +00:00
VAIO73 b2e84fde08 i18n: correct Slovak scheduled OCI update documentation 2026-11-01 15:14:27 +00:00
VAIO73 cf7269c123 i18n: align Slovak OCI lifecycle docs with preserved Proxmox settings 2026-11-01 15:14:27 +00:00
VAIO73 ef1e6932b4 i18n: review Slovak release notes for version 1.2.8 2026-11-01 15:14:27 +00:00
VAIO73 aadd90810f i18n: translate Slovak hardware storage controller label 2026-11-01 15:14:27 +00:00
VAIO73 fc2a5ff4fe i18n: clarify Slovak storage display and disk exclusion settings 2026-11-01 15:14:27 +00:00
VAIO73 ff78912764 i18n: review Slovak storage controller and ZFS messages 2026-11-01 15:14:27 +00:00
github-actions[bot] 256df1ef3e Update AppImage beta build (2026-11-01 14:27:37) 2026-11-01 14:27:37 +00:00
MacRimi 0333d95479 fix(monitor): do not warn about volume groups without active volumes 2026-11-01 15:13:18 +01:00
Manuel Escalante 418afe385b docs(web): translate AI explanations of backup errors 2026-11-01 14:38:05 +01:00
Manuel Escalante 9ed09cc28e docs(web): document AI explanations of backup errors 2026-11-01 14:36:54 +01:00
github-actions[bot] e3113df0a2 Update AppImage beta build (2026-11-01 13:07:50) 2026-11-01 13:07:50 +00:00
Manuel Escalante 4261019549 docs(web): translate settings-intro drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 5dc98e9daf docs(web): translate monitor drift fixes 2026-11-01 14:05:13 +01:00
Manuel Escalante 488044e0b9 docs(web): fix remaining Settings and Introduction drift from docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 7320fc6ff6 docs(web): fix Monitor drift found in docs audit 2026-11-01 14:05:13 +01:00
Manuel Escalante 398ca55247 docs(web): fix Monitor API paths and token and SSL location 2026-11-01 14:05:13 +01:00
Manuel Escalante 4f415c98cf docs(web): fix Monitor install and Change Language drift in Settings 2026-11-01 14:05:12 +01:00
MacRimi 24d069e1dd Show the optional ZFS feature upgrade notice in blue 2026-11-01 14:04:10 +01:00
github-actions[bot] 3a000458b2 Update AppImage beta build (2026-11-01 12:45:30) 2026-11-01 12:45:30 +00:00
MacRimi d5b7d386ea fix(notifications): preserve VM and CT identities in AI diagnoses 2026-11-01 13:35:24 +01:00
MacRimi 624ff628bd fix(notifications): restore AI suggestions and explain backup failures 2026-11-01 13:20:13 +01:00
MacRimi 5ea0645e31 fix(oci): confirm the release of a private network by its result 2026-11-01 11:39:56 +01:00
MacRimi ac2bd28d25 ProxMenux 1.2.8: release notes, changelog, image and version 2026-11-01 10:55:07 +01:00
github-actions[bot] 2327f96479 Update AppImage beta build (2026-11-01 00:50:01) 2026-11-01 00:50:01 +00:00
MacRimi ead77f0d1f fix(monitor): stop intel_gpu_top leaving a process on every GPU reading 2026-11-01 01:38:52 +01:00
MacRimi 52a9034676 fix(monitor): remove the idle disk note from Disk exclusions 2026-11-01 01:30:59 +01:00
MacRimi e8e9e07e6b feat(oci): add Scanopy and force the stop of a container that ignores it 2026-11-01 01:25:04 +01:00
MacRimi cb97fc4c98 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-31 19:45:03 +01:00
MacRimi 75fd803443 fix(app-tracking): support Helper-Scripts core repository split 2026-10-31 19:44:45 +01:00
github-actions[bot] c0a0cdca34 Update AppImage beta build (2026-10-31 18:39:13) 2026-10-31 18:39:13 +00:00
MacRimi d75d44c601 fix(monitor): do not report a service reloading or restarting as failed 2026-10-31 18:49:34 +01:00
MacRimi 276a116696 feat(oci): VLAN tag and updates that keep Proxmox changes 2026-10-31 18:34:14 +01:00
ProxMenuxBot b1010a194a chore(lang): auto-rebuild translation cache
Source: 0faa781
Triggered by: push
2026-10-31 09:40:28 +00:00
MacRimiandGitHub 0faa781dd1 Merge pull request #566 from jcastro/fix/ollama-api-endpoint
fix(oci): correct Ollama API endpoint metadata
2026-10-31 10:39:21 +01:00
ProxMenuxBot 60b66c4668 chore(lang): auto-rebuild translation cache
Source: 393e609
Triggered by: push
2026-10-31 09:32:48 +00:00
MacRimi 393e6095fa fix(backup): repair host backup schedules systemd rejects 2026-10-31 10:31:47 +01:00
ProxMenuxBot 380a9b653c chore(lang): auto-rebuild translation cache
Source: 1920dc2
Triggered by: push
2026-10-31 09:21:18 +00:00
MacRimi 1920dc2937 fix(backup): check the schedule of a host backup job before saving it 2026-10-31 10:19:59 +01:00
Jonatan bfdcd4a42c i18n: translate Ollama catalog metadata 2026-10-31 10:07:52 +01:00
Jonatan 139a4b4089 fix(oci): label Ollama API endpoint accurately 2026-10-31 10:07:51 +01:00
ProxMenuxBot 14e2718275 chore(lang): auto-rebuild translation cache
Source: 6befea0
Triggered by: push
2026-10-31 08:54:29 +00:00
MacRimiandGitHub 6befea0757 Merge pull request #563 from jcastro/feature/oci-authentik
Add Authentik to OCI catalog
2026-10-31 09:53:15 +01:00
MacRimi ed2a696c01 fix(oci): point the Authentik summary to the first-run setup
akadmin has no password until it is created in the setup wizard, which
opens from the root address. The summary now says so instead of reporting
a password that could not be retrieved.
2026-10-31 09:45:27 +01:00
Jonatan 6968409ae5 fix(oci): give Authentik worker outbound access 2026-10-31 09:00:46 +01:00
ProxMenuxBot a6ada401b5 chore(lang): auto-rebuild translation cache
Source: 98d8e78
Triggered by: push
2026-10-30 23:43:27 +00:00
MacRimi 98d8e78dcb fix(i18n): add the Slovak label of the Vulkan profile 2026-10-31 00:42:21 +01:00
MacRimi f9fe4d2793 fix(oci): run Ollama and llama.cpp on Intel and AMD integrated GPUs 2026-10-31 00:25:21 +01:00
ProxMenuxBot 81cc746e19 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: aa4adc3
Triggered by: push
2026-10-30 21:59:48 +00:00
MacRimi aa4adc34a9 fix(monitor): place the Secure Gateway template on an active storage 2026-10-30 22:58:12 +01:00
ProxMenuxBot dc823bc21b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 72908e5
Triggered by: push
2026-10-30 20:50:19 +00:00
ProxMenuxBot 297a41d58a chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 668b557
Triggered by: push
2026-10-30 20:47:44 +00:00
MacRimiandGitHub 72908e55aa Merge pull request #562 from DrOwl/feature/disk-controller-mapping
Map storage devices to their PCI storage controller
2026-10-30 21:39:07 +01:00
MacRimiandGitHub 668b557fd5 Merge pull request #561 from DrOwl/feature/zfs-pool-details
Show ZFS pool layout, scrub status and device health in Storage view
2026-10-30 21:38:12 +01:00
Jonatan f5ce0438f9 test(oci): run the full engine suite in CI 2026-10-30 19:32:24 +01:00
Jonatan 21616fe53d fix(i18n): translate Authentik catalog strings to Slovak 2026-10-30 19:31:48 +01:00
Jonatan 8e6e9d5cab test(oci): run Authentik recipe checks in CI 2026-10-30 19:30:00 +01:00
Jonatan 3b15e14625 feat(oci): add Authentik catalog profile 2026-10-30 19:24:01 +01:00
MacRimi 89531bbab8 fix(monitor): record kernel I/O errors on the right disk 2026-10-30 18:51:55 +01:00
MacRimi c13d3de023 docs(web): use "backups" in the Spanish sidebar 2026-10-30 18:07:51 +01:00
MacRimiandGitHub dd9c21ed75 Merge pull request #558 from jcastro/docs/oci-catalog-contributor-guide
docs(oci): add reusable app catalog guide
2026-10-30 17:56:50 +01:00
MacRimi aaffe4e08a docs(oci): align the catalog guide with the project flow
Suggest the app in discussion #440 first, call the file an oci-compose,
use the names the interface shows, run the engine tests as an
unprivileged user, and add the page to the docs sidebar.
2026-10-30 17:52:32 +01:00
MacRimiandGitHub d3fedeb201 Merge pull request #555 from jcastro/feature/oci-teslamate
feat(oci): add TeslaMate stack
2026-10-30 17:35:30 +01:00
MacRimi bd42df3ead fix(monitor): keep the LVM filter when the scan names its devices 2026-10-30 17:32:25 +01:00
MacRimi cf7b8363da fix(monitor): serve the bundled PWA manifest and service worker 2026-10-30 16:59:02 +01:00
Jonatan d3752f0417 docs(oci): add reusable app catalog guide 2026-10-30 10:43:32 +01:00
Manuel Escalante 64fa4f8115 fix(web): restore Change Language entry in docs sidebar 2026-10-29 19:54:41 +01:00
Jonatan 88ba698758 fix(oci): use readable names for stack LXCs 2026-10-29 18:18:57 +01:00
DrOwlandClaude Opus 5.5 72491b2445 Show ZFS pool layout, scrub status and device health in Storage view
/api/storage now runs a single `zpool status -j --json-int -P` (OpenZFS
2.3+) for all pools and returns, per pool:
- top-level vdevs with type, raidz/draid parity, class (data, special,
  dedup, log, cache, spare), state and read/write/checksum errors
- member devices with path, resolved base disk, state, error counters,
  slow I/Os, and the spare-N/replacing-N they sit under while swapping
- scrub/resilver status (progress only while running), the pool's
  status/action text and data-error count
- fragmentation, capacity and dedup ratio from `zpool list`

This replaces the per-pool `zpool status` text loop whose result
(zfs_disks) was never used. Member paths (by-id, by-partlabel, wwn) are
resolved to base disks via sysfs rather than an lsblk call per device.
On ZFS without `-j`, pools report details_available: false and the UI
falls back to the existing basic card.

The ZFS pool card shows a layout summary (e.g. "RAIDZ2 | 5 disks |
Scrub finished ...") and opens a details dialog with the vdev tree,
per-device error counts, scrub/resilver progress, and links from each
member disk to its disk details dialog.

Tested against `zpool status -j` output from PVE 9 / OpenZFS 2.4.4:
mirror, raidz2, striped mirrors, single-disk pool, and a file-backed
degraded raidz1 with log, cache and spare devices.

Tests cover the `zpool status -j` -> Storage view shape against real
output from PVE 9 / OpenZFS 2.4.4 hosts (serials and WWNs replaced):
mirror, raidz2, single-disk pool, slow I/Os, and a degraded raidz1 with
log, cache and spare devices; plus a synthetic resilver with a spare
swapping in. Member path -> base disk resolution is tested against a
stubbed sysfs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:55:02 +00:00
Jonatan 8eef466949 test(oci): record TeslaMate browser and lifecycle validation 2026-10-29 12:47:14 +01:00
DrOwlandClaude Opus 5.5 30034e7aa3 Map storage devices to their PCI storage controller
Each disk in /api/hardware now carries a controller_slot, resolved from
the disk's sysfs device path: the deepest PCI address in that path is the
controller (bridges sit above it), normalised to lspci's slot format so it
matches pci_devices[].slot directly. NVMe falls back to the controller's
own sysfs link when the namespace resolves to a subsystem with no PCI
parent; zvols and other virtual devices get None.

Hardware page:
- The disk dialog shows its storage controller (click through to the
  controller), and the PCI device dialog lists the disks attached to it.
- Storage controllers in the PCI Devices list get a badge with the
  number of attached disks.

Storage view:
- /api/storage returns controller_slot on each physical disk plus a
  controllers map (slot -> class, vendor, device, subsystem name,
  driver), built from the cached `lspci -vmm` output so the Storage view
  doesn't need the heavier /api/hardware enumeration.
- "Physical Disks & SMART Status" can group disks under their storage
  controller, with a header showing the controller name, slot, driver
  and disk count. Groups follow PCI slot order; disks with no resolvable
  controller are listed last. USB disks keep their own section.
- The disk details dialog shows the storage controller on the Overview
  tab.
- Grouping is opt-in via a new Storage Display card in Settings, stored
  per browser (localStorage) and synced across open views, following the
  existing nav-tab-order preference pattern. Off by default.

Also:
- Treat "IDE interface" as a storage controller, so SATA controllers left
  in BIOS IDE mode (e.g. Intel 7 Series, ata_piix) appear in pci_devices
  and disks behind them can be mapped.
- lspci prints "Device xxxx" when it has no subsystem name; that
  placeholder is ignored so the chip name is shown instead.
- Add the missing sdevice field to the PCIDevice type.

Tests cover the sysfs path -> controller slot rule against real paths
from two PVE 9 hosts (SAS HBA, AHCI SATA, SATA in BIOS IDE mode, USB
behind a hub, NVMe behind chipset bridges), the non-zero PCI domain case
(Intel VMD), the NVMe multipath fallback, and get_storage_controllers'
lspci parsing including the "Device xxxx" placeholder and driver lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:47:00 +00:00
Manuel Escalante c26eb49e2c fix(web): render HTML entities in translated text 2026-10-29 09:41:32 +01:00
Manuel Escalante db12972253 fix(web): pass em tag to host-local-disk format steps 2026-10-29 09:41:32 +01:00
Manuel Escalante fac7ac5021 chore(web): drop unused dependencies and apply minor updates and audit fixes 2026-10-29 09:41:32 +01:00
Jonatan 512b268815 feat(oci): add TeslaMate native stack profile 2026-10-29 01:08:16 +01:00
Jonatan e52460e991 fix(oci): validate managed routes during transaction recovery 2026-10-29 00:51:58 +01:00
ProxMenuxBot 5db3ae9c92 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 581eed6
Triggered by: push
2026-10-28 22:32:09 +00:00
ProxMenuxBot 79bc8d33fd chore(lang): auto-rebuild translation cache
Source: 581eed6
Triggered by: push
2026-10-28 22:26:01 +00:00
MacRimiandGitHub 581eed61e5 Merge pull request #551 from Vaso73/fix/host-update-recovery
Keep Proxmox updates running across sign-out and show pending reboots
2026-10-28 23:24:38 +01:00
MacRimiandGitHub d27dc834db Merge pull request #550 from jcastro/feature/oci-custom-compose-stacks
feat(oci): import multi-service Compose files from Custom image
2026-10-28 23:05:36 +01:00
MacRimi 2ab83c0b88 fix(oci): clarify the summary of imported Compose stacks
Each registry line names its image, the variables of a stack are listed
without announcing questions that are not asked, and the name given to an
imported stack names its containers.
2026-10-28 22:57:15 +01:00
VAIO73 bdf9075639 fix: distinguish the waiting unattended upgrades shutdown helper 2026-10-28 17:30:44 +00:00
VAIO73 65b10cea09 ui: compact the current ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 9147ec2f52 fix: keep reboot actions blocked during health recovery 2026-10-28 17:30:44 +00:00
VAIO73 ba3fb1197f fix: read completed update results without a live terminal 2026-10-28 17:30:44 +00:00
VAIO73 889409de8d fix: preserve dated health results while update cache refreshes 2026-10-28 17:30:44 +00:00
VAIO73 43973e1d06 fix: clarify system update action and package plan 2026-10-28 17:30:44 +00:00
VAIO73 e1be5c22c8 fix: include host update controls in AppImage assembly 2026-10-28 17:30:44 +00:00
VAIO73 79cdf814ed fix: keep restart controls disabled while request is pending 2026-10-28 17:30:44 +00:00
VAIO73 707a45e639 fix: guard host reboot with native package locks 2026-10-28 17:30:44 +00:00
VAIO73 52cd21dcb1 feat: add guarded host restart and read-only ProxMenux version status 2026-10-28 17:30:44 +00:00
VAIO73 503a946d85 fix: retain translated reboot summary in cached health responses 2026-10-28 17:30:44 +00:00
VAIO73 ac9565fa19 fix: refresh reboot evidence after external host updates 2026-10-28 17:30:44 +00:00
VAIO73 f40e0a53e1 Preserve host updates across sign-out and report pending kernel activation 2026-10-28 17:30:44 +00:00
MacRimi 2d4b0cb746 ci(oci): fetch only the commits the validation checks need 2026-10-28 18:12:17 +01:00
MacRimiandGitHub 616ba129b2 Merge pull request #545 from jcastro/feature/zigbee2mqtt-oci-20261027
Add Zigbee2MQTT to the OCI catalog
2026-10-28 18:08:12 +01:00
MacRimi 6a91487d21 Merge branch 'develop' into feature/zigbee2mqtt-oci-20261027 2026-10-28 17:49:43 +01:00
MacRimi 8ee3b29991 fix(update): confirm with Yes focused and keep the package list aligned 2026-10-28 17:24:14 +01:00
github-actions[bot] 52d44a7435 Update AppImage beta build (2026-10-28 10:51:01) 2026-10-28 10:51:01 +00:00
Jonatan 5e89b2bdf5 fix(i18n): translate managed stack routing errors 2026-10-28 11:43:37 +01:00
Jonatan a259b945eb test(oci): isolate cleanup and recovery fixtures from the host 2026-10-28 11:43:36 +01:00
Jonatan bf8c495d3b fix(oci): validate the installation identity before stack backups 2026-10-28 11:43:36 +01:00
Jonatan 05790b494e fix(oci): preserve private access to stack frontends across lifecycle operations 2026-10-28 11:26:59 +01:00
Jonatan a7786a6874 fix(oci): bind stack public URLs to their frontend before startup 2026-10-28 10:56:36 +01:00
Jonatan 3d1c542a05 fix(oci): normalize pinned references for registry inspection 2026-10-28 10:10:57 +01:00
Jonatan e587aa7d79 fix(oci): carry verified stack metadata into native backups 2026-10-28 09:14:11 +01:00
Jonatan 6a30d92ba7 fix(oci): verify web readiness and persist private hook contracts 2026-10-28 08:52:57 +01:00
Jonatan 3a0a7eaf14 feat(oci): support custom Compose workers and migration jobs 2026-10-28 08:41:41 +01:00
Jonatan b383fb9311 fix(oci): normalize read-only time mounts in custom stacks 2026-10-28 06:41:20 +01:00
Jonatan 68ae23df82 fix(oci): allow IPv6 entrypoints during native updates 2026-10-28 06:41:20 +01:00
Jonatan f23f1912c2 fix(oci): reserve down and pending private network definitions 2026-10-28 06:41:20 +01:00
Jonatan 692c7f7256 feat(oci): support custom multi-service Compose applications 2026-10-28 06:28:18 +01:00
Jonatan a079f3fc90 feat(oci): add optional Zigbee serial adapter 2026-10-27 23:21:32 +01:00
ProxMenuxBot cdb1bd0cbf chore(lang): auto-rebuild translation cache
Source: 87c4883
Triggered by: push
2026-10-27 21:31:10 +00:00
MacRimiandGitHub 87c48830c5 Merge pull request #547 from jcastro/feature/netalertx-oci-20261027
Add NetAlertX to OCI catalog
2026-10-27 22:30:11 +01:00
MacRimi ecc8017f71 Merge develop into feature/netalertx 2026-10-27 22:22:57 +01:00
MacRimi fc8c23fd66 feat(monitor): VM disk thresholds in Settings 2026-10-27 22:22:05 +01:00
MacRimiandGitHub 5adafeaa96 Merge pull request #546 from jcastro/feature/scrypted-oci-20261027
Add Scrypted to the OCI app catalog
2026-10-27 22:18:41 +01:00
Jonatan aeb55236c1 Add NetAlertX to OCI catalog 2026-10-27 22:01:12 +01:00
Jonatan f623d29e12 Add Scrypted OCI application profile 2026-10-27 21:36:38 +01:00
Jonatan 9ab744e784 feat(oci): add Zigbee2MQTT 2026-10-27 20:49:25 +01:00
MacRimiandGitHub 9fef8d89a7 Merge pull request #541 from jcastro/feature/proxmenux-oci-apps-20261027
Add Umami OCI application profile
2026-10-27 20:04:41 +01:00
MacRimi 85857acaf9 Merge develop into feature/proxmenux-oci-apps-20261027 2026-10-27 19:58:28 +01:00
ProxMenuxBot f5c98fb700 chore(lang): auto-rebuild translation cache
Source: f79c35b
Triggered by: push
2026-10-27 18:54:38 +00:00
MacRimiandGitHub f79c35b038 Merge pull request #538 from jcastro/feature/proxmenux-oci-next-10
Add ten OCI application profiles
2026-10-27 19:53:44 +01:00
MacRimiandGitHub 6173c012e6 Merge pull request #539 from f3rs3n/fix/overview-chart-axis-clipping
fix(monitor): keep overview Y-axis titles inside charts
2026-10-27 19:53:01 +01:00
MacRimi 4cefc0484b feat(post-install): rebuild Log2RAM with the journal and task logs kept on disk 2026-10-27 19:48:26 +01:00
MacRimi ac9b8c2705 Update remove-banner-pve-v3.sh 2026-10-27 18:14:40 +01:00
MacRimi 085bb084eb fix(monitor): show quiet rotational disks by their identity after a restart 2026-10-27 17:20:44 +01:00
Jonatan 8f8a80785a Add Umami OCI application profile 2026-10-27 11:32:59 +01:00
martino e6d39f6015 fix(monitor): keep overview Y-axis titles inside charts 2026-10-27 02:48:25 +01:00
Jonatan ec5fbd668f Add ten OCI application profiles 2026-10-27 01:50:12 +01:00
MacRimi 688b7f54bc Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-27 00:16:17 +01:00
MacRimi c676d883d9 ci: merge the feature test workflows into one 2026-10-27 00:16:02 +01:00
github-actions[bot] 6feb11ffb9 Update AppImage beta build (2026-10-26 23:09:48) 2026-10-26 23:09:48 +00:00
MacRimi 2b1ae0be72 docs: remove missing Audit & Report images and sample PDF links 2026-10-26 23:45:25 +01:00
MacRimi 28d9ff1c9e New version 1.2.7 2026-10-26 23:37:27 +01:00
MacRimi 6dc2c1f13c Merge remote-tracking branch 'origin/main' into develop 2026-10-26 23:18:22 +01:00
MacRimi a48bccab38 New version 1.2.7 2026-10-26 23:10:13 +01:00
MacRimi 96a388d1e7 New version 1.2.7 2026-10-26 23:07:32 +01:00
MacRimi 314178e0ac docs: add MaEscalanteHe to the contributors page 2026-10-26 23:01:12 +01:00
MacRimi cca3516e65 fix(oci): update and modify apps that mount a host system path (#531) 2026-10-26 22:53:09 +01:00
MacRimiandGitHub 05cf4a40c3 Merge pull request #535 from f3rs3n/fix/italian-release-delta
fix(i18n): refine final Italian OCI and update messages
2026-10-26 22:36:24 +01:00
MacRimiandGitHub e5b5a99c52 Merge pull request #534 from Vaso73/i18n/sk-pre127-review
i18n: review Slovak strings before 1.2.7
2026-10-26 22:36:09 +01:00
MacRimi f6d16fd054 docs: release notes for the OpenAI model update 2026-10-26 22:33:51 +01:00
martino 01584b974a fix(i18n): refine final Italian OCI and update messages 2026-10-26 21:48:59 +01:00
VAIO73 106454d47b docs(i18n): clarify Slovak AI model recommendations 2026-10-26 19:03:00 +00:00
VAIO73 80456679f1 i18n: polish the Slovak OCI release note 2026-10-26 19:03:00 +00:00
VAIO73 575e759718 i18n: clarify Slovak OCI catalog and runtime messages 2026-10-26 19:02:59 +00:00
MacRimiandGitHub 617d85e427 Merge pull request #532 from MaEscalanteHe/feature/update-openai-models
fix: update OpenAI models and send supported reasoning parameters
2026-10-26 19:37:01 +01:00
ProxMenuxBot ede91f30b8 Update helpers_cache.json 2026-10-26 18:08:39 +00:00
Manuel Escalante e9b8fd28db test: cover chat contract for classic OpenAI models 2026-10-26 19:02:32 +01:00
ProxMenuxBot 9b712982e1 Update app tracking hints 2026-10-26 18:02:11 +00:00
MacRimi 85e5c5f29b Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-26 18:47:12 +01:00
MacRimi 7b1736fe3e fix: name the guest in VM/CT health alerts, refresh AI models, propose the recipe's volume choice
- A health problem of a VM or CT names the guest in its title and in the
  Quiet Hours summary; a recovery shows its category by name in the
  reader's language.
- AI model lists reviewed against each provider: Gemini adds 3.6/3.7/3.8
  Flash, Groq adds gpt-oss-120b, and Groq/OpenRouter models no longer
  served are removed.
- OCI installer proposes the volume choice the recipe declares, so a path
  meant for a host directory is proposed as one; Duplicati /source is
  proposed on a host directory.
2026-10-26 18:47:09 +01:00
Manuel Escalante a4148c6616 docs: update OpenAI model examples 2026-10-26 18:35:47 +01:00
Manuel Escalante acbe53ad4a chore: update verified OpenAI models to current lineup 2026-10-26 18:35:47 +01:00
Manuel Escalante 542ff12890 fix: send supported reasoning parameters to gpt-5.1 and gpt-6 models 2026-10-26 18:35:47 +01:00
MacRimiandGitHub 55e830904d Merge pull request #528 from jcastro/feature/oci-input-review
Support owner-aware OCI tmpfs mounts and enable Input
2026-10-26 18:17:48 +01:00
MacRimi a640993b94 Generate the Laravel APP_KEY of Input during the installation
Laravel accepts a 32-character key, which token-hex with 16 bytes provides, so the installation no longer asks the user to create one with openssl.
2026-10-26 18:15:01 +01:00
MacRimi b6500c147e Monitor: forget the App tab of a destroyed guest 2026-10-26 18:02:30 +01:00
Jonatan 92c6a000b5 Translate OCI tmpfs validation messages 2026-10-26 17:51:06 +01:00
Jonatan f6f811f5ea Refresh OCI validation catalog count 2026-10-26 17:46:45 +01:00
Jonatan 0d063d519c Merge develop into OCI input review 2026-10-26 17:44:52 +01:00
MacRimi fc7c3181bc OCI: retry registry lookups and say why one failed 2026-10-26 17:40:33 +01:00
Jonatan a1f90ddaf2 Add owner-aware OCI tmpfs mounts 2026-10-26 17:36:21 +01:00
MacRimi be6ef0069b 1.2.7: IT-Tools installable, catalog categories and release notes
- IT-Tools has the template the menu installs from, so it is offered
  like the rest of the catalog.
- Every curated application is listed in categories.json, and OpenTask's
  template matches its recipe.
- The texts of the LXC update jobs read correctly in Spanish, German,
  French, Italian, Portuguese and Swedish.
- The release notes count 514 applications and credit PRs #526, #527,
  #529 and #530.
2026-10-26 17:21:24 +01:00
ProxMenuxBot ae809142f6 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ba6f691
Triggered by: push
2026-10-26 15:58:38 +00:00
MacRimiandGitHub 3e1729a262 Merge pull request #530 from MaEscalanteHe/feature/update-anthropic-models
fix: update Anthropic models and handle thinking/refusal responses
2026-10-26 16:57:18 +01:00
MacRimiandGitHub 88b942a543 Merge pull request #529 from jcastro/fix/oci-watchdog-instance-identity
Prevent OCI watchdog from restarting recycled VMIDs
2026-10-26 16:56:37 +01:00
MacRimiandGitHub ba6f691f90 Merge pull request #527 from Vaso73/fix/lxc-update-session-resilience
Keep LXC updates running when the browser session expires
2026-10-26 16:56:12 +01:00
MacRimiandGitHub 5e6ed0a19b Merge pull request #526 from jcastro/feature/oci-next-50-apps
Add 50 validated OCI application profiles
2026-10-26 16:55:41 +01:00
ProxMenuxBot d1c7fa0158 Update helpers_cache.json 2026-10-26 13:31:10 +00:00
ProxMenuxBot 5596cc9149 Update app tracking hints 2026-10-26 12:50:03 +00:00
Manuel Escalante c316e901cd docs: update Anthropic model examples in pt, sk and sv 2026-10-26 12:41:23 +01:00
Manuel Escalante 1e0c6e70bf docs: update Anthropic model examples in AI assistant page 2026-10-26 12:07:18 +01:00
Manuel Escalante 0313e58bd8 chore: update verified Anthropic models to the 5.x family 2026-10-26 12:07:18 +01:00
Manuel Escalante f2ef3bebbd fix: read text block and handle refusals in Anthropic provider 2026-10-26 12:07:18 +01:00
Jonatan Castro d403f25fbb Update watchdog localization fixtures for installation identity 2026-10-26 12:07:03 +01:00
Jonatan Castro ff11a6d462 Fix OCI watchdog identity checks for recycled VMIDs 2026-10-26 12:00:17 +01:00
Jonatan e82d95f1cf Document blocked Input OCI profile 2026-10-26 11:17:47 +01:00
Jonatan 4e00fcd746 Update OCI validation catalog count 2026-10-26 09:25:24 +01:00
Jonatan c5458f34d9 Translate A25 catalog text in seven languages 2026-10-26 09:15:28 +01:00
github-actions[bot] 85650ca0e8 Update AppImage beta build (2026-10-26 08:07:43) 2026-10-26 08:07:43 +00:00
Jonatan 25953b03bd Fix rqlite default startup command 2026-10-26 09:06:56 +01:00
Jonatan e99b5f32ee Use rqlite's upstream GHCR mirror 2026-10-26 09:06:56 +01:00
Jonatan 975a3b99cf oci: fix low-port and generated secret profiles 2026-10-26 09:06:56 +01:00
Jonatan a4fb46f861 oci: fix rqlite data directory argument 2026-10-26 09:06:56 +01:00
Jonatan dede474ff4 i18n: translate remaining OCI profile text 2026-10-26 09:06:56 +01:00
Jonatan 2a02154f17 oci: add 25 standalone service profiles 2026-10-26 09:06:09 +01:00
Jonatan ecd0b9c54b Fix OpenTask managed volume ownership 2026-10-26 09:05:06 +01:00
Jonatan 1ff10e7a7f Set Instatic data volume owner 2026-10-26 09:05:06 +01:00
Jonatan 5c90f40ef6 Set Flagr data volume owner 2026-10-26 09:05:06 +01:00
Jonatan ee5729aba2 Set Sofa persistent volume owner 2026-10-26 09:05:06 +01:00
Jonatan a42dd9d28c Align InvoiceShelf session URLs with LXC address 2026-10-26 09:05:06 +01:00
Jonatan 2014105343 Add 25 curated OCI application profiles 2026-10-26 09:05:06 +01:00
VAIO73 744c0165e2 fix: keep update viewers responsive under input backpressure 2026-10-26 07:57:20 +00:00
VAIO73 723f53a1f1 fix: allow update cancellation to restore the original guest state 2026-10-26 07:57:20 +00:00
VAIO73 fd74b3cacc fix: detach closed update viewers and preserve other guest state 2026-10-26 07:57:20 +00:00
VAIO73 11326badb2 test: preserve legacy terminal and update caller coverage 2026-10-26 07:57:20 +00:00
VAIO73 44ab9d506d fix: keep LXC updates running when the browser session ends 2026-10-26 07:57:20 +00:00
MacRimi af29bc9274 Release notes: the catalog lists 464 applications
The release notes, the changelog and What's New in Version 1.2.7 count
the hundred applications of PRs #524 and #525 and credit them, together
with the Italian review of PR #522 and the French field icons of PR #523.
2026-10-26 07:48:58 +01:00
MacRimiandGitHub 9f977ac0be Merge pull request #525 from jcastro/feature/oci-next-50-extra
Add 50 curated OCI app profiles
2026-10-26 07:43:43 +01:00
MacRimi 761385a107 Merge develop into feature/oci-next-50-extra
Resolves the catalog index and translation conflicts with PR #524 by keeping the entries and keys of both, adds the fifty applications to categories.json and renders oci/VALIDATION.md for 464 applications.
2026-10-26 07:41:00 +01:00
MacRimiandGitHub 354966180c Merge pull request #524 from jcastro/feature/oci-next-50
Add 50 curated OCI app profiles
2026-10-26 07:31:32 +01:00
MacRimi fab4aee37a Keep the configuration of Glance, Homer and Prometheus across updates
Glance and Homer write their starter configuration only when it is missing, so an update does not replace the one the user edited. Prometheus keeps /etc/prometheus in a volume of its own, which starts with the configuration of the image and can be a host directory.
2026-10-26 07:28:40 +01:00
MacRimiandGitHub 170293c67a Merge pull request #523 from f3rs3n/fix/monitor-fixtures-localized-icons
fix(monitor): isolate offline tests and preserve localized field icons
2026-10-26 07:26:58 +01:00
MacRimiandGitHub 9340def0e7 Merge pull request #522 from f3rs3n/fix/italian-prerelease-messages
fix(i18n): polish new Italian catalog messages
2026-10-26 07:26:39 +01:00
Jonatan b4bd674a80 Refresh generated OCI validation count 2026-10-26 06:39:53 +01:00
Jonatan 6b62aa2608 Add missing Admin UI translations 2026-10-26 06:38:13 +01:00
Jonatan 541eb6379e Document oCIS URL behind HTTPS proxy 2026-10-26 06:18:35 +01:00
github-actions[bot] 5c6afd1fcb chore: update repository growth [skip ci] 2026-10-26 04:36:49 +00:00
Jonatan 23ac5ba228 Align Kiwix runtime command with official image 2026-10-26 05:27:14 +01:00
Jonatan e255b4cc64 Pass Kiwix ZIM wildcard to image entrypoint 2026-10-26 05:24:48 +01:00
Jonatan e909e1ce67 Fix Kiwix command passthrough 2026-10-26 05:21:49 +01:00
Jonatan 8269d9aaaf Add 50 curated OCI app profiles 2026-10-26 05:04:41 +01:00
ProxMenuxBot aa8a28294d Update helpers_cache.json 2026-10-26 00:15:07 +00:00
ProxMenuxBot 3b983a0a78 Update app tracking hints 2026-10-26 00:02:34 +00:00
Jonatan f2854ebece Refresh OCI validation catalog count 2026-10-26 00:50:18 +01:00
Jonatan 8eeafa1f18 Add 50 curated OCI app profiles 2026-10-26 00:47:35 +01:00
martino 6c2e268c4b fix(monitor): isolate offline tests and preserve localized field icons 2026-10-26 00:35:37 +01:00
martino 12f424911d fix(i18n): polish new Italian catalog messages 2026-10-25 23:41:36 +01:00
MacRimi 1ff63fa955 OCI catalog: keep the Gatus configuration and explain the Pocket ID address
- Gatus keeps config.yaml in a /config volume of its own, which can also
  be a host directory, and the installation writes it only when it is
  missing, so updates and recreations keep the endpoints added to it.
- The address Pocket ID is opened at says what it is for and that
  passkeys need HTTPS.
- The catalog categories are sorted again.
- The release notes count 364 applications and credit PR #521.
2026-10-25 20:49:55 +01:00
MacRimiandGitHub c24d022f40 Merge pull request #521 from jcastro/feature/oci-apps-tested-batch
Add ten curated OCI service profiles
2026-10-25 20:34:49 +01:00
Jonatan 6bb9a1ebb0 Translate OCI catalog additions 2026-10-25 20:28:56 +01:00
Jonatan e649a92f30 Add ten curated OCI applications 2026-10-25 20:22:01 +01:00
MacRimi bab512281c Release notes: the catalog lists 354 applications 2026-10-25 19:26:08 +01:00
MacRimi 3686d613e6 ProxMenux 1.2.7: release notes, changelog and version 2026-10-25 19:22:44 +01:00
MacRimiandGitHub 896817affa Merge pull request #520 from jcastro/feature/oci-curated-apps
Add seven curated OCI applications
2026-10-25 19:19:54 +01:00
MacRimi b2067e18a5 Add the icons of the seven curated OCI applications
Every catalog application carries its icon; these come from the same selfhst/icons set as the rest of the catalog.
2026-10-25 19:11:45 +01:00
ProxMenuxBot d152fc42d3 Update helpers_cache.json 2026-10-25 18:06:00 +00:00
ProxMenuxBot 69518f701f Update app tracking hints 2026-10-25 18:01:08 +00:00
Jonatan 281dfdf638 Translate new OCI catalog descriptions 2026-10-25 18:59:47 +01:00
Jonatan 17fe62062e Refresh OCI validation catalog count 2026-10-25 18:55:30 +01:00
Jonatan 8b1cbbbb0d Add seven curated OCI applications 2026-10-25 18:52:36 +01:00
MacRimi fc41b04e87 Pre-release 1.2.7: OCI fixes, 12 new catalog applications and docs updates
Last changes before version 1.2.7.

OCI manager Apps
- Multi-container applications start while one of their containers is
  being backed up: the start hook waits until vzdump releases it and does
  not start it twice. The DHCP client of a dependency no longer keeps the
  lock of the hook, so later starts do not hang, and the watchdog waits
  while a dependency is locked. Installing or updating ProxMenux replaces
  the published copies of the hook already in place.
- Images built from scratch with a numeric user, such as ddns-updater,
  install: the container is created from a copy of the image that adds
  the /etc/passwd and /etc/group Proxmox VE needs, and the application
  runs with the user the image declares. The downloaded image stays
  unchanged. An image with a named user and no user database stops
  before anything is created, with a message that says why.

Catalog
- Twelve new applications: MDBListarr, CrossWatch, Free Games Claimer
  Remaster, Cloudreve, Poznote and AceStream HTTP Proxy, suggested in
  discussion #440, and the Dockur images of Chrony, Dnsmasq, Tor,
  Stunnel, strfry and Statping. The catalog lists 347 applications.
- Their prompts, labels and notes are translated into the seven
  languages of the menus.

Documentation
- OCI manager: architecture, lifecycle and stacks pages revised, with
  larger diagrams and new screenshots.
- Contributors: the page now thanks those developing, reviewing,
  translating and testing ProxMenux today and those who helped in its
  first steps, with new avatars.

Tests for the stack start hook, the watchdog, the user database check
of OCI images and the Tor installation summary.
2026-10-25 13:17:54 +01:00
github-actions[bot] e233d1856b chore: update repository growth [skip ci] 2026-10-25 04:32:04 +00:00
github-actions[bot] 257c2728fb chore: update repository growth [skip ci] 2026-10-24 04:30:51 +00:00
MacRimi 2f48689087 fix(post-install): explain when an AMD CPU does not need the AMD fixes 2026-10-23 18:24:43 +02:00
MacRimiandGitHub 779dd7fb26 Merge pull request #517 from f3rs3n/fix/optimization-description-wording
fix(post-install): align optimization descriptions with existing behavior
2026-10-23 17:03:08 +01:00
MacRimi 225438209e Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-23 18:00:10 +02:00
MacRimiandGitHub adda62d1ed Merge pull request #516 from f3rs3n/fix/italian-delta-tui-monitor
fix(i18n): refine new Italian TUI and Monitor messages
2026-10-23 16:58:11 +01:00
MacRimiandGitHub 6556d560fa Merge pull request #515 from f3rs3n/fix/bashrc-migration-safety
fix(post-install): preserve Bashrc on reruns and write failures
2026-10-23 16:57:06 +01:00
MacRimi 5ece466d1e Merge commit 'refs/pull/516/head' of https://github.com/MacRimi/ProxMenux into HEAD
# Conflicts:
#	AppImage/messages/it/common.json
2026-10-23 17:50:12 +02:00
MacRimi b879adf54c i18n: update the six corrected descriptions in the other languages
- Translate the new Log2RAM, kexec, AMD, guest agent and backup descriptions into es, de, fr, pt and sv
2026-10-23 17:49:51 +02:00
github-actions[bot] c15834ba63 chore: update repository growth [skip ci] 2026-10-23 04:33:34 +00:00
ProxMenuxBot 39891e5177 Update helpers_cache.json 2026-10-23 00:14:32 +00:00
martino e032d869ff fix(post-install): align optimization descriptions with existing behavior 2026-10-22 23:46:56 +02:00
martino 1f0c72b90b fix(i18n): refine new Italian TUI and Monitor messages 2026-10-22 22:28:20 +02:00
martino 03770129a3 fix(post-install): preserve Bashrc on reruns and write failures 2026-10-22 21:04:15 +02:00
MacRimiandGitHub 6422d6ecd5 Merge pull request #514 from jcastro/fix/oci-zfs-snapshot-mountpoints
fix(oci): preserve image defaults for ZFS snapshot backups
2026-10-22 17:53:14 +01:00
MacRimi d17c017ccf Thank you so much for this, merging it now. 2026-10-22 18:47:32 +02:00
MacRimi 6699a4afbb fix(oci): keep installing when an image mountpoint is not a plain directory
- Log and continue when the helper refuses a mountpoint, as the seed capture does
- Nothing is moved in that case, so the image installs as before
2026-10-22 10:52:35 +02:00
ProxMenuxBot b3319268a7 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3298b55
Triggered by: push
2026-10-22 08:34:26 +00:00
jcastro 00ebb0b2f9 fix(oci): preserve image defaults before attaching managed volumes 2026-10-22 10:27:00 +02:00
ProxMenuxBot 7aacd08a96 chore(lang): auto-rebuild translation cache
Source: 3298b55
Triggered by: push
2026-10-22 08:23:52 +00:00
MacRimiandGitHub 3298b55cc6 Merge pull request #513 from Vaso73/i18n/slovak-monitor-cli-messages
i18n: complete Slovak Monitor and CLI messages
2026-10-22 09:22:59 +01:00
ProxMenuxBot 396b44eacb Update helpers_cache.json 2026-10-22 06:08:57 +00:00
VAIO73 d9bd8c47de test: remove duplicate catalog regression after merge 2026-10-22 05:05:05 +00:00
VAIO73 dbcc4de6e1 fix(i18n): address Monitor and CLI review feedback 2026-10-22 04:49:16 +00:00
VAIO73 61168b9abd Merge develop and preserve Slovak Monitor and CLI messages 2026-10-22 04:44:42 +00:00
github-actions[bot] ff81c4269c chore: update repository growth [skip ci] 2026-10-22 04:32:40 +00:00
MacRimi c04fd65b4f Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	AppImage/messages/de/common.json
#	AppImage/messages/es/common.json
#	AppImage/messages/fr/common.json
#	AppImage/messages/it/common.json
#	AppImage/messages/pt/common.json
#	AppImage/messages/sv/common.json
2026-10-22 00:09:58 +02:00
MacRimi 203496f14f i18n: complete the other languages for the Slovak work
- Translate the new terminal and Monitor texts into es, de, fr, it, pt and sv
- Replace the machine fills of those new keys with reviewed translations
- Keep the complete terminal catalog check in CI
- Keep Machine learning in English in Spanish
- Fix the German Exit label
- Make the fallback tests use a locale without the keys
2026-10-22 00:09:54 +02:00
ProxMenuxBot b0ffcd2049 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a1ee7fa
Triggered by: push
2026-10-21 22:04:28 +00:00
ProxMenuxBot c0046cb24f chore(lang): auto-rebuild translation cache
Source: 53e9f08
Triggered by: push
2026-10-21 21:46:15 +00:00
ProxMenuxBot 4ffbaa26d3 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a5371b0
Triggered by: push
2026-10-21 21:40:53 +00:00
MacRimiandGitHub a1ee7fa744 Merge pull request #512 from Vaso73/i18n/slovak-optimization-source
i18n: localize Slovak optimization source viewer and handle stale responses
2026-10-21 22:36:28 +01:00
MacRimiandGitHub 0d346c6bef Merge pull request #511 from Vaso73/i18n/slovak-app-editor
i18n: complete Slovak VM and LXC application editor messages
2026-10-21 22:34:03 +01:00
MacRimiandGitHub 53e9f08fe7 Merge pull request #510 from Vaso73/i18n/slovak-oci-manager
i18n: complete Slovak OCI Manager messages and cancellation
2026-10-21 22:16:09 +01:00
MacRimi 9864fae62a Merge commit '1e0595aac2b2fc3a1510e2d356ef01120ef918b4' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
2026-10-21 23:14:30 +02:00
MacRimi 1e0595aac2 Merge commit 'c471bccdab95' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
2026-10-21 23:14:28 +02:00
MacRimi c471bccdab Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/run_offline_qualified.py
#	lang/sk.json
2026-10-21 23:13:45 +02:00
MacRimiandGitHub b75f2789b3 Merge pull request #509 from Vaso73/i18n/slovak-zram-pbs-cli
i18n: complete Slovak ZRAM and PBS CLI messages
2026-10-21 22:09:24 +01:00
MacRimiandGitHub a5371b05dc Merge pull request #508 from Vaso73/i18n/slovak-monitor-backup
i18n: complete Slovak node, PBS and host backup messages
2026-10-21 22:06:50 +01:00
VAIO73 6b41ff4247 i18n: complete Slovak Monitor and CLI messages 2026-10-21 09:53:29 +00:00
VAIO73 d687a79ad8 test: match translated source titles to real optimization metadata 2026-10-21 08:56:56 +00:00
VAIO73 09f9ee18fc i18n: translate optimization names in source viewer headings 2026-10-21 08:56:56 +00:00
VAIO73 06d06041c7 test: verify source response after unmount and retain narrowed source 2026-10-21 08:56:56 +00:00
VAIO73 0d7c4554bc fix: validate and type source viewer response fields 2026-10-21 08:56:56 +00:00
VAIO73 08d2b22c14 fix: keep source viewer backdrop aligned with viewport 2026-10-21 08:56:56 +00:00
VAIO73 92cde1bac3 style: align source viewer close button attributes 2026-10-21 08:56:56 +00:00
VAIO73 ae37003df8 i18n: localize optimization source viewer and protect pending reads 2026-10-21 08:56:56 +00:00
VAIO73 40f967d87e test: cover editor cancellation and preserve command diagnostics 2026-10-21 08:13:04 +00:00
VAIO73 62b8b4e2ae test: load application error translations in panel fixtures 2026-10-21 08:13:04 +00:00
VAIO73 e9b310743a i18n: complete Slovak application editor and validation messages 2026-10-21 08:13:04 +00:00
VAIO73 ab1b6ce7a1 test: install JSON Schema validator for offline OCI regressions 2026-10-21 07:39:23 +00:00
VAIO73 a191096ee4 test: allow English fallback for untranslated CLI catalogs 2026-10-21 07:11:28 +00:00
VAIO73 c00207bd20 i18n: unify Slovak network bridge wording across the app 2026-10-21 07:06:04 +00:00
VAIO73 bb8f5b5c06 i18n: retain bridge term alongside Slovak network labels 2026-10-21 07:06:04 +00:00
VAIO73 153743b80d i18n: clarify OCI network bridge labels in Slovak 2026-10-21 07:06:04 +00:00
VAIO73 6c05688196 i18n: complete OCI runtime and maintenance Slovak diagnostics 2026-10-21 07:06:04 +00:00
VAIO73 37a6c02111 i18n: complete OCI catalogue and wizard Slovak wording 2026-10-21 07:06:04 +00:00
VAIO73 092a62e2dd i18n: preserve consume directory name in OCI storage question 2026-10-21 07:05:14 +00:00
VAIO73 3b12e68e8c i18n: clarify OCI storage and shared paths in Slovak 2026-10-21 07:05:14 +00:00
VAIO73 37ad54232a i18n: clarify coordinated OCI update and recreation in Slovak 2026-10-21 07:05:14 +00:00
VAIO73 d5f4691be7 i18n: clarify registration prerequisite checks in initial dialog 2026-10-21 07:05:14 +00:00
VAIO73 06fbea8735 i18n: clarify restored OCI registration and cancellation 2026-10-21 07:05:14 +00:00
VAIO73 56844d7f04 i18n: clarify Slovak interrupted OCI recovery and cancellation 2026-10-21 07:05:14 +00:00
VAIO73 96b62c59cc i18n: clarify Slovak OCI application removal dialogs 2026-10-21 07:05:14 +00:00
VAIO73 5ccc1451ce i18n: correct Slovak multi-container OCI editor dialogs and errors 2026-10-21 07:04:41 +00:00
VAIO73 90f080744a fix: treat OCI editor cancellation as a neutral outcome 2026-10-21 07:04:41 +00:00
VAIO73 c7a134b00a i18n: correct Slovak text in the single-container OCI editor 2026-10-21 07:04:41 +00:00
VAIO73 441c34514d fix: clear the OCI confirmation screen after cancellation 2026-10-21 07:04:41 +00:00
VAIO73 7213fb2553 fix: clarify OCI update dialog and distinguish cancellation 2026-10-21 07:04:41 +00:00
VAIO73 43041846eb i18n: complete Slovak custom OCI image and registry dialogs 2026-10-21 07:03:31 +00:00
VAIO73 3970bafbe0 i18n: complete Slovak PBS post-install and update messages 2026-10-21 06:29:48 +00:00
VAIO73 a0379fee0b i18n: complete Slovak ZRAM CLI messages 2026-10-21 06:29:48 +00:00
github-actions[bot] 0e01b9f652 chore: update repository growth [skip ci] 2026-10-21 04:33:23 +00:00
ProxMenuxBot 34fcc64550 Update helpers_cache.json 2026-10-20 12:08:17 +00:00
ProxMenuxBot f8ebe93730 Update app tracking hints 2026-10-20 12:01:23 +00:00
VAIO73 aed77ecefe i18n: localize host backup application errors 2026-10-20 08:45:29 +00:00
VAIO73 1dc9e92f74 i18n(sk): clarify removal of old backups 2026-10-20 08:35:33 +00:00
VAIO73 f9e36f0b0a fix(i18n): align PBS fields and omit duplicate English errors 2026-10-20 08:13:34 +00:00
VAIO73 c991b8280a i18n: complete Slovak PBS dialogs, status and errors 2026-10-20 07:38:20 +00:00
VAIO73 9ffd154a2a i18n: refine Slovak linked nodes and localize validation errors 2026-10-20 06:46:15 +00:00
github-actions[bot] 133929fda6 chore: update repository growth [skip ci] 2026-10-20 04:32:29 +00:00
github-actions[bot] 65f47ec646 Update AppImage beta build (2026-10-19 22:57:37) 2026-10-19 22:57:37 +00:00
MacRimi 0a7a598210 fix: keep PBS logs and maintenance jobs readable
- Draw ZFS ARC only from recorded history, leaving it out on PBS
- Read the maintenance jobs of a local PBS from its API, with their last state
- Show the OK state of PBS jobs in green whatever its case
- Compress rotated logs at once in the Log2RAM and logrotate profiles
- Offer the Log2RAM 1.8 and logrotate 1.2 update to existing installations
2026-10-20 00:53:21 +02:00
github-actions[bot] d8741a8c5d Update AppImage beta build (2026-10-19 22:28:20) 2026-10-19 22:28:20 +00:00
MacRimi 5f1aa4cde7 fix(oci): show the update list at the size of the other menus 2026-10-20 00:24:22 +02:00
MacRimi 58e16c686e fix(oci): show progress between selected updates
- Show which selected update is starting while its container is checked
- Keep the single-application flow unchanged
- Translate the new progress text
2026-10-20 00:19:02 +02:00
MacRimiandGitHub 29b4913047 Merge pull request #503 from Vaso73/feature/local-app-icon-discovery
feat(monitor): discover icons from local LXC applications
2026-10-19 23:27:37 +02:00
MacRimi ad56c02b21 chore(monitor): pin Pillow 12.3.0 and translate the icon lookup
- Use the 12.x line of Pillow for the bundled image decoder
- Translate the local icon lookup texts
2026-10-19 23:20:50 +02:00
MacRimi 5d87106e69 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 23:16:21 +02:00
MacRimi 77a6d1c397 feat(monitor): sign in to a PBS with user and password and add a Tasks tab
- Connect a PBS with user and password, creating a read-only API token
- Never store the password; remove the token when the role cannot be granted
- Show the PBS connection form as a dialog with the project contrast and blue actions
- Move recent tasks and maintenance schedules of a PBS to a new Tasks tab
- Keep the Datastores tab for datastores, disks and pools
2026-10-19 23:12:31 +02:00
ProxMenuxBot 1384a5e2cc Update helpers_cache.json 2026-10-19 18:07:13 +00:00
ProxMenuxBot 6066442cc6 Update app tracking hints 2026-10-19 18:01:56 +00:00
VAIO73 d7cc914c35 fix(monitor): isolate local icon fetching and image decoding 2026-10-19 17:44:53 +00:00
VAIO73 361f26bf70 Merge develop into local application icon discovery 2026-10-19 17:30:37 +00:00
MacRimi cb585632c6 fix(monitor): handle a PBS on the same host and complete the PBS views
- Open a PBS installed on this host on the address in use, without probing it
- Never carry or offer localhost addresses between Monitors
- Read the PBS of a PVE+PBS host in Backup Server, with its hostname and version
- Offer the PBS storages without a connection, with address and fingerprint
- Show the disks and pools of a PBS in its Datastores tab
- Hide guest and PVE storage settings on a PBS
- Show a clearer status line for a PBS
2026-10-19 18:18:18 +02:00
MacRimiandGitHub 311952cdf5 Merge pull request #504 from jcastro/feature/oci-update-status
feat(oci): show update availability and update selected applications
2026-10-19 17:53:11 +02:00
MacRimi 423156214a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:27:45 +02:00
MacRimi 818ab1308a fix(oci): keep update results when one application fails
- Check that the Monitor answers on its socket, not only that it exists
- Report the applications whose check failed instead of failing the whole request
- Show those applications as not checked, without their older result
- Tell the user in the menu when a check fails
2026-10-19 17:23:58 +02:00
MacRimiandGitHub 95bf21b6de Merge pull request #505 from Vaso73/feature/docker-app-discovery
feat(monitor): discover Docker application links on demand
2026-10-19 17:17:49 +02:00
MacRimi e138f4c800 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:17:17 +02:00
MacRimiandGitHub 1385ab3a7c Merge pull request #507 from Vaso73/fix/oci-managed-volume-startup
fix(oci): handle empty lost+found directories before startup
2026-10-19 17:13:49 +02:00
MacRimi 91fe0b400a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-19 17:12:28 +02:00
MacRimi 7bf1e1e34d fix(monitor): keep Docker web links and schemes accurate
- Reuse the Find snapshot for web links only after a complete search
- Skip unassigned or ranged host ports instead of dropping the container
- Prefill the https scheme of published HTTPS ports in the editor
- Run the Docker discovery tests when they change
- Translate the Docker search texts
2026-10-19 17:12:28 +02:00
MacRimi ec934c8c2d Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-19 17:07:02 +02:00
MacRimi 3e3e0916f2 fix(oci): clean format directories of a running container
- Open the container root without O_NOFOLLOW, as /proc/<pid>/root is a link
- Keep the /config preparation of JDownloader in its overlay
2026-10-19 17:06:49 +02:00
MacRimiandGitHub 83ad947b2c Merge pull request #506 from Vaso73/fix/oci-catalog-web-links
fix(oci): align JDownloader and Arr suite web links
2026-10-19 16:46:52 +02:00
VAIO73 1511680a95 fix(oci): reject missing managed mount roots 2026-10-19 12:53:34 +00:00
VAIO73 3e9c9eb25d fix(oci): prepare managed format directories before startup 2026-10-19 12:23:49 +00:00
ProxMenuxBot ff6f023238 Update helpers_cache.json 2026-10-19 12:09:15 +00:00
VAIO73 7e00904dce fix(oci): align JDownloader and Arr suite web links 2026-10-19 11:02:50 +00:00
Jonatan Castro 70d4101243 feat(oci): show update availability and update selected applications 2026-10-19 11:23:45 +02:00
VAIO73 e44db4897b fix(monitor): label Docker published ports precisely 2026-10-19 08:27:47 +00:00
VAIO73 8775fd30c5 fix(monitor): preserve Docker web link ordering 2026-10-19 08:16:16 +00:00
VAIO73 cfb90a094c feat(monitor): discover Docker application endpoints on demand 2026-10-19 08:03:25 +00:00
github-actions[bot] 4332fec8a3 chore: update repository growth [skip ci] 2026-10-19 04:34:56 +00:00
VAIO73 440b6be6ef style(monitor): make local icon lookup button compact 2026-10-18 19:42:30 +00:00
VAIO73 bc51adcd8f fix(monitor): keep active discovery independent of cache refresh 2026-10-18 19:24:25 +00:00
VAIO73 3071095109 fix(monitor): preserve app editor across lifecycle refreshes 2026-10-18 19:21:52 +00:00
VAIO73 0a06a81871 fix: preserve validated embedded raster icons in SVG 2026-10-18 18:57:00 +00:00
VAIO73 6c269bf7a5 fix: preserve runtime address order for local icon lookup 2026-10-18 18:50:54 +00:00
VAIO73 e63ec2ad7b feat: discover validated icons from local LXC applications 2026-10-18 18:41:29 +00:00
github-actions[bot] 29778aa709 Update AppImage beta build (2026-10-18 18:18:45) 2026-10-18 18:18:45 +00:00
MacRimiandGitHub b1dc624e7d Merge pull request #501 from Vaso73/feature/native-discovery-stopped-message
Explain why Find applications cannot scan a stopped LXC
2026-10-18 20:13:17 +02:00
MacRimi e52d194b9a Translate the stopped-container search notice 2026-10-18 20:09:24 +02:00
MacRimi f6cff70298 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 20:09:15 +02:00
MacRimi ab8c4a56ea Apply one Spanish style to the Monitor and the terminal menus
- The Spanish interface addresses the reader as tú, uses the infinitive on buttons and menu options, and uses Spain vocabulary (pulsar, introducir, añadir, the Intro key)
- Backup, snapshot, host, pool and slot stay in English, and Proxmox web interface paths keep their English labels
- Mistranslated texts, missing spaces and gender agreements corrected
- Spanish documentation quotes the updated screen labels
- The translation guide describes the Spanish style
- The command description fixture follows the updated Spanish text
2026-10-18 20:06:06 +02:00
VAIO73 b5a2a3f52b fix: explain why native discovery cannot scan stopped containers 2026-10-18 17:48:48 +00:00
MacRimi c6f919b085 Show linked node addresses as text with an edit button
- Each linked node shows its Monitor address as plain text, marked Automatic when it was detected
- A pencil button opens the address field with Save and Cancel only when it needs changing
- The node cards keep the dialog background so the editable fields stand out
- The Add button keeps white text when disabled
- New texts translated into all languages
2026-10-18 19:45:54 +02:00
MacRimi b720b4a8de Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-18 19:02:31 +02:00
MacRimi a2b96bf6a4 Bring the web documentation in line with develop in every language
- English pages describe backups, the manifest, ROCm GPUs, OCI host directories and storage as develop does, and say user instead of operator
- New paragraph on host directories mounted with everything the host has mounted inside them, and a new update rule for host directories
- The Proxmox Backup Server section of the post-install page is translated into every language
- Spanish and the other six languages follow the updated English
2026-10-18 19:02:17 +02:00
github-actions[bot] bdb4544ede Update AppImage beta build (2026-10-18 16:48:22) 2026-10-18 16:48:22 +00:00
MacRimi 62ddd7599b Carry the linked nodes across Monitor jumps and clarify PBS web links
- A jump between Monitors carries the Monitors the source links, and the destination offers them in one dialog with a checkbox each
- The offer is not repeated in the same browser session after choosing Not now, and disappears once every node is known
- A Proxmox Backup Server without a Monitor is shown as its web interface, with its own icon
- The linked nodes form follows the edit-mode contrast rule, with a blue Add button
- New texts translated into all languages
2026-10-18 18:43:26 +02:00
MacRimiandGitHub 7e32000302 Merge pull request #470 from jcastro/i18n/complete-translations
Complete remaining dashboard and menu translations
2026-10-18 18:22:41 +02:00
MacRimi 241d7a18bf Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 18:20:11 +02:00
MacRimiandGitHub 0000086a93 Merge pull request #484 from jcastro/docs/review-monitor
docs: review Monitor documentation in all seven translations
2026-10-18 18:18:57 +02:00
MacRimi 06968696e3 Update test_lynis_threshold_wording.py 2026-10-18 17:58:56 +02:00
MacRimi 3c814b24b4 Keep application categories in English in Spanish 2026-10-18 17:50:11 +02:00
MacRimi 0149327367 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	AppImage/messages/es/common.json
2026-10-18 17:49:06 +02:00
MacRimi 947b5291e9 Remove the notification menu from the VM and LXC backup dialog; backups follow the notification settings 2026-10-18 17:43:41 +02:00
MacRimi cc7e626785 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 17:16:51 +02:00
MacRimi c1fb72ed0e Describe the Monitor pages as the code behaves 2026-10-18 17:16:51 +02:00
MacRimi 456f7e64f6 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/monitor/access-auth.json
#	web/messages/es/docs/monitor/api-reference.json
#	web/messages/es/docs/monitor/dashboard/vms-lxcs-app.json
#	web/messages/es/docs/monitor/integrations.json
#	web/messages/es/docs/monitor/notifications.json
#	web/messages/sk/docs/monitor/dashboard/vms-lxcs-updates.json
2026-10-18 16:50:21 +02:00
MacRimiandGitHub 700a4de2ee Merge pull request #482 from jcastro/docs/review-hardware
docs: review GPU and Coral guides in all seven translations
2026-10-18 16:49:39 +02:00
MacRimi d06cf0f6f6 List the IOMMU group members before adding a GPU to a VM and keep the NVENC patch state
- Add GPU to VM lists the devices from other slots that share the IOMMU group of the selected GPU before the summary
- The NVIDIA installer keeps the patched flag of the installed driver while its version is unchanged
- New messages translated into all languages
2026-10-18 16:46:41 +02:00
MacRimi 27a2c35eff Describe the IOMMU group list shown by Add GPU to VM 2026-10-18 16:43:42 +02:00
MacRimi 1f0c78608d Describe the GPU and Coral assistants as the scripts run them 2026-10-18 16:34:35 +02:00
MacRimi 023c512df3 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/hardware/switch-gpu-mode.json
2026-10-18 16:20:10 +02:00
MacRimiandGitHub f648521a73 Merge pull request #491 from jcastro/docs/review-utils
docs: review utility documentation in all seven translations
2026-10-18 16:14:32 +02:00
MacRimi b80a255f93 Update proxmox-upgrade-pve8-to-pve9-manual-guide.sh 2026-10-18 16:09:49 +02:00
MacRimi fae36bc09d Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 16:05:23 +02:00
MacRimi 53191b9e7a Describe the PVE 8 to 9 automatic mode as the script runs it 2026-10-18 16:05:23 +02:00
MacRimi 2d48b87ac1 Keep guests running during the PVE 8 to 9 upgrade and complete the help menu
- The upgrade no longer restarts pve-manager while preparing the host, so running guests are not stopped
- The upgrade stops before touching any repository when the Proxmox VE 8 update fails or is cancelled
- The unattended mode continues into the upgrade instead of exiting after the mode question
- Without a subscription, the enterprise repository added by pve-manager 9 is left disabled after the upgrade
- grub-efi-amd64 is installed with its output visible in interactive mode
- Help menu: qm unlock and pct unlock entries in the VM/CT section, renumbered items and aligned labels
- Help menu: pvesm scan nfs takes the server address, and pveupgrade is described as the interactive upgrade
- Help menu: an empty choice no longer runs the previous command
- translate() finds catalog entries for texts with leading or trailing spaces and keeps the padding
- Spanish help menu texts use the tú form and the Intro key
- New help and upgrade messages translated into all languages
2026-10-18 15:56:35 +02:00
MacRimiandGitHub c3cb726cbf Merge pull request #481 from jcastro/docs/review-guides
docs: review practical guides in all seven translations
2026-10-18 15:22:33 +02:00
MacRimi aedd3085a9 Describe the utilities as the scripts run them
- The OVA/OVF import creates the VM with LSI and e1000
- The utilities picker lists 25 tools; jq and ncdu come from the analysis group
- A finished UUP Dump ISO is chosen with Install with personal ISO
2026-10-18 15:15:38 +02:00
MacRimi 2b3f844eef Fix two steps of the Kodi and Samba guides
- The Kodi LXC configuration is edited on the host without stopping it, since the next step reboots it
- Debian 13 uses wsdd2 for WS-Discovery
2026-10-18 15:12:27 +02:00
MacRimi 0eb8c5db20 Keep Spanish utility docs aligned with the menu 2026-10-18 15:07:57 +02:00
MacRimi f66eea2eb7 Keep Spanish guides aligned with the menu 2026-10-18 15:07:56 +02:00
MacRimi 5ac7d976ab Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 15:07:07 +02:00
MacRimi 7ef834411d Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 15:07:06 +02:00
MacRimiandGitHub e87a1e8f51 Merge pull request #478 from jcastro/docs/review-create-vm
docs: review VM creation documentation in all seven translations
2026-10-18 14:32:51 +02:00
MacRimiandGitHub f6d43e7f5f Merge pull request #489 from jcastro/docs/review-settings
docs: review settings documentation in all seven translations
2026-10-18 14:32:24 +02:00
MacRimi cfe0f31e14 Remove the whole installation directory on uninstall and name the release channel in Spanish
- Uninstall removes the installation directory again after the change journal writes to it
- Show version information counts jq installed from GitHub as installed
- Spanish calls the release channel «canal de versiones»
2026-10-18 14:30:02 +02:00
MacRimi bde0cf8da6 Describe the VM creation options as the scripts offer them
- ZimaOS offers Host or KVM64 and SCSI or SATA
- The disk image import works on an existing VM and several formats
- Synology loaders and the NAS list match the current menu
2026-10-18 14:28:28 +02:00
MacRimi 9e03f59034 Describe the settings behaviour as the code does
- A failed channel switch shows the installer output above the error
- Seven language catalogs plus built-in English
- The version information sample keeps the real utils.sh path and channel label
2026-10-18 14:27:36 +02:00
MacRimi 2868f7b8ac Keep Spanish VM creation docs aligned with the glossary 2026-10-18 14:24:09 +02:00
MacRimi d8efdb440b Keep Spanish settings docs aligned with the screen and the glossary 2026-10-18 14:24:07 +02:00
MacRimi 98998fcbd9 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:23:39 +02:00
MacRimi b85e3a0f49 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:23:38 +02:00
MacRimiandGitHub 944df3f93a Merge pull request #480 from jcastro/docs/review-getting-started
docs: review installation and reference pages in all seven translations
2026-10-18 14:20:14 +02:00
MacRimiandGitHub b18ed8c3d6 Merge pull request #483 from jcastro/docs/review-help-info
docs: review command references in all seven translations
2026-10-18 14:19:35 +02:00
MacRimi 95b7950ca3 Fix the help menu NFS scan and align the FAQ with the installer
- Option 19 scans an NFS server for its exports with pvesm scan nfs
- pveupgrade is described as the interactive package upgrade it starts
- The FAQ describes the wget install command and the Post-Install backups of .bashrc and MOTD
- Two Spanish error titles match the interface
2026-10-18 14:17:16 +02:00
MacRimi d07b970f8b Describe the installer, uninstaller and AI languages as the code does
- The installer runs through bash -c with wget, not curl | bash
- The .bashrc and MOTD backups are created by Post-Install
- Thirteen languages belong to the AI language selector
2026-10-18 14:14:37 +02:00
MacRimi 9247e6d887 Describe the disk image formats qm can import 2026-10-18 14:13:26 +02:00
MacRimi 20e355c09f Keep Spanish introduction docs aligned with the menu and the glossary 2026-10-18 14:10:50 +02:00
MacRimi c09f27d489 Keep Spanish command reference docs aligned with the menu and the glossary 2026-10-18 14:10:48 +02:00
MacRimi 3be6dfbfc8 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:09:51 +02:00
MacRimi f18238aad7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 14:09:50 +02:00
MacRimiandGitHub 8cd262b295 Merge pull request #487 from jcastro/docs/review-post-install
docs: review post-install documentation in all seven translations
2026-10-18 14:04:52 +02:00
MacRimiandGitHub 4213878884 Merge pull request #477 from jcastro/docs/review-backup-restore
docs: review backup and restore documentation in all seven translations
2026-10-18 14:04:26 +02:00
MacRimiandGitHub 8cdb519ccb Merge pull request #490 from jcastro/docs/review-storage-share
docs: review storage sharing documentation in all seven translations
2026-10-18 14:03:54 +02:00
MacRimiandGitHub 5ee0506212 Merge pull request #479 from jcastro/docs/review-disk-manager
docs: review disk manager documentation in all seven translations
2026-10-18 14:03:15 +02:00
MacRimi e5bf63b31f Remove the current open-file limits on uninstall and align the Log2RAM check
- Uninstalling removes DefaultLimitNOFILE=1048576 and ulimit -n 1048576 as well as the legacy 256000 lines
- The customizable post-install runs the Log2RAM check every 10 minutes at low priority, as the automated one does
2026-10-18 14:02:33 +02:00
MacRimi 7a6a6f93dc Describe the backup and restore behaviour as the scripts run it
- BORG_RSH, the Borg SSH wizard and attached jobs match the code
- Restore path classes name the right examples and when each path is written
2026-10-18 13:58:15 +02:00
MacRimi d3da2da48c Describe the post-install behaviour as the scripts run it
- The automated run can also ask to confirm the system update
- Persistent NIC names, ZFS ARC reconcile and ARC policy match the code
- Uninstall rewrites journald.conf with systemd defaults
2026-10-18 13:57:26 +02:00
MacRimi 85bea617d6 Keep Spanish post-install docs aligned with the menu and the glossary
- Menu paths use the names of the Spanish menu
- backup, snapshot and usuario follow the glossary
2026-10-18 13:52:33 +02:00
MacRimi f73154a2c7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:51:44 +02:00
MacRimi 2efd775559 Keep the curated Spanish backup and restore docs
- The reviewed Spanish text is kept where the English source is unchanged
- New and changed texts follow the glossary and the Spanish interface
2026-10-18 13:51:26 +02:00
MacRimi 64095689ac Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/backup-restore/cross-kernel.json
#	web/messages/es/docs/backup-restore/destinations/pbs.json
2026-10-18 13:49:35 +02:00
MacRimi d4c4dce832 Describe the storage sharing behaviour as the scripts run it
- A local disk is registered in Proxmox only when As Proxmox storage is marked
- Test NFS Connectivity checks ping, port 2049 and the export list
- Proxmox keeps CIFS passwords with mode 0600, protected by access control
2026-10-18 13:47:58 +02:00
MacRimi 163c964314 Describe the disk manager behaviour as the scripts run it
- Wipe all trims the whole disk when discard is supported and is not a secure erase
- Declining the privileged conversion changes nothing, and accepting backs up the configuration first
- The by-id path is used when it exists, with /dev/sdX as fallback
- Full report opens in a scrollable dialog; SAS progress comes from the self-test log
2026-10-18 13:47:58 +02:00
MacRimi 746c5a61df Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:43:53 +02:00
MacRimi 85bf3b8584 Keep Spanish storage sharing docs aligned with the screen and the glossary
- Error messages match the Spanish interface
- Destructive actions use eliminar
2026-10-18 13:43:53 +02:00
MacRimi ab2dd5dc02 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:43:52 +02:00
MacRimi 3756a6de9a Keep Spanish disk manager docs aligned with the screen
- Format modes, dialogs and error messages use the names the Spanish menu shows
2026-10-18 13:43:52 +02:00
MacRimi 36a8e84491 Update common.json 2026-10-18 13:37:18 +02:00
MacRimiandGitHub 94a8e44ca5 Merge pull request #476 from jcastro/docs/review-about
docs: review project information in all seven translations
2026-10-18 13:35:01 +02:00
MacRimiandGitHub 3ee8f8cd58 Merge pull request #486 from jcastro/docs/review-oci-manager
docs: review OCI manager documentation in all seven translations
2026-10-18 13:34:03 +02:00
MacRimiandGitHub dd62e5ce7d Merge pull request #488 from jcastro/docs/review-security
docs: review security documentation in all seven translations
2026-10-18 13:31:07 +02:00
MacRimiandGitHub ee919dad24 Merge pull request #485 from jcastro/docs/review-network
docs: review networking documentation in all seven translations
2026-10-18 13:27:10 +02:00
MacRimi d67eb39a65 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:19:12 +02:00
MacRimi b6bce3cae4 Keep Spanish project information aligned with the screen and the glossary
- The uninstaller is reached from Ajustes, as the Spanish menu shows
- Backups are called backup; GitHub Discussions keeps its name
2026-10-18 13:19:12 +02:00
MacRimi fc5884cdc1 Keep Spanish OCI manager docs aligned with the screen and the glossary
- Backups of containers and host storage are called backup
- The watchdog is named vigilancia, as in the menu and the Monitor
- The Monitor tabs keep their names App and Logs
2026-10-18 13:18:20 +02:00
MacRimi 45fdceaaa4 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	web/messages/es/docs/oci-manager/hardware.json
#	web/messages/es/docs/oci-manager/storage-network.json
2026-10-18 13:17:03 +02:00
MacRimi 3e9ac6750b Keep iptraf-ng mode names as the program shows them in every language 2026-10-18 13:16:10 +02:00
MacRimi 68eb53c2d9 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:15:58 +02:00
MacRimi f6d3ad4337 Keep security docs aligned with the screen
- Lynis output stays as the program prints it, in every language
- The Spanish HTTPS steps use the Monitor's button names
2026-10-18 13:15:58 +02:00
MacRimi 2d2789a4b7 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 13:13:38 +02:00
MacRimi efaeed3706 Keep Spanish networking docs aligned with the screen and the glossary
- Quoted messages match the Spanish interface, and untranslated ones stay in English
- backup and slot stay in English; iptraf-ng modes keep the program's own names
2026-10-18 13:13:38 +02:00
MacRimi f8cabb4700 Check translation contracts offline, add Swedish to the installer and enable the website languages
- Offline checks for terminal and website catalogs reject translations that change commands, paths, identifiers or placeholders
- Terminal and documentation texts that broke those contracts are corrected in every language
- Swedish can be chosen in the stable and beta installers
- The website routes, language switcher and exported pages cover the eight languages, with the shared website texts in each of them
- Website documentation translations are generated only when requested
- From PR #475 by @jcastro
2026-10-18 12:58:25 +02:00
MacRimi f363ef4395 Let dismissed remote mount warnings clear the health status 2026-10-18 12:38:40 +02:00
ProxMenuxBot e8b8f95c5b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 1cbcdd6
Triggered by: push
2026-10-18 10:15:05 +00:00
MacRimiandGitHub 1cbcdd69aa Merge pull request #492 from Vaso73/feature/native-lxc-discovery
Add on-demand discovery for native LXC applications
2026-10-18 12:12:57 +02:00
MacRimi 8757bbeb4f Count merged-usr units once and accept guests without ss or netstat
- /lib/systemd/system is skipped when it is the same directory as /usr/lib/systemd/system
- A guest without ss or netstat has no listener evidence instead of an incomplete scan
- Spanish messages for native discovery
2026-10-18 12:05:11 +02:00
MacRimi ccdc21504a Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 11:55:20 +02:00
MacRimi 9861a85ccd Name the overlapping mount paths and fix new OCI review translations 2026-10-18 11:49:48 +02:00
ProxMenuxBot 2aa6e0ba44 chore(lang): auto-rebuild translation cache
Source: e3c9162
Triggered by: push
2026-10-18 09:41:55 +00:00
MacRimi e3c9162982 Carry over OCI variables changed in Proxmox and keep the LXC update badge on old checks 2026-10-18 11:40:17 +02:00
MacRimiandGitHub f2bb006728 Merge pull request #497 from jcastro/perf/oci-native-modify
Apply supported OCI settings in place with recreation fallback
2026-10-18 10:46:53 +02:00
MacRimi bef2f837c2 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-18 10:16:34 +02:00
github-actions[bot] d4e98d16b1 chore: update repository growth [skip ci] 2026-10-18 04:31:05 +00:00
MacRimiandGitHub 41ef07ac55 Merge pull request #498 from jcastro/perf/oci-modify-opening
Speed up OCI menus and Escape navigation
2026-10-17 22:16:24 +02:00
MacRimi 32b71bdd3d test(oci): follow the review inside the Modify menu in the latency test 2026-10-17 21:52:03 +02:00
MacRimi 7efb63c478 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:51:33 +02:00
MacRimi d8ae19c76e open Manage straight to the list and simplify registry rows 2026-10-17 21:49:00 +02:00
MacRimiandGitHub 750421a12f Merge pull request #495 from jcastro/feature/oci-modify-menu
Add a settings menu to OCI Modify
2026-10-17 21:36:09 +02:00
MacRimi 2c8ebcaa9f fix(oci): count only paths added by hand in the Modify menu
- Leave the application's own volumes out of the Extra paths count
- Add a regression test for the count
2026-10-17 21:21:30 +02:00
MacRimi 7d17220294 fix(lang): use directorios compartidos in the Spanish Modify menu 2026-10-17 21:20:56 +02:00
MacRimi a32a7577cc Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:20:56 +02:00
MacRimiandGitHub 84200fadb4 Merge pull request #493 from jcastro/perf/oci-manage-record-sync
Speed up OCI Manage by caching unchanged Proxmox config reads
2026-10-17 21:14:58 +02:00
MacRimi 7e458bae2e Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/workflows/test-oci-registries.yml
2026-10-17 21:00:18 +02:00
MacRimi 1bc38884ce run the runtime HOME regressions in the OCI workflow 2026-10-17 20:59:40 +02:00
MacRimiandGitHub 12ab5ae10a Merge pull request #496 from Vaso73/feature/oci-home-resolution-hardening
fix(oci): harden runtime HOME resolution
2026-10-17 20:55:56 +02:00
ProxMenuxBot 152c3ddfe0 chore(lang): auto-rebuild translation cache
Source: e38c085
Triggered by: push
2026-10-17 18:48:23 +00:00
MacRimi e38c085e5f accept non-HTTP answers from guessed service ports 2026-10-17 20:47:24 +02:00
MacRimiandGitHub 4bce49eb46 Merge pull request #494 from jcastro/fix/oci-readonly-volume-preparation
fix(oci): preserve read-only volumes during recreation
2026-10-17 20:27:33 +02:00
ProxMenuxBot ba9ae51615 Update helpers_cache.json 2026-10-17 18:05:27 +00:00
ProxMenuxBot 4dc19ed4d8 Update app tracking hints 2026-10-17 18:01:15 +00:00
MacRimiandGitHub 26cfc50555 Merge pull request #473 from jcastro/fix/oci-external-tags
Fix OCI management after changing Proxmox tags
2026-10-17 19:49:21 +02:00
MacRimi e938a25da3 fix(oci): keep Proxmox tags editable on native stack members
- Accept tag changes in the native stack check before recreate or update
- Keep identity and every other setting checked as before
- Add a regression test for the native stack check
2026-10-17 19:46:16 +02:00
Jonatan ac42bea70f Preserve Modify fallback and detached disks after native edits 2026-10-17 18:57:27 +02:00
MacRimi e2fa8adcae Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-17 18:55:26 +02:00
Jonatan 99adc343e8 Adapt isolated removal preview tests to verified configuration reader 2026-10-17 18:50:42 +02:00
Jonatan 6496318452 Shorten Escape key delay in OCI dialog menus 2026-10-17 18:48:33 +02:00
Jonatan 1e97f732ac Avoid unnecessary probes while opening OCI menus 2026-10-17 18:48:33 +02:00
Jonatan 8a774d05c7 Merge remote-tracking branch 'origin/develop' into perf/oci-modify-opening 2026-10-17 18:48:24 +02:00
MacRimi 592354b0e0 keep the Proxmox Backup Server name in PBS and node selector texts 2026-10-17 18:28:54 +02:00
ProxMenuxBot 6157f34ced chore(lang): auto-rebuild translation cache
Source: 98ae1f3
Triggered by: push
2026-10-17 16:21:01 +00:00
ProxMenuxBot 74c0321956 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 98ae1f3
Triggered by: push
2026-10-17 16:20:37 +00:00
MacRimi 98ae1f3626 feat(pbs): post-install menu for Proxmox Backup Server
- Open the post-install menu from the PBS menu
- List only the customizable options that apply to PBS
- Offer the PBS no-subscription repository through the native repository API
- Upgrade PBS with the package list shown before applying it
- Remove and restore the subscription notice on the PBS web interface
- Install Figurine on PBS without the Proxmox VE aliases
- Offer the Helper-Scripts PBS post-install and microcode scripts
- Tune firewall bridges after the systemd udev rules in the automated post-install
- Add PBS repository policy tests and syntax checks
- Document the PBS differences on the post-install page
- Use backups in the Spanish PBS update message
2026-10-17 18:13:44 +02:00
Jonatan 14513cc898 Speed up OCI Modify entry by deferring backup probes and reusing verified config 2026-10-17 18:05:26 +02:00
Jonatan b70e968f73 Apply OCI runtime settings to existing containers with recoverable edits 2026-10-17 17:11:27 +02:00
Jonatan d286b650d7 Keep native resource edits independent of OCI recreation checks 2026-10-17 17:11:26 +02:00
Jonatan 784ce13c14 Reuse OCI images and apply resource edits without rebuilding 2026-10-17 17:11:26 +02:00
Jonatan 1c42203166 Add selective settings menu for OCI modifications 2026-10-17 16:25:51 +02:00
Jonatan 2390257515 fix(oci): skip preparation of read-only volumes 2026-10-17 15:53:33 +02:00
Jonatan 3e1ae3c4ee Cache unchanged OCI config reads when opening Manage 2026-10-17 15:49:55 +02:00
Jonatan 178540a94f Use the translated integration heading 2026-10-17 15:15:37 +02:00
Jonatan 9f6296501a docs: review utility documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan a35700f54f docs: review settings documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan 068485d339 docs: review storage sharing documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan a9241d7a6c docs: review security documentation in all seven translations 2026-10-17 15:08:54 +02:00
Jonatan 5210fc415a docs: review GPU and Coral guides in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 2b1ae1620d docs: review practical guides in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 5369c3acce docs: review VM creation documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan f07482f201 docs: review installation and reference pages in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 45756cb45f docs: review command references in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 11b1c895f4 docs: review post-install documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan ea6f6dbf6e docs: review backup and restore documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan de9c29a9e9 docs: review disk manager documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan f36b5e22a8 docs: review project information in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan d15876d940 docs: review OCI manager documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan 427c3dc2b9 docs: review networking documentation in all seven translations 2026-10-17 15:08:53 +02:00
Jonatan dace78214a docs: review Monitor documentation in all seven translations 2026-10-17 15:08:53 +02:00
VAIO73 7aec721eb5 fix: invalidate native scans across lifecycle changes and serialize updates 2026-10-17 12:23:12 +00:00
VAIO73 22098042ba fix: exclude Alpine service supervision from native discovery 2026-10-17 11:53:30 +00:00
VAIO73 c393a56b92 fix: preserve native discovery results after delayed cache reads 2026-10-17 11:31:51 +00:00
VAIO73 503302cea3 fix: prefill verified Hestia panel endpoint 2026-10-17 11:27:16 +00:00
VAIO73 20acf0852a fix: find Hestia behind bounded process metadata 2026-10-17 11:27:16 +00:00
VAIO73 1f75147d51 fix: recognize Hestia panel and filter watch helpers 2026-10-17 11:27:16 +00:00
VAIO73 e978bac91a fix: detect native node release layouts 2026-10-17 11:27:16 +00:00
VAIO73 61fae8f6f7 fix: filter native discovery helper services 2026-10-17 11:27:16 +00:00
VAIO73 6d82cf4f15 fix: exclude native system service candidates 2026-10-17 11:27:16 +00:00
VAIO73 bbe811e873 fix: hide native discovery editor immediately on target change 2026-10-17 11:27:16 +00:00
VAIO73 e89f2596b2 feat: discover native LXC applications on demand with evidence 2026-10-17 11:27:16 +00:00
VAIO73 b7b412b45d fix(oci): harden runtime HOME resolution 2026-10-17 11:23:38 +00:00
Jonatan 111593bc38 Fix OCI management after changing Proxmox tags 2026-10-17 12:48:02 +02:00
ProxMenuxBot ce1a3de719 chore(lang): auto-rebuild translation cache
Source: 85f0123
Triggered by: push
2026-10-17 08:36:16 +00:00
MacRimiandGitHub 85f0123c5d Merge pull request #467 from jcastro/feature/pbs-support
Support PBS hosts and remote PBS monitoring
2026-10-17 10:35:28 +02:00
MacRimi 08ae134860 Keep guest services off PBS hosts, refuse only known PBS in PVE scripts and follow the menu conventions
- On a PBS host the Monitor no longer starts the node metrics and VM prewarmers, the LXC update scheduler, the managed installs detector or the app update emitter, which read guests and PVE metrics that do not exist there
- pmx_require_pve refuses only a host known to be PBS, so an unreadable package list leaves the PVE scripts working as before
- The PBS menu numbers its options, shows the tables proxmox-backup-manager prints instead of raw JSON, and says Goodbye on exit
- The PBS update asks to press Enter before returning
- Spanish: Datastores de PBS
2026-10-17 10:01:37 +02:00
MacRimi 69b2dc31d0 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-17 09:47:40 +02:00
github-actions[bot] a42d402e3a chore: update repository growth [skip ci] 2026-10-17 04:30:13 +00:00
ProxMenuxBot c83559643d Update helpers_cache.json 2026-10-17 00:14:09 +00:00
ProxMenuxBot 40c0701ab1 Update app tracking hints 2026-10-17 00:02:37 +00:00
MacRimiandGitHub 227780aba7 Merge pull request #464 from jcastro/fix/lvm-disk-read-policy
Apply disk exclusions and idle policy to automatic LVM scans
2026-10-17 00:05:07 +02:00
MacRimi 628dbe3aa3 Name only physical volumes in a restricted LVM scan and keep its own reads from counting as activity
- An explicit --devices list makes LVM open every device in it, while a plain scan opens only known volumes; the list now names only LVM physical volumes, read from udev by lsblk, so no other disk is touched
- RAID, multipath and encrypted volumes follow the disks they are built on, instead of turning the whole check off
- After each pvs, lvs or vgs run, the disks it read take their counters as the new baseline, so an unused disk can become idle
2026-10-17 00:03:39 +02:00
MacRimi 72b596ff4b Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-16 23:58:03 +02:00
Jonatan c32a9880d9 Merge develop and retain guest retention beside PBS navigation 2026-10-16 23:56:46 +02:00
MacRimiandGitHub f13ce25071 Merge pull request #461 from jcastro/feature/guest-backup-management
Allow selecting and deleting guest backup archives in Monitor
2026-10-16 23:51:46 +02:00
MacRimi a230e8b067 Use backups in the Spanish backup deletion messages 2026-10-16 23:49:08 +02:00
MacRimi 63ae1ba68f Confirm deletions that end within the delay and keep the storage colours
- A deletion that Proxmox completes within --delay prints its task log line, Removed volume '<volid>', instead of JSON; that exact line for the requested volume now counts as deleted
- The backup list keeps the green dot and the colour of each storage
- The deletion request waits up to 300 s, the worst case of 20 archives checked and deleted four at a time
2026-10-16 23:48:49 +02:00
MacRimi 7e97bcb25b Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-16 23:44:39 +02:00
MacRimi 29260a0e41 mend the translations of the storage retention default option 2026-10-16 23:41:01 +02:00
ProxMenuxBot 8d9c0083cd chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ac391cd
Triggered by: push
2026-10-16 21:37:39 +00:00
MacRimiandGitHub ac391cdb35 Merge pull request #462 from jcastro/feature/backup-storage-retention
Expose native storage retention for VM and CT backups
2026-10-16 23:36:09 +02:00
MacRimi f336eb65f0 Let a storage return to the Proxmox default retention and match the job editor labels
- An empty policy removes the storage setting with --delete prune-backups, and a storage without a policy opens with that choice
- Retention fields use the Keep last, Keep daily... labels of the backup job editor
- A retention count of zero is rejected before calling Proxmox
- Spanish uses backups for the retention messages
2026-10-16 23:34:21 +02:00
MacRimi f7220826a6 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:32:05 +02:00
MacRimiandGitHub 1a20638097 Merge pull request #463 from jcastro/feature/apprise-service-guidance
Clarify Slack and Matrix setup and preserve Apprise recovery severity
2026-10-16 23:26:44 +02:00
MacRimi 9a94045c33 Show the external link icon on the Apprise service help links 2026-10-16 23:25:01 +02:00
MacRimi 3c962fef11 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:25:01 +02:00
MacRimi b75d373f31 remove invisible characters from translations 2026-10-16 23:22:50 +02:00
ProxMenuxBot 5661891d83 chore(lang): auto-rebuild translation cache
Source: c9caea0
Triggered by: push
2026-10-16 21:18:59 +00:00
MacRimiandGitHub c9caea0b7a Merge pull request #465 from jcastro/fix/figurine-release-architecture
fix: update Figurine to 2.1.1 and select matching Linux binaries
2026-10-16 23:18:05 +02:00
MacRimiandGitHub 40fdaf0145 Merge pull request #469 from jcastro/fix/i18n-regression-fixture
Fix recurring i18n fixture failures when adding new messages
2026-10-16 23:08:49 +02:00
Jonatan 9ba5a797c4 Merge develop while preserving reviewed translations 2026-10-16 23:06:47 +02:00
MacRimi 4ca430db3a Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
2026-10-16 23:06:07 +02:00
MacRimi 9558bd3609 mend the translations of the stopped scheduled update messages 2026-10-16 23:04:02 +02:00
Jonatan 3a90f218ac Merge develop and preserve generic i18n regression coverage 2026-10-16 23:03:54 +02:00
Jonatan 5197d84328 Merge develop and preserve generic i18n regression coverage 2026-10-16 23:03:50 +02:00
Jonatan 6a9a025199 Complete remaining dashboard and menu translations 2026-10-16 23:02:18 +02:00
Jonatan aba6da22db Complete translations for all supported languages 2026-10-16 23:00:48 +02:00
Jonatan 766cfa0779 Complete translations for all supported languages 2026-10-16 23:00:43 +02:00
Jonatan 8772b286da Complete translations for all supported languages 2026-10-16 23:00:38 +02:00
Jonatan b035373efb Complete translations for all supported languages 2026-10-16 23:00:33 +02:00
Jonatan 8bbb475614 Complete translations for all supported languages 2026-10-16 23:00:22 +02:00
ProxMenuxBot 2898ecbfdb chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 5063c7a
Triggered by: push
2026-10-16 20:57:33 +00:00
MacRimiandGitHub 5063c7a006 Merge pull request #456 from Vaso73/codex/lxc-scheduled-stopped-guard
fix: skip scheduled updates for stopped LXC
2026-10-16 22:56:15 +02:00
MacRimi def0987711 space after semicolons in the registry account messages 2026-10-16 22:42:52 +02:00
ProxMenuxBot b938e14fc5 chore(lang): auto-rebuild translation cache
Source: 248ac5d
Triggered by: push
2026-10-16 20:36:20 +00:00
MacRimi 248ac5dec6 keep registry accounts for the whole cluster and use them everywhere
- Registry accounts are kept in /etc/pve/priv/proxmenux/registry-auth.json, shared by every node, unless REGISTRY_AUTH_FILE says otherwise
- The custom image importer offers to sign in when a registry refuses an image, and reads it again
- Installers, updates, the watchdog and the Monitor update check read private images with the saved accounts
- The host backup includes the accounts, and uninstalling ProxMenux removes them and keeps the OCI records of the cluster
- Spanish written by hand and spacing fixed for the registry account messages in every locale
- Docs: private registries section, Registry accounts in the OCI menu, README storage notes
2026-10-16 22:35:07 +02:00
Jonatan 52632abbd6 Make i18n steady-state tests handle pending translations 2026-10-16 22:16:55 +02:00
Jonatan ada0f1109f Make i18n steady-state tests handle pending translations 2026-10-16 22:16:45 +02:00
Jonatan 87720f14f1 Make i18n steady-state tests handle pending translations 2026-10-16 22:15:49 +02:00
ProxMenuxBot e4c517f258 chore(lang): auto-rebuild translation cache
Source: 31254b1
Triggered by: push
2026-10-16 19:53:59 +00:00
Jonatan 816efa533e Scope menu search coverage to the PVE platform 2026-10-16 21:51:39 +02:00
Jonatan 511bfe52ea feat: support PBS hosts and remote PBS monitoring 2026-10-16 21:46:26 +02:00
MacRimiandGitHub 31254b1c9c Merge pull request #460 from jcastro/feature/oci-private-registries
Add private registry accounts to OCI manager Apps
2026-10-16 21:28:00 +02:00
MacRimi 8840490da0 mend the bot translations of the Synology loader and paste messages 2026-10-16 21:23:31 +02:00
MacRimi af1a648689 feat(vm): add FygoOS to the NAS systems of Create VM
- NAS menu: FygoOS, with the latest installer published on fygonas.com detected and a fallback version
- Menu search finds the NAS menu by the name of each system
- Docs: FygoOS card and per-system notes, including its web port
2026-10-16 21:19:38 +02:00
Jonatan c2ef5a54a6 fix: update Figurine and select architecture-specific binaries 2026-10-16 20:57:14 +02:00
Jonatan 4ffc883c32 Apply disk exclusions and idle policy to automatic LVM scans 2026-10-16 20:45:17 +02:00
Jonatan ee6b3bb232 Clarify Slack and Matrix setup and preserve Apprise recovery severity 2026-10-16 20:39:43 +02:00
Jonatan 113659c0e3 Expose native storage retention for VM and CT backups 2026-10-16 20:32:05 +02:00
VAIO73 9e6978cf10 fix: guard OCI scheduled updates after stop 2026-10-16 18:27:33 +00:00
Jonatan c9cdac58aa Account for pending guest backup translations in i18n fixture 2026-10-16 20:27:27 +02:00
Jonatan d487f33700 Allow selecting and deleting guest backup archives in Monitor 2026-10-16 20:23:09 +02:00
Jonatan 6fc9f9003b Update OCI wording tests for private registry support 2026-10-16 20:03:58 +02:00
Jonatan 7d2d430320 Add private registry accounts to OCI manager Apps 2026-10-16 19:58:40 +02:00
VAIO73 690ace1e57 i18n: shorten stopped scheduled-update reason 2026-10-16 17:21:34 +00:00
VAIO73 d25747646e fix: distinguish current and historical stopped schedule state 2026-10-16 17:21:34 +00:00
VAIO73 413ac5e80e i18n: describe skipped update as historical state 2026-10-16 17:21:34 +00:00
VAIO73 9bc83a13ef fix: address scheduled stopped LXC review feedback 2026-10-16 17:21:34 +00:00
VAIO73 fdbd420961 test: cover scheduled stopped reason in i18n fixture 2026-10-16 17:21:34 +00:00
VAIO73 59ab7784a7 i18n: localize stopped scheduled update reason 2026-10-16 17:21:34 +00:00
VAIO73 c2ad35ca73 fix: skip scheduled updates for stopped LXC 2026-10-16 17:21:34 +00:00
ProxMenuxBot 3000c1cd66 chore(lang): auto-rebuild translation cache
Source: 1b0d6a0
Triggered by: push
2026-10-16 16:38:57 +00:00
MacRimiandGitHub 1b0d6a086e Merge pull request #459 from f3rs3n/fix/synology-loader-downloads
fix(vm): repair Synology loader downloads and extraction
2026-10-16 18:34:14 +02:00
martino db0e609259 fix(vm): repair Synology loader downloads and extraction 2026-10-16 18:28:29 +02:00
ProxMenuxBot d0f8df39db chore(lang): auto-rebuild translation cache
Source: 653dee1
Triggered by: push
2026-10-16 16:28:11 +00:00
MacRimi 653dee192c fix(oci): end a pasted definition with a single Ctrl+D 2026-10-16 18:26:27 +02:00
MacRimi ea3b579089 docs(post-install): document the ZRAM swap profile and fix its translations
- Optional Settings: new step for the ZRAM swap profile, with what it does, how it is used and how to verify it
- Uninstall Optimizations: add the ZRAM swap profile to the kernel and memory group
- Customizable: list the ZRAM swap profile in the Optional category
- lang: rewrite the Spanish ZRAM messages and add the missing space after semicolons and the initial capital in the other locales
2026-10-16 17:50:13 +02:00
ProxMenuxBot 4c8b5b1188 chore(lang): auto-rebuild translation cache
Source: 728baf3
Triggered by: push
2026-10-16 15:34:11 +00:00
MacRimiandGitHub 728baf3714 Merge pull request #450 from phyrexia/feature/zram-memory-profile
feat: add optional ZRAM swap profile and live metrics
2026-10-16 17:28:11 +02:00
MacRimi 4e572b8c21 Merge branch 'develop' into feature/zram-memory-profile 2026-10-16 17:27:04 +02:00
MacRimiandGitHub ebce0b8511 Merge pull request #458 from f3rs3n/fix/vm-iso-downloads
fix(vm): reject failed ISO downloads and repair image sources
2026-10-16 17:14:53 +02:00
MacRimiandGitHub 2cbeacefb2 Merge pull request #457 from f3rs3n/fix/cloud-init-helper-launcher
fix(vm): repair Cloud-Init helper links and download checks
2026-10-16 17:08:52 +02:00
martino b55eca753d test(vm): keep ISO storage isolation in fixtures 2026-10-16 16:28:23 +02:00
Gabriel Gonzalez Pineda 3ad7407e07 fix(post-install): prioritize ZRAM ahead of disk swap 2026-10-16 15:12:14 +01:00
martino f07dbfe10f fix(vm): reject failed ISO downloads and repair image sources 2026-10-16 15:42:58 +02:00
martino 84eb6d2167 fix(vm): repair Cloud-Init helper links and download checks 2026-10-16 14:36:24 +02:00
ProxMenuxBot 36b7bf1bf9 Update helpers_cache.json 2026-10-16 12:08:07 +00:00
github-actions[bot] 7f21cae196 Update AppImage beta build (2026-10-16 06:26:35) 2026-10-16 06:26:35 +00:00
MacRimi 8220edf3b6 keep the current German where the English changed after the review 2026-10-16 07:31:50 +02:00
MacRimiandGitHub ab730977ab Merge pull request #455 from detlefs/develop
Improve german localization of Monitor web UI
2026-10-16 07:27:22 +02:00
Detlef SchneiderandGitHub 2500fb0cfc Merge branch 'develop' into develop 2026-10-16 07:02:38 +02:00
Detlef Schneider d6f814ba52 Fix a lot of uncommon or wrong translations, improve consistency. 2026-10-16 06:52:55 +02:00
github-actions[bot] 307c7cbb83 chore: update repository growth [skip ci] 2026-10-16 04:31:55 +00:00
MacRimi abf05493b9 number the options of the OCI menus instead of showing internal values
- Dialog menus show their options numbered, as the rest of ProxMenux does, and return the value of the chosen one, so each description has the whole width in every language.
- A storage, a bridge, a device node and the main OCI menu keep their tags, and number tags and separators are left as they are.
- Test for the numbering, the default option, the kept tags and a cancelled menu.
2026-10-15 22:55:21 +02:00
github-actions[bot] c7480fc4e7 Update AppImage beta build (2026-10-15 20:43:55) 2026-10-15 20:43:55 +00:00
MacRimi 6890d770bf correct memory and storage in German and keep Technology Preview untranslated 2026-10-15 22:36:17 +02:00
ProxMenuxBot 1e9d97f1aa chore(lang): auto-rebuild translation cache
Source: f5d2074
Triggered by: push
2026-10-15 20:30:38 +00:00
MacRimi f5d2074a9a label OCI manager Apps as a Technology Preview, as Proxmox VE does
- OCI manager Apps carries the Technology Preview label that Proxmox VE gives to application containers from OCI images, in the main menu, the engine windows, the menu search, the message at the end of each installation, the release notes and the documentation.
- The documentation explains that the label refers to the Proxmox VE support it runs on, not to the ProxMenux engine, and stays until Proxmox VE marks it as stable.
- Spanish for the new message; the other languages are left to the translation bot.
2026-10-15 22:28:57 +02:00
ProxMenuxBot bec077dd9e chore(lang): auto-rebuild translation cache
Source: 0035065
Triggered by: push
2026-10-15 18:08:20 +00:00
ProxMenuxBot cf2c0637a7 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 0035065
Triggered by: push
2026-10-15 18:07:24 +00:00
MacRimi 00350655bf add NUT Server to the catalog
- NUT Server (instantlinux/nut-upsd) runs the Network UPS Tools server for a UPS connected to the host by USB: the installation proposes the UPS the host detects, passes it to the container, generates the password of the upsmon user and starts with the host; clients such as PeaNUT connect on port 3493.
- A recipe can ask for the UPS of the host: without one, the installation stops and says so, and the advanced installation lets the node be typed by hand.
- The catalog lists 335 applications.
- Tests for the proposed UPS, a host without one and the recipe.
2026-10-15 20:06:06 +02:00
MacRimi abbb3afc0c return to the installed applications list after each operation 2026-10-15 19:05:19 +02:00
github-actions[bot] 1ca028481d Update AppImage beta build (2026-10-15 16:40:32) 2026-10-15 16:40:32 +00:00
MacRimi 1056cc50c2 capitalise and align the notification history and Ctrl+D translations 2026-10-15 18:35:41 +02:00
ProxMenuxBot 6677743a82 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 89fdfd0
Triggered by: push
2026-10-15 16:33:26 +00:00
MacRimi 89fdfd0952 fix: notification history, Ctrl+D in web terminals, repository and VFIO rule fixes, translation corrections
- Notification settings show the last 20 notifications with the result of each send, the error when one failed and every daily digest run, also on days with nothing to send; the history is read without the flag the API never returned, and the page does not offer to erase it, as Audit & Report reads it.
- The Ctrl menu of the three web terminals sends Ctrl+D, which ends a text pasted in the terminal, also from a phone or a tablet.
- The repository check lists every Proxmox VE or Enterprise Ceph repository whose suite does not match the installed version, with its channel and file, and says where to disable it; repository addresses are never shown.
- Adding an NVIDIA GPU to a VM writes its VFIO udev rule again when it is missing or was edited, and the pre-check counts a missing rule as a pending host change.
- New texts in English and Spanish, modelled in the translation steady-state test, and the notifications docs page describes the history section.
- Corrected the automatic translations of the new LXC update and power control texts, and the start, shutdown, reboot and stop buttons of VMs and LXCs in German, French, Portuguese and Swedish.
- Tests for the history panel, the terminal Ctrl+D, the repository message and the VFIO rule.
2026-10-15 18:30:53 +02:00
ProxMenuxBot 983ed38e3b chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 54d9283
Triggered by: push
2026-10-15 13:51:39 +00:00
MacRimiandGitHub 54d9283eba Merge pull request #449 from Vaso73/fix/vm-control-task-completion
fix: wait for VM power tasks before completing controls
2026-10-15 15:50:01 +02:00
ProxMenuxBot 346c9988a9 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: cd81752
Triggered by: push
2026-10-15 13:47:49 +00:00
MacRimiandGitHub cd817524ff Merge pull request #448 from Vaso73/fix/lxc-package-check-install
fix: separate LXC package checks from installation
2026-10-15 15:44:35 +02:00
VAIO73 8b0ca0d06b fix: accept HA shutdown tasks 2026-10-15 04:33:38 +00:00
github-actions[bot] 84faf0bce4 chore: update repository growth [skip ci] 2026-10-15 04:31:42 +00:00
VAIO73 3254ca6d05 fix: confirm VM control tasks safely 2026-10-15 04:29:26 +00:00
VAIO73 3e806afe8c fix: wait for VM power tasks before completing controls 2026-10-15 04:29:26 +00:00
VAIO73 017b73fffd fix: clear obsolete LXC check failure markers 2026-10-15 04:11:10 +00:00
VAIO73 18236e06bc fix: require running LXC for package checks 2026-10-15 03:49:58 +00:00
VAIO73 664ef981e5 fix: separate LXC package checks from installation 2026-10-15 03:49:58 +00:00
Gabriel Gonzalez Pineda 51ed84f9b1 feat(post-install): add optional zram profile and metrics 2026-10-15 00:28:52 +01:00
MacRimiandGitHub f3add354e6 Merge pull request #447 from Vaso73/fix/lxc-stopped-update-results
fix: preserve update observations for stopped LXC guests
2026-10-14 22:43:37 +02:00
VAIO73 638552e5f8 fix: preserve verified LXC state before a failed start 2026-10-14 17:51:53 +00:00
VAIO73 3489dc0530 fix: preserve update observations for stopped LXC guests 2026-10-14 17:51:53 +00:00
MacRimi ceb783bcf2 follow the node filter in the empty backup list test 2026-10-14 18:12:24 +02:00
ProxMenuxBot 51ce1184da chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: c9479c6
Triggered by: push
2026-10-14 16:07:00 +00:00
MacRimi c9479c6ac1 set apart host backups of other nodes and of former jobs
- Each host backup carries the node it belongs to, read from its source host or its name (`hostcfg-<node>`, a job of this node or its PBS backup ID); both backup lists also return the node name.
- The backup list offers This node / All nodes when some backup belongs to another node, with This node as the default, a count that follows the choice and a note with the backups not shown.
- Backups whose node cannot be read from their name are listed under All nodes.
- A local backup made on this node by a job that no longer exists is marked as Former job, read from its metadata file; backups without it, manual ones and those of other nodes are not marked.
- New texts in English and Spanish, modelled in the translation steady-state test.
- Tests for the node and the former job read from each backup, and for the cluster node membership notifications.
2026-10-14 18:04:42 +02:00
MacRimiandGitHub f2c5ab1636 Merge pull request #444 from fklnc94/fix/cluster-node-notifications
fix(monitor): notify when a cluster node leaves or rejoins
2026-10-14 07:34:31 +02:00
github-actions[bot] 1a8efb96a5 chore: update repository growth [skip ci] 2026-10-14 04:34:12 +00:00
fklnc94 7e04500e0c fix(monitor): notify when a cluster node leaves or rejoins 2026-10-14 01:45:49 +03:00
github-actions[bot] e0e2f10471 Update AppImage beta build (2026-10-13 20:27:57) 2026-10-13 20:27:57 +00:00
MacRimiandGitHub 2167934289 Merge pull request #443 from f3rs3n/fix/health-resolved-recurrence
fix(monitor): reactivate resolved health conditions
2026-10-13 22:17:11 +02:00
martino 3023ac33ec test(monitor): support discovery without TMPDIR 2026-10-13 21:58:09 +02:00
martino 03c8dc9eb6 fix(monitor): reactivate resolved health conditions 2026-10-13 20:08:22 +02:00
ProxMenuxBot 39bb4f8616 Update helpers_cache.json 2026-10-13 18:08:00 +00:00
github-actions[bot] f3d97724c5 Update AppImage beta build (2026-10-13 18:06:56) 2026-10-13 18:06:56 +00:00
MacRimi 00b00b9db7 review the bot texts of the LXC update check in the Monitor 2026-10-13 20:03:21 +02:00
ProxMenuxBot 7a9878e458 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 6efe322
Triggered by: push
2026-10-13 17:55:50 +00:00
MacRimiandGitHub 6efe322c44 Merge pull request #441 from Vaso73/fix/lxc-observed-state
fix: revalidate LXC data after external changes
2026-10-13 19:54:08 +02:00
VAIO73 63f1e631b9 fix: preserve LXC OS hints and allow daily check margin 2026-10-13 17:11:43 +00:00
VAIO73 e83e66caaf fix: revalidate externally changed LXC state 2026-10-13 17:08:42 +00:00
MacRimi ea5831d1bf fix(monitor): an application installed from the OCI menu shows on the Apps page on its own 2026-10-13 16:56:57 +02:00
ProxMenuxBot 3708e9cf79 Update helpers_cache.json 2026-10-13 12:09:54 +00:00
ProxMenuxBot 61041bcbfd Update app tracking hints 2026-10-13 06:03:23 +00:00
github-actions[bot] 118f7c1cf6 chore: update repository growth [skip ci] 2026-10-13 04:41:04 +00:00
ProxMenuxBot 3414a77aa4 Update helpers_cache.json 2026-10-13 00:17:45 +00:00
MacRimi d7f2f13cd9 review the bot texts of the paste option 2026-10-12 23:08:01 +02:00
ProxMenuxBot 631b1cf253 chore(lang): auto-rebuild translation cache
Source: c8e5421
Triggered by: push
2026-10-12 21:05:11 +00:00
MacRimi c8e5421d75 paste an oci-compose in the terminal
- "Install an image that is not in the catalog" also takes an oci-compose pasted in the terminal, next to reading it from a file or downloading it from an address.
- oci/README.md and the documentation page of the option describe the three ways, and the README links to the thread for suggesting applications (#440).
- oci/VALIDATION.md points to discussion #440 instead of the closed #360.
- The oci-compose tests pass on a host set to another language and without python3-jsonschema.
2026-10-12 23:03:49 +02:00
MacRimi aba805d4d9 review the bot texts of the oci-compose option and fix the wording test 2026-10-12 22:38:44 +02:00
ProxMenuxBot 21ae8e94a4 chore(lang): auto-rebuild translation cache
Source: cc12c62
Triggered by: push
2026-10-12 20:32:18 +00:00
MacRimi cc12c625a6 install from a hand-written oci-compose; Log2RAM journal fix
OCI manager Apps
- "Install an image that is not in the catalog" reads an oci-compose from a file of the host or from an address.
- A file written by hand states the image, its ports, variables and volumes, and what the installation asks; the rest is completed when it is read. A complete file, or the record of an installation, is installed as it is, and of a record only the template is used.
- Errors name the field they are in, also on a host without python3-jsonschema.
- oci/README.md describes the engine and the oci-compose block by block, with two examples in oci/examples/.
- The documentation page of the option covers it, in English and Spanish.

Log2RAM (reported by @f3rs3n)
- The sync removes the old active journal file from the disk copy, so a boot no longer restores it and journald deletes its oldest archives first. Log2RAM optimization 1.7; it takes effect from the next reboot.

Translations
- The bot keeps "oci-compose" as written.
- Spanish texts for the new option and for the Log2RAM notice.
2026-10-12 22:29:08 +02:00
ProxMenuxBot 87403a2e48 Update helpers_cache.json 2026-10-12 18:06:40 +00:00
github-actions[bot] 9089f79342 Update AppImage beta build (2026-10-12 16:09:56) 2026-10-12 16:09:56 +00:00
MacRimi 3198209493 fix(monitor): the Changes view lists the customizable post-install under its options 2026-10-12 18:06:18 +02:00
ProxMenuxBot c496f111b6 Update helpers_cache.json 2026-10-12 12:12:39 +00:00
ProxMenuxBot fbafb92f49 Update app tracking hints 2026-10-12 12:03:18 +00:00
MacRimi 03f2a195a6 Update es.json 2026-10-12 13:06:22 +02:00
MacRimi f17f87c94d fix(oci): FileBrowser Quantum opens the folder it is given 2026-10-12 13:00:55 +02:00
github-actions[bot] 4e4b19213b Update AppImage beta build (2026-10-12 10:32:27) 2026-10-12 10:32:27 +00:00
MacRimi e587e27144 fix(monitor): the storage check reads the node name without waiting for the Proxmox API 2026-10-12 12:29:35 +02:00
MacRimi bff791581c fix(gpu): changing the mode of a GPU leaves the other devices of its slot in their VM 2026-10-12 12:12:59 +02:00
github-actions[bot] de7fc4bd49 Update AppImage beta build (2026-10-12 10:05:03) 2026-10-12 10:05:03 +00:00
MacRimi 80af670794 fix(monitor): a permanent dismissal stays permanent, and every host backup run is notified 2026-10-12 12:02:18 +02:00
MacRimi 1ac1f58afa fix(monitor): read the node name without waiting for the Proxmox API 2026-10-12 10:59:26 +02:00
github-actions[bot] 370b45a2d8 chore: update repository growth [skip ci] 2026-10-12 04:45:52 +00:00
MacRimi 5f3729568e fix(post-install): updating optimizations of both flows prints only its steps 2026-10-12 01:15:39 +02:00
MacRimi c3c29c8692 New version 1.2.6.2-beta 2026-10-12 01:05:34 +02:00
github-actions[bot] 9ebc959530 Update AppImage beta build (2026-10-11 23:03:06) 2026-10-11 23:03:06 +00:00
ProxMenuxBot 35d61beaa1 Update helpers_cache.json 2026-10-11 18:07:14 +00:00
ProxMenuxBot 6a7565c858 Update app tracking hints 2026-10-11 18:01:29 +00:00
MacRimiandGitHub 4b384e64ee Merge pull request #437 from f3rs3n/fix/italian-oci-prompts
fix(i18n): refine Italian OCI prompts and menu messages
2026-10-11 19:32:43 +02:00
martino 5155c0c0c8 fix(i18n): refine Italian OCI prompts and menu messages 2026-10-11 19:29:31 +02:00
MacRimiandGitHub e633ecadcb Merge pull request #436 from Vaso73/i18n/sk-oci-installer
i18n: refine Slovak OCI installer texts
2026-10-11 19:14:18 +02:00
VAIO73 85794a8989 i18n: refine Slovak OCI installer texts 2026-10-11 18:46:20 +02:00
github-actions[bot] 11449239cb Update AppImage beta build (2026-10-11 14:00:21) 2026-10-11 14:00:21 +00:00
MacRimi 69b76116fd fix(oci): services show their address instead of a web link, and applications are not asked for their own 2026-10-11 15:57:05 +02:00
MacRimi 873a3cd8e2 fix(i18n): translate the search texts and review the new translations 2026-10-11 14:21:59 +02:00
ProxMenuxBot dd16682603 chore(lang): auto-rebuild translation cache
Source: cf830ef
Triggered by: push
2026-10-11 12:14:08 +00:00
ProxMenuxBot 7b2d345e83 Update helpers_cache.json 2026-10-11 12:08:28 +00:00
MacRimiandGitHub cf830ef143 Merge pull request #433 from MacRimi/oci-catalog-review
OCI catalog review, search in the main menu and Helper-Scripts addons in LXC
2026-10-11 14:02:42 +02:00
MacRimi bbec8d34d1 fix(menu): the search asks for the name or part of the option or of the application 2026-10-11 13:27:47 +02:00
MacRimi d33b131c4d fix(oci): a new volume starts as the image has it
A container volume is filled with what the image holds at that path, with its owner and permissions, as Docker does with a new volume. It was only done for the recipes that asked for it. Images that run as a user of their own and do not take PUID, such as Node-RED or Grafana, find their data directory writable and start.
2026-10-11 13:26:23 +02:00
MacRimi 0fd3be15c1 feat(menu): search options and applications from the main menu
The main menu opens with a search that finds an option of ProxMenux, an application of the OCI catalog or a Helper-Script by its name or by related words, in that order, and by category after the names.

Every result opens what its name says and nothing is run by the search itself: a script starts at its first screen, an application opens its page, an option of the post-install or a system utility opens its list with it marked, and an option that lives inside a menu is opened by that menu, which stays behind it.

The menus take the option to open as an argument and behave as before without one. The Helper-Scripts catalog is kept on the host and refreshed once a day. The main menu, the lists of OCI manager and the Helper-Scripts menu share the same height.
2026-10-11 13:26:23 +02:00
MacRimi 8b8fed2564 feat(helper-scripts): an addon runs on the host or inside an LXC, as its catalog says
An addon is run where the catalog lists it: on the Proxmox host, inside an LXC chosen from a list, or wherever is chosen when it can be both. The LXC is started when it is stopped, and OCI containers are left out. Until now every addon ran on the host.

A script is downloaded whole before anything runs, so a failed or empty download runs nothing. Descriptions and notes the catalog writes in HTML are shown as plain text.

The menu opens one script given as argument, and is as tall as the main menu.
2026-10-11 13:26:23 +02:00
MacRimi b581d4c498 fix(oci): keep the name the page width of an application is read by
The width of the description keeps the name the script tests give it; the width of the window has its own.
2026-10-11 12:05:31 +02:00
MacRimi 5be6c14221 feat(oci): add Duplicati Official and hide three applications
The image published by the Duplicati project joins the catalog next to the LinuxServer one.

Bitcoin Knots is hidden until its image closes the application cleanly when it stops; EmulatorJS and SickChill, until their images are published again.
2026-10-11 12:02:04 +02:00
MacRimi 04cfa2c587 feat(oci): recipes that say what they ask for
Manyfold, BookStack and NetBox declare the services they need. Lychee and Manyfold install with their own SQLite database.

Folding@home, Healthchecks, NetBird, Speedtest Tracker, Twingate Connector, HA Bridge, ChatGPT Next Web, DDNS Updater and Minisatip explain each value, propose one or leave it to the advanced installation.

Pinchflat gets 1024 MB.
2026-10-11 12:02:04 +02:00
MacRimi a552da6f91 fix(oci): a Compose reservation is not the memory limit of the container
What a Compose file reserves is the least the application is promised, not the most it may use. 81 recipes had taken it as the memory of the container, as low as 128 MB, and the kernel stopped applications such as Home Assistant Official and UniFi Network.

Without a limit in the file the container gets the standard 1024 MB, or the reservation when it is larger. A limit the file sets is kept.
2026-10-11 12:02:04 +02:00
MacRimi 56d7a1839b feat(oci): content folders belong to the application, and an application says first what it needs
A path offered as a container volume or as a host directory is created for the user the application runs as, also as a volume, and is always mounted: the option of leaving it out is gone for those folders.

A container volume is never read-only, which is kept for a host directory that already holds the content, and the time files of the host are not turned into volumes.

An application that expects a database or another service that is already running shows it, with the address of its image documentation, before anything is asked, and its page lists it under Needs.

The installer windows share one width; the page of an application and the final summary are a little wider.
2026-10-11 12:02:04 +02:00
github-actions[bot] b026332032 chore: update repository growth [skip ci] 2026-10-11 04:36:21 +00:00
ProxMenuxBot 7215c551d4 Update helpers_cache.json 2026-10-11 00:18:30 +00:00
ProxMenuxBot bb817ad1f9 Update app tracking hints 2026-10-11 00:02:43 +00:00
MacRimi 155cafcb73 fix(oci): a shared host directory shows what the host has mounted inside it 2026-10-10 20:52:52 +02:00
ProxMenuxBot dad116c8b5 Update app tracking hints 2026-10-10 18:01:28 +00:00
ProxMenuxBot 34c549b738 Update helpers_cache.json 2026-10-10 12:07:59 +00:00
ProxMenuxBot 2d022a873d Update app tracking hints 2026-10-10 12:01:27 +00:00
github-actions[bot] a1bac7aeec Update AppImage beta build (2026-10-10 08:35:33) 2026-10-10 08:35:33 +00:00
MacRimi 716b9ae871 fix(monitor): the OCI terminal window says it runs the same process as the menu 2026-10-10 10:16:09 +02:00
MacRimi b46cc4bd6d ix(monitor): the watchdog news says when an application is not restarted 2026-10-10 10:03:52 +02:00
github-actions[bot] ee3d8db577 chore: update repository growth [skip ci] 2026-10-10 04:35:18 +00:00
ProxMenuxBot 8169aeadce Update helpers_cache.json 2026-10-10 00:16:48 +00:00
MacRimiandGitHub 0460db741e Merge pull request #432 from f3rs3n/fix/italian-beta-release-notes
fix(i18n): refine Italian beta release notes
2026-10-09 23:23:28 +02:00
martino 539f91189e fix(i18n): refine Italian beta release notes 2026-10-09 23:18:30 +02:00
MacRimiandGitHub 522860e352 Merge pull request #431 from Vaso73/i18n/sk-beta-news
i18n: clarify Slovak beta watchdog release note
2026-10-09 22:37:30 +02:00
VAIO73 216298377a i18n: clarify Slovak beta watchdog release note 2026-10-09 21:52:51 +02:00
github-actions[bot] 07321333ca Update AppImage beta build (2026-10-09 19:30:25) 2026-10-09 19:30:25 +00:00
MacRimi f319ba4b2b fix(i18n): correct the machine translations of the 1.2.6.2-beta news 2026-10-09 21:20:09 +02:00
ProxMenuxBot 47ef6a96c5 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 10e42c3
Triggered by: push
2026-10-09 19:14:40 +00:00
MacRimi 10e42c3c9a feat(monitor): what's new in 1.2.6.2-beta 2026-10-09 21:12:36 +02:00
MacRimi c2936d4587 fix(oci): a shared host directory is recognised by its filesystem, not its device number 2026-10-09 21:00:33 +02:00
MacRimiandGitHub 9f09272889 Merge pull request #430 from Vaso73/i18n/sk-oci-manage
i18n: refine Slovak OCI manage messages
2026-10-09 20:14:06 +02:00
ProxMenuxBot 9ac9556940 Update helpers_cache.json 2026-10-09 12:08:28 +00:00
ProxMenuxBot 8902a2574f Update app tracking hints 2026-10-09 12:01:33 +00:00
VAIO73 51851136ab i18n: refine Slovak OCI manage messages 2026-10-09 12:03:50 +02:00
MacRimi 9ef621e049 fix(i18n): French wording of the record copy notice 2026-10-09 09:44:08 +02:00
ProxMenuxBot 9a3f254b10 chore(lang): auto-rebuild translation cache
Source: 815d98a
Triggered by: push
2026-10-09 07:38:50 +00:00
MacRimi 815d98ab9f feat(oci): the menu shows the saved records being brought up to date. 2026-10-09 09:37:51 +02:00
github-actions[bot] ded2af3a57 chore: update repository growth [skip ci] 2026-10-09 04:37:58 +00:00
MacRimi 5a892db7c9 ix(i18n): correct the machine translations of the new access messages 2026-10-09 00:47:30 +02:00
ProxMenuxBot 82f69be859 chore(lang): auto-rebuild translation cache
Source: 16ab980
Triggered by: push
2026-10-08 22:44:58 +00:00
MacRimi 16ab9808ac fix(oci): the access prompt says what it grants, and two checks name what differs 2026-10-09 00:43:32 +02:00
MacRimiandGitHub 316eb2f104 Merge pull request #429 from f3rs3n/fix/italian-oci-maintenance
fix(i18n): refine Italian OCI backup and sharing messages
2026-10-09 00:39:22 +02:00
martino 11479bcfe1 fix(i18n): refine Italian OCI backup and sharing messages 2026-10-09 00:09:36 +02:00
github-actions[bot] 3bc45535e4 Update AppImage beta build (2026-10-08 21:50:45) 2026-10-08 21:50:45 +00:00
MacRimi 66c621fe7a fix(oci): the access rule covers the container's root, and a shared volume is writable by its group 2026-10-08 22:57:37 +02:00
ProxMenuxBot 5604094907 chore(lang): auto-rebuild translation cache
Source: abf436c
Triggered by: push
2026-10-08 20:32:33 +00:00
MacRimi abf436c352 feat(oci): offer access to an existing host directory and gather the file-sharing applications
feat(oci): offer access to a host directory that already exists
feat(oci): gather the file-sharing applications in Files & Sharing
fix(oci): ShareCoveX keeps its settings in a 1 GB volume, and the new translations are mended
2026-10-08 22:28:53 +02:00
ProxMenuxBot 1a5fec13ec chore(lang): auto-rebuild translation cache
Source: 8879abf
Triggered by: push
2026-10-08 20:07:23 +00:00
MacRimiandGitHub 8879abf7bb Merge pull request #428 from MacRimi/oci-lifecycle-dhcp-and-monitor-fixes
OCI: update and modify every application; Monitor fixes for /etc/pve, DHCP and disk observations
2026-10-08 21:58:16 +02:00
MacRimi 73bca5f130 feat(oci): ShareCoveX is recorded as tested in the lab
Installed for real on a lab host through the default and the advanced
installation, with a host directory and a container volume to share, and
removed again. The recipe states what was exercised: clean install and
service health; the rest of its validation fields stay pending.
2026-10-08 21:52:56 +02:00
MacRimi eb5105f582 docs(oci): count ShareCoveX in the validation record 2026-10-08 21:45:52 +02:00
MacRimi c7efdd2e59 feat(oci): add ShareCoveX to the catalog and ask for the directories it shares
ShareCoveX is a file server with a web panel that publishes directories
over SMB, NFS or both. Its recipe declares that it shares directories, so
the installation asks for them one after another, in the default mode as
well: each is a container volume or a directory of the host, mounted under
/shares with the name given to it. Modify offers to add more.

The advanced installation also offers the privileged profile NFSv4 needs;
the default one never asks and stays unprivileged. A secret can declare a
minimum length, and the recipe opens the Files & Sharing category.
2026-10-08 21:43:49 +02:00
MacRimi 693e6ae481 fix(monitor): the observation history of a disk is permanent
- An observation is never left out for its age and cannot be dismissed:
  the rule that hid it after 30 days without a repetition, and the
  clean-up that marked the ones of a missing device, are removed.
- The ones earlier versions left out are shown again; an error that came
  back while hidden appears with its first date and its full count.
- The audit attributes recorded events to a disk by its serial number.
2026-10-08 17:32:47 +02:00
MacRimi 07713d5365 fix(monitor): write Proxmox's notification settings only while /etc/pve is mounted
- Nothing is written under /etc/pve unless it is the mounted cluster
  filesystem; a Monitor that starts while pve-cluster is being restarted
  waits for it and registers its webhook afterwards.
- The Monitor service is ordered after pve-cluster.service.
- The settings are written only when they have to change. The copies
  taken before a change are kept in
  /usr/local/share/proxmenux/backups/pve-notifications, and the ones left
  in /etc/pve by earlier versions are moved there.
- Uninstalling the Monitor removes its target and matcher from Proxmox.

Reported in #427.
2026-10-08 17:14:36 +02:00
MacRimi 053b6c81f8 fix(monitor): disk observations follow the disk, and OCI containers keep their address
- The observations of a disk belong to its serial number, not to its
  connector: a replaced disk starts clean, and the badge of the list
  counts what the detail of the disk lists.
- An OCI container is recognised while it is still being installed, so
  an image that carries Docker inside is not offered Docker.
- Links use the IPv4 address of a container and write IPv6 in brackets;
  an answer without an address of its own is read again.
- The Monitor restores, when it starts, the DHCP clients of containers
  that were started from it.
2026-10-08 17:14:36 +02:00
MacRimi f5ad4072b5 feat(oci): update and modify every application, and keep its address
- The backup taken before an update or a modification is made on another
  storage when it does not fit on the system disk; an operation without
  room says so before anything is stopped.
- Privileged containers, relaxed AppArmor or seccomp, process limits, the
  host monitors Glances and Netdata and single-file host mounts are
  rebuilt and recovered like any other application.
- HAOS One installs the docker-haos image: port 8123, host time zone,
  unprivileged, watchdog proposed by default; it is updated and modified
  with Home Assistant's own check on the rebuilt container.
- A container that reboots itself gets its DHCP client back.
- The installer takes the address of the container's own interface, and
  the notes of a suite or stack container show its LAN address.
- Seerr installs: its volume belongs to the image user and
  no-new-privileges goes through the include of the container.
- Removing an application deletes its seccomp profile.
2026-10-08 17:14:36 +02:00
ProxMenuxBot 4ea1bf4a80 Update helpers_cache.json 2026-10-08 12:08:50 +00:00
github-actions[bot] 1bf8dd00c9 chore: update repository growth [skip ci] 2026-10-08 04:36:45 +00:00
ProxMenuxBot 7305713c80 Update helpers_cache.json 2026-10-08 00:15:36 +00:00
ProxMenuxBot a02057fd52 Update helpers_cache.json 2026-10-07 18:07:31 +00:00
MacRimiandGitHub b629938cf1 Merge pull request #426 from f3rs3n/fix/italian-followup-backup-oci-watchdog
Italian follow-up: backup jobs, OCI prompts and watchdog
2026-10-07 16:51:08 +02:00
ProxMenuxBot 65d55b0f58 Update helpers_cache.json 2026-10-07 12:09:29 +00:00
github-actions[bot] 0d2d639795 chore: update repository growth [skip ci] 2026-10-07 11:07:18 +00:00
martino 957fef8c62 fix(i18n): refine Italian backup, OCI and watchdog wording 2026-10-07 10:15:34 +02:00
ProxMenuxBot bc913ff5eb Update helpers_cache.json 2026-10-07 06:09:12 +00:00
MacRimi b927db8708 fix(lang): mend the translations of the reworded messages 2026-10-07 07:39:23 +02:00
ProxMenuxBot 8ff0d68c89 chore(lang): auto-rebuild translation cache
Source: f0d7d4f
Triggered by: push
2026-10-07 05:36:54 +00:00
MacRimi f0d7d4f8f7 fix: more messages say only what is certain
- Host restore notification: reports its tasks and no longer calls the node fully ready.
- Watchdog notification: covers an application that did not start as well as one that stopped again.
- OCI: Modify summary, Immich recognition preview, private network kept by another guest, empty USB list.
- gzip diagnostic no longer says that nothing changed.

Source questions from the Italian review by @f3rs3n.
2026-10-07 07:35:10 +02:00
ProxMenuxBot 113c31ab09 Update helpers_cache.json 2026-10-06 22:04:22 +00:00
MacRimi fc14ff5c33 fix(lang): one name for the watchdog in each language 2026-10-06 23:23:13 +02:00
ProxMenuxBot feb13949e1 chore(lang): auto-rebuild translation cache
Source: 41ae752
Triggered by: push
2026-10-06 21:21:04 +00:00
MacRimi 41ae752da1 feat(oci): watchdog that restarts an application when it stops on its own
- New service that starts again an OCI application that stopped on its own, such as Frigate after Save & Restart. A stop or shutdown asked by the user, a backup, a migration and a ProxMenux operation are never undone.
- The installer asks it in both modes and proposes what the recipe declares; it is changed from the management menu or with the Watchdog switch of the container modal in the Monitor.
- Notifications when the watchdog restarts an application and when it keeps stopping.
- The service is recorded in the change journal, and the feature is documented.
2026-10-06 23:18:26 +02:00
MacRimi 6b70c3934e fix(lang): mend the translations of the reworded previews and warnings 2026-10-06 21:42:40 +02:00
ProxMenuxBot 1e00eeac4f chore(lang): auto-rebuild translation cache
Source: 19a7068
Triggered by: push
2026-10-06 19:38:21 +00:00
MacRimi 19a7068fee fix: previews and warnings promise only what always happens 2026-10-06 21:36:02 +02:00
MacRimi f6274e360b ci(lang): run the translation cache when an OCI engine script or a .func file changes 2026-10-06 21:25:24 +02:00
MacRimi 42c927ecfd feat(oci): a notification for Modify, and the documentation under the same name 2026-10-06 20:04:46 +02:00
github-actions[bot] a60e065d33 Update AppImage beta build (2026-10-06 17:51:48) 2026-10-06 17:51:48 +00:00
ProxMenuxBot 74df2969dc chore(lang): auto-rebuild translation cache
Source: 3f75b94
Triggered by: push
2026-10-06 17:49:07 +00:00
MacRimi 3f75b94ea1 fix(oci): call the editing of an application Modify everywhere
- The option that edits an installed application is Modify for one container and for a multi-container application, in the OCI menu and on the Monitor button. Recreate is only the rebuild of a multi-container application as it is.
- A stack recreation says "Recreating" and "Recreated" in its steps instead of "Updating".
- The notes of the GPU, Coral, disk and share menus no longer name the option.
- Spanish for the new texts; the English copies of the stack texts are removed from de, fr, pt and sv so the bot translates them.
2026-10-06 19:42:30 +02:00
ProxMenuxBot 747b32489f chore(lang): auto-rebuild translation cache
Source: 4a3dd8c
Triggered by: push
2026-10-06 17:13:33 +00:00
MacRimiandGitHub 4a3dd8cc43 Merge pull request #423 from Vaso73/fix/oci-stack-recreate
fix(oci): recreate coordinated stacks from saved state
2026-10-06 19:12:28 +02:00
ProxMenuxBot c343b5ade1 chore(lang): auto-rebuild translation cache
Source: 69c9984
Triggered by: push
2026-10-06 17:06:53 +00:00
MacRimi 69c9984ff7 fix(i18n): translate texts with line breaks; drop the experimental label of OCI stacks 2026-10-06 19:04:49 +02:00
ProxMenuxBot be396e0054 chore(lang): auto-rebuild translation cache
Source: f788f20
Triggered by: push
2026-10-06 16:48:25 +00:00
MacRimiandMattiaC46 f788f205b8 feat(backup): manage the VM and CT backup jobs of Proxmox
Co-authored-by: MattiaC46 <52442052+MattiaC46@users.noreply.github.com>
2026-10-06 18:29:12 +02:00
VAIO73 f464b62190 fix(oci): complete Monitor modify wrapper contract 2026-10-06 18:22:42 +02:00
VAIO73 ebb25e5a70 fix(oci): preserve Monitor stack editor action 2026-10-06 18:22:42 +02:00
VAIO73 cff386053b fix(oci): gate stack recreate by replay support 2026-10-06 18:22:42 +02:00
VAIO73 0cf62149fd fix(oci): preserve multi-arch digest during recreate 2026-10-06 18:22:42 +02:00
VAIO73 3b6615194f feat(oci): recreate coordinated stacks from saved state 2026-10-06 18:22:42 +02:00
MacRimi b4f4d98e85 fix(backup): send host backup notifications over HTTPS when the Monitor uses it 2026-10-06 17:26:55 +02:00
MacRimi c6b2b74bf0 fix(lang): correct the generated strings of the reworded messages 2026-10-06 17:13:48 +02:00
ProxMenuxBot f04b84cb89 chore(lang): auto-rebuild translation cache
Source: 85b88f7
Triggered by: push
2026-10-06 15:11:43 +00:00
MacRimi 85b88f709a fix: make messages say what was verified (source questions from the Italian review) 2026-10-06 17:08:18 +02:00
MacRimi 12632c027e fix(i18n): correct the generated health popup strings 2026-10-06 16:47:47 +02:00
ProxMenuxBot 8639ed1525 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 374d959
Triggered by: push
2026-10-06 14:41:50 +00:00
MacRimiandGitHub 374d95921c Merge pull request #424 from f3rs3n/feature/health-popup-i18n-it
fix(i18n): translate health popup details and add Italian messages
2026-10-06 16:34:37 +02:00
ProxMenuxBot 8094d69fb4 Update helpers_cache.json 2026-10-06 12:31:03 +00:00
ProxMenuxBot f3a3332327 Update app tracking hints 2026-10-06 12:17:02 +00:00
github-actions[bot] 5734947e17 chore: update repository growth [skip ci] 2026-10-06 11:19:31 +00:00
martino dd28d2d341 fix(i18n): translate health popup details and add Italian messages 2026-10-06 11:41:57 +02:00
github-actions[bot] b2398a1cae Update AppImage beta build (2026-10-05 22:04:09) 2026-10-05 22:04:09 +00:00
MacRimiandGitHub b36e5292ff Merge pull request #422 from f3rs3n/l10n/italian-tui-monitor
Improve Italian localization of the TUI and Monitor
2026-10-05 23:58:26 +02:00
martino c7256042e4 fix(i18n): improve Italian TUI and Monitor wording 2026-10-05 22:31:44 +02:00
github-actions[bot] 65f406f13e Update AppImage beta build (2026-10-05 16:46:26) 2026-10-05 16:46:26 +00:00
MacRimi 8d456f7ca1 fix(lang): correct the generated strings of the OCI menu notes 2026-10-05 18:43:11 +02:00
MacRimi 37acfbc369 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-10-05 18:19:29 +02:00
MacRimi 32e67ba54b Update test_tui_network_storage_wording.py 2026-10-05 18:19:18 +02:00
ProxMenuxBot 75c528e5c2 chore(lang): auto-rebuild translation cache
Source: 6b50763
Triggered by: push
2026-10-05 16:12:32 +00:00
MacRimi 6b50763e54 fix(i18n): natural Spanish for the menus and the OCI catalogue. 2026-10-05 18:10:17 +02:00
ProxMenuxBot ae22df1f9d Update helpers_cache.json 2026-10-05 13:11:02 +00:00
github-actions[bot] e82216eb5b chore: update repository growth [skip ci] 2026-10-05 11:34:22 +00:00
github-actions[bot] 7a4a6e114d Update AppImage beta build (2026-10-04 22:06:03) 2026-10-04 22:06:03 +00:00
MacRimi 55de7d1cbb fix(monitor): keep the last image check when a registry does not answer 2026-10-04 23:58:28 +02:00
MacRimi 73f484834a fix(installer): keep apt quiet when only the enterprise repositories are refused 2026-10-04 22:06:52 +02:00
github-actions[bot] f82d5d1fb0 Update AppImage beta build (2026-10-04 20:00:06) 2026-10-04 20:00:06 +00:00
MacRimi c7dba81f8f ix(i18n): correct the new OCI strings in six locales. 2026-10-04 21:53:04 +02:00
ProxMenuxBot ee6d53acc5 chore(lang): auto-rebuild translation cache
Source: 951acfb
Triggered by: push
2026-10-04 19:31:19 +00:00
MacRimiandGitHub 951acfb14e Merge pull request #420 from MacRimi/oci-restore-recovery-amd-gpu
OCI: recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles
2026-10-04 21:10:16 +02:00
MacRimi f6eccfb25c test(oci): give the extracted menu functions the recovery helpers
The menu registers restored containers and refreshes the carried record
before it lists or manages an instance. The tests that run those menu
functions in isolation provide the new helpers they call.
2026-10-04 21:03:22 +02:00
MacRimi c739699ec7 fix(monitor): read an OCI image saved from a Docker-format manifest
An image published with a Docker-format manifest is saved for Proxmox
under another digest than the one its registry serves. The App tab reads
the installed version through the digest the registry served the image
under, and decides an update by comparing the platform manifest that
digest resolves to with the one the tag points at.
2026-10-04 20:43:15 +02:00
MacRimi 1f9d2acef5 feat(oci): recover applications after a Proxmox reinstall, cluster records and AMD GPU profiles
The installation record of an OCI application travels with its container:
a copy inside the container and another in /etc/pve, written together
with the one kept on the host. A container restored on a newly installed
Proxmox, restored with another ID or moved to another node of a cluster
is recognised and registered again, with its private network, hookscript,
Rclone mount, host firewall rule and NVIDIA runtime. The Monitor offers
the same recovery from the Updates tab.

AMD GPUs are offered by generation. A GPU the ROCm image supports takes
the profile as it is; one of a supported family (Radeon 680M, 780M) is an
experimental option that asks for confirmation and is never proposed; an
older one is not offered. The GPU is checked with a real inference before
the installation accepts it. Recreate changes what runs recognition in an
installed Immich, between the CPU and a GPU of the host.

Updates:
- A failed update that is restored and checked removes its temporary
  container and the disks of the failed attempt.
- Every container volume is part of the backups, so Jellyfin, Plex and
  Hugo update with their default installation.
- An image published with a Docker-format manifest is recognised by its
  layers and build time and updates.
- The Proxmox notes of a multi-container application link to its LAN
  address.
2026-10-04 20:32:17 +02:00
ProxMenuxBot 346d9b15f6 Update helpers_cache.json 2026-10-04 11:35:51 +00:00
github-actions[bot] b9e5b03d6b chore: update repository growth [skip ci] 2026-10-04 10:36:59 +00:00
ProxMenuxBot 3fc99a35b6 Update helpers_cache.json 2026-10-04 03:37:30 +00:00
ProxMenuxBot 071d6081f8 Update helpers_cache.json 2026-10-03 20:25:04 +00:00
ProxMenuxBot af10486436 Update helpers_cache.json 2026-10-03 15:30:08 +00:00
ProxMenuxBot db1ab725d6 Update app tracking hints 2026-10-03 15:17:14 +00:00
github-actions[bot] 85b2d2114a chore: update repository growth [skip ci] 2026-10-03 09:54:30 +00:00
github-actions[bot] 7e9f16fdb3 Update AppImage beta build (2026-10-02 20:11:57) 2026-10-02 20:11:57 +00:00
MacRimi cb07f34626 fix(i18n): correct the new OCI strings in six locales 2026-10-02 22:08:16 +02:00
ProxMenuxBot a40ad157dd chore(lang): auto-rebuild translation cache
Source: 9b5cefb
Triggered by: push
2026-10-02 19:50:26 +00:00
ProxMenuxBot badd0f8771 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 9b5cefb
Triggered by: push
2026-10-02 19:46:44 +00:00
MacRimi 9b5cefb81a feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers
- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
2026-10-02 21:45:38 +02:00
ProxMenuxBot 0cd2efdd4e Update app tracking hints 2026-10-02 11:26:18 +00:00
github-actions[bot] f40910125c chore: update repository growth [skip ci] 2026-10-02 10:32:51 +00:00
github-actions[bot] 20ee21c08f Update AppImage beta build (2026-10-02 08:43:00) 2026-10-02 08:43:00 +00:00
ProxMenuxBot 98da85bf00 Update helpers_cache.json 2026-10-01 22:11:10 +00:00
MacRimiandGitHub 284851627e Merge pull request #418 from pbr85/fix/post-install-test-journal
fix(tests): load journal helpers in post-install test
2026-10-01 23:08:51 +02:00
MacRimi 96f3d82e15 chore: follow-ups to #406, #407 and #415 — locale cleanup, journal entry, pigz 1.1 and docs 2026-10-01 23:06:49 +02:00
MacRimiandGitHub b7e053b88a Merge pull request #417 from pbr85/fix/pmx-write-temp-check
fix(journal): fail write/append when the temp copy can't be written
2026-10-01 23:01:28 +02:00
pbr f80e6c3e39 fix(tests): load journal helpers in post-install test
The test points LOCAL_SCRIPTS at a missing directory, so pmx_journal.sh
was never sourced and it stopped at the first pmx_journal_context call.
2026-10-01 21:43:41 +02:00
pbr e5fc9c7188 fix(journal): fail write/append when the temp copy can't be written
pmx_write_file and pmx_append_file didn't check the first cat into
the temp file, so a full /tmp returned 0 and left a truncated target.
2026-10-01 21:43:40 +02:00
ProxMenuxBot 37a461c5e5 chore(lang): auto-rebuild translation cache
Source: 4078885
Triggered by: push
2026-10-01 19:28:13 +00:00
MacRimiandGitHub 40788855db Merge pull request #407 from f3rs3n/fix/preserve-enterprise-repositories
fix: preserve configured PVE repositories during dependency checks
2026-10-01 21:24:49 +02:00
ProxMenuxBot c91e7ecbac chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 8e5920f
Triggered by: push
2026-10-01 19:24:23 +00:00
ProxMenuxBot 6d40cfc8f9 chore(lang): auto-rebuild translation cache
Source: 872d90e
Triggered by: push
2026-10-01 19:23:11 +00:00
MacRimiandGitHub 8e5920faf0 Merge pull request #406 from f3rs3n/fix/notification-outcome-reporting
Clarify backup outcomes and diagnostic presentation
2026-10-01 21:23:01 +02:00
MacRimiandGitHub 872d90e75a Merge pull request #415 from pbr85/fix/pigz-dpkg-divert
fix(post-install): divert gzip instead of replacing it for pigz
2026-10-01 21:22:14 +02:00
MacRimi 9cbcd0d764 test(oci): a stack that cannot be updated still offers Recreate and Remove 2026-10-01 21:19:46 +02:00
MacRimi 064b169ae9 feat(monitor): offer Recreate for multi-container OCI applications 2026-10-01 20:53:04 +02:00
ProxMenuxBot c609fa0e12 chore(lang): auto-rebuild translation cache
Source: a4cb993
Triggered by: push
2026-10-01 17:44:57 +00:00
MacRimi a4cb9936a5 feat(oci): Recreate for multi-container apps and CPU/memory in every advanced install 2026-10-01 19:39:01 +02:00
pbr 07f88659ea fix(post-install): verify gzip before diverting or restoring it
Only use a file as the real gzip if it is a working binary, otherwise
reinstall the gzip package; bail out without touching gzip if that
fails. Check the .distrib and wrapper writes, and never purge pigz
while gzip still depends on it.
2026-10-01 19:38:06 +02:00
martino ea5fc041b8 fix(notifications): scope outcome improvements to backups 2026-10-01 18:04:08 +02:00
github-actions[bot] ddb0b5a400 chore: update repository growth [skip ci] 2026-10-01 10:59:42 +00:00
martino 21babcc14d test(notifications): allow default temporary directory in CI 2026-10-01 11:18:26 +02:00
martino aac75a0753 fix(notifications): ship recovery helper and reject superseded proofs 2026-10-01 09:03:27 +02:00
martino 54eef11334 Load bounded recovery predicate only for recovery presentation 2026-10-01 08:38:32 +02:00
martino 3be15211b1 Fix principal cause caps and bind measured recovery to incident policy 2026-10-01 08:38:32 +02:00
martino 8e396f957f fix(notifications): avoid repeating subject causes already in error diagnostics 2026-10-01 08:38:32 +02:00
martino 08598d2863 fix(notifications): preserve evidenced outcomes in backup and health notices 2026-10-01 08:38:32 +02:00
martino 147c401864 fix(notifications): retain diagnostic context across final consumers 2026-10-01 08:38:32 +02:00
martino e24308c017 Correct backup outcome diagnostics and email presentation chains 2026-10-01 08:38:32 +02:00
martino e52f52388a fix(monitor): parse PVE 9.2 backup reports and remove Slovak overrides 2026-10-01 08:38:32 +02:00
martino 2ea261dca3 test(notifications): keep label checks independent of git history 2026-10-01 08:38:32 +02:00
martino f80e0b6784 fix(monitor): honor guest terminology and leaf-scoped Slovak fallback 2026-10-01 08:38:32 +02:00
martino 5faf55a746 fix(monitor): reconcile backup outcome reports with maintainer review 2026-10-01 08:38:32 +02:00
martino dcf00cc6b9 fix(monitor): distinguish notification recovery and backup outcomes 2026-10-01 08:38:32 +02:00
ProxMenuxBot f641bab0e0 Update helpers_cache.json 2026-09-30 21:43:02 +00:00
ProxMenuxBot cbd4aff7a4 Update app tracking hints 2026-09-30 21:34:08 +00:00
pbr 4f651ffed5 fix(post-install): divert gzip instead of replacing it for pigz
A gzip package update overwrote the pigz wrapper, and uninstall could
restore a stale gzip.original over a newer binary. Use dpkg-divert
(--no-rename, gzip is Essential), migrate existing installs, and also
divert the /usr path on bookworm so a PVE 8 -> 9 upgrade keeps working.
2026-09-30 22:55:05 +02:00
MacRimi d137bb3d31 fix(oci): accept the console start hook in the dynamic NVIDIA profile check 2026-09-30 22:41:51 +02:00
ProxMenuxBot 4e6d43f6a5 chore(lang): auto-rebuild translation cache
Source: c83f843
Triggered by: push
2026-09-30 20:29:21 +00:00
MacRimi c83f84398b fix(oci): keep every declared image data path on a volume so installs can be updated 2026-09-30 22:28:20 +02:00
MacRimiandGitHub d2ac3b7638 Merge pull request #414 from pbr85/fix/pigz-wrapper-export-gzip
fix(post-install): export GZIP in pigz wrapper
2026-09-30 22:27:01 +02:00
martino 028026a9a0 fix: normalize Proxmox repository state and refresh consented switches 2026-09-30 22:05:46 +02:00
pbr e9957415ed fix(post-install): export GZIP in pigz wrapper so level -1 takes effect
The wrapper assigned GZIP="-1" without exporting it, so pigz never saw
it and compressed at its default level 6. Export it, and refresh
/bin/gzip on re-runs when gzip.original already exists so existing
installs pick up the corrected wrapper.
2026-09-30 21:57:38 +02:00
MacRimi 22e8bf2532 fix(lang): Spanish wording for the container filesystem unmount message 2026-09-30 21:41:09 +02:00
ProxMenuxBot c1012356f2 chore(lang): auto-rebuild translation cache
Source: fda584f
Triggered by: push
2026-09-30 19:38:52 +00:00
MacRimiandGitHub fda584f65b Merge pull request #413 from Vaso73/fix/oci-runtime-home
fix(oci): set HOME for native OCI runtime
2026-09-30 21:37:44 +02:00
github-actions[bot] 8efccc3d03 chore: update repository growth [skip ci] 2026-09-30 10:31:59 +00:00
VAIO73 4fd987fdd5 fix(oci): set HOME for native OCI runtime 2026-09-30 10:46:34 +02:00
github-actions[bot] 0fa112de56 Update AppImage beta build (2026-09-29 19:04:14) 2026-09-29 19:04:14 +00:00
MacRimi 2398b605b2 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-29 20:56:52 +02:00
MacRimi f421ac1cb1 fix(monitor): honour storage exclusions in the PVE storage capacity alert 2026-09-29 20:56:02 +02:00
ProxMenuxBot 889fc4daf4 chore(lang): auto-rebuild translation cache
Source: 438cb93
Triggered by: push
2026-09-29 18:52:18 +00:00
MacRimi 438cb935d0 feat(oci): explain optional image variables and show their example value as a hint 2026-09-29 20:49:45 +02:00
github-actions[bot] c40a335301 Update AppImage beta build (2026-09-29 18:27:29) 2026-09-29 18:27:29 +00:00
ProxMenuxBot 72c4dcd0be chore(lang): auto-rebuild translation cache
Source: 135df4b
Triggered by: push
2026-09-29 18:23:44 +00:00
MacRimi 135df4b2c5 feat(oci): coherent advanced installer flow, container creation progress and USB device list 2026-09-29 20:20:36 +02:00
MacRimiandGitHub db4ef3f29b Merge pull request #412 from Vaso73/fix/oci-uptime-kuma-v2
fix(oci): use Uptime Kuma v2 image
2026-09-29 20:14:51 +02:00
MacRimiandGitHub 41099960d6 Merge pull request #411 from Vaso73/fix/oci-stremio-nginx-runtime
fix(oci): add nginx runtime contract for Stremio
2026-09-29 20:13:41 +02:00
ProxMenuxBot ad24cd9c15 chore(lang): auto-rebuild translation cache
Source: b7154ff
Triggered by: push
2026-09-29 18:12:32 +00:00
MacRimiandGitHub b7154ffee9 Merge pull request #410 from Vaso73/fix/oci-managed-volume-preparation-refresh
fix(oci): prepare managed volumes across stack installs
2026-09-29 20:11:34 +02:00
VAIO73 e18ff82eea fix(oci): prepare managed volumes across stack installs 2026-09-29 19:44:42 +02:00
VAIO73 18bf0c3e7e fix(oci): use Uptime Kuma v2 image 2026-09-29 19:41:15 +02:00
VAIO73 c93c0dbcef fix(oci): add nginx runtime contract for Stremio 2026-09-29 19:40:29 +02:00
ProxMenuxBot 5b789d2433 Update helpers_cache.json 2026-09-29 17:22:53 +00:00
martino e57a2f3aae fix: require subscription-aware consent for PVE repository switches 2026-09-29 18:04:12 +02:00
MacRimi eb7cc548fa fix(monitor): refresh the VM/LXC modal after edits made outside the Monitor 2026-09-29 17:27:41 +02:00
MacRimi a17f4120e3 fix(monitor): round the temperature in alerts and tidy the #408 follow-ups 2026-09-29 17:10:27 +02:00
MacRimiandGitHub 57a69e1f92 Merge pull request #408 from f3rs3n/fix/active-notification-payloads
Fix active health and temperature notification payloads
2026-09-29 17:06:07 +02:00
ProxMenuxBot bfe7794d98 Update helpers_cache.json 2026-09-29 11:53:13 +00:00
ProxMenuxBot ff51c2239e Update app tracking hints 2026-09-29 11:39:08 +00:00
github-actions[bot] f7239889ca chore: update repository growth [skip ci] 2026-09-29 10:44:01 +00:00
martino 1f1a2176fd fix(notifications): wrap unbroken temperature email details 2026-09-29 11:59:56 +02:00
martino 25cb81d740 fix(notifications): localize sensor alerts and preserve email context 2026-09-29 11:31:16 +02:00
martino 4c08ef6ea0 fix(notifications): align active health and temperature payloads
Local source candidate; non-English temperature migration and rendered layout validation remain pending.
2026-09-29 10:45:17 +02:00
MacRimiandGitHub d14b968930 Merge pull request #404 from f3rs3n/fix/lynis-removal-threshold-guidance
Clarify Lynis removal outcomes and printable/threshold guidance
2026-09-29 07:25:49 +02:00
martino d27012806b fix(monitor): clarify Lynis removal outcomes and threshold guidance 2026-09-29 00:42:44 +02:00
MacRimi 72f77069ca fix(oci): limit Frigate's CPU by time instead of pinning it to threads 2026-09-28 23:08:45 +02:00
github-actions[bot] eb869d1250 Update AppImage beta build (2026-09-28 20:37:04) 2026-09-28 20:37:04 +00:00
MacRimi 0b82e91dd7 i18n: fix spacing and capitalisation of the new removal messages 2026-09-28 22:34:02 +02:00
ProxMenuxBot 6e303e35ce chore(lang): auto-rebuild translation cache
Source: 0ae601f
Triggered by: push
2026-09-28 20:32:12 +00:00
MacRimi 0ae601f5e7 fix(oci): remove everything an OCI installation leaves, and name cluster events 2026-09-28 22:31:00 +02:00
MacRimiandGitHub 4a5dd8a94e Merge pull request #403 from f3rs3n/fix/monitor-cluster-event-wording
Describe cluster notifications without asserting split-brain or quorum loss
2026-09-28 22:19:56 +02:00
ProxMenuxBot 55999da58a chore(lang): auto-rebuild translation cache
Source: 328a690
Triggered by: push
2026-09-28 20:19:17 +00:00
MacRimiandGitHub 328a690712 Merge pull request #402 from f3rs3n/fix/oci-removal-firewall-wording
Clarify OCI removal outcomes and host firewall diagnostics
2026-09-28 22:15:50 +02:00
martino 46af79eb51 fix(monitor): report cluster events without unsupported diagnoses 2026-09-28 21:02:04 +02:00
martino 118a757ac7 fix(oci): distinguish clean removal from incomplete cleanup 2026-09-28 19:48:34 +02:00
MacRimi 2922fc235d fix: quiet apt in the installers and fix the duplicate repository cleanup 2026-09-28 19:14:05 +02:00
github-actions[bot] 58fb192b29 Update AppImage beta build (2026-09-28 17:11:49) 2026-09-28 17:11:49 +00:00
MacRimi 7ec6d06dc3 i18n(es): drop an unused catalog tip translation 2026-09-28 19:09:30 +02:00
ProxMenuxBot 488b9b6521 chore(lang): auto-rebuild translation cache
Source: 79547de
Triggered by: push
2026-09-28 17:07:17 +00:00
MacRimi 79547dec35 fix(oci): start OCI containers on any node of a cluster 2026-09-28 19:06:07 +02:00
MacRimi 297c026c95 fix: Health Monitor storage and disk notices, SELinux-safe host backup, Frigate detector keys 2026-09-28 18:18:09 +02:00
martino 0c754bda60 fix(oci): clarify removal and host firewall diagnostics 2026-09-28 18:07:21 +02:00
ProxMenuxBot 33245a423a chore(lang): auto-rebuild translation cache
Source: bf07f0a
Triggered by: push
2026-09-28 15:51:01 +00:00
MacRimi bf07f0a8a9 feat(oci): offer an Intel NPU or a Coral as Frigate's object detector 2026-09-28 17:48:47 +02:00
MacRimi adc42caae3 feat(oci): community validation record, report form and generated list 2026-09-28 17:30:29 +02:00
MacRimi 3d2aff6c66 i18n(es): curate the new OCI messages and the dry-run wording 2026-09-28 17:19:28 +02:00
ProxMenuxBot a3f07e643e chore(lang): auto-rebuild translation cache
Source: b181fbd
Triggered by: push
2026-09-28 15:12:30 +00:00
ProxMenuxBot 642f1207c7 chore(lang): auto-rebuild translation cache
Source: 82e3791
Triggered by: push
2026-09-28 15:11:04 +00:00
MacRimiandGitHub b181fbd1e8 Merge pull request #401 from f3rs3n/fix/oci-update-preservation-wording
Clarify OCI template refresh and update backup guidance
2026-09-28 17:09:47 +02:00
MacRimiandGitHub 82e3791b03 Merge pull request #400 from f3rs3n/fix/oci-rclone-hedgedoc-wording
Clarify Rclone dry-run messages and preserve HedgeDoc description on catalog sync
2026-09-28 17:08:59 +02:00
MacRimiandGitHub 2756af0b33 Merge pull request #399 from f3rs3n/fix/monitor-narrow-report-layout
fix(monitor): prevent narrow-screen overflow in audit and printable reports
2026-09-28 17:07:48 +02:00
MacRimi 4a77d11bc4 fix(monitor): register every OCI service port and read versions from the guest 2026-09-28 17:05:52 +02:00
martino 8e3ef83c19 fix(oci): clarify template refresh and update backup guidance 2026-09-28 16:33:43 +02:00
martino e071fdf4a1 test: raise cancellation sentinel without generator semantics 2026-09-28 16:17:49 +02:00
martino 3e7ebc4be0 fix(oci): clarify Rclone dry runs and preserve HedgeDoc description 2026-09-28 16:15:59 +02:00
martino fa48be17d1 fix(monitor): prevent narrow-screen report overflow 2026-09-28 14:26:58 +02:00
github-actions[bot] 7a3f713670 chore: update repository growth [skip ci] 2026-09-28 10:58:51 +00:00
MacRimi 8aade0e55c feat(oci): show a single Verified status for tested applications 2026-09-28 10:26:18 +02:00
MacRimi 115295acb6 fix(oci): reuse the existing HTTP check message 2026-09-28 09:56:29 +02:00
ProxMenuxBot 2a17ea84ac chore(lang): auto-rebuild translation cache
Source: 8e321e6
Triggered by: push
2026-09-28 07:51:23 +00:00
MacRimiandGitHub 8e321e6d72 Merge pull request #397 from f3rs3n/fix/oci-selection-setup-diagnostics
Clarify OCI selection and stack setup diagnostics
2026-09-28 09:49:58 +02:00
martino c026ae59fe fix(oci): clarify selection and stack setup diagnostics 2026-09-28 02:59:47 +02:00
MacRimi a0ff593241 i18n: fix Yes/No labels in German, Italian and Swedish 2026-09-27 23:01:47 +02:00
MacRimiandGitHub dcf0052039 Merge pull request #396 from Vaso73/i18n/oci-manager-slovak-wording
i18n(oci): refine Slovak manager wording
2026-09-27 22:59:06 +02:00
ProxMenuxBot fd8dd11643 Update helpers_cache.json 2026-09-27 20:39:23 +00:00
VAIO73 291b657218 i18n(oci): refine Slovak manager wording 2026-09-27 22:30:23 +02:00
MacRimi cdedf3ed24 i18n(es): clarify the managed host firewall rule message 2026-09-27 22:12:46 +02:00
ProxMenuxBot cb7609bb05 chore(lang): auto-rebuild translation cache
Source: 5a4fab8
Triggered by: push
2026-09-27 20:07:32 +00:00
MacRimiandGitHub 5a4fab8abd Merge pull request #395 from Vaso73/fix/oci-firewall-lifecycle
fix(oci): complete host firewall lifecycle follow-ups
2026-09-27 22:06:47 +02:00
VAIO73 f5627afe27 test(i18n): stub OCI firewall cleanup in wording test 2026-09-27 22:04:52 +02:00
VAIO73 ec391246a1 fix(oci): clean up owned host firewall rules 2026-09-27 21:44:47 +02:00
VAIO73 d45da0742d test(oci): run Glances port contract in isolation 2026-09-27 21:44:47 +02:00
ProxMenuxBot b1e11e7666 chore(lang): auto-rebuild translation cache
Source: 52bb982
Triggered by: push
2026-09-27 19:18:59 +00:00
MacRimi 52bb982721 Clarify the host-monitor restore notice 2026-09-27 21:18:15 +02:00
github-actions[bot] 1a9136514d Update AppImage beta build (2026-09-27 19:15:16) 2026-09-27 19:15:16 +00:00
MacRimi 687f446f00 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-27 21:05:04 +02:00
MacRimi 0948d7b078 Declare oci_console in the OCI removal wording test 2026-09-27 21:04:54 +02:00
ProxMenuxBot 72d3e6aefe chore(lang): auto-rebuild translation cache
Source: f3c4959
Triggered by: push
2026-09-27 19:03:01 +00:00
MacRimi f3c4959fa4 OCI catalog verification, console start marks and log cleanup 2026-09-27 21:02:16 +02:00
MacRimi 24617f9924 fix(lang): curate the Spanish host-monitor firewall strings 2026-09-27 20:18:18 +02:00
ProxMenuxBot 11b97d1aea chore(lang): auto-rebuild translation cache
Source: c4c654b
Triggered by: push
2026-09-27 18:15:37 +00:00
MacRimiandGitHub c4c654bfc6 Merge pull request #394 from Vaso73/fix/oci-persistence-input-safety
fix(oci): preserve persistent data and caller-owned plans
2026-09-27 20:14:19 +02:00
ProxMenuxBot 73edc51f1a chore(lang): auto-rebuild translation cache
Source: 4d370d3
Triggered by: push
2026-09-27 18:13:58 +00:00
MacRimiandGitHub 4d370d3edc Merge pull request #393 from Vaso73/fix/oci-glances-host-firewall
fix(oci): scope Glances host-monitor firewall access
2026-09-27 20:06:15 +02:00
VAIO73 884817f05c fix(oci): confirm scoped host-monitor firewall access 2026-09-27 13:28:53 +02:00
VAIO73 ca9dbba65b fix(oci): expose only Glances web port 2026-09-27 13:28:53 +02:00
VAIO73 1367bcb7d4 fix(oci): preserve caller-owned install plans 2026-09-27 13:28:39 +02:00
VAIO73 6e7be4c029 fix(oci): preserve PocketBase initial data on first install 2026-09-27 13:28:39 +02:00
ProxMenuxBot dd18ba6cd6 Update helpers_cache.json 2026-09-27 11:07:02 +00:00
github-actions[bot] 8cf19ad79b chore: update repository growth [skip ci] 2026-09-27 10:02:30 +00:00
MacRimiandGitHub 15692fe031 Merge pull request #391 from Vaso73/i18n/sk-oci-review
i18n: polish Slovak OCI and terminal descriptions
2026-09-26 21:43:48 +02:00
VAIO73 1d445f7064 i18n: polish Slovak OCI and terminal descriptions 2026-09-26 20:21:15 +02:00
MacRimi 8ab72b8bcc fix(lang): curate the OCI diagnostic strings from #389 2026-09-26 18:39:30 +02:00
MacRimiandGitHub e72bfcf8be Merge pull request #390 from f3rs3n/fix/tui-literal-placeholder-replacements
fix(tui): preserve literal text in translated placeholder replacements
2026-09-26 18:33:43 +02:00
ProxMenuxBot 8610f66fe1 chore(lang): auto-rebuild translation cache
Source: 8d9f38e
Triggered by: push
2026-09-26 16:32:20 +00:00
MacRimiandGitHub 8d9f38e7c2 Merge pull request #389 from f3rs3n/fix/oci-diagnostic-wording
Clarify OCI import, adoption, recovery and removal diagnostics
2026-09-26 18:27:57 +02:00
martino 3fa5a379f3 fix(tui): preserve literal translated placeholder replacements 2026-09-26 18:23:07 +02:00
martino c2298a09be ci: install PyYAML for OCI wording regressions 2026-09-26 18:05:44 +02:00
martino 6d53035936 fix(oci): qualify import adoption and recovery diagnostics 2026-09-26 18:04:35 +02:00
github-actions[bot] 9998a937d5 Update AppImage beta build (2026-09-26 15:33:59) 2026-09-26 15:33:59 +00:00
MacRimi b69aeea360 Merge branch 'develop' of https://github.com/MacRimi/ProxMenux into develop 2026-09-26 17:20:22 +02:00
MacRimi 02c4206d49 Keep Monitor-started backups running across a Monitor restart 2026-09-26 17:20:09 +02:00
ProxMenuxBot c7b052c424 chore(lang): auto-rebuild translation cache
Source: dd4bcfa
Triggered by: push
2026-09-26 14:59:51 +00:00
MacRimi dd4bcfa867 Fix the script terminal, Arr suite updates and OCI app tracking 2026-09-26 16:57:35 +02:00
github-actions[bot] 7899ffb43d chore: update repository growth [skip ci] 2026-09-26 09:23:21 +00:00
MacRimi 93862abdb0 Update cli.py 2026-09-26 08:49:34 +02:00
MacRimi e14a9911fd Stop asking to confirm host directories before an OCI update 2026-09-26 02:21:35 +02:00
github-actions[bot] 1e6b8b1c20 Update AppImage beta build (2026-09-26 00:14:32) 2026-09-26 00:14:32 +00:00
MacRimi 00bc122611 Update a multi-container OCI application from its main container 2026-09-26 02:11:22 +02:00
MacRimi 756851350a Update es.json 2026-09-26 01:32:15 +02:00
MacRimi cb5da6ce76 Update extra_devices.py 2026-09-26 01:28:29 +02:00
ProxMenuxBot 1bf792adce chore(lang): auto-rebuild translation cache
Source: f7266e7
Triggered by: push
2026-09-25 23:27:40 +00:00
MacRimi f7266e7b44 Generalize OCI device setup and remove unused catalog hashes 2026-09-26 01:22:37 +02:00
MacRimi 88acceb8ef fix(installer): surface apt errors and prevent false success messages 2026-09-25 23:05:55 +02:00
ProxMenuxBot 3cff8caa84 Update helpers_cache.json 2026-09-25 20:56:07 +00:00
MacRimi 14583ad9f6 Back up /var/lib/proxmenux whole in the default host profile 2026-09-25 22:53:35 +02:00
ProxMenuxBot b23eaf1713 Update app tracking hints 2026-09-25 20:47:49 +00:00
github-actions[bot] 557c34e1f6 Update AppImage beta build (2026-09-25 20:41:24) 2026-09-25 20:41:24 +00:00
MacRimi d1bb843f67 Delete ProxMenux-1.2.6.AppImage 2026-09-25 22:37:22 +02:00
ProxMenuxBot 174f178f5b chore(lang): auto-rebuild translation cache
Source: ace445b
Triggered by: push
2026-09-25 20:31:19 +00:00
MacRimiandGitHub ace445b2dd Merge pull request #388 from MacRimi/beta/1.2.6.2
ProxMenux pre 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
2026-09-25 22:23:58 +02:00
MacRimiandClaude Opus 5.5 c0fa75f404 Keep the Mounts tab in place when an LXC modal opens
- Apply the static mount list as soon as it arrives instead of waiting
  for the df/stat runtime probe.
- Refill a guest's mount seed after a lifecycle event rather than
  leaving it empty until the next modal open.
- Retry the bulk modal cache while the server is still warming guests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:11:23 +02:00
MacRimiandClaude Opus 5.5 53b415b127 Drop catalog snapshot assertion from the TUI upgrade/GPU wording test
The translation workflow fills new keys in the shipped catalogs after
merge, so the fixture no longer asserts that lang/it.json lacks them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:03:47 +02:00
MacRimiandClaude Opus 5.5 91e62b16e0 Fix offline i18n tests: refresh semantics and complete Monitor catalogs
- Web docs test no longer passes the removed refresh argument.
- i18n messages tests assert that --refresh keeps existing values and
  that only keys listed in --refresh-keys are re-translated.
- Add the new Monitor keys to de, fr, it, pt, sk and sv.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:01:22 +02:00
MacRimi e8a7ba6f47 Merge remote-tracking branch 'origin/develop' into beta/1.2.6.2 2026-09-25 21:51:23 +02:00
MacRimiandClaude Opus 5.5 4437a671d2 ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:51:12 +02:00
ProxMenuxBot 77080367bb chore(lang): auto-rebuild translation cache
Source: d468b54
Triggered by: push
2026-09-25 19:43:54 +00:00
MacRimiandGitHub d468b54226 Merge pull request #384 from f3rs3n/fix/tui-independent-wording-combined
fix(tui): correct network, storage, upgrade, and GPU guidance
2026-09-25 21:39:33 +02:00
MacRimiandGitHub 29fe30e2f4 Merge pull request #385 from Vaso73/fix/lxc-ip-search
fix(monitor): include all LXC IPs in VM search
2026-09-25 21:36:24 +02:00
ProxMenuxBot b2294a6c59 Update helpers_cache.json 2026-09-25 10:54:10 +00:00
github-actions[bot] 3df04b8428 chore: update repository growth [skip ci] 2026-09-25 09:38:45 +00:00
VAIO73 42bb91868f fix: include all LXC IPs in VM search 2026-09-25 09:36:08 +02:00
martino 583e45f568 fix(tui): correct network, storage, upgrade, and GPU guidance 2026-09-24 22:27:26 +02:00
github-actions[bot] 8a8deed134 chore: update repository growth [skip ci] 2026-09-24 09:20:46 +00:00
ProxMenuxBot 386d33df6e chore(lang): auto-rebuild translation cache
Source: 2124b97
Triggered by: push
2026-09-23 20:07:55 +00:00
MacRimiandGitHub 2124b97347 Merge pull request #382 from MacRimi/fix/oci-labels-and-literal-protection
Protect the literals a reader copies, and name the OCI category
2026-09-23 22:06:59 +02:00
MacRimiandGitHub 8333a7ba1f Merge pull request #383 from f3rs3n/ci/qualified-offline-tests
ci: gate qualified offline Python and Node tests separately
2026-09-23 22:02:25 +02:00
MacRimiandClaude Opus 5 32ffa63879 fix(i18n): put back the sk key the previous commit removed by accident
Staging AppImage/messages/sk/common.json for the Slovak wording also
carried an unrelated deletion that was in the working tree at the time.
The offline suite caught it: with the key gone the generator had work to
do on a clean checkout, and writes are forbidden there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:57:47 +02:00
martino 4b24ab9681 ci: gate qualified offline Python and Node tests separately 2026-09-23 18:46:47 +02:00
MacRimiandClaude Opus 5 72a38dbf38 fix(i18n): drop the string that named the removed script, take @Vaso73's Slovak
The argos run read a copy of lang/ from before #379, so the key naming
proxmenux-oci.sh came back with it — in all seven locales, pointing at a
file that no longer exists. Removed.

The Monitor category takes the Slovak @Vaso73 supplied: Správy a
upozornenia.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:17:16 +02:00
ProxMenuxBot cb3ff72699 Update helpers_cache.json 2026-09-23 15:51:52 +00:00
MacRimiandClaude Opus 5 fe46d3d4bc fix(i18n): protect the literals a reader copies, and name the OCI category
The glossary only holds what someone listed, and what reaches the reader
as a broken command was never on it. Paths, long options and Proxmox
subcommands are now recognised by shape:

    /dev/apex_0        came back as  /dev/apex 0
    --auto-uninstall                 desinstalación automática
    pct config 110                   configuration PCT 110   (fr)
    pct enter 101                    pct inserire 101        (it)

OCI joins the glossary as well. Read as a word it became "BEC" in French
across thirty-one strings — "Vérification du BEC" named nothing — and the
menu entry read "Gestionnaire du BEC Apps". The entry itself and "beta"
are protected too, so the name stays the same in every language.

A provider can also alter a token rather than carry it through: argos
returned MPXTERM000 for PMXTERM000, the restore found nothing, and the
token shipped. "MPXTERM000 configuré" is in the French catalogue today.
The restore now verifies its own tokens and fails the string instead,
which leaves the key absent for the next run to retry.

Spanish category labels: twenty-six were wrong. Seven collapsed two
categories into one — "Backup & Recovery" read only "Recuperación",
"Network & Firewall" read "Red de cortafuegos" — six were half
translated, and the connector alternated between "&", "y" and "e". They
now use "y" or "e" throughout. "IoT & Smart Home" and
"AI / Coding & Dev-Tools" stay in English by choice.

"Messaging & Queues" becomes "Messaging & Notifications": its only
application is Apprise, a notification gateway, not a queue. The
Helper-Scripts import still maps their name, so nothing breaks upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 17:49:59 +02:00
MacRimiandGitHub 4f79144c5a Merge pull request #381 from f3rs3n/test/offline-audit-backup-fixtures
test: align Audit comparison expectations and backup i18n fixtures
2026-09-23 17:04:17 +02:00
MacRimiandGitHub 11d1d125dc Merge pull request #380 from f3rs3n/ci/scheduler-multilingual-coverage
test: exercise every shipped scheduler locale and cover its CI inputs
2026-09-23 17:04:14 +02:00
ProxMenuxBot 7c64f7427a Update helpers_cache.json 2026-09-23 10:32:45 +00:00
github-actions[bot] f1c15a1cbe chore: update repository growth [skip ci] 2026-09-23 09:20:53 +00:00
martino 6cf3f4a520 test: align Audit comparison expectations and backup i18n fixtures 2026-09-23 10:42:15 +02:00
martino 8297a5c935 test: exercise every shipped scheduler locale and cover its CI inputs 2026-09-23 03:16:44 +02:00
ProxMenuxBot dee18df711 chore(lang): auto-rebuild translation cache
Source: d415c55
Triggered by: push
2026-09-22 21:26:18 +00:00
MacRimiandGitHub d415c55187 Merge pull request #379 from MacRimi/remove/oci-shell-entrypoint
Remove the OCI shell entry point nothing runs
2026-09-22 23:25:13 +02:00
MacRimiandClaude Opus 5 f3bda28338 refactor(oci): remove the shell entry point nothing runs
`oci/proxmenux-oci.sh` was installed on every Proxmox host and used on
none of them. The menu entry goes straight to the orchestrator:
scripts/oci/oci_manager_apps.sh runs `python3 -m proxmenux_oci` with
PYTHONPATH pointing at the engine.

Its only remaining caller was a message telling the reader to open it on
the Proxmox host — which is not how anyone gets in, and is what sent one
there to run it. That message now names the menu entry.

It could not be fetched and run either: it needs requirements.txt and
src/ beside it, so `wget | bash` resolved its own directory to the
working directory and failed on a path nobody chose. Making that work
would mean writing a second installer next to the one that already
ships the engine.

What it did offer was a virtualenv for a contributor generating the
catalog. The README now gives the direct invocation and names the two
distribution packages it needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:22:51 +02:00
MacRimiandGitHub 3bda7851a1 Merge pull request #378 from MacRimi/fix/oci-entrypoint-guard
Say where the OCI converter entry point has to run
2026-09-22 23:17:19 +02:00
MacRimiandClaude Opus 5 41b999ffeb fix(oci): say where the converter entry point has to run
Fetched with wget and piped to bash, $0 is "bash" and the script resolves
its own directory to wherever the shell happens to be. It then reports
"shasum: /root/requirements.txt: No such file or directory", which names
a path the reader never chose and says nothing about the real problem.

It now checks for the files it needs and names both places it runs from:
the installed engine directory on a Proxmox host, or oci/ in a clone.

PACKAGE-CONTENTS.md called it the "installer entry point", which is what
invites fetching it over the network in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 23:13:28 +02:00
MacRimiandGitHub 13ad61e8b0 Merge pull request #377 from MacRimi/fix/tui-catalog-damage
Repair strings the translator damaged in the TUI catalogue
2026-09-22 22:14:17 +02:00
MacRimiandClaude Opus 5 7e39aa550c fix(i18n): repair strings the translator damaged in the TUI catalogue
Three faults, all user-visible in the released catalogue.

The context prompt survived into 13 translations. A reader of the
Italian TUI was told "Messaggio tecnico per Proxmox e IT.Traduzione:
impossibile aggiornare initramfs." — the instruction given to the
provider, printed as if it were the message. One in French, eight in
Italian, four in Portuguese. The text after the prefix is correct, so
only the prefix goes.

Fifteen strings carried HTML entities where the source had a plain
character: "Adblock &quot; DNS", "Affari &amp; ERP".

Three product names had been translated as words — "Vaultwarden Courbe
Web" in French, where courbe means curve — and now keep their published
spelling. Two German strings said Kunden, a commercial customer, for an
OAuth client. Existing translations of the category labels and the
descriptive taglines are left as they are.

Twelve strings had no value in some locale and were retried on every
run, about thirty-five minutes a time. A fresh extraction now reports
nothing pending in any of the seven.

The Monitor catalogues are clean: they send no context prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 22:08:34 +02:00
MacRimiandGitHub 852f14e5b4 Merge pull request #376 from MacRimi/fix/tui-placeholder-protection
Keep {placeholders} out of the translator in the TUI cache
2026-09-22 21:54:09 +02:00
MacRimiandClaude Opus 5 75f571ecdb i18n(sk): match the wording already used for the sibling message
Every other Slovak string renders "Completed" as "Dokončené", including
the device message these two sit beside. They came back with "Hotovo" and
"k" instead of "do", which reads as a different hand in a list of four
lines that differ only by the noun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:48:42 +02:00
MacRimiandClaude Opus 5 eafc472279 fix(i18n): keep {placeholders} out of the translator in the TUI cache
The Monitor's generator protects them; this one did not, so a provider
read the word inside the braces and translated it. "{count}" came back
as "{conta}" in Portuguese and "{počet}" in Slovak, and the consumer —
finding no placeholder to substitute — fell back to English, which is
every message the recent batches introduced.

The token carries no underscores: argos splits on them and hands back
"PMX PH 0".

Two further fixes this depends on:

- protect_catalog_titles rebuilt the glossary pattern without the word
  boundaries the module-level one has, so a short term matched inside
  longer words once the catalogue was loaded.
- CT and VM join the glossary. Read as initials they get reordered —
  "CT" comes back as "TC" in Spanish — naming something Proxmox does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:45:54 +02:00
MacRimiandGitHub f8fade3c0a Merge pull request #375 from f3rs3n/fix/disk-passthrough-completion-messages
fix(storage): translate disk passthrough completion messages as a whole
2026-09-22 21:23:52 +02:00
MacRimiandGitHub 2a8f220462 Merge pull request #374 from MacRimi/fix/audit-backup-wording
State what the backup checks observed, not more
2026-09-22 21:23:03 +02:00
MacRimiandClaude Opus 5 44db612d01 i18n(sk): use the Slovak wording supplied by @Vaso73
The regenerated Slovak read as machine output. These carry the same
evidence boundary as the English and name VM and LXC the way a Slovak
reader expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 20:17:54 +02:00
ProxMenuxBot 9c9bc7d15b chore(lang): auto-rebuild translation cache
Source: 24a2557
Triggered by: push
2026-09-22 18:04:01 +00:00
MacRimiandClaude Opus 5 5f09af0162 fix(audit): state what the backup checks observed, not more
Two next steps in the backup area claimed more than the evidence behind
them supports, reported by @Vaso73 while reviewing the pattern.

A failed run is now treated as unsuccessful with a copy to confirm,
rather than asserted to have produced nothing usable — the task log does
not establish that. And a missing host-configuration record says no
backup is recorded here, leaving room for one this node cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:59:39 +02:00
martino 4322ae517b fix(storage): translate disk passthrough completion messages as a whole 2026-09-22 19:59:31 +02:00
MacRimiandGitHub f3a2b70219 Merge pull request #373 from MacRimi/fix/oci-entrypoint-messages
Write the OCI converter entry point messages in English
2026-09-22 19:40:51 +02:00
MacRimiandClaude Opus 5 1858fd5c60 fix(oci): write the converter entry point messages in English
The four setup messages were in Spanish and one told the reader Python 3
was needed "en el Mac", which is the wrong machine for a script the
README documents as running on the Proxmox host.

They stay outside translate(): this script runs before the Python
environment it is preparing exists, so no catalogue is available yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:29:06 +02:00
ProxMenuxBot 3f3e3f05d9 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 6fc893d
Triggered by: push
2026-09-22 17:28:25 +00:00
ProxMenuxBot f6bc0ea969 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ce60060
Triggered by: push
2026-09-22 17:25:47 +00:00
MacRimiandGitHub 6fc893d3f4 Merge pull request #372 from f3rs3n/fix/storage-temperature-summary
Distinguish unavailable disk temperatures in the storage summary
2026-09-22 19:24:43 +02:00
MacRimiandGitHub ce6006094e Merge pull request #369 from f3rs3n/fix/borg-ssh-guidance
Clarify Borg SSH account and key setup guidance
2026-09-22 19:23:55 +02:00
MacRimiandClaude Opus 5 ee12d421f0 Merge develop: combine the i18n test fixture with #367
Both batches seed the same block for their English-only additions. One
read and one write now cover the PBS key guidance and the Borg SSH keys
together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:23:10 +02:00
ProxMenuxBot 4594177035 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 60505a7
Triggered by: push
2026-09-22 17:23:06 +00:00
martino b06e0e8d13 fix: honor idle and excluded temperature availability 2026-09-22 19:22:40 +02:00
martino 7ad3e629c8 fix: distinguish unavailable disk temperatures in summary 2026-09-22 19:22:40 +02:00
MacRimiandGitHub 60505a74a9 Merge pull request #367 from f3rs3n/fix/pbs-key-guidance
Clarify PBS encryption key guidance and PVE key locations
2026-09-22 19:22:03 +02:00
MacRimiandGitHub 246393d3d6 Merge pull request #371 from f3rs3n/fix/samba-guest-outcome-messages
Describe Samba guest listing and access-test outcomes accurately
2026-09-22 19:21:49 +02:00
MacRimiandGitHub e6d3f2e3cb Merge pull request #370 from f3rs3n/fix/vm-storage-selection-messages
Clarify VM storage selection and fix translation lookups
2026-09-22 19:21:45 +02:00
MacRimiandGitHub 24a2557d8e Merge pull request #368 from f3rs3n/fix/scheduler-empty-messages
Use complete messages for empty scheduler job lists
2026-09-22 19:21:41 +02:00
ProxMenuxBot 90a925f88c chore(lang): auto-rebuild translation cache
Source: 9769988
Triggered by: push
2026-09-22 17:13:03 +00:00
martino 56d51dd461 fix(monitor): clarify BORG key guidance 2026-09-22 19:00:29 +02:00
martino 65cb75a6b7 fix(monitor): clarify PBS key guidance 2026-09-22 19:00:29 +02:00
martino c2f7bb4efc fix(samba): distinguish guest listing and access outcomes 2026-09-22 19:00:05 +02:00
martino eb13ca620e fix(i18n): clarify VM storage selection messages 2026-09-22 19:00:04 +02:00
martino 14f0831b05 fix: translate complete scheduler empty-job messages 2026-09-22 18:59:42 +02:00
MacRimiandGitHub 976998806c Merge pull request #366 from MacRimi/oci-manager
OCI manager: run official container images as native LXC containers
2026-09-22 18:32:36 +02:00
MacRimiandClaude Opus 5 dda8deb6e4 fix(i18n): complete the Monitor catalogs so a rerun writes nothing
The 41 keys added for the audit explanations, disk exclusions and idle
disk state existed only in English, so build_i18n_messages had work to do
on a clean checkout and the offline suite rejected it.

Fills them in the six remaining locales with argos, which joins the
provider list here as it already had in build_translation_cache: it runs
locally with no quota, and this script stores the English on a provider
failure, where a remote quota refusal would have been recorded as a
translation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:29:23 +02:00
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00
ProxMenuxBot a26876cff9 Update helpers_cache.json 2026-09-22 16:01:37 +00:00
ProxMenuxBot b36498f215 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 94c3da0
Triggered by: push
2026-09-22 14:58:47 +00:00
ProxMenuxBot 4f1d70775f chore(lang): auto-rebuild translation cache
Source: 94c3da0
Triggered by: push
2026-09-22 14:57:53 +00:00
MacRimiandGitHub 94c3da003d Merge pull request #365 from f3rs3n/storage-message-batch
fix(storage): qualify SMART status and report explanations
2026-09-22 16:56:42 +02:00
MacRimiandClaude Opus 5 2d25585faf Merge develop: combine the i18n test fixture with #363
Both message batches seeded locale copies at the same point of
test_command_descriptions.py. The two blocks are independent — one
seeds the backup messages, the other the storage ones — so they are
merged into a single block that reads and writes the fixture once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:46:04 +02:00
ProxMenuxBot f63362b460 chore(lang): auto-rebuild translation cache
Source: a6ca3d4
Triggered by: push
2026-09-22 14:41:17 +00:00
ProxMenuxBot 4d4828e7c8 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3b18e3e
Triggered by: push
2026-09-22 14:40:45 +00:00
MacRimiandGitHub a6ca3d4222 Merge pull request #364 from f3rs3n/fix/tui-count-messages
fix(tui): use complete messages for device, path and package counts
2026-09-22 16:39:57 +02:00
MacRimiandGitHub 3b18e3e664 Merge pull request #363 from f3rs3n/fix/backup-message-batch
fix(backup): use complete messages for archives and destinations
2026-09-22 16:39:22 +02:00
github-actions[bot] 0fd051a498 chore: update repository growth [skip ci] 2026-09-22 09:20:22 +00:00
martino 402278ecfd fix(storage): qualify SMART status and report explanations 2026-09-21 21:54:04 +02:00
martino db8cc2267a fix(tui): use complete messages for device, path and package counts 2026-09-21 20:37:01 +02:00
martino 6a7ffad9b8 fix(backup): use complete messages for archives and destinations 2026-09-21 19:49:43 +02:00
ProxMenuxBot 4eccbd9269 Update helpers_cache.json 2026-09-21 17:33:23 +00:00
ProxMenuxBot c5752fe582 Update app tracking hints 2026-09-21 17:15:15 +00:00
MacRimiandGitHub 5dffb5cb88 Merge pull request #362 from Vaso73/i18n/sk-terminal-description-alignment
i18n: align Slovak terminal command descriptions
2026-09-21 17:58:15 +02:00
VAIO73 22c569dc41 i18n: align Slovak terminal command descriptions 2026-09-21 17:31:13 +02:00
github-actions[bot] c8f7a4fe3e chore: update repository growth [skip ci] 2026-09-21 09:59:45 +00:00
ProxMenuxBot 704eb3f9fb chore(lang): auto-rebuild translation cache
Source: e318914
Triggered by: push
2026-09-21 08:51:05 +00:00
MacRimiandGitHub bc736926c5 Merge pull request #356 from f3rs3n/fix/backup-recovered-guest-count
fix: count distinct guests in backup recovery summary
2026-09-21 10:51:04 +02:00
MacRimiandGitHub e318914d01 Merge pull request #355 from f3rs3n/fix/fastfetch-config-generation
fix: restore Fastfetch system modules on current releases
2026-09-21 10:50:11 +02:00
MacRimiandGitHub 32355ded88 Merge pull request #354 from f3rs3n/fix/terminal-command-descriptions
fix: align terminal command descriptions with their commands
2026-09-21 10:49:14 +02:00
ProxMenuxBot 7198c19da8 Update helpers_cache.json 2026-09-20 15:10:49 +00:00
ProxMenuxBot acc426e723 Update app tracking hints 2026-09-20 14:57:30 +00:00
github-actions[bot] 662793d7c2 chore: update repository growth [skip ci] 2026-09-20 09:19:16 +00:00
ProxMenuxBot e704210685 Update helpers_cache.json 2026-09-20 02:41:12 +00:00
ProxMenuxBot 33c8ff5e7e Update app tracking hints 2026-09-20 01:51:00 +00:00
martino 6cd2b516b1 fix: count distinct guests in backup recovery summary 2026-09-19 17:58:39 +02:00
martino f5e4427903 fix: restore Fastfetch config generation 2026-09-19 12:05:10 +02:00
ProxMenuxBot dc26a872fd Update helpers_cache.json 2026-09-19 10:02:43 +00:00
martino e19927af48 fix: align terminal command descriptions with their commands 2026-09-19 11:41:01 +02:00
github-actions[bot] af41036c18 chore: update repository growth [skip ci] 2026-09-19 08:50:04 +00:00
MacRimi 44e91c4710 orrect the machine-translated notification catalogs 2026-09-18 21:13:18 +02:00
ProxMenuxBot f6e3738c1e chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: d5bce37
Triggered by: workflow_dispatch
2026-09-18 18:07:38 +00:00
MacRimi d5bce37ed2 Leave untranslated notification keys for the i18n bot to fill 2026-09-18 18:55:14 +02:00
MacRimiandGitHub d8b1716c0a Merge pull request #352 from f3rs3n/fix/audit-safety-wording
fix: clarify audit side effects and boot-check scope
2026-09-18 18:47:23 +02:00
MacRimiandGitHub daf711328b Merge pull request #351 from f3rs3n/feature/f05-i18n-arrays
fix: preserve i18n arrays and restore command descriptions
2026-09-18 18:46:15 +02:00
MacRimiandGitHub a727cf0118 Merge pull request #348 from Vaso73/fix/notification-runtime-i18n
i18n: localize runtime notification delivery
2026-09-18 18:42:37 +02:00
ProxMenuxBot 86d6ae4dec chore(lang): auto-rebuild translation cache
Source: 702c774
Triggered by: push
2026-09-18 16:40:32 +00:00
MacRimiandGitHub 702c774d31 Merge pull request #349 from f3rs3n/fix/pve8-to-9-apt-safety
fix: preserve APT diagnostics and enforce PVE upgrade safety checks
2026-09-18 18:39:30 +02:00
ProxMenuxBot ef6d024a4a Update helpers_cache.json 2026-09-18 10:19:34 +00:00
github-actions[bot] 15e819385f chore: update repository growth [skip ci] 2026-09-18 09:02:34 +00:00
martino 60f71c0c8a fix: clarify audit side effects and boot-check scope 2026-09-17 18:23:56 +02:00
ProxMenuxBot 50b4dc33fd Update helpers_cache.json 2026-09-17 15:57:48 +00:00
martino 12684f483d fix: preserve i18n arrays and restore command descriptions 2026-09-17 17:33:32 +02:00
martino c7f58079b9 fix: preserve APT diagnostics and enforce PVE upgrade safety checks 2026-09-17 16:20:18 +02:00
VAIO73 603fb0e03a fix: deduplicate repeated LXC digest results 2026-09-17 14:48:52 +02:00
VAIO73 63e72abb27 fix: deduplicate identical digest events 2026-09-17 14:26:18 +02:00
VAIO73 40cfe78aa7 fix: add icons to update notification details 2026-09-17 14:10:22 +02:00
VAIO73 1151928b3e fix: honor future digest schedule changes 2026-09-17 13:47:24 +02:00
VAIO73 10a69947dc i18n: localize health degradation alerts 2026-09-17 13:31:58 +02:00
VAIO73 2f8197eb97 fix: add icons to notification digests 2026-09-17 13:23:37 +02:00
VAIO73 82c3a49f4c fix: localize empty important package lists 2026-09-17 13:17:08 +02:00
VAIO73 661b944e0c fix: localize batched app update notifications 2026-09-17 13:12:02 +02:00
VAIO73 d61719dcaf fix: preserve notification icons across locales 2026-09-17 12:57:46 +02:00
VAIO73 25b71efab0 fix: use display name in runtime notifications 2026-09-17 12:49:41 +02:00
ProxMenuxBot 26bc278b34 Update helpers_cache.json 2026-09-17 10:44:02 +00:00
VAIO73 ad23a1167f i18n: localize LXC update notification results 2026-09-17 12:40:54 +02:00
VAIO73 dc283bed9a i18n: localize runtime notification delivery 2026-09-17 11:59:48 +02:00
Vaso73andVAIO73 da7bf37c07 fix: enforce Telegram chunk limits 2026-09-17 11:46:29 +02:00
Vaso73andVAIO73 38ac46460e fix: add notification language UI labels 2026-09-17 11:46:29 +02:00
Vaso73andVAIO73 f11aba3492 Fix notification language and event group parity 2026-09-17 11:45:48 +02:00
Vaso73andVAIO73 376b9504f8 Fix runtime language migration and email localization 2026-09-17 11:45:38 +02:00
Vaso73andVAIO73 584c4c31a3 Localize runtime notifications in Slovak 2026-09-17 11:45:10 +02:00
Vaso73andVAIO73 ca163cd2bb test: cover runtime notification localization on v1.2.6 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 7587b2ad6a Preserve localized backup status with vzdump inventory 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 c83c68b4ee Preserve vzdump inventory when AI is enabled 2026-09-17 11:44:49 +02:00
Vaso73andVAIO73 49cb8133ed test: cover backup notification integrity on v1.2.6 2026-09-17 11:44:49 +02:00
github-actions[bot] 7ba90791ea chore: update repository growth [skip ci] 2026-09-17 09:28:58 +00:00
ProxMenuxBot dede876f0b chore(lang): auto-rebuild translation cache
Source: 256f323
Triggered by: push
2026-09-16 21:25:52 +00:00
MacRimi 256f3232e4 Offer only NVIDIA drivers that can drive the GPU, and prove it before removing the working one 2026-09-16 23:19:39 +02:00
ProxMenuxBot c28b22fb59 Update helpers_cache.json 2026-09-16 20:42:56 +00:00
github-actions[bot] 7640762462 chore: update repository growth [skip ci] 2026-09-16 09:20:11 +00:00
ProxMenuxBot 891c68ba42 Update helpers_cache.json 2026-09-15 15:59:01 +00:00
ProxMenuxBot 7aa9840414 Update helpers_cache.json 2026-09-15 10:45:54 +00:00
ProxMenuxBot 09e4b81d65 Update app tracking hints 2026-09-15 10:32:35 +00:00
github-actions[bot] 80c1cf4475 chore: update repository growth [skip ci] 2026-09-15 09:28:56 +00:00
ProxMenuxBot 26d30481b5 Update helpers_cache.json 2026-09-14 17:20:00 +00:00
ProxMenuxBot 4d32422fef Update app tracking hints 2026-09-14 17:06:00 +00:00
MacRimi 78971f4ed7 New version 1.2.6.1-beta 2026-09-14 18:34:53 +02:00
github-actions[bot] c89c19deb2 Update AppImage beta build (2026-09-14 16:26:25) 2026-09-14 16:26:25 +00:00
ProxMenuxBot 43a655a57a Update helpers_cache.json 2026-09-14 11:21:56 +00:00
github-actions[bot] 93900b872e chore: update repository growth [skip ci] 2026-09-14 09:55:41 +00:00
ProxMenuxBot ac4be7031f Update helpers_cache.json 2026-09-13 20:03:16 +00:00
ProxMenuxBot 102ac40226 Update app tracking hints 2026-09-13 15:08:29 +00:00
ProxMenuxBot 7f90154410 Update helpers_cache.json 2026-09-13 10:51:49 +00:00
github-actions[bot] 013def197a chore: update repository growth [skip ci] 2026-09-13 09:36:39 +00:00
ProxMenuxBot baaff2725b Update helpers_cache.json 2026-09-13 02:24:38 +00:00
MacRimi 2308b4ddc1 Translate the network interface counters as statistics, not sentences 2026-09-12 18:39:10 +02:00
ProxMenuxBot 8e7588f372 Update helpers_cache.json 2026-09-12 14:44:02 +00:00
ProxMenuxBot 8507097a9a Update app tracking hints 2026-09-12 14:27:28 +00:00
MacRimi cc7b38a141 Name host and cluster correctly in every language 2026-09-12 12:01:25 +02:00
MacRimiandGitHub dc05b1125b Merge pull request #345 from Vaso73/i18n/sk-monitor-copy-polish
i18n: refine Slovak copy across Monitor pages
2026-09-12 11:52:53 +02:00
ProxMenuxBot ed5f0e8393 Update app tracking hints 2026-09-12 09:40:05 +00:00
VAIO73 36bca5908b i18n: upravený sprievodca systémovými záznamami 2026-09-12 11:36:49 +02:00
VAIO73 cbc5b9d953 i18n: upravené systémové záznamy 2026-09-12 11:36:49 +02:00
VAIO73 e61fdbf84f i18n: upravené texty karty Sieť 2026-09-12 11:36:49 +02:00
VAIO73 e67d5f1617 i18n: upravené texty úložiska 2026-09-12 11:36:49 +02:00
VAIO73 991cf732e5 i18n: upravené texty na Prehľade 2026-09-12 11:36:49 +02:00
VAIO73 8918675393 i18n: lokalizované kategórie aplikácií 2026-09-12 11:36:49 +02:00
VAIO73 e0c5740a47 i18n: zjednotené VM a LXC v Aplikáciách 2026-09-12 11:36:49 +02:00
VAIO73 9ff1fa1bda i18n: spresnené uzly v Termináli 2026-09-12 11:36:49 +02:00
VAIO73 7b06c90040 i18n: zjednotené pojmy na karte Záloha 2026-09-12 11:36:49 +02:00
VAIO73 f6a3dc2f24 i18n: zjednotené pojmy na karte Hardvér 2026-09-12 11:36:49 +02:00
VAIO73 9bdd0cb9fc i18n: zjednotené pojmy v Nastaveniach 2026-09-12 11:36:49 +02:00
VAIO73 6f7e94d095 i18n: zjednotené pojmy na karte Bezpečnosť 2026-09-12 11:36:49 +02:00
VAIO73 8c67f883c9 i18n: zjednotené pojmy na karte VM a LXC 2026-09-12 11:36:49 +02:00
MacRimiandGitHub 9d3285d0df Merge pull request #344 from Vaso73/i18n/sk-audit-report-pr
i18n: add Slovak Audit & Report copy
2026-09-12 11:05:49 +02:00
MacRimi b5fb747271 Section icons in the change journal, and admin scope on secret reveal 2026-09-12 10:54:36 +02:00
github-actions[bot] 49efa18f97 chore: update repository growth [skip ci] 2026-09-12 08:39:58 +00:00
VAIO73 1e6efe37a0 i18n: doplnené chýbajúce kľúče záloh 2026-09-12 10:22:45 +02:00
VAIO73 abef0c0303 i18n: zjednotené označenie kontroly auditu 2026-09-12 10:18:25 +02:00
VAIO73 bd6d1e323e i18n: zjednotené stavy auditu 2026-09-12 10:18:25 +02:00
VAIO73 9db1696d5a i18n: zjednotené názvy v úvodných novinkách 2026-09-12 10:18:25 +02:00
VAIO73 c6cb14c651 i18n: rozlíšené VM a LXC v audite 2026-09-12 10:18:25 +02:00
VAIO73 57b77eb212 i18n: spresnené pravidlá auditu 2026-09-12 10:18:25 +02:00
VAIO73 8d843c90e9 i18n: prirodzenejšie úvodné novinky 1.2.6.1 2026-09-12 10:18:25 +02:00
VAIO73 9c3f5feda1 i18n: prirodzenejšie texty auditu 2026-09-12 10:18:25 +02:00
VAIO73 e4f78a1227 i18n: preložené úvodné novinky 1.2.6.1 2026-09-12 10:18:25 +02:00
VAIO73 983bf5c610 i18n: preložená záložka Audit a správy 2026-09-12 10:18:25 +02:00
MacRimi 78d2d84f20 Read the login-page version from APP_VERSION 2026-09-12 09:53:33 +02:00
ProxMenuxBot a4537be773 Update helpers_cache.json 2026-09-12 02:26:12 +00:00
ProxMenuxBot 8f62859971 Update app tracking hints 2026-09-12 01:43:51 +00:00
github-actions[bot] 0504fcd41d Update AppImage beta build (2026-09-11 23:02:43) 2026-09-11 23:02:43 +00:00
MacRimi 0b64549230 Focus the Audit & Report tab, scope API tokens 2026-09-12 00:22:14 +02:00
github-actions[bot] f12ca3ed27 Update AppImage beta build (2026-09-11 19:12:00) 2026-09-11 19:12:00 +00:00
MacRimi 2a672889bf Record GPU/TPU host changes in the change journal
Seven gpu_tpu scripts now write through the journal for host changes only — driver and package installs, vfio binding, modprobe.d, /etc/modules, GRUB and udev — while VM and LXC configuration stays out; add_gpu_vm and install_coral_lxc journal only their host writes. amd_gpu_tools records its install, and pmx_journal.sh gains pmx_record_uninstall while changes_journal.py retires an installed package when ProxMenux removes it. What each function writes is byte-identical to before, verified by tests/journal/verify_journal_migration.py.
2026-09-11 21:08:35 +02:00
github-actions[bot] 9c930de186 Update AppImage beta build (2026-09-11 17:59:18) 2026-09-11 17:59:18 +00:00
MacRimi bee242afa9 record Monitor-side installs and dedupe config by file 2026-09-11 19:54:32 +02:00
github-actions[bot] e2dadebda1 Update AppImage beta build (2026-09-11 17:35:28) 2026-09-11 17:35:28 +00:00
MacRimi 1830852901 attribute post-install helpers to their feature and dedupe files 2026-09-11 19:33:23 +02:00
MacRimi 5d73cd4aec Update utils.sh 2026-09-11 19:12:56 +02:00
github-actions[bot] b5b128264d Update AppImage beta build (2026-09-11 17:04:05) 2026-09-11 17:04:06 +00:00
MacRimi 08810d4b16 Update changes_journal.py 2026-09-11 18:45:46 +02:00
ProxMenuxBot fb1517807c Update helpers_cache.json 2026-09-11 10:20:22 +00:00
ProxMenuxBot 685b37bc43 Update app tracking hints 2026-09-11 10:04:12 +00:00
github-actions[bot] 5867d467f3 chore: update repository growth [skip ci] 2026-09-11 08:56:57 +00:00
MacRimi 6b8bb97922 Update utils.sh 2026-09-11 09:17:36 +02:00
github-actions[bot] a61ad04ebe Update AppImage beta build (2026-09-11 06:52:29) 2026-09-11 06:52:29 +00:00
MacRimi 91c453d33c Update audit-changes.tsx 2026-09-11 08:50:03 +02:00
github-actions[bot] 22defbfc7d Update AppImage beta build (2026-09-11 06:40:30) 2026-09-11 06:40:30 +00:00
ProxMenuxBot 0284a7e7da chore(lang): auto-rebuild translation cache
Source: 8e0d4ff
Triggered by: push
2026-09-11 06:38:18 +00:00
MacRimi 8e0d4ff337 eep only package-changing executions and clean up entry presentation 2026-09-11 08:37:23 +02:00
github-actions[bot] ca7c9ec58e Update AppImage beta build (2026-09-11 05:52:37) 2026-09-11 05:52:37 +00:00
ProxMenuxBot dd2a9f611d Update helpers_cache.json 2026-09-10 15:35:46 +00:00
ProxMenuxBot 30e6e64d4f Update app tracking hints 2026-09-10 15:17:31 +00:00
MacRimi b68105e9f0 Update audit-changes.tsx 2026-09-10 15:31:53 +02:00
github-actions[bot] 7a45ac2de3 Update AppImage beta build (2026-09-10 13:24:45) 2026-09-10 13:24:45 +00:00
MacRimi 085cbf7e68 scope the change journal to host changes, in three sections
The change journal exists so a sysadmin sees what ProxMenux changed on the host — its own configuration, packages and services — not how it uses the host or configures a guest. Several scripts recorded operations that are neither: disk passthrough to a VM, container conversions, VM import/export, mounting a share into an LXC. Those are restored to their original, uninstrumented form. Scripts that operate on the host while also installing a package now record only the package: format-disk keeps its exFAT-tools install, the UUP ISO builder its build dependencies, and the share/host scripts their packages, services and /etc/fstab writes, while the mount and unmount operations they used to log are dropped.

The page now reads in three sections: what ProxMenux optimized after install (each function under its menu name), what its other host scripts changed (by script), and what it installed (packages and utilities, each referencing the script that installed it). A file reads as created or modified with its diff, a service shows its state transition, and the undo line appears only when a revert is possible.
2026-09-10 15:20:39 +02:00
ProxMenuxBot f9373f8777 Update helpers_cache.json 2026-09-10 10:19:59 +00:00
github-actions[bot] cad825ae7e chore: update repository growth [skip ci] 2026-09-10 08:58:43 +00:00
github-actions[bot] d99006bb3c Update AppImage beta build (2026-09-09 17:37:09) 2026-09-09 17:37:09 +00:00
MacRimi 4915d4044c tolerate flaky third-party apt repo in AppImage build workflows 2026-09-09 19:34:40 +02:00
MacRimi 90c4a720e3 group host changes by function with a truthful before/after 2026-09-09 19:26:22 +02:00
github-actions[bot] 36071e6bd3 Update AppImage beta build (2026-09-09 16:10:34) 2026-09-09 16:10:34 +00:00
ProxMenuxBot 5b4366fa82 Update helpers_cache.json 2026-09-09 15:40:19 +00:00
ProxMenuxBot 1df63be887 Update app tracking hints 2026-09-09 15:23:31 +00:00
MacRimi 715f4195b2 require full_admin scope for terminal tickets and auth disable 2026-09-09 17:19:48 +02:00
github-actions[bot] b599d1cf26 chore: update repository growth [skip ci] 2026-09-09 08:59:40 +00:00
MacRimi 023350d7e1 apply sentence spacing to every translation provider 2026-09-09 09:03:09 +02:00
ProxMenuxBot c6fde98a80 chore(lang): auto-rebuild translation cache
Source: 53c27f8
Triggered by: push
2026-09-09 06:56:26 +00:00
MacRimiandGitHub 53c27f89dc Merge pull request #340 from f3rs3n/fix/protect-stacked-system-disks
fix(storage): strengthen system-disk protection before formatting
2026-09-09 08:54:02 +02:00
github-actions[bot] 5ec5e11dae Update AppImage beta build (2026-09-09 06:01:22) 2026-09-09 06:01:22 +00:00
MacRimi fb6c9bf22e capitalize audit socket message in Spanish 2026-09-08 21:12:55 +02:00
ProxMenuxBot 68348e0558 chore(lang): auto-rebuild translation cache
Source: da8a480
Triggered by: push
2026-09-08 19:07:06 +00:00
MacRimiandClaude Opus 5 da8a480eff Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:06:04 +02:00
ProxMenuxBot ae75508eff chore(lang): auto-rebuild translation cache
Source: 1c44842
Triggered by: push
2026-09-08 18:41:22 +00:00
ProxMenuxBot fe9afd90c4 chore(lang): auto-rebuild translation cache
Source: 34041b8
Triggered by: push
2026-09-08 18:39:33 +00:00
MacRimiandGitHub 1c4484266d Merge pull request #341 from f3rs3n/fix/validate-pending-restore
fix(backup): reject incomplete pending restore staging
2026-09-08 20:38:54 +02:00
MacRimiandGitHub 34041b87c6 Merge pull request #339 from f3rs3n/fix/preserve-partition-tables
fix(storage): preserve partition tables when removing signatures
2026-09-08 20:38:26 +02:00
ProxMenuxBot 1919e62646 Update helpers_cache.json 2026-09-08 15:45:06 +00:00
ProxMenuxBot 38d445a379 Update helpers_cache.json 2026-09-08 10:21:41 +00:00
ProxMenuxBot 150494bce1 Update app tracking hints 2026-09-08 10:09:14 +00:00
github-actions[bot] 2424545532 chore: update repository growth [skip ci] 2026-09-08 08:54:07 +00:00
ProxMenuxBot e9c5a12702 Update helpers_cache.json 2026-09-07 20:57:05 +00:00
ProxMenuxBot 3dcf6cd661 Update helpers_cache.json 2026-09-07 16:57:37 +00:00
ProxMenuxBot d91599d354 Update app tracking hints 2026-09-07 16:42:34 +00:00
ProxMenuxBot 97bf9e24e5 Update helpers_cache.json 2026-09-07 11:11:20 +00:00
martino cc2983a398 fix(backup): reject incomplete pending restore staging 2026-09-07 12:59:57 +02:00
martino fa4e8fd087 fix(storage): strengthen system-disk protection before formatting 2026-09-07 11:54:28 +02:00
github-actions[bot] 9a1642b53c chore: update repository growth [skip ci] 2026-09-07 09:25:01 +00:00
martino 1b592317a1 fix(storage): preserve partition tables when clearing signatures 2026-09-06 23:24:18 +02:00
github-actions[bot] c90e365d1f chore: update repository growth [skip ci] 2026-09-06 08:42:07 +00:00
MacRimiandGitHub b4e34d0902 Merge pull request #338 from MacRimi/fix/pr337-docker-update-hardening
Fix Docker app version tracking, cache consistency and delegated updates
2026-09-05 17:02:08 +02:00
MacRimi 337cb188c3 Fix Docker app version tracking, cache consistency and delegated updates 2026-09-05 17:01:02 +02:00
ProxMenuxBot f1d8b299db chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 3a49648
Triggered by: push
2026-09-05 14:56:42 +00:00
MacRimiandGitHub 3a49648f0d Merge pull request #337 from byGarcia/feature/docker-remote-image-version
Report the version a Docker image would install, and let the apps inside it be registered
2026-09-05 16:55:07 +02:00
MacRimi 19c46a86d7 Merge develop into PR #337 and resolve shared app cache conflict 2026-09-05 16:51:41 +02:00
MacRimi 6644b62f63 Improve LXC updater selection, error handling and dashboard consistency 2026-09-05 16:10:31 +02:00
github-actions[bot] 9ae3a54e8b chore: update repository growth [skip ci] 2026-09-05 08:23:17 +00:00
ProxMenuxBot cc66939095 Update app tracking hints 2026-09-04 15:13:17 +00:00
byGarcia de72e18a77 feat(docker): name containerised applications and the versions they run
An LXC running its workload in Docker could not answer two questions it
already had the data for: which application is in there, and whether a
newer version exists.

**Which version is available.** The Updates tab resolved that number only
for docker.io, and only when a version tag happened to share the digest of
the tag in use. On ghcr.io, lscr.io or quay.io the row said "New image
available" with no number at all. The image a pull would install carries
its own version label, so it is now read from the registry by digest —
over the same protocol and Bearer challenge the digest comparison already
uses, and through the same label lookup the installed version uses, now
shared as _docker_version_from_labels.

The question this answers is the one the tab asks: what do I get if I
re-pull this tag. Not "what is the newest upstream release", which is a
different number whenever a tag is pinned or the publisher tags releases
differently from images.

Docker Hub keeps priority on docker.io: its tag API is not a pull and does
not spend the anonymous pull-rate budget, and official images carry no
labels for the registry path to read. The digest still decides whether an
update exists; this only names it, and declines to name it when the answer
would be a guess — no build for this platform, no labels, an unreadable
manifest, a moving tag, a rebuild of the same version, or two sides whose
versions came from different label keys. Each refusal is recorded in
available_version_source.

Attestation manifests are skipped explicitly: they advertise
unknown/unknown and their config blob is a provenance document, not an
image. Every document is fetched by digest and verified against it, the
config read is bounded, and it is cached per digest, which never changes
content. The CDN redirect is followed by hand, dropping Authorization:
urllib re-sends it to the redirect target and signed-URL storage rejects a
second auth mechanism.

**Which application it is.** The probe already read "1.37.2" out of a
Vaultwarden container and get_suggestions discarded it, so the panel
answered "No new applications were detected" about an application whose
version it had just measured. Containerised applications are now offered
for registration like any other, with their name, logo, published ports
and installed version.

What they do not get is an update path of their own, because they do not
have one: updating Vaultwarden means pulling and recreating its image. A
new update_via=docker marker records that delegation, so one release stays
one badge, one notification and one button. The marker is validated rather
than inferred from installed_via, since docker_exec with an upstream is a
legitimate registration someone may already rely on; combining it with an
upstream is rejected instead of silently stripped, because registering an
app that checks GitHub behind a delegation promising it will not is worse
than an error message.

Three failure modes the delegation had to be defended against: detector
auto-healing would have migrated the app onto a leftover /root/.<app>
marker and quietly un-delegated it; saving replaces the whole record, so
the editor carries the marker explicitly rather than dropping it on the
first port edit; and the release-age hold gates on a publish date a
delegated app never has, which deferred the whole schedule forever.

Their version is resolved server-side through the container the detector
declares — not through the app's name or image, since Immich's compose
service and image are both immich-server while the application is immich.
The annotation happens on the way out of both endpoints rather than into
their caches: the App tab's cache is invalidated by events, not by time,
and the Docker inventory it reads is built asynchronously, so annotating
before storing froze a response taken before the first scan.

The rows carry that name too. display_name was already computed and
already used by the bulk-update section; the image row, the update
notification and the CT badge now use it as well. A delegated app's
pending update counts in the badge only while its image is not already
being counted, so registering just the application does not leave the
container looking up to date, and registering both does not count twice.

Catalog: four detectors verified on real containers, following the rules
in the file. vaultwarden and immich gain docker fallbacks for installs
where the native marker does not exist. netalertx is new — note its
repository is netalertx/NetAlertX; the Docker Hub namespace 404s.
technitiumdns is new and uses Technitium's own update endpoint rather than
GitHub releases: its marker reads 15.4 while the release tag is v15.4.0,
and _version_tuple compares (15,4,0) > (15,4) as an update that would
never clear.

Verified live against ghcr.io (Immich 3.1.0), docker.io (Vaultwarden
1.37.2) and lscr.io (Radarr 6.3.0.10514-ls314), plus postgres:16, which
correctly reports no version because official images carry no labels.
Exercised end to end on Proxmox VE 9.2.4 with NetAlertX reporting
26.6.3 -> 26.9.0. 31 new unit tests cover the resolution rules, every
refusal, the delegation contract and the container-to-image pairing.
2026-09-04 13:16:28 +02:00
ProxMenuxBot 56da2ac880 Update helpers_cache.json 2026-09-04 10:15:52 +00:00
MacRimi 4f38d0e2e7 notification delivery gaps and locale corrections 2026-09-04 11:38:44 +02:00
ProxMenuxBot ab5767fe94 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: ce6af76
Triggered by: push
2026-09-04 09:28:30 +00:00
MacRimiandGitHub ce6af76cce Merge pull request #336 from Vaso73/fix/lxc-mount-target-normalization
fix: recognize LXC mount targets with trailing slashes
2026-09-04 11:27:35 +02:00
github-actions[bot] 2630e4f016 chore: update repository growth [skip ci] 2026-09-04 08:52:39 +00:00
VAIO73 7ae43310f4 fix: recognize LXC mount targets with trailing slashes 2026-09-04 10:36:51 +02:00
ProxMenuxBot 5e60feab84 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: 9934bd2
Triggered by: push
2026-09-04 06:09:58 +00:00
MacRimiandGitHub 9934bd2628 Merge pull request #334 from Vaso73/feat/vm-lxc-search-update-filter
feat: add VM and LXC list search and update filter
2026-09-04 08:07:14 +02:00
VAIO73 9cc49da019 feat: add VM and LXC list search and update filter 2026-09-04 06:27:58 +02:00
MacRimi 25b5eaddc5 Fix restoration of custom Proxmox web certificates
Validate and restore pveproxy SSL certificate pairs during post-boot recovery, with fingerprint verification and automatic rollback on failure.
2026-09-03 23:46:03 +02:00
MacRimiandGitHub 6c44beb46d Merge pull request #332 from Vaso73/fix/runtime-verified-app-detectors
fix: improve application version detection
2026-09-03 22:25:41 +02:00
MacRimiandGitHub a3d26a2278 Merge pull request #329 from Vaso73/i18n/sk-apps-overview-terminal
i18n: polish Slovak copy for Apps, overview, and terminal
2026-09-03 22:25:08 +02:00
ProxMenuxBot bfbeb97203 chore(i18n): auto-fill missing translations in messages/{locale}/common.json
Source: a2b0341
Triggered by: push
2026-09-03 20:24:41 +00:00
MacRimiandGitHub a2b03419d6 Merge pull request #330 from Vaso73/i18n/sk-vm-lxc-complete
i18n: translate Slovak VM and LXC status and update scheduling
2026-09-03 22:20:27 +02:00
ProxMenuxBot 966e9799f2 Update helpers_cache.json 2026-09-03 15:33:25 +00:00
VAIO73 cecffbc89f fix: prefer runtime-verified app detectors 2026-09-03 13:10:43 +02:00
ProxMenuxBot ae3b93a3c1 Update helpers_cache.json 2026-09-03 10:28:05 +00:00
github-actions[bot] f0f34c3416 Update AppImage beta build (2026-09-03 10:00:28) 2026-09-03 10:00:28 +00:00
ProxMenuxBot 64d1a86fe5 chore(lang): auto-rebuild translation cache
Source: 86305cf
Triggered by: push
2026-09-03 09:56:47 +00:00
MacRimi 86305cf261 fix: harden post-install migrations and bundle multi-ABI Python runtimes
- safely migrate historical Bashrc and Log2RAM installations
- align post-install function versions across both flows
- bundle gevent runtimes for CPython 3.11 and 3.13
- select the correct Python ABI at runtime
- validate both supported Proxmox VE Python versions in AppImage workflows
- fix #327, #328 and #331
2026-09-03 11:55:39 +02:00
github-actions[bot] 459ea9c6db chore: update repository growth [skip ci] 2026-09-03 08:58:03 +00:00
VAIO73 d17c31398c i18n: translate VM and LXC status and schedule states 2026-09-03 10:21:53 +02:00
VAIO73 cbb61d65e4 i18n: translate VM and LXC app link fields 2026-09-03 10:21:53 +02:00
VAIO73 6dc123615b i18n: translate terminal command descriptions 2026-09-03 10:21:27 +02:00
VAIO73 56d841aa64 i18n: translate 1.2.6 overview release notes 2026-09-03 10:21:27 +02:00
VAIO73 8899d8bfe1 i18n: translate Apps dashboard and custom link dialog 2026-09-03 10:21:27 +02:00
github-actions[bot] ac09cc24a8 Update AppImage beta build (2026-09-02 20:31:05) 2026-09-02 20:31:05 +00:00
github-actions[bot] 636983b697 Update AppImage release build (2026-09-02 20:28:55) 2026-09-02 20:28:55 +00:00
MacRimiandGitHub 986301d152 Merge pull request #326 from MacRimi/develop
New version 1.2.6
2026-09-02 22:24:48 +02:00
MacRimi cd40abc359 New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback
and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies)
and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway
wizard's Alpine template selection and pct create with the host's real architecture on
x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification
delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for
app version tracking (#306), and richer replication failure notifications.
2026-09-02 22:19:37 +02:00
MacRimi ea8c29ecbd New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback
and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies)
and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway
wizard's Alpine template selection and pct create with the host's real architecture on
x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification
delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for
app version tracking (#306), and richer replication failure notifications.
2026-09-02 22:18:52 +02:00
MacRimi eecd93bf1a prevent duplicate concurrent deliveries 2026-09-02 20:09:39 +02:00
github-actions[bot] d1fed2fb45 Update AppImage beta build (2026-09-02 17:07:47) 2026-09-02 17:07:47 +00:00
MacRimi e7b69dae91 replication failure notifications 2026-09-02 16:28:03 +02:00
ProxMenuxBot d10acbc895 chore(lang): auto-rebuild translation cache
Source: 91b4200
Triggered by: push
2026-09-02 12:44:36 +00:00
MacRimi 91b4200179 custom SSH port for Borg remote targets
feat(host-backup): custom SSH port for Borg remote targets

Add Borg destinations no longer assume port 22. The Monitor form and
the shell TUI both take an optional port (default 22, range 1-65535)
and embed it in the persisted ssh://user@host:port/path URL. BORG_RSH,
the sshpass key installer and the capacity probe all honour it.

Reported by @songochain in #236 — NAS-style Borg hosts on non-standard
SSH ports are now first-class targets.

Existing borg-targets.txt entries without a port keep working; the
port is only serialised into the URL when it differs from 22.
2026-09-02 14:43:28 +02:00
github-actions[bot] 45cc2ba36e Update AppImage beta build (2026-09-02 12:13:31) 2026-09-02 12:13:31 +00:00
MacRimi 588d8f629d drop editorial preview/exp/experimental filter in Gemini provider 2026-09-02 14:11:24 +02:00
github-actions[bot] 686c9456f4 Update AppImage beta build (2026-09-02 11:50:48) 2026-09-02 11:50:48 +00:00
3189 changed files with 794337 additions and 22385 deletions
+105
View File
@@ -0,0 +1,105 @@
name: OCI validation report
description: Report a real test of an application from the OCI manager catalog.
title: "OCI validation: "
labels: ["oci-validation"]
body:
- type: markdown
attributes:
value: |
One report describes one application and the scenario you tested. Once it is reviewed, the application is recorded as verified: it shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md](https://github.com/MacRimi/ProxMenux/blob/develop/oci/VALIDATION.md) with your GitHub user, the date and a link to this report.
Reports that an application does not work are just as useful: they tell others what to expect and what needs fixing.
- type: input
id: application
attributes:
label: Application
description: Name or ID as it appears in the OCI catalog, for example "Glances" or "glances".
validations:
required: true
- type: input
id: version
attributes:
label: Image version
description: Version or tag shown by the installer.
validations:
required: true
- type: input
id: digest
attributes:
label: Image digest
description: Shown in the App tab of the container in ProxMenux Monitor, after the build date — for example "2026-09-06 · b1f339cf". The full sha256 digest is also accepted.
placeholder: b1f339cf
validations:
required: true
- type: input
id: proxmox
attributes:
label: Proxmox VE version
placeholder: "9.1"
validations:
required: true
- type: dropdown
id: install
attributes:
label: Install mode
options:
- Default configuration
- Advanced configuration
validations:
required: true
- type: dropdown
id: storage
attributes:
label: Data storage
options:
- Volume on Proxmox storage
- Host directory
- Both
- No persistent data
validations:
required: true
- type: dropdown
id: network
attributes:
label: Network
options:
- DHCP
- Static address
validations:
required: true
- type: dropdown
id: gpu
attributes:
label: GPU
options:
- No GPU
- Intel
- AMD
- NVIDIA
validations:
required: true
- type: checkboxes
id: checks
attributes:
label: What was checked
options:
- label: Installs cleanly
- label: The application responds (web interface or service)
- label: Keeps working after a container restart
- label: Survives a Proxmox backup and restore
- label: An image update keeps the data
- type: dropdown
id: result
attributes:
label: Result
options:
- Works
- Works with problems
- Does not work
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: Anything worth knowing. If something failed, what you saw and how to reproduce it.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxmenux-offline-node-tests",
"version": "1.0.0",
"dependencies": {
"lucide-react": "0.454.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"typescript": "5.9.3"
}
},
"node_modules/lucide-react": {
"version": "0.454.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "proxmenux-offline-node-tests",
"private": true,
"version": "1.0.0",
"description": "Only the dependencies of the qualified tests/*.cjs CI lane",
"dependencies": {
"typescript": "5.9.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"lucide-react": "0.454.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
# GitHub users who can validate an OCI validation report by commenting
# /validated on it. A reviewer does not validate their own report.
MacRimi
Vaso73
f3rs3n
+593 -3
View File
@@ -1,11 +1,11 @@
{ {
"version": 1, "version": 1,
"repository": "MacRimi/ProxMenux", "repository": "MacRimi/ProxMenux",
"updatedAt": "2026-09-02T08:48:44.825Z", "updatedAt": "2026-10-26T04:36:49.492Z",
"backfilledAt": "2026-07-22T18:09:20.834Z", "backfilledAt": "2026-07-22T18:09:20.834Z",
"totals": { "totals": {
"downloads": 0, "downloads": 0,
"clones": 22221 "clones": 59792
}, },
"assets": {}, "assets": {},
"traffic": { "traffic": {
@@ -133,6 +133,218 @@
"2026-09-01": { "2026-09-01": {
"count": 552, "count": 552,
"uniques": 396 "uniques": 396
},
"2026-09-02": {
"count": 1065,
"uniques": 726
},
"2026-09-03": {
"count": 1489,
"uniques": 770
},
"2026-09-04": {
"count": 938,
"uniques": 642
},
"2026-09-05": {
"count": 722,
"uniques": 513
},
"2026-09-06": {
"count": 884,
"uniques": 445
},
"2026-09-07": {
"count": 655,
"uniques": 485
},
"2026-09-08": {
"count": 597,
"uniques": 440
},
"2026-09-09": {
"count": 615,
"uniques": 431
},
"2026-09-10": {
"count": 542,
"uniques": 383
},
"2026-09-11": {
"count": 646,
"uniques": 342
},
"2026-09-12": {
"count": 451,
"uniques": 336
},
"2026-09-13": {
"count": 482,
"uniques": 362
},
"2026-09-14": {
"count": 610,
"uniques": 349
},
"2026-09-15": {
"count": 452,
"uniques": 343
},
"2026-09-16": {
"count": 651,
"uniques": 335
},
"2026-09-17": {
"count": 881,
"uniques": 316
},
"2026-09-18": {
"count": 414,
"uniques": 295
},
"2026-09-19": {
"count": 701,
"uniques": 300
},
"2026-09-20": {
"count": 405,
"uniques": 271
},
"2026-09-21": {
"count": 477,
"uniques": 303
},
"2026-09-22": {
"count": 587,
"uniques": 347
},
"2026-09-23": {
"count": 436,
"uniques": 293
},
"2026-09-24": {
"count": 377,
"uniques": 276
},
"2026-09-25": {
"count": 526,
"uniques": 350
},
"2026-09-26": {
"count": 723,
"uniques": 397
},
"2026-09-27": {
"count": 994,
"uniques": 620
},
"2026-09-28": {
"count": 1548,
"uniques": 1091
},
"2026-09-29": {
"count": 1043,
"uniques": 692
},
"2026-09-30": {
"count": 768,
"uniques": 504
},
"2026-10-01": {
"count": 661,
"uniques": 443
},
"2026-10-02": {
"count": 596,
"uniques": 425
},
"2026-10-03": {
"count": 486,
"uniques": 372
},
"2026-10-04": {
"count": 1074,
"uniques": 333
},
"2026-10-05": {
"count": 1183,
"uniques": 366
},
"2026-10-06": {
"count": 648,
"uniques": 367
},
"2026-10-07": {
"count": 527,
"uniques": 381
},
"2026-10-08": {
"count": 540,
"uniques": 352
},
"2026-10-09": {
"count": 548,
"uniques": 363
},
"2026-10-10": {
"count": 443,
"uniques": 305
},
"2026-10-11": {
"count": 511,
"uniques": 305
},
"2026-10-12": {
"count": 520,
"uniques": 362
},
"2026-10-13": {
"count": 564,
"uniques": 354
},
"2026-10-14": {
"count": 540,
"uniques": 372
},
"2026-10-15": {
"count": 1253,
"uniques": 429
},
"2026-10-16": {
"count": 751,
"uniques": 342
},
"2026-10-17": {
"count": 821,
"uniques": 380
},
"2026-10-18": {
"count": 727,
"uniques": 348
},
"2026-10-19": {
"count": 712,
"uniques": 368
},
"2026-10-20": {
"count": 908,
"uniques": 464
},
"2026-10-21": {
"count": 623,
"uniques": 406
},
"2026-10-22": {
"count": 755,
"uniques": 419
},
"2026-10-23": {
"count": 601,
"uniques": 337
},
"2026-10-24": {
"count": 900,
"uniques": 316
} }
} }
}, },
@@ -3369,7 +3581,385 @@
"stars": 2926, "stars": 2926,
"forks": 152, "forks": 152,
"downloads": 0, "downloads": 0,
"clones": 22221 "clones": 23286
},
{
"date": "2026-09-03",
"stars": 2927,
"forks": 152,
"downloads": 0,
"clones": 24775
},
{
"date": "2026-09-04",
"stars": 2930,
"forks": 152,
"downloads": 0,
"clones": 25713
},
{
"date": "2026-09-05",
"stars": 2944,
"forks": 152,
"downloads": 0,
"clones": 26435
},
{
"date": "2026-09-06",
"stars": 2954,
"forks": 152,
"downloads": 0,
"clones": 27319
},
{
"date": "2026-09-07",
"stars": 2959,
"forks": 152,
"downloads": 0,
"clones": 27974
},
{
"date": "2026-09-08",
"stars": 2959,
"forks": 153,
"downloads": 0,
"clones": 28571
},
{
"date": "2026-09-09",
"stars": 2960,
"forks": 153,
"downloads": 0,
"clones": 29186
},
{
"date": "2026-09-10",
"stars": 2961,
"forks": 154,
"downloads": 0,
"clones": 29728
},
{
"date": "2026-09-11",
"stars": 2965,
"forks": 154,
"downloads": 0,
"clones": 30374
},
{
"date": "2026-09-12",
"stars": 2967,
"forks": 154,
"downloads": 0,
"clones": 30825
},
{
"date": "2026-09-13",
"stars": 2971,
"forks": 154,
"downloads": 0,
"clones": 31307
},
{
"date": "2026-09-14",
"stars": 2972,
"forks": 154,
"downloads": 0,
"clones": 31917
},
{
"date": "2026-09-15",
"stars": 2974,
"forks": 155,
"downloads": 0,
"clones": 32369
},
{
"date": "2026-09-16",
"stars": 2974,
"forks": 155,
"downloads": 0,
"clones": 33020
},
{
"date": "2026-09-17",
"stars": 2975,
"forks": 155,
"downloads": 0,
"clones": 33901
},
{
"date": "2026-09-18",
"stars": 2978,
"forks": 155,
"downloads": 0,
"clones": 34315
},
{
"date": "2026-09-19",
"stars": 2981,
"forks": 155,
"downloads": 0,
"clones": 35016
},
{
"date": "2026-09-20",
"stars": 2981,
"forks": 156,
"downloads": 0,
"clones": 35421
},
{
"date": "2026-09-21",
"stars": 2984,
"forks": 156,
"downloads": 0,
"clones": 35898
},
{
"date": "2026-09-22",
"stars": 2986,
"forks": 156,
"downloads": 0,
"clones": 36485
},
{
"date": "2026-09-23",
"stars": 2989,
"forks": 156,
"downloads": 0,
"clones": 36921
},
{
"date": "2026-09-24",
"stars": 2991,
"forks": 156,
"downloads": 0,
"clones": 37298
},
{
"date": "2026-09-25",
"stars": 2993,
"forks": 156,
"downloads": 0,
"clones": 37824
},
{
"date": "2026-09-26",
"stars": 2996,
"forks": 156,
"downloads": 0,
"clones": 38547
},
{
"date": "2026-09-27",
"stars": 3007,
"forks": 156,
"downloads": 0,
"clones": 39541
},
{
"date": "2026-09-28",
"stars": 3041,
"forks": 156,
"downloads": 0,
"clones": 41089
},
{
"date": "2026-09-29",
"stars": 3052,
"forks": 157,
"downloads": 0,
"clones": 42132
},
{
"date": "2026-09-30",
"stars": 3062,
"forks": 157,
"downloads": 0,
"clones": 42900
},
{
"date": "2026-10-01",
"stars": 3067,
"forks": 158,
"downloads": 0,
"clones": 43561
},
{
"date": "2026-10-02",
"stars": 3072,
"forks": 159,
"downloads": 0,
"clones": 44157
},
{
"date": "2026-10-03",
"stars": 3077,
"forks": 159,
"downloads": 0,
"clones": 44643
},
{
"date": "2026-10-04",
"stars": 3082,
"forks": 160,
"downloads": 0,
"clones": 45717
},
{
"date": "2026-10-05",
"stars": 3087,
"forks": 160,
"downloads": 0,
"clones": 46900
},
{
"date": "2026-10-06",
"stars": 3090,
"forks": 161,
"downloads": 0,
"clones": 47548
},
{
"date": "2026-10-07",
"stars": 3103,
"forks": 163,
"downloads": 0,
"clones": 48075
},
{
"date": "2026-10-08",
"stars": 3112,
"forks": 163,
"downloads": 0,
"clones": 48615
},
{
"date": "2026-10-09",
"stars": 3123,
"forks": 165,
"downloads": 0,
"clones": 49163
},
{
"date": "2026-10-10",
"stars": 3128,
"forks": 164,
"downloads": 0,
"clones": 49606
},
{
"date": "2026-10-11",
"stars": 3134,
"forks": 165,
"downloads": 0,
"clones": 50117
},
{
"date": "2026-10-12",
"stars": 3138,
"forks": 168,
"downloads": 0,
"clones": 50637
},
{
"date": "2026-10-13",
"stars": 3141,
"forks": 169,
"downloads": 0,
"clones": 51201
},
{
"date": "2026-10-14",
"stars": 3148,
"forks": 170,
"downloads": 0,
"clones": 51741
},
{
"date": "2026-10-15",
"stars": 3160,
"forks": 171,
"downloads": 0,
"clones": 52994
},
{
"date": "2026-10-16",
"stars": 3166,
"forks": 173,
"downloads": 0,
"clones": 53745
},
{
"date": "2026-10-17",
"stars": 3170,
"forks": 173,
"downloads": 0,
"clones": 54566
},
{
"date": "2026-10-18",
"stars": 3174,
"forks": 174,
"downloads": 0,
"clones": 55293
},
{
"date": "2026-10-19",
"stars": 3180,
"forks": 174,
"downloads": 0,
"clones": 56005
},
{
"date": "2026-10-20",
"stars": 3191,
"forks": 175,
"downloads": 0,
"clones": 56913
},
{
"date": "2026-10-21",
"stars": 3205,
"forks": 175,
"downloads": 0,
"clones": 57536
},
{
"date": "2026-10-22",
"stars": 3214,
"forks": 174,
"downloads": 0,
"clones": 58291
},
{
"date": "2026-10-23",
"stars": 3220,
"forks": 175,
"downloads": 0,
"clones": 58892
},
{
"date": "2026-10-24",
"stars": 3226,
"forks": 175,
"downloads": 0,
"clones": 59792
},
{
"date": "2026-10-25",
"stars": 3229,
"forks": 175,
"downloads": 0,
"clones": 59792
},
{
"date": "2026-10-26",
"stars": 3234,
"forks": 175,
"downloads": 0,
"clones": 59792
} }
] ]
} }
+166 -48
View File
@@ -4,10 +4,19 @@ Auto-translate missing keys in AppImage/messages/<locale>/common.json
against the English source (AppImage/messages/en/common.json). against the English source (AppImage/messages/en/common.json).
Guardrails: Guardrails:
- Keys already translated in a target locale are PRESERVED. A key is - Non-empty string translations are PRESERVED, including values equal
considered "already translated" when the target value is non-empty to English. --refresh explicitly opts into replacing valid strings.
AND differs from the English source. This protects human-curated - Arrays, objects and non-string leaves retain their JSON types. Array
locales (Vaso73's sk) from being overwritten. entries are translated individually, never as serialized Python text.
- Preflight all locales; skip locales with existing type mismatches without
provider calls or writes, including with --refresh. Valid locales proceed,
but any skipped locale makes the run exit 1 (partial failure).
The diagnostic identifies the locale
and JSON Pointer requiring manual reconciliation. No automatic migration
of serialized arrays is attempted: parseability or equal length cannot
prove correspondence to current English indices. Recover human text only
after checking its original source/order; do not delete it to force refill.
Null string leaves remain eligible for filling, as in earlier versions.
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.) - `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
are extracted before translation and restored afterwards, so the are extracted before translation and restored afterwards, so the
interpolation contract stays intact regardless of what the interpolation contract stays intact regardless of what the
@@ -16,6 +25,13 @@ Guardrails:
Vaso73 has curated by hand; auto-translation only fills the keys Vaso73 has curated by hand; auto-translation only fills the keys
that are still on the English fallback in sk. that are still on the English fallback in sk.
Workflow limitation: build-i18n-messages.yml runs Commit + push only after
successful generation. Exit 1 for skipped locales prevents that step, even
when valid locales made local progress. This does not restore automatic
publication for the default locale set until mismatches are reconciled or
workflow policy is separately changed. Invalid JSON syntax still aborts
preflight globally; per-locale skipping covers parsed catalog type mismatches.
Reuses the same translation providers as build_translation_cache.py so Reuses the same translation providers as build_translation_cache.py so
the CI environment (googletrans pinning, AppImage provider) stays the CI environment (googletrans pinning, AppImage provider) stays
identical. identical.
@@ -58,36 +74,38 @@ DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}") PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
def flatten(node: dict, prefix: str = "") -> dict[str, str]: CatalogPath = tuple[str | int, ...]
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
Non-string leaves are coerced to str (should not happen in messages
catalogs, but keeps the function total).""" def flatten(node: object, prefix: CatalogPath = ()) -> dict[CatalogPath, object]:
out: dict[str, str] = {} """Keep typed path segments and empty container markers, without coercion."""
for key, value in node.items(): if isinstance(node, dict):
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" out = {prefix: {}}
if isinstance(value, dict): children = node.items()
out.update(flatten(value, path)) elif isinstance(node, list):
elif value is None: out = {prefix: []}
out[path] = "" children = enumerate(node)
else: else:
out[path] = str(value) return {prefix: node}
for key, value in children:
out.update(flatten(value, prefix + (key,)))
return out return out
def unflatten(flat: dict[str, str]) -> dict: def unflatten(flat: dict[CatalogPath, object]) -> object:
"""Inverse of ``flatten``: rebuild nested structure from dotted keys.""" """Rebuild a tree from typed paths; parents precede children by depth."""
out: dict = {} nodes = {}
for path, value in flat.items(): for path in sorted(flat, key=len):
parts = path.split(".") value = flat[path]
cursor = out nodes[path] = {} if isinstance(value, dict) else [] if isinstance(value, list) else value
for part in parts[:-1]: if path:
existing = cursor.get(part) parent = nodes[path[:-1]]
if not isinstance(existing, dict): key = path[-1]
existing = {} if isinstance(parent, list):
cursor[part] = existing while len(parent) <= key:
cursor = existing parent.append(None)
cursor[parts[-1]] = value parent[key] = nodes[path]
return out return nodes[()]
def protect_placeholders(text: str) -> tuple[str, list[str]]: def protect_placeholders(text: str) -> tuple[str, list[str]]:
@@ -98,7 +116,9 @@ def protect_placeholders(text: str) -> tuple[str, list[str]]:
def _swap(match: re.Match) -> str: def _swap(match: re.Match) -> str:
placeholders.append(match.group(0)) placeholders.append(match.group(0))
return f"__PMX_PH_{len(placeholders) - 1}__" # No underscores: a local model splits on them and returns
# "PMX PH 0", so the placeholder never comes back.
return f"PMXPH{len(placeholders) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), placeholders return PLACEHOLDER_RE.sub(_swap, text), placeholders
@@ -109,10 +129,29 @@ def restore_placeholders(text: str, placeholders: list[str]) -> str:
fallback assignment (target = existing or EN) upstream catches fallback assignment (target = existing or EN) upstream catches
the worst case.""" the worst case."""
for i, original in enumerate(placeholders): for i, original in enumerate(placeholders):
text = text.replace(f"__PMX_PH_{i}__", original) text = text.replace(f"PMXPH{i:03d}", original)
return text return text
def translate_argos(text: str, lang: str) -> str:
"""LibreTranslate's engine, running locally.
The remote providers answer a few thousand strings and then start
refusing, and a refusal here hands back the English and stores it as
a translation. Local models have no quota and no silent failure mode,
which is what makes them the right choice for filling a catalogue by
hand before it is committed.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", lang)
def translate_one( def translate_one(
text: str, text: str,
lang: str, lang: str,
@@ -121,7 +160,7 @@ def translate_one(
timeout: int, timeout: int,
appimage_path: Path, appimage_path: Path,
) -> str: ) -> str:
"""Dispatch to the correct provider. Reuses the same three """Dispatch to the correct provider. Reuses the same
implementations as build_translation_cache.py so there is exactly implementations as build_translation_cache.py so there is exactly
one place to fix if a provider changes upstream.""" one place to fix if a provider changes upstream."""
if provider == "googletrans": if provider == "googletrans":
@@ -130,6 +169,8 @@ def translate_one(
raw = translate_google_web(text, lang, context, timeout) raw = translate_google_web(text, lang, context, timeout)
elif provider == "appimage": elif provider == "appimage":
raw = translate_appimage(text, lang, context, timeout, appimage_path) raw = translate_appimage(text, lang, context, timeout, appimage_path)
elif provider == "argos":
raw = translate_argos(text, lang)
else: else:
raise ValueError(f"Unknown provider: {provider}") raise ValueError(f"Unknown provider: {provider}")
return clean_translation(raw) or text return clean_translation(raw) or text
@@ -182,9 +223,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
"--provider", "--provider",
choices=("appimage", "googletrans", "google-web"), choices=("argos", "appimage", "googletrans", "google-web"),
default="googletrans", default="googletrans",
help="Translation provider. Default matches build_translation_cache.", help=(
"Translation provider. The default matches build_translation_cache "
"and is what CI uses; `argos` runs locally with no quota and is the "
"one to reach for when filling a catalogue before committing it."
),
) )
parser.add_argument( parser.add_argument(
"--appimage-path", "--appimage-path",
@@ -198,9 +243,16 @@ def build_arg_parser() -> argparse.ArgumentParser:
"--refresh", "--refresh",
action="store_true", action="store_true",
help=( help=(
"Re-translate EVERY key, ignoring existing translations. " "Retranslate the keys named by --refresh-keys. On its own it has "
"Dangerous: this DOES overwrite human-curated strings. " "no effect: an existing translation is never replaced."
"Use only when you know what you are doing." ),
)
parser.add_argument(
"--refresh-keys",
type=Path,
help=(
"File with one dotted message key per line. Only these may be "
"retranslated over an existing value, and only with --refresh."
), ),
) )
parser.add_argument( parser.add_argument(
@@ -240,11 +292,53 @@ def main() -> int:
print(f"Provider: {args.provider}", flush=True) print(f"Provider: {args.provider}", flush=True)
print(f"Sleep between calls: {args.sleep}s", flush=True) print(f"Sleep between calls: {args.sleep}s", flush=True)
total_failures: list[tuple[str, str, str]] = [] refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.strip()
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named keys.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
total_failures: list[tuple[str, CatalogPath, str]] = []
# Preflight every locale before any provider call or write. Serialized
# arrays have no trustworthy index correspondence, even if JSON parses.
# Never guess, evaluate Python repr, or discard human text on --refresh.
targets = {}
skipped = set()
for lang in languages: for lang in languages:
locale_path = messages_dir / lang / "common.json" locale_path = messages_dir / lang / "common.json"
target_flat = flatten(read_json(locale_path)) target_flat = flatten(read_json(locale_path))
targets[lang] = target_flat
for key, en_value in en_flat.items():
if key not in target_flat:
continue
existing = target_flat[key]
if isinstance(en_value, str) and existing is None:
continue # Historical null string leaves are treated as empty.
if type(existing) is not type(en_value):
pointer = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in key)
location = f"JSON Pointer {json.dumps(pointer)}" + (" (root)" if not key else "")
print(
f"{locale_path}: {location}: expected {type(en_value).__name__}, "
f"found {type(existing).__name__}; manual reconciliation required. "
"Locale skipped; its catalog will not be written.", file=sys.stderr,
)
skipped.add(lang)
for lang in languages:
if lang in skipped:
continue
locale_path = messages_dir / lang / "common.json"
target_flat = targets[lang]
# Decide what needs translating. Same rule as # Decide what needs translating. Same rule as
# build_translation_cache.py: only touch keys whose target # build_translation_cache.py: only touch keys whose target
@@ -261,14 +355,25 @@ def main() -> int:
# 3. Auto-fills from prior runs whose output happened to # 3. Auto-fills from prior runs whose output happened to
# match EN — leaving them alone is the intended # match EN — leaving them alone is the intended
# steady-state, not a bug. # steady-state, not a bug.
# When someone genuinely wants to redo everything, --refresh # An existing translation is never replaced. --refresh only reaches
# is still available (and is destructive by design). # the keys named in --refresh-keys, so an overwrite is always one
missing: list[str] = [] # somebody asked for by name rather than a side effect of a run.
# Seed structural nodes and non-text leaves before any checkpoint.
# Only strings are sent to a translation provider.
original_flat = target_flat.copy()
for key, en_value in en_flat.items(): for key, en_value in en_flat.items():
if not en_value: if not isinstance(en_value, str) or not en_value:
target_flat.setdefault(key, en_value)
elif key and isinstance(key[-1], int):
# Null keeps an untranslated array position addressable while
# allowing the runtime English fallback and a later retry.
target_flat.setdefault(key, None)
missing: list[CatalogPath] = []
for key, en_value in en_flat.items():
if not isinstance(en_value, str) or not en_value:
continue continue
existing = target_flat.get(key, "") existing = target_flat.get(key, "")
if args.refresh or not existing: if not existing or ".".join(str(part) for part in key) in refresh_keys:
missing.append(key) missing.append(key)
if args.limit > 0: if args.limit > 0:
@@ -276,10 +381,12 @@ def main() -> int:
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True) print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
if not missing: if not missing:
print(f" {lang}: nothing to do", flush=True) if target_flat != original_flat:
write_json(locale_path, unflatten(target_flat))
print(f" {lang}: nothing to translate", flush=True)
continue continue
failures_for_lang: list[tuple[str, str, str]] = [] failures_for_lang: list[tuple[str, CatalogPath, str]] = []
for index, key in enumerate(missing, start=1): for index, key in enumerate(missing, start=1):
en_value = en_flat[key] en_value = en_flat[key]
@@ -336,7 +443,18 @@ def main() -> int:
# Previously we returned 2, which failed the whole run and # Previously we returned 2, which failed the whole run and
# discarded 36 out of 38 successful translations because # discarded 36 out of 38 successful translations because
# 2 googletrans timeouts hit sv at the start of the burst. # 2 googletrans timeouts hit sv at the start of the burst.
return 0 if not skipped:
return 0
if skipped:
names = ', '.join(dict.fromkeys(lang for lang in languages if lang in skipped))
print(
f"\nPartial failure: skipped {len(skipped)} locale(s): {names}. "
"Valid locales processed; skipped catalogs unchanged. "
"Manual reconciliation required; exiting 1.",
file=sys.stderr, flush=True,
)
return 1
print("\ni18n messages generated successfully.", flush=True) print("\ni18n messages generated successfully.", flush=True)
return 0 return 0
+457 -24
View File
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
# provider, then restore the exact source spelling afterwards. Keep longer # provider, then restore the exact source spelling afterwards. Keep longer
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``. # terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
PROTECTED_TECHNICAL_TERMS = ( PROTECTED_TECHNICAL_TERMS = (
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
# and when a provider rightly handed it back unchanged the string was
# retried and then dropped as untranslated.
"Proxmox Backup Server",
"Proxmox Datacenter Manager",
"Proxmox Mail Gateway",
"Proxmox VE",
"ProxMenux Monitor",
"ProxMenux Optimizations",
"Secure Gateway",
"Tailscale",
"google/gasket-driver", "google/gasket-driver",
"feranick/gasket-driver", "feranick/gasket-driver",
"libedgetpu1-std", "libedgetpu1-std",
@@ -57,6 +69,16 @@ PROTECTED_TECHNICAL_TERMS = (
"pveproxy", "pveproxy",
"apt-get", "apt-get",
"Frigate", "Frigate",
# Kernel driver and subsystem names. Left to a translator, `nouveau`
# becomes the adjective it is borrowed from — "el nuevo controlador"
# in Spanish, "Jugendstil" in German — and the reader is told about a
# driver that does not exist instead of the one being blacklisted.
"nvidia-container-toolkit",
"nvidia-smi",
"initramfs",
"nouveau",
"vfio-pci",
"modprobe",
"Docker", "Docker",
"Coral", "Coral",
"gasket", "gasket",
@@ -69,6 +91,49 @@ PROTECTED_TECHNICAL_TERMS = (
"ZFS", "ZFS",
"SSH", "SSH",
"fork", "fork",
# Vendor, API and acceleration names. A label like "NVIDIA (NVDEC/CUDA)"
# is a product name end to end: every provider hands it back as it came,
# and without these entries that correct answer is read as a failure and
# the string is dropped from the catalogue.
"VA-API",
"NVIDIA",
"NVDEC",
"NVENC",
"WebUI",
"Intel",
"CUDA",
"ROCm",
"Vulkan",
"SYCL",
"QSV",
"OpenVINO",
"OpenCL",
"KFD",
"GPU",
"CPU",
"AMD",
# Proxmox's own words for a container and a virtual machine. Read as
# initials they get reordered — "CT" comes back as "TC" in Spanish —
# and the reader is told about something Proxmox does not call that.
"CT",
"VM",
# Open Container Initiative. Read as a word it becomes "BEC" in French,
# which named nothing, across thirty-one strings of the OCI section.
"OCI manager Apps",
# The file an application is installed from. Its name is the one of the
# file on disk; translated, it names something the menu does not offer.
"oci-compose",
"OCI",
"beta",
# Debian release names. "Bookworm" is a bird and a reader of books, and
# came back as "el libro de Debian"; the others are equally ordinary
# words in the languages that borrowed them.
"Bookworm",
"Trixie",
"Bullseye",
"Buster",
"Noble",
"Jammy",
) )
TECHNICAL_TERM_RE = re.compile( TECHNICAL_TERM_RE = re.compile(
"|".join( "|".join(
@@ -81,26 +146,159 @@ TRANSLATE_CALL_RE = re.compile(
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""", r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
re.VERBOSE | re.DOTALL, re.VERBOSE | re.DOTALL,
) )
# Same shape the Monitor's generator uses, so a message reads identically
# whichever of the two catalogues it comes from.
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
# Literals a reader copies and runs. Translated as words they stop working:
# /dev/apex_0 came back as "/dev/apex 0", --auto-uninstall as "desinstalación
# automática", and "pct config 110" as "configuration PCT 110". A glossary can
# only hold what someone listed; these are recognised by shape.
STRUCTURAL_LITERAL_RE = re.compile(
r"""
/(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-]
| (?<![\w-])--[A-Za-z][\w-]*
# Angle-bracket placeholders a reader substitutes: <CTID> came back as
# "■CTID confiar", with the closing bracket translated as a verb.
| <[A-Za-z][\w-]*>
# Shell operators. "&&" became the HTML entity "&apos;" in Spanish, and
# the command it joined stopped being one command; a lone pipe came back
# as the word "Silencio".
| &&|\|\||(?<!\|)\|(?!\|)
# Environment variable names. THELOUNGE_HOME lost its underscore and
# became two words, so the command no longer set anything.
| \b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b
| \b[\w.-]+\.(?:sh|py|json|yml|yaml|conf|service|timer|func|cfg|list|log)\b
# Reverse-DNS identifiers: org.opencontainers.image.version came back as
# "org.open containers.image.version" — a label name with a space in it.
| \b[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,}\b
# A command plus a real subcommand. An allowlist, not "any word": with
# a wildcard, "pvesm command not found" and "apt-get exited" were read as
# invocations, and the strings a contributor had already translated by
# hand were discarded as damaged.
| \b(?:pct|qm)\s+(?:config|start|stop|shutdown|reboot|destroy|create|clone
|enter|exec|push|pull|set|list|status|snapshot|rollback|resize|migrate
|template|unlock|mount|unmount|df|fsck|restore|suspend|resume|monitor)\b
| \bpvesm\s+(?:path|status|list|alloc|free|add|remove|set|scan|export|import)\b
| \b(?:pveam|pveum|pvecm)\s+(?:available|download|list|update|remove|add|create
|delete|status|nodes|keygen|expected|modify|set|useradd|userdel|passwd)\b
| \bapt-get\s+(?:install|update|upgrade|remove|purge|autoremove|dist-upgrade|clean)\b
| \bdpkg\s+(?:-l|-i|-r|-P|--configure|--get-selections)\b
| \b(?:systemctl|journalctl)\s+(?:start|stop|restart|reload|enable|disable|status
|daemon-reload|mask|unmask|is-active|is-enabled|list-units|-u|-f|-b)\b
| \bzpool\s+(?:status|list|import|export|scrub|trim|get|set|history)\b
| \bsmartctl\s+(?:-a|-A|-H|-i|-t|-l|-x|--all|--health|--info)\b
| \b(?:modprobe|blkid|lsblk|mkfs)\s+(?:-a|-r|-n|-o|-f|-t|\.[a-z0-9]+)\b
""",
re.VERBOSE,
)
# Providers that answer the same thing every time for the same input, so a
# second attempt cannot produce a different result. `appimage` shells out to a
# binary that may reach a network service, so it is not on the list.
DETERMINISTIC_PROVIDERS = frozenset({"argos"})
def protect_catalog_titles(directories) -> None:
"""Add every application name in the catalog to the protected glossary.
They are product names, and a translator treats them as words: "HAOS One"
comes back as "HAOS Man". Protecting them costs nothing and the failure it
prevents reaches the reader as an application that does not exist.
"""
global TECHNICAL_TERM_RE
titles: set[str] = set()
for directory in directories:
for path in sorted(Path(directory).rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
title = ((data.get("catalog_ui") or {}).get("title") or {}).get("en_US")
if isinstance(title, str) and title.strip():
titles.add(title.strip())
if not titles:
return
terms = tuple(sorted(set(PROTECTED_TECHNICAL_TERMS) | titles, key=len, reverse=True))
# Same word boundaries as the module-level pattern. Without them a short
# term matches inside longer words, and every application title in the
# catalogue joins the glossary here.
TECHNICAL_TERM_RE = re.compile(
"|".join(
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
for term in terms
),
re.IGNORECASE,
)
print(f"Protected application names: {len(titles)}", flush=True)
def protect_placeholders(text: str) -> tuple[str, list[str]]:
"""Replace each ``{name}`` with a token before translation.
A provider reads the name inside the braces as a word and translates
it: ``{count}`` comes back as ``{conta}`` in Portuguese and ``{počet}``
in Slovak. The consumer then finds no placeholder to substitute and
falls back to English, so the translation was paid for and never used.
The token carries no underscores because argos splits on them.
"""
found: list[str] = []
def _swap(match: re.Match[str]) -> str:
found.append(match.group(0))
return f"PMXPH{len(found) - 1:03d}"
return PLACEHOLDER_RE.sub(_swap, text), found
def restore_placeholders(text: str, found: list[str]) -> str:
"""Put the original ``{name}`` back where each token landed."""
for index, original in enumerate(found):
text = text.replace(f"PMXPH{index:03d}", original)
return text
def protect_technical_terms(text: str) -> tuple[str, list[str]]: def protect_technical_terms(text: str) -> tuple[str, list[str]]:
"""Replace glossary terms with stable tokens before translation.""" """Replace glossary terms with stable tokens before translation.
Structural literals go first. A glossary can only list what someone
thought of, and what reaches the reader as a broken command is never on
that list: an absolute path, a long option, a filename. They are
recognised by shape instead, which also covers the next one added.
"""
protected: list[str] = [] protected: list[str] = []
def _swap(match: re.Match[str]) -> str: def _swap(match: re.Match[str]) -> str:
protected.append(match.group(0)) protected.append(match.group(0))
return f"__PMX_TERM_{len(protected) - 1}__" return f"PMXTERM{len(protected) - 1:03d}"
text = STRUCTURAL_LITERAL_RE.sub(_swap, text)
return TECHNICAL_TERM_RE.sub(_swap, text), protected return TECHNICAL_TERM_RE.sub(_swap, text), protected
def restore_technical_terms(text: str, protected: list[str]) -> str: def restore_technical_terms(text: str, protected: list[str]) -> str:
"""Restore glossary terms exactly as they appeared in the source.""" """Restore glossary terms exactly as they appeared in the source."""
for index, original in enumerate(protected): for index, original in enumerate(protected):
text = text.replace(f"__PMX_TERM_{index}__", original) text = text.replace(f"PMXTERM{index:03d}", original)
return text return text
# A provider can alter a token instead of carrying it through: argos returned
# MPXTERM000 for PMXTERM000, the replace found nothing, and the token shipped
# to the reader — "MPXTERM000 configuré" is in the French catalogue now.
SENTINEL_RESIDUE_RE = re.compile(r"[MP][MPX]X?\s?(?:TERM|PH)\s?\d{2,4}", re.IGNORECASE)
def assert_no_sentinel_residue(text: str) -> None:
"""Fail the translation when a protection token did not survive intact.
Leaving the key out is recoverable: the next run tries again. Storing a
string with a token in it is not, because nothing looks at a value that
already exists.
"""
found = SENTINEL_RESIDUE_RE.search(text)
if found:
raise RuntimeError(f"the provider altered a protection token: {found.group(0)!r}")
def iter_script_files( def iter_script_files(
scripts_dir: Path, extra_files: Iterable[Path] = () scripts_dir: Path, extra_files: Iterable[Path] = ()
) -> Iterable[Path]: ) -> Iterable[Path]:
@@ -151,6 +349,93 @@ def extract_translate_texts(
return sorted(found) return sorted(found)
PYTHON_TRANSLATE_CALLS = {"translate", "N_"}
CATALOG_TEXT_KEYS = {"prompt", "enable_prompt", "path_prompt", "size_prompt", "label", "warning"}
CATALOG_TEXT_LISTS = {"stack_completion_notes", "completion_notes"}
def extract_python_texts(directories: Iterable[Path]) -> list[str]:
"""translate("...") and N_("...") calls with a literal argument. The parser
joins implicitly concatenated literals, so wrapped strings are found whole."""
found: dict[str, None] = {}
for directory in directories:
for path in sorted(directory.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and getattr(node.func, "id", None) in PYTHON_TRANSLATE_CALLS
and node.args and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
text = node.args[0].value.strip()
if text:
found.setdefault(text, None)
return sorted(found)
def extract_catalog_texts(directories: Iterable[Path]) -> list[str]:
"""User-visible text stored in the OCI catalog JSON: prompts, labels,
warnings, completion notes, category labels and descriptive usernames."""
found: dict[str, None] = {}
def add(value: object) -> None:
if isinstance(value, str) and value.strip():
found.setdefault(value.strip(), None)
def walk(value: object, key: str = "") -> None:
if isinstance(value, dict):
for child_key, child in value.items():
if child_key in CATALOG_TEXT_KEYS:
add(child)
elif child_key in CATALOG_TEXT_LISTS and isinstance(child, list):
for item in child:
add(item)
elif child_key == "username" and isinstance(child, str) and " " in child:
add(child)
elif child_key == "catalog_ui" and isinstance(child, dict):
# What the application detail screen shows: the tagline,
# and the description only where there is no tagline. The
# catalog stores the source English; the translation lives
# in the language cache with every other string.
tagline = (child.get("tagline") or {}).get("en_US")
add(tagline or (child.get("description") or {}).get("en_US"))
elif child_key == "labels" and key == "" and isinstance(child, dict):
for item in child.values():
add(item)
walk(child, child_key)
elif isinstance(value, list):
for item in value:
walk(item, key)
for directory in directories:
for path in sorted(directory.rglob("*.json")):
try:
walk(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError):
continue
return sorted(found)
def translate_argos(text: str, dest_lang: str) -> str:
"""LibreTranslate's engine, running locally.
A public endpoint answers a few thousand strings and then starts
refusing — and the library wrapper around it returns the English
unchanged rather than raising, which writes the source text into the
catalogue as if it were a translation. Local models have no quota and
no silent failure mode.
"""
try:
import argostranslate.translate as argos # type: ignore
except Exception as exc:
raise RuntimeError(
"argostranslate is not installed. Install argostranslate and the "
"en->target packages, or run with another provider."
) from exc
return argos.translate(text, "en", dest_lang)
def translate_googletrans(text: str, dest_lang: str, context: str) -> str: def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
try: try:
from googletrans import Translator # type: ignore from googletrans import Translator # type: ignore
@@ -269,6 +554,20 @@ def clean_translation(value: str) -> str:
return value.strip() return value.strip()
# Lowercase-or-digit, then the mark, then an uppercase letter: that is a
# sentence boundary and not `8.0`, `storage.cfg` or `proxmox.com/docs`.
_SENTENCE_RUN = re.compile(
"(?<=[a-z0-9\u00e0-\u00ff\u0107\u010d\u011b\u013e\u0148\u0159\u0161\u0165\u016f\u017a\u017e])"
"([.?!])"
"(?=[A-Z\u00c0-\u00de\u0106\u010c\u011a\u013d\u0147\u0158\u0160\u0164\u016e\u0179\u017d])"
)
def restore_sentence_spacing(text: str) -> str:
"""Every provider drops the blank between sentences it translated apart."""
return _SENTENCE_RUN.sub(r"\1 ", text)
def translate_text( def translate_text(
text: str, text: str,
dest_lang: str, dest_lang: str,
@@ -277,8 +576,11 @@ def translate_text(
timeout: int, timeout: int,
appimage_path: Path, appimage_path: Path,
) -> str: ) -> str:
protected_text, protected_terms = protect_technical_terms(text) protected_text, placeholders = protect_placeholders(text)
if provider == "googletrans": protected_text, protected_terms = protect_technical_terms(protected_text)
if provider == "argos":
translated = translate_argos(protected_text, dest_lang)
elif provider == "googletrans":
translated = translate_googletrans(protected_text, dest_lang, context) translated = translate_googletrans(protected_text, dest_lang, context)
elif provider == "google-web": elif provider == "google-web":
translated = translate_google_web(protected_text, dest_lang, context, timeout) translated = translate_google_web(protected_text, dest_lang, context, timeout)
@@ -289,7 +591,9 @@ def translate_text(
else: else:
raise ValueError(f"Unknown provider: {provider}") raise ValueError(f"Unknown provider: {provider}")
translated = restore_technical_terms(clean_translation(translated), protected_terms) translated = restore_technical_terms(clean_translation(translated), protected_terms)
return translated or text translated = restore_placeholders(translated, placeholders)
assert_no_sentinel_residue(translated)
return restore_sentence_spacing(translated) if translated else text
def load_language_cache(path: Path) -> dict[str, str]: def load_language_cache(path: Path) -> dict[str, str]:
@@ -304,6 +608,35 @@ def load_language_cache(path: Path) -> dict[str, str]:
return {str(text): str(value) for text, value in data.items()} return {str(text): str(value) for text, value in data.items()}
def is_fully_protected(source: str) -> bool:
"""Whether the string is protected material and punctuation, nothing else.
"Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API
names from end to end, and "apt-get exited" is a command with a word
the command itself supplies. Coming back unchanged is the right answer
for all of them, so the guard below must not read it as a silent
failure and throw the result away. Both kinds of protection count:
the glossary catches the names, the structural pattern the rest.
"""
remainder = STRUCTURAL_LITERAL_RE.sub(" ", source)
return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder))
def looks_untranslated(source: str, result: str) -> bool:
"""Whether a provider handed back the text it was given.
A single technical word legitimately survives translation — Docker, GPU,
LXC — but a sentence coming back byte-identical means the provider failed
without saying so. Accepting it writes English into the catalogue, where
it counts as translated and is never looked at again.
"""
if source.strip() != result.strip():
return False
if is_fully_protected(source):
return False
return len([word for word in re.findall(r"[A-Za-z]{2,}", source)]) >= 3
def write_language_cache(path: Path, cache: dict[str, str]) -> None: def write_language_cache(path: Path, cache: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path = path.with_suffix(path.suffix + ".tmp")
@@ -319,6 +652,30 @@ def build_arg_parser() -> argparse.ArgumentParser:
description="Extract translate calls from scripts/ and build json/cache.json." description="Extract translate calls from scripts/ and build json/cache.json."
) )
parser.add_argument("--scripts-dir", default="scripts", type=Path) parser.add_argument("--scripts-dir", default="scripts", type=Path)
parser.add_argument(
"--extra-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Extra directory scanned for translate calls in .sh files. Repeatable.",
)
parser.add_argument(
"--python-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory scanned for translate()/N_() calls in .py files. Repeatable.",
)
parser.add_argument(
"--catalog-dir",
action="append",
default=[],
type=Path,
metavar="PATH",
help="Directory of OCI catalog JSON files with user-visible text. Repeatable.",
)
parser.add_argument( parser.add_argument(
"--extra-file", "--extra-file",
action="append", action="append",
@@ -351,7 +708,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
"--provider", "--provider",
choices=("appimage", "googletrans", "google-web"), choices=("argos", "appimage", "googletrans", "google-web"),
default="appimage", default="appimage",
help="Translation provider to use. Default: appimage", help="Translation provider to use. Default: appimage",
) )
@@ -364,10 +721,21 @@ def build_arg_parser() -> argparse.ArgumentParser:
parser.add_argument("--context", default=DEFAULT_CONTEXT) parser.add_argument("--context", default=DEFAULT_CONTEXT)
parser.add_argument("--timeout", default=30, type=int) parser.add_argument("--timeout", default=30, type=int)
parser.add_argument("--sleep", default=0.15, type=float) parser.add_argument("--sleep", default=0.15, type=float)
parser.add_argument("--retries", default=4, type=int,
help="Attempts per string before giving up on it.")
parser.add_argument("--retry-wait", default=15, type=float,
help="Seconds before the first retry; it doubles each time.")
parser.add_argument( parser.add_argument(
"--refresh", "--refresh",
action="store_true", action="store_true",
help="Translate all entries again instead of reusing existing cache values.", help="Retranslate the strings named by --refresh-keys. On its own it has "
"no effect: an existing translation is never replaced.",
)
parser.add_argument(
"--refresh-keys",
type=Path,
help="File with one source string per line. Only these may be "
"retranslated over an existing value, and only with --refresh.",
) )
parser.add_argument( parser.add_argument(
"--extract-only", "--extract-only",
@@ -406,17 +774,45 @@ def main() -> int:
return 1 return 1
texts = extract_translate_texts(scripts_dir, args.extra_file) texts = extract_translate_texts(scripts_dir, args.extra_file)
for directory in args.extra_dir:
if directory.is_dir():
texts += extract_translate_texts(directory.resolve())
texts += extract_python_texts(d.resolve() for d in args.python_dir if d.is_dir())
catalog_dirs = [d.resolve() for d in args.catalog_dir if d.is_dir()]
protect_catalog_titles(catalog_dirs)
texts += extract_catalog_texts(catalog_dirs)
texts = sorted(dict.fromkeys(text for text in texts if "$" not in text and "`" not in text))
if args.limit > 0: if args.limit > 0:
texts = texts[: args.limit] texts = texts[: args.limit]
existing_by_lang = { existing_by_lang = {
lang: load_language_cache(output_dir / f"{lang}.json") lang: load_language_cache(output_dir / f"{lang}.json")
for lang in languages for lang in languages
} }
next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages} # Seeded with what is already translated so a periodic save — or an
# interrupted run — writes a superset of the file it replaces, never a
# truncated one.
next_by_lang: dict[str, dict[str, str]] = {
lang: dict(existing_by_lang.get(lang, {})) for lang in languages
}
print(f"Found {len(texts)} unique translate strings.", flush=True) print(f"Found {len(texts)} unique translate strings.", flush=True)
print(f"Output directory: {output_dir}", flush=True) print(f"Output directory: {output_dir}", flush=True)
print(f"Languages: {', '.join(languages)}", flush=True) print(f"Languages: {', '.join(languages)}", flush=True)
refresh_keys: set[str] = set()
if args.refresh_keys is not None:
if not args.refresh:
print("--refresh-keys needs --refresh.", file=sys.stderr)
return 1
refresh_keys = {
line.rstrip("\n")
for line in args.refresh_keys.read_text(encoding="utf-8").splitlines()
if line.strip()
}
print(f"Refreshing {len(refresh_keys)} named strings.", flush=True)
elif args.refresh:
print("--refresh without --refresh-keys: existing translations are kept.",
flush=True)
failures: list[tuple[str, str, str]] = [] failures: list[tuple[str, str, str]] = []
total = len(texts) * len(languages) total = len(texts) * len(languages)
done = 0 done = 0
@@ -427,7 +823,13 @@ def main() -> int:
for index, text in enumerate(texts, start=1): for index, text in enumerate(texts, start=1):
done += 1 done += 1
if not args.refresh and existing.get(text): # A translation that is already there is never replaced. Most of
# this catalogue is hand-written by the people who speak the
# language, and a machine pass over it trades their wording for
# something worse without anyone seeing the swap. Only a string
# named in --refresh-keys may be overwritten, so every overwrite
# is one somebody asked for by name.
if existing.get(text) and text not in refresh_keys:
next_by_lang[lang][text] = existing[text] next_by_lang[lang][text] = existing[text]
continue continue
if args.extract_only: if args.extract_only:
@@ -435,20 +837,51 @@ def main() -> int:
continue continue
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True) print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
try: # A rate limit is a "come back later", not an answer. Retrying with
next_by_lang[lang][text] = translate_text( # a growing wait recovers it; giving up on the first one is what
text, # left thousands of strings untranslated.
lang, value, last_error = None, None
args.provider, for attempt in range(1, args.retries + 1):
args.context, unchanged = False
args.timeout, try:
args.appimage_path, value = translate_text(
) text,
print(f" => {next_by_lang[lang][text][:100]}", flush=True) lang,
except Exception as exc: args.provider,
next_by_lang[lang][text] = existing.get(text, text) args.context,
failures.append((text, lang, str(exc))) args.timeout,
print(f" failed: {exc}", file=sys.stderr, flush=True) args.appimage_path,
)
if looks_untranslated(text, value):
value = None
unchanged = True
raise RuntimeError("the provider returned the source text unchanged")
break
except Exception as exc:
last_error = exc
# Unchanged text from a remote provider is how a rate limit
# shows up, so it is worth waiting for. A local engine is
# deterministic: asking again returns the same string, and
# the backoff only buys minutes of sleeping per phrase.
if unchanged and args.provider in DETERMINISTIC_PROVIDERS:
break
if attempt < args.retries:
wait = args.retry_wait * (2 ** (attempt - 1))
print(f" retry {attempt}/{args.retries - 1} in {wait}s: {exc}",
file=sys.stderr, flush=True)
time.sleep(wait)
if value is not None:
next_by_lang[lang][text] = value
print(f" => {value[:100]}", flush=True)
else:
# The key is left out on purpose. Writing the English here
# would count as a translation on the next run and the string
# would never be translated again.
previous = existing.get(text)
if previous:
next_by_lang[lang][text] = previous
failures.append((text, lang, str(last_error)))
print(f" failed: {last_error}", file=sys.stderr, flush=True)
if args.save_every > 0 and index % args.save_every == 0: if args.save_every > 0 and index % args.save_every == 0:
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang]) write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
time.sleep(args.sleep) time.sleep(args.sleep)
+146 -29
View File
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from build_translation_cache import ( # noqa: E402 from build_translation_cache import ( # noqa: E402
clean_translation, clean_translation,
translate_appimage, translate_appimage,
translate_argos,
translate_google_web, translate_google_web,
translate_googletrans, translate_googletrans,
) )
@@ -72,6 +73,24 @@ TECHNICAL_TERMS = (
"DKMS", "DKMS",
"SSH", "SSH",
"API", "API",
"Flask",
"SQLite",
"Prometheus",
"Grafana",
"Uptime Kuma",
"PWA",
"Fail2Ban",
"Tailscale",
"JavaScript",
"TypeScript",
"Next.js",
"Coral",
"rclone",
"journald",
"gasket",
"apex",
"Alpine",
"OpenVINO",
) )
PROTECTED_PATTERNS = ( PROTECTED_PATTERNS = (
@@ -86,9 +105,14 @@ PROTECTED_PATTERNS = (
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"), re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"), re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"), re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
# Identifiers and encoded delimiters remain literal even outside <code>.
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
) )
LITERAL_TAG_RE = re.compile( LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>", r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
@@ -100,18 +124,30 @@ TERM_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>") TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
# Every tag as written, attributes and self-closing slash included.
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
# Set from --provider: a local model rewrites bracketed sentinels, a remote
# one drops bare ones beside inline markup. See protect_text.
BARE_SENTINELS = False
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}") PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = { NON_TRANSLATABLE_KEYS = {
"accent",
"categoryslug",
"command", "command",
"code", "code",
"href", "href",
"icon", "icon",
"iconbg",
"id", "id",
"menucmd",
"path", "path",
"route", "route",
"slug", "slug",
"src", "src",
"url", "url",
"color",
"tone",
"variant",
} }
SOURCE_STATE_VERSION = 1 SOURCE_STATE_VERSION = 1
@@ -179,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
if not source.strip() or not re.search(r"[A-Za-z]", source): if not source.strip() or not re.search(r"[A-Za-z]", source):
return True return True
last = str(path[-1]).lower() if path else "" last = str(path[-1]).lower() if path else ""
# These fields describe the surrounding snippet; they are prose, even
# though their names end in "Code".
if last in {"aftercode", "beforecode"}:
return False
# This UI breadcrumb is prose, rather than a filesystem path.
if last == "uipath" and "<strong>" in source:
return False
if last == "flow" and source in {"loader", "dedicated"}:
return True
# These exact values drive rendering/navigation; similarly named visible
# labels remain translatable.
if last == "branch" and source in {"main", "develop", "feature/*"}:
return True
if last == "kind" and source in {"internal", "external"}:
return True
if last == "key" and source in {"linux", "nas", "windows"}:
return True
if last in NON_TRANSLATABLE_KEYS or any( if last in NON_TRANSLATABLE_KEYS or any(
last.endswith(suffix) last.endswith(suffix)
for suffix in ("url", "href", "path", "command", "code", "icon") for suffix in ("url", "href", "path", "command", "code", "icon")
): ):
return True return True
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source): if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
return True
# Standalone literal blocks contain examples, not prose. Separators between
# several blocks do not make their command/configuration contents translatable.
without_literals = LITERAL_TAG_RE.sub("", source)
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
return True return True
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source): if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
return True return True
@@ -195,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
return json.dumps(path, ensure_ascii=False, separators=(",", ":")) return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
def literal_values_match(source: Any, target: Any) -> bool:
"""Literal commands and rendering metadata must follow the source too."""
return all(
not should_copy(leaf.source, leaf.path)
or get_at_path(target, leaf.path) == leaf.source
for leaf in iter_leaves(source)
)
def source_fingerprints(source: Any) -> dict[str, str]: def source_fingerprints(source: Any) -> dict[str, str]:
return { return {
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20] leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
@@ -206,13 +273,13 @@ def needs_translation(
source: str, source: str,
target: Any, target: Any,
path: tuple[str | int, ...], path: tuple[str | int, ...],
refresh: bool,
forced_tokens: set[str] | None = None, forced_tokens: set[str] | None = None,
) -> bool: ) -> bool:
if should_copy(source, path): if should_copy(source, path):
return False return False
if refresh: # An existing translation is never replaced. Only a leaf whose token was
return True # named on the command line may be retranslated over a real value, so an
# overwrite is always one somebody asked for rather than a side effect.
if forced_tokens and leaf_token(path) in forced_tokens: if forced_tokens and leaf_token(path) in forced_tokens:
return True return True
return not isinstance(target, str) or not target.strip() or target == source return not isinstance(target, str) or not target.strip() or target == source
@@ -233,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
def replace(match: re.Match[str]) -> str: def replace(match: re.Match[str]) -> str:
original = match.group(1) original = match.group(1)
if original.lower() in {"code", "kbd", "pre"}: if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
return match.group(0) return match.group(0)
key = original.lower() key = original.lower()
internal = names.get(key) internal = names.get(key)
@@ -279,6 +346,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
for match in TERM_RE.finditer(text) for match in TERM_RE.finditer(text)
if not overlaps_literal(match.start(), match.end()) if not overlaps_literal(match.start(), match.end())
) )
if BARE_SENTINELS:
# A remote provider carries inline tags through untouched, so they are
# only validated afterwards. A local model rewrites them — dropping a
# </code>, merging two <em> — and the whole file is then discarded for
# a broken contract. Protecting them costs the translator the tag as
# context and saves the paragraph.
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
# A local model also rewrites what a literal block holds: it drops the
# underscores of an identifier, translates a subcommand and turns a
# pipe into punctuation. The whole block is one opaque token instead.
candidates.extend(literal_ranges)
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0]))) candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
selected: list[tuple[int, int]] = [] selected: list[tuple[int, int]] = []
@@ -296,7 +374,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
# sit directly beside inline markup (for example an opening <em> # sit directly beside inline markup (for example an opening <em>
# token followed by translated prose). Triple brackets remain opaque # token followed by translated prose). Triple brackets remain opaque
# in that position and preserve the complete rich-text contract. # in that position and preserve the complete rich-text contract.
token = f"[[[PMXDOC{index:04d}]]]" #
# A local model treats the brackets as punctuation instead, and
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
# survives every provider tried, so it is what a local run uses.
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
chunks.append(text[cursor:start]) chunks.append(text[cursor:start])
chunks.append(token) chunks.append(token)
mapping[token] = text[start:end] mapping[token] = text[start:end]
@@ -306,24 +389,39 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
def restore_text(text: str, mapping: dict[str, str]) -> str: def restore_text(text: str, mapping: dict[str, str]) -> str:
missing = [token for token in mapping if token not in text] # A local model may append a digit to a bare sentinel. A substring match
# would silently restore the prefix and leak the extra digit into prose.
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
if missing: if missing:
raise ValueError(f"translation provider changed protected token {missing[0]}") raise ValueError(f"translation provider changed protected token {missing[0]}")
for token, original in mapping.items(): for token, original in mapping.items():
text = text.replace(token, original) text = patterns[token].sub(lambda match: original, text)
return text return text
def validate_contract(source: str, target: str) -> None: def validate_contract(source: str, target: str) -> None:
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)): if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
raise ValueError("rich-text tag contract changed") raise ValueError("rich-text tag contract changed")
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
raise ValueError("markup changed")
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
if literal(source) != literal(target):
raise ValueError("literal code changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)): if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed") raise ValueError("placeholder contract changed")
# Bare numeric protection markers once leaked a trailing zero just
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
if len(residue.findall(target)) > len(residue.findall(source)):
raise ValueError("unexpected numeric residue next to literal markup")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]: def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
def translate(text: str, language: str) -> str: def translate(text: str, language: str) -> str:
if args.provider == "googletrans": if args.provider == "argos":
raw = translate_argos(text, language)
elif args.provider == "googletrans":
raw = translate_googletrans(text, language, args.context) raw = translate_googletrans(text, language, args.context)
elif args.provider == "google-web": elif args.provider == "google-web":
raw = translate_google_web(text, language, args.context, args.timeout) raw = translate_google_web(text, language, args.context, args.timeout)
@@ -446,7 +544,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic
def pending_leaves( def pending_leaves(
source: Any, source: Any,
target: Any, target: Any,
refresh: bool,
forced_tokens: set[str] | None = None, forced_tokens: set[str] | None = None,
) -> list[Leaf]: ) -> list[Leaf]:
return [ return [
@@ -456,7 +553,6 @@ def pending_leaves(
leaf.source, leaf.source,
get_at_path(target, leaf.path), get_at_path(target, leaf.path),
leaf.path, leaf.path,
refresh,
forced_tokens, forced_tokens,
) )
] ]
@@ -533,13 +629,14 @@ def translate_file(
memory: dict[str, str], memory: dict[str, str],
translate: Callable[[str, str], str], translate: Callable[[str, str], str],
args: argparse.Namespace, args: argparse.Namespace,
) -> tuple[Any | None, list[str], int]: ) -> tuple[Any | None, list[str], int, set[str]]:
resolved: dict[tuple[str | int, ...], str] = {} resolved: dict[tuple[str | int, ...], str] = {}
failures: list[str] = [] failures: list[str] = []
failed_tokens: set[str] = set()
jobs: dict[str, list[tuple[str | int, ...]]] = {} jobs: dict[str, list[tuple[str | int, ...]]] = {}
for leaf in leaves: for leaf in leaves:
if not args.refresh and leaf.source in memory: if leaf.source in memory:
resolved[leaf.path] = memory[leaf.source] resolved[leaf.path] = memory[leaf.source]
continue continue
jobs.setdefault(leaf.source, []).append(leaf.path) jobs.setdefault(leaf.source, []).append(leaf.path)
@@ -566,12 +663,17 @@ def translate_file(
resolved[path] = translated resolved[path] = translated
except Exception as exc: except Exception as exc:
failures.append(f"{text[:90]}: {exc}") failures.append(f"{text[:90]}: {exc}")
failed_tokens.update(leaf_token(path) for path in jobs[text])
if args.sleep: if args.sleep:
time.sleep(args.sleep) time.sleep(args.sleep)
if failures: # What did translate is kept. merge_tree leaves a string it has no
return None, failures, len(jobs) # translation for as whatever the locale already held, or as the English
return merge_tree(source, target, resolved), [], len(jobs) # source, so a file is written with the strings that worked rather than
# discarded whole: a dense paragraph that a provider cannot carry its
# protected tokens through used to take the other hundred and seventy
# strings of its file with it.
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
@@ -586,8 +688,12 @@ def build_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
"--provider", "--provider",
choices=("google-web", "googletrans", "appimage"), choices=("argos", "google-web", "googletrans", "appimage"),
default="googletrans", default="googletrans",
help=(
"Translation provider. The default is what CI uses; `argos` runs "
"locally with no quota, which is what a corpus this size needs."
),
) )
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage")) parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
parser.add_argument( parser.add_argument(
@@ -623,7 +729,9 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int: def main() -> int:
global BARE_SENTINELS
args = build_parser().parse_args() args = build_parser().parse_args()
BARE_SENTINELS = args.provider == "argos"
if args.workers < 1 or args.retries < 0 or args.max_files < 0: if args.workers < 1 or args.retries < 0 or args.max_files < 0:
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr) print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
return 2 return 2
@@ -646,7 +754,8 @@ def main() -> int:
print("No target languages selected.", file=sys.stderr) print("No target languages selected.", file=sys.stderr)
return 2 return 2
if args.refresh: if args.refresh:
print("WARNING: --refresh overwrites existing translations in the selected scope.") print("--refresh no longer overwrites existing translations; "
"name the leaves with --tokens instead.")
translate = provider_function(args) translate = provider_function(args)
state = load_source_state(source_state_path) state = load_source_state(source_state_path)
@@ -716,7 +825,7 @@ def main() -> int:
pending_tokens.update(changed_tokens) pending_tokens.update(changed_tokens)
pending_tokens.update( pending_tokens.update(
leaf_token(leaf.path) leaf_token(leaf.path)
for leaf in pending_leaves(source, target, args.refresh) for leaf in pending_leaves(source, target)
) )
set_state_pending_tokens(state, language, relative, pending_tokens) set_state_pending_tokens(state, language, relative, pending_tokens)
@@ -742,6 +851,11 @@ def main() -> int:
total_failures = 0 total_failures = 0
total_written = 0 total_written = 0
# The provider decides the sentinel format, so a run that cannot be
# attributed to one cannot be diagnosed afterwards: a night of failures
# reading "translation provider changed protected token" left no way to
# tell whether the provider was the one the tokens were shaped for.
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
print(f"English files: {len(files)} | locales: {', '.join(languages)}") print(f"English files: {len(files)} | locales: {', '.join(languages)}")
for language in languages: for language in languages:
@@ -773,10 +887,13 @@ def main() -> int:
source = file_info[relative_key][1] source = file_info[relative_key][1]
target = read_json(target_path) target = read_json(target_path)
queued_tokens = state_pending_tokens(state, language, relative_key) queued_tokens = state_pending_tokens(state, language, relative_key)
leaves = pending_leaves(source, target, args.refresh, queued_tokens) leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source)) total_strings += len(iter_leaves(source))
missing_strings += len(leaves) missing_strings += len(leaves)
schema_changed = not schema_matches(source, target) schema_changed = (
not schema_matches(source, target)
or not literal_values_match(source, target)
)
if leaves or schema_changed: if leaves or schema_changed:
pending.append( pending.append(
( (
@@ -816,7 +933,7 @@ def main() -> int:
f"({len(leaves)} strings{suffix})", f"({len(leaves)} strings{suffix})",
flush=True, flush=True,
) )
built, failures, calls = translate_file( built, failures, calls, failed_tokens = translate_file(
source, source,
target, target,
leaves, leaves,
@@ -827,18 +944,18 @@ def main() -> int:
) )
if failures: if failures:
total_failures += len(failures) total_failures += len(failures)
set_state_pending_tokens(state, language, relative_key, queued_tokens)
write_json(source_state_path, state)
print( print(
f" skipped atomically after {len(failures)} failures " f" {len(failures)} strings left untranslated ({calls} calls)",
f"({calls} calls)",
file=sys.stderr, file=sys.stderr,
) )
for failure in failures[:5]: for failure in failures[:5]:
print(f" - {failure}", file=sys.stderr) print(f" - {failure}", file=sys.stderr)
continue
write_json(target_path, built) write_json(target_path, built)
set_state_pending_tokens(state, language, relative_key, set()) # The strings that failed stay pending, so the next run comes back
# for them and leaves the ones that translated alone.
set_state_pending_tokens(state, language, relative_key, failed_tokens)
if failures:
write_json(source_state_path, state)
write_json(source_state_path, state) write_json(source_state_path, state)
total_written += 1 total_written += 1
print(f" wrote {target_path} ({calls} provider calls)", flush=True) print(f" wrote {target_path} ({calls} provider calls)", flush=True)
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Check active terminal translations offline, without generating any text.
Validates formatting fields and literal commands/identifiers. Missing values
can retain English fallback; --require-complete makes them errors. This does
not establish the accuracy or fluency of the translated prose.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from build_translation_cache import (
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
extract_python_texts, extract_translate_texts,
)
from build_web_docs_i18n import read_json, validate_contract
def active_sources(root: Path) -> list[str]:
# Match build-translation-cache.yml, including launchers outside scripts/.
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
texts = extract_translate_texts(root / 'scripts', extras)
texts += extract_translate_texts(root / 'oci/remote')
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
texts += extract_catalog_texts([root / 'oci/catalog'])
return sorted({text for text in texts if '$' not in text and '`' not in text})
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
validate_contract(source, target)
for match in STRUCTURAL_LITERAL_RE.finditer(source):
literal = match.group()
# A page title may replace its decorative separator with a dash.
# In command examples, a pipe remains a shell operator.
if title_separator and literal == '|':
continue
# DNS hostnames are case-insensitive. Paths, field names and command
# names are not, so do not case-fold those to hide a broken example.
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
raise ValueError(f'literal command or identifier changed: {literal}')
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=Path('.'))
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
parser.add_argument('--require-complete', action='store_true')
args = parser.parse_args()
root = args.root.resolve()
texts = active_sources(root)
errors = 0
for language in args.languages.split(','):
if language not in DEFAULT_LANGUAGES:
parser.error(f'Unsupported locale: {language}')
catalog = read_json(root / 'lang' / f'{language}.json') or {}
missing = 0
for source in texts:
target = catalog.get(source)
if not isinstance(target, str) or not target.strip():
missing += 1
continue
try:
validate_message(source, target)
except ValueError as error:
print(f'[{language}] {source!r}: {error}')
errors += 1
print(f'[{language}] active={len(texts)} missing={missing}')
if args.require_complete and missing:
errors += 1
return 1 if errors else 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Check documentation translation structure and literal contracts offline.
Missing locales/leaves are allowed by default because the website falls back
to English. Use --require-complete when validating a catalog completion PR.
This checks data integrity, not translation fluency or linguistic accuracy.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from build_web_docs_i18n import (
DEFAULT_LANGUAGES,
get_at_path,
iter_leaves,
literal_values_match,
read_json,
schema_matches,
source_files,
)
from check_terminal_catalogs import validate_message
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
parser.add_argument("--require-complete", action="store_true")
args = parser.parse_args()
root = args.messages_dir.resolve()
files = source_files(root / "en", ".")
errors = 0
for language in args.languages.split(","):
language = language.strip()
if language not in DEFAULT_LANGUAGES:
parser.error(f"Unsupported locale: {language}")
missing = checked = present_files = 0
for file in files:
relative = file.relative_to(root / "en")
source = read_json(file)
target = read_json(root / language / relative)
if target is not None:
present_files += 1
if args.require_complete and not schema_matches(source, target):
print(f"[{language}] {relative}: source/target schema differs")
errors += 1
for leaf in iter_leaves(source):
localized = get_at_path(target, leaf.path)
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
missing += 1
continue
try:
validate_message(
leaf.source, localized,
title_separator=bool(
leaf.path and leaf.path[0] == "meta"
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
),
)
except ValueError as exc:
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
errors += 1
checked += 1
if args.require_complete and not literal_values_match(source, target):
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
errors += 1
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
if args.require_complete and missing:
errors += 1
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,7 +2,8 @@
"""Build a verified ProxMenux LXC application-version tracking catalog. """Build a verified ProxMenux LXC application-version tracking catalog.
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE, The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
downloaded through the GitHub REST API. Only ct/*.sh launchers are considered. plus community-scripts/core for its shared version helpers after the split.
Both are downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
An operational hint is emitted only when two independent pieces of the helper An operational hint is emitted only when two independent pieces of the helper
scripts agree: scripts agree:
@@ -37,6 +38,7 @@ from typing import Any, Iterable
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE" DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
DEFAULT_CORE_REPOSITORY = "community-scripts/core"
DEFAULT_REF = "main" DEFAULT_REF = "main"
API_VERSION = "2022-11-28" API_VERSION = "2022-11-28"
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0" USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
@@ -169,6 +171,7 @@ def obtain_snapshot(
repository: str, repository: str,
ref: str, ref: str,
cache_dir: Path, cache_dir: Path,
required_paths: tuple[str, ...] = ("ct",),
) -> tuple[Path, str, str]: ) -> tuple[Path, str, str]:
commit = client.json(f"/repos/{repository}/commits/{ref}") commit = client.json(f"/repos/{repository}/commits/{ref}")
commit_sha = str(commit.get("sha") or "") commit_sha = str(commit.get("sha") or "")
@@ -177,7 +180,7 @@ def obtain_snapshot(
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
marker = snapshot_dir / ".snapshot-complete" marker = snapshot_dir / ".snapshot-complete"
if marker.exists() and (snapshot_dir / "ct").is_dir(): if marker.exists() and all((snapshot_dir / path).exists() for path in required_paths):
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip() return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
payload = client.request( payload = client.request(
@@ -190,6 +193,8 @@ def obtain_snapshot(
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent)) temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
try: try:
safe_extract_tar(payload, temp_dir) safe_extract_tar(payload, temp_dir)
if not all((temp_dir / path).exists() for path in required_paths):
raise CatalogError(f"Invalid source snapshot for {repository}@{commit_sha}")
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8") (temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
if snapshot_dir.exists(): if snapshot_dir.exists():
shutil.rmtree(snapshot_dir) shutil.rmtree(snapshot_dir)
@@ -521,23 +526,66 @@ def repo_from_helper(item: dict[str, Any]) -> str:
return match.group(1) if match else "" return match.group(1) if match else ""
def shared_helper_path(source: Path, core_source: Path | None = None) -> Path:
"""Locate source text only; never source or execute upstream shell code."""
candidates = [source / "misc" / "tools.func"]
if core_source is not None:
candidates.extend(core_source / path for path in ("lib/forge.func", "lib/tools.func"))
for candidate in candidates:
if candidate.is_file():
return candidate
raise CatalogError(
"Shared version helpers not found. Older trees use misc/tools.func; "
"current trees require community-scripts/core (--core-source-dir for a local checkout)."
)
def shell_function(text: str, name: str) -> str:
match = re.search(
rf"(?ms)^(?:function\s+)?{re.escape(name)}\s*\(\)\s*\{{[^\n]*\n(.*?)^\}}",
text,
)
return match.group(1) if match else ""
def verify_shared_helper(path: Path) -> None:
"""Check both ends of the cache contract, including the new forge wrappers."""
text = read_text(path)
read_body = shell_function(text, "check_for_gh_release")
write_body = shell_function(text, "fetch_and_deploy_gh_release")
if re.search(r'\bcheck_for_release\s+github\s+"\$1"\s+"\$2"', read_body):
read_body = shell_function(text, "check_for_release")
if re.search(r'\bfetch_and_deploy_release\s+github\s+"\$1"\s+"\$2"', write_body):
write_body = shell_function(text, "fetch_and_deploy_release")
normalization = r'app_lc="\$\(echo "\$\{app,,\}" \| tr -d [\'\"] [\'\"]\)"'
valid = (
re.search(normalization, read_body)
and re.search(normalization, write_body)
and re.search(r'(?:local\s+)?current_file="\$HOME/\.\$\{app_lc\}"', read_body)
and re.search(r'(?:local\s+)?version_file="\$HOME/\.\$\{app_lc\}"', write_body)
and re.search(r'echo "\$version" >"\$version_file"', write_body)
and re.search(r'\$current_file', read_body)
)
if not valid:
raise CatalogError(f"Could not verify the shared GitHub release cache contract in {path}")
def build_catalog( def build_catalog(
source: Path, source: Path,
helpers: dict[str, dict[str, Any]], helpers: dict[str, dict[str, Any]],
*,
core_source: Path | None = None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
catalog: dict[str, Any] = {} catalog: dict[str, Any] = {}
v2_apps: dict[str, Any] = {} v2_apps: dict[str, Any] = {}
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
launchers = sorted((source / "ct").glob("*.sh")) launchers = sorted((source / "ct").glob("*.sh"))
tools_func = source / "misc" / "tools.func" tools_func = shared_helper_path(source, core_source)
tools_text = read_text(tools_func) verify_shared_helper(tools_func)
shared_helper_verified = bool( helper_contract = (
re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text) "misc/tools.func" if tools_func == source / "misc" / "tools.func"
and re.search(r'echo "\$version" >"\$version_file"', tools_text) else f"core/{tools_func.relative_to(core_source).as_posix()}"
and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text)
) )
if not shared_helper_verified:
raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func")
for launcher_path in launchers: for launcher_path in launchers:
slug = launcher_path.stem slug = launcher_path.stem
@@ -696,7 +744,7 @@ def build_catalog(
"evidence": { "evidence": {
"install": f"install/{installer_path.name}: matching deploy helper", "install": f"install/{installer_path.name}: matching deploy helper",
"update": f"ct/{launcher_path.name}: matching release check", "update": f"ct/{launcher_path.name}: matching release check",
"contract": "misc/tools.func: shared version-cache contract", "contract": f"{helper_contract}: shared version-cache contract",
}, },
} }
) )
@@ -712,7 +760,7 @@ def build_catalog(
"evidence": { "evidence": {
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})", "install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})", "update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
"contract": "misc/tools.func writes and reads /root/.<normalized-app>", "contract": f"{helper_contract} writes and reads /root/.<normalized-app>",
}, },
} }
) )
@@ -874,7 +922,7 @@ def build_catalog(
"status_counts": status_counts, "status_counts": status_counts,
"method_counts": method_counts, "method_counts": method_counts,
"reason_counts": reason_counts, "reason_counts": reason_counts,
"shared_release_cache_contract_verified": shared_helper_verified, "shared_release_cache_contract_verified": True,
"helper_upstream_tag_validation": tag_validation, "helper_upstream_tag_validation": tag_validation,
}, },
"records": records, "records": records,
@@ -1307,6 +1355,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", default=DEFAULT_REPOSITORY) parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
parser.add_argument("--ref", default=DEFAULT_REF) parser.add_argument("--ref", default=DEFAULT_REF)
parser.add_argument("--core-repository", default=DEFAULT_CORE_REPOSITORY)
parser.add_argument("--core-ref", default=DEFAULT_REF)
parser.add_argument(
"--core-source-dir", type=Path,
help="Local community-scripts/core checkout for split source trees (no download)",
)
parser.add_argument( parser.add_argument(
"--source-dir", "--source-dir",
type=Path, type=Path,
@@ -1346,6 +1400,7 @@ def main(argv: list[str] | None = None) -> int:
client = GitHubClient() client = GitHubClient()
commit_sha = "local-source" commit_sha = "local-source"
archive_sha = "not-applicable" archive_sha = "not-applicable"
core_provenance = None
try: try:
if args.source_dir: if args.source_dir:
source = args.source_dir.resolve() source = args.source_dir.resolve()
@@ -1355,10 +1410,27 @@ def main(argv: list[str] | None = None) -> int:
source, commit_sha, archive_sha = obtain_snapshot( source, commit_sha, archive_sha = obtain_snapshot(
client, args.repository, args.ref, args.cache_dir.expanduser().resolve() client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
) )
if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file(): if not (source / "ct").is_dir() or not (source / "install").is_dir():
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}") raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
core_source = None
if not (source / "misc" / "tools.func").is_file():
if args.core_source_dir:
core_source = args.core_source_dir.resolve()
core_commit, core_archive = "local-source", "not-applicable"
elif args.source_dir:
raise CatalogError("Split local source tree: provide --core-source-dir for its shared helpers")
else:
core_source, core_commit, core_archive = obtain_snapshot(
client, args.core_repository, args.core_ref,
args.cache_dir.expanduser().resolve(), required_paths=("lib/forge.func",),
)
core_provenance = {
"repository": args.core_repository, "ref": args.core_ref,
"commit_sha": core_commit, "archive_sha256": core_archive,
"source_dir": str(core_source),
}
helpers = load_helper_catalog(args.helpers_cache) helpers = load_helper_catalog(args.helpers_cache)
catalog, audit, v2 = build_catalog(source, helpers) catalog, audit, v2 = build_catalog(source, helpers, core_source=core_source)
audit["demoted_helper_markers"] = ( audit["demoted_helper_markers"] = (
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit) [] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
) )
@@ -1376,6 +1448,7 @@ def main(argv: list[str] | None = None) -> int:
"ref": args.ref, "ref": args.ref,
"commit_sha": commit_sha, "commit_sha": commit_sha,
"archive_sha256": archive_sha, "archive_sha256": archive_sha,
"core": core_provenance,
"source_dir": str(source), "source_dir": str(source),
"generated_at_unix": int(time.time()), "generated_at_unix": int(time.time()),
"generator_sha256": sha256_file(Path(__file__).resolve()), "generator_sha256": sha256_file(Path(__file__).resolve()),
@@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]:
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if not isinstance(data, list): if not isinstance(data, list):
raise RuntimeError("GitHub API no devolvió una lista.") raise RuntimeError("The GitHub API did not return a list.")
return data return data
@@ -67,7 +67,7 @@ def main() -> int:
try: try:
directory = fetch_directory_json(API_URL) directory = fetch_directory_json(API_URL)
except Exception as e: except Exception as e:
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr) print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr)
return 1 return 1
cache: list[dict] = [] cache: list[dict] = []
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""OCI validation record: renders oci/VALIDATION.md from verification.json,
checks the record, checks who a pull request credits, and records a reviewed
validation report."""
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
VERIFICATION = ROOT / "oci/catalog/verification.json"
INDEX = ROOT / "oci/catalog/index.json"
OUTPUT = ROOT / "oci/VALIDATION.md"
REVIEWERS = ROOT / ".github/oci-validation-reviewers"
REPO = "https://github.com/MacRimi/ProxMenux"
BOARD = "https://github.com/users/MacRimi/projects/1"
FORM = f"{REPO}/issues/new?template=oci-validation.yml"
DISCUSSION = f"{REPO}/discussions/440"
MAINTAINERS = {"macrimi"}
LAB = "laboratory-validated"
USER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$")
REPORT_RE = re.compile(r"^https://github\.com/MacRimi/ProxMenux/"
r"(issues/[0-9]+|discussions/[0-9]+(#discussioncomment-[0-9]+)?)$")
ENTRY_KEYS = {"status", "community_tested"}
COMMUNITY_KEYS = {"by", "date", "report", "digest", "scenario"}
DIGEST_RE = re.compile(r"^[a-f0-9]{8,64}$")
# Form fields that describe the tested scenario, in the order they are read.
SCENARIO_FIELDS = ("Install mode", "Data storage", "Network", "GPU")
NO_RESPONSE = "_No response_"
def load(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def dump(data):
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def applications(index):
return {item["id"]: item for item in index.get("applications", [])}
def lab_validated(entry, item):
# Same rule as the catalog: an application that cannot be installed is not verified.
return entry.get("status") == LAB and bool(item.get("automatic_install_candidate"))
def community(entry):
tested = entry.get("community_tested")
return tested if isinstance(tested, dict) and tested.get("by") else None
def cell(text):
return str(text).replace("|", "\\|").strip()
def render(verification, index):
apps = applications(index)
entries = {app: entry for app, entry in verification.get("applications", {}).items()
if app in apps and isinstance(entry, dict)}
lab = sorted((app for app, entry in entries.items() if lab_validated(entry, apps[app])),
key=lambda app: str(apps[app].get("title") or app).casefold())
tested = sorted((app for app, entry in entries.items() if community(entry)),
key=lambda app: str(apps[app].get("title") or app).casefold())
visible = sum(1 for item in apps.values() if not item.get("hidden"))
verified = len(set(lab) | set(tested))
lines = [
"# OCI application validation",
"",
"<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->",
"",
f"{verified} of {visible} applications in the OCI catalog have been tested for real: "
f"{len(lab)} in the ProxMenux lab and {len(tested)} by the community. "
"The OCI installer shows them as verified, with a ✓ in the lists.",
"",
"Each test describes the scenario that was run and names the image it ran on. It does not cover every possible "
"configuration of the application, and a newer image has not been tested until someone reports it.",
"",
"## Taking part",
"",
"Any application that is not listed here is open for testing.",
"",
f"1. Check the [ProxMenux Roadmap]({BOARD}) to see which applications someone is already testing. "
"With access to the board, create a card for the application and move it to In progress while you test it.",
f"2. Test the application with the OCI manager and fill in the [OCI validation report]({FORM}). "
"The report records the image and the exact scenario: install mode, storage, network and GPU.",
"3. A reviewer reads the report and comments `/validated` on it. The application is then added to this list "
"with your GitHub user and shown as verified in the OCI installer, and the report is closed.",
"",
"Reviewers are listed in [.github/oci-validation-reviewers](../.github/oci-validation-reviewers), and a "
"reviewer does not validate their own report. A validation can also be added with a pull request that adds "
"the entry to `oci/catalog/verification.json` and regenerates this file with "
"`python3 .github/scripts/oci_validation.py render`; CI checks that the entry credits the author of the pull request.",
"",
"An application that cannot be validated for a reason outside the tester's hands — hardware nobody has, something "
"only Docker provides, a fix still pending in ProxMenux — moves to Blocked on the board, with a line saying why "
"and what would make it worth trying again.",
"",
f"Questions and ideas about OCI testing go in [discussion #440]({DISCUSSION}).",
"",
"## Tested by the community",
"",
]
if tested:
lines += ["| Application | Tested by | Date | Image | Scenario | Report |", "|---|---|---|---|---|---|"]
for app in tested:
entry = community(entries[app])
report = f"[Report]({entry['report']})" if entry.get("report") else "—"
image = f"`{entry['digest'][:12]}`" if entry.get("digest") else "—"
lines.append(f"| {cell(apps[app].get('title') or app)} | [@{entry['by']}](https://github.com/{entry['by']}) "
f"| {entry.get('date') or '—'} | {image} | {cell(entry.get('scenario') or '—')} | {report} |")
else:
lines.append("No application has been tested by the community yet.")
lines += ["", "## Tested in the ProxMenux lab", ""]
if lab:
lines += ["| Application | Category |", "|---|---|"]
for app in lab:
lines.append(f"| {cell(apps[app].get('title') or app)} | {cell(apps[app].get('category_label') or '—')} |")
else:
lines.append("No application has been tested in the ProxMenux lab yet.")
return "\n".join(lines) + "\n"
def problems(verification, index, rendered=None, today=None):
today = today or datetime.date.today()
apps = applications(index)
found = []
entries = verification.get("applications") if isinstance(verification, dict) else None
if not isinstance(entries, dict):
return ["verification.json must hold an \"applications\" object"]
for app, entry in entries.items():
where = f"verification.json: {app}"
if app not in apps:
found.append(f"{where}: not an application of the OCI catalog")
if not isinstance(entry, dict) or not entry:
found.append(f"{where}: the entry must be a non-empty object")
continue
if set(entry) - ENTRY_KEYS:
found.append(f"{where}: unknown keys {sorted(set(entry) - ENTRY_KEYS)}")
if "status" in entry and entry["status"] != LAB:
found.append(f"{where}: status can only be \"{LAB}\"")
if "community_tested" not in entry:
continue
tested = entry["community_tested"]
if not isinstance(tested, dict):
found.append(f"{where}: community_tested must be an object")
continue
if set(tested) - COMMUNITY_KEYS:
found.append(f"{where}: unknown community_tested keys {sorted(set(tested) - COMMUNITY_KEYS)}")
if not USER_RE.fullmatch(str(tested.get("by", ""))):
found.append(f"{where}: \"by\" must be a GitHub user name")
try:
date = datetime.date.fromisoformat(str(tested.get("date", "")))
if date > today + datetime.timedelta(days=1):
found.append(f"{where}: the date is in the future")
except ValueError:
found.append(f"{where}: \"date\" must be YYYY-MM-DD")
if "report" in tested and not REPORT_RE.fullmatch(str(tested["report"])):
found.append(f"{where}: \"report\" must link to an issue or discussion of MacRimi/ProxMenux")
if "digest" in tested and not DIGEST_RE.fullmatch(str(tested["digest"])):
found.append(f"{where}: \"digest\" must be 8 to 64 lowercase hex characters of the image digest")
if "scenario" in tested and (not isinstance(tested["scenario"], str) or len(tested["scenario"]) > 300):
found.append(f"{where}: \"scenario\" must be text of at most 300 characters")
if rendered is not None and rendered != render(verification, index):
found.append("oci/VALIDATION.md is out of date: run python3 .github/scripts/oci_validation.py render")
return found
def author_problems(base, head, author):
"""A pull request only adds or changes community tests credited to its author."""
if author.casefold() in MAINTAINERS:
return []
found = []
before = base.get("applications", {}) if isinstance(base, dict) else {}
after = head.get("applications", {})
for app in sorted(set(before) | set(after)):
old = before.get(app) if isinstance(before.get(app), dict) else {}
new = after.get(app) if isinstance(after.get(app), dict) else {}
if old.get("status") != new.get("status"):
found.append(f"{app}: the ProxMenux lab status is changed by the maintainer only")
if old.get("community_tested") == new.get("community_tested"):
continue
credited = community(new)
if not credited:
found.append(f"{app}: a community test is removed by the maintainer only")
elif str(credited["by"]).casefold() != author.casefold():
found.append(f"{app}: credits @{credited['by']}, but the pull request is from @{author}")
return found
def parse_form(body):
"""Issue form answers, keyed by the field label."""
fields, label, lines = {}, None, []
for line in (body or "").replace("\r\n", "\n").split("\n"):
if line.startswith("### "):
if label is not None:
fields[label] = "\n".join(lines).strip()
label, lines = line[4:].strip(), []
elif label is not None:
lines.append(line)
if label is not None:
fields[label] = "\n".join(lines).strip()
return {key: ("" if value == NO_RESPONSE else value) for key, value in fields.items()}
def resolve(name, index):
wanted = " ".join(name.split()).casefold()
matches = [item["id"] for item in index.get("applications", [])
if wanted in (str(item["id"]).casefold(), " ".join(str(item.get("title") or "").split()).casefold())]
return matches[0] if len(matches) == 1 else None
def image_digest(text):
"""The digest as the Monitor shows it (b1f339cf) or in full (sha256:b1f3...)."""
value = str(text or "").strip().lower()
value = value.split("·")[-1].strip()
value = value[7:] if value.startswith("sha256:") else value
return value if DIGEST_RE.fullmatch(value) else None
def scenario(fields):
parts = [fields.get(name, "") for name in SCENARIO_FIELDS]
if fields.get("Proxmox VE version"):
parts.append(f"Proxmox VE {fields['Proxmox VE version']}")
if fields.get("Image version"):
parts.append(f"image {fields['Image version']}")
text = " · ".join(part.strip() for part in parts if part and part.strip())
if fields.get("Result") == "Works with problems":
text = f"Works with problems: {text}"
return text[:300]
def reviewers(text):
"""User names of the reviewer list, one per line; # starts a comment."""
names = {line.split("#", 1)[0].strip() for line in text.splitlines()}
return {name.casefold() for name in names if USER_RE.fullmatch(name)}
def record(event, verification, index, today, allowed=frozenset()):
"""Returns (verification, app id, message); raises ValueError with the reason to refuse.
A label can only be added by someone with write access to the repository.
A /validated comment is accepted from the reviewer list, never from the
report's own author unless it is the maintainer."""
issue = event.get("issue") or {}
labels = {label.get("name") for label in issue.get("labels", [])}
if "oci-validation" not in labels:
raise ValueError("This issue is not an OCI validation report.")
author = str((issue.get("user") or {}).get("login", ""))
report = str(issue.get("html_url", ""))
if not USER_RE.fullmatch(author) or not REPORT_RE.fullmatch(report):
raise ValueError("The report author or link could not be read.")
reviewer = str(((event.get("comment") or {}).get("user") or {}).get("login", ""))
if event.get("comment") is not None:
if reviewer.casefold() not in allowed:
raise ValueError(f"@{reviewer} is not in the list of OCI validation reviewers "
"(.github/oci-validation-reviewers), so the report is not recorded.")
if reviewer.casefold() == author.casefold() and reviewer.casefold() not in MAINTAINERS:
raise ValueError("A report is validated by a reviewer other than its author.")
fields = parse_form(issue.get("body"))
name = fields.get("Application", "")
app = resolve(name, index)
if app is None:
raise ValueError(f"\"{name}\" does not match one application of the OCI catalog. "
"Edit the Application field with its name or ID as shown in the catalog, then add the label again.")
if fields.get("Result") == "Does not work":
raise ValueError("The report says the application does not work, so it is not recorded as verified.")
digest = image_digest(fields.get("Image digest"))
if digest is None:
raise ValueError("The Image digest field does not hold an image digest. Edit it with the digest shown in the "
"App tab of the Monitor, for example b1f339cf, then add the label again.")
data = json.loads(json.dumps(verification))
entries = data.setdefault("applications", {})
entry = entries.setdefault(app, {})
previous = community(entry)
entry["community_tested"] = {"by": author, "date": today.isoformat(), "report": report,
"digest": digest, "scenario": scenario(fields)}
data["applications"] = dict(sorted(entries.items()))
title = applications(index)[app].get("title") or app
validated = f" Validated by @{reviewer}." if reviewer else ""
message = (f"Thank you, @{author}! {title} is now recorded as verified.{validated} "
f"It shows with a ✓ in the OCI installer and is listed in [oci/VALIDATION.md]({REPO}/blob/develop/oci/VALIDATION.md).")
if previous and previous.get("by") != author:
message += f" This report replaces the previous one from @{previous['by']}."
return data, app, message
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("render", help="Write oci/VALIDATION.md")
commands.add_parser("check", help="Check verification.json and that oci/VALIDATION.md is current")
authors = commands.add_parser("authors", help="Check who a pull request credits")
authors.add_argument("--base", required=True, help="verification.json of the base branch; may be missing")
authors.add_argument("--author", required=True)
rec = commands.add_parser("record", help="Record a reviewed validation report")
rec.add_argument("--event", required=True)
rec.add_argument("--message", required=True, help="File that receives the reply for the issue")
args = parser.parse_args(argv)
verification, index = load(VERIFICATION), load(INDEX)
if args.command == "render":
OUTPUT.write_text(render(verification, index), encoding="utf-8")
return 0
if args.command == "check":
rendered = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else ""
found = problems(verification, index, rendered)
elif args.command == "authors":
base = load(args.base) if Path(args.base).exists() and Path(args.base).stat().st_size else {}
found = author_problems(base, verification, args.author)
else:
try:
allowed = reviewers(REVIEWERS.read_text(encoding="utf-8")) if REVIEWERS.exists() else frozenset()
data, app, message = record(load(args.event), verification, index, datetime.date.today(), allowed)
except ValueError as error:
Path(args.message).write_text(str(error) + "\n", encoding="utf-8")
return 2
VERIFICATION.write_text(dump(data), encoding="utf-8")
OUTPUT.write_text(render(data, index), encoding="utf-8")
Path(args.message).write_text(message + "\n", encoding="utf-8")
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"app={app}\n")
return 0
for problem in found:
print(f"::error::{problem}")
return 1 if found else 0
if __name__ == "__main__":
sys.exit(main())
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Run only the independently qualified, host-independent fixtures.
No filesystem discovery widens this manifest. Run from the repository root;
Python and Node have separate CI jobs so either failure blocks the check.
"""
import argparse
import os
from pathlib import Path
import subprocess
import sys
import tempfile
PYTHON_FILES = (
"tests/test_host_update_jobs.py",
"tests/test_host_update_transport.py",
"tests/test_host_update_shell.py",
"tests/test_host_update_auth.py",
"tests/test_kernel_reboot.py",
"tests/test_reboot_health_refresh.py",
"tests/test_host_update_controls.py",
"tests/test_host_update_packaging.py",
"tests/test_host_update_version.py",
"tests/test_lxc_update_jobs.py",
"tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py",
"AppImage/scripts/tests/test_pbs_i18n.py",
"AppImage/scripts/tests/test_monitor_links_i18n.py",
"AppImage/scripts/tests/test_monitor_links.py",
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
"AppImage/scripts/tests/test_local_app_icons.py",
"AppImage/scripts/tests/test_docker_app_discovery.py",
"AppImage/scripts/tests/test_native_app_discovery.py",
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
"tests/test_audit_policy.py",
"tests/test_audit_report.py",
"tests/test_audit_catalog.py",
"tests/storage/test_nvme_status_message.py",
"tests/test_fastfetch_config_generation.py",
"tests/test_notification_active_payloads.py",
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
)
NODE_FILES = (
"tests/test_host_update_ui.cjs",
"tests/test_reboot_health_refresh.cjs",
"tests/test_host_update_controls.cjs",
"tests/test_reboot_recovery.cjs",
"tests/test_proxmenux_version_row.cjs",
"tests/test_lxc_update_jobs.cjs",
"tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs",
"tests/test_optimization_labels.cjs",
"tests/test_optimization_source.cjs",
"tests/lxc_updates/test_app_editor_errors.cjs",
"tests/test_oci_update_cancellation.cjs",
"tests/test_host_backup_messages.cjs",
"tests/test_pbs_i18n.cjs",
"tests/test_monitor_links_i18n.cjs",
"tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
"tests/test_audit_policy.cjs",
"tests/test_audit_presentation.cjs",
"tests/test_audit_safety_wording.cjs",
"tests/test_audit_summary.cjs",
"tests/test_backup_archives_empty.cjs",
"tests/test_backup_destination_messages.cjs",
"tests/test_borg_ssh_guidance.cjs",
"tests/test_storage_messages.cjs",
"tests/test_health_text.cjs",
"tests/test_overview_chart_axis_gutters.cjs",
)
# unittest's CLI accepts a missing pattern as a successful zero-test run.
# Load the exact file in a fresh interpreter and reject zero tests and skips.
PYTHON_RUN = """import sys, unittest
folder, filename = sys.argv[1:]
suite = unittest.TestLoader().discover(start_dir=folder, pattern=filename)
count = suite.countTestCases()
if count == 0:
raise SystemExit('No tests discovered: ' + folder + '/' + filename)
print('DISCOVERED=' + str(count), flush=True)
result = unittest.TextTestRunner(verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() and result.testsRun == count and not result.skipped else 1)
"""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lane", required=True, choices=("python", "node"))
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
files = PYTHON_FILES if args.lane == "python" else NODE_FILES
if not files or Path.cwd().resolve() != root:
parser.error("nonempty manifest and repository-root working directory required")
for path in files:
if not (root / path).is_file():
print(f"Missing qualified test: {path}", file=sys.stderr)
return 1
if args.lane == "node" and not (root / "AppImage/node_modules/typescript").is_dir():
print("Install the locked offline-node dependencies first", file=sys.stderr)
return 1
for path in files:
print(f"RUN {path}", flush=True)
if args.lane == "python":
folder, filename = str(Path(path).parent), Path(path).name
command = [sys.executable, "-I", "-B", "-c", PYTHON_RUN, folder, filename]
with tempfile.TemporaryDirectory(prefix="qualified-offline-") as scratch:
environment = os.environ.copy()
environment["TMPDIR"] = scratch
result = subprocess.run(command, cwd=root, env=environment, check=False)
else:
command = ["node", path]
result = subprocess.run(command, cwd=root, env=os.environ.copy(), check=False)
if result.returncode != 0:
print(f"FAIL {path}: exit {result.returncode}", file=sys.stderr)
return 1
print(f"PASS {path}", flush=True)
print(f"PASS {args.lane}: {len(files)} qualified files", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,558 @@
{
"pin": "dede876f0b92f8a571325e96ed7591b30a85368e",
"fallback_indices": [
9,
10,
19,
20,
21,
22,
31,
32,
33,
34,
43,
44,
45,
58,
59
],
"locales": {
"es": [
"Listar todos los nodos Proxmox",
"Listar VM en un nodo",
"Listar contenedores LXC en un nodo",
"Listar almacenamiento en un nodo",
"Listar interfaces de red",
"Listar todas las máquinas virtuales QEMU/KVM",
"Iniciar una máquina virtual",
"Detener una máquina virtual",
"Apagar una máquina virtual de forma segura",
"Mostrar estado de VM",
"Mostrar configuración de VM",
"Crear un snapshot de VM",
"Listar todos los contenedores LXC",
"Iniciar un contenedor LXC",
"Detener un contenedor LXC",
"Abrir una consola de contenedor LXC",
"Mostrar configuración del contenedor",
"Mostrar estado de almacenamiento",
"Enumerar el contenido del almacenamiento",
"Probar el rendimiento del sistema Proxmox",
"Mostrar la versión de Proxmox VE",
"Verificar el estado del clúster",
"Mostrar el estado del clúster",
"Enumerar los nodos del clúster",
"Mostrar el estado del grupo ZFS",
"Enumerar todos los grupos ZFS",
"Enumerar todo Conjuntos de datos ZFS",
"Enumerar todos los archivos con detalles",
"Cambiar directorio",
"Crear un directorio",
"Eliminar un directorio de forma recursiva",
"Copiar archivos o directorios",
"Mover o cambiar el nombre de archivos",
"Mostrar el contenido del archivo",
"Buscar un patrón en un archivo",
"Buscar archivos por nombre",
"Cambiar permisos de archivos",
"Cambiar el propietario del archivo",
"Extraer un archivo tar.gz",
"Crear un tar.gz archivo",
"Mostrar uso del disco",
"Mostrar tamaños de directorio",
"Mostrar uso de memoria",
"Mostrar procesos en ejecución",
"Buscar un proceso en ejecución",
"Forzar la eliminación de un proceso",
"Verificar el estado del servicio",
"Iniciar un servicio",
"Detener un servicio",
"Reiniciar un servicio",
"Actualizar paquetes de Debian/Ubuntu",
"Instalar un paquete de Debian/Ubuntu",
"Eliminar un paquete",
"Listar contenedores en ejecución",
"Listar Docker imágenes",
"Abrir un shell en un contenedor",
"Mostrar direcciones IP",
"Probar conectividad de red",
"Obtener encabezados HTTP",
"Descargar un archivo",
"Conectarse a través de SSH",
"Copiar un archivo a través de SSH",
"Seguir un archivo de registro en tiempo real",
"Mostrar historial de comandos",
"Borrar la pantalla del terminal"
],
"de": [
"Alle Proxmox-Knoten auflisten",
"VMs auf einem Knoten auflisten",
"LXC-Container auf einem Knoten auflisten",
"Speicher auf einem Knoten auflisten",
"Netzwerkschnittstellen auflisten",
"Alle virtuellen QEMU/KVM-Maschinen auflisten",
"Eine virtuelle Maschine starten",
"Eine virtuelle Maschine stoppen",
"Eine virtuelle Maschine sicher herunterfahren",
"VM-Status anzeigen",
"VM-Konfiguration anzeigen",
"Einen VM-Snapshot erstellen",
"Alle LXC-Container auflisten",
"Einen LXC-Container starten",
"Einen LXC-Container stoppen",
"Eine LXC-Containerkonsole öffnen",
"Containerkonfiguration anzeigen",
"Speicherstatus anzeigen",
"Speicherinhalte auflisten",
"Proxmox-Systemleistung testen",
"Proxmox VE-Version anzeigen",
"Clusterstatus prüfen",
"Clusterstatus anzeigen",
"Clusterknoten auflisten",
"ZFS-Poolstatus anzeigen",
"Alle ZFS-Pools auflisten",
"Alle ZFS-Datensätze auflisten",
"Alle Dateien mit Details auflisten",
"Verzeichnis ändern",
"Verzeichnis erstellen",
"Verzeichnis rekursiv entfernen",
"Dateien oder Verzeichnisse kopieren",
"Dateien verschieben oder umbenennen",
"Dateiinhalte anzeigen",
"Nach einem Muster in einer Datei suchen",
"Dateien nach Namen suchen",
"Dateiberechtigungen ändern",
"Dateieigentümer ändern",
"Tar.gz-Archiv extrahieren",
"Tar.gz-Archiv erstellen",
"Laufwerksnutzung anzeigen",
"Verzeichnisgrößen anzeigen",
"Speichernutzung anzeigen",
"Laufende Prozesse anzeigen",
"Laufenden Prozess suchen",
"Prozess beenden",
"Dienststatus prüfen",
"Dienst starten",
"Dienst stoppen",
"Dienst neu starten",
"Debian/Ubuntu-Pakete aktualisieren",
"Debian/Ubuntu-Paket installieren",
"Paket entfernen",
"Laufende Container auflisten",
"Docker-Images auflisten",
"Eine Shell in einem Container öffnen",
"IP-Adressen anzeigen",
"Netzwerkkonnektivität testen",
"HTTP-Header abrufen",
"Eine Datei herunterladen",
"Über SSH verbinden",
"Eine Datei über SSH kopieren",
"Eine Protokolldatei in Echtzeit verfolgen",
"Befehlsverlauf anzeigen",
"Terminalbildschirm löschen"
],
"fr": [
"Liste de tous les nœuds Proxmox",
"Liste des machines virtuelles sur un nœud",
"Liste des conteneurs LXC sur un nœud",
"Liste du stockage sur un nœud",
"Liste des interfaces réseau",
"Liste de toutes les machines virtuelles QEMU/KVM",
"Démarrer une machine virtuelle",
"Arrêter une machine virtuelle",
"Arrêter une machine virtuelle en toute sécurité",
"Afficher l'état de la VM",
"Afficher la VM configuration",
"Créer un instantané de VM",
"Liste de tous les conteneurs LXC",
"Démarrer un conteneur LXC",
"Arrêter un conteneur LXC",
"Ouvrir une console de conteneur LXC",
"Afficher la configuration du conteneur",
"Afficher l'état du stockage",
"Liste du contenu du stockage",
"Tester les performances du système Proxmox",
"Afficher la version Proxmox VE",
"Vérifier l'état du cluster",
"Afficher l'état du cluster",
"Liste des nœuds du cluster",
"Afficher l'état du pool ZFS",
"Liste de tous les pools ZFS",
"Liste de tous les ensembles de données ZFS",
"Liste de tous les fichiers avec détails",
"Changer de répertoire",
"Créer un répertoire",
"Supprimer un répertoire de manière récursive",
"Copier des fichiers ou des répertoires",
"Déplacer ou renommer des fichiers",
"Afficher le contenu du fichier",
"Rechercher un modèle dans un fichier",
"Rechercher des fichiers par nom",
"Modifier les autorisations du fichier",
"Modifier le propriétaire du fichier",
"Extraire une archive tar.gz",
"Créer une archive tar.gz",
"Afficher l'utilisation du disque",
"Afficher la taille des répertoires",
"Afficher l'utilisation de la mémoire",
"Afficher les processus en cours d'exécution",
"Trouver un processus en cours d'exécution",
"Forcer l'arrêt d'un processus",
"Vérifier le service\". statut",
"Démarrer un service",
"Arrêter un service",
"Redémarrer un service",
"Mettre à jour les packages Debian/Ubuntu",
"Installer un package Debian/Ubuntu",
"Supprimer un package",
"Liste des conteneurs en cours d'exécution",
"Liste des images Docker",
"Ouvrir un shell dans un conteneur",
"Afficher les adresses IP",
"Tester la connectivité réseau",
"Obtenir les en-têtes HTTP",
"Télécharger un fichier",
"Connecter via SSH",
"Copier un fichier via SSH",
"Suivre un fichier journal en temps réel",
"Afficher l'historique des commandes",
"Effacer l'écran du terminal"
],
"it": [
"Elenca tutti i nodi Proxmox",
"Elenca VM su un nodo",
"Elenca container LXC su un nodo",
"Elenca storage su un nodo",
"Elenca interfacce di rete",
"Elenca tutte le VM QEMU/KVM",
"Avvia una VM",
"Arresta una VM",
"Spegni una VM in modo ordinato",
"Mostra stato VM",
"Mostra configurazione VM",
"Crea snapshot VM",
"Elenca tutti i container LXC",
"Avvia un container LXC",
"Arresta un container LXC",
"Apri una shell in un container LXC",
"Mostra configurazione container",
"Mostra stato storage",
"Elenca contenuto storage",
"Verifica prestazioni del sistema Proxmox",
"Mostra versione Proxmox VE",
"Mostra stato servizio pve-cluster",
"Mostra stato cluster",
"Elenca nodi cluster",
"Mostra stato pool ZFS",
"Elenca tutti i pool ZFS",
"Elenca filesystem e volumi ZFS",
"Elenca tutti i file con dettagli",
"Cambia directory",
"Crea una directory",
"Rimuovi una directory ricorsivamente",
"Copia file",
"Sposta o rinomina file",
"Visualizza contenuto file",
"Cerca un pattern in un file",
"Trova file per nome",
"Modifica permessi file",
"Cambia proprietario file",
"Estrai un archivio tar.gz",
"Crea un archivio tar.gz",
"Mostra utilizzo disco",
"Mostra dimensioni directory",
"Mostra utilizzo memoria",
"Mostra processi in esecuzione",
"Trova un processo in esecuzione",
"Termina forzatamente un processo",
"Controlla stato servizio",
"Avvia un servizio",
"Arresta un servizio",
"Riavvia un servizio",
"Aggiorna pacchetti Debian/Ubuntu",
"Installa un pacchetto Debian/Ubuntu",
"Rimuovi un pacchetto",
"Elenca i container in esecuzione",
"Elenca immagini Docker",
"Apri una shell in un container",
"Mostra indirizzi IP",
"Verifica connettività di rete",
"Ottieni intestazioni HTTP",
"Scarica un file",
"Connettiti tramite SSH",
"Copia un file tramite SSH",
"Segui un file di log in tempo reale",
"Mostra cronologia comandi",
"Pulisci lo schermo del terminale"
],
"pt": [
"Listar todos os nós Proxmox",
"Listar VMs em um nó",
"Listar contêineres LXC em um nó",
"Listar armazenamento em um nó",
"Listar interfaces de rede",
"Listar todas as máquinas virtuais QEMU/KVM",
"Iniciar uma máquina virtual",
"Parar uma máquina virtual",
"Desligar uma máquina virtual com segurança",
"Mostrar status da VM",
"Mostrar configuração da VM",
"Criar um instantâneo de VM",
"Listar todos os LXC containers",
"Iniciar um contêiner LXC",
"Parar um contêiner LXC",
"Abrir um console de contêiner LXC",
"Mostrar configuração do contêiner",
"Mostrar status de armazenamento",
"Listar conteúdo de armazenamento",
"Testar desempenho do sistema Proxmox",
"Mostrar a versão Proxmox VE",
"Verificar status do cluster",
"Mostrar status do cluster",
"Listar nós do cluster",
"Mostrar status do pool ZFS",
"Listar todos os pools ZFS",
"Listar todos os conjuntos de dados ZFS",
"Listar todos os arquivos com detalhes",
"Alterar diretório",
"Criar um diretório",
"Remover um diretório recursivamente",
"Copiar arquivos ou diretórios",
"Mover ou renomear arquivos",
"Exibir conteúdo do arquivo",
"Pesquisar um padrão em um arquivo",
"Encontrar arquivos por nome",
"Alterar permissões de arquivo",
"Alterar o proprietário do arquivo",
"Extrair um arquivo tar.gz",
"Criar um arquivo tar.gz",
"Mostrar uso de disco",
"Mostrar tamanhos de diretório",
"Mostrar uso de memória",
"Mostrar processos em execução",
"Encontrar um processo em execução",
"Forçar a eliminação de um processo",
"Verificar status do serviço",
"Iniciar um serviço",
"Parar um serviço",
"Reiniciar um serviço",
"Atualizar pacotes Debian/Ubuntu",
"Instalar um pacote Debian/Ubuntu",
"Remover um pacote",
"Listar contêineres em execução",
"Listar imagens do Docker",
"Abrir um shell em um contêiner",
"Mostrar endereços IP",
"Testar conectividade de rede",
"Obter cabeçalhos HTTP",
"Baixar um arquivo",
"Conectar através de SSH",
"Copiar um arquivo através de SSH",
"Seguir um arquivo de log em tempo real",
"Mostrar histórico de comandos",
"Limpar a tela do terminal"
],
"sv": [
"Lista alla Proxmox-noder",
"Lista virtuella datorer på en nod",
"Lista LXC-behållare på en nod",
"Lista lagringsutrymme på en nod",
"Lista nätverksgränssnitt",
"Lista alla virtuella QEMU/KVM-maskiner",
"Starta en virtuell maskin",
"Stoppa en virtuell maskin",
"Stäng av en virtuell maskin på ett säkert sätt",
"Show VM status",
"Show VM configuration",
"Skapa en VM Snapshot",
"Lista alla LXC-behållare",
"Starta en LXC-behållare",
"Stoppa en LXC-behållare",
"Öppna en LXC-containerkonsol",
"Visa behållarkonfiguration",
"Visa lagringsstatus",
"Lista lagringsinnehåll",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Check cluster status",
"Show cluster status",
"Lista klusternoder",
"Visa ZFS-poolstatus",
"Lista alla ZFS-pooler",
"Lista alla ZFS-datauppsättningar",
"Lista alla filer med detaljer",
"Ändra katalog",
"Skapa en katalog",
"Ta bort en katalog rekursivt",
"Copy files or directories",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Hitta filer efter namn",
"Ändra filbehörigheter",
"Ändra filägare",
"Extrahera ett tar.gz-arkiv",
"Skapa ett tar.gz-arkiv",
"Visa diskanvändning",
"Visa katalogstorlekar",
"Visa minnesanvändning",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Kontrollera tjänsten status",
"Starta en tjänst",
"Stoppa en tjänst",
"Starta om en tjänst",
"Uppdatera Debian/Ubuntu-paket",
"Installera ett Debian/Ubuntu-paket",
"Ta bort ett paket",
"Lista behållare som körs",
"Lista Docker-avbildningar",
"Öppna ett skal i en behållare",
"Visa IP-adresser",
"Testa en nätverksanslutning",
"Get HTTP headers",
"Download a file",
"Anslut via SSH",
"Kopiera en fil via SSH",
"Följ en loggfil i realtid",
"Visa kommandohistorik",
"Rensa terminalskärmen"
]
},
"english": [
"List all Proxmox nodes",
"List VMs on a node",
"List LXC containers on a node",
"List storage on a node",
"List network interfaces",
"List all QEMU/KVM virtual machines",
"Start a virtual machine",
"Stop a virtual machine",
"Shut down a virtual machine safely",
"Show VM status",
"Show VM configuration",
"Create a VM Snapshot",
"List all LXC containers",
"Start an LXC container",
"Stop an LXC container",
"Open a shell in an LXC container",
"Show container configuration",
"Show storage status",
"List storage contents",
"Test Proxmox system performance",
"Show the Proxmox VE version",
"Show pve-cluster service status",
"Show cluster status",
"List cluster nodes",
"Show ZFS pool status",
"List all ZFS pools",
"List ZFS filesystems and volumes",
"List all files with details",
"Change directory",
"Create a directory",
"Remove a directory recursively",
"Copy files",
"Move or rename files",
"Display file contents",
"Search for a pattern in a file",
"Find files by name",
"Change file permissions",
"Change the file owner",
"Extract a tar.gz archive",
"Create a tar.gz archive",
"Show disk usage",
"Show directory sizes",
"Show memory usage",
"Show running processes",
"Find a running process",
"Force-kill a process",
"Check service status",
"Start a service",
"Stop a service",
"Restart a service",
"Update Debian/Ubuntu packages",
"Install a Debian/Ubuntu package",
"Remove a package",
"List running containers",
"List Docker images",
"Open a shell in a container",
"Show IP addresses",
"Test network connectivity",
"Get HTTP headers",
"Download a file",
"Connect through SSH",
"Copy a file through SSH",
"Follow a log file in real time",
"Show command history",
"Clear the terminal screen"
],
"commands": [
"pvesh get /nodes",
"pvesh get /nodes/{node}/qemu",
"pvesh get /nodes/{node}/lxc",
"pvesh get /nodes/{node}/storage",
"pvesh get /nodes/{node}/network",
"qm list",
"qm start <vmid>",
"qm stop <vmid>",
"qm shutdown <vmid>",
"qm status <vmid>",
"qm config <vmid>",
"qm snapshot <vmid> <snapname>",
"pct list",
"pct start <vmid>",
"pct stop <vmid>",
"pct enter <vmid>",
"pct config <vmid>",
"pvesm status",
"pvesm list <storage>",
"pveperf",
"pveversion",
"systemctl status pve-cluster",
"pvecm status",
"pvecm nodes",
"zpool status",
"zpool list",
"zfs list",
"ls -la",
"cd /path/to/dir",
"mkdir dirname",
"rm -rf dirname",
"cp source dest",
"mv source dest",
"cat filename",
"grep 'pattern' file",
"find . -name 'file'",
"chmod 755 file",
"chown user:group file",
"tar -xzf file.tar.gz",
"tar -czf archive.tar.gz dir/",
"df -h",
"du -sh *",
"free -h",
"top",
"ps aux | grep process",
"kill -9 PID",
"systemctl status service",
"systemctl start service",
"systemctl stop service",
"systemctl restart service",
"apt update && apt upgrade",
"apt install package",
"apt remove package",
"docker ps",
"docker images",
"docker exec -it container bash",
"ip addr show",
"ping host",
"curl -I url",
"wget url",
"ssh user@host",
"scp file user@host:/path",
"tail -f /var/log/syslog",
"history",
"clear"
]
}
@@ -0,0 +1,76 @@
// Execute the actual Security handlers against inert UI state and transport.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const catalogs = Object.fromEntries(['en','de','es','fr','it','pt','sk','sv'].map(lang => [lang, require(path.join(root, `AppImage/messages/${lang}/common.json`)).securityPage]));
const begin = source.indexOf(' const handleUninstallLynis = async () => {');
const end = source.indexOf('\n const loadFail2banDetails =', begin);
assert(begin !== -1 && end > begin);
const eraseTypes = text => text.replace(/fetchApi<\{[^}]+\}>/g, 'fetchApi')
.replace(/ as \{ partial\?: boolean \} \| undefined/g, '');
const uninstall = eraseTypes(source.slice(begin, end));
const anchor = source.indexOf('if (confirm(st("confirm.deleteAuditReport")))');
assert(anchor !== -1);
const clickBegin = source.lastIndexOf('onClick={(e) => {', anchor);
const clickEnd = source.indexOf('\n className=', anchor);
assert(clickBegin !== -1 && clickEnd > anchor);
const click = eraseTypes(source.slice(clickBegin + 'onClick={'.length, clickEnd).trim().replace(/}\s*$/, ''));
async function exercise(lang, action, reply, priorSuccess = '') {
const state = {report: {id: 'inert'}, expanded: true, success: priorSuccess, error: '', loads: 0, calls: []};
const st = (key) => key.split('.').reduce((v, k) => v?.[k], catalogs[lang]);
const ctx = {
st, Error, confirm: () => true,
setLynisReport: v => {state.report = v}, setLynisShowReport: v => {state.expanded = v},
setSuccess: v => {state.success = v}, setError: v => {state.error = v},
setUninstallingLynis: () => {}, setShowLynisUninstallConfirm: () => {},
loadSecurityTools: () => {state.loads++},
fetchApi: (endpoint, options) => {
assert.equal(endpoint, action === 'delete' ? '/api/security/lynis/report' : '/api/security/lynis/uninstall');
assert.equal(options.method, action === 'delete' ? 'DELETE' : 'POST');
state.calls.push(options.method);
return reply instanceof Error ? Promise.reject(reply) : Promise.resolve(reply);
},
};
const fn = vm.runInNewContext(action === 'delete' ? `(${click})` : `${uninstall}; handleUninstallLynis`, ctx);
await fn({stopPropagation() {}});
await new Promise(resolve => setImmediate(resolve));
return state;
}
(async () => {
for (const lang of Object.keys(catalogs)) {
for (const action of ['delete', 'uninstall']) {
const success = await exercise(lang, action, {success:true, message:'inert'});
assert.equal(success.report, null, `${lang} ${action} clean report`);
assert(success.success, `${lang} ${action} clean success`);
assert.equal(success.error, '', `${lang} ${action} clean no error`);
assert.equal(success.loads, 1);
for (const [kind, reply] of [
['no_files', {success:false,outcome:'no_files',message:'No report files found to delete'}],
['malformed_success', {success:'false',message:'inert malformed response'}],
['first_failure', {success:false,partial:false,message:'inert denied'}],
['partial_failure', {success:false,partial:true,message:'inert denied'}],
['http_failure', Object.assign(new Error('inert denied'), {body:{success:false,partial:true,message:'inert denied'}})],
['nonjson_failure', new Error('Invalid JSON response')],
['network_failure', new Error('network failure')],
]) {
const state = await exercise(lang, action, reply);
assert.notEqual(state.report, null, `${lang} ${action} ${kind}: report retained`);
assert.equal(state.success, '', `${lang} ${action} ${kind}: no success`);
assert.equal(state.loads, 0, `${lang} ${action} ${kind}: no success refresh`);
assert(state.error, `${lang} ${action} ${kind}: error visible`);
const expectedKey = kind === 'no_files' ? (action === 'delete' ? 'deleteReportNoFiles' : 'lynisUninstallNoFiles')
: kind === 'partial_failure' || kind === 'http_failure' ? 'lynisRemovalPartial'
: 'lynisRemovalUnconfirmed';
assert.equal(state.error, catalogs[lang].errors[expectedKey], `${lang} ${action} ${kind}: native whole-message feedback`);
if (action === 'delete' && kind === 'no_files') {
const stale = await exercise(lang, action, reply, 'Earlier unrelated success');
assert.equal(stale.success, '', `${lang} delete clears stale success before failure`);
}
}
}
}
console.log('actual Lynis handlers: eight locales, clean/no-files/pre-first/partial/HTTP/non-JSON/network state');
})().catch(err => { console.error(err); process.exitCode = 1 });
@@ -0,0 +1,57 @@
// Execute the actual printable-report generator, without mounting the app.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const root = path.resolve(__dirname, '../../../..');
const source = fs.readFileSync(path.join(root, 'AppImage/components/security.tsx'), 'utf8');
const begin = source.indexOf(' const generatePrintableReport = (report: LynisReport) => {');
const end = source.indexOf('\n const loadSslStatus =', begin);
assert(begin > 0 && end > begin, 'print consumer not found');
const body = source.slice(begin, end)
.replace('(report: LynisReport)', '(report)')
.replace('(raw: unknown): string', '(raw)');
const lang = process.env.LYNIS_FIXTURE_LANG || 'en';
assert(['en','de','es','fr','it','pt','sk','sv'].includes(lang));
const catalog = require(path.join(root, `AppImage/messages/${lang}/common.json`));
const translate = (key, params = {}) => {
const value = key.split('.').reduce((v, k) => v?.[k], catalog.securityPage);
assert.equal(typeof value, 'string', `missing ${lang} key ${key}`);
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k]));
};
const context = {
st: translate,
window: { location: { origin: 'https://offline.invalid' } },
document: { documentElement: { lang } },
getLynisScoreState: () => ({rawScore: 80, displayScore: 80, reportComplete: true, hasAdjustment: false}),
getActionableCount: (total, expected) => Math.max(0, total - expected),
lynisCountText: (key, count) => translate(`lynis.counts.${key}.${count === 1 ? 'one' : 'many'}`, { count }),
};
const render = vm.runInNewContext(`${body}\ngeneratePrintableReport`, context);
const report = {
hostname: 'inert', os_name: 'Linux', os_version: '', os_fullname: 'Linux',
kernel_version: 'test', lynis_version: 'test', datetime_start: '2026-01-01',
hardening_index: 80, tests_performed: 1,
warnings: [
{test_id: 'EXPECTED-W', description: 'expected warning', solution: '', proxmox_expected: true},
{test_id: 'ACTION-W', description: 'actionable warning', solution: '', proxmox_expected: false},
],
suggestions: [
{test_id: 'EXPECTED-S', description: 'expected suggestion', solution: '', proxmox_expected: true},
{test_id: 'ACTION-S', description: 'actionable suggestion', solution: '', proxmox_expected: false},
],
proxmox_expected_warnings: 1, proxmox_expected_suggestions: 1, sections: [],
};
const html = render(report);
if (process.env.LYNIS_FIXTURE_HTML) fs.writeFileSync(process.env.LYNIS_FIXTURE_HTML, html);
for (const id of ['EXPECTED-W', 'ACTION-W', 'EXPECTED-S', 'ACTION-S']) {
assert(html.includes(id), `missing actual finding ${id}`);
}
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-W[\s\S]*?f-tag-pve/);
assert.match(html, /finding f-pve[\s\S]*?EXPECTED-S[\s\S]*?f-tag-pve/);
for (const key of ['lynis.report.warningsDescription', 'lynis.report.suggestionsDescription']) {
const description = translate(key);
assert(!/hidden|nascosti|ocult|caché|skryt|dold|versteckt/i.test(description));
assert(html.includes(description), `missing rendered description ${key}`);
}
console.log(`print HTML (${lang}): expected/actionable findings shown and badged`);
@@ -0,0 +1,107 @@
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
import ast
import datetime
import sqlite3
import tempfile
import threading
import time
import types
import typing
from html.parser import HTMLParser
from pathlib import Path
from notification_fixture import templates, SCRIPTS, EmailChannel, extract
def visible(markup):
class Text(HTMLParser):
def __init__(self): super().__init__(); self.parts = []; self.tags = []
def handle_data(self, data): self.parts.append(data)
def handle_starttag(self, tag, attrs): self.tags.append(tag)
parser = Text(); parser.feed(markup)
return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags
def restore_event(warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0',
'components':'2', 'duration':'2m', 'warnings':warnings}),
'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)),
'jsonify':lambda value:value}
handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
assert status == 200 and len(events) == 1
return events[0]
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
captured = []
delivery_errors = []
def record_history(event_type, channel, title, body, severity, success, error, source):
if not success:
delivery_errors.append(f'{channel}: {error}')
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
def sink(title, body, severity, data):
markup = channel._format_html(title, body, severity, data)
text, tags = visible(markup)
captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags))
return {'success':True}
manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(),
_channels={'email':types.SimpleNamespace(send=sink)},
_group_limiter=types.SimpleNamespace(allow=lambda group:True),
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
is_event_enabled=lambda event:True)
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
resolve_notification_hostname=lambda host, config:host or 'node-a',
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
format_backup_diagnostics_with_ai=templates.format_backup_diagnostics_with_ai,
format_with_ai_full=templates.format_with_ai_full,
# AI is disabled in this fixture. Execute the actual formatting helpers
# synchronously, without a worker pool, providers or operational imports.
_format_with_ai_bounded=lambda function,*args,**kwargs:function(*args,**kwargs),
enrich_context_for_ai=lambda **kwargs:'')
manager_path = SCRIPTS/'notification_manager.py'
for node in ast.parse(manager_path.read_text()).body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in ('_AI_BYPASS_EVENTS', '_AI_NO_SUGGESTION_EVENTS'):
ns[target.id] = frozenset(ast.literal_eval(node.value.args[0]))
for name in ('_should_bypass_ai', '_format_backup_with_ai_bounded'):
extract(manager_path, name, None, ns)
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
db = Path(scratch)/'pending.sqlite'
rows = []
if quiet:
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)')
conn.commit(); conn.close()
qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime,
_resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message,
EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI)
for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'):
setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager))
for earlier in quiet_before:
manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert'))
if manual:
assert not quiet
result = manager.send_notification(event_type, severity, '', '', dict(data))
assert result['success'], result.get('errors')
else:
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
manager._dispatch_event(event)
assert not delivery_errors, '; '.join(delivery_errors)
if quiet:
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
assert len(rows)==1+len(quiet_before) and not captured
manager._flush_quiet_for_channel('email', manager._channels['email'])
conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close()
assert remaining==0
assert len(captured)==1
if quiet: captured[0]['buffered']=rows
return captured[0]
@@ -0,0 +1,51 @@
"""Assertion-free inert actual consumers; no operational host imports."""
import ast
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
# Display-name resolution is an infrastructure boundary, never load manager.
templates._get_hostname = lambda: 'node-a'
from notification_channels import EmailChannel
LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
def extract(path, name, owner, ns):
tree = ast.parse(path.read_text())
nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def receive(message, severity='info', title='Backup', kind='vzdump'):
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns)
for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')}
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = methods['_classify_pve']
_map_severity = staticmethod(methods['_map_severity'])
_backup_outcome = staticmethod(methods['_backup_outcome'])
process_webhook = methods['process_webhook']
def __init__(self): self._queue = Queue()
target = Receiver()
result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message})
assert result['accepted'] and len(target._queue.items) == 1
return target._queue.items[0]
def email(event_type, data, severity='INFO', language='en'):
result = templates.render_template(event_type, data, language)
channel = object.__new__(EmailChannel)
channel.subject_prefix = '[ProxMenux]'
context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language}
return result, channel._format_html(result['title'], result['body'], severity, context)
@@ -0,0 +1,169 @@
"""Source layout regressions for the Helper-Scripts/core repository split."""
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
SCRIPT = Path(__file__).resolve().parents[1] / "generate_app_tracking_catalog.py"
SPEC = importlib.util.spec_from_file_location("tracking_sources_under_test", SCRIPT)
generator = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = generator
SPEC.loader.exec_module(generator)
NORMALIZE = ''' app_lc="$(echo "${app,,}" | tr -d ' ')"'''
READ_BODY = NORMALIZE + '''
current_file="$HOME/.${app_lc}"
current="$(<"$current_file")"
'''
WRITE_BODY = NORMALIZE + '''
version_file="$HOME/.${app_lc}"
echo "$version" >"$version_file"
'''
def function(name, body):
return f"{name}() {{\n{body}\n}}\n"
LEGACY = (
function("check_for_gh_release", READ_BODY.replace(' current_file=', ' local current_file='))
+ function("fetch_and_deploy_gh_release", WRITE_BODY.replace(' version_file=', ' local version_file='))
)
SPLIT = (
function("check_for_gh_release", ' check_for_release github "$1" "$2" "${3:-}"')
+ function("fetch_and_deploy_gh_release", ' fetch_and_deploy_release github "$1" "$2" "${3:-tarball}"')
+ function("check_for_release", READ_BODY)
+ function("fetch_and_deploy_release", WRITE_BODY)
)
class TrackingSourcesTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.apps = self.root / "apps"
self.core = self.root / "core"
self.write(self.apps / "ct/example.sh", '''APP="Example"
check_for_gh_release "Example" "example/example"
''')
self.write(self.apps / "install/example-install.sh", '''
fetch_and_deploy_gh_release "Example" "example/example" "tarball" "latest"
''')
def write(self, path, content):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def run_main(self, *extra):
output = self.root / "output.json"
args = ["--source-dir", str(self.apps), "--output", str(output),
"--include-helper-markers",
"--audit-output", str(self.root / "audit.json"),
"--v2-output", str(self.root / "v2.json"), *extra]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
result = generator.main(args)
return result, output
def test_legacy_monolithic_contract_is_supported(self):
self.write(self.apps / "misc/tools.func", LEGACY)
result, output = self.run_main()
self.assertEqual(result, 0)
self.assertEqual(json.loads(output.read_text())["example"]["file_path"], "/root/.example")
def test_split_contract_builds_catalog_and_records_actual_evidence(self):
self.write(self.core / "lib/forge.func", SPLIT)
result, output = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 0)
self.assertEqual(json.loads(output.read_text())["example"]["repo"], "example/example")
audit = json.loads((self.root / "audit.json").read_text())
self.assertEqual(audit["provenance"]["core"]["source_dir"], str(self.core.resolve()))
v2 = (self.root / "v2.json").read_text()
self.assertIn("core/lib/forge.func", v2)
self.assertNotIn("misc/tools.func", v2)
def test_unsplit_core_tools_library_is_supported(self):
path = self.write(self.core / "lib/tools.func", LEGACY)
self.assertEqual(generator.shared_helper_path(self.apps, self.core), path)
generator.verify_shared_helper(path)
def test_changed_contract_never_overwrites_existing_outputs(self):
for old, new in [('echo "$version" >"$version_file"', 'echo "$version" >/tmp/version'),
('current_file="$HOME/.${app_lc}"', 'current_file="/tmp/version"'),
('tr -d \' \'', 'tr -d \'-\'')]:
with self.subTest(change=old):
self.write(self.core / "lib/forge.func", SPLIT.replace(old, new))
output = self.write(self.root / "output.json", '{"keep": true}\n')
result, _ = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 1)
self.assertEqual(output.read_text(), '{"keep": true}\n')
def test_matching_text_in_unrelated_functions_is_not_evidence(self):
path = self.write(self.core / "lib/forge.func", SPLIT.replace(
function("check_for_release", READ_BODY), function("unrelated", READ_BODY)))
with self.assertRaises(generator.CatalogError):
generator.verify_shared_helper(path)
def test_split_local_tree_requires_explicit_local_core_without_network(self):
with patch.object(generator.GitHubClient, "request", side_effect=AssertionError("Network")):
result, output = self.run_main()
self.assertEqual(result, 1)
self.assertFalse(output.exists())
def test_invalid_local_core_fails_without_writing_a_catalog(self):
result, output = self.run_main("--core-source-dir", str(self.core))
self.assertEqual(result, 1)
self.assertFalse(output.exists())
def test_snapshot_cache_accepts_core_without_ct_directory(self):
sha = "a" * 40
source = self.root / "cache/community-scripts--core" / sha
self.write(source / "lib/forge.func", SPLIT)
self.write(source / ".snapshot-complete", "archive-hash\n")
client = Mock()
client.json.return_value = {"sha": sha}
path, commit, archive = generator.obtain_snapshot(
client, "community-scripts/core", "main", self.root / "cache",
required_paths=("lib/forge.func",))
self.assertEqual((path, commit, archive), (source, sha, "archive-hash"))
client.request.assert_not_called()
def test_remote_split_source_pins_both_repositories(self):
self.write(self.core / "lib/forge.func", SPLIT)
output = self.root / "remote.json"
with patch.object(generator, "obtain_snapshot", side_effect=[
(self.apps, "a" * 40, "apps-archive"),
(self.core, "b" * 40, "core-archive"),
]) as snapshots, contextlib.redirect_stdout(io.StringIO()):
result = generator.main([
"--output", str(output), "--include-helper-markers",
"--audit-output", str(self.root / "remote-audit.json"),
"--v2-output", str(self.root / "remote-v2.json"),
])
self.assertEqual(result, 0)
provenance = json.loads((self.root / "remote-audit.json").read_text())["provenance"]
self.assertEqual(provenance["commit_sha"], "a" * 40)
self.assertEqual(provenance["core"]["commit_sha"], "b" * 40)
self.assertEqual(provenance["core"]["archive_sha256"], "core-archive")
self.assertEqual(snapshots.call_args_list[1].args[1], "community-scripts/core")
self.assertEqual(snapshots.call_args_list[1].kwargs["required_paths"], ("lib/forge.func",))
def test_invalid_download_is_not_marked_complete(self):
client = Mock()
client.json.return_value = {"sha": "b" * 40}
client.request.return_value = b"invalid-layout"
with patch.object(generator, "safe_extract_tar"):
with self.assertRaises(generator.CatalogError):
generator.obtain_snapshot(client, "community-scripts/core", "main", self.root / "cache",
required_paths=("lib/forge.func",))
self.assertEqual(list((self.root / "cache").rglob(".snapshot-complete")), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
"""A host backup and a finished restore tell the Monitor on its own port,
over HTTPS once the Monitor has a certificate."""
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LIBRARY = ROOT / 'scripts/backup_restore/lib_host_backup_common.sh'
POSTBOOT = ROOT / 'scripts/backup_restore/apply_cluster_postboot.sh'
SETTING = '/etc/proxmenux/ssl_config.json'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class BackupNotifyHttps(TestCase):
def address(self, setting):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
if setting is not None:
path.write_text(setting)
body = function(LIBRARY.read_text(), 'hb_notify_lifecycle').replace(SETTING, str(path))
# The function silences curl, so the stub writes the address it was given.
called = Path(folder) / 'called'
script = (f'curl() {{ printf "%s" "${{@: -1}}" > {called}; }}\n' + body
+ 'HB_NOTIFY_JOB_ID=job1 hb_notify_lifecycle complete\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return called.read_text()
def test_plain_http_while_the_monitor_has_no_certificate(self):
expected = 'http://127.0.0.1:8008/api/notifications/webhook'
self.assertEqual(self.address(None), expected)
self.assertEqual(self.address('{"enabled": false}'), expected)
def test_https_once_the_monitor_has_a_certificate(self):
self.assertEqual(self.address('{"enabled": true, "source": "proxmox"}'),
'https://127.0.0.1:8008/api/notifications/webhook')
def test_the_finished_restore_follows_the_same_setting(self):
source = POSTBOOT.read_text()
self.assertIn('"${NOTIFY_SCHEME}://127.0.0.1:8008/api/internal/restore-event"', source)
self.assertNotIn('"http://127.0.0.1:8008/api/internal/restore-event"', source)
check = source[source.index('NOTIFY_SCHEME="http"'):source.index('NOTIFY_HTTP=$(curl')]
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'ssl_config.json'
for setting, expected in (('{"enabled": true}', 'https'), ('{"enabled":false}', 'http'), (None, 'http')):
path.unlink(missing_ok=True)
if setting is not None:
path.write_text(setting)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c',
check.replace(SETTING, str(path)) + '\necho "$NOTIFY_SCHEME"'],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.stdout.strip(), expected, setting)
@@ -0,0 +1,102 @@
"""A schedule systemd rejects leaves a timer that never fires and reports no
error, so the wizard asks for it again instead of saving it."""
from pathlib import Path
import subprocess
import tempfile
import unittest
SCHEDULER = Path(__file__).resolve().parents[3] / 'scripts/backup_restore/backup_scheduler.sh'
ANALYZE = '#!/bin/sh\ncase "$2" in "daily 02:00") exit 1;; esac\nexit 0\n'
def function(name):
text = SCHEDULER.read_text()
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
class ScheduleValidation(unittest.TestCase):
def valid(self, expression, **environment):
with tempfile.TemporaryDirectory() as directory:
analyze = Path(directory) / 'systemd-analyze'
analyze.write_text(ANALYZE)
analyze.chmod(0o755)
return subprocess.run(
['/bin/bash', '-c', function('_calendar_is_valid') + '_calendar_is_valid "$1"', 'test', expression],
env={'PATH': f'{directory}:/usr/bin:/bin', **environment},
).returncode == 0
def test_systemd_decides_what_is_valid(self):
self.assertTrue(self.valid('02:30'))
self.assertFalse(self.valid('daily 02:00'))
def test_a_scheduler_without_systemctl_is_not_checked(self):
self.assertTrue(self.valid('daily 02:00', PMX_BACKUP_NO_SYSTEMCTL='1'))
def test_the_wizard_asks_again_before_saving_the_job(self):
text = SCHEDULER.read_text()
check = text.index('while ! _calendar_is_valid "$on_calendar"; do')
self.assertLess(check, text.index('"ON_CALENDAR=$on_calendar"'))
self.assertGreater(check, text.index('on_calendar=$(dialog'))
STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n'
JOBS = {
'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n',
'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n',
'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n',
'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n',
'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n',
'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n',
'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n',
}
class ScheduleRepair(unittest.TestCase):
def run_repair(self):
names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached',
'_write_job_units', '_repaired_calendar', '_repair_job_schedules')
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
root = Path(self.folder.name)
for name, body in (('systemd-analyze', STUB_ANALYZE),
('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')):
(root / name).write_text(body)
(root / name).chmod(0o755)
jobs, units = root / 'jobs', root / 'units'
jobs.mkdir()
for job, body in JOBS.items():
(jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}')
script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n'
'_job_file() { echo "${JOBS_DIR}/$1.env"; }\n'
'_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n'
'_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n'
+ ''.join(function(name) for name in names) + '_repair_job_schedules\n')
subprocess.run(['/bin/bash', '-c', script], check=True,
env={'PATH': f'{root}:/usr/bin:/bin'})
return jobs, units, root
def test_a_time_after_daily_weekly_or_monthly_is_repaired(self):
jobs, units, root = self.run_repair()
self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text())
self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text())
self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text())
self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text())
log = (root / 'systemctl.log').read_text()
self.assertIn('enable --now proxmenux-backup-nas.timer', log)
self.assertNotIn('proxmenux-backup-month.timer', log)
def test_the_rest_of_the_jobs_are_left_as_they_are(self):
jobs, units, _ = self.run_repair()
for job in ('good', 'manual', 'attached', 'custom'):
self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job)
self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job)
def test_both_installers_repair_on_update(self):
root = SCHEDULER.parents[2]
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text())
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,349 @@
"""Run only the Bashrc functions, with all root files in a private fixture."""
import json
import os
from pathlib import Path
import re
import shlex
import stat
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
FLOWS = ("auto_post_install.sh", "customizable_post_install.sh")
BEGIN = b"# BEGIN PMX_CORE_BASHRC\n"
END = b"# END PMX_CORE_BASHRC\n"
# The unmarked block shipped in v1.1.3 (fc974995), without its leading blank line.
LEGACY = rb'''# ProxMenux customizations
export HISTTIMEFORMAT="%d/%m/%y %T "
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
source /etc/profile.d/bash_completion.sh
'''
def extract(source, name):
match = re.search(rf"(?ms)^{name}\(\) \{{\n.*?^\}}$", source)
if match is None:
raise AssertionError(f"missing function: {name}")
return match.group()
class BashrcMigrationTests(unittest.TestCase):
def run_flow(self, flow, original, times=1, injection="", style=""):
with tempfile.TemporaryDirectory(prefix="bashrc-migration-") as temporary:
work = Path(temporary)
bashrc = work / ".bashrc"
bashrc.write_bytes(original)
bashrc.chmod(0o640)
before = bashrc.stat()
(work / "bin").mkdir()
# The embedded Python must use the interpreter running this test.
(work / "bin/python3").symlink_to(sys.executable)
source = (ROOT / "scripts/post_install" / flow).read_text()
functions = "\n".join(extract(source, name) for name in
("_migrate_proxmenux_bashrc", "customize_bashrc"))
# Relocate only the fixture copy; never source the administrative script.
functions = functions.replace("/root/", str(work) + "/")
prelude = f'source {shlex.quote(str(ROOT / "scripts/global/pmx_journal.sh"))}\n'
prelude += r'''
translate() { printf '%s' "$1"; }
msg_info() { :; }
msg_info2() { :; }
msg_warn() { :; }
msg_error() { printf '%s\n' "$*" >&2; }
msg_ok() { printf 'success\n' >> "$FIXTURE_ROOT/messages"; }
register_tool() { printf '%s|%s|%s\n' "$@" >> "$FIXTURE_ROOT/registry"; }
'''
calls = "\n".join(
f'customize_bashrc; rc=$?; printf "%s\\n" "$rc" >> "$FIXTURE_ROOT/status"; '
f'cp "$FIXTURE_ROOT/.bashrc" "$FIXTURE_ROOT/pass-{i}"'
for i in range(times))
env = os.environ.copy()
env.update(HOME=str(work), FIXTURE_ROOT=str(work), TMPDIR=str(work),
PMX_JOURNAL_ROOT=str(work / "journal"), PMX_BASHRC_PATH_STYLE=style,
PATH=str(work / "bin") + os.pathsep + env.get("PATH", ""))
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c",
prelude + functions + "\n" + injection + "\n" + calls],
cwd=work, env=env, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
after = bashrc.stat()
def read(name):
path = work / name
return path.read_bytes() if path.exists() else b""
return {
"passes": [read(f"pass-{i}") for i in range(times)],
"status": read("status").splitlines(), "registry": read("registry"),
"messages": read("messages"), "backup": read(".bashrc.bak"),
"profile": read(".bash_profile"), "stderr": result.stderr,
"detected_style": read("style"),
"before": (stat.S_IMODE(before.st_mode), before.st_uid, before.st_gid),
"after": (stat.S_IMODE(after.st_mode), after.st_uid, after.st_gid),
"inode_before": before.st_ino, "inode_after": after.st_ino,
"leftovers": [p.name for p in work.iterdir() if "proxmenux" in p.name],
"journal": [json.loads(p.read_text()) for p in
sorted((work / "journal/spool").glob("*.json"))],
}
def test_inspect_detects_style_without_rewriting_the_original(self):
for flow in FLOWS:
for block in (LEGACY, LEGACY.replace(rb"\W", rb"\w")):
with self.subTest(flow=flow, full=rb"\w" in block):
injection = r'''customize_bashrc() {
_migrate_proxmenux_bashrc "$FIXTURE_ROOT/.bashrc" inspect > "$FIXTURE_ROOT/style"
}'''
run = self.run_flow(flow, block, injection=injection)
self.assertEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [block])
self.assertEqual(run["detected_style"], b"full\n" if rb"\w" in block else b"short\n")
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["backup"], b"")
self.assertEqual(run["registry"], b"")
self.assertEqual(run["journal"], [])
def test_historical_blocks_migrate_once_with_prompt_backup_and_journal(self):
for flow in FLOWS:
for full in (False, True):
legacy = LEGACY.replace(rb"\W", rb"\w") if full else LEGACY
blocks = (legacy, b"${marker_begin}\n" + legacy + b"${marker_end}\n",
BEGIN + legacy + END, legacy + BEGIN + legacy + END)
for block in blocks:
with self.subTest(flow=flow, full=full, block=block[:40]):
original = b"# user prefix\n" + block + b"# user suffix\n"
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
first, second = run["passes"]
self.assertEqual(first, second)
self.assertTrue(first.startswith(b"# user prefix\n# user suffix\n"))
self.assertEqual(first.count(BEGIN), 1)
self.assertEqual(first.count(END), 1)
self.assertEqual(first.count(b"alias ll="), 1)
self.assertNotIn(b"${marker_", first)
self.assertIn(rb"\w" if full else rb"\W", first)
self.assertEqual(run["backup"], original)
self.assertEqual(len(run["profile"].splitlines()), 1)
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 2)
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["leftovers"], [])
entries = [e for e in run["journal"] if e["target"].endswith("/.bashrc")]
self.assertEqual(len(entries), 1)
entry = entries[0]
self.assertEqual(entry["operation"], "write_file")
self.assertEqual(entry["exactness"], "exact")
self.assertEqual(entry["capture"], "present")
self.assertNotEqual(entry["before"], entry["after"])
self.assertTrue(all("proxmenux." not in e["target"] for e in run["journal"]))
syntax = subprocess.run(["bash", "-n"], input=first, capture_output=True)
self.assertEqual(syntax.returncode, 0, syntax.stderr)
def test_incomplete_and_oversized_ranges_are_preserved_on_rerun(self):
fragments = (LEGACY.rsplit(b"source", 1)[0], END,
b"${marker_begin}\n# user\n", b"${marker_end}\n",
BEGIN + b"# user\n" * 33 + END,
b"${marker_begin}\n" + b"# user\n" * 33 + b"${marker_end}\n",
BEGIN + b"# user\n${marker_begin}\n# inner\n${marker_end}\n# suffix\n")
for flow in FLOWS:
for fragment in fragments:
with self.subTest(flow=flow, fragment=fragment[:40]):
run = self.run_flow(flow, fragment, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
# Complete inner ranges can migrate, but their outer prefix cannot.
preserved = BEGIN + b"# user\n# suffix\n" if b"# inner" in fragment else fragment
self.assertTrue(run["passes"][0].startswith(preserved))
self.assertEqual(run["passes"][0], run["passes"][1])
def test_nested_ranges_with_outer_end_are_preserved_on_repeated_runs(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
for flow in FLOWS:
for outer_begin, outer_end in markers:
for inner_begin, inner_end in markers:
with self.subTest(flow=flow, outer=outer_begin, inner=inner_begin):
original = (outer_begin + b"# OUTER_USER_PREFIX\n"
b"user_function() { :; }\n" + inner_begin
+ b"# old inner block\n" + inner_end
+ b"# OUTER_USER_SUFFIX\n" + outer_end)
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
for content in run["passes"]:
self.assertTrue(content.startswith(original), content)
self.assertEqual(content.count(BEGIN), original.count(BEGIN) + 1)
self.assertEqual(content.count(END), original.count(END) + 1)
self.assertEqual(content.count(b"# ProxMenux core customizations\n"), 1)
self.assertEqual(run["passes"], [run["passes"][0]] * 3)
self.assertEqual(run["backup"], original)
def test_crossed_marker_families_preserve_every_byte_on_repeated_runs(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
for flow in FLOWS:
canonical = self.run_flow(flow, b"")["passes"][0]
for outer, inner in (markers, markers[::-1]):
for newline in (b"\n", b"\r\n"):
with self.subTest(flow=flow, outer=outer[0], newline=newline):
original = (outer[0] + b"user_prefix() { :; } # \xff\n"
+ inner[0] + outer[1]
+ b"user_suffix() { :; }\n" + inner[1])
original = original.replace(b"\n", newline)
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
self.assertEqual(run["passes"], [original + canonical] * 3)
self.assertEqual(run["backup"], original)
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 3)
def test_marker_ambiguity_matrix_preserves_spans_without_capturing_separate_blocks(self):
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
prefix = b"user_prefix() { :; }\n"
suffix = b"user_suffix() { :; }\n"
for flow in FLOWS:
canonical = self.run_flow(flow, b"")["passes"][0]
for (begin, end), (foreign_begin, foreign_end) in (markers, markers[::-1]):
# Each malformed complete span is ambiguous, even if the foreign
# token has no partner. Never expose its user bytes on a rerun.
ambiguous = {
"unmatched-inner-begin": begin + prefix + foreign_begin + suffix + end,
"unmatched-foreign-end": begin + prefix + foreign_end + suffix + end,
"inner-begin-extra-end": begin + prefix + foreign_begin + end + suffix + end,
"nested-with-stray-end": (begin + prefix + foreign_begin + end
+ foreign_end + suffix + end),
"overlap-chain": (begin + prefix + foreign_begin + end + begin
+ foreign_end + suffix + end),
"ambiguous-legacy-signature": (begin + prefix + foreign_end
+ LEGACY + suffix + end),
"oversized-ambiguous-span": (begin + prefix + foreign_end
+ b"# user\n" * 33 + suffix + end),
}
cases = [(name, fragment, fragment) for name, fragment in ambiguous.items()]
# An unclosed old BEGIN is not an enclosing range. Its prefix
# must survive, but independent complete blocks must still migrate.
cases.extend([
("orphan-before-same-family", begin + prefix + begin + b"old\n" + end + suffix,
begin + prefix + suffix),
("orphan-before-other-family", begin + prefix + foreign_begin + b"old\n"
+ foreign_end + suffix, begin + prefix + suffix),
("missing-outer-end", begin + prefix + foreign_begin + b"old\n"
+ foreign_end + suffix + foreign_end, begin + prefix + suffix + foreign_end),
("separated-valid-blocks", foreign_end + prefix + begin + b"old\n" + end
+ suffix + foreign_begin + b"old\n" + foreign_end + foreign_begin,
foreign_end + prefix + suffix + foreign_begin),
("limit-32", begin + b"# old\n" * 31 + end + suffix, suffix),
("over-limit-33", begin + b"# user\n" * 32 + end + suffix,
begin + b"# user\n" * 32 + end + suffix),
])
for name, original, preserved in cases:
with self.subTest(flow=flow, family=begin, case=name):
run = self.run_flow(flow, original, times=3)
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
self.assertEqual(run["passes"], [preserved + canonical] * 3)
self.assertEqual(run["backup"], original)
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["leftovers"], [])
def test_path_override_and_empty_install_remain_idempotent(self):
for flow in FLOWS:
for style in ("short", "full"):
with self.subTest(flow=flow, style=style):
run = self.run_flow(flow, b"", times=2, style=style)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
self.assertEqual(run["passes"][0], run["passes"][1])
self.assertIn(rb"\w" if style == "full" else rb"\W", run["passes"][0])
def test_preparation_and_commit_failures_leave_bashrc_untouched(self):
failures = {
"inspect": 'python3() { [[ "$3" == inspect ]] && return 71; command python3 "$@"; }',
"migrate": 'python3() { [[ "$3" == migrate ]] && return 71; command python3 "$@"; }',
"replace": '''python3() {
command python3 -c 'import os, sys
sys.argv = sys.argv[1:]
def fail(*args):
raise OSError("injected replace failure")
os.replace = fail
exec(sys.stdin.read())' "$@"
}''',
"backup": 'cp() { [[ "${@: -1}" == *.bak ]] && return 71; command cp "$@"; }',
"mktemp": 'mktemp() { return 71; }',
"copy": 'cp() { if [[ "$1" == -p ]]; then printf partial > "$3"; return 71; fi; command cp "$@"; }',
"profile": 'pmx_append_file() { return 71; }',
"commit": 'mv() { [[ "${@: -1}" == "$FIXTURE_ROOT/.bashrc" ]] && return 71; command mv "$@"; }',
}
original = b"# user\n" + LEGACY
for flow in FLOWS:
for failure, injection in failures.items():
with self.subTest(flow=flow, failure=failure):
run = self.run_flow(flow, original, injection=injection)
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["registry"], b"")
self.assertEqual(run["messages"], b"")
self.assertEqual(run["leftovers"], [])
self.assertFalse(any(e["target"].endswith("/.bashrc") for e in run["journal"]))
with self.subTest(flow=flow, failure="invalid-style"):
run = self.run_flow(flow, original, style="invalid")
self.assertNotEqual(run["status"], [b"0"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["registry"], b"")
def test_migration_preserves_unrelated_non_utf8_bytes_and_line_endings(self):
prefix = b"# user bytes: \xff\r\n"
suffix = b"user_function() { :; }\r\n"
original = prefix + BEGIN + b"old block\n" + END + suffix
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
self.assertTrue(run["passes"][0].startswith(prefix + suffix))
self.assertEqual(run["passes"][0], run["passes"][1])
self.assertEqual(run["before"], run["after"])
def test_failed_block_write_preserves_original_without_success(self):
original = b"# user content\n" + BEGIN + b"old managed block\n" + END
# Fail the real stdin writer after a partial canonical-block write.
# The override is confined to this shell fixture, not production.
injection = r'''
cat() {
if [[ $# == 0 ]]; then
local first
IFS= read -r first
if [[ "$first" == '# BEGIN PMX_CORE_BASHRC' ]]; then
printf '# partial block'
return 71
fi
printf '%s\n' "$first"
fi
command cat "$@"
}
'''
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, injection=injection)
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
self.assertEqual(run["passes"], [original])
self.assertEqual(run["before"], run["after"])
self.assertEqual(run["inode_before"], run["inode_after"])
self.assertEqual(run["registry"], b"")
self.assertEqual(run["messages"], b"")
self.assertEqual(run["leftovers"], [])
def test_orphan_begin_cannot_consume_user_content_on_rerun(self):
original = BEGIN + b"user_function() { :; }\n# user suffix\n"
for flow in FLOWS:
with self.subTest(flow=flow):
run = self.run_flow(flow, original, times=2)
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
for content in run["passes"]:
self.assertTrue(content.startswith(original), content)
self.assertEqual(run["passes"][0], run["passes"][1])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,211 @@
"""Offline regression tests for the Monitor catalog generator."""
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
from contextlib import redirect_stdout, redirect_stderr
SCRIPT = Path(__file__).parents[1] / "build_i18n_messages.py"
SPEC = importlib.util.spec_from_file_location("build_i18n_messages", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class I18nMessagesTests(unittest.TestCase):
def run_generator(self, source, target, *options, provider=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
en = root / "en" / "common.json"
it = root / "it" / "common.json"
en.parent.mkdir()
it.parent.mkdir()
en.write_text(json.dumps(source), encoding="utf-8")
it.write_text(json.dumps(target), encoding="utf-8")
before = it.read_bytes()
argv = [str(SCRIPT), "--source", str(en), "--messages-dir", str(root),
"--languages", "it", "--sleep", "0", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), mock.patch.object(
MODULE, "translate_one", side_effect=provider or (lambda text, *args: "IT " + text)
) as translate, redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
return result, json.loads(it.read_text()), translate.call_args_list, output.getvalue(), before == it.read_bytes()
def test_generator_fills_array_leaves_preserving_existing_values(self):
source = {"items": ["One", {"title": "Two", "children": ["Three"]}],
"empty": [], "object": {}, "enabled": True, "count": 2, "none": None}
target = {"items": ["Uno"], "extra": {"0": "Keep"}}
result, actual, calls, _, _ = self.run_generator(source, target, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(actual, {**source, "items": ["Uno", {"title": "IT Two", "children": ["IT Three"]}],
"extra": {"0": "Keep"}})
self.assertEqual([call.args[0] for call in calls], ["Two", "Three"])
def test_wrong_types_defer_locale_without_calls_or_writes_even_on_refresh(self):
source = {"new": "New", "terminal": {"commandDescriptions": ["One", "Two"]}}
for malformed in ["['Uno', 'Due']", '["Uno", "Due"]', "ambiguous human text", {}, None, 7]:
for options in [(), ("--refresh",)]:
with self.subTest(malformed=malformed, options=options):
target = {"terminal": {"commandDescriptions": malformed}, "human": "Keep"}
result, actual, calls, output, unchanged = self.run_generator(source, target, *options)
self.assertEqual(result, 1)
self.assertEqual(actual, target)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
self.assertIn("/terminal/commandDescriptions", output)
self.assertIn("manual", output)
def test_structure_only_catalog_is_written_without_translation(self):
source = {"items": ["", [], {}, None, False, 0]}
result, actual, calls, _, _ = self.run_generator(source, {})
self.assertEqual(result, 0)
self.assertEqual(actual, source)
self.assertEqual(calls, [])
def test_limit_keeps_array_positions_as_null_fallbacks(self):
result, actual, calls, _, _ = self.run_generator(
{"items": ["One", "", "Three", "Four"]}, {}, "--limit", "1")
self.assertEqual(actual, {"items": ["IT One", "", None, None]})
self.assertEqual(len(calls), 1)
def test_existing_nonempty_including_english_and_stale_text_is_unchanged(self):
source = {"items": ["Changed English", "CPU", "Three"]}
target = {"items": ["Human translation", "CPU", "Tre", "Extra"]}
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(actual, target)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
def test_second_run_retries_null_placeholders_preserving_translations(self):
source = {"items": ["One", "Two", "", "Three", "Four"]}
target = {"items": ["Human"], "extra": "Keep"}
result, first, calls, _, _ = self.run_generator(source, target, "--limit", "1")
self.assertEqual(result, 0)
self.assertEqual(first, {"items": ["Human", "IT Two", "", None, None], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Two"])
result, second, calls, _, _ = self.run_generator(source, first, "--save-every", "1")
self.assertEqual(result, 0)
self.assertEqual(second, {"items": ["Human", "IT Two", "", "IT Three", "IT Four"], "extra": "Keep"})
self.assertEqual([call.args[0] for call in calls], ["Three", "Four"])
def test_refresh_and_provider_failure_keep_existing_fallback_semantics(self):
def provider(text, *args):
if text == "Two":
raise RuntimeError("offline fixture failure")
return "IT " + text
# --refresh alone never replaces an existing translation.
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--save-every", "1", provider=provider)
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["Uno", "Due", "IT Three"]})
self.assertEqual(len(calls), 1)
# Named keys are retranslated; a provider failure keeps the value.
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as keys:
keys.write("items.0\nitems.1\n")
try:
result, actual, calls, output, _ = self.run_generator(
{"items": ["One", "Two", "Three"]}, {"items": ["Uno", "Due"]},
"--refresh", "--refresh-keys", keys.name, "--save-every", "1", provider=provider)
finally:
Path(keys.name).unlink()
self.assertEqual(result, 0)
self.assertEqual(actual, {"items": ["IT One", "Due", "IT Three"]})
self.assertEqual(len(calls), 3)
self.assertIn("1 translation failures", output)
def test_null_and_empty_string_are_filled_and_tokens_restored(self):
source = {"items": ["Hello {vmid}", "Use Proxmox"]}
result, actual, calls, _, _ = self.run_generator(source, {"items": [None, ""]})
self.assertEqual(actual, {"items": ["IT Hello {vmid}", "IT Use Proxmox"]})
self.assertNotIn("{vmid}", calls[0].args[0])
self.assertNotIn("Proxmox", calls[1].args[0])
def test_root_pointer_is_empty_not_empty_key_pointer(self):
for source, target, pointer in [({"a": "Text"}, [], 'JSON Pointer "" (root)'),
({"": []}, {"": "Text"}, 'JSON Pointer "/"'),
({"a~/": []}, {"a~/": "Text"}, 'JSON Pointer "/a~0~1"')]:
with self.subTest(pointer=pointer):
result, _, calls, output, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertEqual(calls, [])
self.assertTrue(unchanged)
self.assertIn(pointer + ": expected", output)
def test_wrong_scalar_and_nested_types_are_rejected(self):
for source, target in [({"a": "Text"}, {"a": 42}),
({"a": [{"b": "Text"}]}, {"a": ["human"]}),
({"a": False}, {"a": 0}),
({"a": {}}, {"a": []})]:
with self.subTest(source=source):
result, actual, calls, _, unchanged = self.run_generator(source, target)
self.assertEqual(result, 1)
self.assertTrue(unchanged)
self.assertEqual(calls, [])
def test_real_english_array_round_trip_and_generation(self):
catalog = json.loads((SCRIPT.parents[2] / "AppImage/messages/en/common.json").read_text())
descriptions = catalog["terminal"]["commandDescriptions"]
self.assertIsInstance(descriptions, list)
self.assertTrue(descriptions)
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
result, actual, calls, _, _ = self.run_generator({"terminal": {"commandDescriptions": descriptions}}, {})
self.assertEqual(len(calls), sum(isinstance(text, str) and bool(text) for text in descriptions))
self.assertEqual(actual["terminal"]["commandDescriptions"], ["IT " + text for text in descriptions])
def test_preflight_skips_invalid_locales_but_processes_valid_locales(self):
for options in [(), ("--refresh",)]:
with self.subTest(options=options), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalogs = {"en": {"items": ["One", "Two"]},
"sk": {"items": ["Human"]},
"it": {"items": "Uno"}, "de": {"items": {}}}
for lang, data in catalogs.items():
path = root / lang / "common.json"
path.parent.mkdir()
path.write_text(json.dumps(data))
before = {lang: (root / lang / "common.json").read_bytes() for lang in catalogs}
reads = []
real_read = MODULE.read_json
def read(path):
reads.append(path.parent.name)
return real_read(path)
def translate(text, lang, *args):
self.assertEqual(reads, ["en", "sk", "it", "de"])
self.assertEqual(lang, "sk")
return "SK " + text
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", "sk,it,de",
"--sleep", "0", "--save-every", "1", *options]
output = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "read_json", side_effect=read), \
mock.patch.object(MODULE, "translate_one", side_effect=translate) as provider, \
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as writer, \
redirect_stderr(output), redirect_stdout(output):
self.assertEqual(MODULE.main(), 1)
# --refresh without --refresh-keys keeps the existing value.
self.assertEqual(json.loads((root / "sk/common.json").read_text()),
{"items": ["Human", "SK Two"]})
self.assertEqual(provider.call_count, 1)
self.assertTrue(all(call.args[0].parent.name == "sk" for call in writer.call_args_list))
for lang in ["en", "it", "de"]:
self.assertEqual((root / lang / "common.json").read_bytes(), before[lang])
self.assertIn("Partial failure: skipped 2 locale(s): it, de", output.getvalue())
self.assertNotIn("generated successfully", output.getvalue())
def test_round_trip_preserves_json_types_and_literal_keys(self):
catalog = {"terminal": {"commandDescriptions": ["One", {"nested": ["Two", [], {}]}]},
"0": {"a.b": "literal", "": None}, "flags": [True, False, 2, 1.5]}
self.assertEqual(MODULE.unflatten(MODULE.flatten(catalog)), catalog)
if __name__ == "__main__":
unittest.main()
@@ -58,7 +58,125 @@ class WebDocsI18nTests(unittest.TestCase):
def test_human_translation_is_not_pending(self): def test_human_translation_is_not_pending(self):
source = {"title": "Updates", "url": "https://example.com"} source = {"title": "Updates", "url": "https://example.com"}
target = {"title": "Actualizaciones", "url": "https://example.com"} target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), []) self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
for key, value in (("tone", "amber"), ("variant", "source"),
("accent", "border-blue-300 bg-blue-50"),
("iconBg", "bg-blue-100 text-blue-700"),
("color", "green")):
with self.subTest(key=key):
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
self.assertFalse(MODULE.should_copy("English", ("lang",)))
for value in ("loader", "dedicated"):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
self.assertFalse(MODULE.should_copy(value, ("label",)))
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
for source in ("<code>apt update</code>",
"<code>net0</code> / <code>net1</code>.",
"/api/task-log/&lt;upid&gt;", "/api/vms/<vmid>/config"):
with self.subTest(source=source):
self.assertTrue(MODULE.should_copy(source, ("cell",)))
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at &lt;host&gt;."
protected, mapping = MODULE.protect_text(source)
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "&lt;", "&gt;"):
self.assertIn(literal, mapping.values())
self.assertNotIn(literal, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_custom_code_spans_keep_commands_literal(self):
for tag in ("codeNw", "codeXs"):
source = f"Run <{tag}>pct config 100</{tag}>."
with self.subTest(tag=tag):
protected, mapping = MODULE.protect_rich_tags(source)
self.assertEqual(protected, source)
self.assertEqual(mapping, {})
with self.assertRaisesRegex(ValueError, "literal code changed"):
MODULE.validate_contract(source, source.replace("config", "configuration"))
with self.assertRaisesRegex(ValueError, "numeric residue"):
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
values = {
"categorySlug": ["system", "network"],
"menuCmd": ["menu"],
"branch": ["main", "develop", "feature/*"],
"kind": ["internal", "external"],
"key": ["nas", "linux", "windows"],
}
for key, items in values.items():
for value in items:
with self.subTest(key=key, value=value):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
def test_bare_sentinel_with_added_digit_is_rejected(self):
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
token = next(iter(mapping))
with self.assertRaisesRegex(ValueError, "changed protected token"):
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
def test_numeric_residue_outside_intact_literal_is_rejected(self):
examples = [
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'numeric residue'):
MODULE.validate_contract(source, target)
MODULE.validate_contract('The result is <code>echo</code>0.',
'Výsledok je <code>echo</code>0.')
def test_product_and_kernel_module_names_are_protected(self):
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
protected, mapping = MODULE.protect_text(source)
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
self.assertIn(name, mapping.values())
self.assertNotIn(name, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "messages/en/common.json"
target = root / "messages/de/common.json"
source.parent.mkdir(parents=True)
target.parent.mkdir(parents=True)
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(json.loads(target.read_text()), {
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
})
provider.assert_not_called()
before = target.stat().st_mtime_ns
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(target.stat().st_mtime_ns, before)
provider.assert_not_called()
def test_changed_source_text_is_pending_even_with_existing_translation(self): def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"} source = {"title": "Updated installation guidance"}
@@ -67,7 +185,6 @@ class WebDocsI18nTests(unittest.TestCase):
leaves = MODULE.pending_leaves( leaves = MODULE.pending_leaves(
source, source,
target, target,
refresh=False,
forced_tokens={token}, forced_tokens={token},
) )
self.assertEqual([leaf.path for leaf in leaves], [("title",)]) self.assertEqual([leaf.path for leaf in leaves], [("title",)])
@@ -0,0 +1,38 @@
"""The Changes view files a change under the post-install section by the name
its script records itself with. Both post-install scripts have to be known to
it, or what one of them changes is shown as the work of an unnamed script."""
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[3]
VIEW = (ROOT / "AppImage/components/audit-changes.tsx").read_text()
SCRIPTS = ("scripts/post_install/auto_post_install.sh", "scripts/post_install/customizable_post_install.sh")
class PostInstallSourcesTests(unittest.TestCase):
def setUp(self):
declared = re.search(r"const POST_INSTALL_SOURCES = new Set\(\[([^\]]*)\]\)", VIEW)
self.assertIsNotNone(declared)
self.known = set(re.findall(r'"([^"]+)"', declared.group(1)))
def test_every_post_install_script_is_known_by_the_name_it_records(self):
for script in SCRIPTS:
with self.subTest(script=script):
source = re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M)
self.assertIsNotNone(source)
self.assertIn(source.group(1), self.known)
def test_no_name_is_listed_that_no_script_records(self):
recorded = {re.search(r'^SCRIPT_SOURCE="([^"]+)"', (ROOT / script).read_text(), re.M).group(1)
for script in SCRIPTS}
self.assertEqual(self.known, recorded)
def test_figurine_and_fastfetch_have_a_name_in_the_view(self):
for function in ("configure_figurine", "configure_fastfetch"):
self.assertRegex(VIEW, rf'\b{function}: "')
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,140 @@
"""Exercise the Cloud-Init menu without network access or guest creation."""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SELECTOR = ROOT / "scripts/vm/select_linux_iso.sh"
class CloudInitLauncherTests(unittest.TestCase):
def run_menu(self, selection="2", body=None, curl_exit=0, dialog_exit=0,
installer_exit=0, stdin="\n"):
source = SELECTOR.read_text(encoding="utf-8")
match = re.search(r"(?ms)^function select_linux_cloudinit\(\) \{.*?^\}", source)
assert match is not None, "Cloud-Init menu function not found"
with tempfile.TemporaryDirectory(prefix="cloudinit-launcher-") as temporary:
work = Path(temporary)
(work / "menus").mkdir()
(work / "menus/create_vm_menu.sh").write_text(
'printf "menu-returned\\n" >> "$ACTION_LOG"\n', encoding="utf-8")
environment = os.environ.copy()
environment.update({
"LOCAL_SCRIPTS": str(work), "SELECTION": selection,
"DIALOG_EXIT": str(dialog_exit), "CURL_EXIT": str(curl_exit),
"INSTALLER_EXIT": str(installer_exit),
"CURL_BODY": body if body is not None else
'printf "helper-executed\\n" >> "$ACTION_LOG"\nexit "$INSTALLER_EXIT"\n',
"ACTION_LOG": str(work / "actions"),
"CURL_ARGS": str(work / "curl-args"),
"DIALOG_LOG": str(work / "dialog-args"),
})
prelude = r'''
translate() { printf '%s' "$1"; }
msg_success() { printf 'continue-prompt\n' >> "$ACTION_LOG"; }
whiptail() { printf 'website-prompt\n' >> "$ACTION_LOG"; }
dialog() {
printf '%s\n' "$@" >> "$DIALOG_LOG"
if [[ " $* " == *" --menu "* ]]; then
printf '%s' "$SELECTION" >&2
return "$DIALOG_EXIT"
fi
}
curl() {
printf '%s\n' "$@" > "$CURL_ARGS"
printf '%s' "$CURL_BODY"
return "$CURL_EXIT"
}
'''
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c",
prelude + match.group() + "\nselect_linux_cloudinit\n"],
input=stdin, capture_output=True, text=True, env=environment,
timeout=5)
def contents(name):
path = work / name
return path.read_text(encoding="utf-8") if path.exists() else ""
return result, contents("curl-args"), contents("dialog-args"), contents("actions")
def test_debian_uses_the_maintained_generic_installer(self):
result, curl_args, _, actions = self.run_menu()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(curl_args.splitlines()[-1],
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh")
self.assertIn("helper-executed", actions)
def test_ubuntu_uses_one_generic_entry_without_obsolete_release_labels(self):
result, curl_args, menu, actions = self.run_menu(selection="3")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(curl_args.splitlines()[-1],
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh")
self.assertIn("helper-executed", actions)
self.assertEqual(menu.count("(Cloud-Init automated)"), 3)
for obsolete in ("Debian 13", "Debian 12", "Ubuntu 22.04", "Ubuntu 24.04",
"Ubuntu 24.10", "Ubuntu 25.04"):
self.assertNotIn(obsolete, menu)
def test_failed_or_empty_download_never_runs_a_script(self):
for body, exit_code in (("404: Not Found", 22),
('printf "helper-executed\\n" >> "$ACTION_LOG"', 28),
("", 0), (" \n\t", 0)):
with self.subTest(body=body, exit_code=exit_code):
result, _, messages, actions = self.run_menu(body=body, curl_exit=exit_code)
self.assertNotIn("helper-executed", actions)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Error: Failed to download the script.", messages)
self.assertNotIn("menu-returned", actions)
def test_installer_failure_returns_its_status_without_success_navigation(self):
result, _, _, actions = self.run_menu(installer_exit=17)
self.assertEqual(result.returncode, 17, result.stderr)
self.assertIn("helper-executed", actions)
self.assertNotIn("continue-prompt", actions)
self.assertNotIn("website-prompt", actions)
self.assertNotIn("menu-returned", actions)
def test_arch_and_successful_menu_navigation_are_preserved(self):
result, curl_args, _, actions = self.run_menu(selection="1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(curl_args.splitlines()[-1],
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh")
self.assertEqual(actions.splitlines(),
["helper-executed", "continue-prompt", "website-prompt", "menu-returned"])
arguments = curl_args.splitlines()
self.assertIn("-fsSL", arguments)
self.assertEqual(arguments[arguments.index("--connect-timeout") + 1], "5")
self.assertEqual(arguments[arguments.index("--max-time") + 1], "60")
def test_return_and_cancel_do_not_download_or_execute(self):
for selection, status, expected in (("4", 0, 1), ("2", 1, 0), ("2", 255, 0)):
with self.subTest(selection=selection, status=status):
result, curl_args, _, actions = self.run_menu(
selection=selection, dialog_exit=status)
self.assertEqual(result.returncode, expected, result.stderr)
self.assertEqual(curl_args, "")
self.assertEqual(actions, "")
def test_unknown_selection_is_rejected(self):
result, curl_args, _, actions = self.run_menu(selection="999")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(curl_args, "")
self.assertEqual(actions, "")
def test_installer_keeps_interactive_standard_input(self):
body = 'IFS= read -r answer; printf "input:%s\\n" "$answer" >> "$ACTION_LOG"\n'
result, _, _, actions = self.run_menu(body=body, stdin="answer with spaces\n\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("input:answer with spaces\n", actions)
self.assertIn("menu-returned", actions)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,219 @@
"""Frozen conservative recovery: update the fixture only after explicit review.
Run offline with Python unittest and Node >=22.14 on PATH (or NODE_BINARY).
The consumer test executes only extracted pure lookup functions, not the UI.
"""
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from contextlib import redirect_stdout, redirect_stderr
from unittest import mock
from test_build_i18n_messages import MODULE, SCRIPT
ROOT = SCRIPT.parents[2]
FIXTURE = json.loads((Path(__file__).parent / "fixtures/command_descriptions.json").read_text())
def require_node():
diagnostic = "Node >=22.14 is required; install Node 22 LTS or set NODE_BINARY to its executable"
node = os.environ.get("NODE_BINARY") or shutil.which("node")
if not node:
raise AssertionError(diagnostic)
try:
result = subprocess.run([node, "--version"], capture_output=True, text=True)
except OSError as exc:
raise AssertionError(f"{diagnostic}; cannot execute {node}: {exc}") from exc
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", result.stdout.strip())
if result.returncode or not version or tuple(map(int, version.groups())) < (22, 14, 0):
raise AssertionError(f"{diagnostic}; found {result.stdout.strip() or result.stderr.strip()!r}")
return node
def catalog(lang):
return json.loads((ROOT / f"AppImage/messages/{lang}/common.json").read_text())
class NodeRuntimeTests(unittest.TestCase):
def test_missing_node_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"PATH": "", "NODE_BINARY": ""}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_invalid_override_reports_setup_instructions(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "/nonexistent/f05-node"}):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*NODE_BINARY"):
require_node()
def test_older_node_reports_detected_version(self):
with mock.patch.dict(os.environ, {"NODE_BINARY": "node"}), \
mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess(
["node", "--version"], 0, "v20.19.0\n", "")):
with self.assertRaisesRegex(AssertionError, r"Node >=22\.14.*v20\.19\.0"):
require_node()
class CommandDescriptionsTests(unittest.TestCase):
def test_real_catalogs_exact_indexed_recovery_and_source_order(self):
self.assertEqual(catalog("en")["terminal"]["commandDescriptions"], FIXTURE["english"])
consumer = (ROOT / "AppImage/components/terminal-panel.tsx").read_text()
commands = re.search(r"const proxmoxCommands = (\[.*?\])", consumer, re.S).group(1)
commands = json.loads(re.sub(r",\s*\]$", "]", commands))
self.assertEqual(commands, FIXTURE["commands"])
self.assertIn('desc: t(`terminal.commandDescriptions.${index}`)', consumer)
for lang, expected in FIXTURE["locales"].items():
with self.subTest(locale=lang):
actual = catalog(lang)["terminal"]["commandDescriptions"]
self.assertIsInstance(actual, list)
self.assertEqual(len(actual), len(commands))
self.assertEqual(actual, expected)
self.assertTrue(all(isinstance(x, str) and x for x in actual))
# Explicit English recovery values are frozen locale text, not live fallback.
for index in FIXTURE["fallback_indices"]:
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
FIXTURE["locales"]["sv"][index])
def assert_completed_catalogs_are_stable(self, *, future_messages=None):
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
locales = ["en", *languages.split(",")]
shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales}
original_bytes = {path: path.read_bytes() for path in shipped}
def strings(node, path=()):
# Independent traversal: every existing nonempty string, including
# explicit English and obsolete human keys, must survive completion.
if isinstance(node, str) and node:
yield path, node
elif isinstance(node, dict):
for key, value in node.items():
yield from strings(value, (*path, key))
elif isinstance(node, list):
for index, value in enumerate(node):
yield from strings(value, (*path, index))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
for path in shipped:
destination = root / path.parent.name / "common.json"
destination.parent.mkdir()
shutil.copyfile(path, destination)
if future_messages is not None:
source = json.loads((root / "en/common.json").read_text())
source["futureFeatureFixture"] = future_messages
(root / "en/common.json").write_text(json.dumps(source))
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
output = io.StringIO()
# Establish steady state with the real generator and a deterministic
# offline provider. Translation happens only in disposable catalogs;
# adding an EN-only feature never needs a hand-maintained seed list.
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \
redirect_stdout(output), redirect_stderr(output):
self.assertEqual(MODULE.main(), 0, output.getvalue())
self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures")
self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list))
for path, original in shipped.items():
completed = json.loads((root / path.parent.name / "common.json").read_text())
for keys, value in strings(original):
actual = completed
for key in keys:
actual = actual[key]
self.assertEqual(actual, value, f"{path.parent.name}: {keys}")
# The recovered arrays remain exact, including frozen English.
if path.parent.name != "en":
self.assertEqual(completed["terminal"]["commandDescriptions"],
FIXTURE["locales"].get(path.parent.name,
original["terminal"]["commandDescriptions"]))
if future_messages is not None and path.parent.name != "en":
self.assertEqual(completed["futureFeatureFixture"], {
"title": f"TEST {path.parent.name} Future feature {{count}}",
"items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}],
"enabled": False, "count": 2, "empty": [],
})
if future_messages is not None:
self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(",")))
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
output = io.StringIO()
# Keep the original strict assertion: once completed, any provider
# call or catalog write is a regression. No production locale edits.
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
redirect_stdout(output), redirect_stderr(output):
result = MODULE.main()
self.assertEqual(result, 0, output.getvalue())
provider.assert_not_called()
writer.assert_not_called()
self.assertEqual(before, {p: p.read_bytes() for p in before})
for lang in languages.split(","):
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped})
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
self.assert_completed_catalogs_are_stable()
def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self):
self.assert_completed_catalogs_are_stable(future_messages={
"title": "Future feature {count}",
"items": ["First item", {"label": "Second item"}],
"enabled": False, "count": 2, "empty": [],
})
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
node = require_node()
source = (ROOT / "AppImage/lib/i18n/provider.tsx").read_text()
functions = source[source.index("function getMessage("):source.index("export function I18nProvider(")]
lookup = re.search(r" const localized =.*?return interpolate\(.*?\)\n", source, re.S).group(0)
catalogs = {lang: catalog(lang) for lang in ["en", *FIXTURE["locales"]]}
script = ('import assert from "node:assert/strict";\n'
'type MessageTree = Record<string, unknown>;\n'
'type TranslationParams = Record<string, string | number>;\n' + functions +
'\nconst MESSAGE_CATALOG = ' + json.dumps(catalogs) + ';\n' +
'function t(language: string, key: string, params?: TranslationParams) {\n' + lookup + '}\n' +
'const fixture = ' + json.dumps(FIXTURE) + ';\n' + '''
fixture.english.forEach((text, i) => {
assert.equal(t("en", `terminal.commandDescriptions.${i}`), text);
});
for (const [lang, texts] of Object.entries(fixture.locales)) {
texts.forEach((text, i) => {
const key = `terminal.commandDescriptions.${i}`;
assert.equal(getMessage(MESSAGE_CATALOG[lang], key), text);
assert.equal(t(lang, key), text);
});
}
for (const i of fixture.fallback_indices) {
const key = `terminal.commandDescriptions.${i}`;
const selected = MESSAGE_CATALOG.sv.terminal.commandDescriptions[i];
MESSAGE_CATALOG.en.terminal.commandDescriptions[i] = "CHANGED EN";
assert.equal(t("sv", key), selected); // Explicit English is a locale value.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = null;
assert.equal(t("sv", key), "CHANGED EN"); // Null would dynamically fall back.
MESSAGE_CATALOG.sv.terminal.commandDescriptions[i] = "";
assert.equal(t("sv", key), ""); // Empty strings do NOT fall back.
}
assert.equal(t("sv", "missing.key"), "missing.key");
console.log("390 indexed consumer lookups; 15 explicit-English/null/empty cases passed");
''')
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "consumer.ts"
path.write_text(script)
result = subprocess.run([node, "--experimental-strip-types", str(path)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("390 indexed consumer lookups", result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,16 @@
"""Keep the canonical conduct policy and its English website source in sync."""
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
class DocumentationMarkdownTests(unittest.TestCase):
def test_english_conduct_body_matches_canonical_policy(self):
source = json.loads((ROOT / 'web/messages/en/docs/about/code-of-conduct.json').read_text(encoding='utf-8'))
self.assertEqual(source['contentMarkdown'], (ROOT / 'CODE_OF_CONDUCT.md').read_text(encoding='utf-8'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,30 @@
"""Keep machine generation explicit without losing automatic validation."""
from pathlib import Path
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[3]
class DocumentationReviewWorkflowTests(unittest.TestCase):
def workflow(self, name):
return yaml.load(
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
Loader=yaml.BaseLoader,
)
def test_generation_requires_explicit_dispatch(self):
triggers = self.workflow('build-web-docs-i18n.yml')['on']
self.assertEqual(set(triggers), {'workflow_dispatch'})
def test_website_changes_keep_automatic_offline_validation(self):
triggers = self.workflow('test-i18n.yml')['on']
for event in ('pull_request', 'push'):
with self.subTest(event=event):
self.assertIn('web/**', triggers[event]['paths'])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,172 @@
"""Inert fencing wording contract: execute extracted producers and template renderer."""
import ast
import json
import re
import time
import unittest
from typing import Any
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
EVENTS = ROOT / 'AppImage/scripts/notification_events.py'
TEMPLATES = ROOT / 'AppImage/scripts/notification_templates.py'
MESSAGES = ROOT / 'AppImage/messages'
FIELDS = ('title', 'body', 'label')
def extract_method(path, owner, method, namespace):
tree = ast.parse(path.read_text(encoding='utf-8'))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == method)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), namespace)
return namespace[method]
def notification_renderer(catalogs):
tree = ast.parse(TEMPLATES.read_text(encoding='utf-8'))
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'render_template')
def lookup(obj, dotted):
for part in dotted.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
namespace = {'TEMPLATES': templates, 'Dict': dict, 'Any': object,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: catalogs[lang]['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': lambda key, lang, **kw: key,
'time': time, 're': re}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(TEMPLATES), 'exec'), namespace)
return templates, namespace['render_template']
class FencingWordingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalogs = {p.parent.name: json.loads(p.read_text(encoding='utf-8'))
for p in MESSAGES.glob('*/common.json')}
cls.templates, render = notification_renderer(cls.catalogs)
cls.render = staticmethod(render)
def test_webhook_fencing_renders_without_split_brain_or_quorum_diagnosis(self):
namespace: dict[str, Any] = {'re': re}
classify = extract_method(EVENTS, 'ProxmoxHookWatcher', '_classify_pve', namespace)
severity = extract_method(EVENTS, 'ProxmoxHookWatcher', '_map_severity', namespace)
namespace['capture_journal_context'] = lambda **kw: ''
class Event:
def __init__(self, **kw):
self.__dict__.update(kw)
self.event_id = 'inert-id'
class Queue:
def __init__(self): self.items = []
def put(self, item): self.items.append(item)
namespace['NotificationEvent'] = Event
webhook = extract_method(EVENTS, 'ProxmoxHookWatcher', 'process_webhook', namespace)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity)
def __init__(self): self._queue = Queue()
receiver = Receiver()
for fields in ({'type': 'fencing'}, {'type': 'fencing', 'quorum': 'lost'}):
result = webhook(receiver, {'fields': fields, 'severity': 'warning',
'title': 'Fence requested', 'message': 'Check node state'})
self.assertEqual(result['event_type'], 'split_brain')
event = receiver._queue.items.pop()
self.assertEqual(event.severity, 'WARNING')
self.assertNotIn('quorum', event.data)
for lang, catalog in self.catalogs.items():
rendered = self.render(event.event_type, event.data, lang)
expected = catalog['runtime']['notifications']['templates']['split_brain']
self.assertEqual(rendered['title'], expected['title'].format(hostname='node-a'), lang)
# The body names the source event, as received, and adds no diagnosis of its own.
self.assertEqual(rendered['body'], expected['body'].replace('{reason}', event.data['reason']), lang)
self.assertIn('Check node state', rendered['body'], lang)
self.assertNotIn('split-brain', '\n'.join((rendered['title'], rendered['body'])).lower(), lang)
self.assertNotIn('quorum', rendered['body'].lower(), lang)
self.assertEqual(webhook(receiver, {'type': 'fencing', 'severity': 'warning',
'title': 'Unclassified', 'message': 'Other'})['event_type'], 'system_problem')
self.assertEqual(self.templates['split_brain']['group'], 'cluster')
self.assertIs(self.templates['split_brain']['default_enabled'], True)
def test_journal_family_uses_same_truthful_wording(self):
tree = ast.parse(EVENTS.read_text(encoding='utf-8'))
owner = next(n.name for n in tree.body if isinstance(n, ast.ClassDef)
and any(isinstance(m, ast.FunctionDef) and m.name == '_check_cluster_events' for m in n.body))
namespace: dict[str, Any] = {'re': re}
method = extract_method(EVENTS, owner, '_check_cluster_events', namespace)
class Journal:
_hostname = 'node-a'
def __init__(self): self.events = []
def _emit(self, *args, **kwargs): self.events.append((args, kwargs))
for message in ('fencing required for node', 'cluster partition suspected', 'split-brain reported'):
journal = Journal()
method(journal, message, 'syslog')
args, _ = journal.events[0]
self.assertEqual(args[0], 'split_brain')
for lang, catalog in self.catalogs.items():
rendered = self.render(args[0], args[2], lang)
body = catalog['runtime']['notifications']['templates']['split_brain']['body']
self.assertEqual(rendered['body'], body.replace('{reason}', message), lang)
journal = Journal()
method(journal, 'quorum lost', 'syslog')
self.assertEqual(journal.events[0][0][0], 'node_disconnect')
def test_english_family_and_settings_label_align_without_changing_key(self):
en = self.catalogs['en']
self.assertEqual(self.templates['split_brain']['label'], 'Cluster event')
self.assertEqual(en['runtime']['notifications']['templates']['split_brain']['label'], 'Cluster event')
self.assertEqual(en['settings']['notifications']['eventTypes']['split_brain'], 'Cluster events')
settings = (ROOT / 'AppImage/components/notification-settings.tsx').read_text(encoding='utf-8')
self.assertIn('settings.notifications.eventTypes.${evt.type}', settings)
self.assertIn('evt.title : translated', settings)
for field in FIELDS:
self.assertEqual(en['runtime']['notifications']['templates']['split_brain'][field],
self.templates['split_brain'][field])
def test_every_shipped_locale_renders_cluster_event_without_diagnosis(self):
expected = {
'de': ('{hostname}: Clusterereignis gemeldet', 'Ein Clusterereignis wurde gemeldet:\n{reason}', 'Clusterereignis', 'Clusterereignisse'),
'es': ('{hostname}: evento del clúster notificado', 'Se ha notificado un evento del clúster:\n{reason}', 'Evento del clúster', 'Eventos del clúster'),
'fr': ('{hostname}\u00a0: événement du cluster signalé', 'Un événement du cluster a été signalé\u00a0:\n{reason}', 'Événement du cluster', 'Événements du cluster'),
'it': ('{hostname}: evento del cluster segnalato', 'È stato segnalato un evento del cluster:\n{reason}', 'Evento del cluster', 'Eventi del cluster'),
'pt': ('{hostname}: evento do cluster comunicado', 'Foi comunicado um evento do cluster:\n{reason}', 'Evento do cluster', 'Eventos do cluster'),
'sk': ('{hostname}: hlásená udalosť klastra', 'Bola hlásená udalosť klastra:\n{reason}', 'Udalosť klastra', 'Udalosti klastra'),
'sv': ('{hostname}: klusterhändelse rapporterad', 'En klusterhändelse har rapporterats:\n{reason}', 'Klusterhändelse', 'Klusterhändelser'),
}
self.assertEqual(set(self.catalogs), {'en', *expected})
for lang, (title, body, label, settings) in expected.items():
with self.subTest(lang=lang):
leaves = self.catalogs[lang]['runtime']['notifications']['templates']['split_brain']
self.assertEqual((leaves['title'], leaves['body'], leaves['label'],
self.catalogs[lang]['settings']['notifications']['eventTypes']['split_brain']),
(title, body, label, settings))
result = self.render('split_brain', {'reason': 'fencing node node-b'}, lang)
self.assertEqual(result['title'], title.format(hostname='node-a'))
self.assertEqual(result['body'], body.replace('{reason}', 'fencing node node-b'))
self.assertNotIn('quorum', result['body'].lower())
def test_fallback_and_translated_lookup_are_real_for_missing_and_synthetic_values(self):
import copy
catalogs = copy.deepcopy(self.catalogs)
for field in FIELDS:
catalogs['it']['runtime']['notifications']['templates']['split_brain'].pop(field)
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {'reason': 'fencing node node-b'}, 'it')['body'],
'A cluster event was reported:\nfencing node node-b')
catalogs['it']['runtime']['notifications']['templates']['split_brain']['body'] = 'Evento del cluster segnalato.'
_, render = notification_renderer(catalogs)
self.assertEqual(render('split_brain', {}, 'it')['body'], 'Evento del cluster segnalato.')
for lang, catalog in self.catalogs.items():
if lang == 'en': continue
leaves = catalog['runtime']['notifications']['templates']['split_brain']
self.assertTrue(all(leaves[field] for field in FIELDS), lang)
self.assertTrue(catalog['settings']['notifications']['eventTypes']['split_brain'], lang)
self.assertNotEqual(leaves['body'], self.catalogs['en']['runtime']['notifications']['templates']['split_brain']['body'], lang)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,99 @@
"""The menus that install drivers inside a container leave out the ones
created from an OCI image: those are changed from OCI manager Apps."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
MENUS = ('scripts/gpu_tpu/add_gpu_lxc.sh', 'scripts/gpu_tpu/install_coral_lxc.sh')
ORDINARY = "arch: amd64\ncores: 2\nhostname: debian\nostype: debian\nrootfs: local-lvm:vm-111-disk-0,size=8G\n"
OCI = ORDINARY + "entrypoint: /init\nlxc.signal.halt: SIGTERM\n"
def function(path, name):
source = (ROOT / path).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class GpuMenusHideOci(TestCase):
def is_oci(self, config):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / '111.conf').write_text(config)
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
return subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 111'], capture_output=True).returncode == 0
def test_a_container_from_an_oci_image_is_told_apart_by_its_configuration(self):
self.assertTrue(self.is_oci(OCI))
self.assertTrue(self.is_oci(ORDINARY + "cmd: nginx -g 'daemon off;'\n"))
self.assertFalse(self.is_oci(ORDINARY))
# A setting that only mentions the word is not an entrypoint.
self.assertFalse(self.is_oci(ORDINARY + "description: entrypoint: none\n"))
def test_a_container_without_configuration_is_not_one(self):
with tempfile.TemporaryDirectory() as folder:
helper = function('scripts/utils.sh', 'pmx_lxc_is_oci').replace('/etc/pve/lxc', folder)
self.assertNotEqual(subprocess.run(['bash', '-c', helper + 'pmx_lxc_is_oci 999'],
capture_output=True).returncode, 0)
def test_both_menus_skip_them_and_say_so(self):
for menu in MENUS:
body = function(menu, 'select_container')
self.assertLess(body.index('pmx_lxc_is_oci "$ctid"'), body.index('menu_items+=('), menu)
self.assertIn('Containers created from an OCI image are not listed:', body, menu)
self.assertEqual(body.count('${oci_note}'), 2, menu)
SWITCHES = (('scripts/gpu_tpu/switch_gpu_mode.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/switch_gpu_mode_direct.sh', 'apply_lxc_action_for_vm_mode', 'prompt_lxc_action_for_vm_mode'),
('scripts/gpu_tpu/add_gpu_vm.sh', 'cleanup_lxc_configs', None))
class GpuToVmKeepsOciConfiguration(TestCase):
"""A GPU that moves to a VM stops the containers that use it. One created
from an OCI image keeps its configuration, which OCI manager Apps records."""
def test_an_oci_container_is_never_edited(self):
for script, apply, _ in SWITCHES:
body = function(script, apply)
guard = body.index('pmx_lxc_is_oci "$ctid"')
self.assertLess(guard, body.index('action="keep_gpu_disable_onboot"'), script)
removal = body.index('"$action" == "remove_gpu_keep_onboot"')
self.assertLess(guard, removal, script)
# The only edit of a configuration hangs from the per-container action.
self.assertEqual(len(re.findall(r'_from_lxc_conf "\$', body)), 1, script)
edit = body.index('_from_lxc_conf "$')
condition = body.rindex('if [[', 0, edit)
self.assertIn('"$action" == "remove_gpu_keep_onboot"', body[condition:edit], script)
def test_the_list_names_them_and_nothing_is_asked_when_all_are_oci(self):
for script, _, prompt in SWITCHES:
source = (ROOT / script).read_text() if prompt is None else function(script, prompt)
self.assertIn('", OCI"', source, script)
self.assertIn('OCI containers keep their configuration', source, script)
self.assertIn('"$oci_count" -eq ${#LXC_AFFECTED_CTIDS[@]}', source, script)
class DiskAndSharePickersHideOci(TestCase):
"""Adding a disk or a mount, and the NFS and Samba set-ups, change the
container they are given. A container from an OCI image is not offered."""
def test_every_picker_filters_before_it_lists(self):
pickers = ((function('scripts/global/share-common.func', 'select_lxc_container'), 'options+=('),
(function('scripts/global/share-common.func', 'select_privileged_lxc'), 'ct_list+='),
(function('scripts/share/lxc-mount-manager_minimal.sh', 'select_lxc_container'), 'options+=('))
for body, listing in pickers:
self.assertLess(body.index('pmx_lxc_is_oci "$id"'), body.index(listing))
self.assertIn('Containers created from an OCI image are not listed', body)
disk = (ROOT / 'scripts/storage/disk-passthrough_ct.sh').read_text()
picker = disk[disk.index('CT_LIST=""'):disk.index('if [ -z "$CTID" ]')]
self.assertLess(picker.index('pmx_lxc_is_oci "$ct_id"'), picker.index('CT_LIST+='))
self.assertIn('${OCI_NOTE}" $UI_MENU_H $UI_MENU_W $CT_LIST_H', picker)
def test_a_disk_in_use_is_still_looked_for_in_every_container(self):
for script in ('scripts/storage/disk-passthrough_ct.sh', 'scripts/storage/disk-passthrough.sh'):
source = (ROOT / script).read_text()
check = source[source.index('disk_referenced_in_config "$CT_CONFIG_RAW" "$DISK"'):]
scan = check[:check.index("done < <(pct list | awk 'NR>1 {print $1, $3}')")]
self.assertNotIn('pmx_lxc_is_oci', scan, script)
@@ -0,0 +1,92 @@
"""The passthrough entries of a VM that belong to a GPU are the GPU, the audio
at its slot and the devices of its IOMMU group. Another function at the same
slot is a device of its own: on an AMD APU the USB controllers share the slot
of the integrated graphics, and a VM that only has one of them does not use
the GPU."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
HELPERS = ROOT / "scripts/global/pci_passthrough_helpers.sh"
SCRIPTS = [ROOT / "scripts/gpu_tpu" / name
for name in ("switch_gpu_mode.sh", "switch_gpu_mode_direct.sh", "add_gpu_vm.sh")]
IGPU = "0000:05:00.0"
# The functions of the slot of an AMD APU, and a discrete card.
CLASSES = {"0000:05:00.0": "0x030000", "0000:05:00.1": "0x040300", "0000:05:00.3": "0x0c0330",
"0000:05:00.4": "0x0c0330", "0000:05:00.6": "0x040300",
"0000:01:00.0": "0x030000", "0000:01:00.1": "0x040300"}
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class GpuEntriesTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.folder = Path(folder.name)
for bdf, value in CLASSES.items():
(self.folder / "sys" / bdf).mkdir(parents=True)
(self.folder / "sys" / bdf / "class").write_text(value + "\n")
def entries(self, config, *group, gpu=IGPU):
conf = self.folder / "vm.conf"
conf.write_text(config)
script = f'source "{HELPERS}"; _vm_gpu_hostpci_indexes "$1" "$2" "${{@:3}}"'
done = subprocess.run(["bash", "-c", script, "bash", str(conf), gpu, *group], capture_output=True, text=True,
env={"PMX_PCI_SYSFS": str(self.folder / "sys"), "PATH": "/usr/bin:/bin"}, check=False)
self.assertEqual(done.stderr, "")
return done.stdout.split()
def test_a_vm_with_only_a_usb_controller_of_that_slot_does_not_have_the_gpu(self):
self.assertEqual(self.entries("hostpci0: 0000:01:00.0,pcie=1,x-vga=1\nhostpci1: 0000:01:00.1,pcie=1\n"
"hostpci2: 0000:05:00.4,pcie=1\n"), [])
def test_the_gpu_goes_with_the_audio_of_its_slot_and_the_usb_stays(self):
self.assertEqual(self.entries("hostpci0: 0000:05:00.0,pcie=1,x-vga=1\nhostpci1: 0000:05:00.1,pcie=1\n"
"hostpci2: 0000:05:00.4,pcie=1\nhostpci3: 0000:05:00.6\n"), ["0", "1", "3"])
def test_a_device_of_the_same_iommu_group_goes_with_the_gpu(self):
self.assertEqual(self.entries("hostpci0: 0000:05:00.0\nhostpci1: 0000:05:00.4\n", "0000:05:00.4"), ["0", "1"])
def test_the_whole_slot_carries_the_gpu(self):
self.assertEqual(self.entries("hostpci0: 05:00,pcie=1\nhostpci1: 0000:01:00.0\n"), ["0"])
def test_several_devices_in_one_entry(self):
self.assertEqual(self.entries("hostpci4: 0000:05:00.0;0000:05:00.1,pcie=1\n"), ["4"])
def test_addresses_are_read_with_or_without_domain_and_in_any_case(self):
self.assertEqual(self.entries("hostpci0: 05:00.0,pcie=1\n", gpu="05:00.0"), ["0"])
self.assertEqual(self.entries("hostpci0: 0000:0A:00.0\n", gpu="0000:0a:00.0"), ["0"])
def test_another_gpu_and_a_slot_that_only_looks_alike_are_left(self):
# 00:02 inside 0000:02:00.0 is not the same device.
self.assertEqual(self.entries("hostpci0: 0000:02:00.0\n", gpu="0000:00:02.0"), [])
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\nhostpci1: 0000:05:00.0\n", gpu="0000:01:00.0"), ["0"])
def test_snapshots_and_resource_mappings_are_not_read(self):
self.assertEqual(self.entries("hostpci0: 0000:01:00.0\n[snap1]\nhostpci0: 0000:05:00.0\n"), [])
self.assertEqual(self.entries("hostpci0: mapping=gpu1,pcie=1\nhostpci1: 0000:05:00.1\n"), [])
def test_the_gpu_scripts_decide_through_it(self):
for script in SCRIPTS:
with self.subTest(script=script.name):
source = script.read_text()
self.assertIn("_vm_gpu_hostpci_indexes", source)
# No entry is deleted, or taken for the GPU, by its slot alone.
self.assertNotIn("sed -E -i \"/^hostpci", source)
self.assertNotRegex(source, r'grep -qE "hostpci\[0-9\]\+:\.\*\$\{pci_slot\}"')
self.assertNotRegex(source, r"\$\{slot\}\(\\\\\.\[0-7\]\)\?")
def test_the_device_check_does_not_match_a_neighbour(self):
script = (f'qm() {{ printf "hostpci0: 0000:05:00.4,pcie=1\\n"; }}; source "{HELPERS}"; '
'_pci_slot_assigned_to_vm 0000:05:00.0 100 && echo gpu; '
'_pci_slot_assigned_to_vm 0000:05:00.4 100 && echo usb; true')
done = subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
self.assertEqual(done.stdout.split(), ["usb"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,108 @@
"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
the Proxmox host, inside a container, or wherever the user chooses when it
can be both. Until this, every addon ran on the host."""
from pathlib import Path
import subprocess
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
# Only the functions under test are loaded, not the menu or its catalog.
PRELUDE = textwrap.dedent(f"""
set -uo pipefail
eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
translate() {{ printf '%s' "$1"; }}
clear() {{ :; }}
dialog() {{ :; }}
pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
MENU_HEIGHT=22; MENU_ROWS=14
""")
def bash(script):
return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
class TargetChoiceTests(unittest.TestCase):
def choice(self, kind, places):
return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
def test_an_addon_for_the_host_runs_on_the_host(self):
self.assertEqual(self.choice("addon", "pve"), "host")
def test_an_addon_for_containers_runs_in_one(self):
self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
self.assertEqual(self.choice("addon", "lxc"), "lxc")
def test_an_addon_for_both_asks_where(self):
self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
self.assertEqual(self.choice("addon", ""), "ask")
def test_what_is_not_an_addon_runs_on_the_host(self):
# A script that creates a container or a VM belongs on the host,
# whatever the catalog lists for the application it installs.
self.assertEqual(self.choice("lxc", "lxc"), "host")
self.assertEqual(self.choice("vm", "vm"), "host")
self.assertEqual(self.choice("pve", "pve"), "host")
class ContainerListTests(unittest.TestCase):
def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
result = bash(r"""
pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
helper_lxc_rows
""")
self.assertEqual(result.stdout,
"109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
class RunTests(unittest.TestCase):
def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
result = bash(r"""
curl() { printf 'printf on-host'; }
pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
""")
self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
def test_a_failed_or_empty_download_runs_nothing(self):
result = bash(r"""
bash() { echo RAN; }
pct() { echo RAN; }
HELPER_LXC_TARGET=109
curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
echo done
""")
self.assertEqual(result.stdout, "done\n")
def test_cancelling_the_choice_leaves_no_target(self):
result = bash(r"""
dialog() { return 1; }
select_helper_target Test ask && echo UNEXPECTED
printf '[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "[]\n")
def test_the_host_needs_no_question(self):
result = bash(r"""
dialog() { echo ASKED; return 1; }
select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
""")
self.assertEqual(result.stdout, "ok[]\n")
class PlainTextTests(unittest.TestCase):
def test_html_becomes_text_and_commands_are_kept(self):
result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
input="<p>Run <code>ls -la &amp;&amp; pwd</code></p><ul><li>one</li><li>two</li></ul>")
self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
"""The shared install functions are sourced by both post-install flows. An
update that runs functions of the two sources the file twice in one shell,
which has to be as quiet as the first time."""
from pathlib import Path
import shutil
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[3]
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class SourcedTwiceTests(unittest.TestCase):
def source(self, times):
script = "; ".join([f'source "{SHARED}"'] * times) + '; echo "${PMX_NIC_LINK_DIR}|${#PMX_DKMS_COMPONENTS[@]}"'
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, check=False)
def test_a_second_source_prints_nothing(self):
once, twice = self.source(1), self.source(2)
self.assertEqual(twice.stderr, once.stderr)
self.assertNotIn("readonly variable", twice.stderr)
self.assertEqual(twice.returncode, 0)
def test_the_values_are_the_same_and_stay_read_only(self):
self.assertEqual(self.source(2).stdout.strip(), "/etc/systemd/network|2")
attempt = subprocess.run(["bash", "-c", f'source "{SHARED}"; PMX_NIC_LINK_DIR=/tmp'],
capture_output=True, text=True, check=False)
self.assertIn("readonly variable", attempt.stderr)
def test_no_other_shared_file_declares_a_read_only_variable_unguarded(self):
for path in sorted((ROOT / "scripts/global").glob("*.sh")) + [ROOT / "scripts/utils.sh"]:
for line in path.read_text(errors="replace").splitlines():
self.assertFalse(line.startswith(("readonly ", "declare -r ")), f"{path.name}: {line[:60]}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
"""A Proxmox host without a subscription is refused by the enterprise
repositories on every apt refresh. The installers tell that apart from a
refresh that really failed."""
from pathlib import Path
import re
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
INSTALLERS = ('install_proxmenux.sh', 'install_proxmenux_beta.sh')
ENTERPRISE = """Hit:1 http://deb.debian.org/debian trixie InRelease
Err:2 https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Err:4 https://enterprise.proxmox.com/debian/pve trixie InRelease
401 Unauthorized [IP: 51.91.38.34 443]
Reading package lists...
E: Failed to fetch https://enterprise.proxmox.com/debian/ceph-squid/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/ceph-squid trixie InRelease' is not signed.
E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/trixie/InRelease 401 Unauthorized [IP: 51.91.38.34 443]
E: The repository 'https://enterprise.proxmox.com/debian/pve trixie InRelease' is not signed.
"""
NETWORK = """Err:1 http://deb.debian.org/debian trixie InRelease
Temporary failure resolving 'deb.debian.org'
E: Failed to fetch http://deb.debian.org/debian/dists/trixie/InRelease Temporary failure resolving 'deb.debian.org'
"""
LOCKED = "E: Could not get lock /var/lib/apt/lists/lock. It is held by process 4321 (apt-get)\n"
# apt reports a repository it cannot reach as a warning, not as an error.
UNREACHABLE = """Err:6 http://repo.invalid/debian trixie InRelease
Could not resolve 'repo.invalid'
W: Failed to fetch http://repo.invalid/debian/dists/trixie/InRelease Could not resolve 'repo.invalid'
W: Some index files failed to download. They have been ignored, or old ones used instead.
"""
def function(installer, name):
source = (ROOT / installer).read_text()
return re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', source, re.MULTILINE | re.DOTALL)[0]
class InstallerAptRefresh(TestCase):
def refused(self, installer, log):
with tempfile.NamedTemporaryFile('w', suffix='.log') as handle:
handle.write(log)
handle.flush()
script = function(installer, 'apt_only_enterprise_refused') + \
f'APT_ERROR_LOG={handle.name if log is not None else ""}\napt_only_enterprise_refused\n'
return subprocess.run(['bash', '-c', script], capture_output=True).returncode == 0
def test_a_refresh_refused_only_by_the_enterprise_repositories_is_good(self):
for installer in INSTALLERS:
self.assertTrue(self.refused(installer, ENTERPRISE), installer)
def test_any_other_failure_is_still_a_failure(self):
for installer in INSTALLERS:
self.assertFalse(self.refused(installer, NETWORK), installer)
self.assertFalse(self.refused(installer, LOCKED), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + NETWORK), installer)
self.assertFalse(self.refused(installer, ENTERPRISE + UNREACHABLE), installer)
self.assertFalse(self.refused(installer, 'Reading package lists...\n'), installer)
def test_the_refresh_step_asks_before_it_warns(self):
for installer in INSTALLERS:
source = (ROOT / installer).read_text()
step = source[source.index('if run_apt update -y; then'):]
step = step[:step.index('\n fi\n')]
self.assertLess(step.index('elif apt_only_enterprise_refused; then'), step.index('apt cache refresh failed'))
self.assertIn('discard_apt_error', step[step.index('elif'):step.index('else\n')])
@@ -0,0 +1,30 @@
"""Every shipped terminal language must be selectable during installation."""
from pathlib import Path
import re
import sys
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
from build_translation_cache import DEFAULT_LANGUAGES
class InstallerLanguageTests(unittest.TestCase):
def test_both_installers_offer_every_shipped_language(self):
expected = {'en', *DEFAULT_LANGUAGES}
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
with self.subTest(installer=name):
source = (ROOT / name).read_text(encoding='utf-8')
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
self.assertIsNotNone(function, 'Missing installer language selector')
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
self.assertEqual(set(offered), expected)
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
# English is the source text in the scripts, not a JSON catalog.
for language in set(offered) - {'en'}:
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
if __name__ == '__main__':
unittest.main()
+311
View File
@@ -0,0 +1,311 @@
"""Log2RAM keeps /var/log in RAM while the journal and the task logs of
Proxmox VE and Proxmox Backup Server stay on disk behind a link. An update keeps the size
and interval the host already has."""
import os
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
KEEP = ROOT / "scripts/global/log2ram_keep_on_disk.sh"
SETUP = ROOT / "scripts/global/log2ram_setup.sh"
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class KeepOnDiskTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.root = Path(folder.name)
self.log = self.root / "var/log"
(self.log / "journal/abc").mkdir(parents=True)
(self.log / "journal/abc/system.journal").write_text("entries")
(self.log / "pveproxy").mkdir()
(self.log / "pveproxy/access.log").write_text("GET /")
self.config = self.root / "etc/log2ram.conf"
self.config.parent.mkdir()
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=true\nNOTIFICATION=true\n")
def run_setup(self, edit_failure=False):
# Extract only the shared setup function, never an administrative caller.
body = SETUP.read_text().split("pmx_log2ram_setup() {", 1)[1].split("\n}\n", 1)[0]
body = body.replace("/etc/", str(self.root / "etc") + "/")
for directory in ("cron.d", "tmpfiles.d"):
(self.root / "etc" / directory).mkdir(exist_ok=True)
stubs = r'''
translate() { printf '%s' "$1"; }
msg_info() { :; }
msg_ok() { :; }
pmx_install_pkg() { :; }
_pmx_log2ram_install_release() { :; }
_pmx_log2ram_pbs_logrotate() { :; }
_pmx_log2ram_remove_legacy() { :; }
pmx_edit_file() { local file="$1"; shift; sed -i "$@" "$file"; }
pmx_append_file() { cat >> "$1"; }
pmx_write_file() { cat > "$1"; }
pmx_remove_file() { rm -f "$1"; }
pmx_apply_setting() { :; }
systemctl() { printf '%s\n' "$*" >> "$SYSTEMCTL_TRACE"; }
'''
if edit_failure:
# A failed configuration writer must be handled even by root runners.
stubs += "\npmx_edit_file() { return 1; }\n"
script = (stubs + "\npmx_log2ram_setup() {" + body + "\n}\n"
+ "pmx_log2ram_setup 256 3 false\n")
env = dict(os.environ, SYSTEMCTL_TRACE=str(self.root / "systemctl.trace"),
PMX_L2R_TAG="1.7.2", PMX_L2R_SCRIPTS=str(KEEP.parent),
PMX_L2R_REVERT_UNIT=str(self.root / "etc/revert.service"))
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=10)
def test_setup_keeps_journal_aware_syncing_until_boot(self):
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=true\n")
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertIn("SIZE=256M\n", self.config.read_text())
self.assertFalse((self.log / "journal").is_symlink())
self.assertEqual((self.root / "systemctl.trace").read_text(), "daemon-reload\n")
def test_boot_disables_journal_aware_syncing_after_relocation(self):
self.assertEqual(self.run_setup().returncode, 0)
result = self.run_script()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
self.assertIn("SIZE=256M\n", self.config.read_text())
# Reapplying the repair to an already-relocated installation is safe.
self.assertEqual(self.run_setup().returncode, 0)
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
def test_boot_migration_failure_is_not_ignored_by_startup(self):
self.assertEqual(self.run_setup().returncode, 0)
# A non-directory destination causes a real copy failure, without mounts.
conflict = self.root / "var/hdd.journal"
conflict.write_text("blocked")
unit = self.root / "etc/systemd/system/log2ram.service.d/proxmenux.conf"
command = next(line.split("=", 1)[1] for line in unit.read_text().splitlines()
if line.startswith("ExecStartPre="))
result = subprocess.run(["bash", *shlex.split(command.lstrip("-"))],
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)),
capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertFalse(command.startswith("-"), "systemd must not ignore the helper's failure")
conflict.unlink()
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
def test_setup_adds_journal_aware_setting_when_missing(self):
self.config.write_text("SIZE=128M\n#JOURNALD_AWARE=false\n")
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("\nJOURNALD_AWARE=true\n", self.config.read_text())
self.assertEqual(self.run_script().returncode, 0)
self.assertIn("\nJOURNALD_AWARE=false\n", self.config.read_text())
def check_missing_setting_without_final_newline(self, content):
self.config.write_text(content)
result = self.run_setup()
self.assertEqual(result.returncode, 0, result.stderr)
for expected in ("true", "false"):
with self.subTest(journal_aware=expected):
# Read shell semantics in a fresh Bash, not a substring match.
sourced = subprocess.run(
["bash", "--noprofile", "--norc", "-c",
'unset JOURNALD_AWARE NOTIFICATION; source "$1"; '
'printf "%s\\n%s\\n" "${JOURNALD_AWARE-UNSET}" "${NOTIFICATION-UNSET}"',
"fixture", str(self.config)],
capture_output=True, text=True, timeout=10)
self.assertEqual(sourced.returncode, 0, sourced.stderr)
self.assertEqual(sourced.stdout, expected + "\nfalse\n")
if expected == "true":
self.assertFalse((self.log / "journal").is_symlink())
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
result = self.run_script()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
def test_missing_setting_after_comment_without_final_newline(self):
self.check_missing_setting_without_final_newline(
"SIZE=128M\nNOTIFICATION=false\n#JOURNALD_AWARE=false")
def test_missing_setting_after_assignment_without_final_newline(self):
self.check_missing_setting_without_final_newline("SIZE=128M\nNOTIFICATION=false")
def test_setup_reports_configuration_edit_failure(self):
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=false\n")
result = self.run_setup(edit_failure=True)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
def test_an_existing_link_with_an_unusable_disk_target_fails(self):
self.assertEqual(self.run_script().returncode, 0)
self.config.write_text("JOURNALD_AWARE=true\n")
disk = self.root / "var/hdd.journal"
shutil.rmtree(disk)
disk.write_text("blocked")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=true\n")
def test_an_unexpected_journal_link_does_not_complete_migration(self):
shutil.rmtree(self.log / "journal")
(self.log / "journal").symlink_to("pveproxy")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "pveproxy")
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
def test_a_missing_journal_with_an_unusable_disk_target_fails(self):
shutil.rmtree(self.log / "journal")
(self.root / "var/hdd.journal").write_text("blocked")
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertFalse((self.log / "journal").is_symlink())
def test_failed_source_removal_does_not_complete_migration(self):
(self.root / "var/hdd.journal").mkdir()
result = subprocess.run(
["bash", "-c", 'rm() { return 1; }; export -f rm; exec bash "$1"', "fixture", str(KEEP)],
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)), capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
self.assertTrue((self.log / "journal/abc/system.journal").is_file())
def test_boot_configuration_failure_is_reported_and_can_be_retried(self):
self.config.unlink()
self.config.mkdir()
result = self.run_script()
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.config.rmdir()
self.config.write_text("JOURNALD_AWARE=true\n")
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=false\n")
def run_script(self, revert=False):
env = dict(os.environ, PMX_L2R_ROOT=str(self.root), PMX_L2R_REVERT="1" if revert else "0")
return subprocess.run(["bash", str(KEEP)], env=env, capture_output=True, text=True, timeout=10)
def test_the_journal_moves_to_disk_behind_a_link(self):
self.assertEqual(self.run_script().returncode, 0)
journal = self.log / "journal"
self.assertTrue(journal.is_symlink())
self.assertEqual(os.readlink(journal), "/var/hdd.journal")
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
self.assertTrue((self.log / "pveproxy/access.log").exists())
self.assertFalse((self.root / "var/hdd.proxmox-backup").exists())
def test_running_again_changes_nothing(self):
self.run_script()
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
def test_the_task_logs_of_backup_server_stay_on_disk_too(self):
(self.log / "proxmox-backup/tasks").mkdir(parents=True)
(self.log / "proxmox-backup/tasks/active").write_text("task")
self.run_script()
self.assertEqual(os.readlink(self.log / "proxmox-backup"), "/var/hdd.proxmox-backup")
self.assertEqual((self.root / "var/hdd.proxmox-backup/tasks/active").read_text(), "task")
def test_files_written_back_to_the_folder_join_the_copy_on_disk(self):
self.run_script()
(self.log / "journal").unlink()
(self.log / "journal/def").mkdir(parents=True)
(self.log / "journal/def/system.journal").write_text("later")
self.run_script()
disk = self.root / "var/hdd.journal"
self.assertEqual(sorted(p.name for p in disk.iterdir()), ["abc", "def"])
self.assertTrue((self.log / "journal").is_symlink())
def test_a_host_without_a_journal_folder_gets_one_on_disk(self):
shutil.rmtree(self.log / "journal")
self.run_script()
self.assertTrue((self.root / "var/hdd.journal").is_dir())
self.assertTrue((self.log / "journal").is_symlink())
def test_the_task_logs_of_proxmox_ve_stay_on_disk(self):
(self.log / "pve/tasks").mkdir(parents=True)
(self.log / "pve/tasks/index").write_text("UPID")
self.run_script()
self.assertEqual(os.readlink(self.log / "pve"), "/var/hdd.pve")
self.assertEqual((self.root / "var/hdd.pve/tasks/index").read_text(), "UPID")
def test_removing_log2ram_puts_the_folders_back(self):
(self.log / "proxmox-backup").mkdir()
self.run_script()
self.config.unlink() # Revert must not require or rewrite Log2RAM settings.
self.assertEqual(self.run_script(revert=True).returncode, 0)
for name in ("journal", "proxmox-backup"):
self.assertFalse((self.log / name).is_symlink(), name)
self.assertTrue((self.log / name).is_dir(), name)
self.assertFalse((self.root / f"var/hdd.{name}").exists(), name)
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
class KeptSettingsTests(unittest.TestCase):
def read(self, function, files):
with tempfile.TemporaryDirectory() as folder:
script = SETUP.read_text()
for path, content in files.items():
target = Path(folder) / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
script = script.replace(path, str(target))
library = Path(folder) / "setup.sh"
library.write_text(script)
result = subprocess.run(["bash", "-c", f'source "{library}"; {function}'],
capture_output=True, text=True, timeout=10)
return result.stdout.strip()
def test_the_size_comes_from_the_installed_configuration(self):
self.assertEqual(self.read("pmx_log2ram_current_size", {"/etc/log2ram.conf": "SIZE=256M\n"}), "256")
def test_the_interval_comes_from_the_timer_of_this_version(self):
timer = "[Timer]\nOnCalendar=\nOnCalendar=*-*-* 00/6:55:00\n"
self.assertEqual(self.read("pmx_log2ram_current_hours",
{"/etc/systemd/system/log2ram-daily.timer.d/proxmenux.conf": timer}), "6")
def test_the_interval_of_an_earlier_version_is_kept(self):
cron = "MAILTO=\"\"\n0 */3 * * * root /usr/local/bin/log2ram write >/dev/null 2>&1\n"
self.assertEqual(self.read("pmx_log2ram_current_hours", {"/etc/cron.d/log2ram": cron}), "3")
def test_both_modes_offer_a_repair_to_version_2_0_installs(self):
for filename, function, updater in (
("auto_post_install.sh", "install_log2ram_auto", "_update_existing_log2ram_auto"),
("customizable_post_install.sh", "configure_log2ram", "_update_existing_log2ram_custom"),
):
with self.subTest(mode=filename), tempfile.TemporaryDirectory() as folder:
config = Path(folder) / "log2ram.conf"
config.write_text("JOURNALD_AWARE=false\n")
source = (ROOT / "scripts/post_install" / filename).read_text()
body = source.split(function + "() {", 1)[1].split("\n}\n", 1)[0]
body = body.replace("/etc/log2ram.conf", str(config))
script = ("pmx_journal_context() { :; }\n"
+ updater + "() { printf '%s\\n' \"$1\"; }\n"
+ function + "() {" + body + "\n}\n" + function)
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "2.1")
def test_the_release_is_pinned_to_a_verified_commit(self):
text = SETUP.read_text()
self.assertIn('PMX_L2R_TAG="1.7.2"', text)
self.assertIn('PMX_L2R_COMMIT="f171449530f8984374c8f69fff3c92af813a694b"', text)
self.assertNotIn("install.sh", text.split("_pmx_log2ram_install_release() {", 1)[1].split("\n}\n", 1)[0])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,78 @@
"""Inert branch tests for the real Lynis deletion/uninstall producers."""
import ast
import os
import subprocess
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
def extracted(path, name, namespace):
tree = ast.parse((ROOT / path).read_text())
fn = next(x for x in tree.body if isinstance(x, ast.FunctionDef) and x.name == name)
fn.decorator_list = []
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), str(path), 'exec'), namespace)
return namespace[name]
class LynisFeedbackProducer(unittest.TestCase):
def test_actual_ui_feedback_handlers_all_shipped_locales(self):
result = subprocess.run(['node', str(ROOT / '.github/scripts/tests/fixtures/lynis_feedback_contract.cjs')],
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy())
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_uninstall_nothing_to_remove_is_not_reported_as_uninstalled(self):
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: False, join=lambda *p: '/'.join(p)), remove=lambda p: self.fail(p))
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertFalse(result[0])
self.assertEqual(result[3], 'no_files')
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['outcome'], 'no_files')
def test_report_outcomes(self):
paths = ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 1, 'missing'):
with self.subTest(fail_at=fail_at):
removed = []
def remove(p):
if fail_at in (0, 1) and p == paths[fail_at]:
raise PermissionError('inert denial')
removed.append(p)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: fail_at != 'missing'), remove=remove)
ns = {'security_manager': object(), 'jsonify': lambda data: data}
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_os if n == 'os' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_report_delete', ns)()
body, status = result if isinstance(result, tuple) else (result, 200)
self.assertEqual(removed, paths[:fail_at] if fail_at in (0, 1) else ([] if fail_at == 'missing' else paths))
self.assertEqual(body['success'], fail_at is None)
if fail_at == 'missing':
self.assertEqual(body['outcome'], 'no_files')
elif fail_at in (0, 1):
self.assertEqual(status, 500)
self.assertEqual(body['partial'], fail_at == 1)
self.assertIn('inert denial', body['message'])
def test_uninstall_outcomes_and_route(self):
targets = ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log']
for fail_at in (None, 0, 2):
with self.subTest(fail_at=fail_at):
removed = []
def remove(path):
if fail_at is not None and path == targets[fail_at]: raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p in targets, join=lambda *parts: '/'.join(parts)), remove=remove)
fake_shutil = SimpleNamespace(rmtree=remove)
orig_import = __import__
with patch('builtins.__import__', side_effect=lambda n, *a, **kw: fake_shutil if n == 'shutil' else orig_import(n, *a, **kw)):
result = extracted('AppImage/scripts/security_manager.py', 'uninstall_lynis', {'os': fake_os})()
self.assertEqual(removed, targets[:fail_at] if fail_at is not None else targets)
self.assertEqual(result[0], fail_at is None)
self.assertEqual(result[2], fail_at == 2)
manager = SimpleNamespace(uninstall_lynis=lambda: result)
route = extracted('AppImage/scripts/flask_security_routes.py', 'lynis_uninstall', {'security_manager': manager, 'jsonify': lambda data: data})
self.assertEqual(route()['partial'], fail_at == 2)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Inert source-contract checks; never import operational modules or touch host paths."""
import ast
import json
import os
import subprocess
import unittest
from collections import defaultdict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
APP = ROOT / 'AppImage'
def extract_function(path, name, *, owner=None):
tree = ast.parse(path.read_text())
container = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) if owner else tree
node = next(n for n in container.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
namespace = {}
return node, ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[]))
def leaf(pointer):
data = json.loads((APP / 'messages/en/common.json').read_text())
for segment in pointer.split('.'):
data = data[segment]
return data
class LynisThresholdContracts(unittest.TestCase):
def test_delete_confirmation_names_all_three_actual_targets(self):
node, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=removed.append)
original_import = __import__
def fake_import(name, *args, **kwargs):
if name == 'os':
return fake_os
return original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, str(APP / 'scripts/flask_security_routes.py'), 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertEqual(result['success'], True)
self.assertEqual(removed, ['/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
text = leaf('securityPage.confirm.deleteAuditReport').lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('this audit report?', text)
def test_delete_route_partial_failure_is_not_reported_as_full_success(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
removed = []
def remove(path):
if path.endswith('lynis.log'):
raise PermissionError('inert denial')
removed.append(path)
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: True), remove=remove)
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result, status = namespace['lynis_report_delete']()
self.assertEqual(removed, ['/var/log/lynis-report.dat'])
self.assertEqual(status, 500)
self.assertFalse(result['success'])
self.assertIn('inert denial', result['message'])
def test_delete_route_with_no_files_returns_success_false_not_a_clean_result(self):
_, module = extract_function(APP / 'scripts/flask_security_routes.py', 'lynis_report_delete')
fake_os = SimpleNamespace(path=SimpleNamespace(isfile=lambda p: False), remove=lambda p: self.fail(p))
original_import = __import__
def fake_import(name, *args, **kwargs):
return fake_os if name == 'os' else original_import(name, *args, **kwargs)
namespace = {'security_manager': object(), 'jsonify': lambda x: x}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis route>', 'exec'), namespace)
result = namespace['lynis_report_delete']()
self.assertFalse(result['success'])
self.assertIn('No report files', result['message'])
def test_uninstall_notice_describes_existing_cleanup_targets(self):
_, module = extract_function(APP / 'scripts/security_manager.py', 'uninstall_lynis')
removed = []
fake_os = SimpleNamespace(path=SimpleNamespace(exists=lambda p: p != '/usr/local/share/proxmenux/components_status.json', join=lambda *s: '/'.join(s)), remove=removed.append)
class FakeShutil:
@staticmethod
def rmtree(path):
removed.append(path)
original_import = __import__
def fake_import(name, *args, **kwargs):
return FakeShutil if name == 'shutil' else original_import(name, *args, **kwargs)
namespace = {'os': fake_os}
with patch('builtins.__import__', side_effect=fake_import):
exec(compile(module, '<inert lynis uninstall>', 'exec'), namespace)
result = namespace['uninstall_lynis']()
self.assertTrue(result[0])
self.assertEqual(removed, ['/opt/lynis', '/usr/local/bin/lynis', '/var/log/lynis-report.dat', '/var/log/lynis.log', '/var/log/lynis-output.log'])
for key in ('securityPage.lynis.removeReports', 'securityPage.lynis.uninstallConfirmDescription'):
text = leaf(key).lower()
self.assertIn('report', text)
self.assertIn('logs', text)
self.assertNotIn('monitor-created', text)
def test_swap_hint_qualifies_sampling_and_independent_ram_alarm(self):
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
# The method is bound to an inert class, not the production constructor.
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
MEMORY_WARNING=80, MEMORY_CRITICAL=90, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
memory = SimpleNamespace(percent=50, available=10, total=100)
swap = SimpleNamespace(percent=90, used=90, total=100)
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
'time': SimpleNamespace(time=lambda: 1000)}
exec(compile(module, '<inert memory check>', 'exec'), namespace)
first = namespace['_check_memory_comprehensive'](self_obj)
second = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(first['status'], 'OK')
self.assertEqual(second['checks']['swap_usage']['status'], 'CRITICAL')
self_obj.state_history.clear()
memory.percent = 91
swap.percent = 0
for _ in range(8):
last = namespace['_check_memory_comprehensive'](self_obj)
self.assertEqual(last['status'], 'CRITICAL')
self.assertEqual(last['checks']['swap_usage']['status'], 'OK')
hint = leaf('settings.healthThresholds.swapPressureHint').lower()
self.assertIn('swap usage', hint)
self.assertIn('repeated', hint)
self.assertIn('ram', hint)
self.assertNotIn('swap file', hint)
self.assertNotIn('swap file', leaf('settings.healthThresholds.swapHighLabel').lower())
self.assertNotIn('only when both', hint)
def test_print_generator_and_descriptions(self):
node = ROOT / '.github/scripts/tests/fixtures/lynis_print_contract.cjs'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
result = subprocess.run(['node', str(node)], cwd=ROOT, text=True, capture_output=True,
env={**os.environ, 'LYNIS_FIXTURE_LANG': lang})
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f'print HTML ({lang}):', result.stdout)
if __name__ == '__main__':
unittest.main()
+209
View File
@@ -0,0 +1,209 @@
"""The search of the main menu finds an option of ProxMenux, an OCI application
or a Helper-Script by its name or by related words, and opens it where it
lives: the scripts it launches are the ones the menus launch."""
import importlib.util
import json
import os
from pathlib import Path
import re
import runpy
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / "scripts"
def load():
spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# The repository in the place of the installed copy.
module.BASE, module.SCRIPTS = ROOT, SCRIPTS
module.OCI_INDEX = ROOT / "oci/catalog/index.json"
module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
module._translations = {}
return module
class IndexTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
def test_every_entry_opens_a_script_that_exists(self):
missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
self.assertEqual(missing, [])
def test_every_title_is_a_text_the_menus_already_translate(self):
names = {"ZimaOS", "Synology DSM"}
unknown = [item["title"] for item in self.index
if item["title"] not in self.spanish and item["title"] not in names]
self.assertEqual(unknown, [])
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
def test_every_script_a_pve_menu_launches_can_be_found(self):
indexed = {item["script"] for item in self.index}
launched = set()
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
# PBS replaces the PVE main menu and does not use its search.
# The exclusive platform dispatch is executed in the test below.
if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
continue
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
launched -= {"menus/main_menu.sh"}
# Opened from their own menu, which is what the index points at.
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
self.assertEqual(sorted(launched - indexed), [])
def test_pbs_replaces_the_searchable_pve_menu(self):
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
with tempfile.TemporaryDirectory() as directory:
scripts = Path(directory)
(scripts / "global").mkdir()
(scripts / "menus").mkdir()
helper = scripts / "global/platform-functions.sh"
helper.write_text(
"source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
(scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
with self.subTest(product=product):
result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
"TEST_PROXMENUX_PRODUCT": product})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), expected)
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
self.assertIn('search "$(translate "Search options and applications")"', menu)
self.assertIn('search_menu.py" --handoff "$handoff"', menu)
self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
self.assertIn('exec bash "${target[@]}"', menu)
def test_an_option_inside_a_menu_names_one_that_menu_has(self):
for item in self.index:
argument = item.get("argument")
if not argument:
continue
script = (SCRIPTS / item["script"]).read_text()
self.assertIn("PRESELECTED_OPTION", script, item["script"])
if not argument.isdigit():
self.assertIn(argument, script, f"{item['script']}: {argument}")
class CatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.search = load()
cls.items = cls.search.catalog(refresh=False)
def found(self, query):
return self.search.search(self.items, query)
def test_the_three_sources_are_in_the_catalog(self):
self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
sources = [item["source"] for item in self.found("samba")]
self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
self.assertEqual(sources[0], "ProxMenux")
def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
def test_accents_and_case_do_not_matter(self):
self.assertEqual([item["title"] for item in self.found("NVIDIA")],
[item["title"] for item in self.found("nvidia")])
def test_related_words_find_an_option(self):
self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
def test_a_match_by_category_comes_after_the_names(self):
matches = self.found("vpn")
self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
by_category = False
for item in matches:
words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
in_name = any(word.startswith("vpn") for word in words)
self.assertFalse(in_name and by_category)
by_category = by_category or not in_name
def test_an_application_is_not_found_by_what_its_description_mentions(self):
titles = [item["title"] for item in self.found("plex")]
self.assertNotIn("Jellyfin", titles)
self.assertTrue(all("plex" in title.casefold() for title in titles))
def test_nothing_matches_nothing(self):
self.assertEqual(self.found("zzqqxx"), [])
self.assertEqual(self.found(" "), [])
def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
def test_an_option_inside_a_menu_is_given_to_that_menu(self):
zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
self.assertFalse(zfs["handoff"])
language = next(item for item in self.items if item["action"][-1] == "change_language")
self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
self.assertTrue(language["handoff"])
class OpenedInPlaceTests(unittest.TestCase):
def test_an_option_of_the_post_install_comes_selected(self):
script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
self.assertIn('--default-item "${preselected_item:-1}"', script)
def test_a_utility_comes_selected(self):
script = (SCRIPTS / "utilities/system_utils.sh").read_text()
self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
def test_a_helper_script_is_opened_on_its_own(self):
script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
self.assertIn('run_script_by_slug "$1"', script)
def test_an_oci_application_opens_its_page(self):
cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
self.assertIn('subparsers.add_parser("open"', cli)
class TranslationTests(unittest.TestCase):
"""What the search and the main menu say reaches the translation workflow,
read with the folders the workflow itself passes."""
@classmethod
def setUpClass(cls):
workflow = (ROOT / ".github/workflows/build-translation-cache.yml").read_text()
extractor = runpy.run_path(str(ROOT / ".github/scripts/build_translation_cache.py"))
folders = [ROOT / folder for folder in re.findall(r"--python-dir (\S+)", workflow)]
cls.found = set(extractor["extract_python_texts"](folders))
cls.found |= set(extractor["extract_translate_texts"](SCRIPTS))
cls.triggers = workflow
def test_every_text_of_the_search_is_found(self):
source = (SCRIPTS / "menus/search_menu.py").read_text()
said = set(re.findall(r"translate\('([^']+)'\)", source))
self.assertIn("No results", said)
self.assertEqual(sorted(said - self.found), [])
def test_the_title_of_the_main_menu_is_found(self):
self.assertIn("Main menu", self.found)
def test_a_change_in_the_search_runs_the_workflow(self):
self.assertIn("'scripts/**/*.py'", self.triggers)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,122 @@
"""Backup-only maintainer contract at actual render/dispatch/email seams.
Scope-approved seams: receiver, template lookup, rich enrichment, inert queued
and manual delivery with an email capture sink. No host operations or sends.
"""
import unittest
from notification_fixture import templates, LANGUAGES
from notification_final_fixture import deliver
class BackupSplitTests(unittest.TestCase):
def test_recovery_default_is_upstream_resolved_without_proof(self):
for language in LANGUAGES:
data = {'hostname': 'alias', 'category': 'temperature',
'reason': 'Temperature high (recovered)', 'duration': '2m',
'original_severity': 'WARNING'}
rendered = templates.render_template('error_resolved', data, language)
expected = templates.runtime_message('templates.error_resolved.title', language,
hostname='alias', category='temperature', entity_suffix='')
self.assertEqual(rendered['title'], expected)
rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data)
self.assertTrue(rich.startswith('✅ '))
result = deliver('error_resolved', data, 'OK', language)
self.assertIn('background:#f0fdf4;', result['html'])
def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self):
from notification_fixture import receive
raw_host = 'pve-production.internal.example'
message = ('Details\n=======\nVMID Name Status Time Size Filename\n'
'\nTotal running time: 0s\nTotal size: 0 B')
for raw in (message, 'ERROR: unable to activate storage PBS\n' + message):
event = receive(raw, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: unable to activate storage PBS')
event.data['hostname'] = 'display-alias {rack.location}'
for language in LANGUAGES:
for manual in (False, True):
result = deliver(event.event_type, event.data, event.severity, language, manual=manual)
self.assertNotIn(raw_host, result['text'])
self.assertNotIn('vzdump backup status', result['text'])
self.assertEqual(result['text'].count('unable to activate storage PBS'), 1)
self.assertIn('display-alias {rack.location}', result['title'])
self.assertEqual(result['data']['pve_title'], event.data['pve_title'])
self.assertEqual(result['data']['pve_message'], raw)
# No global hostname or guest-name substitution: actual diagnostics are
# authoritative, even when they happen to contain the original hostname.
event = receive(message, 'error', 'vzdump backup status (' + raw_host +
'): backup failed: cannot connect to ' + raw_host)
result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity)
self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1)
self.assertNotIn('vzdump backup status', result['body'])
def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self):
# Frozen upstream contract; no Git history required in shipped tests.
legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}}
for language, expected in legacy.items():
self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected)
result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language)
self.assertIn('alias {rack.location}', result['title'])
self.assertNotIn('()', result['title'])
new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}')
self.assertTrue(new_title)
self.assertEqual(result['title'], new_title)
self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body'])
# Absent/blank/non-string translation uses English per-key fallback.
from unittest.mock import patch
english = templates._load_runtime_catalog('en')
for value in (None, '', {}, []):
missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}}
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
# Both catalogs missing: new outcome text still has explicit EN defaults.
with patch.object(templates, '_load_runtime_catalog', return_value={}):
result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it')
self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed')
self.assertEqual(result['body'], 'The backup outcome is not confirmed.')
def test_restore_reports_its_tasks_without_calling_the_node_ready_and_keeps_the_success_icon(self):
import json
from pathlib import Path
from notification_final_fixture import restore_event
messages = Path(__file__).resolve().parents[3] / 'AppImage/messages'
first = {language: json.loads((messages / language / 'common.json').read_text())
['runtime']['notifications']['templates']['system_restore_completed']['body'].split('\n')[0]
for language in LANGUAGES}
for warning in ('', 'missing module zfs'):
event = restore_event(warning)
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language)
self.assertTrue(result['title'].startswith('✅ '))
self.assertIn(first[language], result['body'])
self.assertIn(first[language], result['text'])
self.assertNotIn('fully ready', result['body'])
self.assertIn('2m', result['text'])
if warning:
self.assertIn(warning, result['text'])
quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertIn('✅', quiet['body'])
self.assertIn(first[language], quiet['body'])
self.assertIn('white-space:pre-wrap;', quiet['html'])
def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self):
expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias',
'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'}
for outcome, title in expected.items():
result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es')
self.assertEqual(result['title'], 'alias: ' + title)
failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es')
self.assertEqual(failure['title'], 'alias: Backup fallido')
restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es')
self.assertEqual(restore['title'], 'alias: Restauración del host finalizada')
def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self):
result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature',
'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True)
self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body'])
self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body'])
self.assertIn('✅', result['body'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,204 @@
"""Whole-PR outcome corrections, inert producer/actual email consumers."""
import html
import unittest
from notification_fixture import templates, receive, email, LANGUAGES
REPORT = """Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
"""
class CorrectionTests(unittest.TestCase):
def test_reversed_finish_is_not_completion_evidence(self):
event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)')
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
def test_interleaved_complete_logs_keep_both_finished_guests(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n'
'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n'
'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)')
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('✅ VM web (100)', result['body'])
self.assertIn('✅ CT db (101)', result['body'])
self.assertNotIn('❔', result['body'])
self.assertIn('00:01:01', result['body'])
def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self):
result, markup = email('backup_complete', {'hostname': 'n' * 64,
'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'})
self.assertIn('table-layout:fixed;', markup)
title_tag = markup.split('<h2 style="', 1)[1].split('"', 1)[0]
self.assertIn('overflow-wrap:break-word;', title_tag)
self.assertIn('word-wrap:break-word;', title_tag)
_, unrelated = email('node_reconnect', {'hostname': 'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated)
def test_catalog_parity_accepts_eventual_valid_slovak_keys(self):
from notification_fixture import extract, SCRIPTS
import copy
import string
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
ns = {'string': string}
ns['_placeholders'] = extract(path, '_placeholders', None, ns)
parity = extract(path, 'test_runtime_catalog_keys_and_placeholders_match', 'RuntimeCatalogTests', ns)
probe = unittest.TestCase()
probe.catalogs = {lang: copy.deepcopy(templates._load_runtime_catalog(lang)) for lang in LANGUAGES}
parity(probe) # shipped missing keys remain allowed
probe.catalogs['sk']['backup'] = copy.deepcopy(probe.catalogs['en']['backup'])
probe.catalogs['sk']['channels']['email']['status']['unconfirmed'] = probe.catalogs['en']['channels']['email']['status']['unconfirmed']
parity(probe) # generation of exactly the pending keys is legal
probe.catalogs['sk']['backup']['confirmedTitle'] = 'Missing hostname token'
with self.assertRaises(AssertionError): parity(probe)
def test_actual_neutral_style_is_not_success_green(self):
for event, severity, data in (('backup_complete', 'INFO', {'backup_outcome': 'unconfirmed'}),):
result, markup = email(event, data, severity)
self.assertIn('background:#f9fafb;', markup)
self.assertNotIn('background:#f0fdf4;', markup)
def test_actual_restore_endpoint_warnings_and_counts_reach_email(self):
from notification_fixture import extract, SCRIPTS
from types import SimpleNamespace
for warnings in ('', 'missing module zfs'):
events = []
ns = {'request': SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '3', 'stubs': '1', 'stale_nodes': '0',
'components': '2', 'duration': '2m', 'warnings': warnings}),
'notification_manager': SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda value: value}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', None, ns)
response, status = handler()
self.assertEqual(status, 200)
event = events[0]
for language in LANGUAGES:
result, markup = email(event['event_type'], event['data'], event['severity'], language)
self.assertIn('2m', markup)
for line in result['body'].splitlines():
if line.strip(): self.assertIn(line.strip(), html.unescape(markup))
if warnings: self.assertIn(warnings, markup)
def test_raw_display_hostname_is_substituted_exactly_once(self):
for event_type, outcome in (('backup_complete', 'confirmed'), ('backup_complete', 'failed'), ('backup_fail', 'failed')):
for hostname in ('Sala {rack} – Zürich', 'node-{vmid}', 'Sala {rack.location}'):
for language in LANGUAGES:
result, markup = email(event_type, {'hostname': hostname,
'backup_outcome': outcome, 'pve_message': REPORT}, language=language)
self.assertIn(hostname, result['title'])
self.assertIn(hostname, html.unescape(markup))
def test_malformed_numeric_report_is_queued_uncertain_and_renderable(self):
for size in ('1..5 GiB', '..5 GiB'):
message = REPORT.replace('1 GiB ', size.ljust(9)).split('Total size:')[0]
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'unconfirmed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(size, result['body'])
def test_unknown_backup_type_keeps_explicit_err_but_cannot_certify_ok(self):
event = receive(REPORT.replace('ok ', 'err '), kind='')
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.severity, 'INFO')
self.assertEqual(event.data['backup_outcome'], 'failed')
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn('FAILED', markup)
self.assertEqual(receive(REPORT, kind='').data['backup_outcome'], 'unconfirmed')
def test_confirmed_metadata_only_context_is_retained(self):
data = {'hostname': 'node-a', 'backup_outcome': 'confirmed',
'vmid': '100', 'vmname': 'web {literal}', 'storage': 'PBS', 'size': '1 GiB'}
for language in LANGUAGES:
result, markup = email('backup_complete', data, language=language)
self.assertIn('web {literal} (100)', result['title'])
self.assertIn('1 GiB', result['body'])
self.assertIn('1 GiB', markup)
def test_explicit_guest_failure_overrides_only_the_linked_ok_row(self):
other = '101 db ok 1m 1s 1 GiB ct/101/2026-09-29T17:00:00Z'
message = REPORT.replace('\n\nTotal', '\n' + other + '\n\nTotal')
diagnostic = '100: 2026-09-29 17:00:00 ERROR: Backup of VM 100 failed - archive write failed'
for severity in ('info', 'error'):
event = receive(message + '\n' + diagnostic, severity)
self.assertEqual(event.data.get('vmid'), '100')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn('❌ VM web (100)', result['body'])
self.assertNotIn('✅ VM web (100)', markup)
self.assertIn('✅ CT db (101)', result['body'])
self.assertIn('web (100)', result['title'])
def test_official_week_month_year_durations_stay_confirmed(self):
for duration in ('1w', '1w 1m 1s', '1M', '1y'):
# Fixed-column widths are unchanged for these bounded values.
message = REPORT.replace('1m 1s ', duration.ljust(9))
event = receive(message)
self.assertEqual(event.data['backup_outcome'], 'confirmed', duration)
result, markup = email(event.event_type, event.data, event.severity)
self.assertIn(duration, result['body'])
def test_prefixed_error_diagnostics_survive_both_source_severities(self):
diagnostic = '100: 2026-09-29 17:00:00 ERROR: archive write failed: permission denied'
for severity in ('info', 'error'):
event = receive(REPORT + '\n' + diagnostic, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(diagnostic, result['body'])
self.assertIn(diagnostic, html.unescape(markup))
# A job-level error must not invent a failed guest.
self.assertIn('✅ VM web (100)', result['body'])
def test_official_completed_report_warning_is_distinct_and_retained(self):
warning = '100: 2026-09-29 17:00:00 WARN: unable to add notes - permission denied'
event = receive(REPORT + '\n' + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(warning, result['body'])
self.assertIn(warning, html.unescape(markup))
def test_batch_and_abort_failure_titles_have_no_empty_guest_slot(self):
failed = REPORT.replace('ok ', 'err ')
failed = failed.replace('\n\nTotal', '\n101 db err 1m 1s 0 B null\n\nTotal')
for message in (failed, REPORT.replace('ok ', 'todo '),
REPORT.split('100 web')[0] + '\nTotal running time: 0s'):
event = receive(message + '\nINFO: vzdump --storage PBS', 'error')
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertNotIn('()', result['title'])
self.assertIn('PBS', result['title'])
self.assertNotIn('web (100)', result['title'])
def test_subject_only_setup_failure_survives_actual_email(self):
reason = 'unable to activate storage PBS'
title = 'vzdump backup status (node-a): backup failed: ' + reason
message = REPORT.split('100 web')[0] + '\nTotal running time: 0s\nTotal size: 0 B'
event = receive(message, 'error', title)
for language in LANGUAGES:
result, markup = email(event.event_type, event.data, event.severity, language)
self.assertIn(reason, result['body'])
self.assertIn(reason, html.unescape(markup))
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,136 @@
"""Final review contracts at actual locale/manual/queued/quiet email seams.
All operational dependencies are inert; no manager or route module import.
"""
import ast
import copy
import html
import unittest
from unittest.mock import patch
from notification_fixture import templates, SCRIPTS, LANGUAGES, receive
from notification_final_fixture import deliver, restore_event
# Literal native send_notification body, produced by pinned PVE Perl helpers.
NATIVE_REPORT = 'Details\n=======\n' + '''VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: no log available
'''
class FinalCorrectionsTests(unittest.TestCase):
def test_runtime_backup_assertions_accept_generated_and_missing_slovak_title(self):
path = SCRIPTS / 'tests/test_notification_runtime_i18n.py'
tree = ast.parse(path.read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'RuntimeCatalogTests')
method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == 'test_special_formatters_digest_and_test_message_are_slovak')
start = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='backup' for t in n.targets))
stop = next(i for i,n in enumerate(method.body) if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=='manager' for t in n.targets))
block = ast.Module(body=method.body[start:stop], type_ignores=[])
english = templates._load_runtime_catalog('en')
for title in (None, '{hostname}: GENERATED_SK potvrdené'):
sk = copy.deepcopy(templates._load_runtime_catalog('sk'))
if title is not None: sk.setdefault('backup', {})['confirmedTitle'] = title
else: sk.get('backup', {}).pop('confirmedTitle', None)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else sk):
exec(compile(block,str(path),'exec'), {'self':self, 'notification_templates':templates})
def test_native_multiline_failure_diagnostics_survive_receiver_dispatch_once(self):
subject = 'vzdump backup status (node-a): backup failed: multiple problems'
for diagnostic, report in (
('external provider job cleanup failed\njob-abort hook permission denied', NATIVE_REPORT),
('job interrupted\njob-abort hook permission denied', NATIVE_REPORT.replace('ok ', 'todo ')),
('unable to initialize external provider\njob-abort hook permission denied', NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z\n', '')),
):
event = receive(diagnostic + '\n' + report, 'error', subject)
self.assertEqual((event.event_type, event.severity), ('backup_fail', 'CRITICAL'))
for language in LANGUAGES:
result = deliver(event.event_type, event.data, event.severity, language)
for line in diagnostic.splitlines():
self.assertEqual(result['body'].count(line), 1)
self.assertEqual(result['text'].count(line), 1)
if 'ok ' in report:
self.assertIn('✅ VM web (100)', result['body'])
def test_restore_endpoint_quiet_release_keeps_warning_counts_and_truthful_footer(self):
reason = 'missing module zfs <raw> & {literal}'
event = restore_event(reason)
self.assertEqual(event['severity'], 'WARNING')
for language in LANGUAGES:
result = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True)
self.assertEqual(result['severity'], 'INFO')
buffered_body = result['buffered'][0][3]
self.assertEqual(result['text'].count(reason), 1)
for line in buffered_body.splitlines():
if line.strip(): self.assertIn(line.strip(), result['text'])
self.assertIn('2m', result['text'])
self.assertNotIn(templates.runtime_message('digest.footer', language), result['body'])
self.assertNotIn('script', result['tags'])
def test_raw_restore_cells_use_event_scoped_mail_wrapping(self):
token = 'b' * 64
event = restore_event('Boot check: recorded token ' + token + '; verification pending')
for language in LANGUAGES:
results = [deliver(event['event_type'],event['data'],event['severity'],language,quiet=quiet) for quiet in (False,True)]
for result in results:
self.assertIn('table-layout:fixed;', result['html'])
self.assertIn('word-wrap:break-word;', result['html'])
self.assertIn('overflow-wrap:break-word;', result['html'])
self.assertEqual(result['text'].count(token), 1)
unrelated = deliver('node_reconnect', {'hostname':'node-a'}, 'OK')
self.assertNotIn('table-layout:fixed;', unrelated['html'])
def test_manual_failed_and_unconfirmed_backup_keep_short_actionable_reason(self):
for outcome, reason in (('failed','PBS permission denied for datastore remote'),
('unconfirmed','Task status unavailable: upstream API timed out')):
for language in LANGUAGES:
result = deliver('backup_complete',{'hostname':'node-a','vmid':'100','vmname':'web',
'storage':'PBS','backup_outcome':outcome,'reason':reason},'INFO',language,manual=True)
self.assertEqual(result['text'].count(reason), 1)
self.assertNotIn('>COMPLETED</span>', result['html'])
def test_backup_reason_threshold_and_raw_body_deduplication(self):
for event in ('backup_complete', 'backup_fail'):
for length in (79, 80, 81, 120):
prefix = '<raw> & {rack.location} '
reason = prefix + 'b' * (length - len(prefix))
self.assertEqual(len(reason), length)
for raw in ('', reason):
data = {'hostname':'node-a','backup_outcome':'failed','reason':reason,'pve_message':raw}
for manual in (False, True):
result = deliver(event,data,'INFO','en',manual=manual)
self.assertEqual(result['text'].count(reason), 1, (event,length,raw,manual))
self.assertNotIn('raw', result['tags'])
def test_quiet_restore_after_preview_limit_is_not_omitted_or_called_info(self):
event = restore_event('missing module zfs')
earlier = [dict(event_type='service_fail',severity='WARNING',data={'hostname':'node-a','service_name':f'unit-{i}','reason':'recorded'}) for i in range(8)]
result = deliver(event['event_type'],event['data'],event['severity'],quiet=True,quiet_before=earlier)
self.assertEqual(result['text'].count('missing module zfs'), 1)
self.assertNotIn(templates.runtime_message('digest.lead','en',count=9), result['body'])
self.assertEqual(result['data']['_count'], 9)
def test_native_error_block_does_not_deduplicate_a_substring_of_inventory(self):
event = receive('web\nexternal provider job cleanup failed\n' + NATIVE_REPORT,
'error','vzdump backup status (node-a): backup failed: multiple problems')
result = deliver(event.event_type,event.data,event.severity)
self.assertEqual(result['body'].splitlines().count('web'), 1)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,49 @@
"""The notification settings show the delivery history the Monitor records,
the daily digest runs included, and never offer to erase it: Audit & Report
reads the same history to tell whether each channel delivers."""
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[3]
PAGE = ROOT / 'AppImage/components/notification-settings.tsx'
KEYS = ('title', 'description', 'empty', 'loadFailed', 'sent', 'notSent',
'severityInfo', 'severityWarning', 'severityCritical')
class NotificationHistoryPanelTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.page = PAGE.read_text(encoding='utf-8')
def test_the_panel_opens_and_lists_the_history(self):
self.assertIn('setShowHistory(open => !open)', self.page)
self.assertIn('history.map(entry =>', self.page)
self.assertIn('t("settings.notifications.history.title")', self.page)
def test_the_history_is_read_without_a_success_flag(self):
loader = re.search(r'const loadHistory = useCallback\(async \(\) => \{(.*?)\n \}, \[\]\)', self.page, re.S)
self.assertIsNotNone(loader)
self.assertIn('Array.isArray(data.history)', loader.group(1))
self.assertNotIn('data.success', loader.group(1))
def test_a_digest_run_and_a_failed_delivery_show_their_message(self):
self.assertIn('entry.event_type === "digest" ? entry.message', self.page)
self.assertIn('!sent ? entry.error_message', self.page)
def test_the_page_never_erases_the_history(self):
self.assertNotIn('handleClearHistory', self.page)
self.assertIsNone(re.search(r'notifications/history["\'`][^)]*method:\s*["\']DELETE', self.page))
def test_the_texts_exist_in_english_and_spanish(self):
for language in ('en', 'es'):
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
history = catalog['settings']['notifications']['history']
for key in KEYS:
with self.subTest(language=language, key=key):
self.assertTrue(history.get(key))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,159 @@
"""Maintainer acceptance at inert actual notification consumers."""
import unittest
from notification_fixture import templates, receive, LANGUAGES, SCRIPTS, extract
from notification_final_fixture import deliver
NATIVE_REPORT = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
Logs
====
vzdump --all 1 --storage PBS --mode snapshot
100: 2026-09-29 17:00:00 INFO: Starting Backup of VM 100 (qemu)
100: 2026-09-29 17:01:01 INFO: Finished Backup of VM 100 (00:01:01)
'''
class MaintainerFollowupTests(unittest.TestCase):
def test_completed_with_warnings_requires_independent_completion(self):
warning = '\n100: 2026-09-29 17:00:01 WARN: file changed during backup'
event = receive(NATIVE_REPORT + warning)
self.assertEqual(event.data['backup_outcome'], 'completed_with_warnings')
self.assertEqual((event.event_type,event.severity), ('backup_complete','INFO'))
for manual in (False,True):
for lang in LANGUAGES:
result = deliver(event.event_type,event.data,event.severity,lang,manual=manual)
label = templates.runtime_message('backup.warningTitle',lang,hostname='node-a')
self.assertIn(label,result['title'])
status = templates.runtime_message('channels.email.status.completed_with_warnings',lang)
self.assertIn(status,result['text'])
self.assertIn('file changed during backup',result['text'])
# Manual sends intentionally skip channel emoji enrichment.
if not manual: self.assertTrue(result['title'].startswith('💾⚠️'))
rich, _ = templates.enrich_with_emojis(event.event_type,result['title'],result['body'],event.data)
self.assertTrue(rich.startswith('💾⚠️'))
self.assertEqual(receive('WARN: file changed during backup').data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT.split('Total running time:')[0]+warning).data['backup_outcome'],'unconfirmed')
self.assertEqual(receive(NATIVE_REPORT+warning+'\nERROR: cleanup failed').data['backup_outcome'],'failed')
self.assertEqual(receive(NATIVE_REPORT+warning,'warning').data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive('INFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'+warning).data['backup_outcome'],'completed_with_warnings')
self.assertEqual(receive(NATIVE_REPORT+warning,kind='').data['backup_outcome'],'unconfirmed')
def test_null_filename_failed_guest_uses_own_start_identity(self):
report = NATIVE_REPORT.replace('100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z',
'100 web err 1m 1s 0 B null')
for kind, prefix in (('qemu','VM'),('lxc','CT')):
own = report.replace('VM 100 (qemu)',f'VM 100 ({kind})')
# Unrelated guest appears first and must never supply the failed type.
message = f'INFO: Starting Backup of VM 999 ({"lxc" if kind == "qemu" else "qemu"})\n' + own
event = receive(message,'error','vzdump backup status (raw-node): backup failed')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'],kind)
result = deliver(event.event_type,event.data,event.severity)
self.assertIn(f'{prefix} web (100)',result['title'])
self.assertIn(f'❌ {prefix} web (100)',result['body'])
for message in (report.replace('Starting Backup of VM 100','Starting Backup of VM 999'),
report+'\nINFO: Starting Backup of VM 100 (lxc)'):
self.assertEqual(templates._parse_vzdump_message(message)['vms'][0]['type'],'')
def test_native_subject_keeps_cause_without_host_envelope(self):
subject = 'vzdump backup status (raw-host): backup failed: multiple problems'
event = receive('ERROR: archive write failed\n'+NATIVE_REPORT,'error',subject)
event.data['hostname']='configured-alias'
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertNotIn(subject,result['text'])
self.assertEqual(result['text'].count('ERROR: archive write failed'),1)
self.assertIn('configured-alias',result['title'])
setup=receive('Details\n=======\nVMID Name Status Time Size Filename\n\nTotal running time: 0s\nTotal size: 0 B','error',subject.replace('multiple problems','unable to open storage'))
result=deliver(setup.event_type,setup.data,setup.severity)
self.assertEqual(result['text'].count('unable to open storage'),1)
self.assertNotIn(setup.data['pve_title'],result['text'])
unique=receive(NATIVE_REPORT,'error',subject.replace('multiple problems','job-end hook denied'))
result=deliver(unique.event_type,unique.data,unique.severity)
self.assertIn('job-end hook denied',result['body'])
self.assertNotIn('vzdump backup status',result['body'])
def test_backup_diagnostics_are_bounded_with_principal_cause_and_notice(self):
raw = NATIVE_REPORT + '\n' + '\n'.join(f'WARN: repeated warning {i}' for i in range(80)) + '\nERROR: principal archive write failure'
event=receive(raw)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertIn('ERROR: principal archive write failure',result['body'])
self.assertLessEqual(len('\n'.join(line for line in result['body'].splitlines() if line.startswith(('WARN:','ERROR:')))),1024)
self.assertLessEqual(sum(line.startswith(('WARN:','ERROR:')) for line in result['body'].splitlines()),8)
notice=templates.runtime_message('backup.diagnosticsOmitted',lang,count=73)
self.assertIn(notice,result['body'])
self.assertEqual(event.data['pve_message'],raw)
long=receive('ERROR: '+ 'b'*5000,'error','vzdump backup status (node): backup failed')
result=deliver(long.event_type,long.data,long.severity)
self.assertLess(len(result['body']),1400)
self.assertIn('ERROR: '+ 'b'*100,result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted','en',count=1),result['body'])
def test_real_quiet_digest_retains_each_backup_outcome_icon(self):
samples=[(NATIVE_REPORT,'confirmed','💾✅'),(NATIVE_REPORT+'\nWARN: changed file','completed_with_warnings','💾⚠️'),(NATIVE_REPORT+'\nERROR: write failed','failed','💾❌'),('INFO: Starting Backup of VM 100 (qemu)','unconfirmed','💾❔')]
for raw,outcome,icon in samples:
event=receive(raw)
self.assertEqual(event.data['backup_outcome'],outcome)
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang,quiet=True)
self.assertIn(icon,result['body'])
self.assertNotIn('💾❔',result['body']) if outcome!='unconfirmed' else None
import types,datetime
ns={'datetime':datetime.datetime,'runtime_message':templates.runtime_message,'EVENT_EMOJI':templates.EVENT_EMOJI,'CATEGORY_EMOJI':templates.CATEGORY_EMOJI}
compose=extract(SCRIPTS/'notification_manager.py','_compose_digest_body','NotificationManager',ns)
target=types.SimpleNamespace(_notification_language=lambda:'en')
rows=[(i,'backup_complete','backup',1,icon+' node: Backup','') for i,(_,_,icon) in enumerate(samples)]
body=compose(target,rows,use_icons=True)
for _,_,icon in samples:self.assertIn(icon,body)
plain=compose(target,rows,use_icons=False)
for _,_,icon in samples:self.assertNotIn(icon,plain)
should=extract(SCRIPTS/'notification_manager.py','_should_buffer_for_digest','NotificationManager',{})
self.assertFalse(should(types.SimpleNamespace(_DIGEST_EXEMPT_EVENTS={'backup_complete'},_config={'email.digest_enabled':'true'}),'email','INFO','backup_complete'))
def test_quiet_restore_details_are_subordinate_in_text_and_email(self):
from notification_final_fixture import restore_event
event=restore_event('missing module zfs')
for lang in LANGUAGES:
result=deliver(event['event_type'],event['data'],event['severity'],lang,quiet=True)
body_lines=result['buffered'][0][3].splitlines()
for line in body_lines:
if line.strip():
self.assertIn(' '+line.strip(),result['body'])
self.assertIn(' '+__import__('html').escape(line.strip()),result['html'])
self.assertIn('white-space:pre-wrap;',result['html'])
self.assertNotIn(templates.runtime_message('digest.footer',lang),result['body'])
def test_concrete_subject_cause_already_in_error_log_is_not_repeated(self):
event=receive(NATIVE_REPORT+'\n100: ERROR: job-end hook denied','error',
'vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['text'].count('job-end hook denied'),1)
self.assertIn('100: ERROR: job-end hook denied',result['body'])
def test_job_level_subject_cause_survives_warning_cap(self):
raw=NATIVE_REPORT+'\n'+'\n'.join('WARN: repeated diagnostic '+str(i) for i in range(80))
event=receive(raw,'error','vzdump backup status (raw-host): backup failed: job-end hook denied')
for lang in LANGUAGES:
result=deliver(event.event_type,event.data,event.severity,lang)
self.assertEqual(result['body'].count('job-end hook denied'),1)
self.assertNotIn('vzdump backup status',result['body'])
self.assertIn(templates.runtime_message('backup.diagnosticsOmitted',lang,count=73),result['body'])
def test_backup_quiet_email_uses_event_scoped_wrapping(self):
event=receive(NATIVE_REPORT+'\nWARN: changed file')
result=deliver(event.event_type,event.data,event.severity,'sv',quiet=True)
self.assertTrue(result['data'].get('_backup_summary'))
self.assertIn('table-layout:fixed;',result['html'])
self.assertIn('overflow-wrap:break-word;',result['html'])
unrelated=deliver('node_reconnect',{'hostname':'node-a'},'OK')
self.assertNotIn('table-layout:fixed;',unrelated['html'])
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,399 @@
"""Inert producer-to-renderer checks for notification outcome claims."""
import ast
import copy
import json
import re
import time
import sys
import types
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch
from notification_fixture import templates as actual_templates
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
CATALOG = ROOT / 'AppImage/messages/en/common.json'
EXPECTED = {'system_restore_completed': {'body': 'Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}'}}
def extract(path, name, owner=None, namespace=None):
tree = ast.parse(path.read_text())
nodes = tree.body
if owner:
nodes = next(n.body for n in nodes if isinstance(n, ast.ClassDef) and n.name == owner)
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
ns = namespace if namespace is not None else {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns)
return ns[name]
def renderer(catalog, translated=None):
path = SCRIPTS / 'notification_templates.py'
tree = ast.parse(path.read_text())
templates = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets)))
def lookup(obj, key):
for part in key.split('.'):
obj = obj.get(part) if isinstance(obj, dict) else None
return obj
def message(key, language='en', **values):
source = translated if language == 'it' and translated is not None else catalog
namespace = source['runtime']['notifications']
value = lookup(namespace, key) or lookup(catalog['runtime']['notifications'], key) or ''
return value.format_map(type('Safe', (dict,), {'__missing__': lambda self, k: ''})(values))
ns = {'TEMPLATES': templates, 'Dict': dict, 'Any': Any, 'time': time, 're': re,
'_get_hostname': lambda: 'node-a',
'_load_runtime_catalog': lambda lang: (translated if lang == 'it' and translated is not None
else catalog)['runtime']['notifications'],
'_catalog_value': lookup, 'runtime_message': message}
from typing import Optional
ns['Optional'] = Optional
extract(path, '_parse_vzdump_table', namespace=ns)
extract(path, '_parse_vzdump_message', namespace=ns)
extract(path, '_format_vzdump_body', namespace=ns)
return templates, extract(path, 'render_template', namespace=ns)
class OutcomeWording(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalog = json.loads(CATALOG.read_text())
cls.templates, render = renderer(cls.catalog)
cls.render = staticmethod(render)
def test_spanish_restore_uses_maintainer_guests_terminology(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = module.render_template('system_restore_completed', {
'hostname':'node-a','guests':3,'stubs':0,'stale_nodes':0,
'components':1,'duration':'2m','warnings_block':''}, 'es')
self.assertIn('Configuración de guests copiada: 3', result['body'])
self.assertNotIn('invitados', result['body'].lower())
def test_settings_labels_stay_at_upstream_values_in_all_locales(self):
# Frozen from develop eb7cc548; CI shallow checkouts have no base history.
labels = {'en': {'backup_complete': 'Backup complete', 'error_resolved': 'Recovery notification'}, 'de': {'backup_complete': 'Sicherung abgeschlossen', 'error_resolved': 'Wiederherstellungsbenachrichtigung'}, 'es': {'backup_complete': 'Backup completado', 'error_resolved': 'Notificación de recuperación'}, 'fr': {'backup_complete': 'Sauvegarde terminée', 'error_resolved': 'Notification de récupération'}, 'it': {'backup_complete': 'Backup completato', 'error_resolved': 'Notifica di recupero'}, 'pt': {'backup_complete': 'Backup concluído', 'error_resolved': 'Notificação de recuperação'}, 'sk': {'backup_complete': 'Záloha bola dokončená', 'error_resolved': 'Problém bol vyriešený'}, 'sv': {'backup_complete': 'Säkerhetskopieringen är klar', 'error_resolved': 'Återställningsmeddelande'}}
for lang in labels:
path = f'AppImage/messages/{lang}/common.json'
current = json.loads((ROOT / path).read_text())
for event in ('backup_complete', 'error_resolved'):
with self.subTest(lang=lang, event=event):
expected = labels[lang][event]
self.assertEqual(current['runtime']['notifications']['templates'][event]['label'], expected)
if lang == 'en': self.assertEqual(self.templates[event]['label'], expected)
def test_exact_four_english_leaves_match_source_and_catalog(self):
for event, fields in EXPECTED.items():
for field, value in fields.items():
with self.subTest(event=event, field=field):
self.assertEqual(self.templates[event][field], value)
self.assertEqual(self.catalog['runtime']['notifications']['templates'][event][field], value)
def test_webhook_backup_outcome_is_evidence_based_without_rerouting(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
row_ok = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','OK','00:01:00','1.5 GiB','archive')
row_warning = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','WARNINGS','00:01:00','1.5 GiB','archive')
row_error = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','ERROR','00:01:00','1.5 GiB','archive')
row_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('105','beta','err','00:01:00','1.5 GiB','archive')
truncated = 'INFO: Log output was too long to be displayed. Please see task log for details.'
cases = [
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_err+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\n'+header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\n'+truncated, 'confirmed'),
('vzdump', 'info', header+'\n'+row_err+'\nTotal running time: 00:01:00\n'+truncated, 'failed'),
('vzdump', 'warning', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'completed_with_warnings'),
('vzdump', 'info', header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_ok, 'unconfirmed'),
('vzdump', 'warning', header+'\n'+row_err+'\nTotal running time: 00:01:00', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00\n'+truncated, 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00\nERROR: archive write failed', 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\n'+header+'\n'+row_error+'\nTotal running time: 00:02:00', 'failed'),
('vzdump', 'info', header+'\n'+row_error, 'failed'),
('vzdump', 'info', header+'\n'+row_ok+'\nTotal running time: 00:01:00', 'confirmed'),
('vzdump', 'info', header+'\n'+row_ok+'\n'+row_warning+'\nTotal running time: 00:02:00', 'unconfirmed'),
('vzdump', 'info', header+'\n'+row_ok[:30], 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'confirmed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nWARNING: skipped file', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: TASK OK\n104 alpha WARNINGS: 1', 'completed_with_warnings'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 105 (00:01:00)', 'unconfirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Starting Backup of VM 105 (lxc)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Finished Backup of VM 105 (00:01:00)', 'confirmed'),
('vzdump', 'info', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed for VM 104', 'failed'),
('vzdump', 'warning', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'failed'),
('', 'warning', 'Backup scheduled', 'unconfirmed'),
('', 'info', 'Backup complete', 'unconfirmed'),
]
for kind, severity, message, expected in cases:
with self.subTest(message=message, severity=severity):
receiver = Receiver()
receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': 'Backup', 'message': message})
event = receiver._queue.items[0]
self.assertEqual(event.event_type, 'backup_complete')
self.assertEqual(event.data['backup_outcome'], expected)
def test_backup_classifier_preserves_confirmed_and_unverified_paths(self):
path = SCRIPTS / 'notification_events.py'
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
classify = extract(path, '_classify_pve', 'ProxmoxHookWatcher', ns)
severity_map = extract(path, '_map_severity', 'ProxmoxHookWatcher', ns)
backup_outcome = extract(path, '_backup_outcome', 'ProxmoxHookWatcher', ns)
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
ns['NotificationEvent'] = Event
receive = extract(path, 'process_webhook', 'ProxmoxHookWatcher', ns)
class Receiver:
_hostname = 'node-a'
_classify_pve = classify
_map_severity = staticmethod(severity_map)
_backup_outcome = staticmethod(backup_outcome)
def __init__(self): self._queue = Queue()
samples = [('vzdump', 'info', 'Backup', 'job finished'),
('vzdump', 'warning', 'Backup', 'job incomplete'),
('', 'warning', 'backup job', 'Backup scheduled')]
for kind, severity, title, message in samples:
with self.subTest(kind=kind, message=message):
event, entity, _ = classify(None, kind, severity, title, message)
self.assertEqual((event, entity), ('backup_complete', 'vm'))
receiver = Receiver()
reply = receive(receiver, {'fields': {'type': kind}, 'severity': severity,
'title': title, 'message': message})
self.assertEqual(reply['event_type'], event)
self.assertEqual(len(receiver._queue.items), 1)
emitted = receiver._queue.items[0]
self.assertEqual(emitted.severity, 'WARNING' if severity == 'warning' else 'INFO')
output = self.render(emitted.event_type, emitted.data, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertEqual(output['body'].splitlines()[-1], message) # raw PVE body retained
self.assertEqual(classify(None, 'vzdump', 'error', 'Backup', 'failed')[0], 'backup_fail')
def test_incomplete_vzdump_log_does_not_certify_a_guest(self):
from typing import Dict, Optional
ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any,
'runtime_message': lambda key, lang, **kw: key}
extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns)
parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns)
formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns)
incomplete = parser('INFO: Starting Backup of VM 104 (qemu)')
table_header = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('VMID','Name','Status','Time','Size','Filename')
table_err = '{:<8}{:<22}{:<10}{:<10}{:<14}{}'.format('104','alpha','err','00:01:00','1.5 GiB','archive')
failed_table = parser(table_header+'\n'+table_err+'\nTotal running time: 00:01:00')
self.assertEqual(failed_table['vms'][0]['status'].lower(), 'error')
self.assertIn('❌', formatter(failed_table, False, 'en'))
self.assertEqual(incomplete['vms'][0]['status'], 'unknown')
self.assertNotIn('✅', formatter(incomplete, False, 'en'))
mixed = parser('INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nINFO: Starting Backup of VM 105 (lxc)')
self.assertEqual([vm['status'] for vm in mixed['vms']], ['ok', 'unknown'])
formatted = formatter(mixed, False, 'en')
self.assertEqual(formatted.count('✅'), 1)
self.assertNotIn('❌', formatted)
conflicting = parser('INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed\nINFO: Finished Backup of VM 104 (00:01:00)')
self.assertEqual(conflicting['vms'][0]['status'], 'error')
def test_backup_render_preserves_success_and_marks_unconfirmed_and_failure(self):
for outcome, message, title_part, body_part in (
('confirmed', 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)', 'Backup complete', '✅'),
('unconfirmed', 'INFO: Starting Backup of VM 104 (qemu)', 'Backup outcome unconfirmed', '❔'),
('failed', 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed', 'Backup error reported', '❌'),
):
with self.subTest(outcome=outcome):
output = self.render('backup_complete', {'hostname': 'node-a', 'pve_type': 'vzdump',
'pve_message': message, 'pve_title': 'Backup complete', 'backup_outcome': outcome}, 'en')
self.assertIn(title_part, output['title'])
self.assertIn(body_part, output['body'])
if outcome != 'confirmed': self.assertNotIn('Backup complete', output['title'])
output = self.render('backup_complete', {'hostname': 'node-a', 'vmname': 'vm', 'vmid': '104'}, 'en')
self.assertIn('Backup outcome unconfirmed', output['title'])
self.assertNotIn('successfully', output['body'])
conflict = self.render('backup_complete', {'hostname': 'node-a','backup_outcome':'failed',
'pve_message':'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)\nERROR: archive write failed'}, 'en')
self.assertIn('ERROR: archive write failed', conflict['body'])
self.assertNotIn('Backup complete', conflict['title'])
def test_confirmed_title_keeps_single_guest_and_destination_without_misnaming_batches(self):
import importlib.util
spec = importlib.util.spec_from_file_location('isolated_backup_title', SCRIPTS / 'notification_templates.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
actual_render = module.render_template
log = ('INFO: starting new backup job: vzdump 104 --storage PBS-Cloud --mode snapshot\n'
'INFO: Starting Backup of VM 104 (qemu)\nINFO: VM Name: Alpha\n'
'INFO: Finished Backup of VM 104 (00:01:00)')
single = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log}, 'en')
self.assertIn('PBS-Cloud',single['title'])
self.assertIn('VM Alpha (104)',single['title'])
batch = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log+'\nINFO: Starting Backup of VM 105 (lxc)\n'
'INFO: Finished Backup of VM 105 (00:01:00)'}, 'en')
self.assertIn('PBS-Cloud',batch['title'])
self.assertNotIn('Alpha (104)',batch['title'])
no_context = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed'}, 'en')
self.assertEqual(no_context['title'], 'node-a: Backup complete')
named = actual_render('backup_complete', {'hostname':'node-a','backup_outcome':'confirmed',
'pve_message':log.replace('Alpha','Alpha {literal}')}, 'en')
self.assertIn('Alpha {literal} (104)', named['title'])
def test_html_email_badge_and_backup_status_are_context_specific(self):
import html
path = SCRIPTS / 'notification_channels.py'
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
with self.subTest(lang=lang):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
english = self.catalog['runtime']['notifications']
def text(key, data=None, **values):
def lookup(source):
value = source['channels']
for part in key.split('.'):
value = value.get(part) if isinstance(value, dict) else None
return value
return (lookup(catalog) or lookup(english) or '').format(**values)
ns = {'Dict': dict, 'Optional': __import__('typing').Optional,
'_runtime_text': text, '_runtime_notification_text': lambda key, data=None: ''}
build = extract(path, '_build_detail_rows', 'EmailChannel', ns)
fmt = extract(path, '_format_html', 'EmailChannel', ns)
class Email:
from notification_channels import EmailChannel
_SEV_STYLE = EmailChannel._SEV_STYLE
_SEV_DEFAULT = EmailChannel._SEV_DEFAULT
subject_prefix = 'ProxMenux'
_build_detail_rows = staticmethod(build)
unrelated = fmt(Email(), 'Reconnected', 'Body', 'OK', {'_event_type': 'node_reconnect',
'_notification_language': lang, '_group': 'cluster'})
self.assertIn('>' + catalog['channels']['email']['severity']['ok'].upper() + '</span>', unrelated)
for outcome, status in [('confirmed', 'completed'), ('unconfirmed','unconfirmed'), ('failed','failed')]:
email = fmt(Email(), 'Backup', 'Details', 'INFO', {'_event_type': 'backup_complete',
'backup_outcome': outcome, '_notification_language': lang, '_group': 'backup'})
label = catalog['channels']['email']['status'].get(status) or english['channels']['email']['status'][status]
self.assertIn(label, html.unescape(email))
badge_label = label.upper()
self.assertIn('>' + badge_label + '</span>', html.unescape(email))
if outcome == 'failed':
self.assertIn('color:#dc2626;font-weight:600;', email)
self.assertIn('background:#fef2f2;', email)
elif outcome == 'unconfirmed':
self.assertIn('background:#f9fafb;', email)
else:
self.assertIn('background:#f0fdf4;', email)
def test_actual_all_locale_rendering_and_fallback_for_backup_outcomes(self):
import importlib.util
module_path = SCRIPTS / 'notification_templates.py'
spec = importlib.util.spec_from_file_location('isolated_notification_templates', module_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # templates and catalogs only; no manager or sends
samples = {
'confirmed': 'INFO: Starting Backup of VM 104 (qemu)\nINFO: Finished Backup of VM 104 (00:01:00)',
'unconfirmed': 'INFO: Starting Backup of VM 104 (qemu)',
'failed': 'INFO: Starting Backup of VM 104 (qemu)\nERROR: backup failed',
}
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
catalog = json.loads((ROOT / 'AppImage/messages' / lang / 'common.json').read_text())['runtime']['notifications']
for state, message in samples.items():
with self.subTest(lang=lang, state=state):
data = {'hostname':'node-with-a-long-name','backup_outcome':state,'pve_type':'vzdump',
'pve_message':message, 'pve_title':'Backup complete', '_notification_language':lang}
result = module.render_template('backup_complete', data, lang)
if state != 'unconfirmed':
key = 'confirmedTitle' if state == 'confirmed' else 'errorTitle'
expected_title = (catalog.get('backup', {}).get(key) or
self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname'])
self.assertTrue(result['title'].startswith(expected_title), result['title'])
else:
self.assertEqual(result['title'], module.runtime_message('backup.unconfirmedTitle', lang, hostname=data['hostname']))
self.assertNotIn('{hostname}', result['title'])
if state == 'unconfirmed':
source = (catalog if catalog.get('backup', {}).get('unconfirmedBody')
else self.catalog['runtime']['notifications'])
self.assertIn(source['backup']['unconfirmedBody'], result['body'])
if state == 'failed':
self.assertIn(catalog.get('backup', {}).get('errorBody') or
self.catalog['runtime']['notifications']['backup']['errorBody'], result['body'])
enriched, _ = module.enrich_with_emojis('backup_complete', result['title'], result['body'], data)
self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state]))
restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4,
'stubs':1,'stale_nodes':2,'components':1,'duration':'2m','warnings_block':'Missing module'},lang)
self.assertIn('Missing module',restore['body'])
if lang == 'en': self.assertNotIn('fully ready',restore['body'].lower())
def test_warning_and_clean_restore_keep_only_reported_outcome(self):
for warnings in ('', '⚠️ Boot sanity: missing modules\n'):
with self.subTest(warnings=warnings):
events = []
ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: {
'hostname': 'node-a', 'guests': '2', 'stubs': '0', 'stale_nodes': '0',
'components': 'none', 'duration': '2m',
'warnings': 'missing modules' if warnings else ''}),
'notification_manager': types.SimpleNamespace(emit_event=lambda **kw: events.append(kw)),
'jsonify': lambda obj: obj}
handler = extract(SCRIPTS / 'flask_notification_routes.py', 'internal_restore_event', namespace=ns)
response, status = handler()
self.assertEqual((status, response['event_type']), (200, 'system_restore_completed'))
event = events[0]
self.assertEqual(event['severity'], 'WARNING' if warnings else 'INFO')
result = self.render(event['event_type'], event['data'], 'en')
self.assertIn('Post-restore tasks completed', result['body'])
self.assertNotIn('fully ready', result['body'])
if warnings: self.assertIn('missing modules', result['body'])
def test_rich_backup_icon_tracks_outcome_and_digest_default_is_neutral(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
def assign(name):
return ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == name for t in n.targets)))
icons = assign('EVENT_EMOJI')
self.assertNotIn('✅', icons['backup_complete'])
ns = {'TEMPLATES': assign('TEMPLATES'), 'EVENT_EMOJI': icons,
'Dict': dict, 'Any': Any,
'CATEGORY_EMOJI': assign('CATEGORY_EMOJI'), 'SEVERITY_ICONS': assign('SEVERITY_ICONS'),
'FIELD_EMOJI': assign('FIELD_EMOJI'), '_localized_template_labels': lambda *a: {},
'_lxc_update_label_icons': lambda *a: {}}
enrich = extract(SCRIPTS / 'notification_templates.py', 'enrich_with_emojis', namespace=ns)
for state, icon in [('confirmed', '💾✅'), ('unconfirmed', '💾❔'), ('failed', '💾❌')]:
with self.subTest(state=state):
title, body = enrich('backup_complete', 'node-a: Backup', 'Backup report',
{'backup_outcome': state, '_notification_language': 'en'})
self.assertTrue(title.startswith(icon), title)
def test_uncertain_outcomes_have_no_blanket_success_icon(self):
tree = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
icon_map = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'EVENT_EMOJI' for t in n.targets)))
self.assertNotIn('✅', icon_map['backup_complete']) # buffered digest has no outcome metadata
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,50 @@
"""Principal diagnostics must not be deduplicated against inventory substrings."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
def noisy_report(name='ordinary-web', storage='PBS', filename='null', cause='denied'):
# Frozen native Perl notifier/log-reader shape; fixed-column table is
# source-modeled from the official plaintext renderer, not a live PVE run.
return ('\nDetails\n=======\nVMID Name Status Time Size Filename\n'
f'100 {name} err 1m 1s 0 B {filename}\n\n'
'Total running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\n'
f'vzdump --all 1 --storage {storage} --mode snapshot\n\n' +
'\n'.join(f'100: 2026-09-29 17:00:00 ERROR: earlier diagnostic {i}' for i in range(12)) +
f'\n100: 2026-09-29 17:00:00 ERROR: {cause}\n\n')
class PrincipalInventoryTests(unittest.TestCase):
def assert_principal(self, raw, cause):
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'},
event.severity, language, manual=manual)
exact = [line for line in result['body'].splitlines()
if line.strip() == cause or line.rstrip().endswith('ERROR: ' + cause)]
self.assertEqual(len(exact), 1)
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
self.assertNotIn('raw-host', result['text'])
self.assertIn('alias {rack.location}', result['text'])
self.assertEqual(result['text'].count('ERROR: ' + cause), 1)
def test_late_principal_survives_incidental_guest_storage_and_archive(self):
for name, storage, filename in (('ordinary-web','PBS','null'), ('denied','PBS','null'),
('ordinary-web','denied','null'), ('ordinary-web','PBS','denied.tar')):
with self.subTest(name=name, storage=storage, filename=filename):
self.assert_principal(noisy_report(name,storage,filename), 'denied')
def test_raw_principal_braces_and_markup_survive_once(self):
cause = 'denied {rack.location} <native>'
self.assert_principal(noisy_report('denied', cause=cause), cause)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,192 @@
"""Frozen PVE 9.2 report through inert actual receiver and renderers.
No git history, host-management import, notification send or generated fixture.
"""
import ast
import importlib.util
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'AppImage/scripts'
sys.path.insert(0, str(SCRIPTS))
import notification_templates as templates
PVE92 = '''Details
=======
VMID Name Status Time Size Filename
100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z
Total running time: 1m 1s
Total size: 1 GiB
'''
def receiver():
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher')
names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')
ns = {'re': re, 'capture_journal_context': lambda **kw: ''}
class Event:
def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert'
ns['NotificationEvent'] = Event
for name in names:
node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name)
node.decorator_list = []
exec(compile(ast.Module(body=[node], type_ignores=[]), '<inert-webhook>', 'exec'), ns)
class Queue:
def __init__(self): self.items = []
def put(self, event): self.items.append(event)
class Receiver:
_hostname = 'node-a'
_classify_pve = ns['_classify_pve']
_map_severity = staticmethod(ns['_map_severity'])
_backup_outcome = staticmethod(ns['_backup_outcome'])
process_webhook = ns['process_webhook']
def __init__(self): self._queue = Queue()
return Receiver()
def event_for(message, severity='info'):
target = receiver()
target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity,
'title': 'Backup', 'message': message})
return target._queue.items[0]
class PVE92Tests(unittest.TestCase):
def test_exact_maintainer_report_is_confirmed(self):
event = event_for(PVE92)
self.assertEqual(event.data['backup_outcome'], 'confirmed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('Backup complete', result['title'])
self.assertIn('web (100)', result['title'])
self.assertIn('✅', result['body'])
def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self):
failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z'
ok = PVE92.splitlines()[3]
for rows in (ok + '\n' + failed, failed + '\n' + ok):
for severity in ('info', 'error'):
with self.subTest(rows=rows, severity=severity):
message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n'
+ PVE92.replace(ok, rows))
event = event_for(message, severity)
self.assertEqual(event.data['backup_outcome'], 'failed')
self.assertEqual(event.data.get('vmname'), 'db')
self.assertEqual(event.data.get('vmid'), '101')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertNotIn('01:01:06', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_pbs_prefix_is_used_in_parsed_type_and_title(self):
for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'),
('other', '', 'VM/CT')):
with self.subTest(prefix=prefix):
message = PVE92.replace('vm/100/', prefix + '/100/')
parsed = templates._parse_vzdump_message(message)
self.assertEqual(parsed['vms'][0]['type'], kind)
event = event_for(message)
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn(label + ' web (100)', result['title'])
def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self):
from unittest.mock import patch
data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '',
'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING',
'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB',
'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1,
'warnings_block': ''}
slovak = templates._load_runtime_catalog('sk')
english = templates._load_runtime_catalog('en')
for event, field in (('system_restore_completed', 'body'),):
with self.subTest(event=event, field=field):
value = slovak['templates'][event][field]
result = templates.render_template(event, data, 'sk')
self.assertEqual(result[field].strip(), value.format(**data).strip())
self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text())
# Independently updated and absent leaves use the usual provider.
import copy
future = copy.deepcopy(slovak)
future['templates'][event][field] = 'REVIEWED {hostname}'
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a')
future['templates'][event].pop(field)
with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english):
self.assertEqual(templates.render_template(event, data, 'sk')[field],
templates.render_template(event, data, 'en')[field])
def test_complete_table_beats_only_truncated_supplemental_log(self):
message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 +
'\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.')
self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed')
for message, severity, expected in (
(PVE92.replace('ok ', 'OK '), 'info', 'confirmed'),
(message, 'warning', 'completed_with_warnings'),
(message, 'error', 'failed'),
(message + '\nERROR: archive write failed', 'info', 'failed'),
(message + '\nWARNING: skipped file', 'info', 'completed_with_warnings'),
(PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'),
):
with self.subTest(message=message, severity=severity):
self.assertEqual(event_for(message, severity).data['backup_outcome'], expected)
def test_incomplete_and_unrelated_sections_cannot_certify_table(self):
for message in (
PVE92.split('Total running time:')[0],
PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''),
PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'),
PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'),
PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'),
PVE92.replace('1 GiB vm/', 'garbage vm/'),
PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'),
):
with self.subTest(message=message):
# Complete guest logs cannot rescue a genuinely incomplete table.
message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)'
self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed')
def test_blank_line_between_rows_does_not_hide_a_failure(self):
message = PVE92.replace('\n\nTotal',
'\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
event = event_for(message)
self.assertEqual(event.data['backup_outcome'], 'failed')
result = templates.render_template(event.event_type, event.data, 'en')
self.assertIn('db (101)', result['title'])
self.assertIn('❌ CT db (101)', result['body'])
def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self):
import html
from notification_channels import EmailChannel
email = object.__new__(EmailChannel)
email.subject_prefix = '[ProxMenux]'
failed = PVE92.replace('\n\nTotal',
'\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal')
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')):
with self.subTest(lang=lang, severity=severity, message=message):
event = event_for(message, severity)
result = templates.render_template(event.event_type, event.data, lang)
context = {**event.data, '_event_type': event.event_type,
'_notification_language': lang, '_group': result['group']}
markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context))
self.assertIn(result['title'], markup)
if event.data['backup_outcome'] == 'failed':
self.assertIn('db (101)', result['title'])
self.assertNotIn('web', result['title'])
self.assertIn('❌ CT db (101)', markup)
status = templates.runtime_message('channels.email.status.failed', lang)
else:
self.assertIn('VM web (100)', result['title'])
self.assertIn('✅ VM web (100)', markup)
status = templates.runtime_message('channels.email.status.completed', lang)
badge = (templates.runtime_message('channels.email.severity.critical', lang)
if event.event_type == 'backup_fail' else status)
self.assertIn('>' + badge.upper() + '</span>', markup)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,24 @@
"""Review regressions at actual consumer seams; no operational imports."""
import unittest
from notification_fixture import receive, LANGUAGES
from notification_final_fixture import deliver
class ReviewCorrectionTests(unittest.TestCase):
def test_subject_equivalent_late_error_is_reserved_before_cap(self):
cause = 'job-end hook denied'
# Frozen native Perl notifier/log-reader output; Rust table source-modeled.
raw = '\nDetails\n=======\nVMID Name Status Time Size Filename \n100 web err 1m 1s 0 B null \n\nTotal running time: 1m 1s\nTotal size: 0 B\n\nLogs\n====\nvzdump --all 1 --storage PBS --mode snapshot\n\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 0\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 1\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 2\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 3\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 4\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 5\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 6\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 7\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 8\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 9\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 10\n100: 2026-09-29 17:00:00 ERROR: earlier diagnostic 11\n100: 2026-09-29 17:00:00 ERROR: job-end hook denied\n\n\n'
event = receive(raw, 'error', 'vzdump backup status (raw-host): backup failed: ' + cause)
for language in LANGUAGES:
for manual in (False, True):
with self.subTest(language=language, manual=manual):
result = deliver(event.event_type, {**event.data, 'hostname':'alias {rack.location}'}, event.severity, language, manual=manual)
self.assertEqual(result['text'].count(cause), 1)
self.assertNotIn('raw-host', result['text'])
diagnostics = [line for line in result['body'].splitlines() if 'ERROR:' in line]
self.assertLessEqual(len(diagnostics), 8)
self.assertLessEqual(len('\n'.join(diagnostics)), 1024)
self.assertTrue(all(len(line) <= 512 for line in diagnostics))
self.assertEqual(result['data']['pve_message'], raw)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,290 @@
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
import copy
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import urllib.error
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_oci_complete_ui_sk as fixtures
from proxmenux_oci import casaos, cli, converter, i18n
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.github_source import GitHubSource, SourceError
import oci_image_cache
import oci_dhcp_lease
import oci_rocm_check
import oci_ui
import oci_watchdog
import configure_jellyfin_encoding
class TechnicalMessages(unittest.TestCase):
setUp = fixtures.CompleteUI.setUp
patch = fixtures.CompleteUI.patch
def remote_language(self):
self.patch(oci_ui, '_language', 'sk')
self.patch(oci_ui, '_cache', self.sk)
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
for text, expected in [('# App', 'README neobsahuje'),
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
converter.extract_compose(text)
raw = str(caught.exception)
self.assertIn(expected, i18n.diagnostic(caught.exception))
self.assertEqual(str(caught.exception), raw)
def test_schema_error_keeps_field_details_and_original_exception(self):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate({})
raw = str(caught.exception)
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
template = {}
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
with patch.dict(sys.modules, {'jsonschema': None}):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate(template)
raw = 'python3-jsonschema is required to generate templates'
self.assertEqual(str(caught.exception), raw)
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
self.assertEqual(str(caught.exception), raw)
self.assertIsNone(self.catalog.validate(template, required=False))
read.assert_not_called()
self.assertEqual(template, {})
for effect in self.effects:
effect.assert_not_called()
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
with self.assertRaises(converter.ConversionError) as caught:
casaos.parse_casaos_compose('[]')
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
url = 'https://fiction.invalid/catalogue?ref=example'
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
with self.assertRaises(SourceError) as caught:
GitHubSource(token='fictional-token')._request(url)
request.assert_called_once()
text = i18n.diagnostic(caught.exception)
self.assertIn('GitHub vrátil HTTP 403', text)
self.assertIn(url, text)
self.assertIn('GITHUB_TOKEN', text)
self.assertNotIn('fictional-token', text)
def test_github_timeout_retains_external_reason_and_three_attempts(self):
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
side_effect=urllib.error.URLError('fictional external transport reason'))
self.patch(__import__('time'), 'sleep')
with self.assertRaises(SourceError) as caught:
GitHubSource()._request('https://fiction.invalid/template')
self.assertEqual(request.call_count, 3)
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
self.assertEqual(i18n.diagnostic(value), value)
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
slovak = cli.build_parser()
self.assertIn('Vypísať aplikácie', slovak.format_help())
with patch.object(i18n, '_language', 'en'):
english = cli.build_parser()
self.assertIn('List applications', english.format_help())
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
template = self.catalog.compose('tandoor')
saved = copy.deepcopy(template)
text = cli._template_summary_text(template)
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
self.assertIn('Dátové zväzky:', text)
self.assertIn('Stav: ' + template['status'], text)
for mount in template['container_contract']['volumes']:
self.assertIn(mount['container_path'], text)
self.assertEqual(template, saved)
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
template = self.catalog.compose('jellyfin')
saved = copy.deepcopy(template)
self.patch(cli.Catalog, 'compose', return_value=template)
self.patch(cli, 'build_deployment', return_value={'fiction': True})
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
answer = self.patch(__import__('builtins'), 'input', return_value='n')
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
self.assertEqual(template, saved)
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
template = self.catalog.compose('jellyfin')
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
self.patch(cli.Catalog, 'compose', return_value=template)
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
output = io.StringIO()
with redirect_stdout(io.StringIO()), redirect_stderr(output):
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
answer.assert_not_called()
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
self.remote_language()
identity = '11111111-1111-4111-8111-111111111111'
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
self.patch(oci_watchdog, 'same_installation', return_value=True)
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
state = oci_watchdog.new_state(identity); state['seen'] = 50
output = io.StringIO()
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
self.assertTrue(state['settled'])
self.assertEqual(output.getvalue(), '')
start.assert_not_called(); notice.assert_not_called()
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
identity = '11111111-1111-4111-8111-111111111111'
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
self.patch(oci_watchdog, 'same_installation', return_value=True)
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[])
start = self.patch(oci_watchdog, 'start', return_value=True)
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
outcomes = []
for locale in ('en', 'sk'):
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
state = oci_watchdog.new_state(identity); state['seen'] = 50
text = io.StringIO()
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
self.assertIn('CT 777 (Fiction)', text.getvalue())
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
self.assertEqual(outcomes[0], outcomes[1])
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
self.remote_language()
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
self.patch(oci_image_cache, 'archives_of', return_value={})
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
error = io.StringIO()
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
self.remote_language()
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
('external provider exception 0x123', 'external provider exception 0x123')]:
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
output = io.StringIO()
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
self.assertEqual(output.getvalue().strip(), expected)
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
self.remote_language()
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
(False, 'Nastavenia Jellyfin už boli použité:')]:
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
output = io.StringIO()
with redirect_stdout(output): configure_jellyfin_encoding.main()
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
self.remote_language()
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
self.patch(oci_dhcp_lease.fcntl, 'flock')
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
output = io.StringIO()
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
repair.assert_called_once_with(777, 0)
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
class StandalonePublisher(unittest.TestCase):
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
inside = False
def chroot(path):
nonlocal inside
inside = True
def read(path, **kwargs):
if inside:
raise FileNotFoundError('Host language catalog is outside the container root')
return ('{"language":"sk"}' if path.name == 'config.json' else
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
with ExitStack() as guards:
for target, name, options in [
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
(module.platform, 'machine', {'return_value': 'x86_64'}),
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
(module.ctypes, 'CDLL', {'return_value': libc}),
(module.ctypes, 'get_errno', {'return_value': 13}),
(module.os, 'open', {'return_value': 42}),
(module.os, 'close', {}), (module.os, 'fchdir', {}),
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
guards.enter_context(patch.object(target, name, **options))
with self.assertRaises(OSError) as caught:
module.main()
self.assertTrue(inside)
self.assertEqual(caught.exception.errno, 13)
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
root = Path(directory); (root / 'lang').mkdir()
(root / 'config.json').write_text('{"language":"sk"}')
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
spec.loader.exec_module(module)
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
syscall.assert_not_called()
(root / 'config.json').write_text('invalid json')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
(root / 'config.json').write_text('{"language":"en"}')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
import copy
from contextlib import ExitStack, redirect_stdout
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
from proxmenux_oci import cli, i18n, installer, stack
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
from test_advanced_flow_order import RecordingUI, addresses, storages
class CompleteUI(unittest.TestCase):
def setUp(self):
self.scope = ExitStack()
self.addCleanup(self.scope.close)
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
self.catalog = Catalog(ROOT / 'oci')
self.patch(i18n, '_language', 'sk')
self.patch(i18n, '_cache', self.sk)
self.patch(installer.host, 'storages', side_effect=storages)
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
self.effects = []
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
def patch(self, obj, name, *args, **kwargs):
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
def test_search_no_match_uses_application_wording_and_never_installs(self):
ui = Mock()
ui.ask.return_value = 'fiction-no-such-app'
cli._search(self.catalog, ui, [])
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
ui.choose.assert_not_called()
def test_immich_detail_translates_service_roles_without_changing_template(self):
template = self.catalog.compose('immich')
before = copy.deepcopy(template)
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
self.assertIn('Strojové učenie', text)
self.assertIn('Aplikácia', text)
self.assertIn('Oficiálny obraz', text)
self.assertNotIn('Machine learning', text)
self.assertEqual(template, before)
with patch.object(i18n, '_language', 'en'):
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
('en', "The application 'fiction-missing' is not in the index")]:
with self.subTest(language=language), patch.object(i18n, '_language', language):
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
self.catalog.find_item('fiction-missing')
output = io.StringIO()
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
with self.assertRaises(ConversionError) as caught:
self.catalog.compose('fiction-missing')
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
for escape in (False, True):
with self.subTest(app=app, escape=escape):
ui = Mock()
ui.confirm.return_value = False
ui.detail_menu.return_value = None
if escape:
ui.detail_menu.side_effect = UserCancelled()
try:
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
except UserCancelled:
self.assertTrue(escape)
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
with self.subTest(app=app, mode=mode):
template = self.catalog.compose(app)
before = copy.deepcopy(template)
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
with patch.object(i18n, '_language', 'en'):
plan_en = installer.build_deployment(template, RecordingUI(), mode)
self.assertEqual(plan_sk, plan_en)
self.assertEqual(template, before)
text = cli._deployment_summary_text(template, plan_sk)
self.assertNotIn('Consume/export:', text)
if app == 'paperless-ngx':
self.assertIn('Priečinky consume a export', text)
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
ui = RecordingUI()
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
server = next(service for service in plan['services'] if service['name'] == 'romm')
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
template = self.catalog.compose('tandoor')
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
self.assertIn('nevyžaduje privilegovaný', warning)
credentials = template['first_run']['credentials']
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
def test_documented_commands_paths_and_literals_remain_copyable(self):
fragments = {
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
}
for source, tokens in fragments.items():
with self.subTest(source=source):
for token in tokens:
self.assertIn(token, i18n.translate(source))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,268 @@
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
import json
from pathlib import Path
import runpy
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / 'oci/src'))
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
from proxmenux_oci.ui import UserCancelled
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
class UI:
def __init__(self, choices=(), answers=(), confirms=()):
self.choices = iter(choices)
self.answers = iter(answers)
self.confirms = iter(confirms)
self.seen = []
def choose(self, text, options, default=None, **kwargs):
self.seen.append((text, options, kwargs))
return next(self.choices)
def ask(self, text, default=None, **kwargs):
self.seen.append(text)
return next(self.answers)
def password(self, text):
self.seen.append(text)
raise UserCancelled(text)
def confirm(self, text, default=False, **kwargs):
self.seen.append((text, default))
return next(self.confirms)
def review(self, text, title=None, **kwargs):
self.seen.append((text, title, kwargs))
return False
def message(self, text, title=None):
self.seen.append((text, title))
info = message
class CustomRegistryLocalizationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
(self.base / 'lang').mkdir()
(self.base / 'config.json').write_text('{"language":"sk"}')
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
item = patch.object(i18n, name, value)
item.start()
self.addCleanup(item.stop)
# Any unmocked subprocess, network fetch or installation is a failure.
self.run = self.guard(custom.subprocess, 'run')
self.network = self.guard(custom.urllib.request, 'urlopen')
self.install = self.guard(custom, 'install_template')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
self.run.assert_not_called()
self.network.assert_not_called()
self.install.assert_not_called()
def manager(self):
return SimpleNamespace(path=Path('/fixture/auth.json'),
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
login=MagicMock(), logout=MagicMock())
def menu(self, ui, manager):
with patch.object(accounts, 'require_root'):
accounts.registry_accounts_menu(ui, auth=manager)
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
expected = {
'multi-service-compose': 'definuje viac než jeden obraz',
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
}
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
for code, message in expected.items():
with self.subTest(code=code):
self.assertEqual(custom.blocker_text('service:app:' + code), message)
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
self.no_effects()
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
ui = UI([None])
with self.assertRaises(UserCancelled) as caught:
custom.explore(ui)
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
self.no_effects()
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
cases = [
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
('services: [', 'Súbor Compose nie je platný YAML:'),
(COMPOSE + ' environment: 42\n', 'Položka environment'),
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
]
for text, expected in cases:
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
self.assertRaises(custom.ConversionError) as caught:
custom.explore(UI())
self.assertIn(expected, str(caught.exception))
self.no_effects()
def test_converter_raw_contract_and_english_ui_are_preserved(self):
bad = COMPOSE + ' ports: [invalid]\n'
with self.assertRaises(custom.ConversionError) as raw:
custom.template_from_compose(bad)
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
with patch.object(i18n, '_language', 'en'), \
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
self.assertRaises(custom.ConversionError) as ui_error:
custom.explore(UI())
self.assertEqual(str(ui_error.exception), str(raw.exception))
self.no_effects()
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
detail = " {'target': '<path>{literal}\\á'}"
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
unknown = 'unknown <diagnostic> {literal}'
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
self.no_effects()
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
messages = [
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
'Docker Compose CasaOS no valido: fixture',
]
for text in messages:
with self.subTest(text=text):
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
with patch.object(i18n, '_language', 'en'):
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
self.no_effects()
def test_custom_review_cancel_keeps_installation_disabled(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
custom.explore(ui)
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
self.assertIn('Obraz:', repr(ui.seen))
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
self.no_effects()
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
patch.object(custom, '_registry_refused', return_value=False), \
patch.object(accounts, 'offer_sign_in') as offer:
custom.explore(ui)
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
offer.assert_not_called()
self.no_effects()
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
manager = self.manager()
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
self.menu(ui, manager)
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
rendered = repr(ui.seen)
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
self.assertIn(value, rendered)
self.assertNotIn('obráz', rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
manager = self.manager()
ui = UI(['storage', None])
self.menu(ui, manager)
rendered = repr(ui.seen)
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
self.assertIn(value, rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
manager = self.manager()
self.menu(ui, manager)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_mismatched_registry_is_rejected_before_download(self):
manager = self.manager()
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
with patch.object(accounts, 'download_registry_image') as download:
self.menu(ui, manager)
download.assert_not_called()
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
self.no_effects()
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
ui = UI(confirms=[False])
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
manager.assert_not_called()
root.assert_not_called()
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
self.no_effects()
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
('requested access to the resource is denied', 'odmietol prístup'),
('manifest unknown', 'Obraz alebo tag'),
('unsupported manifest', 'metadáta obrazu'),
('docker-credential-helper', 'správu prihlasovacích údajov'),
('unknown failure', 'Obraz sa nepodarilo načítať')]
for language in ('sk', 'en'):
for error, expected in cases:
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
if language == 'sk':
self.assertIn(expected, message)
if 'credential' not in error:
self.assertIn('registry.test:5000', message)
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,176 @@
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, management as menu, recreation
from proxmenux_oci.ui import UserCancelled
class ModifyCancellationTests(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update({
'installation_id': 'fixture-oci-edit-cancel',
'observed': {'config_sha256': 'fixture-config-hash'},
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
'deployment': {'vmid': 110, 'hostname': 'fixture',
'resources': {'cores': 2, 'memory_mb': 512},
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
'mounts': [], 'devices': [], 'environment': [
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
self.original = copy.deepcopy(self.record)
self.ui.back_enabled = False
self.path_write = self.guard(Path, 'write_text')
self.path_bytes = self.guard(Path, 'write_bytes')
def script(self, choices, answers=(), confirms=(), reviews=()):
self.ui.choose.side_effect = choices
self.ui.ask.side_effect = answers
self.ui.confirm.side_effect = confirms
self.ui.review.side_effect = reviews
def no_effects(self):
super().no_effects()
self.path_write.assert_not_called()
self.path_bytes.assert_not_called()
self.assertFalse(self.ui.back_enabled)
def test_main_cancel_is_distinct_falsy_cancellation(self):
self.script([None])
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_main_discard_without_edits_returns_cancel_code(self):
self.script(['discard'])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_not_called()
self.no_effects()
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
self.script([None])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
self.script(['cores', 'discard'], ['3'], [True])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_main_cancel_after_edits_requires_discard_confirmation(self):
self.script(['cores', None], ['3'], [True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
self.script(['cores', 'discard', None], ['3'], [False, True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
last_options = dict(self.ui.choose.call_args.args[1])
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertEqual(self.ui.confirm.call_count, 2)
self.no_effects()
def test_section_cancel_returns_to_editor_before_main_cancel(self):
self.script(['cores', None], [UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 2)
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.ui.review.assert_not_called()
self.no_effects()
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
self.script(['cores', None], ['0', UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.no_effects()
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 3)
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
self.no_effects()
def test_native_menu_returns_to_list_after_editor_cancel(self):
self.script(['110', 'modify', None, None])
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 4)
self.no_effects()
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
for choice in (None, 'discard'):
with self.subTest(choice=choice):
self.script([choice])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
self.no_effects()
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
self.script(['apply'])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
self.no_effects()
def test_real_editor_error_is_not_converted_to_cancellation(self):
self.script(['cores'], [RuntimeError('fixture editor failure')])
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
menu.direct_management(ROOT/'oci', 110, 'modify')
self.no_effects()
def test_real_cli_validation_failure_remains_failure_one(self):
self.script(['cores'], [cli.InstallError('fixture validation failure')])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
self.no_effects()
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
self.ui.choose.assert_not_called()
self.no_effects()
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = True
def lifecycle(command, title):
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
proposal_file = Path(command[command.index('--proposal')+1])
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
proposal = json.loads(proposal_file.read_text())
self.assertEqual(proposal['operation'], 'recreate')
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
self.assertNotIn('private-fixture-value', ' '.join(command))
return True
self.lifecycle.side_effect = lifecycle
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
self.backup.assert_called_once()
self.lifecycle.assert_called_once()
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
self.assertEqual(self.record, self.original)
@@ -0,0 +1,95 @@
"""Keep the Monitor OCI editor path distinct from coordinated stack recreation."""
import ast
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CLI = ROOT / 'oci/src/proxmenux_oci/cli.py'
MONITOR = ROOT / 'AppImage/components/virtual-machines.tsx'
WRAPPER = ROOT / 'scripts/oci/manage_instance.sh'
EXTRA_DEVICES = ROOT / 'oci/src/proxmenux_oci/extra_devices.py'
STACK_RECREATION = ROOT / 'oci/src/proxmenux_oci/stack_recreation.py'
def extracted_stack_manager(scope):
source = MENU.read_text(encoding='utf-8').replace(
' from .stack_recreation import modify_stack\n', '')
node = next(item for item in ast.parse(source).body
if isinstance(item, ast.FunctionDef) and item.name == '_manage_stack')
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope['_manage_stack']
class MonitorModifyContractTests(TestCase):
def test_monitor_sends_modify_for_every_instance(self):
source = MONITOR.read_text(encoding='utf-8')
self.assertIn('action: "update" | "modify" | "recover"', source)
self.assertIn('action: "modify",', source)
self.assertNotIn('"recreate"', source)
def test_the_editor_has_one_name_for_one_container_and_for_several(self):
menu = MENU.read_text(encoding='utf-8')
self.assertIn("('modify', translate('Modify: edit resources, network, paths and GPU'))", menu)
self.assertIn("('modify', translate('Modify extra paths and devices'))", menu)
self.assertEqual(menu.count("('recreate', translate("), 1)
self.assertIn("('recreate', translate('Recreate every container with its saved configuration'))", menu)
def test_recreate_on_a_single_instance_changes_nothing(self):
node = next(item for item in ast.parse(MENU.read_text(encoding='utf-8')).body
if isinstance(item, ast.FunctionDef) and item.name == 'manage_instance')
row = {'vmid': 101, 'reason': 'matched', 'stack': None, 'pending': False, 'status': 'installed'}
run_lifecycle = Mock()
scope = {'carry_records': lambda *_, **__: {}, 'check_selected': lambda _project, selected: selected,
'translate': lambda text: text, '_run_lifecycle': run_lifecycle, '_manage_stack': Mock()}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), str(MENU), 'exec'), scope)
ui = Mock()
self.assertFalse(scope['manage_instance'](Path('/inert'), ui, row, action='recreate'))
self.assertEqual(ui.method_calls, [])
run_lifecycle.assert_not_called()
def test_cli_and_monitor_wrapper_accept_the_modify_action(self):
self.assertIn('choices=("update", "modify", "recreate")', CLI.read_text(encoding='utf-8'))
self.assertIn('${ACTION:-} != "modify"', WRAPPER.read_text(encoding='utf-8'))
def test_modify_device_filter_matches_the_device_prompt_contract(self):
caller = STACK_RECREATION.read_text(encoding='utf-8')
device_prompt = EXTRA_DEVICES.read_text(encoding='utf-8')
prompt = next(node for node in ast.parse(device_prompt).body
if isinstance(node, ast.FunctionDef) and node.name == 'ask_extra_devices')
self.assertIn('kinds', [argument.arg for argument in prompt.args.args])
self.assertIn("ask_extra_devices(ui, attached, True, kinds=", caller)
def test_modify_action_opens_the_stack_editor_without_running_lifecycle(self):
primary = {'stack': {'members': [{'vmid': 101}]}}
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda *_: primary
modify_stack = Mock(return_value=True)
run_lifecycle = Mock()
manager = extracted_stack_manager({
'sys': SimpleNamespace(path=[]),
'Path': Path,
'modify_stack': modify_stack,
'_run_lifecycle': run_lifecycle,
})
ui = SimpleNamespace()
with patch.dict(sys.modules, {
'oci_instances': instances,
'oci_stack_replay': ModuleType('oci_stack_replay'),
}):
result = manager(Path('/inert'), ui, {'vmid': 101}, action='modify')
self.assertTrue(result)
modify_stack.assert_called_once_with(Path('/inert'), ui, primary, run_lifecycle)
run_lifecycle.assert_not_called()
if __name__ == '__main__':
import unittest
unittest.main()
@@ -0,0 +1,43 @@
"""Every result the OCI engine reports has a notification the Monitor accepts and words."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
EVENTS = [f'oci_{kind}_{result}' for kind in ('update', 'modify', 'recreate') for result in ('completed', 'failed')]
EVENTS += ['oci_watchdog_restarted', 'oci_watchdog_failed']
class OciOperationEvents(TestCase):
def test_the_monitor_accepts_and_words_every_event(self):
route = (ROOT / 'AppImage/scripts/flask_notification_routes.py').read_text()
accepted = route[route.index('_OCI_EVENTS = {'):route.index('@notification_bp.route(\'/api/internal/oci-event\'')]
templates = (ROOT / 'AppImage/scripts/notification_templates.py').read_text()
for event in EVENTS:
self.assertIn(f"'{event}':", accepted, event)
self.assertEqual(len(re.findall(rf"^ '{event}': \{{$", templates, re.MULTILINE)), 1, event)
self.assertEqual(len(re.findall(rf"^ '{event}': +'\\[uU]", templates, re.MULTILINE)), 1, event)
def test_every_language_has_the_label_and_the_message_of_every_event(self):
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
for locale in LOCALES:
catalog = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())
for event in EVENTS:
self.assertTrue(catalog['settings']['notifications']['eventTypes'][event].strip(), f'{locale} {event}')
message = catalog['runtime']['notifications']['templates'][event]
for part in ('title', 'body', 'label'):
self.assertEqual(fields(message[part]), fields(english[event][part]), f'{locale} {event} {part}')
self.assertEqual(message[part].count('\n'), english[event][part].count('\n'), f'{locale} {event} {part}')
def test_changing_an_application_is_reported_as_modified(self):
single = (ROOT / 'oci/remote/oci_update_current.py').read_text()
self.assertIn("kind = 'update' if operation == 'update' else 'modify'", single)
self.assertIn('oci_operation_notice.operation([vmid], kind,', single)
stack = (ROOT / 'oci/remote/oci_stack_modify.py').read_text()
self.assertIn("oci_operation_notice.operation([vmid], 'modify',", stack)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']
self.assertIn('new options', english['oci_modify_completed']['body'])
self.assertNotIn('new options', english['oci_recreate_completed']['body'])
@@ -0,0 +1,50 @@
"""OCI manager Apps opens only on Proxmox VE 9.1 or later, the first release
that runs OCI images as LXC containers."""
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
LAUNCHER = ROOT / 'scripts/oci/oci_manager_apps.sh'
class OciPveVersionGateTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
text = LAUNCHER.read_text(encoding='utf-8')
cls.function = ''.join(re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', text, re.M | re.S).group(0)
for name in ('pve_manager_version', 'pve_supports_oci'))
cls.text = text
def supported(self, version):
with tempfile.TemporaryDirectory() as folder:
fake = Path(folder, 'dpkg-query')
# Without Proxmox the package is not installed: dpkg-query fails.
fake.write_text('#!/bin/sh\nexit 1\n' if version is None else
f'#!/bin/sh\nprintf %s "{version}"\n')
fake.chmod(0o755)
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
capture_output=True, text=True)
return result.returncode == 0
def test_versions_from_9_1_open_it(self):
for version in ('9.1.0', '9.1.4', '9.2.2', '10.0.1'):
with self.subTest(version=version):
self.assertTrue(self.supported(version))
def test_older_versions_and_hosts_without_proxmox_do_not(self):
for version in ('8.4.1', '9.0.11', None):
with self.subTest(version=version):
self.assertFalse(self.supported(version))
def test_the_version_is_read_without_the_slow_pveversion(self):
self.assertNotIn('pveversion', self.function)
def test_it_is_checked_before_anything_is_installed(self):
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,235 @@
"""Inert, extracted consumers for Rclone guidance and HedgeDoc metadata."""
import ast
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
INSTALLER = ROOT / 'oci/src/proxmenux_oci/installer.py'
RCLONE = ('Configure the remote in the Rclone Web UI first. Checking its name may start a '
'stopped CT, even in a dry run. Applying the mount restarts the CT and attempts '
'to publish two FUSE views on the host; a dry run does not publish them.')
def extract_function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = dict(dependencies)
future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)
exec(compile(ast.fix_missing_locations(ast.Module(body=[future, node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class RcloneHedgeDocWording(TestCase):
def test_i18n_workflow_runs_for_remote_shell_edits(self):
workflow = (ROOT / '.github/workflows/test-i18n.yml').read_text()
self.assertEqual(workflow.count(" - 'oci/remote/**/*.sh'"), 2)
def test_remote_dry_run_messages_are_extracted_and_translate_with_fallback(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
shell = ROOT / 'oci/remote/configure_rclone_mount.sh'
keys = [
'Dry run completed; mount configuration was not applied. The CT was already running.',
'Dry run completed; mount configuration was not applied. The CT was started for the remote check.',
]
extracted = extractor['extract_translate_texts'](ROOT / 'oci/remote')
self.assertTrue(set(keys).issubset(extracted))
ui = (ROOT / 'oci/remote/oci_ui.sh').read_text()
lookup = 'translate() {' + ui.split('translate() {', 1)[1].split('\n}', 1)[0] + '\n}\n'
gate = shell.read_text().split('if [[ $DRY_RUN == 1 ]]; then', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
gate = 'if [[ $DRY_RUN == 1 ]]; then' + gate + '\nfi\n'
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory) / 'locale.json'
for status, expected_key in [('running', keys[0]), ('stopped', keys[1])]:
for translations, expected in [({}, expected_key),
({expected_key: 'MESSAGGIO DI TEST'}, 'MESSAGGIO DI TEST')]:
with self.subTest(status=status, translations=translations):
cache.write_text(json.dumps(translations))
setup = (f'_OCI_LANGUAGE=it\n_OCI_LANG_FILE={cache}\n'
f'STATUS={status}\nDRY_RUN=1\n' + lookup +
'msg_ok() { printf "%s\\n" "$1"; }\n')
result = subprocess.run(['bash', '-c', setup + gate],
text=True, capture_output=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), expected)
def test_remote_dry_run_reports_stopped_ct_start_and_no_mount_publication(self):
script = (ROOT / 'oci/remote/configure_rclone_mount.sh').read_text()
seam = script.split('msg_info "$(translate "Checking the remote...")"', 1)[1].split('\nfi\n\nRC_USER=', 1)[0]
# This is the real status/listremotes/dry-run gate, not the root-guarded script.
seam = 'msg_info "$(translate "Checking the remote...")"' + seam + '\nfi\n'
for status, dry_run, starts, post_gate in (
('stopped', '1', 1, False), ('running', '1', 0, False),
('stopped', '0', 1, True), ('running', '0', 0, True)):
with self.subTest(status=status, dry_run=dry_run):
with tempfile.TemporaryDirectory() as directory:
trace = Path(directory) / 'pct.log'
harness = '''
set -Eeuo pipefail
VMID=101 REMOTE_NAME=notes DRY_RUN=%s
TRACE_FILE=%s
pct() {
printf 'pct:%%s\\n' "$*" >>"$TRACE_FILE"
case "$1" in
status) printf 'status: %s\\n' ;;
exec) if [[ "${5:-}" == version ]]; then return 0; else printf 'notes:\\n'; fi ;;
start) return 0 ;;
*) return 97 ;;
esac
}
oci_quiet() { "$@"; }
oci_log() { :; }
sleep() { :; }
translate() { printf '%%s' "$1"; }
msg_info() { printf 'info:%%s\\n' "$1"; }
msg_ok() { printf 'ok:%%s\\n' "$1"; }
die() { printf 'error:%%s\\n' "$1"; exit 1; }
''' % (dry_run, str(trace), status)
result = subprocess.run(['bash', '-c', harness + seam + '\nprintf "POST_DRY_GATE\\n"\n'],
text=True, capture_output=True, timeout=5)
calls = trace.read_text()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls.count('pct:start 101'), starts)
self.assertIn('pct:exec 101 -- /usr/local/bin/rclone listremotes', calls)
self.assertEqual('POST_DRY_GATE' in result.stdout, post_gate)
if dry_run == '1':
self.assertIn('mount configuration was not applied', result.stdout)
if starts:
self.assertIn('CT was started for the remote check', result.stdout)
else:
self.assertIn('CT was already running', result.stdout)
self.assertNotIn('pct:stop', calls)
def test_hedgedoc_overlay_survives_index_enrichment(self):
sys.path.insert(0, str(ROOT / 'oci/src'))
try:
from proxmenux_oci.catalog import Catalog
finally:
sys.path.pop(0)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
catalog = Catalog(root, source=Mock())
catalog.apps_dir.mkdir(parents=True)
catalog.overlays_dir.mkdir(parents=True)
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
upstream = 'HedgeDoc gives you access to all your files wherever you are.'
corrected = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template['catalog_ui']['description']['en_US'] = upstream
template['catalog_ui']['tagline']['en_US'] = upstream
overlay = json.loads((ROOT / 'oci/catalog/overlays/hedgedoc.json').read_text())
self.assertTrue(overlay['catalog_ui']['hidden'])
self.assertEqual(overlay['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(overlay['catalog_ui']['tagline']['en_US'], corrected)
(catalog.overlays_dir / 'hedgedoc.json').write_text(json.dumps(overlay))
catalog._apply_overlay('hedgedoc', template)
self.assertEqual(template['catalog_ui']['description']['en_US'], corrected)
self.assertEqual(template['catalog_ui']['tagline']['en_US'], corrected)
(catalog.apps_dir / 'hedgedoc.json').write_text(json.dumps(template))
payload = {'applications': [{'id': 'hedgedoc', 'description': upstream},
{'id': 'unmodified', 'description': 'Original summary'}]}
catalog._enrich_index_from_templates(payload)
self.assertEqual(payload['applications'][0]['description'], corrected)
self.assertEqual(payload['applications'][1]['description'], 'Original summary')
def test_rclone_guidance_precedes_inputs_and_confirmation(self):
events = []
class UI:
def message(self, text): events.append(('message', text))
def ask(self, label, default=None, required=True):
events.append(('ask', label))
return {'VMID of the Rclone OCI container': '101',
'Exact name of the remote': 'notes',
'Mount name': 'notes'}.get(label, default or '')
def choose(self, label, options, default):
events.append(('choose', label))
return default
def confirm(self, label, default):
events.append(('confirm', label))
return True
schema = {name: {'default': value} for name, value in (
('vfs_cache_mode', 'writes'), ('shared_mount_root_parent', '/mnt/rclone'),
('shared_mount_root', '/mnt/rclone/rw'),
('shared_mount_read_only_root', '/mnt/rclone/ro'))}
schema['vfs_cache_mode']['options'] = ['writes']
template = {'proxmox': {'laboratory_contract': {'configuration_schema': schema}}}
fn = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=UI, DialogUI=UI, translate=lambda text: text,
re=re, InstallError=ValueError, UserCancelled=ValueError)
deployment = fn(template, UI())
self.assertEqual(events[0], ('message', RCLONE))
self.assertEqual(events[1][0], 'ask')
self.assertEqual(events[-1][0], 'confirm')
self.assertEqual(deployment['remote_name'], 'notes')
def test_hedgedoc_detail_uses_corrected_template_and_index(self):
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(item for item in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if item['id'] == 'hedgedoc')
self.assertEqual(template['catalog_ui']['tagline']['en_US'], key)
self.assertEqual(template['catalog_ui']['description']['en_US'], key)
self.assertEqual(item['description'], key)
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text',
Any=object)
fn = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lambda text: text,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io',
is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
result = fn(SimpleNamespace(), item, template)
self.assertIn(key, result)
self.assertNotIn('gives you access to all your files', result)
def test_missing_empty_and_synthetic_translation_on_real_consumers(self):
from unittest.mock import Mock
key = 'HedgeDoc is a self-hosted collaborative Markdown editor for notes.'
scope = {'language': lambda: 'it', '_cache': {}}
lookup = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', **scope)
cache = lookup.__globals__
template = json.loads((ROOT / 'oci/catalog/apps/hedgedoc.json').read_text())
item = next(x for x in json.loads((ROOT / 'oci/catalog/index.json').read_text())['applications']
if x['id'] == 'hedgedoc')
source = extract_function(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'source_text')
detail = extract_function(ROOT / 'oci/src/proxmenux_oci/cli.py', '_app_detail_text',
source_text=source, translate=lookup,
textwrap=__import__('textwrap'), DETAIL_WIDTH=92,
publisher=lambda _: 'LinuxServer.io', is_tested=lambda _: False,
_display_architectures=lambda _: 'amd64',
_image_label=lambda text: text,
_recommended_storage=lambda _: 'Container volume')
for values, expected_rclone, expected_hedgedoc in (
({}, RCLONE, key),
({RCLONE: '', key: ''}, RCLONE, key),
({RCLONE: 'GUIDA RCLONE', key: 'EDITOR COLLABORATIVO'},
'GUIDA RCLONE', 'EDITOR COLLABORATIVO')):
with self.subTest(values=values):
cache['_cache'] = values
ui = SimpleNamespace(message=Mock(), ask=Mock(side_effect=StopIteration))
rclone = extract_function(INSTALLER, 'build_rclone_mount_deployment',
TerminalUI=object, DialogUI=object, translate=lookup,
re=re, InstallError=ValueError,
UserCancelled=ValueError)
# Stop at the first prompt: no operational path is entered.
with self.assertRaises(StopIteration):
rclone(json.loads((ROOT / 'oci/catalog/apps/rclone.json').read_text()), ui)
ui.message.assert_called_once_with(expected_rclone)
self.assertIn(expected_hedgedoc, detail(SimpleNamespace(), item, template))
def test_extractor_sees_both_new_sources(self):
import runpy
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
keys = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertIn(RCLONE, keys)
keys = extractor['extract_catalog_texts']([ROOT / 'oci/catalog'])
self.assertIn('HedgeDoc is a self-hosted collaborative Markdown editor for notes.', keys)
@@ -0,0 +1,232 @@
"""Offline checks of real OCI messages; never import administrative modules."""
import ast
import importlib.util
import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
PREVIEW_HOST = 'Host paths found in container configs or saved records (not targeted for removal):'
EMPTY_HOST = 'No host directories found in the available container configs or saved records.'
POST_HOST = 'Host directory listed in saved records (not targeted for removal):'
UPDATE = ('All {count} containers of the application are updated together (main CT: {vmid}). If there are new '
'versions, all images are downloaded and verified, the application is stopped, each container is backed '
'up and replaced with its new image. If the operation fails after a container was changed, the backups are '
'restored. An operation that stops halfway is recovered from the OCI management menu.')
PENDING = ('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack '
'where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.')
KEYS = (PREVIEW_HOST, EMPTY_HOST, POST_HOST, UPDATE, PENDING)
TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
def extracted(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class InventoryMessages(unittest.TestCase):
def preview(self, configs, records, cache=None, translate=lambda text: text):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
def read(root, vmid):
if vmid not in records:
raise OSError('unreadable record')
return records[vmid]
instances.read = read
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {}, [102, 101])
remover.guest_config = lambda vmid: configs.get(vmid)
remover.host_directories = lambda root, members: cache or []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
'translate': translate, 're': re, 'subprocess': subprocess,
'_selected_configuration': remover.guest_config}
summary = extracted(MENU, '_removal_summary', scope)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return summary(Path('/inert'), 101)
def test_missing_metadata_is_not_reported_as_no_host_usage(self):
text = self.preview({101: None, 102: None}, {})
self.assertIn(EMPTY_HOST, text)
self.assertNotIn('No host directory is used by this application.', text)
def test_discovered_paths_do_not_promise_exhaustive_preservation(self):
text = self.preview({101: b'mp0: /bind/current,mp=/data\n', 102: None}, {}, ['/bind/saved'])
self.assertIn(PREVIEW_HOST + '\n /bind/current\n /bind/saved', text)
self.assertNotIn('Host directories that are kept, with their content:', text)
def test_removal_notice_qualifies_saved_record_source(self):
# Extracted operational functions must never inspect the real pmxcfs.
host = Path(self.enterContext(tempfile.TemporaryDirectory()))
inert_path = lambda value: host / Path(value).name if str(value).startswith('/etc/pve/') else Path(value)
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda raw: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'description: owned',
'host_directories': lambda root, members: ['/bind/saved'], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: None,
'remove_owned_host_firewall': lambda primary: True,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
're': re, 'guest_node': lambda vmid: None,
'remove_host_state': lambda vmid: events.append(('host', vmid)),
'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extracted(REMOVE, 'remove', scope)
remove(Path('/inert'), 101)
self.assertIn(('info', POST_HOST + ' /bind/saved'), events)
self.assertIn(('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1')), events)
self.assertIn(('log', 101), events)
self.assertIn(('host', 101), events)
scope['translate'] = lambda text: 'Tradotto: ' + text if text == POST_HOST else text
events.clear()
remove(Path('/inert'), 101)
self.assertIn(('info', 'Tradotto: ' + POST_HOST + ' /bind/saved'), events)
class RecoveryMessages(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.journal = Path(self.tmp.name) / 'journal.json'
self.members = [{'vmid': 101, 'deployment': {'mounts': []}}]
self.primary = {'stack': {'members': self.members}}
self.calls = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
self.ui = SimpleNamespace(
choose=lambda *args: 'update',
review=lambda message, *args, **kwargs: self.calls.append(('review', message)) or False,
confirm=lambda *args: self.fail('host confirmation must not run'),
message=lambda *args: self.fail('unexpected error'))
def manage(self):
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
return next(message for kind, message in self.calls if kind == 'review')
def test_update_preview_names_the_stack_and_what_happens(self):
message = self.manage()
self.assertIn(UPDATE.format(count=len(self.members), vmid=101), message)
def test_pending_terminal_and_recovery_failed_states_share_bounded_wording(self):
for phase in ('committed', 'rolled-back', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.assertEqual(self.manage(), PENDING)
self.assertFalse(any(isinstance(item[0], list) for item in self.calls))
def test_pending_confirmation_preserves_recover_command_in_each_state(self):
self.ui.review = lambda message, *args, **kwargs: True
for phase in ('committed', 'recovery-failed'):
with self.subTest(phase=phase):
self.calls.clear()
self.journal.write_text(json.dumps({'phase': phase, 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
with patch.dict(sys.modules, self.modules):
extracted(MENU, '_manage_stack', self.scope)(Path('/inert'), self.ui, {'vmid': 101})
self.assertEqual(self.calls, [
(['python3', '/inert/remote/oci_stack_native.py', '101', '--recover',
'--acknowledge-external-data'], 'Recover OCI stack')])
def test_actual_transaction_distinguishes_terminal_cleanup_from_failed_restore(self):
events = []
scope = {'TERMINAL': {'committed', 'rolled-back'},
'translate': lambda message: message,
'member_tx': SimpleNamespace(log=lambda message: None),
'write': lambda path, state: Path(path).write_text(json.dumps(state)),
'msg_info': lambda message: None, 'msg_ok': lambda message: None,
'member': lambda adapter, vmid: f'CT {vmid}'}
extracted(TRANSACTION, 'save', scope)
recover = extracted(TRANSACTION, 'recover_state', scope)
class Adapter:
def finalize(self, state): events.append('finalize')
def validate(self, plan): events.append('validate')
def restore(self, vmid, backup, txid):
events.append('restore')
raise RuntimeError('restore failed')
def stop(self, vmid): events.append('stop')
plan = {'start_order': [101], 'stop_order': [101]}
terminal = {'phase': 'committed', 'plan': plan}
self.assertIs(recover(self.journal, terminal, Adapter()), terminal)
self.assertEqual(events, ['finalize'])
events.clear()
failed = dict(terminal, phase='recovery-failed', id='tx', running={'101': True},
stop_intent=True, replacement_intent=True, backups={'101': {'archive': 'a'}})
with self.assertRaisesRegex(RuntimeError, 'restore failed'):
recover(self.journal, failed, Adapter())
self.assertEqual(events, ['validate', 'stop', 'restore'])
self.assertEqual(json.loads(self.journal.read_text())['phase'], 'recovery-failed')
def test_new_keys_extract_and_shipped_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(KEYS), len(set(KEYS)))
self.assertTrue(set(KEYS) <= set(found), set(KEYS) - set(found))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
module._language, module._cache = 'it', {key: 'Tradotto: ' + key for key in KEYS}
self.assertEqual(module.translate(EMPTY_HOST), 'Tradotto: ' + EMPTY_HOST)
preview = InventoryMessages().preview({101: None, 102: None}, {}, translate=module.translate)
self.assertIn('Tradotto: ' + EMPTY_HOST, preview)
preview = InventoryMessages().preview({101: b'mp0: /bind,mp=/data'}, {},
translate=module.translate)
self.assertIn('Tradotto: ' + PREVIEW_HOST + '\n /bind', preview)
# Exercise the actual management consumer, not just the provider.
self.scope['translate'] = module.translate
self.assertIn(('Tradotto: ' + UPDATE).format(count=len(self.members), vmid=101), self.manage())
self.journal.write_text(json.dumps({'phase': 'committed', 'plan': {'members': self.members}}))
self.primary['pending_stack_transaction'] = str(self.journal)
self.calls.clear()
self.assertEqual(self.manage(), 'Tradotto: ' + PENDING)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,246 @@
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_native_modify as native_modify
import oci_stack_transaction as stack_transaction
import oci_ui
ACTUAL_RECOVER = transaction.recover
class PendingRecoveryFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update(pending_transaction='/fiction-only/transaction.json')
self.row['pending'] = True
self.ui.choose.return_value = 'recover'
self.original = copy.deepcopy(self.record)
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.recovery = self.guard(transaction, 'recover')
self.native_recovery = self.guard(native_modify, 'recover')
self.stack_recovery = self.guard(stack_transaction, 'execute')
def stack(self):
self.row['stack'] = True
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
self.original = copy.deepcopy(self.record)
def no_effects(self):
super().no_effects()
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
mock.assert_not_called()
class PendingRecoveryTests(PendingRecoveryFixture):
def test_single_no_is_distinct_falsy_cancellation(self):
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_single_menu_cancel_is_distinct_cancellation(self):
self.ui.choose.return_value = None
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.ui.review.assert_not_called()
self.no_effects()
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
for choice in ('recover', None):
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.ui.choose.return_value = choice
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
self.stack()
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
read.assert_not_called()
self.no_effects()
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
dialog = native_ui.DialogUI()
for stacked in (False, True):
if stacked:
self.stack()
for code in (1, 255):
with self.subTest(stack=stacked, code=code):
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', side_effect=answers):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
dialog = native_ui.DialogUI()
for code in (1, 255):
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
for stacked in (False, True):
if stacked:
self.stack()
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
self.no_effects()
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
self.ui.review.return_value = True
for action in ('recover', 'status'):
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
self.ui.choose.return_value = action
with self.subTest(action=action, result=success):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
'Recover OCI' if action == 'recover' else 'OCI management')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
self.stack()
self.ui.review.return_value = True
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
'Recover OCI stack')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
self.no_effects()
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
self.no_effects()
def test_registration_cli_retains_its_separate_entry_point(self):
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
self.assertEqual(cli.main(['recover']), 0)
register.assert_called_once_with(ROOT/'oci')
self.no_effects()
class RecoverySlovakMeaning(PendingRecoveryFixture):
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT/'oci', self.ui, self.row)
text = self.ui.review.call_args.args[0]
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
self.assertIn('po úspešnom vyčistení', text)
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
self.stack()
menu.manage_instance(ROOT/'oci', self.ui, self.row)
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
self.no_effects()
class RecoveryGuardMessages(PendingRecoveryFixture):
def setUp(self):
super().setUp()
cache = json.loads((ROOT/'lang/sk.json').read_text())
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(oci_ui, name, value)
p.start(); self.addCleanup(p.stop)
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
self.original = copy.deepcopy(self.record)
self.journal = Path(self.record['pending_transaction'])
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
'backup': None, 'backup_sha256': 'saved-sha'}
def rejected(self, text, *, node='fiction-host', changed_backup=False):
calls = []
def readonly(*args):
calls.append(args)
if args[0] == 'pvesh':
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
if args == ('pct', 'config', '110'):
return b'changed'
raise AssertionError('Unexpected recovery effect: ' + repr(args))
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
patch.object(transaction, 'owned', return_value=b'fiction config'), \
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
# Only reads before the rejecting guard are supplied. Any next
# command or attempt to persist state fails this fixture.
with self.assertRaisesRegex(ValueError, text):
ACTUAL_RECOVER(ROOT/'oci', self.journal)
self.no_effects()
return calls
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
self.state['record']['installation_id'] = 'different-installation'
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
self.state['backup'] = '/fiction-only/backup.zst'
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
def test_guest_on_another_node_is_never_overwritten(self):
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
restore=self.stack_recovery)
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
before = copy.deepcopy(state)
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
stack_transaction.recover_state(self.journal, state, adapter)
self.assertEqual(state, before)
self.no_effects()
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
'Obnova dokončená. Kontajner ešte nebol zmenený.')
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
self.no_effects()
@@ -0,0 +1,234 @@
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
from contextlib import nullcontext, redirect_stdout
import copy
import io
import json
from pathlib import Path
import string
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_restore_recovery as restored
import oci_ui
class RegistrationFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
self.original_found = copy.deepcopy(self.found)
self.ui.confirm.return_value = False
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
self.pending = item.start();self.addCleanup(item.stop)
self.firewall = self.guard(menu, '_restored_firewall_rules')
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
def members(self):
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
self.original_found = copy.deepcopy(self.found)
def no_effects(self):
super().no_effects()
self.firewall.assert_not_called()
self.safe_backend()
def safe_backend(self):
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
self.assertEqual(self.found,self.original_found)
def accepted(self, answers, completed=True):
self.ui.confirm.side_effect=answers
self.firewall.side_effect=None
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
'source':'192.0.2.0/24'} for row in self.found]
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
class RegistrationCancellationTests(RegistrationFixture):
def test_offer_no_is_explicit_falsy_cancellation(self):
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
self.assertFalse(menu.CANCELLED);self.no_effects()
def test_direct_single_no_finishes_before_a_third_pending_read(self):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.assertEqual(self.pending.call_count,2)
self.no_effects()
def test_real_cli_single_no_returns_six_without_output(self):
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['recover']),6)
self.assertEqual(output.getvalue(),'');self.no_effects()
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
self.members()
with patch.object(cli,'Catalog'):
self.assertEqual(cli.main(['recover']),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
self.no_effects()
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
dialog=native_ui.DialogUI()
for multi in (False,True):
if multi:self.members()
for code in (1,255):
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.no_effects()
def test_native_menu_continues_to_list_after_registration_no(self):
self.ui.choose.return_value=None
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
menu.interactive_management(ROOT/'oci',self.ui)
self.ui.choose.assert_called_once();self.no_effects()
def test_optional_firewall_and_start_no_registers_without_flags(self):
self.members();self.accepted([True,False,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
def test_single_optional_no_preserves_registration_without_start(self):
self.accepted([True,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
self.members();self.accepted([True,False,True,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
self.accepted([True,False,True])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
def test_failed_registration_stays_failure_with_pending_records(self):
self.accepted([True,False,False],completed=False)
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.safe_backend()
def test_empty_list_is_success_without_confirmation_or_backend(self):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
def test_root_identity_failure_never_probes_restored_apps(self):
with patch.object(menu.os,'geteuid',return_value=1000):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_offer_does_not_cancel_the_native_menu(self):
self.found.clear();self.original_found=[]
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.ui.message.call_args.args[0],
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
self.no_effects()
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
for multi in (False,True):
if multi:self.members()
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('Pred opätovnou registráciou',text)
self.assertNotIn('Obnova najprv',text)
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
self.no_effects()
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
'blockers':['Fictional missing resource'],'notes':[]}
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
patch.object(restored.instances,'locked',return_value=nullcontext()), \
patch.object(restored,'prepare',return_value=[plan]), \
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
self.assertEqual(restored.main(),1)
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
self.no_effects()
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored.os.path,'isdir',return_value=False), \
patch.object(restored,'firewall_rule',return_value=None):
restored.check_host_resources(plan)
self.assertEqual(len(plan['blockers']),1)
self.assertIn('/fiction-only/data',plan['blockers'][0])
self.assertIn('registráciu zopakujte',plan['blockers'][0])
self.no_effects()
class RegistrationContractTests(unittest.TestCase):
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
script=script.replace('/usr/local/share/proxmenux',folder)
import os
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
self.assertEqual(result.stderr,'')
def test_curated_slovak_registration_words_and_format_fields(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
expected={
'Recover restored OCI applications':'Registrácia',
'No restored OCI application is waiting to be recovered.':'registráciu',
'Recover them now?':'zaregistrovať',
'This application cannot be recovered yet:':'zaregistrovať',
'The application could not be recovered:':'zaregistrovať',
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
'it is locked or its disk could not be mounted':'uzamknutý',
'The local storage does not accept snippets':'local',
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
'the NVIDIA hook of the container is not available:':'spúšťací skript',
'its Rclone mount is published again on this host at':'v ceste',
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
'Mount or create it with its data and run the recovery again.':'registráciu',
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
}
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
for key,word in expected.items():
with self.subTest(key=key):
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
for locale in ('en','sk'):
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
with self.subTest(locale=locale):
self.assertEqual(data['ociUpdates']['registerRestored'],
'Znova zaregistrovať' if locale=='sk' else 'Register again')
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
if __name__=='__main__':unittest.main()
@@ -0,0 +1,386 @@
"""Exercise extracted removal/UI/guard consumers with inert dependencies only."""
import ast
import argparse
from contextlib import nullcontext
import importlib.util
import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
REMOVE = ROOT / 'oci/remote/oci_remove.py'
INSTALL = ROOT / 'oci/remote/install_oci.sh'
PREVIEW = 'Private network targeted for release if no other guest uses it:'
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
RESULT = 'Removal command finished; review any warnings above.'
BRIDGE = 'Private network of the application released:'
BRIDGE_FAILURE = 'Could not complete private network release:'
SUCCESS = 'The application was removed'
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
PORT = 'The host-monitor firewall port does not match exactly one TCP port in the container contract'
OPTIONAL = 'A matching managed host firewall rule may also be removed.'
MEMBERS = 'All of them are targeted for removal.'
WHOLE = ('It cannot be removed on its own, because the application would stop '
'working: continuing targets the whole application for removal.')
TARGETS = 'Containers targeted for removal:'
DATA = 'Container data targeted for deletion:'
KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, PORT, OPTIONAL, MEMBERS, WHOLE, TARGETS, DATA)
def extract(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class RemovalWordings(unittest.TestCase):
def preview(self, bridge='vmbr9', translated=None, firewall=True):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}, 'deployment':
{'host_firewall': {'port': 8080}} if firewall else {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: bridge
remover.bridge_in_use = lambda bridge, removed: False
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': translated or (lambda value: value), 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
def test_preview_conditions_network_and_discloses_optional_firewall(self):
preview = self.preview()
self.assertIn(PREVIEW + ' vmbr9', preview)
self.assertIn(OPTIONAL, preview)
self.assertIn(MEMBERS, preview)
self.assertIn(TARGETS, preview)
self.assertIn(DATA, preview)
self.assertNotIn('Private network of the application that is released:', preview)
self.assertNotIn(PREVIEW, self.preview(bridge=None))
self.assertNotIn(OPTIONAL, self.preview(firewall=False))
def test_member_preview_does_not_promise_whole_stack_removed(self):
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'installation_id': 'owned'}
remover = ModuleType('oci_remove')
remover.members_of = lambda root, vmid: (101, {'stack': {}}, [102, 101])
remover.guest_config = lambda member: None
remover.host_directories = lambda root, members: []
remover.granted_directories = lambda root, members: []
remover.private_bridge = lambda primary: None
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': lambda value: value, 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
self.assertIn(WHOLE, preview)
def test_cancellation_preserves_lifecycle_boundary_and_warns_precisely(self):
calls = []
ui = SimpleNamespace(review=lambda message, title, **kw: calls.append((message, kw)) or False,
message=lambda *args: self.fail('unexpected error'))
scope = {'_removal_summary': lambda project, vmid: 'Preview',
'_run_lifecycle': lambda *args: self.fail('must not run'),
'translate': lambda value: value, 'sys': SimpleNamespace(executable='python3'),
'Path': Path, 'OSError': OSError, 'ValueError': ValueError, 'KeyError': KeyError}
self.assertFalse(extract(MENU, '_remove', scope)(Path('/inert'), ui, 101))
self.assertEqual(calls, [('Preview', {'question': CONFIRM, 'default': False})])
def test_shared_bridge_and_skipped_member_are_not_reported_removed(self):
events = []
records = {102: {'installation_id': 'owned'}, 101: {'installation_id': 'owned'}}
scope = {'members_of': lambda root, vmid: (101, records[101], [102, 101]),
'instances': SimpleNamespace(read=lambda root, vmid: records[vmid],
identity=lambda raw: 'reassigned' if raw == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'reassigned' if vmid == 102 else b'owned',
'host_directories': lambda root, members: [], 'granted_directories': lambda *args: [], 'private_bridge': lambda primary: 'vmbr9',
'bridge_in_use': lambda bridge, removed: True,
'release_bridge': lambda bridge: self.fail('shared bridge release'),
'remove_owned_host_firewall': lambda record: None,
'run': lambda *args: events.append(('run', args)),
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
remove = extract(REMOVE, 'remove', scope)
for skipped_config in (b'reassigned', None):
with self.subTest(skipped_config=skipped_config):
scope['guest_config'] = lambda vmid: skipped_config if vmid == 102 else b'owned'
events.clear()
remove(Path('/inert'), 101)
self.assertEqual([x for x in events if x[0] == 'run'],
[('run', ('pct', 'destroy', '101', '--purge', '1', '--destroy-unreferenced-disks', '1'))])
self.assertFalse(any(x == ('ok', 'The application was removed') for x in events))
self.assertTrue(any('still used' in x[1] for x in events if x[0] == 'info'))
self.assertTrue(any('no longer exists' in x[1] if skipped_config is None
else 'belongs to another container' in x[1]
for x in events if x[0] == 'warn'))
# The actual main() success line is independently guarded by the expected neutral literal.
tree = ast.parse(REMOVE.read_text())
main = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'main')
self.assertIn(RESULT, [n.value for n in ast.walk(main) if isinstance(n, ast.Constant) and isinstance(n.value, str)])
def test_actual_main_success_is_completion_not_all_members_removed(self):
events = self.lifecycle_events(missing=True)
self.assertIn(('warn', 'The container no longer exists: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def lifecycle_events(self, *, missing=False, reassigned=False, bridge='none',
ip_rc=0, pvesh_rc=0, ip_error=False, firewall_failure=False,
firewall_success=False, kept=False):
events, commands = [], []
self.last_commands = commands
record = {'installation_id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}
if firewall_failure or firewall_success:
record['deployment'] = {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}
records = {101: record, 102: {'installation_id': 'owned'}}
def fake_command(args, **kwargs):
commands.append(tuple(args))
if ip_error and args[:2] == ['ip', 'link']:
raise OSError('ip unavailable')
if args[:2] == ['pvesh', 'get']:
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + record['installation_id'],
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]), returncode=0)
if args[:2] == ['pvesh', 'delete'] and '/firewall/' in args[2] and firewall_failure:
raise subprocess.CalledProcessError(1, args)
return SimpleNamespace(returncode=ip_rc if args[:2] == ['ip', 'link'] else
pvesh_rc if args[:2] == ['pvesh', 'delete'] else 0)
fake_subprocess = SimpleNamespace(run=fake_command, CalledProcessError=subprocess.CalledProcessError)
scope = {'members_of': lambda root, vmid: (101, record, [102, 101]),
'instances': SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: records[vmid],
identity=lambda cfg: record['installation_id'] if cfg == b'primary' else
'other' if cfg == b'reassigned' else 'owned',
location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: (None if missing else b'reassigned' if reassigned else b'owned')
if vmid == 102 else b'primary',
'host_directories': lambda *args: ['/retained'] if kept else [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9' if bridge != 'none' else None,
'bridge_in_use': lambda *args: bridge == 'shared',
'run': lambda *args: commands.append(args), 'subprocess': fake_subprocess,
'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json, 're': re,
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None,
'translate': lambda text: text,
'msg_info': lambda text: events.append(('info', text)),
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)),
'msg_error': lambda text: events.append(('error', text)),
'argparse': argparse, 'os': SimpleNamespace(geteuid=lambda: 0),
'sys': SimpleNamespace(argv=['oci_remove.py', '101'])}
scope['release_bridge'] = extract(REMOVE, 'release_bridge', scope)
scope['remove_owned_host_firewall'] = extract(REMOVE, 'remove_owned_host_firewall', scope)
scope['remove'] = extract(REMOVE, 'remove', scope)
with patch.object(sys, 'argv', ['oci_remove.py', '101']):
self.assertEqual(extract(REMOVE, 'main', scope)(), 0)
return events
def test_clean_removal_has_clear_success(self):
events = self.lifecycle_events()
self.assertEqual(events[-1], ('ok', 'The application was removed'))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
def test_skipped_identity_and_kept_bridge_have_partial_result(self):
events = self.lifecycle_events(reassigned=True, bridge='shared')
self.assertIn(('warn', 'The VMID belongs to another container now and is not touched: CT 102'), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_shared_bridge_intentionally_kept_does_not_taint_clean_removal(self):
events = self.lifecycle_events(bridge='shared')
self.assertTrue(any('still used' in text for kind, text in events if kind == 'info'))
self.assertEqual(events[-1], ('ok', 'The application was removed'))
def test_failed_bridge_commands_have_partial_result(self):
for ip_rc, pvesh_rc in ((1, 0), (0, 1), (1, 1)):
with self.subTest(ip_rc=ip_rc, pvesh_rc=pvesh_rc):
events = self.lifecycle_events(bridge='private', ip_rc=ip_rc, pvesh_rc=pvesh_rc)
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_bridge_release_and_retained_paths_are_clean(self):
events = self.lifecycle_events(bridge='private', kept=True)
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
self.assertTrue(any(kind == 'info' and '/retained' in text for kind, text in events))
self.assertFalse(any(kind == 'warn' for kind, _ in events))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_bridge_exception_is_reported_but_cleanup_continues(self):
events = self.lifecycle_events(bridge='private', ip_error=True)
self.assertEqual([cmd[:2] for cmd in self.last_commands],
[('pct', 'stop'), ('pct', 'destroy'), ('pct', 'stop'),
('pct', 'destroy'), ('ip', 'link'), ('pvesh', 'delete')])
self.assertTrue(any(kind == 'warn' and 'private network' in text.lower()
for kind, text in events), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_successful_firewall_delete_is_clean(self):
events = self.lifecycle_events(firewall_success=True)
self.assertTrue(any('Host firewall rule removed:' in text for kind, text in events if kind == 'ok'))
self.assertEqual(events[-1], ('ok', SUCCESS))
def test_firewall_delete_failure_has_partial_result(self):
events = self.lifecycle_events(firewall_failure=True)
self.assertIn(('warn', FIREWALL), events)
self.assertEqual(events[-1], ('ok', RESULT))
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
events = []
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
identity=lambda cfg: 'owned', location=lambda root, vmid: Path('/inert/absent/record.json')),
'guest_config': lambda vmid: b'owned', 'host_directories': lambda *args: [], 'granted_directories': lambda *args: [],
'private_bridge': lambda primary: 'vmbr9', 'bridge_in_use': lambda *args: False,
'release_bridge': lambda bridge: events.append(('attempt', bridge)),
'remove_owned_host_firewall': lambda record: None, 'run': lambda *args: None,
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
'Path': lambda value: Path('/inert/no-lifecycle') if str(value).startswith('/etc/pve/') else Path(value),
'shutil': SimpleNamespace(rmtree=lambda path: None),
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
'translate': lambda text: text, 'msg_info': lambda text: None,
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
self.assertIn(('attempt', 'vmbr9'), events)
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
events = []
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': subprocess, 'translate': lambda text: text,
'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': 'invalid', 'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
def test_ambiguous_firewall_match_warns_without_deleting_other_rules(self):
events, commands = [], []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
rule = {'comment': 'ProxMenux OCI firewall ' + installation_id, 'dport': '8080',
'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in', 'action': 'ACCEPT', 'pos': 2}
def fake_run(args, **kwargs):
commands.append(args)
return SimpleNamespace(stdout=json.dumps([rule, {**rule, 'pos': 3}]))
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text))}
result = extract(REMOVE, 'remove_owned_host_firewall', scope)(
{'installation_id': installation_id,
'deployment': {'host_firewall': {'source': '192.0.2.0/24', 'port': 8080}}})
self.assertFalse(result)
self.assertEqual(events, [('warn', FIREWALL)])
self.assertEqual([command[1] for command in commands], ['get'])
def test_firewall_delete_exception_has_unknown_outcome_not_unchanged_rule(self):
events = []
installation_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
commands = []
def fake_run(args, **kwargs):
commands.append(args)
if args[1] == 'get':
return SimpleNamespace(stdout=json.dumps([{'comment': 'ProxMenux OCI firewall ' + installation_id,
'dport': '8080', 'source': '192.0.2.0/24', 'proto': 'tcp', 'type': 'in',
'action': 'ACCEPT', 'pos': 2}]))
raise subprocess.CalledProcessError(1, args)
scope = {'re': re, 'socket': SimpleNamespace(gethostname=lambda: 'node'), 'json': json,
'subprocess': SimpleNamespace(run=fake_run, CalledProcessError=subprocess.CalledProcessError),
'translate': lambda text: text, 'msg_ok': lambda text: events.append(('ok', text)),
'msg_warn': lambda text: events.append(('warn', text)), 'OSError': OSError, 'ValueError': ValueError}
extract(REMOVE, 'remove_owned_host_firewall', scope)({'installation_id': installation_id,
'deployment': {'host_firewall': {'port': 8080, 'source': '192.0.2.0/24'}}})
self.assertEqual(commands[-1][1], 'delete')
self.assertEqual(events, [('warn', FIREWALL)])
def test_extractor_and_runtime_lookup_fallback_and_synthetic_translation(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = set(generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote']))
shell_keys = set(generator['extract_translate_texts'](ROOT / 'oci/remote'))
self.assertTrue(set(KEYS) - {PORT} <= found, (set(KEYS) - {PORT}) - found)
self.assertIn(PORT, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
for key in KEYS:
self.assertEqual(module.translate(key), key)
module._language, module._cache = 'it', {key: 'IT: ' + key for key in KEYS}
self.assertIn('IT: ' + PREVIEW, self.preview(translated=module.translate))
self.assertIn('IT: ' + OPTIONAL, self.preview(translated=module.translate))
def test_actual_shell_guard_fails_for_missing_tcp_contract_not_web_role(self):
source = INSTALL.read_text()
function = source[source.index('validate_host_monitor_firewall() {'):source.index('\napply_host_monitor_firewall() {')]
script = '''set -eu
translate() { printf '%s' "$1"; }
die() { printf '%s' "$1" >&2; exit 7; }
jq() {
case "$*" in
*'.host_firewall == null'*) printf 'false\\n' ;;
*'.host_firewall | type'*) printf 'true\\n' ;;
*'.host_firewall.bridge'*) printf 'vmbr0\\n' ;;
*'.host_firewall.source'*) printf '192.0.2.0/24\\n' ;;
*'.host_firewall.port'*) printf '8080\\n' ;;
*'.proxmox.installer_profile.host_monitor_firewall.web_port'*) printf '8080\\n' ;;
*'.container_contract.ports'*) printf '0\\n' ;;
*) exit 99 ;;
esac
}
ip() { printf '2: vmbr0 inet 192.0.2.1/24 scope global vmbr0\\n'; }
BRIDGE=vmbr0 HOST_MONITOR=netdata DEPLOYMENT_FILE=/inert/deployment TEMPLATE_FILE=/inert/template
''' + function + '\nvalidate_host_monitor_firewall\n'
result = subprocess.run(['bash', '-c', script], text=True, capture_output=True, check=False)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(result.stderr, PORT)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,235 @@
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import i18n, management
from proxmenux_oci.ui import UserCancelled
import oci_instances as instances
import oci_remove as removal
import oci_ui
class RemovalSlovakTests(unittest.TestCase):
def setUp(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(module, name, value)
p.start(); self.addCleanup(p.stop)
self.records = {}
self.make_records(2)
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
self.start(management, '_selected_configuration', side_effect=self.config)
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
self.ui = Mock()
self.ui.review.return_value = False
self.forbidden = []
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
(management, '_run_lifecycle'), (removal, 'run'),
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
(removal.image_cache, 'prune')):
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
def start(self, module, name, **kwargs):
p = patch.object(module, name, **kwargs)
result = p.start(); self.addCleanup(p.stop)
return result
def make_records(self, count):
self.records.clear()
for offset in range(count):
vmid = 999990 + offset
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
'template': {'catalog_ui': {'title': 'Fixture App'}},
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
'container_path': '/saved'}]}}
if offset:
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
if count > 1:
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
self.original = copy.deepcopy(self.records)
def config(self, vmid):
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
'mp1: /fixture/shared,mp=/shared\n').encode()
def tearDown(self):
self.assertEqual(self.records, self.original)
for effect in self.forbidden:
effect.assert_not_called()
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
self.make_records(1)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Kontajnery určené na odstránenie:', text)
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
self.assertIn('/data (16G)', text)
kept = text.split('Cesty hostiteľa nájdené')[1]
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
self.assertNotIn('súkromnej siete:', text)
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
for count in (2, 3, 5):
with self.subTest(count=count):
self.make_records(count)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
self.assertIn('Všetky sú určené na odstránenie.', text)
for vmid in self.records:
self.assertIn(f'CT {vmid}', text)
def test_member_summary_warns_that_entire_application_is_selected(self):
text = management._removal_summary(ROOT / 'oci', 999991)
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
self.assertIn('odstránenie celej aplikácie', text)
self.assertNotIn('nádob', text)
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
self.bridge.return_value = True
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť zachovaná', text)
self.assertNotIn('určená na uvoľnenie', text)
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('/fixture/saved', text)
self.assertIn('rootfs -', text)
def test_no_from_real_remove_review_never_calls_lifecycle(self):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
review = self.ui.review.call_args
self.assertFalse(review.kwargs['default'])
self.assertEqual(review.kwargs['question'],
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
self.ui.review.side_effect = UserCancelled('fixture cancelled')
with self.assertRaises(UserCancelled):
management._remove(ROOT / 'oci', self.ui, 999990)
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
self.ui.review.assert_not_called()
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
self.make_records(1)
self.records[999990]['pending_transaction'] = {'fixture': True}
self.original = copy.deepcopy(self.records)
with patch.object(removal, 'guest_config') as config:
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
removal.remove(Path('/fixture/oci'), 999990)
config.assert_not_called()
def test_container_on_other_node_errors_before_host_cleanup(self):
with patch.object(removal, 'guest_config', return_value=None), \
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
removal.remove(Path('/fixture/oci'), 999990)
def run_main(self, result=False, busy=False):
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
patch.object(removal.os, 'geteuid', return_value=0), \
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
return_value=nullcontext()), \
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
code = removal.main()
return code, mocked_remove, ok, error
def test_busy_registry_error_does_not_enter_removal(self):
code, remove, ok, error = self.run_main(busy=True)
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
code, remove, ok, error = self.run_main(result=True)
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0],
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
def test_clean_completion_keeps_existing_success_contract(self):
code, remove, ok, error = self.run_main()
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
with patch.object(i18n, '_language', 'en'):
primary = management._removal_summary(ROOT / 'oci', 999990)
member = management._removal_summary(ROOT / 'oci', 999991)
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
def test_missing_translation_falls_back_to_the_same_english_summary(self):
with patch.object(i18n, '_cache', {}):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
self.ui.review.return_value = True
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
self.assertEqual(inert.call_args.args[0],
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
for count in (1, 2):
with self.subTest(count=count):
self.make_records(count)
self.ui.reset_mock()
self.ui.choose.return_value = 'remove'
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
'status': 'installed', 'reason': 'matched'}
with patch.object(management, 'carry_records', return_value={}), \
patch.object(management, 'check_selected', return_value=row):
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
options = self.ui.choose.call_args.args[1]
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
self.ui.review.assert_called_once()
self.assertFalse(self.ui.review.call_args.kwargs['default'])
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
self.make_records(1)
# Exercise the real backend branch; record cleanup is inert as well.
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
patch.object(removal, 'release_shared_host_files'), \
patch.object(removal.work_backup, 'forget'), \
patch.object(removal.image_cache, 'prune', return_value=[]), \
patch.object(Path, 'exists', return_value=False), \
patch.object(Path, 'is_dir', return_value=False), \
patch.object(removal, 'msg_warn') as warning, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
self.assertEqual(warning.call_args.args[0],
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,124 @@
"""Exercise the real OCI preview/import functions without importing host-management modules."""
import ast
import json
from pathlib import Path
import runpy
import unittest
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
ROWS = {
'It needs a privileged container, which is not isolated from the host.':
'This profile requires a privileged LXC, which reduces isolation from the host.',
'It asks for capabilities or a relaxed confinement profile.':
'A relaxed AppArmor or seccomp profile is requested; this may be optional.',
'The image expects files that are given to it one by one:':
'These container mount paths have an extension-like suffix:',
'ProxMenux attaches directories, not single files, so this image cannot be installed yet.':
'This import rejects these paths without checking whether they are files or directories.',
}
class ConversionError(Exception):
pass
def extracted(name, **deps):
node = next(n for n in ast.parse(CUSTOM.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
scope = {'Any': Any, 'Path': Path, 'yaml': yaml, 'ConversionError': ConversionError,
'translate': lambda text: text}
scope.update(deps)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(CUSTOM), 'exec'), scope)
return scope[name]
class SecurityMountWording(unittest.TestCase):
def test_security_warnings_match_their_guards_without_claiming_capabilities(self):
describe = extracted('describe', blocker_text=str)
template = {'container_contract': {'image': {'reference': 'test:latest'},
'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {}}}
for profile, present in (
({}, ()),
({'optional_privileged_lxc': True}, ()),
({'requires_privileged_lxc': True}, (ROWS['It needs a privileged container, which is not isolated from the host.'],)),
({'requires_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'source_requests_relaxed_confinement': True}, (ROWS['It asks for capabilities or a relaxed confinement profile.'],)),
({'requires_privileged_lxc': True, 'source_requests_relaxed_confinement': True},
(ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.'])),
):
with self.subTest(profile=profile):
template['proxmox']['security_profile'] = profile
text = describe(template)
for warning in (ROWS['It needs a privileged container, which is not isolated from the host.'],
ROWS['It asks for capabilities or a relaxed confinement profile.']):
if warning in present:
self.assertIn(' ' + warning, text)
else:
self.assertNotIn(warning, text)
template['proxmox']['security_profile'] = {}
template['proxmox']['installer_profile'] = {'security': {'required_capabilities': ['NET_ADMIN']}}
text = describe(template)
self.assertNotIn(ROWS['It asks for capabilities or a relaxed confinement profile.'], text)
def test_suffix_hit_rejects_dotted_directory_without_claiming_file_type(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/data.v2', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
with self.assertRaises(ConversionError) as caught:
fn('services:\n app:\n image: example:latest\n')
self.assertEqual(str(caught.exception),
ROWS['The image expects files that are given to it one by one:'] + ' /srv/data.v2. ' +
ROWS['ProxMenux attaches directories, not single files, so this image cannot be installed yet.'])
def test_suffix_free_path_still_accepted(self):
fn = extracted('template_from_compose',
_services=lambda d: d['services'], _check_mounts=lambda s: None,
normalize_app_id=lambda s: s, _published_port=lambda s: None,
_dump=lambda s: '', _keep_reference=lambda *a: None,
TIME_SETTINGS=('/etc/localtime', '/etc/timezone'),
convert_casaos_compose=lambda *args: {'container_contract': {
'image': {'reference': 'example:latest'},
'volumes': [{'container_path': '/srv/config', 'installation_choice': ['managed-volume', 'host-bind']}]},
'proxmox': {}})
self.assertEqual(fn('services:\n app:\n image: example:latest\n')['container_contract']['volumes'][0]['container_path'], '/srv/config')
def test_all_four_keys_extract_and_fallback_in_seven_shipped_locales(self):
found = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(ROWS), 4)
self.assertTrue(set(ROWS.values()) <= set(found), set(ROWS.values()) - set(found))
import importlib.util
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for new in ROWS.values():
self.assertEqual(module.translate(new), cache.get(new) or new, (path.name, new))
setattr(module, '_cache', {})
for new in ROWS.values():
self.assertEqual(module.translate(new), new, (path.name, new, 'synthetic missing'))
module._language, module._cache = 'it', {new: 'IT: ' + new for new in ROWS.values()}
self.assertIn('IT: ' + ROWS['It needs a privileged container, which is not isolated from the host.'],
extracted('describe', translate=module.translate, blocker_text=str)({
'container_contract': {'image': {'reference': 'test:latest'}, 'volumes': [], 'ports': [], 'environment': []},
'proxmox': {'security_profile': {'requires_privileged_lxc': True}}}))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,197 @@
"""Inert extracted OCI selection/setup consumers; no admin module imports or scripts sourced."""
import ast
import importlib.util
import json
import tempfile
from pathlib import Path
import subprocess
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
OLD_EMPTY = 'No OCI instances are registered.'
EMPTY = 'No registered OCI containers are available for selection on this host.'
OLD_REPLAY = 'This stack requires replaying specific rootfs adaptations. Coordinated updates are not yet enabled for it.'
NO_MEMBERS = 'This stack has no saved members to update.'
REPLAY = 'This stack needs rootfs adaptations that coordinated updates cannot replay yet.'
HTTP = 'The application did not pass its HTTP check:'
def extracted(name, **dependencies):
node = next(n for n in ast.parse(MENU.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Drop only infrastructure imports, retaining all selection, guard, and return logic.
node.body = [n for n in node.body if not (isinstance(n, ast.Import) and
any(a.name == 'oci_instances' or a.name == 'oci_stack_replay' for a in n.names))]
for n in ast.walk(node):
if isinstance(n, (ast.If, ast.With)):
n.body = [x for x in n.body if not (isinstance(x, ast.Import) and
any(a.name in ('oci_instances', 'oci_stack_replay') for a in x.names))]
scope = dict(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(MENU), 'exec'), scope)
return scope[name]
class SelectionSetupWording(TestCase):
def test_selector_empty_explains_availability_not_registry_absence(self):
ui = SimpleNamespace(message=Mock(), choose=Mock())
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
translate=lambda s: s)
fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called()
def _menu(self, inventories, selections, unread=True):
rows = iter(inventories)
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
refresh = Mock(return_value={})
manage = Mock(return_value=True)
fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0),
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows),
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=refresh, _records_unread=lambda project: unread,
manage_instance=manage, menu_size=lambda: 10,
STATUS_LABELS={}, translate=lambda s: s)
fn(Path('/fixture'), ui)
return ui, refresh, manage
def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
ui, refresh, manage = self._menu([[row], [row]], ['101', None])
self.assertEqual(ui.choose.call_count, 2)
manage.assert_called_once()
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
heading='Manage installed OCI applications')
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
_, refresh, _ = self._menu([[row]], [None], unread=False)
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
heading='Manage installed OCI applications')
def test_removing_the_last_application_returns_without_a_message(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
ui, _, manage = self._menu([[row], []], ['101'])
manage.assert_called_once()
ui.message.assert_not_called()
def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self):
refresh = Mock(return_value={})
watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]]))
fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh,
instances=SimpleNamespace(ROOT=Path('/fixture')))
with patch.dict('sys.modules', {'oci_watchdog': watchdog}):
self.assertTrue(fn(Path('/fixture'), [102], True))
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102])
def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
class Entry:
name = '101'
class Lock:
def __enter__(self): return None
def __exit__(self, *_): return False
fake = SimpleNamespace(ROOT=SimpleNamespace(iterdir=lambda: [Entry()]),
locked=lambda _: Lock(), has_contract=lambda *args: True,
guest_exists=lambda _: False, read=Mock())
fn = extracted('saved_inventory', sys=SimpleNamespace(path=[]),
instances=fake, public_row=Mock())
self.assertEqual(fn(Path('/fixture')), [])
fake.read.assert_not_called()
def test_stack_without_members_is_not_diagnosed_as_replay_failure(self):
self._stack({}, NO_MEMBERS).choose.assert_not_called()
def test_stack_unsupported_replay_retains_distinct_verdict(self):
# It cannot be updated, but it can still be changed or removed.
ui = self._stack({'members': [{'native_stack_intent': {'adapt': True}}]}, REPLAY)
ui.choose.assert_called_once()
self.assertEqual([tag for tag, _ in ui.choose.call_args.args[1]], ['modify', 'watchdog', 'remove'])
def _stack(self, stack, expected):
record = {'stack': stack}
fake = SimpleNamespace(ROOT=Path('/fixture'), read=lambda *args: record)
replay = SimpleNamespace(**{n + '_menu_ready': lambda _: False
for n in ('nextcloud', 'paperless', 'tandoor', 'immich')})
ui = SimpleNamespace(message=Mock(), choose=Mock(return_value=None), review=Mock())
fn = extracted('_manage_stack', sys=SimpleNamespace(path=[]), instances=fake,
oci_stack_replay=replay, translate=lambda s: s)
self.assertFalse(fn(Path('/fixture'), ui, {'vmid': 101}))
ui.message.assert_called_once_with(expected, 'OCI stack management')
ui.review.assert_not_called()
return ui
def test_actual_http_failure_seams_preserve_app_and_log_redirect(self):
for app, port in (('paperless', ':8000'), ('tandoor', '')):
with self.subTest(app=app):
path = ROOT / f'oci/remote/install_{app}_stack.sh'
lines = path.read_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
# Actual Bash failure expression, never source the parent installer.
harness = '''set -u
APPLICATION_LAN_IP=192.0.2.1
APPLICATION_ID=101
OCI_LOG=/dev/null
curl() { return 7; }
oci_curl() { shift; curl "$@"; }
translate() { printf '%s' "$1"; }
print_first_boot_diagnostics() { :; }
die() { printf 'ERROR:%s\\n' "$1"; }
''' + command + '\n'
run = subprocess.run(['bash', '-c', harness], text=True,
capture_output=True, timeout=3)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(run.stdout.strip(),
f'ERROR:{HTTP} {"Paperless-ngx" if app == "paperless" else "Tandoor"}')
self.assertIn('2>>"$OCI_LOG"', command)
def test_nextcloud_explicit_setup_guard_kept(self):
source = (ROOT / 'oci/remote/install_nextcloud_stack.sh').read_text()
self.assertIn("jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false'", source)
self.assertIn('die "$(translate "The application did not complete its initial setup:") Nextcloud"', source)
def test_shell_lookup_shipped_missing_and_synthetic_translation(self):
source = (ROOT / 'oci/remote/oci_ui.sh').read_text()
start = source.index('translate() {')
fn = source[start:source.index('\n}', start) + 2]
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / 'missing.json'
missing.write_text('{}')
translated = Path(tmp) / 'translated.json'
translated.write_text(json.dumps({HTTP: 'VERIFICA HTTP FALLITA:'}))
for path in [*sorted((ROOT / 'lang').glob('*.json')), missing, translated]:
cache = json.loads(path.read_text())
bash = '_OCI_LANGUAGE=it\n_OCI_LANG_FILE="$1"\n' + fn + '\ntranslate "$2"\n'
result = subprocess.run(['bash', '-c', bash, 'fixture', str(path), HTTP],
text=True, capture_output=True, timeout=3)
self.assertEqual(result.returncode, 0, (path, result.stderr))
self.assertEqual(result.stdout, cache.get(HTTP) or HTTP, path)
def test_extraction_and_real_lookup_all_locales(self):
import runpy
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted_keys = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (EMPTY, NO_MEMBERS, REPLAY):
self.assertIn(key, extracted_keys)
shell_keys = generator['extract_translate_texts'](ROOT / 'oci/remote')
self.assertIn(HTTP, shell_keys)
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
module._language, module._cache = path.stem, cache
for key in (EMPTY, NO_MEMBERS, REPLAY, HTTP):
self.assertEqual(module.translate(key), cache.get(key) or key)
module._cache = {}
self.assertEqual(module.translate(EMPTY), EMPTY)
module._cache = {EMPTY: 'LOCALIZED SELECTOR'}
self.assertEqual(module.translate(EMPTY), 'LOCALIZED SELECTOR')
@@ -0,0 +1,239 @@
"""Exercise the multi-container editor in Slovak without applying a change."""
from contextlib import redirect_stderr
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
import oci_immich_recognition as recognition
import oci_instances as instances
import oci_stack_modify as modify
import oci_ui
CONFIG = {
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
'mp2': '/fixture/shared,mp=/shared',
'dev0': 'path=/dev/ttyACM7,mode=0660',
}
class StackEditorSlovakTests(unittest.TestCase):
def setUp(self):
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
'role': 'application'}}}
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
self.original = copy.deepcopy(self.primary)
self.ui = Mock()
self.ui.choose.return_value = None
self.ui.confirm.return_value = False
self.ui.review.return_value = False
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
config = patch.object(editor, '_config', return_value=CONFIG)
config.start()
self.addCleanup(config.stop)
self.forbidden = []
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
(subprocess, 'check_output'), (Path, 'write_text'),
(Path, 'write_bytes'), (instances, 'write'),
(instances, 'command'), (modify, 'apply'),
(recognition, 'apply'), (recognition, 'configure'),
(management, '_run_lifecycle')):
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
self.forbidden.append(item.start())
self.addCleanup(item.stop)
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
self.forbidden.append(self.lifecycle)
def tearDown(self):
self.assertEqual(self.primary, self.original)
for callback in self.forbidden:
callback.assert_not_called()
def shown(self):
return '\n'.join(str(call) for call in self.ui.mock_calls)
def immich(self, acceleration='cpu'):
self.member['deployment']['replay_profile'] = {
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
self.original = copy.deepcopy(self.primary)
return self.member
def changes(self, **values):
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
'remove_devices': [], **values}
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
shown = self.shown()
self.assertIn('odznačené sa odstránia po potvrdení', shown)
self.assertIn('odznačené sa odpoja po potvrdení', shown)
options = self.ui.checklist.call_args_list[0].args[1]
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
self.assertNotIn('zásobník', shown)
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
warning = self.ui.confirm.call_args_list[0].args[0]
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
self.assertIn('/extra', warning)
summary = self.ui.review.call_args.args[0]
self.assertIn('- /shared', summary)
self.assertNotIn('- /extra', summary)
self.assertIn('Spustený kontajner sa reštartuje', summary)
self.assertIn('zastavený zostane zastavený', summary)
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
self.ui.checklist.side_effect = [[], []]
self.ui.confirm.side_effect = [True, False, False]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('- /extra', self.ui.review.call_args.args[0])
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
def test_member_selection_cancels_before_any_configuration_read(self):
other = copy.deepcopy(self.member)
other['vmid'] = 999993
self.primary['stack']['members'].append(other)
self.original = copy.deepcopy(self.primary)
with patch.object(editor, '_config') as read:
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
read.assert_not_called()
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
def test_missing_member_error_uses_application_language(self):
self.primary['stack']['members'] = []
self.original = copy.deepcopy(self.primary)
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
self.immich()
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
member = self.immich()
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.assertIn('nenašla použiteľná grafická karta', self.shown())
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
self.ui.review.assert_not_called()
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
member = self.immich()
self.ui.choose.return_value = 'openvino'
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
with patch.object(host, 'gpus', return_value=found):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
choices = self.ui.choose.call_args.args[1]
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
text = self.ui.review.call_args.args[0]
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
'ponuky správy OCI'):
self.assertIn(fragment, text)
self.assertEqual(self.ui.review.call_args.kwargs['question'],
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
member = self.immich()
self.ui.choose.return_value = 'cpu'
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
('cuda', 'NVIDIA GPU', {})]):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.ui.review.assert_not_called()
self.assertIn('Nič sa nezmenilo.', self.shown())
def test_experimental_rocm_can_be_declined_before_final_review(self):
member = self.immich()
self.ui.choose.return_value = 'rocm'
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
with patch.object(editor, 'recognition_choices', return_value=choices), \
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
text = self.ui.confirm.call_args.args[0]
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
self.assertIn('GPU prestať reagovať', text)
self.assertIn('bez preinštalovania', text)
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
self.assertFalse(self.ui.confirm.call_args.args[1])
self.ui.review.assert_not_called()
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
mount = {'type': 'managed-volume', 'container_path': '/new-data',
'size_gb': 0, 'source': 'fixture'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
modify.validate(999991, self.changes(add_mounts=[mount]))
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
modify.validate(999991, self.changes(add_devices=[device]))
def test_root_error_is_localized_without_starting_the_operation(self):
with patch.object(modify.os, 'geteuid', return_value=1000), \
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
redirect_stderr(io.StringIO()) as output:
with self.assertRaises(SystemExit) as result:
modify.main()
self.assertEqual(result.exception.code, 2)
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
self.assertNotIn('required', output.getvalue())
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
recognition.validate('unknown', None, None)
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
incomplete = RuntimeError('fixture diagnostic')
incomplete.recognition_kept = False
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
with self.subTest(error=type(error).__name__), \
patch.object(recognition.os, 'geteuid', return_value=0), \
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
patch.object(recognition, 'change', side_effect=error), \
patch.object(recognition, 'msg_error') as message:
self.assertEqual(recognition.main(), 1)
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
def test_english_and_missing_translation_fallback_preserve_source_text(self):
changes = self.changes(remove_mounts=['/extra'])
for language, cache in (('en', self.translations), ('sk', {})):
with self.subTest(language=language, empty_cache=not cache), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
text = editor.summary(self.member, changes)
self.assertIn('A running container is restarted', text)
self.assertIn('- /extra', text)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,196 @@
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
import copy
import json
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_stack_plan as stack_plan
import oci_stack_transaction as transaction
import oci_ui
class StackFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.row['stack'] = True
self.record.update(installation_id='fiction-main', stack={
'id': 'fiction-main', 'members': [
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
self.original = copy.deepcopy(self.record)
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
self.guard(menu.images, 'offer_removal')]
def no_effects(self):
super().no_effects()
for effect in self.extra_effects:
effect.assert_not_called()
class StackCancellationTests(StackFixture):
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_cancel_returns_six_for_both_actions(self):
for action in ('update', 'recreate'):
with self.subTest(action=action), patch.object(cli, 'Catalog'):
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
self.no_effects()
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
dialog = native_ui.DialogUI()
for action in ('update', 'recreate'):
for code in (1, 255):
with self.subTest(action=action, code=code), \
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
self.no_effects()
def test_confirmation_defaults_and_member_count_are_preserved(self):
for action, default in [('update', True), ('recreate', False)]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
args, kwargs = self.ui.review.call_args
self.assertIn('2 containers', args[0])
self.assertIn('main CT: 110', args[0])
self.assertEqual(kwargs['default'], default)
self.no_effects()
def test_native_menu_returns_to_the_application_list_after_no(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', action, None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_cancel_never_checks_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_rejected_backup_remains_failure_one_without_dispatch(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
for action in ('update', 'recreate'):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
self.lifecycle.assert_not_called()
self.assertEqual(self.record, self.original)
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
for action in ('update', 'recreate'):
for result in (False, True):
self.lifecycle.reset_mock()
self.lifecycle.return_value = result
with patch.object(menu.images, 'offer_removal') as offer:
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
lifecycle_args=('--keep-backup',)), 0 if result else 1)
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
if action == 'recreate':
expected += ['--operation', 'recreate']
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
self.assertEqual(offer.call_count, int(result))
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
self.command.assert_not_called()
def test_acknowledgement_is_not_duplicated(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
class StackSlovakTests(StackFixture):
def setUp(self):
super().setUp()
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
def test_questions_and_titles_name_the_whole_application(self):
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
self.no_effects()
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
text = self.ui.review.call_args.args[0]
self.assertIn('uložených identifikátorov obrazov', text)
self.assertIn('2', text)
self.assertIn('110', text)
self.assertIn('zálohy sa obnovia', text)
self.assertNotIn('Stack', text)
self.no_effects()
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
primary = copy.deepcopy(self.record)
primary['vmid'] = 112
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
stack_plan.build(primary, {}, {}, 'update')
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
self.no_effects()
def test_missing_member_error_keeps_volume_verification_requirement(self):
records = {row['vmid']: row for row in self.record['stack']['members']}
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
self.no_effects()
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
inventory = {key: row['installation_id'] for key, row in records.items()}
records[111]['status'] = 'pending'
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
stack_plan.build(self.record, records, inventory, 'update')
records[111]['status'] = 'installed'
inventory[111] = 'different-installation'
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
stack_plan.build(self.record, records, inventory, 'update')
self.no_effects()
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
primary = copy.deepcopy(self.record)
primary['stack']['id'] = 'different-stack'
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
stack_plan.build(primary, {}, {}, 'recreate')
self.no_effects()
@@ -0,0 +1,216 @@
"""Slovak storage questions through real builders and UI; installation forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT=Path(__file__).resolve().parents[3]
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
from proxmenux_oci import catalog_refresh, cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
from proxmenux_oci.catalog import Catalog
class Recorder:
def __init__(self, answers=None):
self.answers=answers or {}
self.asked=[]
self.messages=[]
def _answer(self,text,default):
self.asked.append(text)
return self.answers.get(text,default)
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
def review(self,*args,**kwargs):return False
def info(self,text):pass
def message(self,text,*args):self.messages.append(text)
class StorageFixture(unittest.TestCase):
def setUp(self):
self.catalog=Catalog(ROOT/'oci')
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
self.forbidden=[]
self.patch(i18n,'_language','sk')
self.patch(i18n,'_cache',self.sk)
self.patch(installer.host,'storages',side_effect=lambda content: [
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
{'storage':'local','type':'dir','avail':100*2**30}])
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
self.addCleanup(self.no_effects)
def patch(self,module,name,*args,**kwargs):
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
def no_effects(self):
for effect in self.forbidden:effect.assert_not_called()
def recorder(self,answers=None):
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
ui=self.recorder(answers)
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
return ui,plan
class StorageWordingTests(StorageFixture):
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
self.assertEqual(plan['media']['size_gb'],9)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
self.build('tandoor',{'Files volume size in GB':'1'})
def test_paperless_storage_documents_and_consume_export_names(self):
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
'Kam uložiť priečinky consume a export',
'Veľkosť dátového zväzku pre dokumenty v GB',
'Veľkosť každého dátového zväzku pre consume a export v GB']:
self.assertIn(text,ui.asked)
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
def test_paperless_volume_limit_and_transfer_limit_errors(self):
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
'Size of each consume/export volume in GB':'0'})
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
for app in ('immich','nextcloud-stack'):
with self.subTest(app=app):
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
self.assertEqual(plan['database_size_gb'],12)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
self.build(app,{'PostgreSQL volume size in GB':'7'})
def test_host_directory_remains_outside_the_container_backup(self):
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
'Shared host directory':'/fiction-only/existing-data'})
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
('host-bind','/fiction-only/existing-data',False))
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
with self.subTest(app=app,mode=mode):
_ui,slovak=self.build(app,mode=mode)
with patch.object(i18n,'_language','en'):
_ui,english=self.build(app,mode=mode)
self.assertEqual(slovak,english)
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
original=copy.deepcopy(services)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
self.assertEqual(services,original)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
'Data location':None})
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services,original)
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
ui=self.recorder()
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
patch.object(ui,'checklist',return_value=[]):
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
extra_devices.ask_stack_extra_devices(ui,services)
self.assertEqual(services,original)
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services[1]['deployment']['mounts'],[])
mount=services[0]['deployment']['mounts'][0]
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
('fiction-storage','/media-extra',8,True))
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
with patch.object(ui,'confirm',return_value=True):
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
class InputAndCancellationTests(StorageFixture):
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
dialog=native_ui.DialogUI()
empty=subprocess.CompletedProcess('dialog',0,stderr='')
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
def test_terminal_required_value_and_password_are_also_localized(self):
terminal=native_ui.TerminalUI()
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
dialog=native_ui.DialogUI()
for code in (1,255):
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
self.assertEqual(run.call_count,5)
self.assertEqual(template,original)
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
self.assertEqual(template,original)
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
ui=Mock();ui.choose.side_effect=['custom',None]
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker, \
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
# Keep this wording/error-handler test offline and free of writes.
# The real background worker has its own catalog lifecycle tests.
catalog_check=catalog_checker.return_value.__enter__.return_value
catalog_check.active=False;catalog_check.consume.return_value=False
catalog_check.describe.return_value=''
check=checker.return_value.__enter__.return_value
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
self.assertEqual(cli.interactive(self.catalog),0)
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
@@ -0,0 +1,172 @@
"""Real native OCI menu/CLI paths with all external effects replaced."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_instance_reconcile as reconcile
class CancellationFixture(unittest.TestCase):
def setUp(self):
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
self.original = copy.deepcopy(self.record)
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
title='Fixture application', hostname='fixture', image='fixture:latest')
self.ui = Mock()
self.ui.unattended = False
self.ui.review.return_value = False
for module, name, options in [
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
(menu, 'recover_automatically', dict(return_value=None)),
(menu, 'carry_records', dict(return_value={})),
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
(menu, 'saved_inventory', dict(return_value=[self.row])),
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
(instances, 'read', dict(return_value=self.record)),
(reconcile, 'propose', dict(return_value=None)),
]:
item = patch.object(module, name, **options)
item.start()
self.addCleanup(item.stop)
self.lifecycle = self.guard(menu, '_run_lifecycle')
self.backup = self.guard(menu, '_work_backup_ready')
self.write = self.guard(instances, 'write')
self.command = self.guard(instances, 'command')
self.commit = self.guard(reconcile, 'commit')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
mock.assert_not_called()
self.assertEqual(self.record, self.original)
class UpdateCancellationTests(CancellationFixture):
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
result = cli.main(['manage', '110', '--action', 'update'])
self.assertEqual(result, 6)
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
self.no_effects()
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
self.row['stack'] = True
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
self.original = copy.deepcopy(self.record)
with redirect_stdout(io.StringIO()):
result = menu.direct_management(ROOT / 'oci', 110, 'update')
self.assertEqual(result, 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_no(self):
self.ui.choose.side_effect = ['110', 'update', None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT / 'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_no_does_not_start_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
rows = [self.row, {**self.row, 'vmid': 111}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_accepted_update_failure_keeps_failure_code_one(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
self.lifecycle.assert_called_once()
command = self.lifecycle.call_args.args[0]
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
self.assertIn('--acknowledge-external-data', command)
self.write.assert_not_called()
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_unattended_positive_default_still_reaches_existing_update(self):
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
def declined(unattended, *_args, **_kwargs):
unattended.declined = 'review'
return False
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
patch.object(menu.UnattendedUI, 'review', declined), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
with patch.object(menu, 'saved_inventory', return_value=[]):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
self.no_effects()
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
text = self.ui.review.call_args.args[0]
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
self.assertIn(part, text)
self.assertNotIn('kanál obrazov', text)
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,199 @@
"""Inert source-as-key checks for OCI update and recreation; no host modules imported."""
import ast
from contextlib import nullcontext
import copy
import json
import os
from pathlib import Path
import re
import runpy
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
RECREATE = ROOT / 'oci/src/proxmenux_oci/recreation.py'
MANAGE = ROOT / 'oci/src/proxmenux_oci/management.py'
UPDATE = ROOT / 'oci/remote/oci_update_current.py'
OLD_CONFIRM = 'Apply the options from the current catalog template? Your data and configuration are kept.'
CONFIRM = ('Apply options from the current catalog template? New required paths and settings may be requested. '
'Review the resulting configuration before recreating the CT.')
OLD_GUARD = 'The current template changes the image or identity; an explicit migration is required'
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
'integrity is verified. The container is then updated using the new image.\n\n'
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
'installation from the backup. If the operation remains incomplete, select the application in OCI '
'management and choose "Recover the previous installation".\n\n'
'Host directories mounted in the container are not restored by the container backup.')
def extracted(path, name, scope):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
class RecreationWording(unittest.TestCase):
def refresh(self, mutate, proceed=True):
old = {'id': 'app-old', 'container_contract': {'image': {'repository': 'example/app', 'reference': 'example/app:v1'},
'volumes': [], 'environment': []},
'proxmox': {'installer_profile': {}}, 'catalog_ui': {'title': 'old'}}
latest = copy.deepcopy(old)
mutate(latest)
candidate = {'template': old, 'deployment': {'mounts': [], 'environment': [], 'security': {}}}
events = []
catalog_module = ModuleType('proxmenux_oci.catalog')
class Catalog:
def __init__(self, root):
pass
def load_index(self):
return {'applications': [{'template_id': old['id'], 'id': 'app'}]}
def compose(self, name):
return latest
catalog_module.Catalog = Catalog
ui = SimpleNamespace(confirm=lambda message, default: events.append(('confirm', message, default)) or proceed,
info=lambda message: events.append(('info', message)))
scope = {'__package__': 'proxmenux_oci', '__file__': str(RECREATE), 'Path': Path, 're': re,
'translate': lambda text: text, 'copy': copy}
with patch.dict(sys.modules, {'proxmenux_oci.catalog': catalog_module}):
try:
extracted(RECREATE, 'refresh_template', scope)(candidate, ui)
except ValueError as error:
events.append(('error', str(error)))
return candidate, events
def test_confirmation_describes_followup_without_preservation_guarantee(self):
candidate, events = self.refresh(lambda latest: latest['catalog_ui'].update(title='new'), proceed=False)
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['catalog_ui']['title'], 'old')
self.assertNotIn(OLD_CONFIRM, str(events))
def test_guard_names_exact_rejected_identity_and_repository_changes(self):
for change in (lambda latest: latest.update(id='app-new'),
lambda latest: latest['container_contract']['image'].update(repository='other/app')):
with self.subTest(change=change):
candidate, events = self.refresh(change)
self.assertEqual(events, [('confirm', CONFIRM, True), ('error', GUARD)])
self.assertEqual(candidate['template']['id'], 'app-old')
def test_same_repository_new_tag_does_not_trigger_migration_guard(self):
candidate, events = self.refresh(lambda latest: latest['container_contract']['image'].update(reference='example/app:v2'))
self.assertEqual(events, [('confirm', CONFIRM, True)])
self.assertEqual(candidate['template']['container_contract']['image']['reference'], 'example/app:v2')
class UpdateWording(unittest.TestCase):
def test_actual_native_backup_verification_succeeds_or_fails_before_replacement(self):
transaction = ROOT / 'oci/remote/oci_instance_transaction.py'
for failures in (1, 2):
with self.subTest(failed_integrity_checks=failures), tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
archive = directory / 'vzdump-lxc-101.tar.zst'
calls = []
def inert_run(*args):
calls.append(args)
if args[0] == 'vzdump':
archive.write_bytes(b'inert fixture')
elif args[0] == 'zstd' and sum(call[0] == 'zstd' for call in calls) <= failures:
raise RuntimeError('invalid backup')
scope = {'run': inert_run, 'log': lambda text: None,
'msg_warn': lambda text: None, 'translate': lambda text: text}
backup = extracted(transaction, 'verified_backup', scope)
if failures == 2:
with self.assertRaisesRegex(RuntimeError, 'invalid backup'):
backup(101, directory, 'zstd', 'missing backup')
else:
self.assertEqual(backup(101, directory, 'zstd', 'missing backup'), archive)
self.assertEqual([call[0] for call in calls], ['vzdump', 'zstd', 'vzdump', 'zstd'])
self.assertTrue(all(call[call.index('--mode') + 1] == 'stop'
for call in calls if call[0] == 'vzdump'))
apply_source = transaction.read_text()
apply_node = next(n for n in ast.parse(apply_source).body
if isinstance(n, ast.FunctionDef) and n.name == 'apply')
calls = [(n.lineno, n.func.id) for n in ast.walk(apply_node) if isinstance(n, ast.Call)
and isinstance(n.func, ast.Name) and n.func.id in ('verified_backup', 'checkpoint', 'install_candidate')]
backup_line = next(line for line, name in calls if name == 'verified_backup')
ready_line = next(line for line, name in calls if name == 'checkpoint' and line > backup_line)
install_line = next(line for line, name in calls if name == 'install_candidate')
self.assertLess(backup_line, ready_line)
self.assertLess(ready_line, install_line)
def test_preview_blocks_lifecycle_when_cancelled_and_keeps_command_on_accept(self):
events = []
instances = ModuleType('oci_instances')
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: {'status': 'installed'}
instances.command = lambda *args: b'description: owned'
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
'_run_lifecycle': lambda command, title: events.append(('run', command, title)) or True}
row = {'vmid': 101, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'}
decision = False
def review(message, title, **kwargs):
events.append(('review', message, title, kwargs))
return decision
ui = SimpleNamespace(review=review, unattended=True)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_instance_reconcile': reconcile}):
manage = extracted(MANAGE, 'manage_instance', scope)
self.assertFalse(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events, [('review', PREVIEW, 'Update OCI', {'question': 'Update now?', 'default': True})])
decision = True
self.assertTrue(manage(ROOT / 'oci', ui, row, action='update'))
self.assertEqual(events[-1], ('run', ['python3', str(ROOT / 'oci/remote/oci_update_current.py'), '101',
'--acknowledge-external-data'], 'Update OCI'))
self.assertNotIn(OLD_PREVIEW, str(events))
def test_noop_image_digest_returns_before_any_backup_or_apply(self):
events = []
record = {'status': 'installed', 'observed': {'image': {'manifest_digest': 'sha256:current'}}}
instances = SimpleNamespace(ROOT=Path('/inert'), locked=lambda root: nullcontext(),
read=lambda root, vmid: record, command=lambda *args: b'arch: amd64')
transaction = SimpleNamespace(candidate_contract=lambda *args: {'deployment': {}},
external_changes=lambda *args: {}, preflight=lambda *args: None,
require_backup_space=lambda *args: self.fail('backup space checked on no-op'),
apply=lambda *args, **kwargs: self.fail('apply on no-op'))
scope = {'instances': instances, 'transaction': transaction,
'translate': lambda text: text,
'msg_info': lambda text: events.append(text), 'msg_ok': lambda text: events.append(text),
'resolve_archive': lambda desired, config, current, check: (None, current),
'kept_settings': lambda *args: self.fail('kept settings on no-op')}
extracted(UPDATE, 'update', scope)(101, keep_backup='not-a-real-storage')
self.assertIn('The image is already up to date; nothing was changed.', events)
def test_new_keys_extracted_and_shipped_and_missing_locale_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
found = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
for key in (CONFIRM, GUARD, PREVIEW):
self.assertIn(key, found)
scope = {'json': json, 'Path': Path, 'BASE_DIR': ROOT,
'_language': 'it', '_cache': None}
lookup = extracted(ROOT / 'oci/src/proxmenux_oci/i18n.py', 'translate', scope)
scope['language'] = lambda: scope['_language']
for path in sorted((ROOT / 'lang').glob('*.json')):
scope['_language'] = path.stem
scope['_cache'] = None
values = json.loads(path.read_text())
for key in (CONFIRM, GUARD, PREVIEW):
self.assertEqual(lookup(key), values.get(key) or key, (path.name, key))
scope['_language'] = 'it'
for key in (CONFIRM, GUARD, PREVIEW):
scope['_cache'] = {}
self.assertEqual(lookup(key), key, ('missing', key))
scope['_cache'] = {key: 'LOCALIZED: ' + key}
self.assertEqual(lookup(key), 'LOCALIZED: ' + key, ('synthetic', key))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,231 @@
"""OCI validation record: generated list, record checks, credited authors and the report bot."""
import datetime
import importlib.util
import json
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("oci_validation", ROOT / ".github/scripts/oci_validation.py")
validation = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validation)
TODAY = datetime.date(2026, 9, 28)
REPORT = "https://github.com/MacRimi/ProxMenux/issues/400"
INDEX = {"applications": [
{"id": "glances", "title": "Glances", "category_label": "Monitoring", "automatic_install_candidate": True},
{"id": "2fauth", "title": "2FAuth", "category_label": "Security", "automatic_install_candidate": True},
{"id": "legacy", "title": "Legacy | App", "category_label": "Other", "automatic_install_candidate": False},
{"id": "hidden", "title": "Hidden", "hidden": True},
]}
def form(**answers):
fields = {"Application": "Glances", "Image version": "4.3", "Image digest": "2026-09-06 · B1F339CF",
"Proxmox VE version": "9.1", "Install mode": "Default configuration",
"Data storage": "Volume on Proxmox storage", "Network": "DHCP", "GPU": "No GPU",
"Result": "Works", "Notes": "_No response_"}
fields.update(answers)
return "\n\n".join(f"### {label}\n\n{value}" for label, value in fields.items())
def event(body=None, labels=("oci-validation", "validated"), user="Tester-1"):
return {"issue": {"number": 400, "html_url": REPORT, "user": {"login": user}, "body": form() if body is None else body,
"labels": [{"name": name} for name in labels]}}
class Render(unittest.TestCase):
def test_lists_community_and_installable_lab_entries(self):
record = {"applications": {
"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}},
"2fauth": {"status": "laboratory-validated"},
"legacy": {"status": "laboratory-validated"}}}
text = validation.render(record, INDEX)
self.assertIn("2 of 3 applications", text)
self.assertIn("| Glances | [@Vaso73](https://github.com/Vaso73) | 2026-09-27 | — | — | [Report](" + REPORT + ") |", text)
self.assertIn("| 2FAuth | Security |", text)
# An application that cannot be installed is not listed as verified.
self.assertNotIn("Legacy", text)
def test_empty_record_and_escaped_titles(self):
text = validation.render({"applications": {}}, INDEX)
self.assertIn("No application has been tested by the community yet.", text)
record = {"applications": {"legacy": {"community_tested": {"by": "a", "date": "2026-09-27"}}}}
self.assertIn("| Legacy \\| App |", validation.render(record, INDEX))
def test_repository_record_is_valid_and_current(self):
record = validation.load(validation.VERIFICATION)
index = validation.load(validation.INDEX)
self.assertEqual(validation.problems(record, index, validation.OUTPUT.read_text(encoding="utf-8")), [])
class Problems(unittest.TestCase):
def check(self, entry):
return validation.problems({"applications": {"glances": entry}}, INDEX, today=TODAY)
def test_valid_entries(self):
self.assertEqual(self.check({"status": "laboratory-validated"}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27", "report": REPORT}}), [])
self.assertEqual(self.check({"community_tested": {"by": "Vaso73", "date": "2026-09-27",
"report": "https://github.com/MacRimi/ProxMenux/discussions/392#discussioncomment-18623868"}}), [])
def test_rejected_entries(self):
cases = [
({"status": "works"}, "status can only be"),
({"community_tested": {"by": "bad user", "date": "2026-09-27"}}, "GitHub user name"),
({"community_tested": {"by": "a", "date": "27/09/2026"}}, "YYYY-MM-DD"),
({"community_tested": {"by": "a", "date": "2027-01-01"}}, "in the future"),
({"community_tested": {"by": "a", "date": "2026-09-27", "report": "https://example.com/x"}}, "must link"),
({"community_tested": {"by": "a", "date": "2026-09-27", "extra": 1}}, "unknown community_tested keys"),
({"verified": True}, "unknown keys"),
({}, "non-empty object"),
]
for entry, message in cases:
with self.subTest(entry=entry):
self.assertTrue(any(message in problem for problem in self.check(entry)), self.check(entry))
self.assertTrue(validation.problems({"applications": {"nope": {"status": "laboratory-validated"}}}, INDEX))
def test_stale_list_is_reported(self):
record = {"applications": {"2fauth": {"status": "laboratory-validated"}}}
self.assertTrue(any("out of date" in p for p in validation.problems(record, INDEX, "old", TODAY)))
class Authors(unittest.TestCase):
base = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}},
"2fauth": {"status": "laboratory-validated"}}}
def head(self, **changes):
data = json.loads(json.dumps(self.base))
data["applications"].update(changes)
return data
def test_author_adds_own_test(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "F3rs3n"), [])
def test_crediting_someone_else_is_refused(self):
head = self.head(pocketbase={"community_tested": {"by": "Vaso73", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems(self.base, head, "f3rs3n"),
["pocketbase: credits @Vaso73, but the pull request is from @f3rs3n"])
def test_lab_status_and_removals_are_for_the_maintainer(self):
head = self.head(glances={}, pocketbase={"status": "laboratory-validated"})
found = validation.author_problems(self.base, head, "f3rs3n")
self.assertIn("glances: a community test is removed by the maintainer only", found)
self.assertIn("pocketbase: the ProxMenux lab status is changed by the maintainer only", found)
self.assertEqual(validation.author_problems(self.base, head, "MacRimi"), [])
def test_missing_base_file(self):
head = self.head(pocketbase={"community_tested": {"by": "f3rs3n", "date": "2026-09-28"}})
self.assertEqual(validation.author_problems({}, {"applications": {"pocketbase": head["applications"]["pocketbase"]}},
"f3rs3n"), [])
class Record(unittest.TestCase):
def test_parse_form_reads_github_issue_form_body(self):
fields = validation.parse_form(form(Notes="Line one\r\nLine two"))
self.assertEqual(fields["Application"], "Glances")
self.assertEqual(fields["Notes"], "Line one\nLine two")
self.assertEqual(validation.parse_form(form())["Notes"], "")
def test_records_the_report_author(self):
data, app, message = validation.record(event(), {"applications": {"glances": {"status": "laboratory-validated"}}},
INDEX, TODAY)
self.assertEqual(app, "glances")
self.assertEqual(data["applications"]["glances"], {"status": "laboratory-validated", "community_tested": {
"by": "Tester-1", "date": "2026-09-28", "report": REPORT, "digest": "b1f339cf",
"scenario": "Default configuration · Volume on Proxmox storage · DHCP · No GPU · Proxmox VE 9.1 · image 4.3"}})
self.assertIn("@Tester-1", message)
self.assertEqual(validation.problems(data, INDEX, today=TODAY), [])
def test_matches_id_or_title_and_replaces_older_test(self):
record = {"applications": {"glances": {"community_tested": {"by": "Vaso73", "date": "2026-09-27"}}}}
data, app, message = validation.record(event(form(Application=" glances ")), record, INDEX, TODAY)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("replaces the previous one from @Vaso73", message)
self.assertEqual(record["applications"]["glances"]["community_tested"]["by"], "Vaso73")
def test_refusals(self):
for case, message in [
(event(form(Application="Unknown")), "does not match"),
(event(form(Result="Does not work")), "does not work"),
(event(labels=("validated",)), "not an OCI validation report"),
(event(user="bad user"), "could not be read"),
(event(form(**{"Image digest": "latest"})), "does not hold an image digest"),
]:
with self.subTest(message=message):
with self.assertRaisesRegex(ValueError, message):
validation.record(case, {"applications": {}}, INDEX, TODAY)
class ReviewerComment(unittest.TestCase):
allowed = frozenset({"macrimi", "vaso73", "f3rs3n"})
def comment(self, reviewer, author="Tester-1"):
data = event(labels=("oci-validation",), user=author)
data["comment"] = {"user": {"login": reviewer}, "body": "/validated"}
return data
def test_listed_reviewer_validates_and_is_named(self):
data, app, message = validation.record(self.comment("Vaso73"), {"applications": {}}, INDEX, TODAY, self.allowed)
self.assertEqual(data["applications"]["glances"]["community_tested"]["by"], "Tester-1")
self.assertIn("Validated by @Vaso73.", message)
def test_unlisted_reviewer_and_own_report_are_refused(self):
with self.assertRaisesRegex(ValueError, "not in the list of OCI validation reviewers"):
validation.record(self.comment("someone"), {"applications": {}}, INDEX, TODAY, self.allowed)
with self.assertRaisesRegex(ValueError, "other than its author"):
validation.record(self.comment("f3rs3n", author="f3rs3n"), {"applications": {}}, INDEX, TODAY, self.allowed)
def test_maintainer_may_validate_own_report_and_label_needs_no_list(self):
validation.record(self.comment("MacRimi", author="MacRimi"), {"applications": {}}, INDEX, TODAY, self.allowed)
validation.record(event(), {"applications": {}}, INDEX, TODAY)
def test_reviewer_list_file(self):
self.assertEqual(validation.reviewers("# comment\nMacRimi\n Vaso73 # tester\nbad name\n"),
{"macrimi", "vaso73"})
listed = validation.reviewers(validation.REVIEWERS.read_text(encoding="utf-8"))
self.assertEqual(listed, self.allowed)
class ImageAndScenario(unittest.TestCase):
def test_digest_as_the_monitor_shows_it_or_in_full(self):
full = "sha256:" + "ab" * 32
self.assertEqual(validation.image_digest("2026-09-06 · b1f339cf"), "b1f339cf")
self.assertEqual(validation.image_digest(full.upper()), "ab" * 32)
for value in ("", "b1f3", "latest", "sha256:xyz12345"):
self.assertIsNone(validation.image_digest(value))
def test_scenario_names_problems_and_skips_empty_fields(self):
fields = validation.parse_form(form(Result="Works with problems", GPU="_No response_"))
self.assertEqual(validation.scenario(fields), "Works with problems: Default configuration · "
"Volume on Proxmox storage · DHCP · Proxmox VE 9.1 · image 4.3")
def test_list_shows_image_and_scenario(self):
record = {"applications": {"glances": {"community_tested": {
"by": "Vaso73", "date": "2026-09-27", "digest": "b1f339cf08ae", "scenario": "Default | DHCP"}}}}
self.assertIn("| `b1f339cf08ae` | Default \\| DHCP |", validation.render(record, INDEX))
def test_bad_digest_and_long_scenario_are_rejected(self):
found = validation.problems({"applications": {"glances": {"community_tested": {
"by": "a", "date": "2026-09-27", "digest": "XYZ", "scenario": "x" * 301}}}}, INDEX, today=TODAY)
self.assertTrue(any("digest" in f for f in found) and any("scenario" in f for f in found), found)
class IssueForm(unittest.TestCase):
def test_form_labels_match_the_bot(self):
text = (ROOT / ".github/ISSUE_TEMPLATE/oci-validation.yml").read_text(encoding="utf-8")
labels = re.findall(r"^\s+label: (.+)$", text, re.M)
self.assertIn("Application", labels)
self.assertIn("Result", labels)
self.assertIn("Image digest", labels)
for name in validation.SCENARIO_FIELDS:
self.assertIn(name, labels)
self.assertIn("- Does not work", text)
self.assertIn('labels: ["oci-validation"]', text)
self.assertIn("template=oci-validation.yml", validation.FORM)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
import ast
import copy
import json
from pathlib import Path
import re
from unittest import TestCase
from unittest.mock import Mock
from types import SimpleNamespace
from typing import Any
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class OciWatchdogWiring(TestCase):
def test_changing_it_needs_an_administrator_and_goes_through_the_engine(self):
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
route = re.search(r"@app\.route\('/api/lxc/<int:vmid>/oci-watchdog', methods=\['POST'\]\)\n@(\w+)\ndef (\w+)", server)
self.assertEqual(route.group(1), 'require_admin_scope')
body = server[route.end():server.index("@app.route('/api/vms/<int:vmid>/logs'")]
self.assertIn("if not isinstance(enabled, bool):", body)
self.assertIn("oci/engine/remote/oci_watchdog.py',", body)
self.assertNotIn('shell=True', body)
self.assertIn('"watchdog": False,', (ROOT / 'AppImage/scripts/oci_instance_info.py').read_text())
def test_the_toggle_is_only_offered_for_an_oci_application(self):
modal = (ROOT / 'AppImage/components/virtual-machines.tsx').read_text()
block = modal[modal.index('{/* Watchdog of an OCI application'):]
self.assertTrue(block.split('\n', 3)[2].strip().startswith('{ociInstance?.oci_instance && ('))
self.assertIn('t("vmLxc.details.watchdog")', block)
self.assertIn('`/api/lxc/${selectedVM.vmid}/oci-watchdog`', modal)
def test_every_language_names_and_explains_it(self):
for locale in LOCALES:
details = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['vmLxc']['details']
self.assertTrue(details['watchdog'].strip(), locale)
self.assertTrue(details['watchdogHelp'].strip(), locale)
def test_the_engine_restarts_its_service_when_proxmenux_replaces_it(self):
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
self.assertIn('systemctl try-restart proxmenux-oci-watchdog.service', (ROOT / installer).read_text(), installer)
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn("UNIT = 'proxmenux-oci-watchdog.service'", engine)
class OciWatchdogInstaller(TestCase):
CLI = (ROOT / 'oci/src/proxmenux_oci/cli.py').read_text()
MENU = (ROOT / 'oci/src/proxmenux_oci/management.py').read_text()
def default(self, template):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'watchdog_default')
scope = {'Any': object}
exec(compile(ast.Module(body=[node], type_ignores=[]), 'cli.py', 'exec'), scope)
return scope['watchdog_default'](template)
def test_the_recipe_decides_what_is_proposed(self):
self.assertTrue(self.default({'container_contract': {'restart': 'unless-stopped'}}))
self.assertTrue(self.default({'container_contract': {'restart': 'always'}}))
self.assertTrue(self.default({'compose_stack': {'services': [{'compose': {}}, {'compose': {'restart': 'on-failure'}}]}}))
self.assertFalse(self.default({'container_contract': {'restart': None}}))
self.assertFalse(self.default({'container_contract': {'restart': 'no'}, 'compose_stack': {'services': [{'compose': {'restart': 'no'}}]}}))
self.assertFalse(self.default({}))
def test_every_installation_asks_it_and_the_engine_never_receives_the_answer(self):
install = self.CLI[self.CLI.index('def install_template('):self.CLI.index('def _rclone_mount(')]
asked = install.index('deployment["watchdog"] = wizard.confirm(')
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
self.assertLess(asked, install.index('approved = wizard.review('))
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
self._assert_jobs_are_excluded_from_installed_watchdog()
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
def _assert_jobs_are_excluded_from_installed_watchdog(self):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
class InertImports(ast.NodeTransformer):
def visit_ImportFrom(self, item):
return None
node = InertImports().visit(node)
deployment = {'services': [
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
{'name': 'database', 'deployment': {}},
{'name': 'worker', 'deployment': {}}]}
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
'database': 4802, 'worker': 4803}}
set_watchdog = Mock(return_value=True)
class UI:
def confirm(self, *args, **kwargs): return True
def review(self, *args, **kwargs): return True
def close(self): pass
def install(project, template, plan, target):
self.assertNotIn('watchdog', plan)
return result
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
'RestartWizard': RuntimeError, 'InstallError': ValueError,
'build_deployment': lambda *args: copy.deepcopy(deployment),
'watchdog_default': lambda *args: False,
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
'console': SimpleNamespace(**{name: lambda *args: None for name in
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
def test_both_management_menus_offer_it(self):
self.assertEqual(self.MENU.count("('watchdog', "), 2)
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
def test_the_service_is_written_through_the_change_journal(self):
engine = (ROOT / 'oci/remote/oci_watchdog.py').read_text()
self.assertIn('pmx_write_file "$2" && systemctl daemon-reload && pmx_enable_service "$3"', engine)
journal = (ROOT / 'scripts/global/pmx_journal.sh').read_text()
for helper in ('pmx_journal_context()', 'pmx_write_file()', 'pmx_enable_service()'):
self.assertIn(helper, journal)
+188
View File
@@ -0,0 +1,188 @@
"""Offline OCI wording seams; compile actual functions, never import operational modules."""
import ast
import importlib.util
import io
import json
import runpy
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest import TestCase
from unittest.mock import Mock
ROOT = Path(__file__).resolve().parents[3]
CUSTOM = ROOT / 'oci/src/proxmenux_oci/custom.py'
TRANSACTION = ROOT / 'oci/remote/oci_instance_transaction.py'
MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
CHOICE = 'Recover the previous installation'
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
NEW_KEYS = (
'Containers of the application:',
'No docker run command was given',
'Image selection was cancelled',
'Could not inspect the image in its registry:',
REGISTRY_ADVICE,
BEFORE, AFTER,
)
class ConversionError(Exception):
pass
class UserCancelled(Exception):
pass
def function(path, name, **dependencies):
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
# Only read_definition's infrastructure import is omitted; keep the function intact.
if name == 'read_definition':
node.body = [n for n in node.body if not isinstance(n, ast.ImportFrom)]
scope = dict(translate=lambda text: text, ConversionError=ConversionError,
UserCancelled=UserCancelled, Any=Any, yaml=__import__('yaml'), json=json,
Path=Path, MENU_SIZE=(20, 80), MAX_COMPOSE_BYTES=256*1024,
MAX_OCI_COMPOSE_BYTES=2*1024*1024, OCI_COMPOSE='oci-compose')
scope.update(dependencies)
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
str(path), 'exec'), scope)
return scope[name]
class OciWordingTests(TestCase):
def test_extractor_shipped_locales_and_synthetic_fallback(self):
generator = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = generator['extract_python_texts']([ROOT / 'oci/src', ROOT / 'oci/remote'])
self.assertEqual(len(NEW_KEYS), len(set(NEW_KEYS)))
self.assertTrue(set(NEW_KEYS) <= set(extracted), set(NEW_KEYS) - set(extracted))
spec = importlib.util.spec_from_file_location('oci_i18n', ROOT / 'oci/src/proxmenux_oci/i18n.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = sorted((ROOT / 'lang').glob('*.json'))
self.assertEqual(len(paths), 7)
for path in paths:
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
for key in NEW_KEYS:
self.assertEqual(module.translate(key), cache.get(key) or key, (path.name, key))
setattr(module, '_cache', {})
for key in NEW_KEYS:
self.assertEqual(module.translate(key), key, (path.name, key, 'synthetic missing'))
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
def test_multiple_services_keep_their_names_and_enter_stack_conversion(self):
# Reusing one image twice still creates two distinct Compose services.
document = {'services': {'alpha': {'image': 'same:latest'},
'beta': {'image': 'same:latest'}}}
converted = object()
stack = Mock(return_value=converted)
template = function(CUSTOM, 'template_from_compose',
_services=lambda doc: doc['services'], _template_from_stack=stack)
self.assertIs(template(json.dumps(document)), converted)
stack.assert_called_once()
self.assertEqual(stack.call_args.args, (document, None))
self.assertFalse(stack.call_args.kwargs['interpolated'])
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
for title, expected in ((None, 'No Compose file was given'),
('docker run command of the application', 'No docker run command was given')):
def paste(content):
return function(CUSTOM, '_read_pasted',
console=SimpleNamespace(show_logo=lambda: None, msg_title=lambda *_: None,
msg_info2=lambda *_: None),
sys=SimpleNamespace(stdin=io.StringIO(content)))
with self.assertRaises(UserCancelled) as caught:
paste('')(None, title)
self.assertEqual(str(caught.exception), expected)
self.assertEqual(paste(' literal input\n')(None, title), ' literal input\n')
def test_cancelled_image_menu(self):
with self.assertRaises(UserCancelled) as caught:
function(CUSTOM, 'read_definition')(SimpleNamespace(choose=lambda *_, **__: None))
self.assertEqual(str(caught.exception), 'Image selection was cancelled')
def test_registry_nonzero_and_success_command_unchanged(self):
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
run = Mock(return_value=result)
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
_registry_lookup=lambda reference: reference)
self.assertEqual(report('example:latest'),
(False, 'Could not inspect the image in its registry: example:latest'))
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
capture_output=True, text=True, check=False, timeout=120)
run.reset_mock()
run.return_value = SimpleNamespace(returncode=0,
stdout='{"manifests":[{"platform":{"architecture":"amd64"}}]}', stderr='')
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
self.assertEqual(run.call_count, 1)
def test_failed_registry_inspection_points_to_registry_accounts(self):
ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
_registry_refused=lambda _: False)
fn(ui)
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
# Pin the real translated management choice, not an English AST constant alone.
source = ast.parse(MENU.read_text())
self.assertTrue(any(isinstance(n, ast.Tuple) and len(n.elts) == 2
and isinstance(n.elts[0], ast.Constant) and n.elts[0].value == 'recover'
and isinstance(n.elts[1], ast.Call) and isinstance(n.elts[1].func, ast.Name)
and n.elts[1].func.id == 'translate' and n.elts[1].args
and isinstance(n.elts[1].args[0], ast.Constant)
and n.elts[1].args[0].value == CHOICE for n in ast.walk(source)))
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for path in sorted((ROOT / 'lang').glob('*.json')):
cache = json.loads(path.read_text())
setattr(module, '_language', path.stem)
setattr(module, '_cache', cache)
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
label = module.translate(CHOICE)
self.assertNotEqual(label, CHOICE, path.name)
expected = [(cache.get(key) or key).replace('{choice}', label)
if '{choice}' in (cache.get(key) or key) else key.replace('{choice}', label)
for key in (BEFORE, AFTER)]
self.assertEqual(warnings, expected, path.name)
# An intentionally absent message key proves fallback independently of shipped catalogs.
setattr(module, '_cache', {CHOICE: label})
warnings.clear()
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [BEFORE.replace('{choice}', label),
AFTER.replace('{choice}', label)], (path.name, 'synthetic missing'))
def test_recovery_hint_uses_translated_whole_messages_and_malformed_fallback(self):
spec = importlib.util.spec_from_file_location('oci_remote_ui', ROOT / 'oci/remote/oci_ui.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module._language = 'it'
for translation in ('Before: «{choice}».', 'Before: label missing.'):
module._cache = {CHOICE: 'Recupero', BEFORE: translation,
AFTER: 'After: «{choice}».'}
warnings = []
hint = function(TRANSACTION, 'recovery_hint', translate=module.translate,
msg_warn=warnings.append)
hint()
hint(after_recovery=True)
self.assertEqual(warnings, [
('Before: «Recupero».' if '{choice}' in translation
else BEFORE.replace('{choice}', 'Recupero')),
'After: «Recupero».'])
@@ -0,0 +1,135 @@
"""Wording contracts through the real metadata parser and exact-match formatter.
No post-install function or scanner is imported or executed. Node 22.6+ is
required, as in test_command_descriptions.py; no npm dependencies are needed.
"""
import ast
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
class OptimizationDescriptionWordingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Admit only the pure parser and its actual dependencies, not the scanner.
path = ROOT / "AppImage/scripts/post_install_versions.py"
functions = {"_read_text", "_version_tuple", "parse_post_install_script"}
constants = {"_FN_DEF_RE", "_VERSION_RE", "_DESC_RE", "_REGISTER_RE", "_HEREDOC_RE"}
nodes: list[ast.stmt] = [node for node in ast.parse(path.read_text(encoding="utf-8")).body
if (isinstance(node, ast.FunctionDef) and node.name in functions)
or (isinstance(node, ast.Assign) and any(
isinstance(target, ast.Name) and target.id in constants
for target in node.targets))]
if len(nodes) != len(functions) + len(constants):
raise AssertionError("Metadata parser seam changed: review its dependencies")
scope = {"re": re, "Path": Path}
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), scope)
cls.metadata = {
source: scope["parse_post_install_script"](ROOT / "scripts/post_install" / filename)
for source, filename in (("auto", "auto_post_install.sh"),
("custom", "customizable_post_install.sh"))
}
cls.catalogs = {
locale: json.loads((ROOT / f"AppImage/messages/{locale}/common.json").read_text(encoding="utf-8"))
for locale in ("en", "it")
}
node = os.environ.get("NODE_BINARY") or shutil.which("node")
if not node:
raise AssertionError("Install Node.js 22.6+ or set NODE_BINARY")
cls.node = node
version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)\s*", version)
if not match or tuple(map(int, match.groups())) < (22, 6, 0):
raise AssertionError(f"Node.js 22.6+ required; detected {version.strip()}")
# Replace only the JSON import; the formatter body executes unchanged.
formatter = (ROOT / "AppImage/lib/optimization-descriptions.ts").read_text(encoding="utf-8")
json_import = 'import enMessages from "../messages/en/common.json"'
if formatter.count(json_import) != 1:
raise AssertionError("Formatter import seam changed")
fixture = formatter.replace(json_import, "const enMessages = " + json.dumps(cls.catalogs["en"]))
fixture += '''
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const [key, description, italian, catalogs] = JSON.parse(readFileSync(0, "utf8"));
for (const locale of ["en", "it"]) {
const calls: string[] = [];
const t = (path: string) => {
calls.push(path);
return path.split(".").reduce((value, part) => value[part], catalogs[locale]);
};
assert.equal(optimizationDescription(description, t), locale === "en" ? description : italian);
assert.deepEqual(calls, [`settings.optimizations.toolDescriptions.${key}`]);
calls.length = 0;
const unknown = description + " [new server wording]";
assert.equal(optimizationDescription(unknown, t), unknown);
assert.deepEqual(calls, []);
}
console.log("PASS actual exact lookup: EN/IT and unknown-description fallback");
'''
cls.directory = tempfile.TemporaryDirectory(prefix="optimization-wording-")
cls.addClassCleanup(cls.directory.cleanup)
cls.fixture = Path(cls.directory.name) / "formatter.mts"
cls.fixture.write_text(fixture, encoding="utf-8")
def assert_description(self, key, source, tool, function, english, italian):
for locale, expected in (("en", english), ("it", italian)):
with self.subTest(key=key, locale=locale):
self.assertEqual(self.catalogs[locale]["settings"]["optimizations"]["toolDescriptions"][key], expected)
metadata = self.metadata[source][tool]
self.assertEqual(metadata["function"], function)
self.assertEqual(metadata["description"], english)
result = subprocess.run(
[self.node, "--experimental-strip-types", str(self.fixture)],
input=json.dumps([key, metadata["description"], italian, self.catalogs]),
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS actual exact lookup", result.stdout)
def test_log2ram_auto_fresh_install_detection_and_update_size(self):
self.assert_description(
"log2ramAuto", "auto", "log2ram", "install_log2ram_auto",
"Fresh installs size Log2RAM from host RAM and ask for confirmation if storage is not detected as SSD. Updates preserve the configured size.",
"Alla prima installazione, dimensiona Log2RAM in base alla RAM dell'host e chiede conferma se il dispositivo di archiviazione non viene rilevato come SSD. Gli aggiornamenti mantengono la dimensione configurata.")
def test_kexec_prepares_unit_and_alias_not_hotkey(self):
self.assert_description(
"kexec", "custom", "kexec", "enable_kexec",
"Install kexec-tools and prepare a systemd unit plus the reboot-quick alias for fast reboots that skip BIOS/POST.",
"Installa kexec-tools e prepara un'unità systemd e l'alias reboot-quick per riavvii rapidi che saltano BIOS/POST.")
def test_amd_only_claims_idle_parameter_and_kvm_module_options(self):
self.assert_description(
"amd", "custom", "amd_fixes", "apply_amd_fixes",
"Apply idle=nomwait and KVM module options on AMD EPYC/Ryzen hosts.",
"Applica idle=nomwait e opzioni del modulo KVM sugli host AMD EPYC/Ryzen.")
def test_guest_agent_only_lists_supported_environments_and_skip(self):
self.assert_description(
"guestAgent", "custom", "guest_agent", "install_guest_agent",
"Detect QEMU/KVM, VMware or VirtualBox and install the matching guest agent; skip other environments.",
"Rileva QEMU/KVM, VMware o VirtualBox e installa il guest agent corrispondente; salta gli altri ambienti.")
def test_backup_describes_global_defaults_without_speed_guarantee(self):
self.assert_description(
"backupLimits", "custom", "vzdump_speed", "optimize_vzdump",
"Set global vzdump defaults to bwlimit: 0 and ionice: 5. This does not guarantee faster backups.",
"Imposta i valori predefiniti globali di vzdump su bwlimit: 0 e ionice: 5. Questo non garantisce backup più veloci.")
def test_log2ram_custom_choices_and_update_preservation(self):
self.assert_description(
"log2ramCustom", "custom", "log2ram", "configure_log2ram",
"Install Log2RAM with a chosen size, sync interval and optional autosync. Updates preserve the size and autosync choice.",
"Installa Log2RAM con dimensione e intervallo di sincronizzazione a scelta e autosync facoltativo. Gli aggiornamenti mantengono la dimensione e la scelta sull'autosync.")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,75 @@
"""A preview or a warning promises only what the code always does."""
import ast
import json
from pathlib import Path
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
def oci_menu_texts():
tree = ast.parse((ROOT / 'oci/src/proxmenux_oci/management.py').read_text())
return [node.args[0].value for node in ast.walk(tree)
if isinstance(node, ast.Call) and getattr(node.func, 'id', '') == 'translate'
and node.args and isinstance(node.args[0], ast.Constant)]
class OutcomePromises(TestCase):
def test_a_failure_is_not_promised_to_end_in_a_restore(self):
texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
update = next(text for text in texts if text.startswith('If a newer application image is available'))
self.assertIn('attempts to restore the previous installation', update)
self.assertIn('If the operation remains incomplete', update)
self.assertIn('choose "Recover the previous installation"', update)
def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
self.assertIn('If the container had already been changed', preview)
self.assertIn('when the cleanup succeeds', preview)
def test_a_rclone_mount_is_not_listed_as_a_registration_problem(self):
prompt = next(text for text in oci_menu_texts() if text.startswith('Until they are registered again'))
self.assertIn('does not prevent it', prompt)
self.assertIn('not started automatically', prompt)
def test_an_empty_retention_is_the_one_of_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertIn('Leave empty to use the retention of the storage.', menu)
self.assertNotIn('keep every backup', menu)
def test_host_backups_are_said_to_follow_the_storage(self):
menu = (ROOT / 'scripts/backup_restore/vm_backup_jobs.sh').read_text()
self.assertNotIn('stop running', menu)
self.assertIn('Another job that writes to the same storage still starts them.', menu)
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertNotIn('stop running', english['deleteAttached'] + english['storageAttached'])
self.assertIn('scheduled runs', english['disableBody'])
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
for key in ('deleteAttached', 'storageAttached'):
self.assertEqual(texts[key].count('{ids}'), 1, f'{locale} {key}')
self.assertEqual(texts['disableBody'].count('. '), 1, f'{locale} disableBody')
def test_the_host_restore_notice_reports_tasks_and_does_not_call_the_node_ready(self):
for locale in LOCALES:
body = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['runtime']['notifications']['templates']['system_restore_completed']['body']
self.assertEqual(body.split('\n')[-1], '{warnings_block}', locale)
self.assertEqual(body.count('\n'), 7, locale)
def test_the_watchdog_failure_covers_an_application_that_did_not_start(self):
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['runtime']['notifications']['templates']['oci_watchdog_failed']
self.assertNotIn('keeps stopping', english['title'] + english['body'] + english['label'])
self.assertIn('did not start', english['body'])
def test_other_wording_that_promised_too_much(self):
texts = oci_menu_texts()
self.assertIn('Private network kept, because other guests still use it:', texts)
recreation = (ROOT / 'oci/src/proxmenux_oci/stack_recreation.py').read_text()
self.assertNotIn('if anything fails', recreation)
self.assertIn('a stopped one is left stopped', recreation)
for script in ('scripts/post_install/customizable_post_install.sh', 'scripts/post_install/uninstall-tools.sh'):
self.assertNotIn('leaving it unchanged', (ROOT / script).read_text(), script)
@@ -0,0 +1,250 @@
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
import contextlib
import copy
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
def function(path, name):
text = (ROOT/path).read_text()
start = text.index(name+'() {')
return text[start:text.index('\n}\n', start)+3]
def repository(components, suite='trixie'):
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
Components=components.split(), Enabled=True)
class RepositoryCliTests(unittest.TestCase):
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
status='notfound'; calls=[]
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
if case=='status': status='unknown'
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
if case=='active': status='active'; state['files']=state['files'][1:]
if case=='debian': state['files']=state['files'][:1]
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
if case=='handle': state['standard-repos']=[]
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
original=copy.deepcopy(state)
def run(args):
calls.append(args)
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
if 'create' in args:
state['files'][0]['repositories'][0]['Enabled']=False
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
return ''
if 'set' in args:
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
return ''
return json.dumps(state)
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
module.BASE=Path(folder)
(module.BASE/'lang').mkdir()
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
(module.BASE/'lang/sk.json').write_text(catalog)
stdout,stderr=io.StringIO(),io.StringIO()
suite='sid' if case=='unsupported' else 'trixie'
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
result=module.main()
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
'partial':'stav repozitárov je neznámy'}
for case,expected in expectations.items():
with self.subTest(case=case):
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
writes=[c for c in calls if 'create' in c or 'set' in c]
if case=='partial':
self.assertEqual(len(writes),1)
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
self.assertIn('Automatický návrat',error)
else:
self.assertEqual(writes,[]); self.assertEqual(state,original)
if case=='unsupported': self.assertEqual(calls,[])
def test_english_error_and_stdout_tokens_remain_compatible(self):
en=self.run_policy('unsupported',language='en')
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
pve=self.run_policy('unsupported',product='pve')
self.assertEqual(pve[2],en[2])
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
result,out,error,*_=self.run_policy('offer',mode=mode)
self.assertEqual((result,out,error),(0,expected,''))
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
self.assertEqual((result,out,error),(0,'preserve\n',''))
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
def test_bad_or_missing_translation_falls_back_to_original_english(self):
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
with self.subTest(catalog=catalog):
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
def test_translation_cannot_drop_actual_repository_details(self):
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
(module.BASE/'config.json').write_text('{"language":"sk"}')
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
self.assertEqual(module.cli_error(error,'pbs'),str(error))
for invalid in ('not json','[]','{"language":"../../private"}'):
(module.BASE/'config.json').write_text(invalid)
self.assertEqual(module.cli_error(error,'pbs'),str(error))
UPDATE_HARNESS=r'''
BASE_DIR="$FIXTURE"
LANG_DIR="$REPO/lang"
SCRIPT_TITLE='fixture'
TAB='' BGN='' CL='' GN='' BL=''
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
: > "$FIXTURE/calls"
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
pmx_journal_context() { :; }
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
ensure_pbs_repositories() { record repository-plan; return 0; }
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
apt() {
record apt "$@"
if [[ "$*" == 'list --upgradable' ]]; then
printf 'Listing...\n'
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
return 0
fi
[[ "$CASE" != failure ]]
}
fixture_env() { "$@"; }
APT_ENV='fixture_env'
pmx_format_upgradable() { cat; }
pmx_record_execution() { record journal "$1"; }
pmx_dpkg_changes_since() { :; }
msg_error() { printf '%s\n' "$*"; }
msg_ok() { printf '%s\n' "$*"; }
msg_info2() { printf '%s\n' "$*"; }
msg_warn() { printf '%s\n' "$*"; }
msg_title() { :; }
clear() { :; }
show_proxmenux_logo() { :; }
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
uname() { printf old-kernel; }
touch "$FIXTURE/boot/vmlinuz-new-kernel"
'''
class UpdateCliTests(unittest.TestCase):
def run_update(self,case,language='sk',suite='trixie'):
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
body=function('scripts/global/update-pbs.sh','update_pbs')
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+function('scripts/global/common-functions.sh','pmx_confirm_update')+body+'\nupdate_pbs\n'
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
return result,files
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
result,files=self.run_update('pve')
self.assertEqual(result.returncode,1,result.stderr)
self.assertEqual(files['calls'],'')
self.assertFalse(any(name.startswith('log/') for name in files))
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
result,files=self.run_update('disk')
self.assertEqual(result.returncode,1,result.stderr)
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
self.assertIn('500MB',result.stdout)
self.assertNotIn('apt',files['calls'])
result,files=self.run_update('failure')
self.assertEqual(result.returncode,1,result.stderr)
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
self.assertIn('/log/proxmox-update-',result.stdout)
self.assertNotIn('autoremove',files['calls'])
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
for suite in ('bookworm','trixie'):
for case in ('current','cancel','refresh','success'):
with self.subTest(suite=suite,case=case):
result,files=self.run_update(case,suite=suite)
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
if case=='success': self.assertIn('reštartujte server',result.stdout)
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
class DialogTests(unittest.TestCase):
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
for path,name,expected in cases:
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
PMX_ON_PBS=true
LOCAL_SCRIPTS='/fixture/scripts'
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
msg_warn() { printf '%s\n' "$*"; }
bash() { printf forbidden >> "$CAPTURE/actions"; }
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
whiptail() { dialog "$@"; }
'''+name+'\n'
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
dialog=(Path(folder)/'dialog').read_text()
for text in expected:self.assertIn(text,dialog)
self.assertFalse((Path(folder)/'actions').exists())
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
('Proxmox Backup Server Microcode','mikrokód procesora')]:
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
apt-config() { return 1; }
msg_warn() { printf '%s\n' "$*"; }
create_apt_hook
'''
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
self.assertEqual(result.returncode,0,result.stderr)
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
self.assertFalse((Path(folder)/'hook').exists())
@@ -0,0 +1,178 @@
"""Offline actual scheduler gates; never source the administrative script."""
import importlib.util
import json
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCHEDULER = ROOT / 'scripts/backup_restore/backup_scheduler.sh'
MESSAGES = {
'pbs': 'No matching PVE vzdump jobs were found for the PBS backend.',
'local': 'No matching PVE vzdump jobs were found for the local archive backend.',
}
HINT = 'Create one first in Datacenter → Backup, then return here to attach.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def consumers():
text = SCHEDULER.read_text()
attached = function(text, '_create_job_attached').split(' local pve_prune\n')[0] + '}\n'
# Only the backend/attach selection gate, not ID/config writes or scheduling.
new = function(text, '_create_job')
new = '_create_job() {\n local id=fixture backend on_calendar\n' + new[new.index(' backend=$(dialog'):new.index(' on_calendar=$(dialog')]
new += ' return 42\n}\n'
return attached + new
def fixture(directory, backend='pbs', consumer='attached', rows='', second=None,
locale='en', catalog=None, real_dialog=None):
directory = Path(directory)
(directory / 'xx.json').write_text(json.dumps(catalog or {}))
(directory / 'rows').write_text(rows)
(directory / 'second').write_text(rows if second is None else second)
bindir = directory / 'bin'
bindir.mkdir(exist_ok=True)
# Only jq is executable via PATH; wrapper validates all lookup arguments.
(bindir / 'jq').symlink_to('/usr/bin/jq') if not (bindir / 'jq').exists() else None
translation = function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
filter_code = function((ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text(), 'hb_pve_list_vzdump_jobs_for_backend')
preamble = f'''PATH={shlex.quote(str(bindir))}
LANG_DIR={shlex.quote(str(directory))}
LANGUAGE={shlex.quote(locale)}
FIXTURE={shlex.quote(str(directory))}
BACKEND={backend}
HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10
command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }}
jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // .[($text | gsub("\\\\\\\\n"; "\\n"))] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }}
head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }}
hb_pve_list_vzdump_jobs() {{
local file="$FIXTURE/rows" line
[[ -f "$FIXTURE/read" ]] && file="$FIXTURE/second"
printf read >> "$FIXTURE/read"
while IFS= read -r line; do printf '%s\\n' "$line"; done < "$file"
}}
dialog() {{
printf '%s\\0' "$@" >> "$FIXTURE/dialogs"
local title='' kind='' message='' previous='' arg
for arg in "$@"; do
[[ "$previous" == --title ]] && title="$arg"
[[ "$previous" == --msgbox || "$previous" == --menu ]] && message="$arg"
[[ "$arg" == --msgbox || "$arg" == --menu ]] && kind="$arg"
previous="$arg"
done
if [[ "$kind" == --msgbox ]]; then
printf '%s' "$message" > "$FIXTURE/message"
{'command ' + shlex.quote(real_dialog) + ' "$@"' if real_dialog else ':'}
return 0
fi
case "$title" in
"$(translate 'Backend')") printf '%s' "$BACKEND" >&2 ;;
"$(translate 'How to schedule')") printf attach >&2 ;;
"$(translate 'Pick PVE vzdump job')") return 1 ;;
*) printf 'unexpected dialog:%s' "$title" >> "$FIXTURE/errors"; return 99 ;;
esac
}}
'''
return preamble + translation + filter_code + consumers() + f'\nif _create_job{"_attached fixture " + backend if consumer == "attached" else ""}; then rc=0; else rc=$?; fi\nprintf "%s" "$rc" > "$FIXTURE/status"\n'
def run_case(**kwargs):
with tempfile.TemporaryDirectory(prefix='scheduler-') as directory:
code = fixture(directory, **kwargs)
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', code],
env={'PATH': '/nonexistent', 'LC_ALL': 'C.UTF-8'}, capture_output=True, text=True)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr))
p = Path(directory)
if (p / 'errors').exists():
raise AssertionError((p / 'errors').read_text())
return {name: (p / name).read_text() if (p / name).exists() else ''
for name in ('message', 'status', 'dialogs', 'read')}
class SchedulerMessages(unittest.TestCase):
def test_new_whole_message_and_separate_hint(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend, consumer='new')
self.assertEqual(result['message'], MESSAGES[backend] + '\n\n' + HINT)
self.assertEqual(result['status'], '1')
def test_lookup_cache_modes_at_both_consumers(self):
shipped = json.loads((ROOT / 'lang/it.json').read_text())
for backend, message in MESSAGES.items():
for consumer in ('attached', 'new'):
for locale, catalog, expected in (
('en', {message: 'ignored'}, message),
('missing', {}, message),
('xx', {'unrelated': 'unrelated'}, message),
('xx', shipped, shipped.get(message, message)),
('xx', {message: 'SYNTHETIC reordered backend: ' + backend},
'SYNTHETIC reordered backend: ' + backend),
):
with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected):
result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog)
suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else ''
self.assertEqual(result['message'], expected + suffix)
self.assertEqual(result['status'], '1')
def test_nonempty_menus_disabled_and_second_read_empty(self):
for backend, storage in (('pbs', 'pbs'), ('local', 'nfs')):
for count in (1, 3):
rows = ''.join(f'job{i}\tstore{i}\t{storage}\tdaily\t-\t0\n' for i in range(count))
for consumer in ('attached', 'new'):
with self.subTest(backend=backend, count=count, consumer=consumer):
result = run_case(backend=backend, consumer=consumer, rows=rows)
self.assertEqual(result['message'], '')
self.assertEqual(result['status'], '1') # cancel at actual parent picker
for i in range(count):
self.assertIn(f'job{i} · store{i} · daily (disabled)', result['dialogs'])
result = run_case(backend=backend, consumer='new', rows=rows, second='')
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['read'], 'readread')
self.assertEqual(result['status'], '1')
def test_actual_filter_types_and_enabled_states(self):
for backend in MESSAGES:
for storage in ('pbs', 'dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs', 'rbd', '-'):
for enabled in ('0', '1'):
with self.subTest(backend=backend, storage=storage, enabled=enabled):
result = run_case(backend=backend, rows=f'job\tstore\t{storage}\tdaily\t-\t{enabled}\n')
matches = storage == 'pbs' if backend == 'pbs' else storage in ('dir', 'nfs', 'cifs', 'zfspool', 'lvmthin', 'btrfs')
self.assertEqual(result['message'], '' if matches else MESSAGES[backend])
if matches:
self.assertEqual('(disabled)' in result['dialogs'], enabled == '0')
def test_borg_excludes_attach_gate(self):
result = run_case(backend='borg', consumer='new')
self.assertEqual(result['status'], '42') # stopped before real schedule/config work
self.assertEqual(result['message'], '')
self.assertEqual(result['read'], '')
self.assertNotIn('How to schedule', result['dialogs'])
def test_actual_offline_extractor_discovers_whole_messages(self):
spec = importlib.util.spec_from_file_location('scheduler_cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
discovered = module.extract_translate_texts(ROOT / 'scripts')
for message in MESSAGES.values():
self.assertIn(message, discovered)
def test_attached_whole_message(self):
for backend in MESSAGES:
with self.subTest(backend=backend):
result = run_case(backend=backend)
self.assertEqual(result['message'], MESSAGES[backend])
self.assertEqual(result['status'], '1')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,475 @@
"""Offline acquisition fixtures; never source or run the administrative script.
Requires Bash, jq, curl, wget, gzip and unzip. Only bounded native functions
are loaded; HTTP clients are confined to a loopback fixture server. No host
package manager, Proxmox command, installer or VM is executed.
"""
from contextlib import contextmanager
import gzip
import http.server
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
import warnings
import zipfile
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "scripts/vm/synology.sh"
RAW = b"tiny offline loader fixture\x00\x01\x02" * 8
PLAIN = "alpine-redpill.v1.4.5.6.m-shell.img.gz"
VARIANTS = [
"alpine-redpill.v1.4.5.6.m-shell-5GB.img.gz",
"alpine-redpill.v1.4.5.6.xtcrp.img.gz",
"alpine-redpill.v1.4.5.6.m-shell.vmdk.gz",
"alpine-redpill.v1.4.5.6.xtcrp-5GB.img.gz",
]
def native_function(name):
match = re.search(
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}",
SOURCE.read_text(), re.MULTILINE | re.DOTALL,
)
if not match:
raise AssertionError(f"Native function not found: {name}")
return match.group()
def asset(name):
return {"name": name, "browser_download_url": f"https://fixtures.invalid/{name}"}
def zip_bytes(member, payload=RAW, extras=()):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(member, payload)
for name, content in extras:
archive.writestr(name, content)
return buffer.getvalue()
@contextmanager
def fixture_server(metadata, payload, *, metadata_status=200, asset_status=200,
partial=False):
requests = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
requests.append(self.path)
is_metadata = self.path.startswith("/metadata/")
body = metadata if is_metadata else payload
status = metadata_status if is_metadata else asset_status
self.send_response(status)
self.send_header("Content-Length", str(len(body) + (
37 if partial and not is_metadata else 0)))
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def log_message(self, format, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever,
kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", requests
finally:
server.shutdown()
server.server_close()
thread.join()
class SynologyLoaderTests(unittest.TestCase):
def run_loader(self, loader="tinycore", *, names=None, metadata=None,
payload=None, pretty=True, before="", invoke="download_loader",
functions=(), **server_options):
if names is None:
names = [PLAIN, *VARIANTS]
if metadata is None:
metadata = json.dumps({"assets": [asset(name) for name in names]},
indent=2 if pretty else None).encode()
if payload is None:
payload = gzip.compress(RAW)
expected = {"arc": "arc.img", "redpill": "rr.img", "tinycore": "tinycore.img"}[loader]
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
images = base / "images with spaces"
images.mkdir()
previous = images / expected
previous.write_bytes(b"previous verified image")
bin_dir = base / "bin"
bin_dir.mkdir()
for command in ("bash", "jq", "curl", "wget", "gzip", "unzip"):
self.assertIsNotNone(shutil.which(command), f"Install test prerequisite: {command}")
with fixture_server(metadata, payload, **server_options) as (url, requests):
for command in ("curl", "wget"):
real = shutil.which(command)
client_options = ["--no-config", "--tries=1"] if command == "wget" else ["-q"]
wrapper = bin_dir / command
wrapper.write_text(
f"#!{sys.executable}\n"
"import os, sys\n"
"args = sys.argv[1:]\n"
"skip = False\n"
"for arg in args:\n"
" if skip:\n"
" skip = False\n"
" continue\n"
" if arg in ('-O', '--connect-timeout', '--max-time'):\n"
" skip = True\n"
" elif not arg.startswith('-') and not arg.startswith(('https://api.github.com/repos/', 'https://fixtures.invalid/')):\n"
" sys.exit('Forbidden non-fixture argument: ' + arg)\n"
"for i, arg in enumerate(args):\n"
" if arg.startswith('https://api.github.com/repos/'):\n"
f" args[i] = {url!r} + '/metadata/' + arg.split('/repos/', 1)[1]\n"
" elif arg.startswith('https://fixtures.invalid/'):\n"
f" args[i] = {url!r} + '/asset/' + arg.rsplit('/', 1)[1]\n"
" elif '://' in arg:\n"
" sys.exit('Forbidden non-fixture URL: ' + arg)\n"
"os.environ['no_proxy'] = os.environ['NO_PROXY'] = '127.0.0.1'\n"
f"os.execv({real!r}, [{real!r}, *{client_options!r}, *args])\n"
)
wrapper.chmod(0o755)
# Administrative boundaries are fail-closed even if a test regresses.
for command in ("apt-get", "qm", "pct", "pvesm", "pvesh"):
wrapper = bin_dir / command
wrapper.write_text("#!/bin/bash\nprintf 'FORBIDDEN %s\\n' \"$0\" >&2\nexit 97\n")
wrapper.chmod(0o755)
definitions = "\n".join(native_function(name) for name in
dict.fromkeys(("download_loader", *functions)))
script = r'''
translate() { printf '%s' "$1"; }
msg_info() { printf 'INFO:%s\n' "$*"; }
msg_ok() { printf 'OK:%s\n' "$*"; }
msg_error() { printf 'ERROR:%s\n' "$*"; }
msg_warn() { printf 'WARN:%s\n' "$*"; }
stop_spinner() { :; }
sleep() { :; }
select_loader() { printf 'UNEXPECTED_RECURSION\n'; return 91; }
'''
script += definitions + "\n" + before + "\n"
script += invoke + r'''
result=$?
printf 'RESULT:%s\nFILE:%s\nLOADER_FILE:%s\nLOADER_TYPE:%s\nLOADER_NAME:%s\n' "$result" "$FILE" "$LOADER_FILE" "$LOADER_TYPE" "$LOADER_NAME"
exit "$result"
'''
environment = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}",
IMAGES_DIR=str(images), LOADER_TYPE=loader,
LOADER_NAME="fixture loader", FILE="stale.img",
LOADER_FILE="/stale.img")
result = subprocess.run(["bash", "-c", script], env=environment,
cwd=base, capture_output=True, text=True, timeout=15)
state = dict(re.findall(r"^(RESULT|FILE|LOADER_FILE|LOADER_TYPE|LOADER_NAME):(.*)$", result.stdout, re.MULTILINE))
return result, state, previous.read_bytes(), list(requests), sorted(
str(path.relative_to(images)) for path in images.rglob("*"))
def test_plain_alpine_selection_ignores_competing_variants(self):
for pretty, names in ((True, [*VARIANTS, PLAIN]),
(False, [VARIANTS[2], PLAIN, *VARIANTS[:2], VARIANTS[3]])):
with self.subTest(pretty=pretty):
result, state, contents, requests, files = self.run_loader(names=names, pretty=pretty)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], "tinycore.img")
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
self.assertEqual([path for path in requests if path.startswith("/asset/")],
[f"/asset/{PLAIN}"])
self.assertEqual(files, ["tinycore.img"])
def assert_failed_acquisition(self, outcome, expected="tinycore.img"):
result, state, contents, requests, files = outcome
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["FILE"], "", result.stdout)
self.assertEqual(state["LOADER_FILE"], "", result.stdout)
self.assertEqual(contents, b"previous verified image")
self.assertNotIn("OK:", result.stdout)
self.assertNotIn("UNEXPECTED_RECURSION", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(files, [expected])
return requests
def test_zip_loaders_parse_minified_metadata(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
with self.subTest(loader=loader):
result, state, contents, requests, files = self.run_loader(
loader, names=["README.txt", name], pretty=False,
payload=zip_bytes(member))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], member)
self.assertTrue(state["LOADER_FILE"].endswith("/" + member))
self.assertEqual(files, [member])
self.assertEqual(requests[-1], "/asset/" + name)
def test_metadata_requires_one_typed_unambiguous_asset(self):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
cases = {
"none": {"assets": []},
"duplicate": {"assets": [asset(name), asset(name)]},
"two_versions": {"assets": [asset(name), asset(name.replace("v1.4.5.6", "v2.0.0") if loader == "tinycore" else "another.img.zip")]},
"null_name": {"assets": [{"name": None, "browser_download_url": asset(name)["browser_download_url"]}]},
"numeric_name": {"assets": [{"name": 7, "browser_download_url": asset(name)["browser_download_url"]}]},
"missing_name": {"assets": [{"browser_download_url": asset(name)["browser_download_url"]}]},
"null_url": {"assets": [{"name": name, "browser_download_url": None}]},
"numeric_url": {"assets": [{"name": name, "browser_download_url": 7}]},
"empty_url": {"assets": [{"name": name, "browser_download_url": ""}]},
"http_url": {"assets": [{"name": name, "browser_download_url": "http://bad/" + name}]},
"mismatched_url": {"assets": [{"name": name, "browser_download_url": "https://bad/other.img.gz"}]},
"whitespace_url": {"assets": [{"name": name, "browser_download_url": "https://bad/ has space/" + name}]},
"control_url": {"assets": [{"name": name, "browser_download_url": "https://fixtures.invalid/\u0000/" + name}]},
"wrong_assets_type": {"assets": {}},
"array_root": [asset(name)],
"error_object": {"message": "API rate limit exceeded"},
}
for case, document in cases.items():
with self.subTest(loader=loader, case=case):
requests = self.assert_failed_acquisition(self.run_loader(
loader, metadata=json.dumps(document).encode()), member)
self.assertFalse(any(path.startswith("/asset/") for path in requests))
def test_metadata_transport_or_json_failure_is_not_success(self):
for label, metadata, status in (
("http_failure_with_valid_body", json.dumps({"assets": [asset(PLAIN)]}).encode(), 503),
("html_error_body", b"<html>unavailable</html>", 200),
("truncated_json", b'{"assets":[', 200),
("multiple_documents", json.dumps({"assets": [asset(PLAIN)]}).encode() * 2, 200),
):
with self.subTest(case=label):
requests = self.assert_failed_acquisition(self.run_loader(
metadata=metadata, metadata_status=status))
self.assertFalse(any(path.startswith("/asset/") for path in requests))
def test_failed_empty_or_partial_download_preserves_previous_image(self):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
payload = gzip.compress(RAW) if loader == "tinycore" else zip_bytes(member)
for label, body, options in (
("http_error_with_archive_body", payload, {"asset_status": 503}),
("empty_download", b"", {}),
("partial_download", payload, {"partial": True}),
):
with self.subTest(loader=loader, case=label):
requests = self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=body, **options), member)
self.assertEqual(requests[-1], "/asset/" + name)
def test_invalid_or_empty_compressed_image_cannot_replace_cached_image(self):
valid_gzip = gzip.compress(RAW)
crc_gzip = valid_gzip[:-8] + bytes([valid_gzip[-8] ^ 1]) + valid_gzip[-7:]
for label, payload in (
("not_gzip", b"<html>not an image</html>"),
("truncated_gzip", valid_gzip[:-6]),
("gzip_crc_failure", crc_gzip),
("empty_raw_image", gzip.compress(b"")),
):
with self.subTest(case=label):
self.assert_failed_acquisition(self.run_loader(payload=payload))
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
stored = io.BytesIO()
with zipfile.ZipFile(stored, "w", zipfile.ZIP_STORED) as archive:
archive.writestr(member, RAW)
crc_zip = stored.getvalue().replace(RAW, b"?" + RAW[1:], 1)
valid_zip = zip_bytes(member)
for label, payload in (
("not_zip", b"<html>not an archive</html>"),
("truncated_zip", valid_zip[:-30]),
("zip_crc_failure", crc_zip),
("empty_raw_image", zip_bytes(member, b"")),
("missing_member", zip_bytes("other.img")),
("nested_member", zip_bytes("subdirectory/" + member)),
):
with self.subTest(loader=loader, case=label):
self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=payload), member)
def test_alpine_menu_and_selection_name_preserve_internal_contract(self):
result, state, contents, requests, files = self.run_loader(
functions=("select_loader",), invoke="select_loader",
before=r'''whiptail() { printf 'MENU:%s\n' "$*"; printf '3' >&2; }''')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Alpine RedPill Loader (PeterSuh-Q3 M-shell)", result.stderr)
self.assertIn("Selected Loader: Alpine RedPill M-shell", result.stdout)
self.assertNotIn("TinyCore RedPill", result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(state["FILE"], "tinycore.img")
self.assertIn("/metadata/PeterSuh-Q3/tinycore-redpill/releases/latest", requests)
def test_zip_expected_member_must_be_unique(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
duplicate = io.BytesIO()
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
with zipfile.ZipFile(duplicate, "w") as archive:
archive.writestr(member, RAW)
archive.writestr(member, b"competing image")
with self.subTest(loader=loader):
self.assert_failed_acquisition(self.run_loader(
loader, names=[name], payload=duplicate.getvalue()), member)
def run_wizard(self, loader, mode, *, main=False, **options):
functions = (
"select_loader", "select_disk_type", "_build_storage_plan_summary",
"default_settings", "advanced_settings", "start_script", "create_vm",
)
before = f"WIZARD_MODE={mode}\nLOADER_CHOICE={ {'arc': 1, 'redpill': 2, 'tinycore': 3}[loader] }\n"
# Only UI/storage-entry/capture/host boundaries are replaced. The actual
# loader -> disk plan -> settings -> start_script chain runs in Bash.
before += r'''
NEXTID=123
NAME='Synology VM'
GEN_MAC='02:00:00:00:00:01'
header_info() { :; }
start_vm_wizard_capture() { :; }
stop_vm_wizard_capture() { :; }
prompt_optional_gpu_passthrough() { printf 'GPU_BOUNDARY\n'; }
select_virtual_disk() { VIRTUAL_DISKS+=('local:32'); }
pct() { return 1; }
qm() {
[[ "$1" == status ]] && return 1
printf 'QM_BOUNDARY:%s\n' "$*"
return 97
}
whiptail() {
case "$*" in
*'--title SETTINGS --yesno'*) [[ "$WIZARD_MODE" == default ]]; return ;;
*'VIRTUAL MACHINE ID'*) printf '123' >&2 ;;
*'MACHINE TYPE'*) printf 'q35' >&2 ;;
*'BIOS TYPE'*) printf 'ovmf' >&2 ;;
*'HOSTNAME'*) printf 'Synology-DSM' >&2 ;;
*'CPU MODEL'*) printf '1' >&2 ;;
*'CORE COUNT'*) printf '2' >&2 ;;
*'--title RAM '*) printf '4096' >&2 ;;
*'--title BRIDGE '*) printf 'vmbr0' >&2 ;;
*'MAC ADDRESS'*) printf '02:00:00:00:00:01' >&2 ;;
*'STORAGE PLAN'*)
if [[ ${#VIRTUAL_DISKS[@]} -eq 0 ]]; then printf 'a' >&2; else printf 'd' >&2; fi ;;
*'SELECT LOADER'*) printf '%s' "$LOADER_CHOICE" >&2 ;;
esac
return 0
}
'''
invoke = "start_script"
if main:
match = re.search(r"^# Main execution\n# =+\n(.*)", SOURCE.read_text(),
re.MULTILINE | re.DOTALL)
if match is None:
self.fail("Native main gate not found")
invoke = match.group(1)
return self.run_loader(loader, functions=functions, before=before,
invoke=invoke, **options)
def test_native_settings_chain_propagates_loader_failure(self):
for mode in ("default", "advanced"):
for loader, name, member in (("tinycore", PLAIN, "tinycore.img"),
("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
for failure in ("metadata", "archive", "download"):
with self.subTest(mode=mode, loader=loader, failure=failure):
result, state, contents, requests, files = self.run_wizard(
loader, mode, names=[name],
metadata=b'{"assets":[]}' if failure == "metadata" else None,
payload=b"invalid compressed image",
asset_status=503 if failure == "download" else 200)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["FILE"], "")
self.assertEqual(state["LOADER_FILE"], "")
self.assertEqual(contents, b"previous verified image")
self.assertNotIn("Downloaded", result.stdout)
self.assertNotIn("QM_BOUNDARY", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(files, [member])
def test_native_main_gate_never_reaches_qm_create_after_loader_failure(self):
for mode in ("default", "advanced"):
for loader in ("tinycore", "arc", "redpill"):
with self.subTest(mode=mode, loader=loader):
result, state, contents, requests, files = self.run_wizard(
loader, mode, main=True, metadata=b'{"assets":[]}')
# The existing cancellation convention exits zero; failure
# must still stop the native main before GPU or VM creation.
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("VM creation cancelled before disk planning.", result.stdout)
self.assertNotIn("QM_BOUNDARY", result.stdout)
self.assertNotIn("GPU_BOUNDARY", result.stdout)
self.assertNotIn("FORBIDDEN", result.stderr)
self.assertEqual(contents, b"previous verified image")
def test_native_main_positive_control_reaches_only_stubbed_qm_boundary(self):
for mode in ("default", "advanced"):
with self.subTest(mode=mode):
result, state, contents, requests, files = self.run_wizard("tinycore", mode, main=True)
self.assertEqual(result.stdout.count("QM_BOUNDARY:create "), 1, result.stdout + result.stderr)
self.assertIn("GPU_BOUNDARY", result.stdout)
self.assertEqual(contents, RAW)
def test_custom_loader_selection_keeps_existing_path_without_network(self):
result, state, contents, requests, files = self.run_loader(
functions=("select_loader", "select_custom_image"), invoke="select_loader",
before=r'''
whiptail() {
if [[ "$*" == *'SELECT CUSTOM IMAGE'* ]]; then
printf '%s/tinycore.img' "$IMAGES_DIR" >&2
else
printf '4' >&2
fi
}
''')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(state["LOADER_TYPE"], "custom")
self.assertEqual(state["FILE"], "tinycore.img")
self.assertTrue(state["LOADER_FILE"].endswith("/tinycore.img"))
self.assertEqual(contents, b"previous verified image")
self.assertEqual(requests, [])
self.assertEqual(files, ["tinycore.img"])
def test_zip_extracts_only_expected_image_not_sibling_files(self):
for loader, name, member in (("arc", "arc-26.10.img.zip", "arc.img"),
("redpill", "rr-26.10.img.zip", "rr.img")):
with self.subTest(loader=loader):
result, state, contents, requests, files = self.run_loader(
loader, names=[name], payload=zip_bytes(member, extras=(
("sha256sum", b"fixture checksum text"),
("other.img", b"must not be published"))))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(files, [member])
def test_alpine_selection_is_not_pinned_to_current_release(self):
name = "alpine-redpill.v2.0.1.m-shell.img.gz"
result, state, contents, requests, files = self.run_loader(names=[name, *VARIANTS])
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(contents, RAW)
self.assertEqual(requests[-1], "/asset/" + name)
def test_variants_or_legacy_family_never_substitute_for_plain_alpine(self):
self.assert_failed_acquisition(self.run_loader(
names=[*VARIANTS, "tinycore-redpill.v1.0.0.m-shell.img.gz"]))
def test_staging_or_promotion_failure_keeps_previous_image(self):
for boundary in ("mktemp", "mv"):
with self.subTest(boundary=boundary):
self.assert_failed_acquisition(self.run_loader(
before=f"{boundary}() {{ return 1; }}"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,113 @@
"""Offline regressions for copyable commands and runtime format fields."""
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)
class TerminalCatalogsTests(unittest.TestCase):
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
fixtures = {
'menu': 'translate "Main launcher"',
'install_proxmenux.sh': 'translate "Stable installer"',
'install_proxmenux_beta.sh': 'translate "Beta installer"',
'scripts/check.sh': 'translate "Host shell script"',
'scripts/check.py': 'translate("Host Python script")',
'oci/src/check.py': 'N_("OCI Python script")',
'oci/remote/check.py': 'translate("Remote Python script")',
'oci/remote/check.sh': 'translate "Remote shell script"',
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name, content in fixtures.items():
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content, encoding='utf-8')
self.assertEqual(set(MODULE.active_sources(root)), {
'Main launcher', 'Stable installer', 'Beta installer',
'Host shell script', 'Host Python script', 'OCI Python script',
'Remote Python script', 'Remote shell script', 'Application tagline',
})
def test_runtime_format_field_cannot_be_translated(self):
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
with self.assertRaisesRegex(ValueError, 'placeholder'):
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
def test_copyable_command_and_environment_variable_are_literal(self):
examples = [
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message(source, target)
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
'Ejemplo: pct config 110 | grep unprivileged')
def test_dns_capitalization_does_not_relax_file_path_case(self):
MODULE.validate_message('Cannot reach download.proxmox.com.',
'Download.proxmox.com ist nicht erreichbar.')
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
def test_shipped_active_catalogs_keep_literal_contracts(self):
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout)
# New strings are reviewed in Slovak. Other locales keep the runtime
# English fallback until the translation bot supplies their values;
# still validate every translation already present in those locales.
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('missing=0', result.stdout)
def test_optional_completeness_still_rejects_invalid_present_translations(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'scripts').mkdir()
(root / 'lang').mkdir()
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
(root / 'lang/es.json').write_text('{}')
def check(languages, *flags):
return subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(root), '--languages', languages, *flags],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(check('es,sk').returncode, 0)
self.assertEqual(check('sk', '--require-complete').returncode, 0)
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
self.assertNotEqual(check('es,sk').returncode, 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,31 @@
"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so
a pasted text can be finished from a phone or a tablet."""
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
TERMINALS = {
'terminal-panel': 'sendSequence',
'script-terminal-modal': 'sendCommand',
'lxc-terminal-modal': 'sendKey',
}
class TerminalCtrlKeysTests(unittest.TestCase):
def test_each_terminal_sends_ctrl_d(self):
for name, send in TERMINALS.items():
source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8')
with self.subTest(terminal=name):
self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source)
self.assertIn('t("scriptTerminal.endOfInput")', source)
def test_the_label_exists_in_english_and_spanish(self):
for language in ('en', 'es'):
catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8'))
with self.subTest(language=language):
self.assertTrue(catalog['scriptTerminal'].get('endOfInput'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,42 @@
"""A script writes a line break as \\n inside the text it translates; the
language files store that text with a real line break."""
import json
from pathlib import Path
import subprocess
import tempfile
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}\n', start) + 3]
class TranslateLineBreaks(TestCase):
def translate(self, text, catalog):
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / 'xx.json').write_text(json.dumps(catalog))
script = (f'LANGUAGE=xx LANG_DIR={folder}\n' + function((ROOT / 'scripts/utils.sh').read_text(), 'translate')
+ 'translate "$1"\n')
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', script, 'bash', text],
capture_output=True, text=True, timeout=10)
self.assertEqual((result.returncode, result.stderr), (0, ''))
return result.stdout.rstrip('\n')
def test_a_text_stored_with_a_real_line_break_is_found(self):
catalog = {'Restore this backup?\nThe configuration is overwritten.': '¿Restaurar?\nSe sobrescribe.'}
self.assertEqual(self.translate('Restore this backup?\\nThe configuration is overwritten.', catalog),
'¿Restaurar?\nSe sobrescribe.')
def test_the_exact_text_is_preferred_and_a_missing_one_comes_back_unchanged(self):
catalog = {'one\\ntwo': 'LITERAL', 'one\ntwo': 'REAL', 'plain': 'simple'}
self.assertEqual(self.translate('one\\ntwo', catalog), 'LITERAL')
self.assertEqual(self.translate('plain', catalog), 'simple')
self.assertEqual(self.translate('not\\nthere', catalog), 'not\\nthere')
def test_the_shipped_texts_with_a_line_break_reach_the_spanish_file(self):
spanish = json.loads((ROOT / 'lang/es.json').read_text())
text = 'Are you sure you want to restore this backup?\\nCurrent configuration will be overwritten.'
self.assertEqual(self.translate(text, spanish), spanish[text.replace('\\n', '\n')])
@@ -0,0 +1,25 @@
"""A label made only of product and API names is right when it comes back
unchanged, so the translation bot keeps it instead of retrying and dropping it."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import build_translation_cache as cache # noqa: E402
class ProtectedTermsTests(unittest.TestCase):
def test_acceleration_profile_labels_are_kept_unchanged(self):
for label in ("Intel/AMD (Vulkan)", "AMD (ROCm)", "Intel (SYCL)", "NVIDIA (CUDA)",
"Intel/AMD (VA-API)", "Intel (OpenVINO)"):
with self.subTest(label=label):
self.assertTrue(cache.is_fully_protected(label))
self.assertFalse(cache.looks_untranslated(label, label))
def test_a_sentence_coming_back_unchanged_is_still_a_failure(self):
sentence = "The GPU is used through Vulkan when it is available"
self.assertTrue(cache.looks_untranslated(sentence, sentence))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,152 @@
"""Offline, bounded real Bash consumers: no administrative entrypoints are sourced."""
import importlib.util
import json
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = ROOT / 'scripts'
NETWORK = {
'share/iscsi_host.sh': 1,
'share/nfs_client.sh': 1,
'share/nfs_host.sh': 2,
'share/samba_client.sh': 1,
'share/samba_host.sh': 2,
}
MOUNT = 'Mounting existing filesystem ({filesystem})...'
RAID = 'If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.'
def function(text, name):
start = text.index(name + '() {')
return text[start:text.index('\n}', start) + 2] + '\n'
def network_blocks(path):
lines = path.read_text().splitlines()
blocks = []
for i, line in enumerate(lines):
if re.search(r'^\s*if (?:pct exec .* -- )?nc -z -w 2 ', line):
depth = len(line) - len(line.lstrip())
end = next(j for j in range(i + 1, len(lines))
if lines[j].strip() == 'fi' and len(lines[j]) - len(lines[j].lstrip()) == depth)
block = '\n'.join(lines[i:end + 1])
assert '$(translate "Open")' in block and '$(translate "Closed")' in block or '$(translate "Probe failed")' in block
blocks.append(block)
return blocks
def raid_dialog():
"""The dialog that reports a RAID member, wherever it sits in the script."""
lines = (SCRIPTS / 'storage/disk-passthrough_ct.sh').read_text().splitlines()
message = next(i for i, line in enumerate(lines) if RAID in line)
assert lines[message - 2].lstrip().startswith('dialog ')
return '\n'.join(lines[message - 2:message + 1])
def run(code, translations=None, statuses='1', variables='', dialog=False):
translations = translations or {}
with tempfile.TemporaryDirectory(prefix='tui-wording-') as directory:
root = Path(directory)
(root / 'xx.json').write_text(json.dumps(translations))
body = f'''LANGUAGE=xx LANG_DIR={root!s}
{function((SCRIPTS / 'utils.sh').read_text(), 'translate')}
GN='\\033[32m' RD='\\033[31m' CL='\\033[0m'
{variables}
statuses=({statuses})
nc() {{ local code=${{statuses[0]}}; statuses=("${{statuses[@]:1}}"); return "$code"; }}
pct() {{ [[ "$1" == exec && "$3" == -- && "$4" == nc ]] || return 98; shift 3; nc "$@"; }}
msg_info() {{ printf 'INFO:%s\\n' "$1"; }}
msg_error() {{ printf 'ERROR:%s\\n' "$1"; }}
msg_ok() {{ printf 'OK:%s\\n' "$1"; }}
msg_title() {{ :; }}
show_proxmenux_logo() {{ :; }}
pmx_journal_context() {{ :; }}
blkid() {{ [[ "$2" == TYPE ]] && printf 'ext4\\n' || printf 'UUID123\\n'; }}
mkdir() {{ :; }}
mount() {{ return 1; }}
dialog() {{ printf 'DIALOG:%s\\n' "$*"; }}
{code}
'''
result = subprocess.run(['/bin/bash', '--noprofile', '--norc', '-c', body],
env={'PATH': '/usr/bin:/bin', 'LC_ALL': 'C.UTF-8'},
capture_output=True, text=True, timeout=10)
if result.returncode or result.stderr:
raise AssertionError((result.returncode, result.stderr, result.stdout))
return result.stdout
class TuiNetworkStorageWording(unittest.TestCase):
def test_failed_tcp_probe_is_not_declared_closed_at_every_caller(self):
for path, count in NETWORK.items():
blocks = network_blocks(SCRIPTS / path)
self.assertEqual(len(blocks), count, path)
for index, block in enumerate(blocks):
cases = [('0 1', 'Open'), ('1 1', 'Probe failed'), ('124 124', 'Probe failed')]
if 'samba_' in path:
cases.append(('1 0', 'Open'))
for codes, expected in cases:
with self.subTest(path=path, index=index, codes=codes):
output = run(block, statuses=codes, translations={'Probe failed': 'SONDAGGIO FALLITO'})
self.assertIn('SONDAGGIO FALLITO' if expected == 'Probe failed' else 'Open', output)
self.assertNotIn('Closed', output)
if expected == 'Open':
self.assertNotIn('SONDAGGIO FALLITO', output)
else:
fallback = run(block, statuses=codes)
self.assertIn('Probe failed', fallback)
self.assertNotIn('Closed', fallback)
def test_mount_progress_is_whole_and_uses_real_lookup_with_fallback(self):
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
portion = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\n'
for translation, expected in [({}, MOUNT.replace('{filesystem}', 'ext4')),
({MOUNT: 'SYNTHETIC {filesystem} in corso'}, 'SYNTHETIC ext4 in corso'),
({MOUNT: 'SYNTHETIC malformed'}, MOUNT.replace('{filesystem}', 'ext4'))]:
with self.subTest(translation=translation):
output = run(portion + '\nmount_existing_disk /dev/fixture /mnt/fixture', translations=translation)
self.assertIn('INFO:' + expected, output)
self.assertNotIn('{filesystem}', output)
def test_raid_warning_is_complete_message_with_disk_identifier_and_fallback(self):
invocation = raid_dialog() # the dialog invocation, not the raid detection command
for translation, expected in [({}, RAID), ({RAID: 'SYNTHETIC RAID metadata remains'}, 'SYNTHETIC RAID metadata remains')]:
with self.subTest(translation=translation):
output = run(invocation, translations=translation,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture 2TiB' UI_RESULT_H=16 UI_RESULT_W=80")
self.assertIn(expected, output)
self.assertIn('/dev/fixture 2TiB', output)
self.assertNotIn('remove the RAID metadata or format it manually using external tools. RAID metadata', output)
def test_shipped_catalogs_use_english_fallback_for_new_keys(self):
catalogs = sorted((ROOT / 'lang').glob('*.json'))
self.assertTrue(catalogs)
block = network_blocks(SCRIPTS / 'share/iscsi_host.sh')[0]
source = (SCRIPTS / 'share/disk_host.sh').read_text()
start = source.index('mount_existing_disk() {')
mount = source[start:source.index(' if ! mount "$disk"', start)] + ' return 0\n}\nmount_existing_disk /dev/fixture /mnt/fixture'
raid = raid_dialog()
for path in catalogs:
with self.subTest(locale=path.name):
catalog = json.loads(path.read_text())
self.assertIn(catalog.get('Probe failed') or 'Probe failed', run(block, statuses='1', translations=catalog))
self.assertIn((catalog.get(MOUNT) or MOUNT).replace('{filesystem}', 'ext4'), run(mount, translations=catalog))
self.assertIn(catalog.get(RAID) or RAID, run(raid, translations=catalog,
variables="BACKTITLE=fixture DISK_INFO='/dev/fixture' UI_RESULT_H=16 UI_RESULT_W=80"))
def test_extractor_discovers_whole_keys(self):
spec = importlib.util.spec_from_file_location('cache_builder', ROOT / '.github/scripts/build_translation_cache.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
keys = module.extract_translate_texts(SCRIPTS)
for key in (MOUNT, RAID, 'Probe failed'):
self.assertTrue(key in keys, key)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,23 @@
"""Run the inert Node TUI consumer fixture in the existing offline i18n lane."""
import os
from pathlib import Path
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[3]
class TuiUpgradeGpuWording(unittest.TestCase):
def test_source_extracted_node_consumer(self):
fixture = ROOT / 'tests/test_tui_upgrade_gpu_wording.cjs'
self.assertTrue(fixture.is_file(), 'Node fixture must be packaged with the candidate')
node = os.environ.get('NODE_BINARY') or 'node'
result = subprocess.run([node, str(fixture)], cwd=ROOT, capture_output=True,
text=True, timeout=60, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'})
self.assertEqual(result.returncode, 0, result.stdout + '\n' + result.stderr)
self.assertIn('PASS: extracted upgrade/GPU consumers', result.stdout)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,72 @@
"""The VM/CT backup jobs feature is protected, shipped and translated."""
import json
from pathlib import Path
import re
from unittest import TestCase
ROOT = Path(__file__).resolve().parents[3]
ROUTES = ROOT / 'AppImage/scripts/flask_vm_backup_routes.py'
SCRIPT = ROOT / 'scripts/backup_restore/vm_backup_jobs.sh'
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
class VmBackupJobsWiring(TestCase):
def test_every_route_is_protected_and_every_change_needs_an_administrator(self):
source = ROUTES.read_text()
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
self.assertEqual(len(routes), 10)
for path, method, guard, name in routes:
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
self.assertEqual(guard, expected, f'{method} {path} ({name})')
def test_proxmox_is_called_without_a_shell(self):
source = ROUTES.read_text()
self.assertNotIn('shell=True', source)
self.assertNotIn('os.system', source)
# pvesh, and the transient unit a backup started by hand runs in.
programs = re.findall(r'subprocess\.run\(\s*\[\s*"([a-z-]+)"', source)
self.assertEqual(len(programs), source.count('subprocess.run('))
self.assertEqual(set(programs), {'pvesh', 'systemd-run', 'systemctl'})
self.assertIn('subprocess.run(["pvesh", *args]', source)
def test_the_monitor_ships_and_registers_it(self):
self.assertIn('flask_vm_backup_routes.py', (ROOT / 'AppImage/scripts/build_appimage.sh').read_text())
server = (ROOT / 'AppImage/scripts/flask_server.py').read_text()
self.assertIn('from flask_vm_backup_routes import vm_backup_bp', server)
self.assertIn('app.register_blueprint(vm_backup_bp)', server)
self.assertIn('<BackupTab ', (ROOT / 'AppImage/components/proxmox-dashboard.tsx').read_text())
tab = (ROOT / 'AppImage/components/backup-tab.tsx').read_text()
self.assertIn('<HostBackup />', tab)
self.assertIn('<VmBackupJobs />', tab)
# The backups of the host and those of the guests are separate views.
self.assertNotIn('VmBackupJobs', (ROOT / 'AppImage/components/host-backup.tsx').read_text())
def test_every_text_of_the_card_exists_in_the_eight_languages(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
used = set(re.findall(r'backup\.vmJobs\.([A-Za-z]+)', component))
english = json.loads((ROOT / 'AppImage/messages/en/common.json').read_text())['backup']['vmJobs']
self.assertEqual(used - set(english), set())
fields = lambda text: sorted(re.findall(r'\{(\w+)\}', text))
for locale in LOCALES:
texts = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['vmJobs']
self.assertEqual(set(texts), set(english), locale)
self.assertEqual(set(texts['presets']), set(english['presets']), locale)
switch = json.loads((ROOT / f'AppImage/messages/{locale}/common.json').read_text())['backup']['viewSwitch']
self.assertEqual(set(switch), {'ariaLabel', 'host', 'guests'}, locale)
for key, value in english.items():
if isinstance(value, str):
self.assertTrue(texts[key].strip(), f'{locale} {key}')
self.assertEqual(fields(texts[key]), fields(value), f'{locale} {key}')
def test_the_edit_dialog_follows_the_edit_mode_contrast_rule(self):
component = (ROOT / 'AppImage/components/vm-backup-jobs.tsx').read_text()
self.assertIn('bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background', component)
def test_the_menu_runs_a_job_as_a_proxmox_task_and_installs_nothing(self):
source = SCRIPT.read_text()
self.assertNotIn('apt-get', source)
self.assertIn('pvesh create "/nodes/$node/vzdump"', source)
self.assertEqual(re.findall(r'^\s*vzdump ', source, re.MULTILINE), [])
menu = (ROOT / 'scripts/backup_restore/backup_host.sh').read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/backup_restore/vm_backup_jobs.sh"', menu)
@@ -0,0 +1,275 @@
"""The VM wizard must not accept failed ISO downloads as installation media.
Run its function logic against a loopback HTTP fixture. Redirect the fixed
native ISO directory only in the loaded test copy and stub Proxmox/UI services.
No product script is sourced, no guest operation runs, and no public network
or real ISO storage is accessed.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[3]
CREATOR = ROOT / "scripts/vm/vm_creator.sh"
PAYLOAD = b"fixture installation media\x00\x01\n"
REAL_WGET = shutil.which("wget")
class MediaHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/missing.iso":
self.send_response(404)
self.end_headers()
return
if self.path == "/partial.iso":
self.send_response(200)
self.send_header("Content-Length", "1024")
self.end_headers()
self.wfile.write(b"partial")
self.close_connection = True
return
body = b"" if self.path == "/empty.iso" else PAYLOAD
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("Content-Disposition", 'attachment; filename="remote-name.iso"')
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
class VmIsoDownloadTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if REAL_WGET is None:
raise RuntimeError("These offline tests require wget, as the VM wizard does")
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MediaHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.http = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="vm-iso-download-")
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.iso = self.directory / "installation media.iso"
self.qm_log = self.directory / "qm.log"
self.wget_log = self.directory / "wget.log"
self.bin = self.directory / "bin"
self.bin.mkdir()
# Route only the optional fixed VirtIO/SourceForge endpoints to our
# fixture. Wget still performs the actual HTTP transfer and file I/O.
assert REAL_WGET is not None
wrapper = self.bin / "wget"
wrapper.write_text(
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >> "$WGET_LOG"\n'
'args=("$@")\n'
'for i in "${!args[@]}"; do\n'
' case "${args[$i]}" in\n'
' https://fedorapeople.org/*) args[$i]="$VIRTIO_FIXTURE_URL";;\n'
' https://sourceforge.net/*) args[$i]="$HTTP_FIXTURE/success.iso";;\n'
' esac\n'
' if [[ "${args[$i]}" == http://* || "${args[$i]}" == https://* ]]; then\n'
' [[ "${args[$i]}" == "$HTTP_FIXTURE"/* ]] || exit 98\n'
' fi\n'
'done\n'
'for i in "${!args[@]}"; do\n'
' if [[ "${args[$i]}" == -O ]]; then\n'
' case "${args[$((i+1))]}" in "$FIXTURE_DIR"/*) ;; *) exit 97;; esac\n'
' fi\n'
'done\n'
f"exec {shlex.quote(REAL_WGET)} --no-config --no-proxy --tries=1 --timeout=1 "
'"${args[@]}"\n'
)
wrapper.chmod(0o755)
def run_wizard(self, route="/success.iso", windows=False, overwrite=True, url=None,
caller_iso_dir=None):
source = CREATOR.read_text()
functions = "\n\n".join(re.findall(r"^function \w+\(\) \{\n.*?^\}", source, re.M | re.S))
self.assertIn("function create_vm()", functions)
# Only relocate this external filesystem anchor. Keep all predicates,
# transfers and promotion logic unchanged, without a production hook.
self.assertIn("/var/lib/vz/template/iso", functions)
functions = functions.replace("/var/lib/vz/template/iso", "${FIXTURE_DIR}")
prelude = r'''
translate() { printf '%s' "$1"; }
msg_ok() { printf 'OK:%s\n' "$*"; }
msg_error() { printf 'ERROR:%s\n' "$*"; }
msg_warn() { printf 'WARN:%s\n' "$*"; }
msg_info() { :; }
msg_success() { :; }
qm() { printf '%q ' "$@" >> "$QM_LOG"; printf '\n' >> "$QM_LOG"; }
pvesm() { printf 'Name Type Status\nfixture dir active\n'; }
select_storage_target() { printf fixture; }
# No next menu, start, or guest-facing process is launched.
bash() { :; }
whiptail() {
if [[ " $* " == *' --yesno '* ]]; then
return "$OVERWRITE_STATUS"
fi
if [[ ! -f "$DIALOG_ONCE" ]]; then
: > "$DIALOG_ONCE"
printf '1' >&2
return 0
fi
return 1
}
IMPORT_DISKS=(); PASSTHROUGH_DISKS=(); VIRTUAL_DISKS=(); CONTROLLER_NVME_PCIS=()
VMID=99999; HN=fixture; CORE_COUNT=1; RAM_SIZE=512; BRG=fixture
MAC='02:00:00:00:00:01'; VLAN=''; MTU=''; BIOS_TYPE=''; CPU_TYPE=''
MACHINE=''; SERIAL_PORT=''; START_VM=no; WIZARD_ADD_GPU=no
TAB=''; GN=''; CL=''
create_vm
'''
env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
ISO_PATH=str(self.iso), ISO_URL=url if url is not None else self.http + route,
ISO_VOLID="local:iso/installation media.iso",
OS_TYPE="2" if windows else "3",
QM_LOG=str(self.qm_log), WGET_LOG=str(self.wget_log),
DIALOG_ONCE=str(self.directory / "dialog-once"), FIXTURE_DIR=str(self.directory),
OVERWRITE_STATUS="0" if overwrite else "1", HTTP_FIXTURE=self.http,
VIRTIO_FIXTURE_URL=self.http + route)
env.pop("ISO_DIR", None)
if caller_iso_dir is not None:
env["ISO_DIR"] = str(caller_iso_dir)
return subprocess.run(["bash", "--noprofile", "--norc", "-c", functions + prelude],
env=env, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=15)
def qm_calls(self):
return self.qm_log.read_text() if self.qm_log.exists() else ""
def test_http_failure_stops_before_creating_a_guest(self):
result = self.run_wizard("/missing.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertNotIn("OK:ISO image downloaded", result.stdout)
self.assertIn("ERROR:Failed to download ISO image", result.stdout)
def test_partial_transfer_is_not_published_as_cached_media(self):
result = self.run_wizard("/partial.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertFalse(self.iso.exists(), "A failed transfer must not become reusable media")
def test_an_empty_cached_download_is_retried(self):
self.iso.write_bytes(b"")
result = self.run_wizard()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
self.assertIn("create ", self.qm_calls())
def test_virtio_download_keeps_native_storage_despite_caller_environment(self):
self.iso.write_bytes(PAYLOAD)
unrelated = self.directory / "unrelated directory"
unrelated.mkdir()
result = self.run_wizard(windows=True, caller_iso_dir=unrelated)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
driver = self.directory / "virtio-win.iso"
self.assertTrue(driver.is_file(), result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), PAYLOAD)
self.assertIn("-ide3 ", self.qm_calls())
self.assertEqual(list(unrelated.iterdir()), [])
def test_failed_virtio_fetch_is_not_selected_for_attachment(self):
self.iso.write_bytes(PAYLOAD)
result = self.run_wizard("/missing.iso", windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
self.assertFalse((self.directory / "virtio-win.iso").exists())
def test_successful_virtio_replacement_preserves_existing_permissions(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
driver.chmod(0o640)
result = self.run_wizard(windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), PAYLOAD)
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
def test_empty_http_success_is_rejected(self):
result = self.run_wizard("/empty.iso")
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertFalse(self.iso.exists())
self.assertEqual({p.name for p in self.directory.iterdir()}, {"bin", "wget.log"})
def test_existing_nonempty_media_is_reused_without_fetching(self):
self.iso.write_bytes(b"existing installation media")
result = self.run_wizard("/missing.iso")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), b"existing installation media")
self.assertFalse(self.wget_log.exists())
self.assertIn("-ide2 ", self.qm_calls())
def test_sourceforge_download_keeps_the_selected_destination_name(self):
result = self.run_wizard(url="https://sourceforge.net/projects/fixture/iso/download")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.iso.read_bytes(), PAYLOAD)
self.assertFalse((self.directory / "remote-name.iso").exists())
self.assertEqual({p.name for p in self.directory.iterdir()},
{"bin", "wget.log", "qm.log", self.iso.name})
def test_failed_virtio_replacement_preserves_previous_media(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
driver.chmod(0o640)
result = self.run_wizard("/partial.iso", windows=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), b"previous driver")
self.assertEqual(driver.stat().st_mode & 0o777, 0o640)
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout)
def test_declining_virtio_replacement_reuses_previous_media(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"previous driver")
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(driver.read_bytes(), b"previous driver")
self.assertFalse(self.wget_log.exists())
self.assertIn("-ide3 ", self.qm_calls())
def test_empty_cached_virtio_media_is_retried_not_attached(self):
self.iso.write_bytes(PAYLOAD)
driver = self.directory / "virtio-win.iso"
driver.write_bytes(b"")
result = self.run_wizard("/missing.iso", windows=True, overwrite=False)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue(self.wget_log.exists())
self.assertNotIn("-ide3 ", self.qm_calls())
self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout)
def test_a_directory_cannot_be_published_as_media(self):
self.iso.mkdir()
result = self.run_wizard()
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.qm_calls(), "")
self.assertTrue(self.iso.is_dir())
self.assertEqual(list(self.iso.iterdir()), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,217 @@
"""Offline VM source regressions; execute bounded real functions, never selectors.
Fixtures are reduced official metadata excerpts captured 2026-10-16. Network
commands are shell stubs: no ISO downloads, utils sourcing, or host changes.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
LINUX = ROOT / "scripts/vm/select_linux_iso.sh"
NAS = ROOT / "scripts/vm/select_nas_iso.sh"
FIELDS = ("ISO_NAME", "ISO_TYPE", "ISO_URL", "ISO_FILE", "ISO_PATH", "HN")
def extract_function(path, name):
"""Only top-level function definitions with an unindented closing brace."""
match = re.search(
rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}(?=\n|$)",
path.read_text(), re.MULTILINE | re.DOTALL,
)
if match is None:
raise AssertionError(f"Missing bounded function: {name}")
return match.group(0)
class IsoSourceTests(unittest.TestCase):
def run_selector(self, path, function, *, menu_name="", metadata="",
metadata_url="", fetch_status=0, has_curl=True):
with tempfile.TemporaryDirectory(prefix="vm-iso-sources-") as directory:
temp = Path(directory)
fixture = temp / "metadata.html"
fixture.write_text(metadata)
trace = temp / "fetches.txt"
trace.touch()
iso_dir = temp / "iso"
iso_dir.mkdir()
env = os.environ.copy()
env.update(ISO_DIR=str(iso_dir), MENU_NAME=menu_name,
METADATA_FILE=str(fixture), METADATA_URL=metadata_url,
FETCH_STATUS=str(fetch_status), FETCH_TRACE=str(trace))
helpers = "\n".join(
extract_function(NAS, name)
for name in ("_has_curl", "_latest_version_from_lines")
)
script = helpers + r'''
translate() { printf '%s' "$1"; }
curl() {
local url="${@: -1}"
printf '%s\n' "$url" >> "$FETCH_TRACE"
[[ "$url" == "$METADATA_URL" ]] || return 97
[[ "$FETCH_STATUS" == 0 ]] || return "$FETCH_STATUS"
cat "$METADATA_FILE"
}
wget() { return 97; }
dialog() {
local arg previous="" name
for arg in "$@"; do
name="${arg%%│*}"
name="${name%"${name##*[![:space:]]}"}"
if [[ "$name" == "$MENU_NAME" ]]; then
printf '%s' "$previous" >&2
return 0
fi
previous="$arg"
done
return 1
}
'''
if not has_curl:
script += "\n_has_curl() { return 1; }\n"
script += extract_function(path, function)
script += f"\n{function} || exit $?\n"
script += 'printf \'%s\\0\' ' + " ".join(
f'"${{{field}:-}}"' for field in FIELDS
)
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c", script],
env=env, capture_output=True, timeout=10,
)
self.assertEqual(result.returncode, 0, result.stderr.decode())
values = result.stdout.decode().split("\0")
self.assertEqual(len(values), len(FIELDS) + 1, result.stdout)
selected = dict(zip(FIELDS, values))
self.assertEqual(selected["ISO_PATH"], str(iso_dir / selected["ISO_FILE"]))
return selected, trace.read_text().splitlines()
def test_debian13_dvd_uses_fixed_official_archive(self):
selected, calls = self.run_selector(
LINUX, "select_linux_iso_official", menu_name="Debian 13",
)
self.assertEqual(selected["ISO_URL"],
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/"
"debian-13.6.0-amd64-DVD-1.iso")
self.assertEqual(selected["ISO_TYPE"], "Desktop")
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-DVD-1.iso")
self.assertEqual(calls, [])
def test_debian13_netinst_uses_fixed_official_archive(self):
selected, calls = self.run_selector(
LINUX, "select_linux_iso_official", menu_name="Debian 13 Netinst",
)
self.assertEqual(selected["ISO_URL"],
"https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/"
"debian-13.6.0-amd64-netinst.iso")
self.assertEqual(selected["ISO_TYPE"], "CLI")
self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-netinst.iso")
self.assertEqual(calls, [])
def test_omv_metadata_unavailable_uses_verified_fallback(self):
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
for has_curl, fetch_status, metadata in (
(False, 0, ""), (True, 22, ""),
(True, 0, "<html>No release links</html>"),
):
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, has_curl=has_curl,
fetch_status=fetch_status, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
self.assertEqual(selected["ISO_NAME"], "OpenMediaVault 8.3.1")
self.assertEqual(selected["ISO_FILE"], "openmediavault_8.3.1-amd64.iso")
self.assertEqual(calls, [url] if has_curl else [])
def test_rockstor_resolves_official_landing_without_directory_listing(self):
# Exact installer anchor excerpt from https://rockstor.com/dls/index.html.
url = "https://rockstor.com/dls/index.html"
metadata = (
'<a href="https://rockstor.com/downloads/installer/leap/15.6/x86_64/'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso">'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso</a> <br/>'
)
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["ISO_FILE"],
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["HN"], "Rockstor")
self.assertEqual(calls, [url])
def test_rockstor_metadata_unavailable_uses_verified_fallback(self):
url = "https://rockstor.com/dls/index.html"
for has_curl, fetch_status, metadata in (
(False, 0, ""), (True, 22, ""),
(True, 0, "<html>No installer links</html>"),
):
with self.subTest(has_curl=has_curl, fetch_status=fetch_status):
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, has_curl=has_curl,
fetch_status=fetch_status, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(selected["ISO_NAME"], "Rockstor")
self.assertEqual(selected["ISO_FILE"],
"Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso")
self.assertEqual(calls, [url] if has_curl else [])
def test_omv_current_metadata_resolution_is_preserved(self):
# Reduced release links from the official SourceForge ISO index.
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
metadata = (
'<a href="/projects/openmediavault/files/iso/8.3.1/" '
'title="Click to enter 8.3.1">8.3.1</a>\n'
'<a href="/projects/openmediavault/files/iso/7.4.17/" '
'title="Click to enter 7.4.17">7.4.17</a>'
)
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.3.1/openmediavault_8.3.1-amd64.iso/download")
self.assertEqual(calls, [url])
def test_omv_detected_release_takes_precedence_over_fallback(self):
url = "https://sourceforge.net/projects/openmediavault/files/iso/"
# Synthetic ordering case: must execute the real version parser.
metadata = ('<a href="/projects/openmediavault/files/iso/8.10.0/">new</a>'
'<a href="/projects/openmediavault/files/iso/8.9.0/">old</a>')
selected, calls = self.run_selector(
NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
url + "8.10.0/openmediavault_8.10.0-amd64.iso/download")
self.assertEqual(calls, [url])
def test_rockstor_retains_generic_x86_64_leap156_edition(self):
url = "https://rockstor.com/dls/index.html"
# Synthetic ordering / edition cases; not claims of published images.
metadata = "\n".join((
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso",
"Rockstor-Leap15.6-generic.x86_64-5.9.0-0.install.iso",
"Rockstor-Leap15.6-generic.aarch64-9.0.0-0.install.iso",
"Rockstor-Leap16.0-generic.x86_64-9.0.0-0.install.iso",
"Rockstor-Leap15.5-generic.x86_64-9.0.0-0.install.iso",
))
selected, calls = self.run_selector(
NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata,
)
self.assertEqual(selected["ISO_URL"],
"https://rockstor.com/downloads/installer/leap/15.6/x86_64/"
"Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso")
self.assertEqual(calls, [url])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,94 @@
"""Validate complete catalogs without rejecting intentional English fallback."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
class WebDocsCatalogTests(unittest.TestCase):
def run_check(self, root, *options):
return subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
"--languages", "de", *options],
capture_output=True, text=True,
)
def write_catalog(self, root, locale, content):
file = root / locale / "common.json"
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(json.dumps(content), encoding="utf-8")
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
partial = self.run_check(root)
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
self.assertIn("missing=1", partial.stdout)
complete = self.run_check(root, "--require-complete")
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
self.assertIn("schema differs", complete.stdout)
def test_translated_code_is_rejected_in_both_modes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
for options in ((), ("--require-complete",)):
with self.subTest(options=options):
result = self.run_check(root, *options)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_bare_commands_and_operators_are_not_prose(self):
examples = [
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for source, target in examples:
with self.subTest(source=source):
self.write_catalog(root, "en", {"body": source})
self.write_catalog(root, "de", {"body": target})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("literal command or identifier", result.stdout)
def test_title_separator_can_change_without_relaxing_body_pipes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
result = self.run_check(root, "--require-complete")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_shipped_web_catalogs_keep_literal_contracts(self):
messages = SCRIPT.parents[2] / "web" / "messages"
result = subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
invalid = self.run_check(root, "--require-complete")
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
self.assertIn("rendering metadata differ", invalid.stdout)
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
valid = self.run_check(root, "--require-complete")
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
@@ -0,0 +1,41 @@
"""Exercise the static-export language repair on real HTML files."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_command_descriptions import require_node
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
class WebExportLanguageTests(unittest.TestCase):
def test_locales_nested_pages_preserved_content_and_idempotence(self):
node = require_node()
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
for locale in locales:
file = root / "out" / locale / "docs/monitor/index.html"
file.parent.mkdir(parents=True)
file.write_text(html)
for relative in ["out/index.html", "out/assets/index.html"]:
file = root / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(html)
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("8 localized pages", result.stdout)
for locale in locales:
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
for relative in ["out/index.html", "out/assets/index.html"]:
self.assertEqual((root / relative).read_text(), html)
tracked = root / "out/de/docs/monitor/index.html"
before = tracked.stat().st_mtime_ns
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual(tracked.stat().st_mtime_ns, before)
@@ -0,0 +1,168 @@
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
DETAILS = {
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
}
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
def function(path, name):
text = path.read_text()
start = text.index(name + "() {")
return text[start:text.index("\n}\n", start) + 3]
HARNESS = r'''
set -u
BASE_DIR="$FIXTURE/state"
LANG_DIR="$REPO/lang"
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
MARKER="$BASE_DIR/zram-swap.state"
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
: > "$FIXTURE/calls"
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
pmx_journal_context() { :; }
msg_error() { printf '%s\n' "$*"; }
msg_success() { printf '%s\n' "$*"; }
register_tool() { record register "$@"; }
pmx_install_pkg() { record package-install; return 1; }
apt-get() { record apt "$@"; return 1; }
dpkg-query() {
record dpkg-query
case "$*" in
*'${Status}'*) printf 'install ok installed\n';;
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
esac
}
dpkg() { return 0; }
install() { record directory; [[ "$CASE" != directory ]]; }
pmx_write_file() {
record write "$1"
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
cat >/dev/null
return 1
fi
cat > "$1"
}
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
pmx_record_uninstall() { record record-uninstall; }
swapoff() { record swapoff; return 0; }
systemctl() {
record systemctl "$@"
[[ "$CASE" != start || "$1" != start ]] &&
[[ "$CASE" != stop || "$1" != stop ]]
}
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
printf 'installed_by_proxmenux=false\n' > "$MARKER"
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
fi
if [[ "$CASE" == foreign ]]; then
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
fi
if [[ "$CASE" == unreadable ]]; then
zram_foreign_generator_config() { return 2; }
fi
'''
class ZramLocalizationTests(unittest.TestCase):
def test_missing_optional_translation_uses_original_english(self):
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
(Path(folder) / 'xx.json').write_text('{}')
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
text=True, capture_output=True, timeout=15,
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
def run_case(self, case, language="sk"):
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
directory = Path(folder)
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
calls = (directory / "calls").read_text().splitlines()
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
return result, calls, files
def test_all_activation_details_are_localized_and_english_is_preserved(self):
for case, (english, slovak) in DETAILS.items():
for language, expected in (("sk", slovak), ("en", english)):
with self.subTest(case=case, language=language):
result, calls, files = self.run_case(case, language)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(expected, result.stdout)
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
if case in ("version", "mismatch"):
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
elif case == "stop":
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
else:
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
result, calls, files = self.run_case("foreign", language)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn(expected, result.stdout)
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
self.assertEqual(calls, [])
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
result, calls, files = self.run_case("unreadable")
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
self.assertEqual(calls, [])
self.assertNotIn("state/zram-swap.state", files)
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
tools = Path(folder) / "tools.json"
tools.write_text('{"zram_swap":true}')
script = function(ROOT / "scripts/utils.sh", "translate")
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
self.assertNotIn("forbidden", result.stdout + result.stderr)
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
+25 -1
View File
@@ -32,7 +32,11 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
sudo apt-get update # Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable - name: Make build script executable
@@ -42,6 +46,26 @@ jobs:
- name: Build AppImage - name: Build AppImage
working-directory: AppImage working-directory: AppImage
run: ./scripts/build_appimage.sh run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json - name: Get version from package.json
id: version id: version
+25 -1
View File
@@ -32,7 +32,11 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
sudo apt-get update # Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable - name: Make build script executable
@@ -42,6 +46,26 @@ jobs:
- name: Build AppImage - name: Build AppImage
working-directory: AppImage working-directory: AppImage
run: ./scripts/build_appimage.sh run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json - name: Get version from package.json
id: version id: version
+25 -1
View File
@@ -30,7 +30,11 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
sudo apt-get update # Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable - name: Make build script executable
@@ -40,6 +44,26 @@ jobs:
- name: Build AppImage - name: Build AppImage
working-directory: AppImage working-directory: AppImage
run: ./scripts/build_appimage.sh run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json - name: Get version from package.json
id: version id: version
+25 -1
View File
@@ -32,7 +32,11 @@ jobs:
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
sudo apt-get update # Third-party apt repos on the runner (Google Chrome, Microsoft)
# sometimes serve a hash-mismatched index and abort the refresh.
# They are irrelevant here, so a failed refresh must not stop the
# build: python3 comes from the Ubuntu repos, which fetch fine.
sudo apt-get update || true
sudo apt-get install -y python3 python3-pip python3-venv sudo apt-get install -y python3 python3-pip python3-venv
- name: Make build script executable - name: Make build script executable
@@ -42,6 +46,26 @@ jobs:
- name: Build AppImage - name: Build AppImage
working-directory: AppImage working-directory: AppImage
run: ./scripts/build_appimage.sh run: ./scripts/build_appimage.sh
- name: Set up Python 3.11 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify bundled Python 3.11 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp311:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Set up Python 3.13 validation runtime
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Verify bundled Python 3.13 runtime
env:
PYTHONPATH: /tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/vendor/cp313:/tmp/proxmenux_build/ProxMenux.AppDir/usr/lib/python3/dist-packages
run: python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from gevent import pywsgi; print(gevent.__version__)'
- name: Get version from package.json - name: Get version from package.json
id: version id: version
@@ -15,9 +15,15 @@ on:
branches: [develop] branches: [develop]
paths: paths:
- 'scripts/**/*.sh' - 'scripts/**/*.sh'
- 'scripts/**/*.func'
- 'scripts/**/*.py'
- 'menu' - 'menu'
- 'install_proxmenux.sh' - 'install_proxmenux.sh'
- 'install_proxmenux_beta.sh' - 'install_proxmenux_beta.sh'
- 'oci/src/**/*.py'
- 'oci/remote/*.sh'
- 'oci/remote/*.py'
- 'oci/catalog/**/*.json'
- '.github/scripts/build_translation_cache.py' - '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-translation-cache.yml' - '.github/workflows/build-translation-cache.yml'
workflow_dispatch: workflow_dispatch:
@@ -79,6 +85,11 @@ jobs:
--extra-file menu \ --extra-file menu \
--extra-file install_proxmenux.sh \ --extra-file install_proxmenux.sh \
--extra-file install_proxmenux_beta.sh \ --extra-file install_proxmenux_beta.sh \
--extra-dir oci/remote \
--python-dir scripts \
--python-dir oci/src \
--python-dir oci/remote \
--catalog-dir oci/catalog \
--output-dir lang \ --output-dir lang \
--provider googletrans \ --provider googletrans \
$REFRESH_FLAG $REFRESH_FLAG
+3 -8
View File
@@ -1,14 +1,9 @@
name: Build web documentation translations name: Build web documentation translations
on: on:
push: # Source and locale corrections are reviewed together. An automatic run
branches: [develop] # would treat the changed English fingerprints as stale translations and
paths: # overwrite those reviewed locale strings with provider output.
- 'web/messages/en/*.json'
- 'web/messages/en/**/*.json'
- '.github/scripts/build_web_docs_i18n.py'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-web-docs-i18n.yml'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
languages: languages:
@@ -0,0 +1,25 @@
name: Label OCI validation pull requests
# A pull request that changes verification.json gets the "oci-validation"
# label, which adds it to the ProxMenux Roadmap. The pull request's code is
# never checked out: only its list of changed files is read.
on:
pull_request_target:
types: [opened, reopened, synchronize]
paths:
- 'oci/catalog/verification.json'
permissions:
issues: write
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.pull_request.number }}
run: gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels" -f "labels[]=oci-validation" --silent
@@ -0,0 +1,85 @@
name: Record OCI validation
# When an OCI validation report is validated, the report's author is recorded
# in oci/catalog/verification.json on develop and oci/VALIDATION.md is
# regenerated. A report is validated by the "validated" label, which only users
# with write access can add, or by a /validated comment from a user listed in
# .github/oci-validation-reviewers, checked by the script. The issue body and
# the comment are read from the event file, never interpolated into a shell.
on:
issues:
types: [labeled]
issue_comment:
types: [created]
concurrency:
group: oci-validation-record
cancel-in-progress: false
jobs:
record:
if: >-
contains(github.event.issue.labels.*.name, 'oci-validation') && (
(github.event_name == 'issues' && github.event.label.name == 'validated') ||
(github.event_name == 'issue_comment' && !github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/validated')))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Record the report
id: record
run: |
set +e
python3 .github/scripts/oci_validation.py record --event "$GITHUB_EVENT_PATH" --message "$RUNNER_TEMP/reply.md"
echo "code=$?" >> "$GITHUB_OUTPUT"
- name: Commit + push
if: steps.record.outputs.code == '0'
env:
APP: ${{ steps.record.outputs.app }}
NUMBER: ${{ github.event.issue.number }}
run: |
git config user.name "ProxMenuxBot"
git config user.email "bot@proxmenux.local"
git add oci/catalog/verification.json oci/VALIDATION.md
git commit -m "chore(oci): record the validation of $APP (#$NUMBER)"
for attempt in 1 2 3 4 5; do
git fetch origin develop
if git rebase origin/develop && git push origin HEAD:develop; then
exit 0
fi
git rebase --abort 2>/dev/null || true
sleep $(( attempt * 3 ))
done
echo "push failed after 5 attempts"
exit 1
- name: Reply and close
if: steps.record.outputs.code == '0'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
gh issue close "$NUMBER" --repo "$GITHUB_REPOSITORY" --reason completed
- name: Explain why it was not recorded
if: steps.record.outputs.code == '2'
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$NUMBER" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/reply.md"
if [ "$GITHUB_EVENT_NAME" = issues ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/$NUMBER/labels/validated" --silent
fi
- name: Fail on an unexpected error
if: steps.record.outputs.code != '0' && steps.record.outputs.code != '2'
run: exit 1
+54
View File
@@ -0,0 +1,54 @@
name: OCI validation record
# verification.json is the record of OCI applications tested for real, and
# oci/VALIDATION.md is generated from it. A pull request only adds or changes
# the community tests credited to its own author.
on:
pull_request:
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
push:
branches: [main, develop]
paths:
- 'oci/catalog/verification.json'
- 'oci/catalog/index.json'
- 'oci/VALIDATION.md'
- '.github/scripts/oci_validation.py'
- '.github/workflows/oci-validation.yml'
workflow_dispatch:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check the record and oci/VALIDATION.md
run: python3 .github/scripts/oci_validation.py check
- name: Check who the pull request credits
if: github.event_name == 'pull_request'
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
BASE: ${{ github.event.pull_request.base.sha }}
run: |
git fetch --no-tags --depth=1 origin "$BASE"
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
+389
View File
@@ -0,0 +1,389 @@
name: Feature tests
on:
pull_request:
paths:
- '.github/workflows/test-features.yml'
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/base.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'AppImage/components/notification-settings.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/components/backup-tab.tsx'
- 'AppImage/components/vm-backup-jobs.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
- 'tests/test_figurine_installation.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'AppImage/messages/**'
- 'tests/test_guest_backup_archives.*'
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
- 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/components/pbs-*.tsx'
- 'AppImage/components/proxmox-dashboard.tsx'
- 'AppImage/tests/pbs-navigation.cjs'
- 'scripts/global/proxmox_platform.py'
- 'scripts/global/platform-functions.sh'
- 'scripts/global/repository-functions.sh'
- 'scripts/menus/pbs_menu.sh'
- 'scripts/utilities/pbs_update.sh'
- 'scripts/global/update-pbs.sh'
- 'scripts/menus/menu_post_install.sh'
- 'scripts/post_install/*.sh'
push:
branches: [main, develop]
paths:
- '.github/workflows/test-features.yml'
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/base.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
- 'tests/test_apprise_services.py'
- 'tests/test_apprise_service_help.cjs'
- 'AppImage/scripts/flask_vm_backup_routes.py'
- 'AppImage/components/backup-storage-retention.tsx'
- 'AppImage/lib/backup-retention.ts'
- 'tests/test_vm_backup_jobs_api.py'
- 'tests/test_backup_storage_retention.cjs'
- 'tests/test_figurine_installation.py'
- 'scripts/post_install/customizable_post_install.sh'
- 'AppImage/scripts/post_install_versions.py'
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
- 'AppImage/lib/guest-backup-archives.ts'
- 'tests/test_guest_backup_archives.*'
- 'AppImage/scripts/lvm_device_scope.py'
- 'AppImage/scripts/disk_temperature_history.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/flask_notification_routes.py'
- 'AppImage/scripts/proxmox_storage_monitor.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/tests/test_host_backup_node.py'
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
- 'tests/test_lvm_disk_read_policy.py'
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/*pbs*.py'
- 'scripts/global/proxmox_platform.py'
workflow_dispatch:
permissions:
contents: read
# A newer push to the same branch or pull request replaces the run in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
apprise-services:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
apprise: ['1.7.0', '2.0.1']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'apprise==${{ matrix.apprise }}'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test real provider plugins with mocked HTTP
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
- name: Render provider help and locale fallback
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_apprise_service_help.cjs
storage-retention:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native job and storage configuration APIs
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
- name: Test shared retention properties
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_backup_storage_retention.cjs
installer-and-release:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check shell syntax and isolated installer regressions
run: |
bash -n scripts/post_install/customizable_post_install.sh
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
- name: Install ARM user-mode emulation for release smoke tests
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends qemu-user
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
env:
FIGURINE_VERSION: '2.1.1'
shell: bash
run: |
set -euo pipefail
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
while read -r arch sha; do
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
target="$scratch/$arch"
mkdir -p "$target"
curl --fail --location --retry 3 --output "$target/$archive" \
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
tar -xf "$target/$archive" -C "$target"
runner=()
case "$arch" in
arm64) runner=(qemu-aarch64) ;;
arm) runner=(qemu-arm) ;;
esac
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
test -s "$target/banner"
printf 'PASS: %s version and login banner\n' "$arch"
done <<'ASSETS'
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
ASSETS
guest-backups:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Install fixture dependencies
run: |
python -m pip install 'Flask==3.1.2'
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
- name: Test native deletion, protection, authorization and cache invalidation
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
- name: Test frontend deletion and completion handling
env:
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
run: node tests/test_guest_backup_archives.cjs
disk-policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test automatic LVM readers with mocked inventory and policy
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
- name: Test backup node ownership and former jobs
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
- name: Test local node lookup without Proxmox API commands
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
- name: Test notification configuration writes against a cluster filesystem fixture
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
- name: Test AI diagnostic explanations and backup inventory integrity
run: |
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_backup_ai_diagnostics.py -v
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_vzdump_ai_integrity.py -v
registry-accounts:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install OCI test dependencies
run: python3 -m pip install -r oci/requirements.txt
- name: Run watchdog identity and lifecycle regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
- name: Run OCI image reuse and resource edit regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
- name: Run carried record, recovery and configuration cache regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
- name: Run read-only volume preparation regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
test -x /bin/bash
test -x /usr/bin/jq
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
- name: Run OCI menu update checks and selection regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
- name: Run OCI Modify latency regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
- name: Run OCI modification menu regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
- name: Run external OCI settings regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
- name: Run managed volume startup regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
- name: Run catalog web-link regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
- name: Run full OCI engine test suite
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -v
pbs:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install test dependencies
run: python -m pip install Flask PyJWT cryptography pyotp segno
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
done
- uses: actions/setup-node@v6
if: matrix.python == '3.11'
with:
node-version: '22.14.0'
- name: Dashboard platform routing
if: matrix.python == '3.11'
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs

Some files were not shown because too many files have changed in this diff Show More