1password-secrets 0.0.1.dev218__tar.gz → 0.0.1.dev236__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 1password-secrets
3
- Version: 0.0.1.dev218
3
+ Version: 0.0.1.dev236
4
4
  Summary: 1password-secrets is a set of utilities to sync 1password secrets.
5
5
  Home-page: https://github.com/significa/fly-1password-secrets
6
6
  Author: Significa
@@ -18,9 +18,9 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: python-dotenv==1.2.1
22
- Requires-Dist: sgqlc==18
23
- Requires-Dist: rich==14.3.2
21
+ Requires-Dist: python-dotenv==1.2.4
22
+ Requires-Dist: requests==2.32.5
23
+ Requires-Dist: rich==15.0.0
24
24
  Requires-Dist: inquirer==3.4.1
25
25
  Dynamic: author
26
26
  Dynamic: classifier
@@ -0,0 +1,4 @@
1
+ python-dotenv==1.2.4
2
+ requests==2.32.5
3
+ rich==15.0.0
4
+ inquirer==3.4.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 1password-secrets
3
- Version: 0.0.1.dev218
3
+ Version: 0.0.1.dev236
4
4
  Summary: 1password-secrets is a set of utilities to sync 1password secrets.
5
5
  Home-page: https://github.com/significa/fly-1password-secrets
6
6
  Author: Significa
@@ -18,9 +18,9 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: python-dotenv==1.2.1
22
- Requires-Dist: sgqlc==18
23
- Requires-Dist: rich==14.3.2
21
+ Requires-Dist: python-dotenv==1.2.4
22
+ Requires-Dist: requests==2.32.5
23
+ Requires-Dist: rich==15.0.0
24
24
  Requires-Dist: inquirer==3.4.1
25
25
  Dynamic: author
26
26
  Dynamic: classifier
@@ -12,20 +12,23 @@ from tempfile import NamedTemporaryFile
12
12
  from typing import NoReturn
13
13
 
14
14
  import inquirer
15
+ import requests
15
16
  from dotenv import dotenv_values
16
17
  from rich.console import Console
17
18
  from rich.panel import Panel
18
19
  from rich.table import Table
19
20
  from rich.text import Text
20
- from sgqlc.endpoint.http import HTTPEndpoint
21
21
 
22
- FLY_GRAPHQL_ENDPOINT = "https://api.fly.io/graphql"
22
+ FLY_MACHINES_API_ENDPOINT = "https://api.machines.dev/v1"
23
+ FLY_API_TIMEOUT_SECONDS = 30
23
24
  DATE_FORMAT = "%Y/%m/%d %H:%M:%S"
24
25
  DEFAULT_ENV_FILE_NAME = ".env"
25
26
  ONE_PASSWORD_FILE_PATH_FIELD_NAME = "file_name" # noqa: S105
26
27
  ONE_PASSWORD_NOTES_CONTENT_FIELD_NAME = "notesPlain" # noqa: S105
27
28
  ONE_PASSWORD_SECURE_NOTE_CATEGORY = "Secure Note" # noqa: S105
28
29
  DEFAULT_REMOTE_NAME = "origin"
30
+ REDACTED_PLACEHOLDER = "<REDACTED>" # noqa: S105
31
+ SENSITIVE_HEADER_NAMES = frozenset({"authorization", "proxy-authorization", "cookie", "set-cookie"})
29
32
 
30
33
  console = Console()
31
34
 
@@ -64,6 +67,63 @@ def _setup_logger():
64
67
  logger = _setup_logger()
65
68
 
66
69
 
70
+ def _redact_headers(headers) -> dict:
71
+ return {
72
+ name: (REDACTED_PLACEHOLDER if name.lower() in SENSITIVE_HEADER_NAMES else value)
73
+ for name, value in dict(headers or {}).items()
74
+ }
75
+
76
+
77
+ def _format_command(command_args) -> str:
78
+ return " ".join((f'"{arg}"' if " " in arg else arg) for arg in command_args)
79
+
80
+
81
+ def _debug_enabled() -> bool:
82
+ return logger.isEnabledFor(logging.DEBUG)
83
+
84
+
85
+ def _debug_request(method, url, headers, json_body) -> None:
86
+ if not _debug_enabled():
87
+ return
88
+
89
+ lines = [f"Request: {method} {url}", "Request headers:"]
90
+ lines += [f" {name}: {value}" for name, value in _redact_headers(headers).items()]
91
+
92
+ if json_body is not None:
93
+ lines += ["Request body:", json.dumps(json_body, indent=2)]
94
+
95
+ logger.debug("\n".join(lines))
96
+
97
+
98
+ def _debug_response(response) -> None:
99
+ if not _debug_enabled():
100
+ return
101
+
102
+ lines = [f"Response: {response.status_code}", "Response headers:"]
103
+ lines += [f" {name}: {value}" for name, value in _redact_headers(response.headers).items()]
104
+ lines += ["Response body:", response.text]
105
+
106
+ logger.debug("\n".join(lines))
107
+
108
+
109
+ def _debug_command(command_args, output=None, returncode=None, redact_output=False) -> None:
110
+ if not _debug_enabled():
111
+ return
112
+
113
+ lines = [f"Running command: {_format_command(command_args)}"]
114
+
115
+ if returncode is not None:
116
+ lines.append(f"Exit code: {returncode}")
117
+
118
+ if output is not None:
119
+ if isinstance(output, bytes):
120
+ output = output.decode("utf-8", errors="replace")
121
+
122
+ lines += ["Output:", REDACTED_PLACEHOLDER if redact_output else output]
123
+
124
+ logger.debug("\n".join(lines))
125
+
126
+
67
127
  def get_1password_env_file_item_id(title_substring, vault=None):
68
128
  secure_notes = json.loads(
69
129
  _run_1password_command(
@@ -129,9 +189,11 @@ def get_filename_from_1password(item_id, vault=None):
129
189
 
130
190
 
131
191
  def get_fly_auth_token():
132
- return json.loads(
133
- subprocess.check_output(["fly", "auth", "token", "--json"]) # noqa: S603, S607
134
- )["token"]
192
+ command_args = ["fly", "auth", "token", "--json"]
193
+ output = subprocess.check_output(command_args) # noqa: S603
194
+ _debug_command(command_args, output=output, returncode=0, redact_output=True)
195
+
196
+ return json.loads(output)["token"]
135
197
 
136
198
 
137
199
  def _get_file_contents(filepath, raise_if_not_found=True):
@@ -160,132 +222,120 @@ def _boolean_prompt(prompt: str, default: bool = False) -> bool:
160
222
  return answers["confirm"]
161
223
 
162
224
 
163
- def _make_fly_graphql_request(graphql_query, variables, status_message="Communicating with Fly.io"):
225
+ def _make_fly_api_request(
226
+ method,
227
+ path,
228
+ json_body=None,
229
+ token=None,
230
+ status_message="Communicating with Fly.io",
231
+ ):
232
+ url = f"{FLY_MACHINES_API_ENDPOINT}{path}"
233
+ headers = {
234
+ "Authorization": f"Bearer {token or get_fly_auth_token()}",
235
+ "Content-Type": "application/json",
236
+ }
237
+
238
+ _debug_request(method, url, headers, json_body)
239
+
164
240
  with console.status(f"[bold cyan]{status_message}...", spinner="dots"):
165
- headers = {"Authorization": f"Bearer {get_fly_auth_token()}"}
241
+ try:
242
+ response = requests.request(
243
+ method,
244
+ url,
245
+ headers=headers,
246
+ json=json_body,
247
+ timeout=FLY_API_TIMEOUT_SECONDS,
248
+ )
249
+ except requests.RequestException as error:
250
+ raise_error(f"Failed to reach the Fly API: {error}")
166
251
 
167
- endpoint = HTTPEndpoint(FLY_GRAPHQL_ENDPOINT, headers)
252
+ _debug_response(response)
168
253
 
169
- response = endpoint(query=graphql_query, variables=variables)
254
+ if not response.ok:
255
+ try:
256
+ body = response.json()
257
+ except ValueError:
258
+ body = response.text
170
259
 
171
- logger.debug(
172
- f"Fly request:\n{graphql_query}\n{json.dumps(variables, indent=2)}\n\n"
173
- f"Fly response:\n{json.dumps(response, indent=2)}\n"
174
- )
260
+ message = body.get("error") if isinstance(body, dict) else body
261
+ raise_error(f"Fly API error ({response.status_code}): {message or 'unknown error'}")
175
262
 
176
- if response.get("errors") is not None:
177
- raise_error(json.dumps(response["errors"][0]))
263
+ if not response.content:
264
+ return None
178
265
 
179
- return response["data"]
266
+ try:
267
+ return response.json()
268
+ except ValueError:
269
+ return None
180
270
 
181
271
 
182
272
  def update_fly_secrets(app_id, secrets):
183
- secrets_input = [{"key": key, "value": value} for key, value in secrets.items()]
184
-
185
- last_update_secrets_response = _make_fly_graphql_request(
186
- """
187
- mutation(
188
- $appId: ID!
189
- $secrets: [SecretInput!]!
190
- $replaceAll: Boolean!
191
- ) {
192
- setSecrets(
193
- input: {
194
- appId: $appId
195
- replaceAll: $replaceAll
196
- secrets: $secrets
197
- }
198
- ) {
199
- app {
200
- name
201
- }
202
- release {
203
- version
204
- }
205
- }
206
- }
207
- """,
208
- {"appId": app_id, "secrets": secrets_input, "replaceAll": True},
209
- status_message="Uploading secrets to Fly",
273
+ token = get_fly_auth_token()
274
+
275
+ current_secrets_response = (
276
+ _make_fly_api_request(
277
+ "GET",
278
+ f"/apps/{app_id}/secrets",
279
+ token=token,
280
+ status_message="Fetching current Fly secrets",
281
+ )
282
+ or {}
210
283
  )
211
284
 
212
- get_secrets_response = _make_fly_graphql_request(
213
- """
214
- query(
215
- $appName: String
216
- ) {
217
- app(name: $appName){
218
- secrets{
219
- name
220
- }
221
- }
222
- }
223
- """,
224
- {
225
- "appName": app_id,
226
- },
227
- status_message="Fetching current Fly secrets",
228
- )
285
+ secret_names_in_fly = {
286
+ secret["name"]
287
+ for secret in current_secrets_response.get("secrets") or []
288
+ if secret.get("name")
289
+ }
229
290
 
230
- secrets_names_in_env_file = set(secrets.keys())
231
- secret_names_in_fly = {secret["name"] for secret in get_secrets_response["app"]["secrets"]}
291
+ secrets_names_in_fly_only = secret_names_in_fly.difference(secrets.keys())
232
292
 
233
- secrets_names_in_fly_only = secret_names_in_fly.difference(secrets_names_in_env_file)
293
+ deletions = {}
234
294
 
235
295
  if len(secrets_names_in_fly_only) > 0 and _boolean_prompt(
236
296
  "The following secrets will be deleted from Fly: {}. Are you sure".format(
237
297
  ", ".join(sorted(secrets_names_in_fly_only))
238
298
  )
239
299
  ):
240
- last_update_secrets_response = _make_fly_graphql_request(
241
- """
242
- mutation(
243
- $appId: ID!
244
- $secretNames: [String!]!
245
- ) {
246
- unsetSecrets(
247
- input: {
248
- appId: $appId
249
- keys: $secretNames
250
- }
251
- ){
252
- release{
253
- id
254
- }
255
- }
256
- }
257
- """,
258
- {
259
- "appId": app_id,
260
- "secretNames": list(secrets_names_in_fly_only),
261
- },
262
- status_message="Removing deleted secrets from Fly",
263
- )
300
+ deletions = dict.fromkeys(secrets_names_in_fly_only, None)
264
301
 
265
- release = last_update_secrets_response.get("setSecrets", {}).get("release", None)
302
+ values = {**secrets, **deletions}
266
303
 
267
- if release:
268
- release_version = release.get("version", "unknown")
269
- console.print(
270
- f"[bold green]Releasing Fly app '{app_id}' version {release_version}[/bold green]"
271
- )
272
- else:
304
+ if not values:
273
305
  console.print()
274
- console.print("[dim]Fly secrets updated, no release created.[/dim]")
275
- if _boolean_prompt("Deploy secrets now"):
276
- _deploy_fly_secrets(app_id)
306
+ console.print("[dim]No secret changes to push to Fly.[/dim]")
307
+ return
308
+
309
+ _make_fly_api_request(
310
+ "POST",
311
+ f"/apps/{app_id}/secrets",
312
+ json_body={"values": values},
313
+ token=token,
314
+ status_message="Uploading secrets to Fly",
315
+ )
316
+
317
+ console.print()
318
+ console.print(f"[bold green]Secrets staged on Fly app '{app_id}'[/bold green]")
319
+
320
+ if _boolean_prompt("Deploy secrets now"):
321
+ _deploy_fly_secrets(app_id)
277
322
 
278
323
 
279
324
  def _deploy_fly_secrets(app_id):
280
- """Deploy secrets to a Fly app using the flyctl CLI."""
325
+ """Deploy secrets to a Fly app using the fly CLI."""
281
326
  console.print()
282
327
  console.print(f"[bold cyan]Deploying secrets to Fly app '{app_id}'...[/bold cyan]")
283
328
  console.print()
284
329
 
285
- result = subprocess.run( # noqa: S603
286
- ["flyctl", "secrets", "deploy", "-a", app_id], # noqa: S607
287
- check=False,
288
- )
330
+ command_args = ["fly", "secrets", "deploy", "-a", app_id]
331
+ _debug_command(command_args)
332
+
333
+ try:
334
+ result = subprocess.run(command_args, check=False) # noqa: S603
335
+ except FileNotFoundError:
336
+ raise_error("The 'fly' CLI was not found. See https://fly.io/docs/flyctl/install/")
337
+
338
+ _debug_command(command_args, returncode=result.returncode)
289
339
 
290
340
  if result.returncode != 0:
291
341
  raise_error(f"Failed to deploy secrets (exit code {result.returncode})")
@@ -352,18 +402,16 @@ def _run_1password_command(*args, vault=None, json_output=True, status_message=N
352
402
  if json_output:
353
403
  command_args.extend(["--format", "json"])
354
404
 
355
- logger.debug(
356
- "Running command: {}".format(
357
- " ".join((f'"{arg}"' if " " in arg else arg) for arg in command_args)
358
- )
359
- )
360
-
361
405
  def run_command():
362
406
  try:
363
- return subprocess.check_output(command_args) # noqa: S603
407
+ output = subprocess.check_output(command_args) # noqa: S603
364
408
  except subprocess.CalledProcessError as e:
409
+ _debug_command(command_args, returncode=e.returncode)
365
410
  raise_error(f"1Password command failed with exit code {e.returncode}")
366
411
 
412
+ _debug_command(command_args, output=output, returncode=0)
413
+ return output
414
+
367
415
  if status_message:
368
416
  with console.status(f"[bold cyan]{status_message}...", spinner="dots"):
369
417
  return run_command()
@@ -484,7 +532,10 @@ def edit_1password_fly_secrets(app_id, vault=None):
484
532
  )
485
533
  )
486
534
 
487
- subprocess.check_output(["code", "--wait", "--disable-extensions", file.name]) # noqa: S603, S607
535
+ editor_command_args = ["code", "--wait", "--disable-extensions", file.name]
536
+ _debug_command(editor_command_args)
537
+ editor_output = subprocess.check_output(editor_command_args) # noqa: S603
538
+ _debug_command(editor_command_args, output=editor_output, returncode=0)
488
539
 
489
540
  console.print("[green]Editor closed.[/green]")
490
541
  console.print()
@@ -578,26 +629,23 @@ def _get_git_remote_name(remote=DEFAULT_REMOTE_NAME) -> tuple[str | None, str |
578
629
  git_repository_regex = r"^(\w+)(:\/\/|@)([^\/:]+)[\/:]([^\/:]+)\/(.+).git$"
579
630
 
580
631
  git_remote_url = None
632
+ command_args = ["git", "config", "--get", f"remote.{remote}.url"]
581
633
 
582
634
  try:
583
635
  git_remote_url = (
584
- subprocess.check_output( # noqa: S603
585
- [ # noqa: S607
586
- "git",
587
- "config",
588
- "--get",
589
- f"remote.{remote}.url",
590
- ]
591
- )
636
+ subprocess.check_output(command_args) # noqa: S603
592
637
  .decode("utf-8")
593
638
  .strip()
594
639
  )
640
+ _debug_command(command_args, output=git_remote_url, returncode=0)
595
641
 
596
642
  except FileNotFoundError:
643
+ _debug_command(command_args, output="git not in the PATH")
597
644
  return ("git not in the PATH", None)
598
645
 
599
646
  except subprocess.CalledProcessError as error:
600
647
  exit_code, _command = error.args
648
+ _debug_command(command_args, returncode=exit_code)
601
649
 
602
650
  if exit_code == 1:
603
651
  return (f"Either not in a git repository or remote {remote!r} is not set", None)
@@ -666,7 +714,10 @@ def main():
666
714
  "--debug",
667
715
  action=argparse.BooleanOptionalAction,
668
716
  default=False,
669
- help="run in debug mode",
717
+ help=(
718
+ "run in debug mode, logging every command, HTTP request and response "
719
+ "(authorization headers and the fly auth token are redacted)"
720
+ ),
670
721
  )
671
722
 
672
723
  parser.add_argument(
@@ -1,4 +0,0 @@
1
- python-dotenv==1.2.1
2
- sgqlc==18
3
- rich==14.3.2
4
- inquirer==3.4.1