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.
@@ -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.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dsimaging-admin"
7
- version = "0.3.2"
7
+ version = "0.3.5"
8
8
  description = "Admin CLI for managing medical imaging datasets in S3/MinIO for DataSHIELD"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """dsimaging-admin: Admin CLI for managing medical imaging datasets in S3/MinIO."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.5"
@@ -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
- mc event add "local/${BUCKET_NAME}" arn:minio:sqs::DSIMAGING:webhook \
301
- --event put,delete \
302
- --prefix "datasets/" \
303
- 2>/dev/null || echo "[init] Webhook notification already configured or controller not ready"
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/lung"
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, "lung")
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/lung"
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, "lung")
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/lung"
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, "lung", quick=True)
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("lung1_site-a.v1")
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/lung"
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/lung"
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/lung/source/masks/case001_GTV-1.nii.gz",
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
- "lung",
119
+ "study_ct_v1",
120
120
  "imaging-data",
121
- "datasets/lung",
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/lung/indexes/masks_content_hash_index.parquet",
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/lung")
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/lung/source/images/a.nii.gz",
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