Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-04-21 18:46:45
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""
|
|
Deploy staged pipeline changes to AD2:C:\\Shares\\testdatadb\\.
|
|
|
|
Backs up each existing target to <name>.bak-YYYYMMDD before overwriting.
|
|
Fails if a target file does not exist on AD2 (excluding brand-new files
|
|
declared in NEW_FILES below).
|
|
|
|
Usage:
|
|
python deploy-to-ad2.py --dry-run
|
|
python deploy-to-ad2.py
|
|
|
|
Credentials: fetched at runtime from the SOPS vault
|
|
(clients/dataforth/ad2.sops.yaml -> credentials.password). No hardcoded
|
|
password; no env-var / prompt fallback. Fails loud if the vault read fails.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import paramiko
|
|
|
|
HOST = '192.168.0.6'
|
|
USER = 'sysadmin'
|
|
|
|
VAULT_SH = 'D:/vault/scripts/vault.sh'
|
|
VAULT_ENTRY = 'clients/dataforth/ad2.sops.yaml'
|
|
VAULT_FIELD = 'credentials.password'
|
|
|
|
SMTP_VAULT_ENTRY = 'clients/dataforth/m365.sops.yaml'
|
|
SMTP_VAULT_FIELD = 'credentials.password'
|
|
SMTP_USER = 'sysadmin@dataforth.com'
|
|
SMTP_HOST = 'smtp.office365.com'
|
|
SMTP_PORT = 587
|
|
NOTIFY_TO = 'mike@azcomputerguru.com'
|
|
|
|
|
|
def get_ad2_password() -> str:
|
|
"""Fetch the AD2 sysadmin password from the SOPS vault.
|
|
|
|
Fails loud (raises) on any error: missing vault, decryption failure,
|
|
empty value. Do NOT fall back to env vars or prompts -- per CLAUDE.md
|
|
deploy scripts must not hold credentials.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
['bash', VAULT_SH, 'get-field', VAULT_ENTRY, VAULT_FIELD],
|
|
capture_output=True, text=True, timeout=30, check=False,
|
|
)
|
|
except FileNotFoundError as e:
|
|
raise RuntimeError(
|
|
f'[FAIL] vault helper not runnable: {VAULT_SH} ({e})'
|
|
) from e
|
|
except subprocess.TimeoutExpired as e:
|
|
raise RuntimeError(
|
|
f'[FAIL] vault read timed out after 30s for {VAULT_ENTRY}'
|
|
) from e
|
|
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or '').strip()
|
|
raise RuntimeError(
|
|
f'[FAIL] vault read failed (rc={result.returncode}) for '
|
|
f'{VAULT_ENTRY}:{VAULT_FIELD}: {stderr}'
|
|
)
|
|
|
|
pwd = (result.stdout or '').strip()
|
|
if not pwd:
|
|
raise RuntimeError(
|
|
f'[FAIL] vault returned empty value for {VAULT_ENTRY}:{VAULT_FIELD}'
|
|
)
|
|
return pwd
|
|
|
|
|
|
def get_smtp_password() -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
['bash', VAULT_SH, 'get-field', SMTP_VAULT_ENTRY, SMTP_VAULT_FIELD],
|
|
capture_output=True, text=True, timeout=30, check=False,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
raise RuntimeError(f'[FAIL] vault read failed for SMTP creds: {e}') from e
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f'[FAIL] vault read failed (rc={result.returncode}) for '
|
|
f'{SMTP_VAULT_ENTRY}:{SMTP_VAULT_FIELD}: {result.stderr.strip()}'
|
|
)
|
|
|
|
pwd = (result.stdout or '').strip().replace('\\', '')
|
|
if not pwd:
|
|
raise RuntimeError(f'[FAIL] vault returned empty SMTP password')
|
|
return pwd
|
|
|
|
REMOTE_ROOT = 'C:/Shares/testdatadb'
|
|
LOCAL_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deployment file lists. Each list has different semantics:
|
|
#
|
|
# UPDATE_FILES -- file MUST already exist on AD2. Backup-then-overwrite.
|
|
# Fails loud if the remote file is missing (that's a drift
|
|
# signal -- something changed on the box we didn't expect).
|
|
#
|
|
# NEW_FILES -- file must NOT already exist on AD2. Creates it.
|
|
# Fails loud if the remote file is already present (we would
|
|
# otherwise silently clobber something we didn't back up).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Files that already exist on AD2 and will be backed up + overwritten.
|
|
UPDATE_FILES = [
|
|
('parsers/spec-reader.js', 'parsers/spec-reader.js'),
|
|
('templates/datasheet-exact.js', 'templates/datasheet-exact.js'),
|
|
('database/import.js', 'database/import.js'),
|
|
('database/export-datasheets.js', 'database/export-datasheets.js'),
|
|
]
|
|
|
|
# Files that do NOT yet exist on AD2 and must be created fresh.
|
|
NEW_FILES = [
|
|
('parsers/vaslog-engtxt.js', 'parsers/vaslog-engtxt.js'),
|
|
('server/notify.js', 'server/notify.js'),
|
|
]
|
|
|
|
|
|
def connect() -> paramiko.SSHClient:
|
|
pwd = get_ad2_password()
|
|
c = paramiko.SSHClient()
|
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
c.connect(
|
|
HOST, username=USER, password=pwd,
|
|
timeout=15, look_for_keys=False, allow_agent=False, banner_timeout=30,
|
|
)
|
|
return c
|
|
|
|
|
|
def remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool:
|
|
try:
|
|
sftp.stat(path)
|
|
return True
|
|
except IOError:
|
|
return False
|
|
|
|
|
|
def to_remote(rel: str) -> str:
|
|
return f'{REMOTE_ROOT}/{rel}'
|
|
|
|
|
|
def backup_and_copy(sftp: paramiko.SFTPClient, ssh: paramiko.SSHClient,
|
|
local_rel: str, remote_rel: str, dry_run: bool, stamp: str) -> None:
|
|
local_path = os.path.join(LOCAL_ROOT, local_rel.replace('/', os.sep))
|
|
remote_path = to_remote(remote_rel)
|
|
backup_path = f'{remote_path}.bak-{stamp}'
|
|
|
|
if not os.path.isfile(local_path):
|
|
raise FileNotFoundError(f'[FAIL] local file missing: {local_path}')
|
|
|
|
if not remote_exists(sftp, remote_path):
|
|
raise FileNotFoundError(f'[FAIL] remote file missing on AD2: {remote_path}')
|
|
|
|
print(f'[INFO] {remote_rel}')
|
|
if dry_run:
|
|
print(f' would back up to: {backup_path}')
|
|
print(f' would upload: {local_path} -> {remote_path}')
|
|
return
|
|
|
|
# Backup via SFTP copy (read + re-upload). Paramiko has no server-side copy.
|
|
with sftp.open(remote_path, 'rb') as src:
|
|
data = src.read()
|
|
with sftp.open(backup_path, 'wb') as dst:
|
|
dst.write(data)
|
|
print(f' backup: {backup_path} ({len(data)} bytes)')
|
|
|
|
sftp.put(local_path, remote_path)
|
|
size = os.path.getsize(local_path)
|
|
print(f' uploaded: {local_path} -> {remote_path} ({size} bytes)')
|
|
|
|
|
|
def create_new(sftp: paramiko.SFTPClient, local_rel: str, remote_rel: str,
|
|
dry_run: bool) -> None:
|
|
"""Create a file that is expected to be NEW on AD2.
|
|
|
|
Fails loud if the remote file already exists -- NEW_FILES declares this
|
|
is a brand-new file, so pre-existence is a drift signal. If a previous
|
|
deploy partially ran, clean up manually or move the entry to
|
|
UPDATE_FILES.
|
|
"""
|
|
local_path = os.path.join(LOCAL_ROOT, local_rel.replace('/', os.sep))
|
|
remote_path = to_remote(remote_rel)
|
|
|
|
if not os.path.isfile(local_path):
|
|
raise FileNotFoundError(f'[FAIL] local file missing: {local_path}')
|
|
|
|
print(f'[INFO] {remote_rel} (NEW)')
|
|
|
|
if remote_exists(sftp, remote_path):
|
|
raise FileExistsError(
|
|
f'[FAIL] remote target already exists but is declared NEW: {remote_path} '
|
|
f'-- move to UPDATE_FILES or remove remote manually'
|
|
)
|
|
|
|
if dry_run:
|
|
print(f' would create: {local_path} -> {remote_path}')
|
|
return
|
|
|
|
sftp.put(local_path, remote_path)
|
|
size = os.path.getsize(local_path)
|
|
print(f' created: {remote_path} ({size} bytes)')
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument('--dry-run', action='store_true', help='print actions without writing')
|
|
args = ap.parse_args()
|
|
|
|
stamp = datetime.date.today().strftime('%Y%m%d')
|
|
|
|
print('=' * 72)
|
|
print('Deploy staged pipeline changes to AD2')
|
|
print('=' * 72)
|
|
print(f'Host: {HOST}')
|
|
print(f'Remote root: {REMOTE_ROOT}')
|
|
print(f'Local root: {LOCAL_ROOT}')
|
|
print(f'Dry run: {args.dry_run}')
|
|
print(f'Backup tag: .bak-{stamp}')
|
|
print('')
|
|
|
|
smtp_pass = get_smtp_password()
|
|
|
|
ssh = connect()
|
|
try:
|
|
sftp = ssh.open_sftp()
|
|
try:
|
|
for local_rel, remote_rel in UPDATE_FILES:
|
|
backup_and_copy(sftp, ssh, local_rel, remote_rel, args.dry_run, stamp)
|
|
|
|
for local_rel, remote_rel in NEW_FILES:
|
|
create_new(sftp, local_rel, remote_rel, args.dry_run)
|
|
|
|
# Write notify config (creds fetched from vault, never committed to git)
|
|
import json
|
|
notify_cfg = json.dumps({
|
|
'smtp': {
|
|
'host': SMTP_HOST,
|
|
'port': SMTP_PORT,
|
|
'user': SMTP_USER,
|
|
'pass': smtp_pass,
|
|
},
|
|
'from': SMTP_USER,
|
|
'to': NOTIFY_TO,
|
|
}, indent=2)
|
|
notify_remote = f'{REMOTE_ROOT}/config/notify.json'
|
|
print(f'[INFO] config/notify.json (SMTP creds)')
|
|
if not args.dry_run:
|
|
# Ensure config dir exists
|
|
stdin, stdout, stderr = ssh.exec_command(
|
|
f'powershell -Command "New-Item -ItemType Directory -Force -Path '
|
|
f'C:\\Shares\\testdatadb\\config | Out-Null"'
|
|
)
|
|
stdout.channel.recv_exit_status()
|
|
with sftp.open(notify_remote, 'w') as f:
|
|
f.write(notify_cfg)
|
|
print(f' written: {notify_remote}')
|
|
else:
|
|
print(f' would write: {notify_remote}')
|
|
|
|
finally:
|
|
sftp.close()
|
|
|
|
# Install nodemailer if not already present
|
|
print('[INFO] npm install nodemailer')
|
|
if not args.dry_run:
|
|
cmd = 'cd C:\\Shares\\testdatadb && npm list nodemailer --depth=0 2>nul || npm install nodemailer'
|
|
stdin, stdout, stderr = ssh.exec_command(f'cmd /c "{cmd}"')
|
|
exit_code = stdout.channel.recv_exit_status()
|
|
out = stdout.read().decode(errors='replace').strip()
|
|
if out:
|
|
print(f' {out}')
|
|
if exit_code != 0:
|
|
err = stderr.read().decode(errors='replace').strip()
|
|
raise RuntimeError(f'[FAIL] npm install nodemailer failed: {err}')
|
|
print('[OK] nodemailer ready')
|
|
else:
|
|
print(' would run: npm install nodemailer (if not already installed)')
|
|
|
|
finally:
|
|
ssh.close()
|
|
|
|
print('')
|
|
print('[OK] done' if not args.dry_run else '[OK] dry-run complete (no changes made)')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as e:
|
|
print(f'[FAIL] {e}', file=sys.stderr)
|
|
sys.exit(1)
|