mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 13:43:12 +00:00
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* 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. * feat(infra): migrate afterhours to HCP Terraform (PLAT-74) Replace the mgmt SAM stack with a prod-only HCP workspace, in-repo hcptf IAM, stub Lambdas, and zip CD on push to main. * fix(cutover): retry DDB unprocessed items and skip past at() holidays Unprocessed BatchWriteItem rows and leftover past at() schedules would drop roster data or abort holiday recreation during prod cutover.
130 lines
4.1 KiB
Python
130 lines
4.1 KiB
Python
#!/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())
|