Refactor first-boot process to introduce ordered execution and new one-shot scripts
Revise the first-boot script to implement a structured approach with 13 numbered steps, allowing for better control over the execution order. Introduce two new one-shot scripts: `01-set-rotation-once.sh` and `02-set-wallpaper-once.sh`, replacing the previous single script approach. Update documentation to reflect these changes, including the new configuration options for enabling/disabling steps and the revised file structure for one-shot scripts. Enhance the dashboard to display first-boot progress, improving user feedback during the initial setup.
This commit is contained in:
@@ -46,6 +46,7 @@ BUILD_STATUS_FILE = Path(os.environ.get("CM4_BUILD_STATUS_FILE", str(BASE_DIR /
|
||||
BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR / "build_cloudinit_request.json")))
|
||||
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
|
||||
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
|
||||
FIRST_BOOT_STATUS_FILE = Path(os.environ.get("CM4_FIRST_BOOT_STATUS_FILE", str(BASE_DIR / "first_boot_status.json")))
|
||||
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
|
||||
PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json")))
|
||||
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
|
||||
@@ -587,6 +588,59 @@ def api_status_clear():
|
||||
return jsonify({"ok": False, "error": "Could not write status"}), 500
|
||||
|
||||
|
||||
def _first_boot_status_read():
|
||||
"""Read first-boot status (device reports progress during first-boot.sh)."""
|
||||
try:
|
||||
with open(FIRST_BOOT_STATUS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"phase": "idle", "message": "", "step": "", "step_name": "", "hostname": "", "ip": "", "updated": None}
|
||||
|
||||
|
||||
def _first_boot_status_write(phase, message="", step="", step_name="", hostname="", ip="", error=None):
|
||||
"""Write first-boot status (called by POST from device)."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(FIRST_BOOT_STATUS_FILE) or ".", exist_ok=True)
|
||||
data = {
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"step": step,
|
||||
"step_name": step_name,
|
||||
"hostname": hostname,
|
||||
"ip": ip or "",
|
||||
"updated": time.time(),
|
||||
}
|
||||
if error:
|
||||
data["error"] = error
|
||||
with open(FIRST_BOOT_STATUS_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/api/first-boot-status", methods=["GET"])
|
||||
def api_first_boot_status_get():
|
||||
"""Return current first-boot progress (for dashboard to poll)."""
|
||||
return jsonify(_first_boot_status_read())
|
||||
|
||||
|
||||
@app.route("/api/first-boot-status", methods=["POST"])
|
||||
def api_first_boot_status_post():
|
||||
"""Called by device during first-boot.sh to report progress. No auth (device on local net)."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
phase = (body.get("phase") or "running").strip().lower()
|
||||
if phase not in ("started", "running", "done", "error"):
|
||||
phase = "running"
|
||||
message = (body.get("message") or "").strip()[:500]
|
||||
step = (body.get("step") or "").strip()[:10]
|
||||
step_name = (body.get("step_name") or "").strip()[:80]
|
||||
hostname = (body.get("hostname") or "").strip()[:64]
|
||||
ip = (body.get("ip") or "").strip()[:45]
|
||||
error = (body.get("error") or "").strip()[:500] if phase == "error" else None
|
||||
_first_boot_status_write(phase=phase, message=message, step=step, step_name=step_name, hostname=hostname, ip=ip, error=error)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/log")
|
||||
def api_log():
|
||||
return jsonify({"log": read_log_tail()})
|
||||
@@ -653,6 +707,8 @@ def api_device_action():
|
||||
pass # host may still have SHRINK_BACKUP=1
|
||||
with open(ACTION_REQUEST_FILE, "w") as f:
|
||||
f.write(action)
|
||||
if action == "deploy":
|
||||
_first_boot_status_write("idle", "", hostname="", ip="")
|
||||
return jsonify({"ok": True})
|
||||
except (PermissionError, OSError):
|
||||
return jsonify({"ok": False, "error": "Could not write action file"}), 500
|
||||
@@ -669,6 +725,7 @@ def api_device_action():
|
||||
ip = d.get("ip") or mac
|
||||
if action == "deploy":
|
||||
_write_status("flashing", f"Deploying to {ip} (network)...")
|
||||
_first_boot_status_write("idle", "", hostname="", ip="")
|
||||
elif action == "backup":
|
||||
_write_status("backup", f"Backing up {ip} (network)...")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="firstBootCard" class="card" style="margin-bottom: 1rem; display:none;">
|
||||
<h2 class="card-title">First-boot progress</h2>
|
||||
<div id="firstBootStatus" class="status-row">
|
||||
<span id="firstBootStep" class="status-pill idle"></span>
|
||||
<span id="firstBootMsg" class="status-msg"></span>
|
||||
</div>
|
||||
<p id="firstBootIpWrap" style="margin-top: 0.5rem; font-size: 0.9rem; display:none;"><strong>Device IP:</strong> <code id="firstBootIp" class="mono"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Capture or deploy</h2>
|
||||
<p id="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState">—</span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button> <button type="button" id="dhcpNetbootEnableBtn" class="btn btn-outline btn-sm" style="display:none;">Enable network boot</button></p>
|
||||
@@ -230,6 +239,28 @@
|
||||
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
|
||||
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
|
||||
function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); }
|
||||
function renderFirstBootStatus(data) {
|
||||
const phase = data.phase || 'idle';
|
||||
const card = document.getElementById('firstBootCard');
|
||||
if (phase === 'idle' || phase === '') { card.style.display = 'none'; return; }
|
||||
card.style.display = 'block';
|
||||
const stepEl = document.getElementById('firstBootStep');
|
||||
const msgEl = document.getElementById('firstBootMsg');
|
||||
const ipWrap = document.getElementById('firstBootIpWrap');
|
||||
const ipEl = document.getElementById('firstBootIp');
|
||||
stepEl.textContent = data.step ? 'Step ' + data.step : (phase === 'done' ? 'Done' : phase);
|
||||
stepEl.className = 'status-pill ' + (phase === 'done' ? 'done' : phase === 'error' ? 'error' : 'flashing');
|
||||
msgEl.textContent = data.message || (phase === 'done' && data.ip ? 'First-boot finished. Device IP: ' + data.ip : '');
|
||||
if (phase === 'done' && data.ip) {
|
||||
ipWrap.style.display = 'block';
|
||||
ipEl.textContent = data.ip;
|
||||
} else {
|
||||
ipWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function fetchFirstBootStatus() {
|
||||
fetch('/api/first-boot-status').then(function(r){ return r.json(); }).then(renderFirstBootStatus).catch(function(){});
|
||||
}
|
||||
function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); }
|
||||
function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
|
||||
function fetchGolden() {
|
||||
@@ -286,10 +317,11 @@
|
||||
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
|
||||
.catch(function(){ alert('Request failed'); });
|
||||
});
|
||||
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases();
|
||||
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases(); fetchFirstBootStatus();
|
||||
setInterval(fetchStatus, 2000);
|
||||
setInterval(fetchLog, 4000);
|
||||
setInterval(fetchPending, 2000);
|
||||
setInterval(fetchFirstBootStatus, 3000);
|
||||
setInterval(fetchGolden, 10000);
|
||||
setInterval(fetchDhcpNetboot, 10000);
|
||||
setInterval(fetchDhcpLeases, 10000);
|
||||
|
||||
Reference in New Issue
Block a user