cloudrepo-migrate 0.1.0__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,3 @@
1
+ # The published sdist is the tool and its README. The tests need a Nexus 2 container and the
2
+ # repository's other files, so they stay in the repository.
3
+ prune test
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudrepo-migrate
3
+ Version: 0.1.0
4
+ Summary: Move a Nexus 2 server's Maven and npm repositories into CloudRepo, verifying every file.
5
+ Project-URL: Homepage, https://www.cloudrepo.io
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+
9
+ # cloudrepo-migrate (early access)
10
+
11
+ Moves a Nexus 2 server's repositories into CloudRepo and verifies every file.
12
+
13
+ - **Maven**: every file of each hosted Maven repository, POMs and checksum files included.
14
+ - **npm**: the latest dist-tag of each package in each hosted npm repository (older versions are counted in the report, not moved).
15
+ - **Skipped with a reason**: proxy repositories (create a CloudRepo proxy instead), group repositories (create a CloudRepo group of the moved ones), virtual repositories, other formats, and files Nexus generates itself (`.index`, `.meta`, the root `archetype-catalog.xml`).
16
+
17
+ Every file is read back from CloudRepo after upload and its sha1 and sha256 compared with the source's bytes. A checksum file the source carries (`X.sha1`, `X.sha256`, `X.sha512`, `X.md5`) must name the digest of `X`. Any mismatch fails the run and names the file.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ pipx install cloudrepo-migrate
23
+ ```
24
+
25
+ That installs it from pypi.org and puts `cloudrepo-migrate` on the PATH (`pip install cloudrepo-migrate` in a virtual environment works too).
26
+
27
+ It is one file, Python 3.8 or later, with no dependencies, so copying `cloudrepo_migrate.py` to a machine that can reach the Nexus server and running `python3 cloudrepo_migrate.py --help` works too.
28
+
29
+ ## Run
30
+
31
+ Create each target repository in CloudRepo first (same name as the Nexus repository, or map it with `--repo SOURCE=TARGET`) at https://admin.cloudrepo.io/repositories/create, and a repository token that can write them.
32
+
33
+ ```sh
34
+ export NEXUS_USERNAME=deployer NEXUS_PASSWORD=... # leave both unset for anonymous read
35
+ export CLOUDREPO_TOKEN=...
36
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --dry-run
37
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --report-json report.json
38
+ ```
39
+
40
+ | Flag | Meaning |
41
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
42
+ | `--nexus-url URL` | The Nexus 2 base URL, ending in `/nexus` on a default install. |
43
+ | `--org ORG` or `--cloudrepo-url URL` | Your CloudRepo organization, or its full base URL (https). |
44
+ | `--repo SOURCE[=TARGET]` | Move only these repositories (repeatable). Default: every hosted Maven and npm repository. |
45
+ | `--dry-run` | List what would move. Uploads nothing. Checks the token and targets when `CLOUDREPO_TOKEN` is set. |
46
+ | `--report-json PATH` | Also write the report as JSON. |
47
+ | `--jobs N` | Parallel Maven uploads (default 4). |
48
+ | `--work-dir DIR` | Where each file is spooled while it moves (default `TMPDIR`). |
49
+ | `--nexus-ca-file PEM` | CA bundle for a Nexus with a private certificate. |
50
+
51
+ With the environment set, nothing prompts. On a terminal with a credential missing it asks for it.
52
+
53
+ **Resume**: run the same command again. A file CloudRepo already holds with the same bytes is counted as already present and not uploaded again; a `409` on a release CloudRepo already holds counts as moved once its bytes match.
54
+
55
+ **Exit codes**: `0` everything moved and verified; `1` at least one file failed (every failure is listed with its path; re-run to resume); `2` a usage or preflight problem, such as a missing target repository or a refused token (nothing moved).
56
+
57
+ ## Credentials
58
+
59
+ - Read only from the environment or a terminal prompt: never from the command line, never printed, never written to the report.
60
+ - The Nexus login is sent only to `--nexus-url`, the CloudRepo token only to CloudRepo.
61
+ - Redirects are never followed, and no URL is taken from a server's answer (a packument's `dist.tarball` contributes only its file name), so neither credential can be steered to another host.
62
+ - CloudRepo must be https (plain http only to localhost). A plain-http Nexus gets a warning.
@@ -0,0 +1,54 @@
1
+ # cloudrepo-migrate (early access)
2
+
3
+ Moves a Nexus 2 server's repositories into CloudRepo and verifies every file.
4
+
5
+ - **Maven**: every file of each hosted Maven repository, POMs and checksum files included.
6
+ - **npm**: the latest dist-tag of each package in each hosted npm repository (older versions are counted in the report, not moved).
7
+ - **Skipped with a reason**: proxy repositories (create a CloudRepo proxy instead), group repositories (create a CloudRepo group of the moved ones), virtual repositories, other formats, and files Nexus generates itself (`.index`, `.meta`, the root `archetype-catalog.xml`).
8
+
9
+ Every file is read back from CloudRepo after upload and its sha1 and sha256 compared with the source's bytes. A checksum file the source carries (`X.sha1`, `X.sha256`, `X.sha512`, `X.md5`) must name the digest of `X`. Any mismatch fails the run and names the file.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pipx install cloudrepo-migrate
15
+ ```
16
+
17
+ That installs it from pypi.org and puts `cloudrepo-migrate` on the PATH (`pip install cloudrepo-migrate` in a virtual environment works too).
18
+
19
+ It is one file, Python 3.8 or later, with no dependencies, so copying `cloudrepo_migrate.py` to a machine that can reach the Nexus server and running `python3 cloudrepo_migrate.py --help` works too.
20
+
21
+ ## Run
22
+
23
+ Create each target repository in CloudRepo first (same name as the Nexus repository, or map it with `--repo SOURCE=TARGET`) at https://admin.cloudrepo.io/repositories/create, and a repository token that can write them.
24
+
25
+ ```sh
26
+ export NEXUS_USERNAME=deployer NEXUS_PASSWORD=... # leave both unset for anonymous read
27
+ export CLOUDREPO_TOKEN=...
28
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --dry-run
29
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --report-json report.json
30
+ ```
31
+
32
+ | Flag | Meaning |
33
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
34
+ | `--nexus-url URL` | The Nexus 2 base URL, ending in `/nexus` on a default install. |
35
+ | `--org ORG` or `--cloudrepo-url URL` | Your CloudRepo organization, or its full base URL (https). |
36
+ | `--repo SOURCE[=TARGET]` | Move only these repositories (repeatable). Default: every hosted Maven and npm repository. |
37
+ | `--dry-run` | List what would move. Uploads nothing. Checks the token and targets when `CLOUDREPO_TOKEN` is set. |
38
+ | `--report-json PATH` | Also write the report as JSON. |
39
+ | `--jobs N` | Parallel Maven uploads (default 4). |
40
+ | `--work-dir DIR` | Where each file is spooled while it moves (default `TMPDIR`). |
41
+ | `--nexus-ca-file PEM` | CA bundle for a Nexus with a private certificate. |
42
+
43
+ With the environment set, nothing prompts. On a terminal with a credential missing it asks for it.
44
+
45
+ **Resume**: run the same command again. A file CloudRepo already holds with the same bytes is counted as already present and not uploaded again; a `409` on a release CloudRepo already holds counts as moved once its bytes match.
46
+
47
+ **Exit codes**: `0` everything moved and verified; `1` at least one file failed (every failure is listed with its path; re-run to resume); `2` a usage or preflight problem, such as a missing target repository or a refused token (nothing moved).
48
+
49
+ ## Credentials
50
+
51
+ - Read only from the environment or a terminal prompt: never from the command line, never printed, never written to the report.
52
+ - The Nexus login is sent only to `--nexus-url`, the CloudRepo token only to CloudRepo.
53
+ - Redirects are never followed, and no URL is taken from a server's answer (a packument's `dist.tarball` contributes only its file name), so neither credential can be steered to another host.
54
+ - CloudRepo must be https (plain http only to localhost). A plain-http Nexus gets a warning.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudrepo-migrate
3
+ Version: 0.1.0
4
+ Summary: Move a Nexus 2 server's Maven and npm repositories into CloudRepo, verifying every file.
5
+ Project-URL: Homepage, https://www.cloudrepo.io
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+
9
+ # cloudrepo-migrate (early access)
10
+
11
+ Moves a Nexus 2 server's repositories into CloudRepo and verifies every file.
12
+
13
+ - **Maven**: every file of each hosted Maven repository, POMs and checksum files included.
14
+ - **npm**: the latest dist-tag of each package in each hosted npm repository (older versions are counted in the report, not moved).
15
+ - **Skipped with a reason**: proxy repositories (create a CloudRepo proxy instead), group repositories (create a CloudRepo group of the moved ones), virtual repositories, other formats, and files Nexus generates itself (`.index`, `.meta`, the root `archetype-catalog.xml`).
16
+
17
+ Every file is read back from CloudRepo after upload and its sha1 and sha256 compared with the source's bytes. A checksum file the source carries (`X.sha1`, `X.sha256`, `X.sha512`, `X.md5`) must name the digest of `X`. Any mismatch fails the run and names the file.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ pipx install cloudrepo-migrate
23
+ ```
24
+
25
+ That installs it from pypi.org and puts `cloudrepo-migrate` on the PATH (`pip install cloudrepo-migrate` in a virtual environment works too).
26
+
27
+ It is one file, Python 3.8 or later, with no dependencies, so copying `cloudrepo_migrate.py` to a machine that can reach the Nexus server and running `python3 cloudrepo_migrate.py --help` works too.
28
+
29
+ ## Run
30
+
31
+ Create each target repository in CloudRepo first (same name as the Nexus repository, or map it with `--repo SOURCE=TARGET`) at https://admin.cloudrepo.io/repositories/create, and a repository token that can write them.
32
+
33
+ ```sh
34
+ export NEXUS_USERNAME=deployer NEXUS_PASSWORD=... # leave both unset for anonymous read
35
+ export CLOUDREPO_TOKEN=...
36
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --dry-run
37
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --report-json report.json
38
+ ```
39
+
40
+ | Flag | Meaning |
41
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
42
+ | `--nexus-url URL` | The Nexus 2 base URL, ending in `/nexus` on a default install. |
43
+ | `--org ORG` or `--cloudrepo-url URL` | Your CloudRepo organization, or its full base URL (https). |
44
+ | `--repo SOURCE[=TARGET]` | Move only these repositories (repeatable). Default: every hosted Maven and npm repository. |
45
+ | `--dry-run` | List what would move. Uploads nothing. Checks the token and targets when `CLOUDREPO_TOKEN` is set. |
46
+ | `--report-json PATH` | Also write the report as JSON. |
47
+ | `--jobs N` | Parallel Maven uploads (default 4). |
48
+ | `--work-dir DIR` | Where each file is spooled while it moves (default `TMPDIR`). |
49
+ | `--nexus-ca-file PEM` | CA bundle for a Nexus with a private certificate. |
50
+
51
+ With the environment set, nothing prompts. On a terminal with a credential missing it asks for it.
52
+
53
+ **Resume**: run the same command again. A file CloudRepo already holds with the same bytes is counted as already present and not uploaded again; a `409` on a release CloudRepo already holds counts as moved once its bytes match.
54
+
55
+ **Exit codes**: `0` everything moved and verified; `1` at least one file failed (every failure is listed with its path; re-run to resume); `2` a usage or preflight problem, such as a missing target repository or a refused token (nothing moved).
56
+
57
+ ## Credentials
58
+
59
+ - Read only from the environment or a terminal prompt: never from the command line, never printed, never written to the report.
60
+ - The Nexus login is sent only to `--nexus-url`, the CloudRepo token only to CloudRepo.
61
+ - Redirects are never followed, and no URL is taken from a server's answer (a packument's `dist.tarball` contributes only its file name), so neither credential can be steered to another host.
62
+ - CloudRepo must be https (plain http only to localhost). A plain-http Nexus gets a warning.
@@ -0,0 +1,9 @@
1
+ MANIFEST.in
2
+ README.md
3
+ cloudrepo_migrate.py
4
+ pyproject.toml
5
+ cloudrepo_migrate.egg-info/PKG-INFO
6
+ cloudrepo_migrate.egg-info/SOURCES.txt
7
+ cloudrepo_migrate.egg-info/dependency_links.txt
8
+ cloudrepo_migrate.egg-info/entry_points.txt
9
+ cloudrepo_migrate.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cloudrepo-migrate = cloudrepo_migrate:main
@@ -0,0 +1 @@
1
+ cloudrepo_migrate
@@ -0,0 +1,729 @@
1
+ #!/usr/bin/env python3
2
+ """cloudrepo-migrate: move a Nexus 2 server's repositories into CloudRepo (early access).
3
+
4
+ v1 moves Nexus 2 hosted Maven repositories (every file, checksum files and POMs included)
5
+ and hosted npm repositories (the latest dist-tag of each package) into CloudRepo
6
+ repositories that already exist, and verifies every file after upload by reading it back
7
+ and comparing its sha1 and sha256 with the source's bytes. Proxy, group and virtual
8
+ repositories are listed and skipped with a reason. A re-run resumes: a file CloudRepo
9
+ already holds with the same bytes is counted, not uploaded again.
10
+
11
+ export NEXUS_USERNAME=... NEXUS_PASSWORD=... CLOUDREPO_TOKEN=...
12
+ cloudrepo-migrate nexus2 --nexus-url https://nexus.example.com/nexus --org acme --dry-run
13
+
14
+ Credentials come only from the environment (or a prompt on a terminal): never argv, never
15
+ printed, never in the report. The Nexus credential is sent only to --nexus-url and the
16
+ CloudRepo token only to CloudRepo. Redirects are never followed and no URL is taken from a
17
+ server's answer, so neither credential can be steered to another host.
18
+
19
+ Exit codes: 0 everything moved and verified; 1 at least one file failed (each is listed,
20
+ re-run to resume); 2 a usage or preflight problem (nothing moved).
21
+
22
+ Python 3.8 or later, standard library only.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import base64
28
+ import concurrent.futures
29
+ import getpass
30
+ import hashlib
31
+ import http.client
32
+ import json
33
+ import os
34
+ import re
35
+ import shutil
36
+ import ssl
37
+ import sys
38
+ import tempfile
39
+ import time
40
+ import urllib.parse
41
+
42
+ VERSION = "0.1.0"
43
+ CREATE_REPOSITORY_URL = "https://admin.cloudrepo.io/repositories/create"
44
+ RETRY_DELAYS = (1, 4, 10) # seconds between tries on a network error, a 5xx or a 429
45
+ TIMEOUT = 120
46
+ CHUNK = 1 << 20
47
+ SIDECAR_TEXT_LIMIT = 1024
48
+ REPO_NAME = re.compile(r"[a-z0-9_\-]{1,32}")
49
+ STORAGE_PATH = re.compile(r"[A-Za-z0-9+_\-.!*'()/@~]+")
50
+ NPM_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*")
51
+ NPM_VERSION = re.compile(r"[0-9A-Za-z][0-9A-Za-z.+_-]*")
52
+ TARBALL_FILE = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._-]*\.tgz")
53
+ ORG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
54
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
55
+ SIDECARS = {".sha1": "sha1", ".sha256": "sha256", ".sha512": "sha512", ".md5": "md5"}
56
+ COMPARED = ("sha1", "sha256")
57
+ NEXUS_GENERATED = "archetype-catalog.xml"
58
+
59
+
60
+ class Usage(Exception):
61
+ """A usage or preflight problem: nothing was moved (exit 2)."""
62
+
63
+
64
+ class Refused(Exception):
65
+ """One file or request could not be completed; the run continues past it."""
66
+
67
+
68
+ # --- HTTP: one origin, one credential ---------------------------------------------------
69
+
70
+
71
+ def retrying(attempt):
72
+ """Run attempt(), retrying a network error, a 5xx or a 429. attempt returns a tuple
73
+ whose first item is the HTTP status."""
74
+ for delay in tuple(RETRY_DELAYS) + (None,):
75
+ try:
76
+ result = attempt()
77
+ except (OSError, http.client.HTTPException) as e:
78
+ if delay is None:
79
+ raise Refused(f"network error: {e.__class__.__name__}: {e}")
80
+ time.sleep(delay)
81
+ continue
82
+ if delay is not None and (result[0] >= 500 or result[0] == 429):
83
+ time.sleep(delay)
84
+ continue
85
+ return result
86
+ raise AssertionError("unreachable")
87
+
88
+
89
+ class Endpoint:
90
+ """One origin and the only credential ever sent to it.
91
+
92
+ Built on http.client, which has no redirect machinery: a 3xx is an error naming its
93
+ Location, so a credential can never follow a redirect to another host."""
94
+
95
+ def __init__(self, base_url, authorization=None, ca_file=None):
96
+ parts = urllib.parse.urlsplit(base_url)
97
+ if parts.scheme not in ("http", "https") or not parts.hostname:
98
+ raise Usage(f"not an http or https URL: {base_url}")
99
+ if parts.username or parts.password or parts.query or parts.fragment:
100
+ raise Usage(f"give the URL without a login, query or fragment: {parts.scheme}://{parts.hostname}")
101
+ self.https = parts.scheme == "https"
102
+ self.host = parts.hostname
103
+ self.port = parts.port
104
+ self.prefix = parts.path.rstrip("/")
105
+ self.base = f"{parts.scheme}://{parts.netloc}{self.prefix}"
106
+ self._authorization = authorization
107
+ self._context = ssl.create_default_context(cafile=ca_file) if self.https else None
108
+
109
+ def url(self, path):
110
+ return f"{self.base}/{path}"
111
+
112
+ def open(self, method, path, body=None, headers=None):
113
+ """Send one request to base/path (path relative and already encoded); returns
114
+ (connection, response) for the caller to read and close."""
115
+ if self.https:
116
+ conn = http.client.HTTPSConnection(self.host, self.port, timeout=TIMEOUT, context=self._context)
117
+ else:
118
+ conn = http.client.HTTPConnection(self.host, self.port, timeout=TIMEOUT)
119
+ sent = {"User-Agent": f"cloudrepo-migrate/{VERSION}", "Accept": "*/*"}
120
+ sent.update(headers or {})
121
+ if self._authorization:
122
+ sent["Authorization"] = self._authorization
123
+ try:
124
+ conn.request(method, f"{self.prefix}/{path}", body=body, headers=sent)
125
+ resp = conn.getresponse()
126
+ except BaseException:
127
+ conn.close()
128
+ raise
129
+ if 300 <= resp.status < 400:
130
+ location = resp.getheader("Location") or "(none)"
131
+ conn.close()
132
+ raise Refused(f"{method} {self.url(path)} answered {resp.status}, a redirect to {location}; "
133
+ "redirects are never followed, so a credential cannot leave its server")
134
+ return conn, resp
135
+
136
+ def call(self, method, path, body=None, headers=None):
137
+ """One request with retries; returns (status, lower-cased headers, body bytes)."""
138
+ def once():
139
+ conn, resp = self.open(method, path, body, headers)
140
+ try:
141
+ return resp.status, {k.lower(): v for k, v in resp.getheaders()}, resp.read()
142
+ finally:
143
+ conn.close()
144
+ return retrying(once)
145
+
146
+ def fetch(self, path, spool_dir=None):
147
+ """GET path, hashing as it streams, into a spool file when spool_dir is given.
148
+ Returns (status, spool path or None, digests or None, size)."""
149
+ def once():
150
+ conn, resp = self.open("GET", path)
151
+ try:
152
+ if resp.status != 200:
153
+ resp.read()
154
+ return resp.status, None, None, 0
155
+ hashes = new_hashes()
156
+ fd, spool = tempfile.mkstemp(dir=spool_dir) if spool_dir else (None, None)
157
+ size = 0
158
+ with (os.fdopen(fd, "wb") if fd is not None else open(os.devnull, "wb")) as out:
159
+ while True:
160
+ chunk = resp.read(CHUNK)
161
+ if not chunk:
162
+ break
163
+ out.write(chunk)
164
+ size += len(chunk)
165
+ for h in hashes.values():
166
+ h.update(chunk)
167
+ return 200, spool, hexes(hashes), size
168
+ finally:
169
+ conn.close()
170
+ return retrying(once)
171
+
172
+ def put_file(self, path, spool, size):
173
+ """PUT a spool file with an explicit length; returns the status."""
174
+ def once():
175
+ with open(spool, "rb") as body:
176
+ conn, resp = self.open("PUT", path, body, {"Content-Type": "application/octet-stream",
177
+ "Content-Length": str(size)})
178
+ try:
179
+ resp.read()
180
+ return (resp.status,)
181
+ finally:
182
+ conn.close()
183
+ return retrying(once)[0]
184
+
185
+
186
+ def new_hashes():
187
+ hashes = {name: hashlib.new(name) for name in ("sha1", "sha256", "sha512")}
188
+ try:
189
+ hashes["md5"] = hashlib.md5()
190
+ except ValueError: # a FIPS host: .md5 checksum files are then not checked
191
+ pass
192
+ return hashes
193
+
194
+
195
+ def hexes(hashes):
196
+ return {name: h.hexdigest() for name, h in hashes.items()}
197
+
198
+
199
+ def digests_of(data):
200
+ hashes = new_hashes()
201
+ for h in hashes.values():
202
+ h.update(data)
203
+ return hexes(hashes)
204
+
205
+
206
+ def same_bytes(a, b):
207
+ return bool(a) and bool(b) and all(a[k] == b[k] for k in COMPARED)
208
+
209
+
210
+ def qpath(path):
211
+ return urllib.parse.quote(path, safe="/")
212
+
213
+
214
+ def npm_doc_path(name):
215
+ """A validated npm name as one path segment: @scope%2fname."""
216
+ return name.replace("/", "%2f")
217
+
218
+
219
+ # --- Nexus 2 ----------------------------------------------------------------------------
220
+
221
+
222
+ class Nexus2:
223
+ """Reads a Nexus 2 server through its REST API and content URLs."""
224
+
225
+ def __init__(self, endpoint):
226
+ self.ep = endpoint
227
+
228
+ def json(self, path):
229
+ status, _, body = self.ep.call("GET", path, headers={"Accept": "application/json"})
230
+ if status == 401:
231
+ raise Refused("Nexus refused the login (set NEXUS_USERNAME and NEXUS_PASSWORD)")
232
+ if status != 200:
233
+ raise Refused(f"Nexus answered {status} for {self.ep.url(path)}")
234
+ try:
235
+ return json.loads(body)
236
+ except ValueError:
237
+ raise Refused(f"Nexus answered something other than JSON for {self.ep.url(path)}")
238
+
239
+ def repositories(self):
240
+ repos = [dict(r, kind=r.get("repoType", "")) for r in self.json("service/local/repositories").get("data", [])]
241
+ groups = [dict(g, kind="group") for g in self.json("service/local/repo_groups").get("data", [])]
242
+ return repos + groups
243
+
244
+ def maven_files(self, repo_id):
245
+ """[(path, size)] of every file in a hosted Maven repository. What Nexus generates
246
+ itself is skipped, as it is not an artifact anyone deployed: its own trees (.index,
247
+ .meta, .nexus) and the root archetype-catalog.xml, which Nexus renders on each read
248
+ (measured on 2.15.2: its recorded sha1 did not match the bytes it served)."""
249
+ todo, seen, files = [""], set(), []
250
+ while todo:
251
+ directory = todo.pop()
252
+ if directory in seen:
253
+ continue
254
+ seen.add(directory)
255
+ listing = self.json(f"service/local/repositories/{qpath(repo_id)}/content/{qpath(directory)}")
256
+ for item in listing.get("data", []):
257
+ rel = str(item.get("relativePath", "")).strip("/")
258
+ segments = rel.split("/")
259
+ if not rel or rel == NEXUS_GENERATED or any(not s or s.startswith(".") for s in segments):
260
+ continue
261
+ if item.get("leaf"):
262
+ files.append((rel, int(item.get("sizeOnDisk") or 0)))
263
+ else:
264
+ todo.append(rel + "/")
265
+ return sorted(files)
266
+
267
+ def recorded_sha1(self, repo_id, path):
268
+ info = self.json(f"service/local/repositories/{qpath(repo_id)}/content/{qpath(path)}?describe=info")
269
+ return str((info.get("data") or {}).get("sha1Hash") or "").lower()
270
+
271
+ def content(self, repo_id, path):
272
+ return f"content/repositories/{qpath(repo_id)}/{qpath(path)}"
273
+
274
+ def npm_names(self, repo_id):
275
+ listing = self.json(f"content/repositories/{qpath(repo_id)}/-/all")
276
+ return sorted(k for k in listing if not k.startswith("_"))
277
+
278
+ def packument(self, repo_id, name):
279
+ return self.json(f"content/repositories/{qpath(repo_id)}/{npm_doc_path(name)}")
280
+
281
+
282
+ def nexus_tarball_path(repo_id, name, dist_tarball):
283
+ """The Nexus path of a package tarball, built from --nexus-url's repository, the
284
+ validated package name and only the file name at the end of dist.tarball. The host and
285
+ path in the packument are never requested."""
286
+ file = str(dist_tarball or "").rsplit("/", 1)[-1]
287
+ if not TARBALL_FILE.fullmatch(file):
288
+ raise Refused(f"the packument's dist.tarball does not end in a tarball file name: {file!r}")
289
+ return f"content/repositories/{qpath(repo_id)}/{name}/-/{file}"
290
+
291
+
292
+ def classify(repo):
293
+ """(format to move or None, reason it is skipped)."""
294
+ kind, fmt = repo.get("kind", ""), repo.get("format", "")
295
+ if kind == "group":
296
+ return None, "group repository: create a CloudRepo group of the moved repositories instead"
297
+ if kind == "proxy":
298
+ return None, f"proxy of {repo.get('remoteUri') or 'a remote'}: create a CloudRepo proxy repository instead"
299
+ if kind in ("virtual", "shadow"):
300
+ return None, "virtual repository: it stores nothing of its own"
301
+ if kind != "hosted":
302
+ return None, f"{kind or 'unknown'} repository: not supported"
303
+ if fmt == "maven2":
304
+ return "maven", ""
305
+ if fmt == "npm":
306
+ return "npm", ""
307
+ return None, f"{fmt or 'unknown'} format is not supported yet; v1 moves Maven and npm"
308
+
309
+
310
+ # --- CloudRepo --------------------------------------------------------------------------
311
+
312
+
313
+ def cloudrepo_base(args):
314
+ if args.org:
315
+ if not ORG.fullmatch(args.org):
316
+ raise Usage(f"--org must be a CloudRepo organization id (a-z, 0-9 and -): {args.org!r}")
317
+ return f"https://{args.org}.mycloudrepo.io"
318
+ parts = urllib.parse.urlsplit(args.cloudrepo_url)
319
+ if parts.scheme != "https" and parts.hostname not in LOCAL_HOSTS:
320
+ raise Usage("--cloudrepo-url must be https (plain http only to localhost), so the token is never sent in clear")
321
+ return args.cloudrepo_url
322
+
323
+
324
+ def check_token(cr):
325
+ try:
326
+ status, _, _ = cr.call("GET", "repositories/")
327
+ except Refused as e:
328
+ raise Usage(f"could not reach CloudRepo at {cr.base}: {e}")
329
+ if status != 200:
330
+ raise Usage(f"CloudRepo refused the token: GET {cr.url('repositories/')} answered {status}. "
331
+ "Check CLOUDREPO_TOKEN and that the token belongs to this organization.")
332
+
333
+
334
+ def _is_repository_not_found(body):
335
+ try:
336
+ error = json.loads(body).get("error", "")
337
+ except (ValueError, AttributeError):
338
+ return False
339
+ return "repository could not be found" in str(error)
340
+
341
+
342
+ def ensure_repository(cr, name, fmt):
343
+ """None when CloudRepo has repository `name` of format `fmt` and the token reaches it,
344
+ else the problem to print.
345
+
346
+ Slice A of #6777 (a scoped repository-create route) plugs in here: create the missing
347
+ repository instead of reporting it."""
348
+ if not REPO_NAME.fullmatch(name):
349
+ return (f"{name!r} is not a valid CloudRepo repository name (a-z, 0-9, _ and -, at most 32); "
350
+ f"choose one with --repo SOURCE=TARGET")
351
+ try:
352
+ status, _, body = cr.call("GET", f"repositories/{name}/-/ping")
353
+ except Refused as e:
354
+ return f"could not check CloudRepo repository {name}: {e}"
355
+ if status == 404 and _is_repository_not_found(body):
356
+ return f"CloudRepo has no repository {name}: create it as {_a(fmt)} repository at {CREATE_REPOSITORY_URL}"
357
+ if status == 403:
358
+ return f"the token cannot reach CloudRepo repository {name}: give it write access to {name}"
359
+ if status not in (200, 404):
360
+ return f"CloudRepo answered {status} checking repository {name}"
361
+ found = "npm" if status == 200 else "maven" # -/ping answers only on an npm repository
362
+ if found != fmt:
363
+ return f"CloudRepo repository {name} is not {_a(fmt)} repository: create one at {CREATE_REPOSITORY_URL}"
364
+ return None
365
+
366
+
367
+ def _a(fmt):
368
+ return "an npm" if fmt == "npm" else "a Maven"
369
+
370
+
371
+ # --- moving -----------------------------------------------------------------------------
372
+
373
+
374
+ def _result(repo, path, status, reason="", size=0, digests=None, text=None):
375
+ return {"repository": repo, "path": path, "status": status, "reason": reason, "bytes": size,
376
+ "digests": digests or {}, "text": text}
377
+
378
+
379
+ def is_metadata(path):
380
+ return path.rsplit("/", 1)[-1].startswith("maven-metadata")
381
+
382
+
383
+ def move_maven_file(nexus, cr, work, src, dst, path):
384
+ """Move one Maven file and read it back. Never raises: returns its result."""
385
+ try:
386
+ if not STORAGE_PATH.fullmatch(path) or len(path) > 1024:
387
+ raise Refused("CloudRepo cannot store this path (only A-Z a-z 0-9 + _ - . ! * ' ( ) / @ ~, "
388
+ "at most 1024 characters)")
389
+ recorded = nexus.recorded_sha1(src, path)
390
+ status, spool, digests, size = nexus.ep.fetch(nexus.content(src, path), work)
391
+ if status != 200:
392
+ raise Refused(f"Nexus answered {status} reading the file")
393
+ try:
394
+ if recorded and digests["sha1"] != recorded:
395
+ raise Refused(f"the bytes Nexus served (sha1 {digests['sha1']}) do not match the sha1 Nexus "
396
+ f"recorded for the file ({recorded}); not uploaded")
397
+ text = None
398
+ if any(path.endswith(ext) for ext in SIDECARS) and size <= SIDECAR_TEXT_LIMIT:
399
+ with open(spool, "rb") as f:
400
+ text = f.read().decode("ascii", "replace")
401
+ target = f"repositories/{dst}/{qpath(path)}"
402
+ head, headers, _ = cr.call("HEAD", target)
403
+ if head == 200 and headers.get("content-length") == str(size):
404
+ if same_bytes(cr.fetch(target)[2], digests):
405
+ return _result(src, path, "present", size=size, digests=digests, text=text)
406
+ put = cr.put_file(target, spool, size)
407
+ if put == 403:
408
+ raise Refused(f"the token cannot publish to CloudRepo repository {dst}")
409
+ if put not in (200, 201, 204, 409):
410
+ raise Refused(f"CloudRepo answered {put} to the upload")
411
+ back = cr.fetch(target)
412
+ if back[0] != 200:
413
+ raise Refused(f"CloudRepo answered {back[0]} reading the file back after the upload")
414
+ if not same_bytes(back[2], digests):
415
+ raise Refused("CloudRepo already holds different bytes for this path and refuses overwrites"
416
+ if put == 409 else "the bytes CloudRepo serves back differ from the source")
417
+ return _result(src, path, "present" if put == 409 else "moved", size=size, digests=digests, text=text)
418
+ finally:
419
+ os.unlink(spool)
420
+ except Refused as e:
421
+ return _result(src, path, "failed", str(e))
422
+
423
+
424
+ def move_maven_repository(nexus, cr, work, src, dst, jobs, files):
425
+ """Every ordinary file first, then the maven-metadata files, so a build reading the
426
+ metadata never sees a version whose files have not landed."""
427
+ results = []
428
+ with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
429
+ for phase in ([p for p, _ in files if not is_metadata(p)], [p for p, _ in files if is_metadata(p)]):
430
+ results.extend(pool.map(lambda p: move_maven_file(nexus, cr, work, src, dst, p), phase))
431
+ return results
432
+
433
+
434
+ def _sri(data):
435
+ return "sha512-" + base64.b64encode(hashlib.sha512(data).digest()).decode()
436
+
437
+
438
+ def _target_has(cr, doc_path, tarball_path, version, digests):
439
+ status, _, body = cr.call("GET", doc_path, headers={"Accept": "application/json"})
440
+ if status != 200:
441
+ return False
442
+ try:
443
+ shasum = json.loads(body)["versions"][version]["dist"]["shasum"]
444
+ except (ValueError, KeyError, TypeError):
445
+ return False
446
+ return shasum == digests["sha1"] and same_bytes(cr.fetch(tarball_path)[2], digests)
447
+
448
+
449
+ def move_npm_package(nexus, cr, src, dst, name):
450
+ """Move the latest dist-tag of one package and read it back. Never raises: returns
451
+ (result, number of older versions not moved)."""
452
+ older = 0
453
+ try:
454
+ if not NPM_NAME.fullmatch(name) or len(name) > 214:
455
+ raise Refused("not a package name CloudRepo accepts")
456
+ packument = nexus.packument(src, name)
457
+ versions = packument.get("versions") or {}
458
+ version = (packument.get("dist-tags") or {}).get("latest")
459
+ manifest = versions.get(version) if isinstance(version, str) else None
460
+ if not isinstance(manifest, dict) or not NPM_VERSION.fullmatch(version):
461
+ raise Refused("the package has no usable latest dist-tag")
462
+ older = len(versions) - 1
463
+ dist = manifest.get("dist") or {}
464
+ status, _, tgz = nexus.ep.call("GET", nexus_tarball_path(src, name, dist.get("tarball")))
465
+ if status != 200:
466
+ raise Refused(f"Nexus answered {status} reading the {version} tarball")
467
+ digests = digests_of(tgz)
468
+ if str(dist.get("shasum") or "").lower() != digests["sha1"]:
469
+ raise Refused(f"the {version} tarball does not match the packument's dist.shasum; not uploaded")
470
+ if dist.get("integrity", "").startswith("sha512-") and dist["integrity"] != _sri(tgz):
471
+ raise Refused(f"the {version} tarball does not match the packument's dist.integrity; not uploaded")
472
+ file = f"{name.rsplit('/', 1)[-1]}-{version}.tgz"
473
+ doc_path = f"repositories/{dst}/{npm_doc_path(name)}"
474
+ tarball_path = f"repositories/{dst}/{name}/-/{qpath(file)}"
475
+ path = f"{name}@{version}"
476
+ if _target_has(cr, doc_path, tarball_path, version, digests):
477
+ return _result(src, path, "present", size=len(tgz), digests=digests), older
478
+ stored = dict(manifest, dist=dict(dist, tarball=cr.url(tarball_path)))
479
+ doc = {"_id": name, "name": name, "description": manifest.get("description", ""),
480
+ "dist-tags": {"latest": version}, "versions": {version: stored},
481
+ "_attachments": {file: {"content_type": "application/octet-stream",
482
+ "data": base64.b64encode(tgz).decode(), "length": len(tgz)}}}
483
+ put, _, body = cr.call("PUT", doc_path, json.dumps(doc).encode(), {"Content-Type": "application/json"})
484
+ if put == 403:
485
+ raise Refused(f"the token cannot publish to CloudRepo repository {dst}")
486
+ if put == 400:
487
+ raise Refused(f"CloudRepo refused the package: {body[:200].decode('utf-8', 'replace')}")
488
+ if put not in (200, 201, 409):
489
+ raise Refused(f"CloudRepo answered {put} to the publish")
490
+ if not _target_has(cr, doc_path, tarball_path, version, digests):
491
+ raise Refused("CloudRepo already holds a different tarball for this version and refuses overwrites"
492
+ if put == 409 else "the tarball CloudRepo serves back differs from the source")
493
+ return _result(src, path, "present" if put == 409 else "moved", size=len(tgz), digests=digests), older
494
+ except Refused as e:
495
+ return _result(src, name, "failed", str(e)), older
496
+
497
+
498
+ def sidecar_failures(results):
499
+ """Pure: a moved checksum file (X.sha1, X.sha256, X.sha512, X.md5) must name the digest
500
+ of X's bytes. The bytes moved unchanged, so a disagreement is already in the source; it
501
+ is still a failure, naming X, so nobody trusts a checksum that is wrong."""
502
+ landed = {(r["repository"], r["path"]): r for r in results if r["status"] != "failed"}
503
+ failures = []
504
+ for (repo, path), r in sorted(landed.items()):
505
+ for ext, algorithm in SIDECARS.items():
506
+ if not path.endswith(ext) or r.get("text") is None:
507
+ continue
508
+ main = landed.get((repo, path[: -len(ext)]))
509
+ if not main or algorithm not in main["digests"]:
510
+ continue
511
+ claimed = (r["text"].split() or [""])[0].lower()
512
+ actual = main["digests"][algorithm]
513
+ if claimed != actual:
514
+ failures.append({"repository": repo, "path": main["path"],
515
+ "reason": f"its {ext[1:]} checksum file (where the source has it) says {claimed}, "
516
+ f"but the file's bytes hash to {actual}"})
517
+ return failures
518
+
519
+
520
+ # --- the run ----------------------------------------------------------------------------
521
+
522
+
523
+ def _secret(var, prompt, required):
524
+ value = os.environ.get(var)
525
+ if value:
526
+ return value
527
+ if required and sys.stdin.isatty():
528
+ return getpass.getpass(prompt)
529
+ return None
530
+
531
+
532
+ def parse_mapping(values):
533
+ mapping = {}
534
+ for value in values:
535
+ src, _, dst = value.partition("=")
536
+ if not src:
537
+ raise Usage(f"--repo wants SOURCE or SOURCE=TARGET: {value!r}")
538
+ mapping[src] = dst or src
539
+ return mapping
540
+
541
+
542
+ def _entry(repo, fmt, action, reason="", target=""):
543
+ return {"source": repo.get("id", ""), "target": target, "format": fmt or repo.get("format", ""),
544
+ "action": action, "reason": reason, "files": 0, "bytes": 0, "verified": 0,
545
+ "already_present": 0, "failed": 0}
546
+
547
+
548
+ def run_nexus2(args):
549
+ started = time.monotonic()
550
+ if args.jobs < 1 or args.jobs > 32:
551
+ raise Usage("--jobs must be between 1 and 32")
552
+ if urllib.parse.urlsplit(args.nexus_url).scheme == "http":
553
+ print("warning: --nexus-url is plain http, so the Nexus login crosses the network unencrypted",
554
+ file=sys.stderr)
555
+ base = cloudrepo_base(args)
556
+ user = os.environ.get("NEXUS_USERNAME")
557
+ nexus_auth = None
558
+ if user:
559
+ password = _secret("NEXUS_PASSWORD", "Nexus password: ", True) or ""
560
+ nexus_auth = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
561
+ nexus = Nexus2(Endpoint(args.nexus_url, nexus_auth, args.nexus_ca_file))
562
+ token = _secret("CLOUDREPO_TOKEN", "CloudRepo token: ", not args.dry_run)
563
+ if not token and not args.dry_run:
564
+ raise Usage("set CLOUDREPO_TOKEN to a CloudRepo repository token that can write the target repositories")
565
+ cr = Endpoint(base, f"Bearer {token}") if token else None
566
+ report = {"source": nexus.ep.base, "target": base, "dry_run": bool(args.dry_run), "repositories": [],
567
+ "failures": []}
568
+
569
+ try:
570
+ repos = nexus.repositories()
571
+ except Refused as e:
572
+ raise Usage(f"could not list the Nexus repositories: {e}")
573
+ mapping = parse_mapping(args.repo)
574
+ known = {r.get("id") for r in repos}
575
+ unknown = sorted(set(mapping) - known)
576
+ if unknown:
577
+ raise Usage(f"Nexus has no repository {', '.join(unknown)}")
578
+ plan = []
579
+ for repo in sorted(repos, key=lambda r: str(r.get("id"))):
580
+ if mapping and repo.get("id") not in mapping:
581
+ continue
582
+ fmt, reason = classify(repo)
583
+ if fmt is None:
584
+ report["repositories"].append(_entry(repo, None, "skipped", reason))
585
+ else:
586
+ plan.append((repo["id"], mapping.get(repo["id"], repo["id"]), fmt))
587
+
588
+ if cr:
589
+ check_token(cr)
590
+ problems = {src: ensure_repository(cr, dst, fmt) for src, dst, fmt in plan}
591
+ if any(problems.values()):
592
+ for src, dst, fmt in plan:
593
+ report["repositories"].append(_entry({"id": src}, fmt, "blocked" if problems[src] else "ready",
594
+ problems[src] or "", dst))
595
+ report["minutes"] = _minutes(started)
596
+ return 2, report
597
+
598
+ work = tempfile.mkdtemp(prefix="cloudrepo-migrate-", dir=args.work_dir) # mode 0700
599
+ results = []
600
+ try:
601
+ for src, dst, fmt in plan:
602
+ entry = _entry({"id": src}, fmt, "would move" if args.dry_run else "moved", target=dst)
603
+ report["repositories"].append(entry)
604
+ try:
605
+ if fmt == "maven":
606
+ files = nexus.maven_files(src)
607
+ if args.dry_run:
608
+ entry.update(files=len(files), bytes=sum(size for _, size in files))
609
+ continue
610
+ moved = move_maven_repository(nexus, cr, work, src, dst, args.jobs, files)
611
+ else:
612
+ names = nexus.npm_names(src)
613
+ if args.dry_run:
614
+ entry.update(files=len(names), reason="latest dist-tag of each package")
615
+ continue
616
+ moved, older = [], 0
617
+ for name in names:
618
+ result, skipped = move_npm_package(nexus, cr, src, dst, name)
619
+ moved.append(result)
620
+ older += skipped
621
+ if older:
622
+ entry["reason"] = (f"{older} older version{'s' if older != 1 else ''} not moved "
623
+ "(v1 moves the latest dist-tag only)")
624
+ except Refused as e:
625
+ entry["failed"] += 1
626
+ report["failures"].append({"repository": src, "path": "", "reason": f"could not list: {e}"})
627
+ continue
628
+ results.extend(moved)
629
+ for r in moved:
630
+ entry["files"] += 1
631
+ if r["status"] == "failed":
632
+ entry["failed"] += 1
633
+ report["failures"].append({"repository": src, "path": r["path"], "reason": r["reason"]})
634
+ else:
635
+ entry["bytes"] += r["bytes"]
636
+ entry["verified"] += 1
637
+ entry["already_present"] += r["status"] == "present"
638
+ finally:
639
+ shutil.rmtree(work, ignore_errors=True)
640
+
641
+ for failure in sidecar_failures(results):
642
+ report["failures"].append(failure)
643
+ next(e for e in report["repositories"] if e["source"] == failure["repository"])["failed"] += 1
644
+ report["minutes"] = _minutes(started)
645
+ return (1 if report["failures"] else 0), report
646
+
647
+
648
+ def _minutes(started):
649
+ return round((time.monotonic() - started) / 60, 1)
650
+
651
+
652
+ def totals(report):
653
+ moving = [e for e in report["repositories"] if e["action"] in ("moved", "would move")]
654
+ keys = ("files", "bytes", "verified", "already_present")
655
+ t = {k: sum(e[k] for e in moving) for k in keys}
656
+ t.update(repositories=len(moving), failed=len(report["failures"]))
657
+ return t
658
+
659
+
660
+ def render(report):
661
+ t = report["totals"]
662
+ lines = [f"cloudrepo-migrate nexus2: {report['source']} -> {report['target']}"
663
+ + (" (dry run, nothing uploaded)" if report["dry_run"] else "")]
664
+ for e in report["repositories"]:
665
+ where = f"{e['source']} -> {e['target']}" if e["target"] else e["source"]
666
+ note = f"; {e['reason']}" if e["reason"] else ""
667
+ if e["action"] == "moved":
668
+ lines.append(f" moved {where} ({e['format']}): {e['files']} files, {e['bytes']} bytes, "
669
+ f"{e['verified']} verified ({e['already_present']} already present), {e['failed']} failed{note}")
670
+ elif e["action"] == "would move":
671
+ lines.append(f" would move {where} ({e['format']}): {e['files']} files, {e['bytes']} bytes{note}")
672
+ else:
673
+ lines.append(f" {e['action']:<11} {where} ({e['format']}){': ' + e['reason'] if e['reason'] else ''}")
674
+ if report["failures"]:
675
+ lines.append("Failures (re-run the same command to resume):")
676
+ lines.extend(f" {f['repository']} {f['path']}: {f['reason']}" for f in report["failures"])
677
+ if any(e["action"] == "blocked" for e in report["repositories"]):
678
+ lines.append("Nothing moved: fix the blocked repositories above, then run again.")
679
+ elif report["dry_run"]:
680
+ lines.append(f"Would move {t['repositories']} repositories, {t['files']} files, {t['bytes']} bytes.")
681
+ else:
682
+ lines.append(f"Moved {t['repositories']} repositories, {t['files']} files, {t['bytes']} bytes in "
683
+ f"{report['minutes']} minutes; {t['verified']} of {t['files']} verified, {t['failed']} failed.")
684
+ return "\n".join(lines)
685
+
686
+
687
+ def parser():
688
+ p = argparse.ArgumentParser(
689
+ prog="cloudrepo-migrate",
690
+ description="Move repositories into CloudRepo, verifying every file. Credentials come from the "
691
+ "environment: NEXUS_USERNAME and NEXUS_PASSWORD (leave unset for anonymous), CLOUDREPO_TOKEN.")
692
+ p.add_argument("--version", action="version", version=f"cloudrepo-migrate {VERSION}")
693
+ sub = p.add_subparsers(dest="source", required=True)
694
+ n = sub.add_parser("nexus2", help="move a Nexus 2 server's hosted Maven and npm repositories")
695
+ n.add_argument("--nexus-url", required=True, help="the Nexus base URL, such as https://nexus.example.com/nexus")
696
+ target = n.add_mutually_exclusive_group(required=True)
697
+ target.add_argument("--org", help="your CloudRepo organization id")
698
+ target.add_argument("--cloudrepo-url", help="the CloudRepo base URL, instead of --org")
699
+ n.add_argument("--repo", action="append", default=[], metavar="SOURCE[=TARGET]",
700
+ help="move only this Nexus repository (repeatable); TARGET defaults to SOURCE")
701
+ n.add_argument("--dry-run", action="store_true", help="list what would move; upload nothing")
702
+ n.add_argument("--report-json", metavar="PATH", help="also write the report as JSON")
703
+ n.add_argument("--jobs", type=int, default=4, help="parallel Maven uploads (default 4)")
704
+ n.add_argument("--work-dir", metavar="DIR", help="where files are spooled while they move (default: TMPDIR)")
705
+ n.add_argument("--nexus-ca-file", metavar="PEM", help="CA bundle for a Nexus with a private certificate")
706
+ return p
707
+
708
+
709
+ def main(argv=None):
710
+ try:
711
+ args = parser().parse_args(argv)
712
+ except SystemExit as e:
713
+ return int(e.code or 0)
714
+ try:
715
+ code, report = run_nexus2(args)
716
+ except Usage as e:
717
+ print(f"cloudrepo-migrate: {e}", file=sys.stderr)
718
+ return 2
719
+ report["totals"] = totals(report)
720
+ print(render(report))
721
+ if args.report_json:
722
+ with open(args.report_json, "w", encoding="utf-8") as f:
723
+ json.dump(report, f, indent=2)
724
+ f.write("\n")
725
+ return code
726
+
727
+
728
+ if __name__ == "__main__":
729
+ sys.exit(main())
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cloudrepo-migrate"
7
+ # One version, VERSION in cloudrepo_migrate.py, which `--version` and the User-Agent also print.
8
+ # setuptools reads the literal without importing the module.
9
+ dynamic = ["version"]
10
+ description = "Move a Nexus 2 server's Maven and npm repositories into CloudRepo, verifying every file."
11
+ # The README is the project page on pypi.org.
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://www.cloudrepo.io"
18
+
19
+ [project.scripts]
20
+ cloudrepo-migrate = "cloudrepo_migrate:main"
21
+
22
+ [tool.setuptools]
23
+ py-modules = ["cloudrepo_migrate"]
24
+
25
+ [tool.setuptools.dynamic]
26
+ version = { attr = "cloudrepo_migrate.VERSION" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+