npmctl 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- npmctl-0.3.1/PKG-INFO +208 -0
- npmctl-0.3.1/README.md +179 -0
- npmctl-0.3.1/pyproject.toml +48 -0
- npmctl-0.3.1/src/npmctl/__init__.py +3 -0
- npmctl-0.3.1/src/npmctl/__main__.py +5 -0
- npmctl-0.3.1/src/npmctl/adoption.py +5 -0
- npmctl-0.3.1/src/npmctl/apply.py +238 -0
- npmctl-0.3.1/src/npmctl/cli.py +407 -0
- npmctl-0.3.1/src/npmctl/client/__init__.py +5 -0
- npmctl-0.3.1/src/npmctl/client/access_lists.py +10 -0
- npmctl-0.3.1/src/npmctl/client/auth.py +5 -0
- npmctl-0.3.1/src/npmctl/client/base.py +275 -0
- npmctl-0.3.1/src/npmctl/client/certificates.py +10 -0
- npmctl-0.3.1/src/npmctl/client/contracts.py +30 -0
- npmctl-0.3.1/src/npmctl/client/proxy_hosts.py +10 -0
- npmctl-0.3.1/src/npmctl/config.py +40 -0
- npmctl-0.3.1/src/npmctl/diagnostics.py +41 -0
- npmctl-0.3.1/src/npmctl/errors.py +31 -0
- npmctl-0.3.1/src/npmctl/loader.py +281 -0
- npmctl-0.3.1/src/npmctl/logging.py +13 -0
- npmctl-0.3.1/src/npmctl/metadata.py +78 -0
- npmctl-0.3.1/src/npmctl/migrations/__init__.py +5 -0
- npmctl-0.3.1/src/npmctl/migrations/base.py +18 -0
- npmctl-0.3.1/src/npmctl/migrations/registry.py +86 -0
- npmctl-0.3.1/src/npmctl/migrations/v1.py +7 -0
- npmctl-0.3.1/src/npmctl/models.py +750 -0
- npmctl-0.3.1/src/npmctl/operational.py +245 -0
- npmctl-0.3.1/src/npmctl/output.py +50 -0
- npmctl-0.3.1/src/npmctl/planner.py +459 -0
- npmctl-0.3.1/src/npmctl/plugins.py +95 -0
- npmctl-0.3.1/src/npmctl/py.typed +0 -0
- npmctl-0.3.1/src/npmctl/schema.py +170 -0
- npmctl-0.3.1/src/npmctl/validation.py +5 -0
npmctl-0.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: npmctl
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Owner-scoped plan/apply/adopt controller for Nginx Proxy Manager resources.
|
|
5
|
+
Keywords: nginx-proxy-manager,reverse-proxy,gitops,controller,ssl-certificates,access-lists
|
|
6
|
+
Author: npmctl contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
19
|
+
Classifier: Topic :: System :: Systems Administration
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
22
|
+
Requires-Dist: requests>=2.32.0
|
|
23
|
+
Requires-Python: >=3.11, <3.14
|
|
24
|
+
Project-URL: Homepage, https://github.com/groupsum/npmctl
|
|
25
|
+
Project-URL: Documentation, https://github.com/groupsum/npmctl/tree/master/docs
|
|
26
|
+
Project-URL: Issues, https://github.com/groupsum/npmctl/issues
|
|
27
|
+
Project-URL: Repository, https://github.com/groupsum/npmctl
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# npmctl
|
|
31
|
+
|
|
32
|
+
`npmctl` is the Python package and console script for owner-scoped Nginx Proxy Manager automation. It validates desired-state YAML, computes safe plans, applies clean changes, and adopts unmanaged resources only when explicitly requested.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Use `pipx` for an isolated CLI install:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pipx install npmctl
|
|
40
|
+
npmctl --version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use `uv` if you manage tools with uv:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install npmctl
|
|
47
|
+
npmctl --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `pip` inside an existing virtual environment:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m venv .venv
|
|
54
|
+
. .venv/bin/activate
|
|
55
|
+
python -m pip install npmctl
|
|
56
|
+
npmctl --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
PowerShell activation:
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
python -m venv .venv
|
|
63
|
+
.\.venv\Scripts\Activate.ps1
|
|
64
|
+
python -m pip install npmctl
|
|
65
|
+
npmctl --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Configure NPM
|
|
69
|
+
|
|
70
|
+
Set Nginx Proxy Manager API credentials as environment variables:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export NPM_BASE_URL=http://127.0.0.1:81/api
|
|
74
|
+
export NPM_IDENTITY=admin@example.com
|
|
75
|
+
export NPM_SECRET=changeme
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or pass them directly:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npmctl --base-url http://127.0.0.1:81/api --identity admin@example.com --secret changeme health
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Desired State
|
|
85
|
+
|
|
86
|
+
Every managed resource needs npmctl ownership metadata:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
apiVersion: npmctl.io/v1
|
|
90
|
+
schemaVersion: 1
|
|
91
|
+
proxy_hosts:
|
|
92
|
+
- domain_names: [app.example.com]
|
|
93
|
+
forward_scheme: http
|
|
94
|
+
forward_host: app
|
|
95
|
+
forward_port: 3000
|
|
96
|
+
meta:
|
|
97
|
+
managed_by: npmctl
|
|
98
|
+
owner: workload-a
|
|
99
|
+
resource_id: proxy.app
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
References use `resource_id` values:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
apiVersion: npmctl.io/v1
|
|
106
|
+
schemaVersion: 1
|
|
107
|
+
certificates:
|
|
108
|
+
- name: wildcard-example
|
|
109
|
+
domain_names: ["*.example.com", example.com]
|
|
110
|
+
certificate_type: letsencrypt
|
|
111
|
+
api_payload:
|
|
112
|
+
provider: letsencrypt
|
|
113
|
+
meta:
|
|
114
|
+
managed_by: npmctl
|
|
115
|
+
owner: workload-a
|
|
116
|
+
resource_id: cert.wildcard-example
|
|
117
|
+
access_lists:
|
|
118
|
+
- name: private-admins
|
|
119
|
+
api_payload:
|
|
120
|
+
satisfy_any: 0
|
|
121
|
+
items: []
|
|
122
|
+
clients: []
|
|
123
|
+
meta:
|
|
124
|
+
managed_by: npmctl
|
|
125
|
+
owner: workload-a
|
|
126
|
+
resource_id: acl.private-admins
|
|
127
|
+
proxy_hosts:
|
|
128
|
+
- domain_names: [app.example.com]
|
|
129
|
+
forward_host: app
|
|
130
|
+
forward_port: 3000
|
|
131
|
+
certificate_ref: cert.wildcard-example
|
|
132
|
+
access_list_ref: acl.private-admins
|
|
133
|
+
ssl_forced: 1
|
|
134
|
+
allow_websocket_upgrade: 1
|
|
135
|
+
caching_enabled: 1
|
|
136
|
+
block_exploits: 1
|
|
137
|
+
meta:
|
|
138
|
+
managed_by: npmctl
|
|
139
|
+
owner: workload-a
|
|
140
|
+
resource_id: proxy.app
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Commands
|
|
144
|
+
|
|
145
|
+
Validate files without calling the NPM API:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npmctl validate ./desired-state
|
|
149
|
+
npmctl --output json validate ./desired-state
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Check or write desired-state schema migrations:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npmctl migrate ./desired-state --check
|
|
156
|
+
npmctl migrate ./desired-state --write
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Check the target NPM API:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npmctl health
|
|
163
|
+
npmctl schema fetch --write npm-openapi.json
|
|
164
|
+
npmctl schema capabilities
|
|
165
|
+
npmctl schema check
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Plan and apply by owner:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npmctl plan ./desired-state --owner workload-a
|
|
172
|
+
npmctl apply ./desired-state --owner workload-a
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Preview apply without mutation:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npmctl apply ./desired-state --owner workload-a --dry-run
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Prune owned resources absent from desired state:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npmctl apply ./desired-state --owner workload-a --prune-owned
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Adopt unmanaged matching resources:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npmctl adopt ./desired-state --owner workload-a
|
|
191
|
+
npmctl adopt ./desired-state --owner workload-a --allow-field-drift
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Exit Codes
|
|
195
|
+
|
|
196
|
+
- `0`: success
|
|
197
|
+
- `1`: plan conflict
|
|
198
|
+
- `2`: usage, validation, or migration error
|
|
199
|
+
- `3`: API error
|
|
200
|
+
- `4`: endpoint capability error
|
|
201
|
+
|
|
202
|
+
## More Documentation
|
|
203
|
+
|
|
204
|
+
The source repository includes detailed docs and examples:
|
|
205
|
+
|
|
206
|
+
- https://github.com/groupsum/npmctl
|
|
207
|
+
- https://github.com/groupsum/npmctl/tree/master/examples/desired-state
|
|
208
|
+
- https://github.com/groupsum/npmctl/tree/master/docs
|
npmctl-0.3.1/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# npmctl
|
|
2
|
+
|
|
3
|
+
`npmctl` is the Python package and console script for owner-scoped Nginx Proxy Manager automation. It validates desired-state YAML, computes safe plans, applies clean changes, and adopts unmanaged resources only when explicitly requested.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Use `pipx` for an isolated CLI install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install npmctl
|
|
11
|
+
npmctl --version
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use `uv` if you manage tools with uv:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv tool install npmctl
|
|
18
|
+
npmctl --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use `pip` inside an existing virtual environment:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python -m venv .venv
|
|
25
|
+
. .venv/bin/activate
|
|
26
|
+
python -m pip install npmctl
|
|
27
|
+
npmctl --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
PowerShell activation:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
python -m venv .venv
|
|
34
|
+
.\.venv\Scripts\Activate.ps1
|
|
35
|
+
python -m pip install npmctl
|
|
36
|
+
npmctl --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configure NPM
|
|
40
|
+
|
|
41
|
+
Set Nginx Proxy Manager API credentials as environment variables:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export NPM_BASE_URL=http://127.0.0.1:81/api
|
|
45
|
+
export NPM_IDENTITY=admin@example.com
|
|
46
|
+
export NPM_SECRET=changeme
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or pass them directly:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npmctl --base-url http://127.0.0.1:81/api --identity admin@example.com --secret changeme health
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Desired State
|
|
56
|
+
|
|
57
|
+
Every managed resource needs npmctl ownership metadata:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
apiVersion: npmctl.io/v1
|
|
61
|
+
schemaVersion: 1
|
|
62
|
+
proxy_hosts:
|
|
63
|
+
- domain_names: [app.example.com]
|
|
64
|
+
forward_scheme: http
|
|
65
|
+
forward_host: app
|
|
66
|
+
forward_port: 3000
|
|
67
|
+
meta:
|
|
68
|
+
managed_by: npmctl
|
|
69
|
+
owner: workload-a
|
|
70
|
+
resource_id: proxy.app
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
References use `resource_id` values:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
apiVersion: npmctl.io/v1
|
|
77
|
+
schemaVersion: 1
|
|
78
|
+
certificates:
|
|
79
|
+
- name: wildcard-example
|
|
80
|
+
domain_names: ["*.example.com", example.com]
|
|
81
|
+
certificate_type: letsencrypt
|
|
82
|
+
api_payload:
|
|
83
|
+
provider: letsencrypt
|
|
84
|
+
meta:
|
|
85
|
+
managed_by: npmctl
|
|
86
|
+
owner: workload-a
|
|
87
|
+
resource_id: cert.wildcard-example
|
|
88
|
+
access_lists:
|
|
89
|
+
- name: private-admins
|
|
90
|
+
api_payload:
|
|
91
|
+
satisfy_any: 0
|
|
92
|
+
items: []
|
|
93
|
+
clients: []
|
|
94
|
+
meta:
|
|
95
|
+
managed_by: npmctl
|
|
96
|
+
owner: workload-a
|
|
97
|
+
resource_id: acl.private-admins
|
|
98
|
+
proxy_hosts:
|
|
99
|
+
- domain_names: [app.example.com]
|
|
100
|
+
forward_host: app
|
|
101
|
+
forward_port: 3000
|
|
102
|
+
certificate_ref: cert.wildcard-example
|
|
103
|
+
access_list_ref: acl.private-admins
|
|
104
|
+
ssl_forced: 1
|
|
105
|
+
allow_websocket_upgrade: 1
|
|
106
|
+
caching_enabled: 1
|
|
107
|
+
block_exploits: 1
|
|
108
|
+
meta:
|
|
109
|
+
managed_by: npmctl
|
|
110
|
+
owner: workload-a
|
|
111
|
+
resource_id: proxy.app
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Commands
|
|
115
|
+
|
|
116
|
+
Validate files without calling the NPM API:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npmctl validate ./desired-state
|
|
120
|
+
npmctl --output json validate ./desired-state
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Check or write desired-state schema migrations:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npmctl migrate ./desired-state --check
|
|
127
|
+
npmctl migrate ./desired-state --write
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Check the target NPM API:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npmctl health
|
|
134
|
+
npmctl schema fetch --write npm-openapi.json
|
|
135
|
+
npmctl schema capabilities
|
|
136
|
+
npmctl schema check
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Plan and apply by owner:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npmctl plan ./desired-state --owner workload-a
|
|
143
|
+
npmctl apply ./desired-state --owner workload-a
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Preview apply without mutation:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npmctl apply ./desired-state --owner workload-a --dry-run
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Prune owned resources absent from desired state:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npmctl apply ./desired-state --owner workload-a --prune-owned
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Adopt unmanaged matching resources:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npmctl adopt ./desired-state --owner workload-a
|
|
162
|
+
npmctl adopt ./desired-state --owner workload-a --allow-field-drift
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Exit Codes
|
|
166
|
+
|
|
167
|
+
- `0`: success
|
|
168
|
+
- `1`: plan conflict
|
|
169
|
+
- `2`: usage, validation, or migration error
|
|
170
|
+
- `3`: API error
|
|
171
|
+
- `4`: endpoint capability error
|
|
172
|
+
|
|
173
|
+
## More Documentation
|
|
174
|
+
|
|
175
|
+
The source repository includes detailed docs and examples:
|
|
176
|
+
|
|
177
|
+
- https://github.com/groupsum/npmctl
|
|
178
|
+
- https://github.com/groupsum/npmctl/tree/master/examples/desired-state
|
|
179
|
+
- https://github.com/groupsum/npmctl/tree/master/docs
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "npmctl"
|
|
3
|
+
version = "0.3.1"
|
|
4
|
+
description = "Owner-scoped plan/apply/adopt controller for Nginx Proxy Manager resources."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11,<3.14"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "npmctl contributors" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"nginx-proxy-manager",
|
|
11
|
+
"reverse-proxy",
|
|
12
|
+
"gitops",
|
|
13
|
+
"controller",
|
|
14
|
+
"ssl-certificates",
|
|
15
|
+
"access-lists",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
29
|
+
"Topic :: System :: Systems Administration",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"PyYAML>=6.0.2",
|
|
34
|
+
"requests>=2.32.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/groupsum/npmctl"
|
|
39
|
+
Documentation = "https://github.com/groupsum/npmctl/tree/master/docs"
|
|
40
|
+
Issues = "https://github.com/groupsum/npmctl/issues"
|
|
41
|
+
Repository = "https://github.com/groupsum/npmctl"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
npmctl = "npmctl.cli:main"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.11.8,<0.12"]
|
|
48
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Apply owner-scoped plans to NPM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from npmctl.client import NpmClient
|
|
9
|
+
from npmctl.errors import ApiError, ConflictError, ValidationError
|
|
10
|
+
from npmctl.metadata import merge_managed_meta
|
|
11
|
+
from npmctl.models import (
|
|
12
|
+
DesiredAccessList,
|
|
13
|
+
DesiredCertificate,
|
|
14
|
+
DesiredGenericResource,
|
|
15
|
+
DesiredProxyHost,
|
|
16
|
+
ExistingResource,
|
|
17
|
+
PlanAction,
|
|
18
|
+
ResourceKind,
|
|
19
|
+
)
|
|
20
|
+
from npmctl.planner import Plan, PlanOperation
|
|
21
|
+
from npmctl.schema import Capabilities
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ApplyResult:
|
|
26
|
+
"""Result of applying a plan."""
|
|
27
|
+
|
|
28
|
+
applied: bool
|
|
29
|
+
mutations: list[dict[str, Any]] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {"applied": self.applied, "mutations": list(self.mutations)}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApplyEngine:
|
|
36
|
+
"""Executes a validated plan in dependency order."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, client: NpmClient, capabilities: Capabilities) -> None:
|
|
39
|
+
self.client = client
|
|
40
|
+
self.capabilities = capabilities
|
|
41
|
+
self.created_by_resource_id: dict[str, ExistingResource] = {}
|
|
42
|
+
|
|
43
|
+
def apply(self, plan: Plan) -> ApplyResult:
|
|
44
|
+
"""Apply the plan. Conflicts prevent all mutations."""
|
|
45
|
+
|
|
46
|
+
if plan.conflicts:
|
|
47
|
+
raise ConflictError("refusing to apply plan with conflicts")
|
|
48
|
+
for operation in plan.operations:
|
|
49
|
+
if operation.desired is not None and operation.existing is not None:
|
|
50
|
+
self.created_by_resource_id.setdefault(operation.desired.identity.resource_id, operation.existing)
|
|
51
|
+
result = ApplyResult(applied=True)
|
|
52
|
+
for operation in _ordered_operations(plan.operations):
|
|
53
|
+
if operation.action == PlanAction.NOOP:
|
|
54
|
+
continue
|
|
55
|
+
mutation = self._apply_operation(operation)
|
|
56
|
+
result.mutations.append(mutation)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def _apply_operation(self, operation: PlanOperation) -> dict[str, Any]:
|
|
60
|
+
if operation.action == PlanAction.CREATE:
|
|
61
|
+
return self._create(operation)
|
|
62
|
+
if operation.action == PlanAction.UPDATE:
|
|
63
|
+
return self._update(operation)
|
|
64
|
+
if operation.action == PlanAction.ADOPT:
|
|
65
|
+
return self._adopt(operation)
|
|
66
|
+
if operation.action == PlanAction.DELETE:
|
|
67
|
+
return self._delete(operation)
|
|
68
|
+
raise ValidationError(f"unsupported apply operation {operation.action}")
|
|
69
|
+
|
|
70
|
+
def _create(self, operation: PlanOperation) -> dict[str, Any]:
|
|
71
|
+
desired = _require_desired(operation)
|
|
72
|
+
payload = self._payload_for(desired)
|
|
73
|
+
created = self.client.create_resource(desired.kind, payload)
|
|
74
|
+
self.created_by_resource_id[desired.identity.resource_id] = created
|
|
75
|
+
return {
|
|
76
|
+
"action": "create",
|
|
77
|
+
"kind": desired.kind.value,
|
|
78
|
+
"resource_id": desired.identity.resource_id,
|
|
79
|
+
"id": created.id,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def _update(self, operation: PlanOperation) -> dict[str, Any]:
|
|
83
|
+
desired = _require_desired(operation)
|
|
84
|
+
existing = _require_existing(operation)
|
|
85
|
+
payload = self._merge_existing_with_desired(existing, desired)
|
|
86
|
+
cap = self.capabilities.for_kind(desired.kind)
|
|
87
|
+
updated = self.client.update_resource(desired.kind, existing.id, payload, method=cap.update_method or "put")
|
|
88
|
+
return {
|
|
89
|
+
"action": "update",
|
|
90
|
+
"kind": desired.kind.value,
|
|
91
|
+
"resource_id": desired.identity.resource_id,
|
|
92
|
+
"id": updated.id,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _adopt(self, operation: PlanOperation) -> dict[str, Any]:
|
|
96
|
+
desired = _require_desired(operation)
|
|
97
|
+
existing = _require_existing(operation)
|
|
98
|
+
payload = _updateable_existing_payload(existing)
|
|
99
|
+
payload["meta"] = merge_managed_meta(payload.get("meta"), desired.meta)
|
|
100
|
+
cap = self.capabilities.for_kind(desired.kind)
|
|
101
|
+
updated = self.client.update_resource(desired.kind, existing.id, payload, method=cap.update_method or "put")
|
|
102
|
+
return {
|
|
103
|
+
"action": "adopt",
|
|
104
|
+
"kind": desired.kind.value,
|
|
105
|
+
"resource_id": desired.identity.resource_id,
|
|
106
|
+
"id": updated.id,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def _delete(self, operation: PlanOperation) -> dict[str, Any]:
|
|
110
|
+
existing = _require_existing(operation)
|
|
111
|
+
deleted = self.client.delete_resource(existing.kind, existing.id)
|
|
112
|
+
if not deleted:
|
|
113
|
+
raise ApiError(f"delete failed for {existing.kind.value} id={existing.id}")
|
|
114
|
+
resource_id = existing.identity.resource_id if existing.identity else None
|
|
115
|
+
return {"action": "delete", "kind": existing.kind.value, "resource_id": resource_id, "id": existing.id}
|
|
116
|
+
|
|
117
|
+
def _merge_existing_with_desired(
|
|
118
|
+
self,
|
|
119
|
+
existing: ExistingResource,
|
|
120
|
+
desired: DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
payload = self._payload_for(desired)
|
|
123
|
+
payload["meta"] = merge_managed_meta(existing.raw.get("meta"), desired.meta)
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
def _payload_for(
|
|
127
|
+
self, desired: DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
if isinstance(desired, DesiredProxyHost):
|
|
130
|
+
certificate_id = self._resolve_reference(desired.certificate_ref, ResourceKind.CERTIFICATE)
|
|
131
|
+
access_list_id = self._resolve_reference(desired.access_list_ref, ResourceKind.ACCESS_LIST)
|
|
132
|
+
return desired.to_payload(certificate_id=certificate_id, access_list_id=access_list_id)
|
|
133
|
+
return desired.to_payload()
|
|
134
|
+
|
|
135
|
+
def _resolve_reference(self, ref: str | None, kind: ResourceKind) -> int | None:
|
|
136
|
+
if ref is None:
|
|
137
|
+
return None
|
|
138
|
+
created = self.created_by_resource_id.get(ref)
|
|
139
|
+
if created is not None:
|
|
140
|
+
if created.kind != kind:
|
|
141
|
+
raise ValidationError(f"reference {ref!r} resolved to {created.kind.value}, expected {kind.value}")
|
|
142
|
+
return created.id
|
|
143
|
+
raise ValidationError(f"unresolved {kind.value} reference: {ref}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ordered_operations(operations: tuple[PlanOperation, ...]) -> list[PlanOperation]:
|
|
147
|
+
creates_updates_adopts = [
|
|
148
|
+
op for op in operations if op.action in {PlanAction.CREATE, PlanAction.UPDATE, PlanAction.ADOPT}
|
|
149
|
+
]
|
|
150
|
+
deletes = [op for op in operations if op.action == PlanAction.DELETE]
|
|
151
|
+
order = {
|
|
152
|
+
ResourceKind.CERTIFICATE: 0,
|
|
153
|
+
ResourceKind.ACCESS_LIST: 1,
|
|
154
|
+
ResourceKind.REDIRECTION_HOST: 2,
|
|
155
|
+
ResourceKind.DEAD_HOST: 2,
|
|
156
|
+
ResourceKind.STREAM: 2,
|
|
157
|
+
ResourceKind.USER: 2,
|
|
158
|
+
ResourceKind.SETTING: 2,
|
|
159
|
+
ResourceKind.PROXY_HOST: 3,
|
|
160
|
+
}
|
|
161
|
+
delete_order = {
|
|
162
|
+
ResourceKind.PROXY_HOST: 0,
|
|
163
|
+
ResourceKind.REDIRECTION_HOST: 1,
|
|
164
|
+
ResourceKind.DEAD_HOST: 1,
|
|
165
|
+
ResourceKind.STREAM: 1,
|
|
166
|
+
ResourceKind.USER: 1,
|
|
167
|
+
ResourceKind.SETTING: 1,
|
|
168
|
+
ResourceKind.ACCESS_LIST: 2,
|
|
169
|
+
ResourceKind.CERTIFICATE: 3,
|
|
170
|
+
}
|
|
171
|
+
return sorted(creates_updates_adopts, key=lambda op: order[op.kind]) + sorted(
|
|
172
|
+
deletes, key=lambda op: delete_order[op.kind]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _require_desired(
|
|
177
|
+
operation: PlanOperation,
|
|
178
|
+
) -> DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource:
|
|
179
|
+
if operation.desired is None:
|
|
180
|
+
raise ValidationError(f"operation {operation.action} requires desired resource")
|
|
181
|
+
return operation.desired
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _require_existing(operation: PlanOperation) -> ExistingResource:
|
|
185
|
+
if operation.existing is None:
|
|
186
|
+
raise ValidationError(f"operation {operation.action} requires existing resource")
|
|
187
|
+
return operation.existing
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _updateable_existing_payload(existing: ExistingResource) -> dict[str, Any]:
|
|
191
|
+
fields = {
|
|
192
|
+
ResourceKind.PROXY_HOST: (
|
|
193
|
+
"domain_names",
|
|
194
|
+
"forward_scheme",
|
|
195
|
+
"forward_host",
|
|
196
|
+
"forward_port",
|
|
197
|
+
"certificate_id",
|
|
198
|
+
"ssl_forced",
|
|
199
|
+
"hsts_enabled",
|
|
200
|
+
"hsts_subdomains",
|
|
201
|
+
"http2_support",
|
|
202
|
+
"block_exploits",
|
|
203
|
+
"caching_enabled",
|
|
204
|
+
"allow_websocket_upgrade",
|
|
205
|
+
"access_list_id",
|
|
206
|
+
"advanced_config",
|
|
207
|
+
"enabled",
|
|
208
|
+
"locations",
|
|
209
|
+
"meta",
|
|
210
|
+
),
|
|
211
|
+
ResourceKind.ACCESS_LIST: ("name", "satisfy_any", "pass_auth", "items", "clients", "meta"),
|
|
212
|
+
ResourceKind.CERTIFICATE: ("provider", "nice_name", "domain_names", "meta"),
|
|
213
|
+
ResourceKind.REDIRECTION_HOST: ("domain_names", "forward_domain_name", "meta"),
|
|
214
|
+
ResourceKind.DEAD_HOST: ("domain_names", "meta"),
|
|
215
|
+
ResourceKind.STREAM: ("incoming_port", "forward_host", "forward_port", "protocol", "meta"),
|
|
216
|
+
ResourceKind.USER: ("name", "email", "roles", "is_disabled", "meta"),
|
|
217
|
+
ResourceKind.SETTING: ("name", "value", "meta"),
|
|
218
|
+
}[existing.kind]
|
|
219
|
+
payload = {field: existing.raw[field] for field in fields if field in existing.raw}
|
|
220
|
+
if existing.kind == ResourceKind.PROXY_HOST:
|
|
221
|
+
defaults = {
|
|
222
|
+
"access_list_id": 0,
|
|
223
|
+
"certificate_id": 0,
|
|
224
|
+
"ssl_forced": 0,
|
|
225
|
+
"hsts_enabled": 0,
|
|
226
|
+
"hsts_subdomains": 0,
|
|
227
|
+
"http2_support": 0,
|
|
228
|
+
"block_exploits": 0,
|
|
229
|
+
"caching_enabled": 0,
|
|
230
|
+
"allow_websocket_upgrade": 0,
|
|
231
|
+
"advanced_config": "",
|
|
232
|
+
"enabled": 1,
|
|
233
|
+
"locations": [],
|
|
234
|
+
}
|
|
235
|
+
for field, default in defaults.items():
|
|
236
|
+
if payload.get(field) is None:
|
|
237
|
+
payload[field] = default
|
|
238
|
+
return payload
|