geodeploy 1.3.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.
- geodeploy/__init__.py +52 -0
- geodeploy/__main__.py +13 -0
- geodeploy/admin.py +191 -0
- geodeploy/catalog.py +96 -0
- geodeploy/cli/__init__.py +7 -0
- geodeploy/cli/commands/__init__.py +2 -0
- geodeploy/cli/commands/_common.py +233 -0
- geodeploy/cli/commands/admin.py +317 -0
- geodeploy/cli/commands/auth.py +280 -0
- geodeploy/cli/commands/browse.py +224 -0
- geodeploy/cli/commands/catalog.py +107 -0
- geodeploy/cli/commands/imports.py +125 -0
- geodeploy/cli/commands/jobs.py +38 -0
- geodeploy/cli/commands/layers.py +536 -0
- geodeploy/cli/commands/portals.py +515 -0
- geodeploy/cli/commands/sources.py +97 -0
- geodeploy/cli/commands/upload.py +154 -0
- geodeploy/cli/main.py +263 -0
- geodeploy/cli/output.py +320 -0
- geodeploy/client.py +327 -0
- geodeploy/config.py +438 -0
- geodeploy/errors.py +93 -0
- geodeploy/imports.py +65 -0
- geodeploy/jobs.py +73 -0
- geodeploy/layers.py +433 -0
- geodeploy/portals.py +280 -0
- geodeploy/py.typed +0 -0
- geodeploy/sources.py +70 -0
- geodeploy/styles.py +467 -0
- geodeploy/transport.py +355 -0
- geodeploy/uploads.py +474 -0
- geodeploy-1.3.0.dist-info/METADATA +102 -0
- geodeploy-1.3.0.dist-info/RECORD +38 -0
- geodeploy-1.3.0.dist-info/WHEEL +5 -0
- geodeploy-1.3.0.dist-info/entry_points.txt +2 -0
- geodeploy-1.3.0.dist-info/licenses/LICENSE +202 -0
- geodeploy-1.3.0.dist-info/licenses/NOTICE +11 -0
- geodeploy-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""`geodeploy admin …` and `geodeploy users …` — operating an instance.
|
|
2
|
+
|
|
3
|
+
Everything under `admin` needs a **password session** (`geodeploy login --password`), because the
|
|
4
|
+
API rejects API tokens on those routes so that a leaked token cannot restart your database or read
|
|
5
|
+
your storage credentials. `users` is different: it is scope-gated, so a token with `users:admin`
|
|
6
|
+
works.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ...errors import GeoDeployError
|
|
11
|
+
from ..main import add_command, group_parser
|
|
12
|
+
from ..output import EXIT_GENERIC, EXIT_OK, human_size
|
|
13
|
+
from ._common import confirm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(subparsers) -> None:
|
|
17
|
+
group = group_parser(subparsers, "admin", "health, services, updates, backups, activity log")
|
|
18
|
+
|
|
19
|
+
add_command(group, "health", cmd_health, "status of every service in the stack")
|
|
20
|
+
|
|
21
|
+
services = add_command(group, "service", cmd_service, "start, stop or restart a service")
|
|
22
|
+
services.add_argument("name", help="postgres | minio | redis | martin | titiler | nginx | "
|
|
23
|
+
"celery | ui")
|
|
24
|
+
services.add_argument("action", choices=["start", "stop", "restart"])
|
|
25
|
+
services.add_argument("--yes", action="store_true")
|
|
26
|
+
|
|
27
|
+
logs = add_command(group, "logs", cmd_logs, "recent log lines from one service")
|
|
28
|
+
logs.add_argument("name")
|
|
29
|
+
logs.add_argument("-n", "--tail", type=int, default=200)
|
|
30
|
+
logs.add_argument("--no-timestamps", action="store_true")
|
|
31
|
+
|
|
32
|
+
add_command(group, "storage", cmd_storage, "how much space each store is using")
|
|
33
|
+
|
|
34
|
+
updates = add_command(group, "updates", cmd_updates,
|
|
35
|
+
"what this instance could update to (main, releases, branches)")
|
|
36
|
+
updates.add_argument("--refresh", action="store_true",
|
|
37
|
+
help="bypass the 10-minute cache and ask GitHub now")
|
|
38
|
+
|
|
39
|
+
update = add_command(group, "update", cmd_update, "update the instance")
|
|
40
|
+
update.add_argument("target", nargs="?", help="main, a release tag, or a branch "
|
|
41
|
+
"(default: whatever the instance follows)")
|
|
42
|
+
update.add_argument("--yes", action="store_true")
|
|
43
|
+
update.add_argument("--watch", action="store_true", help="follow the update to completion")
|
|
44
|
+
|
|
45
|
+
add_command(group, "reload-martin", cmd_reload_martin,
|
|
46
|
+
"regenerate the vector tile server's config from the ready PostGIS layers")
|
|
47
|
+
|
|
48
|
+
audit = add_command(group, "audit", cmd_audit, "the activity log (paginated, filtered server-side)")
|
|
49
|
+
audit.add_argument("-n", "--limit", type=int, default=20)
|
|
50
|
+
audit.add_argument("--offset", type=int, default=0)
|
|
51
|
+
audit.add_argument("--action", help="exact action, or a family prefix like 'portal'")
|
|
52
|
+
audit.add_argument("--resource-type")
|
|
53
|
+
audit.add_argument("--resource-id")
|
|
54
|
+
audit.add_argument("--actor", type=int, dest="actor_id")
|
|
55
|
+
audit.add_argument("--query", dest="search")
|
|
56
|
+
audit.add_argument("--since", help="ISO instant, e.g. 2026-08-01T00:00:00Z")
|
|
57
|
+
audit.add_argument("--until")
|
|
58
|
+
|
|
59
|
+
backups = add_command(group, "backups", cmd_backups, "backup history and destination status")
|
|
60
|
+
backups.add_argument("--stored", action="store_true",
|
|
61
|
+
help="list what is actually AT the destination, not our own log")
|
|
62
|
+
backups.add_argument("--run", action="store_true", help="start a backup now")
|
|
63
|
+
backups.add_argument("-n", "--limit", type=int, default=10)
|
|
64
|
+
|
|
65
|
+
listing = add_command(group, "public-index", cmd_public_index,
|
|
66
|
+
"show or change whether this instance is publicly browsable",
|
|
67
|
+
epilog="""examples:
|
|
68
|
+
geodeploy admin public-index is it listed?
|
|
69
|
+
geodeploy admin public-index --off stop listing (links keep working)
|
|
70
|
+
geodeploy admin public-index --on
|
|
71
|
+
|
|
72
|
+
Listing controls whether anyone can DISCOVER your published public portals and public layers from
|
|
73
|
+
the instance URL — what `geodeploy browse` and the QGIS plugin read. It does not change who may
|
|
74
|
+
open them: a public portal stays reachable by its link either way.
|
|
75
|
+
""")
|
|
76
|
+
state = listing.add_mutually_exclusive_group()
|
|
77
|
+
state.add_argument("--on", dest="enabled", action="store_true", default=None)
|
|
78
|
+
state.add_argument("--off", dest="enabled", action="store_false", default=None)
|
|
79
|
+
|
|
80
|
+
add_command(group, "credentials", cmd_credentials,
|
|
81
|
+
"connection details for the managed PostGIS and MinIO (owner only, audited)")
|
|
82
|
+
|
|
83
|
+
# ── users ───────────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
users = group_parser(subparsers, "users", "members, roles and invitations")
|
|
85
|
+
add_command(users, "list", cmd_users_list, "list members", aliases=["ls"])
|
|
86
|
+
invite = add_command(users, "invite", cmd_users_invite, "invite someone (returns a link once)")
|
|
87
|
+
invite.add_argument("email")
|
|
88
|
+
invite.add_argument("--role", choices=["viewer", "editor", "admin"], default="viewer")
|
|
89
|
+
add_command(users, "invitations", cmd_users_invitations, "pending invitations")
|
|
90
|
+
role = add_command(users, "role", cmd_users_role, "change a member's role")
|
|
91
|
+
role.add_argument("user_id", type=int)
|
|
92
|
+
role.add_argument("role", choices=["viewer", "editor", "admin"])
|
|
93
|
+
remove = add_command(users, "remove", cmd_users_remove,
|
|
94
|
+
"remove a member (their data is reassigned to the owner)", aliases=["rm"])
|
|
95
|
+
remove.add_argument("user_id", type=int)
|
|
96
|
+
remove.add_argument("--yes", action="store_true")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _admin(ctx):
|
|
100
|
+
return ctx.client(session=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── health & services ────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def cmd_health(ctx, args) -> int:
|
|
106
|
+
rows = _admin(ctx).admin.health()
|
|
107
|
+
ctx.out.render(rows, ["name", "status", "controllable", "message"])
|
|
108
|
+
if not ctx.out.json_mode:
|
|
109
|
+
bad = [r for r in rows if (r.get("status") or "") not in ("healthy", "running")]
|
|
110
|
+
if bad:
|
|
111
|
+
ctx.out.warn("{0} service(s) not healthy: {1}".format(
|
|
112
|
+
len(bad), ", ".join(r.get("name") for r in bad)))
|
|
113
|
+
return EXIT_OK
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_service(ctx, args) -> int:
|
|
117
|
+
if args.action in ("stop", "restart") and not confirm(
|
|
118
|
+
ctx.out, "{0} the {1} service?".format(args.action.capitalize(), args.name), args.yes):
|
|
119
|
+
return EXIT_GENERIC
|
|
120
|
+
ctx.out.render(_admin(ctx).admin.service(args.name, args.action))
|
|
121
|
+
if not ctx.out.json_mode:
|
|
122
|
+
ctx.out.success("{0} {1}ed.".format(args.name, args.action.rstrip("e")))
|
|
123
|
+
return EXIT_OK
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_logs(ctx, args) -> int:
|
|
127
|
+
data = _admin(ctx).admin.logs(args.name, tail=args.tail, timestamps=not args.no_timestamps)
|
|
128
|
+
if ctx.out.json_mode:
|
|
129
|
+
ctx.out.json(data)
|
|
130
|
+
return EXIT_OK
|
|
131
|
+
text = data.get("logs") if isinstance(data, dict) else data
|
|
132
|
+
ctx.out.out(text if isinstance(text, str) else str(text))
|
|
133
|
+
return EXIT_OK
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_storage(ctx, args) -> int:
|
|
137
|
+
stats = _admin(ctx).admin.storage_stats()
|
|
138
|
+
if ctx.out.json_mode:
|
|
139
|
+
ctx.out.json(stats)
|
|
140
|
+
return EXIT_OK
|
|
141
|
+
rows = []
|
|
142
|
+
for key, label in (("postgis_bytes", "PostGIS tables"), ("raster_bytes", "Raster COGs"),
|
|
143
|
+
("geoparquet_bytes", "GeoParquet + PMTiles"),
|
|
144
|
+
("portal_bundle_bytes", "Published portals"), ("used_bytes", "Total")):
|
|
145
|
+
value = stats.get(key)
|
|
146
|
+
# None means "could not be measured", which is NOT zero — saying 0 for an unreachable
|
|
147
|
+
# database would read as "empty" and send someone looking for missing data.
|
|
148
|
+
rows.append({"store": label,
|
|
149
|
+
"size": human_size(value) if value is not None else "not measured"})
|
|
150
|
+
ctx.out.table(rows, ["store", "size"])
|
|
151
|
+
ctx.out.info("{0} vector layers, {1} rasters, {2} portals.".format(
|
|
152
|
+
stats.get("vector_layers"), stats.get("raster_layers"), stats.get("portals")))
|
|
153
|
+
return EXIT_OK
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── updates ──────────────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def cmd_updates(ctx, args) -> int:
|
|
159
|
+
data = _admin(ctx).admin.updates(refresh=args.refresh)
|
|
160
|
+
if ctx.out.json_mode:
|
|
161
|
+
ctx.out.json(data)
|
|
162
|
+
return EXIT_OK
|
|
163
|
+
main = data.get("main") or {}
|
|
164
|
+
ctx.out.record({"channel": data.get("channel"), "current": data.get("current_ref"),
|
|
165
|
+
"main": main.get("status"), "commits behind": main.get("commits"),
|
|
166
|
+
"latest release": (data.get("latest_release") or {}).get("tag")},
|
|
167
|
+
["channel", "current", "main", "commits behind", "latest release"])
|
|
168
|
+
releases = data.get("releases") or []
|
|
169
|
+
if releases:
|
|
170
|
+
ctx.out.out("")
|
|
171
|
+
ctx.out.table([{"tag": r.get("tag"), "published": r.get("published_at"),
|
|
172
|
+
"title": r.get("name")} for r in releases[:10]],
|
|
173
|
+
["tag", "published", "title"])
|
|
174
|
+
return EXIT_OK
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_update(ctx, args) -> int:
|
|
178
|
+
client = _admin(ctx)
|
|
179
|
+
preflight = client.admin.preflight()
|
|
180
|
+
if not ctx.out.json_mode and preflight:
|
|
181
|
+
ctx.out.record(preflight)
|
|
182
|
+
if preflight.get("blocked") or preflight.get("ok") is False:
|
|
183
|
+
ctx.out.error(preflight.get("reason") or "The instance refused: work is in progress.")
|
|
184
|
+
return EXIT_GENERIC
|
|
185
|
+
if not confirm(ctx.out, "Update this instance to {0}?".format(args.target or "its channel"),
|
|
186
|
+
args.yes):
|
|
187
|
+
return EXIT_GENERIC
|
|
188
|
+
started = client.admin.update(args.target)
|
|
189
|
+
ctx.out.render(started)
|
|
190
|
+
if args.watch:
|
|
191
|
+
import time
|
|
192
|
+
while True:
|
|
193
|
+
time.sleep(4)
|
|
194
|
+
try:
|
|
195
|
+
status = client.admin.update_status()
|
|
196
|
+
except GeoDeployError as exc:
|
|
197
|
+
# Expected: the API container is recreated BY the update, so the poll that hits
|
|
198
|
+
# that moment fails. That is progress, not an error.
|
|
199
|
+
ctx.out.info(" (instance restarting: {0})".format(exc))
|
|
200
|
+
continue
|
|
201
|
+
phase = status.get("phase") or status.get("status")
|
|
202
|
+
ctx.out.info(" {0}".format(phase))
|
|
203
|
+
if phase in ("done", "complete", "completed", "error", "failed"):
|
|
204
|
+
ctx.out.render(status)
|
|
205
|
+
return EXIT_OK if phase.startswith(("done", "complete")) else EXIT_GENERIC
|
|
206
|
+
if not ctx.out.json_mode:
|
|
207
|
+
ctx.out.success("Update started. `geodeploy admin updates` will show the new version once "
|
|
208
|
+
"the API comes back.")
|
|
209
|
+
return EXIT_OK
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_reload_martin(ctx, args) -> int:
|
|
213
|
+
ctx.out.render(_admin(ctx).admin.reload_martin())
|
|
214
|
+
return EXIT_OK
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── audit & backups ──────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def cmd_audit(ctx, args) -> int:
|
|
220
|
+
page = _admin(ctx).admin.audit(limit=args.limit, offset=args.offset, action=args.action,
|
|
221
|
+
resource_type=args.resource_type, resource_id=args.resource_id,
|
|
222
|
+
actor_id=args.actor_id, query=args.search, since=args.since,
|
|
223
|
+
until=args.until)
|
|
224
|
+
if ctx.out.json_mode:
|
|
225
|
+
ctx.out.json(page)
|
|
226
|
+
return EXIT_OK
|
|
227
|
+
ctx.out.table(page.get("items") or [],
|
|
228
|
+
["created_at", "actor_name", "action", "resource_type", "resource_id"])
|
|
229
|
+
ctx.out.info("{0}-{1} of {2}".format(page.get("offset", 0) + 1,
|
|
230
|
+
page.get("offset", 0) + len(page.get("items") or []),
|
|
231
|
+
page.get("total")))
|
|
232
|
+
return EXIT_OK
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_backups(ctx, args) -> int:
|
|
236
|
+
client = _admin(ctx)
|
|
237
|
+
if args.run:
|
|
238
|
+
ctx.out.render(client.admin.backup_run())
|
|
239
|
+
if not ctx.out.json_mode:
|
|
240
|
+
ctx.out.success("Backup started — it runs in the worker, so this returns immediately.")
|
|
241
|
+
return EXIT_OK
|
|
242
|
+
if args.stored:
|
|
243
|
+
ctx.out.render(client.admin.backup_stored(), ["key", "size", "created_at"],
|
|
244
|
+
empty="Nothing at the destination.")
|
|
245
|
+
return EXIT_OK
|
|
246
|
+
settings = client.admin.backup_settings()
|
|
247
|
+
if not ctx.out.json_mode:
|
|
248
|
+
ctx.out.record(settings, ["enabled", "schedule", "hour", "keep", "bucket", "prefix",
|
|
249
|
+
"endpoint", "include_postgis", "include_objects",
|
|
250
|
+
"include_state"])
|
|
251
|
+
ctx.out.out("")
|
|
252
|
+
ctx.out.render(client.admin.backup_runs(limit=args.limit),
|
|
253
|
+
["id", "started_at", "status", "trigger", "size_bytes", "error_message"],
|
|
254
|
+
empty="No backup runs recorded.")
|
|
255
|
+
return EXIT_OK
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_public_index(ctx, args) -> int:
|
|
259
|
+
client = _admin(ctx)
|
|
260
|
+
if args.enabled is None:
|
|
261
|
+
state = client.admin.public_index()
|
|
262
|
+
else:
|
|
263
|
+
state = client.admin.set_public_index(args.enabled)
|
|
264
|
+
ctx.out.render(state, ["enabled"])
|
|
265
|
+
if not ctx.out.json_mode:
|
|
266
|
+
if state.get("enabled"):
|
|
267
|
+
ctx.out.success("This instance is listed — `geodeploy browse {0}` shows what anyone "
|
|
268
|
+
"can see.".format(ctx.resolved.url))
|
|
269
|
+
else:
|
|
270
|
+
ctx.out.success("Not listed. Published public portals stay reachable by their links; "
|
|
271
|
+
"they are simply not enumerated.")
|
|
272
|
+
return EXIT_OK
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_credentials(ctx, args) -> int:
|
|
276
|
+
ctx.out.render(_admin(ctx).admin.credentials())
|
|
277
|
+
return EXIT_OK
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── users ────────────────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def cmd_users_list(ctx, args) -> int:
|
|
283
|
+
ctx.out.render(ctx.client().users.list(),
|
|
284
|
+
["id", "name", "email", "role", "vector_count", "raster_count", "portal_count",
|
|
285
|
+
"created_at"])
|
|
286
|
+
return EXIT_OK
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cmd_users_invite(ctx, args) -> int:
|
|
290
|
+
invite = ctx.client().users.invite(args.email, args.role)
|
|
291
|
+
ctx.out.render(invite, ["id", "email", "role", "expires_at", "invite_url", "token",
|
|
292
|
+
"email_sent"])
|
|
293
|
+
if not ctx.out.json_mode:
|
|
294
|
+
ctx.out.warn("The invitation link is shown once — regenerating is the only way to get it "
|
|
295
|
+
"again.")
|
|
296
|
+
return EXIT_OK
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def cmd_users_invitations(ctx, args) -> int:
|
|
300
|
+
ctx.out.render(ctx.client().users.invitations(),
|
|
301
|
+
["id", "email", "role", "purpose", "expires_at", "created_at"],
|
|
302
|
+
empty="No pending invitations.")
|
|
303
|
+
return EXIT_OK
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_users_role(ctx, args) -> int:
|
|
307
|
+
ctx.out.render(ctx.client().users.set_role(args.user_id, args.role), ["id", "email", "role"])
|
|
308
|
+
return EXIT_OK
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_users_remove(ctx, args) -> int:
|
|
312
|
+
if not confirm(ctx.out, "Remove user {0}? Their layers and portals move to the owner.".format(
|
|
313
|
+
args.user_id), args.yes):
|
|
314
|
+
return EXIT_GENERIC
|
|
315
|
+
ctx.client().users.delete(args.user_id)
|
|
316
|
+
ctx.out.render({"ok": True, "removed": args.user_id})
|
|
317
|
+
return EXIT_OK
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""login / logout / whoami / profile / token — who you are, and against which instance.
|
|
2
|
+
|
|
3
|
+
**An API token is the normal credential.** `geodeploy login <url> --token gdp_…` (or the
|
|
4
|
+
`GEODEPLOY_TOKEN` environment variable) is what scripts, CI and the QGIS plugin use, and it is all
|
|
5
|
+
the CLI needs for data, portals and publishing.
|
|
6
|
+
|
|
7
|
+
`login --password` exists for one reason: the API rejects API tokens on `/admin/*`, `/backups/*`,
|
|
8
|
+
`/tokens` and ownership transfer, so that a leaked token cannot reconfigure an instance or mint
|
|
9
|
+
more tokens. Those commands therefore need a *session*. The password is never stored and never
|
|
10
|
+
accepted as a flag value — it is prompted for (or read from stdin, for CI), exchanged once for the
|
|
11
|
+
same 7-day JWT the browser gets, and only that is kept.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import getpass
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ... import config as cfg
|
|
20
|
+
from ...errors import AuthError, ConfigError
|
|
21
|
+
from ..main import add_command, group_parser
|
|
22
|
+
from ..output import EXIT_AUTH, EXIT_GENERIC, EXIT_OK, EXIT_USAGE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(subparsers) -> None:
|
|
26
|
+
login = add_command(
|
|
27
|
+
subparsers, "login", cmd_login, "log in to an instance and remember it",
|
|
28
|
+
epilog="""\
|
|
29
|
+
examples:
|
|
30
|
+
geodeploy login https://gd.example.org --token gdp_xxx # normal: a scoped API token
|
|
31
|
+
geodeploy login https://gd.example.org # prompts for the token
|
|
32
|
+
geodeploy login https://gd.example.org --password # a session too, for admin commands
|
|
33
|
+
echo "$PW" | geodeploy login https://gd.example.org --email me@x.org --password-stdin
|
|
34
|
+
""")
|
|
35
|
+
login.add_argument("url", help="instance URL, e.g. https://geodeploy.example.org")
|
|
36
|
+
# NOTE: the API token comes from the GLOBAL --token flag, so `geodeploy login <url> --token
|
|
37
|
+
# gdp_…` reads the same as every other command and there is only one spelling to remember.
|
|
38
|
+
login.add_argument("--token-stdin", action="store_true", help="read the API token from stdin")
|
|
39
|
+
login.add_argument("--password", action="store_true",
|
|
40
|
+
help="ALSO start a password session, for the admin commands that refuse "
|
|
41
|
+
"API tokens (prompts; the password is never stored)")
|
|
42
|
+
login.add_argument("--password-stdin", action="store_true",
|
|
43
|
+
help="read the password from stdin instead of prompting (for CI)")
|
|
44
|
+
login.add_argument("--email", help="account email for the password session")
|
|
45
|
+
login.add_argument("--name", help="profile name (default: the instance host)")
|
|
46
|
+
login.add_argument("--no-keyring", action="store_true",
|
|
47
|
+
help="store credentials in the 0600 file rather than the OS keyring")
|
|
48
|
+
|
|
49
|
+
logout = add_command(subparsers, "logout", cmd_logout,
|
|
50
|
+
"forget the credentials stored for an instance")
|
|
51
|
+
logout.add_argument("--all", action="store_true", help="every instance, not just the active one")
|
|
52
|
+
logout.add_argument("--session-only", action="store_true",
|
|
53
|
+
help="drop the password session but keep the API token")
|
|
54
|
+
|
|
55
|
+
add_command(subparsers, "whoami", cmd_whoami, "show the authenticated account and its role")
|
|
56
|
+
|
|
57
|
+
profile = group_parser(subparsers, "profile", "manage saved instances", aliases=["profiles"])
|
|
58
|
+
add_command(profile, "list", cmd_profile_list, "list saved profiles")
|
|
59
|
+
use = add_command(profile, "use", cmd_profile_use, "make a profile the active one")
|
|
60
|
+
use.add_argument("name")
|
|
61
|
+
add_command(profile, "show", cmd_profile_show,
|
|
62
|
+
"show the active profile and where each setting came from")
|
|
63
|
+
remove = add_command(profile, "remove", cmd_profile_remove, "delete a saved profile")
|
|
64
|
+
remove.add_argument("name")
|
|
65
|
+
|
|
66
|
+
token = group_parser(subparsers, "token", "manage API tokens (needs a password session)")
|
|
67
|
+
add_command(token, "list", cmd_token_list, "list your API tokens")
|
|
68
|
+
create = add_command(token, "create", cmd_token_create, "mint an API token (shown once)")
|
|
69
|
+
create.add_argument("name", help="what this token is for — it appears in Settings")
|
|
70
|
+
create.add_argument("--scopes", default="data:read,data:write,portal:read,portal:write,portal:publish",
|
|
71
|
+
help="comma-separated: data:read, data:write, portal:read, portal:write, "
|
|
72
|
+
"portal:publish, users:admin")
|
|
73
|
+
create.add_argument("--expires", type=int, default=90, choices=[30, 90, 365],
|
|
74
|
+
help="days until it expires (default 90)")
|
|
75
|
+
create.add_argument("--save", action="store_true",
|
|
76
|
+
help="store the new token as this instance's credential")
|
|
77
|
+
revoke = add_command(token, "revoke", cmd_token_revoke, "revoke an API token")
|
|
78
|
+
revoke.add_argument("id", type=int)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── login ────────────────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def cmd_login(ctx, args) -> int:
|
|
84
|
+
from ...client import Client
|
|
85
|
+
|
|
86
|
+
url = cfg.normalize_url(args.url)
|
|
87
|
+
out = ctx.out
|
|
88
|
+
|
|
89
|
+
token = getattr(args, "token", None)
|
|
90
|
+
if args.token_stdin:
|
|
91
|
+
token = sys.stdin.readline().strip()
|
|
92
|
+
if not token and not args.password and not args.password_stdin:
|
|
93
|
+
if not sys.stdin.isatty():
|
|
94
|
+
raise ConfigError("Pass --token, --token-stdin, or --password-stdin with --email.")
|
|
95
|
+
out.info("Create a token in {0} → Settings → API tokens. It is shown once.".format(url))
|
|
96
|
+
token = getpass.getpass("API token (input hidden): ").strip()
|
|
97
|
+
|
|
98
|
+
client = Client(url, token=token or None,
|
|
99
|
+
verify_tls=not getattr(args, "insecure", False))
|
|
100
|
+
|
|
101
|
+
jwt = None
|
|
102
|
+
if args.password or args.password_stdin:
|
|
103
|
+
email = args.email
|
|
104
|
+
if not email:
|
|
105
|
+
if not sys.stdin.isatty():
|
|
106
|
+
raise ConfigError("--password-stdin needs --email.")
|
|
107
|
+
email = input("Email: ").strip()
|
|
108
|
+
if args.password_stdin:
|
|
109
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
110
|
+
else:
|
|
111
|
+
password = getpass.getpass("Password for {0} (input hidden): ".format(email))
|
|
112
|
+
jwt = client.login(email, password)
|
|
113
|
+
del password # nothing keeps it: not the config, not this process any longer than needed
|
|
114
|
+
client.token = token or None # restore the token; login() cleared it on the client
|
|
115
|
+
client.jwt = jwt
|
|
116
|
+
|
|
117
|
+
identity = client.whoami()
|
|
118
|
+
|
|
119
|
+
name = args.name or _profile_name(url)
|
|
120
|
+
ctx.config.set_profile(name, url, email=identity.get("email"))
|
|
121
|
+
ctx.config.save()
|
|
122
|
+
where = cfg.save_credential(url, token=token or None, jwt=jwt,
|
|
123
|
+
email=identity.get("email"),
|
|
124
|
+
use_keyring=not getattr(args, "no_keyring", False))
|
|
125
|
+
|
|
126
|
+
if ctx.out.json_mode:
|
|
127
|
+
ctx.out.json({"ok": True, "instance": url, "profile": name, "stored": where,
|
|
128
|
+
"user": identity, "session": bool(jwt)})
|
|
129
|
+
return EXIT_OK
|
|
130
|
+
out.success("Logged in to {0} as {1} ({2}).".format(url, identity.get("email"),
|
|
131
|
+
identity.get("role")))
|
|
132
|
+
out.info("Profile {0!r} is now active; credentials stored in {1}.".format(name, where))
|
|
133
|
+
if jwt:
|
|
134
|
+
out.info("A password session was stored too — that is what the admin commands use. "
|
|
135
|
+
"It expires in 7 days, and a password change revokes it immediately.")
|
|
136
|
+
elif token:
|
|
137
|
+
out.info("Administration commands (`geodeploy admin …`) need `--password`, because the "
|
|
138
|
+
"API refuses API tokens on those routes by design.")
|
|
139
|
+
return EXIT_OK
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _profile_name(url: str) -> str:
|
|
143
|
+
host = url.split("://", 1)[-1].split("/", 1)[0]
|
|
144
|
+
return host.split(":")[0] or "default"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_logout(ctx, args) -> int:
|
|
148
|
+
kind = "jwt" if args.session_only else None
|
|
149
|
+
if args.all:
|
|
150
|
+
removed = []
|
|
151
|
+
for name, entry in list(ctx.config.profiles.items()):
|
|
152
|
+
if entry.get("url") and cfg.delete_token(entry["url"], kind):
|
|
153
|
+
removed.append(entry["url"])
|
|
154
|
+
ctx.out.render({"ok": True, "forgotten": removed})
|
|
155
|
+
if not ctx.out.json_mode:
|
|
156
|
+
ctx.out.success("Forgot credentials for {0} instance(s).".format(len(removed)))
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
|
|
159
|
+
info = ctx.resolved
|
|
160
|
+
if not info.url:
|
|
161
|
+
ctx.out.error("No instance to log out of.")
|
|
162
|
+
return EXIT_USAGE
|
|
163
|
+
removed = cfg.delete_token(info.url, kind)
|
|
164
|
+
if ctx.out.json_mode:
|
|
165
|
+
ctx.out.json({"ok": True, "instance": info.url, "forgotten": removed})
|
|
166
|
+
return EXIT_OK
|
|
167
|
+
if removed:
|
|
168
|
+
ctx.out.success("Forgot {0} for {1}.".format(
|
|
169
|
+
"the password session" if args.session_only else "the stored credentials", info.url))
|
|
170
|
+
else:
|
|
171
|
+
ctx.out.info("Nothing was stored for {0}.".format(info.url))
|
|
172
|
+
return EXIT_OK
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── whoami / profiles ────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def cmd_whoami(ctx, args) -> int:
|
|
178
|
+
info = ctx.resolved
|
|
179
|
+
if not info.url:
|
|
180
|
+
ctx.out.error("Not logged in to any instance.",
|
|
181
|
+
hint="Run `geodeploy login <url>`, or set GEODEPLOY_URL and GEODEPLOY_TOKEN.")
|
|
182
|
+
return EXIT_AUTH
|
|
183
|
+
identity = ctx.client().whoami()
|
|
184
|
+
payload = dict(identity)
|
|
185
|
+
payload.update({"instance": info.url, "profile": info.profile,
|
|
186
|
+
"credential": _credential_kind(info)})
|
|
187
|
+
ctx.out.render(payload, ["instance", "profile", "credential", "email", "name", "role", "id"])
|
|
188
|
+
return EXIT_OK
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _credential_kind(info) -> str:
|
|
192
|
+
if info.token:
|
|
193
|
+
return "API token ({0})".format(info.source_token)
|
|
194
|
+
if info.jwt:
|
|
195
|
+
return "password session (stored)"
|
|
196
|
+
return "none"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_profile_list(ctx, args) -> int:
|
|
200
|
+
rows = []
|
|
201
|
+
for name, entry in sorted(ctx.config.profiles.items()):
|
|
202
|
+
stored = cfg.load_credential(entry.get("url") or "") if entry.get("url") else {}
|
|
203
|
+
rows.append({"active": name == ctx.config.current, "name": name,
|
|
204
|
+
"url": entry.get("url"), "email": entry.get("email"),
|
|
205
|
+
"token": bool(stored.get("token")), "session": bool(stored.get("jwt"))})
|
|
206
|
+
ctx.out.render(rows, ["active", "name", "url", "email", "token", "session"],
|
|
207
|
+
empty="No profiles yet — run `geodeploy login <url>`.")
|
|
208
|
+
return EXIT_OK
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_profile_use(ctx, args) -> int:
|
|
212
|
+
if args.name not in ctx.config.profiles:
|
|
213
|
+
ctx.out.error("No profile named {0!r}.".format(args.name),
|
|
214
|
+
hint="`geodeploy profile list` shows what there is.")
|
|
215
|
+
return EXIT_GENERIC
|
|
216
|
+
ctx.config.current = args.name
|
|
217
|
+
ctx.config.save()
|
|
218
|
+
ctx.out.render({"ok": True, "current": args.name,
|
|
219
|
+
"url": ctx.config.profiles[args.name].get("url")})
|
|
220
|
+
if not ctx.out.json_mode:
|
|
221
|
+
ctx.out.success("Now using {0} ({1}).".format(
|
|
222
|
+
args.name, ctx.config.profiles[args.name].get("url")))
|
|
223
|
+
return EXIT_OK
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_profile_show(ctx, args) -> int:
|
|
227
|
+
info = ctx.resolved
|
|
228
|
+
ctx.out.render({"profile": info.profile, "instance": info.url,
|
|
229
|
+
"instance_from": info.source_url, "credential": _credential_kind(info),
|
|
230
|
+
"credential_from": info.source_token, "email": info.email,
|
|
231
|
+
"config_file": cfg.config_path(),
|
|
232
|
+
"credentials_file": cfg.credentials_path()},
|
|
233
|
+
["profile", "instance", "instance_from", "credential", "credential_from",
|
|
234
|
+
"email", "config_file", "credentials_file"])
|
|
235
|
+
return EXIT_OK
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_profile_remove(ctx, args) -> int:
|
|
239
|
+
entry = ctx.config.profiles.get(args.name)
|
|
240
|
+
if not ctx.config.remove_profile(args.name):
|
|
241
|
+
ctx.out.error("No profile named {0!r}.".format(args.name))
|
|
242
|
+
return EXIT_GENERIC
|
|
243
|
+
ctx.config.save()
|
|
244
|
+
if entry and entry.get("url"):
|
|
245
|
+
cfg.delete_token(entry["url"])
|
|
246
|
+
ctx.out.render({"ok": True, "removed": args.name})
|
|
247
|
+
if not ctx.out.json_mode:
|
|
248
|
+
ctx.out.success("Removed profile {0!r} and its stored credentials.".format(args.name))
|
|
249
|
+
return EXIT_OK
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── API tokens ───────────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def cmd_token_list(ctx, args) -> int:
|
|
255
|
+
rows = ctx.client(session=True).tokens()
|
|
256
|
+
ctx.out.render(rows, ["id", "name", "prefix", "scopes", "expires_at", "last_used_at"],
|
|
257
|
+
empty="No API tokens.")
|
|
258
|
+
return EXIT_OK
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cmd_token_create(ctx, args) -> int:
|
|
262
|
+
scopes = [s.strip() for s in args.scopes.split(",") if s.strip()]
|
|
263
|
+
created = ctx.client(session=True).create_token(args.name, scopes, args.expires)
|
|
264
|
+
if ctx.out.json_mode:
|
|
265
|
+
ctx.out.json(created)
|
|
266
|
+
else:
|
|
267
|
+
ctx.out.render(created, ["id", "name", "prefix", "scopes", "expires_at", "token"])
|
|
268
|
+
ctx.out.warn("Copy the token now — it is stored hashed and cannot be shown again.")
|
|
269
|
+
if args.save and created.get("token"):
|
|
270
|
+
where = cfg.save_credential(ctx.resolved.url, token=created["token"])
|
|
271
|
+
ctx.out.info("Saved as this instance's credential ({0}).".format(where))
|
|
272
|
+
return EXIT_OK
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_token_revoke(ctx, args) -> int:
|
|
276
|
+
ctx.client(session=True).revoke_token(args.id)
|
|
277
|
+
ctx.out.render({"ok": True, "revoked": args.id})
|
|
278
|
+
if not ctx.out.json_mode:
|
|
279
|
+
ctx.out.success("Token {0} revoked.".format(args.id))
|
|
280
|
+
return EXIT_OK
|