Lewati ke konten

Pra-terbang peserta

Periksa slug sebelum menyentuh Tahap 4.

Berkas: alat/hari-3/Praktik 11/tools/pra-terbang.mjs

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);