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
@@ -0,0 +1,309 @@
1
+ """Run installed-product checks in fresh, network-isolated test environments."""
2
+
3
+ import shutil
4
+ import sys
5
+ from dataclasses import asdict
6
+
7
+ from pg_maker.config import resource_path
8
+ from pg_maker.errors import CancelledError, CommandError, PreconditionError
9
+ from pg_maker.models import CleanupPolicy, CommandSpec, EnvironmentSpec
10
+ from pg_maker.packaging.deb import inspect_deb
11
+ from pg_maker.packaging.service import settings
12
+ from pg_maker.planning.runtime_repository import verify_repository
13
+ from pg_maker.runners.docker import DockerRunner
14
+ from pg_maker.storage import content_hash, ensure_directory, file_hash, read_json, write_json
15
+ from pg_maker.yamlio import load_yaml
16
+
17
+ INSTALL_CAPABILITIES = ("CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "KILL")
18
+ SERVER_CHECKS = {
19
+ "missing-internal-dependency-rejected",
20
+ "installed-inventory-and-loader",
21
+ "install-does-not-initdb",
22
+ "empty-pgdata-start-rejected",
23
+ "service-start-status-reload",
24
+ "socket-and-tcp",
25
+ "compiled-socket-default",
26
+ "installed-server-version",
27
+ "restart-preserves-data",
28
+ "dump-restore",
29
+ "crash-recovery",
30
+ "checksums",
31
+ "invalid-config-permissions-port-socket-rejected",
32
+ "reinstall-preserves-config-data",
33
+ "config-and-file-logs-in-pgdata",
34
+ "remove-preserves-data",
35
+ "install-after-remove",
36
+ "purge-preserves-data",
37
+ }
38
+
39
+
40
+ def runtime_repository(branch):
41
+ image = next(
42
+ i
43
+ for i in branch.request.lock["spec"]["environments"]["spec"]["images"]
44
+ if i["id"] == branch.variant["target"] + ".build"
45
+ )
46
+ repository = image.get("runtime_repository")
47
+ if not repository or repository["repository_hash"] != content_hash(repository["files"]):
48
+ raise PreconditionError(
49
+ "prepared build image has no pinned runtime repository; run env prepare"
50
+ )
51
+ destination = branch.output / "runtime-repository"
52
+ if not destination.exists():
53
+ branch.prepare()
54
+ branch.runner._control(
55
+ ["cp", branch.handle.container_id + ":" + repository["path"], str(destination)]
56
+ )
57
+ # APT's empty scratch entries are not part of its package inventory.
58
+ for name in ("partial", "lock"):
59
+ path = destination / name
60
+ if path.is_symlink():
61
+ raise PreconditionError("runtime repository contains a symlink")
62
+ if path.is_dir() and not any(path.iterdir()):
63
+ path.rmdir()
64
+ elif path.is_file() and not path.stat().st_size:
65
+ path.unlink()
66
+ verify_repository(destination, repository)
67
+ write_json(branch.output / "runtime-repository.json", repository)
68
+ return destination
69
+
70
+
71
+ def validate_packages(branch, *, run_database=True):
72
+ repository = runtime_repository(branch)
73
+ package_list = read_json(branch.output / "packages.json")
74
+ for package in package_list:
75
+ if file_hash(branch.output / package["path"]) != package["sha256"]:
76
+ raise PreconditionError("package changed before installation")
77
+ tree = read_json(branch.output / "install-tree.json")
78
+ previous = next(
79
+ (
80
+ r
81
+ for r in branch.config.get("previous_releases", [])
82
+ if r["variant"] == branch.variant["id"] and branch.branch == "candidate"
83
+ ),
84
+ None,
85
+ )
86
+ previous_packages = []
87
+ if previous:
88
+ for item in previous["packages"]:
89
+ path = branch.inputs.blob(item["sha256"])
90
+ metadata = inspect_deb(path)["control"]
91
+ current = next(p for p in package_list if p["role"] == item["role"])
92
+ if (
93
+ metadata.get("Package") != current["name"]
94
+ or metadata.get("Architecture") != current["native_arch"]
95
+ ):
96
+ raise PreconditionError("previous package identity differs from the upgrade target")
97
+ previous_packages.append(
98
+ {**item, "version": metadata["Version"], "filename": item["role"] + ".deb"}
99
+ )
100
+ branch.release()
101
+ acceptance, seen = [], set()
102
+ for change in branch.feature["spec"]["changes"]:
103
+ for test in change["spec"]["acceptance"]:
104
+ if branch.branch not in test["expectations"]:
105
+ continue
106
+ if test["id"] in seen:
107
+ continue
108
+ seen.add(test["id"])
109
+ blob = branch.inputs.blob(test["script"]["sha256"])
110
+ item = dict(test)
111
+ if test["kind"] == "command":
112
+ document = load_yaml(blob)
113
+ if set(document) != {"argv"} or not isinstance(document["argv"], list):
114
+ raise PreconditionError("invalid command acceptance script")
115
+ item["argv"] = document["argv"]
116
+ else:
117
+ item["sql"] = blob.read_text()
118
+ acceptance.append(item)
119
+ outcomes = []
120
+ scenarios = [
121
+ ("libpq", ("libpq",)),
122
+ ("client", ("libpq", "client")),
123
+ ("server", ("libpq", "client", "server")),
124
+ ]
125
+ documentation = branch.config.get("documentation", {}).get("enabled", False)
126
+ if documentation:
127
+ scenarios[-1] = ("server", ("libpq", "client", "server", "docs"))
128
+ scenarios.append(("docs", ("docs",)))
129
+ if previous and run_database:
130
+ scenarios.append(("upgrade", ("libpq", "client", "server")))
131
+ for label, roles in scenarios:
132
+ branch.context.check()
133
+ workspace = (
134
+ branch.context.directory
135
+ / "validation"
136
+ / branch.id
137
+ / str(branch.context.state["attempt"])
138
+ / label
139
+ )
140
+ ensure_directory(workspace)
141
+ runner = DockerRunner(
142
+ branch.context.directory,
143
+ operation_id=branch.context.directory.name,
144
+ owner_token=branch.context.state["owner_token"],
145
+ cancelled=branch.context.token.requested,
146
+ on_process=branch.context.on_process,
147
+ attempt=branch.context.state["attempt"],
148
+ workspace=workspace,
149
+ )
150
+ source = workspace / "source"
151
+ ensure_directory(source)
152
+ shutil.copytree(branch.output / "packages", source / "packages")
153
+ verify_repository(repository, read_json(branch.output / "runtime-repository.json"))
154
+ shutil.copytree(repository, source / "repository", symlinks=True)
155
+ if label == "upgrade":
156
+ ensure_directory(source / "previous")
157
+ for item in previous_packages:
158
+ shutil.copyfile(
159
+ branch.inputs.blob(item["sha256"]), source / "previous" / item["filename"]
160
+ )
161
+ for name in ("systemctl.py", "installed.py"):
162
+ shutil.copyfile(resource_path("validation", name), source / name)
163
+ if documentation:
164
+ shutil.copytree(
165
+ resource_path(),
166
+ source / "doc-tools/pg_maker",
167
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
168
+ )
169
+ config = {
170
+ "service": settings(branch.config, branch.variant),
171
+ "roles": roles,
172
+ "packages": package_list,
173
+ "inventory": tree["entries"],
174
+ "branch": branch.branch,
175
+ "postgresql_version": branch.config["postgresql"]["version"],
176
+ "components": [
177
+ {"id": c["metadata"]["id"], **c["spec"]}
178
+ for c in branch.feature["spec"]["components"]
179
+ ],
180
+ "acceptance": acceptance,
181
+ "run_database": run_database,
182
+ "previous_declared": previous is not None,
183
+ "previous": {**previous, "packages": previous_packages} if label == "upgrade" else None,
184
+ "documentation": read_json(branch.output / "installed-docs.json")
185
+ if documentation
186
+ else None,
187
+ "documentation_root": branch.variant["identity"]["paths"]["documentation_directory"],
188
+ "documentation_branding": branch.config["documentation"]["branding"],
189
+ }
190
+ write_json(source / "config.json", config)
191
+ environment = EnvironmentSpec(
192
+ branch.step["image_id"], "0:0", branch.id + ".packages." + label
193
+ )
194
+ handle = runner.intended_handle(environment)
195
+
196
+ def record(handle):
197
+ branch.context.event(
198
+ "environment_recorded",
199
+ resources=[
200
+ {
201
+ "kind": "docker",
202
+ "name": handle.name,
203
+ "labels": handle.labels,
204
+ "container_id": handle.container_id,
205
+ "environment": asdict(handle.environment),
206
+ }
207
+ ],
208
+ )
209
+
210
+ record(handle)
211
+ try:
212
+ handle = runner.prepare(
213
+ environment,
214
+ memory_bytes=branch.step["resources"]["memory_bytes"],
215
+ cpu_jobs=branch.step["resources"]["cpu_jobs"],
216
+ capabilities=INSTALL_CAPABILITIES,
217
+ )
218
+ record(handle)
219
+ command = CommandSpec(
220
+ branch.id + ".packages." + label,
221
+ (
222
+ "python3",
223
+ "/work/source/installed.py",
224
+ "/work/source/config.json",
225
+ "/tmp/pg-maker-results",
226
+ ),
227
+ cwd="/",
228
+ timeout_seconds=branch.step["timeout_seconds"],
229
+ )
230
+ result = runner.execute(handle, command)
231
+ output = branch.output / "package-tests" / label
232
+ ensure_directory(output.parent)
233
+ copied = runner._control(
234
+ ["cp", handle.container_id + ":/tmp/pg-maker-results", str(output)],
235
+ check=False,
236
+ )
237
+ if result.cancelled:
238
+ raise CancelledError("package validation cancelled")
239
+ if (
240
+ not result.succeeded
241
+ or not copied.succeeded
242
+ or not (output / "result.json").is_file()
243
+ ):
244
+ raise CommandError(
245
+ "installed package checks failed",
246
+ details={
247
+ "set": label,
248
+ "stderr": result.stderr_tail,
249
+ "timed_out": result.timed_out,
250
+ },
251
+ )
252
+ report = read_json(output / "result.json")
253
+ required = (
254
+ {"previous-release-upgrade"}
255
+ if label == "upgrade"
256
+ else SERVER_CHECKS
257
+ if label == "server" and run_database
258
+ else {
259
+ "installed-inventory-and-loader",
260
+ "install-does-not-initdb",
261
+ "empty-pgdata-start-rejected",
262
+ "remove-preserves-empty-pgdata",
263
+ }
264
+ if label == "server"
265
+ else {
266
+ "installed-inventory-and-loader",
267
+ "no-cluster-or-service",
268
+ "standalone-remove",
269
+ }
270
+ )
271
+ passed = {c["id"] for c in report["checks"] if c["status"] == "passed"}
272
+ if documentation and label != "upgrade":
273
+ required = required | {"installed-documentation"}
274
+ if not report["passed"] or not required <= passed:
275
+ raise CommandError("installed check evidence is incomplete")
276
+ if (
277
+ label == "server"
278
+ and run_database
279
+ and not {"acceptance:" + t["id"] for t in acceptance} <= passed
280
+ ):
281
+ raise CommandError("installed acceptance evidence is incomplete")
282
+ outcomes.append(
283
+ {
284
+ "set": label,
285
+ "passed": True,
286
+ "checks": sorted(passed),
287
+ "path": "package-tests/" + label + "/result.json",
288
+ "sha256": file_hash(output / "result.json"),
289
+ }
290
+ )
291
+ finally:
292
+ original_error = sys.exc_info()[1]
293
+ try:
294
+ runner.release(handle, CleanupPolicy.REMOVE)
295
+ branch.context.event("environment_released", resources=[])
296
+ except Exception:
297
+ if original_error is None:
298
+ raise
299
+ # Keep the exact refs for cancel/resume without hiding the original test error.
300
+ branch.context.event("environment_cleanup_failed")
301
+ result = {
302
+ "passed": True,
303
+ "sets": outcomes,
304
+ "run_database": run_database,
305
+ "service_manager": "emulated",
306
+ }
307
+ write_json(branch.output / "package-tests.json", result)
308
+ branch.report["package_tests"] = result
309
+ return result
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/python3
2
+ """Strict test-only systemctl subset for pg_maker's generated units.
3
+
4
+ This is an independent, small harness, not the legacy EUPL systemctl implementation.
5
+ It never claims native systemd/cgroup behavior and rejects unsupported directives.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import configparser
11
+ import os
12
+ import pwd
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ SUPPORTED = {
20
+ "Unit": {"Description", "After"},
21
+ "Service": {
22
+ "Type",
23
+ "User",
24
+ "Group",
25
+ "WorkingDirectory",
26
+ "Environment",
27
+ "PIDFile",
28
+ "ExecStartPre",
29
+ "ExecStart",
30
+ "ExecStop",
31
+ "ExecReload",
32
+ "TimeoutStartSec",
33
+ "TimeoutStopSec",
34
+ },
35
+ "Install": {"WantedBy"},
36
+ }
37
+
38
+
39
+ def load_unit(name):
40
+ if "/" in name or not name.endswith(".service"):
41
+ raise ValueError("an explicit service name is required")
42
+ path = Path("/usr/lib/systemd/system") / name
43
+ parser = configparser.ConfigParser(interpolation=None, strict=True)
44
+ parser.optionxform = str
45
+ with path.open() as stream:
46
+ parser.read_file(stream)
47
+ for section in parser.sections():
48
+ if section not in SUPPORTED or set(parser[section]) - SUPPORTED[section]:
49
+ raise ValueError("unsupported unit directive")
50
+ service = dict(parser["Service"])
51
+ if service["Type"] != "forking":
52
+ raise ValueError("only forking units are supported")
53
+ if parser["Install"]["WantedBy"] != "multi-user.target":
54
+ raise ValueError("unsupported install target")
55
+ if any("%" in value for section in parser.sections() for value in parser[section].values()):
56
+ raise ValueError("unit specifiers are unsupported by this harness")
57
+ return service
58
+
59
+
60
+ def pid(service):
61
+ try:
62
+ lines = Path(service["PIDFile"]).read_text().splitlines()
63
+ number = int(lines[0])
64
+ data = str(Path(service["PIDFile"]).parent)
65
+ if len(lines) < 2 or lines[1] != data:
66
+ return None
67
+ if number <= 1:
68
+ return None
69
+ process = Path("/proc") / str(number)
70
+ if process.stat().st_uid != pwd.getpwnam(service["User"]).pw_uid:
71
+ return None
72
+ if process.joinpath("stat").read_text().rsplit(")", 1)[1].split()[0] == "Z":
73
+ return None
74
+ command = process.joinpath("cmdline").read_bytes().split(b"\0")[0].decode()
75
+ expected = str(Path(shlex.split(service["ExecStart"])[0]).with_name("postgres"))
76
+ if command != expected and not command.startswith(expected + " "):
77
+ return None
78
+ arguments = shlex.split(
79
+ process.joinpath("cmdline").read_bytes().replace(b"\0", b" ").decode()
80
+ )
81
+ if "-D" not in arguments or arguments[arguments.index("-D") + 1] != data:
82
+ return None
83
+ os.kill(number, 0)
84
+ return number
85
+ except (OSError, ValueError, KeyError, IndexError):
86
+ return None
87
+
88
+
89
+ def execute(service, key):
90
+ command = service[key]
91
+ privileged = command.startswith("+")
92
+ argv = shlex.split(command[1:] if privileged else command)
93
+ if not argv or not argv[0].startswith("/"):
94
+ raise ValueError("unit executable must be absolute")
95
+ environment = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C.UTF-8"}
96
+ for item in shlex.split(service.get("Environment", "")):
97
+ name, value = item.split("=", 1)
98
+ environment[name] = value
99
+ options = (
100
+ {}
101
+ if privileged
102
+ else {
103
+ "user": service["User"],
104
+ "group": service["Group"],
105
+ "extra_groups": [],
106
+ }
107
+ )
108
+ result = subprocess.run(
109
+ argv,
110
+ cwd="/" if privileged else service["WorkingDirectory"],
111
+ env=environment,
112
+ timeout=int(service["TimeoutStopSec" if key == "ExecStop" else "TimeoutStartSec"]),
113
+ check=False,
114
+ **options,
115
+ )
116
+ if result.returncode:
117
+ raise RuntimeError(key + " failed with status " + str(result.returncode))
118
+
119
+
120
+ def main(argv=None):
121
+ arguments = list(sys.argv[1:] if argv is None else argv)
122
+ quiet = "--quiet" in arguments
123
+ arguments = [a for a in arguments if a != "--quiet"]
124
+ if arguments == ["--version"]:
125
+ print("pg_maker systemctl test harness v1; service_manager=emulated")
126
+ return 0
127
+ if arguments == ["daemon-reload"]:
128
+ return 0
129
+ if len(arguments) != 2:
130
+ raise ValueError("unsupported systemctl invocation")
131
+ command, name = arguments
132
+ if command not in {
133
+ "start",
134
+ "stop",
135
+ "restart",
136
+ "reload",
137
+ "status",
138
+ "is-active",
139
+ "enable",
140
+ "disable",
141
+ }:
142
+ raise ValueError("unsupported systemctl command")
143
+ service = load_unit(name)
144
+ if command in {"enable", "disable"}:
145
+ link = Path("/etc/systemd/system/multi-user.target.wants") / name
146
+ if command == "enable":
147
+ link.parent.mkdir(parents=True, exist_ok=True)
148
+ if link.is_symlink():
149
+ if os.readlink(link) != "/usr/lib/systemd/system/" + name:
150
+ raise ValueError("conflicting service link")
151
+ else:
152
+ link.symlink_to("/usr/lib/systemd/system/" + name)
153
+ else:
154
+ link.unlink(missing_ok=True)
155
+ return 0
156
+ if command in {"status", "is-active"}:
157
+ active = pid(service) is not None
158
+ if not quiet:
159
+ print("active" if active else "inactive")
160
+ return 0 if active else 3
161
+ if command in {"stop", "restart"} and pid(service):
162
+ execute(service, "ExecStop")
163
+ deadline = time.monotonic() + int(service["TimeoutStopSec"])
164
+ while pid(service) and time.monotonic() < deadline:
165
+ time.sleep(0.05)
166
+ if pid(service):
167
+ raise RuntimeError("service did not stop")
168
+ if command in {"start", "restart"} and not pid(service):
169
+ execute(service, "ExecStartPre")
170
+ execute(service, "ExecStart")
171
+ if not pid(service):
172
+ raise RuntimeError("service command returned success without a running server")
173
+ if command == "reload":
174
+ if not pid(service):
175
+ raise RuntimeError("cannot reload an inactive service")
176
+ execute(service, "ExecReload")
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ try:
182
+ raise SystemExit(main())
183
+ except Exception as exc:
184
+ print("pg-maker systemctl: " + str(exc), file=sys.stderr)
185
+ raise SystemExit(1) from exc
pg_maker/yamlio.py ADDED
@@ -0,0 +1,54 @@
1
+ """JSON-compatible YAML with explicit duplicate-key, alias and tag rejection."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from yaml.events import AliasEvent
8
+
9
+ from pg_maker.documents import validate_document
10
+ from pg_maker.errors import ValidationError
11
+ from pg_maker.storage import canonical_json
12
+
13
+
14
+ class StrictLoader(yaml.SafeLoader):
15
+ def compose_node(self, parent, index):
16
+ if self.check_event(AliasEvent):
17
+ raise ValidationError("YAML aliases are not supported")
18
+ return super().compose_node(parent, index)
19
+
20
+ def construct_mapping(self, node, deep=False):
21
+ result = {}
22
+ for key_node, value_node in node.value:
23
+ key = self.construct_object(key_node, deep=deep)
24
+ if not isinstance(key, str):
25
+ raise ValidationError("YAML mapping keys must be strings")
26
+ if key in result:
27
+ raise ValidationError(
28
+ "duplicate YAML key", details={"key": key, "line": key_node.start_mark.line + 1}
29
+ )
30
+ result[key] = self.construct_object(value_node, deep=deep)
31
+ return result
32
+
33
+
34
+ def load_yaml(path: Path, schema_name: str | None = None) -> dict[str, Any]:
35
+ try:
36
+ value = yaml.load(path.read_text(encoding="utf-8"), Loader=StrictLoader)
37
+ except OSError as exc:
38
+ raise ValidationError("cannot read YAML document", details={"path": str(path)}) from exc
39
+ except yaml.YAMLError as exc:
40
+ # PyYAML's exception text can echo secrets from the offending source line.
41
+ mark = getattr(exc, "problem_mark", None)
42
+ raise ValidationError(
43
+ "invalid or unsupported YAML",
44
+ details={
45
+ "path": str(path),
46
+ "line": mark.line + 1 if mark else None,
47
+ },
48
+ ) from exc
49
+ if not isinstance(value, dict):
50
+ raise ValidationError("YAML document must be a mapping")
51
+ canonical_json(value)
52
+ if schema_name:
53
+ validate_document(value, schema_name)
54
+ return value
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-18
4
+
5
+ Initial alpha of the PostgreSQL distribution controller:
6
+
7
+ - Strict YAML configuration, pinned sources/environments, immutable locks and plans.
8
+ - Isolated Docker builds, durable operations, events, cancellation, recovery and caches.
9
+ - PostgreSQL 14–19 catalog; initial Debian 13 / x86_64 / DEB platform.
10
+ - Default pg_stat_statements, pg_stat_kcache and pg_wait_sampling recipes with manuals.
11
+ - Documentation-only and combined builds using DocBook/Sphinx, local assets, HTML/man/PDF
12
+ and explicit translation bundles.
13
+ - Server/client/libpq/docs packaging, manifests, offline installation checks and exports.
14
+ - Owned Git development sessions, patch import/refresh/pop/push/rebase, conflict recovery,
15
+ selected component rebuilds, debug commands and iteration retention.
16
+ - Optional pg_play CLI/API/MCP integration and runtime image handoff.
17
+ - English project documentation, distribution-content checks and a tag-triggered PyPI
18
+ Trusted Publishing workflow.
19
+
20
+ Database suites are opt-in; quick/development builds remain unverified. RPM, additional
21
+ platforms and PostgreSQL distribution repository publication remain future work.
22
+ See the [roadmap](docs/roadmap.md) and dated validation reports for precise boundaries.
@@ -0,0 +1,63 @@
1
+ # Contributing
2
+
3
+ Use Linux and Python 3.10 or newer. Install the checkout in a virtual environment:
4
+
5
+ ```bash
6
+ python3 -m venv .venv
7
+ .venv/bin/python -m pip install -e '.[dev]'
8
+ .venv/bin/ruff check .
9
+ .venv/bin/ruff format --check .
10
+ .venv/bin/python tools/generate_planning_schemas.py
11
+ .venv/bin/python tools/check_release.py
12
+ .venv/bin/pytest -q tests/unit tests/contracts
13
+ ```
14
+
15
+ Review generated schema changes with the matching interface contract. Keep every
16
+ project Markdown file in English, including examples and fixture descriptions.
17
+ PostgreSQL manuals produced by the utility can use configured languages. Preserve
18
+ upstream copyright/license notices in fixtures and generated artifacts.
19
+
20
+ Public changes need an updated MD contract and, where applicable, JSON Schema.
21
+ Use MakerService for CLI/API behavior, typed argv for commands, the shared logger,
22
+ durable state/events and owned-resource cleanup. Prefer small concrete adapters;
23
+ add protocols only at real replacement boundaries. See [architecture](docs/architecture.md)
24
+ and [interfaces](docs/interfaces/README.md).
25
+
26
+ ## Tests and storage
27
+
28
+ Unit/contract tests do not require PostgreSQL builds. Small Docker tests are opt-in;
29
+ variables select already available images. A tiny development pipeline fixture needs
30
+ an image with Make and the current non-root build user:
31
+
32
+ ```bash
33
+ PG_MAKER_DEVELOPMENT_IMAGE=sha256:FULL_IMAGE_ID .venv/bin/pytest -q tests/integration/test_development_pipeline.py
34
+ ```
35
+
36
+ This checks failed extension builds, Git fixes, core/component reuse, debug execution
37
+ and retention without starting a database. Other fixtures use PG_MAKER_DOCKER_IMAGE,
38
+ PG_MAKER_BUILD_IMAGE, PG_MAKER_PREPARATION_IMAGE, PG_MAKER_PACKAGE_ENVIRONMENTS and
39
+ PG_MAKER_DOC_ENVIRONMENTS. CI prepares its own fixture environments through
40
+ `tools/ci_pipeline.py --fixtures-only`.
41
+
42
+ Database suites remain off by default. The expensive PG_MAKER_BUILD_LOCK integration
43
+ and manual --run-db-tests path are separate checks, not routine prerequisites for
44
+ editing the Python utility. Never treat a successful quick build as database qualification.
45
+ Check free disk before work; use [explicit cleanup](docs/development-storage.md) and
46
+ retain useful evidence. Do not put runs, inputs, environments, credentials or build
47
+ artifacts into Git.
48
+
49
+ ## Distribution checks
50
+
51
+ ```bash
52
+ .venv/bin/python -m build
53
+ .venv/bin/python -m twine check --strict dist/*
54
+ .venv/bin/python tools/check_release.py --dist dist
55
+ .venv/bin/python tools/smoke_wheel.py dist/*.whl
56
+ ```
57
+
58
+ Use a distribution directory containing exactly one version. Release checks compare
59
+ all Markdown and runtime package files byte-for-byte between source, wheel and sdist,
60
+ and ensure test fixtures/tooling are present in sdist. Wheel smoke creates and removes
61
+ a temporary isolated environment, installs dependencies, checks entry points and
62
+ initializes/validates a project outside checkout. It does not contact Docker.
63
+ Publication is described in [releasing](docs/releasing.md).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleg Gurov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.