mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-05 08:22:05 +00:00
fix(cutover): write Slack secrets into empty Terraform shells
DescribeSecret succeeds on HCP-created shells with no version, so skip-if-exists left roster and Slack tokens unset.
This commit is contained in:
parent
dbef3bda52
commit
8f0ab60974
2 changed files with 229 additions and 0 deletions
130
scripts/cutover/copy_secrets.py
Normal file
130
scripts/cutover/copy_secrets.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Copy afterhours secrets mgmt → prod. Dry-run unless --execute.
|
||||||
|
|
||||||
|
Terraform creates empty secret shells. Slack, signing, and roster tokens are
|
||||||
|
written into those shells when the dest has no current string value. Populated
|
||||||
|
dest values are left alone. 3CX secrets are verified only and never written.
|
||||||
|
Strips trailing newlines. Never prints secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
SRC_ACCOUNT = "328440206208"
|
||||||
|
DST_ACCOUNT = "011934824531"
|
||||||
|
|
||||||
|
COPY = [
|
||||||
|
"afterhours-shift-manager/slack-bot-token",
|
||||||
|
"afterhours-shift-manager/slack-signing-secret",
|
||||||
|
"afterhours-shift-manager/roster-api-token",
|
||||||
|
]
|
||||||
|
|
||||||
|
VERIFY_ONLY = [
|
||||||
|
"afterhours-shift-manager/3cx-domain",
|
||||||
|
"afterhours-shift-manager/3cx-client-id",
|
||||||
|
"afterhours-shift-manager/3cx-client-secret",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Describe succeeds on a Terraform shell; GetSecretValue fails until a version exists.
|
||||||
|
_NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestException"})
|
||||||
|
|
||||||
|
|
||||||
|
def _client(profile: str, region: str):
|
||||||
|
return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager")
|
||||||
|
|
||||||
|
|
||||||
|
def _account(profile: str) -> str:
|
||||||
|
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
|
||||||
|
|
||||||
|
|
||||||
|
def secret_string(client, name: str) -> str | None:
|
||||||
|
"""Return the current SecretString, or None if the secret does not exist.
|
||||||
|
|
||||||
|
An empty string means the secret exists (Terraform shell) but has no usable
|
||||||
|
current version.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client.describe_secret(SecretId=name)
|
||||||
|
except ClientError as exc:
|
||||||
|
if exc.response["Error"]["Code"] == "ResourceNotFoundException":
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
payload = client.get_secret_value(SecretId=name)
|
||||||
|
except ClientError as exc:
|
||||||
|
if exc.response["Error"]["Code"] in _NO_VALUE_CODES:
|
||||||
|
return ""
|
||||||
|
raise
|
||||||
|
value = payload.get("SecretString")
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def copy_secrets(src, dst, *, execute: bool) -> int:
|
||||||
|
rc = 0
|
||||||
|
|
||||||
|
for name in VERIFY_ONLY:
|
||||||
|
value = secret_string(dst, name)
|
||||||
|
if value is None:
|
||||||
|
print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
elif not value.strip():
|
||||||
|
print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
else:
|
||||||
|
print(f"keep existing prod secret {name}")
|
||||||
|
|
||||||
|
for name in COPY:
|
||||||
|
src_value = secret_string(src, name)
|
||||||
|
if src_value is None or not src_value.strip():
|
||||||
|
print(f"missing mgmt secret {name}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
dest_value = secret_string(dst, name)
|
||||||
|
if dest_value is None:
|
||||||
|
print(f"missing prod secret shell {name}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
if dest_value.strip():
|
||||||
|
print(f"skip populated prod secret {name}")
|
||||||
|
continue
|
||||||
|
print(f"would copy {name}")
|
||||||
|
if not execute:
|
||||||
|
continue
|
||||||
|
value = src_value.rstrip("\n")
|
||||||
|
dst.put_secret_value(SecretId=name, SecretString=value)
|
||||||
|
print(f"wrote {name} ({len(value)} chars)")
|
||||||
|
|
||||||
|
if not execute:
|
||||||
|
print("dry-run; pass --execute to PutSecretValue")
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--src-profile", required=True)
|
||||||
|
parser.add_argument("--dst-profile", required=True)
|
||||||
|
parser.add_argument("--region", default="us-east-1")
|
||||||
|
parser.add_argument("--execute", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if _account(args.src_profile) != SRC_ACCOUNT:
|
||||||
|
print("src profile is not mgmt", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if _account(args.dst_profile) != DST_ACCOUNT:
|
||||||
|
print("dst profile is not prod", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
src = _client(args.src_profile, args.region)
|
||||||
|
dst = _client(args.dst_profile, args.region)
|
||||||
|
return copy_secrets(src, dst, execute=args.execute)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
99
tests/scripts/test_copy_secrets.py
Normal file
99
tests/scripts/test_copy_secrets.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""copy_secrets.py writes Slack tokens into empty Terraform shells."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"copy_secrets", ROOT / "scripts" / "cutover" / "copy_secrets.py"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["copy_secrets"] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
mod = _load()
|
||||||
|
|
||||||
|
|
||||||
|
def _client_error(code: str) -> ClientError:
|
||||||
|
return ClientError({"Error": {"Code": code, "Message": code}}, "GetSecretValue")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSecrets:
|
||||||
|
def __init__(self, described, strings=None, get_errors=None):
|
||||||
|
self.described = set(described)
|
||||||
|
self.strings = dict(strings or {})
|
||||||
|
self.get_errors = dict(get_errors or {})
|
||||||
|
self.puts = []
|
||||||
|
|
||||||
|
def describe_secret(self, SecretId):
|
||||||
|
if SecretId not in self.described:
|
||||||
|
raise _client_error("ResourceNotFoundException")
|
||||||
|
return {"Name": SecretId}
|
||||||
|
|
||||||
|
def get_secret_value(self, SecretId):
|
||||||
|
if SecretId in self.get_errors:
|
||||||
|
raise _client_error(self.get_errors[SecretId])
|
||||||
|
if SecretId not in self.strings:
|
||||||
|
raise _client_error("ResourceNotFoundException")
|
||||||
|
return {"SecretString": self.strings[SecretId]}
|
||||||
|
|
||||||
|
def put_secret_value(self, SecretId, SecretString):
|
||||||
|
self.puts.append((SecretId, SecretString))
|
||||||
|
self.strings[SecretId] = SecretString
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_puts_into_empty_terraform_shells():
|
||||||
|
src = FakeSecrets(
|
||||||
|
described=mod.COPY,
|
||||||
|
strings={name: f"{name}-value\n" for name in mod.COPY},
|
||||||
|
)
|
||||||
|
dst = FakeSecrets(
|
||||||
|
described=mod.COPY + mod.VERIFY_ONLY,
|
||||||
|
strings={name: "already-copied" for name in mod.VERIFY_ONLY},
|
||||||
|
get_errors={name: "InvalidRequestException" for name in mod.COPY},
|
||||||
|
)
|
||||||
|
rc = mod.copy_secrets(src, dst, execute=True)
|
||||||
|
assert rc == 0
|
||||||
|
assert [name for name, _ in dst.puts] == list(mod.COPY)
|
||||||
|
assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_populated_copy_targets_and_never_write_3cx():
|
||||||
|
src = FakeSecrets(
|
||||||
|
described=mod.COPY,
|
||||||
|
strings={name: "from-mgmt" for name in mod.COPY},
|
||||||
|
)
|
||||||
|
dst = FakeSecrets(
|
||||||
|
described=mod.COPY + mod.VERIFY_ONLY,
|
||||||
|
strings={
|
||||||
|
**{name: "prod-already" for name in mod.COPY},
|
||||||
|
**{name: "3cx-prod" for name in mod.VERIFY_ONLY},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rc = mod.copy_secrets(src, dst, execute=True)
|
||||||
|
assert rc == 0
|
||||||
|
assert dst.puts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_does_not_put():
|
||||||
|
src = FakeSecrets(
|
||||||
|
described=mod.COPY,
|
||||||
|
strings={name: "from-mgmt" for name in mod.COPY},
|
||||||
|
)
|
||||||
|
dst = FakeSecrets(
|
||||||
|
described=mod.COPY + mod.VERIFY_ONLY,
|
||||||
|
strings={name: "3cx-prod" for name in mod.VERIFY_ONLY},
|
||||||
|
get_errors={name: "InvalidRequestException" for name in mod.COPY},
|
||||||
|
)
|
||||||
|
rc = mod.copy_secrets(src, dst, execute=False)
|
||||||
|
assert rc == 0
|
||||||
|
assert dst.puts == []
|
||||||
Loading…
Add table
Reference in a new issue