fix(oci): clarify Rclone dry runs and preserve HedgeDoc description

This commit is contained in:
martino
2026-09-28 16:15:59 +02:00
parent 8aade0e55c
commit 3e7ebc4be0
8 changed files with 263 additions and 7 deletions
@@ -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=lambda *_args, **_kw: (_ for _ in ()).throw(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)
+6
View File
@@ -14,6 +14,9 @@ on:
- 'scripts/**/*.sh' - 'scripts/**/*.sh'
- 'scripts/**/*.func' - 'scripts/**/*.func'
- 'lang/*.json' - 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
push: push:
branches: [main, develop] branches: [main, develop]
paths: paths:
@@ -28,6 +31,9 @@ on:
- 'scripts/**/*.sh' - 'scripts/**/*.sh'
- 'scripts/**/*.func' - 'scripts/**/*.func'
- 'lang/*.json' - 'lang/*.json'
- 'oci/src/**/*.py'
- 'oci/catalog/**/*.json'
- 'oci/remote/**/*.sh'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+2 -2
View File
@@ -8,10 +8,10 @@
"en_US": "Hedgedoc" "en_US": "Hedgedoc"
}, },
"tagline": { "tagline": {
"en_US": "HedgeDoc gives you access to all your files wherever you are." "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes."
}, },
"description": { "description": {
"en_US": "HedgeDoc gives you access to all your files wherever you are." "en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes."
}, },
"category": "documents", "category": "documents",
"category_label": "Documents & Notes", "category_label": "Documents & Notes",
+1 -1
View File
@@ -3995,7 +3995,7 @@
"title": "Hedgedoc", "title": "Hedgedoc",
"repository_name": "docker-hedgedoc", "repository_name": "docker-hedgedoc",
"repository": "https://github.com/linuxserver/docker-hedgedoc", "repository": "https://github.com/linuxserver/docker-hedgedoc",
"description": "HedgeDoc gives you access to all your files wherever you are.", "description": "HedgeDoc is a self-hosted collaborative Markdown editor for notes.",
"website": "https://hedgedoc.org/", "website": "https://hedgedoc.org/",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hedgedoc.webp", "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hedgedoc.webp",
"architectures": [ "architectures": [
+7 -1
View File
@@ -1,6 +1,12 @@
{ {
"catalog_ui": { "catalog_ui": {
"hidden": true, "hidden": true,
"hidden_reason": "Needs MariaDB or PostgreSQL in its own container; planned as a multi-container application" "hidden_reason": "Needs MariaDB or PostgreSQL in its own container; planned as a multi-container application",
"tagline": {
"en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes."
},
"description": {
"en_US": "HedgeDoc is a self-hosted collaborative Markdown editor for notes."
}
} }
} }
+5 -1
View File
@@ -98,7 +98,11 @@ grep -Fxq "${REMOTE_NAME}:" <<<"$REMOTES" \
msg_ok "$(translate "Remote verified:") ${REMOTE_NAME}:" msg_ok "$(translate "Remote verified:") ${REMOTE_NAME}:"
if [[ $DRY_RUN == 1 ]]; then if [[ $DRY_RUN == 1 ]]; then
msg_ok "$(translate "Dry run completed; the container and the mounts were not changed.")" if [[ $STATUS == running ]]; then
msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was already running.")"
else
msg_ok "$(translate "Dry run completed; mount configuration was not applied. The CT was started for the remote check.")"
fi
exit 0 exit 0
fi fi
+4
View File
@@ -402,6 +402,10 @@ class Catalog:
overlay_path = self.overlays_dir / f"{item['id']}.json" overlay_path = self.overlays_dir / f"{item['id']}.json"
overlay_ui = json.loads(overlay_path.read_text(encoding="utf-8")).get("catalog_ui", {}) if overlay_path.exists() else {} overlay_ui = json.loads(overlay_path.read_text(encoding="utf-8")).get("catalog_ui", {}) if overlay_path.exists() else {}
item["hidden"] = overlay_ui.get("hidden", False) item["hidden"] = overlay_ui.get("hidden", False)
# The index is rebuilt from upstream README summaries; preserve a
# locally curated English description from the existing overlay.
item["description"] = overlay_ui.get("description", {}).get(
"en_US", item.get("description"))
path = self.apps_dir / f"{item['id']}.json" path = self.apps_dir / f"{item['id']}.json"
if not path.exists(): if not path.exists():
item["architectures"] = supported_architectures( item["architectures"] = supported_architectures(
+3 -2
View File
@@ -621,8 +621,9 @@ def build_rclone_mount_deployment(
) -> dict[str, Any]: ) -> dict[str, Any]:
ui = ui or TerminalUI() ui = ui or TerminalUI()
schema = template["proxmox"]["laboratory_contract"]["configuration_schema"] schema = template["proxmox"]["laboratory_contract"]["configuration_schema"]
ui.message(translate("The remote must already be created and authorized in the Rclone web UI. This " ui.message(translate("Configure the remote in the Rclone Web UI first. Checking its name may start a "
"operation restarts the CT and publishes two FUSE views on the host.")) "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."))
vmid = int(ui.ask(translate("VMID of the Rclone OCI container"))) vmid = int(ui.ask(translate("VMID of the Rclone OCI container")))
remote_name = ui.ask(translate("Exact name of the remote")) remote_name = ui.ask(translate("Exact name of the remote"))
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", remote_name): if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", remote_name):