alchemite-setup 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,3 @@
1
+ from alchemite_setup.cli import main
2
+
3
+ main()
alchemite_setup/cli.py ADDED
@@ -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)