devpi-admin 1.4.2__tar.gz → 1.4.3__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.
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/PKG-INFO +29 -1
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/README.md +28 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/_version.py +3 -3
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/main.py +65 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/css/style.css +49 -2
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/app.js +100 -19
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/PKG-INFO +29 -1
- devpi_admin-1.4.3/tests/test_view_helpers.py +151 -0
- devpi_admin-1.4.2/tests/test_view_helpers.py +0 -79
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.github/workflows/publish.yml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.github/workflows/tests.yml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.gitignore +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/LICENSE +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/__init__.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/customizer.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/favicon.svg +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/index.html +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/api.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/marked.min.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/theme.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/tokens.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/SOURCES.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/dependency_links.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/entry_points.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/requires.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/top_level.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/pyproject.toml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/setup.cfg +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/__init__.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_acl_read.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_devpi_tokens_ui.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_filter.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_hooks.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_json_safe.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_package.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_pipconf.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_tokens.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_tween.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_wants_html.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
4
4
|
Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
|
|
|
61
61
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
62
62
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
63
63
|
ones with the index pre-filled and locked
|
|
64
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
65
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
66
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
67
|
+
(etag-conditional, cheap)
|
|
64
68
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
65
69
|
- Create / edit / delete indexes via modal dialogs
|
|
66
70
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -597,6 +601,30 @@ Revoke a single token.
|
|
|
597
601
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
598
602
|
- **404:** token id not found
|
|
599
603
|
|
|
604
|
+
### Mirror cache
|
|
605
|
+
|
|
606
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
607
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
608
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
609
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
610
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
611
|
+
queried). Two caches are expired:
|
|
612
|
+
|
|
613
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
614
|
+
project, in one pass)
|
|
615
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
616
|
+
projects" call)
|
|
617
|
+
|
|
618
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
619
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
620
|
+
|
|
621
|
+
- **Auth:** required (any authenticated user)
|
|
622
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
623
|
+
persisted state via the changelog stream once the primary refetches)
|
|
624
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
625
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
626
|
+
- **404:** index doesn't exist
|
|
627
|
+
|
|
600
628
|
### Replication observability (primary only)
|
|
601
629
|
|
|
602
630
|
#### `GET /+admin-api/replicas`
|
|
@@ -37,6 +37,10 @@ talks to the standard devpi JSON API directly.
|
|
|
37
37
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
38
38
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
39
39
|
ones with the index pre-filled and locked
|
|
40
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
41
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
42
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
43
|
+
(etag-conditional, cheap)
|
|
40
44
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
41
45
|
- Create / edit / delete indexes via modal dialogs
|
|
42
46
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -573,6 +577,30 @@ Revoke a single token.
|
|
|
573
577
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
574
578
|
- **404:** token id not found
|
|
575
579
|
|
|
580
|
+
### Mirror cache
|
|
581
|
+
|
|
582
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
583
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
584
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
585
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
586
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
587
|
+
queried). Two caches are expired:
|
|
588
|
+
|
|
589
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
590
|
+
project, in one pass)
|
|
591
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
592
|
+
projects" call)
|
|
593
|
+
|
|
594
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
595
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
596
|
+
|
|
597
|
+
- **Auth:** required (any authenticated user)
|
|
598
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
599
|
+
persisted state via the changelog stream once the primary refetches)
|
|
600
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
601
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
602
|
+
- **404:** index doesn't exist
|
|
603
|
+
|
|
576
604
|
### Replication observability (primary only)
|
|
577
605
|
|
|
578
606
|
#### `GET /+admin-api/replicas`
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 3)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'geb4eac9d6'
|
|
@@ -255,6 +255,16 @@ def devpiserver_pyramid_configure(config, pyramid_config):
|
|
|
255
255
|
_revoke_token_view, route_name="devpi_admin_token_revoke",
|
|
256
256
|
request_method="DELETE")
|
|
257
257
|
|
|
258
|
+
# Mirror cache refresh — invalidate per-project retrieve-times and
|
|
259
|
+
# the project-names list so the next pip request re-fetches upstream.
|
|
260
|
+
pyramid_config.add_route(
|
|
261
|
+
"devpi_admin_mirror_refresh_cache",
|
|
262
|
+
"/+admin-api/mirror/{user}/{index}/refresh-cache")
|
|
263
|
+
pyramid_config.add_view(
|
|
264
|
+
_refresh_mirror_cache_view,
|
|
265
|
+
route_name="devpi_admin_mirror_refresh_cache",
|
|
266
|
+
request_method="POST")
|
|
267
|
+
|
|
258
268
|
# Redirect browser visits to "/" to the SPA. Other routes (JSON API
|
|
259
269
|
# calls, CLI requests) pass through untouched because they send
|
|
260
270
|
# Accept: application/json.
|
|
@@ -1415,6 +1425,61 @@ def _revoke_token_view(request):
|
|
|
1415
1425
|
return _json_response({"revoked": True, "id": tid})
|
|
1416
1426
|
|
|
1417
1427
|
|
|
1428
|
+
def _refresh_mirror_cache_view(request):
|
|
1429
|
+
"""POST /+admin-api/mirror/{user}/{index}/refresh-cache
|
|
1430
|
+
|
|
1431
|
+
Invalidate the in-memory mirror caches so the next pip request goes
|
|
1432
|
+
back to upstream:
|
|
1433
|
+
|
|
1434
|
+
* ``cache_retrieve_times`` — per-project last-fetch timestamp + etag.
|
|
1435
|
+
Expiring forces a conditional GET on the next ``+simple/<project>/``
|
|
1436
|
+
lookup; etag match still reuses the persisted ``PROJSIMPLELINKS``
|
|
1437
|
+
keyfs entry, so the only cost is one HTTP round-trip per project
|
|
1438
|
+
that pip actually queries (we don't pre-fetch).
|
|
1439
|
+
* ``cache_projectnames`` — full PyPI manifest. Expiring forces the
|
|
1440
|
+
next "list all projects" call to refetch (rare path — mostly used
|
|
1441
|
+
by ``pip install <unknown>`` to discover the project exists).
|
|
1442
|
+
|
|
1443
|
+
Caches are process-local: this only works on the primary (the
|
|
1444
|
+
replica's local cache is meaningless to invalidate, and replicas
|
|
1445
|
+
sync persisted state via the changelog stream). Any authenticated
|
|
1446
|
+
user may trigger the refresh — anonymous spam is blocked by auth,
|
|
1447
|
+
upstream-side abuse is bounded by mirror semantics (etag-conditional
|
|
1448
|
+
requests dominate).
|
|
1449
|
+
"""
|
|
1450
|
+
xom = request.registry["xom"]
|
|
1451
|
+
_refuse_on_replica(xom)
|
|
1452
|
+
_require_authenticated(request)
|
|
1453
|
+
user = request.matchdict["user"]
|
|
1454
|
+
index = request.matchdict["index"]
|
|
1455
|
+
_validate_name(user, "user")
|
|
1456
|
+
_validate_name(index, "index")
|
|
1457
|
+
with xom.keyfs.read_transaction(allow_reuse=True):
|
|
1458
|
+
stage = xom.model.getstage(user, index)
|
|
1459
|
+
if stage is None:
|
|
1460
|
+
raise HTTPNotFound(
|
|
1461
|
+
json_body={"error": f"index {user}/{index} does not exist"})
|
|
1462
|
+
if stage.ixconfig.get("type") != "mirror":
|
|
1463
|
+
raise HTTPBadRequest(json_body={
|
|
1464
|
+
"error": "cache refresh applies to mirror indexes only"})
|
|
1465
|
+
# `_project2time` is the per-xom singleton dict that backs the
|
|
1466
|
+
# per-project cache; iterating its keys lets us expire only entries
|
|
1467
|
+
# devpi has actually populated. The public `expire(project)` API is
|
|
1468
|
+
# safe for non-tracked projects too (it's pop-with-default), so we
|
|
1469
|
+
# don't need to special-case empty caches.
|
|
1470
|
+
crt = stage.cache_retrieve_times
|
|
1471
|
+
projects = list(getattr(crt, "_project2time", {}).keys())
|
|
1472
|
+
for project in projects:
|
|
1473
|
+
crt.expire(project)
|
|
1474
|
+
stage.cache_projectnames.expire()
|
|
1475
|
+
return _json_response({
|
|
1476
|
+
"result": {
|
|
1477
|
+
"projects_invalidated": len(projects),
|
|
1478
|
+
"projectnames_invalidated": True,
|
|
1479
|
+
},
|
|
1480
|
+
})
|
|
1481
|
+
|
|
1482
|
+
|
|
1418
1483
|
def _reset_tokens_view(request):
|
|
1419
1484
|
"""DELETE /+admin-api/users/{user}/tokens — revoke all for a user."""
|
|
1420
1485
|
xom = request.registry["xom"]
|
|
@@ -428,7 +428,7 @@ body {
|
|
|
428
428
|
|
|
429
429
|
.index-grid {
|
|
430
430
|
display: grid;
|
|
431
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
431
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
432
432
|
gap: 12px;
|
|
433
433
|
}
|
|
434
434
|
|
|
@@ -461,10 +461,19 @@ body {
|
|
|
461
461
|
gap: 8px;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
.index-card-path {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: baseline;
|
|
467
|
+
gap: 2px;
|
|
468
|
+
min-width: 0;
|
|
469
|
+
flex: 1 1 auto;
|
|
470
|
+
}
|
|
471
|
+
|
|
464
472
|
.index-card-tags {
|
|
465
473
|
display: flex;
|
|
466
474
|
gap: 4px;
|
|
467
475
|
margin-left: auto;
|
|
476
|
+
flex-shrink: 0;
|
|
468
477
|
}
|
|
469
478
|
|
|
470
479
|
.index-card-owner {
|
|
@@ -481,7 +490,7 @@ body {
|
|
|
481
490
|
.index-card-sep {
|
|
482
491
|
font-size: 16px;
|
|
483
492
|
color: var(--text-faint);
|
|
484
|
-
margin: 0
|
|
493
|
+
margin: 0;
|
|
485
494
|
}
|
|
486
495
|
|
|
487
496
|
.index-card-name {
|
|
@@ -1088,6 +1097,44 @@ body {
|
|
|
1088
1097
|
color: var(--tag-text);
|
|
1089
1098
|
}
|
|
1090
1099
|
|
|
1100
|
+
/* Compact single-letter variant used in index-card heads where the
|
|
1101
|
+
full word (mirror/stage/volatile/...) would push the kebab off the
|
|
1102
|
+
row in narrow grid columns. Hover tooltip (data-tooltip) carries
|
|
1103
|
+
the long form — uses a CSS pseudo so it appears instantly instead
|
|
1104
|
+
of waiting for the browser's native title delay. */
|
|
1105
|
+
.tag-letter {
|
|
1106
|
+
padding: 1px 6px;
|
|
1107
|
+
font-size: 11px;
|
|
1108
|
+
font-weight: 700;
|
|
1109
|
+
font-family: var(--mono, ui-monospace, monospace);
|
|
1110
|
+
min-width: 18px;
|
|
1111
|
+
text-align: center;
|
|
1112
|
+
position: relative;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.tag-letter[data-tooltip]:hover::after,
|
|
1116
|
+
.tag-letter[data-tooltip]:focus-visible::after {
|
|
1117
|
+
content: attr(data-tooltip);
|
|
1118
|
+
position: absolute;
|
|
1119
|
+
bottom: calc(100% + 6px);
|
|
1120
|
+
left: 50%;
|
|
1121
|
+
transform: translateX(-50%);
|
|
1122
|
+
background: var(--bg-surface);
|
|
1123
|
+
color: var(--text);
|
|
1124
|
+
border: 1px solid var(--border);
|
|
1125
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
1126
|
+
padding: 5px 8px;
|
|
1127
|
+
border-radius: 4px;
|
|
1128
|
+
font-size: 12px;
|
|
1129
|
+
font-weight: 400;
|
|
1130
|
+
font-family: inherit;
|
|
1131
|
+
white-space: normal;
|
|
1132
|
+
max-width: 260px;
|
|
1133
|
+
width: max-content;
|
|
1134
|
+
z-index: 100;
|
|
1135
|
+
pointer-events: none;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1091
1138
|
.tag-volatile {
|
|
1092
1139
|
background: #fef3c7;
|
|
1093
1140
|
color: #92400e;
|
|
@@ -2421,7 +2421,13 @@
|
|
|
2421
2421
|
closeModal();
|
|
2422
2422
|
updateAuthUI();
|
|
2423
2423
|
navigate();
|
|
2424
|
-
|
|
2424
|
+
// Preserve the current hash through the PRG redirect that
|
|
2425
|
+
// form.submit() to /+admin triggers — otherwise the
|
|
2426
|
+
// browser lands on /+admin/ with no fragment and the
|
|
2427
|
+
// user's original deep link (or the page they were on
|
|
2428
|
+
// when the session expired) is lost.
|
|
2429
|
+
var currentHash = (window.location.hash || '').replace(/^#/, '');
|
|
2430
|
+
_triggerPasswordSave(user, pass, currentHash);
|
|
2425
2431
|
})
|
|
2426
2432
|
.catch(showModalError);
|
|
2427
2433
|
}
|
|
@@ -2825,47 +2831,61 @@
|
|
|
2825
2831
|
|
|
2826
2832
|
// Card header: name + type badge
|
|
2827
2833
|
var cardHead = el('div', {className: 'index-card-head'});
|
|
2828
|
-
|
|
2834
|
+
// Path container groups owner / sep / name so the only
|
|
2835
|
+
// wide flex gap in the head is between path, tags, and
|
|
2836
|
+
// kebab — the separator hugs both sides like a real
|
|
2837
|
+
// filesystem path.
|
|
2838
|
+
var pathWrap = el('div', {className: 'index-card-path'});
|
|
2839
|
+
pathWrap.appendChild(el('a', {
|
|
2829
2840
|
href: '#indexes/' + idx._user,
|
|
2830
2841
|
className: 'index-card-owner',
|
|
2831
2842
|
textContent: idx._user,
|
|
2832
2843
|
}));
|
|
2833
|
-
|
|
2844
|
+
pathWrap.appendChild(el('span', {
|
|
2834
2845
|
className: 'index-card-sep',
|
|
2835
2846
|
textContent: '/',
|
|
2836
2847
|
}));
|
|
2837
|
-
|
|
2848
|
+
pathWrap.appendChild(el('a', {
|
|
2838
2849
|
href: '#packages/' + idx._full,
|
|
2839
2850
|
className: 'index-card-name',
|
|
2840
2851
|
textContent: idx._name,
|
|
2841
2852
|
}));
|
|
2853
|
+
cardHead.appendChild(pathWrap);
|
|
2854
|
+
// Type / state badges. Single-letter labels (M/S/V/W/N)
|
|
2855
|
+
// keep multi-badge headers from wrapping in tight grids;
|
|
2856
|
+
// `title` exposes the full word on hover for clarity.
|
|
2842
2857
|
var tagGroup = el('div', {className: 'index-card-tags'});
|
|
2843
2858
|
tagGroup.appendChild(el('span', {
|
|
2844
|
-
className: 'tag
|
|
2845
|
-
|
|
2859
|
+
className: 'tag tag-letter'
|
|
2860
|
+
+ (isMirror ? ' tag-mirror' : ' tag-stage'),
|
|
2861
|
+
textContent: isMirror ? 'M' : 'S',
|
|
2862
|
+
'data-tooltip': idx.type || 'stage',
|
|
2846
2863
|
}));
|
|
2847
2864
|
if (!isMirror && idx.volatile) {
|
|
2848
2865
|
tagGroup.appendChild(el('span', {
|
|
2849
|
-
className: 'tag tag-volatile',
|
|
2850
|
-
textContent: '
|
|
2866
|
+
className: 'tag tag-letter tag-volatile',
|
|
2867
|
+
textContent: 'V',
|
|
2868
|
+
'data-tooltip': 'volatile — uploads may overwrite '
|
|
2869
|
+
+ 'existing versions',
|
|
2851
2870
|
}));
|
|
2852
2871
|
}
|
|
2853
2872
|
if (!isMirror && isAnonymousAclUpload(idx.acl_upload)) {
|
|
2854
2873
|
tagGroup.appendChild(el('span', {
|
|
2855
|
-
className: 'tag tag-world-writable',
|
|
2856
|
-
textContent: '
|
|
2857
|
-
|
|
2858
|
-
+ '
|
|
2859
|
-
+ 'publish
|
|
2874
|
+
className: 'tag tag-letter tag-world-writable',
|
|
2875
|
+
textContent: 'W',
|
|
2876
|
+
'data-tooltip': 'world-writable — acl_upload '
|
|
2877
|
+
+ 'contains :ANONYMOUS:; anyone (including '
|
|
2878
|
+
+ 'unauthenticated callers) can publish to '
|
|
2879
|
+
+ 'this index.',
|
|
2860
2880
|
}));
|
|
2861
2881
|
} else if (!isMirror && isUploadFrozen(idx.acl_upload)) {
|
|
2862
2882
|
tagGroup.appendChild(el('span', {
|
|
2863
|
-
className: 'tag tag-no-upload',
|
|
2864
|
-
textContent: '
|
|
2865
|
-
|
|
2866
|
-
+ 'to this index, not even
|
|
2867
|
-
+ 'Add a principal to
|
|
2868
|
-
+ 'uploads.',
|
|
2883
|
+
className: 'tag tag-letter tag-no-upload',
|
|
2884
|
+
textContent: 'N',
|
|
2885
|
+
'data-tooltip': 'no upload — acl_upload is empty; '
|
|
2886
|
+
+ 'nobody can publish to this index, not even '
|
|
2887
|
+
+ 'the owner or root. Add a principal to '
|
|
2888
|
+
+ 'acl_upload to enable uploads.',
|
|
2869
2889
|
}));
|
|
2870
2890
|
}
|
|
2871
2891
|
cardHead.appendChild(tagGroup);
|
|
@@ -2959,6 +2979,22 @@
|
|
|
2959
2979
|
});
|
|
2960
2980
|
})(idx);
|
|
2961
2981
|
}
|
|
2982
|
+
// Mirror-only: any authenticated user may trigger an
|
|
2983
|
+
// upstream re-fetch (etag-conditional, low cost). Useful
|
|
2984
|
+
// when waiting for a freshly-published upstream release
|
|
2985
|
+
// that's hidden behind the `mirror_cache_expiry` TTL.
|
|
2986
|
+
if (isMirror && loggedIn) {
|
|
2987
|
+
(function (idxRef) {
|
|
2988
|
+
menuItems.push({
|
|
2989
|
+
label: 'Refresh cache',
|
|
2990
|
+
onclick: function () {
|
|
2991
|
+
closeAllKebabs();
|
|
2992
|
+
refreshMirrorCache(
|
|
2993
|
+
idxRef._user, idxRef._name);
|
|
2994
|
+
},
|
|
2995
|
+
});
|
|
2996
|
+
})(idx);
|
|
2997
|
+
}
|
|
2962
2998
|
if (menuItems.length) {
|
|
2963
2999
|
cardHead.appendChild(buildKebabMenu(menuItems));
|
|
2964
3000
|
}
|
|
@@ -3223,6 +3259,51 @@
|
|
|
3223
3259
|
.catch(handleApiError);
|
|
3224
3260
|
}
|
|
3225
3261
|
|
|
3262
|
+
function refreshMirrorCache(user, index) {
|
|
3263
|
+
// Non-destructive: just bumps the upstream-fetch clocks. Skip
|
|
3264
|
+
// the confirm dialog; show a small result modal so the user
|
|
3265
|
+
// knows whether the request reached the primary.
|
|
3266
|
+
Api.post(
|
|
3267
|
+
'/+admin-api/mirror/'
|
|
3268
|
+
+ encodeURIComponent(user) + '/'
|
|
3269
|
+
+ encodeURIComponent(index) + '/refresh-cache',
|
|
3270
|
+
{})
|
|
3271
|
+
.then(function (data) {
|
|
3272
|
+
var r = (data && data.result) || {};
|
|
3273
|
+
var count = r.projects_invalidated;
|
|
3274
|
+
openModal('Cache refreshed', function (body) {
|
|
3275
|
+
body.appendChild(el('p', {
|
|
3276
|
+
textContent: 'Mirror "' + user + '/' + index
|
|
3277
|
+
+ '" cache invalidated. '
|
|
3278
|
+
+ count + ' project'
|
|
3279
|
+
+ (count === 1 ? '' : 's')
|
|
3280
|
+
+ ' will re-check upstream on next access.',
|
|
3281
|
+
}));
|
|
3282
|
+
body.appendChild(el('p', {
|
|
3283
|
+
className: 'note',
|
|
3284
|
+
textContent: 'Note: cache is process-local; '
|
|
3285
|
+
+ 'replicas will sync once the primary refetches.',
|
|
3286
|
+
}));
|
|
3287
|
+
}, [el('button', {
|
|
3288
|
+
className: 'btn btn-primary',
|
|
3289
|
+
textContent: 'OK',
|
|
3290
|
+
onclick: closeModal,
|
|
3291
|
+
})]);
|
|
3292
|
+
})
|
|
3293
|
+
.catch(function (err) {
|
|
3294
|
+
openModal('Cache refresh failed', function (body) {
|
|
3295
|
+
body.appendChild(el('p', {
|
|
3296
|
+
className: 'error',
|
|
3297
|
+
textContent: (err && err.message) || String(err),
|
|
3298
|
+
}));
|
|
3299
|
+
}, [el('button', {
|
|
3300
|
+
className: 'btn btn-primary',
|
|
3301
|
+
textContent: 'Close',
|
|
3302
|
+
onclick: closeModal,
|
|
3303
|
+
})]);
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3226
3307
|
// ========== PACKAGES ==========
|
|
3227
3308
|
|
|
3228
3309
|
var PKG_LIMIT = 100;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
4
4
|
Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
|
|
|
61
61
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
62
62
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
63
63
|
ones with the index pre-filled and locked
|
|
64
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
65
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
66
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
67
|
+
(etag-conditional, cheap)
|
|
64
68
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
65
69
|
- Create / edit / delete indexes via modal dialogs
|
|
66
70
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -597,6 +601,30 @@ Revoke a single token.
|
|
|
597
601
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
598
602
|
- **404:** token id not found
|
|
599
603
|
|
|
604
|
+
### Mirror cache
|
|
605
|
+
|
|
606
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
607
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
608
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
609
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
610
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
611
|
+
queried). Two caches are expired:
|
|
612
|
+
|
|
613
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
614
|
+
project, in one pass)
|
|
615
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
616
|
+
projects" call)
|
|
617
|
+
|
|
618
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
619
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
620
|
+
|
|
621
|
+
- **Auth:** required (any authenticated user)
|
|
622
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
623
|
+
persisted state via the changelog stream once the primary refetches)
|
|
624
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
625
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
626
|
+
- **404:** index doesn't exist
|
|
627
|
+
|
|
600
628
|
### Replication observability (primary only)
|
|
601
629
|
|
|
602
630
|
#### `GET /+admin-api/replicas`
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound
|
|
7
|
+
|
|
8
|
+
from devpi_admin.main import (
|
|
9
|
+
_check_read_access, _get_stage_or_404, _refresh_mirror_cache_view,
|
|
10
|
+
_serve_index)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GetStageOr404Tests(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_returns_stage(self):
|
|
16
|
+
xom = MagicMock()
|
|
17
|
+
stage = MagicMock()
|
|
18
|
+
xom.model.getstage.return_value = stage
|
|
19
|
+
self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
|
|
20
|
+
|
|
21
|
+
def test_raises_404_when_missing(self):
|
|
22
|
+
xom = MagicMock()
|
|
23
|
+
xom.model.getstage.return_value = None
|
|
24
|
+
with self.assertRaises(HTTPNotFound):
|
|
25
|
+
_get_stage_or_404(xom, "ghost", "ix")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CheckReadAccessTests(unittest.TestCase):
|
|
29
|
+
|
|
30
|
+
def _make(self, allow, auth_user):
|
|
31
|
+
req = MagicMock()
|
|
32
|
+
req.has_permission.return_value = allow
|
|
33
|
+
req.authenticated_userid = auth_user
|
|
34
|
+
return req
|
|
35
|
+
|
|
36
|
+
def test_allowed_returns_none(self):
|
|
37
|
+
# Should silently pass — no exception, no return value relied upon.
|
|
38
|
+
self.assertIsNone(
|
|
39
|
+
_check_read_access(self._make(True, "alice"), MagicMock()))
|
|
40
|
+
|
|
41
|
+
def test_denied_anonymous_gets_403(self):
|
|
42
|
+
# Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
|
|
43
|
+
with self.assertRaises(HTTPForbidden):
|
|
44
|
+
_check_read_access(self._make(False, None), MagicMock())
|
|
45
|
+
|
|
46
|
+
def test_denied_authenticated_gets_404(self):
|
|
47
|
+
# Authenticated user without read access gets 404 — hides the
|
|
48
|
+
# existence of the private index.
|
|
49
|
+
with self.assertRaises(HTTPNotFound):
|
|
50
|
+
_check_read_access(self._make(False, "bob"), MagicMock())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ServeIndexTests(unittest.TestCase):
|
|
54
|
+
|
|
55
|
+
def _serve(self):
|
|
56
|
+
# FileResponse needs a real file on disk; STATIC_DIR/index.html
|
|
57
|
+
# is bundled and exists in test runs (verified by test_package).
|
|
58
|
+
request = MagicMock()
|
|
59
|
+
return _serve_index(request)
|
|
60
|
+
|
|
61
|
+
def test_csp_header_present(self):
|
|
62
|
+
resp = self._serve()
|
|
63
|
+
csp = resp.headers.get("Content-Security-Policy", "")
|
|
64
|
+
self.assertIn("default-src 'self'", csp)
|
|
65
|
+
self.assertIn("frame-ancestors 'none'", csp)
|
|
66
|
+
self.assertIn("script-src 'self'", csp)
|
|
67
|
+
# inline script must NOT be allowed
|
|
68
|
+
self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
|
|
69
|
+
|
|
70
|
+
def test_csp_allows_pypi_for_readme_fallback(self):
|
|
71
|
+
resp = self._serve()
|
|
72
|
+
csp = resp.headers.get("Content-Security-Policy", "")
|
|
73
|
+
self.assertIn("https://pypi.org", csp)
|
|
74
|
+
|
|
75
|
+
def test_security_headers_present(self):
|
|
76
|
+
resp = self._serve()
|
|
77
|
+
self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
|
|
78
|
+
self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RefreshMirrorCacheViewTests(unittest.TestCase):
|
|
82
|
+
|
|
83
|
+
def _stub_xom(self, stage=None, is_replica=False):
|
|
84
|
+
xom = MagicMock()
|
|
85
|
+
xom.config.role = "replica" if is_replica else "primary"
|
|
86
|
+
xom.model.getstage.return_value = stage
|
|
87
|
+
ctx = MagicMock()
|
|
88
|
+
ctx.__enter__ = MagicMock(return_value=ctx)
|
|
89
|
+
ctx.__exit__ = MagicMock(return_value=False)
|
|
90
|
+
xom.keyfs.read_transaction.return_value = ctx
|
|
91
|
+
return xom
|
|
92
|
+
|
|
93
|
+
def _stub_mirror_stage(self, tracked_projects=("setuptools", "pip")):
|
|
94
|
+
stage = MagicMock()
|
|
95
|
+
stage.ixconfig = {"type": "mirror"}
|
|
96
|
+
stage.cache_retrieve_times._project2time = {
|
|
97
|
+
p: (1.0, None) for p in tracked_projects}
|
|
98
|
+
stage.cache_retrieve_times.expire = MagicMock()
|
|
99
|
+
stage.cache_projectnames.expire = MagicMock()
|
|
100
|
+
return stage
|
|
101
|
+
|
|
102
|
+
def _make(self, xom, user="root", index="pypi", auth_user="alice"):
|
|
103
|
+
req = MagicMock()
|
|
104
|
+
req.registry = {"xom": xom}
|
|
105
|
+
req.matchdict = {"user": user, "index": index}
|
|
106
|
+
req.authenticated_userid = auth_user
|
|
107
|
+
return req
|
|
108
|
+
|
|
109
|
+
def test_expires_all_tracked_projects_and_projectnames(self):
|
|
110
|
+
stage = self._stub_mirror_stage(("setuptools", "pip", "wheel"))
|
|
111
|
+
xom = self._stub_xom(stage=stage)
|
|
112
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
113
|
+
resp = _refresh_mirror_cache_view(self._make(xom))
|
|
114
|
+
body = json.loads(resp.body)
|
|
115
|
+
self.assertEqual(body["result"]["projects_invalidated"], 3)
|
|
116
|
+
self.assertTrue(body["result"]["projectnames_invalidated"])
|
|
117
|
+
# Every tracked project must have been expired exactly once;
|
|
118
|
+
# the project-names cache must be expired regardless.
|
|
119
|
+
self.assertEqual(stage.cache_retrieve_times.expire.call_count, 3)
|
|
120
|
+
stage.cache_projectnames.expire.assert_called_once_with()
|
|
121
|
+
|
|
122
|
+
def test_404_when_index_missing(self):
|
|
123
|
+
xom = self._stub_xom(stage=None)
|
|
124
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
125
|
+
with self.assertRaises(HTTPNotFound):
|
|
126
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
127
|
+
|
|
128
|
+
def test_400_when_index_is_not_mirror(self):
|
|
129
|
+
stage = MagicMock()
|
|
130
|
+
stage.ixconfig = {"type": "stage"}
|
|
131
|
+
xom = self._stub_xom(stage=stage)
|
|
132
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
133
|
+
with self.assertRaises(HTTPBadRequest):
|
|
134
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
135
|
+
|
|
136
|
+
def test_replica_refuses(self):
|
|
137
|
+
xom = self._stub_xom(stage=self._stub_mirror_stage())
|
|
138
|
+
with patch("devpi_admin.main._is_replica", return_value=True):
|
|
139
|
+
with self.assertRaises(HTTPBadRequest):
|
|
140
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
141
|
+
|
|
142
|
+
def test_unauthenticated_blocked(self):
|
|
143
|
+
xom = self._stub_xom(stage=self._stub_mirror_stage())
|
|
144
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
145
|
+
with self.assertRaises(HTTPForbidden):
|
|
146
|
+
_refresh_mirror_cache_view(
|
|
147
|
+
self._make(xom, auth_user=None))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
unittest.main()
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
|
|
2
|
-
import unittest
|
|
3
|
-
from unittest.mock import MagicMock, patch
|
|
4
|
-
|
|
5
|
-
from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound
|
|
6
|
-
|
|
7
|
-
from devpi_admin.main import _check_read_access, _get_stage_or_404, _serve_index
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class GetStageOr404Tests(unittest.TestCase):
|
|
11
|
-
|
|
12
|
-
def test_returns_stage(self):
|
|
13
|
-
xom = MagicMock()
|
|
14
|
-
stage = MagicMock()
|
|
15
|
-
xom.model.getstage.return_value = stage
|
|
16
|
-
self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
|
|
17
|
-
|
|
18
|
-
def test_raises_404_when_missing(self):
|
|
19
|
-
xom = MagicMock()
|
|
20
|
-
xom.model.getstage.return_value = None
|
|
21
|
-
with self.assertRaises(HTTPNotFound):
|
|
22
|
-
_get_stage_or_404(xom, "ghost", "ix")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class CheckReadAccessTests(unittest.TestCase):
|
|
26
|
-
|
|
27
|
-
def _make(self, allow, auth_user):
|
|
28
|
-
req = MagicMock()
|
|
29
|
-
req.has_permission.return_value = allow
|
|
30
|
-
req.authenticated_userid = auth_user
|
|
31
|
-
return req
|
|
32
|
-
|
|
33
|
-
def test_allowed_returns_none(self):
|
|
34
|
-
# Should silently pass — no exception, no return value relied upon.
|
|
35
|
-
self.assertIsNone(
|
|
36
|
-
_check_read_access(self._make(True, "alice"), MagicMock()))
|
|
37
|
-
|
|
38
|
-
def test_denied_anonymous_gets_403(self):
|
|
39
|
-
# Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
|
|
40
|
-
with self.assertRaises(HTTPForbidden):
|
|
41
|
-
_check_read_access(self._make(False, None), MagicMock())
|
|
42
|
-
|
|
43
|
-
def test_denied_authenticated_gets_404(self):
|
|
44
|
-
# Authenticated user without read access gets 404 — hides the
|
|
45
|
-
# existence of the private index.
|
|
46
|
-
with self.assertRaises(HTTPNotFound):
|
|
47
|
-
_check_read_access(self._make(False, "bob"), MagicMock())
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class ServeIndexTests(unittest.TestCase):
|
|
51
|
-
|
|
52
|
-
def _serve(self):
|
|
53
|
-
# FileResponse needs a real file on disk; STATIC_DIR/index.html
|
|
54
|
-
# is bundled and exists in test runs (verified by test_package).
|
|
55
|
-
request = MagicMock()
|
|
56
|
-
return _serve_index(request)
|
|
57
|
-
|
|
58
|
-
def test_csp_header_present(self):
|
|
59
|
-
resp = self._serve()
|
|
60
|
-
csp = resp.headers.get("Content-Security-Policy", "")
|
|
61
|
-
self.assertIn("default-src 'self'", csp)
|
|
62
|
-
self.assertIn("frame-ancestors 'none'", csp)
|
|
63
|
-
self.assertIn("script-src 'self'", csp)
|
|
64
|
-
# inline script must NOT be allowed
|
|
65
|
-
self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
|
|
66
|
-
|
|
67
|
-
def test_csp_allows_pypi_for_readme_fallback(self):
|
|
68
|
-
resp = self._serve()
|
|
69
|
-
csp = resp.headers.get("Content-Security-Policy", "")
|
|
70
|
-
self.assertIn("https://pypi.org", csp)
|
|
71
|
-
|
|
72
|
-
def test_security_headers_present(self):
|
|
73
|
-
resp = self._serve()
|
|
74
|
-
self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
|
|
75
|
-
self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if __name__ == "__main__":
|
|
79
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|