pg-maker 0.1.0__py3-none-any.whl

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 (181) hide show
  1. pg_maker/__init__.py +3 -0
  2. pg_maker/__main__.py +3 -0
  3. pg_maker/api.py +594 -0
  4. pg_maker/artifacts/__init__.py +4 -0
  5. pg_maker/artifacts/catalog.py +67 -0
  6. pg_maker/artifacts/manifest.py +301 -0
  7. pg_maker/assets.py +93 -0
  8. pg_maker/builders/__init__.py +4 -0
  9. pg_maker/builders/autotools.py +430 -0
  10. pg_maker/builders/components.py +359 -0
  11. pg_maker/bundled/PROJECT.md +57 -0
  12. pg_maker/bundled/build.yaml +29 -0
  13. pg_maker/bundled/components/pg-stat-kcache.yaml +30 -0
  14. pg_maker/bundled/components/pg-stat-statements.yaml +24 -0
  15. pg_maker/bundled/components/pg-wait-sampling.yaml +30 -0
  16. pg_maker/bundled/config/defaults.yaml +80 -0
  17. pg_maker/bundled/config/documentation.yaml +24 -0
  18. pg_maker/bundled/config/features/my-feature.yaml +17 -0
  19. pg_maker/bundled/config/vendor.yaml +13 -0
  20. pg_maker/bundled/documentation/README.md +20 -0
  21. pg_maker/bundled/documentation/pg-stat-kcache.sgml +99 -0
  22. pg_maker/bundled/documentation/pg-stat-kcache.yaml +15 -0
  23. pg_maker/bundled/documentation/pg-wait-sampling.sgml +122 -0
  24. pg_maker/bundled/documentation/pg-wait-sampling.yaml +15 -0
  25. pg_maker/bundled/patches/unix-socket-directory.patch +5 -0
  26. pg_maker/changes/__init__.py +1 -0
  27. pg_maker/changes/apply.py +298 -0
  28. pg_maker/changes/loader.py +135 -0
  29. pg_maker/changes/workspace.py +221 -0
  30. pg_maker/cli.py +546 -0
  31. pg_maker/components/__init__.py +4 -0
  32. pg_maker/config.py +514 -0
  33. pg_maker/development/__init__.py +1 -0
  34. pg_maker/development/cli.py +104 -0
  35. pg_maker/development/git.py +362 -0
  36. pg_maker/development/session.py +735 -0
  37. pg_maker/documentation/__init__.py +4 -0
  38. pg_maker/documentation/builder.py +467 -0
  39. pg_maker/documentation/cli.py +24 -0
  40. pg_maker/documentation/config.py +127 -0
  41. pg_maker/documentation/install.py +79 -0
  42. pg_maker/documentation/site.py +166 -0
  43. pg_maker/documentation/sphinx_ext.py +150 -0
  44. pg_maker/documentation/theme/_static/pg-maker.css +26 -0
  45. pg_maker/documentation/theme/_static/pg-maker.js +35 -0
  46. pg_maker/documentation/theme/_templates/footer_en.html +5 -0
  47. pg_maker/documentation/theme/_templates/footer_metadata.html +13 -0
  48. pg_maker/documentation/theme/_templates/footer_ru.html +5 -0
  49. pg_maker/documentation/translation.py +341 -0
  50. pg_maker/documentation/validation.py +132 -0
  51. pg_maker/documentation/worker.py +62 -0
  52. pg_maker/documentation/writer.py +324 -0
  53. pg_maker/documentation/xml.py +210 -0
  54. pg_maker/documents.py +118 -0
  55. pg_maker/errors.py +50 -0
  56. pg_maker/execution/__init__.py +1 -0
  57. pg_maker/execution/build.py +502 -0
  58. pg_maker/execution/cache.py +287 -0
  59. pg_maker/execution/cancellation.py +60 -0
  60. pg_maker/execution/checkpoints.py +218 -0
  61. pg_maker/execution/cleanup.py +122 -0
  62. pg_maker/execution/component_cache.py +164 -0
  63. pg_maker/execution/development.py +109 -0
  64. pg_maker/execution/engine.py +171 -0
  65. pg_maker/execution/events.py +65 -0
  66. pg_maker/execution/incremental.py +178 -0
  67. pg_maker/execution/preparation.py +130 -0
  68. pg_maker/execution/process.py +155 -0
  69. pg_maker/execution/resources.py +41 -0
  70. pg_maker/execution/state.py +82 -0
  71. pg_maker/execution/worker.py +45 -0
  72. pg_maker/identity.py +116 -0
  73. pg_maker/integrations/__init__.py +1 -0
  74. pg_maker/integrations/machine.py +163 -0
  75. pg_maker/integrations/runtime.py +387 -0
  76. pg_maker/models.py +245 -0
  77. pg_maker/packaging/__init__.py +4 -0
  78. pg_maker/packaging/deb.py +337 -0
  79. pg_maker/packaging/service.py +165 -0
  80. pg_maker/packaging/spec.py +64 -0
  81. pg_maker/packaging/templates/deb-control.in +8 -0
  82. pg_maker/packaging/tree.py +239 -0
  83. pg_maker/planning/__init__.py +1 -0
  84. pg_maker/planning/documents.py +65 -0
  85. pg_maker/planning/environments.py +318 -0
  86. pg_maker/planning/graph.py +23 -0
  87. pg_maker/planning/plan.py +418 -0
  88. pg_maker/planning/resolve.py +117 -0
  89. pg_maker/planning/reuse.py +80 -0
  90. pg_maker/planning/runtime_repository.py +90 -0
  91. pg_maker/postgresql.py +59 -0
  92. pg_maker/progress.py +64 -0
  93. pg_maker/publishing/__init__.py +4 -0
  94. pg_maker/py.typed +0 -0
  95. pg_maker/recipes/README.md +13 -0
  96. pg_maker/recipes/platforms/debian-13.yaml +125 -0
  97. pg_maker/recipes/postgresql.yaml +114 -0
  98. pg_maker/runners/__init__.py +1 -0
  99. pg_maker/runners/base.py +29 -0
  100. pg_maker/runners/docker.py +312 -0
  101. pg_maker/schemas/build.schema.json +5 -0
  102. pg_maker/schemas/change-set.schema.json +5 -0
  103. pg_maker/schemas/component-envelope.schema.json +69 -0
  104. pg_maker/schemas/component.schema.json +5 -0
  105. pg_maker/schemas/defaults.schema.json +5 -0
  106. pg_maker/schemas/doc-rules.schema.json +5 -0
  107. pg_maker/schemas/documentation.schema.json +5 -0
  108. pg_maker/schemas/environment.schema.json +228 -0
  109. pg_maker/schemas/feature.schema.json +5 -0
  110. pg_maker/schemas/insertions.schema.json +5 -0
  111. pg_maker/schemas/lock.schema.json +3963 -0
  112. pg_maker/schemas/operation-input.schema.json +306 -0
  113. pg_maker/schemas/operation-state.schema.json +127 -0
  114. pg_maker/schemas/plan.schema.json +284 -0
  115. pg_maker/schemas/planning-common.schema.json +8192 -0
  116. pg_maker/schemas/platform.schema.json +5 -0
  117. pg_maker/schemas/postgresql-catalog.schema.json +5 -0
  118. pg_maker/schemas/run-event.schema.json +72 -0
  119. pg_maker/schemas/translations.schema.json +5 -0
  120. pg_maker/schemas/vendor.schema.json +5 -0
  121. pg_maker/security.py +73 -0
  122. pg_maker/sources/__init__.py +1 -0
  123. pg_maker/sources/resolver.py +238 -0
  124. pg_maker/sources/store.py +279 -0
  125. pg_maker/storage.py +127 -0
  126. pg_maker/validation/__init__.py +4 -0
  127. pg_maker/validation/installed.py +667 -0
  128. pg_maker/validation/packages.py +309 -0
  129. pg_maker/validation/systemctl.py +185 -0
  130. pg_maker/yamlio.py +54 -0
  131. pg_maker-0.1.0.data/data/share/doc/pg-maker/CHANGELOG.md +22 -0
  132. pg_maker-0.1.0.data/data/share/doc/pg-maker/CONTRIBUTING.md +63 -0
  133. pg_maker-0.1.0.data/data/share/doc/pg-maker/LICENSE +21 -0
  134. pg_maker-0.1.0.data/data/share/doc/pg-maker/README.md +163 -0
  135. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/architecture.md +55 -0
  136. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/development-storage.md +72 -0
  137. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/development.md +86 -0
  138. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/README.md +32 -0
  139. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/api.md +75 -0
  140. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/build.md +112 -0
  141. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/changes.md +58 -0
  142. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/cleanup.md +17 -0
  143. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/components.md +39 -0
  144. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/configuration.md +83 -0
  145. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/development.md +88 -0
  146. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/documentation.md +241 -0
  147. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/documents.md +42 -0
  148. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/environments.md +40 -0
  149. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/execution.md +49 -0
  150. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/machine.md +31 -0
  151. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/observability.md +28 -0
  152. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/packaging.md +107 -0
  153. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/planning.md +63 -0
  154. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/postgresql.md +79 -0
  155. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/publication.md +20 -0
  156. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/recovery.md +83 -0
  157. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/runtime.md +31 -0
  158. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/sources.md +48 -0
  159. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/state.md +49 -0
  160. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/storage.md +24 -0
  161. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/releasing.md +98 -0
  162. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/reuse.md +24 -0
  163. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/roadmap.md +54 -0
  164. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/default-components.md +56 -0
  165. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/development-release.md +49 -0
  166. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/documentation-theme.md +45 -0
  167. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/integration.md +49 -0
  168. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/postgresql-majors.md +107 -0
  169. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/review-fixes.md +68 -0
  170. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage4.md +65 -0
  171. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage56.md +66 -0
  172. pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage7.md +72 -0
  173. pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/guc/README.md +11 -0
  174. pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/stage4/README.md +31 -0
  175. pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/stage7/README.md +16 -0
  176. pg_maker-0.1.0.dist-info/METADATA +200 -0
  177. pg_maker-0.1.0.dist-info/RECORD +181 -0
  178. pg_maker-0.1.0.dist-info/WHEEL +5 -0
  179. pg_maker-0.1.0.dist-info/entry_points.txt +4 -0
  180. pg_maker-0.1.0.dist-info/licenses/LICENSE +21 -0
  181. pg_maker-0.1.0.dist-info/top_level.txt +1 -0
pg_maker/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PostgreSQL distribution maker. Public services live in pg_maker.api."""
2
+
3
+ __version__ = "0.1.0"
pg_maker/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pg_maker.cli import main
2
+
3
+ raise SystemExit(main())
pg_maker/api.py ADDED
@@ -0,0 +1,594 @@
1
+ """Public CLI-independent service. All operation paths are explicit and absolute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ import uuid
12
+ from contextlib import ExitStack
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pg_maker.errors import OwnershipError, PreconditionError, ValidationError
17
+ from pg_maker.execution.cancellation import process_identity, stop_process_group
18
+ from pg_maker.execution.events import read_events
19
+ from pg_maker.execution.state import OperationStore
20
+ from pg_maker.models import (
21
+ TERMINAL_STATUSES,
22
+ BuildRequest,
23
+ CleanupPolicy,
24
+ EnvironmentSpec,
25
+ OperationRequest,
26
+ PreparationRequest,
27
+ RuntimeRequest,
28
+ parse_request,
29
+ )
30
+ from pg_maker.runners.docker import DockerRunner
31
+ from pg_maker.storage import (
32
+ content_hash,
33
+ ensure_directory,
34
+ exclusive_lock,
35
+ private_open,
36
+ read_json,
37
+ utc_now,
38
+ write_json,
39
+ )
40
+
41
+
42
+ class MakerService:
43
+ def __init__(self, operations_directory: Path) -> None:
44
+ if not operations_directory.is_absolute():
45
+ raise ValidationError("operations_directory must be absolute")
46
+ self.directory = operations_directory
47
+ self._children: dict[str, subprocess.Popen] = {}
48
+
49
+ @staticmethod
50
+ def _reference(directory: Path) -> Path:
51
+ if not directory.is_absolute() or not directory.is_dir():
52
+ raise PreconditionError("operation reference must be an existing absolute directory")
53
+ for path in (directory, *directory.parents):
54
+ if path.is_symlink():
55
+ raise OwnershipError("operation reference must not traverse symbolic links")
56
+ if directory.stat().st_uid != os.getuid():
57
+ raise OwnershipError("operation directory belongs to another uid")
58
+ return directory
59
+
60
+ def doctor(self) -> dict[str, Any]:
61
+ with tempfile.TemporaryDirectory(prefix="pg-maker-doctor-") as name:
62
+ return DockerRunner(
63
+ Path(name), operation_id="doctor", owner_token=uuid.uuid4().hex
64
+ ).doctor()
65
+
66
+ def dev_open(
67
+ self,
68
+ directory: Path,
69
+ *,
70
+ lock=None,
71
+ from_run=None,
72
+ feature=None,
73
+ input_store=None,
74
+ repository=None,
75
+ ):
76
+ from pg_maker.development.session import DevelopmentSession
77
+
78
+ return DevelopmentSession(directory).open(
79
+ lock=lock,
80
+ from_run=from_run,
81
+ feature=feature,
82
+ input_store=input_store,
83
+ repository=repository,
84
+ )
85
+
86
+ def dev_status(self, directory: Path):
87
+ from pg_maker.development.session import DevelopmentSession
88
+
89
+ return DevelopmentSession(directory).status()
90
+
91
+ def dev_patch(self, directory: Path, action: str, **options):
92
+ from pg_maker.development.session import DevelopmentSession
93
+
94
+ return DevelopmentSession(directory).patch(action, **options)
95
+
96
+ def dev_build(self, directory: Path, *, goal="build", components=None):
97
+ from pg_maker.development.session import DevelopmentSession
98
+
99
+ return DevelopmentSession(directory).build(goal=goal, components=components)
100
+
101
+ def dev_explain(self, directory: Path, *, goal="build", components=None):
102
+ from pg_maker.development.session import DevelopmentSession
103
+
104
+ return DevelopmentSession(directory).explain(goal=goal, components=components)
105
+
106
+ def dev_execute(self, directory: Path, argv, **options):
107
+ from pg_maker.development.session import DevelopmentSession
108
+
109
+ return DevelopmentSession(directory).execute(argv, **options)
110
+
111
+ def dev_pin(self, directory: Path, iteration: str, *, pinned=True):
112
+ from pg_maker.development.session import DevelopmentSession
113
+
114
+ return DevelopmentSession(directory).pin(iteration, pinned=pinned)
115
+
116
+ def dev_prune(self, directory: Path, *, keep_last=2, apply=None):
117
+ from pg_maker.development.session import DevelopmentSession
118
+
119
+ return DevelopmentSession(directory).prune(keep_last=keep_last, apply=apply)
120
+
121
+ def start_operation(
122
+ self,
123
+ request: OperationRequest | PreparationRequest | BuildRequest | RuntimeRequest,
124
+ *,
125
+ operation_id: str | None = None,
126
+ request_id: str | None = None,
127
+ ) -> dict[str, Any]:
128
+ document = request.to_document()
129
+ request = parse_request(document)
130
+ if request_id is not None and (
131
+ not isinstance(request_id, str) or not 1 <= len(request_id) <= 256
132
+ ):
133
+ raise ValidationError("request_id must contain 1..256 characters")
134
+ operation_id = operation_id or "op-" + uuid.uuid4().hex
135
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}", operation_id):
136
+ raise ValidationError("invalid operation_id")
137
+ input_hash = content_hash(document)
138
+ ensure_directory(self.directory)
139
+ with exclusive_lock(self.directory / ".start.lock"):
140
+ index = (
141
+ self.directory / ".requests" / (content_hash(request_id).split(":")[1] + ".json")
142
+ )
143
+ if request_id is not None and index.exists():
144
+ previous = read_json(index)
145
+ if previous["input_hash"] != input_hash:
146
+ raise PreconditionError("request_id was already used with different inputs")
147
+ return self.status(self.directory / previous["operation_id"])
148
+ directory = self.directory / operation_id
149
+ try:
150
+ directory.mkdir(mode=0o700)
151
+ except FileExistsError as exc:
152
+ raise OwnershipError("operation_id already exists") from exc
153
+ write_json(directory / "input.json", document)
154
+ store = OperationStore(directory)
155
+ created = utc_now()
156
+ with exclusive_lock(directory / "owner.lock") as owner_fd:
157
+ with exclusive_lock(directory / "control.lock"):
158
+ state = store.record(
159
+ "operation_created",
160
+ initial={
161
+ "api_version": "pg_maker/operation/v1",
162
+ "kind": "OperationState",
163
+ "operation_id": operation_id,
164
+ "operation_kind": request.operation_kind,
165
+ "input_hash": input_hash,
166
+ "request_id": request_id,
167
+ "status": "planned",
168
+ "owner": None,
169
+ "owner_token": uuid.uuid4().hex,
170
+ "attempt": 1,
171
+ "sequence": 0,
172
+ "created_at": created,
173
+ "updated_at": created,
174
+ "progress": {"completed": 0, "total": len(request.commands)},
175
+ "resources": [],
176
+ "result": None,
177
+ "error": None,
178
+ },
179
+ )
180
+ if request_id is not None:
181
+ write_json(index, {"operation_id": operation_id, "input_hash": input_hash})
182
+ try:
183
+ with os.fdopen(
184
+ private_open(directory / "worker.log", append=True), "wb"
185
+ ) as log:
186
+ child = subprocess.Popen(
187
+ [
188
+ sys.executable,
189
+ "-m",
190
+ "pg_maker.execution.worker",
191
+ "--directory",
192
+ str(directory),
193
+ "--owner-fd",
194
+ str(owner_fd),
195
+ ],
196
+ stdin=subprocess.DEVNULL,
197
+ stdout=log,
198
+ stderr=log,
199
+ start_new_session=True,
200
+ pass_fds=(owner_fd,),
201
+ )
202
+ self._children[operation_id] = child
203
+ state = store.record("worker_started", owner=process_identity(child.pid))
204
+ except OSError as exc:
205
+ error = PreconditionError("cannot start operation worker")
206
+ store.record("worker_start_failed", status="failed", error=error.as_dict())
207
+ raise error from exc
208
+ return state
209
+
210
+ def status(self, directory: Path) -> dict[str, Any]:
211
+ directory = self._reference(directory)
212
+ child = self._children.get(directory.name)
213
+ if child and child.poll() is not None:
214
+ del self._children[directory.name]
215
+ # A live lock is stronger than a PID check and closes the worker-start race.
216
+ with ExitStack() as ownership:
217
+ try:
218
+ ownership.enter_context(exclusive_lock(directory / "owner.lock", blocking=False))
219
+ except OwnershipError:
220
+ with exclusive_lock(directory / "control.lock"):
221
+ return OperationStore(directory).load()
222
+ with exclusive_lock(directory / "control.lock"):
223
+ store = OperationStore(directory)
224
+ state = store.load(repair=True)
225
+ if state["status"] not in TERMINAL_STATUSES:
226
+ state = store.record(
227
+ "worker_lost",
228
+ status="interrupted",
229
+ owner=None,
230
+ error={
231
+ "code": "worker_lost",
232
+ "message": "operation worker is no longer running",
233
+ "details": {},
234
+ },
235
+ )
236
+ return state
237
+
238
+ def resume(
239
+ self,
240
+ directory: Path,
241
+ *,
242
+ plan_hash: str | None = None,
243
+ input_hash: str | None = None,
244
+ ) -> dict:
245
+ from pg_maker.errors import UnsupportedError
246
+ from pg_maker.planning.plan import build_plan
247
+
248
+ directory = self._reference(directory)
249
+ state = self.status(directory)
250
+ if (directory / "scratch-cleaned.json").exists():
251
+ raise PreconditionError(
252
+ "operation scratch was cleaned; start a new operation from exported inputs"
253
+ )
254
+ if state["status"] not in {"failed", "cancelled", "interrupted", "partial"}:
255
+ raise PreconditionError(
256
+ "only failed, partial, cancelled or interrupted operations can resume"
257
+ )
258
+ document = read_json(directory / "input.json")
259
+ if content_hash(document) != state["input_hash"]:
260
+ raise PreconditionError("operation input hash changed")
261
+ request = parse_request(document)
262
+ if isinstance(request, BuildRequest):
263
+ if plan_hash is None or plan_hash != request.plan["plan_hash"]:
264
+ raise PreconditionError("resume requires the original --plan-hash")
265
+ expected = build_plan(
266
+ request.lock,
267
+ goal=request.plan["spec"]["goal"],
268
+ input_store=Path(request.input_store),
269
+ run_db_tests=request.plan["spec"]["run_db_tests"],
270
+ components=request.plan["spec"].get("components"),
271
+ )
272
+ if expected["plan_hash"] != plan_hash:
273
+ raise PreconditionError("resume plan differs from the current required graph")
274
+ elif isinstance(request, RuntimeRequest):
275
+ from pg_maker.integrations.runtime import verify_runtime_plan
276
+
277
+ verify_runtime_plan(request.plan, plan_hash)
278
+ elif isinstance(request, PreparationRequest):
279
+ if input_hash is None or input_hash != state["input_hash"]:
280
+ raise PreconditionError("preparation resume requires the original --input-hash")
281
+ else:
282
+ raise UnsupportedError("arbitrary container command operations cannot resume")
283
+ # Reconcile only the exact recorded resources, before granting a new attempt ownership.
284
+ self.cancel(directory)
285
+ with exclusive_lock(directory / "owner.lock", blocking=False) as owner_fd:
286
+ with exclusive_lock(directory / "control.lock"):
287
+ store = OperationStore(directory)
288
+ state = store.load(repair=True)
289
+ if (
290
+ state["status"] not in {"failed", "cancelled", "interrupted", "partial"}
291
+ or state["resources"]
292
+ ):
293
+ raise OwnershipError("operation changed or still owns live resources")
294
+ attempt = state["attempt"] + 1
295
+ previous = directory / "attempts" / str(state["attempt"])
296
+ write_json(previous / "state.json", state)
297
+ if (directory / "cancel.json").exists():
298
+ os.replace(directory / "cancel.json", previous / "cancel.json")
299
+ state = store.record(
300
+ "operation_resumed",
301
+ status="planned",
302
+ attempt=attempt,
303
+ owner=None,
304
+ owner_token=uuid.uuid4().hex,
305
+ result=None,
306
+ error=None,
307
+ progress={"completed": 0, "total": len(request.commands)},
308
+ )
309
+ try:
310
+ with os.fdopen(
311
+ private_open(directory / "worker.log", append=True), "wb"
312
+ ) as log:
313
+ child = subprocess.Popen(
314
+ [
315
+ sys.executable,
316
+ "-m",
317
+ "pg_maker.execution.worker",
318
+ "--directory",
319
+ str(directory),
320
+ "--owner-fd",
321
+ str(owner_fd),
322
+ ],
323
+ stdin=subprocess.DEVNULL,
324
+ stdout=log,
325
+ stderr=log,
326
+ start_new_session=True,
327
+ pass_fds=(owner_fd,),
328
+ )
329
+ self._children[directory.name] = child
330
+ return store.record("worker_started", owner=process_identity(child.pid))
331
+ except OSError as exc:
332
+ error = PreconditionError("cannot start resumed worker")
333
+ store.record("worker_start_failed", status="failed", error=error.as_dict())
334
+ raise error from exc
335
+
336
+ def events(
337
+ self, directory: Path, *, after_sequence: int = 0, limit: int = 100
338
+ ) -> dict[str, Any]:
339
+ directory = self._reference(directory)
340
+ with exclusive_lock(directory / "control.lock"):
341
+ events, last, _ = read_events(
342
+ directory / "events.jsonl", after_sequence=after_sequence, limit=limit
343
+ )
344
+ cursor = events[-1]["sequence"] if events else after_sequence
345
+ return {
346
+ "events": events,
347
+ "last_sequence": cursor,
348
+ "has_more": last is not None and last["sequence"] > cursor,
349
+ }
350
+
351
+ def cancel(self, directory: Path) -> dict[str, Any]:
352
+ directory = self._reference(directory)
353
+ with exclusive_lock(directory / "control.lock"):
354
+ state = OperationStore(directory).load()
355
+ if state["status"] not in TERMINAL_STATUSES:
356
+ write_json(directory / "cancel.json", {"requested_at": utc_now()})
357
+ state = self.status(directory)
358
+ if state["status"] == "interrupted" or (
359
+ state["status"] in TERMINAL_STATUSES and state["resources"]
360
+ ):
361
+ # A lost worker cannot consume the request. Clean only its recorded exact resources.
362
+ with exclusive_lock(directory / "owner.lock", blocking=False):
363
+ with exclusive_lock(directory / "control.lock"):
364
+ store = OperationStore(directory)
365
+ state = store.load(repair=True)
366
+ if state["resources"]:
367
+ payload = read_json(directory / "input.json")
368
+ if content_hash(payload) != state["input_hash"]:
369
+ raise PreconditionError("operation input hash changed")
370
+ request = parse_request(payload)
371
+ runner = DockerRunner(
372
+ directory,
373
+ operation_id=state["operation_id"],
374
+ owner_token=state["owner_token"],
375
+ )
376
+ store.record("cleanup_requested")
377
+ for resource in state["resources"]:
378
+ if resource["kind"] == "process":
379
+ stop_process_group(resource["identity"], grace_seconds=1)
380
+ elif resource["kind"] == "docker":
381
+ environment = (
382
+ request.environment
383
+ if isinstance(request, OperationRequest)
384
+ else EnvironmentSpec(**resource["environment"])
385
+ )
386
+ handle = runner.intended_handle(environment)
387
+ from dataclasses import replace
388
+
389
+ handle = replace(handle, container_id=resource.get("container_id"))
390
+ if (
391
+ handle.name != resource["name"]
392
+ or handle.labels != resource["labels"]
393
+ ):
394
+ raise OwnershipError(
395
+ "recorded resource does not match operation owner"
396
+ )
397
+ runner.release(handle, CleanupPolicy.REMOVE)
398
+ state = store.record("cleanup_finished", resources=[])
399
+ return state
400
+
401
+ def config_show(self, manifest: Path, **options) -> dict:
402
+ from pg_maker.config import load_config
403
+
404
+ return load_config(manifest, **options).to_document()
405
+
406
+ def export(self, directory: Path, destination: Path, *, include_inputs=False) -> dict:
407
+ from pg_maker.artifacts.manifest import export_operation
408
+
409
+ directory = self._reference(directory)
410
+ state = self.status(directory)
411
+ if state["status"] not in TERMINAL_STATUSES:
412
+ raise PreconditionError("export requires a finished operation")
413
+ with exclusive_lock(directory / "owner.lock", blocking=False):
414
+ return export_operation(
415
+ directory, destination.absolute(), include_inputs=include_inputs
416
+ )
417
+
418
+ def validate(self, manifest: Path, **options) -> dict:
419
+ from pg_maker.config import environment_requirements, load_config
420
+
421
+ config = load_config(manifest, **options)
422
+ requirements = environment_requirements(config.data)
423
+ return {
424
+ "valid": True,
425
+ "variants": [v["id"] for v in config.data["variants"]],
426
+ "environment_requirements_hash": content_hash(requirements),
427
+ }
428
+
429
+ def prepare(
430
+ self,
431
+ manifest: Path,
432
+ *,
433
+ output: Path,
434
+ input_store: Path,
435
+ environments: Path | None = None,
436
+ offline: bool = False,
437
+ request_id: str | None = None,
438
+ operation_id: str | None = None,
439
+ **options,
440
+ ) -> dict:
441
+ from pg_maker.config import environment_requirements, load_config
442
+ from pg_maker.planning.documents import verify
443
+
444
+ config = load_config(manifest, **options)
445
+ requirements = environment_requirements(config.data)
446
+ env = read_json(environments) if environments else None
447
+ if env is not None:
448
+ verify(env, "environment")
449
+ if env["spec"]["requirements_hash"] != content_hash(requirements):
450
+ raise PreconditionError("prepared environments are stale; run env prepare")
451
+ request = PreparationRequest(
452
+ "resolve_inputs" if env is not None else "prepare_environments",
453
+ config.data,
454
+ config.bindings,
455
+ str(output.absolute()),
456
+ str(input_store.absolute()),
457
+ env,
458
+ offline,
459
+ )
460
+ return self.start_operation(request, request_id=request_id, operation_id=operation_id)
461
+
462
+ def prepare_environments(
463
+ self, manifest: Path, *, output: Path, input_store: Path, **options
464
+ ) -> dict:
465
+ return self.prepare(manifest, output=output, input_store=input_store, **options)
466
+
467
+ def resolve(
468
+ self, manifest: Path, *, environments: Path, output: Path, input_store: Path, **options
469
+ ) -> dict:
470
+ return self.prepare(
471
+ manifest, environments=environments, output=output, input_store=input_store, **options
472
+ )
473
+
474
+ def plan(
475
+ self,
476
+ lock: Path,
477
+ *,
478
+ goal: str = "build",
479
+ input_store: Path | None = None,
480
+ run_db_tests: bool | None = None,
481
+ ) -> dict:
482
+ from pg_maker.planning.plan import build_plan
483
+
484
+ result = build_plan(
485
+ read_json(lock), goal=goal, input_store=input_store, run_db_tests=run_db_tests
486
+ )
487
+ result["transport"]["lock"] = str(lock.absolute())
488
+ return result
489
+
490
+ def wait(self, directory: Path, *, timeout_seconds: int | None = None) -> dict[str, Any]:
491
+ if timeout_seconds is not None and (
492
+ type(timeout_seconds) is not int or timeout_seconds <= 0
493
+ ):
494
+ raise ValidationError("timeout_seconds must be a positive integer")
495
+ start = time.monotonic()
496
+ while True:
497
+ state = self.status(directory)
498
+ if state["status"] in TERMINAL_STATUSES:
499
+ return state
500
+ if timeout_seconds is not None and time.monotonic() - start >= timeout_seconds:
501
+ raise PreconditionError("waiting timed out; operation is still running")
502
+ time.sleep(0.1)
503
+
504
+ def build(
505
+ self,
506
+ lock: Path,
507
+ *,
508
+ goal: str | None = None,
509
+ plan: Path | None = None,
510
+ plan_hash: str | None = None,
511
+ run_db_tests: bool | None = None,
512
+ input_store: Path | None = None,
513
+ cache_store: Path | None = None,
514
+ use_cache: bool = True,
515
+ operation_id: str | None = None,
516
+ request_id: str | None = None,
517
+ with_doc: bool | None = None,
518
+ doc_languages: list[str] | None = None,
519
+ features: list[str] | None = None,
520
+ build_mode: str | None = None,
521
+ components: list[str] | None = None,
522
+ reuse_from: Path | None = None,
523
+ ) -> dict:
524
+ from pg_maker.builders.autotools import validate_build
525
+ from pg_maker.planning.documents import verify
526
+ from pg_maker.planning.plan import build_plan
527
+
528
+ locked = read_json(lock)
529
+ if locked.get("kind") == "BuildPlan":
530
+ if plan is not None:
531
+ raise ValidationError("supply either a positional plan or --plan")
532
+ plan = lock
533
+ reference = locked.get("transport", {}).get("lock")
534
+ if not reference:
535
+ raise PreconditionError("plan has no lock transport reference")
536
+ locked = read_json(Path(reference))
537
+ verify(locked, "lock")
538
+ config = locked["spec"]["config"]
539
+ if doc_languages is not None:
540
+ if not config["documentation"]["enabled"]:
541
+ raise ValidationError("doc-language requires enabled documentation")
542
+ aliases = config["documentation"].get("language_aliases", {})
543
+ doc_languages = [aliases.get(value, value) for value in doc_languages]
544
+ assertions = (
545
+ (with_doc, config["documentation"]["enabled"], "with-doc"),
546
+ (doc_languages, config["documentation"]["languages"], "doc-language"),
547
+ (features, [f["metadata"]["id"] for f in config["features"]], "feature"),
548
+ (build_mode, config["defaults"]["build"]["mode"], "build-mode"),
549
+ )
550
+ for expected_value, actual, name in assertions:
551
+ if expected_value is not None and expected_value != actual:
552
+ raise PreconditionError(name + " differs from locked configuration; resolve again")
553
+ supplied = None
554
+ if plan is not None:
555
+ supplied = read_json(plan)
556
+ verify(supplied, "plan")
557
+ if goal is None:
558
+ goal = supplied["spec"]["goal"]
559
+ if run_db_tests is None:
560
+ run_db_tests = supplied["spec"]["run_db_tests"]
561
+ if components is None:
562
+ components = supplied["spec"].get("components")
563
+ goal = goal or "build"
564
+ expected = build_plan(
565
+ locked,
566
+ goal=goal,
567
+ input_store=input_store,
568
+ run_db_tests=run_db_tests,
569
+ components=components,
570
+ )
571
+ validate_build(
572
+ locked["spec"]["config"], goal, run_db_tests=expected["spec"]["run_db_tests"]
573
+ )
574
+ if goal == "verified" and any(
575
+ not image.get("runtime_repository")
576
+ for image in locked["spec"]["environments"]["spec"]["images"]
577
+ if image["role"] == "build"
578
+ ):
579
+ raise PreconditionError("verified goal requires a prepared offline runtime repository")
580
+ # Regenerating a plan must not change an idempotent build request's input hash.
581
+ expected["created_at"] = locked["created_at"]
582
+ if supplied is not None:
583
+ if supplied["plan_hash"] != expected["plan_hash"]:
584
+ raise PreconditionError("supplied plan differs from the required build graph")
585
+ if plan_hash is not None and plan_hash != expected["plan_hash"]:
586
+ raise PreconditionError("plan hash does not match")
587
+ request = BuildRequest(
588
+ locked,
589
+ expected,
590
+ expected["transport"]["input_store"],
591
+ str((cache_store or self.directory / ".cache").absolute()) if use_cache else None,
592
+ reuse_from=str(reuse_from.absolute()) if reuse_from else None,
593
+ )
594
+ return self.start_operation(request, operation_id=operation_id, request_id=request_id)
@@ -0,0 +1,4 @@
1
+ """Reserved project boundary; planned contract: docs/interfaces/packaging.md.
2
+
3
+ Concrete adapters are introduced with their implementation stage.
4
+ """
@@ -0,0 +1,67 @@
1
+ """Discover the bounded set of public outputs of an operation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pg_maker.artifacts.manifest import verify_manifest
6
+ from pg_maker.documents import relative_output
7
+ from pg_maker.errors import PreconditionError
8
+ from pg_maker.storage import file_hash, read_json
9
+
10
+
11
+ def operation_artifacts(directory: Path, state: dict) -> dict:
12
+ references = []
13
+
14
+ def add(path, role):
15
+ if path.is_file() and not path.is_symlink():
16
+ references.append(
17
+ {
18
+ "path": str(path),
19
+ "role": role,
20
+ "sha256": file_hash(path),
21
+ "size_bytes": path.stat().st_size,
22
+ }
23
+ )
24
+
25
+ add(directory / "state.json", "operation_state")
26
+ add(directory / "events.jsonl", "operation_events")
27
+ for path in sorted((directory / "workspace/output").glob("*/artifact-manifest.json")):
28
+ manifest = read_json(path)
29
+ verify_manifest(path.parent, manifest)
30
+ add(path, "artifact_manifest")
31
+ for artifact in manifest["artifacts"]:
32
+ references.append({**artifact, "path": str(path.parent / artifact["path"])})
33
+ for name, role in (
34
+ ("report.json", "report"),
35
+ ("report.html", "report_html"),
36
+ ("verification.json", "verification"),
37
+ ("docs-manifest.json", "documentation_manifest"),
38
+ ):
39
+ add(path.parent / name, role)
40
+ result = state.get("result") or {}
41
+ for artifact in result.get("artifacts", []):
42
+ relative = artifact["path"]
43
+ if not relative_output(relative):
44
+ raise PreconditionError("operation output reference must be relative")
45
+ path = directory / relative
46
+ if (
47
+ any(p.is_symlink() for p in (path, *path.parents))
48
+ or file_hash(path) != artifact["sha256"]
49
+ ):
50
+ raise PreconditionError("operation output changed")
51
+ add(path, "operation_output")
52
+ if result.get("document"):
53
+ path = Path(result["document"])
54
+ if file_hash(path) != result["sha256"]:
55
+ raise PreconditionError("operation output changed")
56
+ add(path, "operation_output")
57
+ for report in result.get("reports", []):
58
+ if file_hash(Path(report["path"])) != report["sha256"]:
59
+ raise PreconditionError("operation report changed")
60
+ if report["path"] not in {r["path"] for r in references}:
61
+ add(Path(report["path"]), "report")
62
+ return {
63
+ "operation_id": state["operation_id"],
64
+ "status": state["status"],
65
+ "qualification": result.get("qualification", "unverified"),
66
+ "artifacts": references,
67
+ }