1password-secrets 0.0.1.dev236__tar.gz → 0.0.1.dev238__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.
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/1password_secrets.egg-info/PKG-INFO +1 -1
- 1password_secrets-0.0.1.dev238/1password_secrets.egg-info/SOURCES.txt +22 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/PKG-INFO +1 -1
- 1password_secrets-0.0.1.dev238/onepassword_secrets/__init__.py +3 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/__main__.py +4 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/cli.py +103 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/commands.py +141 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/constants.py +10 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/envs.py +20 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/errors.py +12 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/fly.py +145 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/git.py +66 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/logs.py +90 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/op.py +188 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/prompts.py +71 -0
- 1password_secrets-0.0.1.dev238/onepassword_secrets/utils.py +19 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/setup.py +1 -1
- 1password_secrets-0.0.1.dev236/1password_secrets.egg-info/SOURCES.txt +0 -10
- 1password_secrets-0.0.1.dev236/onepassword_secrets.py +0 -784
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/1password_secrets.egg-info/dependency_links.txt +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/1password_secrets.egg-info/entry_points.txt +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/1password_secrets.egg-info/requires.txt +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/1password_secrets.egg-info/top_level.txt +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/README.md +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/pyproject.toml +0 -0
- {1password_secrets-0.0.1.dev236 → 1password_secrets-0.0.1.dev238}/setup.cfg +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
1password_secrets.egg-info/PKG-INFO
|
|
5
|
+
1password_secrets.egg-info/SOURCES.txt
|
|
6
|
+
1password_secrets.egg-info/dependency_links.txt
|
|
7
|
+
1password_secrets.egg-info/entry_points.txt
|
|
8
|
+
1password_secrets.egg-info/requires.txt
|
|
9
|
+
1password_secrets.egg-info/top_level.txt
|
|
10
|
+
onepassword_secrets/__init__.py
|
|
11
|
+
onepassword_secrets/__main__.py
|
|
12
|
+
onepassword_secrets/cli.py
|
|
13
|
+
onepassword_secrets/commands.py
|
|
14
|
+
onepassword_secrets/constants.py
|
|
15
|
+
onepassword_secrets/envs.py
|
|
16
|
+
onepassword_secrets/errors.py
|
|
17
|
+
onepassword_secrets/fly.py
|
|
18
|
+
onepassword_secrets/git.py
|
|
19
|
+
onepassword_secrets/logs.py
|
|
20
|
+
onepassword_secrets/op.py
|
|
21
|
+
onepassword_secrets/prompts.py
|
|
22
|
+
onepassword_secrets/utils.py
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from .commands import (
|
|
7
|
+
create_local_secrets,
|
|
8
|
+
edit_1password_fly_secrets,
|
|
9
|
+
import_1password_secrets_to_fly,
|
|
10
|
+
pull_local_secrets,
|
|
11
|
+
push_local_secrets,
|
|
12
|
+
)
|
|
13
|
+
from .constants import DEFAULT_REMOTE_NAME
|
|
14
|
+
from .errors import UserError
|
|
15
|
+
from .logs import logger
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
APP_VERSION = version("1password-secrets")
|
|
19
|
+
except ImportError:
|
|
20
|
+
APP_VERSION = "unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main():
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
description="1password-secrets is a set of utilities to sync 1Password secrets."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-v",
|
|
30
|
+
"--version",
|
|
31
|
+
action="version",
|
|
32
|
+
version=f"%(prog)s {APP_VERSION}",
|
|
33
|
+
help="show program's version number and exit",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--debug",
|
|
38
|
+
action=argparse.BooleanOptionalAction,
|
|
39
|
+
default=False,
|
|
40
|
+
help=(
|
|
41
|
+
"run in debug mode, logging every command, HTTP request and response "
|
|
42
|
+
"(authorization headers and the fly auth token are redacted)"
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--vault",
|
|
48
|
+
type=str,
|
|
49
|
+
default=None,
|
|
50
|
+
help=(
|
|
51
|
+
"Specify a vault name or id to operate on. "
|
|
52
|
+
"Defaults to all vaults across the logged in account."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--remote",
|
|
58
|
+
type=str,
|
|
59
|
+
default=DEFAULT_REMOTE_NAME,
|
|
60
|
+
help='Construct secret name based on this git remote. Defaults to "origin"',
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
64
|
+
|
|
65
|
+
fly_parser = subparsers.add_parser("fly", help="manage fly secrets")
|
|
66
|
+
fly_parser.add_argument("action", type=str, choices=["import", "edit"])
|
|
67
|
+
fly_parser.add_argument("app_name", type=str, help="fly application name")
|
|
68
|
+
|
|
69
|
+
local_parser = subparsers.add_parser("local", help="manage local secrets")
|
|
70
|
+
local_subparsers = local_parser.add_subparsers(dest="action", required=True)
|
|
71
|
+
|
|
72
|
+
local_subparsers.add_parser("pull")
|
|
73
|
+
local_subparsers.add_parser("push")
|
|
74
|
+
|
|
75
|
+
create_parser = local_subparsers.add_parser("create")
|
|
76
|
+
create_parser.add_argument(
|
|
77
|
+
"secrets_file_path",
|
|
78
|
+
type=str,
|
|
79
|
+
help="secrets file path",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
|
|
84
|
+
if args.debug:
|
|
85
|
+
logger.setLevel(logging.DEBUG)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if args.subcommand == "fly":
|
|
89
|
+
if args.action == "import":
|
|
90
|
+
import_1password_secrets_to_fly(args.app_name, vault=args.vault)
|
|
91
|
+
elif args.action == "edit":
|
|
92
|
+
edit_1password_fly_secrets(args.app_name, vault=args.vault)
|
|
93
|
+
|
|
94
|
+
elif args.subcommand == "local":
|
|
95
|
+
if args.action == "pull":
|
|
96
|
+
pull_local_secrets(remote=args.remote, vault=args.vault)
|
|
97
|
+
elif args.action == "push":
|
|
98
|
+
push_local_secrets(remote=args.remote, vault=args.vault)
|
|
99
|
+
elif args.action == "create":
|
|
100
|
+
create_local_secrets(args.secrets_file_path, vault=args.vault, remote=args.remote)
|
|
101
|
+
|
|
102
|
+
except UserError:
|
|
103
|
+
sys.exit(1)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""High level workflows wiring 1Password, Fly and the local filesystem together."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from tempfile import NamedTemporaryFile
|
|
7
|
+
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from .constants import DATE_FORMAT, DEFAULT_ENV_FILE_NAME, DEFAULT_REMOTE_NAME
|
|
11
|
+
from .envs import get_secrets_from_envs
|
|
12
|
+
from .fly import update_fly_secrets
|
|
13
|
+
from .git import get_secret_name_label_from_current_directory
|
|
14
|
+
from .logs import console, debug_command, logger
|
|
15
|
+
from .op import (
|
|
16
|
+
create_1password_secrets,
|
|
17
|
+
get_1password_env_file_item_id,
|
|
18
|
+
get_envs_from_1password,
|
|
19
|
+
get_filename_from_1password,
|
|
20
|
+
get_item_share_link,
|
|
21
|
+
update_1password_custom_field,
|
|
22
|
+
update_1password_secrets,
|
|
23
|
+
)
|
|
24
|
+
from .prompts import boolean_prompt, prompt_secret_diff
|
|
25
|
+
from .utils import get_file_contents
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def import_1password_secrets_to_fly(app_id, vault=None):
|
|
29
|
+
item_id = get_1password_env_file_item_id(f"fly:{app_id}", vault=vault)
|
|
30
|
+
|
|
31
|
+
secrets = get_secrets_from_envs(get_envs_from_1password(item_id, vault=vault))
|
|
32
|
+
|
|
33
|
+
logger.debug(f"Secrets loaded from env: {json.dumps(secrets, indent=2)}\n")
|
|
34
|
+
|
|
35
|
+
update_fly_secrets(app_id, secrets)
|
|
36
|
+
|
|
37
|
+
now_formatted = datetime.now(tz=timezone.utc).strftime(DATE_FORMAT)
|
|
38
|
+
update_1password_custom_field(item_id, "last imported at", now_formatted, vault=vault)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def edit_1password_fly_secrets(app_id, vault=None):
|
|
42
|
+
item_id = get_1password_env_file_item_id(f"fly:{app_id}", vault=vault)
|
|
43
|
+
|
|
44
|
+
current_raw_secrets = get_envs_from_1password(item_id, vault=vault)
|
|
45
|
+
|
|
46
|
+
with NamedTemporaryFile("w+", suffix=".env") as file:
|
|
47
|
+
file.writelines(current_raw_secrets)
|
|
48
|
+
file.flush()
|
|
49
|
+
|
|
50
|
+
console.print()
|
|
51
|
+
console.print(
|
|
52
|
+
Panel(
|
|
53
|
+
"[bold]Edit the secrets in your editor, then save and close the file to continue.[/bold]\n"
|
|
54
|
+
"[dim]Waiting for editor to close...[/dim]",
|
|
55
|
+
title="Editor",
|
|
56
|
+
border_style="cyan",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
editor_command_args = ["code", "--wait", "--disable-extensions", file.name]
|
|
61
|
+
debug_command(editor_command_args)
|
|
62
|
+
editor_output = subprocess.check_output(editor_command_args) # noqa: S603
|
|
63
|
+
debug_command(editor_command_args, output=editor_output, returncode=0)
|
|
64
|
+
|
|
65
|
+
console.print("[green]Editor closed.[/green]")
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
file.seek(0)
|
|
69
|
+
new_raw_secrets = file.read()
|
|
70
|
+
|
|
71
|
+
update_1password_secrets(
|
|
72
|
+
item_id,
|
|
73
|
+
new_raw_secrets=new_raw_secrets,
|
|
74
|
+
previous_raw_secrets=current_raw_secrets,
|
|
75
|
+
vault=vault,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
console.print()
|
|
79
|
+
if boolean_prompt(f"Secrets updated in 1Password. Import to Fly app '{app_id}'"):
|
|
80
|
+
import_1password_secrets_to_fly(app_id, vault=vault)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pull_local_secrets(remote=DEFAULT_REMOTE_NAME, vault=None):
|
|
84
|
+
secret_note_label = get_secret_name_label_from_current_directory(remote=remote)
|
|
85
|
+
item_id = get_1password_env_file_item_id(secret_note_label, vault=vault)
|
|
86
|
+
|
|
87
|
+
secrets = get_envs_from_1password(item_id, vault=vault)
|
|
88
|
+
|
|
89
|
+
env_file_name = get_filename_from_1password(item_id, vault=vault) or DEFAULT_ENV_FILE_NAME
|
|
90
|
+
|
|
91
|
+
previous_raw_secrets = get_file_contents(env_file_name, raise_if_not_found=False)
|
|
92
|
+
|
|
93
|
+
if previous_raw_secrets:
|
|
94
|
+
prompt_secret_diff(
|
|
95
|
+
previous_raw_secrets=previous_raw_secrets,
|
|
96
|
+
new_raw_secrets=secrets,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
with open(env_file_name, "w") as file:
|
|
100
|
+
file.writelines(secrets)
|
|
101
|
+
|
|
102
|
+
console.print(f"[bold green]Successfully updated {env_file_name} from 1Password[/bold green]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def push_local_secrets(remote=DEFAULT_REMOTE_NAME, vault=None):
|
|
106
|
+
secret_note_label = get_secret_name_label_from_current_directory(remote=remote)
|
|
107
|
+
item_id = get_1password_env_file_item_id(secret_note_label, vault=vault)
|
|
108
|
+
|
|
109
|
+
env_file_name = get_filename_from_1password(item_id) or DEFAULT_ENV_FILE_NAME
|
|
110
|
+
|
|
111
|
+
secrets = get_file_contents(env_file_name, raise_if_not_found=True)
|
|
112
|
+
|
|
113
|
+
update_1password_secrets(item_id, secrets, vault=vault)
|
|
114
|
+
|
|
115
|
+
console.print(
|
|
116
|
+
f"[bold green]Successfully pushed secrets from {env_file_name} to 1Password[/bold green]"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def create_local_secrets(secrets_file_path, vault=None, remote=DEFAULT_REMOTE_NAME):
|
|
121
|
+
secret_note_label = get_secret_name_label_from_current_directory(remote=remote)
|
|
122
|
+
|
|
123
|
+
raw_secrets = get_file_contents(secrets_file_path, raise_if_not_found=True)
|
|
124
|
+
|
|
125
|
+
title = f"{secrets_file_path} local development {secret_note_label}"
|
|
126
|
+
|
|
127
|
+
item = create_1password_secrets(
|
|
128
|
+
file_path=secrets_file_path, raw_secrets=raw_secrets, title=title, vault=vault
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
item_url = get_item_share_link(item["id"], vault=vault)
|
|
132
|
+
|
|
133
|
+
app_url = item_url.replace("https://start.1password.com/", "onepassword://")
|
|
134
|
+
|
|
135
|
+
console.print()
|
|
136
|
+
console.print(f"[bold green]Item '{title}' created in 1Password![/bold green]")
|
|
137
|
+
console.print()
|
|
138
|
+
console.print(
|
|
139
|
+
Panel(f"[link={item_url}]{item_url}[/link]", title="Web Link", border_style="blue")
|
|
140
|
+
)
|
|
141
|
+
console.print(Panel(f"[link={app_url}]{app_url}[/link]", title="App Link", border_style="blue"))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
FLY_MACHINES_API_ENDPOINT = "https://api.machines.dev/v1"
|
|
2
|
+
FLY_API_TIMEOUT_SECONDS = 30
|
|
3
|
+
DATE_FORMAT = "%Y/%m/%d %H:%M:%S"
|
|
4
|
+
DEFAULT_ENV_FILE_NAME = ".env"
|
|
5
|
+
ONE_PASSWORD_FILE_PATH_FIELD_NAME = "file_name" # noqa: S105
|
|
6
|
+
ONE_PASSWORD_NOTES_CONTENT_FIELD_NAME = "notesPlain" # noqa: S105
|
|
7
|
+
ONE_PASSWORD_SECURE_NOTE_CATEGORY = "Secure Note" # noqa: S105
|
|
8
|
+
DEFAULT_REMOTE_NAME = "origin"
|
|
9
|
+
REDACTED_PLACEHOLDER = "<REDACTED>" # noqa: S105
|
|
10
|
+
SENSITIVE_HEADER_NAMES = frozenset({"authorization", "proxy-authorization", "cookie", "set-cookie"})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from io import StringIO
|
|
2
|
+
|
|
3
|
+
from dotenv import dotenv_values
|
|
4
|
+
|
|
5
|
+
from .errors import raise_error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_secrets_from_envs(input: str):
|
|
9
|
+
secrets = dotenv_values(stream=StringIO(input))
|
|
10
|
+
|
|
11
|
+
keys_with_values_null_values = [key for key, value in secrets.items() if value is None]
|
|
12
|
+
|
|
13
|
+
if len(keys_with_values_null_values) > 0:
|
|
14
|
+
raise_error(
|
|
15
|
+
"Failed to parse env file, values for the following keys are null: {}".format(
|
|
16
|
+
", ".join(keys_with_values_null_values)
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return secrets
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Interactions with Fly.io: the machines REST API and the `fly` CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .constants import FLY_API_TIMEOUT_SECONDS, FLY_MACHINES_API_ENDPOINT
|
|
9
|
+
from .errors import raise_error
|
|
10
|
+
from .logs import console, debug_command, debug_request, debug_response
|
|
11
|
+
from .prompts import boolean_prompt
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_fly_auth_token():
|
|
15
|
+
command_args = ["fly", "auth", "token", "--json"]
|
|
16
|
+
output = subprocess.check_output(command_args) # noqa: S603
|
|
17
|
+
debug_command(command_args, output=output, returncode=0, redact_output=True)
|
|
18
|
+
|
|
19
|
+
return json.loads(output)["token"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_fly_api_request(
|
|
23
|
+
method,
|
|
24
|
+
path,
|
|
25
|
+
json_body=None,
|
|
26
|
+
token=None,
|
|
27
|
+
status_message="Communicating with Fly.io",
|
|
28
|
+
):
|
|
29
|
+
url = f"{FLY_MACHINES_API_ENDPOINT}{path}"
|
|
30
|
+
headers = {
|
|
31
|
+
"Authorization": f"Bearer {token or get_fly_auth_token()}",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
debug_request(method, url, headers, json_body)
|
|
36
|
+
|
|
37
|
+
with console.status(f"[bold cyan]{status_message}...", spinner="dots"):
|
|
38
|
+
try:
|
|
39
|
+
response = requests.request(
|
|
40
|
+
method,
|
|
41
|
+
url,
|
|
42
|
+
headers=headers,
|
|
43
|
+
json=json_body,
|
|
44
|
+
timeout=FLY_API_TIMEOUT_SECONDS,
|
|
45
|
+
)
|
|
46
|
+
except requests.RequestException as error:
|
|
47
|
+
raise_error(f"Failed to reach the Fly API: {error}")
|
|
48
|
+
|
|
49
|
+
debug_response(response)
|
|
50
|
+
|
|
51
|
+
if not response.ok:
|
|
52
|
+
try:
|
|
53
|
+
body = response.json()
|
|
54
|
+
except ValueError:
|
|
55
|
+
body = response.text
|
|
56
|
+
|
|
57
|
+
message = body.get("error") if isinstance(body, dict) else body
|
|
58
|
+
raise_error(f"Fly API error ({response.status_code}): {message or 'unknown error'}")
|
|
59
|
+
|
|
60
|
+
if not response.content:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
return response.json()
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_fly_secret_names(app_id, token=None) -> set[str]:
|
|
70
|
+
response = (
|
|
71
|
+
make_fly_api_request(
|
|
72
|
+
"GET",
|
|
73
|
+
f"/apps/{app_id}/secrets",
|
|
74
|
+
token=token,
|
|
75
|
+
status_message="Fetching current Fly secrets",
|
|
76
|
+
)
|
|
77
|
+
or {}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return {secret["name"] for secret in response.get("secrets") or [] if secret.get("name")}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_fly_secrets(app_id, values, token=None) -> None:
|
|
84
|
+
make_fly_api_request(
|
|
85
|
+
"POST",
|
|
86
|
+
f"/apps/{app_id}/secrets",
|
|
87
|
+
json_body={"values": values},
|
|
88
|
+
token=token,
|
|
89
|
+
status_message="Uploading secrets to Fly",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def deploy_fly_secrets(app_id):
|
|
94
|
+
"""Deploy secrets to a Fly app using the fly CLI."""
|
|
95
|
+
console.print()
|
|
96
|
+
console.print(f"[bold cyan]Deploying secrets to Fly app '{app_id}'...[/bold cyan]")
|
|
97
|
+
console.print()
|
|
98
|
+
|
|
99
|
+
command_args = ["fly", "secrets", "deploy", "-a", app_id]
|
|
100
|
+
debug_command(command_args)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = subprocess.run(command_args, check=False) # noqa: S603
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
raise_error("The 'fly' CLI was not found. See https://fly.io/docs/flyctl/install/")
|
|
106
|
+
|
|
107
|
+
debug_command(command_args, returncode=result.returncode)
|
|
108
|
+
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
raise_error(f"Failed to deploy secrets (exit code {result.returncode})")
|
|
111
|
+
|
|
112
|
+
console.print()
|
|
113
|
+
console.print(f"[bold green]Secrets deployed to Fly app '{app_id}'[/bold green]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update_fly_secrets(app_id, secrets):
|
|
117
|
+
token = get_fly_auth_token()
|
|
118
|
+
|
|
119
|
+
secret_names_in_fly = get_fly_secret_names(app_id, token=token)
|
|
120
|
+
|
|
121
|
+
secrets_names_in_fly_only = secret_names_in_fly.difference(secrets.keys())
|
|
122
|
+
|
|
123
|
+
deletions = {}
|
|
124
|
+
|
|
125
|
+
if len(secrets_names_in_fly_only) > 0 and boolean_prompt(
|
|
126
|
+
"The following secrets will be deleted from Fly.io: {}. Are you sure".format(
|
|
127
|
+
", ".join(sorted(secrets_names_in_fly_only))
|
|
128
|
+
)
|
|
129
|
+
):
|
|
130
|
+
deletions = dict.fromkeys(secrets_names_in_fly_only, None)
|
|
131
|
+
|
|
132
|
+
values = {**secrets, **deletions}
|
|
133
|
+
|
|
134
|
+
if not values:
|
|
135
|
+
console.print()
|
|
136
|
+
console.print("[dim]No secret changes to push to Fly.[/dim]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
set_fly_secrets(app_id, values, token=token)
|
|
140
|
+
|
|
141
|
+
console.print()
|
|
142
|
+
console.print(f"[bold green]Secrets staged on Fly app '{app_id}'[/bold green]")
|
|
143
|
+
|
|
144
|
+
if boolean_prompt("Deploy secrets now (run fly secrets deploy)?"):
|
|
145
|
+
deploy_fly_secrets(app_id)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from .constants import DEFAULT_REMOTE_NAME
|
|
6
|
+
from .logs import console, debug_command
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_git_remote_name(remote=DEFAULT_REMOTE_NAME) -> tuple[str | None, str | None]:
|
|
10
|
+
git_repository_regex = r"^(\w+)(:\/\/|@)([^\/:]+)[\/:]([^\/:]+)\/(.+).git$"
|
|
11
|
+
|
|
12
|
+
git_remote_url = None
|
|
13
|
+
command_args = ["git", "config", "--get", f"remote.{remote}.url"]
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
git_remote_url = (
|
|
17
|
+
subprocess.check_output(command_args) # noqa: S603
|
|
18
|
+
.decode("utf-8")
|
|
19
|
+
.strip()
|
|
20
|
+
)
|
|
21
|
+
debug_command(command_args, output=git_remote_url, returncode=0)
|
|
22
|
+
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
debug_command(command_args, output="git not in the PATH")
|
|
25
|
+
return ("git not in the PATH", None)
|
|
26
|
+
|
|
27
|
+
except subprocess.CalledProcessError as error:
|
|
28
|
+
exit_code, _command = error.args
|
|
29
|
+
debug_command(command_args, returncode=exit_code)
|
|
30
|
+
|
|
31
|
+
if exit_code == 1:
|
|
32
|
+
return (f"Either not in a git repository or remote {remote!r} is not set", None)
|
|
33
|
+
|
|
34
|
+
return (f"Failed to retrieve the git remote {remote!r} url", None)
|
|
35
|
+
|
|
36
|
+
regex_match = re.match(git_repository_regex, git_remote_url)
|
|
37
|
+
|
|
38
|
+
if regex_match is None:
|
|
39
|
+
return (f'Failed to parse git remote "{remote}"', None)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
None,
|
|
43
|
+
f"{regex_match.group(4)}/{regex_match.group(5)}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_secret_name_label_from_current_directory(remote=DEFAULT_REMOTE_NAME) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Returns a predictable label for identifying the secrets based on the current directory.
|
|
50
|
+
If within a git repository with a remote named "origin" (and git is installed), it will output
|
|
51
|
+
something like: `repo:my-org/my-repo` or `repo:my-org/my-team/my-repo`.
|
|
52
|
+
Otherwise it will return the name of the directory as `local-dir:my-directory-name`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
error_message, git_remote_name = get_git_remote_name(remote=remote)
|
|
56
|
+
|
|
57
|
+
if not error_message:
|
|
58
|
+
return f"repo:{git_remote_name}"
|
|
59
|
+
|
|
60
|
+
directory_name = os.path.basename(os.getcwd())
|
|
61
|
+
label = f"local-dir:{directory_name}"
|
|
62
|
+
|
|
63
|
+
console.print(
|
|
64
|
+
f"[dim]{error_message}, using the label based on the current directory: {label!r}[/dim]"
|
|
65
|
+
)
|
|
66
|
+
return label
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from .constants import REDACTED_PLACEHOLDER, SENSITIVE_HEADER_NAMES
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _setup_logger():
|
|
12
|
+
class Formatter(logging.Formatter):
|
|
13
|
+
def format(self, record):
|
|
14
|
+
if record.levelno == logging.INFO:
|
|
15
|
+
self._style._fmt = "%(message)s"
|
|
16
|
+
else:
|
|
17
|
+
self._style._fmt = "%(levelname)s: %(message)s"
|
|
18
|
+
return super().format(record)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger()
|
|
21
|
+
logger.setLevel(logging.INFO)
|
|
22
|
+
|
|
23
|
+
if logger.hasHandlers():
|
|
24
|
+
logger.handlers.clear()
|
|
25
|
+
|
|
26
|
+
stdout_handler = logging.StreamHandler()
|
|
27
|
+
stdout_handler.setFormatter(Formatter())
|
|
28
|
+
logger.addHandler(stdout_handler)
|
|
29
|
+
|
|
30
|
+
return logger
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = _setup_logger()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def redact_headers(headers) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
name: (REDACTED_PLACEHOLDER if name.lower() in SENSITIVE_HEADER_NAMES else value)
|
|
39
|
+
for name, value in dict(headers or {}).items()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_command(command_args) -> str:
|
|
44
|
+
return " ".join((f'"{arg}"' if " " in arg else arg) for arg in command_args)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def debug_enabled() -> bool:
|
|
48
|
+
return logger.isEnabledFor(logging.DEBUG)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def debug_request(method, url, headers, json_body) -> None:
|
|
52
|
+
if not debug_enabled():
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
lines = [f"Request: {method} {url}", "Request headers:"]
|
|
56
|
+
lines += [f" {name}: {value}" for name, value in redact_headers(headers).items()]
|
|
57
|
+
|
|
58
|
+
if json_body is not None:
|
|
59
|
+
lines += ["Request body:", json.dumps(json_body, indent=2)]
|
|
60
|
+
|
|
61
|
+
logger.debug("\n".join(lines))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def debug_response(response) -> None:
|
|
65
|
+
if not debug_enabled():
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
lines = [f"Response: {response.status_code}", "Response headers:"]
|
|
69
|
+
lines += [f" {name}: {value}" for name, value in redact_headers(response.headers).items()]
|
|
70
|
+
lines += ["Response body:", response.text]
|
|
71
|
+
|
|
72
|
+
logger.debug("\n".join(lines))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def debug_command(command_args, output=None, returncode=None, redact_output=False) -> None:
|
|
76
|
+
if not debug_enabled():
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
lines = [f"Running command: {format_command(command_args)}"]
|
|
80
|
+
|
|
81
|
+
if returncode is not None:
|
|
82
|
+
lines.append(f"Exit code: {returncode}")
|
|
83
|
+
|
|
84
|
+
if output is not None:
|
|
85
|
+
if isinstance(output, bytes):
|
|
86
|
+
output = output.decode("utf-8", errors="replace")
|
|
87
|
+
|
|
88
|
+
lines += ["Output:", REDACTED_PLACEHOLDER if redact_output else output]
|
|
89
|
+
|
|
90
|
+
logger.debug("\n".join(lines))
|