'use strict' const express = require('express') const cors = require('cors') const Docker = require('dockerode') const fs = require('fs') const path = require('path') const app = express() const docker = new Docker({ socketPath: '/var/run/docker.sock' }) app.use(cors()) app.use(express.json()) const PORT = process.env.PORT || 3022 const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data') const ISTORE_URL = process.env.ISTORE_URL || 'http://istore:3021' const DOCKER_NETWORK = process.env.DOCKER_NETWORK || 'falah-net' const DB_FILE = path.join(DATA_DIR, 'installed.json') // ── Persistence (JSON file) ─────────────────────────────────────────────────── function readDb() { try { if (!fs.existsSync(DB_FILE)) return {} return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')) } catch { return {} } } function writeDb(data) { fs.mkdirSync(DATA_DIR, { recursive: true }) fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2)) } function getApp(id) { return readDb()[id] || null } function upsertApp(id, fields) { const db = readDb() db[id] = { ...(db[id] || {}), id, ...fields, updatedAt: new Date().toISOString() } writeDb(db) return db[id] } function removeApp(id) { const db = readDb() delete db[id] writeDb(db) } // ── Docker helpers ──────────────────────────────────────────────────────────── const CONTAINER_PREFIX = 'falah-app-' function containerName(id) { return `${CONTAINER_PREFIX}${id}` } async function pullImage(image) { return new Promise((resolve, reject) => { docker.pull(image, (err, stream) => { if (err) return reject(err) docker.modem.followProgress(stream, (err2) => { if (err2) reject(err2) else resolve() }) }) }) } async function startContainer(id, image, port) { const name = containerName(id) // Remove any existing stopped container with the same name try { const old = docker.getContainer(name) await old.remove({ force: true }) } catch { /* not found — fine */ } const portSpec = port ? { [`${port}/tcp`]: [{ HostPort: String(port) }] } : {} const container = await docker.createContainer({ name, Image: image, RestartPolicy: { Name: 'unless-stopped' }, HostConfig: { PortBindings: portSpec, NetworkMode: DOCKER_NETWORK, }, ExposedPorts: port ? { [`${port}/tcp`]: {} } : {}, Labels: { 'falah.managed': 'true', 'falah.app': id }, }) await container.start() return container.id } async function stopAndRemoveContainer(id) { const name = containerName(id) try { const container = docker.getContainer(name) const info = await container.inspect() if (info.State.Running) await container.stop() await container.remove() } catch (err) { if (!err.message?.includes('No such container')) throw err } } async function containerStatus(id) { try { const info = await docker.getContainer(containerName(id)).inspect() return info.State.Running ? 'running' : 'stopped' } catch { return 'not_found' } } // ── Install worker (async, non-blocking) ────────────────────────────────────── async function installApp(id, manifest) { const { image, port, name, version } = manifest try { upsertApp(id, { name, version, image, port, status: 'pulling', error: null }) await pullImage(image) upsertApp(id, { status: 'starting' }) const containerId = await startContainer(id, image, port) upsertApp(id, { status: 'running', containerId, installedAt: new Date().toISOString() }) } catch (err) { upsertApp(id, { status: 'error', error: err.message }) } } // ── Routes ──────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ ok: true })) app.get('/api/installed', (_req, res) => { res.json({ apps: Object.values(readDb()) }) }) app.get('/api/status/:id', (req, res) => { const record = getApp(req.params.id) if (!record) return res.json({ id: req.params.id, status: 'not_installed' }) res.json(record) }) app.post('/api/install/:id', async (req, res) => { const { id } = req.params const existing = getApp(id) if (existing && ['pulling', 'starting'].includes(existing.status)) { return res.status(409).json({ error: 'Install already in progress' }) } if (existing?.status === 'running') { return res.status(409).json({ error: 'Already installed' }) } // Fetch manifest from iStore service let manifest try { const r = await fetch(`${ISTORE_URL}/api/apps/${id}`) if (!r.ok) return res.status(404).json({ error: 'App not found in iStore' }) manifest = await r.json() } catch { return res.status(502).json({ error: 'Cannot reach iStore service' }) } if (!manifest.image) { return res.status(400).json({ error: 'App has no Docker image defined in manifest' }) } // Start install in background — respond immediately upsertApp(id, { status: 'pending', name: manifest.name, image: manifest.image }) installApp(id, manifest).catch(console.error) res.status(202).json({ id, status: 'pending', message: 'Installation started' }) }) app.delete('/api/uninstall/:id', async (req, res) => { const { id } = req.params const record = getApp(id) if (!record) return res.status(404).json({ error: 'App not installed' }) try { await stopAndRemoveContainer(id) removeApp(id) res.json({ id, status: 'uninstalled' }) } catch (err) { res.status(500).json({ error: err.message }) } }) // Sync container state with Docker reality (e.g. after CE restarts) app.post('/api/sync', async (_req, res) => { const db = readDb() for (const [id, record] of Object.entries(db)) { if (record.status === 'running') { const live = await containerStatus(id) if (live !== 'running') { upsertApp(id, { status: live === 'stopped' ? 'stopped' : 'error' }) } } } res.json({ synced: Object.keys(db).length }) }) app.listen(PORT, () => console.log(`[app-manager] running → http://localhost:${PORT} | Docker network: ${DOCKER_NETWORK}`) )