dsimaging-admin 0.3.2__tar.gz → 0.3.5__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.
- dsimaging_admin-0.3.5/IMPROVEMENTS.md +85 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/PKG-INFO +1 -1
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/pyproject.toml +1 -1
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/__init__.py +1 -1
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/s3.py +11 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/store.py +33 -4
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/tests/test_admin_features.py +6 -6
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/tests/test_manifest.py +9 -9
- dsimaging_admin-0.3.2/IMPROVEMENTS.md +0 -244
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/.gitignore +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/LICENSE +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/README.md +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/cli.py +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/controller.py +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/hashing.py +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/manifest.py +0 -0
- {dsimaging_admin-0.3.2 → dsimaging_admin-0.3.5}/src/dsimaging_admin/verify.py +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# IMPROVEMENTS
|
|
2
|
+
|
|
3
|
+
Validated status after the `dsimaging-admin` / `dsimaging-store` work for
|
|
4
|
+
store-centric administration.
|
|
5
|
+
|
|
6
|
+
## Product boundary
|
|
7
|
+
|
|
8
|
+
`dsimaging-admin` manages `dsimaging-store` deployments and the datasets inside
|
|
9
|
+
those stores. It must stay agnostic about clients that may consume those
|
|
10
|
+
datasets later.
|
|
11
|
+
|
|
12
|
+
That means Opal registration is not part of the public admin surface. Earlier
|
|
13
|
+
notes in this file suggested an `opal` module and `--opal-*` publish flags; that
|
|
14
|
+
was the wrong boundary for this project and has been intentionally removed.
|
|
15
|
+
|
|
16
|
+
## Implemented
|
|
17
|
+
|
|
18
|
+
### Store management
|
|
19
|
+
|
|
20
|
+
- `store init` generates a ready-to-run `dsimaging-store` directory with
|
|
21
|
+
Docker Compose, `.env`, MinIO init script and controller settings.
|
|
22
|
+
- `store up`, `store down`, `store ps`, `store logs`, `store config` and
|
|
23
|
+
`store doctor` operate that generated store from the admin CLI.
|
|
24
|
+
- The default controller image is
|
|
25
|
+
`davidsarratgonzalez/dsimaging-store:latest`, with local-build support for
|
|
26
|
+
development.
|
|
27
|
+
|
|
28
|
+
### Dataset administration
|
|
29
|
+
|
|
30
|
+
- `delete <dataset-id> --yes [--purge-versions]` removes current dataset
|
|
31
|
+
objects, with optional version purging for versioned buckets.
|
|
32
|
+
- `verify <dataset-id> [--quick] [--sample-fraction ...] [--output text|json]`
|
|
33
|
+
checks S3 objects against `content_hash_index.parquet` and mask indexes.
|
|
34
|
+
- `status <dataset-id> [--output text|json]` reports object counts, manifest
|
|
35
|
+
state, hash-index rows and controller state when reachable.
|
|
36
|
+
- `download <dataset-id> <dest>` pulls a dataset locally for inspection.
|
|
37
|
+
- `copy <src-id> <dst-id> --yes` performs server-side S3 copies for renames or
|
|
38
|
+
forks.
|
|
39
|
+
- `list --output text|json` now ignores deleted prefixes that only remain as
|
|
40
|
+
version history and can merge controller state.
|
|
41
|
+
- `reconcile <dataset-id>` asks the store controller to rebuild artifacts for a
|
|
42
|
+
dataset.
|
|
43
|
+
|
|
44
|
+
### Publish robustness
|
|
45
|
+
|
|
46
|
+
- `publish` is atomic by default: uploads go to a staging prefix, then are
|
|
47
|
+
copied into the canonical dataset prefix after the upload completes.
|
|
48
|
+
- A `.publish-lock` marker prevents the controller from reconciling a dataset
|
|
49
|
+
while the CLI is still publishing it.
|
|
50
|
+
- `--no-atomic`, `--no-skip`, `--dry-run` and `--skip-dicom-checks` cover the
|
|
51
|
+
operational escape hatches.
|
|
52
|
+
- Pre-publish DICOM checks validate consistent series UID, modality and
|
|
53
|
+
instance ordering when `pydicom` can parse the series.
|
|
54
|
+
|
|
55
|
+
### Store observability
|
|
56
|
+
|
|
57
|
+
- `doctor --output text|json` checks S3 connectivity, bucket existence,
|
|
58
|
+
versioning, init marker, controller health, webhook prefix/source prefixes and
|
|
59
|
+
controller/S3 dataset parity.
|
|
60
|
+
- `--controller-url` and `--skip-controller` are global options.
|
|
61
|
+
- Multi-profile config is supported through `profiles.<name>` in
|
|
62
|
+
`~/.dsimaging.yaml` and `--profile <name>`.
|
|
63
|
+
|
|
64
|
+
### Shared scan/hash/manifest core
|
|
65
|
+
|
|
66
|
+
The controller now imports the scan, hash-index and manifest builders from
|
|
67
|
+
`dsimaging_admin.manifest` instead of carrying a divergent copy. This keeps
|
|
68
|
+
CLI-side publish/rescan and controller-side reconcile on the same rules for
|
|
69
|
+
images, masks, metadata and manifests.
|
|
70
|
+
|
|
71
|
+
## Explicitly not implemented
|
|
72
|
+
|
|
73
|
+
- Opal commands and `--opal-*` flags, by product decision.
|
|
74
|
+
- A web UI; the CLI plus MinIO console remains the operating surface.
|
|
75
|
+
- Image-level processing such as DICOM de-identification, resampling or format
|
|
76
|
+
conversion. Those belong in imaging/radiomics pipelines, not store admin.
|
|
77
|
+
|
|
78
|
+
## Remaining follow-ups
|
|
79
|
+
|
|
80
|
+
- Add CI release automation with Trusted Publishing so future PyPI releases do
|
|
81
|
+
not require a manual API token.
|
|
82
|
+
- Decide whether a successful or failed `verify` should write an audit artifact
|
|
83
|
+
such as `indexes/last_verify.json`.
|
|
84
|
+
- Add richer structured runtime logging if operators need machine-readable log
|
|
85
|
+
streams beyond the existing `--output json` command outputs.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dsimaging-admin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Admin CLI for managing medical imaging datasets in S3/MinIO for DataSHIELD
|
|
5
5
|
Project-URL: Homepage, https://github.com/isglobal-brge/dsimaging-admin
|
|
6
6
|
Project-URL: Repository, https://github.com/isglobal-brge/dsimaging-admin
|
|
@@ -33,6 +33,8 @@ def list_datasets(s3, bucket: str) -> list[dict]:
|
|
|
33
33
|
for page in paginator.paginate(Bucket=bucket, Prefix="datasets/", Delimiter="/"):
|
|
34
34
|
for cp in page.get("CommonPrefixes", []):
|
|
35
35
|
ds_id = cp["Prefix"].strip("/").split("/")[-1]
|
|
36
|
+
if not _prefix_has_current_objects(s3, bucket, f"datasets/{ds_id}/"):
|
|
37
|
+
continue
|
|
36
38
|
has_manifest = _object_exists(s3, bucket, f"datasets/{ds_id}/manifest.yaml")
|
|
37
39
|
datasets.append({
|
|
38
40
|
"dataset_id": ds_id,
|
|
@@ -175,3 +177,12 @@ def _object_exists(s3, bucket: str, key: str) -> bool:
|
|
|
175
177
|
return True
|
|
176
178
|
except Exception:
|
|
177
179
|
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prefix_has_current_objects(s3, bucket: str, prefix: str) -> bool:
|
|
183
|
+
paginator = s3.get_paginator("list_objects_v2")
|
|
184
|
+
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
|
185
|
+
for obj in page.get("Contents", []):
|
|
186
|
+
if _object_exists(s3, bucket, obj["Key"]):
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
@@ -251,6 +251,8 @@ services:
|
|
|
251
251
|
MINIO_ROOT_USER: ${{MINIO_ROOT_USER:-minioadmin}}
|
|
252
252
|
MINIO_ROOT_PASSWORD: ${{MINIO_ROOT_PASSWORD:-minioadmin123}}
|
|
253
253
|
BUCKET_NAME: ${{BUCKET_NAME:-imaging-data}}
|
|
254
|
+
WEBHOOK_RETRIES: ${{WEBHOOK_RETRIES:-12}}
|
|
255
|
+
WEBHOOK_RETRY_SECONDS: ${{WEBHOOK_RETRY_SECONDS:-5}}
|
|
254
256
|
depends_on:
|
|
255
257
|
minio:
|
|
256
258
|
condition: service_healthy
|
|
@@ -269,6 +271,8 @@ MINIO_CONSOLE_PORT={console_port}
|
|
|
269
271
|
CONTROLLER_PORT={controller_port}
|
|
270
272
|
BUCKET_NAME={bucket}
|
|
271
273
|
RECONCILE_INTERVAL_SECONDS={reconcile_interval}
|
|
274
|
+
WEBHOOK_RETRIES=12
|
|
275
|
+
WEBHOOK_RETRY_SECONDS=5
|
|
272
276
|
DSIMAGING_STORE_CONTROLLER_IMAGE={controller_image}
|
|
273
277
|
DSIMAGING_STORE_MINIO_IMAGE={minio_image}
|
|
274
278
|
DSIMAGING_STORE_MC_IMAGE={mc_image}
|
|
@@ -283,6 +287,8 @@ MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://minio:9000}"
|
|
|
283
287
|
MINIO_ROOT_USER="${MINIO_ROOT_USER:-minioadmin}"
|
|
284
288
|
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-minioadmin123}"
|
|
285
289
|
BUCKET_NAME="${BUCKET_NAME:-imaging-data}"
|
|
290
|
+
WEBHOOK_RETRIES="${WEBHOOK_RETRIES:-12}"
|
|
291
|
+
WEBHOOK_RETRY_SECONDS="${WEBHOOK_RETRY_SECONDS:-5}"
|
|
286
292
|
|
|
287
293
|
mc alias set local "${MINIO_ENDPOINT}" "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}" --api s3v4
|
|
288
294
|
|
|
@@ -297,10 +303,33 @@ mc version enable "local/${BUCKET_NAME}"
|
|
|
297
303
|
mkdir -p /tmp/dsimaging-init
|
|
298
304
|
: > /tmp/dsimaging-init/.keep
|
|
299
305
|
mc cp /tmp/dsimaging-init/.keep "local/${BUCKET_NAME}/datasets/.keep" 2>/dev/null || true
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
configure_webhook() {
|
|
307
|
+
attempt=1
|
|
308
|
+
while [ "${attempt}" -le "${WEBHOOK_RETRIES}" ]; do
|
|
309
|
+
if output=$(mc event add "local/${BUCKET_NAME}" arn:minio:sqs::DSIMAGING:webhook \
|
|
310
|
+
--event put,delete \
|
|
311
|
+
--prefix "datasets/" 2>&1); then
|
|
312
|
+
echo "[init] Webhook notification configured"
|
|
313
|
+
return 0
|
|
314
|
+
fi
|
|
315
|
+
|
|
316
|
+
if echo "${output}" | grep -qi "already"; then
|
|
317
|
+
echo "[init] Webhook notification already configured"
|
|
318
|
+
return 0
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
echo "[init] Webhook notification attempt ${attempt}/${WEBHOOK_RETRIES} failed: ${output}"
|
|
322
|
+
attempt=$((attempt + 1))
|
|
323
|
+
if [ "${attempt}" -le "${WEBHOOK_RETRIES}" ]; then
|
|
324
|
+
sleep "${WEBHOOK_RETRY_SECONDS}"
|
|
325
|
+
fi
|
|
326
|
+
done
|
|
327
|
+
|
|
328
|
+
echo "[init] ERROR: Unable to configure webhook notification after ${WEBHOOK_RETRIES} attempts" >&2
|
|
329
|
+
return 1
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
configure_webhook
|
|
304
333
|
|
|
305
334
|
echo "[init] MinIO initialization complete"
|
|
306
335
|
"""
|
|
@@ -104,7 +104,7 @@ class AdminFeatureTests(unittest.TestCase):
|
|
|
104
104
|
|
|
105
105
|
def test_verify_dataset_accepts_matching_single_file(self):
|
|
106
106
|
bucket = "imaging-data"
|
|
107
|
-
prefix = "datasets/
|
|
107
|
+
prefix = "datasets/study_ct_v1"
|
|
108
108
|
payload = b"nifti"
|
|
109
109
|
content_hash = hashlib.sha256(payload).hexdigest()
|
|
110
110
|
sample = {
|
|
@@ -131,7 +131,7 @@ class AdminFeatureTests(unittest.TestCase):
|
|
|
131
131
|
},
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
-
result = verify_dataset(s3, bucket, "
|
|
134
|
+
result = verify_dataset(s3, bucket, "study_ct_v1")
|
|
135
135
|
|
|
136
136
|
self.assertTrue(result.ok)
|
|
137
137
|
self.assertEqual(result.checked, 1)
|
|
@@ -139,7 +139,7 @@ class AdminFeatureTests(unittest.TestCase):
|
|
|
139
139
|
|
|
140
140
|
def test_verify_dataset_reports_mismatch(self):
|
|
141
141
|
bucket = "imaging-data"
|
|
142
|
-
prefix = "datasets/
|
|
142
|
+
prefix = "datasets/study_ct_v1"
|
|
143
143
|
sample = {
|
|
144
144
|
"sample_id": "case001",
|
|
145
145
|
"source_kind": "single_file",
|
|
@@ -162,14 +162,14 @@ class AdminFeatureTests(unittest.TestCase):
|
|
|
162
162
|
},
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
-
result = verify_dataset(s3, bucket, "
|
|
165
|
+
result = verify_dataset(s3, bucket, "study_ct_v1")
|
|
166
166
|
|
|
167
167
|
self.assertFalse(result.ok)
|
|
168
168
|
self.assertEqual(result.mismatched, 1)
|
|
169
169
|
|
|
170
170
|
def test_verify_quick_accepts_unchanged_etag(self):
|
|
171
171
|
bucket = "imaging-data"
|
|
172
|
-
prefix = "datasets/
|
|
172
|
+
prefix = "datasets/study_ct_v1"
|
|
173
173
|
sample = {
|
|
174
174
|
"sample_id": "case001",
|
|
175
175
|
"source_kind": "single_file",
|
|
@@ -194,7 +194,7 @@ class AdminFeatureTests(unittest.TestCase):
|
|
|
194
194
|
},
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
-
result = verify_dataset(s3, bucket, "
|
|
197
|
+
result = verify_dataset(s3, bucket, "study_ct_v1", quick=True)
|
|
198
198
|
|
|
199
199
|
self.assertTrue(result.ok)
|
|
200
200
|
self.assertEqual(result.quick_ok, 1)
|
|
@@ -32,7 +32,7 @@ class FakeS3:
|
|
|
32
32
|
|
|
33
33
|
class ManifestTests(unittest.TestCase):
|
|
34
34
|
def test_validate_dataset_id_rejects_unsafe_values(self):
|
|
35
|
-
validate_dataset_id("
|
|
35
|
+
validate_dataset_id("study_ct_v1_site-a.v1")
|
|
36
36
|
with self.assertRaises(ValueError):
|
|
37
37
|
validate_dataset_id("../bad")
|
|
38
38
|
with self.assertRaises(ValueError):
|
|
@@ -56,7 +56,7 @@ class ManifestTests(unittest.TestCase):
|
|
|
56
56
|
|
|
57
57
|
def test_scan_s3_images_groups_single_files_and_dicom(self):
|
|
58
58
|
bucket = "imaging-data"
|
|
59
|
-
prefix = "datasets/
|
|
59
|
+
prefix = "datasets/study_ct_v1"
|
|
60
60
|
payloads = {
|
|
61
61
|
f"{prefix}/source/images/a.nii.gz": b"nifti",
|
|
62
62
|
f"{prefix}/source/images/series1/001.dcm": b"one",
|
|
@@ -93,7 +93,7 @@ class ManifestTests(unittest.TestCase):
|
|
|
93
93
|
|
|
94
94
|
def test_scan_s3_masks_and_hash_index_use_mask_uris(self):
|
|
95
95
|
bucket = "imaging-data"
|
|
96
|
-
prefix = "datasets/
|
|
96
|
+
prefix = "datasets/study_ct_v1"
|
|
97
97
|
payloads = {
|
|
98
98
|
f"{prefix}/source/masks/case001_GTV-1.nii.gz": b"mask",
|
|
99
99
|
}
|
|
@@ -111,14 +111,14 @@ class ManifestTests(unittest.TestCase):
|
|
|
111
111
|
self.assertEqual(masks[0]["sample_id"], "case001")
|
|
112
112
|
self.assertEqual(
|
|
113
113
|
mask_index.to_pydict()["uri"][0],
|
|
114
|
-
"s3://imaging-data/datasets/
|
|
114
|
+
"s3://imaging-data/datasets/study_ct_v1/source/masks/case001_GTV-1.nii.gz",
|
|
115
115
|
)
|
|
116
116
|
|
|
117
117
|
def test_generate_manifest_can_declare_mask_asset(self):
|
|
118
118
|
manifest = generate_manifest(
|
|
119
|
-
"
|
|
119
|
+
"study_ct_v1",
|
|
120
120
|
"imaging-data",
|
|
121
|
-
"datasets/
|
|
121
|
+
"datasets/study_ct_v1",
|
|
122
122
|
modality="ct",
|
|
123
123
|
has_masks=True,
|
|
124
124
|
)
|
|
@@ -126,7 +126,7 @@ class ManifestTests(unittest.TestCase):
|
|
|
126
126
|
self.assertEqual(manifest["assets"]["masks"]["kind"], "mask_root")
|
|
127
127
|
self.assertEqual(
|
|
128
128
|
manifest["assets"]["masks"]["content_hash_index"],
|
|
129
|
-
"s3://imaging-data/datasets/
|
|
129
|
+
"s3://imaging-data/datasets/study_ct_v1/indexes/masks_content_hash_index.parquet",
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
def test_build_tables_use_canonical_uris(self):
|
|
@@ -140,12 +140,12 @@ class ManifestTests(unittest.TestCase):
|
|
|
140
140
|
"size": 5,
|
|
141
141
|
}]
|
|
142
142
|
|
|
143
|
-
hash_index = build_hash_index(samples, "imaging-data", "datasets/
|
|
143
|
+
hash_index = build_hash_index(samples, "imaging-data", "datasets/study_ct_v1")
|
|
144
144
|
manifests = build_sample_manifests(samples)
|
|
145
145
|
|
|
146
146
|
self.assertEqual(
|
|
147
147
|
hash_index.to_pydict()["uri"][0],
|
|
148
|
-
"s3://imaging-data/datasets/
|
|
148
|
+
"s3://imaging-data/datasets/study_ct_v1/source/images/a.nii.gz",
|
|
149
149
|
)
|
|
150
150
|
self.assertEqual(manifests.to_pydict()["n_files"][0], 1)
|
|
151
151
|
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
# IMPROVEMENTS
|
|
2
|
-
|
|
3
|
-
Candidate improvements to make `dsimaging-admin` a complete administration tool
|
|
4
|
-
for `dsimaging-store`, beyond the happy-path coverage shipped in **v0.2.0**.
|
|
5
|
-
|
|
6
|
-
This is a roadmap, not a backlog. Items are prioritised by how much real
|
|
7
|
-
operator pain they remove on the current `dsImaging` / `dsHPC` deployment.
|
|
8
|
-
|
|
9
|
-
## Current coverage (v0.2.0)
|
|
10
|
-
|
|
11
|
-
| Command | What it does |
|
|
12
|
-
|-----------|------------------------------------------------------------------------|
|
|
13
|
-
| `init` | Write `~/.dsimaging.yaml`. |
|
|
14
|
-
| `publish` | Local dir -> S3 (upload + indexes + manifest, optional Opal register). |
|
|
15
|
-
| `rescan` | Recompute indexes / manifest from current S3 contents. |
|
|
16
|
-
| `list` | Enumerate datasets; `published` if `manifest.yaml` exists. |
|
|
17
|
-
| `doctor` | Check S3 connectivity, bucket existence, versioning, dataset count. |
|
|
18
|
-
|
|
19
|
-
Two structural observations that motivate most of what follows:
|
|
20
|
-
|
|
21
|
-
1. The CLI does **not** talk to the `dsimaging-store` controller
|
|
22
|
-
(`:8080/health`, `:8080/datasets`). All status comes from S3 directly.
|
|
23
|
-
2. `cli.py::_write_dataset_artifacts` and
|
|
24
|
-
`dsimaging-store/controller/controller.py::write_dataset_artifacts`
|
|
25
|
-
re-implement the same scan / hash / manifest pipeline in parallel. They
|
|
26
|
-
already differ subtly (extra-metadata handling). This is a drift hazard.
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Priority 1 — missing core admin verbs
|
|
31
|
-
|
|
32
|
-
### 1.1 `delete <dataset-id>`
|
|
33
|
-
|
|
34
|
-
Hard gap. Today the only way to drop a broken or obsolete dataset is
|
|
35
|
-
`mc rm --recursive`.
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
dsimaging-admin delete <dataset-id> [--yes]
|
|
39
|
-
[--purge-versions]
|
|
40
|
-
[--opal-url ... --opal-project ... --opal-resource ...]
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
- Refuse without `--yes`.
|
|
44
|
-
- By default, delete current objects only; prior versions stay (the bucket has
|
|
45
|
-
versioning on). `--purge-versions` issues `delete_object_versions` in pages
|
|
46
|
-
of 1000.
|
|
47
|
-
- If `--opal-*` is provided, also remove the Opal resource.
|
|
48
|
-
|
|
49
|
-
Effort: **low**. Pure `list_objects_v2` + `delete_objects` paginator work.
|
|
50
|
-
|
|
51
|
-
### 1.2 `verify <dataset-id>`
|
|
52
|
-
|
|
53
|
-
Validate that the dataset on S3 still matches its
|
|
54
|
-
`content_hash_index.parquet`. The hash index is the whole reason content
|
|
55
|
-
hashes are computed; there is no command to check it.
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
dsimaging-admin verify <dataset-id> [--sample-fraction 1.0]
|
|
59
|
-
[--output text|json]
|
|
60
|
-
[--quick]
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
- Reads `indexes/content_hash_index.parquet` (and the masks index if present).
|
|
64
|
-
- Re-streams every object, recomputes sha256, reports
|
|
65
|
-
`missing` / `extra` / `mismatch` per sample.
|
|
66
|
-
- `--quick` compares S3 ETag against the recorded etag first; only falls back
|
|
67
|
-
to full hashing on mismatch. Caveat: ETag == sha256 only for single-part
|
|
68
|
-
uploads, so `--quick` is an heuristic, not a proof.
|
|
69
|
-
- Non-zero exit on drift; suitable for CI.
|
|
70
|
-
|
|
71
|
-
Effort: **medium**. DICOM series must hash with the same composition rule as
|
|
72
|
-
`controller.scan_s3_images` (sorted per-file sha256 fed back into a parent
|
|
73
|
-
sha256) — another reason to share code (see P4).
|
|
74
|
-
|
|
75
|
-
### 1.3 `opal {list,register,unregister}` standalone
|
|
76
|
-
|
|
77
|
-
Today Opal registration is a side-effect of `publish`. Adding a fourth Opal
|
|
78
|
-
site, or replaying registration after a wipe, forces either a full re-upload
|
|
79
|
-
or hand-crafted Opal REST calls.
|
|
80
|
-
|
|
81
|
-
```
|
|
82
|
-
dsimaging-admin opal list --opal-url ... [--project IMAGING]
|
|
83
|
-
dsimaging-admin opal register --dataset-id <id> --opal-url ... [--name ... --replace]
|
|
84
|
-
dsimaging-admin opal unregister --opal-url ... --project IMAGING --resource <name>
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
`register` rebuilds the `imaging+dataset://` URL from the dataset's
|
|
88
|
-
manifest on S3, so it does not need access to the original local source.
|
|
89
|
-
|
|
90
|
-
Implementation: extract `_register_opal_resource` from `cli.py` into an
|
|
91
|
-
`opal.py` module and reuse from the new sub-commands and from `publish`.
|
|
92
|
-
|
|
93
|
-
Effort: **low**.
|
|
94
|
-
|
|
95
|
-
---
|
|
96
|
-
|
|
97
|
-
## Priority 2 — observability & drift detection
|
|
98
|
-
|
|
99
|
-
### 2.1 `doctor` extended for the full module
|
|
100
|
-
|
|
101
|
-
The current `doctor` is bucket-scoped. Extend it to validate the deployment:
|
|
102
|
-
|
|
103
|
-
- Ping controller `http://<host>:8080/health`, surface `dirty_datasets` and
|
|
104
|
-
`last_errors`.
|
|
105
|
-
- Confirm the MinIO webhook is configured for `datasets/` (compare against
|
|
106
|
-
what the controller advertises).
|
|
107
|
-
- Compare CLI-side `list` against controller `/datasets` and flag any
|
|
108
|
-
divergence.
|
|
109
|
-
- Validate the init container ran (presence of `datasets/.keep`,
|
|
110
|
-
versioning enabled).
|
|
111
|
-
|
|
112
|
-
New flags:
|
|
113
|
-
|
|
114
|
-
```
|
|
115
|
-
--controller-url http://127.0.0.1:8080 (default; from ~/.dsimaging.yaml)
|
|
116
|
-
--skip-controller (for remote / firewalled stores)
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### 2.2 `list` enriched with controller state
|
|
120
|
-
|
|
121
|
-
When `--controller-url` is reachable, merge `dirty` and `last_error` into the
|
|
122
|
-
table. Same network call as the extended `doctor`; gracefully degrade to
|
|
123
|
-
S3-only when the controller is unreachable.
|
|
124
|
-
|
|
125
|
-
### 2.3 `status <dataset-id>`
|
|
126
|
-
|
|
127
|
-
Drill-down for a single dataset:
|
|
128
|
-
|
|
129
|
-
- Object counts under `source/images/` and `source/masks/`.
|
|
130
|
-
- Manifest `schema_version` + last-modified.
|
|
131
|
-
- Row count of `content_hash_index.parquet` vs object count (cheap drift hint
|
|
132
|
-
short of a full `verify`).
|
|
133
|
-
- Controller `last_reconcile` / `last_error` if reachable.
|
|
134
|
-
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
## Priority 3 — robustness of `publish`
|
|
138
|
-
|
|
139
|
-
### 3.1 Atomic publish via staging prefix
|
|
140
|
-
|
|
141
|
-
Today, a mid-upload failure leaves a partial set of objects under
|
|
142
|
-
`datasets/<id>/source/`. The controller fires on each PUT and writes a
|
|
143
|
-
partial manifest. Replace with a two-phase flow:
|
|
144
|
-
|
|
145
|
-
1. Upload to `datasets/<id>/.staging-<uuid>/source/...`.
|
|
146
|
-
2. On full success, server-side `CopyObject` into the canonical prefix and
|
|
147
|
-
delete the staging copy.
|
|
148
|
-
3. Only then do the webhook events on the final prefix trigger the
|
|
149
|
-
controller; the controller never sees an in-progress dataset.
|
|
150
|
-
|
|
151
|
-
Trade-off: intra-bucket copy adds IO but no egress. Acceptable for the
|
|
152
|
-
target scale.
|
|
153
|
-
|
|
154
|
-
### 3.2 Idempotent upload (skip-if-hash-matches)
|
|
155
|
-
|
|
156
|
-
For larger public datasets (LUNG1, other TCIA cohorts), re-running `publish`
|
|
157
|
-
should skip objects whose local sha256 already appears in
|
|
158
|
-
`content_hash_index.parquet`. Add `--no-skip` to opt out.
|
|
159
|
-
|
|
160
|
-
### 3.3 `--dry-run`
|
|
161
|
-
|
|
162
|
-
Scan + report what would be uploaded, no S3 writes. Useful for sanity-checking
|
|
163
|
-
metadata CSV joins (`--metadata`) before paying the upload cost.
|
|
164
|
-
|
|
165
|
-
---
|
|
166
|
-
|
|
167
|
-
## Priority 4 — internal refactor (drift killer)
|
|
168
|
-
|
|
169
|
-
`cli.py::_write_dataset_artifacts` and
|
|
170
|
-
`dsimaging-store/controller/controller.py::write_dataset_artifacts` are
|
|
171
|
-
siblings that must stay in sync. They already diverge:
|
|
172
|
-
|
|
173
|
-
- The controller has no concept of `extra_metadata` provided from outside;
|
|
174
|
-
it only preserves whatever was already in `metadata/samples.parquet`.
|
|
175
|
-
- Mask handling code paths are similar but not identical.
|
|
176
|
-
|
|
177
|
-
Two viable options:
|
|
178
|
-
|
|
179
|
-
**Option A — shared core library (recommended).**
|
|
180
|
-
Extract `manifest`, `hashing`, `schema`, `s3_layout` into a small package
|
|
181
|
-
(`dsimaging-core` or similar) installed in both the CLI distribution and the
|
|
182
|
-
controller container. CLI keeps `cli` + `opal`; controller keeps the HTTP
|
|
183
|
-
layer + reconcile loop.
|
|
184
|
-
|
|
185
|
-
**Option B — controller as sole writer.**
|
|
186
|
-
CLI never writes indexes/manifests directly; after upload it issues
|
|
187
|
-
`POST /reconcile/<dataset-id>` and waits. Single source of truth, but the
|
|
188
|
-
CLI now hard-requires the controller stack — that breaks the use-case of
|
|
189
|
-
publishing into a vanilla S3 bucket without the `dsimaging-store` services
|
|
190
|
-
deployed.
|
|
191
|
-
|
|
192
|
-
Recommendation: **A**. Keep `publish` self-sufficient.
|
|
193
|
-
|
|
194
|
-
---
|
|
195
|
-
|
|
196
|
-
## Priority 5 — nice-to-haves
|
|
197
|
-
|
|
198
|
-
- `download <dataset-id> <dest>` — pull a dataset locally for inspection
|
|
199
|
-
/ debugging.
|
|
200
|
-
- `copy <src-id> <dst-id>` — server-side intra-bucket copy for renames or
|
|
201
|
-
forks.
|
|
202
|
-
- Multi-profile config: `~/.dsimaging.yaml` with `profiles.<name>:` and a
|
|
203
|
-
`--profile <name>` flag (today only `default` is honoured).
|
|
204
|
-
- `--output json` on `list`, `verify`, `status`, `doctor` for scripting.
|
|
205
|
-
- Structured logging (`--log-format json`).
|
|
206
|
-
- Pre-publish DICOM sanity checks (consistent SeriesInstanceUID per folder,
|
|
207
|
-
monotonic InstanceNumber, single Modality value).
|
|
208
|
-
|
|
209
|
-
---
|
|
210
|
-
|
|
211
|
-
## Non-goals (explicit)
|
|
212
|
-
|
|
213
|
-
- Web UI. The MinIO console plus the CLI is enough for operator needs.
|
|
214
|
-
- Authentication / RBAC inside the CLI. Inherited from S3 and Opal endpoints.
|
|
215
|
-
- Image-level operations (DICOM de-identification, resampling, format
|
|
216
|
-
conversion). That is `dsImaging` / `dsRadiomics` territory, not admin.
|
|
217
|
-
|
|
218
|
-
---
|
|
219
|
-
|
|
220
|
-
## Open questions
|
|
221
|
-
|
|
222
|
-
- Should `delete` cascade to `dsHPC` artifacts that reference the dataset?
|
|
223
|
-
No back-reference index exists today; probably out of scope for the admin
|
|
224
|
-
CLI.
|
|
225
|
-
- Should `verify` write a tombstone (`indexes/last_verify.json`) on detected
|
|
226
|
-
drift so the controller can refuse to republish stale manifests?
|
|
227
|
-
- Minimal `release.yml` for PyPI Trusted Publishing from
|
|
228
|
-
`isglobal-brge/dsimaging-admin`? Tracked separately; only blocks future
|
|
229
|
-
cuts.
|
|
230
|
-
|
|
231
|
-
---
|
|
232
|
-
|
|
233
|
-
## Sequencing suggestion
|
|
234
|
-
|
|
235
|
-
**One full day of work**:
|
|
236
|
-
|
|
237
|
-
1. Extract shared core (**P4 Option A**). Pays for everything below.
|
|
238
|
-
2. `delete` + `opal *` standalone (**P1.1** + **P1.3**). Both small, both
|
|
239
|
-
unblock real operations.
|
|
240
|
-
3. `verify` (**P1.2**). Turns the hash index from a hopeful artifact into a
|
|
241
|
-
real safety net.
|
|
242
|
-
|
|
243
|
-
**An afternoon**: skip P4, do P1.1 + P1.3 only. Defer the shared-core
|
|
244
|
-
refactor until the next time the manifest schema 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
|