Periksa berkas deploy
Periksa berkas deploy
Section titled “Periksa berkas deploy”Keterkaitan compose, nginx, dan cloudbuild.
Berkas: alat/hari-3/Praktik 11/tools/periksa-deploy.mjs
#!/usr/bin/env node// =====================================================================// Praktik 11 - periksa-deploy.mjs//// Memeriksa berkas deployment Praktik 11 sebagai satu kesatuan:// docker-compose.yml, .env.example, nginx.conf, cloudbuild.yaml.//// Pemeriksa terpisah (check-config.mjs dan periksa-nginx.mjs) hanya melihat// satu berkas pada satu waktu, sehingga ketidakcocokan ANTAR berkas tidak// pernah ketahuan. Skrip ini memeriksa hubungan antar berkas tersebut://// - variabel ${...} di compose ada di .env.example// - image placeholder bisa benar-benar ditarik// - port yang dipakai nginx.conf benar-benar dipublikasikan compose// - berkas yang di-mount nginx ada di repository// - tahapan HTTPS Praktik 11 bagian 3 didukung konfigurasi atau tidak// - substitution variable cloudbuild.yaml lengkap//// Pakai:// node periksa-deploy.mjs "<folder file latihan>"// =====================================================================
import { readFileSync, existsSync } from 'node:fs';import { join, basename, dirname } from 'node:path';import { fileURLToPath } from 'node:url';import { parseYaml as parseYamlSederhana } from './periksa-yaml.mjs';
const target = process.argv.slice(2).find((a) => !a.startsWith('--')) ?? '.';if (!existsSync(target)) { console.error(`Folder tidak ditemukan: ${target}`); process.exit(2);}
const temuan = [];const catat = (severity, judul, detail) => temuan.push({ severity, judul, detail });const baca = (nama) => { const p = join(target, nama); return existsSync(p) ? readFileSync(p, 'utf8') : null;};
function parseEnv(teks) { const hasil = {}; for (const b of teks.replace(/\r\n?/g, '\n').split('\n')) { const t = b.trim(); if (!t || t.startsWith('#')) continue; const i = t.indexOf('='); if (i === -1) continue; hasil[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, ''); } return hasil;}
console.log(`Folder : ${target}`);console.log('');
// ------------------------------------------------------------------ 1const teksCompose = baca('docker-compose.yml');const teksEnvContoh = baca('.env.example');const teksNginx = baca('nginx.conf');const teksCloudbuild = baca('cloudbuild.yaml');
for (const [nama, isi] of [ ['docker-compose.yml', teksCompose], ['.env.example', teksEnvContoh], ['nginx.conf', teksNginx], ['cloudbuild.yaml', teksCloudbuild],]) { console.log(` ${isi ? 'ADA ' : 'HILANG'} ${nama}`);}console.log('');
if (!teksCompose) { catat('kritis', 'docker-compose.yml tidak ada', 'Praktik 11 tahap pertama membuat berkas ini.');}
// ------------------------------------------------------------------ 2// Variabel ${...} di compose harus ada di .env.exampleif (teksCompose && teksEnvContoh) { const envContoh = parseEnv(teksEnvContoh); const dipakai = new Set(); for (const m of teksCompose.matchAll(/\$\{([A-Z_][A-Z0-9_]*)/g)) dipakai.add(m[1]); const hilang = [...dipakai].filter((v) => !(v in envContoh)); console.log(`2. Variabel compose : ${[...dipakai].join(', ')}`); if (hilang.length) { catat('tinggi', `Variabel ada di compose tetapi tidak ada di .env.example: ${hilang.join(', ')}`, 'Peserta yang menyalin .env.example tidak akan punya nilainya, dan compose gagal start.'); }
// Nilai wajib (:) harus benar-benar diisi for (const m of teksCompose.matchAll(/\$\{([A-Z_][A-Z0-9_]*):\?([^}]*)\}/g)) { const nilai = envContoh[m[1]]; if (nilai === undefined || nilai === '') { catat('tinggi', `${m[1]} diberi tanda wajib di compose tetapi kosong di .env.example`, `Pesan compose: "${m[2]}". Compose akan menolak start sampai nilai ini diisi.`); } }
// Placeholder NEXTJS_IMAGE const imageNext = envContoh.NEXTJS_IMAGE; if (imageNext) { if (/^(belum|todo|xxx|placeholder|nama-image)/i.test(imageNext)) { catat('kritis', `NEXTJS_IMAGE di .env.example bernilai karangan ("${imageNext}")`, 'docker compose akan mencoba menariknya dan gagal dengan pull access denied.\n' + 'Pakai image yang benar-benar ada, misalnya nginx:1.27-alpine.'); } else { console.log(` NEXTJS_IMAGE contoh: ${imageNext}`); } }
// Rahasia yang dibiarkan kosong for (const rahasia of ['JWT_SECRET', 'NEXTAUTH_SECRET', 'GEOSERVER_ADMIN_PASSWORD']) { if (rahasia in envContoh && !envContoh[rahasia]) { console.log(` ${rahasia} sengaja dikosongkan (harus diisi di VM)`); } }}
// ------------------------------------------------------------------ 3// Port yang dipakai nginx.conf harus dipublikasikan oleh composeif (teksNginx && teksCompose) { const portNginx = [...teksNginx.matchAll(/^\s*listen\s+(\d+)/gm)].map((m) => m[1]); const portDipublikasi = [...teksCompose.matchAll(/^\s*-\s*"(\d+):(\d+)"/gm)].map((m) => m[2]); console.log(`3. Port listen nginx : ${portNginx.join(', ') || '(tidak ada)'}`); console.log(` Port dipublikasi : ${portDipublikasi.join(', ') || '(tidak ada)'}`); for (const p of portNginx) { if (!portDipublikasi.includes(p)) { catat('tinggi', `nginx listen di port ${p} tetapi port itu tidak dipublikasikan compose`, `Tambahkan "${p}:${p}" pada bagian ports service nginx, atau trafik dari host tidak akan sampai.`); } } if (portDipublikasi.length === 0) { catat('tinggi', 'Tidak ada port yang dipublikasikan compose', 'Halaman tidak bisa diakses dari luar VM.'); }}
// ------------------------------------------------------------------ 4// Berkas/direktori yang di-mount nginx harus adaif (teksCompose) { const volumeNginx = [...teksCompose.matchAll(/^\s*-\s*\.\/([^:]+):/gm)].map((m) => m[1].trim()); console.log(`4. Mount lokal : ${volumeNginx.join(', ') || '(tidak ada)'}`); for (const v of volumeNginx) { if (existsSync(join(target, v))) continue; if (v.endsWith('.conf') || v.endsWith('.yml') || v.endsWith('.yaml')) { // Berkas konfigurasi: harus ada, kalau tidak Docker membuat DIREKTORI // bernama sama dan Nginx gagal start. catat('tinggi', `Berkas konfigurasi "${v}" di-mount tetapi tidak ada di repository`, 'Docker akan membuat direktori kosong dengan nama tersebut, lalu Nginx gagal start.'); } else { // Direktori data: Docker membuatnya otomatis dan itu memang diinginkan. catat('info', `Direktori data "${v}" belum ada di repository`, 'Docker akan membuatnya otomatis saat start, dan GeoServer mengisinya.\n' + 'Pastikan "/' + v + '/" ada di .gitignore supaya isinya tidak ikut ter-commit.'); } }
// Direktori yang dipakai tahap HTTPS tetapi belum ada for (const perlu of ['certbot-webroot', 'tls']) { if (teksCompose.includes(perlu) === false && !existsSync(join(target, perlu))) { catat('info', `Direktori "${perlu}" belum ada`, 'Dipakai pada tahap HTTPS Praktik 11 bagian 3. Dibuat di VM dengan mkdir -p, jadi tidak wajib ada di repository.'); } }}
// ------------------------------------------------------------------ 5// Kesiapan HTTPS (Praktik 11 bagian 3)if (teksNginx) { const ada443 = /listen\s+443/.test(teksNginx); const adaAcme = /\.well-known\/acme-challenge/.test(teksNginx); const adaIncludeTls = /include\s+[^;]*tls/i.test(teksNginx); console.log(`5. HTTPS di nginx.conf : listen 443=${ada443 ? 'ada' : 'TIDAK ADA'}, ` + `location ACME=${adaAcme ? 'ada' : 'TIDAK ADA'}, include tls=${adaIncludeTls ? 'ada' : 'TIDAK ADA'}`);
if (!adaAcme) { catat('kritis', 'nginx.conf tidak punya location untuk /.well-known/acme-challenge/', 'Praktik 11 bagian 3 Tahap 9 menjalankan:\n' + ' certbot certonly --webroot -w /opt/webgis/app/certbot-webroot -d $SUBDOMAIN\n' + 'Tanpa location ini, permintaan challenge diteruskan ke aplikasi Next.js, bukan ke webroot,\n' + 'sehingga certbot gagal dengan "Invalid response ... 404".'); } if (!ada443) { catat('tinggi', 'nginx.conf belum punya blok listen 443', 'Tahap 10 menulis berkas tls/aktifkan.conf berisi listen 443, tetapi nginx.conf\n' + 'harus memuatnya (mis. include /etc/nginx/tls/*.conf;) atau tahap itu tidak berpengaruh.'); } if (!adaIncludeTls && !ada443) { catat('tinggi', 'Tidak ada include untuk berkas TLS di nginx.conf', 'Tahap 10 Praktik 11 bagian 3 menulis konfigurasi ke /opt/webgis/app/tls/aktifkan.conf.\n' + 'Tanpa include, berkas itu tidak pernah dibaca Nginx.'); }}
// ------------------------------------------------------------------ 6if (teksCompose) { const { data: compose } = parseYamlSederhana(teksCompose); const layanan = Object.keys(compose.services ?? {}); console.log(`6. Service compose : ${layanan.join(', ') || '(tidak terbaca)'}`); if (layanan.length !== 3) { catat('tinggi', `Jumlah service ${layanan.length}, seharusnya 3`, 'Praktik 11 mengharapkan nextjs, geoserver, dan nginx.'); }
// rahasia yang ditulis langsung di compose if (/GEOSERVER_ADMIN_PASSWORD\s*=\s*[^$]/.test(teksCompose)) { catat('kritis', 'Kata sandi GeoServer ditulis langsung di docker-compose.yml', 'Berkas ini ikut ter-commit ke repository publik.'); }
if (/GEOSERVER_CORS_ALLOWED_ORIGINS/.test(teksCompose)) { catat('rendah', 'GEOSERVER_CORS_ENABLED / GEOSERVER_CORS_ALLOWED_ORIGINS tidak dibaca image kartoza', 'Entrypoint kartoza/geoserver tidak memakai kedua variabel ini. CORS diatur lewat web.xml\n' + '(cors.allowed.origins bernilai *). Jadi menulis "GEOSERVER_CORS_ALLOWED_ORIGINS=*"\n' + 'tidak mengubah apa pun — origin tetap terbuka untuk semua.'); }}
// ------------------------------------------------------------------ 7if (teksCloudbuild) { const dipakai = new Set([...teksCloudbuild.matchAll(/\$\{(_[A-Z0-9_]+)\}/g)].map((m) => m[1])); console.log(`7. Substitusi cloudbuild : ${[...dipakai].join(', ') || '(tidak ada)'}`); const diharapkan = ['_VM_NAME', '_VM_ZONE', '_VM_APP_DIR', '_IMAGE_NAME']; const belum = diharapkan.filter((v) => !dipakai.has(v)); if (belum.length) { catat('sedang', `Substitution variable belum dipakai: ${belum.join(', ')}`, 'Tahap 22 Praktik 11 meminta mengisi empat variabel ini di trigger Cloud Build.'); } if (!/images:/.test(teksCloudbuild)) { catat('rendah', 'cloudbuild.yaml tidak punya blok images', 'Tanpa itu Cloud Build tidak menandai image hasil build.'); } // Jalur SSH harus memakai IAP agar tidak butuh IP publik untuk SSH if (!/tunnel-through-iap/.test(teksCloudbuild)) { catat('tinggi', 'Cloud Build tidak memakai --tunnel-through-iap', 'SSH langsung ke IP publik membuka port 22 ke internet dan bertentangan dengan Tahap 7\n' + 'yang memakai metadata enable-oslogin=TRUE.'); }}
// ------------------------------------------------------------------ ringkasanconst WARNA = { kritis: '\u001b[31m', tinggi: '\u001b[31m', sedang: '\u001b[33m', rendah: '\u001b[36m', info: '\u001b[2m' };const URUT = { kritis: 0, tinggi: 1, sedang: 2, rendah: 3, info: 4 };const R = '\u001b[0m';const w = (s, t) => (process.stdout.isTTY ? WARNA[s] + t + R : t);
console.log('');if (temuan.length === 0) { console.log('\u001b[32mTidak ada temuan.\u001b[0m'); process.exit(0);}console.log(`HASIL: ${temuan.length} temuan`);for (const t of [...temuan].sort((a, b) => URUT[a.severity] - URUT[b.severity])) { console.log(`${w(t.severity, '[' + t.severity.toUpperCase() + ']')} ${t.judul}`); if (t.detail) for (const b of String(t.detail).split('\n')) console.log(` ${b}`);}process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);