login-auth-tui 0.4__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,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: login-auth-tui
3
+ Version: 0.4
4
+ Summary: TUI for login-auth stuff
5
+ Author: Nicholas Hurley
6
+ License-Expression: CC0-1.0
7
+ Requires-Dist: click
8
+ Requires-Dist: textual>=6.4.0
9
+ Requires-Dist: pyyaml
10
+ Requires-Python: >=3.13, <3.14
11
+ Project-URL: CI, https://builds.sr.ht/~nwgh/login-auth-tui
12
+ Project-URL: Changelog, https://git.sr.ht/~nwgh/login-auth-tui/log
13
+ Project-URL: Homepage, https://sr.ht/~nwgh/login-auth-tui
14
+ Project-URL: Issues, https://todo.sr.ht/~nwgh/login-auth-tui
15
+ Description-Content-Type: text/markdown
16
+
17
+ # login-auth-tui
18
+
19
+ [![Changelog](https://img.shields.io/pypi/v/login-auth-tui)](https://git.sr.ht/~nwgh/login-auth-tui/log)
20
+
21
+ [![builds.sr.ht status](https://builds.sr.ht/~nwgh/login-auth-tui.svg)](https://builds.sr.ht/~nwgh/login-auth-tui?)
22
+
23
+ [![License](https://img.shields.io/badge/license-CC0%201.0-blue.svg)](https://git.sr.ht/~nwgh/login-auth-tui/tree/main/item/LICENSE)
24
+
25
+ TUI for login-auth stuff
26
+
27
+ ## Installation
28
+
29
+ Install this tool using `uv`:
30
+
31
+ ```bash
32
+ uv tool install auth-manage --from login-auth-tui
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ For help, run:
38
+
39
+ ```bash
40
+ auth-manage --help
41
+ ```
42
+
43
+ ## Development
44
+
45
+ To contribute to this tool, first install `uv`. See [the uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for how.
46
+
47
+ Next, checkout the code
48
+
49
+ ```bash
50
+ git clone https://git.sr.ht/~nwgh/login-auth-tui
51
+ ```
52
+
53
+ Then create a new virtual environment and sync the dependencies:
54
+
55
+ ```bash
56
+ cd login-auth-tui
57
+ uv sync
58
+ ```
59
+
60
+ To run the tests:
61
+
62
+ ```bash
63
+ uv run python -m pytest
64
+ ```
@@ -0,0 +1,48 @@
1
+ # login-auth-tui
2
+
3
+ [![Changelog](https://img.shields.io/pypi/v/login-auth-tui)](https://git.sr.ht/~nwgh/login-auth-tui/log)
4
+
5
+ [![builds.sr.ht status](https://builds.sr.ht/~nwgh/login-auth-tui.svg)](https://builds.sr.ht/~nwgh/login-auth-tui?)
6
+
7
+ [![License](https://img.shields.io/badge/license-CC0%201.0-blue.svg)](https://git.sr.ht/~nwgh/login-auth-tui/tree/main/item/LICENSE)
8
+
9
+ TUI for login-auth stuff
10
+
11
+ ## Installation
12
+
13
+ Install this tool using `uv`:
14
+
15
+ ```bash
16
+ uv tool install auth-manage --from login-auth-tui
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ For help, run:
22
+
23
+ ```bash
24
+ auth-manage --help
25
+ ```
26
+
27
+ ## Development
28
+
29
+ To contribute to this tool, first install `uv`. See [the uv documentation](https://docs.astral.sh/uv/getting-started/installation/) for how.
30
+
31
+ Next, checkout the code
32
+
33
+ ```bash
34
+ git clone https://git.sr.ht/~nwgh/login-auth-tui
35
+ ```
36
+
37
+ Then create a new virtual environment and sync the dependencies:
38
+
39
+ ```bash
40
+ cd login-auth-tui
41
+ uv sync
42
+ ```
43
+
44
+ To run the tests:
45
+
46
+ ```bash
47
+ uv run python -m pytest
48
+ ```
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "login-auth-tui"
3
+ version = "0.4"
4
+ description = "TUI for login-auth stuff"
5
+ readme = "README.md"
6
+ authors = [{ name = "Nicholas Hurley" }]
7
+ license = "CC0-1.0"
8
+ requires-python = ">=3.13,<3.14"
9
+ classifiers = []
10
+ dependencies = ["click", "textual>=6.4.0", "pyyaml"]
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.9.5,<0.10.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [project.urls]
17
+ Homepage = "https://sr.ht/~nwgh/login-auth-tui"
18
+ Changelog = "https://git.sr.ht/~nwgh/login-auth-tui/log"
19
+ Issues = "https://todo.sr.ht/~nwgh/login-auth-tui"
20
+ CI = "https://builds.sr.ht/~nwgh/login-auth-tui"
21
+
22
+ [project.scripts]
23
+ auth-manage = "login_auth_tui.cli:cli"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ { include-group = "autoflake" },
28
+ { include-group = "bandit" },
29
+ { include-group = "black" },
30
+ { include-group = "flake8" },
31
+ { include-group = "installer" },
32
+ { include-group = "isort" },
33
+ { include-group = "test" },
34
+ "pre-commit",
35
+ "textual-dev",
36
+ ]
37
+ autoflake = ["autoflake"]
38
+ bandit = ["bandit", "tomli"]
39
+ black = ["black"]
40
+ flake8 = ["flake8", "flake8-bugbear", "Flake8-pyproject", "pep8-naming"]
41
+ isort = ["isort"]
42
+ installer = ["pyinstaller"]
43
+ test = ["pytest"]
44
+
45
+ [tool.black]
46
+ line-length = 99
47
+
48
+ [tool.isort]
49
+ line_length = 99
50
+ profile = "black"
51
+
52
+ [tool.pytest.ini_options]
53
+ console_output_style = "progress"
54
+ minversion = "7.2.2"
55
+ python_files = "test_*.py"
56
+ addopts = "-vvvv --ignore-glob .venv* --ignore-glob venv*"
57
+
58
+ [tool.flake8]
59
+ max-line-length = 99
60
+ exclude = [".venv*", "venv*", "typings"]
61
+ extend-ignore = ["E203", "E501"]
62
+ extend-select = ["B950"]
63
+
64
+ [tool.bandit]
65
+ exclude_dirs = [".venv", "venv", "tests"]
66
+
67
+ [[tool.uv.index]]
68
+ name = "testpypi"
69
+ url = "https://test.pypi.org/simple/"
70
+ publish-url = "https://test.pypi.org/legacy/"
71
+ explicit = true
File without changes
@@ -0,0 +1,4 @@
1
+ from login_auth_tui.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,126 @@
1
+ # This program is used to get AWS CLI credentials from kion and
2
+ # save them to your aws configuration. It assumes you are already
3
+ # connected to zscaler, and will likely fail in strange and
4
+ # unusual ways if you aren't. Ideally, you would run this from a
5
+ # cron/otherwise scheduled job to keep things updated.
6
+ import configparser
7
+ import datetime
8
+ import json
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import subprocess # nosec: B404 this is all trusted inputs
13
+
14
+ DEFAULT_AWS_CREDENTIALS = os.path.join(os.getenv("HOME"), ".aws", "credentials") # type: ignore
15
+ DEFAULT_KION_SOURCE_CONFIG = os.path.join(os.getenv("HOME"), ".config", "kion.yml") # type: ignore
16
+
17
+ logger = logging.getLogger("aws-kion")
18
+
19
+
20
+ def creds_need_update(creds_file_path: str, profile_name: str) -> bool:
21
+ """Inspect the credentials we currently have to see if they
22
+ really need updated.
23
+ """
24
+ logger.info(f"Checking {creds_file_path} to see if update is necessary")
25
+ config = configparser.ConfigParser()
26
+ config.read(creds_file_path)
27
+
28
+ if profile_name not in config.sections():
29
+ logger.debug(f"Profile [{profile_name}] does not exist, forcing update.")
30
+ return True
31
+
32
+ expiration = config[profile_name].get("expiration")
33
+ if not expiration:
34
+ logger.debug(f"No expiration date found for [{profile_name}], forcing update.")
35
+ return True
36
+
37
+ expiration_time = datetime.datetime.fromisoformat(expiration)
38
+ now = datetime.datetime.now(datetime.UTC)
39
+ logger.debug(f"Expiration={expiration_time.utctimetuple()}")
40
+ logger.debug(f"Now={now.utctimetuple()}")
41
+ if expiration_time <= (now + datetime.timedelta(minutes=10)):
42
+ # Assume we're running no less than every 10 minutes
43
+ logger.debug(f"Credentials for [{profile_name}] expire in under 10 minutes. Updating.")
44
+ return True
45
+
46
+ logger.debug(
47
+ f"Credentials for [{profile_name}] are still good for at least 10 minutes. Not updating."
48
+ )
49
+ return False
50
+
51
+
52
+ def replace_kion_yaml(kion_yaml_path: str) -> None:
53
+ """Copy our template kion cli config to the blessed
54
+ kion location.
55
+ """
56
+ destination_yaml = os.path.join(
57
+ os.getenv("HOME"), # type: ignore
58
+ ".kion.yml",
59
+ ) # kion cli is inflexible
60
+ if kion_yaml_path == destination_yaml:
61
+ logger.debug("Kion yaml is sourced from the expected location, not copying.")
62
+ return
63
+
64
+ logger.debug(f"Copying {kion_yaml_path} -> {destination_yaml}")
65
+ shutil.copyfile(kion_yaml_path, destination_yaml)
66
+
67
+
68
+ def update_aws_credentials(
69
+ creds_file_path: str, profile_name: str, aws_creds: dict[str, str]
70
+ ) -> None:
71
+ """Given the new access key, secret key, and session token, save
72
+ them for the named profile in the file indicated.
73
+ """
74
+ logger.info(f"Updating credentials in {creds_file_path}")
75
+ # Initialize ConfigParser and read the credentials file
76
+ config = configparser.ConfigParser()
77
+ config.read(creds_file_path)
78
+
79
+ # Ensure the profile exists in the credentials file, create it if missing
80
+ if profile_name not in config.sections():
81
+ logger.debug(f"Adding section {profile_name}")
82
+ config.add_section(profile_name)
83
+
84
+ # Update the profile with new credentials
85
+ config[profile_name]["aws_access_key_id"] = aws_creds["AccessKeyId"]
86
+ config[profile_name]["aws_secret_access_key"] = aws_creds["SecretAccessKey"]
87
+ config[profile_name]["aws_session_token"] = aws_creds["SessionToken"]
88
+ config[profile_name]["expiration"] = aws_creds["Expiration"]
89
+
90
+ # Write the updated configuration back to the file
91
+ with open(creds_file_path, "w") as creds_file:
92
+ config.write(creds_file)
93
+
94
+ logger.info(f"Credentials for profile [{profile_name}] updated successfully.")
95
+
96
+
97
+ def get_new_aws_credentials(
98
+ favourite: str,
99
+ kion: str = "/opt/homebrew/bin/kion",
100
+ ) -> dict[str, str]:
101
+ """Retrieve new AWS CLI credentials from kion using the CLI."""
102
+ logger.info("Retrieving AWS credentials from kion")
103
+ json_output = subprocess.check_output([kion, "favorite", "--credential-process", favourite])
104
+ logger.debug(f"New credentials: {json_output}")
105
+ return json.loads(json_output)
106
+
107
+
108
+ def aws_kion(
109
+ profile: str, credentials: str, favourite: str, kion_yaml: str, kion_bin: str
110
+ ) -> None:
111
+ logger.debug(f"Profile: {profile}")
112
+ logger.debug(f"Credentials File: {credentials}")
113
+ logger.debug(f"Kion Favorite: {favourite}")
114
+ logger.debug(f"Kion YAML: {kion_yaml}")
115
+
116
+ try:
117
+ if not creds_need_update(credentials, profile): # type: ignore
118
+ logger.info("Credentials have not yet expired. Not updating.")
119
+ return
120
+
121
+ replace_kion_yaml(kion_yaml) # type: ignore
122
+ aws_creds = get_new_aws_credentials(favourite, kion=kion_bin) # type: ignore
123
+ update_aws_credentials(credentials, profile, aws_creds) # type: ignore
124
+ except Exception:
125
+ logger.exception("Exception occurred during update")
126
+ raise
@@ -0,0 +1,10 @@
1
+ import logging
2
+ import subprocess
3
+
4
+ logger = logging.getLogger("aws-kion-check")
5
+
6
+
7
+ def aws_kion_check(profile: str) -> None:
8
+ output = subprocess.check_output(["aws", "--profile", profile, "s3", "ls"])
9
+ logger.debug("Output from aws s3 ls")
10
+ logger.debug(output)
@@ -0,0 +1,61 @@
1
+ import configparser
2
+ import datetime
3
+ import logging
4
+ import os
5
+
6
+ from .util import log_command, log_command_output
7
+
8
+ logger = logging.getLogger("aws")
9
+
10
+ HOMEDIR = str(os.getenv("HOME"))
11
+
12
+
13
+ def credential_is_valid(profile: str) -> bool:
14
+ now = datetime.datetime.now(datetime.UTC)
15
+
16
+ p = configparser.ConfigParser()
17
+ p.read(os.path.join(HOMEDIR, ".aws", "credentials"))
18
+
19
+ then_s = p.get(profile, "expiration")
20
+ then = datetime.datetime.strptime(f"{then_s} +0000", "%Y-%m-%d %H:%M:%S %z")
21
+
22
+ if now < then:
23
+ return True
24
+ return False
25
+
26
+
27
+ def aws_mfa(profile: str, force: bool) -> None:
28
+ logger.info(f"Running mfa process for {profile}")
29
+
30
+ no_mfa_file = os.path.join(HOMEDIR, f".no-mfa-{profile}")
31
+ if os.path.exists(no_mfa_file) and not force:
32
+ logger.info("Not doing mfa due to temp file existing. (Removing the file.)")
33
+ os.unlink(no_mfa_file)
34
+ return
35
+
36
+ if credential_is_valid(profile) and not force:
37
+ logger.info("Not doing mfa due to credentials still valid.")
38
+ return
39
+
40
+ code = log_command_output(
41
+ logger, ["op", "read", "op://Private/AWS/one-time password?attribute=totp"]
42
+ )
43
+ logger.info(f"Using {code} for {profile}")
44
+ rval = log_command(
45
+ logger,
46
+ [f"{os.getenv('HOMEBREW_PREFIX')}/bin/aws", "mfa", profile],
47
+ input=code.encode("utf-8"),
48
+ ).returncode
49
+
50
+ if rval != 0:
51
+ # For some reason, recently, aws-mfa has been returning 1 even though
52
+ # it successfully updates the tokens. So if it does return non-zero,
53
+ # we need to check the credentials file itself to verify if it's a real
54
+ # failure or not.
55
+ logger.info("Warning: MFA seems to have failed by rval, checking expiration")
56
+ if not credential_is_valid(profile):
57
+ logger.error(f"Not rotating long-term keys due to MFA failure ({rval})")
58
+ raise Exception("AWS MFA failure")
59
+
60
+ logger.info("Rotating long-term keys")
61
+ log_command(logger, ["aws", "rotate-iam-keys", f"{profile}-long-term"], check=True)
@@ -0,0 +1,335 @@
1
+ import configparser
2
+ import copy
3
+ import logging
4
+ import os
5
+ import sqlite3
6
+ import sys
7
+ import time
8
+
9
+ import click
10
+ from click.core import ParameterSource as Source
11
+
12
+ from .aws_kion import DEFAULT_AWS_CREDENTIALS, DEFAULT_KION_SOURCE_CONFIG, aws_kion
13
+ from .aws_mfa import aws_mfa
14
+ from .dbx_token_rotate import manage_dbx_tokens
15
+ from .kion_auth_password import update_kion_auth_password
16
+ from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
17
+ from .tui import run_tui
18
+ from .tui_bootstrap import bootstrap_tui_config
19
+ from .util import configure_logging, log_command
20
+
21
+ TWELVE_HOURS = 12 * 60 * 60
22
+ DEFAULT_LOG = os.path.join(str(os.getenv("HOME")), ".logs", "login-auth.log")
23
+
24
+
25
+ class CliArgs:
26
+ __slots__ = ["force", "log", "level"]
27
+
28
+ def __init__(self):
29
+ self.force = False
30
+ self.log = DEFAULT_LOG
31
+ self.level = "INFO"
32
+
33
+
34
+ @click.group()
35
+ @click.version_option()
36
+ @click.option(
37
+ "--force",
38
+ is_flag=True,
39
+ default=False,
40
+ help="Force update regardless of last update time (only aws, batch, and dbx).",
41
+ )
42
+ @click.option(
43
+ "--log",
44
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True, allow_dash=True),
45
+ default=DEFAULT_LOG,
46
+ help="File to log to ('-' for stdout).",
47
+ )
48
+ @click.option(
49
+ "--level",
50
+ type=click.Choice([k for k in logging.getLevelNamesMapping().keys() if k != "NOTSET"]),
51
+ default="INFO",
52
+ help="Logging level to use.",
53
+ )
54
+ @click.pass_context
55
+ def cli(ctx: click.Context, force: bool, log: str, level: str):
56
+ """Run authentication useful when developing on DataConnect."""
57
+ ctx.ensure_object(CliArgs)
58
+ ctx.obj.force = force
59
+ ctx.obj.log = log
60
+ ctx.obj.level = level
61
+
62
+
63
+ def login_auth(force: bool, conn: sqlite3.Connection) -> None:
64
+ # Now that we hold an exclusive lock on the config, see if we actually need
65
+ # to do anything.
66
+ last_update = conn.execute("SELECT * FROM last_update").fetchone()[0]
67
+ now = time.time()
68
+ if last_update < (now - TWELVE_HOURS):
69
+ logging.info("More than twelve hours since last update.")
70
+ elif force:
71
+ logging.info("Running due to force")
72
+ else:
73
+ logging.info("Not running; auth does not need to be updated")
74
+ return
75
+
76
+ res = conn.execute("SELECT * FROM config")
77
+ config = dict(zip([r[0] for r in res.description], res.fetchone()))
78
+
79
+ if config["wait"]:
80
+ logging.info(f"Waiting {config['wait']} seconds for boot...")
81
+ time.sleep(config["wait"])
82
+
83
+ if config["ssh_add"]:
84
+ logging.info("Adding SSH keys...")
85
+ env = copy.deepcopy(os.environ)
86
+ args = ["/usr/bin/ssh-add"]
87
+ if sys.platform == "darwin":
88
+ env["APPLE_SSH_ADD_BEHAVIOR"] = "yes"
89
+ args.append("-A")
90
+ log_command(logging.getLogger(), args, env=env)
91
+
92
+ success = True
93
+ res = conn.execute("SELECT * FROM aws_profiles")
94
+ aws_profiles = [r[0] for r in res.fetchall()]
95
+ for aws_profile in aws_profiles:
96
+ logging.info(f"Authenticating AWS profile {aws_profile}")
97
+ try:
98
+ aws_mfa(aws_profile, force)
99
+ except Exception:
100
+ logging.exception(f"Exception running AWS MFA {aws_profile}")
101
+ success = False
102
+
103
+ res = conn.execute("SELECT * FROM dbx_profiles")
104
+ for row in res.fetchall():
105
+ profile, alias = row
106
+ logging.info(f"Rotating DBX token {profile} (alias={alias})")
107
+ try:
108
+ manage_dbx_tokens(profile, [alias], force)
109
+ except Exception:
110
+ logging.exception(f"Exception rotating DBX token {profile}")
111
+ success = False
112
+
113
+ kion_auth_config_file = config["kion_auth_config_file"]
114
+ if kion_auth_config_file:
115
+ logging.info("Update kion-auth config file with current password")
116
+ update_kion_auth_password(kion_auth_config_file)
117
+
118
+ kion_cli_config_file = config["kion_cli_config_file"]
119
+ if kion_cli_config_file:
120
+ logging.info("Update kion cli config file with current password")
121
+ update_kion_cli_password(kion_cli_config_file)
122
+
123
+ if success:
124
+ logging.info("Success!")
125
+ conn.execute(f"UPDATE last_update SET tstamp = {int(now)}") # nosec
126
+ else:
127
+ logging.info("Failed")
128
+
129
+
130
+ @cli.command("batch")
131
+ @click.option("--background", is_flag=True, default=False, help="Run in the background.")
132
+ @click.option(
133
+ "--config",
134
+ type=click.Path(dir_okay=False, exists=True, resolve_path=True),
135
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
136
+ help="Path to configuration database.",
137
+ )
138
+ @click.pass_context
139
+ def login_auth_main(ctx: click.Context, background: bool, config: str) -> None:
140
+ """Run all the login management in one batch."""
141
+ if background:
142
+ # TODO - run in the background
143
+ pid = os.fork()
144
+ if pid != 0:
145
+ # This is the parent, we done
146
+ return
147
+
148
+ # This is the child, detach from the parent
149
+ os.setpgrp()
150
+
151
+ configure_logging(ctx.obj.log, ctx.obj.level)
152
+
153
+ conn = sqlite3.connect(config)
154
+ conn.execute("BEGIN EXCLUSIVE TRANSACTION")
155
+
156
+ try:
157
+ login_auth(ctx.obj.force, conn)
158
+ except Exception:
159
+ logging.exception("Exception doing login_auth")
160
+ conn.execute("ROLLBACK")
161
+ raise
162
+ else:
163
+ logging.info("Commit transaction")
164
+ conn.execute("COMMIT")
165
+
166
+
167
+ @cli.command("aws")
168
+ @click.argument("profile")
169
+ @click.pass_context
170
+ def aws_mfa_main(ctx: click.Context, profile: str) -> None:
171
+ """Run AWS MFA process.
172
+
173
+ PROFILE the AWS profile to run the MFA process for.
174
+ """
175
+ configure_logging(ctx.obj.log, ctx.obj.level)
176
+
177
+ aws_mfa(profile, ctx.obj.force)
178
+
179
+
180
+ @cli.command("dbx")
181
+ @click.option("--alias", multiple=True, help="Other profiles that are aliases.")
182
+ @click.argument("profile")
183
+ @click.pass_context
184
+ def dbx_token_rotate_main(ctx: click.Context, alias: tuple[str], profile: str) -> None:
185
+ """Rotate Databricks API token.
186
+
187
+ PROFILE the Databricks configuration profile to rotate the API token for.
188
+ """
189
+ configure_logging(ctx.obj.log, ctx.obj.level)
190
+
191
+ manage_dbx_tokens(profile, alias, ctx.obj.force)
192
+
193
+
194
+ @cli.command("kion-auth")
195
+ @click.option(
196
+ "--file",
197
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
198
+ default=os.path.join(str(os.getenv("HOME")), ".config", "kion-auth.ini"),
199
+ help="Path of kion-auth configuration file.",
200
+ )
201
+ @click.pass_context
202
+ def update_kion_auth_password_main(ctx: click.Context, file: str) -> None:
203
+ """Update the password used by kion-auth from 1Password."""
204
+ configure_logging(ctx.obj.log, ctx.obj.level)
205
+
206
+ update_kion_auth_password(file)
207
+
208
+
209
+ @cli.command("kion-cli")
210
+ @click.option(
211
+ "--file",
212
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
213
+ default=DEFAULT_CONFIG_FILE,
214
+ help="Path of kion cli configuration file.",
215
+ )
216
+ @click.pass_context
217
+ def update_kion_cli_password_main(ctx: click.Context, file: str) -> None:
218
+ """Update the password used by the kion cli from 1Password."""
219
+ configure_logging(ctx.obj.log, ctx.obj.level)
220
+
221
+ update_kion_cli_password(file)
222
+
223
+
224
+ @cli.command("tui")
225
+ @click.option(
226
+ "--config",
227
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
228
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
229
+ help="Path of TUI configuration file.",
230
+ )
231
+ @click.pass_context
232
+ def tui(ctx: click.Context, config: str) -> None:
233
+ """Run the TUI for authentication processes."""
234
+ configure_logging(ctx.obj.log, ctx.obj.level)
235
+ run_tui(config)
236
+
237
+
238
+ @cli.command("tui-bootstrap")
239
+ @click.option(
240
+ "--in-config",
241
+ type=click.Path(dir_okay=False, exists=True, resolve_path=True),
242
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
243
+ help="Path to configuration database.",
244
+ )
245
+ @click.option(
246
+ "--out-config",
247
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True),
248
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
249
+ help="Path of TUI configuration file.",
250
+ )
251
+ @click.pass_context
252
+ def tui_bootstrap(ctx: click.Context, in_config: str, out_config: str) -> None:
253
+ """Bootstrap the tui configuration from the batch configuration."""
254
+ configure_logging(ctx.obj.log, ctx.obj.level)
255
+ if os.path.exists(out_config) and not ctx.obj.force:
256
+ raise ValueError(f"Not overwriting existing TUI config {out_config}")
257
+ bootstrap_tui_config(in_config, out_config)
258
+
259
+
260
+ @cli.command("aws-kion")
261
+ @click.option(
262
+ "--credentials",
263
+ help="Location of AWS Credentials file",
264
+ default=DEFAULT_AWS_CREDENTIALS,
265
+ show_default=True,
266
+ )
267
+ @click.option(
268
+ "--config",
269
+ help="Path of configuration file",
270
+ default=os.path.join(os.getenv("HOME"), ".config", "aws-token-updater.ini"), # type: ignore
271
+ show_default=True,
272
+ )
273
+ @click.option(
274
+ "--kion-yaml",
275
+ help="Path of kion configuration file",
276
+ default=DEFAULT_KION_SOURCE_CONFIG,
277
+ show_default=True,
278
+ )
279
+ @click.option(
280
+ "--kion-bin",
281
+ help="Path to kion executable",
282
+ default="/opt/homebrew/bin/kion",
283
+ )
284
+ @click.option("--profile", help="Name of AWS profile to update")
285
+ @click.option("--favourite/--favorite", help="Name of kion favourite to use")
286
+ @click.pass_context
287
+ def aws_kion_main(
288
+ ctx,
289
+ credentials: str,
290
+ config: str,
291
+ kion_yaml: str,
292
+ kion_bin: str,
293
+ profile: str,
294
+ favourite: str,
295
+ ):
296
+ configure_logging(ctx.obj.log, ctx.obj.level)
297
+
298
+ # Read configuration from the file if it exists, otherwise start
299
+ # with an empty configuration and hope the user used the CLI args
300
+ # Configuration prefers the CLI arguments if given, otherwise it
301
+ # takes information from the config file.
302
+ if os.path.exists(config):
303
+ c = configparser.ConfigParser()
304
+ c.read(config)
305
+ cfg = c["aws_token_updater"]
306
+ else:
307
+ cfg = {}
308
+
309
+ if not profile:
310
+ profile = cfg.get("profile") # type: ignore
311
+
312
+ if not favourite:
313
+ favourite = cfg.get("favourite") # type: ignore
314
+
315
+ # Just like the log destination file, we only want to override
316
+ # what came in from the CLI args if what came in was the default
317
+ if ctx.get_parameter_source("credentials") == Source.DEFAULT and cfg.get("credentials"):
318
+ credentials = cfg.get("credentials") # type: ignore
319
+
320
+ # Just like the log destination file, we only want to override
321
+ # what came in from the CLI args if what came in was the default
322
+ if ctx.get_parameter_source("kion_yaml") == Source.DEFAULT and cfg.get("kion_yaml"):
323
+ kion_yaml = cfg.get("kion_yaml") # type: ignore
324
+
325
+ # Just like the log destination file, we only want to override
326
+ # what came in from the CLI args if what came in was the default
327
+ if ctx.get_parameter_source("kion_bin") == Source.DEFAULT and cfg.get("kion_bin"):
328
+ cfg.get("kion_bin")
329
+
330
+ if not all([profile, credentials, favourite]):
331
+ raise ValueError(
332
+ "Missing one configuration. Ensure configurtion file exists or all arguments are passed"
333
+ )
334
+
335
+ aws_kion(profile, credentials, favourite, kion_yaml, kion_bin)
@@ -0,0 +1,89 @@
1
+ import configparser
2
+ import datetime
3
+ import json
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import time
8
+ import typing
9
+
10
+ from .util import log_command_output
11
+
12
+ logger = logging.getLogger("dbx")
13
+
14
+ HOMEDIR = os.getenv("HOME")
15
+ TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000
16
+ NINETY_DAYS_S = int((TEN_DAYS_MS / 1000) * 9)
17
+
18
+
19
+ def run_dbx(profile: str, *args) -> str:
20
+ subprocess_args = ["databricks", "--profile", profile, "-o", "json"] + list(args)
21
+ logger.info(f"Run {' '.join(subprocess_args)}")
22
+ return log_command_output(logger, subprocess_args)
23
+
24
+
25
+ def create_new_token(profile: str) -> str:
26
+ logger.info(f"Creating new token for {profile}")
27
+ today = str(datetime.date.today())
28
+ new_token_json = run_dbx(
29
+ profile,
30
+ "tokens",
31
+ "create",
32
+ "--comment",
33
+ f"dbx cli {today}",
34
+ "--lifetime-seconds",
35
+ str(NINETY_DAYS_S),
36
+ )
37
+ new_token = json.loads(new_token_json)
38
+
39
+ return new_token["token_value"]
40
+
41
+
42
+ def delete_old_token(profile: str, token_id: str) -> None:
43
+ logger.info(f"Deleting old token {token_id} for profile {profile}")
44
+ run_dbx(profile, "tokens", "delete", token_id)
45
+
46
+
47
+ def overwrite_dbx_config(
48
+ dbxcfg: str,
49
+ cp: configparser.ConfigParser,
50
+ profile: str,
51
+ aliases: typing.Iterable[str],
52
+ new_token: str,
53
+ ) -> None:
54
+ logger.info(f"Updating config file for profile {profile} (alias={aliases})")
55
+ cp[profile]["token"] = new_token
56
+ cp[profile]["nwgh-last-update"] = str(datetime.date.today())
57
+ if aliases:
58
+ for alias in aliases:
59
+ cp[alias]["token"] = new_token
60
+ with open(f"{dbxcfg}.new", "w") as f:
61
+ cp.write(f)
62
+ shutil.copyfile(dbxcfg, f"{dbxcfg}.old")
63
+ shutil.move(f"{dbxcfg}.new", dbxcfg)
64
+
65
+
66
+ def manage_dbx_tokens(profile: str, aliases: typing.Iterable[str], force: bool) -> None:
67
+ dbxcfg = os.path.join(str(os.getenv("HOME")), ".databrickscfg")
68
+ if not os.path.exists(dbxcfg):
69
+ raise Exception(f"Databricks config file {dbxcfg} does not exist")
70
+
71
+ cp = configparser.ConfigParser()
72
+ cp.read(dbxcfg)
73
+ if profile not in cp:
74
+ raise Exception(f"Databricks config does not have profile {dbxcfg}")
75
+
76
+ json_result = run_dbx(profile, "tokens", "list")
77
+ tokens = json.loads(json_result)
78
+ if len(tokens) != 1:
79
+ raise Exception(
80
+ f"This script does not work with {len(tokens)} tokens! There can be only one."
81
+ )
82
+
83
+ now_ms = int(time.time()) * 1000
84
+ if force or (tokens[0]["expiry_time"] - now_ms <= TEN_DAYS_MS):
85
+ new_token = create_new_token(profile)
86
+ overwrite_dbx_config(dbxcfg, cp, profile, aliases, new_token)
87
+ delete_old_token(profile, tokens[0]["token_id"])
88
+ else:
89
+ logger.info(f"Token for {profile} is still ok, not rotating")
@@ -0,0 +1,26 @@
1
+ import configparser
2
+ import logging
3
+ import os
4
+
5
+ from .util import log_command_output
6
+
7
+ logger = logging.getLogger("kion-auth")
8
+
9
+
10
+ def update_kion_auth_password(config_file: str) -> None:
11
+ if not os.path.exists(config_file):
12
+ logger.error(f"kion-auth config file {config_file} does not exist")
13
+ return
14
+
15
+ cp = configparser.ConfigParser()
16
+ cp.read(config_file)
17
+
18
+ if not cp.get("kion_auth", "password"):
19
+ logger.info("Kion password is not defined, not updating")
20
+ return
21
+
22
+ password = log_command_output(logger, ["op", "read", "op://Private/CMS EUA/password"])
23
+ cp["kion_auth"]["password"] = password
24
+
25
+ with open(config_file, "w") as f:
26
+ cp.write(f)
@@ -0,0 +1,27 @@
1
+ import logging
2
+ import os
3
+
4
+ from .util import log_command_output
5
+
6
+ logger = logging.getLogger("kion-cli")
7
+
8
+ DEFAULT_CONFIG_FILE = os.path.join(str(os.getenv("HOME")), ".config", "kion.yml")
9
+
10
+
11
+ def update_kion_cli_password(config_file: str) -> None:
12
+ if not os.path.exists(config_file):
13
+ logger.error(f"kion cli config file {config_file} does not exist")
14
+ return
15
+
16
+ password = log_command_output(logger, ["op", "read", "op://Private/CMS EUA/password"])
17
+
18
+ new_lines = []
19
+ with open(config_file) as f:
20
+ for line in f:
21
+ line = line.rstrip()
22
+ if line.startswith(" password:"):
23
+ line = f" password: {password}"
24
+ new_lines.append(line)
25
+
26
+ with open(config_file, "w") as f:
27
+ f.write("\n".join(new_lines))
@@ -0,0 +1,17 @@
1
+ import datetime
2
+ import logging
3
+ import random
4
+
5
+ logger = logging.getLogger("noop")
6
+
7
+
8
+ def run_noop():
9
+ now = datetime.datetime.now()
10
+ nlines = random.randint(2, 5)
11
+ for i in range(1, nlines):
12
+ logger.info(f"Message {i} from run at {now}")
13
+
14
+ if random.choice([0, 1]):
15
+ msg = f"Faking error for run at {now}"
16
+ logger.error(msg)
17
+ raise Exception(msg)
@@ -0,0 +1,282 @@
1
+ import enum
2
+ import io
3
+ import logging
4
+
5
+ import yaml
6
+ from textual import on, work
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import HorizontalGroup
10
+ from textual.logging import TextualHandler
11
+ from textual.message import Message
12
+ from textual.reactive import reactive
13
+ from textual.screen import ModalScreen
14
+ from textual.widget import Widget
15
+ from textual.widgets import (
16
+ Footer,
17
+ Label,
18
+ ListItem,
19
+ ListView,
20
+ Log,
21
+ Rule,
22
+ )
23
+
24
+ from .aws_kion import DEFAULT_AWS_CREDENTIALS, DEFAULT_KION_SOURCE_CONFIG, aws_kion
25
+ from .aws_kion_check import aws_kion_check
26
+ from .aws_mfa import aws_mfa
27
+ from .dbx_token_rotate import manage_dbx_tokens
28
+ from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
29
+ from .noop import run_noop
30
+
31
+ logger = logging.getLogger("tui")
32
+
33
+
34
+ class ProcessStatus(enum.Enum):
35
+ OK = 0
36
+ FAIL = 1
37
+ UNKNOWN = 2
38
+ RUNNING = 3
39
+
40
+
41
+ # Each process will write to its own io.StringIO
42
+ class AuthProcess:
43
+ def __init__(self, process_config: dict):
44
+ self.name = process_config["name"]
45
+ self.type = process_config["type"]
46
+
47
+ if self.type == "aws":
48
+ self.profile = process_config["profile"]
49
+ elif self.type == "dbx":
50
+ self.profile = process_config["profile"]
51
+ self.aliases = []
52
+ alias = process_config.get("alias")
53
+ if alias:
54
+ self.aliases.append(alias)
55
+ elif self.type == "kion-cli":
56
+ self.config = process_config.get("config", DEFAULT_CONFIG_FILE)
57
+ elif self.type == "aws-kion":
58
+ self.profile = process_config["profile"]
59
+ self.favourite = process_config.get("favourite", process_config["favorite"])
60
+ self.credentials_file = process_config.get("credentials_file", DEFAULT_AWS_CREDENTIALS)
61
+ self.kion_config_src = process_config.get("kion_config", DEFAULT_KION_SOURCE_CONFIG)
62
+ self.kion_bin = process_config.get("kion_bin", "/opt/homebrew/bin/kion")
63
+ elif self.type == "aws-kion-check":
64
+ self.profile = process_config["profile"]
65
+ elif self.type == "noop":
66
+ # This is used for testing things
67
+ pass
68
+ else:
69
+ raise ValueError(f"Unexpected auth process type: {self.type}")
70
+
71
+ self.log_stream = io.StringIO()
72
+ self.log_handler = logging.StreamHandler(self.log_stream)
73
+
74
+ def run(self):
75
+ logger.debug(f"Run {self.name} {self.type}")
76
+ process_logger = logging.getLogger(self.type)
77
+ process_logger.addHandler(self.log_handler)
78
+ try:
79
+ if self.type == "aws":
80
+ aws_mfa(self.profile, False)
81
+ elif self.type == "dbx":
82
+ manage_dbx_tokens(self.profile, self.aliases, False)
83
+ elif self.type == "kion-cli":
84
+ update_kion_cli_password(self.config)
85
+ elif self.type == "aws-kion":
86
+ aws_kion(
87
+ self.profile,
88
+ self.credentials_file,
89
+ self.favourite,
90
+ self.kion_config_src,
91
+ self.kion_bin,
92
+ )
93
+ elif self.type == "aws-kion-check":
94
+ aws_kion_check(self.profile)
95
+ elif self.type == "noop":
96
+ run_noop()
97
+ else:
98
+ raise ValueError(f"Unexpected auth process type: {self.type}")
99
+ except Exception:
100
+ process_logger.exception(f"Exception running {self.name}")
101
+ raise
102
+ finally:
103
+ process_logger.removeHandler(self.log_handler)
104
+
105
+
106
+ def load_config(config_file: str) -> list[AuthProcess]:
107
+ logger.debug(f"Loading config from {config_file}")
108
+ processes = []
109
+ with open(config_file) as f:
110
+ config = yaml.safe_load(f)
111
+ for auth_process in config:
112
+ processes.append(AuthProcess(auth_process))
113
+ return processes
114
+
115
+
116
+ class AuthProcessStatus(Widget):
117
+ value = reactive(ProcessStatus.UNKNOWN)
118
+
119
+ def render(self) -> str:
120
+ if self.value == ProcessStatus.OK:
121
+ return "\u2714"
122
+ if self.value == ProcessStatus.FAIL:
123
+ return "\u2716"
124
+ if self.value == ProcessStatus.RUNNING:
125
+ return "-"
126
+ return "?"
127
+
128
+
129
+ class AuthRunMessage(Message):
130
+ def __init__(self, status: ProcessStatus, *args, **kwargs):
131
+ super().__init__(*args, **kwargs)
132
+ self.status = status
133
+
134
+
135
+ class AuthRunCompleteMessage(Message):
136
+ pass
137
+
138
+
139
+ class AuthProcessItem(ListItem):
140
+ def __init__(self, proc: AuthProcess, *args, **kwargs):
141
+ super().__init__(*args, **kwargs)
142
+ self.process_config = proc
143
+ self.status = AuthProcessStatus()
144
+
145
+ def compose(self) -> ComposeResult:
146
+ # TODO - make use of more stuff here
147
+ yield HorizontalGroup(Label(self.process_config.name), Label(" "), self.status)
148
+
149
+ @on(AuthRunMessage)
150
+ def run_complete(self, msg: AuthRunMessage):
151
+ self.status.value = msg.status
152
+ self.post_message(AuthRunCompleteMessage())
153
+
154
+ @work(exclusive=True, thread=True)
155
+ def run_auth(self):
156
+ logger.debug(f"Run auth: {self}")
157
+ result = ProcessStatus.UNKNOWN
158
+ try:
159
+ self.process_config.run()
160
+ result = ProcessStatus.OK
161
+ except Exception:
162
+ logger.exception("Error updating auth")
163
+ result = ProcessStatus.FAIL
164
+ self.post_message(AuthRunMessage(result))
165
+
166
+
167
+ class UpdateLogMessage(Message):
168
+ def __init__(self, name: str, value: str, *args, **kwargs):
169
+ super().__init__(*args, **kwargs)
170
+ self.proc_name = name
171
+ self.log_value = value
172
+
173
+
174
+ class AuthProcessList(ListView):
175
+ BINDINGS = [
176
+ Binding("j, down", "cursor_down", "Cursor down"),
177
+ Binding("k, up", "cursor_up", "Cursor up"),
178
+ Binding("o, enter", "select_cursor", "Select"),
179
+ Binding("r", "run_auth", "Run highlighted"),
180
+ ]
181
+
182
+ def __init__(self, procs: list[AuthProcess], *args, **kwargs):
183
+ super().__init__(*args, **kwargs)
184
+ self._auth_processes: list[AuthProcess] = procs
185
+ self._highlighted: AuthProcessItem
186
+ self.running = False
187
+ self.running_all = False
188
+ self.current_item = 0
189
+ self.set_interval(300, self.run_all)
190
+
191
+ def compose(self) -> ComposeResult:
192
+ for p in self._auth_processes:
193
+ yield AuthProcessItem(p)
194
+
195
+ def action_run_auth(self):
196
+ self.running = True
197
+ self._highlighted.status.value = ProcessStatus.RUNNING
198
+ self._highlighted.run_auth()
199
+
200
+ def on_list_view_selected(self, event: ListView.Selected):
201
+ logger.debug(f"Opening {event.item}")
202
+ proc: AuthProcessItem = event.item # type: ignore
203
+ proc_name = proc.process_config.name
204
+ log_value = proc.process_config.log_stream.getvalue()
205
+ self.post_message(UpdateLogMessage(proc_name, log_value))
206
+
207
+ def on_list_view_highlighted(self, event: ListView.Highlighted):
208
+ logger.debug(f"Item is {event.item}")
209
+ self._highlighted = event.item # type: ignore
210
+
211
+ def run_all(self):
212
+ self.running_all = True
213
+ self.running = True
214
+ self.current_item = 0
215
+ self.run_next()
216
+
217
+ def run_next(self):
218
+ if self.current_item >= len(self.children):
219
+ self.running_all = False
220
+ else:
221
+ self.running = True
222
+ item: AuthProcessItem = self.children[self.current_item] # type: ignore
223
+ item.run_auth()
224
+
225
+ @on(AuthRunCompleteMessage)
226
+ def handle_auth_run_complete(self):
227
+ self.running = False
228
+ if self.running_all:
229
+ self.current_item += 1
230
+ self.run_next()
231
+
232
+
233
+ class ProcessLogHeader(Widget):
234
+ process = reactive("none")
235
+
236
+ def render(self):
237
+ return f"Log for process: {self.process}"
238
+
239
+
240
+ class LogScreen(ModalScreen):
241
+ BINDINGS = [Binding("x", "close_log", "Close")]
242
+
243
+ def __init__(self, auth_name: str, log_value: str, *args, **kwargs):
244
+ super().__init__(*args, **kwargs)
245
+ self.auth_name = auth_name
246
+ self.log_value = log_value
247
+
248
+ def compose(self) -> ComposeResult:
249
+ yield Label(f"Log for {self.auth_name}")
250
+ yield Rule()
251
+
252
+ log = Log()
253
+ yield log
254
+ log.write(self.log_value)
255
+ yield Footer()
256
+
257
+ def action_close_log(self):
258
+ self.app.pop_screen()
259
+
260
+
261
+ class AuthTUI(App):
262
+ BINDINGS = [Binding("q", "quit", "Quit")]
263
+ CSS_PATH = "tui.tcss"
264
+
265
+ def __init__(self, processes: list[AuthProcess], **kwargs):
266
+ super().__init__(**kwargs)
267
+ self._processes = processes
268
+
269
+ def compose(self) -> ComposeResult:
270
+ yield AuthProcessList(self._processes, id="auth_list")
271
+ yield Footer()
272
+
273
+ @on(UpdateLogMessage)
274
+ def handle_update_log(self, msg: UpdateLogMessage):
275
+ self.push_screen(LogScreen(msg.proc_name, msg.log_value))
276
+
277
+
278
+ def run_tui(config_file: str):
279
+ logger.addHandler(TextualHandler())
280
+ processes = load_config(config_file)
281
+ app = AuthTUI(processes)
282
+ app.run()
@@ -0,0 +1,11 @@
1
+ ProcessLogHeader {
2
+ height: 1;
3
+ }
4
+
5
+ LogScreen {
6
+ align: center middle;
7
+ height: 80%;
8
+ width: 80%;
9
+ background: $surface;
10
+ padding: 0 1;
11
+ }
@@ -0,0 +1,34 @@
1
+ import sqlite3
2
+
3
+ import yaml
4
+
5
+
6
+ def bootstrap_tui_config(input_config: str, output_config: str):
7
+ tui_config = []
8
+ with sqlite3.connect(input_config) as conn:
9
+ res = conn.execute("SELECT * FROM aws_profiles").fetchall()
10
+ for r in res:
11
+ profile_name = r[0]
12
+ tui_config.append(
13
+ {"name": f"AWS ({profile_name})", "type": "aws", "profile": profile_name}
14
+ )
15
+
16
+ res = conn.execute("SELECT * FROM dbx_profiles").fetchall()
17
+ for r in res:
18
+ profile_name = r[0]
19
+ alias = r[1]
20
+ config_entry = {
21
+ "name": f"Databricks ({profile_name})",
22
+ "type": "dbx",
23
+ "profile": profile_name,
24
+ }
25
+ if alias:
26
+ config_entry["alias"] = alias
27
+ tui_config.append(config_entry)
28
+
29
+ res = conn.execute("SELECT kion_cli_config_file FROM config").fetchall()
30
+ if res[0][0]:
31
+ tui_config.append({"name": "Kion", "type": "kion-cli", "config": res[0][0]})
32
+
33
+ with open(output_config, "w") as f:
34
+ yaml.dump(tui_config, f)
@@ -0,0 +1,49 @@
1
+ import logging
2
+ import logging.handlers
3
+ import os
4
+ import subprocess # nosec
5
+ import sys
6
+
7
+ HOME = str(os.getenv("HOME"))
8
+
9
+
10
+ def configure_logging(log: str, level: str) -> None:
11
+ if log in ("stdout", "-"):
12
+ handler = logging.StreamHandler(sys.stdout)
13
+ else:
14
+ handler = logging.handlers.RotatingFileHandler(
15
+ log,
16
+ backupCount=1,
17
+ maxBytes=1024 * 1024,
18
+ )
19
+ try:
20
+ log_level = logging.getLevelNamesMapping()[level]
21
+ except Exception:
22
+ raise ValueError(f"Unhandled log level {level}")
23
+ logging.basicConfig(
24
+ format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
25
+ level=log_level,
26
+ handlers=[handler],
27
+ )
28
+
29
+
30
+ def log_command(
31
+ logger: logging.Logger, command: list[str], **kwargs
32
+ ) -> subprocess.CompletedProcess:
33
+ logger.info(f"Run {' '.join(command)}")
34
+ result = subprocess.run(command, capture_output=True, **kwargs) # nosec
35
+ logger.info("--- BEGIN PROCESS STDOUT ---")
36
+ for line in result.stdout.decode("utf-8").strip().split("\n"):
37
+ logger.info(line)
38
+ logger.info("--- END PROCESS STDOUT ---")
39
+ logger.info("--- BEGIN PROCESS STDERR ---")
40
+ for line in result.stderr.decode("utf-8").strip().split("\n"):
41
+ logger.error(line)
42
+ logger.info("--- END PROCESS STDERR ---")
43
+ return result
44
+
45
+
46
+ def log_command_output(logger: logging.Logger, command: list[str], **kwargs) -> str:
47
+ logger.info(f"Run: {' '.join(command)}")
48
+ output = subprocess.check_output(command, **kwargs) # nosec
49
+ return output.decode("utf-8").strip()