feat: GenieACS config export + TR-069 to TR-369 migration plan

- Add /acs/export endpoint: dumps all provisions, presets, virtual
  params, files metadata in one call (insurance policy for migration)
- Add /acs/provisions, /acs/presets, /acs/virtual-parameters, /acs/files
- Shell script export_genieacs.sh for offline full backup
- TR069-TO-TR369-MIGRATION.md: phased migration plan from GenieACS
  to Oktopus with parallel run, provision mapping, CPE batching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-02 21:03:41 -04:00
parent ea71eec194
commit 56ad97bc71
3 changed files with 358 additions and 0 deletions

View File

@ -0,0 +1,144 @@
# TR-069 (GenieACS) → TR-369 (Oktopus) Migration Plan
## Why Migrate
1. **GenieACS maintenance risk** — single maintainer, slow release cadence, Node.js/MongoDB stack aging
2. **TR-069 is polling-based** — CPE connects every X minutes, no real-time push. Reboot command waits for next inform.
3. **TR-369 (USP) is the ITU/BBF successor** — MQTT/WebSocket transport, bidirectional, real-time, certificate-based auth
4. **Oktopus is actively developed** — Go-based, NATS messaging, supports both TR-069 and TR-369
## Current GenieACS Architecture
```
CPE fleet (TR-069)
│ HTTP/SOAP (CWMP)
96.125.192.25 — GenieACS Core (CWMP, NBI, FS)
10.5.2.125 — MongoDB (devices, tasks, faults, provisions)
10.5.2.124 — GenieACS GUI (admin portal)
targo-hub /devices/* — Ops UI integration
```
## Target Oktopus Architecture
```
CPE fleet (TR-369 USP + legacy TR-069)
├── MQTT/WebSocket (USP) ──▶ Oktopus Controller
│ │
├── HTTP/SOAP (CWMP) ──────▶ Oktopus TR-069 Adapter
│ │
│ ▼
│ NATS (internal messaging)
│ │
│ ▼
│ PostgreSQL / TimescaleDB
targo-hub /devices/* — Ops UI integration (same endpoints, new backend)
```
## Migration Phases
### Phase 0: Export & Document (NOW)
- Run `scripts/migration/export_genieacs.sh` on GenieACS server
- Catalog all provisions, presets, virtual parameters
- Document each provision's purpose and parameter paths
- Download all firmware files from GridFS
### Phase 1: Deploy Oktopus with TR-069 Adapter (Parallel Run)
- Deploy Oktopus CE alongside GenieACS (different port/IP)
- Enable Oktopus TR-069 adapter to accept CWMP connections
- Point a small test group of CPEs (5-10 units) to Oktopus
- Verify: inform, reboot, parameter read/write, firmware upgrade
- Keep GenieACS running for the rest of the fleet
### Phase 2: Reproduce Provisions in Oktopus
- Map each GenieACS provision to Oktopus equivalent:
| GenieACS Concept | Oktopus Equivalent |
|---|---|
| Provision script (JS) | USP Set/Get/Operate messages + webhooks |
| `declare(path, ...)` | USP Get/Set on TR-181 path (same paths!) |
| `ext('script', ...)` | Oktopus webhook to targo-hub |
| Preset (trigger rule) | Device group + event subscription |
| Preset tag filter | Oktopus device group membership |
| Preset event (inform/boot) | MQTT topic subscription |
| Virtual parameter | Oktopus computed metric or targo-hub enrichment |
| File (firmware) | Oktopus firmware repository |
### Phase 3: Gradual CPE Migration
- Update CPE ACS URL via GenieACS setParameterValues:
```
Device.ManagementServer.URL = "https://acs-new.gigafibre.ca"
```
- Migrate in batches: 50 → 500 → all
- Monitor for faults, missed informs, failed tasks
- Keep GenieACS read-only as fallback for 30 days
### Phase 4: TR-369 Firmware Upgrade
- For CPEs that support USP (newer models):
- Push firmware with TR-369 agent enabled
- Configure USP controller URL and MQTT broker
- Migrate from TR-069 adapter to native TR-369
- For legacy CPEs (TR-069 only):
- Keep on Oktopus TR-069 adapter permanently
- Replace hardware on failure with TR-369 capable units
### Phase 5: Decommission GenieACS
- Verify 100% of fleet reports to Oktopus
- Archive MongoDB data (export to JSON/PostgreSQL)
- Shut down GenieACS servers
- Update targo-hub to point to Oktopus API instead of GenieACS NBI
## targo-hub Integration Changes
The `/devices/*` endpoints in targo-hub currently proxy GenieACS NBI. For Oktopus:
```
# Current (GenieACS NBI)
GENIEACS_NBI_URL=http://10.5.2.115:7557
# Future (Oktopus API)
OKTOPUS_API_URL=https://oss.gigafibre.ca/api
```
The `summarizeDevice()` function already handles both TR-098 and TR-181 parameter paths, so the Ops UI won't need changes — only the backend proxy target.
## CPE Compatibility Check
Before migration, verify each CPE model's protocol support:
| Model | TR-069 | TR-369 | Notes |
|---|---|---|---|
| ZTE F670L | ✅ | ❌ | Legacy, keep on TR-069 adapter |
| ZTE F680 | ✅ | ✅ | Firmware update enables USP |
| Huawei HG8245H | ✅ | ❌ | Legacy |
| Nokia G-010G-Q | ✅ | ✅ | USP via firmware 3.x+ |
(Fill in with actual fleet models after running device summary export)
## Risk Mitigation
1. **GenieACS stays running** during entire migration — no big bang cutover
2. **Same TR-181 data model** — parameter paths don't change
3. **targo-hub abstraction layer** — Ops UI doesn't care which ACS backend serves `/devices/*`
4. **Export script** preserves all business logic — can recreate GenieACS from scratch if needed
5. **Oktopus TR-069 adapter** means even legacy CPEs work without firmware changes
## Timeline Estimate
| Phase | Duration | Prerequisite |
|---|---|---|
| Phase 0: Export | 1 day | SSH access to GenieACS |
| Phase 1: Parallel deploy | 1 week | Oktopus server provisioned |
| Phase 2: Reproduce provisions | 1-2 weeks | Understanding of all scripts |
| Phase 3: CPE migration | 2-4 weeks | Phase 2 validated |
| Phase 4: TR-369 firmware | Ongoing | CPE vendor firmware availability |
| Phase 5: Decommission | 1 day | 30 days after Phase 3 |

View File

@ -0,0 +1,120 @@
#!/bin/bash
# ============================================================
# GenieACS Full Config Export
# ============================================================
# Exports all provisions, presets, virtual parameters, and file
# metadata from GenieACS NBI API to local JSON files.
#
# This is the "insurance policy" — captures all ACS logic before
# migrating to Oktopus TR-369 or if GenieACS becomes unmaintained.
#
# Usage:
# ./export_genieacs.sh [NBI_URL]
# ./export_genieacs.sh http://10.5.2.115:7557
#
# Output: ./genieacs-export-YYYY-MM-DD/ directory with:
# provisions.json — all provision scripts (the business logic)
# presets.json — trigger rules (when provisions fire)
# virtual-parameters.json — computed/virtual parameters
# files.json — firmware/config file metadata
# faults.json — current active faults
# devices-summary.json — device fleet summary
# full-export.json — everything combined
# ============================================================
set -euo pipefail
NBI="${1:-http://10.5.2.115:7557}"
DATE=$(date +%Y-%m-%d)
DIR="genieacs-export-${DATE}"
echo "=== GenieACS Config Export ==="
echo "NBI URL: ${NBI}"
echo "Output: ${DIR}/"
echo ""
mkdir -p "${DIR}"
echo "[1/7] Exporting provisions (scripts)..."
curl -sf "${NBI}/provisions/" | python3 -m json.tool > "${DIR}/provisions.json" 2>/dev/null || echo "[]" > "${DIR}/provisions.json"
COUNT=$(python3 -c "import json; print(len(json.load(open('${DIR}/provisions.json'))))" 2>/dev/null || echo "?")
echo " ${COUNT} provisions exported"
echo "[2/7] Exporting presets (trigger rules)..."
curl -sf "${NBI}/presets/" | python3 -m json.tool > "${DIR}/presets.json" 2>/dev/null || echo "[]" > "${DIR}/presets.json"
COUNT=$(python3 -c "import json; print(len(json.load(open('${DIR}/presets.json'))))" 2>/dev/null || echo "?")
echo " ${COUNT} presets exported"
echo "[3/7] Exporting virtual parameters..."
curl -sf "${NBI}/virtual-parameters/" | python3 -m json.tool > "${DIR}/virtual-parameters.json" 2>/dev/null || echo "[]" > "${DIR}/virtual-parameters.json"
COUNT=$(python3 -c "import json; print(len(json.load(open('${DIR}/virtual-parameters.json'))))" 2>/dev/null || echo "?")
echo " ${COUNT} virtual parameters exported"
echo "[4/7] Exporting files metadata..."
curl -sf "${NBI}/files/" | python3 -m json.tool > "${DIR}/files.json" 2>/dev/null || echo "[]" > "${DIR}/files.json"
COUNT=$(python3 -c "import json; print(len(json.load(open('${DIR}/files.json'))))" 2>/dev/null || echo "?")
echo " ${COUNT} files catalogued"
echo "[5/7] Exporting active faults..."
curl -sf "${NBI}/faults/" | python3 -m json.tool > "${DIR}/faults.json" 2>/dev/null || echo "[]" > "${DIR}/faults.json"
COUNT=$(python3 -c "import json; print(len(json.load(open('${DIR}/faults.json'))))" 2>/dev/null || echo "?")
echo " ${COUNT} active faults"
echo "[6/7] Exporting device summary..."
curl -sf "${NBI}/devices/?projection=DeviceID,_lastInform,_tags&limit=10000" | python3 -c "
import json, sys
from datetime import datetime, timezone
devices = json.load(sys.stdin)
now = datetime.now(timezone.utc)
stats = {'total': len(devices), 'online': 0, 'offline': 0, 'models': {}, 'manufacturers': {}}
for d in devices:
did = d.get('DeviceID', {})
model = did.get('ProductClass', {}).get('_value', 'Unknown') if isinstance(did.get('ProductClass'), dict) else 'Unknown'
mfr = did.get('Manufacturer', {}).get('_value', 'Unknown') if isinstance(did.get('Manufacturer'), dict) else 'Unknown'
li = d.get('_lastInform')
online = False
if li:
try:
age = (now - datetime.fromisoformat(li.replace('Z', '+00:00'))).total_seconds()
online = age < 300
except: pass
if online: stats['online'] += 1
else: stats['offline'] += 1
stats['models'][model] = stats['models'].get(model, 0) + 1
stats['manufacturers'][mfr] = stats['manufacturers'].get(mfr, 0) + 1
json.dump(stats, sys.stdout, indent=2)
" > "${DIR}/devices-summary.json" 2>/dev/null || echo "{}" > "${DIR}/devices-summary.json"
echo " Done"
echo "[7/7] Creating combined export..."
python3 -c "
import json
data = {
'exportedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
'source': '${NBI}',
}
for name in ['provisions', 'presets', 'virtual-parameters', 'files', 'faults', 'devices-summary']:
try:
with open('${DIR}/' + name + '.json') as f:
data[name.replace('-', '_')] = json.load(f)
except:
data[name.replace('-', '_')] = []
json.dump(data, open('${DIR}/full-export.json', 'w'), indent=2)
" 2>/dev/null
echo " ${DIR}/full-export.json"
echo ""
echo "=== Export Complete ==="
echo ""
echo "Next steps:"
echo " 1. Review provisions.json — these are your business logic scripts"
echo " 2. Review presets.json — these define WHEN each provision runs"
echo " 3. Map each provision to an Oktopus USP equivalent"
echo " 4. Download firmware files: curl -o firmware.bin '${NBI}/files/<file_id>'"
echo ""
echo "Provision → Oktopus mapping guide:"
echo " GenieACS declare() → USP Set message"
echo " GenieACS ext() → Oktopus webhook/script"
echo " GenieACS log() → Oktopus event logging"
echo " Preset tags → Oktopus device groups"
echo " Preset events → Oktopus MQTT subscriptions"

View File

@ -683,6 +683,11 @@ const server = http.createServer(async (req, res) => {
return handleGenieACS(req, res, method, path, url)
}
// ─── GenieACS Config Export (provisions, presets, virtual params, files) ───
if (path.startsWith('/acs/')) {
return handleACSConfig(req, res, method, path, url)
}
// ─── 404 ───
json(res, 404, { error: 'Not found' })
@ -1238,6 +1243,95 @@ async function handleGenieACS (req, res, method, path, url) {
}
}
// ── GenieACS Config Export ──
// Extract provisions, presets, virtual parameters, files metadata from GenieACS NBI
// Used for migration to Oktopus TR-369 and as backup of all ACS logic
async function handleACSConfig (req, res, method, path, url) {
if (!GENIEACS_NBI_URL) return json(res, 503, { error: 'GenieACS NBI not configured' })
try {
const parts = path.replace('/acs/', '').split('/').filter(Boolean)
const resource = parts[0]
// GET /acs/provisions — list all provision scripts
if (resource === 'provisions' && method === 'GET') {
const r = await genieRequest('GET', '/provisions/')
return json(res, r.status, r.data)
}
// GET /acs/provisions/:id — get single provision script
if (resource === 'provisions' && parts[1] && method === 'GET') {
const id = decodeURIComponent(parts[1])
const r = await genieRequest('GET', '/provisions/' + encodeURIComponent(id))
return json(res, r.status, r.data)
}
// GET /acs/presets — list all presets (trigger rules)
if (resource === 'presets' && method === 'GET') {
const r = await genieRequest('GET', '/presets/')
return json(res, r.status, r.data)
}
// GET /acs/virtual-parameters — list all virtual parameters
if (resource === 'virtual-parameters' && method === 'GET') {
const r = await genieRequest('GET', '/virtual-parameters/')
return json(res, r.status, r.data)
}
// GET /acs/files — list all files (firmware, configs)
if (resource === 'files' && method === 'GET') {
const r = await genieRequest('GET', '/files/')
return json(res, r.status, r.data)
}
// GET /acs/faults — list all active faults
if (resource === 'faults' && method === 'GET') {
const r = await genieRequest('GET', '/faults/')
return json(res, r.status, r.data)
}
// GET /acs/export — full export of all config (provisions + presets + virtual params + files metadata)
if (resource === 'export' && method === 'GET') {
const [provisions, presets, virtualParams, files, faults] = await Promise.all([
genieRequest('GET', '/provisions/').then(r => r.data).catch(() => []),
genieRequest('GET', '/presets/').then(r => r.data).catch(() => []),
genieRequest('GET', '/virtual-parameters/').then(r => r.data).catch(() => []),
genieRequest('GET', '/files/').then(r => r.data).catch(() => []),
genieRequest('GET', '/faults/').then(r => r.data).catch(() => []),
])
const exportData = {
exportedAt: new Date().toISOString(),
source: GENIEACS_NBI_URL,
provisions: Array.isArray(provisions) ? provisions : [],
presets: Array.isArray(presets) ? presets : [],
virtualParameters: Array.isArray(virtualParams) ? virtualParams : [],
files: Array.isArray(files) ? files.map(f => ({
_id: f._id,
metadata: f.metadata || {},
filename: f.filename,
length: f.length,
uploadDate: f.uploadDate,
})) : [],
faultCount: Array.isArray(faults) ? faults.length : 0,
summary: {
provisionCount: Array.isArray(provisions) ? provisions.length : 0,
presetCount: Array.isArray(presets) ? presets.length : 0,
virtualParamCount: Array.isArray(virtualParams) ? virtualParams.length : 0,
fileCount: Array.isArray(files) ? files.length : 0,
},
}
return json(res, 200, exportData)
}
return json(res, 404, { error: 'Unknown ACS config endpoint' })
} catch (e) {
log('ACS config error:', e.message)
return json(res, 502, { error: 'GenieACS config error: ' + e.message })
}
}
// ── GenieACS NBI Proxy ──
// Proxies requests to the GenieACS NBI API for CPE/ONT device management
// Endpoints: