sync: auto-sync from DESKTOP-0O8A1RL at 2026-04-21 18:46:45

Author: Mike Swanson
Machine: DESKTOP-0O8A1RL
Timestamp: 2026-04-21 18:46:45
This commit is contained in:
2026-04-21 18:46:49 -07:00
parent a9bcbc2580
commit 63089c45c9
6 changed files with 1504 additions and 1324 deletions

View File

@@ -92,6 +92,17 @@ C:\Shares\testdatadb\ # Node.js application
└── DBHV.BAS # Database editor (TYPE DBASE definition)
```
## Email / SMTP
Dataforth is **M365 hybrid** — Exchange Online is the mail system. Use SMTP via M365:
- **SMTP host:** smtp.office365.com **Port:** 587 (STARTTLS)
- **Auth:** sysadmin@dataforth.com (vault: `clients/dataforth/m365.sops.yaml``credentials.password`)
- **Tenant ID:** `7dfa3ce8-c496-4b51-ab8d-bd3dcd78b584`
- **Neptune Exchange (neptune.acghosting.com):** ACG infrastructure — NOT Dataforth's, do not use
---
## Anti-Patterns (DON'T DO THIS)
**DO NOT hardcode Paper123!@#** - Always fetch from vault:

View File

@@ -1,3 +1,6 @@
# SMTP credentials written by deploy-to-ad2.py (never commit)
implementation/config/notify.json
# Python cache
__pycache__/
*.pyc

View File

@@ -18,6 +18,7 @@ const db = require('./db');
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
const { generateExactDatasheet } = require('../templates/datasheet-exact');
const { sendFailureEmail } = require('../server/notify');
// Configuration
const OUTPUT_DIR = 'X:\\For_Web';
@@ -251,7 +252,22 @@ async function exportNewRecords(specMap, filePaths) {
}
if (require.main === module) {
run().catch(console.error);
run()
.then(({ exported, skipped, errors }) => {
if (errors > 0) {
return sendFailureEmail(
`[testdatadb] Datasheet export completed with ${errors} error(s)`,
`Export finished but ${errors} record(s) failed to write to the web directory.\n\nExported: ${exported}\nSkipped: ${skipped}\nErrors: ${errors}\n\nCheck the service log on AD2 for details.`
);
}
})
.catch(async (err) => {
console.error(err);
await sendFailureEmail(
'[testdatadb] Datasheet export failed',
`Export task crashed before completion.\n\nError: ${err.message}\n\nStack:\n${err.stack || '(none)'}`
);
});
}
module.exports = { exportNewRecords };

View File

@@ -11,6 +11,7 @@ const { parseMultilineFile, extractTestStation } = require('../parsers/multiline
const { parseCsvFile } = require('../parsers/csvline');
const { parseShtFile } = require('../parsers/shtfile');
const { parseVaslogEngTxt } = require('../parsers/vaslog-engtxt');
const { sendFailureEmail } = require('../server/notify');
// Data source paths
const TEST_PATH = 'C:/Shares/test';
@@ -366,6 +367,10 @@ async function importFiles(filePaths) {
await exportNewRecords(specMap, filePaths);
} catch (err) {
console.error(`[EXPORT] Datasheet export failed: ${err.message}`);
await sendFailureEmail(
'[testdatadb] Datasheet export failed after import',
`Export step failed after importing ${totalImported} record(s).\n\nError: ${err.message}\n\nStack:\n${err.stack || '(none)'}`
);
}
}
@@ -389,7 +394,13 @@ if (require.main === module) {
console.log(' node import.js --file <f> Import specific file(s)');
process.exit(0);
} else {
runImport().catch(console.error);
runImport().catch(async (err) => {
console.error(err);
await sendFailureEmail(
'[testdatadb] DB import failed',
`The scheduled import job crashed.\n\nError: ${err.message}\n\nStack:\n${err.stack || '(none)'}`
);
});
}
}

View File

@@ -30,6 +30,13 @@ 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.
@@ -66,6 +73,27 @@ def get_ad2_password() -> str:
)
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__))
@@ -92,6 +120,7 @@ UPDATE_FILES = [
# 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'),
]
@@ -197,6 +226,8 @@ def main() -> int:
print(f'Backup tag: .bak-{stamp}')
print('')
smtp_pass = get_smtp_password()
ssh = connect()
try:
sftp = ssh.open_sftp()
@@ -206,8 +237,53 @@ def main() -> int:
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()

View File

@@ -0,0 +1,63 @@
/**
* Failure notification via email.
*
* Reads SMTP config from config/notify.json (gitignored, written by deploy-to-ad2.py).
* Silently swallows send errors so a notification failure never masks the real error.
*/
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(__dirname, '..', 'config', 'notify.json');
function loadConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (err) {
console.error(`[NOTIFY] Could not read ${CONFIG_PATH}: ${err.message}`);
return null;
}
}
/**
* Send a failure notification email.
* @param {string} subject
* @param {string} body - plain text
*/
async function sendFailureEmail(subject, body) {
const cfg = loadConfig();
if (!cfg) return;
let nodemailer;
try {
nodemailer = require('nodemailer');
} catch (err) {
console.error('[NOTIFY] nodemailer not installed — skipping email');
return;
}
const transporter = nodemailer.createTransport({
host: cfg.smtp.host,
port: cfg.smtp.port,
secure: false,
requireTLS: true,
auth: {
user: cfg.smtp.user,
pass: cfg.smtp.pass,
},
});
try {
await transporter.sendMail({
from: cfg.from,
to: cfg.to,
subject,
text: body,
});
console.log(`[NOTIFY] Failure email sent: ${subject}`);
} catch (err) {
console.error(`[NOTIFY] Failed to send email: ${err.message}`);
}
}
module.exports = { sendFailureEmail };