@mriekki/lean 0.4.5 → 0.4.7
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.
- package/assets/components.json +3 -3
- package/assets/core/distribution.json +1 -0
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +21 -6
- package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +2 -0
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
- package/assets/generated/core/tools/docs_site/docs_site.py +150 -20
- package/assets/generated/deployment/src/lean_poc_deployment/local_cli.py +70 -2
- package/assets/generated/deployment/src/lean_poc_deployment/local_spec.py +82 -0
- package/assets/generated/deployment/src/lean_poc_deployment/local_sqlite.py +205 -0
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/lean",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.7",
|
|
5
5
|
"defaultProfile": "greenfield-poc",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-poc": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
16
|
"repository": "mriekki/lean-agentic-core",
|
|
17
|
-
"commit": "
|
|
17
|
+
"commit": "c53620078d22ec8acb6fee99006045736a2d6491",
|
|
18
18
|
"version": "1.13.0",
|
|
19
19
|
"distribution": "greenfield-workspace",
|
|
20
20
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"deployment": {
|
|
25
25
|
"repository": "mriekki/lean-poc-deployment-kit",
|
|
26
|
-
"commit": "
|
|
26
|
+
"commit": "a4cdb4f43982692536b669a135a4c81057368fce",
|
|
27
27
|
"distribution": "greenfield-poc",
|
|
28
28
|
"descriptor": "assets/deployment/distribution.json",
|
|
29
29
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
{"source":"core/skills/core-sync/SKILL.md","destination":".github/skills/core-sync/SKILL.md"},
|
|
21
21
|
{"source":"core/skills/upstream-fix/SKILL.md","destination":".github/skills/upstream-fix/SKILL.md"},
|
|
22
22
|
{"source":"core/skills/wait-what/SKILL.md","destination":".github/skills/wait-what/SKILL.md"},
|
|
23
|
+
{"source":"core/skills/developer-env-mode/SKILL.md","destination":".github/skills/developer-env-mode/SKILL.md"},
|
|
23
24
|
{"source":"core/skills/deployment-readiness/SKILL.md","destination":".github/skills/deployment-readiness/SKILL.md"},
|
|
24
25
|
{"source":"core/skills/deploy/SKILL.md","destination":".github/skills/deploy/SKILL.md"},
|
|
25
26
|
{"source":"core/skills/tear-down/SKILL.md","destination":".github/skills/tear-down/SKILL.md"},
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
{"source": "src/lean_poc_deployment/app_runtime_remote.py", "destination": "tools/lean-deployment/lean_poc_deployment/app_runtime_remote.py"},
|
|
12
12
|
{"source": "src/lean_poc_deployment/local_cli.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_cli.py"},
|
|
13
13
|
{"source": "src/lean_poc_deployment/local_runtime.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_runtime.py"},
|
|
14
|
+
{"source": "src/lean_poc_deployment/local_spec.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_spec.py"},
|
|
15
|
+
{"source": "src/lean_poc_deployment/local_sqlite.py", "destination": "tools/lean-deployment/lean_poc_deployment/local_sqlite.py"},
|
|
14
16
|
{"source": "src/lean_poc_deployment/box_platform.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform.py"},
|
|
15
17
|
{"source": "src/lean_poc_deployment/box_platform_assets.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform_assets.py"},
|
|
16
18
|
{"source": "src/lean_poc_deployment/box_platform_remote.py", "destination": "tools/lean-deployment/lean_poc_deployment/box_platform_remote.py"},
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/lean",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.7",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"repository": "mriekki/lean-agentic-core",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "c53620078d22ec8acb6fee99006045736a2d6491",
|
|
9
9
|
"version": "1.13.0",
|
|
10
10
|
"distribution": "greenfield-workspace",
|
|
11
11
|
"files": [
|
|
@@ -99,6 +99,11 @@
|
|
|
99
99
|
"destination": ".github/skills/wait-what/SKILL.md",
|
|
100
100
|
"sha256": "9b5968158992681837071b60465682a887540b1fdab510861ed739ffa996fbdb"
|
|
101
101
|
},
|
|
102
|
+
{
|
|
103
|
+
"source": "core/skills/developer-env-mode/SKILL.md",
|
|
104
|
+
"destination": ".github/skills/developer-env-mode/SKILL.md",
|
|
105
|
+
"sha256": "23a026a3c2e999855fe64006e76bffcbecf6348696266fd94452c605ef1d4c03"
|
|
106
|
+
},
|
|
102
107
|
{
|
|
103
108
|
"source": "core/skills/deployment-readiness/SKILL.md",
|
|
104
109
|
"destination": ".github/skills/deployment-readiness/SKILL.md",
|
|
@@ -192,7 +197,7 @@
|
|
|
192
197
|
{
|
|
193
198
|
"source": "core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
|
|
194
199
|
"destination": "docs/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
|
|
195
|
-
"sha256": "
|
|
200
|
+
"sha256": "8dc0773a245ed23089caa02a1a2f0dd5670710646d446d190406ae47a85da4c6"
|
|
196
201
|
},
|
|
197
202
|
{
|
|
198
203
|
"source": "core/process/LEAN_ENGINEERING_GUIDE.md",
|
|
@@ -282,7 +287,7 @@
|
|
|
282
287
|
{
|
|
283
288
|
"source": "tools/docs_site/docs_site.py",
|
|
284
289
|
"destination": "tools/lean-docs/docs-site.py",
|
|
285
|
-
"sha256": "
|
|
290
|
+
"sha256": "50ea30f5d42f71176a74a642123089c6e246f2da616e0c0bba2f16168d2c15c9"
|
|
286
291
|
},
|
|
287
292
|
{
|
|
288
293
|
"source": "tools/package/check_baseline.py",
|
|
@@ -298,7 +303,7 @@
|
|
|
298
303
|
},
|
|
299
304
|
"deployment": {
|
|
300
305
|
"repository": "mriekki/lean-poc-deployment-kit",
|
|
301
|
-
"commit": "
|
|
306
|
+
"commit": "a4cdb4f43982692536b669a135a4c81057368fce",
|
|
302
307
|
"distribution": "greenfield-poc",
|
|
303
308
|
"files": [
|
|
304
309
|
{
|
|
@@ -334,13 +339,23 @@
|
|
|
334
339
|
{
|
|
335
340
|
"source": "src/lean_poc_deployment/local_cli.py",
|
|
336
341
|
"destination": "tools/lean-deployment/lean_poc_deployment/local_cli.py",
|
|
337
|
-
"sha256": "
|
|
342
|
+
"sha256": "b183d937b21cef10c4766d588dccd03a729a373e587a82e7b80a9e2390797eb9"
|
|
338
343
|
},
|
|
339
344
|
{
|
|
340
345
|
"source": "src/lean_poc_deployment/local_runtime.py",
|
|
341
346
|
"destination": "tools/lean-deployment/lean_poc_deployment/local_runtime.py",
|
|
342
347
|
"sha256": "82cf795733b59f4591515396be65317da4e515fde19874e3c5851bcff412d84d"
|
|
343
348
|
},
|
|
349
|
+
{
|
|
350
|
+
"source": "src/lean_poc_deployment/local_spec.py",
|
|
351
|
+
"destination": "tools/lean-deployment/lean_poc_deployment/local_spec.py",
|
|
352
|
+
"sha256": "372b8c041edd9d3a1005861d85bb3a6bad38064b0be081a470460ad2dc62be4d"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"source": "src/lean_poc_deployment/local_sqlite.py",
|
|
356
|
+
"destination": "tools/lean-deployment/lean_poc_deployment/local_sqlite.py",
|
|
357
|
+
"sha256": "767c91f2af1f0df6f88b81a867826e4beaa1871865a97a9002a6b3fc53c781c9"
|
|
358
|
+
},
|
|
344
359
|
{
|
|
345
360
|
"source": "src/lean_poc_deployment/box_platform.py",
|
|
346
361
|
"destination": "tools/lean-deployment/lean_poc_deployment/box_platform.py",
|
|
@@ -190,6 +190,8 @@ Build-only mode:
|
|
|
190
190
|
python .\tools\lean-docs\docs-site.py build --root . --out .\.lean\docs-site
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
The builder protects existing data: it refuses the repository root, paths overlapping either lab cache, linked paths, and non-empty output directories without its `.lean-docs-site` ownership marker. Existing unmarked output from an older viewer must be inspected and moved or a new output path selected before rebuilding.
|
|
194
|
+
|
|
193
195
|
The viewer should:
|
|
194
196
|
|
|
195
197
|
- bind to loopback only;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: developer-env-mode
|
|
3
|
+
description: "Converge and verify the developer workstation for local-only or remote-enabled work. Mode changes become effective only after the selected readiness contract passes."
|
|
4
|
+
argument-hint: "status|local|remote"
|
|
5
|
+
user-invocable: true
|
|
6
|
+
disable-model-invocation: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Developer Environment Mode
|
|
10
|
+
|
|
11
|
+
`/developer-env-mode` manages the current workstation's operational development mode. It is not a deployment command and it does not weaken Staging or Production gates.
|
|
12
|
+
|
|
13
|
+
Supported intents:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
/developer-env-mode status
|
|
17
|
+
/developer-env-mode local
|
|
18
|
+
/developer-env-mode remote
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The governing invariant is:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
MODE != preference
|
|
25
|
+
MODE = verified operational state
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Never record a requested mode as ready until its readiness contract passes. If convergence fails, preserve the prior ready mode when one exists and report the blocker.
|
|
29
|
+
|
|
30
|
+
## 1. Resolve repository and package provenance read-only
|
|
31
|
+
|
|
32
|
+
From the repository root inspect, when present:
|
|
33
|
+
|
|
34
|
+
- `POC_BOOTSTRAP.json`;
|
|
35
|
+
- `LEAN_BASELINE.json` and component baselines;
|
|
36
|
+
- `DEV_ENVIRONMENT.json`;
|
|
37
|
+
- application setup/build/test commands and project-owned persistence configuration;
|
|
38
|
+
- deployment topology and generated deployment tooling;
|
|
39
|
+
- Git status so unrelated work is preserved.
|
|
40
|
+
|
|
41
|
+
For package-managed consumers, verify managed files are coherent before relying on distributed Core or Deployment tooling. Route managed drift to `/lean-update` rather than overwriting it.
|
|
42
|
+
|
|
43
|
+
Do not require a Bootstrap/Core/Deployment Kit source checkout for normal consumer use.
|
|
44
|
+
|
|
45
|
+
## 2. Keep current mode state workstation-local
|
|
46
|
+
|
|
47
|
+
Mode state is user/workstation state, not repository authority. Store it outside the repository under the user-scoped Lean directory, using a repository-scoped location such as:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
~/.lean-poc/workspaces/<owner>/<repo>/developer-environment.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Do not commit this state. Do not store credentials, private keys, target trust stores, database contents, or secret values in it.
|
|
54
|
+
|
|
55
|
+
Record enough non-secret evidence to distinguish requested mode from readiness, for example:
|
|
56
|
+
|
|
57
|
+
- mode;
|
|
58
|
+
- ready/not-ready;
|
|
59
|
+
- verification timestamp;
|
|
60
|
+
- source revision when available;
|
|
61
|
+
- build/test state or fingerprint;
|
|
62
|
+
- local database state when applicable;
|
|
63
|
+
- local runtime health when applicable;
|
|
64
|
+
- remote target/access readiness summary when applicable.
|
|
65
|
+
|
|
66
|
+
A later `status` check must be allowed to report that the recorded mode is no longer ready when Docker stops, tools drift, the build becomes stale, the local database is missing/incompatible, or remote access disappears.
|
|
67
|
+
|
|
68
|
+
## 3. `status` is read-only
|
|
69
|
+
|
|
70
|
+
`/developer-env-mode status` performs no setup writes. Inspect current workstation evidence and live prerequisites, then report both the selected mode and current readiness.
|
|
71
|
+
|
|
72
|
+
Prefer a compact result such as:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
DEVELOPER ENVIRONMENT
|
|
76
|
+
|
|
77
|
+
Selected mode: LOCAL
|
|
78
|
+
Readiness: NOT READY
|
|
79
|
+
|
|
80
|
+
Docker ........ READY
|
|
81
|
+
Build ......... STALE
|
|
82
|
+
Tests ......... READY
|
|
83
|
+
SQLite ........ READY
|
|
84
|
+
Runtime ....... HEALTHY
|
|
85
|
+
Remote access . UNAVAILABLE
|
|
86
|
+
|
|
87
|
+
NEXT
|
|
88
|
+
/developer-env-mode local
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Do not silently repair state during `status`.
|
|
92
|
+
|
|
93
|
+
## 4. `local` means converge a complete local development environment
|
|
94
|
+
|
|
95
|
+
Selecting `local` authorizes bounded, non-destructive workstation setup needed to make the current repository usable locally. It does not authorize destructive database reset, deletion of unrelated Docker state, remote publication, remote deployment, or Production writes.
|
|
96
|
+
|
|
97
|
+
Converge in this order.
|
|
98
|
+
|
|
99
|
+
### A. Workstation prerequisites
|
|
100
|
+
|
|
101
|
+
Verify the current OS is supported by repository configuration. Use repository setup/doctor tooling when present to verify required tools and versions.
|
|
102
|
+
|
|
103
|
+
For Docker-based local execution require a working Docker CLI/engine. Prefer repository-distributed Deployment Kit commands when `DEPLOYMENT_BASELINE.json` proves they are installed.
|
|
104
|
+
|
|
105
|
+
### B. Dependencies and restore
|
|
106
|
+
|
|
107
|
+
Run repository-defined dependency restore/setup commands when missing or stale. Preserve pinned versions and project conventions. Do not substitute arbitrary global tooling when the repository defines a supported path.
|
|
108
|
+
|
|
109
|
+
### C. Local persistence
|
|
110
|
+
|
|
111
|
+
If the application uses SQLite for local development:
|
|
112
|
+
|
|
113
|
+
1. resolve the project-owned database filename/container path and migration/initialization contract from repository configuration/docs/code;
|
|
114
|
+
2. prefer the installed Deployment Kit local SQLite ensure/status primitive when available for workstation-owned persistent storage;
|
|
115
|
+
3. when the database is absent, create the owned local SQLite file/directory non-destructively;
|
|
116
|
+
4. run the repository-provided migration/schema initialization needed to make that database usable;
|
|
117
|
+
5. verify the resulting database can be opened and that the application-required schema/migration state is current when the repository provides such a check.
|
|
118
|
+
|
|
119
|
+
If an existing SQLite database is corrupt, incompatible, or has unexpected ownership/schema state, **STOP**. Never delete, replace, reset, or migrate destructively merely to make mode convergence pass. Present the exact recovery/reset decision separately.
|
|
120
|
+
|
|
121
|
+
Do not assume every project uses SQLite. Other persistence requires an explicit repository-owned local setup contract.
|
|
122
|
+
|
|
123
|
+
### D. Build and tests
|
|
124
|
+
|
|
125
|
+
Run the repository's required local build and test commands. A missing or stale build is a normal convergence condition; rebuild it.
|
|
126
|
+
|
|
127
|
+
If build or required tests fail, report `LOCAL MODE NOT READY` and do not mark the mode ready.
|
|
128
|
+
|
|
129
|
+
When the project policy says application validation is local-only, GitHub publication/PR activity does not replace this local build/test evidence.
|
|
130
|
+
|
|
131
|
+
### E. Local immutable Docker artifact
|
|
132
|
+
|
|
133
|
+
When the local runtime is Docker-based, build the application image locally using repository-owned commands. Resolve an immutable local image identity.
|
|
134
|
+
|
|
135
|
+
The standard Deployment Kit workstation adapter may accept either:
|
|
136
|
+
|
|
137
|
+
- an exact registry digest (`name@sha256:...`), or
|
|
138
|
+
- a local Docker image ID (`sha256:<64 hex>`) for workstation-only execution.
|
|
139
|
+
|
|
140
|
+
Never use a mutable tag as the final deployed identity. A local image ID is valid only for workstation placement and is not remote-release evidence.
|
|
141
|
+
|
|
142
|
+
### F. Workstation runtime
|
|
143
|
+
|
|
144
|
+
Converge the local application runtime through the repository-distributed workstation adapter when available:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
placement: workstation
|
|
148
|
+
isolation: container
|
|
149
|
+
exposure: loopback
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Keep exposure loopback-only. Mount the project-owned local SQLite persistence directory when required. Verify the declared health endpoint and any required local smoke check.
|
|
153
|
+
|
|
154
|
+
Do not resolve SSH targets, publish images, create public routes, or deploy Staging/Production while entering local mode.
|
|
155
|
+
|
|
156
|
+
### G. Commit local mode only after readiness passes
|
|
157
|
+
|
|
158
|
+
Only after prerequisites, persistence, build, tests, runtime, and health requirements pass, write the workstation-local mode state as ready.
|
|
159
|
+
|
|
160
|
+
A successful summary should distinguish local evidence from remote evidence:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
LOCAL DEVELOPER ENVIRONMENT READY
|
|
164
|
+
|
|
165
|
+
Build ........ READY
|
|
166
|
+
Tests ........ READY
|
|
167
|
+
Docker ....... READY
|
|
168
|
+
SQLite ....... READY / NOT APPLICABLE
|
|
169
|
+
Application .. HEALTHY
|
|
170
|
+
Remote deploy. DISABLED BY MODE
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## 5. `remote` means verify remote-enabled capability, not deploy
|
|
174
|
+
|
|
175
|
+
Selecting `remote` authorizes bounded, non-destructive checks/setup needed to prove that the current workstation can work with the repository's configured remote non-Production environment(s). It does **not** publish an artifact or deploy an application.
|
|
176
|
+
|
|
177
|
+
Verify as applicable:
|
|
178
|
+
|
|
179
|
+
- normal local developer prerequisites still needed to build/test the project;
|
|
180
|
+
- repository deployment topology contains a non-deferred remote environment;
|
|
181
|
+
- exact target/provider binding exists when required;
|
|
182
|
+
- workstation-to-target trust/access is valid;
|
|
183
|
+
- target OS/runtime/platform readiness is valid;
|
|
184
|
+
- persistence/readiness prerequisites can be inspected safely;
|
|
185
|
+
- GitHub/registry access needed for a later release/publish step is available;
|
|
186
|
+
- repository release/deployment tooling and baselines are coherent.
|
|
187
|
+
|
|
188
|
+
For the standard remote-SSH path preserve strict host identity. Unknown or changed host identity is a stop condition. Never fall back to another environment or host.
|
|
189
|
+
|
|
190
|
+
Do not require a Production target merely to enter remote mode. Do not weaken Production authorization because remote mode is ready.
|
|
191
|
+
|
|
192
|
+
Only after the selected remote readiness checks pass, record `remote` as ready.
|
|
193
|
+
|
|
194
|
+
Then report the next explicit operation, typically:
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
/deployment-readiness <environment>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A later `/deploy <environment>` remains separately gated.
|
|
201
|
+
|
|
202
|
+
## 6. Fail closed on destructive or ambiguous repair
|
|
203
|
+
|
|
204
|
+
Automatic convergence may safely create missing local directories, dependency caches, build outputs, a new owned SQLite file, migrations that the project defines as normal forward initialization, and a new owned local application container.
|
|
205
|
+
|
|
206
|
+
Stop for a separate explicit decision before:
|
|
207
|
+
|
|
208
|
+
- deleting/resetting an existing database;
|
|
209
|
+
- destructive migration or rollback;
|
|
210
|
+
- replacing unexplained persistent data;
|
|
211
|
+
- deleting unrelated containers/images/volumes;
|
|
212
|
+
- publishing an artifact remotely;
|
|
213
|
+
- rebinding a target;
|
|
214
|
+
- weakening host verification;
|
|
215
|
+
- deploying Staging or Production;
|
|
216
|
+
- changing public/LAN exposure.
|
|
217
|
+
|
|
218
|
+
## 7. Relationship to other Lean commands
|
|
219
|
+
|
|
220
|
+
`/developer-env-mode` prepares a workstation and establishes current operational readiness.
|
|
221
|
+
|
|
222
|
+
It does not replace:
|
|
223
|
+
|
|
224
|
+
- `/lean-update` for managed Lean package changes;
|
|
225
|
+
- project setup/doctor tooling;
|
|
226
|
+
- `/start-work` for implementation authorization;
|
|
227
|
+
- `/deployment-readiness` for environment-specific pre-deploy evidence;
|
|
228
|
+
- `/deploy` for deployment authorization/execution;
|
|
229
|
+
- `/tear-down` for destructive cleanup.
|
|
230
|
+
|
|
231
|
+
Mode switching never implies merge, release publication, Staging deployment, or Production deployment.
|
|
@@ -13,13 +13,15 @@ import webbrowser
|
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from functools import partial
|
|
15
15
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
-
from pathlib import Path
|
|
16
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
IGNORE_DIRS = {".git", ".lean", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
|
|
20
20
|
DEFAULT_CACHE = Path(".lean/docs-cache")
|
|
21
21
|
DEFAULT_SITE = Path(".lean/docs-site")
|
|
22
22
|
MANIFEST = Path("docs/lean-docs.json")
|
|
23
|
+
SITE_MARKER = ".lean-docs-site"
|
|
24
|
+
SITE_MARKER_CONTENT = "lean-docs-site\n"
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class DocsSiteError(RuntimeError):
|
|
@@ -86,17 +88,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
|
|
|
86
88
|
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
87
89
|
warnings.append(f"docs/lean-docs.json documents[{index}] is invalid")
|
|
88
90
|
continue
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
raw_path = item["path"]
|
|
92
|
+
windows_path = PureWindowsPath(raw_path)
|
|
93
|
+
relative_path = PurePosixPath(raw_path.replace("\\", "/"))
|
|
94
|
+
if (
|
|
95
|
+
relative_path.is_absolute()
|
|
96
|
+
or windows_path.root
|
|
97
|
+
or windows_path.drive
|
|
98
|
+
or ".." in relative_path.parts
|
|
99
|
+
):
|
|
91
100
|
warnings.append(f"Skipped unsafe documentation path: {item['path']}")
|
|
92
101
|
continue
|
|
102
|
+
rel = relative_path.as_posix()
|
|
93
103
|
path = root / rel
|
|
104
|
+
try:
|
|
105
|
+
resolved_path = root / _safe_rel(root, path)
|
|
106
|
+
except DocsSiteError:
|
|
107
|
+
warnings.append(f"Skipped unsafe documentation path: {item['path']}")
|
|
108
|
+
continue
|
|
94
109
|
seen.add(rel)
|
|
95
|
-
if not
|
|
110
|
+
if not resolved_path.is_file():
|
|
96
111
|
if item.get("optional") is not True:
|
|
97
112
|
warnings.append(f"Listed documentation is not present: {rel}")
|
|
98
113
|
continue
|
|
99
|
-
content =
|
|
114
|
+
content = resolved_path.read_text(encoding="utf-8", errors="replace")
|
|
100
115
|
docs.append(
|
|
101
116
|
{
|
|
102
117
|
"path": rel,
|
|
@@ -116,14 +131,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
|
|
|
116
131
|
candidates.append(path)
|
|
117
132
|
docs_root = root / "docs"
|
|
118
133
|
if docs_root.is_dir():
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
if _path_contains_link(docs_root):
|
|
135
|
+
warnings.append(f"Skipped unsafe documentation directory: {docs_root}")
|
|
136
|
+
else:
|
|
137
|
+
for base, dirs, names in os.walk(docs_root):
|
|
138
|
+
safe_dirs = []
|
|
139
|
+
for directory in dirs:
|
|
140
|
+
candidate = Path(base) / directory
|
|
141
|
+
if directory in IGNORE_DIRS:
|
|
142
|
+
continue
|
|
143
|
+
if _is_link(candidate):
|
|
144
|
+
warnings.append(f"Skipped unsafe documentation directory: {candidate}")
|
|
145
|
+
continue
|
|
146
|
+
safe_dirs.append(directory)
|
|
147
|
+
dirs[:] = safe_dirs
|
|
148
|
+
for name in names:
|
|
149
|
+
if name.lower().endswith(".md"):
|
|
150
|
+
candidates.append(Path(base) / name)
|
|
124
151
|
|
|
125
152
|
for path in sorted(candidates):
|
|
126
|
-
|
|
153
|
+
try:
|
|
154
|
+
rel = _safe_rel(root, path)
|
|
155
|
+
except DocsSiteError:
|
|
156
|
+
warnings.append(f"Skipped unsafe documentation path: {path}")
|
|
157
|
+
continue
|
|
127
158
|
if rel in seen:
|
|
128
159
|
continue
|
|
129
160
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -220,14 +251,113 @@ def _prepare_system_lab(root: Path) -> str | None:
|
|
|
220
251
|
return None
|
|
221
252
|
|
|
222
253
|
|
|
223
|
-
def
|
|
254
|
+
def _validate_lab_cache(root: Path, kind: str) -> Path:
|
|
224
255
|
source = root / DEFAULT_CACHE / kind
|
|
225
|
-
if
|
|
226
|
-
|
|
256
|
+
if _path_contains_link(source):
|
|
257
|
+
raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
|
|
258
|
+
if source.exists():
|
|
259
|
+
try:
|
|
260
|
+
source.resolve().relative_to(root)
|
|
261
|
+
except ValueError as exc:
|
|
262
|
+
raise DocsSiteError(f"Lab cache escapes repository root: {source}") from exc
|
|
263
|
+
if source.is_dir() and _directory_contains_link(source):
|
|
264
|
+
raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
|
|
265
|
+
return source
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
|
|
269
|
+
source = _validate_lab_cache(root, kind)
|
|
227
270
|
target = out / "labs" / kind
|
|
271
|
+
try:
|
|
272
|
+
target.resolve().relative_to(out)
|
|
273
|
+
except ValueError as exc:
|
|
274
|
+
raise DocsSiteError(f"Lab output escapes generated site directory: {target}") from exc
|
|
275
|
+
if _path_contains_link(target):
|
|
276
|
+
raise DocsSiteError(f"Lab output contains a symlink or junction: {target}")
|
|
228
277
|
if target.exists():
|
|
278
|
+
if target.is_symlink() or not target.is_dir():
|
|
279
|
+
raise DocsSiteError(f"Unexpected lab output path: {target}")
|
|
229
280
|
shutil.rmtree(target)
|
|
230
|
-
|
|
281
|
+
if source.is_dir():
|
|
282
|
+
shutil.copytree(source, target)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_link(path: Path) -> bool:
|
|
286
|
+
if path.is_symlink():
|
|
287
|
+
return True
|
|
288
|
+
is_junction = getattr(path, "is_junction", None)
|
|
289
|
+
return bool(is_junction and is_junction())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _path_contains_link(path: Path) -> bool:
|
|
293
|
+
absolute = Path(os.path.abspath(path))
|
|
294
|
+
current = Path(absolute.anchor)
|
|
295
|
+
for part in absolute.parts[1:]:
|
|
296
|
+
current /= part
|
|
297
|
+
if _is_link(current):
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _paths_overlap(left: Path, right: Path) -> bool:
|
|
303
|
+
left = Path(os.path.abspath(left))
|
|
304
|
+
right = Path(os.path.abspath(right))
|
|
305
|
+
return left == right or left in right.parents or right in left.parents
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _directory_contains_link(root: Path) -> bool:
|
|
309
|
+
directories = [root]
|
|
310
|
+
while directories:
|
|
311
|
+
current = directories.pop()
|
|
312
|
+
with os.scandir(current) as entries:
|
|
313
|
+
for entry in entries:
|
|
314
|
+
path = Path(entry.path)
|
|
315
|
+
if _is_link(path):
|
|
316
|
+
return True
|
|
317
|
+
if entry.is_dir(follow_symlinks=False):
|
|
318
|
+
directories.append(path)
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _prepare_output(root: Path, out: Path) -> None:
|
|
323
|
+
if _path_contains_link(out):
|
|
324
|
+
raise DocsSiteError(f"Refusing to use an output path containing a symlink or junction: {out}")
|
|
325
|
+
if out == root or out in root.parents:
|
|
326
|
+
raise DocsSiteError(f"Refusing to use the repository root or one of its parents as output: {out}")
|
|
327
|
+
for kind in ("system", "database"):
|
|
328
|
+
cache = root / DEFAULT_CACHE / kind
|
|
329
|
+
if _paths_overlap(out, cache):
|
|
330
|
+
raise DocsSiteError(f"Output path overlaps a lab cache: {cache}")
|
|
331
|
+
|
|
332
|
+
if out.exists():
|
|
333
|
+
if not out.is_dir():
|
|
334
|
+
raise DocsSiteError(f"Output path is not a directory: {out}")
|
|
335
|
+
marker = out / SITE_MARKER
|
|
336
|
+
if any(out.iterdir()):
|
|
337
|
+
if marker.is_symlink() or not marker.is_file():
|
|
338
|
+
raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
|
|
339
|
+
try:
|
|
340
|
+
marker_content = marker.read_text(encoding="utf-8")
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
raise DocsSiteError(f"Unable to verify output ownership: {marker}") from exc
|
|
343
|
+
if marker_content != SITE_MARKER_CONTENT:
|
|
344
|
+
raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
|
|
345
|
+
|
|
346
|
+
if _directory_contains_link(out):
|
|
347
|
+
raise DocsSiteError(f"Refusing to write through a symlink in output directory: {out}")
|
|
348
|
+
|
|
349
|
+
for name in (SITE_MARKER, "index.html", "site.json"):
|
|
350
|
+
path = out / name
|
|
351
|
+
if path.is_file():
|
|
352
|
+
try:
|
|
353
|
+
link_count = path.stat().st_nlink
|
|
354
|
+
except OSError as exc:
|
|
355
|
+
raise DocsSiteError(f"Unable to verify output file links: {path}") from exc
|
|
356
|
+
if link_count > 1:
|
|
357
|
+
raise DocsSiteError(f"Refusing to overwrite hard-linked output file: {path}")
|
|
358
|
+
|
|
359
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
(out / SITE_MARKER).write_text(SITE_MARKER_CONTENT, encoding="utf-8")
|
|
231
361
|
|
|
232
362
|
|
|
233
363
|
def _site_payload(root: Path) -> dict[str, Any]:
|
|
@@ -299,16 +429,16 @@ document.querySelector('#siteTitle').textContent=SITE.title;document.querySelect
|
|
|
299
429
|
|
|
300
430
|
def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
|
|
301
431
|
root = root.resolve()
|
|
302
|
-
out =
|
|
432
|
+
out = Path(os.path.abspath(out))
|
|
303
433
|
if not root.is_dir():
|
|
304
434
|
raise DocsSiteError(f"Repository root does not exist: {root}")
|
|
435
|
+
for kind in ("system", "database"):
|
|
436
|
+
_validate_lab_cache(root, kind)
|
|
437
|
+
_prepare_output(root, out)
|
|
305
438
|
prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
|
|
306
439
|
payload = _site_payload(root)
|
|
307
440
|
if prep_warning:
|
|
308
441
|
payload["warnings"].append(prep_warning)
|
|
309
|
-
if out.exists():
|
|
310
|
-
shutil.rmtree(out)
|
|
311
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
312
442
|
_copy_lab_cache(root, out, "system")
|
|
313
443
|
_copy_lab_cache(root, out, "database")
|
|
314
444
|
index = out / "index.html"
|
|
@@ -359,7 +489,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
359
489
|
def main() -> int:
|
|
360
490
|
args = parse_args()
|
|
361
491
|
root = args.root.resolve()
|
|
362
|
-
out = args.out
|
|
492
|
+
out = args.out if args.out else root / DEFAULT_SITE
|
|
363
493
|
try:
|
|
364
494
|
if args.command == "build":
|
|
365
495
|
index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
|
|
@@ -12,9 +12,13 @@ from .local_runtime import (
|
|
|
12
12
|
apply_local_deploy,
|
|
13
13
|
local_deploy_write_phrase,
|
|
14
14
|
local_inventory,
|
|
15
|
-
normalize_local_spec,
|
|
16
15
|
plan_local_deploy,
|
|
17
16
|
)
|
|
17
|
+
from .local_spec import normalize_workstation_spec
|
|
18
|
+
from .local_sqlite import ensure_local_sqlite, local_sqlite_status
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LOCAL_SQLITE_ENSURE_WRITE_PHRASE = "[LOCAL SQLITE ENSURE]"
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
def _print(value: Any, *, as_json: bool) -> None:
|
|
@@ -45,6 +49,14 @@ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
|
45
49
|
raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
|
|
46
50
|
|
|
47
51
|
|
|
52
|
+
def _add_sqlite_identity(parser: argparse.ArgumentParser) -> None:
|
|
53
|
+
parser.add_argument("--owner-repo", required=True)
|
|
54
|
+
parser.add_argument("--environment", required=True)
|
|
55
|
+
parser.add_argument("--app-slug", required=True)
|
|
56
|
+
parser.add_argument("--database-file", required=True)
|
|
57
|
+
parser.add_argument("--json", action="store_true")
|
|
58
|
+
|
|
59
|
+
|
|
48
60
|
def build_parser() -> argparse.ArgumentParser:
|
|
49
61
|
parser = argparse.ArgumentParser(prog="lean-poc-deploy local")
|
|
50
62
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -55,6 +67,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
55
67
|
)
|
|
56
68
|
inventory.add_argument("--json", action="store_true")
|
|
57
69
|
|
|
70
|
+
sqlite_status = sub.add_parser(
|
|
71
|
+
"sqlite-status",
|
|
72
|
+
help="Inspect one Lean-owned workstation SQLite database without writes",
|
|
73
|
+
)
|
|
74
|
+
_add_sqlite_identity(sqlite_status)
|
|
75
|
+
|
|
76
|
+
sqlite_ensure = sub.add_parser(
|
|
77
|
+
"sqlite-ensure",
|
|
78
|
+
help="Create a missing Lean-owned workstation SQLite database non-destructively",
|
|
79
|
+
)
|
|
80
|
+
_add_sqlite_identity(sqlite_ensure)
|
|
81
|
+
sqlite_ensure.add_argument("--yes", action="store_true")
|
|
82
|
+
|
|
58
83
|
deploy = sub.add_parser(
|
|
59
84
|
"deploy",
|
|
60
85
|
help="Deploy one immutable application container bound only to loopback",
|
|
@@ -83,7 +108,50 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
83
108
|
_print(local_inventory(), as_json=args.json)
|
|
84
109
|
return 0
|
|
85
110
|
|
|
86
|
-
|
|
111
|
+
if args.command == "sqlite-status":
|
|
112
|
+
_print(
|
|
113
|
+
local_sqlite_status(
|
|
114
|
+
owner_repo=args.owner_repo,
|
|
115
|
+
environment=args.environment,
|
|
116
|
+
app_slug=args.app_slug,
|
|
117
|
+
database_file=args.database_file,
|
|
118
|
+
),
|
|
119
|
+
as_json=args.json,
|
|
120
|
+
)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
if args.command == "sqlite-ensure":
|
|
124
|
+
status = local_sqlite_status(
|
|
125
|
+
owner_repo=args.owner_repo,
|
|
126
|
+
environment=args.environment,
|
|
127
|
+
app_slug=args.app_slug,
|
|
128
|
+
database_file=args.database_file,
|
|
129
|
+
)
|
|
130
|
+
if status["state"] == "ready":
|
|
131
|
+
_print(status, as_json=args.json)
|
|
132
|
+
return 0
|
|
133
|
+
if status["state"] == "unmanaged-existing":
|
|
134
|
+
raise DeploymentKitError(
|
|
135
|
+
"Refusing automatic adoption of an existing unmanaged local SQLite database."
|
|
136
|
+
)
|
|
137
|
+
_print(status, as_json=False)
|
|
138
|
+
_confirm(
|
|
139
|
+
args,
|
|
140
|
+
LOCAL_SQLITE_ENSURE_WRITE_PHRASE,
|
|
141
|
+
f"create local SQLite storage for {args.app_slug} ({args.environment})",
|
|
142
|
+
)
|
|
143
|
+
_print(
|
|
144
|
+
ensure_local_sqlite(
|
|
145
|
+
owner_repo=args.owner_repo,
|
|
146
|
+
environment=args.environment,
|
|
147
|
+
app_slug=args.app_slug,
|
|
148
|
+
database_file=args.database_file,
|
|
149
|
+
),
|
|
150
|
+
as_json=args.json,
|
|
151
|
+
)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
spec = normalize_workstation_spec(
|
|
87
155
|
owner_repo=args.owner_repo,
|
|
88
156
|
environment=args.environment,
|
|
89
157
|
app_slug=args.app_slug,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Workstation-only application spec normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Iterable
|
|
9
|
+
|
|
10
|
+
from .app_runtime import normalize_app_spec
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_LOCAL_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _host_port(value: int) -> int:
|
|
18
|
+
if type(value) is not int or not 1 <= value <= 65535:
|
|
19
|
+
raise DeploymentKitError("hostPort must be an integer from 1 through 65535.")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _base_image(image: str) -> tuple[str, bool]:
|
|
24
|
+
if isinstance(image, str) and _LOCAL_IMAGE_ID_RE.fullmatch(image):
|
|
25
|
+
return f"local-workstation/immutable@{image}", True
|
|
26
|
+
return image, False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_workstation_spec(
|
|
30
|
+
*,
|
|
31
|
+
owner_repo: str,
|
|
32
|
+
environment: str,
|
|
33
|
+
app_slug: str,
|
|
34
|
+
container_name: str,
|
|
35
|
+
image: str,
|
|
36
|
+
runtime_port: int,
|
|
37
|
+
host_port: int,
|
|
38
|
+
health_path: str,
|
|
39
|
+
run_as: str,
|
|
40
|
+
persistence_mode: str,
|
|
41
|
+
data_container_path: str | None,
|
|
42
|
+
environment_variables: dict[str, str] | Iterable[tuple[str, str]],
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""Normalize a loopback-only workstation spec.
|
|
45
|
+
|
|
46
|
+
Remote deployment keeps the registry-digest-only contract. Workstation execution
|
|
47
|
+
additionally accepts an immutable local Docker image ID (sha256:<64 hex>) so a
|
|
48
|
+
developer can build/test/run without publishing to a registry first.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
normalized_image, local_image_id = _base_image(image)
|
|
52
|
+
base = normalize_app_spec(
|
|
53
|
+
owner_repo=owner_repo,
|
|
54
|
+
environment=environment,
|
|
55
|
+
app_slug=app_slug,
|
|
56
|
+
container_name=container_name,
|
|
57
|
+
image=normalized_image,
|
|
58
|
+
runtime_port=runtime_port,
|
|
59
|
+
health_path=health_path,
|
|
60
|
+
run_as=run_as,
|
|
61
|
+
persistence_mode=persistence_mode,
|
|
62
|
+
data_container_path=data_container_path,
|
|
63
|
+
environment_variables=environment_variables,
|
|
64
|
+
)
|
|
65
|
+
base.pop("specSha256", None)
|
|
66
|
+
if local_image_id:
|
|
67
|
+
base["image"] = image
|
|
68
|
+
base.update(
|
|
69
|
+
{
|
|
70
|
+
"schemaVersion": 1,
|
|
71
|
+
"placement": "workstation",
|
|
72
|
+
"isolation": "container",
|
|
73
|
+
"exposure": "loopback",
|
|
74
|
+
"hostAddress": "127.0.0.1",
|
|
75
|
+
"hostPort": _host_port(host_port),
|
|
76
|
+
"imageSource": "local-image-id" if local_image_id else "registry-digest",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
base["specSha256"] = hashlib.sha256(
|
|
80
|
+
json.dumps(base, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
81
|
+
).hexdigest()
|
|
82
|
+
return base
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Non-destructive workstation SQLite lifecycle helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
from .profiles import config_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
16
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
17
|
+
_DB_FILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
18
|
+
_METADATA_FILE = "sqlite-local.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _slug(value: str, field: str) -> str:
|
|
22
|
+
if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
|
|
23
|
+
raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _owner_repo(value: str) -> str:
|
|
28
|
+
if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
|
|
29
|
+
raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _database_file(value: str) -> str:
|
|
34
|
+
if not isinstance(value, str) or _DB_FILE_RE.fullmatch(value) is None or value in {".", ".."}:
|
|
35
|
+
raise DeploymentKitError("databaseFile must be a safe file name without path separators.")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _state_root(app_slug: str, environment: str) -> Path:
|
|
40
|
+
return config_root() / "local-apps" / app_slug / environment
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _data_dir(app_slug: str, environment: str) -> Path:
|
|
44
|
+
return _state_root(app_slug, environment) / "data"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _metadata_path(app_slug: str, environment: str) -> Path:
|
|
48
|
+
return _state_root(app_slug, environment) / _METADATA_FILE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _database_path(app_slug: str, environment: str, database_file: str) -> Path:
|
|
52
|
+
return _data_dir(app_slug, environment) / database_file
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_metadata(app_slug: str, environment: str) -> dict[str, Any] | None:
|
|
56
|
+
path = _metadata_path(app_slug, environment)
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
62
|
+
raise DeploymentKitError(f"Cannot read local SQLite metadata {path}: {error}") from error
|
|
63
|
+
if not isinstance(value, dict) or value.get("schemaVersion") != 1:
|
|
64
|
+
raise DeploymentKitError(f"Unsupported local SQLite metadata: {path}")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _write_metadata(path: Path, value: dict[str, Any]) -> None:
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
temporary = path.with_suffix(".tmp")
|
|
71
|
+
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
72
|
+
temporary.replace(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _quick_check(path: Path) -> str:
|
|
76
|
+
try:
|
|
77
|
+
with sqlite3.connect(path) as connection:
|
|
78
|
+
row = connection.execute("PRAGMA quick_check").fetchone()
|
|
79
|
+
except sqlite3.DatabaseError as error:
|
|
80
|
+
raise DeploymentKitError(f"Local SQLite database is not valid: {path}: {error}") from error
|
|
81
|
+
result = str(row[0]) if row else "missing-result"
|
|
82
|
+
if result.lower() != "ok":
|
|
83
|
+
raise DeploymentKitError(f"Local SQLite quick_check failed for {path}: {result}")
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def local_sqlite_status(
|
|
88
|
+
*, owner_repo: str, environment: str, app_slug: str, database_file: str
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
owner_repo = _owner_repo(owner_repo)
|
|
91
|
+
environment = _slug(environment, "environment")
|
|
92
|
+
app_slug = _slug(app_slug, "appSlug")
|
|
93
|
+
database_file = _database_file(database_file)
|
|
94
|
+
metadata = _load_metadata(app_slug, environment)
|
|
95
|
+
path = _database_path(app_slug, environment, database_file)
|
|
96
|
+
|
|
97
|
+
if metadata is not None:
|
|
98
|
+
expected = {
|
|
99
|
+
"ownerRepo": owner_repo,
|
|
100
|
+
"environment": environment,
|
|
101
|
+
"appSlug": app_slug,
|
|
102
|
+
"databaseFile": database_file,
|
|
103
|
+
}
|
|
104
|
+
for key, value in expected.items():
|
|
105
|
+
if metadata.get(key) != value:
|
|
106
|
+
raise DeploymentKitError(
|
|
107
|
+
f"Local SQLite metadata ownership mismatch for {app_slug}/{environment}: {key}."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if path.exists() and metadata is None:
|
|
111
|
+
return {
|
|
112
|
+
"schemaVersion": 1,
|
|
113
|
+
"ownerRepo": owner_repo,
|
|
114
|
+
"environment": environment,
|
|
115
|
+
"appSlug": app_slug,
|
|
116
|
+
"databaseFile": database_file,
|
|
117
|
+
"databasePath": str(path),
|
|
118
|
+
"state": "unmanaged-existing",
|
|
119
|
+
"managed": False,
|
|
120
|
+
"present": True,
|
|
121
|
+
"valid": False,
|
|
122
|
+
"mutated": False,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return {
|
|
127
|
+
"schemaVersion": 1,
|
|
128
|
+
"ownerRepo": owner_repo,
|
|
129
|
+
"environment": environment,
|
|
130
|
+
"appSlug": app_slug,
|
|
131
|
+
"databaseFile": database_file,
|
|
132
|
+
"databasePath": str(path),
|
|
133
|
+
"state": "missing",
|
|
134
|
+
"managed": metadata is not None,
|
|
135
|
+
"present": False,
|
|
136
|
+
"valid": False,
|
|
137
|
+
"mutated": False,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_quick_check(path)
|
|
141
|
+
return {
|
|
142
|
+
"schemaVersion": 1,
|
|
143
|
+
"ownerRepo": owner_repo,
|
|
144
|
+
"environment": environment,
|
|
145
|
+
"appSlug": app_slug,
|
|
146
|
+
"databaseFile": database_file,
|
|
147
|
+
"databasePath": str(path),
|
|
148
|
+
"state": "ready",
|
|
149
|
+
"managed": True,
|
|
150
|
+
"present": True,
|
|
151
|
+
"valid": True,
|
|
152
|
+
"bytes": path.stat().st_size,
|
|
153
|
+
"mutated": False,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure_local_sqlite(
|
|
158
|
+
*, owner_repo: str, environment: str, app_slug: str, database_file: str
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
owner_repo = _owner_repo(owner_repo)
|
|
161
|
+
environment = _slug(environment, "environment")
|
|
162
|
+
app_slug = _slug(app_slug, "appSlug")
|
|
163
|
+
database_file = _database_file(database_file)
|
|
164
|
+
|
|
165
|
+
status = local_sqlite_status(
|
|
166
|
+
owner_repo=owner_repo,
|
|
167
|
+
environment=environment,
|
|
168
|
+
app_slug=app_slug,
|
|
169
|
+
database_file=database_file,
|
|
170
|
+
)
|
|
171
|
+
if status["state"] == "unmanaged-existing":
|
|
172
|
+
raise DeploymentKitError(
|
|
173
|
+
"Refusing to adopt an existing unmanaged local SQLite database automatically. "
|
|
174
|
+
"Review its ownership/data before using or moving it."
|
|
175
|
+
)
|
|
176
|
+
if status["state"] == "ready":
|
|
177
|
+
return status
|
|
178
|
+
|
|
179
|
+
path = _database_path(app_slug, environment, database_file)
|
|
180
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
try:
|
|
182
|
+
with sqlite3.connect(path) as connection:
|
|
183
|
+
connection.execute("PRAGMA user_version")
|
|
184
|
+
connection.commit()
|
|
185
|
+
except sqlite3.DatabaseError as error:
|
|
186
|
+
raise DeploymentKitError(f"Unable to create local SQLite database {path}: {error}") from error
|
|
187
|
+
|
|
188
|
+
metadata = {
|
|
189
|
+
"schemaVersion": 1,
|
|
190
|
+
"ownerRepo": owner_repo,
|
|
191
|
+
"environment": environment,
|
|
192
|
+
"appSlug": app_slug,
|
|
193
|
+
"databaseFile": database_file,
|
|
194
|
+
}
|
|
195
|
+
_write_metadata(_metadata_path(app_slug, environment), metadata)
|
|
196
|
+
_quick_check(path)
|
|
197
|
+
result = local_sqlite_status(
|
|
198
|
+
owner_repo=owner_repo,
|
|
199
|
+
environment=environment,
|
|
200
|
+
app_slug=app_slug,
|
|
201
|
+
database_file=database_file,
|
|
202
|
+
)
|
|
203
|
+
result["mutated"] = True
|
|
204
|
+
result["created"] = True
|
|
205
|
+
return result
|