Lewati ke konten

Periksa berkas deploy

Keterkaitan compose, nginx, dan cloudbuild.

Berkas: alat/hari-3/Praktik 11/tools/periksa-deploy.mjs

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('');
// ------------------------------------------------------------------ 1
const 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.example
if (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 compose
if (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 ada
if (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.');
}
}
// ------------------------------------------------------------------ 6
if (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.');
}
}
// ------------------------------------------------------------------ 7
if (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.');
}
}
// ------------------------------------------------------------------ ringkasan
const 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);