login-auth-tui 0.3__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,4 @@
1
+ from login_auth_tui.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -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)
login_auth_tui/cli.py ADDED
@@ -0,0 +1,254 @@
1
+ import copy
2
+ import logging
3
+ import os
4
+ import sqlite3
5
+ import sys
6
+ import time
7
+
8
+ import click
9
+
10
+ from .aws_mfa import aws_mfa
11
+ from .dbx_token_rotate import manage_dbx_tokens
12
+ from .kion_auth_password import update_kion_auth_password
13
+ from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
14
+ from .tui import run_tui
15
+ from .tui_bootstrap import bootstrap_tui_config
16
+ from .util import configure_logging, log_command
17
+
18
+ TWELVE_HOURS = 12 * 60 * 60
19
+ DEFAULT_LOG = os.path.join(str(os.getenv("HOME")), ".logs", "login-auth.log")
20
+
21
+
22
+ class CliArgs:
23
+ __slots__ = ["force", "log", "level"]
24
+
25
+ def __init__(self):
26
+ self.force = False
27
+ self.log = DEFAULT_LOG
28
+ self.level = "INFO"
29
+
30
+
31
+ @click.group()
32
+ @click.version_option()
33
+ @click.option(
34
+ "--force",
35
+ is_flag=True,
36
+ default=False,
37
+ help="Force update regardless of last update time (only aws, batch, and dbx).",
38
+ )
39
+ @click.option(
40
+ "--log",
41
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True, allow_dash=True),
42
+ default=DEFAULT_LOG,
43
+ help="File to log to ('-' for stdout).",
44
+ )
45
+ @click.option(
46
+ "--level",
47
+ type=click.Choice([k for k in logging.getLevelNamesMapping().keys() if k != "NOTSET"]),
48
+ default="INFO",
49
+ help="Logging level to use.",
50
+ )
51
+ @click.pass_context
52
+ def cli(ctx: click.Context, force: bool, log: str, level: str):
53
+ """Run authentication useful when developing on DataConnect."""
54
+ ctx.ensure_object(CliArgs)
55
+ ctx.obj.force = force
56
+ ctx.obj.log = log
57
+ ctx.obj.level = level
58
+
59
+
60
+ def login_auth(force: bool, conn: sqlite3.Connection) -> None:
61
+ # Now that we hold an exclusive lock on the config, see if we actually need
62
+ # to do anything.
63
+ last_update = conn.execute("SELECT * FROM last_update").fetchone()[0]
64
+ now = time.time()
65
+ if last_update < (now - TWELVE_HOURS):
66
+ logging.info("More than twelve hours since last update.")
67
+ elif force:
68
+ logging.info("Running due to force")
69
+ else:
70
+ logging.info("Not running; auth does not need to be updated")
71
+ return
72
+
73
+ res = conn.execute("SELECT * FROM config")
74
+ config = dict(zip([r[0] for r in res.description], res.fetchone()))
75
+
76
+ if config["wait"]:
77
+ logging.info(f"Waiting {config['wait']} seconds for boot...")
78
+ time.sleep(config["wait"])
79
+
80
+ if config["ssh_add"]:
81
+ logging.info("Adding SSH keys...")
82
+ env = copy.deepcopy(os.environ)
83
+ args = ["/usr/bin/ssh-add"]
84
+ if sys.platform == "darwin":
85
+ env["APPLE_SSH_ADD_BEHAVIOR"] = "yes"
86
+ args.append("-A")
87
+ log_command(logging.getLogger(), args, env=env)
88
+
89
+ success = True
90
+ res = conn.execute("SELECT * FROM aws_profiles")
91
+ aws_profiles = [r[0] for r in res.fetchall()]
92
+ for aws_profile in aws_profiles:
93
+ logging.info(f"Authenticating AWS profile {aws_profile}")
94
+ try:
95
+ aws_mfa(aws_profile, force)
96
+ except Exception:
97
+ logging.exception(f"Exception running AWS MFA {aws_profile}")
98
+ success = False
99
+
100
+ res = conn.execute("SELECT * FROM dbx_profiles")
101
+ for row in res.fetchall():
102
+ profile, alias = row
103
+ logging.info(f"Rotating DBX token {profile} (alias={alias})")
104
+ try:
105
+ manage_dbx_tokens(profile, [alias], force)
106
+ except Exception:
107
+ logging.exception(f"Exception rotating DBX token {profile}")
108
+ success = False
109
+
110
+ kion_auth_config_file = config["kion_auth_config_file"]
111
+ if kion_auth_config_file:
112
+ logging.info("Update kion-auth config file with current password")
113
+ update_kion_auth_password(kion_auth_config_file)
114
+
115
+ kion_cli_config_file = config["kion_cli_config_file"]
116
+ if kion_cli_config_file:
117
+ logging.info("Update kion cli config file with current password")
118
+ update_kion_cli_password(kion_cli_config_file)
119
+
120
+ if success:
121
+ logging.info("Success!")
122
+ conn.execute(f"UPDATE last_update SET tstamp = {int(now)}") # nosec
123
+ else:
124
+ logging.info("Failed")
125
+
126
+
127
+ @cli.command("batch")
128
+ @click.option("--background", is_flag=True, default=False, help="Run in the background.")
129
+ @click.option(
130
+ "--config",
131
+ type=click.Path(dir_okay=False, exists=True, resolve_path=True),
132
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
133
+ help="Path to configuration database.",
134
+ )
135
+ @click.pass_context
136
+ def login_auth_main(ctx: click.Context, background: bool, config: str) -> None:
137
+ """Run all the login management in one batch."""
138
+ if background:
139
+ # TODO - run in the background
140
+ pid = os.fork()
141
+ if pid != 0:
142
+ # This is the parent, we done
143
+ return
144
+
145
+ # This is the child, detach from the parent
146
+ os.setpgrp()
147
+
148
+ configure_logging(ctx.obj.log, ctx.obj.level)
149
+
150
+ conn = sqlite3.connect(config)
151
+ conn.execute("BEGIN EXCLUSIVE TRANSACTION")
152
+
153
+ try:
154
+ login_auth(ctx.obj.force, conn)
155
+ except Exception:
156
+ logging.exception("Exception doing login_auth")
157
+ conn.execute("ROLLBACK")
158
+ raise
159
+ else:
160
+ logging.info("Commit transaction")
161
+ conn.execute("COMMIT")
162
+
163
+
164
+ @cli.command("aws")
165
+ @click.argument("profile")
166
+ @click.pass_context
167
+ def aws_mfa_main(ctx: click.Context, profile: str) -> None:
168
+ """Run AWS MFA process.
169
+
170
+ PROFILE the AWS profile to run the MFA process for.
171
+ """
172
+ configure_logging(ctx.obj.log, ctx.obj.level)
173
+
174
+ aws_mfa(profile, ctx.obj.force)
175
+
176
+
177
+ @cli.command("dbx")
178
+ @click.option("--alias", multiple=True, help="Other profiles that are aliases.")
179
+ @click.argument("profile")
180
+ @click.pass_context
181
+ def dbx_token_rotate_main(ctx: click.Context, alias: tuple[str], profile: str) -> None:
182
+ """Rotate Databricks API token.
183
+
184
+ PROFILE the Databricks configuration profile to rotate the API token for.
185
+ """
186
+ configure_logging(ctx.obj.log, ctx.obj.level)
187
+
188
+ manage_dbx_tokens(profile, alias, ctx.obj.force)
189
+
190
+
191
+ @cli.command("kion-auth")
192
+ @click.option(
193
+ "--file",
194
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
195
+ default=os.path.join(str(os.getenv("HOME")), ".config", "kion-auth.ini"),
196
+ help="Path of kion-auth configuration file.",
197
+ )
198
+ @click.pass_context
199
+ def update_kion_auth_password_main(ctx: click.Context, file: str) -> None:
200
+ """Update the password used by kion-auth from 1Password."""
201
+ configure_logging(ctx.obj.log, ctx.obj.level)
202
+
203
+ update_kion_auth_password(file)
204
+
205
+
206
+ @cli.command("kion-cli")
207
+ @click.option(
208
+ "--file",
209
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
210
+ default=DEFAULT_CONFIG_FILE,
211
+ help="Path of kion cli configuration file.",
212
+ )
213
+ @click.pass_context
214
+ def update_kion_cli_password_main(ctx: click.Context, file: str) -> None:
215
+ """Update the password used by the kion cli from 1Password."""
216
+ configure_logging(ctx.obj.log, ctx.obj.level)
217
+
218
+ update_kion_cli_password(file)
219
+
220
+
221
+ @cli.command("tui")
222
+ @click.option(
223
+ "--config",
224
+ type=click.Path(exists=True, dir_okay=False, writable=True, resolve_path=True),
225
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
226
+ help="Path of TUI configuration file.",
227
+ )
228
+ @click.pass_context
229
+ def tui(ctx: click.Context, config: str) -> None:
230
+ """Run the TUI for authentication processes."""
231
+ configure_logging(ctx.obj.log, ctx.obj.level)
232
+ run_tui(config)
233
+
234
+
235
+ @cli.command("tui-bootstrap")
236
+ @click.option(
237
+ "--in-config",
238
+ type=click.Path(dir_okay=False, exists=True, resolve_path=True),
239
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login-auth.sqlite"),
240
+ help="Path to configuration database.",
241
+ )
242
+ @click.option(
243
+ "--out-config",
244
+ type=click.Path(dir_okay=False, writable=True, resolve_path=True),
245
+ default=os.path.join(str(os.getenv("HOME")), ".config", "login_auth.yml"),
246
+ help="Path of TUI configuration file.",
247
+ )
248
+ @click.pass_context
249
+ def tui_bootstrap(ctx: click.Context, in_config: str, out_config: str) -> None:
250
+ """Bootstrap the tui configuration from the batch configuration."""
251
+ configure_logging(ctx.obj.log, ctx.obj.level)
252
+ if os.path.exists(out_config) and not ctx.obj.force:
253
+ raise ValueError(f"Not overwriting existing TUI config {out_config}")
254
+ bootstrap_tui_config(in_config, out_config)
@@ -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))
login_auth_tui/noop.py ADDED
@@ -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)
login_auth_tui/tui.py ADDED
@@ -0,0 +1,259 @@
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_mfa import aws_mfa
25
+ from .dbx_token_rotate import manage_dbx_tokens
26
+ from .kion_cli_password import DEFAULT_CONFIG_FILE, update_kion_cli_password
27
+ from .noop import run_noop
28
+
29
+ logger = logging.getLogger("tui")
30
+
31
+
32
+ class ProcessStatus(enum.Enum):
33
+ OK = 0
34
+ FAIL = 1
35
+ UNKNOWN = 2
36
+ RUNNING = 3
37
+
38
+
39
+ # Each process will write to its own io.StringIO
40
+ class AuthProcess:
41
+ def __init__(self, process_config: dict):
42
+ self.name = process_config["name"]
43
+ self.type = process_config["type"]
44
+
45
+ if self.type == "aws":
46
+ self.profile = process_config["profile"]
47
+ elif self.type == "dbx":
48
+ self.profile = process_config["profile"]
49
+ self.aliases = []
50
+ alias = process_config.get("alias")
51
+ if alias:
52
+ self.aliases.append(alias)
53
+ elif self.type == "kion-cli":
54
+ self.config = process_config.get("config", DEFAULT_CONFIG_FILE)
55
+ elif self.type == "noop":
56
+ # This is used for testing things
57
+ pass
58
+ else:
59
+ raise ValueError(f"Unexpected auth process type: {self.type}")
60
+
61
+ self.log_stream = io.StringIO()
62
+ self.log_handler = logging.StreamHandler(self.log_stream)
63
+
64
+ def run(self):
65
+ logger.debug(f"Run {self.name} {self.type}")
66
+ process_logger = logging.getLogger(self.type)
67
+ process_logger.addHandler(self.log_handler)
68
+ try:
69
+ if self.type == "aws":
70
+ aws_mfa(self.profile, False)
71
+ elif self.type == "dbx":
72
+ manage_dbx_tokens(self.profile, self.aliases, False)
73
+ elif self.type == "kion-cli":
74
+ update_kion_cli_password(self.config)
75
+ elif self.type == "noop":
76
+ run_noop()
77
+ else:
78
+ raise ValueError(f"Unexpected auth process type: {self.type}")
79
+ finally:
80
+ process_logger.removeHandler(self.log_handler)
81
+
82
+
83
+ def load_config(config_file: str) -> list[AuthProcess]:
84
+ logger.debug(f"Loading config from {config_file}")
85
+ processes = []
86
+ with open(config_file) as f:
87
+ config = yaml.safe_load(f)
88
+ for auth_process in config:
89
+ processes.append(AuthProcess(auth_process))
90
+ return processes
91
+
92
+
93
+ class AuthProcessStatus(Widget):
94
+ value = reactive(ProcessStatus.UNKNOWN)
95
+
96
+ def render(self) -> str:
97
+ if self.value == ProcessStatus.OK:
98
+ return "\u2714"
99
+ if self.value == ProcessStatus.FAIL:
100
+ return "\u2716"
101
+ if self.value == ProcessStatus.RUNNING:
102
+ return "-"
103
+ return "?"
104
+
105
+
106
+ class AuthRunMessage(Message):
107
+ def __init__(self, status: ProcessStatus, *args, **kwargs):
108
+ super().__init__(*args, **kwargs)
109
+ self.status = status
110
+
111
+
112
+ class AuthRunCompleteMessage(Message):
113
+ pass
114
+
115
+
116
+ class AuthProcessItem(ListItem):
117
+ def __init__(self, proc: AuthProcess, *args, **kwargs):
118
+ super().__init__(*args, **kwargs)
119
+ self.process_config = proc
120
+ self.status = AuthProcessStatus()
121
+
122
+ def compose(self) -> ComposeResult:
123
+ # TODO - make use of more stuff here
124
+ yield HorizontalGroup(Label(self.process_config.name), Label(" "), self.status)
125
+
126
+ @on(AuthRunMessage)
127
+ def run_complete(self, msg: AuthRunMessage):
128
+ self.status.value = msg.status
129
+ self.post_message(AuthRunCompleteMessage())
130
+
131
+ @work(exclusive=True, thread=True)
132
+ def run_auth(self):
133
+ logger.debug(f"Run auth: {self}")
134
+ result = ProcessStatus.UNKNOWN
135
+ try:
136
+ self.process_config.run()
137
+ result = ProcessStatus.OK
138
+ except Exception:
139
+ logger.exception("Error updating auth")
140
+ result = ProcessStatus.FAIL
141
+ self.post_message(AuthRunMessage(result))
142
+
143
+
144
+ class UpdateLogMessage(Message):
145
+ def __init__(self, name: str, value: str, *args, **kwargs):
146
+ super().__init__(*args, **kwargs)
147
+ self.proc_name = name
148
+ self.log_value = value
149
+
150
+
151
+ class AuthProcessList(ListView):
152
+ BINDINGS = [
153
+ Binding("j, down", "cursor_down", "Cursor down"),
154
+ Binding("k, up", "cursor_up", "Cursor up"),
155
+ Binding("o, enter", "select_cursor", "Select"),
156
+ Binding("r", "run_auth", "Run highlighted"),
157
+ ]
158
+
159
+ def __init__(self, procs: list[AuthProcess], *args, **kwargs):
160
+ super().__init__(*args, **kwargs)
161
+ self._auth_processes: list[AuthProcess] = procs
162
+ self._highlighted: AuthProcessItem
163
+ self.running = False
164
+ self.running_all = False
165
+ self.current_item = 0
166
+ self.set_interval(300, self.run_all)
167
+
168
+ def compose(self) -> ComposeResult:
169
+ for p in self._auth_processes:
170
+ yield AuthProcessItem(p)
171
+
172
+ def action_run_auth(self):
173
+ self.running = True
174
+ self._highlighted.status.value = ProcessStatus.RUNNING
175
+ self._highlighted.run_auth()
176
+
177
+ def on_list_view_selected(self, event: ListView.Selected):
178
+ logger.debug(f"Opening {event.item}")
179
+ proc: AuthProcessItem = event.item # type: ignore
180
+ proc_name = proc.process_config.name
181
+ log_value = proc.process_config.log_stream.getvalue()
182
+ self.post_message(UpdateLogMessage(proc_name, log_value))
183
+
184
+ def on_list_view_highlighted(self, event: ListView.Highlighted):
185
+ logger.debug(f"Item is {event.item}")
186
+ self._highlighted = event.item # type: ignore
187
+
188
+ def run_all(self):
189
+ self.running_all = True
190
+ self.running = True
191
+ self.current_item = 0
192
+ self.run_next()
193
+
194
+ def run_next(self):
195
+ if self.current_item >= len(self.children):
196
+ self.running_all = False
197
+ else:
198
+ self.running = True
199
+ item: AuthProcessItem = self.children[self.current_item] # type: ignore
200
+ item.run_auth()
201
+
202
+ @on(AuthRunCompleteMessage)
203
+ def handle_auth_run_complete(self):
204
+ self.running = False
205
+ if self.running_all:
206
+ self.current_item += 1
207
+ self.run_next()
208
+
209
+
210
+ class ProcessLogHeader(Widget):
211
+ process = reactive("none")
212
+
213
+ def render(self):
214
+ return f"Log for process: {self.process}"
215
+
216
+
217
+ class LogScreen(ModalScreen):
218
+ BINDINGS = [Binding("x", "close_log", "Close")]
219
+
220
+ def __init__(self, auth_name: str, log_value: str, *args, **kwargs):
221
+ super().__init__(*args, **kwargs)
222
+ self.auth_name = auth_name
223
+ self.log_value = log_value
224
+
225
+ def compose(self) -> ComposeResult:
226
+ yield Label(f"Log for {self.auth_name}")
227
+ yield Rule()
228
+
229
+ log = Log()
230
+ yield log
231
+ log.write(self.log_value)
232
+ yield Footer()
233
+
234
+ def action_close_log(self):
235
+ self.app.pop_screen()
236
+
237
+
238
+ class AuthTUI(App):
239
+ BINDINGS = [Binding("q", "quit", "Quit")]
240
+ CSS_PATH = "tui.tcss"
241
+
242
+ def __init__(self, processes: list[AuthProcess], **kwargs):
243
+ super().__init__(**kwargs)
244
+ self._processes = processes
245
+
246
+ def compose(self) -> ComposeResult:
247
+ yield AuthProcessList(self._processes, id="auth_list")
248
+ yield Footer()
249
+
250
+ @on(UpdateLogMessage)
251
+ def handle_update_log(self, msg: UpdateLogMessage):
252
+ self.push_screen(LogScreen(msg.proc_name, msg.log_value))
253
+
254
+
255
+ def run_tui(config_file: str):
256
+ logger.addHandler(TextualHandler())
257
+ processes = load_config(config_file)
258
+ app = AuthTUI(processes)
259
+ 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)
login_auth_tui/util.py ADDED
@@ -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()
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: login-auth-tui
3
+ Version: 0.3
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,16 @@
1
+ login_auth_tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ login_auth_tui/__main__.py,sha256=NdADfCPzs5EAB6hEUNtEpxZNgLdWpqA5iuLGiQmNwlE,73
3
+ login_auth_tui/aws_mfa.py,sha256=rXOcl8DmwSBtRszhubUiT6DmNpGHT_t1oS9kXTPRh8s,2041
4
+ login_auth_tui/cli.py,sha256=3WXcB7uf3ZDBfrekZKk8Iwf1sznGBlgOtMpQ9MhmxiA,8250
5
+ login_auth_tui/dbx_token_rotate.py,sha256=vOp4WtyU6eSH9d0ZpjN5I7Y3J7jDwt0Uz2A2wKdSLuU,2748
6
+ login_auth_tui/kion_auth_password.py,sha256=TA90E0VbKJf2_L8i-Sk51MF0GSLlYyydhN92925KJ6M,697
7
+ login_auth_tui/kion_cli_password.py,sha256=vQM3xyOtSRl1K2KmUYGY5AoDCHCyH_mkoIeS8ZiviuY,783
8
+ login_auth_tui/noop.py,sha256=oqgboJVQte12AO55EPCpJEZ-Q-kRtdNX4bKiTaaOo5M,385
9
+ login_auth_tui/tui.py,sha256=AKSLt0Hq5ZVvDbFZt9tGimPSdPM1y43095DF2_EcuB0,7821
10
+ login_auth_tui/tui.tcss,sha256=T4bylO35mSyN4rkzhYiv80mNILXMaThV8PKVfksCvb0,158
11
+ login_auth_tui/tui_bootstrap.py,sha256=-WVRZwktQWkLtIoiTay5HupQxPufNMncKtDkSVNSnh0,1118
12
+ login_auth_tui/util.py,sha256=JWoyP1K5kvjsX2il5RNlR02HhvK0bqnyx1-yVaguAXg,1570
13
+ login_auth_tui-0.3.dist-info/WHEEL,sha256=5w2T7AS2mz1-rW9CNagNYWRCaB0iQqBMYLwKdlgiR4Q,78
14
+ login_auth_tui-0.3.dist-info/entry_points.txt,sha256=gudzjnTw1su5H2XCHjbHX2MfqDjEiozvUAEXqar9Fgk,56
15
+ login_auth_tui-0.3.dist-info/METADATA,sha256=rLVBYB2mlo0c8coKL9vbMOLKzzZ00Ia8HWEwND15nNE,1507
16
+ login_auth_tui-0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ auth-manage = login_auth_tui.cli:cli
3
+