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.
- pg_maker/__init__.py +3 -0
- pg_maker/__main__.py +3 -0
- pg_maker/api.py +594 -0
- pg_maker/artifacts/__init__.py +4 -0
- pg_maker/artifacts/catalog.py +67 -0
- pg_maker/artifacts/manifest.py +301 -0
- pg_maker/assets.py +93 -0
- pg_maker/builders/__init__.py +4 -0
- pg_maker/builders/autotools.py +430 -0
- pg_maker/builders/components.py +359 -0
- pg_maker/bundled/PROJECT.md +57 -0
- pg_maker/bundled/build.yaml +29 -0
- pg_maker/bundled/components/pg-stat-kcache.yaml +30 -0
- pg_maker/bundled/components/pg-stat-statements.yaml +24 -0
- pg_maker/bundled/components/pg-wait-sampling.yaml +30 -0
- pg_maker/bundled/config/defaults.yaml +80 -0
- pg_maker/bundled/config/documentation.yaml +24 -0
- pg_maker/bundled/config/features/my-feature.yaml +17 -0
- pg_maker/bundled/config/vendor.yaml +13 -0
- pg_maker/bundled/documentation/README.md +20 -0
- pg_maker/bundled/documentation/pg-stat-kcache.sgml +99 -0
- pg_maker/bundled/documentation/pg-stat-kcache.yaml +15 -0
- pg_maker/bundled/documentation/pg-wait-sampling.sgml +122 -0
- pg_maker/bundled/documentation/pg-wait-sampling.yaml +15 -0
- pg_maker/bundled/patches/unix-socket-directory.patch +5 -0
- pg_maker/changes/__init__.py +1 -0
- pg_maker/changes/apply.py +298 -0
- pg_maker/changes/loader.py +135 -0
- pg_maker/changes/workspace.py +221 -0
- pg_maker/cli.py +546 -0
- pg_maker/components/__init__.py +4 -0
- pg_maker/config.py +514 -0
- pg_maker/development/__init__.py +1 -0
- pg_maker/development/cli.py +104 -0
- pg_maker/development/git.py +362 -0
- pg_maker/development/session.py +735 -0
- pg_maker/documentation/__init__.py +4 -0
- pg_maker/documentation/builder.py +467 -0
- pg_maker/documentation/cli.py +24 -0
- pg_maker/documentation/config.py +127 -0
- pg_maker/documentation/install.py +79 -0
- pg_maker/documentation/site.py +166 -0
- pg_maker/documentation/sphinx_ext.py +150 -0
- pg_maker/documentation/theme/_static/pg-maker.css +26 -0
- pg_maker/documentation/theme/_static/pg-maker.js +35 -0
- pg_maker/documentation/theme/_templates/footer_en.html +5 -0
- pg_maker/documentation/theme/_templates/footer_metadata.html +13 -0
- pg_maker/documentation/theme/_templates/footer_ru.html +5 -0
- pg_maker/documentation/translation.py +341 -0
- pg_maker/documentation/validation.py +132 -0
- pg_maker/documentation/worker.py +62 -0
- pg_maker/documentation/writer.py +324 -0
- pg_maker/documentation/xml.py +210 -0
- pg_maker/documents.py +118 -0
- pg_maker/errors.py +50 -0
- pg_maker/execution/__init__.py +1 -0
- pg_maker/execution/build.py +502 -0
- pg_maker/execution/cache.py +287 -0
- pg_maker/execution/cancellation.py +60 -0
- pg_maker/execution/checkpoints.py +218 -0
- pg_maker/execution/cleanup.py +122 -0
- pg_maker/execution/component_cache.py +164 -0
- pg_maker/execution/development.py +109 -0
- pg_maker/execution/engine.py +171 -0
- pg_maker/execution/events.py +65 -0
- pg_maker/execution/incremental.py +178 -0
- pg_maker/execution/preparation.py +130 -0
- pg_maker/execution/process.py +155 -0
- pg_maker/execution/resources.py +41 -0
- pg_maker/execution/state.py +82 -0
- pg_maker/execution/worker.py +45 -0
- pg_maker/identity.py +116 -0
- pg_maker/integrations/__init__.py +1 -0
- pg_maker/integrations/machine.py +163 -0
- pg_maker/integrations/runtime.py +387 -0
- pg_maker/models.py +245 -0
- pg_maker/packaging/__init__.py +4 -0
- pg_maker/packaging/deb.py +337 -0
- pg_maker/packaging/service.py +165 -0
- pg_maker/packaging/spec.py +64 -0
- pg_maker/packaging/templates/deb-control.in +8 -0
- pg_maker/packaging/tree.py +239 -0
- pg_maker/planning/__init__.py +1 -0
- pg_maker/planning/documents.py +65 -0
- pg_maker/planning/environments.py +318 -0
- pg_maker/planning/graph.py +23 -0
- pg_maker/planning/plan.py +418 -0
- pg_maker/planning/resolve.py +117 -0
- pg_maker/planning/reuse.py +80 -0
- pg_maker/planning/runtime_repository.py +90 -0
- pg_maker/postgresql.py +59 -0
- pg_maker/progress.py +64 -0
- pg_maker/publishing/__init__.py +4 -0
- pg_maker/py.typed +0 -0
- pg_maker/recipes/README.md +13 -0
- pg_maker/recipes/platforms/debian-13.yaml +125 -0
- pg_maker/recipes/postgresql.yaml +114 -0
- pg_maker/runners/__init__.py +1 -0
- pg_maker/runners/base.py +29 -0
- pg_maker/runners/docker.py +312 -0
- pg_maker/schemas/build.schema.json +5 -0
- pg_maker/schemas/change-set.schema.json +5 -0
- pg_maker/schemas/component-envelope.schema.json +69 -0
- pg_maker/schemas/component.schema.json +5 -0
- pg_maker/schemas/defaults.schema.json +5 -0
- pg_maker/schemas/doc-rules.schema.json +5 -0
- pg_maker/schemas/documentation.schema.json +5 -0
- pg_maker/schemas/environment.schema.json +228 -0
- pg_maker/schemas/feature.schema.json +5 -0
- pg_maker/schemas/insertions.schema.json +5 -0
- pg_maker/schemas/lock.schema.json +3963 -0
- pg_maker/schemas/operation-input.schema.json +306 -0
- pg_maker/schemas/operation-state.schema.json +127 -0
- pg_maker/schemas/plan.schema.json +284 -0
- pg_maker/schemas/planning-common.schema.json +8192 -0
- pg_maker/schemas/platform.schema.json +5 -0
- pg_maker/schemas/postgresql-catalog.schema.json +5 -0
- pg_maker/schemas/run-event.schema.json +72 -0
- pg_maker/schemas/translations.schema.json +5 -0
- pg_maker/schemas/vendor.schema.json +5 -0
- pg_maker/security.py +73 -0
- pg_maker/sources/__init__.py +1 -0
- pg_maker/sources/resolver.py +238 -0
- pg_maker/sources/store.py +279 -0
- pg_maker/storage.py +127 -0
- pg_maker/validation/__init__.py +4 -0
- pg_maker/validation/installed.py +667 -0
- pg_maker/validation/packages.py +309 -0
- pg_maker/validation/systemctl.py +185 -0
- pg_maker/yamlio.py +54 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/CHANGELOG.md +22 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/CONTRIBUTING.md +63 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/LICENSE +21 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/README.md +163 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/architecture.md +55 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/development-storage.md +72 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/development.md +86 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/README.md +32 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/api.md +75 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/build.md +112 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/changes.md +58 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/cleanup.md +17 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/components.md +39 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/configuration.md +83 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/development.md +88 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/documentation.md +241 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/documents.md +42 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/environments.md +40 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/execution.md +49 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/machine.md +31 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/observability.md +28 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/packaging.md +107 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/planning.md +63 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/postgresql.md +79 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/publication.md +20 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/recovery.md +83 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/runtime.md +31 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/sources.md +48 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/state.md +49 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/interfaces/storage.md +24 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/releasing.md +98 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/reuse.md +24 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/roadmap.md +54 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/default-components.md +56 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/development-release.md +49 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/documentation-theme.md +45 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/integration.md +49 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/postgresql-majors.md +107 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/review-fixes.md +68 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage4.md +65 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage56.md +66 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/docs/validation/stage7.md +72 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/guc/README.md +11 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/stage4/README.md +31 -0
- pg_maker-0.1.0.data/data/share/doc/pg-maker/tests/fixtures/stage7/README.md +16 -0
- pg_maker-0.1.0.dist-info/METADATA +200 -0
- pg_maker-0.1.0.dist-info/RECORD +181 -0
- pg_maker-0.1.0.dist-info/WHEEL +5 -0
- pg_maker-0.1.0.dist-info/entry_points.txt +4 -0
- pg_maker-0.1.0.dist-info/licenses/LICENSE +21 -0
- pg_maker-0.1.0.dist-info/top_level.txt +1 -0
pg_maker/__init__.py
ADDED
pg_maker/__main__.py
ADDED
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,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
|
+
}
|