Audit tabrakan nama
Audit tabrakan nama
Section titled “Audit tabrakan nama”Model nama resource dan skenario pesimistis.
Berkas: alat/hari-3/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(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'|'/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 membuatnyaif (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);