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.
- cloudrepo_migrate-0.1.0/MANIFEST.in +3 -0
- cloudrepo_migrate-0.1.0/PKG-INFO +62 -0
- cloudrepo_migrate-0.1.0/README.md +54 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.egg-info/PKG-INFO +62 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.egg-info/SOURCES.txt +9 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.egg-info/dependency_links.txt +1 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.egg-info/entry_points.txt +2 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.egg-info/top_level.txt +1 -0
- cloudrepo_migrate-0.1.0/cloudrepo_migrate.py +729 -0
- cloudrepo_migrate-0.1.0/pyproject.toml +26 -0
- cloudrepo_migrate-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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" }
|