21b48d3d53
- iStore service (port 3021): fetches app manifests from git.falahos.my/falahos org via Gitea API, 5-min cache, query/search support - UmmahID login: replaced stub form with Casdoor OAuth2 PKCE flow (RFC 7636), no client secret required; /api/auth/config and /api/auth/token endpoints added to server.cjs - App Manager service (port 3022): installs/uninstalls Docker apps via /var/run/docker.sock, pulls images, starts containers on falah-net, persists state to /data/installed.json - docker-compose.yml: orchestrates falah-app + istore + app-manager with health checks, named volumes, and shared falah-net network - install.sh: one-line curl installer — checks prereqs, clones repo, prompts for credentials, builds and starts all containers - .env.example: all env vars documented (CASDOOR_CLIENT_ID, GITEA_TOKEN, APP_MANAGER_PORT, etc.) - Frontend: IStore overlay now fetches live apps from Gitea, real install/uninstall via App Manager with 2s polling, indeterminate progress bar, Uninstall button for installed apps - app.yml.example: developer manifest spec for publishing to iStore
211 lines
6.4 KiB
JavaScript
211 lines
6.4 KiB
JavaScript
'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}`)
|
|
)
|