datatrail-cli 1.0.0__tar.gz → 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datatrail_cli-1.0.1/.release-please-manifest.json +3 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/CHANGELOG.md +13 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/PKG-INFO +1 -1
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/pull.py +20 -2
- datatrail_cli-1.0.1/dtcli/scout.py +323 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/src/functions.py +66 -3
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/pyproject.toml +1 -1
- datatrail_cli-1.0.1/tests/test_canfar_permissions.py +206 -0
- datatrail_cli-1.0.1/tests/test_pull_size.py +164 -0
- datatrail_cli-1.0.1/tests/test_scout.py +419 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/uv.lock +1 -1
- datatrail_cli-1.0.0/.release-please-manifest.json +0 -3
- datatrail_cli-1.0.0/dtcli/scout.py +0 -233
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/codeql.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/continuous-deployment.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/continuous-integration.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/docs.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/stale.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.gitignore +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.pre-commit-config.yaml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/LICENSE +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/README.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/clear.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/cli.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/commands.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/images/Datatrail-logo.png +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/index.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/initialising.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/install.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/inventory.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/list.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/ps.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/pull-manifest.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/pull.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/scout.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/unregistered.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/user_guide.md +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/__init__.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/clear.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/cli.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/config.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/config.yaml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/doctor.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/inventory.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/locking.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/ls.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/ps.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/pull_manifest.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/unregistered.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/cadcclient.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/results.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/utilities.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/verify.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/mkdocs.yml +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/release-please-config.json +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/conftest.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_cadc_summary.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_cli.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_config.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_doctor.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_functions.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_inventory.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_locking.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_pull_manifest.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_pull_reliability.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_utils.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_verify.py +0 -0
- {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tox.ini +0 -0
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.1](https://github.com/CHIMEFRB/datatrail-cli/compare/datatrail-cli-v1.0.0...datatrail-cli-v1.0.1) (2026-09-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **pull:** continue when optional size query is unavailable ([#195](https://github.com/CHIMEFRB/datatrail-cli/issues/195)) ([b36bbbf](https://github.com/CHIMEFRB/datatrail-cli/commit/b36bbbf90d47eb22f20eaa7a12cfea8f4ae703bb))
|
|
9
|
+
* **scout:** restore discrepancy detection and confirmed repairs ([#197](https://github.com/CHIMEFRB/datatrail-cli/issues/197)) ([54dfbf4](https://github.com/CHIMEFRB/datatrail-cli/commit/54dfbf4b3abf8ff0f7f9db13e5eb7ccdb28ccce1))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Performance Improvements
|
|
13
|
+
|
|
14
|
+
* **pull:** apply CANFAR permissions without subprocesses ([#157](https://github.com/CHIMEFRB/datatrail-cli/issues/157)) ([b8c2230](https://github.com/CHIMEFRB/datatrail-cli/commit/b8c22306432a6b24b435b5ddb421ac5cdcd9e1eb))
|
|
15
|
+
|
|
3
16
|
## [1.0.0](https://github.com/CHIMEFRB/datatrail-cli/compare/datatrail-cli-v0.13.0...datatrail-cli-v1.0.0) (2026-09-28)
|
|
4
17
|
|
|
5
18
|
|
|
@@ -4,7 +4,13 @@ import logging
|
|
|
4
4
|
from os import cpu_count, path
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
|
-
from
|
|
7
|
+
from cadcutils.exceptions import (
|
|
8
|
+
HttpException,
|
|
9
|
+
InternalServerException,
|
|
10
|
+
TransferException,
|
|
11
|
+
UnexpectedException,
|
|
12
|
+
)
|
|
13
|
+
from requests.exceptions import ConnectionError, SSLError, Timeout
|
|
8
14
|
from rich.console import Console
|
|
9
15
|
from rich.prompt import Confirm
|
|
10
16
|
|
|
@@ -149,6 +155,18 @@ Create one using 'cadc-get-cert -u <USERNAME>'.
|
|
|
149
155
|
"""
|
|
150
156
|
)
|
|
151
157
|
return None
|
|
158
|
+
except (ConnectionError, Timeout, HttpException) as error:
|
|
159
|
+
# CADC also uses the base HttpException for failed connections/retries.
|
|
160
|
+
# Preserve its authentication, certificate, and invalid-request errors.
|
|
161
|
+
if isinstance(error, HttpException) and type(error) not in (
|
|
162
|
+
HttpException,
|
|
163
|
+
InternalServerException,
|
|
164
|
+
TransferException,
|
|
165
|
+
UnexpectedException,
|
|
166
|
+
):
|
|
167
|
+
raise
|
|
168
|
+
logger.debug("Unable to query Luskan for download size", exc_info=True)
|
|
169
|
+
to_download_size = -1
|
|
152
170
|
elif not luskan_up:
|
|
153
171
|
to_download_size = -1
|
|
154
172
|
else:
|
|
@@ -161,7 +179,7 @@ Create one using 'cadc-get-cert -u <USERNAME>'.
|
|
|
161
179
|
f" - {len(files['missing'])} files can be downloaded from minoc.",
|
|
162
180
|
style="yellow",
|
|
163
181
|
)
|
|
164
|
-
if
|
|
182
|
+
if to_download_size >= 0:
|
|
165
183
|
console.print(
|
|
166
184
|
f" - Size to download: {to_download_size:.2f} GB.\n",
|
|
167
185
|
style="yellow",
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Datatrail Scout Command."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import requests
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.prompt import Confirm
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from dtcli.config import procure
|
|
15
|
+
from dtcli.ls import list as ls
|
|
16
|
+
from dtcli.utilities import cadcclient
|
|
17
|
+
from dtcli.utilities.utilities import (
|
|
18
|
+
REQUEST_TIMEOUT,
|
|
19
|
+
check_canfar_status,
|
|
20
|
+
set_log_level,
|
|
21
|
+
validate_scope,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("scout")
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
error_console = Console(stderr=True, style="bold red")
|
|
28
|
+
|
|
29
|
+
# Remote roots used by the Datatrail server's scout proxy. These are independent
|
|
30
|
+
# of the client's local root_mounts configuration.
|
|
31
|
+
SCOUT_ROOT_MOUNTS = {
|
|
32
|
+
"chime": "/",
|
|
33
|
+
"baseband_buffer": "/data/baseband_buffer/",
|
|
34
|
+
"kko": "/",
|
|
35
|
+
"gbo": "/",
|
|
36
|
+
"hco": "/",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@click.command(name="scout", help="Scout a dataset.")
|
|
41
|
+
@click.argument("scopes", required=False, type=click.STRING, nargs=-1)
|
|
42
|
+
@click.argument("dataset", required=True, type=click.STRING, nargs=1)
|
|
43
|
+
@click.option("-v", "--verbose", count=True, help="Verbosity: v=INFO, vv=DEBUG.")
|
|
44
|
+
@click.option("-q", "--quiet", is_flag=True, help="Set log level to ERROR.")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def scout( # noqa: C901
|
|
47
|
+
ctx: click.Context,
|
|
48
|
+
scopes: List[str],
|
|
49
|
+
dataset: str,
|
|
50
|
+
verbose: int,
|
|
51
|
+
quiet: bool,
|
|
52
|
+
):
|
|
53
|
+
"""Scout a dataset.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
ctx (click.Context): Click context.
|
|
57
|
+
scopes (List[str]): Scopes of dataset.
|
|
58
|
+
dataset (str): Name of dataset.
|
|
59
|
+
verbose (int): Verbosity: v=INFO, vv=DUBUG.
|
|
60
|
+
quiet (bool): Set log level to ERROR.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
None
|
|
64
|
+
"""
|
|
65
|
+
# Set logging level.
|
|
66
|
+
set_log_level(logger, verbose, quiet)
|
|
67
|
+
logger.debug("`scout` called with:")
|
|
68
|
+
logger.debug(f"scopes: {scopes} [{type(scopes)}]")
|
|
69
|
+
logger.debug(f"dataset: {dataset} [{type(dataset)}]")
|
|
70
|
+
logger.debug(f"verbose: {verbose} [{type(verbose)}]")
|
|
71
|
+
logger.debug(f"quiet: {quiet} [{type(quiet)}]")
|
|
72
|
+
|
|
73
|
+
# Check if scopes are valid.
|
|
74
|
+
if scopes:
|
|
75
|
+
logger.debug(f"Scopes limited to: {list(scopes)}")
|
|
76
|
+
try:
|
|
77
|
+
valid_scopes = all(validate_scope(scope) for scope in scopes)
|
|
78
|
+
except Exception:
|
|
79
|
+
error_console.print("Unable to validate scopes.")
|
|
80
|
+
ctx.exit(1)
|
|
81
|
+
if not valid_scopes:
|
|
82
|
+
error_console.print("A scope is invalid.")
|
|
83
|
+
console.print("Valid scopes are:")
|
|
84
|
+
ctx.invoke(ls)
|
|
85
|
+
ctx.exit(1)
|
|
86
|
+
|
|
87
|
+
# Load configuration.
|
|
88
|
+
try:
|
|
89
|
+
config = procure()
|
|
90
|
+
server = config["server"]
|
|
91
|
+
logger.debug("Configuration loaded successfully.")
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.error(
|
|
94
|
+
"No configuration file found. Create one with `datatrail config init`."
|
|
95
|
+
)
|
|
96
|
+
ctx.exit(1)
|
|
97
|
+
|
|
98
|
+
# Check Canfar status.
|
|
99
|
+
check_canfar_status(error_console)
|
|
100
|
+
|
|
101
|
+
# Scout dataset.
|
|
102
|
+
url = server.rstrip("/") + "/query/dataset/scout"
|
|
103
|
+
params: Dict[str, Any] = {"name": dataset}
|
|
104
|
+
if scopes:
|
|
105
|
+
params["scopes"] = scopes
|
|
106
|
+
logger.debug(f"URL: {url}")
|
|
107
|
+
try:
|
|
108
|
+
response = requests.get(url, params=params, timeout=REQUEST_TIMEOUT)
|
|
109
|
+
except requests.exceptions.Timeout:
|
|
110
|
+
error_console.print("Error: Datatrail server timed out.")
|
|
111
|
+
ctx.exit(1)
|
|
112
|
+
except requests.RequestException:
|
|
113
|
+
error_console.print("Error: Datatrail scout request failed.")
|
|
114
|
+
ctx.exit(1)
|
|
115
|
+
if not 200 <= response.status_code < 300:
|
|
116
|
+
error_console.print(
|
|
117
|
+
f"Error: Datatrail server returned HTTP {response.status_code}."
|
|
118
|
+
)
|
|
119
|
+
ctx.exit(1)
|
|
120
|
+
try:
|
|
121
|
+
data = response.json()
|
|
122
|
+
logger.debug(f"Data: {data}")
|
|
123
|
+
except ValueError:
|
|
124
|
+
error_console.print("Error: Datatrail server returned invalid JSON.")
|
|
125
|
+
ctx.exit(1)
|
|
126
|
+
if not _valid_scout_data(data):
|
|
127
|
+
error_console.print("Error: Datatrail returned no valid scout results.")
|
|
128
|
+
ctx.exit(1)
|
|
129
|
+
|
|
130
|
+
file_discrepancies: List[List] = []
|
|
131
|
+
failed = False
|
|
132
|
+
|
|
133
|
+
for scope in data:
|
|
134
|
+
basepath = data[scope]["basepath"].replace("'", "''")
|
|
135
|
+
query = f"select count(*) from inventory.Artifact where uri like 'cadc:CHIMEFRB/{basepath}%'" # noqa: E501
|
|
136
|
+
try:
|
|
137
|
+
count = int(cadcclient.query(query)[0][0])
|
|
138
|
+
if count < 0:
|
|
139
|
+
raise ValueError("Invalid file count.")
|
|
140
|
+
except Exception:
|
|
141
|
+
error_console.print(f"{scope} - Minoc count query failed.")
|
|
142
|
+
count = -1
|
|
143
|
+
failed = True
|
|
144
|
+
data[scope]["observed"]["minoc"] = count
|
|
145
|
+
storage_elements = dict.fromkeys(
|
|
146
|
+
[*data[scope]["observed"], *data[scope]["expected"]]
|
|
147
|
+
)
|
|
148
|
+
for se in storage_elements:
|
|
149
|
+
data[scope]["observed"].setdefault(se, 0)
|
|
150
|
+
data[scope]["expected"].setdefault(se, 0)
|
|
151
|
+
if data[scope]["observed"][se] > data[scope]["expected"][se]:
|
|
152
|
+
file_discrepancies.append([scope, se])
|
|
153
|
+
|
|
154
|
+
show_scout_results(dataset, data)
|
|
155
|
+
|
|
156
|
+
if file_discrepancies:
|
|
157
|
+
error_console.print("File discrepancies:")
|
|
158
|
+
for scope, se in file_discrepancies:
|
|
159
|
+
error_console.print(f" - {se}: {scope}")
|
|
160
|
+
if Confirm.ask("\nWould you like to attempt to heal this discrepancy?"):
|
|
161
|
+
if not _heal(server, dataset, scope, se, data[scope]):
|
|
162
|
+
failed = True
|
|
163
|
+
if failed:
|
|
164
|
+
ctx.exit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _valid_scout_data(data: Any) -> bool:
|
|
168
|
+
"""Require the report fields used to display and select repairs."""
|
|
169
|
+
if not isinstance(data, dict) or not data or "error" in data:
|
|
170
|
+
return False
|
|
171
|
+
for scope, info in data.items():
|
|
172
|
+
if not isinstance(scope, str) or not scope or not isinstance(info, dict):
|
|
173
|
+
return False
|
|
174
|
+
if not isinstance(info.get("basepath"), str) or not info["basepath"]:
|
|
175
|
+
return False
|
|
176
|
+
if not isinstance(info.get("filetype"), str):
|
|
177
|
+
return False
|
|
178
|
+
for field in ("observed", "expected"):
|
|
179
|
+
counts = info.get(field)
|
|
180
|
+
if not isinstance(counts, dict) or not all(
|
|
181
|
+
isinstance(site, str)
|
|
182
|
+
and site
|
|
183
|
+
and isinstance(count, int)
|
|
184
|
+
and not isinstance(count, bool)
|
|
185
|
+
and count >= (0 if field == "expected" else -1)
|
|
186
|
+
for site, count in counts.items()
|
|
187
|
+
):
|
|
188
|
+
return False
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validated_checksums(data: Any) -> Optional[Dict[str, str]]:
|
|
193
|
+
"""Accept filename-to-MD5 mappings and normalize the optional MD5 prefix."""
|
|
194
|
+
if not isinstance(data, dict) or not data or "error" in data:
|
|
195
|
+
return None
|
|
196
|
+
checksums = {}
|
|
197
|
+
for filename, value in data.items():
|
|
198
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
199
|
+
return None
|
|
200
|
+
if not isinstance(value, str):
|
|
201
|
+
return None
|
|
202
|
+
checksum = value.strip().lower()
|
|
203
|
+
if checksum.startswith("md5:"):
|
|
204
|
+
checksum = checksum[4:]
|
|
205
|
+
if re.fullmatch(r"[0-9a-f]{32}", checksum) is None:
|
|
206
|
+
return None
|
|
207
|
+
checksums[filename] = checksum
|
|
208
|
+
return checksums
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _site_checksums(
|
|
212
|
+
checksums: Dict[str, str], site: str, basepath: str
|
|
213
|
+
) -> Optional[Dict[str, str]]:
|
|
214
|
+
"""Convert scout filenames to the dataset-relative names used by sync."""
|
|
215
|
+
if site not in SCOUT_ROOT_MOUNTS:
|
|
216
|
+
return None
|
|
217
|
+
root = PurePosixPath(SCOUT_ROOT_MOUNTS[site])
|
|
218
|
+
base = PurePosixPath(basepath)
|
|
219
|
+
if base.is_absolute() or ".." in base.parts:
|
|
220
|
+
return None
|
|
221
|
+
normalized = {}
|
|
222
|
+
for filename, checksum in checksums.items():
|
|
223
|
+
path = PurePosixPath(filename)
|
|
224
|
+
if ".." in path.parts:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
relative = path.relative_to(root) if path.is_absolute() else path
|
|
228
|
+
suffix = relative.relative_to(base)
|
|
229
|
+
except ValueError:
|
|
230
|
+
return None
|
|
231
|
+
name = relative.as_posix()
|
|
232
|
+
if not suffix.parts or name in normalized:
|
|
233
|
+
return None
|
|
234
|
+
normalized[name] = checksum
|
|
235
|
+
return normalized
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _heal(server: str, dataset: str, scope: str, site: str, info: Dict) -> bool:
|
|
239
|
+
"""Fetch validated checksums and submit one explicitly confirmed repair."""
|
|
240
|
+
try:
|
|
241
|
+
if site == "minoc":
|
|
242
|
+
data = cadcclient.dataset_md5s(info["basepath"])
|
|
243
|
+
else:
|
|
244
|
+
response = requests.get(
|
|
245
|
+
server.rstrip("/") + "/query/dataset/scout/md5sums",
|
|
246
|
+
params={
|
|
247
|
+
"basepath": info["basepath"],
|
|
248
|
+
"site": site,
|
|
249
|
+
"filetype": info["filetype"],
|
|
250
|
+
},
|
|
251
|
+
timeout=REQUEST_TIMEOUT,
|
|
252
|
+
)
|
|
253
|
+
if not 200 <= response.status_code < 300:
|
|
254
|
+
raise ValueError("Checksum request failed.")
|
|
255
|
+
data = response.json()
|
|
256
|
+
except Exception:
|
|
257
|
+
error_console.print(f"{scope} ({site}) - Checksum query failed; skipping.")
|
|
258
|
+
return False
|
|
259
|
+
checksums = _validated_checksums(data)
|
|
260
|
+
if checksums is not None and site != "minoc":
|
|
261
|
+
checksums = _site_checksums(checksums, site, info["basepath"])
|
|
262
|
+
if checksums is None:
|
|
263
|
+
error_console.print(f"{scope} ({site}) - Invalid checksum response; skipping.")
|
|
264
|
+
return False
|
|
265
|
+
try:
|
|
266
|
+
response = requests.post(
|
|
267
|
+
server.rstrip("/") + "/commit/dataset/scout/sync",
|
|
268
|
+
params={"name": dataset, "scope": scope, "replicate_to": site},
|
|
269
|
+
json=checksums,
|
|
270
|
+
timeout=REQUEST_TIMEOUT,
|
|
271
|
+
)
|
|
272
|
+
except requests.RequestException:
|
|
273
|
+
error_console.print(f"{scope} ({site}) - Healing failed.")
|
|
274
|
+
return False
|
|
275
|
+
if not 200 <= response.status_code < 300:
|
|
276
|
+
error_console.print(f"{scope} ({site}) - Healing failed.")
|
|
277
|
+
return False
|
|
278
|
+
console.print(f"{scope} ({site}) - Healing successful.")
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def show_scout_results(dataset: str, data: dict):
|
|
283
|
+
"""Create and display a table with scout results.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
dataset: Name of dataset.
|
|
287
|
+
data: Data to display.
|
|
288
|
+
"""
|
|
289
|
+
# Display results.
|
|
290
|
+
scopes = list(data.keys())
|
|
291
|
+
storage_elements = dict.fromkeys(
|
|
292
|
+
site for info in data.values() for site in [*info["observed"], *info["expected"]]
|
|
293
|
+
)
|
|
294
|
+
table = Table(
|
|
295
|
+
title=f"Scout Results for {dataset}",
|
|
296
|
+
header_style="magenta",
|
|
297
|
+
title_style="bold magenta",
|
|
298
|
+
)
|
|
299
|
+
table.add_column("Scope", style="bold")
|
|
300
|
+
for se in storage_elements:
|
|
301
|
+
table.add_column(se, style="bold")
|
|
302
|
+
|
|
303
|
+
for scope in scopes:
|
|
304
|
+
# Observed
|
|
305
|
+
row = [scope]
|
|
306
|
+
for se in storage_elements:
|
|
307
|
+
row.append(str(data[scope]["observed"].get(se, 0)))
|
|
308
|
+
table.add_row(*row, style="blue")
|
|
309
|
+
|
|
310
|
+
# Expected
|
|
311
|
+
row = [scope]
|
|
312
|
+
for se in storage_elements:
|
|
313
|
+
row.append(str(data[scope]["expected"].get(se, 0)))
|
|
314
|
+
table.add_row(*row, style="yellow", end_section=True)
|
|
315
|
+
|
|
316
|
+
console.print(table)
|
|
317
|
+
console.print("Legend: [blue]Observed[/blue], [yellow]Expected[/yellow]")
|
|
318
|
+
console.print(
|
|
319
|
+
"NOTE: In the case where more files are expected at a site other than \
|
|
320
|
+
minoc, that this may be due to the file type filtering when querying each site. This \
|
|
321
|
+
is a known limitation of the current implementation.",
|
|
322
|
+
)
|
|
323
|
+
console.print()
|
|
@@ -4,7 +4,7 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import shutil
|
|
7
|
-
import
|
|
7
|
+
import stat
|
|
8
8
|
import time
|
|
9
9
|
from collections import Counter
|
|
10
10
|
from collections.abc import Sequence
|
|
@@ -461,6 +461,63 @@ def find_missing_dataset_files(
|
|
|
461
461
|
return {"missing": missing_files, "existing": existing_files}
|
|
462
462
|
|
|
463
463
|
|
|
464
|
+
def _apply_file_group_permissions(
|
|
465
|
+
filename: str, directory_fd: int, group_id: Optional[int]
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Update a regular file relative to its directory without following links."""
|
|
468
|
+
try:
|
|
469
|
+
metadata = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
|
|
470
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
471
|
+
return
|
|
472
|
+
if group_id is not None:
|
|
473
|
+
try:
|
|
474
|
+
os.chown(
|
|
475
|
+
filename, -1, group_id, dir_fd=directory_fd, follow_symlinks=False
|
|
476
|
+
)
|
|
477
|
+
except OSError as error:
|
|
478
|
+
logger.warning("Could not update CANFAR ownership: %s", error)
|
|
479
|
+
metadata = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
|
|
480
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
481
|
+
os.chmod(
|
|
482
|
+
filename,
|
|
483
|
+
stat.S_IMODE(metadata.st_mode) | stat.S_IWGRP,
|
|
484
|
+
dir_fd=directory_fd,
|
|
485
|
+
follow_symlinks=False,
|
|
486
|
+
)
|
|
487
|
+
except (OSError, ValueError, NotImplementedError) as error:
|
|
488
|
+
# If a symlink replaces the file, do not follow it. Some platforms
|
|
489
|
+
# reject no-follow chmod rather than applying it.
|
|
490
|
+
logger.warning("Could not update CANFAR permissions: %s", error)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _apply_chime_frb_rw_permissions(folder: str, group_id: Optional[int]) -> None:
|
|
494
|
+
"""Set group ownership and write permission without following symlinks."""
|
|
495
|
+
|
|
496
|
+
def report_error(error: OSError) -> None:
|
|
497
|
+
logger.warning("Could not update CANFAR permissions: %s", error)
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
# fwalk keeps directory access relative to open descriptors and skips
|
|
501
|
+
# symlinked directories, including a symlink passed as the root folder.
|
|
502
|
+
for _, _, filenames, directory_fd in os.fwalk(
|
|
503
|
+
folder, follow_symlinks=False, onerror=report_error
|
|
504
|
+
):
|
|
505
|
+
if group_id is not None:
|
|
506
|
+
try:
|
|
507
|
+
os.fchown(directory_fd, -1, group_id)
|
|
508
|
+
except OSError as error:
|
|
509
|
+
report_error(error)
|
|
510
|
+
try:
|
|
511
|
+
mode = stat.S_IMODE(os.fstat(directory_fd).st_mode)
|
|
512
|
+
os.fchmod(directory_fd, mode | stat.S_IWGRP)
|
|
513
|
+
except OSError as error:
|
|
514
|
+
report_error(error)
|
|
515
|
+
for filename in filenames:
|
|
516
|
+
_apply_file_group_permissions(filename, directory_fd, group_id)
|
|
517
|
+
except OSError as error:
|
|
518
|
+
report_error(error)
|
|
519
|
+
|
|
520
|
+
|
|
464
521
|
def get_files(
|
|
465
522
|
files: List[str],
|
|
466
523
|
site: str,
|
|
@@ -499,10 +556,16 @@ def get_files(
|
|
|
499
556
|
# make directory structure if it does not exist.
|
|
500
557
|
folders = {os.path.dirname(path) for path in destinations}
|
|
501
558
|
if site == "canfar":
|
|
559
|
+
import grp
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
group_id = grp.getgrnam("chime-frb-rw").gr_gid
|
|
563
|
+
except (KeyError, OSError) as error:
|
|
564
|
+
logger.warning("Could not resolve CANFAR group: %s", error)
|
|
565
|
+
group_id = None
|
|
502
566
|
for folder in folders:
|
|
503
567
|
os.makedirs(folder, exist_ok=True)
|
|
504
|
-
|
|
505
|
-
subprocess.run(["chmod", "-R", "g+w", folder])
|
|
568
|
+
_apply_chime_frb_rw_permissions(folder, group_id)
|
|
506
569
|
else:
|
|
507
570
|
for folder in folders:
|
|
508
571
|
os.makedirs(folder, exist_ok=True)
|