Pra-terbang peserta
Pra-terbang peserta
Section titled “Pra-terbang peserta”Periksa slug sebelum menyentuh Tahap 4.
Berkas: alat/hari-3/Praktik 11/tools/pra-terbang.mjs
#!/usr/bin/env node// =====================================================================// Praktik 11 - pra-terbang.mjs//// Pemeriksa yang dijalankan PESERTA sebelum menyentuh Tahap 4.//// Kenapa ini perlu// ----------------// Satu project kelompok dipakai EMPAT peserta bersama-sama. Modul Praktik 11// memakai PARTICIPANT_ID="nama01" sebagai contoh, dan peserta yang hanya// mengikuti modul akan menyalinnya apa adanya. Bagi peserta pertama itu// kebetulan benar; bagi peserta kedua, ketiga, dan keempat, seluruh rantai// nama menjadi sama://// VM webgis-nama01 · SA cb-deployer-nama01 · trigger deploy-nama01// IP webgis-ip-nama01 · image nextjs-nama01//// Peserta pertama berhasil. Peserta kedua gagal di Tahap 4 dengan// ALREADY_EXISTS, dan pesan itu tidak menjelaskan penyebabnya.//// Skrip ini menutup celah itu dengan dua cara://// 1. Format — menolak slug yang tidak sah SEBELUM dipakai membuat nama.// 2. Kepemilikan — memastikan satu slug hanya dipakai satu email. Kalau// slug sudah ada dan pemiliknya email lain, peserta diberhentikan.//// Pemeriksaan ini sengaja TIDAK memanggil GCP. Tujuannya menangkap kesalahan// sebelum peserta mengetik perintah yang mengubah apa pun, dan supaya bisa// dijalankan dari mana saja tanpa kredensial.//// Pakai:// node pra-terbang.mjs --email nama01@example.com --slug nama01 \// --project geoportal-kelompok-a-xxxxx --peserta 4//// node pra-terbang.mjs --roster daftar-peserta.csv --email nama01@example.com//// --roster menerima CSV berisi: email,slug,project// =====================================================================
import { readFileSync, existsSync } from 'node:fs';
const argv = process.argv.slice(2);
function ambilOpsi(nama) { const i = argv.indexOf(nama); return i > -1 ? argv[i + 1] : null;}
const email = ambilOpsi('--email');const slug = ambilOpsi('--slug');const project = ambilOpsi('--project');const jalurRoster = ambilOpsi('--roster');const jumlahPeserta = Number(ambilOpsi('--peserta') ?? 4);const keluaranJson = argv.includes('--json');
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);const benar = (t) => (process.stdout.isTTY ? '\u001b[32m' + t + R : t);
// ---------------------------------------------------------------------// Aturan format slug.//// Sumber: panduan setup-project bagian 8// "Peserta boleh memakai nama sendiri selama slug memakai huruf kecil,// angka, atau tanda hubung dan tidak sama dengan peserta lain di dalam// project yang sama ... tidak lebih dari 18 karakter."//// Batas 18 karakter bukan hiasan: slug dipakai membentuk nama VM// (webgis-<slug>), nama Service Account (cb-deployer-<slug>), dan nama// trigger. Service Account punya batas panjang, dan nama VM juga.// ---------------------------------------------------------------------const SLUG_MAKS = 18;const SLUG_POLA = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
// Kata yang tidak boleh dipakai sebagai slug.//// Isinya sengaja pendek dan hanya yang benar-benar bertabrakan:// - nama resource bersama milik akun master ("katalog-images", "allow-*")// - nama service di docker-compose (geoserver, nginx, nextjs, postgres)// - nama peran/sistem (admin, portal, default)//// "nama01" sampai "nama04" TIDAK ada di daftar ini: itu justru slug contoh// yang sah menurut panduan setup-project bagian 8. Yang membuatnya berbahaya// bukan namanya, melainkan dipakai berulang oleh peserta berbeda di project// yang sama — dan itu diperiksa lewat roster, bukan lewat daftar kata.const SLUG_TERLARANG = new Set([ 'katalog', 'images', 'katalog-images', 'allow', 'webgis-http', 'webgis-iap-ssh', 'geoserver', 'nginx', 'nextjs', 'postgres', 'default', 'admin', 'portal', 'cb', 'cloudbuild', 'peserta', 'participant',]);
// ---------------------------------------------------------------------// Kalau memakai roster, ambil identitas peserta dari sana.// ---------------------------------------------------------------------let emailFinal = email;let slugFinal = slug;let projectFinal = project;let roster = null;
if (jalurRoster) { if (!existsSync(jalurRoster)) { console.error(`Roster tidak ditemukan: ${jalurRoster}`); process.exit(2); } const baris = readFileSync(jalurRoster, 'utf8') .replace(/\r\n?/g, '\n') .split('\n') .map((b) => b.trim()) .filter((b) => b && !b.startsWith('#'));
const header = baris[0].split(',').map((h) => h.trim().toLowerCase()); const iEmail = header.indexOf('email'); const iSlug = header.indexOf('slug'); const iProject = header.indexOf('project'); if (iEmail === -1 || iSlug === -1) { console.error('Roster harus punya kolom "email" dan "slug".'); process.exit(2); }
roster = baris.slice(1).map((b) => { const sel = b.split(',').map((s) => s.trim()); return { email: sel[iEmail], slug: sel[iSlug], project: iProject > -1 ? sel[iProject] : null }; });
if (emailFinal) { const kecocokan = roster.find((r) => r.email.toLowerCase() === emailFinal.toLowerCase()); if (!kecocokan) { catat('kritis', `Email ${emailFinal} tidak ada di roster`, 'Hubungi koordinator. Jangan memilih slug sendiri.'); } else { slugFinal = kecocokan.slug; projectFinal = projectFinal ?? kecocokan.project; console.log(`Identitas dari roster: ${emailFinal} -> slug "${slugFinal}"`); } }}
console.log(TEBAL('Pemeriksa pra-terbang Praktik 11'));console.log(`Email : ${emailFinal ?? '(belum diisi)'}`);console.log(`Slug : ${slugFinal ?? '(belum diisi)'}`);console.log(`Project : ${projectFinal ?? '(belum diisi)'}`);console.log(`Peserta : ${jumlahPeserta} per project`);console.log('');
// ---------------------------------------------------------------------// 1. Kelengkapan// ---------------------------------------------------------------------if (!emailFinal) { catat('kritis', 'Email peserta belum diisi', 'Pakai --email nama01@example.com');}if (!slugFinal) { catat('kritis', 'Slug peserta belum diisi', 'Slug TIDAK BOLEH dipilih sendiri. Minta dari koordinator, atau pakai --roster.\n' + 'Empat peserta dalam satu project harus memakai empat slug berbeda.');}if (!projectFinal) { catat('sedang', 'Project ID belum diisi', 'Dipakai untuk menampilkan perintah siap pakai.');}
if (!emailFinal || !slugFinal) { // Tanpa dua nilai ini tidak ada gunanya melanjutkan. console.log(TEBAL('='.repeat(66))); 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(1);}
// ---------------------------------------------------------------------// 2. Format slug// ---------------------------------------------------------------------if (/[A-Z]/.test(slugFinal)) { catat('kritis', `Slug "${slugFinal}" memakai huruf besar`, 'Nama VM GCP hanya boleh huruf kecil, angka, dan tanda hubung.\n' + 'Pesan errornya menyebut nama resource, bukan nama variabel, sehingga sulit dilacak.');}if (/\s/.test(slugFinal)) { catat('kritis', `Slug "${slugFinal}" memakai spasi`, 'Spasi membuat seluruh perintah gcloud perlu tanda kutip tambahan dan mudah salah.');}if (slugFinal.includes('_')) { catat('kritis', `Slug "${slugFinal}" memakai garis bawah`, 'Nama VM dan Service Account GCP tidak menerima garis bawah. Pakai tanda hubung.');}if (!SLUG_POLA.test(slugFinal)) { catat('tinggi', `Slug "${slugFinal}" tidak sesuai pola`, 'Harus diawali dan diakhiri huruf kecil atau angka, dan hanya berisi huruf kecil, angka, tanda hubung.');}if (slugFinal.length > SLUG_MAKS) { catat('tinggi', `Slug "${slugFinal}" ${slugFinal.length} karakter, melebihi batas ${SLUG_MAKS}`, 'Slug dipakai membentuk nama Service Account "cb-deployer-<slug>" dan nama VM "webgis-<slug>".');}if (SLUG_TERLARANG.has(slugFinal)) { catat('tinggi', `Slug "${slugFinal}" bertabrakan dengan nama resource bersama`, 'Slug ini dipakai akun master atau oleh awalan nama di modul. Pilih slug lain.');}
// Nama yang akan dibentuk — perlihatkan supaya peserta bisa memeriksa sendiri.const namaTerbentuk = { VM_NAME: `webgis-${slugFinal}`, STATIC_IP_NAME: `webgis-ip-${slugFinal}`, BUILD_SA_NAME: `cb-deployer-${slugFinal}`, CONNECTION_NAME: `github-${slugFinal}`, LINKED_REPO_NAME: `repo-${slugFinal}`, TRIGGER_NAME: `deploy-${slugFinal}`, IMAGE_NAME: `nextjs-${slugFinal}`, SUBDOMAIN: `${slugFinal}.gisbigtrainer.com`,};
// Panjang nama Service Account: GCP membatasi 6-30 karakter untuk account ID.const panjangSa = namaTerbentuk.BUILD_SA_NAME.length;if (panjangSa > 30) { catat('tinggi', `Nama Service Account "${namaTerbentuk.BUILD_SA_NAME}" ${panjangSa} karakter`, 'Account ID Service Account GCP dibatasi 30 karakter. Perpendek slug.');} else if (panjangSa < 6) { catat('sedang', `Nama Service Account "${namaTerbentuk.BUILD_SA_NAME}" hanya ${panjangSa} karakter`, 'Account ID Service Account GCP minimal 6 karakter.');}
console.log(TEBAL('Nama resource yang akan dibuat'));for (const [k, v] of Object.entries(namaTerbentuk)) { console.log(` ${k.padEnd(17)} ${v}`);}console.log('');
// ---------------------------------------------------------------------// 3. Keunikan di dalam roster// ---------------------------------------------------------------------if (roster) { const slugSaya = roster.filter((r) => r.slug === slugFinal); if (slugSaya.length > 1) { catat('kritis', `Slug "${slugFinal}" dipakai ${slugSaya.length} email di roster`, slugSaya.map((r) => ' - ' + r.email).join('\n') + '\nPerbaiki roster lebih dahulu. Kalau dijalankan apa adanya, peserta kedua akan gagal di Tahap 4.'); }
// Periksa keunikan di dalam project yang sama, dan jumlah peserta. if (projectFinal) { const seProject = roster.filter((r) => !r.project || r.project === projectFinal); const slugProject = seProject.map((r) => r.slug); const ganda = slugProject.filter((s, i) => slugProject.indexOf(s) !== i); if (new Set(ganda).size > 0) { catat('kritis', `Slug ganda di project ${projectFinal}: ${[...new Set(ganda)].join(', ')}`, 'Setiap peserta dalam satu project wajib memakai slug yang berbeda.'); } if (seProject.length > 4) { catat('kritis', `Project ${projectFinal} berisi ${seProject.length} peserta`, 'Kuota alamat IP publik eksternal adalah 4 per region. Peserta kelima tidak akan\n' + 'mendapat IP, dan Tahap 9 akan gagal.'); } console.log(`Roster berisi ${seProject.length} peserta untuk project ini.`); } console.log('');}
// ---------------------------------------------------------------------// 4. Yang TIDAK boleh dibuat peserta// ---------------------------------------------------------------------console.log(TEBAL('Resource milik akun master — JANGAN dibuat peserta'));console.log(' katalog-images Artifact Registry bersama');console.log(' allow-webgis-http Firewall rule (port 80, 443)');console.log(' allow-webgis-iap-ssh Firewall rule (port 22 lewat IAP)');console.log('');console.log(' Kalau salah satu perintah pembuatannya dijalankan, hasilnya ALREADY_EXISTS.');console.log(' Resource ini sudah ada sebelum peserta mulai. Kalau ternyata BELUM ada,');console.log(' lapor ke koordinator — jangan dibuat sendiri.');console.log('');
// ---------------------------------------------------------------------// 5. Perintah siap pakai// ---------------------------------------------------------------------if (projectFinal) { console.log(TEBAL('Blok identitas — tempel ini di Cloud Shell, jangan ketik ulang')); console.log(''); console.log(` PROJECT_ID="${projectFinal}"`); console.log(` PARTICIPANT_ID="${slugFinal}"`); console.log(` EMAIL_PESERTA="${emailFinal}"`); console.log(` REGION="asia-southeast2"`); console.log(` ZONE="asia-southeast2-b"`); console.log(''); console.log(' Verifikasi cepat bahwa project sudah benar:'); console.log(` gcloud config set project "$PROJECT_ID"`); console.log(' gcloud config get-value project'); console.log('');}
// ---------------------------------------------------------------------// ringkasan// ---------------------------------------------------------------------if (keluaranJson) { console.log(JSON.stringify({ email: emailFinal, slug: slugFinal, project: projectFinal, nama: namaTerbentuk, temuan }, null, 2)); process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);}
console.log(TEBAL('='.repeat(66)));if (temuan.length === 0) { console.log(benar('LOLOS. Identitas sudah sah dan siap dipakai.')); console.log(''); console.log('Lanjutkan ke Tahap 3 modul (periksa API).'); 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}`);}console.log('');console.log(w('merah', 'JANGAN lanjut ke Tahap 4 sebelum temuan di atas diselesaikan.'));console.log('Tahap 4 membuat Service Account, dan sejak titik itu nama tidak bisa diperbaiki');console.log('tanpa menghapus resource yang sudah dibuat.');process.exit(1);