chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* validate-content.js
|
||||
* Sysadmin Chronicles content validation tool.
|
||||
*
|
||||
* Run from project root:
|
||||
* node tools/content/validate-content.js
|
||||
* node tools/content/validate-content.js --quests-only
|
||||
* node tools/content/validate-content.js --verbose
|
||||
*
|
||||
* Exit code 0 = no errors. Exit code 1 = validation errors found.
|
||||
*
|
||||
* Checks performed:
|
||||
* - All JSON files parse correctly
|
||||
* - No duplicate IDs across content domains
|
||||
* - Every world flag referenced anywhere exists in world_flags.json
|
||||
* - Every required_vms entry maps to a known VM profile
|
||||
* - Every blast_radius entry maps to a known incident ID
|
||||
* - Every ticket_id in a quest maps to an existing ticket
|
||||
* - Every linked_quest in a ticket maps to an existing quest
|
||||
* - Branch priorities within a quest are unique (no ties)
|
||||
* - Every follow_up_incident maps to an existing incident file
|
||||
* - Every series_id has at least 2 members
|
||||
* - clue_fingerprint evidence uses valid rule types
|
||||
* - file_absent and file_owner_is_not are accepted rule types (OI-010 resolved)
|
||||
* - Package-manager-specific paths/commands match the authored VM distro
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONFIG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTENT_ROOT = path.resolve(__dirname, "../../content");
|
||||
const QUESTS_DIR = path.join(CONTENT_ROOT, "quests");
|
||||
const TICKETS_DIR = path.join(CONTENT_ROOT, "tickets");
|
||||
const INCIDENTS_DIR= path.join(CONTENT_ROOT, "incidents");
|
||||
const DIALOGUE_DIR = path.join(CONTENT_ROOT, "dialogue");
|
||||
const FLAGS_FILE = path.join(CONTENT_ROOT, "world_flags", "world_flags.json");
|
||||
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, "vm_profiles");
|
||||
const PRESSURE_PROFILES_DIR = path.join(CONTENT_ROOT, "pressure_profiles");
|
||||
|
||||
const VALID_RULE_TYPES = new Set([
|
||||
// Standard validation rule types (used in objectives and solution branches)
|
||||
"file_exists", "file_absent", "file_contains", "file_mode", "file_owner",
|
||||
"file_owner_is_not", "directory_exists", "service_state", "service_enabled",
|
||||
"process_running", "process_user", "port_listening", "package_installed",
|
||||
"mount_present", "disk_usage_below", "disk_usage_above", "command_assert",
|
||||
"log_contains", "and", "or", "not",
|
||||
// Advisory clue_fingerprint-only types (descriptive evidence, not used in branch validation)
|
||||
// These document what evidence EXISTS in the VM baseline — not evaluated at runtime.
|
||||
"service_state_is", "service_enabled_is", "file_size_above", "file_size_below",
|
||||
]);
|
||||
|
||||
const VALID_NARRATIVE_PHASES = new Set([
|
||||
"normal_work", "unease", "suspicion", "investigation", "conflict", "resolution"
|
||||
]);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const verbose = args.includes("--verbose");
|
||||
const questsOnly = args.includes("--quests-only");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LOAD HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function err(msg) {
|
||||
console.error(` ❌ ERROR: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.warn(` ⚠ WARN: ${msg}`);
|
||||
warnings++;
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
if (verbose) console.log(` ✓ ${msg}`);
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
try {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
err(`JSON parse error in ${path.relative(CONTENT_ROOT, filePath)}: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadDir(dirPath, idField = "id") {
|
||||
const result = {};
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
warn(`Directory not found: ${dirPath}`);
|
||||
return result;
|
||||
}
|
||||
for (const fname of fs.readdirSync(dirPath)) {
|
||||
if (!fname.endsWith(".json") || fname.startsWith(".")) continue;
|
||||
// Skip split artifacts. Pending splits still deserve a warning; completed
|
||||
// split archives and backups are intentional and should stay quiet.
|
||||
if (fname.includes("SPLIT_PENDING")) {
|
||||
warn(`Skipping non-standard file: ${fname}`);
|
||||
continue;
|
||||
}
|
||||
if (fname.includes("SPLIT_DONE") || fname.includes(".bak")) {
|
||||
continue;
|
||||
}
|
||||
const data = loadJson(path.join(dirPath, fname));
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
warn(`${fname} is an array — expected single object with '${idField}' field. Split into individual files.`);
|
||||
continue;
|
||||
}
|
||||
const id = data[idField];
|
||||
if (!id) {
|
||||
err(`Missing '${idField}' field in ${fname}`);
|
||||
continue;
|
||||
}
|
||||
if (result[id]) {
|
||||
err(`Duplicate ID '${id}' in ${fname} (already seen)`);
|
||||
}
|
||||
result[id] = { data, fname };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RULE TYPE VALIDATOR (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateRuleTypes(rule, context) {
|
||||
if (!rule || typeof rule !== "object") return;
|
||||
const type = rule.type;
|
||||
if (!type) {
|
||||
err(`${context}: rule missing 'type' field`);
|
||||
return;
|
||||
}
|
||||
if (!VALID_RULE_TYPES.has(type)) {
|
||||
err(`${context}: unknown rule type '${type}'`);
|
||||
return;
|
||||
}
|
||||
// Recurse into composite rules
|
||||
if (type === "and" || type === "or") {
|
||||
for (const sub of (rule.rules || [])) {
|
||||
validateRuleTypes(sub, context);
|
||||
}
|
||||
}
|
||||
if (type === "not") {
|
||||
validateRuleTypes(rule.rule, context);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLLECT ALL FLAG REFERENCES IN A RULE (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function collectFlagRefs(obj, refs = new Set()) {
|
||||
if (!obj || typeof obj !== "object") return refs;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) collectFlagRefs(item, refs);
|
||||
return refs;
|
||||
}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === "world_flags" && Array.isArray(v)) {
|
||||
for (const f of v) refs.add(f);
|
||||
} else if (k === "trigger" && typeof v === "string" && v.startsWith("world_flag:")) {
|
||||
refs.add(v.slice("world_flag:".length));
|
||||
} else if (typeof v === "object") {
|
||||
collectFlagRefs(v, refs);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function distroForVm(vmId, vmProfiles) {
|
||||
return vmProfiles[vmId]?.data?.distro || "";
|
||||
}
|
||||
|
||||
function validateVmSpecificSemantics(rule, context, vmProfiles) {
|
||||
if (!rule || typeof rule !== "object") return;
|
||||
if (Array.isArray(rule)) {
|
||||
for (const item of rule) validateVmSpecificSemantics(item, context, vmProfiles);
|
||||
return;
|
||||
}
|
||||
|
||||
const vmId = typeof rule.vm === "string" ? rule.vm : "";
|
||||
const distro = distroForVm(vmId, vmProfiles);
|
||||
const pathValue = typeof rule.path === "string" ? rule.path : "";
|
||||
const commandValue = typeof rule.command === "string" ? rule.command : "";
|
||||
|
||||
if (vmId && distro) {
|
||||
const pathCtx = `${context} (${vmId}/${distro})`;
|
||||
if (pathValue === "/etc/pacman.conf" && distro !== "arch") {
|
||||
err(`${pathCtx}: /etc/pacman.conf is Arch-specific but VM distro is '${distro}'`);
|
||||
}
|
||||
if (pathValue.startsWith("/etc/apt/") && distro === "arch") {
|
||||
err(`${pathCtx}: /etc/apt/* is Debian/Ubuntu-specific but VM distro is 'arch'`);
|
||||
}
|
||||
if (commandValue.includes("pacman") && distro !== "arch") {
|
||||
err(`${pathCtx}: command references pacman but VM distro is '${distro}'`);
|
||||
}
|
||||
if ((commandValue.includes("apt ") || commandValue.includes("apt-get") || commandValue.includes("dpkg ")) && distro === "arch") {
|
||||
err(`${pathCtx}: command references apt/dpkg but VM distro is 'arch'`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(rule)) {
|
||||
if (value && typeof value === "object") {
|
||||
validateVmSpecificSemantics(value, context, vmProfiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAIN VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("═══════════════════════════════════════");
|
||||
console.log(" Sysadmin Chronicles Content Validator");
|
||||
console.log("═══════════════════════════════════════\n");
|
||||
|
||||
// Load all content
|
||||
console.log("Loading content...");
|
||||
const quests = loadDir(QUESTS_DIR);
|
||||
const tickets = loadDir(TICKETS_DIR);
|
||||
const incidents = loadDir(INCIDENTS_DIR);
|
||||
const dialogue = loadDir(DIALOGUE_DIR);
|
||||
const vmProfiles= loadDir(VM_PROFILES_DIR);
|
||||
const pressureProfiles = loadDir(PRESSURE_PROFILES_DIR);
|
||||
const flagsRaw = loadJson(FLAGS_FILE);
|
||||
// flags field may be an Array (list of {id, ...}) or a Dict (id -> {...})
|
||||
// Normalize to Dict keyed by id for validation lookups.
|
||||
let flagRegistry = {};
|
||||
if (flagsRaw) {
|
||||
const raw = flagsRaw.flags || flagsRaw;
|
||||
if (Array.isArray(raw)) {
|
||||
for (const f of raw) { if (f.id) flagRegistry[f.id] = f; }
|
||||
} else if (typeof raw === "object") {
|
||||
flagRegistry = raw;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Quests: ${Object.keys(quests).length}, Tickets: ${Object.keys(tickets).length}, Incidents: ${Object.keys(incidents).length}, Dialogue: ${Object.keys(dialogue).length}, Pressure Profiles: ${Object.keys(pressureProfiles).length}\n`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QUEST VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("Validating quests...");
|
||||
for (const [qid, { data: quest, fname }] of Object.entries(quests)) {
|
||||
const ctx = `Quest ${qid} (${fname})`;
|
||||
|
||||
// required_vms
|
||||
for (const vmId of (quest.required_vms || [])) {
|
||||
if (!vmProfiles[vmId]) err(`${ctx}: required_vms references unknown VM profile '${vmId}'`);
|
||||
}
|
||||
|
||||
// baseline_snapshot
|
||||
if (!quest.baseline_snapshot || typeof quest.baseline_snapshot !== 'string' || !quest.baseline_snapshot.trim()) {
|
||||
err(`${ctx}: missing or empty 'baseline_snapshot' field`);
|
||||
}
|
||||
|
||||
// ticket_id
|
||||
if (quest.ticket_id && !tickets[quest.ticket_id]) {
|
||||
err(`${ctx}: ticket_id '${quest.ticket_id}' not found in tickets/`);
|
||||
}
|
||||
|
||||
// pressure_profile
|
||||
if (quest.pressure_profile && !pressureProfiles[quest.pressure_profile]) {
|
||||
err(`${ctx}: pressure_profile '${quest.pressure_profile}' not found in pressure_profiles/`);
|
||||
}
|
||||
|
||||
// blast_radius
|
||||
for (const incId of (quest.blast_radius || [])) {
|
||||
if (!incidents[incId]) warn(`${ctx}: blast_radius references '${incId}' which doesn't exist yet`);
|
||||
}
|
||||
|
||||
// Branch priority uniqueness
|
||||
const branches = quest.solution_branches || [];
|
||||
const priorities = new Set();
|
||||
for (const branch of branches) {
|
||||
const p = branch.priority;
|
||||
if (priorities.has(p)) {
|
||||
err(`${ctx}: duplicate branch priority ${p} — each branch must have a unique priority`);
|
||||
}
|
||||
priorities.add(p);
|
||||
|
||||
// follow_up_incident
|
||||
if (branch.follow_up_incident && !incidents[branch.follow_up_incident]) {
|
||||
err(`${ctx}: branch '${branch.id}' follow_up_incident '${branch.follow_up_incident}' not found`);
|
||||
}
|
||||
|
||||
// Validate branch validation rules
|
||||
validateRuleTypes(branch.validation, `${ctx} branch '${branch.id}'`);
|
||||
}
|
||||
|
||||
// clue_fingerprint evidence rule types
|
||||
const evidence = (quest.clue_fingerprint || {}).evidence || [];
|
||||
for (const ev of evidence) {
|
||||
if (!VALID_RULE_TYPES.has(ev.type)) {
|
||||
err(`${ctx}: clue_fingerprint evidence uses unknown rule type '${ev.type}'`);
|
||||
}
|
||||
validateVmSpecificSemantics(ev, `${ctx} clue_fingerprint`, vmProfiles);
|
||||
}
|
||||
|
||||
// Objective rule types
|
||||
for (const obj of (quest.objectives || [])) {
|
||||
validateRuleTypes(obj.validation, `${ctx} objective '${obj.id}'`);
|
||||
validateVmSpecificSemantics(obj.validation, `${ctx} objective '${obj.id}'`, vmProfiles);
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
validateVmSpecificSemantics(branch.validation, `${ctx} branch '${branch.id}'`, vmProfiles);
|
||||
}
|
||||
|
||||
// narrative_phase
|
||||
if (!quest.narrative_phase) {
|
||||
warn(`${ctx}: missing 'narrative_phase' field`);
|
||||
} else if (!VALID_NARRATIVE_PHASES.has(quest.narrative_phase)) {
|
||||
err(`${ctx}: unknown narrative_phase '${quest.narrative_phase}'`);
|
||||
}
|
||||
|
||||
// behavior_impact
|
||||
if (quest.behavior_impact !== undefined) {
|
||||
for (const [branchKey, impact] of Object.entries(quest.behavior_impact)) {
|
||||
for (const field of ['curiosity_delta', 'obedience_delta', 'risk_delta', 'suspicion_delta']) {
|
||||
if (impact[field] !== undefined && typeof impact[field] !== 'number') {
|
||||
err(`${ctx}: behavior_impact[${branchKey}].${field} must be a number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hidden_hook shape
|
||||
if (quest.hidden_hook !== undefined && quest.hidden_hook !== null) {
|
||||
if (typeof quest.hidden_hook.id !== 'string') {
|
||||
err(`${ctx}: hidden_hook.id must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
// access_requirements
|
||||
if (quest.access_requirements?.minimum_access) {
|
||||
for (const [vmId] of Object.entries(quest.access_requirements.minimum_access)) {
|
||||
if (!vmProfiles[vmId]) {
|
||||
err(`${ctx}: access_requirements.minimum_access references unknown VM '${vmId}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
if (!questsOnly) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// TICKET VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating tickets...");
|
||||
for (const [tid, { data: ticket, fname }] of Object.entries(tickets)) {
|
||||
const ctx = `Ticket ${tid} (${fname})`;
|
||||
|
||||
if (ticket.linked_quest && !quests[ticket.linked_quest]) {
|
||||
warn(`${ctx}: linked_quest '${ticket.linked_quest}' not found`);
|
||||
}
|
||||
|
||||
if (ticket.initial_priority === undefined) {
|
||||
err(`${ctx}: missing 'initial_priority'`);
|
||||
}
|
||||
if (ticket.current_priority === undefined) {
|
||||
err(`${ctx}: missing 'current_priority'`);
|
||||
}
|
||||
if (ticket.initial_priority !== ticket.current_priority) {
|
||||
warn(`${ctx}: initial_priority != current_priority at authoring time`);
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INCIDENT VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating incidents...");
|
||||
for (const [iid, { data: incident, fname }] of Object.entries(incidents)) {
|
||||
const ctx = `Incident ${iid} (${fname})`;
|
||||
|
||||
if (!Array.isArray(incident.blast_radius_quests)) {
|
||||
err(`${ctx}: missing 'blast_radius_quests' (must be present, can be [])`);
|
||||
}
|
||||
if (!Array.isArray(incident.blast_radius_incidents)) {
|
||||
err(`${ctx}: missing 'blast_radius_incidents' (must be present, can be [])`);
|
||||
}
|
||||
if (incident.resolution_requirements?.validation) {
|
||||
validateRuleTypes(incident.resolution_requirements.validation, `${ctx} resolution_requirements`);
|
||||
validateVmSpecificSemantics(incident.resolution_requirements.validation, `${ctx} resolution_requirements`, vmProfiles);
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DIALOGUE VALIDATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating dialogue...");
|
||||
const seriesMembers = {};
|
||||
for (const [did, { data: dlg, fname }] of Object.entries(dialogue)) {
|
||||
const ctx = `Dialogue ${did} (${fname})`;
|
||||
|
||||
if (dlg.series_id) {
|
||||
if (dlg.series_position === undefined) {
|
||||
err(`${ctx}: has series_id '${dlg.series_id}' but missing series_position`);
|
||||
}
|
||||
if (!seriesMembers[dlg.series_id]) seriesMembers[dlg.series_id] = [];
|
||||
seriesMembers[dlg.series_id].push(did);
|
||||
}
|
||||
|
||||
// world_flag trigger
|
||||
if (dlg.trigger && dlg.trigger.startsWith("world_flag:")) {
|
||||
const flagId = dlg.trigger.slice("world_flag:".length);
|
||||
if (!flagRegistry[flagId]) {
|
||||
err(`${ctx}: trigger flag '${flagId}' not in world_flags registry`);
|
||||
}
|
||||
}
|
||||
ok(`${ctx}: OK`);
|
||||
}
|
||||
|
||||
// series membership count check
|
||||
for (const [sid, members] of Object.entries(seriesMembers)) {
|
||||
if (members.length < 2) {
|
||||
warn(`Series '${sid}' has only 1 member (${members[0]}) — needs 2+ for series tracking`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WORLD FLAG CROSS-REFERENCE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\nValidating world flag references...");
|
||||
const allContent = [
|
||||
...Object.values(quests).map(v => v.data),
|
||||
...Object.values(tickets).map(v => v.data),
|
||||
...Object.values(incidents).map(v => v.data),
|
||||
...Object.values(dialogue).map(v => v.data),
|
||||
];
|
||||
|
||||
const referencedFlags = new Set();
|
||||
for (const item of allContent) {
|
||||
collectFlagRefs(item, referencedFlags);
|
||||
}
|
||||
|
||||
for (const flagId of referencedFlags) {
|
||||
if (!flagRegistry[flagId]) {
|
||||
err(`World flag '${flagId}' is referenced in content but not declared in world_flags.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SUMMARY
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n═══════════════════════════════════════");
|
||||
if (errors === 0 && warnings === 0) {
|
||||
console.log(" ✅ All content valid. Zero errors, zero warnings.");
|
||||
} else {
|
||||
if (errors > 0) console.log(` ❌ ${errors} error(s) found.`);
|
||||
if (warnings > 0) console.log(` ⚠ ${warnings} warning(s) found.`);
|
||||
}
|
||||
console.log("═══════════════════════════════════════\n");
|
||||
process.exit(errors > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* verify-clue-fingerprints.js
|
||||
* Verifies that clue_fingerprint evidence in quest files is detectable
|
||||
* in the actual VM state.
|
||||
*
|
||||
* Usage (run from project root):
|
||||
* node tools/content/verify-clue-fingerprints.js
|
||||
* node tools/content/verify-clue-fingerprints.js --quest Q002
|
||||
* node tools/content/verify-clue-fingerprints.js --revert
|
||||
* node tools/content/verify-clue-fingerprints.js --start-vms
|
||||
* node tools/content/verify-clue-fingerprints.js --dry-run
|
||||
*
|
||||
* Flags:
|
||||
* --quest <ID> Check only the specified quest (e.g. Q002)
|
||||
* --revert Revert each VM to its quest baseline snapshot before checking.
|
||||
* Leaves VMs running at baseline afterwards (ready for play).
|
||||
* --start-vms Start VMs that are not running (without reverting snapshot).
|
||||
* Use after a fresh boot if VMs are just stopped, not reverted.
|
||||
* --dry-run Print the SSH commands that would run without executing them.
|
||||
* --help Show this help text.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 All evidence confirmed (or dry-run)
|
||||
* 1 One or more failures, or no checks ran
|
||||
*
|
||||
* Prerequisites:
|
||||
* - virsh available and pointing at qemu:///system
|
||||
* - ~/.ssh/sc_host_key present
|
||||
* - VMs running (or --revert / --start-vms provided)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const os = require('os');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONFIG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
const CONTENT_ROOT = path.join(PROJECT_ROOT, 'content');
|
||||
const QUESTS_DIR = path.join(CONTENT_ROOT, 'quests');
|
||||
const VM_PROFILES_DIR = path.join(CONTENT_ROOT, 'vm_profiles');
|
||||
const SSH_KEY = path.join(os.homedir(), '.ssh/sc_host_key');
|
||||
const LIBVIRT_URI = 'qemu:///system';
|
||||
const VM_PREFIX = 'sc-';
|
||||
const SSH_WAIT_SECS = 90; // max seconds to wait for SSH after revert/start
|
||||
const SSH_POLL_SECS = 5;
|
||||
|
||||
const SSH_OPTS = [
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=8',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-i', SSH_KEY,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ARGS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const dryRun = rawArgs.includes('--dry-run');
|
||||
const doRevert = rawArgs.includes('--revert');
|
||||
const startVms = rawArgs.includes('--start-vms');
|
||||
const showHelp = rawArgs.includes('--help') || rawArgs.includes('-h');
|
||||
|
||||
const questIdx = rawArgs.indexOf('--quest');
|
||||
const questFilter = questIdx >= 0 ? rawArgs[questIdx + 1] : null;
|
||||
|
||||
if (showHelp) {
|
||||
const usage = fs.readFileSync(__filename, 'utf8').split('\n')
|
||||
.slice(1, 20).map(l => l.replace(/^ \* ?/, '')).join('\n');
|
||||
console.log(usage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VIRSH HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function virsh(...args) {
|
||||
const r = spawnSync('virsh', ['--connect', LIBVIRT_URI, ...args], {
|
||||
encoding: 'utf8', timeout: 20000,
|
||||
});
|
||||
return {
|
||||
ok: r.status === 0,
|
||||
stdout: (r.stdout || '').trim(),
|
||||
stderr: (r.stderr || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function domainName(vmId) {
|
||||
return VM_PREFIX + vmId.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
function vmIsRunning(vmId) {
|
||||
const r = virsh('domstate', domainName(vmId));
|
||||
return r.ok && r.stdout === 'running';
|
||||
}
|
||||
|
||||
function getVmIp(vmId) {
|
||||
const domain = domainName(vmId);
|
||||
// Primary: guest agent
|
||||
const r = virsh('domifaddr', domain, '--source', 'agent');
|
||||
if (r.ok && r.stdout) {
|
||||
const m = r.stdout.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
// Fallback: DHCP leases table
|
||||
const leases = virsh('net-dhcp-leases', 'sc-internal');
|
||||
if (leases.ok && leases.stdout) {
|
||||
const hostname = domain.replace(VM_PREFIX, '');
|
||||
for (const line of leases.stdout.split('\n')) {
|
||||
if (line.includes(hostname)) {
|
||||
const m = line.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(secs) {
|
||||
spawnSync('sleep', [String(secs)]);
|
||||
}
|
||||
|
||||
function waitForSsh(vmId, user) {
|
||||
process.stdout.write(` Waiting for SSH on ${domainName(vmId)}`);
|
||||
const deadline = Date.now() + SSH_WAIT_SECS * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const ip = getVmIp(vmId);
|
||||
if (ip) {
|
||||
const r = spawnSync('ssh', [...SSH_OPTS, '-o', 'ConnectTimeout=5', `${user}@${ip}`, 'true'], {
|
||||
encoding: 'utf8', timeout: 8000,
|
||||
});
|
||||
if (r.status === 0) {
|
||||
console.log(` ready (${ip})`);
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
process.stdout.write('.');
|
||||
sleep(SSH_POLL_SECS);
|
||||
}
|
||||
console.log(' TIMEOUT');
|
||||
return null;
|
||||
}
|
||||
|
||||
function revertVmToBaseline(vmId, snapshot) {
|
||||
const domain = domainName(vmId);
|
||||
console.log(` Reverting ${domain} → ${snapshot}...`);
|
||||
// Destroy (force-stop) if running — state will be discarded
|
||||
if (vmIsRunning(vmId)) {
|
||||
virsh('destroy', domain);
|
||||
sleep(1);
|
||||
}
|
||||
const r = virsh('snapshot-revert', domain, snapshot, '--running');
|
||||
if (!r.ok) {
|
||||
console.error(` ERROR reverting ${domain}: ${r.stderr}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONTENT LOADING
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadVmProfiles() {
|
||||
const profiles = {};
|
||||
for (const f of fs.readdirSync(VM_PROFILES_DIR)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
const d = JSON.parse(fs.readFileSync(path.join(VM_PROFILES_DIR, f), 'utf8'));
|
||||
profiles[d.id] = d;
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function loadQuests() {
|
||||
return fs.readdirSync(QUESTS_DIR)
|
||||
.filter(f => f.endsWith('.json') && !f.includes('SPLIT'))
|
||||
.map(f => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(QUESTS_DIR, f), 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EVIDENCE CHECK COMMAND BUILDER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sq(s) {
|
||||
// Shell-quote a value for embedding in a bash command string
|
||||
return "'" + String(s).replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
function buildCmd(ev) {
|
||||
// Returns a bash one-liner that prints "PASS" or "FAIL [detail]"
|
||||
// All commands run via sudo to handle restricted paths.
|
||||
switch (ev.type) {
|
||||
|
||||
case 'file_absent':
|
||||
return `sudo test ! -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file exists)'`;
|
||||
|
||||
case 'file_exists':
|
||||
return `sudo test -e ${sq(ev.path)} && echo PASS || echo 'FAIL (file missing)'`;
|
||||
|
||||
case 'file_contains':
|
||||
case 'log_contains': {
|
||||
const pat = sq(ev.contains);
|
||||
return `sudo grep -qF ${pat} ${sq(ev.path)} 2>/dev/null && echo PASS || echo 'FAIL (pattern not found)'`;
|
||||
}
|
||||
|
||||
case 'file_owner_is_not': {
|
||||
const notExpected = ev.expected_user || ev.expected_owner || '';
|
||||
return (
|
||||
`owner=$(sudo stat -c '%U' ${sq(ev.path)} 2>/dev/null || echo '?'); ` +
|
||||
`[ "\${owner}" != ${sq(notExpected)} ] && echo PASS || ` +
|
||||
`echo "FAIL (owner=\${owner}, expected NOT ${notExpected})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'file_size_above': {
|
||||
const threshold = ev.threshold_bytes;
|
||||
return (
|
||||
`bytes=$(sudo stat -c '%s' ${sq(ev.path)} 2>/dev/null || echo 0); ` +
|
||||
`[ "\${bytes}" -gt ${threshold} ] && echo PASS || ` +
|
||||
`echo "FAIL (size=\${bytes} bytes, threshold=${threshold})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'disk_usage_above': {
|
||||
const threshold = ev.threshold_percent;
|
||||
return (
|
||||
`pct=$(sudo df --output=pcent ${sq(ev.path)} 2>/dev/null | tail -1 | tr -d ' %'); ` +
|
||||
`[ "\${pct}" -gt ${threshold} ] && echo PASS || ` +
|
||||
`echo "FAIL (usage=\${pct}%, threshold=${threshold}%)"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'service_state_is': {
|
||||
const expected = ev.state;
|
||||
return (
|
||||
`state=$(sudo systemctl is-active ${sq(ev.service)} 2>/dev/null || echo inactive); ` +
|
||||
`[ "\${state}" = ${sq(expected)} ] && echo PASS || ` +
|
||||
`echo "FAIL (state=\${state}, expected=${expected})"`
|
||||
);
|
||||
}
|
||||
|
||||
case 'service_enabled_is': {
|
||||
const wantEnabled = ev.enabled === true || ev.enabled === 'true';
|
||||
return (
|
||||
`estate=$(sudo systemctl is-enabled ${sq(ev.service)} 2>/dev/null || echo disabled); ` +
|
||||
(wantEnabled
|
||||
? `[ "\${estate}" = "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=enabled)"`
|
||||
: `[ "\${estate}" != "enabled" ] && echo PASS || echo "FAIL (is-enabled=\${estate}, expected=disabled)"`)
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvidence(ev) {
|
||||
switch (ev.type) {
|
||||
case 'file_absent': return `file absent: ${ev.path}`;
|
||||
case 'file_exists': return `file exists: ${ev.path}`;
|
||||
case 'file_contains':
|
||||
case 'log_contains': return `${ev.path} ∋ ${JSON.stringify(ev.contains)}`;
|
||||
case 'file_owner_is_not': return `${ev.path} owner ≠ ${ev.expected_user || ev.expected_owner}`;
|
||||
case 'file_size_above': return `${ev.path} > ${(ev.threshold_bytes / 1e9).toFixed(1)} GB`;
|
||||
case 'disk_usage_above': return `${ev.path} disk > ${ev.threshold_percent}%`;
|
||||
case 'service_state_is': return `${ev.service} state = ${ev.state}`;
|
||||
case 'service_enabled_is': return `${ev.service} enabled = ${ev.enabled}`;
|
||||
default: return `${ev.type}: ${JSON.stringify(ev)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAIN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
console.log('\n Sysadmin Chronicles — Clue Fingerprint Verifier');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
if (dryRun) console.log(' Mode: DRY-RUN (no SSH commands executed)\n');
|
||||
if (doRevert) console.log(' Mode: REVERT — VMs will be reverted to baseline snapshots\n');
|
||||
|
||||
const vmProfiles = loadVmProfiles();
|
||||
let quests = loadQuests();
|
||||
|
||||
if (questFilter) {
|
||||
quests = quests.filter(q => q.id === questFilter);
|
||||
if (quests.length === 0) {
|
||||
console.error(`No quest found with id: ${questFilter}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect VMs needed (evidence items might reference multiple VMs, e.g. Q008)
|
||||
const vmsNeeded = new Set();
|
||||
for (const q of quests) {
|
||||
for (const ev of (q.clue_fingerprint?.evidence || [])) {
|
||||
if (ev.vm) vmsNeeded.add(ev.vm);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve IPs for non-revert mode upfront (revert mode resolves per-quest)
|
||||
const vmIps = {};
|
||||
if (!doRevert) {
|
||||
for (const vmId of vmsNeeded) {
|
||||
const domain = domainName(vmId);
|
||||
const profile = vmProfiles[vmId];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
|
||||
if (!vmIsRunning(vmId)) {
|
||||
if (startVms) {
|
||||
console.log(`Starting ${domain}...`);
|
||||
virsh('start', domain);
|
||||
vmIps[vmId] = waitForSsh(vmId, user);
|
||||
} else {
|
||||
console.warn(` ⚠ ${domain} is not running. Use --start-vms or --revert.`);
|
||||
vmIps[vmId] = null;
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(` Resolving IP for ${domain}...`);
|
||||
let ip = null;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (!ip && Date.now() < deadline) {
|
||||
ip = getVmIp(vmId);
|
||||
if (!ip) { process.stdout.write('.'); sleep(3); }
|
||||
}
|
||||
console.log(ip ? ` ${ip}` : ' (could not resolve)');
|
||||
vmIps[vmId] = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-quest checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let totalPass = 0, totalFail = 0, totalSkip = 0;
|
||||
const failures = [];
|
||||
|
||||
for (const quest of quests) {
|
||||
const evidence = quest.clue_fingerprint?.evidence || [];
|
||||
if (evidence.length === 0) {
|
||||
console.log(`\n[${quest.id}] ${quest.title} — no evidence entries, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseline = quest.baseline_snapshot || '(unknown)';
|
||||
const primaryVm = quest.primary_vm || '';
|
||||
console.log(`\n[${quest.id}] ${quest.title}`);
|
||||
console.log(` Baseline: ${primaryVm} @ ${baseline}`);
|
||||
|
||||
// Revert primary VM if --revert
|
||||
if (doRevert && primaryVm && !dryRun) {
|
||||
const revOk = revertVmToBaseline(primaryVm, baseline);
|
||||
if (!revOk) {
|
||||
console.log(` ✗ Cannot revert — skipping ${evidence.length} checks`);
|
||||
totalSkip += evidence.length;
|
||||
continue;
|
||||
}
|
||||
const profile = vmProfiles[primaryVm];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
const ip = waitForSsh(primaryVm, user);
|
||||
vmIps[primaryVm] = ip;
|
||||
if (!ip) {
|
||||
console.log(` ✗ SSH timeout after revert — skipping ${evidence.length} checks`);
|
||||
totalSkip += evidence.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle multi-VM quests (Q008 references both web_server and build_machine)
|
||||
const questVms = new Set(evidence.map(ev => ev.vm).filter(Boolean));
|
||||
if (doRevert) {
|
||||
for (const extraVm of questVms) {
|
||||
if (extraVm === primaryVm) continue;
|
||||
if (!vmIps[extraVm]) {
|
||||
// Extra VM: just ensure it's running (don't revert — only primary gets reverted)
|
||||
if (!vmIsRunning(extraVm)) {
|
||||
virsh('start', domainName(extraVm));
|
||||
}
|
||||
const profile = vmProfiles[extraVm];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
vmIps[extraVm] = waitForSsh(extraVm, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run each evidence check
|
||||
for (const ev of evidence) {
|
||||
const vmId = ev.vm || primaryVm;
|
||||
const ip = vmIps[vmId];
|
||||
const profile = vmProfiles[vmId];
|
||||
const user = profile?.management_user || profile?.ssh_user || 'player';
|
||||
const desc = describeEvidence(ev);
|
||||
|
||||
if (!ip && !dryRun) {
|
||||
console.log(` ⚠ SKIP ${desc}`);
|
||||
console.log(` → VM ${vmId} not reachable`);
|
||||
totalSkip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmd = buildCmd(ev);
|
||||
if (!cmd) {
|
||||
console.log(` ⚠ SKIP ${desc}`);
|
||||
console.log(` → evidence type '${ev.type}' not supported by this tool`);
|
||||
totalSkip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let passed;
|
||||
let detail = '';
|
||||
|
||||
if (dryRun) {
|
||||
console.log(` ○ DRY ${desc}`);
|
||||
console.log(` → ssh ${user}@<${vmId}-ip> '${cmd.slice(0, 80)}${cmd.length > 80 ? '...' : ''}'`);
|
||||
totalPass++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const r = spawnSync('ssh', [...SSH_OPTS, `${user}@${ip}`, cmd], {
|
||||
encoding: 'utf8', timeout: 25000,
|
||||
});
|
||||
const stdout = (r.stdout || '').trim();
|
||||
const stderr = (r.stderr || '').trim();
|
||||
|
||||
if (r.error || r.status === null) {
|
||||
passed = false;
|
||||
detail = r.error ? r.error.message : 'SSH process failed';
|
||||
} else {
|
||||
passed = stdout.startsWith('PASS');
|
||||
detail = stdout.startsWith('FAIL') ? stdout.replace(/^FAIL\s*/, '') : (stderr || `exit ${r.status}`);
|
||||
}
|
||||
|
||||
if (passed) {
|
||||
console.log(` ✓ PASS ${desc}`);
|
||||
totalPass++;
|
||||
} else {
|
||||
console.log(` ✗ FAIL ${desc}`);
|
||||
if (detail) console.log(` → ${detail}`);
|
||||
totalFail++;
|
||||
failures.push({ quest: quest.id, desc, detail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(` Results: ${totalPass} passed ${totalFail} failed ${totalSkip} skipped`);
|
||||
|
||||
if (totalFail > 0) {
|
||||
console.log('\n Failed checks:');
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ [${f.quest}] ${f.desc}`);
|
||||
if (f.detail) console.log(` → ${f.detail}`);
|
||||
}
|
||||
console.log('\n ✗ Clue fingerprint verification FAILED');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!dryRun && totalPass === 0 && totalSkip > 0) {
|
||||
console.log('\n ⚠ No checks ran. Are the VMs running?');
|
||||
console.log(' Tips:');
|
||||
console.log(' --start-vms start VMs then check on current state');
|
||||
console.log(' --revert revert to baseline snapshot then check');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n Dry-run complete. No checks were executed.');
|
||||
} else {
|
||||
console.log('\n ✓ All evidence confirmed in VM state');
|
||||
}
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-content.sh — Run content validation from CLI
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/dev/test-content.sh
|
||||
# bash tools/dev/test-content.sh --verbose
|
||||
# bash tools/dev/test-content.sh --quests-only
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "ERROR: node not found. Install Node.js to run content validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node "$PROJECT_ROOT/tools/content/validate-content.js" "$@"
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install config management for Sysadmin Chronicles.
|
||||
# Config lives at ~/.config/sysadmin-chronicles/config (survives game dir moves).
|
||||
# Source this file; do not execute directly.
|
||||
|
||||
SC_CONFIG_DIR="${SC_CONFIG_DIR:-$HOME/.config/sysadmin-chronicles}"
|
||||
SC_CONFIG_FILE="$SC_CONFIG_DIR/config"
|
||||
|
||||
config_read() {
|
||||
[ -f "$SC_CONFIG_FILE" ] && source "$SC_CONFIG_FILE" || true
|
||||
}
|
||||
|
||||
config_write() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
mkdir -p "$SC_CONFIG_DIR"
|
||||
local tmp
|
||||
tmp="$(mktemp "$SC_CONFIG_DIR/config.XXXXXX")"
|
||||
if [ -f "$SC_CONFIG_FILE" ]; then
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { found = 0 }
|
||||
index($0, key "=") == 1 {
|
||||
print key "=" value
|
||||
found = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!found) {
|
||||
print key "=" value
|
||||
}
|
||||
}
|
||||
' "$SC_CONFIG_FILE" > "$tmp"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" > "$tmp"
|
||||
fi
|
||||
mv "$tmp" "$SC_CONFIG_FILE"
|
||||
}
|
||||
|
||||
config_show() {
|
||||
if [ ! -f "$SC_CONFIG_FILE" ]; then
|
||||
echo " (no config file at $SC_CONFIG_FILE)"
|
||||
return
|
||||
fi
|
||||
echo " Config: $SC_CONFIG_FILE"
|
||||
local line key value
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^# ]] && continue
|
||||
[[ -z "$line" ]] && continue
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
printf ' %-28s %s\n' "$key" "$value"
|
||||
done < "$SC_CONFIG_FILE"
|
||||
}
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dependency detection and installation for Sysadmin Chronicles.
|
||||
# Source this file; do not execute directly.
|
||||
|
||||
SC_DISTRO=""
|
||||
|
||||
detect_distro() {
|
||||
if [ -f /etc/arch-release ]; then
|
||||
SC_DISTRO=arch
|
||||
elif grep -qi ubuntu /etc/os-release 2>/dev/null; then
|
||||
SC_DISTRO=ubuntu
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
SC_DISTRO=debian
|
||||
elif [ -f /etc/fedora-release ]; then
|
||||
SC_DISTRO=fedora
|
||||
elif grep -qi opensuse /etc/os-release 2>/dev/null; then
|
||||
SC_DISTRO=opensuse
|
||||
else
|
||||
SC_DISTRO=unknown
|
||||
fi
|
||||
}
|
||||
|
||||
# Per-distro package names for canonical dep names.
|
||||
# Format: canonical:arch:debian:ubuntu:fedora:opensuse
|
||||
# Empty field = not applicable / same as above / handled specially.
|
||||
_SC_DEP_MAP=(
|
||||
"libvirt:libvirt:libvirt-daemon-system:libvirt-daemon-system:libvirt:libvirt"
|
||||
"qemu-system:qemu-system-x86:qemu-system-x86:qemu-kvm:qemu-kvm:qemu-kvm"
|
||||
"qemu-img:qemu-img:qemu-utils:qemu-utils:qemu-img:qemu-tools"
|
||||
"virt-install:virt-install:virtinst:virtinst:virt-install:virt-install"
|
||||
"virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer"
|
||||
"cloud-localds:cloud-image-utils:cloud-image-utils:cloud-image-utils:cloud-utils:cloud-utils"
|
||||
"genisoimage:cdrtools:genisoimage:genisoimage:genisoimage:genisoimage"
|
||||
"xorriso:libisoburn:xorriso:xorriso:xorriso:xorriso"
|
||||
"nodejs:nodejs:nodejs:nodejs:nodejs:nodejs"
|
||||
"openssh:openssh:openssh-client:openssh-client:openssh-clients:openssh-clients"
|
||||
)
|
||||
|
||||
# Arch-only QEMU SPICE/display extras installed alongside qemu-system
|
||||
_SC_ARCH_QEMU_EXTRAS=(
|
||||
qemu-hw-display-qxl
|
||||
qemu-hw-display-virtio-gpu
|
||||
qemu-ui-spice-core
|
||||
qemu-chardev-spice
|
||||
qemu-audio-spice
|
||||
)
|
||||
|
||||
map_package() {
|
||||
local dep="$1"
|
||||
local distro="${2:-$SC_DISTRO}"
|
||||
local entry
|
||||
for entry in "${_SC_DEP_MAP[@]}"; do
|
||||
IFS=':' read -r name arch debian ubuntu fedora opensuse <<< "$entry"
|
||||
if [ "$name" = "$dep" ]; then
|
||||
case "$distro" in
|
||||
arch) printf '%s' "$arch" ;;
|
||||
debian) printf '%s' "$debian" ;;
|
||||
ubuntu) printf '%s' "$ubuntu" ;;
|
||||
fedora) printf '%s' "$fedora" ;;
|
||||
opensuse) printf '%s' "$opensuse" ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Outputs canonical dep names that are not yet installed (one per line)
|
||||
check_deps() {
|
||||
local missing=()
|
||||
command -v virsh >/dev/null 2>&1 || missing+=(libvirt)
|
||||
command -v qemu-system-x86_64 >/dev/null 2>&1 || missing+=(qemu-system)
|
||||
command -v qemu-img >/dev/null 2>&1 || missing+=(qemu-img)
|
||||
command -v virt-install >/dev/null 2>&1 || missing+=(virt-install)
|
||||
command -v remote-viewer >/dev/null 2>&1 || missing+=(virt-viewer)
|
||||
command -v node >/dev/null 2>&1 || missing+=(nodejs)
|
||||
command -v ssh >/dev/null 2>&1 || missing+=(openssh)
|
||||
# Need at least one cloud-init ISO tool
|
||||
if ! command -v cloud-localds >/dev/null 2>&1 \
|
||||
&& ! command -v genisoimage >/dev/null 2>&1 \
|
||||
&& ! command -v mkisofs >/dev/null 2>&1 \
|
||||
&& ! command -v xorriso >/dev/null 2>&1; then
|
||||
missing+=(cloud-localds genisoimage xorriso)
|
||||
fi
|
||||
[ "${#missing[@]}" -gt 0 ] && printf '%s\n' "${missing[@]}" || true
|
||||
}
|
||||
|
||||
# Install a list of canonical dep names. Logs to SC_INSTALL_LOG if set.
|
||||
install_deps() {
|
||||
local deps=("$@")
|
||||
local pkgs=()
|
||||
|
||||
for dep in "${deps[@]}"; do
|
||||
local pkg
|
||||
pkg="$(map_package "$dep")"
|
||||
[ -n "$pkg" ] && pkgs+=("$pkg")
|
||||
done
|
||||
|
||||
if [ "$SC_DISTRO" = "arch" ]; then
|
||||
pkgs+=("${_SC_ARCH_QEMU_EXTRAS[@]}")
|
||||
fi
|
||||
|
||||
[ "${#pkgs[@]}" -eq 0 ] && return 0
|
||||
|
||||
local -a pm_cmd
|
||||
case "$SC_DISTRO" in
|
||||
arch) pm_cmd=(pacman -S --noconfirm --needed) ;;
|
||||
debian|ubuntu) pm_cmd=(apt-get install -y) ;;
|
||||
fedora) pm_cmd=(dnf install -y) ;;
|
||||
opensuse) pm_cmd=(zypper install -y) ;;
|
||||
*)
|
||||
echo " Unsupported distro '$SC_DISTRO' — install these manually:"
|
||||
printf ' %s\n' "${pkgs[@]}"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
sudo "${pm_cmd[@]}" "${pkgs[@]}"
|
||||
|
||||
if [ -n "${SC_INSTALL_LOG:-}" ] && [ "$SC_INSTALL_LOG" != "/dev/null" ]; then
|
||||
mkdir -p "$(dirname "$SC_INSTALL_LOG")"
|
||||
local distro_label="$SC_DISTRO"
|
||||
for pkg in "${pkgs[@]}"; do
|
||||
local ver=""
|
||||
case "$SC_DISTRO" in
|
||||
arch) ver="$(pacman -Q "$pkg" 2>/dev/null | awk '{print $2}' || true)" ;;
|
||||
debian|ubuntu) ver="$(dpkg -l "$pkg" 2>/dev/null | awk '/^ii/{print $3}' | head -1 || true)" ;;
|
||||
fedora) ver="$(rpm -q --queryformat '%{VERSION}' "$pkg" 2>/dev/null || true)" ;;
|
||||
esac
|
||||
printf '[INSTALLED] %-36s %-14s via %s\n' "$pkg" "${ver:-}" "$distro_label" \
|
||||
>> "$SC_INSTALL_LOG"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Outputs already-installed canonical deps (for log completeness)
|
||||
log_present_deps() {
|
||||
local log_file="${SC_INSTALL_LOG:-}"
|
||||
[ -z "$log_file" ] || [ "$log_file" = "/dev/null" ] && return
|
||||
local distro_label="$SC_DISTRO"
|
||||
for dep in libvirt qemu-system qemu-img virt-install virt-viewer nodejs openssh; do
|
||||
local bin
|
||||
case "$dep" in
|
||||
libvirt) bin=virsh ;;
|
||||
qemu-system) bin=qemu-system-x86_64 ;;
|
||||
qemu-img) bin=qemu-img ;;
|
||||
virt-install) bin=virt-install ;;
|
||||
virt-viewer) bin=remote-viewer ;;
|
||||
nodejs) bin=node ;;
|
||||
openssh) bin=ssh ;;
|
||||
esac
|
||||
if command -v "$bin" >/dev/null 2>&1; then
|
||||
printf '[SKIPPED] %-36s already installed\n' "$dep" >> "$log_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
dep_label() {
|
||||
case "$1" in
|
||||
libvirt) echo "Virtual machine manager (libvirt)" ;;
|
||||
qemu-system) echo "KVM virtualization support (QEMU)" ;;
|
||||
qemu-img) echo "VM disk image tools (qemu-img)" ;;
|
||||
virt-install) echo "VM installer (virt-install)" ;;
|
||||
virt-viewer) echo "SPICE display viewer (virt-viewer)" ;;
|
||||
cloud-localds|genisoimage|xorriso) echo "Cloud image tools" ;;
|
||||
nodejs) echo "Node.js runtime" ;;
|
||||
openssh) echo "SSH client" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared internal HTTPS/URL helpers for Sysadmin Chronicles launch and VM build scripts.
|
||||
# Source this file; do not execute directly.
|
||||
|
||||
sc_internal_port() {
|
||||
printf '%s\n' "${PORT:-3000}"
|
||||
}
|
||||
|
||||
sc_cert_dir() {
|
||||
printf '%s\n' "${SC_CERT_DIR:-$HOME/.local/share/sysadmin-chronicles/certs}"
|
||||
}
|
||||
|
||||
sc_tls_cert() {
|
||||
printf '%s/server.crt\n' "$(sc_cert_dir)"
|
||||
}
|
||||
|
||||
sc_tls_key() {
|
||||
printf '%s/server.key\n' "$(sc_cert_dir)"
|
||||
}
|
||||
|
||||
sc_ca_cert() {
|
||||
printf '%s/ca.crt\n' "$(sc_cert_dir)"
|
||||
}
|
||||
|
||||
sc_hud_url() {
|
||||
printf '%s\n' "${SC_HUD_URL:-https://portal.axiomworks.internal:$(sc_internal_port)}"
|
||||
}
|
||||
|
||||
sc_sage_url() {
|
||||
printf '%s\n' "${SC_SAGE_URL:-https://sage.axiomworks.internal:$(sc_internal_port)/sage/}"
|
||||
}
|
||||
|
||||
sc_company_url() {
|
||||
printf '%s\n' "${SC_COMPANY_URL:-https://www.axiomworks.corp/}"
|
||||
}
|
||||
|
||||
sc_have_internal_certs() {
|
||||
[[ -f "$(sc_tls_cert)" && -f "$(sc_tls_key)" && -f "$(sc_ca_cert)" ]]
|
||||
}
|
||||
|
||||
sc_ensure_internal_certs() {
|
||||
local project_root="$1"
|
||||
if sc_have_internal_certs; then
|
||||
return 0
|
||||
fi
|
||||
bash "$project_root/tools/setup/generate-certs.sh"
|
||||
}
|
||||
|
||||
sc_export_internal_https_env() {
|
||||
export SC_CERT_DIR="$(sc_cert_dir)"
|
||||
export SC_TLS_CERT="$(sc_tls_cert)"
|
||||
export SC_TLS_KEY="$(sc_tls_key)"
|
||||
export SC_HUD_URL="$(sc_hud_url)"
|
||||
export SC_SAGE_URL="$(sc_sage_url)"
|
||||
export SC_COMPANY_URL="$(sc_company_url)"
|
||||
}
|
||||
|
||||
sc_listen_pids() {
|
||||
local port="$1"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u
|
||||
return 0
|
||||
fi
|
||||
ss -H -ltnp "sport = :$port" 2>/dev/null \
|
||||
| sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' \
|
||||
| sort -u
|
||||
}
|
||||
|
||||
sc_pid_is_repo_server() {
|
||||
local pid="$1"
|
||||
local project_root="$2"
|
||||
local server_dir="$project_root/server"
|
||||
local cwd=""
|
||||
local cmdline=""
|
||||
|
||||
[[ -r "/proc/$pid/cmdline" ]] || return 1
|
||||
cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)"
|
||||
cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
|
||||
|
||||
[[ "$cwd" == "$server_dir" ]] || return 1
|
||||
[[ "$cmdline" == *"node"* && "$cmdline" == *"src/index.js"* ]]
|
||||
}
|
||||
|
||||
sc_pid_has_internal_tls() {
|
||||
local pid="$1"
|
||||
[[ -r "/proc/$pid/environ" ]] || return 1
|
||||
tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
|
||||
| grep -q '^SC_TLS_CERT=.*server\.crt$' \
|
||||
&& tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
|
||||
| grep -q '^SC_TLS_KEY=.*server\.key$'
|
||||
}
|
||||
|
||||
sc_stop_pid() {
|
||||
local pid="$1"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
sleep 0.2
|
||||
done
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# libvirt wrappers for Sysadmin Chronicles.
|
||||
# Source this file; do not execute directly.
|
||||
# Expects LIBVIRT_DEFAULT_URI to be set by the caller.
|
||||
#
|
||||
# SC_VIRSH_SUDO=true — prefix all virsh calls with sudo.
|
||||
# Set this in install.sh when the current session doesn't yet have
|
||||
# the libvirt group active (e.g., right after usermod -aG libvirt).
|
||||
|
||||
_virsh() {
|
||||
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
|
||||
sudo virsh "$@"
|
||||
else
|
||||
virsh "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_network() {
|
||||
local name="$1"
|
||||
local xml_path="$2"
|
||||
if _virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
|
||||
if ! _virsh net-info "$name" 2>/dev/null | grep -q "Active:.*yes"; then
|
||||
_virsh net-start "$name" >/dev/null 2>&1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
_virsh net-define "$xml_path" >/dev/null 2>&1
|
||||
_virsh net-autostart "$name" >/dev/null 2>&1
|
||||
_virsh net-start "$name" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_pool() {
|
||||
local name="$1"
|
||||
local path="$2"
|
||||
if _virsh pool-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
|
||||
if ! _virsh pool-info "$name" 2>/dev/null | grep -q "State:.*running"; then
|
||||
_virsh pool-start "$name" >/dev/null 2>&1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$path"
|
||||
_virsh pool-define-as "$name" dir --target "$path" >/dev/null 2>&1
|
||||
_virsh pool-autostart "$name" >/dev/null 2>&1
|
||||
_virsh pool-start "$name" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pool_path() {
|
||||
local name="$1"
|
||||
_virsh pool-dumpxml "$name" 2>/dev/null \
|
||||
| sed -n 's:.*<path>\(.*\)</path>.*:\1:p' \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
domain_exists() {
|
||||
_virsh dominfo "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
domain_state() {
|
||||
_virsh domstate "$1" 2>/dev/null | tr -d ' \n'
|
||||
}
|
||||
|
||||
network_active() {
|
||||
_virsh net-info "$1" 2>/dev/null | grep -q "Active:.*yes"
|
||||
}
|
||||
|
||||
ensure_network_active() {
|
||||
local name="$1"
|
||||
_virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b" || return 1
|
||||
network_active "$name" || _virsh net-start "$name" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
snapshot_exists() {
|
||||
_virsh snapshot-info "$1" "$2" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
snapshot_create() {
|
||||
local domain="$1"
|
||||
local name="$2"
|
||||
local desc="${3:-}"
|
||||
_virsh snapshot-delete "$domain" "$name" >/dev/null 2>&1 || true
|
||||
_virsh snapshot-create-as "$domain" "$name" --description "$desc" --atomic
|
||||
}
|
||||
|
||||
snapshot_revert() {
|
||||
_virsh snapshot-revert "$1" "$2" --running
|
||||
}
|
||||
|
||||
snapshot_delete() {
|
||||
_virsh snapshot-delete "$1" "$2"
|
||||
}
|
||||
|
||||
snapshot_list_names() {
|
||||
_virsh snapshot-list "$1" --name 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Returns approximate qcow2 disk usage for a domain in human-readable form
|
||||
domain_disk_usage() {
|
||||
local domain="$1"
|
||||
local total=0
|
||||
local disk
|
||||
for disk in $(_virsh domblklist "$domain" --details 2>/dev/null | awk '/disk/ && $4 != "-" {print $4}' || true); do
|
||||
[ -f "$disk" ] || continue
|
||||
local sz
|
||||
sz="$(du -sb "$disk" 2>/dev/null | awk '{print $1}' || echo 0)"
|
||||
total=$(( total + sz ))
|
||||
done
|
||||
if [ "$total" -gt 0 ]; then
|
||||
numfmt --to=iec-i --suffix=B "$total" 2>/dev/null || echo "${total}B"
|
||||
else
|
||||
echo "0B"
|
||||
fi
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Save slot management for Sysadmin Chronicles.
|
||||
# Source this file; do not execute directly.
|
||||
|
||||
SC_SAVE_DIR="${SC_SAVE_DIR:-$HOME/.local/share/sysadmin-chronicles/saves}"
|
||||
|
||||
_save_path() { printf '%s/%s.json' "$SC_SAVE_DIR" "$1"; }
|
||||
|
||||
_save_active_slot() {
|
||||
local slot=""
|
||||
if [ -f "$SC_SAVE_DIR/.active" ]; then
|
||||
slot="$(cat "$SC_SAVE_DIR/.active")"
|
||||
fi
|
||||
printf '%s' "${slot:-autosave}"
|
||||
}
|
||||
|
||||
_save_valid_slot() {
|
||||
case "$1" in
|
||||
autosave|slot-1|slot-2|slot-3) return 0 ;;
|
||||
*) echo " ✗ Invalid slot name: $1 (use autosave, slot-1, slot-2, or slot-3)"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_new_game_json() {
|
||||
local slot="$1"
|
||||
printf '{"slot":"%s","day":1,"trust":50,"questsCompleted":0,"quests":{},"flags":{},"inbox":[],"clock":{"shift":1,"day":1}}\n' \
|
||||
"$slot"
|
||||
}
|
||||
|
||||
save_list() {
|
||||
mkdir -p "$SC_SAVE_DIR"
|
||||
local active
|
||||
active="$(_save_active_slot)"
|
||||
printf ' %-14s %-10s %-10s %-10s\n' "Slot" "Day" "Trust" "Quests"
|
||||
printf ' %-14s %-10s %-10s %-10s\n' "──────────────" "──────────" "──────────" "──────────"
|
||||
local slot
|
||||
for slot in autosave slot-1 slot-2 slot-3; do
|
||||
local path
|
||||
path="$(_save_path "$slot")"
|
||||
if [ -f "$path" ]; then
|
||||
local day trust quests marker=""
|
||||
day="$( grep -o '"day":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
|
||||
trust="$( grep -o '"trust":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
|
||||
quests="$( grep -o '"questsCompleted":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
|
||||
[ "$slot" = "$active" ] && marker=" [active]"
|
||||
printf ' %-14s %-10s %-10s %-10s%s\n' "$slot" "Day $day" "T:$trust" "Q:$quests" "$marker"
|
||||
else
|
||||
printf ' %-14s %s\n' "$slot" "—empty—"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
save_switch() {
|
||||
local slot="$1"
|
||||
_save_valid_slot "$slot" || return 1
|
||||
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
|
||||
printf '%s' "$slot" > "$SC_SAVE_DIR/.active"
|
||||
echo " ✓ Switched to $slot"
|
||||
}
|
||||
|
||||
save_new() {
|
||||
local slot="$1"
|
||||
_save_valid_slot "$slot" || return 1
|
||||
mkdir -p "$SC_SAVE_DIR"
|
||||
_new_game_json "$slot" > "$(_save_path "$slot")"
|
||||
echo " ✓ Created new save: $slot"
|
||||
}
|
||||
|
||||
save_reset() {
|
||||
local slot="${1:-$(_save_active_slot)}"
|
||||
_save_valid_slot "$slot" || return 1
|
||||
mkdir -p "$SC_SAVE_DIR"
|
||||
_new_game_json "$slot" > "$(_save_path "$slot")"
|
||||
echo " ✓ Reset $slot to new game state"
|
||||
}
|
||||
|
||||
save_export() {
|
||||
local slot="$1"
|
||||
local dest="$2"
|
||||
_save_valid_slot "$slot" || return 1
|
||||
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
|
||||
[ -n "$dest" ] || { echo " ✗ No destination path given"; return 1; }
|
||||
cp "$(_save_path "$slot")" "$dest"
|
||||
echo " ✓ Exported $slot → $dest"
|
||||
}
|
||||
|
||||
save_import() {
|
||||
local src="$1"
|
||||
local slot="$2"
|
||||
_save_valid_slot "$slot" || return 1
|
||||
[ -f "$src" ] || { echo " ✗ File not found: $src"; return 1; }
|
||||
# Basic JSON sanity check
|
||||
grep -q '^{' "$src" 2>/dev/null || { echo " ✗ File does not look like a save: $src"; return 1; }
|
||||
mkdir -p "$SC_SAVE_DIR"
|
||||
cp "$src" "$(_save_path "$slot")"
|
||||
echo " ✓ Imported $src → $slot"
|
||||
}
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared UI helpers for Sysadmin Chronicles scripts.
|
||||
# Source this file; do not execute directly.
|
||||
|
||||
_SC_STEP_N=0
|
||||
|
||||
# Colors — disabled if stdout is not a terminal or NO_COLOR is set
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
_C_RESET='\033[0m'
|
||||
_C_BOLD='\033[1m'
|
||||
_C_GREEN='\033[0;32m'
|
||||
_C_YELLOW='\033[0;33m'
|
||||
_C_RED='\033[0;31m'
|
||||
_C_CYAN='\033[0;36m'
|
||||
_C_DIM='\033[2m'
|
||||
else
|
||||
_C_RESET='' _C_BOLD='' _C_GREEN='' _C_YELLOW='' _C_RED='' _C_CYAN='' _C_DIM=''
|
||||
fi
|
||||
|
||||
sc_header() {
|
||||
local title="${1:-Sysadmin Chronicles}"
|
||||
echo ""
|
||||
printf "${_C_CYAN}${_C_BOLD}"
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
printf "║ %-40s║\n" "$title"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
printf "${_C_RESET}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
sc_done_banner() {
|
||||
echo ""
|
||||
printf "${_C_GREEN}${_C_BOLD}"
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
printf "║ %-40s║\n" "SETUP COMPLETE!"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
printf "${_C_RESET}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
sc_section() {
|
||||
echo ""
|
||||
printf "${_C_BOLD}── %s ${_C_DIM}─────────────────────────────────${_C_RESET}\n" "$*"
|
||||
}
|
||||
|
||||
sc_step() {
|
||||
(( _SC_STEP_N++ )) || true
|
||||
echo ""
|
||||
printf "${_C_BOLD}── Step %d: %s${_C_RESET}\n" "$_SC_STEP_N" "$*"
|
||||
}
|
||||
|
||||
sc_ok() { printf " ${_C_GREEN}✓${_C_RESET} %s\n" "$*"; }
|
||||
sc_warn() { printf " ${_C_YELLOW}⚠${_C_RESET} %s\n" "$*"; }
|
||||
sc_err() { printf " ${_C_RED}✗${_C_RESET} %s\n" "$*" >&2; }
|
||||
sc_fail() { sc_err "$*"; exit 1; }
|
||||
sc_info() { printf " ${_C_DIM}→${_C_RESET} %s\n" "$*"; }
|
||||
|
||||
# Prompt — writes question to /dev/tty, returns answer on stdout
|
||||
sc_prompt() {
|
||||
local question="$1"
|
||||
local default="${2:-}"
|
||||
if [ -n "$default" ]; then
|
||||
printf " %s [%s] > " "$question" "$default" >/dev/tty
|
||||
else
|
||||
printf " %s > " "$question" >/dev/tty
|
||||
fi
|
||||
local answer
|
||||
read -r answer </dev/tty
|
||||
printf '%s' "${answer:-$default}"
|
||||
}
|
||||
|
||||
# Confirm — returns 0 for yes, 1 for no
|
||||
sc_confirm() {
|
||||
local question="$1"
|
||||
local default="${2:-Y}"
|
||||
local prompt
|
||||
if [ "${default^^}" = "Y" ]; then
|
||||
prompt="[Y/n]"
|
||||
else
|
||||
prompt="[y/N]"
|
||||
fi
|
||||
printf " %s %s " "$question" "$prompt" >/dev/tty
|
||||
local answer
|
||||
read -r answer </dev/tty
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y|yes) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
sc_progress() {
|
||||
local label="$1"
|
||||
local current="$2"
|
||||
local total="$3"
|
||||
printf " %-38s %d/%d\n" "$label" "$current" "$total"
|
||||
}
|
||||
|
||||
_SC_SPINNER_PID=""
|
||||
|
||||
sc_spinner() {
|
||||
local label="${1:-Working...}"
|
||||
printf " %s " "$label" >/dev/tty
|
||||
(
|
||||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
local i=0
|
||||
while true; do
|
||||
printf "\r %s %s " "$label" "${frames[$((i % ${#frames[@]}))]}" >/dev/tty
|
||||
sleep 0.12
|
||||
(( i++ )) || true
|
||||
done
|
||||
) &
|
||||
_SC_SPINNER_PID=$!
|
||||
disown "$_SC_SPINNER_PID" 2>/dev/null || true
|
||||
}
|
||||
|
||||
sc_spinner_stop() {
|
||||
if [ -n "${_SC_SPINNER_PID:-}" ]; then
|
||||
kill "$_SC_SPINNER_PID" 2>/dev/null || true
|
||||
wait "$_SC_SPINNER_PID" 2>/dev/null || true
|
||||
_SC_SPINNER_PID=""
|
||||
printf "\r%-60s\r" "" >/dev/tty
|
||||
fi
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# VM operations for Sysadmin Chronicles.
|
||||
# Source this file; requires lib/libvirt.sh and PROJECT_ROOT set.
|
||||
|
||||
SC_VM_TOOLS="${SC_VM_TOOLS:-${PROJECT_ROOT:-}/tools/vm}"
|
||||
|
||||
_sc_validate_snapshot_name() {
|
||||
[[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]
|
||||
}
|
||||
|
||||
_sc_protected_snapshot() {
|
||||
[[ "$1" == baseline.* ]] || [[ "$1" == checkpoint.* ]]
|
||||
}
|
||||
|
||||
vm_build() {
|
||||
local profile="$1"
|
||||
shift
|
||||
local dry_run=false force=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) dry_run=true ;;
|
||||
--force) force=true ;;
|
||||
esac
|
||||
done
|
||||
local script="$SC_VM_TOOLS/build-${profile}.sh"
|
||||
[ -f "$script" ] || { echo " ✗ No build script for profile: $profile"; return 1; }
|
||||
local args=()
|
||||
[ "$dry_run" = true ] && args+=(--dry-run)
|
||||
[ "$force" = true ] && args+=(--force)
|
||||
bash "$script" "${args[@]}"
|
||||
}
|
||||
|
||||
vm_rebuild() {
|
||||
local profile="$1"
|
||||
shift
|
||||
local dry_run=false
|
||||
for arg in "$@"; do [ "$arg" = "--dry-run" ] && dry_run=true; done
|
||||
|
||||
local domain="sc-${profile}"
|
||||
if domain_exists "$domain" && [ "$dry_run" = false ]; then
|
||||
virsh destroy "$domain" >/dev/null 2>&1 || true
|
||||
virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \
|
||||
|| virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \
|
||||
|| virsh undefine "$domain" >/dev/null 2>&1 || true
|
||||
fi
|
||||
local extra_args=()
|
||||
[ "$dry_run" = true ] && extra_args+=(--dry-run)
|
||||
vm_build "$profile" "${extra_args[@]}"
|
||||
}
|
||||
|
||||
vm_revert() {
|
||||
snapshot_revert "$1" "$2"
|
||||
}
|
||||
|
||||
vm_status() {
|
||||
local vm_id="$1"
|
||||
domain_exists "$vm_id" && domain_state "$vm_id" || printf 'missing'
|
||||
}
|
||||
|
||||
vm_start() {
|
||||
virsh start "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
vm_stop() {
|
||||
virsh shutdown "$1" >/dev/null 2>&1 || virsh destroy "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
vm_snapshot_create() {
|
||||
local vm_id="$1"
|
||||
local name="$2"
|
||||
_sc_validate_snapshot_name "$name" \
|
||||
|| { echo " ✗ Invalid name (letters, numbers, hyphens only): $name"; return 1; }
|
||||
snapshot_create "$vm_id" "$name" "User snapshot — $(date '+%Y-%m-%d %H:%M')"
|
||||
}
|
||||
|
||||
vm_snapshot_list() {
|
||||
local vm_id="$1"
|
||||
virsh snapshot-list "$vm_id" 2>/dev/null || true
|
||||
}
|
||||
|
||||
vm_snapshot_revert() {
|
||||
local vm_id="$1"
|
||||
local name="$2"
|
||||
snapshot_exists "$vm_id" "$name" \
|
||||
|| { echo " ✗ Snapshot not found: $name"; return 1; }
|
||||
snapshot_revert "$vm_id" "$name"
|
||||
}
|
||||
|
||||
vm_snapshot_delete() {
|
||||
local vm_id="$1"
|
||||
local name="$2"
|
||||
if _sc_protected_snapshot "$name"; then
|
||||
echo " ✗ Cannot delete protected snapshot: $name"
|
||||
return 1
|
||||
fi
|
||||
snapshot_delete "$vm_id" "$name"
|
||||
}
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
# Save slot and VM snapshot management for Sysadmin Chronicles.
|
||||
#
|
||||
# Usage:
|
||||
# manage-saves.sh Interactive menu
|
||||
# manage-saves.sh --list List save slots (non-interactive)
|
||||
# manage-saves.sh --reset Reset current save
|
||||
# manage-saves.sh --reset slot-1 Reset a specific slot
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
source "$PROJECT_ROOT/tools/lib/ui.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/config.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/save.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/libvirt.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/vm.sh"
|
||||
|
||||
config_read || true
|
||||
export LIBVIRT_DEFAULT_URI="${SC_LIBVIRT_URI:-${LIBVIRT_DEFAULT_URI:-qemu:///system}}"
|
||||
|
||||
# Non-interactive flags
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--list)
|
||||
save_list
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${1:-}" == "--reset" ]]; then
|
||||
slot="${2:-}"
|
||||
if [ -n "$slot" ]; then
|
||||
save_reset "$slot"
|
||||
else
|
||||
save_reset
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
declare -A VM_LABEL=(
|
||||
[sc-workstation]="workstation (ares)"
|
||||
[sc-web-server]="web server (hermes)"
|
||||
[sc-build-machine]="build server (vulcan)"
|
||||
)
|
||||
ALL_VMS=(sc-workstation sc-web-server sc-build-machine)
|
||||
|
||||
sc_header "SYSADMIN CHRONICLES — SAVE MANAGEMENT"
|
||||
|
||||
_main_menu() {
|
||||
while true; do
|
||||
echo ""
|
||||
save_list
|
||||
echo ""
|
||||
echo " ── Save Actions ─────────────────────────────"
|
||||
echo " s) Switch active save slot"
|
||||
echo " n) New save slot"
|
||||
echo " r) Reset a save slot"
|
||||
echo " e) Export save to file"
|
||||
echo " i) Import save from file"
|
||||
echo ""
|
||||
echo " ── VM Snapshots ─────────────────────────────"
|
||||
echo " v) View and manage VM snapshots"
|
||||
echo ""
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
printf " > " >/dev/tty
|
||||
read -r choice </dev/tty
|
||||
echo ""
|
||||
|
||||
case "$choice" in
|
||||
s|S) _switch_slot ;;
|
||||
n|N) _new_slot ;;
|
||||
r|R) _reset_slot ;;
|
||||
e|E) _export_slot ;;
|
||||
i|I) _import_slot ;;
|
||||
v|V) _vm_snapshots ;;
|
||||
q|Q) break ;;
|
||||
*) sc_warn "Unknown choice." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
_switch_slot() {
|
||||
echo " Switch to which slot? (autosave / slot-1 / slot-2 / slot-3)"
|
||||
printf " > " >/dev/tty
|
||||
read -r slot </dev/tty
|
||||
echo ""
|
||||
save_switch "$slot" || true
|
||||
|
||||
# Warn if VM state may not match
|
||||
local active_day
|
||||
active_day="$(grep -o '"day":[0-9]*' "$SC_SAVE_DIR/${slot}.json" 2>/dev/null | head -1 | cut -d: -f2 || echo '')"
|
||||
if [ -n "$active_day" ] && [ "$active_day" -gt 1 ] 2>/dev/null; then
|
||||
echo ""
|
||||
sc_warn "This save is on Day $active_day. Your VMs may not match this slot's expected state."
|
||||
sc_info "If things look wrong, use 'tools/vm/rebuild-vms.sh' to revert VMs."
|
||||
fi
|
||||
}
|
||||
|
||||
_new_slot() {
|
||||
echo " Create in which slot? (slot-1 / slot-2 / slot-3)"
|
||||
printf " > " >/dev/tty
|
||||
read -r slot </dev/tty
|
||||
echo ""
|
||||
save_new "$slot" || true
|
||||
}
|
||||
|
||||
_reset_slot() {
|
||||
echo " Reset which slot? (autosave / slot-1 / slot-2 / slot-3)"
|
||||
printf " > " >/dev/tty
|
||||
read -r slot </dev/tty
|
||||
echo ""
|
||||
if sc_confirm " Reset $slot to new game state? This cannot be undone." "N"; then
|
||||
save_reset "$slot"
|
||||
fi
|
||||
}
|
||||
|
||||
_export_slot() {
|
||||
echo " Export which slot? (autosave / slot-1 / slot-2 / slot-3)"
|
||||
printf " > " >/dev/tty
|
||||
read -r slot </dev/tty
|
||||
echo ""
|
||||
echo " Save to (full path, e.g. ~/Desktop/my-save.json):"
|
||||
printf " > " >/dev/tty
|
||||
read -r dest </dev/tty
|
||||
dest="${dest/#\~/$HOME}"
|
||||
echo ""
|
||||
save_export "$slot" "$dest" || true
|
||||
}
|
||||
|
||||
_import_slot() {
|
||||
echo " Path to save file to import:"
|
||||
printf " > " >/dev/tty
|
||||
read -r src </dev/tty
|
||||
src="${src/#\~/$HOME}"
|
||||
echo ""
|
||||
echo " Import into which slot? (autosave / slot-1 / slot-2 / slot-3)"
|
||||
printf " > " >/dev/tty
|
||||
read -r slot </dev/tty
|
||||
echo ""
|
||||
save_import "$src" "$slot" || true
|
||||
}
|
||||
|
||||
_vm_snapshots() {
|
||||
while true; do
|
||||
echo ""
|
||||
echo " VM Snapshots"
|
||||
echo ""
|
||||
|
||||
local vm label snaps snap ts prot
|
||||
for vm in "${ALL_VMS[@]}"; do
|
||||
label="${VM_LABEL[$vm]:-$vm}"
|
||||
echo " $label"
|
||||
if domain_exists "$vm" 2>/dev/null; then
|
||||
snaps="$(virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' || true)"
|
||||
if [ -n "$snaps" ]; then
|
||||
while IFS= read -r snap; do
|
||||
ts="$(virsh snapshot-info "$vm" "$snap" 2>/dev/null | grep 'Creation Time' | awk '{print $3}' || true)"
|
||||
if [[ "$snap" == baseline.* ]] || [[ "$snap" == checkpoint.* ]]; then
|
||||
prot=" [protected]"
|
||||
else
|
||||
prot=""
|
||||
fi
|
||||
printf " %-36s %-12s%s\n" "$snap" "$ts" "$prot"
|
||||
done <<< "$snaps"
|
||||
else
|
||||
echo " (no snapshots)"
|
||||
fi
|
||||
else
|
||||
echo " (VM not found)"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo " Actions: [t]ake snapshot [r]evert [d]elete [q]uit"
|
||||
echo ""
|
||||
printf " > " >/dev/tty
|
||||
local snap_action
|
||||
read -r snap_action </dev/tty
|
||||
echo ""
|
||||
|
||||
case "$snap_action" in
|
||||
t|T)
|
||||
_pick_vm vm_id " Snapshot which VM? (workstation / web-server / build-machine)"
|
||||
echo " Snapshot name (letters, numbers, hyphens):"
|
||||
printf " > " >/dev/tty
|
||||
read -r snap_name </dev/tty
|
||||
echo ""
|
||||
vm_snapshot_create "sc-${vm_id}" "$snap_name" \
|
||||
&& sc_ok "Snapshot taken: $snap_name on $vm_id" \
|
||||
|| sc_warn "Snapshot failed."
|
||||
;;
|
||||
r|R)
|
||||
_pick_vm vm_id " Revert which VM?"
|
||||
echo " Snapshot name:"
|
||||
printf " > " >/dev/tty
|
||||
read -r snap_name </dev/tty
|
||||
echo ""
|
||||
if sc_confirm " Revert $vm_id to '$snap_name'?" "N"; then
|
||||
vm_snapshot_revert "sc-${vm_id}" "$snap_name" \
|
||||
&& sc_ok "Reverted to $snap_name" \
|
||||
|| sc_warn "Revert failed."
|
||||
fi
|
||||
;;
|
||||
d|D)
|
||||
_pick_vm vm_id " Delete snapshot from which VM?"
|
||||
echo " Snapshot name:"
|
||||
printf " > " >/dev/tty
|
||||
read -r snap_name </dev/tty
|
||||
echo ""
|
||||
vm_snapshot_delete "sc-${vm_id}" "$snap_name" \
|
||||
&& sc_ok "Deleted $snap_name from $vm_id" \
|
||||
|| true
|
||||
;;
|
||||
q|Q) break ;;
|
||||
*) sc_warn "Unknown action." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
_pick_vm() {
|
||||
local -n _result_ref="$1"
|
||||
local _prompt="$2"
|
||||
echo " $_prompt (workstation / web-server / build-machine)"
|
||||
printf " > " >/dev/tty
|
||||
read -r _result_ref </dev/tty
|
||||
echo ""
|
||||
}
|
||||
|
||||
_main_menu
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-host.sh — Sysadmin Chronicles host prerequisite checker
|
||||
#
|
||||
# Run this before first-run-setup.sh to see what's missing.
|
||||
# Safe to run any number of times — read-only checks only.
|
||||
#
|
||||
# Exit code 0 = all required dependencies present.
|
||||
# Exit code 1 = one or more required dependencies missing.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OWNER_USER="${SUDO_USER:-$USER}"
|
||||
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
|
||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
PASS="✓"
|
||||
FAIL="✗"
|
||||
WARN="⚠"
|
||||
|
||||
errors=0
|
||||
warnings=0
|
||||
|
||||
run_virsh_quick() {
|
||||
timeout 5 virsh "$@" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_cmd() {
|
||||
local cmd="$1"
|
||||
local label="${2:-$cmd}"
|
||||
if command -v "$cmd" &>/dev/null; then
|
||||
echo " $PASS $label"
|
||||
else
|
||||
echo " $FAIL $label — NOT FOUND"
|
||||
((errors+=1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_file() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
local required="${3:-true}"
|
||||
if [ -e "$path" ]; then
|
||||
echo " $PASS $label ($path)"
|
||||
else
|
||||
if [ "$required" = "true" ]; then
|
||||
echo " $FAIL $label — NOT FOUND ($path)"
|
||||
((errors+=1))
|
||||
else
|
||||
echo " $WARN $label — not found ($path) [optional]"
|
||||
((warnings+=1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_group() {
|
||||
local group="$1"
|
||||
if id -nG "$OWNER_USER" | grep -qw "$group"; then
|
||||
echo " $PASS User is in group: $group"
|
||||
else
|
||||
echo " $WARN Not in group '$group' — libvirt access may require sudo or group add"
|
||||
((warnings+=1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_kvm_access() {
|
||||
if id -nG "$OWNER_USER" | grep -qw kvm; then
|
||||
echo " $PASS User is in group: kvm"
|
||||
elif [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
|
||||
echo " $PASS /dev/kvm is accessible to this session"
|
||||
else
|
||||
echo " $WARN Not in group 'kvm' — KVM acceleration may require sudo or group add"
|
||||
((warnings+=1))
|
||||
fi
|
||||
}
|
||||
|
||||
libvirt_ready=false
|
||||
socket_ready=false
|
||||
|
||||
if run_virsh_quick -q list --all; then
|
||||
libvirt_ready=true
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet libvirtd.socket 2>/dev/null || \
|
||||
systemctl is-active --quiet virtqemud.socket 2>/dev/null; then
|
||||
socket_ready=true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Sysadmin Chronicles — Host Prerequisite Check"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
echo "── Virtualization ─────────────────────────────────"
|
||||
check_file "/dev/kvm" "KVM device node"
|
||||
check_cmd "virsh" "virsh (libvirt CLI)"
|
||||
check_cmd "qemu-system-x86_64" "QEMU (x86_64)"
|
||||
|
||||
# libvirt runtime
|
||||
if [ "$libvirt_ready" = "true" ]; then
|
||||
echo " $PASS libvirt responds to virsh"
|
||||
elif [ "$socket_ready" = "true" ]; then
|
||||
echo " $WARN libvirt socket activation is available, but this user cannot reach $LIBVIRT_DEFAULT_URI"
|
||||
echo " Add yourself to the libvirt group or use sudo for setup."
|
||||
((warnings+=1))
|
||||
else
|
||||
echo " $FAIL libvirt is not reachable — start socket activation or the daemon"
|
||||
echo " Example: sudo systemctl enable --now libvirtd.socket"
|
||||
((errors+=1))
|
||||
fi
|
||||
|
||||
check_group "libvirt"
|
||||
check_kvm_access
|
||||
|
||||
echo ""
|
||||
echo "── Storage ────────────────────────────────────────"
|
||||
check_cmd "qemu-img" "qemu-img (disk image tool)"
|
||||
# Storage pool exists check
|
||||
if run_virsh_quick pool-info sc-images; then
|
||||
echo " $PASS sc-images storage pool exists"
|
||||
else
|
||||
echo " $WARN sc-images storage pool not yet created (run first-run-setup.sh)"
|
||||
((warnings+=1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── Networking ─────────────────────────────────────"
|
||||
if run_virsh_quick net-info sc-internal; then
|
||||
echo " $PASS sc-internal network exists"
|
||||
if timeout 5 virsh net-dumpxml sc-internal 2>/dev/null | grep -q "<forward mode=['\"]nat['\"]"; then
|
||||
echo " $PASS sc-internal has NAT egress for VM provisioning"
|
||||
else
|
||||
echo " $WARN sc-internal is missing NAT egress — rebuilds may fail package installation"
|
||||
echo " Run: sudo bash tools/setup/first-run-setup.sh"
|
||||
((warnings+=1))
|
||||
fi
|
||||
else
|
||||
echo " $WARN sc-internal network not yet created (run first-run-setup.sh)"
|
||||
((warnings+=1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── SSH Keys ───────────────────────────────────────"
|
||||
check_file "$OWNER_HOME/.ssh/sc_host_key" "Host→Guest SSH key" "false"
|
||||
check_file "$OWNER_HOME/.ssh/sc_host_key.pub" "Host→Guest SSH public key" "false"
|
||||
|
||||
echo ""
|
||||
echo "── Runtime Tools ──────────────────────────────────"
|
||||
check_cmd "ssh" "ssh"
|
||||
check_cmd "node" "node (Node.js, for content validation)"
|
||||
check_cmd "godot" "godot (Godot 4 engine binary)" || true # warn only
|
||||
|
||||
echo ""
|
||||
echo "── VM Images ──────────────────────────────────────"
|
||||
for vm in sc-workstation sc-web-server sc-build-machine; do
|
||||
if run_virsh_quick dominfo "$vm"; then
|
||||
echo " $PASS Domain exists: $vm"
|
||||
else
|
||||
echo " $WARN Domain not yet created: $vm (run seed-vms.sh)"
|
||||
((warnings+=1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
if [ "$errors" -eq 0 ] && [ "$warnings" -eq 0 ]; then
|
||||
echo " ✅ All checks passed. Ready to play."
|
||||
elif [ "$errors" -eq 0 ]; then
|
||||
echo " ⚠ $warnings warning(s). Run first-run-setup.sh if this is a fresh install."
|
||||
else
|
||||
echo " ❌ $errors error(s), $warnings warning(s). Run first-run-setup.sh to resolve."
|
||||
fi
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
exit $([ "$errors" -eq 0 ] && echo 0 || echo 1)
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
# first-run-setup.sh — Sysadmin Chronicles one-time host setup
|
||||
#
|
||||
# This script creates the libvirt resources (networks, storage pool) and SSH
|
||||
# keys required for the game. It is safe to re-run — all actions are idempotent.
|
||||
#
|
||||
# What this does:
|
||||
# 1. Creates sc-internal libvirt network (private NAT, game-only)
|
||||
# 2. Creates sc-images storage pool for qcow2 VM images
|
||||
# 3. Generates the host→guest SSH key pair (sc_host_key)
|
||||
# 4. Checks libvirtd is running and user has access
|
||||
#
|
||||
# What this does NOT do:
|
||||
# - Build VM images (that's seed-vms.sh)
|
||||
# - Modify /etc/libvirt or system-wide config beyond the named libvirt resources
|
||||
# - Require broad sudo during normal gameplay
|
||||
#
|
||||
# AGENT RULES: Never run provisioning scripts against VMs from here.
|
||||
# This script only creates host infrastructure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OWNER_USER="${SUDO_USER:-$USER}"
|
||||
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
|
||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
echo "[DRY-RUN] No changes will be made."
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [would run] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
step() { echo ""; echo "── $* ───────────────────────"; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
info() { echo " → $*"; }
|
||||
|
||||
libvirt_reachable() {
|
||||
virsh -q list --all >/dev/null 2>&1
|
||||
}
|
||||
|
||||
libvirt_socket_available() {
|
||||
systemctl is-active --quiet libvirtd.socket 2>/dev/null || \
|
||||
systemctl is-active --quiet virtqemud.socket 2>/dev/null
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Sysadmin Chronicles — First-Run Setup"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
|
||||
# Sanity: check libvirt access
|
||||
step "Checking libvirt access"
|
||||
if libvirt_reachable; then
|
||||
ok "libvirt responds to virsh"
|
||||
elif libvirt_socket_available; then
|
||||
ok "libvirt socket activation is available"
|
||||
else
|
||||
echo " ERROR: libvirt is not reachable."
|
||||
echo " Run: sudo systemctl enable --now libvirtd.socket"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Creating sc-internal libvirt network"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NETWORK_XML="$SCRIPT_DIR/../vm/network-sc-internal.xml"
|
||||
|
||||
write_network_xml() {
|
||||
local target="$1"
|
||||
cat > "$target" << 'EOF'
|
||||
<network>
|
||||
<name>sc-internal</name>
|
||||
<forward mode='nat'/>
|
||||
<bridge name='sc-br0' stp='on' delay='0'/>
|
||||
<ip address='10.42.0.1' netmask='255.255.255.0'>
|
||||
<dhcp>
|
||||
<range start='10.42.0.10' end='10.42.0.50'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
EOF
|
||||
}
|
||||
|
||||
network_has_nat() {
|
||||
virsh net-dumpxml sc-internal 2>/dev/null | grep -q "<forward mode=['\"]nat['\"]"
|
||||
}
|
||||
|
||||
define_network() {
|
||||
local label="$1"
|
||||
local tmpxml
|
||||
tmpxml=$(mktemp /tmp/sc-internal-XXXXXX.xml)
|
||||
if [ -f "$NETWORK_XML" ]; then
|
||||
cp "$NETWORK_XML" "$tmpxml"
|
||||
else
|
||||
write_network_xml "$tmpxml"
|
||||
fi
|
||||
run virsh net-define "$tmpxml"
|
||||
run virsh net-autostart sc-internal
|
||||
run virsh net-start sc-internal
|
||||
rm -f "$tmpxml"
|
||||
ok "$label"
|
||||
}
|
||||
|
||||
if virsh net-list --all | grep -q "sc-internal"; then
|
||||
if network_has_nat; then
|
||||
ok "sc-internal network already exists with NAT"
|
||||
else
|
||||
info "Recreating sc-internal with NAT egress for guest provisioning..."
|
||||
run virsh net-destroy sc-internal >/dev/null 2>&1 || true
|
||||
run virsh net-undefine sc-internal
|
||||
define_network "sc-internal network recreated and started"
|
||||
fi
|
||||
else
|
||||
info "Creating sc-internal (private NAT, game-scoped network)..."
|
||||
define_network "sc-internal network created and started"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Creating sc-images storage pool"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "${LIBVIRT_DEFAULT_URI}" = "qemu:///system" ]; then
|
||||
IMAGES_DIR="/var/lib/libvirt/images/sysadmin-chronicles"
|
||||
else
|
||||
IMAGES_DIR="$OWNER_HOME/.local/share/sysadmin-chronicles/images"
|
||||
fi
|
||||
|
||||
if virsh pool-list --all | grep -q "sc-images"; then
|
||||
CURRENT_POOL_PATH="$(virsh pool-dumpxml sc-images 2>/dev/null | sed -n 's:.*<path>\(.*\)</path>.*:\1:p' | head -n1)"
|
||||
if [ "$CURRENT_POOL_PATH" = "$IMAGES_DIR" ]; then
|
||||
ok "sc-images pool already exists"
|
||||
else
|
||||
info "Recreating sc-images pool at $IMAGES_DIR (was $CURRENT_POOL_PATH)..."
|
||||
run mkdir -p "$IMAGES_DIR"
|
||||
run virsh pool-destroy sc-images >/dev/null 2>&1 || true
|
||||
run virsh pool-undefine sc-images
|
||||
run virsh pool-define-as sc-images dir --target "$IMAGES_DIR"
|
||||
run virsh pool-autostart sc-images
|
||||
run virsh pool-start sc-images
|
||||
ok "sc-images pool recreated"
|
||||
fi
|
||||
else
|
||||
info "Creating sc-images pool at $IMAGES_DIR..."
|
||||
run mkdir -p "$IMAGES_DIR"
|
||||
run virsh pool-define-as sc-images dir --target "$IMAGES_DIR"
|
||||
run virsh pool-autostart sc-images
|
||||
run virsh pool-start sc-images
|
||||
ok "sc-images pool created"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Generating SSH key pair"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
KEY_PATH="$OWNER_HOME/.ssh/sc_host_key"
|
||||
if [ -f "$KEY_PATH" ]; then
|
||||
run chown "$OWNER_USER:$OWNER_USER" "$KEY_PATH" "${KEY_PATH}.pub" >/dev/null 2>&1 || true
|
||||
run chmod 600 "$KEY_PATH" >/dev/null 2>&1 || true
|
||||
run chmod 644 "${KEY_PATH}.pub" >/dev/null 2>&1 || true
|
||||
ok "SSH key already exists: $KEY_PATH"
|
||||
else
|
||||
info "Generating ed25519 key: $KEY_PATH"
|
||||
run mkdir -p "$(dirname "$KEY_PATH")"
|
||||
run ssh-keygen -t ed25519 -N "" -C "sysadmin-chronicles-host" -f "$KEY_PATH"
|
||||
run chmod 600 "$KEY_PATH"
|
||||
run chmod 644 "${KEY_PATH}.pub"
|
||||
run chown "$OWNER_USER:$OWNER_USER" "$KEY_PATH" "${KEY_PATH}.pub"
|
||||
ok "SSH key generated"
|
||||
info "Public key (add to VM authorized_keys during build):"
|
||||
if [ "$DRY_RUN" = "false" ]; then
|
||||
cat "${KEY_PATH}.pub"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Verifying group membership"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if id -nG | grep -qw "libvirt"; then
|
||||
ok "User is in libvirt group"
|
||||
else
|
||||
info "Consider adding yourself to the libvirt group for passwordless VM control:"
|
||||
info " sudo usermod -aG libvirt \$USER && newgrp libvirt"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Setup complete."
|
||||
echo " Next step: bash tools/setup/seed-vms.sh"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates a self-signed CA and server certificate for Sysadmin Chronicles TLS.
|
||||
# Idempotent — skips if certs already exist.
|
||||
# Run this before building VMs. Called by install.sh automatically.
|
||||
set -euo pipefail
|
||||
|
||||
SC_CERT_DIR="${SC_CERT_DIR:-$HOME/.local/share/sysadmin-chronicles/certs}"
|
||||
mkdir -p "$SC_CERT_DIR"
|
||||
chmod 700 "$SC_CERT_DIR"
|
||||
|
||||
if [[ -f "$SC_CERT_DIR/server.crt" && -f "$SC_CERT_DIR/server.key" && -f "$SC_CERT_DIR/ca.crt" ]]; then
|
||||
echo "TLS certs already exist at $SC_CERT_DIR — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Generating Axiom Works internal CA..."
|
||||
openssl genrsa -out "$SC_CERT_DIR/ca.key" 4096 2>/dev/null
|
||||
openssl req -new -x509 -days 3650 \
|
||||
-key "$SC_CERT_DIR/ca.key" \
|
||||
-out "$SC_CERT_DIR/ca.crt" \
|
||||
-subj "/CN=Axiom Works Internal CA/O=Axiom Works" 2>/dev/null
|
||||
|
||||
echo "Generating server certificate..."
|
||||
openssl genrsa -out "$SC_CERT_DIR/server.key" 4096 2>/dev/null
|
||||
openssl req -new \
|
||||
-key "$SC_CERT_DIR/server.key" \
|
||||
-out "$SC_CERT_DIR/server.csr" \
|
||||
-subj "/CN=portal.axiomworks.internal/O=Axiom Works" 2>/dev/null
|
||||
|
||||
cat > "$SC_CERT_DIR/server.ext" <<'EXTEOF'
|
||||
subjectAltName=DNS:portal.axiomworks.internal,DNS:sage.axiomworks.internal,DNS:axiomworks.corp,DNS:www.axiomworks.corp,DNS:*.axiomworks.internal,DNS:*.axiomworks.corp
|
||||
EXTEOF
|
||||
|
||||
openssl x509 -req -days 3650 \
|
||||
-in "$SC_CERT_DIR/server.csr" \
|
||||
-CA "$SC_CERT_DIR/ca.crt" \
|
||||
-CAkey "$SC_CERT_DIR/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$SC_CERT_DIR/server.crt" \
|
||||
-extfile "$SC_CERT_DIR/server.ext" 2>/dev/null
|
||||
|
||||
chmod 600 "$SC_CERT_DIR/ca.key" "$SC_CERT_DIR/server.key"
|
||||
rm -f "$SC_CERT_DIR/server.csr" "$SC_CERT_DIR/server.ext"
|
||||
|
||||
echo "TLS certs generated at $SC_CERT_DIR"
|
||||
echo " CA cert: $SC_CERT_DIR/ca.crt"
|
||||
echo " Server cert: $SC_CERT_DIR/server.crt"
|
||||
echo " Server key: $SC_CERT_DIR/server.key"
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env bash
|
||||
# seed-vms.sh — Build all game VMs and create baseline snapshots.
|
||||
#
|
||||
# This script orchestrates the full VM provisioning pipeline:
|
||||
# 1. Build base VM images (cloud-init or manual install)
|
||||
# 2. Install guest helper binaries
|
||||
# 3. Run quest-prep scripts for each Tier 1 quest
|
||||
# 4. Take named baseline snapshots
|
||||
#
|
||||
# Prerequisites: Run first-run-setup.sh first (creates networks + pool).
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/setup/seed-vms.sh # Build all VMs
|
||||
# bash tools/setup/seed-vms.sh --dry-run # Preview only
|
||||
# bash tools/setup/seed-vms.sh --vm workstation # One VM only
|
||||
# bash tools/setup/seed-vms.sh --skip-build # Prep scripts + snapshots only
|
||||
#
|
||||
# AGENT RULES:
|
||||
# - Never run quest-prep scripts against live player VMs.
|
||||
# - All prep scripts must be idempotent (safe to run twice).
|
||||
# - Snapshots are only taken after prep scripts complete successfully.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OWNER_USER="${SUDO_USER:-$USER}"
|
||||
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
|
||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
export SC_OWNER_USER="$OWNER_USER"
|
||||
export SC_OWNER_HOME="$OWNER_HOME"
|
||||
export SC_SSH_KEY="${SC_SSH_KEY:-$OWNER_HOME/.ssh/sc_host_key}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VM_TOOLS="$PROJECT_ROOT/tools/vm"
|
||||
QUEST_PREP="$VM_TOOLS/quest-prep"
|
||||
|
||||
source "$PROJECT_ROOT/tools/lib/config.sh"
|
||||
config_read || true
|
||||
|
||||
normalize_dir_path() {
|
||||
local path="${1:-}"
|
||||
while [[ "$path" == *//* ]]; do
|
||||
path="${path//\/\//\/}"
|
||||
done
|
||||
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
|
||||
path="${path%/}"
|
||||
done
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
if [ -n "${SC_IMAGES_DIR:-}" ]; then
|
||||
SC_IMAGES_DIR="$(normalize_dir_path "$SC_IMAGES_DIR")"
|
||||
export SC_IMAGE_ROOT="$SC_IMAGES_DIR"
|
||||
fi
|
||||
export SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
|
||||
export SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
|
||||
|
||||
DRY_RUN=false
|
||||
SKIP_BUILD=false
|
||||
SINGLE_VM=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--skip-build) SKIP_BUILD=true; shift ;;
|
||||
--vm) SINGLE_VM="$2"; shift 2 ;;
|
||||
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
step() { echo ""; echo "── $* ───────────────────────────────────────"; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
info() { echo " → $*"; }
|
||||
fail() { echo " ✗ $*"; exit 1; }
|
||||
vm_selected() {
|
||||
local key="$1"
|
||||
[ -z "$SINGLE_VM" ] || [ "$SINGLE_VM" = "$key" ]
|
||||
}
|
||||
domain_selected() {
|
||||
local domain="$1"
|
||||
case "$domain" in
|
||||
sc-workstation) vm_selected "workstation" ;;
|
||||
sc-web-server) vm_selected "web_server" ;;
|
||||
sc-build-machine) vm_selected "build_machine" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
require_file() {
|
||||
local path="$1"
|
||||
local label="$2"
|
||||
if [ ! -f "$path" ]; then
|
||||
fail "$label is missing: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Sysadmin Chronicles — VM Seed Pipeline"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode]"
|
||||
echo ""
|
||||
|
||||
step "Validating provisioning toolchain"
|
||||
require_file "$QUEST_PREP/Q001-prep.sh" "Q001 prep script"
|
||||
require_file "$QUEST_PREP/Q002-prep.sh" "Q002 prep script"
|
||||
require_file "$QUEST_PREP/Q003-prep.sh" "Q003 prep script"
|
||||
require_file "$QUEST_PREP/Q004-prep.sh" "Q004 prep script"
|
||||
require_file "$QUEST_PREP/Q006-prep.sh" "Q006 prep script"
|
||||
require_file "$QUEST_PREP/Q006-post-clean.sh" "Q006 post-clean script"
|
||||
|
||||
if [ "$SKIP_BUILD" = "false" ]; then
|
||||
missing_scripts=()
|
||||
for script in \
|
||||
"$VM_TOOLS/build-workstation.sh" \
|
||||
"$VM_TOOLS/build-web-server.sh" \
|
||||
"$VM_TOOLS/build-build-machine.sh" \
|
||||
"$VM_TOOLS/install-guest-helper.sh" \
|
||||
"$VM_TOOLS/suppress-maintenance-noise.sh"
|
||||
do
|
||||
if [ ! -f "$script" ]; then
|
||||
missing_scripts+=("$script")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing_scripts[@]}" -gt 0 ]; then
|
||||
echo " ✗ VM provisioning pipeline is incomplete in this repo checkout."
|
||||
echo " Missing files:"
|
||||
for script in "${missing_scripts[@]}"; do
|
||||
echo " - $script"
|
||||
done
|
||||
echo ""
|
||||
echo " Current state:"
|
||||
echo " - The Godot game and authored content are present."
|
||||
echo " - The VM image build/provision helper scripts are not."
|
||||
echo ""
|
||||
echo " Real VM seeding cannot complete until those scripts are added."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STEP 1 — Build base images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$SKIP_BUILD" = "false" ]; then
|
||||
step "Building VM base images"
|
||||
info "NOTE: VM image builds require cloud-init ISO or manual install."
|
||||
info " See docs/ARCHITECTURE.md §5.3.1 for workstation profile guidance."
|
||||
info " See tools/vm/build-*.sh scripts for per-VM build details."
|
||||
echo ""
|
||||
|
||||
if vm_selected "workstation"; then
|
||||
info "Building workstation (ares) — Debian XFCE desktop..."
|
||||
run bash "$VM_TOOLS/build-workstation.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
fi
|
||||
|
||||
if vm_selected "web_server"; then
|
||||
info "Building web_server (hermes) — headless Debian..."
|
||||
run bash "$VM_TOOLS/build-web-server.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
fi
|
||||
|
||||
if vm_selected "build_machine"; then
|
||||
info "Building build_machine (vulcan) — headless Arch..."
|
||||
run bash "$VM_TOOLS/build-build-machine.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STEP 1b — Verify baseline connectivity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$SKIP_BUILD" = "false" ] && [ "$DRY_RUN" = "false" ]; then
|
||||
step "Verifying baseline connectivity"
|
||||
for dom_host in "sc-web-server:hermes" "sc-build-machine:vulcan"; do
|
||||
dom="${dom_host%%:*}"
|
||||
host="${dom_host##*:}"
|
||||
addr="$(virsh domifaddr "$dom" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | head -n1 || true)"
|
||||
if [ -z "$addr" ]; then
|
||||
info "$dom: no IP yet — skipping connectivity check"
|
||||
continue
|
||||
fi
|
||||
result="$(ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 -i "$SC_SSH_KEY" "player@$addr" hostname 2>/dev/null || echo FAIL)"
|
||||
if [ "$result" = "FAIL" ] || [ -z "$result" ]; then
|
||||
fail "$dom ($host): 'hostname' failed — check inetutils and shell PATH provisioning"
|
||||
fi
|
||||
ok "$dom ($host): hostname=$result"
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STEP 2 — Suppress guest maintenance noise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Suppressing guest maintenance noise"
|
||||
info "Tuning base images to suppress package manager notices..."
|
||||
for dom in sc-workstation sc-web-server sc-build-machine; do
|
||||
domain_selected "$dom" || continue
|
||||
if virsh dominfo "$dom" &>/dev/null 2>&1 || [ "$DRY_RUN" = "true" ]; then
|
||||
run bash "$VM_TOOLS/suppress-maintenance-noise.sh" "$dom" \
|
||||
$([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
ok "$dom: maintenance noise suppressed"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STEP 3 — Install guest helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Installing guest helpers"
|
||||
info "Guest helpers are non-authoritative — advisory signals only."
|
||||
for dom in sc-workstation sc-web-server sc-build-machine; do
|
||||
domain_selected "$dom" || continue
|
||||
if virsh dominfo "$dom" &>/dev/null 2>&1 || [ "$DRY_RUN" = "true" ]; then
|
||||
run bash "$VM_TOOLS/install-guest-helper.sh" "$dom" \
|
||||
$([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
ok "$dom: guest helper installed"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STEP 4 — Run quest-prep scripts and snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "Running quest-prep scripts and snapshotting"
|
||||
|
||||
run_prep_and_snapshot() {
|
||||
local quest_id="$1"
|
||||
local domain="$2"
|
||||
local snapshot_name="$3"
|
||||
local prep_script="$QUEST_PREP/${quest_id}-prep.sh"
|
||||
|
||||
if [ ! -f "$prep_script" ]; then
|
||||
echo " ⚠ No prep script found for $quest_id — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Running $quest_id prep on $domain..."
|
||||
run bash "$prep_script" "$domain" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
|
||||
info "Taking snapshot '$snapshot_name' on $domain..."
|
||||
run virsh snapshot-delete "$domain" "$snapshot_name" >/dev/null 2>&1 || true
|
||||
run virsh snapshot-create-as "$domain" "$snapshot_name" \
|
||||
--description "${quest_id} baseline — created by seed-vms.sh" \
|
||||
--atomic
|
||||
ok "$domain → $snapshot_name"
|
||||
}
|
||||
|
||||
run_post_clean_and_snapshot() {
|
||||
local quest_id="$1"
|
||||
local domain="$2"
|
||||
local snapshot_name="$3"
|
||||
local clean_script="$QUEST_PREP/${quest_id}-post-clean.sh"
|
||||
|
||||
if [ ! -f "$clean_script" ]; then
|
||||
echo " ⚠ No post-clean script found for $quest_id — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Applying ${quest_id} clean branch state on $domain..."
|
||||
run bash "$clean_script" "$domain" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
|
||||
|
||||
info "Taking snapshot '$snapshot_name' on $domain..."
|
||||
run virsh snapshot-delete "$domain" "$snapshot_name" >/dev/null 2>&1 || true
|
||||
run virsh snapshot-create-as "$domain" "$snapshot_name" \
|
||||
--description "${quest_id} clean branch baseline — created by seed-vms.sh" \
|
||||
--atomic
|
||||
ok "$domain → $snapshot_name"
|
||||
}
|
||||
|
||||
# Q001: workstation day-one state
|
||||
if vm_selected "workstation"; then
|
||||
run_prep_and_snapshot "Q001" "sc-workstation" "baseline.day-one"
|
||||
fi
|
||||
|
||||
# Q002–Q004 share hermes clean baseline; prep scripts layer on top
|
||||
if vm_selected "web_server"; then
|
||||
run_prep_and_snapshot "Q002" "sc-web-server" "baseline.clean"
|
||||
run_prep_and_snapshot "Q003" "sc-web-server" "baseline.post-q002"
|
||||
run_prep_and_snapshot "Q004" "sc-web-server" "baseline.post-q003"
|
||||
fi
|
||||
|
||||
# Q005 and Q007 use post-q004 baseline
|
||||
if vm_selected "web_server"; then
|
||||
info "Creating baseline.post-q004 snapshot (used by Q005, Q007)..."
|
||||
run virsh snapshot-delete "sc-web-server" "baseline.post-q004" >/dev/null 2>&1 || true
|
||||
run virsh snapshot-create-as "sc-web-server" "baseline.post-q004" \
|
||||
--description "Post-Q004 baseline" --atomic || true
|
||||
fi
|
||||
|
||||
# Q006: build machine broken baseline, then authored clean handoff for later quests
|
||||
if vm_selected "build_machine"; then
|
||||
run_prep_and_snapshot "Q006" "sc-build-machine" "baseline.clean"
|
||||
run_post_clean_and_snapshot "Q006" "sc-build-machine" "baseline.post-q006"
|
||||
fi
|
||||
|
||||
info "Q008 remains a multi-VM authored-state gap and is not provisioned by seed-vms.sh yet."
|
||||
|
||||
# Take recovery snapshots (always available as fallback)
|
||||
step "Creating recovery snapshots"
|
||||
for dom in sc-workstation sc-web-server sc-build-machine; do
|
||||
domain_selected "$dom" || continue
|
||||
info "Creating baseline.recovery on $dom..."
|
||||
run virsh snapshot-delete "$dom" "baseline.recovery" >/dev/null 2>&1 || true
|
||||
run virsh snapshot-create-as "$dom" "baseline.recovery" \
|
||||
--description "Recovery fallback — created by seed-vms.sh" \
|
||||
--atomic || true
|
||||
ok "$dom → baseline.recovery"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Seed pipeline complete."
|
||||
echo " Verify with: bash tools/setup/check-host.sh"
|
||||
echo " Run content validation: bash tools/dev/test-content.sh"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# uninstall.sh — Remove all Sysadmin Chronicles game-owned host resources.
|
||||
#
|
||||
# Removes:
|
||||
# - sc- prefixed libvirt VM domains (after confirmation)
|
||||
# - sc- prefixed libvirt networks
|
||||
# - sc-images storage pool and its directory
|
||||
# - SSH key pair (~/.ssh/sc_host_key)
|
||||
#
|
||||
# Does NOT remove:
|
||||
# - The game directory itself (remove manually if desired)
|
||||
# - Any system-wide libvirt config
|
||||
# - Any resources not prefixed with sc-
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/setup/uninstall.sh
|
||||
# bash tools/setup/uninstall.sh --dry-run
|
||||
# bash tools/setup/uninstall.sh --yes (skip confirmation)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
ASSUME_YES=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--yes) ASSUME_YES=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "true" ]; then echo " [DRY-RUN] $*"; else "$@"; fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Sysadmin Chronicles — Uninstall"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode — no changes]"
|
||||
echo ""
|
||||
echo " This will PERMANENTLY remove all sc- prefixed VMs,"
|
||||
echo " networks, storage, and SSH keys."
|
||||
echo ""
|
||||
|
||||
if [ "$ASSUME_YES" = "false" ] && [ "$DRY_RUN" = "false" ]; then
|
||||
read -rp " Type YES to confirm uninstall: " confirm
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
echo " Aborted."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove VMs
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "── Removing VM domains ──────────────────────────"
|
||||
for domain in $(virsh list --all --name 2>/dev/null | grep "^sc-" || true); do
|
||||
echo " Removing $domain..."
|
||||
# Stop if running
|
||||
if virsh domstate "$domain" 2>/dev/null | grep -q "running"; then
|
||||
run virsh destroy "$domain"
|
||||
fi
|
||||
# Remove all snapshots
|
||||
for snap in $(virsh snapshot-list "$domain" --name 2>/dev/null || true); do
|
||||
run virsh snapshot-delete "$domain" "$snap"
|
||||
done
|
||||
run virsh undefine "$domain" --remove-all-storage
|
||||
echo " ✓ $domain removed"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove networks
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "── Removing sc- networks ─────────────────────────"
|
||||
for net in $(virsh net-list --all --name 2>/dev/null | grep "^sc-" || true); do
|
||||
echo " Removing network $net..."
|
||||
if virsh net-info "$net" 2>/dev/null | grep -q "Active:.*yes"; then
|
||||
run virsh net-destroy "$net"
|
||||
fi
|
||||
run virsh net-undefine "$net"
|
||||
echo " ✓ $net removed"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove storage pool
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "── Removing sc-images storage pool ──────────────"
|
||||
if virsh pool-list --all | grep -q "sc-images"; then
|
||||
POOL_PATH=$(virsh pool-dumpxml sc-images 2>/dev/null | grep -oP '(?<=<path>)[^<]+' || echo "")
|
||||
if virsh pool-info sc-images 2>/dev/null | grep -q "State:.*running"; then
|
||||
run virsh pool-destroy sc-images
|
||||
fi
|
||||
run virsh pool-undefine sc-images
|
||||
if [ -n "$POOL_PATH" ] && [ -d "$POOL_PATH" ]; then
|
||||
run rm -rf "$POOL_PATH"
|
||||
fi
|
||||
echo " ✓ sc-images pool removed"
|
||||
else
|
||||
echo " (sc-images pool not found — skipping)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove SSH keys
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "── Removing SSH keys ─────────────────────────────"
|
||||
KEY="$HOME/.ssh/sc_host_key"
|
||||
if [ -f "$KEY" ]; then
|
||||
run rm -f "$KEY" "${KEY}.pub"
|
||||
echo " ✓ SSH keys removed"
|
||||
else
|
||||
echo " (No sc_host_key found — skipping)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Uninstall complete."
|
||||
echo " Game files (this directory) were not removed."
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper — delegates to the modular build-vm.sh driver.
|
||||
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/build-machine.sh" "$@"
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-vm.sh — Modular VM builder. Sources a profile file that declares VM
|
||||
# variables and a generate_user_data() function, then runs the common build
|
||||
# pipeline against it.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-vm.sh <profile> [--dry-run] [--force]
|
||||
#
|
||||
# Example:
|
||||
# ./build-vm.sh profiles/web-server.sh --force
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <profile> [--dry-run] [--force]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROFILE_ARG="$1"; shift
|
||||
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
source "$PROJECT_ROOT/tools/lib/config.sh"
|
||||
config_read || true
|
||||
if [ -n "${SC_IMAGES_DIR:-}" ]; then
|
||||
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-$SC_IMAGES_DIR}"
|
||||
export SC_IMAGE_ROOT
|
||||
fi
|
||||
if [ -n "${SC_LIBVIRT_URI:-}" ]; then
|
||||
LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-$SC_LIBVIRT_URI}"
|
||||
export LIBVIRT_DEFAULT_URI
|
||||
fi
|
||||
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
# Resolve profile path: bare name (e.g. "web-server") or explicit path.
|
||||
if [[ -f "$PROFILE_ARG" ]]; then
|
||||
PROFILE="$PROFILE_ARG"
|
||||
elif [[ -f "$SCRIPT_DIR/profiles/${PROFILE_ARG}.sh" ]]; then
|
||||
PROFILE="$SCRIPT_DIR/profiles/${PROFILE_ARG}.sh"
|
||||
elif [[ -f "$SCRIPT_DIR/profiles/${PROFILE_ARG}" ]]; then
|
||||
PROFILE="$SCRIPT_DIR/profiles/${PROFILE_ARG}"
|
||||
else
|
||||
echo "Profile not found: $PROFILE_ARG"
|
||||
echo "Available profiles:"
|
||||
ls "$SCRIPT_DIR/profiles/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$PROFILE"
|
||||
|
||||
# Validate required profile variables.
|
||||
for var in DOMAIN HOSTNAME RAM_MB VCPUS DISK_SIZE GRAPHICS BASE_URL BASE_IMAGE; do
|
||||
[[ -n "${!var:-}" ]] || { echo "Profile must set $var"; exit 1; }
|
||||
done
|
||||
declare -f generate_user_data >/dev/null || { echo "Profile must define generate_user_data()"; exit 1; }
|
||||
|
||||
GAME_HOST_IP="${SC_GAME_HOST_IP:-10.42.0.1}"
|
||||
POOL_DIR="$(pool_path)"
|
||||
DISK_PATH="$POOL_DIR/${DOMAIN}.qcow2"
|
||||
SEED_ISO="$SC_SEED_DIR/${DOMAIN}-seed.iso"
|
||||
PUBKEY="$(<"${SC_SSH_KEY}.pub")"
|
||||
|
||||
export DOMAIN HOSTNAME RAM_MB VCPUS DISK_SIZE GRAPHICS BASE_URL BASE_IMAGE
|
||||
export GAME_HOST_IP POOL_DIR DISK_PATH SEED_ISO PUBKEY
|
||||
|
||||
ensure_vm_tooling
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Building VM: $DOMAIN ($HOSTNAME)"
|
||||
echo " Profile: $(basename "$PROFILE")"
|
||||
echo " RAM: ${RAM_MB} MB vCPUs: ${VCPUS} Disk: ${DISK_SIZE}"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
|
||||
if domain_exists "$DOMAIN" && [ "$FORCE" = "false" ]; then
|
||||
ok "$DOMAIN already exists. Use --force to rebuild it."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "Preparing base image"
|
||||
download_if_missing "$BASE_URL" "$BASE_IMAGE"
|
||||
|
||||
step "Preparing cloud-init seed"
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
generate_user_data > "$tmpdir/user-data"
|
||||
|
||||
cat > "$tmpdir/meta-data" <<EOF
|
||||
instance-id: ${DOMAIN}
|
||||
local-hostname: ${HOSTNAME}
|
||||
EOF
|
||||
|
||||
create_seed_iso "$tmpdir/user-data" "$tmpdir/meta-data" "$SEED_ISO"
|
||||
|
||||
step "Building domain"
|
||||
destroy_domain "$DOMAIN"
|
||||
create_backing_disk "$BASE_IMAGE" "$DISK_PATH" "$DISK_SIZE"
|
||||
build_import_domain "$DOMAIN" "$DISK_PATH" "$SEED_ISO" "$RAM_MB" "$VCPUS" "$GRAPHICS"
|
||||
|
||||
step "Waiting for guest networking"
|
||||
guest_addr=""
|
||||
if [ "$DRY_RUN" = "false" ]; then
|
||||
if guest_addr="$(wait_for_agent_ip "$DOMAIN" 300)"; then
|
||||
ok "$DOMAIN is reachable at $guest_addr"
|
||||
else
|
||||
info "Guest IP not available yet. First boot may still be running cloud-init."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "false" ] && [ -n "${READY_COMMAND:-}" ]; then
|
||||
step "Waiting for guest readiness"
|
||||
if [ -n "$guest_addr" ]; then
|
||||
if [ -n "${READY_WATCH_TEMPLATE:-}" ]; then
|
||||
watch_command="${READY_WATCH_TEMPLATE//\{ADDR\}/$guest_addr}"
|
||||
info "Watch live progress in another terminal:"
|
||||
info "$watch_command"
|
||||
fi
|
||||
fi
|
||||
if wait_for_guest_command "$DOMAIN" "${READY_TIMEOUT:-600}" "$READY_COMMAND" "${READY_PROGRESS_COMMAND:-}" "${READY_PROGRESS_EVERY_SEC:-30}"; then
|
||||
ok "$DOMAIN passed readiness check"
|
||||
detach_seed_iso "$DOMAIN" "$SEED_ISO"
|
||||
else
|
||||
fail "$DOMAIN did not pass readiness check within ${READY_TIMEOUT:-600}s"
|
||||
fi
|
||||
fi
|
||||
|
||||
ok "$DOMAIN build complete"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper — delegates to the modular build-vm.sh driver.
|
||||
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/web-server.sh" "$@"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper — delegates to the modular build-vm.sh driver.
|
||||
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/workstation.sh" "$@"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-guest-helper.sh — Install the advisory guest helper onto a VM.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DRY_RUN=false
|
||||
DOMAIN="${1:-}"
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
echo "Usage: bash tools/vm/install-guest-helper.sh <domain> [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${2:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
helper_name=""
|
||||
case "$DOMAIN" in
|
||||
sc-workstation) helper_name="atlas-index" ;;
|
||||
sc-web-server) helper_name="yardd" ;;
|
||||
sc-build-machine) helper_name="ops-telemetry-cache" ;;
|
||||
*) echo "Unknown domain: $DOMAIN"; exit 1 ;;
|
||||
esac
|
||||
|
||||
ensure_vm_tooling
|
||||
|
||||
tmp_script="$(mktemp)"
|
||||
cat > "$tmp_script" <<EOF
|
||||
cat > /usr/local/bin/${helper_name} <<'HELPER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '{"helper":"%s","hostname":"%s","timestamp":"%s"}\n' \
|
||||
"${helper_name}" \
|
||||
"\$(hostname)" \
|
||||
"\$(date -Iseconds)"
|
||||
HELPER
|
||||
chmod 755 /usr/local/bin/${helper_name}
|
||||
EOF
|
||||
|
||||
info "Installing guest helper ${helper_name} on ${DOMAIN}"
|
||||
guest_run_sudo_script "$DOMAIN" "$tmp_script"
|
||||
rm -f "$tmp_script"
|
||||
ok "${DOMAIN}: helper ${helper_name} installed"
|
||||
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VM_TOOLS_DIR="$(cd "$COMMON_DIR/.." && pwd)"
|
||||
PROJECT_ROOT="$(cd "$VM_TOOLS_DIR/../.." && pwd)"
|
||||
SC_OWNER_USER="${SC_OWNER_USER:-${SUDO_USER:-$USER}}"
|
||||
SC_OWNER_HOME="${SC_OWNER_HOME:-$(getent passwd "$SC_OWNER_USER" | cut -d: -f6)}"
|
||||
SC_OWNER_HOME="${SC_OWNER_HOME:-$HOME}"
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
if [ "${LIBVIRT_DEFAULT_URI}" = "qemu:///system" ]; then
|
||||
SC_HOME="${SC_HOME:-/var/lib/libvirt/sysadmin-chronicles}"
|
||||
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-/var/lib/libvirt/images/sysadmin-chronicles}"
|
||||
else
|
||||
SC_HOME="${SC_HOME:-$SC_OWNER_HOME/.local/share/sysadmin-chronicles}"
|
||||
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-$SC_HOME/images}"
|
||||
fi
|
||||
SC_BASE_DIR="$SC_IMAGE_ROOT/base"
|
||||
SC_SEED_DIR="$SC_IMAGE_ROOT/seed"
|
||||
SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
|
||||
SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-$SC_OWNER_HOME/.ssh/sc_host_key}"
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
|
||||
_virsh() {
|
||||
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
|
||||
sudo virsh "$@"
|
||||
else
|
||||
virsh "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
_virt_install() {
|
||||
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
|
||||
sudo virt-install "$@"
|
||||
else
|
||||
virt-install "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
step() { echo ""; echo "── $* ───────────────────────────────────────"; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
info() { echo " → $*"; }
|
||||
fail() { echo " ✗ $*"; exit 1; }
|
||||
|
||||
ssh_login_user() {
|
||||
local domain="${1:-}"
|
||||
case "$domain" in
|
||||
sc-workstation) printf '%s\n' "opsbridge" ;;
|
||||
*) printf '%s\n' "player" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
||||
}
|
||||
|
||||
ensure_vm_tooling() {
|
||||
require_cmd virsh
|
||||
require_cmd qemu-img
|
||||
require_cmd curl
|
||||
require_cmd virt-install
|
||||
|
||||
if ! command -v cloud-localds >/dev/null 2>&1 \
|
||||
&& ! command -v genisoimage >/dev/null 2>&1 \
|
||||
&& ! command -v mkisofs >/dev/null 2>&1 \
|
||||
&& ! command -v xorriso >/dev/null 2>&1; then
|
||||
fail "Need cloud-localds, genisoimage, mkisofs, or xorriso to build NoCloud seed images"
|
||||
fi
|
||||
|
||||
[ -f "$SC_SSH_KEY" ] || fail "Missing SSH private key: $SC_SSH_KEY"
|
||||
[ -f "${SC_SSH_KEY}.pub" ] || fail "Missing SSH public key: ${SC_SSH_KEY}.pub"
|
||||
|
||||
run mkdir -p "$SC_BASE_DIR" "$SC_SEED_DIR"
|
||||
|
||||
_virsh pool-info "$SC_POOL_NAME" >/dev/null 2>&1 || fail "Missing libvirt pool: $SC_POOL_NAME"
|
||||
_virsh net-info "$SC_NETWORK_NAME" >/dev/null 2>&1 || fail "Missing libvirt network: $SC_NETWORK_NAME"
|
||||
}
|
||||
|
||||
pool_path() {
|
||||
local path
|
||||
path="$(_virsh pool-dumpxml "$SC_POOL_NAME" | sed -n 's:.*<path>\(.*\)</path>.*:\1:p' | head -n1)"
|
||||
[ -n "$path" ] || fail "Could not determine pool path for $SC_POOL_NAME"
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
domain_exists() {
|
||||
local domain="$1"
|
||||
_virsh dominfo "$domain" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
download_if_missing() {
|
||||
local url="$1"
|
||||
local dest="$2"
|
||||
if [ -f "$dest" ]; then
|
||||
ok "Using cached base image: $(basename "$dest")"
|
||||
return
|
||||
fi
|
||||
info "Downloading $(basename "$dest")"
|
||||
run curl -L --fail --output "$dest" "$url"
|
||||
}
|
||||
|
||||
create_backing_disk() {
|
||||
local base_image="$1"
|
||||
local target_disk="$2"
|
||||
local disk_size="${3:-}"
|
||||
run mkdir -p "$(dirname "$target_disk")"
|
||||
run rm -f "$target_disk"
|
||||
if [ -n "$disk_size" ]; then
|
||||
run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk" "$disk_size"
|
||||
else
|
||||
run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk"
|
||||
fi
|
||||
}
|
||||
|
||||
create_seed_iso() {
|
||||
local user_data="$1"
|
||||
local meta_data="$2"
|
||||
local output_iso="$3"
|
||||
local seed_dir
|
||||
seed_dir="$(mktemp -d)"
|
||||
cp "$user_data" "$seed_dir/user-data"
|
||||
cp "$meta_data" "$seed_dir/meta-data"
|
||||
run rm -f "$output_iso"
|
||||
|
||||
if command -v cloud-localds >/dev/null 2>&1; then
|
||||
run cloud-localds "$output_iso" "$seed_dir/user-data" "$seed_dir/meta-data"
|
||||
elif command -v genisoimage >/dev/null 2>&1; then
|
||||
run genisoimage -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
|
||||
elif command -v mkisofs >/dev/null 2>&1; then
|
||||
run mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
|
||||
else
|
||||
run xorriso -as mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
|
||||
fi
|
||||
|
||||
rm -rf "$seed_dir"
|
||||
}
|
||||
|
||||
destroy_domain() {
|
||||
local domain="$1"
|
||||
if ! domain_exists "$domain"; then
|
||||
return
|
||||
fi
|
||||
info "Removing existing domain definition: $domain"
|
||||
run _virsh destroy "$domain" >/dev/null 2>&1 || true
|
||||
run _virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \
|
||||
|| run _virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \
|
||||
|| run _virsh undefine "$domain" --nvram >/dev/null 2>&1 \
|
||||
|| run _virsh undefine "$domain" >/dev/null 2>&1 \
|
||||
|| true
|
||||
}
|
||||
|
||||
build_import_domain() {
|
||||
local domain="$1"
|
||||
local disk_path="$2"
|
||||
local seed_iso="$3"
|
||||
local ram_mb="$4"
|
||||
local vcpus="$5"
|
||||
local graphics_mode="$6"
|
||||
|
||||
local args=(
|
||||
--name "$domain"
|
||||
--memory "$ram_mb"
|
||||
--vcpus "$vcpus"
|
||||
--import
|
||||
--disk "path=$disk_path,format=qcow2,bus=virtio"
|
||||
--disk "path=$seed_iso,device=cdrom"
|
||||
--network "network=$SC_NETWORK_NAME,model=virtio"
|
||||
--channel "unix,target_type=virtio,name=org.qemu.guest_agent.0"
|
||||
--rng /dev/urandom
|
||||
--osinfo detect=on,require=off
|
||||
--noautoconsole
|
||||
)
|
||||
|
||||
case "$graphics_mode" in
|
||||
none)
|
||||
args+=(--graphics none --console pty,target_type=serial)
|
||||
;;
|
||||
vnc)
|
||||
args+=(--graphics vnc,listen=127.0.0.1)
|
||||
;;
|
||||
spice)
|
||||
args+=(
|
||||
--graphics spice,listen=127.0.0.1
|
||||
--video virtio
|
||||
--channel "spicevmc,target_type=virtio,name=com.redhat.spice.0"
|
||||
)
|
||||
;;
|
||||
spice-qxl)
|
||||
args+=(
|
||||
--graphics spice,listen=127.0.0.1
|
||||
--video qxl
|
||||
--channel "spicevmc,target_type=virtio,name=com.redhat.spice.0"
|
||||
)
|
||||
;;
|
||||
*)
|
||||
fail "Unknown graphics mode: $graphics_mode"
|
||||
;;
|
||||
esac
|
||||
|
||||
run _virt_install "${args[@]}"
|
||||
run _virsh autostart "$domain"
|
||||
}
|
||||
|
||||
seed_cdrom_target() {
|
||||
local domain="$1"
|
||||
local seed_iso="$2"
|
||||
_virsh dumpxml "$domain" 2>/dev/null \
|
||||
| awk -v seed="$seed_iso" '
|
||||
/<disk / { in_disk=1; target="" }
|
||||
in_disk && /<source file=/ && index($0, seed) { matched=1 }
|
||||
in_disk && /<target dev=/ {
|
||||
line=$0
|
||||
sub(/.*<target dev=.?/, "", line)
|
||||
sub(/[ '\'"'"'"].*/, "", line)
|
||||
target=line
|
||||
}
|
||||
in_disk && /<\/disk>/ {
|
||||
if (matched && target != "") {
|
||||
print target
|
||||
exit
|
||||
}
|
||||
in_disk=0
|
||||
matched=0
|
||||
target=""
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
detach_seed_iso() {
|
||||
local domain="$1"
|
||||
local seed_iso="$2"
|
||||
local target
|
||||
|
||||
target="$(seed_cdrom_target "$domain" "$seed_iso" || true)"
|
||||
if [ -z "$target" ]; then
|
||||
info "No cloud-init seed ISO attached to $domain"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Detaching cloud-init seed ISO from $domain ($target)"
|
||||
if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then
|
||||
run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true
|
||||
run _virsh detach-disk "$domain" "$target" --live >/dev/null 2>&1 || true
|
||||
if seed_cdrom_target "$domain" "$seed_iso" >/dev/null 2>&1; then
|
||||
info "Restarting $domain to apply cloud-init seed ISO detach"
|
||||
run _virsh shutdown "$domain" >/dev/null 2>&1 || true
|
||||
local waited=0
|
||||
while [ "$waited" -lt 60 ] && _virsh domstate "$domain" 2>/dev/null | grep -qi running; do
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then
|
||||
run _virsh destroy "$domain" >/dev/null 2>&1 || true
|
||||
fi
|
||||
run _virsh start "$domain" >/dev/null 2>&1
|
||||
wait_for_agent_ip "$domain" 180 >/dev/null || true
|
||||
fi
|
||||
else
|
||||
run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
domain_mac() {
|
||||
local domain="$1"
|
||||
_virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\([^']*\)'.*/\1/p" | head -n1
|
||||
}
|
||||
|
||||
dhcp_lease_ip() {
|
||||
local domain="$1"
|
||||
local mac
|
||||
mac="$(domain_mac "$domain")"
|
||||
[ -n "$mac" ] || return 1
|
||||
_virsh net-dhcp-leases "$SC_NETWORK_NAME" 2>/dev/null \
|
||||
| awk -v mac="$mac" '$0 ~ mac {print $5}' \
|
||||
| cut -d/ -f1 \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
valid_guest_ip() {
|
||||
local addr="${1:-}"
|
||||
[[ -n "$addr" ]] || return 1
|
||||
[[ "$addr" != 127.* ]] || return 1
|
||||
[[ "$addr" != "0.0.0.0" ]] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_for_agent_ip() {
|
||||
local domain="$1"
|
||||
local timeout_sec="${2:-300}"
|
||||
local waited=0
|
||||
|
||||
while [ "$waited" -lt "$timeout_sec" ]; do
|
||||
local addr
|
||||
addr="$(_virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | head -n1 || true)"
|
||||
if ! valid_guest_ip "$addr"; then
|
||||
addr=""
|
||||
fi
|
||||
if [ -z "$addr" ]; then
|
||||
addr="$(dhcp_lease_ip "$domain" || true)"
|
||||
fi
|
||||
if valid_guest_ip "$addr"; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
local domain="$1"
|
||||
local timeout_sec="${2:-180}"
|
||||
local waited=0
|
||||
local login_user
|
||||
login_user="$(ssh_login_user "$domain")"
|
||||
|
||||
while [ "$waited" -lt "$timeout_sec" ]; do
|
||||
local addr
|
||||
addr="$(wait_for_agent_ip "$domain" 10 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
if ssh_base_args "$addr" "$login_user" true >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_guest_command() {
|
||||
local domain="$1"
|
||||
local timeout_sec="$2"
|
||||
local command="$3"
|
||||
local progress_command="${4:-}"
|
||||
local progress_every_sec="${5:-30}"
|
||||
local waited=0
|
||||
local last_progress=-9999
|
||||
local login_user
|
||||
login_user="$(ssh_login_user "$domain")"
|
||||
|
||||
while [ "$waited" -lt "$timeout_sec" ]; do
|
||||
local addr
|
||||
addr="$(wait_for_agent_ip "$domain" 10 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
if ssh_base_args "$addr" "$login_user" "$command" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$progress_command" ] && [ $((waited - last_progress)) -ge "$progress_every_sec" ]; then
|
||||
last_progress="$waited"
|
||||
info "Guest progress for $domain:"
|
||||
ssh_base_args "$addr" "$login_user" "$progress_command" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ssh_base_args() {
|
||||
local host="$1"
|
||||
local login_user="${2:-player}"
|
||||
shift
|
||||
shift || true
|
||||
ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=5 \
|
||||
-o LogLevel=ERROR \
|
||||
-i "$SC_SSH_KEY" \
|
||||
"${login_user}@${host}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
guest_run() {
|
||||
local domain="$1"
|
||||
shift
|
||||
wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain"
|
||||
local addr
|
||||
addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain"
|
||||
local login_user
|
||||
login_user="$(ssh_login_user "$domain")"
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN][SSH $domain@$addr] $*"
|
||||
return 0
|
||||
fi
|
||||
ssh_base_args "$addr" "$login_user" "$@"
|
||||
}
|
||||
|
||||
guest_run_sudo_script() {
|
||||
local domain="$1"
|
||||
local script_file="$2"
|
||||
wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain"
|
||||
local addr
|
||||
addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain"
|
||||
local login_user
|
||||
login_user="$(ssh_login_user "$domain")"
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN][SSH $domain@$addr] sudo bash -s < $script_file"
|
||||
return 0
|
||||
fi
|
||||
ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=5 \
|
||||
-o LogLevel=ERROR \
|
||||
-i "$SC_SSH_KEY" \
|
||||
"${login_user}@${addr}" "sudo bash -s" < "$script_file"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<network>
|
||||
<name>sc-internal</name>
|
||||
<forward mode='nat'/>
|
||||
<bridge name='sc-br0' stp='on' delay='0'/>
|
||||
<ip address='10.42.0.1' netmask='255.255.255.0'>
|
||||
<dhcp>
|
||||
<range start='10.42.0.10' end='10.42.0.50'/>
|
||||
<!-- Fixed reservations — must match /etc/hosts in each VM profile -->
|
||||
<host mac='52:54:00:49:9b:64' name='hermes' ip='10.42.0.40'/>
|
||||
<host mac='52:54:00:5e:9f:b9' name='vulcan' ip='10.42.0.24'/>
|
||||
<host mac='52:54:00:bd:aa:29' name='ares' ip='10.42.0.36'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# Profile: sc-build-machine (vulcan)
|
||||
# Role: Arch Linux build machine — compiles AxiomFlow artifacts, runs scheduled
|
||||
# jobs, deploys to hermes. Intentionally different distro from Debian servers.
|
||||
# Distro: Arch Linux cloud image
|
||||
|
||||
DOMAIN="sc-build-machine"
|
||||
HOSTNAME="vulcan"
|
||||
RAM_MB=768
|
||||
VCPUS=2
|
||||
DISK_SIZE="10G"
|
||||
GRAPHICS="vnc"
|
||||
BASE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2"
|
||||
BASE_IMAGE="$SC_BASE_DIR/Arch-Linux-x86_64-cloudimg.qcow2"
|
||||
|
||||
generate_user_data() {
|
||||
cat <<EOF
|
||||
#cloud-config
|
||||
hostname: ${HOSTNAME}
|
||||
fqdn: ${HOSTNAME}.axiomworks.internal
|
||||
manage_etc_hosts: false
|
||||
ssh_pwauth: false
|
||||
users:
|
||||
- default
|
||||
- name: player
|
||||
gecos: Axiom Works Builder
|
||||
groups: [wheel]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
ssh_authorized_keys:
|
||||
- ${PUBKEY}
|
||||
write_files:
|
||||
- path: /etc/hosts
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 vulcan vulcan.axiomworks.internal
|
||||
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal
|
||||
10.42.0.40 hermes hermes.axiomworks.internal
|
||||
- path: /etc/sudoers.d/99-player
|
||||
owner: root:root
|
||||
permissions: '0440'
|
||||
content: |
|
||||
player ALL=(ALL) NOPASSWD:ALL
|
||||
- path: /etc/sysctl.d/99-sc-vulcan.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
vm.swappiness=10
|
||||
vm.vfs_cache_pressure=50
|
||||
vm.dirty_ratio=25
|
||||
vm.dirty_background_ratio=5
|
||||
net.ipv6.conf.all.disable_ipv6=1
|
||||
net.ipv6.conf.default.disable_ipv6=1
|
||||
- path: /home/player/.bashrc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[ -z "\$PS1" ] && return
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
|
||||
export TERM=xterm-256color
|
||||
export EDITOR=vim
|
||||
PS1='\[\e[0;35m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
|
||||
HISTSIZE=5000
|
||||
HISTFILESIZE=10000
|
||||
HISTCONTROL=ignoredups:erasedups
|
||||
shopt -s histappend
|
||||
alias ll='ls -lh --color=auto'
|
||||
alias la='ls -lha --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias ..='cd ..'
|
||||
alias pacs='pacman -Ss'
|
||||
alias paci='sudo pacman -S'
|
||||
alias pacq='pacman -Qi'
|
||||
if [ -f /usr/share/bash-completion/bash_completion ]; then
|
||||
. /usr/share/bash-completion/bash_completion
|
||||
fi
|
||||
- path: /home/player/.bash_profile
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[[ -f ~/.bashrc ]] && . ~/.bashrc
|
||||
runcmd:
|
||||
- pacman -Sy --noconfirm archlinux-keyring
|
||||
- pacman -Su --noconfirm
|
||||
- pacman -S --noconfirm --needed sudo openssh qemu-guest-agent base-devel git inetutils iproute2 curl wget rsync vim nano htop python python-pip jq less tree unzip tcpdump lsof strace bind-tools openbsd-netcat bash-completion
|
||||
- systemctl enable qemu-guest-agent sshd
|
||||
- systemctl start qemu-guest-agent sshd
|
||||
- mkdir -p /srv/repo /srv/builds /var/log/axiomworks
|
||||
- printf 'vulcan — AxiomFlow build machine\n' > /srv/repo/README.txt
|
||||
- dd if=/dev/zero of=/swapfile bs=1M count=1024 status=progress
|
||||
- chmod 600 /swapfile
|
||||
- mkswap /swapfile
|
||||
- swapon /swapfile
|
||||
- echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
- sysctl -p /etc/sysctl.d/99-sc-vulcan.conf
|
||||
- chown -R player:player /home/player /srv/repo /srv/builds
|
||||
- systemctl disable ModemManager || true
|
||||
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
|
||||
final_message: "Vulcan build machine is ready."
|
||||
EOF
|
||||
}
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# Profile: sc-web-server (hermes)
|
||||
# Role: nginx web/app server — staging and demo environment for AxiomFlow.
|
||||
# Distro: Debian 12 (bookworm) cloud image
|
||||
|
||||
DOMAIN="sc-web-server"
|
||||
HOSTNAME="hermes"
|
||||
RAM_MB=512
|
||||
VCPUS=1
|
||||
DISK_SIZE="8G"
|
||||
GRAPHICS="vnc"
|
||||
BASE_URL="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
|
||||
BASE_IMAGE="$SC_BASE_DIR/debian-12-genericcloud-amd64.qcow2"
|
||||
|
||||
generate_user_data() {
|
||||
cat <<EOF
|
||||
#cloud-config
|
||||
hostname: ${HOSTNAME}
|
||||
fqdn: ${HOSTNAME}.axiomworks.internal
|
||||
manage_etc_hosts: false
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
packages:
|
||||
- qemu-guest-agent
|
||||
- openssh-server
|
||||
- sudo
|
||||
- nginx
|
||||
- logrotate
|
||||
- rsync
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- python3
|
||||
- jq
|
||||
- vim
|
||||
- nano
|
||||
- htop
|
||||
- procps
|
||||
- psmisc
|
||||
- iproute2
|
||||
- iputils-ping
|
||||
- dnsutils
|
||||
- netcat-openbsd
|
||||
- tcpdump
|
||||
- lsof
|
||||
- strace
|
||||
- less
|
||||
- tree
|
||||
- unzip
|
||||
- bash-completion
|
||||
users:
|
||||
- default
|
||||
- name: player
|
||||
gecos: Axiom Works Operator
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
ssh_authorized_keys:
|
||||
- ${PUBKEY}
|
||||
write_files:
|
||||
- path: /etc/hosts
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 hermes hermes.axiomworks.internal
|
||||
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal
|
||||
- path: /etc/sudoers.d/99-player
|
||||
owner: root:root
|
||||
permissions: '0440'
|
||||
content: |
|
||||
player ALL=(ALL) NOPASSWD:ALL
|
||||
- path: /etc/nginx/sites-available/axiomworks.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name hermes hermes.axiomworks.internal _;
|
||||
|
||||
root /var/www/axiomworks;
|
||||
index index.html;
|
||||
|
||||
access_log /var/log/nginx/axiomworks.access.log;
|
||||
error_log /var/log/nginx/axiomworks.error.log;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ =404;
|
||||
}
|
||||
}
|
||||
- path: /var/www/axiomworks/index.html
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
<!doctype html>
|
||||
<html><head><title>AxiomFlow</title></head>
|
||||
<body><h1>AxiomFlow Staging</h1><p>Build not yet deployed.</p></body>
|
||||
</html>
|
||||
- path: /opt/deploy/deploy.sh
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SRC="\${1:-/home/player/build/dist}"
|
||||
rsync -av --delete "\$SRC/" /var/www/axiomworks/
|
||||
echo "\$(date) Deploy from \$SRC complete." >> /var/log/axiomworks/deploy.log
|
||||
- path: /home/player/.bashrc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[ -z "\$PS1" ] && return
|
||||
export TERM=xterm-256color
|
||||
export EDITOR=vim
|
||||
PS1='\[\e[0;33m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
|
||||
HISTSIZE=5000
|
||||
HISTFILESIZE=10000
|
||||
HISTCONTROL=ignoredups:erasedups
|
||||
shopt -s histappend
|
||||
alias ll='ls -lh --color=auto'
|
||||
alias la='ls -lha --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias ..='cd ..'
|
||||
alias nginx-test='nginx -t'
|
||||
alias nginx-reload='systemctl reload nginx'
|
||||
alias logs='journalctl -f'
|
||||
if [ -f /usr/share/bash-completion/bash_completion ]; then
|
||||
. /usr/share/bash-completion/bash_completion
|
||||
fi
|
||||
- path: /etc/sysctl.d/99-sc-hermes.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
vm.swappiness=10
|
||||
vm.vfs_cache_pressure=50
|
||||
vm.dirty_ratio=15
|
||||
vm.dirty_background_ratio=3
|
||||
net.ipv6.conf.all.disable_ipv6=1
|
||||
net.ipv6.conf.default.disable_ipv6=1
|
||||
runcmd:
|
||||
- ln -sf /etc/nginx/sites-available/axiomworks.conf /etc/nginx/sites-enabled/axiomworks.conf
|
||||
- rm -f /etc/nginx/sites-enabled/default
|
||||
- mkdir -p /var/www/axiomworks /var/log/axiomworks /opt/deploy
|
||||
- chown -R www-data:www-data /var/www/axiomworks
|
||||
- touch /var/log/axiomworks/deploy.log
|
||||
- chown www-data:www-data /var/log/axiomworks/deploy.log
|
||||
- chown -R player:player /home/player
|
||||
- fallocate -l 512M /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
- sysctl -p /etc/sysctl.d/99-sc-hermes.conf
|
||||
- systemctl enable --now qemu-guest-agent ssh nginx
|
||||
- systemctl disable --now unattended-upgrades || true
|
||||
- systemctl disable --now apt-daily.timer apt-daily-upgrade.timer || true
|
||||
- systemctl disable --now ModemManager || true
|
||||
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
|
||||
final_message: "Hermes web server is ready."
|
||||
EOF
|
||||
}
|
||||
Executable
+734
@@ -0,0 +1,734 @@
|
||||
#!/usr/bin/env bash
|
||||
# Profile: sc-workstation (ares)
|
||||
# Role: XFCE desktop workstation — where the player works.
|
||||
# Distro: Debian 12 (bookworm) cloud image
|
||||
|
||||
DOMAIN="sc-workstation"
|
||||
HOSTNAME="ares"
|
||||
RAM_MB=2048
|
||||
VCPUS=2
|
||||
DISK_SIZE="20G"
|
||||
GRAPHICS="${SC_WORKSTATION_GRAPHICS:-spice}"
|
||||
BASE_URL="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
|
||||
BASE_IMAGE="$SC_BASE_DIR/debian-12-genericcloud-amd64.qcow2"
|
||||
READY_TIMEOUT=1200
|
||||
READY_COMMAND='cloud-init status 2>/dev/null | grep -q "status: done" && ! uname -r | grep -q cloud && test -e /dev/dri/card0 && systemctl is-active --quiet lightdm'
|
||||
READY_PROGRESS_COMMAND='cloud-init status --long; echo "---"; tail -n 12 /var/log/cloud-init-output.log'
|
||||
READY_WATCH_TEMPLATE='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=5 -o LogLevel=ERROR -i ~/.ssh/sc_host_key opsbridge@{ADDR} "sudo tail -f /var/log/cloud-init-output.log"'
|
||||
|
||||
# Extra variables used in user-data
|
||||
WALLPAPER_PATH="${SC_WALLPAPER_PATH:-$PROJECT_ROOT/server/public/wallpaper.png}"
|
||||
WALLPAPER_B64=""
|
||||
if [ -f "$WALLPAPER_PATH" ]; then
|
||||
WALLPAPER_B64="$(base64 -w 0 "$WALLPAPER_PATH")"
|
||||
fi
|
||||
WALLPAPER_B64_INDENT="$(printf '%s\n' "$WALLPAPER_B64" | fold -w 76 | sed 's/^/ /')"
|
||||
|
||||
PRIVKEY_INDENT="$(sed 's/^/ /' "$SC_SSH_KEY")"
|
||||
|
||||
source "$PROJECT_ROOT/tools/lib/internal-https.sh"
|
||||
sc_ensure_internal_certs "$PROJECT_ROOT"
|
||||
sc_export_internal_https_env
|
||||
|
||||
SC_CERT_DIR="$(sc_cert_dir)"
|
||||
HUD_URL="$(sc_hud_url)"
|
||||
SAGE_URL="$(sc_sage_url)"
|
||||
COMPANY_URL="$(sc_company_url)"
|
||||
_SC_CA_CERT_PEM=""
|
||||
_SC_SERVER_CERT_PEM=""
|
||||
_SC_SERVER_KEY_PEM=""
|
||||
if [[ -f "$(sc_ca_cert)" && -f "$(sc_tls_cert)" && -f "$(sc_tls_key)" ]]; then
|
||||
_SC_CA_CERT_PEM="$(cat "$SC_CERT_DIR/ca.crt")"
|
||||
_SC_SERVER_CERT_PEM="$(cat "$SC_CERT_DIR/server.crt")"
|
||||
_SC_SERVER_KEY_PEM="$(cat "$SC_CERT_DIR/server.key")"
|
||||
fi
|
||||
_SC_CA_CERT_INDENT="$(printf '%s\n' "$_SC_CA_CERT_PEM" | sed 's/^/ /')"
|
||||
_SC_SERVER_CERT_INDENT="$(printf '%s\n' "$_SC_SERVER_CERT_PEM" | sed 's/^/ /')"
|
||||
_SC_SERVER_KEY_INDENT="$(printf '%s\n' "$_SC_SERVER_KEY_PEM" | sed 's/^/ /')"
|
||||
_SC_CA_CERT_JSON="$(printf '%s' "$_SC_CA_CERT_PEM" | tr '\n' '|' | sed 's/|/\\n/g')"
|
||||
|
||||
PLAYER_SSH_CONFIG="$(cat <<'EOF'
|
||||
Host hermes
|
||||
HostName 10.42.0.40
|
||||
User player
|
||||
IdentityFile ~/.ssh/sc_host_key
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
BatchMode yes
|
||||
ConnectTimeout 5
|
||||
LogLevel ERROR
|
||||
|
||||
Host vulcan
|
||||
HostName 10.42.0.24
|
||||
User player
|
||||
IdentityFile ~/.ssh/sc_host_key
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
BatchMode yes
|
||||
ConnectTimeout 5
|
||||
LogLevel ERROR
|
||||
|
||||
Host 10.42.0.*
|
||||
User player
|
||||
IdentityFile ~/.ssh/sc_host_key
|
||||
StrictHostKeyChecking no
|
||||
UserKnownHostsFile /dev/null
|
||||
BatchMode yes
|
||||
ConnectTimeout 5
|
||||
LogLevel ERROR
|
||||
EOF
|
||||
)"
|
||||
PLAYER_SSH_CONFIG_INDENT="$(printf '%s\n' "$PLAYER_SSH_CONFIG" | sed 's/^/ /')"
|
||||
|
||||
_nginx_config() {
|
||||
printf '%s\n' \
|
||||
' server {' \
|
||||
' listen 443 ssl;' \
|
||||
' server_name axiomworks.corp www.axiomworks.corp;' \
|
||||
' ssl_certificate /etc/nginx/certs/server.crt;' \
|
||||
' ssl_certificate_key /etc/nginx/certs/server.key;' \
|
||||
' location / {' \
|
||||
" proxy_pass https://${GAME_HOST_IP}:3000/company/;" \
|
||||
' proxy_ssl_verify off;' \
|
||||
' proxy_set_header Host $host;' \
|
||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||
' }' \
|
||||
' }' \
|
||||
' server {' \
|
||||
' listen 80;' \
|
||||
' server_name axiomworks.corp www.axiomworks.corp;' \
|
||||
' return 301 https://$host$request_uri;' \
|
||||
' }'
|
||||
}
|
||||
|
||||
_cert_write_files() {
|
||||
if [[ -z "$_SC_CA_CERT_PEM" ]]; then return; fi
|
||||
printf '%s\n' ' - path: /usr/local/share/ca-certificates/axiomworks-ca.crt'
|
||||
printf '%s\n' ' owner: root:root'
|
||||
printf '%s\n' " permissions: '0644'"
|
||||
printf '%s\n' ' content: |'
|
||||
printf '%s\n' "$_SC_CA_CERT_INDENT"
|
||||
printf '%s\n' ' - path: /etc/nginx/certs/server.crt'
|
||||
printf '%s\n' ' owner: root:root'
|
||||
printf '%s\n' " permissions: '0644'"
|
||||
printf '%s\n' ' content: |'
|
||||
printf '%s\n' "$_SC_SERVER_CERT_INDENT"
|
||||
printf '%s\n' ' - path: /etc/nginx/certs/server.key'
|
||||
printf '%s\n' ' owner: root:root'
|
||||
printf '%s\n' " permissions: '0600'"
|
||||
printf '%s\n' ' content: |'
|
||||
printf '%s\n' "$_SC_SERVER_KEY_INDENT"
|
||||
printf '%s\n' ' - path: /etc/chromium/policies/managed/axiomworks-ca.json'
|
||||
printf '%s\n' ' owner: root:root'
|
||||
printf '%s\n' " permissions: '0644'"
|
||||
printf '%s\n' ' content: |'
|
||||
printf '%s\n' ' {'
|
||||
printf '%s\n' ' "AdditionalTrustAnchors": ['
|
||||
printf '%s\n' " \"$_SC_CA_CERT_JSON\""
|
||||
printf '%s\n' ' ]'
|
||||
printf '%s\n' ' }'
|
||||
}
|
||||
|
||||
generate_user_data() {
|
||||
cat <<EOF
|
||||
#cloud-config
|
||||
hostname: ${HOSTNAME}
|
||||
fqdn: ${HOSTNAME}.internal
|
||||
manage_etc_hosts: false
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
packages:
|
||||
- qemu-guest-agent
|
||||
- spice-vdagent
|
||||
- accountsservice
|
||||
- openssh-server
|
||||
- sudo
|
||||
- bash-completion
|
||||
- xfce4
|
||||
- xfce4-goodies
|
||||
- lightdm
|
||||
- lightdm-gtk-greeter
|
||||
- tilix
|
||||
- chromium
|
||||
- thunar
|
||||
- gvfs
|
||||
- libglib2.0-bin
|
||||
- libnss3-tools
|
||||
- dbus-x11
|
||||
- geany
|
||||
- meld
|
||||
- fonts-hack
|
||||
- fonts-firacode
|
||||
- vim
|
||||
- nano
|
||||
- htop
|
||||
- tmux
|
||||
- curl
|
||||
- wget
|
||||
- rsync
|
||||
- git
|
||||
- jq
|
||||
- python3
|
||||
- openssh-client
|
||||
- nmap
|
||||
- netcat-openbsd
|
||||
- dnsutils
|
||||
- traceroute
|
||||
- mtr
|
||||
- tcpdump
|
||||
- strace
|
||||
- lsof
|
||||
- openssl
|
||||
- whois
|
||||
- iperf3
|
||||
- logwatch
|
||||
- gnome-themes-extra
|
||||
- avahi-daemon
|
||||
- libnss-mdns
|
||||
- nginx
|
||||
users:
|
||||
- default
|
||||
- name: opsbridge
|
||||
gecos: Axiom Works Ops Bridge
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
ssh_authorized_keys:
|
||||
- ${PUBKEY}
|
||||
- name: player
|
||||
gecos: Axiom Works Player
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
ssh_authorized_keys:
|
||||
- ${PUBKEY}
|
||||
write_files:
|
||||
- path: /etc/hosts
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
127.0.0.1 localhost axiomworks.corp www.axiomworks.corp
|
||||
127.0.1.1 ares ares.axiomworks.internal
|
||||
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal sage.axiomworks.internal www.axiomworks.internal
|
||||
10.42.0.40 hermes hermes.axiomworks.internal
|
||||
10.42.0.24 vulcan vulcan.axiomworks.internal
|
||||
- path: /etc/axiom/onboarding
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
Welcome to Axiom Works.
|
||||
Hostname: ares
|
||||
User: player
|
||||
Portal: ${HUD_URL}
|
||||
Knowledge base: ${SAGE_URL}
|
||||
SSH targets: hermes.axiomworks.internal vulcan.axiomworks.internal
|
||||
Open Tilix for terminal work. Use ssh hermes or ssh vulcan once your SSH key is configured.
|
||||
- path: /etc/sudoers.d/99-player
|
||||
owner: root:root
|
||||
permissions: '0440'
|
||||
content: |
|
||||
player ALL=(ALL) NOPASSWD:ALL
|
||||
- path: /etc/sudoers.d/99-opsbridge
|
||||
owner: root:root
|
||||
permissions: '0440'
|
||||
content: |
|
||||
opsbridge ALL=(ALL) NOPASSWD:ALL
|
||||
- path: /etc/lightdm/lightdm.conf.d/50-autologin.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[Seat:*]
|
||||
autologin-user=player
|
||||
autologin-user-timeout=0
|
||||
- path: /home/player/.config/chromium/Default/Bookmarks
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
{
|
||||
"checksum": "",
|
||||
"roots": {
|
||||
"bookmark_bar": {
|
||||
"children": [
|
||||
{
|
||||
"date_added": "13369637600000000",
|
||||
"guid": "1a2b3c4d-0001-0001-0001-000000000001",
|
||||
"id": "2",
|
||||
"name": "Axiom Works Portal",
|
||||
"type": "url",
|
||||
"url": "${HUD_URL}"
|
||||
},
|
||||
{
|
||||
"date_added": "13369637600000000",
|
||||
"guid": "1a2b3c4d-0001-0001-0001-000000000002",
|
||||
"id": "3",
|
||||
"name": "Sage (KB)",
|
||||
"type": "url",
|
||||
"url": "${SAGE_URL}"
|
||||
},
|
||||
{
|
||||
"date_added": "13369637600000000",
|
||||
"guid": "1a2b3c4d-0001-0001-0001-000000000003",
|
||||
"id": "6",
|
||||
"name": "Axiom Works Website",
|
||||
"type": "url",
|
||||
"url": "${COMPANY_URL}"
|
||||
}
|
||||
],
|
||||
"date_added": "13369637600000000",
|
||||
"date_modified": "13369637600000000",
|
||||
"guid": "0bc5d13f-2cba-48a8-9801-375a6731a4b8",
|
||||
"id": "1",
|
||||
"name": "Bookmarks bar",
|
||||
"type": "folder"
|
||||
},
|
||||
"other": {
|
||||
"children": [],
|
||||
"date_added": "13369637600000000",
|
||||
"date_modified": "0",
|
||||
"guid": "82b081ec-3dd3-493c-b8d3-c1c01c3ce438",
|
||||
"id": "4",
|
||||
"name": "Other bookmarks",
|
||||
"type": "folder"
|
||||
},
|
||||
"synced": {
|
||||
"children": [],
|
||||
"date_added": "13369637600000000",
|
||||
"date_modified": "0",
|
||||
"guid": "4cf2e351-0e85-532b-bb37-df045d8f8d0f",
|
||||
"id": "5",
|
||||
"name": "Mobile bookmarks",
|
||||
"type": "folder"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
- path: /usr/local/bin/open-portal
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
# Wait for game server before opening Chromium — avoids "site can't be reached" on fast VM boot.
|
||||
until curl -sf --max-time 2 "${HUD_URL}" >/dev/null 2>&1; do sleep 2; done
|
||||
exec chromium --no-first-run --no-default-browser-check --new-window "${HUD_URL}"
|
||||
- path: /usr/local/share/axiomworks-wallpaper.png
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
encoding: b64
|
||||
content: |
|
||||
${WALLPAPER_B64_INDENT}
|
||||
- path: /usr/share/backgrounds/wallpaper.png
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
encoding: b64
|
||||
content: |
|
||||
${WALLPAPER_B64_INDENT}
|
||||
- path: /home/player/Desktop/Portal.desktop
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Axiom Works Portal
|
||||
Exec=chromium --no-first-run --no-default-browser-check --new-window ${HUD_URL}
|
||||
Icon=chromium
|
||||
Terminal=false
|
||||
- path: /home/player/Desktop/Terminal.desktop
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Terminal
|
||||
Exec=tilix
|
||||
Icon=utilities-terminal
|
||||
Terminal=false
|
||||
Path=/home/player
|
||||
- path: /usr/local/bin/trust-desktop-launchers
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
# Trust every player desktop launcher from the real player login session.
|
||||
set -u
|
||||
PATH=/usr/local/bin:/usr/bin:/bin
|
||||
player_uid="\$(id -u player)"
|
||||
desktop_dir=/home/player/Desktop
|
||||
export HOME=/home/player
|
||||
export USER=player
|
||||
export LOGNAME=player
|
||||
export DISPLAY="\${DISPLAY:-:0}"
|
||||
export XAUTHORITY="\${XAUTHORITY:-/home/player/.Xauthority}"
|
||||
export XDG_RUNTIME_DIR="/run/user/\$player_uid"
|
||||
if [ -S "\$XDG_RUNTIME_DIR/bus" ]; then
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
|
||||
fi
|
||||
|
||||
metadata_daemon=""
|
||||
for candidate in /usr/libexec/gvfsd-metadata /usr/lib/gvfs/gvfsd-metadata /usr/lib/x86_64-linux-gnu/gvfs/gvfsd-metadata; do
|
||||
if [ -x "\$candidate" ]; then
|
||||
metadata_daemon="\$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "\$metadata_daemon" ] && ! /usr/bin/pgrep -u "\$player_uid" -x gvfsd-metadata >/dev/null 2>&1; then
|
||||
"\$metadata_daemon" >/dev/null 2>&1 &
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
for i in \$(/usr/bin/seq 1 20); do
|
||||
trusted_any=false
|
||||
failed=false
|
||||
for launcher in "\$desktop_dir"/*.desktop; do
|
||||
[ -e "\$launcher" ] || continue
|
||||
chmod 0755 "\$launcher" 2>/dev/null || true
|
||||
checksum="\$(/usr/bin/sha256sum "\$launcher" | /usr/bin/awk '{print \$1}')" || {
|
||||
failed=true
|
||||
continue
|
||||
}
|
||||
if /usr/bin/gio set -t string "\$launcher" metadata::xfce-exe-checksum "\$checksum" 2>/dev/null; then
|
||||
actual_checksum="\$(/usr/bin/gio info -a metadata::xfce-exe-checksum "\$launcher" 2>/dev/null | /usr/bin/awk -F': ' '/metadata::xfce-exe-checksum:/ {print \$2; exit}')"
|
||||
owner_mode="\$(/usr/bin/stat -c '%U:%G %a' "\$launcher" 2>/dev/null || true)"
|
||||
if [ "\$actual_checksum" != "\$checksum" ] || [ "\$owner_mode" != "player:player 755" ]; then
|
||||
failed=true
|
||||
continue
|
||||
fi
|
||||
trusted_any=true
|
||||
else
|
||||
failed=true
|
||||
fi
|
||||
done
|
||||
if [ "\$trusted_any" = true ] && [ "\$failed" = false ]; then
|
||||
/usr/bin/xfdesktop --reload >/dev/null 2>&1 || /usr/bin/pkill -HUP xfdesktop 2>/dev/null || true
|
||||
rm -f /home/player/.config/autostart/trust-launchers.desktop
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# gvfsd not ready — will retry next login
|
||||
exit 1
|
||||
- path: /home/player/.local/bin/trust-desktop-launchers.sh
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
exec /usr/local/bin/trust-desktop-launchers
|
||||
- path: /home/player/.config/autostart/trust-launchers.desktop
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Trust Desktop Launchers
|
||||
Exec=/usr/local/bin/trust-desktop-launchers
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
Hidden=false
|
||||
NoDisplay=true
|
||||
- path: /home/player/Desktop/VIEWER_HELP.txt
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
Workstation Viewer — Quick Reference
|
||||
=====================================
|
||||
Toggle fullscreen: F11
|
||||
Release mouse/kb: Shift+F12 (or Ctrl+Alt on some builds)
|
||||
Scale display: View → Zoom (or Ctrl+scroll)
|
||||
Copy from guest: Select text, then right-click → Copy
|
||||
Paste to guest: Right-click input field → Paste
|
||||
Switch USB redirect: Input → USB Device Redirection
|
||||
- path: /home/player/.config/xfce4/desktop/icons.screen0-1264x757.rc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[xfdesktop-version-4.10.3+-rcfile_format]
|
||||
4.10.3+=true
|
||||
|
||||
[/home/player/Desktop/VIEWER_HELP.txt]
|
||||
row=6
|
||||
col=0
|
||||
|
||||
[/home/player/Desktop/Terminal.desktop]
|
||||
row=0
|
||||
col=6
|
||||
|
||||
[/home/player/Desktop/Portal.desktop]
|
||||
row=0
|
||||
col=7
|
||||
|
||||
[Trash]
|
||||
row=6
|
||||
col=11
|
||||
|
||||
[/]
|
||||
row=0
|
||||
col=4
|
||||
|
||||
[/home/player]
|
||||
row=0
|
||||
col=5
|
||||
- path: /home/player/.config/xfce4/desktop/icons.screen.latest.rc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[xfdesktop-version-4.10.3+-rcfile_format]
|
||||
4.10.3+=true
|
||||
|
||||
[/home/player/Desktop/VIEWER_HELP.txt]
|
||||
row=6
|
||||
col=0
|
||||
|
||||
[/home/player/Desktop/Terminal.desktop]
|
||||
row=0
|
||||
col=6
|
||||
|
||||
[/home/player/Desktop/Portal.desktop]
|
||||
row=0
|
||||
col=7
|
||||
|
||||
[Trash]
|
||||
row=6
|
||||
col=11
|
||||
|
||||
[/]
|
||||
row=0
|
||||
col=4
|
||||
|
||||
[/home/player]
|
||||
row=0
|
||||
col=5
|
||||
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<channel name="xfwm4" version="1.0">
|
||||
<property name="general" type="empty">
|
||||
<property name="use_compositing" type="bool" value="false"/>
|
||||
</property>
|
||||
</channel>
|
||||
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<channel name="xfce4-screensaver" version="1.0">
|
||||
<property name="saver" type="empty">
|
||||
<property name="enabled" type="bool" value="false"/>
|
||||
</property>
|
||||
<property name="lock" type="empty">
|
||||
<property name="enabled" type="bool" value="false"/>
|
||||
</property>
|
||||
</channel>
|
||||
- path: /home/player/.ssh/sc_host_key
|
||||
owner: root:root
|
||||
permissions: '0600'
|
||||
content: |
|
||||
${PRIVKEY_INDENT}
|
||||
- path: /home/player/.ssh/config
|
||||
owner: root:root
|
||||
permissions: '0600'
|
||||
content: |
|
||||
${PLAYER_SSH_CONFIG_INDENT}
|
||||
- path: /home/player/.config/chromium/Default/Preferences
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
{
|
||||
"bookmark_bar": { "show_on_all_tabs": true },
|
||||
"browser": {
|
||||
"check_default_browser": false,
|
||||
"show_home_button": false
|
||||
},
|
||||
"background_mode": { "enabled": false },
|
||||
"signin": { "allowed": false },
|
||||
"metrics": { "reporting_enabled": false },
|
||||
"safebrowsing": { "enabled": false },
|
||||
"translate": { "enabled": false }
|
||||
}
|
||||
- path: /home/player/.config/chromium/First Run
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: ''
|
||||
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<channel name="xsettings" version="1.0">
|
||||
<property name="Net" type="empty">
|
||||
<property name="ThemeName" type="string" value="Adwaita-dark"/>
|
||||
<property name="IconThemeName" type="string" value="Adwaita"/>
|
||||
</property>
|
||||
<property name="Gtk" type="empty">
|
||||
<property name="CursorThemeName" type="string" value="Adwaita"/>
|
||||
</property>
|
||||
</channel>
|
||||
- path: /home/player/.config/xfce4/helpers.rc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
TerminalEmulator=tilix
|
||||
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<channel name="xfce4-desktop" version="1.0">
|
||||
<property name="backdrop" type="empty">
|
||||
<property name="screen0" type="empty">
|
||||
<property name="monitorVirtual-0" type="empty">
|
||||
<property name="workspace0" type="empty">
|
||||
<property name="color-style" type="int" value="0"/>
|
||||
<property name="rgba1" type="array">
|
||||
<value type="uint" value="16"/>
|
||||
<value type="uint" value="22"/>
|
||||
<value type="uint" value="30"/>
|
||||
<value type="uint" value="255"/>
|
||||
</property>
|
||||
<property name="image-style" type="int" value="2"/>
|
||||
<property name="last-image" type="string" value="/usr/local/share/axiomworks-wallpaper.png"/>
|
||||
</property>
|
||||
</property>
|
||||
<property name="monitorVirtual-1" type="empty">
|
||||
<property name="workspace0" type="empty">
|
||||
<property name="color-style" type="int" value="1"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
|
||||
</property>
|
||||
<property name="workspace1" type="empty">
|
||||
<property name="color-style" type="int" value="1"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
|
||||
</property>
|
||||
<property name="workspace2" type="empty">
|
||||
<property name="color-style" type="int" value="1"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
|
||||
</property>
|
||||
<property name="workspace3" type="empty">
|
||||
<property name="color-style" type="int" value="1"/>
|
||||
<property name="image-style" type="int" value="5"/>
|
||||
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
|
||||
</property>
|
||||
</property>
|
||||
</property>
|
||||
</property>
|
||||
<property name="desktop-icons" type="empty">
|
||||
<property name="show-removable" type="bool" value="false"/>
|
||||
<property name="show-device-icons" type="bool" value="false"/>
|
||||
<property name="show-network-removable" type="bool" value="false"/>
|
||||
<property name="show-trash" type="bool" value="true"/>
|
||||
<property name="show-home" type="bool" value="true"/>
|
||||
<property name="show-filesystem" type="bool" value="true"/>
|
||||
</property>
|
||||
</channel>
|
||||
- path: /home/player/.bashrc
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
[ -z "\$PS1" ] && return
|
||||
export TERM=xterm-256color
|
||||
export EDITOR=nano
|
||||
PS1='\[\e[0;32m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
|
||||
HISTSIZE=5000
|
||||
HISTFILESIZE=10000
|
||||
HISTCONTROL=ignoredups:erasedups
|
||||
shopt -s histappend
|
||||
alias ll='ls -lh --color=auto'
|
||||
alias la='ls -lha --color=auto'
|
||||
alias l='ls -CF --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias ..='cd ..'
|
||||
alias ...='cd ../..'
|
||||
export LS_COLORS='di=0;34:ln=0;36:ex=0;32:'
|
||||
if [ -f /usr/share/bash-completion/bash_completion ]; then
|
||||
. /usr/share/bash-completion/bash_completion
|
||||
fi
|
||||
- path: /etc/sysctl.d/99-sc-workstation.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
vm.swappiness=10
|
||||
vm.vfs_cache_pressure=50
|
||||
vm.dirty_ratio=20
|
||||
vm.dirty_background_ratio=5
|
||||
net.ipv6.conf.all.disable_ipv6=1
|
||||
net.ipv6.conf.default.disable_ipv6=1
|
||||
- path: /etc/udev/rules.d/90-sysadmin-chronicles-hide-system-disk.rules
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
# Hide the internal VirtIO system disk from desktop file-manager device lists.
|
||||
KERNEL=="vd[a-z]", ENV{UDISKS_IGNORE}="1"
|
||||
KERNEL=="vd[a-z][0-9]*", ENV{UDISKS_IGNORE}="1"
|
||||
- path: /etc/nginx/sites-available/axiomworks
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
$(_nginx_config)
|
||||
$(_cert_write_files)
|
||||
runcmd:
|
||||
- mkdir -p /home/player/Desktop /home/player/projects /home/player/.ssh /home/player/.config/autostart /home/player/.config/xfce4/desktop /home/player/.config/xfce4/xfconf/xfce-perchannel-xml /home/player/.config/chromium/Default /home/opsbridge/.ssh /home/player/.local/bin
|
||||
- chown -R player:player /home/player
|
||||
- chown -R opsbridge:opsbridge /home/opsbridge
|
||||
- passwd -d player
|
||||
- chmod 700 /home/player/.ssh
|
||||
- chmod 700 /home/opsbridge/.ssh
|
||||
- touch /home/player/.ssh/authorized_keys
|
||||
- touch /home/opsbridge/.ssh/authorized_keys
|
||||
- chown player:player /home/player/.ssh/authorized_keys
|
||||
- chown opsbridge:opsbridge /home/opsbridge/.ssh/authorized_keys
|
||||
- chmod 600 /home/player/.ssh/authorized_keys
|
||||
- chmod 600 /home/opsbridge/.ssh/authorized_keys
|
||||
- printf '%s\n' 'Axiom Works workstation ready.' > /home/player/notes.txt
|
||||
- chown player:player /home/player/notes.txt
|
||||
- mkdir -p /var/lib/lightdm/data
|
||||
- chown lightdm:lightdm /var/lib/lightdm/data || chown 108:114 /var/lib/lightdm/data || true
|
||||
- test -f /swapfile || fallocate -l 1G /swapfile
|
||||
- chmod 600 /swapfile
|
||||
- mkswap -f /swapfile
|
||||
- swapon /swapfile || true
|
||||
- grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
- ln -sf /etc/nginx/sites-available/axiomworks /etc/nginx/sites-enabled/axiomworks
|
||||
- mkdir -p /etc/nginx/certs
|
||||
- test -f /usr/local/share/ca-certificates/axiomworks-ca.crt && update-ca-certificates || true
|
||||
- mkdir -p /etc/chromium/policies/managed
|
||||
- |
|
||||
if [ -f /usr/local/share/ca-certificates/axiomworks-ca.crt ]; then
|
||||
mkdir -p /home/player/.pki/nssdb
|
||||
certutil -d sql:/home/player/.pki/nssdb -N --empty-password 2>/dev/null || true
|
||||
certutil -d sql:/home/player/.pki/nssdb -A -t "CT,," -n "Axiom Works CA" -i /usr/local/share/ca-certificates/axiomworks-ca.crt 2>/dev/null || true
|
||||
chown -R player:player /home/player/.pki
|
||||
fi
|
||||
- rm -f /etc/nginx/sites-enabled/default
|
||||
- systemctl enable --now nginx
|
||||
- systemctl enable --now qemu-guest-agent ssh spice-vdagent
|
||||
- systemctl enable lightdm
|
||||
- systemctl set-default graphical.target
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get purge -y plymouth plymouth-label || true
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y linux-image-amd64
|
||||
- cloud_kernels="\$(dpkg-query -W -f='\${Package}\\n' 'linux-image-*-cloud-amd64' 2>/dev/null | tr '\\n' ' ')"; if [ -n "\$cloud_kernels" ]; then DEBIAN_FRONTEND=noninteractive apt-get purge -y linux-image-cloud-amd64 \$cloud_kernels; fi
|
||||
- update-grub || true
|
||||
- update-alternatives --set x-www-browser /usr/bin/chromium || true
|
||||
- update-alternatives --set x-terminal-emulator /usr/bin/tilix || true
|
||||
- sysctl -p /etc/sysctl.d/99-sc-workstation.conf
|
||||
- udevadm control --reload-rules || true
|
||||
- udevadm trigger --subsystem-match=block || true
|
||||
- systemctl enable --now avahi-daemon
|
||||
- "sed -i 's/^hosts:.*/hosts: files mdns4_minimal [NOTFOUND=return] dns/' /etc/nsswitch.conf"
|
||||
- systemctl disable --now unattended-upgrades || true
|
||||
- systemctl disable --now apt-daily.timer apt-daily-upgrade.timer || true
|
||||
- systemctl disable --now ModemManager || true
|
||||
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
|
||||
- rm -f /home/player/.config/autostart/game-hud.desktop
|
||||
- rm -f /home/player/.Xauthority /home/player/.ICEauthority
|
||||
- find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chmod 0755 {} +
|
||||
- chown -R player:player /home/player
|
||||
power_state:
|
||||
mode: reboot
|
||||
timeout: 30
|
||||
condition: true
|
||||
final_message: "Ares XFCE workstation is ready."
|
||||
EOF
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q001-prep.sh — Workstation baseline: SSH key missing
|
||||
#
|
||||
# Prepares the workstation VM for Q001 "Welcome Aboard".
|
||||
# The player's SSH key was never added during provisioning.
|
||||
#
|
||||
# What this does:
|
||||
# - Ensures the player account exists
|
||||
# - Removes /home/player/.ssh/authorized_keys (key not provisioned)
|
||||
# - Leaves /var/log/auth.log with a "Permission denied (publickey)" entry
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
# AGENT RULES: Never run against a live player session.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-workstation}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_USER="${SSH_USER:-opsbridge}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
|
||||
VM_IP="$(get_vm_ip "$DOMAIN")"
|
||||
SSH="ssh $SSH_OPTS $SSH_USER@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
$SSH "sudo $*"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q001-prep: Preparing $DOMAIN for 'Welcome Aboard'..."
|
||||
|
||||
run_in_vm "bash -lc 'mkdir -p /home/player/.ssh; touch /var/log/auth.log; ts=\$(date +\"%b %d %H:%M:%S\"); echo \"\$ts ares sshd[1234]: Failed publickey for player from 10.42.0.1 port 22 ssh2\" >> /var/log/auth.log; rm -f /home/player/.ssh/authorized_keys; echo Q001-prep: authorized_keys removed'"
|
||||
|
||||
echo "Q001-prep: Done."
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q002-prep.sh — hermes baseline: nginx config syntax error
|
||||
#
|
||||
# Prepares sc-web-server for Q002 "Syntax Error in Aisle Four".
|
||||
# Introduces a deliberate nginx config syntax error that breaks the service.
|
||||
#
|
||||
# What this does:
|
||||
# - Installs nginx if not present
|
||||
# - Writes a broken /etc/nginx/sites-enabled/axiomworks.conf
|
||||
# (missing semicolon on the server_name line)
|
||||
# - Stops nginx so the player finds it down
|
||||
# - Adds error log evidence
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-web-server}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
VM_IP=$(get_vm_ip "$DOMAIN")
|
||||
SSH="ssh $SSH_OPTS player@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
printf '%s\n' "$*" | $SSH "sudo bash -se"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q002-prep: Preparing $DOMAIN for 'Syntax Error in Aisle Four'..."
|
||||
|
||||
run_in_vm "mkdir -p /etc/nginx/sites-enabled /etc/nginx/sites-available"
|
||||
|
||||
# Write broken nginx config (missing semicolon after server_name)
|
||||
run_in_vm "cat > /etc/nginx/sites-enabled/axiomworks.conf <<'NGINX_CONF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name axiomworks.internal # <-- MISSING SEMICOLON: this is the bug
|
||||
root /var/www/axiomworks;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ =404;
|
||||
}
|
||||
}
|
||||
NGINX_CONF"
|
||||
|
||||
# Disable the default site to make this the only relevant config
|
||||
run_in_vm "rm -f /etc/nginx/sites-enabled/default"
|
||||
|
||||
# Stop nginx (it fails to start with bad config)
|
||||
run_in_vm "systemctl stop nginx || true"
|
||||
|
||||
# Populate nginx error log with the kind of evidence a player would find
|
||||
run_in_vm "mkdir -p /var/log/nginx && echo '[emerg] unexpected \";\" in /etc/nginx/sites-enabled/axiomworks.conf:3' >> /var/log/nginx/error.log"
|
||||
|
||||
# Create the web root (nginx would serve from here if config were valid)
|
||||
run_in_vm "mkdir -p /var/www/axiomworks && echo '<h1>Axiom Works</h1>' > /var/www/axiomworks/index.html"
|
||||
|
||||
echo "Q002-prep: Done. nginx is stopped with broken config on $DOMAIN."
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q003-prep.sh — hermes baseline: logrotate missing, nginx access log ballooning
|
||||
#
|
||||
# Prepares sc-web-server for Q003 "The Log That Ate the Disk".
|
||||
# Assumes Q002 is already resolved (nginx is running, config is clean).
|
||||
#
|
||||
# What this does:
|
||||
# - Removes /etc/logrotate.d/nginx (log rotation not configured)
|
||||
# - Grows /var/log/nginx/access.log to ~80% disk pressure
|
||||
# - Disk usage should read >85% on /var so player sees the pressure
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-web-server}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
VM_IP=$(get_vm_ip "$DOMAIN")
|
||||
SSH="ssh $SSH_OPTS player@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
printf '%s\n' "$*" | $SSH "sudo bash -se"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q003-prep: Preparing $DOMAIN for 'The Log That Ate the Disk'..."
|
||||
|
||||
# Remove logrotate config for nginx
|
||||
run_in_vm "rm -f /etc/logrotate.d/nginx"
|
||||
|
||||
# Generate a large access log (~500MB of fake log entries, enough to fill a 6GB VM)
|
||||
# Use truncate for speed rather than generating real content
|
||||
run_in_vm "mkdir -p /var/log/nginx"
|
||||
run_in_vm "truncate -s 500M /var/log/nginx/access.log"
|
||||
|
||||
# Write real-looking last few lines so tail shows something plausible
|
||||
run_in_vm "echo '10.42.0.1 - - [\$(date +\"%d/%b/%Y:%H:%M:%S +0000\")] \"GET / HTTP/1.1\" 200 612 \"-\" \"Mozilla/5.0\"' >> /var/log/nginx/access.log"
|
||||
|
||||
echo "Q003-prep: Done. /var/log/nginx/access.log inflated on $DOMAIN."
|
||||
echo " Check disk pressure with: df -h (on the VM)"
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q004-prep.sh — hermes baseline: web root owned by root, deploy script in place
|
||||
#
|
||||
# Prepares sc-web-server for Q004 "Not My Files".
|
||||
# A bad deploy re-ran as root and chowned the web root to root.
|
||||
# The deploy script itself is in /opt/deploy/deploy.sh.
|
||||
#
|
||||
# What this does:
|
||||
# - Chowns /var/www/axiomworks and all contents to root:root
|
||||
# - Places a deploy script at /opt/deploy/deploy.sh (chowned player:player)
|
||||
# - Ensures nginx is running (deploy will fail but nginx serves stale content)
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-web-server}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
VM_IP=$(get_vm_ip "$DOMAIN")
|
||||
SSH="ssh $SSH_OPTS player@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
printf '%s\n' "$*" | $SSH "sudo bash -se"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q004-prep: Preparing $DOMAIN for 'Not My Files'..."
|
||||
|
||||
# Ensure web root exists and is owned by root (the bug)
|
||||
run_in_vm "mkdir -p /var/www/axiomworks && chown -R root:root /var/www/axiomworks"
|
||||
|
||||
# Create the deploy script as player:player (this is correct — player runs it)
|
||||
run_in_vm "mkdir -p /opt/deploy"
|
||||
run_in_vm "cat > /opt/deploy/deploy.sh <<'DEPLOY_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — Axiom Works web deploy
|
||||
# Copies build artifacts to /var/www/axiomworks/
|
||||
set -e
|
||||
SRC=\"\${1:-/home/player/build/dist}\"
|
||||
rsync -av \"\$SRC/\" /var/www/axiomworks/
|
||||
echo 'Deploy complete.'
|
||||
DEPLOY_SCRIPT"
|
||||
run_in_vm "chown player:player /opt/deploy/deploy.sh && chmod 755 /opt/deploy/deploy.sh"
|
||||
|
||||
# Ensure nginx is running (serves stale content with root-owned files)
|
||||
run_in_vm "systemctl start nginx || true"
|
||||
|
||||
echo "Q004-prep: Done. /var/www/axiomworks is owned by root on $DOMAIN."
|
||||
echo " Player must: sudo chown -R player:player /var/www/axiomworks"
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q006-post-clean.sh — vulcan clean branch state after Q006
|
||||
#
|
||||
# Applies the authored clean outcome of Q006 so seed-vms.sh can materialize
|
||||
# baseline.post-q006 for later quests.
|
||||
#
|
||||
# What this does:
|
||||
# - Enables and starts systemd-timesyncd
|
||||
# - Verifies archlinux-keyring is installed
|
||||
# - Replaces pacman.log failure evidence with a healthy update trail
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-build-machine}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
VM_IP="$(get_vm_ip "$DOMAIN")"
|
||||
SSH="ssh $SSH_OPTS player@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
printf '%s\n' "$*" | $SSH "sudo bash -se"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q006-post-clean: Applying clean Q006 outcome on $DOMAIN..."
|
||||
|
||||
run_in_vm "pacman -Q archlinux-keyring >/dev/null"
|
||||
run_in_vm "timedatectl set-ntp true || true"
|
||||
run_in_vm "systemctl enable --now systemd-timesyncd"
|
||||
|
||||
run_in_vm "cat > /var/log/pacman.log <<'PACMAN_LOG'
|
||||
[2026-04-23T09:02:14-0400] [PACMAN] synchronizing package lists
|
||||
[2026-04-23T09:02:19-0400] [ALPM] transaction started
|
||||
[2026-04-23T09:02:19-0400] [ALPM] upgraded archlinux-keyring (20260401-1 -> 20260420-1)
|
||||
[2026-04-23T09:02:20-0400] [ALPM] transaction completed
|
||||
PACMAN_LOG"
|
||||
|
||||
run_in_vm "cat > /var/log/axiomworks/time-drift.note <<'NOTE'
|
||||
Time sync restored.
|
||||
systemd-timesyncd is enabled and active.
|
||||
archlinux-keyring is present and package operations are healthy.
|
||||
NOTE"
|
||||
|
||||
echo "Q006-post-clean: Done. systemd-timesyncd is active and baseline.post-q006 is ready on $DOMAIN."
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Q006-prep.sh — vulcan baseline: time sync disabled, pacman signature errors logged
|
||||
#
|
||||
# Prepares sc-build-machine for Q006 "Time Is A Flat Circle".
|
||||
# The machine clock is drifting because time sync was disabled, which surfaces
|
||||
# as pacman signature verification failures.
|
||||
#
|
||||
# What this does:
|
||||
# - Disables and stops common NTP services
|
||||
# - Seeds pacman.log with realistic signature failure evidence
|
||||
# - Leaves a small operator note pointing at time drift symptoms
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||
|
||||
DOMAIN="${1:-sc-build-machine}"
|
||||
DRY_RUN=false
|
||||
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
|
||||
|
||||
get_vm_ip() {
|
||||
local domain="$1"
|
||||
local addr=""
|
||||
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
if [ -n "$addr" ]; then
|
||||
printf '%s\n' "$addr"
|
||||
return 0
|
||||
fi
|
||||
local mac=""
|
||||
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
|
||||
[ -n "$mac" ] || return 1
|
||||
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
|
||||
[ -n "$addr" ] || return 1
|
||||
printf '%s\n' "$addr"
|
||||
}
|
||||
|
||||
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
|
||||
VM_IP="$(get_vm_ip "$DOMAIN")"
|
||||
SSH="ssh $SSH_OPTS player@$VM_IP"
|
||||
|
||||
run_in_vm() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN in $DOMAIN] $*"
|
||||
else
|
||||
printf '%s\n' "$*" | $SSH "sudo bash -se"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Q006-prep: Preparing $DOMAIN for 'Time Is A Flat Circle'..."
|
||||
|
||||
run_in_vm "timedatectl set-ntp false || true"
|
||||
run_in_vm "systemctl stop systemd-timesyncd ntpd chronyd 2>/dev/null || true"
|
||||
run_in_vm "systemctl disable systemd-timesyncd ntpd chronyd 2>/dev/null || true"
|
||||
run_in_vm "mkdir -p /var/log/axiomworks /srv/repo /srv/builds"
|
||||
|
||||
run_in_vm "cat > /var/log/pacman.log <<'PACMAN_LOG'
|
||||
[2026-04-23T08:10:51-0400] [PACMAN] synchronizing package lists
|
||||
[2026-04-23T08:10:57-0400] [ALPM] transaction started
|
||||
[2026-04-23T08:10:58-0400] [ALPM] warning: Public keyring not found; have you run 'pacman-key --init'?
|
||||
[2026-04-23T08:10:58-0400] [ALPM] error: archlinux-keyring: signature from \"Arch Linux Master Key\" is invalid
|
||||
[2026-04-23T08:10:58-0400] [ALPM] error: failed to commit transaction (invalid or corrupted package (PGP signature))
|
||||
[2026-04-23T08:10:58-0400] [ALPM] transaction failed
|
||||
PACMAN_LOG"
|
||||
|
||||
run_in_vm "cat > /var/log/axiomworks/time-drift.note <<'NOTE'
|
||||
Builds started failing after the machine clock fell behind.
|
||||
Symptoms:
|
||||
- pacman reports invalid or corrupted package (PGP signature)
|
||||
- signed packages appear to come from the future
|
||||
- timedatectl shows NTP inactive
|
||||
NOTE"
|
||||
|
||||
echo "Q006-prep: Done. NTP is disabled and pacman signature failures are seeded on $DOMAIN."
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild or revert game virtual machines.
|
||||
#
|
||||
# Usage:
|
||||
# rebuild-vms.sh Interactive menu
|
||||
# rebuild-vms.sh --vm workstation Rebuild a single VM (interactive)
|
||||
# rebuild-vms.sh --revert Revert all VMs to baseline snapshot
|
||||
# rebuild-vms.sh --revert --vm workstation
|
||||
# rebuild-vms.sh --snapshot --vm workstation --name before-risky-thing
|
||||
# rebuild-vms.sh --snapshot --all --name pre-shift-4
|
||||
# rebuild-vms.sh --revert --name before-risky-thing --vm workstation
|
||||
# rebuild-vms.sh --dry-run [other flags]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
source "$PROJECT_ROOT/tools/lib/ui.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/config.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/libvirt.sh"
|
||||
source "$PROJECT_ROOT/tools/lib/vm.sh"
|
||||
|
||||
config_read || true
|
||||
|
||||
_normalize_dir_path() {
|
||||
local path="${1:-}"
|
||||
while [[ "$path" == *//* ]]; do
|
||||
path="${path//\/\//\/}"
|
||||
done
|
||||
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
|
||||
path="${path%/}"
|
||||
done
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
if [ -n "${SC_GAME_DIR:-}" ]; then
|
||||
normalized_game_dir="$(_normalize_dir_path "$SC_GAME_DIR")"
|
||||
if [ "$normalized_game_dir" != "$SC_GAME_DIR" ]; then
|
||||
SC_GAME_DIR="$normalized_game_dir"
|
||||
config_write SC_GAME_DIR "$SC_GAME_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${SC_IMAGES_DIR:-}" ]; then
|
||||
normalized_images_dir="$(_normalize_dir_path "$SC_IMAGES_DIR")"
|
||||
elif [ -n "${SC_GAME_DIR:-}" ]; then
|
||||
normalized_images_dir="$SC_GAME_DIR/images"
|
||||
else
|
||||
normalized_images_dir=""
|
||||
fi
|
||||
|
||||
if [ -n "$normalized_images_dir" ]; then
|
||||
if [ "${SC_IMAGES_DIR:-}" != "$normalized_images_dir" ]; then
|
||||
SC_IMAGES_DIR="$normalized_images_dir"
|
||||
config_write SC_IMAGES_DIR "$SC_IMAGES_DIR"
|
||||
fi
|
||||
export SC_IMAGE_ROOT="$SC_IMAGES_DIR"
|
||||
fi
|
||||
|
||||
export LIBVIRT_DEFAULT_URI="${SC_LIBVIRT_URI:-${LIBVIRT_DEFAULT_URI:-qemu:///system}}"
|
||||
export SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
|
||||
export SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
|
||||
|
||||
# VM display names
|
||||
declare -A VM_LABEL=(
|
||||
[sc-workstation]="workstation"
|
||||
[sc-web-server]="web server"
|
||||
[sc-build-machine]="build server"
|
||||
)
|
||||
declare -A VM_PROFILE=(
|
||||
[sc-workstation]=workstation
|
||||
[sc-web-server]=web-server
|
||||
[sc-build-machine]=build-machine
|
||||
)
|
||||
ALL_VMS=(sc-workstation sc-web-server sc-build-machine)
|
||||
|
||||
DRY_RUN=false
|
||||
MODE=""
|
||||
SINGLE_VM=""
|
||||
SNAP_NAME=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--revert) MODE=revert; shift ;;
|
||||
--snapshot) MODE=snapshot; shift ;;
|
||||
--vm) SINGLE_VM="sc-$2"; shift 2 ;;
|
||||
--name) SNAP_NAME="$2"; shift 2 ;;
|
||||
--all) SINGLE_VM=""; shift ;;
|
||||
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo " [dry-run] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Revert one VM to its newest baseline snapshot; prints result.
|
||||
_revert_to_baseline() {
|
||||
local vm="$1"
|
||||
local label="${VM_LABEL[$vm]:-$vm}"
|
||||
local candidate local_snap=""
|
||||
for candidate in "baseline.recovery" "baseline.day-one" "baseline.clean"; do
|
||||
if snapshot_exists "$vm" "$candidate" 2>/dev/null; then
|
||||
local_snap="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$local_snap" ]; then
|
||||
sc_info "Reverting $label to $local_snap..."
|
||||
run snapshot_revert "$vm" "$local_snap"
|
||||
sc_ok "$label reverted to $local_snap"
|
||||
else
|
||||
sc_warn "No baseline snapshot found for $vm — skipping"
|
||||
fi
|
||||
}
|
||||
|
||||
_target_vms() {
|
||||
if [ -n "$SINGLE_VM" ]; then
|
||||
echo "$SINGLE_VM"
|
||||
else
|
||||
printf '%s\n' "${ALL_VMS[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-interactive flag modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$MODE" = "revert" ] && [ -n "$SNAP_NAME" ]; then
|
||||
sc_header "REVERTING TO SNAPSHOT: $SNAP_NAME"
|
||||
while IFS= read -r vm; do
|
||||
label="${VM_LABEL[$vm]:-$vm}"
|
||||
sc_info "Reverting $label..."
|
||||
if snapshot_exists "$vm" "$SNAP_NAME"; then
|
||||
run snapshot_revert "$vm" "$SNAP_NAME"
|
||||
sc_ok "$label reverted to $SNAP_NAME"
|
||||
else
|
||||
sc_warn "Snapshot '$SNAP_NAME' not found on $vm — skipping"
|
||||
fi
|
||||
done < <(_target_vms)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "snapshot" ]; then
|
||||
[ -n "$SNAP_NAME" ] || { echo " --snapshot requires --name"; exit 1; }
|
||||
while IFS= read -r vm; do
|
||||
label="${VM_LABEL[$vm]:-$vm}"
|
||||
sc_info "Snapshotting $label as '$SNAP_NAME'..."
|
||||
run vm_snapshot_create "$vm" "$SNAP_NAME"
|
||||
sc_ok "$label → $SNAP_NAME"
|
||||
done < <(_target_vms)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "revert" ]; then
|
||||
sc_header "REVERTING TO BASELINE"
|
||||
while IFS= read -r vm; do
|
||||
_revert_to_baseline "$vm"
|
||||
done < <(_target_vms)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sc_header "SYSADMIN CHRONICLES — VM TOOLS"
|
||||
|
||||
while true; do
|
||||
echo " What would you like to do?"
|
||||
echo ""
|
||||
echo " 1) Revert all VMs to last known good (fast — ~30s)"
|
||||
echo " 2) Rebuild workstation (~8 min)"
|
||||
echo " 3) Rebuild web server (~4 min)"
|
||||
echo " 4) Rebuild build server (~5 min)"
|
||||
echo " 5) Rebuild everything (~20 min)"
|
||||
echo " 6) Take a snapshot"
|
||||
echo " 7) Revert to a named snapshot"
|
||||
echo ""
|
||||
echo " q) Cancel"
|
||||
echo ""
|
||||
printf " > " >/dev/tty
|
||||
read -r choice </dev/tty
|
||||
echo ""
|
||||
|
||||
case "$choice" in
|
||||
q|Q)
|
||||
echo " Cancelled."
|
||||
exit 0
|
||||
;;
|
||||
1)
|
||||
sc_section "Reverting all VMs to baseline"
|
||||
for vm in "${ALL_VMS[@]}"; do
|
||||
_revert_to_baseline "$vm"
|
||||
done
|
||||
break
|
||||
;;
|
||||
2|3|4|5)
|
||||
case "$choice" in
|
||||
2) targets=(sc-workstation) ;;
|
||||
3) targets=(sc-web-server) ;;
|
||||
4) targets=(sc-build-machine) ;;
|
||||
5) targets=("${ALL_VMS[@]}") ;;
|
||||
esac
|
||||
overall_status=0
|
||||
for vm in "${targets[@]}"; do
|
||||
label="${VM_LABEL[$vm]:-$vm}"
|
||||
profile="${VM_PROFILE[$vm]}"
|
||||
echo ""
|
||||
sc_warn "This will permanently rebuild $label."
|
||||
sc_warn "Quest progress on this VM will be lost."
|
||||
echo ""
|
||||
if sc_confirm "Back up save data first?" "Y"; then
|
||||
BACKUP="$HOME/.local/share/sysadmin-chronicles/saves/pre-rebuild-$(date +%Y%m%d-%H%M%S).json"
|
||||
[ -f "$HOME/.local/share/sysadmin-chronicles/saves/autosave.json" ] \
|
||||
&& cp "$HOME/.local/share/sysadmin-chronicles/saves/autosave.json" "$BACKUP" \
|
||||
&& sc_ok "Save backed up to $BACKUP" || sc_info "(no autosave found)"
|
||||
fi
|
||||
if ! sc_confirm "Rebuild $label now?" "N"; then
|
||||
sc_info "Skipping $label."
|
||||
continue
|
||||
fi
|
||||
sc_section "Rebuilding $label"
|
||||
logfile="$HOME/.local/share/sysadmin-chronicles/rebuild-${profile}.log"
|
||||
printf " Rebuilding %-18s " "$label"
|
||||
start_ts="$(date +%s)"
|
||||
if run vm_rebuild "$profile" $( [ "$DRY_RUN" = true ] && echo "--dry-run" ) \
|
||||
> "$logfile" 2>&1; then
|
||||
elapsed=$(( $(date +%s) - start_ts ))
|
||||
printf "✓ %dm %02ds\n" $(( elapsed / 60 )) $(( elapsed % 60 ))
|
||||
else
|
||||
printf "✗\n"
|
||||
sc_warn "Rebuild failed — see $logfile"
|
||||
overall_status=1
|
||||
continue
|
||||
fi
|
||||
# Re-run quest prep and re-snapshot
|
||||
sc_info "Re-running quest prep for $vm..."
|
||||
if run bash "$PROJECT_ROOT/tools/setup/seed-vms.sh" --skip-build --vm "${profile//-/_}" \
|
||||
>> "$logfile" 2>&1; then
|
||||
sc_ok "$label rebuild complete"
|
||||
else
|
||||
sc_warn "Quest prep had errors — see $logfile"
|
||||
overall_status=1
|
||||
fi
|
||||
done
|
||||
[ "$overall_status" -eq 0 ] || exit "$overall_status"
|
||||
break
|
||||
;;
|
||||
6)
|
||||
echo " Take a snapshot"
|
||||
echo ""
|
||||
echo " Which VM?"
|
||||
for i in "${!ALL_VMS[@]}"; do
|
||||
vm="${ALL_VMS[$i]}"
|
||||
printf " %d) %s\n" $(( i + 1 )) "${VM_LABEL[$vm]:-$vm}"
|
||||
done
|
||||
printf " > " >/dev/tty
|
||||
read -r vm_choice </dev/tty
|
||||
echo ""
|
||||
vm="${ALL_VMS[$(( vm_choice - 1 ))]:-}"
|
||||
[ -n "$vm" ] || { sc_warn "Invalid choice"; continue; }
|
||||
printf " Snapshot name (letters, numbers, hyphens): " >/dev/tty
|
||||
read -r snap_name </dev/tty
|
||||
echo ""
|
||||
run vm_snapshot_create "$vm" "$snap_name" \
|
||||
&& sc_ok "Snapshot created: $snap_name on ${VM_LABEL[$vm]:-$vm}" \
|
||||
|| sc_warn "Snapshot failed."
|
||||
break
|
||||
;;
|
||||
7)
|
||||
echo " Revert to a named snapshot"
|
||||
echo ""
|
||||
echo " Which VM?"
|
||||
for i in "${!ALL_VMS[@]}"; do
|
||||
vm="${ALL_VMS[$i]}"
|
||||
printf " %d) %s\n" $(( i + 1 )) "${VM_LABEL[$vm]:-$vm}"
|
||||
done
|
||||
printf " > " >/dev/tty
|
||||
read -r vm_choice </dev/tty
|
||||
echo ""
|
||||
vm="${ALL_VMS[$(( vm_choice - 1 ))]:-}"
|
||||
[ -n "$vm" ] || { sc_warn "Invalid choice"; continue; }
|
||||
echo " Available snapshots on ${VM_LABEL[$vm]:-$vm}:"
|
||||
virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' | sed 's/^/ /' || true
|
||||
echo ""
|
||||
printf " Snapshot name to revert to: " >/dev/tty
|
||||
read -r snap_name </dev/tty
|
||||
echo ""
|
||||
snap_date="$(virsh snapshot-info "$vm" "$snap_name" 2>/dev/null | grep 'Creation Time' | awk '{print $3, $4}' || echo "")"
|
||||
[ -n "$snap_date" ] && sc_info "Snapshot date: $snap_date"
|
||||
if sc_confirm "Revert ${VM_LABEL[$vm]:-$vm} to '$snap_name'?" "N"; then
|
||||
run vm_snapshot_revert "$vm" "$snap_name" \
|
||||
&& sc_ok "Reverted to $snap_name" \
|
||||
|| sc_warn "Revert failed."
|
||||
fi
|
||||
break
|
||||
;;
|
||||
*)
|
||||
sc_warn "Invalid choice — enter 1–7 or q."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repair trusted desktop launcher metadata in an existing sc-workstation VM.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
DOMAIN="${SC_WORKSTATION_DOMAIN:-sc-workstation}"
|
||||
|
||||
tmp_script="$(mktemp)"
|
||||
trap 'rm -f "$tmp_script"' EXIT
|
||||
|
||||
cat > "$tmp_script" <<'GUESTEOF'
|
||||
set -euo pipefail
|
||||
|
||||
install -d -o player -g player /home/player/Desktop /home/player/.local/bin /home/player/.config/autostart
|
||||
find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chown player:player {} +
|
||||
find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chmod 0755 {} +
|
||||
if [ -f /home/player/.config/chromium/Default/Bookmarks ]; then
|
||||
sudo -u player sed -i 's#http://www\.axiomworks\.corp/#https://www.axiomworks.corp/#g' /home/player/.config/chromium/Default/Bookmarks
|
||||
fi
|
||||
|
||||
cat > /usr/local/bin/trust-desktop-launchers <<'SCRIPTEOF'
|
||||
#!/bin/bash
|
||||
set -u
|
||||
PATH=/usr/local/bin:/usr/bin:/bin
|
||||
player_uid="$(id -u player)"
|
||||
desktop_dir=/home/player/Desktop
|
||||
export HOME=/home/player
|
||||
export USER=player
|
||||
export LOGNAME=player
|
||||
export DISPLAY="${DISPLAY:-:0}"
|
||||
export XAUTHORITY="${XAUTHORITY:-/home/player/.Xauthority}"
|
||||
export XDG_RUNTIME_DIR="/run/user/$player_uid"
|
||||
if [ -S "$XDG_RUNTIME_DIR/bus" ]; then
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
|
||||
fi
|
||||
|
||||
metadata_daemon=""
|
||||
for candidate in /usr/libexec/gvfsd-metadata /usr/lib/gvfs/gvfsd-metadata /usr/lib/x86_64-linux-gnu/gvfs/gvfsd-metadata; do
|
||||
if [ -x "$candidate" ]; then
|
||||
metadata_daemon="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$metadata_daemon" ] && ! /usr/bin/pgrep -u "$player_uid" -x gvfsd-metadata >/dev/null 2>&1; then
|
||||
"$metadata_daemon" >/dev/null 2>&1 &
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
for i in $(/usr/bin/seq 1 20); do
|
||||
trusted_any=false
|
||||
failed=false
|
||||
for launcher in "$desktop_dir"/*.desktop; do
|
||||
[ -e "$launcher" ] || continue
|
||||
chmod 0755 "$launcher" 2>/dev/null || true
|
||||
checksum="$(/usr/bin/sha256sum "$launcher" | /usr/bin/awk '{print $1}')" || {
|
||||
failed=true
|
||||
continue
|
||||
}
|
||||
if /usr/bin/gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" 2>/dev/null; then
|
||||
actual_checksum="$(/usr/bin/gio info -a metadata::xfce-exe-checksum "$launcher" 2>/dev/null | /usr/bin/awk -F': ' '/metadata::xfce-exe-checksum:/ {print $2; exit}')"
|
||||
owner_mode="$(/usr/bin/stat -c '%U:%G %a' "$launcher" 2>/dev/null || true)"
|
||||
if [ "$actual_checksum" != "$checksum" ] || [ "$owner_mode" != "player:player 755" ]; then
|
||||
failed=true
|
||||
continue
|
||||
fi
|
||||
trusted_any=true
|
||||
else
|
||||
failed=true
|
||||
fi
|
||||
done
|
||||
if [ "$trusted_any" = true ] && [ "$failed" = false ]; then
|
||||
/usr/bin/xfdesktop --reload >/dev/null 2>&1 || /usr/bin/pkill -HUP xfdesktop 2>/dev/null || true
|
||||
rm -f /home/player/.config/autostart/trust-launchers.desktop
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
SCRIPTEOF
|
||||
chmod 0755 /usr/local/bin/trust-desktop-launchers
|
||||
|
||||
cat > /home/player/.local/bin/trust-desktop-launchers.sh <<'SCRIPTEOF'
|
||||
#!/bin/bash
|
||||
exec /usr/local/bin/trust-desktop-launchers
|
||||
SCRIPTEOF
|
||||
chown player:player /home/player/.local/bin/trust-desktop-launchers.sh
|
||||
chmod 0755 /home/player/.local/bin/trust-desktop-launchers.sh
|
||||
|
||||
cat > /home/player/.config/autostart/trust-launchers.desktop <<'DESKTOPEOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Trust Desktop Launchers
|
||||
Exec=/usr/local/bin/trust-desktop-launchers
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
Hidden=false
|
||||
NoDisplay=true
|
||||
DESKTOPEOF
|
||||
chown player:player /home/player/.config/autostart/trust-launchers.desktop
|
||||
chmod 0644 /home/player/.config/autostart/trust-launchers.desktop
|
||||
|
||||
if [ -S "/run/user/$(id -u player)/bus" ]; then
|
||||
sudo -u player env HOME=/home/player /usr/local/bin/trust-desktop-launchers
|
||||
else
|
||||
echo "Player DBus session is not active; repair will retry on next graphical login." >&2
|
||||
fi
|
||||
GUESTEOF
|
||||
|
||||
guest_run_sudo_script "$DOMAIN" "$tmp_script"
|
||||
ok "Desktop launcher repair applied to $DOMAIN"
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# snapshot-all.sh — Snapshot or revert all game VMs at once.
|
||||
#
|
||||
# Usage:
|
||||
# bash tools/vm/snapshot-all.sh --snapshot <name> Create named snapshot on all VMs
|
||||
# bash tools/vm/snapshot-all.sh --revert-to <name> Revert all VMs to named snapshot
|
||||
# bash tools/vm/snapshot-all.sh --list List all snapshots per VM
|
||||
# bash tools/vm/snapshot-all.sh --dry-run --revert-to ... Dry run (no state changes)
|
||||
#
|
||||
# SAFETY:
|
||||
# - Only operates on sc- prefixed domains.
|
||||
# - Always prints a summary before modifying state.
|
||||
# - --revert-to requires explicit confirmation (skipped with --yes flag).
|
||||
# - This script is for developer use only. It is NOT available in-game.
|
||||
#
|
||||
# AGENT RULES:
|
||||
# - Never run --revert-to without explicit user instruction.
|
||||
# - Never run against domains that don't start with sc-.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VMS=("sc-workstation" "sc-web-server" "sc-build-machine")
|
||||
DRY_RUN=false
|
||||
ASSUME_YES=false
|
||||
SNAPSHOT_NAME=""
|
||||
REVERT_NAME=""
|
||||
LIST_MODE=false
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--snapshot) SNAPSHOT_NAME="$2"; shift 2 ;;
|
||||
--revert-to) REVERT_NAME="$2"; shift 2 ;;
|
||||
--list) LIST_MODE=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--yes) ASSUME_YES=true; shift ;;
|
||||
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " [DRY-RUN] $*"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
guard_prefix() {
|
||||
local dom="$1"
|
||||
if [[ "$dom" != sc-* ]]; then
|
||||
echo "SAFETY: refusing to operate on non-game domain: $dom"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$LIST_MODE" = "true" ]; then
|
||||
echo ""
|
||||
echo "── Snapshots per VM ─────────────────────────────"
|
||||
for dom in "${VMS[@]}"; do
|
||||
echo ""
|
||||
echo " $dom:"
|
||||
if virsh dominfo "$dom" &>/dev/null 2>&1; then
|
||||
virsh snapshot-list "$dom" --name 2>/dev/null | sed 's/^/ /' || echo " (none)"
|
||||
else
|
||||
echo " (domain does not exist)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SNAPSHOT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$SNAPSHOT_NAME" ]; then
|
||||
echo ""
|
||||
echo "Creating snapshot '$SNAPSHOT_NAME' on all game VMs..."
|
||||
[ "$DRY_RUN" = "true" ] && echo "[DRY-RUN mode]"
|
||||
echo ""
|
||||
for dom in "${VMS[@]}"; do
|
||||
guard_prefix "$dom"
|
||||
if virsh dominfo "$dom" &>/dev/null 2>&1; then
|
||||
echo " Snapshotting $dom..."
|
||||
run virsh snapshot-create-as "$dom" "$SNAPSHOT_NAME" \
|
||||
--description "Created by snapshot-all.sh" \
|
||||
--atomic
|
||||
echo " ✓ $dom → $SNAPSHOT_NAME"
|
||||
else
|
||||
echo " ⚠ $dom not found — skipping"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "Done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REVERT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$REVERT_NAME" ]; then
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " REVERT ALL VMs TO: $REVERT_NAME"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " VMs: ${VMS[*]}"
|
||||
echo " This will DISCARD all unsaved VM state."
|
||||
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode — no changes will be made]"
|
||||
echo ""
|
||||
|
||||
if [ "$ASSUME_YES" = "false" ] && [ "$DRY_RUN" = "false" ]; then
|
||||
read -rp " Type YES to confirm revert: " confirm
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
echo " Aborted."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
for dom in "${VMS[@]}"; do
|
||||
guard_prefix "$dom"
|
||||
if virsh dominfo "$dom" &>/dev/null 2>&1; then
|
||||
echo " Reverting $dom..."
|
||||
# Stop VM first if running
|
||||
if virsh domstate "$dom" 2>/dev/null | grep -q "running"; then
|
||||
run virsh destroy "$dom"
|
||||
fi
|
||||
run virsh snapshot-revert "$dom" "$REVERT_NAME" --running
|
||||
echo " ✓ $dom → $REVERT_NAME"
|
||||
else
|
||||
echo " ⚠ $dom not found — skipping"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo "Revert complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No mode selected
|
||||
echo "Usage:"
|
||||
echo " bash snapshot-all.sh --snapshot <name>"
|
||||
echo " bash snapshot-all.sh --revert-to <name>"
|
||||
echo " bash snapshot-all.sh --list"
|
||||
echo " Add --dry-run to preview without changes."
|
||||
exit 1
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# suppress-maintenance-noise.sh — Reduce guest maintenance output noise.
|
||||
#
|
||||
# Suppresses on Debian/Ubuntu guests:
|
||||
# - APT periodic background updates
|
||||
# - MOTD dynamic scripts (package counts, landscape-sysinfo, news)
|
||||
# - PAM motd modules (dynamic MOTD printed at login)
|
||||
# - "X updates can be applied immediately" login banner
|
||||
#
|
||||
# Suppresses on Arch guests:
|
||||
# - pkgfile update timer (if present)
|
||||
# - quiet-mode marker for game to detect
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DRY_RUN=false
|
||||
DOMAIN="${1:-}"
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
echo "Usage: bash tools/vm/suppress-maintenance-noise.sh <domain> [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${2:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
ensure_vm_tooling
|
||||
|
||||
tmp_script="$(mktemp)"
|
||||
cat > "$tmp_script" <<'EOF'
|
||||
# --- Debian/Ubuntu ---
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
# Disable APT background periodic tasks
|
||||
mkdir -p /etc/apt/apt.conf.d
|
||||
cat > /etc/apt/apt.conf.d/99sysadmin-chronicles-quiet <<'APT'
|
||||
APT::Periodic::Enable "0";
|
||||
APT::Periodic::Update-Package-Lists "0";
|
||||
APT::Periodic::Unattended-Upgrade "0";
|
||||
APT::Periodic::Download-Upgradeable-Packages "0";
|
||||
Acquire::Languages "none";
|
||||
APT
|
||||
|
||||
# Disable dynamic MOTD scripts that show update counts, ads, news
|
||||
if [ -d /etc/update-motd.d ]; then
|
||||
chmod -x /etc/update-motd.d/* 2>/dev/null || true
|
||||
# Preserve a minimal placeholder so PAM doesn't error
|
||||
printf '#!/bin/sh\n' > /etc/update-motd.d/00-sysadmin-chronicles
|
||||
chmod +x /etc/update-motd.d/00-sysadmin-chronicles
|
||||
fi
|
||||
|
||||
# Remove static /etc/motd content if present
|
||||
if [ -f /etc/motd ]; then
|
||||
printf '' > /etc/motd
|
||||
fi
|
||||
|
||||
# Disable PAM dynamic motd in sshd PAM config (suppresses update counts at login)
|
||||
for pam_file in /etc/pam.d/sshd /etc/pam.d/login; do
|
||||
if [ -f "$pam_file" ]; then
|
||||
sed -i 's/^session\s\+optional\s\+pam_motd\.so/#&/' "$pam_file"
|
||||
fi
|
||||
done
|
||||
|
||||
# Suppress "X updates can be applied" from landscape-sysinfo / update-notifier
|
||||
if [ -f /etc/landscape/client.conf ]; then
|
||||
sed -i '/sysinfo/d' /etc/landscape/client.conf 2>/dev/null || true
|
||||
fi
|
||||
# Disable landscape-sysinfo if installed
|
||||
if command -v landscape-sysinfo >/dev/null 2>&1; then
|
||||
if [ -f /etc/landscape/client.conf ]; then
|
||||
grep -q 'include_sysinfo_plugins' /etc/landscape/client.conf || \
|
||||
printf '[sysinfo]\ninclude_sysinfo_plugins =\n' >> /etc/landscape/client.conf
|
||||
else
|
||||
mkdir -p /etc/landscape
|
||||
printf '[sysinfo]\ninclude_sysinfo_plugins =\n' > /etc/landscape/client.conf
|
||||
fi
|
||||
fi
|
||||
|
||||
# Disable update-notifier login hint (Debian/Ubuntu)
|
||||
if [ -d /etc/profile.d ]; then
|
||||
for f in /etc/profile.d/update-notifier.sh /etc/profile.d/motd-news.sh; do
|
||||
[ -f "$f" ] && chmod -x "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Arch Linux ---
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
# Disable pkgfile update timer if present (produces periodic output)
|
||||
if systemctl list-unit-files pkgfile-update.timer &>/dev/null; then
|
||||
systemctl disable --now pkgfile-update.timer 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Quiet-mode marker for game to detect
|
||||
mkdir -p /etc/sysadmin-chronicles
|
||||
printf 'managed=true\n' > /etc/sysadmin-chronicles/quiet-mode.conf
|
||||
fi
|
||||
EOF
|
||||
|
||||
info "Suppressing maintenance noise on ${DOMAIN}"
|
||||
guest_run_sudo_script "$DOMAIN" "$tmp_script"
|
||||
rm -f "$tmp_script"
|
||||
ok "${DOMAIN}: maintenance noise suppressed"
|
||||
Reference in New Issue
Block a user