Skip to content

Commit ad111f5

Browse files
committed
fix: restore pre-stop stack state and runtime config path
1 parent 2681bb1 commit ad111f5

5 files changed

Lines changed: 109 additions & 30 deletions

File tree

apphub/src/services/back_manager.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,20 @@ def restore_backup(self, app_id: str, snapshot_id: str) -> None:
357357
if not extra_volumes:
358358
raise CustomException(400, f"No valid volume mounts found for app: {app_id}", "No Volume Mounts")
359359

360-
# Stop containers before restore to release file locks and caches
361360
portainer = PortainerManager()
361+
was_active = False
362+
stack_id = None
362363
if endpoint_id:
364+
try:
365+
stack_info = portainer.get_stack_by_name(app_id, endpoint_id)
366+
if stack_info:
367+
stack_id = stack_info.get("Id")
368+
was_active = stack_info.get("Status") == 1
369+
except Exception as exc:
370+
logger.warning(f"Could not determine stack state for {app_id}: {exc}")
371+
372+
# Stop containers before restore to release file locks and caches
373+
if endpoint_id and was_active:
363374
try:
364375
portainer.stop_stack(app_id, endpoint_id)
365376
logger.access(f"Stopped containers for app {app_id} before restore")
@@ -391,23 +402,17 @@ def restore_backup(self, app_id: str, snapshot_id: str) -> None:
391402
if not summary_found:
392403
raise CustomException(500, f"Restore incomplete — no summary returned for snapshot: {snapshot_id}", "Restore Failed")
393404

394-
# Start containers after restore via stack lifecycle APIs instead of
395-
# starting every container individually. Some apps include one-shot
396-
# init containers that should remain exited after a successful run.
405+
# Use the stack state observed before restore. Once an active stack
406+
# is stopped, Portainer reports it as inactive, which would wrongly
407+
# push restores down the up_stack path and recreate containers.
397408
if endpoint_id:
398409
try:
399-
stack_info = portainer.get_stack_by_name(app_id, endpoint_id)
400-
if stack_info is None:
401-
raise CustomException(404, "Not Found", f"Stack {app_id} not found")
402-
403-
stack_status = stack_info.get("Status", 0)
404-
if stack_status == 2:
405-
stack_id = stack_info.get("Id")
406-
if stack_id is None:
407-
raise CustomException(404, "Not Found", f"Stack {app_id} has no Id")
410+
if was_active:
411+
portainer.start_stack(app_id, endpoint_id)
412+
elif stack_id is not None:
408413
portainer.up_stack(stack_id, endpoint_id)
409414
else:
410-
portainer.start_stack(app_id, endpoint_id)
415+
raise CustomException(404, "Not Found", f"Stack {app_id} not found")
411416

412417
self._ensure_restored_app_running(portainer, app_id, endpoint_id)
413418
logger.access(f"Started containers for app {app_id} after restore")

apphub/tests/test_backup_restore.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,23 @@
3939

4040

4141
class FakePortainer:
42-
def __init__(self, stack_status, container_sequences):
42+
def __init__(self, stack_status, container_sequences, stack_status_sequence=None):
4343
self.stack_status = stack_status
44+
self.stack_status_sequence = list(stack_status_sequence or [])
4445
self.container_sequences = list(container_sequences)
4546
self.up_calls = []
4647
self.start_calls = []
48+
self.stop_calls = []
4749

4850
def get_stack_by_name(self, app_id, endpoint_id):
49-
return {"Id": 9, "Name": app_id, "Status": self.stack_status}
51+
if self.stack_status_sequence:
52+
status = self.stack_status_sequence.pop(0)
53+
else:
54+
status = self.stack_status
55+
return {"Id": 9, "Name": app_id, "Status": status}
56+
57+
def stop_stack(self, app_id, endpoint_id):
58+
self.stop_calls.append((app_id, endpoint_id))
5059

5160
def up_stack(self, stack_id, endpoint_id):
5261
self.up_calls.append((stack_id, endpoint_id))
@@ -115,6 +124,34 @@ def test_restore_start_uses_start_stack_for_active_stack(monkeypatch):
115124
assert portainer.up_calls == []
116125

117126

127+
def test_restore_uses_pre_stop_stack_state_for_active_stack(monkeypatch):
128+
manager = _build_manager()
129+
portainer = FakePortainer(
130+
stack_status=1,
131+
stack_status_sequence=[1],
132+
container_sequences=[[{"Names": ["/wordpress_demo"], "State": "running"}]],
133+
)
134+
135+
monkeypatch.setattr(manager, '_check_repository', lambda: True)
136+
monkeypatch.setattr(manager, 'list_snapshots', lambda app_id: [{"id": "snap-1", "short_id": "snap-1"}])
137+
monkeypatch.setattr(manager, '_run_restic_container', lambda command, extra_volumes: '{"message_type":"summary"}')
138+
monkeypatch.setattr(manager, '_ensure_restored_app_running', lambda *args, **kwargs: None)
139+
monkeypatch.setattr(back_manager_module, 'AppManger', lambda: types.SimpleNamespace(
140+
get_app_by_id=lambda app_id: types.SimpleNamespace(
141+
endpointId=1,
142+
volumes=[{"Mountpoint": "/var/lib/docker/volumes/wordpress_demo/_data", "Name": "wordpress_demo"}],
143+
)
144+
))
145+
monkeypatch.setattr(back_manager_module, 'PortainerManager', lambda: portainer)
146+
monkeypatch.setattr(manager, '_resolve_host_path', lambda path: path)
147+
148+
manager.restore_backup('wordpress_demo', 'snap-1')
149+
150+
assert portainer.stop_calls == [('wordpress_demo', 1)]
151+
assert portainer.start_calls == [('wordpress_demo', 1)]
152+
assert portainer.up_calls == []
153+
154+
118155
def test_restore_validation_rejects_only_exited_runtime_containers(monkeypatch):
119156
manager = _build_manager()
120157
portainer = FakePortainer(

apphub/tests/test_portainer_manager.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import sys
22
import types
3+
import importlib.util
34
from pathlib import Path
45

56
PROJECT_ROOT = Path(__file__).resolve().parents[1]
67
if str(PROJECT_ROOT) not in sys.path:
78
sys.path.insert(0, str(PROJECT_ROOT))
89

9-
from src.services.portainer_manager import PortainerManager
10+
module_path = PROJECT_ROOT / 'src' / 'services' / 'portainer_manager.py'
11+
module_spec = importlib.util.spec_from_file_location('real_portainer_manager', module_path)
12+
portainer_manager_module = importlib.util.module_from_spec(module_spec)
13+
assert module_spec and module_spec.loader
14+
module_spec.loader.exec_module(portainer_manager_module)
15+
PortainerManager = portainer_manager_module.PortainerManager
1016

1117

1218
class FakeResponse:
@@ -29,6 +35,12 @@ def create_stack_standlone_repository(self, stack_name, endpoint_id, repository_
2935
return self.responses.pop(0)
3036

3137

38+
def _build_manager(fake_api):
39+
manager = PortainerManager.__new__(PortainerManager)
40+
manager.portainer = fake_api
41+
return manager
42+
43+
3244
def test_create_stack_retries_after_cleaning_stale_compose_workspace(monkeypatch, tmp_path):
3345
workdir = tmp_path / 'compose' / '2'
3446
workdir.mkdir(parents=True)
@@ -39,8 +51,7 @@ def test_create_stack_retries_after_cleaning_stale_compose_workspace(monkeypatch
3951
FakeResponse(200, payload={'Id': 2}),
4052
])
4153

42-
manager = object.__new__(PortainerManager)
43-
manager.portainer = fake_api
54+
manager = _build_manager(fake_api)
4455
monkeypatch.setattr(manager, '_extract_compose_workdir', lambda message: str(workdir))
4556

4657
result = manager.create_stack_from_repository('moodle_qy7wv', 1, 'http://repo', 'user', 'pwd')

install/lib/upgrade-legacy.sh

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,9 +510,12 @@ data_root = Path(os.environ.get("WEBSOFT9_DATA_ROOT", "/opt/websoft9/data"))
510510
511511
legacy_config_path = data_root / ".w9-migration/legacy-config.ini"
512512
legacy_daemon_path = data_root / ".w9-migration/legacy-daemon.json"
513-
# Respect the WEBSOFT9_APPHUB_CONFIG_PATH env var so migrated settings land in the
514-
# same persistent config file the rest of the system reads, not the bundled copy.
515-
runtime_config_path = Path(os.environ.get("WEBSOFT9_APPHUB_CONFIG_PATH", "/websoft9/apphub/src/config/config.ini"))
513+
# docker exec shells do not inherit the PID 1 AppHub config env vars reliably.
514+
# Write directly to the persistent host-backed runtime config path used by the platform.
515+
runtime_config_path = Path("/opt/websoft9/data/config/apphub/config.ini")
516+
legacy_runtime_config_path = Path("/data/config/apphub/config.ini")
517+
if not runtime_config_path.parent.exists() and legacy_runtime_config_path.parent.exists():
518+
runtime_config_path = legacy_runtime_config_path
516519
# Bundled defaults shipped inside the image — used as a fallback when the
517520
# persistent config hasn't been bootstrapped yet (first migration).
518521
bundled_config_path = Path("/websoft9/apphub/src/config/config.ini")

scripts/platform-sync-config.sh

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
set -euo pipefail
44

55
mode="base"
6+
data_root="${WEBSOFT9_DATA_ROOT:-/opt/websoft9/data}"
7+
runtime_config_path="${WEBSOFT9_APPHUB_CONFIG_PATH:-$data_root/config/apphub/config.ini}"
8+
runtime_system_config_path="${WEBSOFT9_APPHUB_SYSTEM_CONFIG_PATH:-$data_root/config/apphub/system.ini}"
9+
bundled_config_path="/websoft9/apphub/src/config/config.ini"
10+
bundled_system_config_path="/websoft9/apphub/src/config/system.ini"
611

712
while [[ $# -gt 0 ]]; do
813
case "$1" in
@@ -21,19 +26,34 @@ set_config() {
2126
websoft9 setconfig --section "$1" --key "$2" --value "$3" >/dev/null
2227
}
2328

29+
bootstrap_runtime_config() {
30+
local source_path="$1"
31+
local target_path="$2"
32+
33+
mkdir -p "$(dirname "$target_path")"
34+
if [[ ! -f "$target_path" && -f "$source_path" ]]; then
35+
cp -a "$source_path" "$target_path"
36+
fi
37+
}
38+
39+
ensure_runtime_config_files() {
40+
bootstrap_runtime_config "$bundled_config_path" "$runtime_config_path"
41+
bootstrap_runtime_config "$bundled_system_config_path" "$runtime_system_config_path"
42+
}
43+
2444
set_config_if_missing() {
25-
python3 - "$1" "$2" "$3" <<'PY'
45+
python3 - "$runtime_config_path" "$1" "$2" "$3" <<'PY'
2646
import configparser
2747
import sys
2848
29-
section, key, value = sys.argv[1:4]
49+
config_path, section, key, value = sys.argv[1:5]
3050
config = configparser.ConfigParser()
31-
config.read('/websoft9/apphub/src/config/config.ini')
51+
config.read(config_path)
3252
if not config.has_section(section):
3353
config.add_section(section)
3454
if not config.has_option(section, key):
3555
config.set(section, key, value)
36-
with open('/websoft9/apphub/src/config/config.ini', 'w', encoding='utf-8') as file:
56+
with open(config_path, 'w', encoding='utf-8') as file:
3757
config.write(file)
3858
PY
3959
}
@@ -43,18 +63,21 @@ set_system_config() {
4363
}
4464

4565
sync_base() {
66+
ensure_runtime_config_files
4667
set_config_if_missing platform_gateway https_enabled "${WEBSOFT9_PLATFORM_HTTPS_ENABLED:-false}"
47-
set_config_if_missing platform_gateway ssl_cert "${WEBSOFT9_PLATFORM_GATEWAY_CERT_PATH:-/etc/custom/platform-gateway/ssl/websoft9-platform-gateway.cert}"
48-
set_config_if_missing platform_gateway ssl_key "${WEBSOFT9_PLATFORM_GATEWAY_KEY_PATH:-/etc/custom/platform-gateway/ssl/websoft9-platform-gateway.key}"
68+
set_config_if_missing platform_gateway ssl_cert "${WEBSOFT9_PLATFORM_GATEWAY_CERT_PATH:-$data_root/config/platform-gateway/ssl/websoft9-platform-gateway.cert}"
69+
set_config_if_missing platform_gateway ssl_key "${WEBSOFT9_PLATFORM_GATEWAY_KEY_PATH:-$data_root/config/platform-gateway/ssl/websoft9-platform-gateway.key}"
4970
set_system_config docker_library path "${WEBSOFT9_LIBRARY_PATH:-/websoft9/library/apps}"
5071
set_system_config app_media path "${WEBSOFT9_MEDIA_PATH:-/websoft9/media/json}"
5172
}
5273

5374
sync_credentials() {
54-
python3 - <<'PY'
75+
ensure_runtime_config_files
76+
python3 - "$runtime_config_path" <<'PY'
5577
import configparser
78+
import sys
5679
57-
config_path = '/websoft9/apphub/src/config/config.ini'
80+
config_path = sys.argv[1]
5881
config = configparser.ConfigParser()
5982
config.read(config_path, encoding='utf-8')
6083

0 commit comments

Comments
 (0)