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:
@@ -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:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SMTP credentials written by deploy-to-ad2.py (never commit)
|
||||
implementation/config/notify.json
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)'}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user