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)
|
└── 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)
|
## Anti-Patterns (DON'T DO THIS)
|
||||||
|
|
||||||
❌ **DO NOT hardcode Paper123!@#** - Always fetch from vault:
|
❌ **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
|
# Python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const db = require('./db');
|
|||||||
|
|
||||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||||
|
const { sendFailureEmail } = require('../server/notify');
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const OUTPUT_DIR = 'X:\\For_Web';
|
const OUTPUT_DIR = 'X:\\For_Web';
|
||||||
@@ -251,7 +252,22 @@ async function exportNewRecords(specMap, filePaths) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
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 };
|
module.exports = { exportNewRecords };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { parseMultilineFile, extractTestStation } = require('../parsers/multiline
|
|||||||
const { parseCsvFile } = require('../parsers/csvline');
|
const { parseCsvFile } = require('../parsers/csvline');
|
||||||
const { parseShtFile } = require('../parsers/shtfile');
|
const { parseShtFile } = require('../parsers/shtfile');
|
||||||
const { parseVaslogEngTxt } = require('../parsers/vaslog-engtxt');
|
const { parseVaslogEngTxt } = require('../parsers/vaslog-engtxt');
|
||||||
|
const { sendFailureEmail } = require('../server/notify');
|
||||||
|
|
||||||
// Data source paths
|
// Data source paths
|
||||||
const TEST_PATH = 'C:/Shares/test';
|
const TEST_PATH = 'C:/Shares/test';
|
||||||
@@ -366,6 +367,10 @@ async function importFiles(filePaths) {
|
|||||||
await exportNewRecords(specMap, filePaths);
|
await exportNewRecords(specMap, filePaths);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[EXPORT] Datasheet export failed: ${err.message}`);
|
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)');
|
console.log(' node import.js --file <f> Import specific file(s)');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} 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_ENTRY = 'clients/dataforth/ad2.sops.yaml'
|
||||||
VAULT_FIELD = 'credentials.password'
|
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:
|
def get_ad2_password() -> str:
|
||||||
"""Fetch the AD2 sysadmin password from the SOPS vault.
|
"""Fetch the AD2 sysadmin password from the SOPS vault.
|
||||||
@@ -66,6 +73,27 @@ def get_ad2_password() -> str:
|
|||||||
)
|
)
|
||||||
return pwd
|
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'
|
REMOTE_ROOT = 'C:/Shares/testdatadb'
|
||||||
LOCAL_ROOT = os.path.dirname(os.path.abspath(__file__))
|
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.
|
# Files that do NOT yet exist on AD2 and must be created fresh.
|
||||||
NEW_FILES = [
|
NEW_FILES = [
|
||||||
('parsers/vaslog-engtxt.js', 'parsers/vaslog-engtxt.js'),
|
('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(f'Backup tag: .bak-{stamp}')
|
||||||
print('')
|
print('')
|
||||||
|
|
||||||
|
smtp_pass = get_smtp_password()
|
||||||
|
|
||||||
ssh = connect()
|
ssh = connect()
|
||||||
try:
|
try:
|
||||||
sftp = ssh.open_sftp()
|
sftp = ssh.open_sftp()
|
||||||
@@ -206,8 +237,53 @@ def main() -> int:
|
|||||||
|
|
||||||
for local_rel, remote_rel in NEW_FILES:
|
for local_rel, remote_rel in NEW_FILES:
|
||||||
create_new(sftp, local_rel, remote_rel, args.dry_run)
|
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:
|
finally:
|
||||||
sftp.close()
|
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:
|
finally:
|
||||||
ssh.close()
|
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