alchemite-setup 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.
- alchemite_setup-0.1.0/PKG-INFO +15 -0
- alchemite_setup-0.1.0/pyproject.toml +109 -0
- alchemite_setup-0.1.0/setup.cfg +4 -0
- alchemite_setup-0.1.0/src/alchemite_setup/__init__.py +0 -0
- alchemite_setup-0.1.0/src/alchemite_setup/__main__.py +3 -0
- alchemite_setup-0.1.0/src/alchemite_setup/cli.py +151 -0
- alchemite_setup-0.1.0/src/alchemite_setup/exception.py +2 -0
- alchemite_setup-0.1.0/src/alchemite_setup/gensecrets.py +140 -0
- alchemite_setup-0.1.0/src/alchemite_setup/helm.py +598 -0
- alchemite_setup-0.1.0/src/alchemite_setup/images.py +681 -0
- alchemite_setup-0.1.0/src/alchemite_setup/install.py +74 -0
- alchemite_setup-0.1.0/src/alchemite_setup/manual_steps.py +374 -0
- alchemite_setup-0.1.0/src/alchemite_setup/versions.py +106 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/PKG-INFO +15 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/SOURCES.txt +22 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/dependency_links.txt +1 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/entry_points.txt +2 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/requires.txt +10 -0
- alchemite_setup-0.1.0/src/alchemite_setup.egg-info/top_level.txt +2 -0
- alchemite_setup-0.1.0/src/tests/conftest.py +92 -0
- alchemite_setup-0.1.0/src/tests/test_gensecrets.py +60 -0
- alchemite_setup-0.1.0/src/tests/test_helm.py +99 -0
- alchemite_setup-0.1.0/src/tests/test_images.py +409 -0
- alchemite_setup-0.1.0/src/tests/test_versions.py +73 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alchemite-setup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A script that configures and installs an Alchemite Cluster
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
7
|
+
Requires-Dist: bcrypt>=5.0.0
|
|
8
|
+
Requires-Dist: pyjwt>=2.10.1
|
|
9
|
+
Requires-Dist: requests>=2.32.5
|
|
10
|
+
Requires-Dist: parsy>=2.2
|
|
11
|
+
Requires-Dist: pyhelm3>=0.4.0
|
|
12
|
+
Requires-Dist: ruff>=0.14.9
|
|
13
|
+
Requires-Dist: docker>=7.1.0
|
|
14
|
+
Requires-Dist: tqdm>=4.67.1
|
|
15
|
+
Requires-Dist: python-keycloak>=7.0.2
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alchemite-setup"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A script that configures and installs an Alchemite Cluster"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pyyaml>=6.0.3",
|
|
8
|
+
"bcrypt>=5.0.0",
|
|
9
|
+
"pyjwt>=2.10.1",
|
|
10
|
+
"requests>=2.32.5",
|
|
11
|
+
"parsy>=2.2",
|
|
12
|
+
"pyhelm3>=0.4.0",
|
|
13
|
+
"ruff>=0.14.9",
|
|
14
|
+
"docker>=7.1.0",
|
|
15
|
+
"tqdm>=4.67.1",
|
|
16
|
+
"python-keycloak>=7.0.2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
alchemite-setup = "alchemite_setup.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.uv]
|
|
23
|
+
package = true
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 80
|
|
27
|
+
exclude = [
|
|
28
|
+
".direnv",
|
|
29
|
+
".eggs",
|
|
30
|
+
".git",
|
|
31
|
+
".hg",
|
|
32
|
+
".mypy_cache",
|
|
33
|
+
".nox",
|
|
34
|
+
".tox",
|
|
35
|
+
".venv",
|
|
36
|
+
"venv",
|
|
37
|
+
".svn",
|
|
38
|
+
"_build",
|
|
39
|
+
"buck-out",
|
|
40
|
+
"build",
|
|
41
|
+
"dist",
|
|
42
|
+
"packages/apiserver/alembic/versions",
|
|
43
|
+
"packages/apiserver/apiserver/models",
|
|
44
|
+
"packages/apiserver/apiserver/util.py",
|
|
45
|
+
"packages/apiserver/apiserver/typing_utils.py",
|
|
46
|
+
"typings",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
# Enable a comprehensive set of errors
|
|
51
|
+
# See https://docs.astral.sh/ruff/rules/ for details on what these are
|
|
52
|
+
select = ["E", "F", "W", "N", "I", "UP", "PLC", "PLE", "PLW", "PTH", "ANN", "RUF"]
|
|
53
|
+
ignore = ["E501", "RUF100", "PLC0414", "ANN002", "ANN003", "ANN401"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.per-file-ignores]
|
|
56
|
+
# Don't require full typing on tests
|
|
57
|
+
"*/tests/*" = ["ANN"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.format]
|
|
60
|
+
# Like Black, use double quotes for strings.
|
|
61
|
+
quote-style = "double"
|
|
62
|
+
|
|
63
|
+
# Like Black, indent with spaces, rather than tabs.
|
|
64
|
+
indent-style = "space"
|
|
65
|
+
|
|
66
|
+
# Like Black, respect magic trailing commas.
|
|
67
|
+
skip-magic-trailing-comma = false
|
|
68
|
+
|
|
69
|
+
# Like Black, automatically detect the appropriate line ending.
|
|
70
|
+
line-ending = "auto"
|
|
71
|
+
|
|
72
|
+
# Enable auto-formatting of code examples in docstrings. Markdown,
|
|
73
|
+
# reStructuredText code/literal blocks and doctests are all supported.
|
|
74
|
+
#
|
|
75
|
+
# This is currently disabled by default, but it is planned for this
|
|
76
|
+
# to be opt-out in the future.
|
|
77
|
+
docstring-code-format = true
|
|
78
|
+
|
|
79
|
+
# Set the line length limit used when formatting code snippets in
|
|
80
|
+
# docstrings.
|
|
81
|
+
#
|
|
82
|
+
# This only has an effect when the `docstring-code-format` setting is
|
|
83
|
+
# enabled.
|
|
84
|
+
docstring-code-line-length = "dynamic"
|
|
85
|
+
|
|
86
|
+
[dependency-groups]
|
|
87
|
+
dev = [
|
|
88
|
+
"pytest-asyncio>=1.3.0",
|
|
89
|
+
"pytest>=9.0.2",
|
|
90
|
+
"pyright>=1.1.408",
|
|
91
|
+
"ruff>=0.14.9",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.isort]
|
|
95
|
+
known-first-party = ["alchemite_setup"]
|
|
96
|
+
|
|
97
|
+
[tool.pytest.ini_options]
|
|
98
|
+
asyncio_mode = "auto"
|
|
99
|
+
|
|
100
|
+
[tool.pyright]
|
|
101
|
+
exclude = [
|
|
102
|
+
".git",
|
|
103
|
+
"**/node_modules",
|
|
104
|
+
"**/__pycache__",
|
|
105
|
+
"**/.eggs",
|
|
106
|
+
"**/build",
|
|
107
|
+
"**/setup.py",
|
|
108
|
+
"**/tests/**"
|
|
109
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from argparse import ArgumentParser
|
|
5
|
+
|
|
6
|
+
from alchemite_setup.exception import Abort
|
|
7
|
+
from alchemite_setup.install import deploy, prepare
|
|
8
|
+
from alchemite_setup.versions import VERSION_SANITIZERS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_cli() -> ArgumentParser:
|
|
12
|
+
parser = ArgumentParser(
|
|
13
|
+
prog="uvx alchemite_setup",
|
|
14
|
+
description="An installation script for self-hosted alchemite installations",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--config-path",
|
|
18
|
+
action="store",
|
|
19
|
+
default="./intellegens_config",
|
|
20
|
+
help="The directory used to store or load the deployment configuration",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--log-level",
|
|
24
|
+
action="store",
|
|
25
|
+
default="INFO",
|
|
26
|
+
help="The log level to use for this script",
|
|
27
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
31
|
+
# prepare arguments
|
|
32
|
+
prep_parser = subparsers.add_parser(
|
|
33
|
+
"prepare",
|
|
34
|
+
help="Prepare for deployment",
|
|
35
|
+
description="Prepare for new deployment, or update existing configuration for application update",
|
|
36
|
+
)
|
|
37
|
+
prep_parser.add_argument(
|
|
38
|
+
"--rehost-images",
|
|
39
|
+
action="store",
|
|
40
|
+
help="Instructs the script to rehost the images to an alternative repository",
|
|
41
|
+
metavar="repository_name",
|
|
42
|
+
)
|
|
43
|
+
prep_parser.add_argument(
|
|
44
|
+
"--rotate",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Rotate the config secret values. This may require additional steps to deploy successfully",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
prep_parser.add_argument(
|
|
50
|
+
"--oligo",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Enable oligonucleotide features in the cluster",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for i in VERSION_SANITIZERS.keys():
|
|
56
|
+
prep_parser.add_argument(
|
|
57
|
+
f"--{i}-version",
|
|
58
|
+
action="store",
|
|
59
|
+
default="latest",
|
|
60
|
+
help=f"Specify the version of {i} to install",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
prep_parser.add_argument(
|
|
64
|
+
"--pull-secret",
|
|
65
|
+
action="store",
|
|
66
|
+
help="The image pull secret to use when the cluster is starting pods",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
prep_parser.add_argument(
|
|
70
|
+
"--domain",
|
|
71
|
+
action="store",
|
|
72
|
+
help="The domain that the application will be hosted on",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# deploy arguments
|
|
76
|
+
dep_parser = subparsers.add_parser(
|
|
77
|
+
"deploy",
|
|
78
|
+
help="Deploy alchemite",
|
|
79
|
+
description="Use a prepared configuration to deploy a new version of alchemite",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
dep_parser.add_argument("--dry-run", action="store_true")
|
|
83
|
+
|
|
84
|
+
dep_parser.add_argument(
|
|
85
|
+
"--namespace",
|
|
86
|
+
action="store",
|
|
87
|
+
help="The namespace to deploy Alchemite application charts into",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
dep_parser.add_argument("--atomic", action="store_true")
|
|
91
|
+
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clean_domain(domain: str | None) -> str | None:
|
|
96
|
+
if domain is None:
|
|
97
|
+
return None
|
|
98
|
+
domain = domain.strip().strip("/")
|
|
99
|
+
if "://" in domain:
|
|
100
|
+
_, domain = domain.rsplit("/", 1)
|
|
101
|
+
return domain
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def amain() -> None:
|
|
105
|
+
parser = generate_cli()
|
|
106
|
+
args = parser.parse_args()
|
|
107
|
+
logging.basicConfig(
|
|
108
|
+
level=args.log_level, stream=sys.stderr, format="%(message)s"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Suppress helm command errors
|
|
112
|
+
logging.getLogger("pyhelm3.command").setLevel(logging.ERROR)
|
|
113
|
+
if args.log_level == "INFO":
|
|
114
|
+
# A few httpx logs are at info that I'd prefer be at DEBUG, so suppress
|
|
115
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
116
|
+
|
|
117
|
+
command = args.command
|
|
118
|
+
if command == "prepare":
|
|
119
|
+
versions = {
|
|
120
|
+
i: getattr(args, f"{i.replace('-', '_')}_version")
|
|
121
|
+
for i in VERSION_SANITIZERS.keys()
|
|
122
|
+
}
|
|
123
|
+
await prepare(
|
|
124
|
+
raw_config_path=args.config_path,
|
|
125
|
+
rotate_secrets=args.rotate,
|
|
126
|
+
enable_oligo=args.oligo,
|
|
127
|
+
rehost_images=args.rehost_images,
|
|
128
|
+
desired_versions=versions,
|
|
129
|
+
pull_secret=args.pull_secret,
|
|
130
|
+
domain=clean_domain(args.domain),
|
|
131
|
+
)
|
|
132
|
+
if command == "deploy":
|
|
133
|
+
await deploy(
|
|
134
|
+
raw_config_path=args.config_path,
|
|
135
|
+
dry_run=args.dry_run,
|
|
136
|
+
namespace=args.namespace,
|
|
137
|
+
atomic=args.atomic,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
try:
|
|
143
|
+
asyncio.run(amain())
|
|
144
|
+
except Abort as e:
|
|
145
|
+
logging.error(e)
|
|
146
|
+
logging.shutdown()
|
|
147
|
+
exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import secrets
|
|
4
|
+
import string
|
|
5
|
+
from json import JSONDecodeError
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import bcrypt
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from alchemite_setup.exception import Abort
|
|
12
|
+
from alchemite_setup.helm import API_SECRET_PATH, AUM_SECRET_PATH, LICENCE_PATH
|
|
13
|
+
|
|
14
|
+
DEFAULT_PASSWORD_CHARSET = (
|
|
15
|
+
string.ascii_letters + string.digits + "!#%&'()*+,-./:;<=>?[]^_`{|}~"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_password(
|
|
20
|
+
length: int = 30, charset: str = DEFAULT_PASSWORD_CHARSET
|
|
21
|
+
) -> str:
|
|
22
|
+
return "".join(secrets.choice(charset) for i in range(length))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def secrets_exist(values_dir: Path) -> bool:
|
|
26
|
+
return all(
|
|
27
|
+
(values_dir / file).exists()
|
|
28
|
+
for file in [LICENCE_PATH, API_SECRET_PATH, AUM_SECRET_PATH]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_licence(licence: str) -> bool:
|
|
33
|
+
# Check that the licence is likely to work once deployed.
|
|
34
|
+
# This does NOT validate the signature, that is left to the application itself
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(licence)
|
|
37
|
+
except JSONDecodeError:
|
|
38
|
+
data_segments = licence.split(".")
|
|
39
|
+
if len(data_segments) != 3:
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(
|
|
43
|
+
base64.urlsafe_b64decode(f"{data_segments[1]}====")
|
|
44
|
+
)
|
|
45
|
+
except JSONDecodeError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
for k in ["e", "p", "t", "u", "v", "w"]:
|
|
52
|
+
if k not in data:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
if data["v"] not in [1, 2]:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
if "__all__" not in data["p"]:
|
|
59
|
+
if "apiserver" not in data["p"]:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
# Installs now also require ichnite for the smiles extension
|
|
63
|
+
if "ichnite_beta" not in data["p"]:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def create_licence_secret(values_dir: Path) -> None:
|
|
70
|
+
licence = input("Enter Intellegens licence: ")
|
|
71
|
+
|
|
72
|
+
if not validate_licence(licence):
|
|
73
|
+
cont = input(
|
|
74
|
+
"Licence looks to be invalid. Are you sure you wish to continue? (y/n)"
|
|
75
|
+
)
|
|
76
|
+
if len(cont) == 0 or cont[0] != "y":
|
|
77
|
+
raise Abort("Abort due to user request")
|
|
78
|
+
|
|
79
|
+
with (values_dir / LICENCE_PATH).open("w") as f:
|
|
80
|
+
yaml.dump({"licence": licence}, f, sort_keys=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_api_secrets(values_dir: Path, postgres_pw: str) -> None:
|
|
84
|
+
keycloak_pw = generate_password()
|
|
85
|
+
opensearch_service_pw = generate_password()
|
|
86
|
+
opensearch_admin_pw = generate_password()
|
|
87
|
+
hashed_opensearch_service_pw = bcrypt.hashpw(
|
|
88
|
+
opensearch_service_pw.encode("ascii"), bcrypt.gensalt(prefix=b"2a")
|
|
89
|
+
).decode()
|
|
90
|
+
hashed_opensearch_admin_pw = bcrypt.hashpw(
|
|
91
|
+
opensearch_admin_pw.encode("ascii"), bcrypt.gensalt(prefix=b"2a")
|
|
92
|
+
).decode()
|
|
93
|
+
data = {
|
|
94
|
+
"postgresql": {"postgresqlPassword": postgres_pw},
|
|
95
|
+
"keycloak": {"username": "intellegensadmin", "password": keycloak_pw},
|
|
96
|
+
"opensearch": {
|
|
97
|
+
"alchemiteSecrets": {
|
|
98
|
+
"adminPasswordHashed": hashed_opensearch_admin_pw,
|
|
99
|
+
"serviceAccountPasswordHashed": hashed_opensearch_service_pw,
|
|
100
|
+
"serviceAccountPassword": opensearch_service_pw,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
with (values_dir / API_SECRET_PATH).open("w") as f:
|
|
106
|
+
yaml.dump(data, f, sort_keys=False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_aum_secrets(values_dir: Path, postgres_pw: str) -> None:
|
|
110
|
+
keycloak_pw = generate_password()
|
|
111
|
+
|
|
112
|
+
data = {
|
|
113
|
+
"keycloak": {
|
|
114
|
+
"username": "aum-service-account",
|
|
115
|
+
"password": keycloak_pw,
|
|
116
|
+
},
|
|
117
|
+
"sql": {
|
|
118
|
+
"aum": {
|
|
119
|
+
"password": postgres_pw,
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
with (values_dir / AUM_SECRET_PATH).open("w") as f:
|
|
125
|
+
yaml.dump(data, f, sort_keys=False)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_secrets(config_path: Path) -> None:
|
|
129
|
+
create_licence_secret(config_path)
|
|
130
|
+
|
|
131
|
+
postgres_pw = generate_password()
|
|
132
|
+
create_api_secrets(config_path, postgres_pw)
|
|
133
|
+
create_aum_secrets(config_path, postgres_pw)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
output_path = Path("../local/intellegensconfig")
|
|
138
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
|
|
140
|
+
create_secrets(output_path)
|