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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,3 @@
1
+ from alchemite_setup.cli import main
2
+
3
+ main()
@@ -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,2 @@
1
+ class Abort(BaseException):
2
+ pass
@@ -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)