pg-stand 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {pg_stand-0.2.2/src/pg_stand.egg-info → pg_stand-0.2.4}/PKG-INFO +7 -4
  2. {pg_stand-0.2.2 → pg_stand-0.2.4}/README.md +6 -3
  3. {pg_stand-0.2.2 → pg_stand-0.2.4}/pyproject.toml +1 -1
  4. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/__init__.py +1 -1
  5. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime.py +30 -3
  6. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_observe.py +6 -4
  7. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_state.py +6 -0
  8. {pg_stand-0.2.2 → pg_stand-0.2.4/src/pg_stand.egg-info}/PKG-INFO +7 -4
  9. {pg_stand-0.2.2 → pg_stand-0.2.4}/tests/test_runtime.py +109 -0
  10. {pg_stand-0.2.2 → pg_stand-0.2.4}/LICENSE +0 -0
  11. {pg_stand-0.2.2 → pg_stand-0.2.4}/MANIFEST.in +0 -0
  12. {pg_stand-0.2.2 → pg_stand-0.2.4}/setup.cfg +0 -0
  13. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/.dockerignore +0 -0
  14. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/__main__.py +0 -0
  15. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/assets.py +0 -0
  16. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/cli.py +0 -0
  17. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/cli_parser.py +0 -0
  18. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/config.py +0 -0
  19. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/configs/primary-standby-async.yaml +0 -0
  20. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/configs/primary-standby-sync-logical.yaml +0 -0
  21. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/configs/primary-standby-sync.yaml +0 -0
  22. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/configs/single.yaml +0 -0
  23. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/credentials.py +0 -0
  24. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/database_credentials.py +0 -0
  25. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/Dockerfile.pg10 +0 -0
  26. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/Dockerfile.pg11-pg12 +0 -0
  27. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/Dockerfile.pg13-pg17 +0 -0
  28. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/Dockerfile.pg18 +0 -0
  29. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/pg-stand-entrypoint.sh +0 -0
  30. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/docker/pg-stand-pgbouncer.sh +0 -0
  31. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/errors.py +0 -0
  32. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/orchestration.py +0 -0
  33. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_common.py +0 -0
  34. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_docker.py +0 -0
  35. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_nodes.py +0 -0
  36. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/runtime_replication.py +0 -0
  37. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand/schema/pg_stand-v1.schema.json +0 -0
  38. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand.egg-info/SOURCES.txt +0 -0
  39. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand.egg-info/dependency_links.txt +0 -0
  40. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand.egg-info/entry_points.txt +0 -0
  41. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand.egg-info/requires.txt +0 -0
  42. {pg_stand-0.2.2 → pg_stand-0.2.4}/src/pg_stand.egg-info/top_level.txt +0 -0
  43. {pg_stand-0.2.2 → pg_stand-0.2.4}/tests/integration_tls.sh +0 -0
  44. {pg_stand-0.2.2 → pg_stand-0.2.4}/tests/test_cli.py +0 -0
  45. {pg_stand-0.2.2 → pg_stand-0.2.4}/tests/test_config.py +0 -0
  46. {pg_stand-0.2.2 → pg_stand-0.2.4}/tests/test_packaging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pg-stand
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Declarative Docker testbed manager for PostgreSQL
5
5
  Author-email: O2eg <oleg.ispu@yandex.ru>
6
6
  License-Expression: MIT
@@ -259,9 +259,10 @@ avoided; the runtime selects a compatible image family automatically.
259
259
  Every managed image installs PgBouncer 1.25.2, `iproute2`, `lshw`, OpenSSH server, `procps`,
260
260
  `sysstat`, `util-linux`, plus the `pg_wait_sampling` and `pg_stat_kcache` packages. Every node
261
261
  runs independent PgBouncer processes for session pooling on container port 6432 and transaction
262
- pooling on container port 6433. Every application
263
- database from `app_01` through `app_10` automatically gets `pg_stat_statements`,
264
- `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. The `auto_explain` module is also
262
+ pooling on container port 6433. Every connectable database, including `app_01` through
263
+ `app_10`, `postgres`, and `template1`, automatically gets `pg_stat_statements`,
264
+ `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. New databases created from
265
+ `template1` inherit these extensions; `template0` remains pristine. The `auto_explain` module is also
265
266
  loaded through
266
267
  `shared_preload_libraries`. Planning tracking for `pg_stat_statements` and `pg_stat_kcache` is
267
268
  enabled only on PostgreSQL 13 and newer.
@@ -503,6 +504,8 @@ stand is stopped or removed. `stop` and `down` release the lock. A failed partia
503
504
  the lock when it has already started a database node, so another stand cannot start around the
504
505
  failure. Destructive storage cleanup retains the lock until mounted data and credentials are no
505
506
  longer being changed, which prevents a concurrent `up` from racing with deletion.
507
+ `plan` reports `blocked` when the active lease belongs to another stand or project
508
+ directory, before suggesting `up` or an apply operation.
506
509
 
507
510
  Persistent container and network names must end with `-pg-stand-managed`. Every resource also
508
511
  has mandatory project, project-directory instance, managed, resource-kind, node, and
@@ -219,9 +219,10 @@ avoided; the runtime selects a compatible image family automatically.
219
219
  Every managed image installs PgBouncer 1.25.2, `iproute2`, `lshw`, OpenSSH server, `procps`,
220
220
  `sysstat`, `util-linux`, plus the `pg_wait_sampling` and `pg_stat_kcache` packages. Every node
221
221
  runs independent PgBouncer processes for session pooling on container port 6432 and transaction
222
- pooling on container port 6433. Every application
223
- database from `app_01` through `app_10` automatically gets `pg_stat_statements`,
224
- `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. The `auto_explain` module is also
222
+ pooling on container port 6433. Every connectable database, including `app_01` through
223
+ `app_10`, `postgres`, and `template1`, automatically gets `pg_stat_statements`,
224
+ `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. New databases created from
225
+ `template1` inherit these extensions; `template0` remains pristine. The `auto_explain` module is also
225
226
  loaded through
226
227
  `shared_preload_libraries`. Planning tracking for `pg_stat_statements` and `pg_stat_kcache` is
227
228
  enabled only on PostgreSQL 13 and newer.
@@ -463,6 +464,8 @@ stand is stopped or removed. `stop` and `down` release the lock. A failed partia
463
464
  the lock when it has already started a database node, so another stand cannot start around the
464
465
  failure. Destructive storage cleanup retains the lock until mounted data and credentials are no
465
466
  longer being changed, which prevents a concurrent `up` from racing with deletion.
467
+ `plan` reports `blocked` when the active lease belongs to another stand or project
468
+ directory, before suggesting `up` or an apply operation.
466
469
 
467
470
  Persistent container and network names must end with `-pg-stand-managed`. Every resource also
468
471
  has mandatory project, project-directory instance, managed, resource-kind, node, and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pg-stand"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Declarative Docker testbed manager for PostgreSQL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,6 +2,6 @@
2
2
 
3
3
  from pg_stand.config import MANAGED_POSTGRES_PARAMETERS
4
4
 
5
- __version__ = "0.2.2"
5
+ __version__ = "0.2.4"
6
6
 
7
7
  __all__ = ["MANAGED_POSTGRES_PARAMETERS", "__version__"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import shlex
6
7
  import time
7
8
  from collections.abc import Callable
@@ -15,7 +16,6 @@ from pg_stand.config import MANAGED_RESOURCE_SUFFIX, NodeConfig, StandConfig
15
16
  from pg_stand.credentials import credential_paths, credentials_status, ensure_credentials
16
17
  from pg_stand.database_credentials import (
17
18
  APPLICATION_ACCOUNTS,
18
- APPLICATION_DATABASES,
19
19
  DEFAULT_REPLICATION_USER,
20
20
  DatabaseCredentials,
21
21
  read_database_credentials,
@@ -393,6 +393,11 @@ class _StandManagerCore:
393
393
  nano_cpus=int(node.cpu_limit * 1_000_000_000),
394
394
  mem_limit=node.memory_limit,
395
395
  restart_policy={"Name": "unless-stopped"},
396
+ # Without an init process the postmaster is PID 1 and adopts orphaned
397
+ # children (docker exec/ssh pipelines). PostgreSQL 18 treats an unknown
398
+ # child exiting with a non-zero status as a backend crash and restarts
399
+ # the whole cluster (observed 2026-09-11 on pg-trace: exit code 141).
400
+ init=True,
396
401
  )
397
402
  except APIError as exc:
398
403
  raise DockerRuntimeError(
@@ -460,8 +465,16 @@ class _StandManagerCore:
460
465
  "/bin/sh",
461
466
  "-ceu",
462
467
  """process=$(cat /proc/1/comm)
468
+ if [ "$process" = "docker-init" ]; then
469
+ # Its first child is the entrypoint, which execs the final server. Later
470
+ # children may include the temporary initdb server adopted by docker-init.
471
+ for child in $(cat /proc/1/task/1/children); do
472
+ process=$(cat "/proc/$child/comm")
473
+ break
474
+ done
475
+ fi
463
476
  if [ "$process" != "postgres" ]; then
464
- echo "container PID 1 is $process, waiting for final postgres process"
477
+ echo "container main process is $process, waiting for final postgres process"
465
478
  exit 1
466
479
  fi
467
480
  exec pg_isready --username "$1" --dbname postgres
@@ -563,7 +576,11 @@ exec pg_isready --username "$1" --dbname postgres
563
576
  f"image {self.config.postgres.image_name} lacks diagnostic extensions: "
564
577
  + ", ".join(missing)
565
578
  )
566
- for database in APPLICATION_DATABASES:
579
+ # Include postgres and template1, so databases created later inherit the
580
+ # same diagnostic support. Keep template0 pristine and non-connectable
581
+ # databases untouched.
582
+ databases = self._diagnostic_databases(container)
583
+ for database in databases:
567
584
  for extension in DIAGNOSTIC_EXTENSIONS:
568
585
  self._psql(
569
586
  container,
@@ -582,6 +599,16 @@ exec pg_isready --username "$1" --dbname postgres
582
599
  "unexpected shared_preload_libraries order: " + ",".join(actual_preloads)
583
600
  )
584
601
 
602
+ def _diagnostic_databases(self, container: Any) -> list[str]:
603
+ value = self._psql(
604
+ container,
605
+ "select coalesce(json_agg(datname order by datname), '[]'::json)::text "
606
+ "from pg_catalog.pg_database where datallowconn and datname <> 'template0'",
607
+ database=self.config.postgres.database,
608
+ synchronous_commit_off=True,
609
+ )
610
+ return json.loads(value)
611
+
585
612
  def _ensure_superuser_password(self, container: Any) -> None:
586
613
  self._psql(
587
614
  container,
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
8
8
  from typing import Any
9
9
 
10
10
  from pg_stand.config import NodeConfig
11
- from pg_stand.database_credentials import APPLICATION_ACCOUNTS, APPLICATION_DATABASES
11
+ from pg_stand.database_credentials import APPLICATION_ACCOUNTS
12
12
  from pg_stand.errors import DockerRuntimeError
13
13
  from pg_stand.runtime_common import (
14
14
  CONFIG_HASH_LABEL,
@@ -412,7 +412,8 @@ class ObservationMixin:
412
412
  }
413
413
  )
414
414
  missing_by_database: dict[str, list[str]] = {}
415
- for database in APPLICATION_DATABASES:
415
+ diagnostic_databases = self._diagnostic_databases(container)
416
+ for database in diagnostic_databases:
416
417
  extension_output = self._psql(
417
418
  container,
418
419
  "select extname from pg_catalog.pg_extension where extname = any (array["
@@ -428,9 +429,10 @@ class ObservationMixin:
428
429
  {
429
430
  "id": f"node.{node_name}.extensions",
430
431
  "status": "healthy" if not missing_by_database else "degraded",
431
- "summary": "all diagnostic extensions are installed in all 10 databases"
432
+ "summary": "diagnostic extensions are installed in all connectable databases"
432
433
  if not missing_by_database
433
- else "diagnostic extensions are missing from application databases",
434
+ else "diagnostic extensions are missing from connectable databases",
435
+ "databases": diagnostic_databases,
434
436
  "missing_by_database": missing_by_database,
435
437
  }
436
438
  )
@@ -182,6 +182,12 @@ class StateStorageMixin:
182
182
  "desired_state_hash": self.config.config_hash,
183
183
  "applied_state_hash": state.get("config_hash") if state else None,
184
184
  }
185
+ if self._find_active_lock() is not None:
186
+ try:
187
+ self._assert_current_stand_is_active()
188
+ except DockerRuntimeError as exc:
189
+ result.update(required_action="blocked", can_apply=False, reason=str(exc))
190
+ return self._finalize_plan(result)
185
191
  if state is None:
186
192
  result.update(
187
193
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pg-stand
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Declarative Docker testbed manager for PostgreSQL
5
5
  Author-email: O2eg <oleg.ispu@yandex.ru>
6
6
  License-Expression: MIT
@@ -259,9 +259,10 @@ avoided; the runtime selects a compatible image family automatically.
259
259
  Every managed image installs PgBouncer 1.25.2, `iproute2`, `lshw`, OpenSSH server, `procps`,
260
260
  `sysstat`, `util-linux`, plus the `pg_wait_sampling` and `pg_stat_kcache` packages. Every node
261
261
  runs independent PgBouncer processes for session pooling on container port 6432 and transaction
262
- pooling on container port 6433. Every application
263
- database from `app_01` through `app_10` automatically gets `pg_stat_statements`,
264
- `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. The `auto_explain` module is also
262
+ pooling on container port 6433. Every connectable database, including `app_01` through
263
+ `app_10`, `postgres`, and `template1`, automatically gets `pg_stat_statements`,
264
+ `pg_wait_sampling`, `pg_buffercache`, and `pg_stat_kcache`. New databases created from
265
+ `template1` inherit these extensions; `template0` remains pristine. The `auto_explain` module is also
265
266
  loaded through
266
267
  `shared_preload_libraries`. Planning tracking for `pg_stat_statements` and `pg_stat_kcache` is
267
268
  enabled only on PostgreSQL 13 and newer.
@@ -503,6 +504,8 @@ stand is stopped or removed. `stop` and `down` release the lock. A failed partia
503
504
  the lock when it has already started a database node, so another stand cannot start around the
504
505
  failure. Destructive storage cleanup retains the lock until mounted data and credentials are no
505
506
  longer being changed, which prevents a concurrent `up` from racing with deletion.
507
+ `plan` reports `blocked` when the active lease belongs to another stand or project
508
+ directory, before suggesting `up` or an apply operation.
506
509
 
507
510
  Persistent container and network names must end with `-pg-stand-managed`. Every resource also
508
511
  has mandatory project, project-directory instance, managed, resource-kind, node, and
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import shutil
5
+ import subprocess
5
6
  from dataclasses import replace
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
@@ -37,6 +38,8 @@ from pg_stand.runtime import (
37
38
  standby_postgres_command,
38
39
  )
39
40
  from pg_stand.runtime_common import (
41
+ DIAGNOSTIC_EXTENSIONS,
42
+ DIAGNOSTIC_PRELOADS,
40
43
  IMAGE_BASE_LABEL,
41
44
  IMAGE_FAMILY_LABEL,
42
45
  IMAGE_MAJOR_LABEL,
@@ -51,6 +54,37 @@ ROOT = Path(__file__).resolve().parents[1]
51
54
  ASSET_ROOT = ROOT / "src" / "pg_stand"
52
55
 
53
56
 
57
+ def test_diagnostic_extensions_cover_existing_and_future_databases(tmp_path: Path) -> None:
58
+ config = load_config(ASSET_ROOT / "configs" / "single.yaml", project_directory=tmp_path)
59
+ manager = StandManager(config, client=object())
60
+ container = object()
61
+ databases = ["app_01", "custom\ndb", "postgres", "template1"]
62
+
63
+ def psql(_container, sql, **kwargs):
64
+ if "pg_available_extensions" in sql:
65
+ return "\n".join(DIAGNOSTIC_EXTENSIONS)
66
+ if "pg_catalog.pg_database" in sql:
67
+ assert "datallowconn" in sql and "datname <> 'template0'" in sql
68
+ return json.dumps(databases)
69
+ if sql == "show shared_preload_libraries":
70
+ return ",".join(DIAGNOSTIC_PRELOADS)
71
+ return ""
72
+
73
+ manager._exec = MagicMock()
74
+ manager._psql = MagicMock(side_effect=psql)
75
+ manager._ensure_diagnostic_extensions(container)
76
+ installed = {
77
+ (call.kwargs["database"], call.args[1])
78
+ for call in manager._psql.call_args_list
79
+ if call.args[1].startswith("create extension")
80
+ }
81
+ assert installed == {
82
+ (database, f'create extension if not exists "{extension}"')
83
+ for database in databases
84
+ for extension in DIAGNOSTIC_EXTENSIONS
85
+ }
86
+
87
+
54
88
  @pytest.fixture(scope="session")
55
89
  def credential_template(tmp_path_factory: pytest.TempPathFactory) -> Path:
56
90
  project = tmp_path_factory.mktemp("credential-template")
@@ -277,6 +311,47 @@ def test_readiness_waits_for_final_postgres_pid_one() -> None:
277
311
  assert "/proc/1/comm" in command[2]
278
312
 
279
313
 
314
+ @pytest.mark.parametrize(
315
+ ("pid_one", "children", "child_processes", "ready"),
316
+ [
317
+ ("postgres", "", {}, True),
318
+ ("docker-init", "7 ", {"7": "postgres"}, True),
319
+ ("docker-init", "7 84 ", {"7": "bash", "84": "postgres"}, False),
320
+ ("docker-init", "", {}, False),
321
+ ("bash", "84 ", {"84": "postgres"}, False),
322
+ ],
323
+ )
324
+ def test_readiness_checks_final_server_behind_docker_init(
325
+ tmp_path: Path, pid_one: str, children: str, child_processes: dict[str, str], ready: bool
326
+ ) -> None:
327
+ config = load_config(ASSET_ROOT / "configs" / "single.yaml", environment={})
328
+ manager = StandManager(config, client=object())
329
+ container = MagicMock(status="running")
330
+ container.exec_run.return_value = SimpleNamespace(exit_code=0, output=b"ready")
331
+ manager._wait_for_postgres(container, timeout_seconds=5)
332
+ command = container.exec_run.call_args.args[0]
333
+
334
+ proc = tmp_path / "proc"
335
+ (proc / "1/task/1").mkdir(parents=True)
336
+ (proc / "1/comm").write_text(pid_one + "\n")
337
+ (proc / "1/task/1/children").write_text(children)
338
+ for pid, process in child_processes.items():
339
+ (proc / pid).mkdir()
340
+ (proc / pid / "comm").write_text(process + "\n")
341
+ probe = tmp_path / "pg_isready"
342
+ probe.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n')
343
+ probe.chmod(0o755)
344
+ script = command[2].replace("/proc/", "./proc/").replace("exec pg_isready", "exec ./pg_isready")
345
+ result = subprocess.run(
346
+ [*command[:2], script, *command[3:]], cwd=tmp_path, text=True, capture_output=True
347
+ )
348
+ assert (result.returncode == 0) is ready, result.stderr
349
+ if ready:
350
+ assert result.stdout.splitlines() == ["--username", "postgres", "--dbname", "postgres"]
351
+ else:
352
+ assert "waiting for final postgres process" in result.stdout
353
+
354
+
280
355
  def test_active_lock_rejects_a_second_project() -> None:
281
356
  config = load_config(ASSET_ROOT / "configs" / "single.yaml", environment={})
282
357
  client = MagicMock()
@@ -325,6 +400,40 @@ def test_active_lock_is_created_with_exclusive_managed_identity() -> None:
325
400
  assert kwargs["labels"][RESOURCE_KIND_LABEL] == "active-lock"
326
401
 
327
402
 
403
+ @pytest.mark.parametrize("applied", [False, True])
404
+ def test_plan_reports_foreign_active_stand_before_claiming_it_can_apply(
405
+ tmp_path: Path,
406
+ applied: bool,
407
+ ) -> None:
408
+ config = load_config(ASSET_ROOT / "configs" / "single.yaml", project_directory=tmp_path)
409
+ client = MagicMock()
410
+ client.networks.list.return_value = [
411
+ _Resource(
412
+ {
413
+ "Labels": {
414
+ PROJECT_LABEL: "another-stand",
415
+ MANAGED_LABEL: "true",
416
+ RESOURCE_KIND_LABEL: "active-lock",
417
+ }
418
+ },
419
+ name=ACTIVE_LOCK_NETWORK,
420
+ ),
421
+ ]
422
+ manager = StandManager(config, client=client)
423
+ if applied:
424
+ manager._record_applied_state()
425
+ blocked = manager.plan()
426
+ assert blocked["required_action"] == "blocked"
427
+ assert blocked["can_apply"] is False
428
+ assert "another-stand" in blocked["reason"]
429
+ client.networks.create.assert_not_called()
430
+ client.containers.run.assert_not_called()
431
+ client.networks.list.return_value = []
432
+ available = manager.plan()
433
+ assert available["required_action"] == ("none" if applied else "up")
434
+ assert available["plan_hash"] != blocked["plan_hash"]
435
+
436
+
328
437
  @pytest.mark.parametrize(
329
438
  ("version", "pgdata", "data_mount"),
330
439
  [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes