Lewati ke konten

Periksa variabel modul

Variabel tak terdeklarasi, variabel mati, VM_SA, dan firewall.

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

Praktik 11/tools/periksa-variabel.mjs
#!/usr/bin/env node
// =====================================================================
// Praktik 11 - periksa-variabel.mjs
//
// Tiga temuan yang tidak terdeteksi pemeriksa mana pun sebelumnya:
//
// P11-6 SUBDOMAIN dipakai di sepuluh tempat tetapi tidak pernah
// dideklarasikan di blok variabel Tahap 2
// P11-7 DNS_ZONE_NAME dideklarasikan tetapi tidak pernah dipakai
// P11-16 VM_SA memakai alamat "@example.com" yang bukan bentuk
// service account Google
//
// Ketiganya bisa diperiksa tanpa GCP, cukup dari teks dokumen.
//
// Pakai:
// node periksa-variabel.mjs <folder dokumen .docx> [--json]
// =====================================================================
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';
import { inflateRawSync } from 'node:zlib';
// Variabel yang seharusnya diturunkan dari PARTICIPANT_ID dan karena itu
// wajib ada di blok variabel Tahap 2, bukan ditetapkan ulang di dokumen lain.
const SEHARUSNYA_DIDEKLARASIKAN = ['SUBDOMAIN', 'VM_NAME', 'STATIC_IP_NAME', 'IMAGE_NAME'];
// Variabel yang boleh kosong di luar blok utama.
const DIKECUALIKAN = new Set([
'EMAIL', 'EXTERNAL_IP', 'PROJECT_NUMBER', 'GITHUB_USERNAME', 'GITHUB_REPOSITORY',
'NAMA_LAYER', 'ROLE', 'USER', 'UBUNTU_CODENAME', 'VERSION_CODENAME',
'GEOSERVER_PASSWORD', 'GEOSERVER_ADMIN_PASSWORD', 'NEXTJS_IMAGE', 'DBNAME',
'SUBDOMAIN', 'SLUG', 'VM'
]);
// Pola alamat service account Google yang sah.
const POLA_SA_SAH = /^(?:\$\{[A-Z_]+\}|\d+)-(?:compute@developer\.gserviceaccount\.com|[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com)$/;
const argv = process.argv.slice(2);
const keluaranJson = argv.includes('--json');
const target = argv.find((a) => !a.startsWith('--')) ?? '.';
if (!existsSync(target)) {
console.error(`Folder tidak ditemukan: ${target}`);
process.exit(2);
}
// ---------------------------------------------------------------- baca docx
// Membaca teks dari .docx tanpa dependensi: berkas .docx adalah arsip ZIP,
// dan isinya ada di word/document.xml.
function bacaDocx(jalur) {
let buf;
try {
buf = readFileSync(jalur);
} catch {
return '';
}
if (buf.length < 4 || buf.readUInt32LE(0) !== 0x04034b50) return '';
let pos = 0;
let xml = null;
while (pos + 30 <= buf.length && buf.readUInt32LE(pos) === 0x04034b50) {
const metode = buf.readUInt16LE(pos + 8);
const ukuran = 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 (ukuran === 0) return '';
const data = buf.subarray(awalData, awalData + ukuran);
try {
xml = metode === 0 ? data.toString('utf8') : inflateRawSync(data).toString('utf8');
} catch {
return '';
}
break;
}
if (ukuran === 0) {
if (nama.endsWith('/')) { pos = awalData; continue; }
return '';
}
pos = awalData + ukuran;
}
if (!xml) return '';
return xml
.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, "'");
}
function kumpulkanDokumen(folder) {
const hasil = [];
for (const nama of readdirSync(folder)) {
const jalur = join(folder, nama);
if (statSync(jalur).isDirectory()) hasil.push(...kumpulkanDokumen(jalur));
else if (nama.endsWith('.docx')) hasil.push({ nama, isi: bacaDocx(jalur) });
}
return hasil;
}
const dokumen = kumpulkanDokumen(target).filter((d) => d.isi.length > 0);
if (dokumen.length === 0) {
console.error('Tidak ada dokumen .docx yang bisa dibaca di ' + target);
process.exit(2);
}
const temuan = [];
const catat = (severity, judul, detail) => temuan.push({ severity, judul, detail });
// ---------------------------------------------------------------- 1. deklarasi
// Blok variabel dikenali dari satu baris berisi PROJECT_ID="..." diikuti
// baris-baris VAR="...". Ambil dari dokumen yang memuatnya.
const semuaTeks = dokumen.map((d) => d.isi).join('\n');
const dideklarasikan = new Set();
{
const blok = semuaTeks.match(/PROJECT_ID="[\s\S]{0,900}?VM_REGION="[^"]*"/);
if (blok) {
for (const m of blok[0].matchAll(/^\s*([A-Z_][A-Z0-9_]*)=/gm)) {
dideklarasikan.add(m[1]);
}
}
}
if (dideklarasikan.size === 0) {
catat('sedang', 'Blok variabel Tahap 2 tidak terbaca',
'Pemeriksaan deklarasi dilewati. Pastikan dokumen memuat PROJECT_ID="..." dan VM_REGION="...".');
}
const dipakai = new Set();
for (const m of semuaTeks.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)) dipakai.add(m[1]);
for (const v of SEHARUSNYA_DIDEKLARASIKAN) {
if (dipakai.has(v) && !dideklarasikan.has(v)) {
const jumlah = (semuaTeks.match(new RegExp(`\\$\\{${v}\\}|\\$${v}\\b`, 'g')) ?? []).length;
catat('tinggi', `${v} dipakai ${jumlah} kali tetapi tidak dideklarasikan di blok Tahap 2`,
'Nilai itu ditetapkan ulang di dokumen lain, sehingga sesi Cloud Shell yang terputus\n'
+ 'akan menemukan variabel kosong dan perintahnya gagal tanpa menjelaskan sebabnya.');
}
}
// ---------------------------------------------------------------- 2. variabel mati
//
// Dua tingkat pemakaian dibedakan:
// - dipakai sebagai ${VAR} -> benar, nilainya benar-benar diambil
// - disebut sebagai VAR -> hanya narasi, peserta membaca namanya
//
// Variabel yang hanya disebut tanpa pernah dipakai sebagai ${VAR} tetap
// berguna (peserta tahu nama yang harus diisi di Console), jadi tidak
// dilaporkan. Yang dilaporkan hanya yang tidak muncul sama sekali.
for (const v of dideklarasikan) {
if (DIKECUALIKAN.has(v)) continue;
const sebagaiNilai = (semuaTeks.match(new RegExp(`\\$\\{${v}\\}|\\$${v}\\b`, 'g')) ?? []).length;
const disebut = (semuaTeks.match(new RegExp(`(^|[^A-Z0-9_])_?${v}([^A-Z0-9_]|$)`, 'g')) ?? []).length;
if (sebagaiNilai === 0 && disebut <= 1) {
catat('sedang', `${v} dideklarasikan tetapi tidak pernah dipakai`,
'Tidak muncul lagi sebagai nilai maupun sebagai nama di seluruh dokumen.');
}
}
// ---------------------------------------------------------------- 3. VM_SA
for (const m of semuaTeks.matchAll(/VM_SA="([^"]+)"/g)) {
const nilai = m[1];
if (!POLA_SA_SAH.test(nilai)) {
catat('tinggi', `VM_SA bukan alamat service account yang sah: ${nilai}`,
'Bentuk yang benar:\n'
+ ' ${PROJECT_NUMBER}-asisten01@example.com\n'
+ 'Perintah IAM yang memakainya akan gagal dengan pesan bahwa service account tidak ada.');
}
}
// ---------------------------------------------------------------- 4. firewall
// Modul hanya memeriksa firewall tanpa pernah membuatnya, dan tidak
// menyebut siapa yang membuat. Diperiksa sebagai catatan, bukan kesalahan.
const adaList = /firewall-rules list/.test(semuaTeks);
const adaCreate = /firewall-rules create/.test(semuaTeks);
if (adaList && !adaCreate) {
catat('sedang', 'Firewall diperiksa tetapi tidak pernah dibuat di modul',
'Tidak ada perintah firewall-rules create di seluruh dokumen. Peserta yang melihat\n'
+ 'daftar kosong tidak tahu apakah firewall belum siap atau ada yang salah pada projectnya.');
}
// ---------------------------------------------------------------- hasil
if (keluaranJson) {
console.log(JSON.stringify({
dokumen: dokumen.map((d) => d.nama),
dideklarasikan: [...dideklarasikan].sort(),
dipakai: [...dipakai].sort(),
temuan
}, null, 2));
process.exit(temuan.some((t) => ['kritis', 'tinggi'].includes(t.severity)) ? 1 : 0);
}
const WARNA = { kritis: '\u001b[31m', tinggi: '\u001b[31m', sedang: '\u001b[33m', rendah: '\u001b[36m' };
const URUT = { kritis: 0, tinggi: 1, sedang: 2, rendah: 3 };
const R = '\u001b[0m';
const w = (s, t) => (process.stdout.isTTY ? WARNA[s] + t + R : t);
console.log(`Folder : ${target}`);
console.log(`Dokumen : ${dokumen.length} berkas (${dokumen.map((d) => basename(d.nama)).join(', ')})`);
console.log(`Variabel dideklarasikan : ${dideklarasikan.size}`);
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);