1542 lines
55 KiB
Nix
1542 lines
55 KiB
Nix
{ modulesPath, lib, pkgs, ... }:
|
|
|
|
let
|
|
pythonWithBoto3 = pkgs.python3.withPackages (ps: [ ps.boto3 ps.python-multipart ]);
|
|
photonVmDemoIamProto = pkgs.writeText "photon-vm-demo-iam.proto" (builtins.readFile ../../iam/proto/iam.proto);
|
|
photonVmDemoApi = pkgs.writeText "photon-vm-demo-api.py" ''
|
|
import html
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
from python_multipart import create_form_parser
|
|
|
|
DATA_MOUNT = "/mnt/photon-vm-data"
|
|
CONFIG_PATH = os.path.join(DATA_MOUNT, "demo-config.json")
|
|
ROOT_BOOT_COUNT_PATH = "/var/lib/photon-vm-smoke/boot-count"
|
|
DATA_BOOT_COUNT_PATH = os.path.join(DATA_MOUNT, "boot-count")
|
|
CONSOLE_PATH = "/dev/ttyS0"
|
|
LISTEN_HOST = "0.0.0.0"
|
|
LISTEN_PORT = 8080
|
|
GATEWAY_IP_FALLBACK = "10.62.10.1"
|
|
UPLOAD_TMP_DIR = os.path.join(DATA_MOUNT, ".upload-tmp")
|
|
MAX_IN_MEMORY_UPLOAD = 1024 * 1024
|
|
AWS_REGION = "us-east-1"
|
|
ORG_ID = "matrix-tenant-org"
|
|
PROJECT_ID = "matrix-tenant-project"
|
|
FLAREDB_SHARED_NAMESPACE = "validation"
|
|
STATE_OBJECT_KEY = "state.json"
|
|
ATTACHMENT_PREFIX = "attachments"
|
|
GRPCURL_BIN = "${pkgs.grpcurl}/bin/grpcurl"
|
|
IAM_PROTO_PATH = "${photonVmDemoIamProto}"
|
|
IAM_PROTO_DIR = os.path.dirname(IAM_PROTO_PATH)
|
|
IAM_PROTO_FILE = os.path.basename(IAM_PROTO_PATH)
|
|
UI_HTML = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Photon Tasks</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f5efe4;
|
|
--panel: rgba(255, 252, 245, 0.9);
|
|
--panel-strong: #fffaf0;
|
|
--text: #1f1e1a;
|
|
--muted: #6a6459;
|
|
--line: rgba(60, 52, 38, 0.14);
|
|
--accent: #166b4a;
|
|
--accent-soft: rgba(22, 107, 74, 0.12);
|
|
--warn: #8b3a24;
|
|
--shadow: 0 24px 60px rgba(50, 40, 24, 0.12);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(232, 174, 92, 0.22), transparent 30%),
|
|
radial-gradient(circle at top right, rgba(71, 140, 114, 0.18), transparent 28%),
|
|
linear-gradient(180deg, #fbf7ef 0%, var(--bg) 55%, #eee5d3 100%);
|
|
color: var(--text);
|
|
font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif;
|
|
}
|
|
|
|
.shell {
|
|
width: min(980px, calc(100vw - 32px));
|
|
margin: 0 auto;
|
|
padding: 28px 0 56px;
|
|
}
|
|
|
|
.hero {
|
|
padding: 32px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.eyebrow {
|
|
margin: 0 0 10px;
|
|
font-size: 12px;
|
|
letter-spacing: 0.22em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(38px, 8vw, 64px);
|
|
line-height: 0.96;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.lede {
|
|
max-width: 52rem;
|
|
margin: 18px 0 0;
|
|
color: var(--muted);
|
|
font-size: 18px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
gap: 18px;
|
|
grid-template-columns: 1.2fr 0.8fr;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.panel-inner {
|
|
padding: 24px;
|
|
}
|
|
|
|
.panel h2 {
|
|
margin: 0 0 10px;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.panel p {
|
|
margin: 0;
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.composer {
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
|
|
input[type="text"],
|
|
textarea,
|
|
input[type="file"] {
|
|
width: 100%;
|
|
border: 1px solid rgba(47, 39, 26, 0.16);
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.82);
|
|
color: var(--text);
|
|
font: inherit;
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
textarea {
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.actions,
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
button {
|
|
border: none;
|
|
border-radius: 999px;
|
|
padding: 12px 18px;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
background: var(--accent);
|
|
color: #fbf8f1;
|
|
transition: transform 120ms ease, opacity 120ms ease;
|
|
}
|
|
|
|
button.secondary {
|
|
background: rgba(31, 30, 26, 0.08);
|
|
color: var(--text);
|
|
}
|
|
|
|
button:hover { transform: translateY(-1px); }
|
|
button:disabled { opacity: 0.6; cursor: wait; transform: none; }
|
|
|
|
.status {
|
|
min-height: 24px;
|
|
margin-top: 10px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.status.error {
|
|
color: var(--warn);
|
|
}
|
|
|
|
.summary-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.stat {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
background: var(--panel-strong);
|
|
border: 1px solid var(--line);
|
|
}
|
|
|
|
.stat strong {
|
|
display: block;
|
|
font-size: 30px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat span {
|
|
display: block;
|
|
margin-top: 8px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.meta {
|
|
margin-top: 18px;
|
|
padding-top: 18px;
|
|
border-top: 1px solid var(--line);
|
|
font-size: 14px;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.todo-list {
|
|
list-style: none;
|
|
margin: 18px 0 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
|
|
.todo-card {
|
|
padding: 18px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 255, 255, 0.74);
|
|
}
|
|
|
|
.todo-card.done {
|
|
background: rgba(22, 107, 74, 0.08);
|
|
}
|
|
|
|
.todo-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.todo-title {
|
|
margin: 0;
|
|
font-size: 22px;
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.todo-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.todo-card.done .todo-badge {
|
|
background: rgba(31, 30, 26, 0.08);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.todo-details {
|
|
margin: 14px 0 0;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.attachment {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-top: 16px;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 249, 239, 0.92);
|
|
}
|
|
|
|
.attachment a {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.attachment small {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.empty {
|
|
margin-top: 18px;
|
|
padding: 26px;
|
|
border: 1px dashed rgba(47, 39, 26, 0.2);
|
|
border-radius: 20px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
background: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
.grid { grid-template-columns: 1fr; }
|
|
.hero { padding: 28px 24px; }
|
|
.panel-inner { padding: 20px; }
|
|
.summary-grid { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
|
|
@media (max-width: 540px) {
|
|
.shell { width: min(100vw - 20px, 980px); }
|
|
.summary-grid { grid-template-columns: 1fr; }
|
|
.todo-top,
|
|
.attachment,
|
|
.actions,
|
|
.toolbar { flex-direction: column; align-items: stretch; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<section class="panel hero">
|
|
<p class="eyebrow">UltraCloud demo</p>
|
|
<h1>Photon Tasks</h1>
|
|
<p class="lede">
|
|
TODO items live in FlareDB, attached files live in LightningStor, and the whole app stays alive across restart and migration.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="grid">
|
|
<section class="panel">
|
|
<div class="panel-inner">
|
|
<div class="toolbar">
|
|
<div>
|
|
<h2>New Task</h2>
|
|
<p>Add a title, optional notes, and a file attachment.</p>
|
|
</div>
|
|
<button id="refresh-button" class="secondary" type="button">Refresh</button>
|
|
</div>
|
|
|
|
<form id="todo-form" class="composer">
|
|
<div>
|
|
<label for="title">Title</label>
|
|
<input id="title" name="title" type="text" maxlength="120" placeholder="Example: Ship nested VM demo">
|
|
</div>
|
|
<div>
|
|
<label for="details">Details</label>
|
|
<textarea id="details" name="details" maxlength="1200" placeholder="Short note for the task"></textarea>
|
|
</div>
|
|
<div>
|
|
<label for="attachment">Attachment</label>
|
|
<input id="attachment" name="attachment" type="file">
|
|
</div>
|
|
<div class="actions">
|
|
<p>Files are uploaded into LightningStor and served back through the guest app.</p>
|
|
<button id="submit-button" type="submit">Create Task</button>
|
|
</div>
|
|
</form>
|
|
<div id="status" class="status" aria-live="polite"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-inner">
|
|
<h2>Cluster Snapshot</h2>
|
|
<p>Useful while you are bouncing the VM around the cluster.</p>
|
|
<div class="summary-grid">
|
|
<div class="stat">
|
|
<strong id="todo-count">0</strong>
|
|
<span>Tasks</span>
|
|
</div>
|
|
<div class="stat">
|
|
<strong id="attachment-count">0</strong>
|
|
<span>Attachments</span>
|
|
</div>
|
|
<div class="stat">
|
|
<strong id="root-boots">0</strong>
|
|
<span>Root boots</span>
|
|
</div>
|
|
<div class="stat">
|
|
<strong id="data-boots">0</strong>
|
|
<span>Data disk boots</span>
|
|
</div>
|
|
</div>
|
|
<div class="meta">
|
|
<div><strong>Bucket:</strong> <span id="bucket-name">-</span></div>
|
|
<div><strong>FlareDB namespace:</strong> <span id="namespace-name">-</span></div>
|
|
<div><strong>Last task:</strong> <span id="latest-task">No tasks yet</span></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="panel" style="margin-top: 20px;">
|
|
<div class="panel-inner">
|
|
<div class="toolbar">
|
|
<div>
|
|
<h2>Tasks</h2>
|
|
<p>Mark tasks done or open the attached file straight from the VM.</p>
|
|
</div>
|
|
</div>
|
|
<div id="todo-empty" class="empty">No tasks yet. Add one with a file to exercise FlareDB and LightningStor together.</div>
|
|
<ul id="todo-list" class="todo-list"></ul>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function formatBytes(size) {
|
|
if (!size) {
|
|
return "0 B";
|
|
}
|
|
if (size < 1024) {
|
|
return size + " B";
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return (size / 1024).toFixed(1) + " KiB";
|
|
}
|
|
return (size / (1024 * 1024)).toFixed(1) + " MiB";
|
|
}
|
|
|
|
async function requestJson(url, options) {
|
|
const response = await fetch(url, options || {});
|
|
const text = await response.text();
|
|
let payload = {};
|
|
if (text) {
|
|
payload = JSON.parse(text);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(payload.detail || payload.error || ("HTTP " + response.status));
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function setStatus(message, isError) {
|
|
const node = document.getElementById("status");
|
|
node.textContent = message || "";
|
|
node.className = isError ? "status error" : "status";
|
|
}
|
|
|
|
function renderTodo(todo) {
|
|
const details = todo.details ? '<p class="todo-details">' + escapeHtml(todo.details) + '</p>' : "";
|
|
const stateLabel = todo.done ? "Done" : "Open";
|
|
const toggleLabel = todo.done ? "Reopen" : "Mark Done";
|
|
let attachment = "";
|
|
if (todo.attachment) {
|
|
attachment =
|
|
'<div class="attachment">' +
|
|
'<div>' +
|
|
'<a href="' + escapeHtml(todo.attachment_url) + '">' + escapeHtml(todo.attachment.filename) + '</a>' +
|
|
'<small>' + escapeHtml(formatBytes(todo.attachment.size)) + " from LightningStor" + '</small>' +
|
|
'</div>' +
|
|
'<button class="secondary" type="button" data-download-url="' + escapeHtml(todo.attachment_url) + '">Open File</button>' +
|
|
'</div>';
|
|
}
|
|
return (
|
|
'<li class="todo-card' + (todo.done ? " done" : "") + '">' +
|
|
'<div class="todo-top">' +
|
|
'<div>' +
|
|
'<h3 class="todo-title">' + escapeHtml(todo.title) + '</h3>' +
|
|
'<div class="todo-badge">Task #' + String(todo.id).padStart(2, "0") + " • " + stateLabel + '</div>' +
|
|
'</div>' +
|
|
'<button class="secondary" type="button" data-toggle-id="' + escapeHtml(String(todo.id)) + '">' + toggleLabel + '</button>' +
|
|
'</div>' +
|
|
details +
|
|
attachment +
|
|
'</li>'
|
|
);
|
|
}
|
|
|
|
function updateSummary(state) {
|
|
document.getElementById("todo-count").textContent = String(state.todo_count || 0);
|
|
document.getElementById("attachment-count").textContent = String(state.attachment_count || 0);
|
|
document.getElementById("root-boots").textContent = String(state.root_boot_count || 0);
|
|
document.getElementById("data-boots").textContent = String(state.data_boot_count || 0);
|
|
document.getElementById("bucket-name").textContent = state.bucket || "-";
|
|
document.getElementById("namespace-name").textContent = state.state_namespace || "-";
|
|
document.getElementById("latest-task").textContent = state.latest_todo_title || "No tasks yet";
|
|
}
|
|
|
|
function updateTodos(state) {
|
|
const list = document.getElementById("todo-list");
|
|
const empty = document.getElementById("todo-empty");
|
|
const todos = Array.isArray(state.todos) ? state.todos : [];
|
|
if (todos.length === 0) {
|
|
list.innerHTML = "";
|
|
empty.style.display = "block";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
list.innerHTML = todos.map(renderTodo).join("");
|
|
}
|
|
|
|
async function loadState() {
|
|
const state = await requestJson("/api/todos");
|
|
updateSummary(state);
|
|
updateTodos(state);
|
|
return state;
|
|
}
|
|
|
|
async function refresh() {
|
|
setStatus("Refreshing app state...", false);
|
|
try {
|
|
await loadState();
|
|
setStatus("State refreshed.", false);
|
|
} catch (error) {
|
|
setStatus(error.message, true);
|
|
}
|
|
}
|
|
|
|
async function submitTodo(event) {
|
|
event.preventDefault();
|
|
const form = document.getElementById("todo-form");
|
|
const button = document.getElementById("submit-button");
|
|
const formData = new FormData(form);
|
|
button.disabled = true;
|
|
setStatus("Uploading task and attachment...", false);
|
|
try {
|
|
await requestJson("/api/todos", { method: "POST", body: formData });
|
|
form.reset();
|
|
await loadState();
|
|
setStatus("Task saved.", false);
|
|
} catch (error) {
|
|
setStatus(error.message, true);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function onListClick(event) {
|
|
const toggleId = event.target.getAttribute("data-toggle-id");
|
|
if (toggleId) {
|
|
setStatus("Updating task...", false);
|
|
try {
|
|
await requestJson("/api/todos/" + toggleId + "/toggle", { method: "POST" });
|
|
await loadState();
|
|
setStatus("Task updated.", false);
|
|
} catch (error) {
|
|
setStatus(error.message, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const downloadUrl = event.target.getAttribute("data-download-url");
|
|
if (downloadUrl) {
|
|
window.open(downloadUrl, "_blank");
|
|
}
|
|
}
|
|
|
|
document.getElementById("todo-form").addEventListener("submit", submitTodo);
|
|
document.getElementById("todo-list").addEventListener("click", onListClick);
|
|
document.getElementById("refresh-button").addEventListener("click", refresh);
|
|
refresh();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def detect_default_gateway() -> str:
|
|
try:
|
|
with open("/proc/net/route", "r", encoding="utf-8") as handle:
|
|
next(handle, None)
|
|
for line in handle:
|
|
fields = line.strip().split()
|
|
if len(fields) < 4:
|
|
continue
|
|
if fields[1] != "00000000" or fields[2] == "00000000":
|
|
continue
|
|
if not (int(fields[3], 16) & 0x2):
|
|
continue
|
|
return socket.inet_ntoa(struct.pack("<L", int(fields[2], 16)))
|
|
except (FileNotFoundError, OSError, StopIteration, ValueError):
|
|
pass
|
|
return GATEWAY_IP_FALLBACK
|
|
|
|
|
|
CONTROL_PLANE_PROXY_HOST = detect_default_gateway()
|
|
FLAREDB_BASE_URL = "http://%s:8082" % CONTROL_PLANE_PROXY_HOST
|
|
LIGHTNINGSTOR_S3_ENDPOINT = "http://%s:9000" % CONTROL_PLANE_PROXY_HOST
|
|
IAM_ENDPOINT = "%s:50080" % CONTROL_PLANE_PROXY_HOST
|
|
|
|
BACKEND_LOCK = threading.RLock()
|
|
MUTATION_LOCK = threading.Lock()
|
|
BACKEND = {
|
|
"ready": False,
|
|
"config": None,
|
|
"s3_client": None,
|
|
"last_error": "",
|
|
}
|
|
|
|
|
|
def log_console(message: str) -> None:
|
|
try:
|
|
with open(CONSOLE_PATH, "a", encoding="utf-8") as console:
|
|
console.write(message + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def sanitize(message: str) -> str:
|
|
return message.replace("\n", " ").replace("\r", " ")[:240]
|
|
|
|
|
|
def read_int(path: str) -> int:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return int(handle.read().strip() or "0")
|
|
except (FileNotFoundError, OSError, ValueError):
|
|
return 0
|
|
|
|
|
|
def write_json_atomic(path: str, payload: dict) -> None:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp_path = path + ".tmp"
|
|
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
os.replace(tmp_path, path)
|
|
|
|
|
|
def now_iso() -> str:
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
|
|
|
|
def safe_filename(name: str) -> str:
|
|
base = os.path.basename(name or "attachment.bin").strip()
|
|
cleaned = []
|
|
for char in base:
|
|
if char.isalnum() or char in "._-":
|
|
cleaned.append(char)
|
|
else:
|
|
cleaned.append("-")
|
|
value = "".join(cleaned).strip(".-")
|
|
return value or "attachment.bin"
|
|
|
|
|
|
def load_or_create_config() -> dict:
|
|
if os.path.exists(CONFIG_PATH):
|
|
with open(CONFIG_PATH, "r", encoding="utf-8") as handle:
|
|
config = json.load(handle)
|
|
else:
|
|
suffix = uuid.uuid4().hex[:12]
|
|
prefix = "vm-demo-" + suffix
|
|
config = {
|
|
"principal_id": prefix,
|
|
"bucket": prefix,
|
|
"todo_prefix": prefix + "/todos/",
|
|
"next_id_key": prefix + "/next-id",
|
|
"state_namespace": FLAREDB_SHARED_NAMESPACE,
|
|
"access_key_id": "",
|
|
"secret_key": "",
|
|
}
|
|
write_json_atomic(CONFIG_PATH, config)
|
|
|
|
if "todo_prefix" not in config:
|
|
legacy_prefix = config.get("principal_id") or ("vm-demo-" + uuid.uuid4().hex[:12])
|
|
config["todo_prefix"] = legacy_prefix + "/todos/"
|
|
if "next_id_key" not in config:
|
|
config["next_id_key"] = config["todo_prefix"].rstrip("/") + "/next-id"
|
|
write_json_atomic(CONFIG_PATH, config)
|
|
return config
|
|
|
|
|
|
def grpcurl_json(endpoint: str, proto_dir: str, proto_file: str, service: str, payload: dict, ignore_errors=()) -> dict:
|
|
command = [
|
|
GRPCURL_BIN,
|
|
"-plaintext",
|
|
"-import-path",
|
|
proto_dir,
|
|
"-proto",
|
|
proto_file,
|
|
"-d",
|
|
json.dumps(payload, sort_keys=True),
|
|
endpoint,
|
|
service,
|
|
]
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
combined = (result.stdout or "") + "\n" + (result.stderr or "")
|
|
if result.returncode != 0:
|
|
lowered = combined.lower()
|
|
for pattern in ignore_errors:
|
|
if pattern.lower() in lowered:
|
|
return {}
|
|
raise RuntimeError("grpcurl %s failed: %s" % (service, sanitize(combined)))
|
|
output = result.stdout.strip()
|
|
return json.loads(output) if output else {}
|
|
|
|
|
|
def http_json(method: str, url: str, payload=None, timeout: int = 10) -> dict:
|
|
body = None
|
|
headers = {}
|
|
if payload is not None:
|
|
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def ensure_service_account(config: dict) -> None:
|
|
grpcurl_json(
|
|
IAM_ENDPOINT,
|
|
IAM_PROTO_DIR,
|
|
IAM_PROTO_FILE,
|
|
"iam.v1.IamAdmin/CreatePrincipal",
|
|
{
|
|
"id": config["principal_id"],
|
|
"kind": "PRINCIPAL_KIND_SERVICE_ACCOUNT",
|
|
"name": config["principal_id"],
|
|
"orgId": ORG_ID,
|
|
"projectId": PROJECT_ID,
|
|
},
|
|
ignore_errors=("already exists", "alreadyexists"),
|
|
)
|
|
|
|
|
|
def ensure_s3_credentials(config: dict) -> None:
|
|
if config.get("access_key_id") and config.get("secret_key"):
|
|
return
|
|
|
|
response = grpcurl_json(
|
|
IAM_ENDPOINT,
|
|
IAM_PROTO_DIR,
|
|
IAM_PROTO_FILE,
|
|
"iam.v1.IamCredential/CreateS3Credential",
|
|
{
|
|
"principalId": config["principal_id"],
|
|
"principalKind": "PRINCIPAL_KIND_SERVICE_ACCOUNT",
|
|
"orgId": ORG_ID,
|
|
"projectId": PROJECT_ID,
|
|
"description": "vm-demo",
|
|
},
|
|
)
|
|
config["access_key_id"] = response["accessKeyId"]
|
|
config["secret_key"] = response["secretKey"]
|
|
write_json_atomic(CONFIG_PATH, config)
|
|
|
|
|
|
def s3_client(config: dict):
|
|
return boto3.session.Session().client(
|
|
"s3",
|
|
endpoint_url=LIGHTNINGSTOR_S3_ENDPOINT,
|
|
region_name=AWS_REGION,
|
|
aws_access_key_id=config["access_key_id"],
|
|
aws_secret_access_key=config["secret_key"],
|
|
use_ssl=False,
|
|
verify=False,
|
|
config=Config(
|
|
retries={"max_attempts": 8, "mode": "standard"},
|
|
s3={"addressing_style": "path"},
|
|
signature_version="s3v4",
|
|
),
|
|
)
|
|
|
|
|
|
def ensure_bucket(config: dict, client) -> None:
|
|
try:
|
|
client.head_bucket(Bucket=config["bucket"])
|
|
return
|
|
except ClientError as error:
|
|
code = str(error.response.get("Error", {}).get("Code", ""))
|
|
if code not in ("404", "NoSuchBucket", "NotFound", "400", "403"):
|
|
raise
|
|
client.create_bucket(Bucket=config["bucket"])
|
|
|
|
|
|
def flaredb_scan(config: dict, start: str, end: str) -> list:
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"start": start,
|
|
"end": end,
|
|
"namespace": config["state_namespace"],
|
|
}
|
|
)
|
|
payload = http_json("GET", FLAREDB_BASE_URL + "/api/v1/scan?" + query)
|
|
return payload.get("data", {}).get("items", [])
|
|
|
|
|
|
def flaredb_get_value(config: dict, key: str):
|
|
items = flaredb_scan(config, key, key + "~")
|
|
if not items:
|
|
return None
|
|
return items[0].get("value")
|
|
|
|
|
|
def flaredb_put_value(config: dict, key: str, value: str) -> None:
|
|
http_json(
|
|
"PUT",
|
|
FLAREDB_BASE_URL + "/api/v1/kv/" + urllib.parse.quote(key, safe=""),
|
|
{
|
|
"value": value,
|
|
"namespace": config["state_namespace"],
|
|
},
|
|
)
|
|
|
|
|
|
def flaredb_put_json(config: dict, key: str, payload: dict) -> None:
|
|
flaredb_put_value(config, key, json.dumps(payload, sort_keys=True))
|
|
|
|
|
|
def flaredb_get_json(config: dict, key: str):
|
|
value = flaredb_get_value(config, key)
|
|
if value is None:
|
|
return None
|
|
return json.loads(value)
|
|
|
|
|
|
def flaredb_next_todo_id(config: dict) -> int:
|
|
value = flaredb_get_value(config, config["next_id_key"])
|
|
if value is None:
|
|
return 1
|
|
return int(value)
|
|
|
|
|
|
def todo_record_key(config: dict, todo_id: int) -> str:
|
|
return config["todo_prefix"] + ("%06d" % todo_id)
|
|
|
|
|
|
def todo_attachment_key(todo_id: int, filename: str) -> str:
|
|
return ATTACHMENT_PREFIX + "/" + ("%06d" % todo_id) + "/" + safe_filename(filename)
|
|
|
|
|
|
def todo_view(todo: dict) -> dict:
|
|
attachment = todo.get("attachment")
|
|
view = {
|
|
"id": int(todo["id"]),
|
|
"title": todo.get("title", ""),
|
|
"details": todo.get("details", ""),
|
|
"done": bool(todo.get("done", False)),
|
|
"created_at": todo.get("created_at", ""),
|
|
"updated_at": todo.get("updated_at", ""),
|
|
"attachment": None,
|
|
"attachment_url": "",
|
|
}
|
|
if isinstance(attachment, dict):
|
|
view["attachment"] = {
|
|
"filename": attachment.get("filename", ""),
|
|
"content_type": attachment.get("content_type", "application/octet-stream"),
|
|
"object_key": attachment.get("object_key", ""),
|
|
"size": int(attachment.get("size", 0)),
|
|
}
|
|
view["attachment_url"] = "/api/todos/%s/attachment" % int(todo["id"])
|
|
return view
|
|
|
|
|
|
def list_todos(config: dict) -> list:
|
|
todos = []
|
|
for item in flaredb_scan(config, config["todo_prefix"], config["todo_prefix"] + "~"):
|
|
try:
|
|
payload = json.loads(item.get("value", "{}"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(payload, dict) and "id" in payload:
|
|
todos.append(payload)
|
|
todos.sort(key=lambda todo: int(todo.get("id", 0)))
|
|
return todos
|
|
|
|
|
|
def snapshot_payload(config: dict, todos: list) -> dict:
|
|
latest = todos[-1] if todos else None
|
|
latest_attachment = latest.get("attachment") if isinstance(latest, dict) else None
|
|
return {
|
|
"bucket": config["bucket"],
|
|
"hostname": socket.gethostname(),
|
|
"latest_object_key": STATE_OBJECT_KEY,
|
|
"latest_todo_id": int(latest["id"]) if latest else 0,
|
|
"latest_todo_title": latest.get("title", "") if latest else "",
|
|
"latest_attachment_object_key": latest_attachment.get("object_key", "") if isinstance(latest_attachment, dict) else "",
|
|
"latest_attachment_filename": latest_attachment.get("filename", "") if isinstance(latest_attachment, dict) else "",
|
|
"object_store_backend": "lightningstor",
|
|
"object_store_endpoint": LIGHTNINGSTOR_S3_ENDPOINT,
|
|
"root_boot_count": read_int(ROOT_BOOT_COUNT_PATH),
|
|
"data_boot_count": read_int(DATA_BOOT_COUNT_PATH),
|
|
"state_backend": "flaredb",
|
|
"state_endpoint": FLAREDB_BASE_URL,
|
|
"state_namespace": config["state_namespace"],
|
|
"todo_prefix": config["todo_prefix"],
|
|
"next_id_key": config["next_id_key"],
|
|
"todo_count": len(todos),
|
|
"attachment_count": sum(1 for todo in todos if isinstance(todo.get("attachment"), dict)),
|
|
"todos": [todo_view(todo) for todo in todos],
|
|
}
|
|
|
|
|
|
def write_snapshot(config: dict, client, todos: list) -> dict:
|
|
payload = snapshot_payload(config, todos)
|
|
encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
client.put_object(
|
|
Bucket=config["bucket"],
|
|
Key=STATE_OBJECT_KEY,
|
|
Body=encoded,
|
|
ContentType="application/json",
|
|
)
|
|
return payload
|
|
|
|
|
|
def read_snapshot(config: dict, client) -> dict:
|
|
response = client.get_object(Bucket=config["bucket"], Key=STATE_OBJECT_KEY)
|
|
return json.loads(response["Body"].read().decode("utf-8"))
|
|
|
|
|
|
def response_payload(config: dict, todos: list, snapshot: dict) -> dict:
|
|
return {
|
|
"status": "ok",
|
|
"hostname": socket.gethostname(),
|
|
"listen_port": LISTEN_PORT,
|
|
"root_boot_count": read_int(ROOT_BOOT_COUNT_PATH),
|
|
"data_boot_count": read_int(DATA_BOOT_COUNT_PATH),
|
|
"state_backend": "flaredb",
|
|
"state_endpoint": FLAREDB_BASE_URL,
|
|
"state_namespace": config["state_namespace"],
|
|
"todo_prefix": config["todo_prefix"],
|
|
"next_id_key": config["next_id_key"],
|
|
"object_store_backend": "lightningstor",
|
|
"object_store_endpoint": LIGHTNINGSTOR_S3_ENDPOINT,
|
|
"bucket": config["bucket"],
|
|
"latest_object_key": snapshot["latest_object_key"],
|
|
"latest_todo_id": int(snapshot["latest_todo_id"]),
|
|
"latest_todo_title": snapshot["latest_todo_title"],
|
|
"latest_attachment_object_key": snapshot["latest_attachment_object_key"],
|
|
"latest_attachment_filename": snapshot["latest_attachment_filename"],
|
|
"latest_object_todo_count": int(snapshot["todo_count"]),
|
|
"todo_count": len(todos),
|
|
"attachment_count": sum(1 for todo in todos if isinstance(todo.get("attachment"), dict)),
|
|
"todos": [todo_view(todo) for todo in todos],
|
|
}
|
|
|
|
|
|
def bootstrap() -> None:
|
|
deadline = time.time() + 120
|
|
while True:
|
|
try:
|
|
config = load_or_create_config()
|
|
ensure_service_account(config)
|
|
ensure_s3_credentials(config)
|
|
client = s3_client(config)
|
|
ensure_bucket(config, client)
|
|
if flaredb_get_value(config, config["next_id_key"]) is None:
|
|
flaredb_put_value(config, config["next_id_key"], "1")
|
|
todos = list_todos(config)
|
|
write_snapshot(config, client, todos)
|
|
with BACKEND_LOCK:
|
|
BACKEND["ready"] = True
|
|
BACKEND["config"] = config
|
|
BACKEND["s3_client"] = client
|
|
BACKEND["last_error"] = ""
|
|
return
|
|
except Exception as error:
|
|
message = sanitize(str(error))
|
|
with BACKEND_LOCK:
|
|
BACKEND["last_error"] = message
|
|
if time.time() >= deadline:
|
|
raise RuntimeError(message)
|
|
log_console("PHOTON_VM_DEMO_BOOTSTRAP_RETRY detail=%s" % message)
|
|
time.sleep(2)
|
|
|
|
|
|
def get_backend():
|
|
with BACKEND_LOCK:
|
|
if not BACKEND["ready"]:
|
|
raise RuntimeError(BACKEND["last_error"] or "backend not ready")
|
|
return BACKEND["config"], BACKEND["s3_client"]
|
|
|
|
|
|
def current_state() -> dict:
|
|
config, client = get_backend()
|
|
todos = list_todos(config)
|
|
snapshot = write_snapshot(config, client, todos)
|
|
snapshot = read_snapshot(config, client)
|
|
return response_payload(config, todos, snapshot)
|
|
|
|
|
|
def create_todo(title: str, details: str, attachment_name: str, attachment_file, attachment_size: int, attachment_content_type: str) -> dict:
|
|
config, client = get_backend()
|
|
try:
|
|
with MUTATION_LOCK:
|
|
todo_id = flaredb_next_todo_id(config)
|
|
created_at = now_iso()
|
|
attachment = None
|
|
safe_name = safe_filename(attachment_name)
|
|
|
|
if attachment_file is not None and attachment_size > 0:
|
|
object_key = todo_attachment_key(todo_id, safe_name)
|
|
attachment_file.file_object.seek(0)
|
|
client.put_object(
|
|
Bucket=config["bucket"],
|
|
Key=object_key,
|
|
Body=attachment_file.file_object,
|
|
ContentLength=attachment_size,
|
|
ContentType=attachment_content_type or "application/octet-stream",
|
|
)
|
|
attachment = {
|
|
"filename": safe_name,
|
|
"content_type": attachment_content_type or "application/octet-stream",
|
|
"object_key": object_key,
|
|
"size": attachment_size,
|
|
}
|
|
|
|
todo = {
|
|
"id": todo_id,
|
|
"title": title.strip() or "Untitled task",
|
|
"details": details.strip(),
|
|
"done": False,
|
|
"created_at": created_at,
|
|
"updated_at": created_at,
|
|
"attachment": attachment,
|
|
}
|
|
flaredb_put_json(config, todo_record_key(config, todo_id), todo)
|
|
flaredb_put_value(config, config["next_id_key"], str(todo_id + 1))
|
|
|
|
todos = list_todos(config)
|
|
snapshot = write_snapshot(config, client, todos)
|
|
snapshot = read_snapshot(config, client)
|
|
return response_payload(config, todos, snapshot)
|
|
finally:
|
|
if attachment_file is not None:
|
|
try:
|
|
attachment_file.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def toggle_todo(todo_id: int) -> dict:
|
|
config, client = get_backend()
|
|
with MUTATION_LOCK:
|
|
todo = flaredb_get_json(config, todo_record_key(config, todo_id))
|
|
if not isinstance(todo, dict):
|
|
raise KeyError("todo %s not found" % todo_id)
|
|
|
|
todo["done"] = not bool(todo.get("done", False))
|
|
todo["updated_at"] = now_iso()
|
|
flaredb_put_json(config, todo_record_key(config, todo_id), todo)
|
|
|
|
todos = list_todos(config)
|
|
snapshot = write_snapshot(config, client, todos)
|
|
snapshot = read_snapshot(config, client)
|
|
return response_payload(config, todos, snapshot)
|
|
|
|
|
|
def attachment_response(todo_id: int):
|
|
config, client = get_backend()
|
|
todo = flaredb_get_json(config, todo_record_key(config, todo_id))
|
|
if not isinstance(todo, dict):
|
|
raise KeyError("todo %s not found" % todo_id)
|
|
attachment = todo.get("attachment")
|
|
if not isinstance(attachment, dict):
|
|
raise FileNotFoundError("todo %s has no attachment" % todo_id)
|
|
|
|
response = client.get_object(Bucket=config["bucket"], Key=attachment["object_key"])
|
|
return {
|
|
"filename": attachment.get("filename", "attachment.bin"),
|
|
"content_type": attachment.get("content_type", "application/octet-stream"),
|
|
"body": response["Body"].read(),
|
|
}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "PhotonVMDemo/2.0"
|
|
|
|
def log_message(self, format: str, *args) -> None:
|
|
return
|
|
|
|
def _send_bytes(self, body: bytes, status: int, content_type: str, extra_headers=None) -> None:
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
if extra_headers:
|
|
for key, value in extra_headers.items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_json(self, payload: dict, status: int = HTTPStatus.OK) -> None:
|
|
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
self._send_bytes(body, status, "application/json")
|
|
|
|
def _send_html(self, body: str) -> None:
|
|
self._send_bytes(body.encode("utf-8"), HTTPStatus.OK, "text/html; charset=utf-8")
|
|
|
|
def _parsed_path(self):
|
|
return urllib.parse.urlparse(self.path).path
|
|
|
|
def _todo_id_from_path(self, path: str, suffix: str):
|
|
prefix = "/api/todos/"
|
|
if not path.startswith(prefix) or not path.endswith(suffix):
|
|
return None
|
|
value = path[len(prefix):len(path) - len(suffix)]
|
|
if not value.isdigit():
|
|
return None
|
|
return int(value)
|
|
|
|
def _parse_todo_submission(self):
|
|
content_type = self.headers.get("Content-Type", "")
|
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
|
|
|
if content_type.startswith("application/json"):
|
|
raw = self.rfile.read(content_length)
|
|
payload = json.loads(raw.decode("utf-8") or "{}")
|
|
return {
|
|
"title": str(payload.get("title", "")),
|
|
"details": str(payload.get("details", "")),
|
|
"attachment_name": "",
|
|
"attachment_file": None,
|
|
"attachment_size": 0,
|
|
"attachment_content_type": "",
|
|
}
|
|
|
|
if content_type.startswith("multipart/form-data"):
|
|
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
|
|
fields = {}
|
|
files = {}
|
|
headers = {
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(content_length),
|
|
}
|
|
|
|
def on_field(field) -> None:
|
|
field_name = (field.field_name or b"").decode("utf-8", errors="replace")
|
|
fields[field_name] = (field.value or b"").decode("utf-8", errors="replace")
|
|
|
|
def on_file(file_obj) -> None:
|
|
field_name = (file_obj.field_name or b"").decode("utf-8", errors="replace")
|
|
files[field_name] = file_obj
|
|
|
|
try:
|
|
parser = create_form_parser(
|
|
headers,
|
|
on_field,
|
|
on_file,
|
|
config={
|
|
"UPLOAD_DIR": UPLOAD_TMP_DIR,
|
|
"UPLOAD_DELETE_TMP": True,
|
|
"MAX_MEMORY_FILE_SIZE": MAX_IN_MEMORY_UPLOAD,
|
|
},
|
|
)
|
|
bytes_read = 0
|
|
while bytes_read < content_length:
|
|
chunk = self.rfile.read(min(262144, content_length - bytes_read))
|
|
if not chunk:
|
|
break
|
|
parser.write(chunk)
|
|
bytes_read += len(chunk)
|
|
parser.finalize()
|
|
except Exception:
|
|
for file_obj in files.values():
|
|
try:
|
|
file_obj.close()
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
title = fields.get("title", "")
|
|
details = fields.get("details", "")
|
|
attachment_file = files.get("attachment")
|
|
attachment_name = ""
|
|
attachment_size = 0
|
|
attachment_content_type = ""
|
|
|
|
if attachment_file is not None and int(attachment_file.size or 0) > 0:
|
|
attachment_name = (attachment_file.file_name or b"").decode("utf-8", errors="replace")
|
|
attachment_size = int(attachment_file.size or 0)
|
|
attachment_content_type = mimetypes.guess_type(attachment_name)[0] or "application/octet-stream"
|
|
|
|
return {
|
|
"title": title,
|
|
"details": details,
|
|
"attachment_name": attachment_name,
|
|
"attachment_file": attachment_file,
|
|
"attachment_size": attachment_size,
|
|
"attachment_content_type": attachment_content_type,
|
|
}
|
|
else:
|
|
raw = self.rfile.read(content_length)
|
|
fields = urllib.parse.parse_qs(raw.decode("utf-8", errors="replace"), keep_blank_values=True)
|
|
title = fields.get("title", [""])[0]
|
|
details = fields.get("details", [""])[0]
|
|
return {
|
|
"title": title,
|
|
"details": details,
|
|
"attachment_name": "",
|
|
"attachment_file": None,
|
|
"attachment_size": 0,
|
|
"attachment_content_type": "",
|
|
}
|
|
|
|
def do_GET(self) -> None:
|
|
path = self._parsed_path()
|
|
if path == "/":
|
|
self._send_html(UI_HTML)
|
|
return
|
|
|
|
if path == "/health":
|
|
with BACKEND_LOCK:
|
|
if BACKEND["ready"]:
|
|
self._send_json({"status": "ok"})
|
|
else:
|
|
self._send_json(
|
|
{"status": "starting", "detail": BACKEND["last_error"]},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
|
|
if path in ("/state", "/api/todos"):
|
|
try:
|
|
self._send_json(current_state())
|
|
except Exception as error:
|
|
self._send_json(
|
|
{"error": "backend_error", "detail": sanitize(str(error))},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
|
|
attachment_todo_id = self._todo_id_from_path(path, "/attachment")
|
|
if attachment_todo_id is not None:
|
|
try:
|
|
payload = attachment_response(attachment_todo_id)
|
|
self._send_bytes(
|
|
payload["body"],
|
|
HTTPStatus.OK,
|
|
payload["content_type"],
|
|
{
|
|
"Content-Disposition": 'inline; filename="%s"' % payload["filename"],
|
|
},
|
|
)
|
|
except FileNotFoundError:
|
|
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
except KeyError:
|
|
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
except Exception as error:
|
|
self._send_json(
|
|
{"error": "backend_error", "detail": sanitize(str(error))},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
|
|
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
|
|
def do_POST(self) -> None:
|
|
path = self._parsed_path()
|
|
|
|
if path == "/api/todos":
|
|
try:
|
|
submission = self._parse_todo_submission()
|
|
payload = create_todo(
|
|
submission["title"],
|
|
submission["details"],
|
|
submission["attachment_name"],
|
|
submission["attachment_file"],
|
|
submission["attachment_size"],
|
|
submission["attachment_content_type"],
|
|
)
|
|
log_console(
|
|
"PHOTON_VM_DEMO_TODO_CREATED count=%s title=%s attachment=%s"
|
|
% (
|
|
payload["todo_count"],
|
|
sanitize(payload["latest_todo_title"]),
|
|
payload["latest_attachment_filename"] or "none",
|
|
)
|
|
)
|
|
self._send_json(payload, HTTPStatus.CREATED)
|
|
except Exception as error:
|
|
self._send_json(
|
|
{"error": "backend_error", "detail": sanitize(str(error))},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
|
|
toggle_todo_id = self._todo_id_from_path(path, "/toggle")
|
|
if toggle_todo_id is not None:
|
|
try:
|
|
payload = toggle_todo(toggle_todo_id)
|
|
log_console(
|
|
"PHOTON_VM_DEMO_TODO_TOGGLED id=%s latest=%s"
|
|
% (toggle_todo_id, sanitize(payload["latest_todo_title"]))
|
|
)
|
|
self._send_json(payload)
|
|
except KeyError:
|
|
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
except Exception as error:
|
|
self._send_json(
|
|
{"error": "backend_error", "detail": sanitize(str(error))},
|
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
)
|
|
return
|
|
|
|
self._send_json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
|
|
|
|
|
def main() -> None:
|
|
os.makedirs(DATA_MOUNT, exist_ok=True)
|
|
bootstrap()
|
|
with BACKEND_LOCK:
|
|
config = BACKEND["config"]
|
|
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
|
|
log_console(
|
|
"PHOTON_VM_DEMO_WEB_READY count=%s port=%s app=todo bucket=%s namespace=%s"
|
|
% (
|
|
read_int(ROOT_BOOT_COUNT_PATH),
|
|
LISTEN_PORT,
|
|
config["bucket"],
|
|
config["state_namespace"],
|
|
)
|
|
)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except Exception:
|
|
log_console("PHOTON_VM_DEMO_FATAL")
|
|
for line in traceback.format_exc().splitlines():
|
|
log_console(sanitize(line))
|
|
raise
|
|
'';
|
|
in {
|
|
imports = [
|
|
(modulesPath + "/virtualisation/disk-image.nix")
|
|
(modulesPath + "/profiles/qemu-guest.nix")
|
|
];
|
|
|
|
image = {
|
|
baseName = "photon-vm-smoke";
|
|
format = "qcow2";
|
|
efiSupport = false;
|
|
};
|
|
|
|
virtualisation.diskSize = 4096;
|
|
|
|
boot.kernelParams = [ "console=ttyS0" "console=tty0" ];
|
|
|
|
networking.hostName = "photon-vm-smoke";
|
|
networking.useDHCP = lib.mkDefault true;
|
|
networking.firewall.enable = false;
|
|
|
|
services.getty.autologinUser = "root";
|
|
users.mutableUsers = false;
|
|
users.users.root.hashedPassword = "$6$ultracloud$aUJCEE5wm/b5O.9KIKGm84qUWdWXwnebsFEiMBF7u9Y7AOWodaMrjbbKGMOf0X59VJyJeMRsgbT7VWeqMHpUe.";
|
|
|
|
documentation.enable = false;
|
|
services.openssh.enable = false;
|
|
environment.systemPackages = [ pkgs.e2fsprogs pkgs.util-linux ];
|
|
|
|
systemd.services.photon-vm-smoke = {
|
|
description = "UltraCloud VM smoke marker";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "systemd-udev-settle.service" ];
|
|
after = [ "local-fs.target" "systemd-udev-settle.service" ];
|
|
path = with pkgs; [
|
|
bash
|
|
coreutils
|
|
e2fsprogs
|
|
gawk
|
|
gnugrep
|
|
gnused
|
|
util-linux
|
|
];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
Restart = "always";
|
|
RestartSec = "1";
|
|
};
|
|
script = ''
|
|
mkdir -p /var/lib/photon-vm-smoke
|
|
count_file=/var/lib/photon-vm-smoke/boot-count
|
|
if [ -f "$count_file" ]; then
|
|
count=$(( $(cat "$count_file") + 1 ))
|
|
else
|
|
count=1
|
|
fi
|
|
echo "$count" > "$count_file"
|
|
echo "PHOTON_VM_SMOKE_READY count=$count" >/dev/ttyS0
|
|
|
|
root_source="$(lsblk -nrpo NAME,MOUNTPOINT | awk '$2 == "/" { print $1; exit }' 2>/dev/null || true)"
|
|
root_disk=""
|
|
if [ -n "$root_source" ] && [ -b "$root_source" ]; then
|
|
root_disk="$(lsblk -ndo PKNAME "$root_source" 2>/dev/null || true)"
|
|
if [ -z "$root_disk" ]; then
|
|
root_disk="$(basename "$root_source")"
|
|
else
|
|
root_disk="/dev/$root_disk"
|
|
fi
|
|
fi
|
|
echo "PHOTON_VM_SMOKE_DATA_ROOT count=$count source=''${root_source:-none} root=''${root_disk:-unknown}" >/dev/ttyS0
|
|
|
|
data_disk=""
|
|
if [ -b /dev/disk/by-label/photon-vm-data ]; then
|
|
data_disk="$(readlink -f /dev/disk/by-label/photon-vm-data)"
|
|
fi
|
|
|
|
pick_data_disk() {
|
|
while IFS= read -r disk; do
|
|
[ -n "$disk" ] || continue
|
|
if [ -n "$root_source" ] && [ "$disk" = "$root_source" ]; then
|
|
continue
|
|
fi
|
|
if [ -n "$root_disk" ] && [ "$disk" = "$root_disk" ]; then
|
|
continue
|
|
fi
|
|
if lsblk -nrpo MOUNTPOINT "$disk" 2>/dev/null | grep -qx '/'; then
|
|
continue
|
|
fi
|
|
printf '%s\n' "$disk"
|
|
return 0
|
|
done < <(lsblk -dnpr -o NAME,TYPE,RO | awk '$2 == "disk" && $3 == "0" { print $1 }')
|
|
return 1
|
|
}
|
|
|
|
deadline=$((SECONDS + 60))
|
|
scan_attempt=0
|
|
while [ -z "$data_disk" ] && [ "$SECONDS" -lt "$deadline" ]; do
|
|
scan_attempt=$((scan_attempt + 1))
|
|
data_disk="$(pick_data_disk || true)"
|
|
echo "PHOTON_VM_SMOKE_DATA_SCAN count=$count attempt=$scan_attempt data=''${data_disk:-none}" >/dev/ttyS0
|
|
[ -n "$data_disk" ] && break
|
|
udevadm settle >/dev/null 2>&1 || true
|
|
sleep 1
|
|
done
|
|
|
|
if [ -z "$data_disk" ]; then
|
|
echo "PHOTON_VM_SMOKE_DATA_MISSING count=$count" >/dev/ttyS0
|
|
lsblk -dn -o NAME,TYPE,SIZE >/dev/ttyS0 2>&1 || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "PHOTON_VM_SMOKE_DATA_PROBE count=$count root=''${root_disk:-unknown} data=$(basename "$data_disk")" >/dev/ttyS0
|
|
mkdir -p /mnt/photon-vm-data
|
|
if ! blkid "$data_disk" >/dev/null 2>&1; then
|
|
mkfs_output="$(mkfs.ext4 -L photon-vm-data -F "$data_disk" 2>&1)" || {
|
|
mkfs_output="$(printf '%s' "$mkfs_output" | tr '\r\n' ' ' | sed 's/ */ /g')"
|
|
echo "PHOTON_VM_SMOKE_DATA_ERROR count=$count step=mkfs device=$(basename "$data_disk") detail=''${mkfs_output}" >/dev/ttyS0
|
|
lsblk -dn -o NAME,TYPE,RO,SIZE >/dev/ttyS0 2>&1 || true
|
|
blockdev --getsize64 "$data_disk" >/dev/ttyS0 2>&1 || true
|
|
exit 1
|
|
}
|
|
fi
|
|
if ! mountpoint -q /mnt/photon-vm-data; then
|
|
if ! mount "$data_disk" /mnt/photon-vm-data; then
|
|
echo "PHOTON_VM_SMOKE_DATA_ERROR count=$count step=mount device=$(basename "$data_disk")" >/dev/ttyS0
|
|
lsblk -f >/dev/ttyS0 2>&1 || true
|
|
exit 1
|
|
fi
|
|
fi
|
|
data_count_file=/mnt/photon-vm-data/boot-count
|
|
if [ -f "$data_count_file" ]; then
|
|
data_count=$(( $(cat "$data_count_file") + 1 ))
|
|
else
|
|
data_count=1
|
|
fi
|
|
echo "$data_count" > "$data_count_file"
|
|
sync
|
|
echo "PHOTON_VM_SMOKE_DATA_READY count=$data_count device=$(basename "$data_disk")" >/dev/ttyS0
|
|
|
|
while true; do
|
|
echo "PHOTON_VM_SMOKE_HEARTBEAT count=$count ts=$(date +%s)" >/dev/ttyS0
|
|
sleep 2
|
|
done
|
|
'';
|
|
};
|
|
|
|
systemd.services.photon-vm-demo-api = {
|
|
description = "UltraCloud VM demo web app";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" "photon-vm-smoke.service" ];
|
|
after = [ "network-online.target" "photon-vm-smoke.service" ];
|
|
path = with pkgs; [
|
|
bash
|
|
coreutils
|
|
util-linux
|
|
];
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
Restart = "always";
|
|
RestartSec = "1";
|
|
};
|
|
script = ''
|
|
deadline=$((SECONDS + 60))
|
|
while ! mountpoint -q /mnt/photon-vm-data; do
|
|
if [ "$SECONDS" -ge "$deadline" ]; then
|
|
echo "PHOTON_VM_DEMO_WEB_ERROR step=mount-timeout" >/dev/ttyS0
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
exec ${pythonWithBoto3}/bin/python3 ${photonVmDemoApi} >>/dev/ttyS0 2>&1
|
|
'';
|
|
};
|
|
|
|
system.stateVersion = "24.05";
|
|
}
|