deploymaster-panel 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/bin/deploymaster.js +52 -0
- package/package.json +44 -4
- package/public/app.js +186 -0
- package/public/docs.html +188 -0
- package/public/index.html +152 -0
- package/scripts/install-ubuntu.sh +8 -0
- package/scripts/run-windows.bat +6 -0
- package/src/config.js +32 -0
- package/src/deployer.js +203 -0
- package/src/detector.js +126 -0
- package/src/proc.js +116 -0
- package/src/server.js +272 -0
- package/src/store.js +40 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const multer = require('multer');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const AdmZip = require('adm-zip');
|
|
10
|
+
const selfsigned = require('selfsigned');
|
|
11
|
+
|
|
12
|
+
const cfg = require('./config');
|
|
13
|
+
const store = require('./store');
|
|
14
|
+
const { analyzeProject, suggestManifest } = require('./detector');
|
|
15
|
+
const deployer = require('./deployer');
|
|
16
|
+
const proc = require('./proc');
|
|
17
|
+
|
|
18
|
+
const app = express();
|
|
19
|
+
app.use(cors());
|
|
20
|
+
app.use(express.json({ limit: '5mb' }));
|
|
21
|
+
app.use(express.static(path.join(cfg.ROOT, 'public')));
|
|
22
|
+
|
|
23
|
+
// uploads (ZIP project via HTTP/HTTPS)
|
|
24
|
+
const storage = multer.diskStorage({
|
|
25
|
+
destination: (req, file, cb) => cb(null, cfg.UPLOAD_DIR),
|
|
26
|
+
filename: (req, file, cb) => cb(null, Date.now() + '-' + (file.originalname || 'upload.zip')),
|
|
27
|
+
});
|
|
28
|
+
const upload = multer({
|
|
29
|
+
storage,
|
|
30
|
+
limits: { fileSize: 500 * 1024 * 1024 },
|
|
31
|
+
fileFilter: (req, file, cb) => {
|
|
32
|
+
if (/\.zip$/i.test(file.originalname) || file.mimetype.includes('zip') || file.mimetype.includes('octet')) cb(null, true);
|
|
33
|
+
else cb(new Error('Hanya file .zip yang diterima. ZIP project Anda dulu. Lihat Docs.'));
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---------- helpers ----------
|
|
38
|
+
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
39
|
+
|
|
40
|
+
// ---------- SYSTEM ----------
|
|
41
|
+
app.get('/api/system', (req, res) => {
|
|
42
|
+
res.json({
|
|
43
|
+
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
44
|
+
platform: os.platform(),
|
|
45
|
+
node: process.version,
|
|
46
|
+
cpus: os.cpus().length,
|
|
47
|
+
totalMemMB: Math.round(os.totalmem() / 1024 / 1024),
|
|
48
|
+
freeMemMB: Math.round(os.freemem() / 1024 / 1024),
|
|
49
|
+
httpPort: cfg.PORT,
|
|
50
|
+
httpsPort: cfg.HTTPS_PORT,
|
|
51
|
+
httpsEnabled: cfg.HTTPS_ENABLED,
|
|
52
|
+
tlsReady: tlsState.ready,
|
|
53
|
+
tlsInfo: tlsState.info,
|
|
54
|
+
suggestPort: store.suggestPort(),
|
|
55
|
+
maxReleases: cfg.MAX_RELEASES,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.get('/api/health', (req, res) => res.json({ ok: true, time: new Date().toISOString() }));
|
|
60
|
+
|
|
61
|
+
// ---------- APPS ----------
|
|
62
|
+
app.get('/api/apps', (req, res) => {
|
|
63
|
+
const apps = store.getApps().map(a => ({
|
|
64
|
+
...a,
|
|
65
|
+
running: proc.isRunning(a.id),
|
|
66
|
+
releases: deployer.listReleases(a.id),
|
|
67
|
+
}));
|
|
68
|
+
res.json(apps);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.get('/api/apps/:id', (req, res) => {
|
|
72
|
+
const a = store.getApp(req.params.id);
|
|
73
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
74
|
+
res.json({ ...a, running: proc.isRunning(a.id), releases: deployer.listReleases(a.id) });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Analisis ZIP tanpa deploy → preview manifest + checklist syarat
|
|
78
|
+
app.post('/api/apps/analyze', upload.single('file'), asyncH(async (req, res) => {
|
|
79
|
+
if (!req.file) return res.status(400).json({ error: 'Upload file .zip dulu' });
|
|
80
|
+
const tmp = path.join(cfg.UPLOAD_DIR, 'analyze-' + Date.now());
|
|
81
|
+
fs.mkdirSync(tmp, { recursive: true });
|
|
82
|
+
try {
|
|
83
|
+
new AdmZip(req.file.path).extractAllTo(tmp, true);
|
|
84
|
+
// normalisasi
|
|
85
|
+
const entries = fs.readdirSync(tmp);
|
|
86
|
+
if (entries.length === 1 && fs.statSync(path.join(tmp, entries[0])).isDirectory() && fs.existsSync(path.join(tmp, entries[0], 'package.json'))) {
|
|
87
|
+
const only = path.join(tmp, entries[0]);
|
|
88
|
+
for (const f of fs.readdirSync(only)) fs.renameSync(path.join(only, f), path.join(tmp, f));
|
|
89
|
+
fs.rmdirSync(only);
|
|
90
|
+
}
|
|
91
|
+
const serviceType = req.body.serviceType || 'web';
|
|
92
|
+
const analysis = analyzeProject(tmp, serviceType);
|
|
93
|
+
const port = store.suggestPort();
|
|
94
|
+
const manifest = suggestManifest({ name: req.body.name || 'my-app', framework: analysis.framework, scripts: analysis.scripts, pkg: analysis.pkg, port: serviceType === 'worker' ? null : port, serviceType });
|
|
95
|
+
res.json({ analysis, manifest, suggestPort: serviceType === 'worker' ? null : port });
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
98
|
+
fs.rmSync(req.file.path, { force: true });
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// Deploy baru dari ZIP
|
|
103
|
+
app.post('/api/apps/deploy', upload.single('file'), asyncH(async (req, res) => {
|
|
104
|
+
if (!req.file) return res.status(400).json({ error: 'Upload file .zip dulu' });
|
|
105
|
+
const name = (req.body.name || path.basename(req.file.originalname, '.zip')).trim() || 'my-app';
|
|
106
|
+
let envExtra = {}, manifestOverride = null;
|
|
107
|
+
try { if (req.body.env) envExtra = JSON.parse(req.body.env); } catch {}
|
|
108
|
+
try { if (req.body.manifest) manifestOverride = JSON.parse(req.body.manifest); } catch {}
|
|
109
|
+
const serviceType = (manifestOverride && manifestOverride.serviceType) || req.body.serviceType || 'web';
|
|
110
|
+
const isWorker = serviceType === 'worker';
|
|
111
|
+
const port = isWorker ? null : parseInt(req.body.port || (manifestOverride && manifestOverride.port) || store.suggestPort(), 10);
|
|
112
|
+
if (!isWorker && store.usedPorts().includes(port)) { fs.rmSync(req.file.path, { force: true }); return res.status(400).json({ error: `Port ${port} sudah dipakai app lain` }); }
|
|
113
|
+
if (manifestOverride) { manifestOverride.serviceType = serviceType; manifestOverride.port = port; }
|
|
114
|
+
try {
|
|
115
|
+
const result = await deployer.deployNew({ name, zipPath: req.file.path, port, envExtra, manifestOverride });
|
|
116
|
+
res.json(result);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
res.status(400).json({ error: e.message, analysis: e.analysis || null, appId: e.appId || null });
|
|
119
|
+
} finally { try { fs.rmSync(req.file.path, { force: true }); } catch {} }
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
// Redeploy (upload versi baru → release baru; gagal = auto-rollback)
|
|
123
|
+
app.post('/api/apps/:id/redeploy', upload.single('file'), asyncH(async (req, res) => {
|
|
124
|
+
if (!req.file) return res.status(400).json({ error: 'Upload file .zip versi baru' });
|
|
125
|
+
let manifestOverride = null, envExtra = {};
|
|
126
|
+
try { if (req.body.manifest) manifestOverride = JSON.parse(req.body.manifest); } catch {}
|
|
127
|
+
try { if (req.body.env) envExtra = JSON.parse(req.body.env); } catch {}
|
|
128
|
+
try {
|
|
129
|
+
const result = await deployer.redeploy(req.params.id, { zipPath: req.file.path, manifestOverride, envExtra });
|
|
130
|
+
res.json(result);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
res.status(400).json({ error: e.message, analysis: e.analysis || null });
|
|
133
|
+
} finally { try { fs.rmSync(req.file.path, { force: true }); } catch {} }
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// Rollback ke release sebelumnya / tertentu
|
|
137
|
+
app.post('/api/apps/:id/rollback', asyncH(async (req, res) => {
|
|
138
|
+
const result = await deployer.rollback(req.params.id, req.body.release || null);
|
|
139
|
+
res.json(result);
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
app.get('/api/apps/:id/releases', (req, res) => {
|
|
143
|
+
const a = store.getApp(req.params.id);
|
|
144
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
145
|
+
const rels = deployer.listReleases(a.id).map(r => ({
|
|
146
|
+
release: r,
|
|
147
|
+
current: r === a.currentRelease,
|
|
148
|
+
manifest: deployer.readReleaseManifest(a.id, r),
|
|
149
|
+
}));
|
|
150
|
+
res.json(rels);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
app.post('/api/apps/:id/start', asyncH(async (req, res) => {
|
|
154
|
+
const a = store.getApp(req.params.id);
|
|
155
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
156
|
+
if (proc.isRunning(a.id)) return res.json(a);
|
|
157
|
+
const rel = a.currentRelease || deployer.listReleases(a.id)[0];
|
|
158
|
+
if (!rel) return res.status(400).json({ error: 'Belum ada release. Deploy dulu.' });
|
|
159
|
+
deployer.pointCurrentTo(a.id, rel);
|
|
160
|
+
const child = await proc.startApp({ id: a.id, appDir: deployer.appDirOf(a.id), currentDir: deployer.releaseDirOf(a.id, rel) }, a.manifest);
|
|
161
|
+
const next = { ...a, status: 'running', pid: child.pid, currentRelease: rel, currentDir: deployer.releaseDirOf(a.id, rel) };
|
|
162
|
+
store.upsertApp(next);
|
|
163
|
+
res.json(next);
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
app.post('/api/apps/:id/stop', asyncH(async (req, res) => {
|
|
167
|
+
const a = store.getApp(req.params.id);
|
|
168
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
169
|
+
await proc.stopApp({ id: a.id, pid: a.pid, appDir: deployer.appDirOf(a.id) });
|
|
170
|
+
const next = { ...a, status: 'stopped', pid: null };
|
|
171
|
+
store.upsertApp(next);
|
|
172
|
+
res.json(next);
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
app.post('/api/apps/:id/restart', asyncH(async (req, res) => {
|
|
176
|
+
const a = store.getApp(req.params.id);
|
|
177
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
178
|
+
await proc.stopApp({ id: a.id, pid: a.pid, appDir: deployer.appDirOf(a.id) }).catch(() => {});
|
|
179
|
+
const rel = a.currentRelease || deployer.listReleases(a.id)[0];
|
|
180
|
+
const child = await proc.startApp({ id: a.id, appDir: deployer.appDirOf(a.id), currentDir: deployer.releaseDirOf(a.id, rel) }, a.manifest);
|
|
181
|
+
const next = { ...a, status: 'running', pid: child.pid };
|
|
182
|
+
store.upsertApp(next);
|
|
183
|
+
res.json(next);
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
app.delete('/api/apps/:id', asyncH(async (req, res) => {
|
|
187
|
+
const a = store.getApp(req.params.id);
|
|
188
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
189
|
+
await proc.stopApp({ id: a.id, pid: a.pid, appDir: deployer.appDirOf(a.id) }).catch(() => {});
|
|
190
|
+
fs.rmSync(deployer.appDirOf(a.id), { recursive: true, force: true });
|
|
191
|
+
store.removeApp(a.id);
|
|
192
|
+
res.json({ ok: true });
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
app.get('/api/apps/:id/logs', (req, res) => {
|
|
196
|
+
const a = store.getApp(req.params.id);
|
|
197
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
198
|
+
const lines = parseInt(req.query.lines || '300', 10);
|
|
199
|
+
const mem = proc.getMemLog(a.id);
|
|
200
|
+
const file = proc.readFileLog(deployer.appDirOf(a.id), lines);
|
|
201
|
+
res.json({ mem, file, running: proc.isRunning(a.id) });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.get('/api/apps/:id/manifest', (req, res) => {
|
|
205
|
+
const a = store.getApp(req.params.id);
|
|
206
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
207
|
+
res.json(a.manifest || {});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
app.put('/api/apps/:id/manifest', asyncH(async (req, res) => {
|
|
211
|
+
const a = store.getApp(req.params.id);
|
|
212
|
+
if (!a) return res.status(404).json({ error: 'App tidak ditemukan' });
|
|
213
|
+
const next = { ...a, manifest: req.body, port: req.body.port || a.port, updatedAt: new Date().toISOString() };
|
|
214
|
+
store.upsertApp(next);
|
|
215
|
+
// simpan juga ke release aktif agar rollback konsisten
|
|
216
|
+
if (a.currentRelease) {
|
|
217
|
+
try { fs.writeFileSync(path.join(deployer.releaseDirOf(a.id, a.currentRelease), 'deployment-manifest.json'), JSON.stringify(req.body, null, 2)); } catch {}
|
|
218
|
+
}
|
|
219
|
+
res.json(next);
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
// ---------- TLS / HTTPS ----------
|
|
223
|
+
app.post('/api/tls/generate', (req, res) => {
|
|
224
|
+
const attrs = [{ name: 'commonName', value: req.body.cn || 'localhost' }];
|
|
225
|
+
const pems = selfsigned.generate(attrs, { days: 825, keySize: 2048, algorithm: 'sha256' });
|
|
226
|
+
fs.writeFileSync(path.join(cfg.CERTS_DIR, 'key.pem'), pems.private);
|
|
227
|
+
fs.writeFileSync(path.join(cfg.CERTS_DIR, 'cert.pem'), pems.cert);
|
|
228
|
+
tlsState.ready = true;
|
|
229
|
+
tlsState.info = 'self-signed certs/cert.pem (generate via Panel)';
|
|
230
|
+
res.json({ ok: true, info: tlsState.info, note: 'Restart panel agar HTTPS aktif. Akses https://IP:9443 (browser akan warning karena self-signed — wajar untuk testing).' });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// error handler
|
|
234
|
+
app.use((err, req, res, next) => {
|
|
235
|
+
console.error(err);
|
|
236
|
+
res.status(500).json({ error: err.message || 'Internal error' });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------- TLS state + servers ----------
|
|
240
|
+
const tlsState = { ready: false, info: 'belum ada cert' };
|
|
241
|
+
function loadTLS() {
|
|
242
|
+
// prioritas: env path → certs/cert.pem & key.pem → auto self-signed
|
|
243
|
+
let cert, key;
|
|
244
|
+
const c1 = cfg.TLS_CERT_PATH ? (path.isAbsolute(cfg.TLS_CERT_PATH) ? cfg.TLS_CERT_PATH : path.join(cfg.ROOT, cfg.TLS_CERT_PATH)) : path.join(cfg.CERTS_DIR, 'cert.pem');
|
|
245
|
+
const k1 = cfg.TLS_KEY_PATH ? (path.isAbsolute(cfg.TLS_KEY_PATH) ? cfg.TLS_KEY_PATH : path.join(cfg.ROOT, cfg.TLS_KEY_PATH)) : path.join(cfg.CERTS_DIR, 'key.pem');
|
|
246
|
+
if (fs.existsSync(c1) && fs.existsSync(k1)) {
|
|
247
|
+
cert = fs.readFileSync(c1); key = fs.readFileSync(k1);
|
|
248
|
+
tlsState.ready = true; tlsState.info = `${c1}`;
|
|
249
|
+
return { cert, key };
|
|
250
|
+
}
|
|
251
|
+
if (cfg.AUTO_SELF_SIGNED) {
|
|
252
|
+
const pems = selfsigned.generate([{ name: 'commonName', value: 'localhost' }], { days: 825, keySize: 2048, algorithm: 'sha256' });
|
|
253
|
+
fs.writeFileSync(path.join(cfg.CERTS_DIR, 'cert.pem'), pems.cert);
|
|
254
|
+
fs.writeFileSync(path.join(cfg.CERTS_DIR, 'key.pem'), pems.private);
|
|
255
|
+
tlsState.ready = true; tlsState.info = 'auto self-signed (testing)';
|
|
256
|
+
return { cert: pems.cert, key: pems.private };
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
http.createServer(app).listen(cfg.PORT, cfg.HOST, () => {
|
|
262
|
+
console.log(`\n✓ DeployMaster HTTP : http://${cfg.HOST === '0.0.0.0' ? 'localhost' : cfg.HOST}:${cfg.PORT} (untuk testing/upload)`);
|
|
263
|
+
});
|
|
264
|
+
if (cfg.HTTPS_ENABLED) {
|
|
265
|
+
try {
|
|
266
|
+
const tls = loadTLS();
|
|
267
|
+
if (tls) https.createServer(tls, app).listen(cfg.HTTPS_PORT, cfg.HOST, () => {
|
|
268
|
+
console.log(`✓ DeployMaster HTTPS : https://localhost:${cfg.HTTPS_PORT} (${tlsState.info})`);
|
|
269
|
+
});
|
|
270
|
+
else console.log('! HTTPS diminta tapi tidak ada cert & AUTO_SELF_SIGNED=false');
|
|
271
|
+
} catch (e) { console.error('! Gagal start HTTPS:', e.message); }
|
|
272
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const cfg = require('./config');
|
|
4
|
+
|
|
5
|
+
const DB_FILE = path.join(cfg.DATA_DIR, 'db.json');
|
|
6
|
+
|
|
7
|
+
function load() {
|
|
8
|
+
try {
|
|
9
|
+
if (!fs.existsSync(DB_FILE)) return { apps: [] };
|
|
10
|
+
return JSON.parse(fs.readFileSync(DB_FILE, 'utf8'));
|
|
11
|
+
} catch { return { apps: [] }; }
|
|
12
|
+
}
|
|
13
|
+
function save(db) {
|
|
14
|
+
fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2));
|
|
15
|
+
}
|
|
16
|
+
function getApps() { return load().apps; }
|
|
17
|
+
function getApp(id) { return load().apps.find(a => a.id === id); }
|
|
18
|
+
function upsertApp(app) {
|
|
19
|
+
const db = load();
|
|
20
|
+
const i = db.apps.findIndex(a => a.id === app.id);
|
|
21
|
+
if (i >= 0) db.apps[i] = app; else db.apps.push(app);
|
|
22
|
+
save(db);
|
|
23
|
+
return app;
|
|
24
|
+
}
|
|
25
|
+
function removeApp(id) {
|
|
26
|
+
const db = load();
|
|
27
|
+
db.apps = db.apps.filter(a => a.id !== id);
|
|
28
|
+
save(db);
|
|
29
|
+
}
|
|
30
|
+
function usedPorts(exceptId) {
|
|
31
|
+
return getApps().filter(a => a.id !== exceptId).map(a => a.port);
|
|
32
|
+
}
|
|
33
|
+
function suggestPort() {
|
|
34
|
+
const used = new Set(usedPorts());
|
|
35
|
+
let p = 3001;
|
|
36
|
+
while (used.has(p)) p++;
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { load, save, getApps, getApp, upsertApp, removeApp, usedPorts, suggestPort, DB_FILE };
|