Lewati ke konten

Audit tabrakan nama

Model nama resource dan skenario pesimistis.

Berkas: alat/hari-3/Praktik 11/tools/audit-tabrakan.mjs

Praktik 11/tools/audit-tabrakan.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 11 - audit-tabrakan.mjs
//
// Menjawab satu pertanyaan: dari seluruh nama resource yang muncul di
// Praktik 11, mana yang WAJIB unik antar peserta dalam satu project, dan
// mana yang memang boleh sama karena dipakai bersama?
//
// Latar belakang
// --------------
// Satu akun master mengelola tiga project, dan setiap project dipakai
// EMPAT peserta bersama-sama:
//
// akun master
// ├── geoportal-kelompok-a-xxxxx peserta A01..A04
// ├── geoportal-kelompok-b-xxxxx peserta B01..B04
// └── geoportal-kelompok-c-xxxxx peserta C01..C04
//
// Jadi setiap nama resource punya dua kemungkinan: dibuat sekali oleh
// akun master untuk dipakai berempat, atau dibuat sendiri-sendiri oleh
// tiap peserta. Kalau peserta salah menebak, hasilnya ALREADY_EXISTS.
//
// Skrip ini tidak memanggil GCP. Ia membaca modul Praktik 11 dan berkas
// latihannya, lalu mengelompokkan setiap nama resource.
//
// Pakai:
// node audit-tabrakan.mjs "<folder Praktik 11>"
// node audit-tabrakan.mjs "<folder Praktik 11>" --peserta 4
// =====================================================================
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';
import { inflateRawSync } from 'node:zlib';
const argv = process.argv.slice(2);
const idxPeserta = argv.indexOf('--peserta');
const jumlahPeserta = idxPeserta > -1 ? Number(argv[idxPeserta + 1]) : 4;
// Hanya buang indeks nilai opsi bila opsinya memang ada. Tanpa penjagaan ini
// idxPeserta bernilai -1, sehingga "idxPeserta + 1" menjadi 0 dan argumen
// posisional PERTAMA ikut terbuang — target selalu jatuh ke ".".
const buang = new Set();
if (idxPeserta > -1) { buang.add(idxPeserta); buang.add(idxPeserta + 1); }
const posisi = argv.filter((a, i) => !a.startsWith('--') && !buang.has(i));
const target = posisi[0] ?? '.';
if (!existsSync(target)) {
console.error(`Folder tidak ditemukan: ${target}`);
process.exit(2);
}
// ---------------------------------------------------------------------
// Resource milik project, dibuat SATU KALI oleh akun master.
//
// Sumber: panduan setup-project bagian 7 "Buat resource bersama project":
// "Artifact Registry, firewall, dan network tag dipakai bersama. Jangan
// meminta setiap peserta membuat resource ini karena nama yang sama akan
// menghasilkan error ALREADY_EXISTS."
// ---------------------------------------------------------------------
const MILIK_MASTER = {
'katalog-images': {
jenis: 'Artifact Registry',
dibuatOleh: 'akun master (setup-project bagian 7)',
kalauDibuatPeserta: 'ALREADY_EXISTS. Keempat peserta memakai repository yang sama.',
catatan: 'Build peserta hanya menambah tag di dalamnya: katalog-images/nextjs-<slug>.',
},
'allow-webgis-http': {
jenis: 'Firewall rule',
dibuatOleh: 'akun master',
kalauDibuatPeserta: 'ALREADY_EXISTS. Semua peserta memakai tag webgis-http yang sama.',
catatan: 'Port 80 dan 443, target tag webgis-http.',
},
'allow-webgis-iap-ssh': {
jenis: 'Firewall rule',
dibuatOleh: 'akun master',
kalauDibuatPeserta: 'ALREADY_EXISTS. Semua peserta memakai tag webgis-iap-ssh yang sama.',
catatan: 'Port 22 dari rentang IAP 35.235.240.0/20.',
},
'webgis-http': {
jenis: 'Network tag',
dibuatOleh: 'akun master (menempel di firewall rule)',
kalauDibuatPeserta: 'Tag bukan resource, tapi namanya ditentukan firewall aturan master.',
catatan: 'Dipakai di --tags saat membuat VM. Sama untuk semua peserta.',
},
'webgis-iap-ssh': {
jenis: 'Network tag',
dibuatOleh: 'akun master (menempel di firewall rule)',
kalauDibuatPeserta: 'Tag bukan resource, tapi namanya ditentukan firewall aturan master.',
catatan: 'Dipakai di --tags saat membuat VM. Sama untuk semua peserta.',
},
default: {
jenis: 'VPC network',
dibuatOleh: 'GCP otomatis saat project dibuat',
kalauDibuatPeserta: 'Tidak dibuat peserta. Firewall master menempel di network ini.',
catatan: 'Dipakai sebagai --network=default.',
},
};
// ---------------------------------------------------------------------
// Resource per peserta: nama mengandung slug peserta.
//
// Sumber: panduan setup-project bagian 8 "Tetapkan pola nama unik".
// ---------------------------------------------------------------------
const PER_PESERTA = [
{ kunci: 'VM_NAME', pola: 'webgis-${PARTICIPANT_ID}', jenis: 'Compute Engine VM', unik: true },
{ kunci: 'STATIC_IP_NAME', pola: 'webgis-ip-${PARTICIPANT_ID}', jenis: 'Alamat IP statis', unik: true },
{ kunci: 'BUILD_SA_NAME', pola: 'cb-deployer-${PARTICIPANT_ID}', jenis: 'Service Account', unik: true },
{ kunci: 'CONNECTION_NAME', pola: 'github-${PARTICIPANT_ID}', jenis: 'GitHub connection (Cloud Build)', unik: true },
{ kunci: 'LINKED_REPO_NAME', pola: 'repo-${PARTICIPANT_ID}', jenis: 'Linked repository', unik: true },
{ kunci: 'TRIGGER_NAME', pola: 'deploy-${PARTICIPANT_ID}', jenis: 'Cloud Build trigger', unik: true },
{ kunci: 'IMAGE_NAME', pola: 'nextjs-${PARTICIPANT_ID}', jenis: 'Tag image di Artifact Registry', unik: true },
{ kunci: 'SUBDOMAIN', pola: '${PARTICIPANT_ID}.gisbigtrainer.com', jenis: 'Subdomain DNS', unik: true },
];
// ---------------------------------------------------------------------
// Nama yang SAMA untuk semua peserta. Bukan tabrakan; ini memang desain.
// ---------------------------------------------------------------------
const BERSAMA = [
{ nama: 'APP_DIR', nilai: '/opt/webgis/app', catatan: 'Path di dalam VM masing-masing, jadi tidak bertabrakan.' },
{ nama: '_VM_APP_DIR', nilai: '/opt/webgis/app', catatan: 'Substitution variable; sama untuk semua peserta.' },
{ nama: 'REGION', nilai: 'asia-southeast2', catatan: '' },
{ nama: 'ZONE', nilai: 'asia-southeast2-b', catatan: '' },
{ nama: 'REPOSITORY', nilai: 'katalog-images', catatan: 'Repository bersama, lihat tabel di atas.' },
];
const temuan = [];
const catat = (severity, judul, detail) => temuan.push({ severity, judul, detail });
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);
const TEBAL = (t) => (process.stdout.isTTY ? '\u001b[1m' + t + R : t);
// ---------------------------------------------------------------------
// Baca seluruh teks Praktik 11, termasuk isi .docx bila ada.
// ---------------------------------------------------------------------
function bacaTeksPraktik11(akar) {
const teks = [];
const berkasTeks = ['docker-compose.yml', 'nginx.conf', 'cloudbuild.yaml', '.env.example'];
(function jelajah(d) {
for (const n of readdirSync(d)) {
const p = join(d, n);
if (statSync(p).isDirectory()) jelajah(p);
else if (berkasTeks.includes(n)) teks.push({ nama: p, isi: readFileSync(p, 'utf8') });
}
})(akar);
// .docx dan .pptx adalah arsip zip. Dibaca tanpa dependensi.
for (const n of readdirSync(akar)) {
const p = join(akar, n);
if (n.endsWith('.docx')) teks.push({ nama: p, isi: bacaDocx(p) });
}
return teks;
}
// Membaca teks dari .docx tanpa dependensi: berkas .docx adalah arsip ZIP
// biasa, dan isinya ada di word/document.xml. Node sudah punya pembaca ZIP
// mentah lewat zlib, jadi cukup membongkar header lokalnya sendiri.
function bacaDocx(jalur) {
let buf;
try {
buf = readFileSync(jalur);
} catch {
return '';
}
if (buf.readUInt32LE(0) !== 0x04034b50) return '';
// Telusuri entri ZIP satu per satu lewat local file header.
let pos = 0;
let xml = null;
while (pos + 30 <= buf.length && buf.readUInt32LE(pos) === 0x04034b50) {
const metode = buf.readUInt16LE(pos + 8);
const ukuranTerkompresi = buf.readUInt32LE(pos + 18);
const panjangNama = buf.readUInt16LE(pos + 26);
const panjangExtra = buf.readUInt16LE(pos + 28);
const nama = buf.toString('utf8', pos + 30, pos + 30 + panjangNama);
const awalData = pos + 30 + panjangNama + panjangExtra;
if (nama === 'word/document.xml') {
if (ukuranTerkompresi === 0) return ''; // memakai data descriptor, tidak didukung
const data = buf.subarray(awalData, awalData + ukuranTerkompresi);
try {
xml = metode === 0 ? data.toString('utf8') : inflateRawSync(data).toString('utf8');
} catch {
return '';
}
break;
}
// Entri berupa direktori punya ukuran 0 dan tidak punya data.
// Entri biasa dengan ukuran 0 berarti memakai data descriptor; berhenti.
if (ukuranTerkompresi === 0) {
if (nama.endsWith('/')) { pos = awalData; continue; }
return '';
}
pos = awalData + ukuranTerkompresi;
}
if (!xml) return '';
// Ambil isi <w:t>...</w:t> dan tandai akhir paragraf pada </w:p>.
const teks = xml
.replace(/<w:tab\b[^>]*\/>/g, '\t')
.replace(/<\/w:p>/g, '\n')
.replace(/<w:br\b[^>]*\/>/g, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n');
return teks;
}
const dokumen = bacaTeksPraktik11(target);
const semuaTeks = dokumen.map((d) => d.isi).join('\n');
const punyaDocx = dokumen.some((d) => d.isi.length > 0);
console.log(TEBAL(`Audit tabrakan nama resource — Praktik 11`));
console.log(`Folder : ${target}`);
console.log(`Peserta/project: ${jumlahPeserta}`);
console.log(`Berkas terbaca : ${dokumen.length}`);
if (!punyaDocx) {
console.log(w('kuning', 'Catatan: isi .docx tidak terbaca. Pemeriksaan bagian dokumen dilewati.'));
}
console.log('');
// ---------------------------------------------------------------------
// BAGIAN 1 — model tabrakan
// ---------------------------------------------------------------------
console.log(TEBAL('1. Model tabrakan antar peserta'));
console.log('');
console.log(` Project kelompok dipakai bersama ${jumlahPeserta} peserta. Setiap nama resource`);
console.log(' harus jatuh ke salah satu dari dua kelompok berikut.');
console.log('');
console.log(` ${w('info', 'A. Dibuat akun master, dipakai bersama (HARUS SAMA)')}`);
for (const [nama, info] of Object.entries(MILIK_MASTER)) {
if (nama === 'default') continue;
console.log(` ${nama.padEnd(24)} ${info.jenis}`);
}
console.log('');
console.log(` ${w('info', 'B. Dibuat peserta, HARUS BERBEDA tiap peserta')}`);
for (const p of PER_PESERTA) {
console.log(` ${p.pola.padEnd(34)} ${p.jenis}`);
}
console.log('');
// ---------------------------------------------------------------------
// BAGIAN 2 — cek apakah modul menyebut aturan ini
// ---------------------------------------------------------------------
console.log(TEBAL('2. Apakah modul menyampaikan aturan itu?'));
console.log('');
if (punyaDocx) {
const sebutBersama = /digunakan bersama|dipakai bersama|tidak perlu dibuat ulang|sudah tersedia/i.test(semuaTeks);
const sebutFirewallDibuat = /firewall-rules create/i.test(semuaTeks);
const sebutArtifactDibuat = /artifacts repositories create/i.test(semuaTeks);
if (sebutBersama) {
console.log(` ${w('info', 'ADA ')} Modul menyebut resource bersama ("digunakan bersama, tidak perlu dibuat ulang").`);
const baris = semuaTeks.split('\n').filter((b) => /digunakan bersama|tidak perlu dibuat ulang/i.test(b));
for (const b of baris) console.log(` "${b.trim().slice(0, 90)}"`);
} else {
catat('tinggi', 'Modul tidak menyebut sama sekali bahwa ada resource milik akun master',
'Peserta akan mengira semua resource di modul harus dia buat sendiri.');
}
if (sebutFirewallDibuat && !/jangan.*membuat|tidak perlu.*firewall/i.test(semuaTeks)) {
catat('tinggi', 'Modul memuat perintah membuat firewall tanpa mengatakan itu milik master',
'Nama rule yang sama dipakai keempat peserta dalam satu project.');
}
if (sebutArtifactDibuat) {
catat('sedang', 'Modul memuat perintah membuat Artifact Registry',
'Repository "katalog-images" hanya boleh dibuat sekali per project.');
}
} else {
console.log(' (dilewati: teks .docx tidak terbaca)');
}
// Tahap 8 memeriksa firewall tetapi tidak pernah membuatnya
if (punyaDocx && /firewall-rules list/i.test(semuaTeks) && !/firewall-rules create/i.test(semuaTeks)) {
console.log('');
console.log(` ${w('sedang', 'PERHATIAN')} Modul memeriksa firewall (Tahap 8) tetapi tidak pernah membuatnya.`);
catat('sedang', 'Firewall hanya diperiksa, tidak pernah dibuat',
'Kalau akun master melewatkannya, peserta buntu di Tahap 10 tanpa petunjuk.');
}
// ---------------------------------------------------------------------
// BAGIAN 3 — peserta tidak membaca panduan, apa yang terjadi
// ---------------------------------------------------------------------
console.log('');
console.log(TEBAL('3. Skenario: peserta hanya mengikuti modul Praktik 11'));
console.log('');
const skenario = [
{
tahap: 'Tahap 2',
aksi: 'Menyalin blok variabel apa adanya',
akibat: 'PARTICIPANT_ID="nama01" terisi harfiah. Di project yang sama, keempat peserta memakai "nama01".',
parah: 'kritis',
},
{
tahap: 'Tahap 4',
aksi: 'iam service-accounts create "cb-deployer-nama01"',
akibat: 'Peserta kedua mendapat ALREADY_EXISTS, atau — lebih buruk — memakai SA milik peserta pertama.',
parah: 'kritis',
},
{
tahap: 'Tahap 7',
aksi: 'instances create "webgis-nama01"',
akibat: 'VM peserta kedua tidak bisa dibuat. Peserta ketiga dan keempat juga.',
parah: 'kritis',
},
{
tahap: 'Tahap 9',
aksi: 'addresses create "webgis-ip-nama01" --addresses=<ip VM sendiri>',
akibat: 'Gagal karena IP itu sudah dikunci peserta pertama. Peserta kedua tidak punya IP statis.',
parah: 'kritis',
},
{
tahap: 'Tahap 21',
aksi: 'Trigger "deploy-nama01"',
akibat: 'Hanya satu yang bisa ada. Push keempat peserta memicu trigger yang sama, semuanya men-deploy ke VM yang sama.',
parah: 'kritis',
},
{
tahap: 'Tahap 2 + cloudbuild',
aksi: '_IMAGE_NAME = nextjs-nama01',
akibat: 'Tag image yang sama di repository bersama. Build peserta B menimpa image peserta A, lalu VM peserta A menarik image milik B.',
parah: 'kritis',
},
{
tahap: 'Tahap 6',
aksi: 'VM_SA="${PROJECT_NUMBER}-asisten01@example.com"',
akibat: 'Nilai ini bukan alamat service account. Lihat bagian 5.',
parah: 'tinggi',
},
{
tahap: 'Tahap 19-20',
aksi: 'Membuat GitHub connection "github-nama01"',
akibat: 'Sama seperti trigger. Connection bersifat per-project, bukan per-peserta.',
parah: 'tinggi',
},
];
for (const s of skenario) {
console.log(` ${w(s.parah, '[' + s.parah.toUpperCase() + ']')} ${s.tahap}: ${s.aksi}`);
console.log(` -> ${s.akibat}`);
catat(s.parah, `${s.tahap} — ${s.aksi}`, s.akibat);
}
// ---------------------------------------------------------------------
// BAGIAN 4 — berapa banyak peserta yang bisa tertampung
// ---------------------------------------------------------------------
console.log('');
console.log(TEBAL('4. Batas kapasitas yang membatasi jumlah peserta'));
console.log('');
console.log(' Kuota alamat IP publik eksternal per region: 4.');
console.log(' Karena itu satu project maksimum 4 peserta di region yang sama.');
if (jumlahPeserta > 4) {
catat('kritis', `${jumlahPeserta} peserta dalam satu project melebihi kuota IP publik`,
'Maksimum 4 alamat IP eksternal per region per project. Peserta kelima tidak akan mendapat IP.');
} else {
console.log(' Jumlah peserta saat ini masih dalam batas.');
}
// ---------------------------------------------------------------------
// BAGIAN 5 — nilai yang salah dan variabel mati
// ---------------------------------------------------------------------
console.log('');
console.log(TEBAL('5. Nilai dan variabel yang bermasalah di modul'));
console.log('');
if (punyaDocx) {
// VM_SA
const m = semuaTeks.match(/VM_SA="\$\{PROJECT_NUMBER\}-([^"]+)"/);
if (m) {
catat('kritis', `VM_SA = "\${PROJECT_NUMBER}-${m[1]}" bukan alamat service account yang sah`,
'Alamat service account Compute Engine bawaan berbentuk:\n' +
' ${PROJECT_NUMBER}-asisten02@example.com\n' +
'Nilai di modul berakhiran "@example.com" sehingga perintah IAM di Tahap 6 akan gagal\n' +
'dengan "Service account ... does not exist".');
}
// DNS_ZONE_NAME
if (/DNS_ZONE_NAME=/i.test(semuaTeks)) {
catat('sedang', 'DNS_ZONE_NAME masih ada di blok variabel modul',
'Panduan setup-project bagian 8 menyatakan: "DNS zone tidak lagi memakai slug peserta,\n' +
'karena pelatihan ini memakai subdomain dari domain milik instruktur."\n' +
'Variabel ini tidak dipakai di tahap mana pun dan meninggalkan kesan peserta harus\n' +
'membuat Cloud DNS zone sendiri.');
}
// REPOSITORY termasuk blok yang disuruh disesuaikan?
const blokTahap2 = semuaTeks.match(/PARTICIPANT_ID="[^"]*"[\s\S]{0,700}/)?.[0] ?? '';
if (/REPOSITORY="katalog-images"/.test(blokTahap2)) {
console.log(` ${w('info', 'INFO')} REPOSITORY="katalog-images" ada di blok Tahap 2 bersama nama-nama per peserta.`);
catat('sedang', 'REPOSITORY diletakkan dalam satu blok dengan nama milik peserta',
'"katalog-images" adalah resource bersama milik akun master, bukan milik peserta.\n' +
'Menaruhnya di blok yang sama membuat peserta mengira ia boleh menggantinya,\n' +
'padahal menggantinya akan memutus jalur image dari Cloud Build ke VM.');
}
}
// ---------------------------------------------------------------------
// BAGIAN 6 — apa yang perlu ditambahkan ke modul
// ---------------------------------------------------------------------
console.log('');
console.log(TEBAL('6. Yang perlu ditambahkan ke modul'));
console.log('');
const perbaikan = [
['Blok identitas peserta siap tempel',
'Satu blok berisi PROJECT_ID, PARTICIPANT_ID, dan EMAIL peserta, dengan penjagaan format.\n' +
'Peserta tidak boleh mengetik PARTICIPANT_ID sendiri.'],
['Pemeriksa pra-terbang',
'Dijalankan SEBELUM Tahap 4. Menolak kalau slug tidak sah atau sudah dipakai peserta lain.'],
['Daftar tegas resource milik master',
'Tabel: mana yang dibuat master, mana yang dibuat peserta, dan apa yang terjadi kalau salah.'],
['Peringatan di Tahap 8',
'Tahap 8 memeriksa firewall. Sebutkan bahwa firewall dibuat akun master dan TIDAK dibuat peserta.'],
['Hapus DNS_ZONE_NAME',
'Tidak dipakai lagi sejak DNS subdomain ditangani penyelenggara.'],
['Perbaiki VM_SA',
'Pakai ${PROJECT_NUMBER}-asisten02@example.com.'],
['Tabel pemetaan email -> slug',
'Empat peserta satu project harus tahu slug masing-masing. Pengumuman satu kali saja tidak cukup.'],
];
for (const [judul, isi] of perbaikan) {
console.log(` - ${TEBAL(judul)}`);
for (const b of isi.split('\n')) console.log(` ${b}`);
}
// ---------------------------------------------------------------------
// ringkasan
// ---------------------------------------------------------------------
console.log('');
console.log(TEBAL('='.repeat(68)));
if (temuan.length === 0) {
console.log('\u001b[32mTidak ada temuan.\u001b[0m');
process.exit(0);
}
const urut = [...temuan].sort((a, b) => URUT[a.severity] - URUT[b.severity]);
console.log(`HASIL: ${temuan.length} temuan`);
for (const t of urut) {
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}`);
}
const ringkas = {};
for (const t of temuan) ringkas[t.severity] = (ringkas[t.severity] ?? 0) + 1;
console.log('');
console.log('Ringkasan: ' + Object.entries(ringkas).sort((a, b) => URUT[a[0]] - URUT[b[0]]).map(([s, n]) => `${s.toUpperCase()}=${n}`).join(' '));
process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);