From d04d9c206382e444d89e0ce9b4f7411b909802bb Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 2 Oct 2026 23:45:57 +0300 Subject: [PATCH 1/3] Control Center: S3 stores, tiers & probes and site-profile bindings are edited in the console From the moment the hub and the sites are deployed, everything but the workload itself is meant to be done here. Three things were still notes saying "with kubectl": the plan's per-site S3 stores and Velero namespace, an application's tiers (boot order) and health probes, and a site profile's bindings (logical networks, guest networks, DHCP server). - ProtectionPlan: "New plan" takes the S3 stores (site, bucket, endpoint, region, secret) and the Velero namespace; "Edit S3 stores" on a plan patches them (spec.s3Profiles, spec.veleroNamespace are mutable). - ProtectedApplication: "Protect an application" takes tiers (name, selector by labels or resource types, ready gates incl. exec) and probes (http/tcp, target, timeout, status); "Edit tiers & probes" patches them. - SiteProfile: "Edit bindings" patches logicalNetworks, guestNetworks and dhcpServerRef; "Register a DHCP server" binds it to the site profile. - Field type "rows": a generic table editor the three dialogs share. Co-Authored-By: Claude Fable 5.1 --- control-center/actions.jsx | 27 ++ control-center/app.jsx | 2 +- control-center/dist/app.js | 552 ++++++++++++++++++++++++++++++++++- control-center/drhub-api.jsx | 8 + control-center/drhub.jsx | 160 +++++++++- 5 files changed, 731 insertions(+), 18 deletions(-) diff --git a/control-center/actions.jsx b/control-center/actions.jsx index 2a33c5d95..e7d63a5b4 100644 --- a/control-center/actions.jsx +++ b/control-center/actions.jsx @@ -1455,6 +1455,33 @@ function Field({f, val, setVal}) { ); } + if (f.type === "rows") { + const rows = val || []; + const set = (i, k, x) => setVal(rows.map((r, j) => j === i ? Object.assign({}, r, {[k]: x}) : r)); + const cell = (r, i, c) => { + const w = {flex: c.flex || 1, minWidth: 0}; + if (c.type === "select") return ; + return set(i, c.k, e.target.value)} />; + }; + return ( + + ); + } if (f.type === "bschedule") { const rows = val || []; const set = (i, k, x) => setVal(rows.map((r, j) => j === i ? Object.assign({}, r, {[k]: x}) : r)); diff --git a/control-center/app.jsx b/control-center/app.jsx index 0e261f7eb..166f994e0 100644 --- a/control-center/app.jsx +++ b/control-center/app.jsx @@ -490,7 +490,7 @@ function OverviewView({seg, parent, nav, prefs, rev, up, upLabel}) { : seg.t === "paths" ? : seg.t === "protectedapps" ? : seg.t === "rplans" ? - : seg.t === "dhcpservers" ? + : seg.t === "dhcpservers" ? : seg.t === "pairs" ? : seg.t === "rpolicies" ? : seg.t === "__pairs_old" ? diff --git a/control-center/dist/app.js b/control-center/dist/app.js index 817edb9d2..838d78dd5 100644 --- a/control-center/dist/app.js +++ b/control-center/dist/app.js @@ -4313,6 +4313,22 @@ const drhub = { }, spec: spec.spec }), + // Mutable parts of a plan's spec: the per-site S3 stores and the Velero + // namespace (Ramen keys on sites and methods, which stay). + patchPlan: (p, spec) => k8s.patch("ProtectionPlan", p.name, { + spec + }), + // Tiers (boot order) and health probes of an application; a merge patch + // replaces the lists wholesale. + patchApp: (a, spec) => k8s.patch("ProtectedApplication", a.name, { + spec + }, { + namespace: a.namespace + }), + // A site profile's bindings: logical networks, guest networks, DHCP server. + patchSiteProfile: (s, spec) => k8s.patch("SiteProfile", s.name, { + spec + }), createPath: spec => k8s.create("DRPath", { apiVersion: DR_API_GROUP, kind: "DRPath", @@ -9155,6 +9171,77 @@ function Field({ className: "fhint" }, f.hint)); } + if (f.type === "rows") { + const rows = val || []; + const set = (i, k, x) => setVal(rows.map((r, j) => j === i ? Object.assign({}, r, { + [k]: x + }) : r)); + const cell = (r, i, c) => { + const w = { + flex: c.flex || 1, + minWidth: 0 + }; + if (c.type === "select") return /*#__PURE__*/React.createElement("select", { + key: c.k, + className: "finput sm", + style: w, + value: r[c.k] || "", + onChange: e => set(i, c.k, e.target.value) + }, (c.options || []).map(o => /*#__PURE__*/React.createElement("option", { + key: o.v, + value: o.v + }, o.l))); + return /*#__PURE__*/React.createElement("input", { + key: c.k, + className: "finput sm", + style: w, + type: c.type === "number" ? "number" : "text", + placeholder: c.placeholder || "", + value: r[c.k] == null ? "" : r[c.k], + onChange: e => set(i, c.k, e.target.value) + }); + }; + return /*#__PURE__*/React.createElement("label", { + className: "field" + }, /*#__PURE__*/React.createElement("span", { + className: "flabel" + }, f.label, " ", /*#__PURE__*/React.createElement("em", null, "(", rows.length, f.max ? ` of ${f.max}` : "", ")")), /*#__PURE__*/React.createElement("div", { + className: "schedbox" + }, /*#__PURE__*/React.createElement("div", { + className: "schedrow head" + }, f.cols.map(c => /*#__PURE__*/React.createElement("span", { + key: c.k, + className: "sl", + style: { + flex: c.flex || 1 + } + }, c.label)), /*#__PURE__*/React.createElement("span", { + style: { + width: 24 + } + })), rows.map((r, i) => /*#__PURE__*/React.createElement("div", { + className: "schedrow", + key: i + }, f.cols.map(c => cell(r, i, c)), /*#__PURE__*/React.createElement("button", { + type: "button", + className: "kebab", + title: "Remove", + onClick: () => setVal(rows.filter((_, j) => j !== i)) + }, /*#__PURE__*/React.createElement(Icon, { + n: "x", + s: 11 + })))), /*#__PURE__*/React.createElement("button", { + type: "button", + className: "schedadd", + disabled: f.max && rows.length >= f.max, + onClick: () => setVal(rows.concat(f.add ? f.add(rows) : {})) + }, /*#__PURE__*/React.createElement(Icon, { + n: "plus", + s: 11 + }), f.addLabel || "Add")), f.hint && /*#__PURE__*/React.createElement("span", { + className: "fhint" + }, f.hint)); + } if (f.type === "bschedule") { const rows = val || []; const set = (i, k, x) => setVal(rows.map((r, j) => j === i ? Object.assign({}, r, { @@ -23503,6 +23590,242 @@ const parseSites = txt => String(txt || "").split(/[\n;]+/).map(l => l.trim()).f region } : {}); }); +// ---- form <-> spec helpers for the editable parts of the DR objects -------- +const S3_COLS = [{ + k: "site", + label: "Site", + placeholder: "site-a", + flex: 1 +}, { + k: "bucket", + label: "Bucket", + placeholder: "dr-site-a", + flex: 1.4 +}, { + k: "endpoint", + label: "Endpoint", + placeholder: "https://s3.eu-central-1.amazonaws.com", + flex: 2 +}, { + k: "region", + label: "Region", + placeholder: "eu-central-1", + flex: 1 +}, { + k: "secretRef", + label: "Secret", + placeholder: "ramen-s3-secret", + flex: 1 +}]; +const s3Rows = profiles => (profiles || []).map(p => ({ + site: p.site || "", + bucket: p.bucket || "", + endpoint: p.endpoint || "", + region: p.region || "", + secretRef: typeof p.secretRef === "string" ? p.secretRef : (p.secretRef || {}).name || "" +})); +const s3Profiles = rows => (rows || []).filter(r => (r.site || "").trim() && (r.bucket || "").trim()).map(r => Object.assign({ + site: r.site.trim(), + bucket: r.bucket.trim() +}, r.endpoint && r.endpoint.trim() ? { + endpoint: r.endpoint.trim() +} : {}, r.region && r.region.trim() ? { + region: r.region.trim() +} : {}, r.secretRef && r.secretRef.trim() ? { + secretRef: r.secretRef.trim() +} : {})); + +// Tiers: one row per tier. The selector is either labels (k=v, k2=v2) or +// resource types (configmaps, secrets); the ready gates are a short list: +// vmRunning | deploymentsReady | podsReady | exec(app=shop-tools; nc -z -w 3 db 3306; 900) +const READY_RE = /^exec\((.*)\)$/; +const tierRows = tiers => (tiers || []).map(t => { + const sel = t.selector || {}; + const byLabels = sel.matchLabels && Object.keys(sel.matchLabels).length; + return { + name: t.name || "", + by: byLabels ? "labels" : "resources", + selector: byLabels ? Object.entries(sel.matchLabels).map(([k, v]) => `${k}=${v}`).join(", ") : (sel.resourceTypes || []).join(", "), + ready: (t.ready || []).map(r => r.type === "exec" ? `exec(${Object.entries(r.selector || {}).map(([k, v]) => `${k}=${v}`).join(",")}; ${(r.command || []).join(" ")}${r.timeoutSeconds ? `; ${r.timeoutSeconds}` : ""})` : r.type).join(", ") + }; +}); +const parseReady = s => (s || "").split(/,(?![^(]*\))/).map(x => x.trim()).filter(Boolean).map(x => { + const m = READY_RE.exec(x); + if (!m) return { + type: x + }; + const parts = m[1].split(";").map(p => p.trim()); + const sel = {}; + (parts[0] || "").split(",").map(p => p.trim()).filter(Boolean).forEach(kv => { + const [k, v] = kv.split("="); + if (k) sel[k.trim()] = (v || "").trim(); + }); + const out = { + type: "exec", + selector: sel, + command: (parts[1] || "").split(/\s+/).filter(Boolean) + }; + if (parts[2] && Number(parts[2])) out.timeoutSeconds = Number(parts[2]); + return out; +}); +const tiersSpec = rows => (rows || []).filter(r => (r.name || "").trim()).map(r => { + const selector = r.by === "resources" ? { + resourceTypes: csv(r.selector) + } : { + matchLabels: Object.fromEntries(csv(r.selector).map(kv => { + const [k, v] = kv.split("="); + return [k.trim(), (v || "").trim()]; + }).filter(([k]) => k)) + }; + const ready = parseReady(r.ready); + return Object.assign({ + name: r.name.trim(), + selector + }, ready.length ? { + ready + } : {}); +}); +const TIER_COLS = [{ + k: "name", + label: "Tier", + placeholder: "db", + flex: 0.8 +}, { + k: "by", + label: "Select by", + type: "select", + options: [{ + v: "labels", + l: "labels" + }, { + v: "resources", + l: "resource types" + }], + flex: 0.9 +}, { + k: "selector", + label: "Selector", + placeholder: "dr.simplyblock.io/tier=db | configmaps, secrets", + flex: 2 +}, { + k: "ready", + label: "Ready when", + placeholder: "vmRunning, exec(app=shop-tools; nc -z -w 3 db 3306; 900)", + flex: 2.4 +}]; +const probeRows = probes => (probes || []).map(p => ({ + name: p.name || "", + type: p.type || "http", + target: p.target || "", + timeout: p.timeout || "", + expectStatus: p.expectStatus || "" +})); +const probesSpec = rows => (rows || []).filter(r => (r.target || "").trim() || r.type === "vmRunning").map(r => Object.assign({ + name: (r.name || "").trim() || r.type, + type: r.type || "http" +}, r.target && r.target.trim() ? { + target: r.target.trim() +} : {}, r.timeout && String(r.timeout).trim() ? { + timeout: String(r.timeout).trim() +} : {}, Number(r.expectStatus) ? { + expectStatus: Number(r.expectStatus) +} : {})); +const PROBE_COLS = [{ + k: "name", + label: "Probe", + placeholder: "web", + flex: 0.8 +}, { + k: "type", + label: "Type", + type: "select", + options: [{ + v: "http", + l: "http" + }, { + v: "tcp", + l: "tcp" + }], + flex: 0.7 +}, { + k: "target", + label: "Target (URL / host:port)", + placeholder: "http://web.shop.svc.cluster.local/", + flex: 2.4 +}, { + k: "timeout", + label: "Timeout", + placeholder: "15s", + flex: 0.7 +}, { + k: "expectStatus", + label: "HTTP status", + type: "number", + placeholder: "any 2xx", + flex: 0.8 +}]; +const TIER_HINT = "Ready gates: vmRunning, deploymentsReady, podsReady, or exec(; ; ) run in a pod of the tier's namespace. Tiers restore in order; the next starts when every gate of the previous holds."; + +// Site profile bindings (ADR 0020) +const LNET_COLS = [{ + k: "role", + label: "Role", + placeholder: "app", + flex: 0.8 +}, { + k: "nad", + label: "NetworkAttachmentDefinition (namespace/name)", + placeholder: "app-net/vlan110", + flex: 2.4 +}]; +const GNET_COLS = [{ + k: "role", + label: "Role", + placeholder: "app", + flex: 0.7 +}, { + k: "cidr", + label: "Guest subnet", + placeholder: "192.168.110.0/24", + flex: 1.3 +}, { + k: "reservedHostIDs", + label: "Reserved host ids", + placeholder: "1, 2", + flex: 0.9 +}, { + k: "dhcpServerRef", + label: "DHCP server (name)", + placeholder: "site-a", + flex: 1.1 +}]; +// The DHCP servers a profile already refers to, offered as the defaults. +const knownServers = sp => Array.from(new Set([sp.dhcpServerRef].concat((sp.guestNetworks || []).map(g => g.dhcpServerRef)).filter(Boolean))); +const lnetRows = sp => (sp.logicalNetworks || []).map(l => ({ + role: l.role || "", + nad: l.nad || "" +})); +const gnetRows = sp => (sp.guestNetworks || []).map(g => ({ + role: g.role || "", + cidr: g.cidr || "", + reservedHostIDs: (g.reservedHostIDs || []).join(", "), + dhcpServerRef: g.dhcpServerRef || "" +})); +const bindingsSpec = v => ({ + logicalNetworks: (v.lnets || []).filter(r => (r.role || "").trim() && (r.nad || "").trim()).map(r => ({ + role: r.role.trim(), + nad: r.nad.trim() + })), + guestNetworks: (v.gnets || []).filter(r => (r.role || "").trim() && (r.cidr || "").trim()).map(r => Object.assign({ + role: r.role.trim(), + cidr: r.cidr.trim() + }, csv(r.reservedHostIDs).length ? { + reservedHostIDs: csv(r.reservedHostIDs).map(Number).filter(n => !Number.isNaN(n)) + } : {}, r.dhcpServerRef ? { + dhcpServerRef: r.dhcpServerRef + } : {})), + dhcpServerRef: v.dhcp || null +}); const newPlanDialog = () => ({ title: "New protection plan", confirm: "Create plan", @@ -23566,11 +23889,32 @@ const newPlanDialog = () => ({ label: "Consistency groups", type: "checkbox", def: false + }, { + k: "s3", + label: "S3 stores — one per site (Ramen's metadata store and Velero's backups)", + type: "rows", + cols: S3_COLS, + max: 8, + addLabel: "Add store", + add: rows => ({ + site: "", + bucket: "", + endpoint: rows.length ? rows[rows.length - 1].endpoint : "", + region: rows.length ? rows[rows.length - 1].region : "", + secretRef: rows.length ? rows[rows.length - 1].secretRef : "ramen-s3-secret" + }), + hint: "The secret (access key id / secret access key) must exist in Ramen's namespace on the hub. Leave empty to name one existing profile below instead." + }, { + k: "velero", + label: "Velero namespace on the sites", + type: "text", + def: "velero", + placeholder: "velero" }, { k: "s3Profile", - label: "Ramen S3 profile (single store)", + label: "Ramen S3 profile (single store, instead of per-site stores)", type: "text", - placeholder: "existing profile name; leave empty when using per-site stores" + placeholder: "existing profile name" }, { k: "autoRestart", label: "Restart applications in place after a storage recovery", @@ -23579,7 +23923,7 @@ const newPlanDialog = () => ({ }, { k: "n2", type: "note", - label: "Per-site S3 stores, snapshot class selectors and replication parameters are written with kubectl for now: the plan's spec is editable afterwards except for the immutable fields Ramen keys on." + label: "Snapshot class selectors and replication parameters are taken from the storage class and the method; the spec stays editable afterwards except for the fields Ramen keys on (sites, methods)." }].filter(Boolean), run: v => { const type = v.type; @@ -23595,6 +23939,7 @@ const newPlanDialog = () => ({ } } : {}); const sc = kvToObj(v.sc); + const stores = s3Profiles(v.s3); const spec = Object.assign({ sites: parseSites(v.sites), methods: [method], @@ -23605,7 +23950,11 @@ const newPlanDialog = () => ({ }, { consistencyGroups: v.cg ? "Enabled" : "Disabled" }) - }, v.s3Profile && v.s3Profile.trim() ? { + }, stores.length ? { + s3Profiles: stores + } : {}, v.velero && v.velero.trim() ? { + veleroNamespace: v.velero.trim() + } : {}, v.s3Profile && v.s3Profile.trim() ? { s3Profile: { name: v.s3Profile.trim() } @@ -23620,6 +23969,37 @@ const newPlanDialog = () => ({ }); } }); +const editPlanS3Dialog = p => ({ + title: `S3 stores of ${p.name}`, + confirm: "Save", + done: "ProtectionPlan updated", + desc: "Ramen keeps its metadata and Velero its backups in one S3 store per site. Changing a store re-derives the DRClusters; applications keep their protection.", + fields: [{ + k: "s3", + label: "S3 stores — one per site", + type: "rows", + cols: S3_COLS, + max: 8, + addLabel: "Add store", + def: s3Rows(p.s3Profiles), + add: rows => ({ + site: "", + bucket: "", + endpoint: rows.length ? rows[rows.length - 1].endpoint : "", + region: rows.length ? rows[rows.length - 1].region : "", + secretRef: rows.length ? rows[rows.length - 1].secretRef : "ramen-s3-secret" + }) + }, { + k: "velero", + label: "Velero namespace on the sites", + type: "text", + def: p.veleroNamespace || "velero" + }], + run: v => drhub.patchPlan(p, { + s3Profiles: s3Profiles(v.s3), + veleroNamespace: v.velero && v.velero.trim() ? v.velero.trim() : null + }) +}); const newPathDialog = plans => ({ title: "Declare a DR path", confirm: "Create path", @@ -23812,10 +24192,38 @@ const protectAppDialogDR = (plans, cfg) => ({ label: "Hand-written Recipe (name, optional)", type: "text", placeholder: "leave empty to let the hub generate one from tiers" + }, { + k: "tiers", + label: "Tiers — the boot order the hub generates the Recipe from", + type: "rows", + cols: TIER_COLS, + max: 12, + addLabel: "Add tier", + hint: TIER_HINT, + add: () => ({ + name: "", + by: "labels", + selector: "", + ready: "" + }) + }, { + k: "probes", + label: "Health probes — what a move waits for on the target", + type: "rows", + cols: PROBE_COLS, + max: 8, + addLabel: "Add probe", + add: () => ({ + name: "", + type: "http", + target: "", + timeout: "15s", + expectStatus: "" + }) }, { k: "n1", type: "note", - label: "Both directions between source and target must exist as DR paths for readiness to become Ready. Tiers, probes and hooks are edited on the object afterwards." + label: "Both directions between source and target must exist as DR paths for readiness to become Ready. External hooks are edited on the object." }].filter(Boolean); }, run: v => { @@ -23823,6 +24231,8 @@ const protectAppDialogDR = (plans, cfg) => ({ const sel = Object.keys(pvc).length ? { matchLabels: pvc } : {}; + const tiers = tiersSpec(v.tiers), + probes = probesSpec(v.probes); const spec = Object.assign({ planRef: { name: v.plan @@ -23832,6 +24242,12 @@ const protectAppDialogDR = (plans, cfg) => ({ kind: v.appKind }, v.method ? { method: v.method + } : {}, tiers.length ? { + tiers + } : {}, probes.length ? { + health: { + probes + } } : {}, v.appKind === "managed" ? { managed: { placementRef: { @@ -23856,6 +24272,49 @@ const protectAppDialogDR = (plans, cfg) => ({ }); } }); +const editTiersDialog = a => ({ + title: `Tiers & probes of ${a.name}`, + confirm: "Save", + done: "ProtectedApplication updated", + desc: "The tiers are the boot order: the hub generates the Recipe Ramen restores by from them. The probes are what a Failover or Relocate waits for before it reports the application up on the target.", + fields: [{ + k: "tiers", + label: "Tiers (boot order)", + type: "rows", + cols: TIER_COLS, + max: 12, + addLabel: "Add tier", + hint: TIER_HINT, + def: tierRows(a.tiers), + add: () => ({ + name: "", + by: "labels", + selector: "", + ready: "" + }) + }, { + k: "probes", + label: "Health probes", + type: "rows", + cols: PROBE_COLS, + max: 8, + addLabel: "Add probe", + def: probeRows(a.probes), + add: () => ({ + name: "", + type: "http", + target: "", + timeout: "15s", + expectStatus: "" + }) + }], + run: v => drhub.patchApp(a, { + tiers: tiersSpec(v.tiers), + health: { + probes: probesSpec(v.probes) + } + }) +}); const newRPlanDialog = (paths, apps) => ({ title: "New recovery plan", confirm: "Create plan", @@ -24002,7 +24461,51 @@ const newScheduleDialog = (target, nsHint) => ({ suspend: v.suspend }) }); -const newDHCPServerDialog = sites => ({ +const editBindingsDialog = s => ({ + title: `Bindings of ${s.name}`, + confirm: "Save", + done: "SiteProfile updated", + desc: "How this site's networks map for recovered VMs (ADR 0020): the NAD each logical role is on here, the guest subnet of each role with the host ids never handed out, and the DHCP server the reservations are rendered to.", + fields: [{ + k: "lnets", + label: "Logical networks — role → NAD on this site", + type: "rows", + cols: LNET_COLS, + max: 8, + addLabel: "Add network", + def: lnetRows(s.spec || {}), + add: () => ({ + role: "app", + nad: "" + }), + hint: s.nads && s.nads.length ? `NADs reported here: ${s.nads.map(n => n.namespace ? `${n.namespace}/${n.name}` : n.name || n).slice(0, 8).join(", ")}` : "" + }, { + k: "gnets", + label: "Guest networks — the subnet of each role here", + type: "rows", + cols: GNET_COLS, + max: 8, + addLabel: "Add subnet", + def: gnetRows(s.spec || {}), + add: () => ({ + role: "app", + cidr: "", + reservedHostIDs: "1, 2", + dhcpServerRef: knownServers(s.spec || {})[0] || "" + }), + hint: "The DHCP server is the name of a registered DHCPServer of this site (Disaster recovery → DHCP servers)." + }, { + k: "dhcp", + label: "DHCP server of the site (default for every guest network)", + type: "text", + def: (s.spec || {}).dhcpServerRef || "", + placeholder: knownServers(s.spec || {}).join(", ") || "name of a registered DHCPServer" + }], + run: v => drhub.patchSiteProfile(s, bindingsSpec(Object.assign({}, v, { + dhcp: v.dhcp && v.dhcp.trim() ? v.dhcp.trim() : "" + }))) +}); +const newDHCPServerDialog = (sites, profiles) => ({ title: "Register a DHCP server", confirm: "Create", done: "DHCPServer created", @@ -24043,16 +24546,32 @@ const newDHCPServerDialog = sites => ({ type: "text", required: true, placeholder: "sitemap-hosts" + }, { + k: "bind", + label: "Bind it as the site's DHCP server (the site profile's default and every guest network without one)", + type: "checkbox", + def: true }, { k: "n1", type: "note", - label: "Then bind it on the site profile: spec.guestNetworks[].dhcpServerRef or spec.dhcpServerRef (kubectl in this phase). Guests need a pinned MAC and an address inside the role's CIDR to get a reservation." + label: "Guests need a pinned MAC and an address inside the role's guest subnet to get a reservation; the subnets are the site profile's bindings." }], run: v => drhub.createDHCPServer({ name: v.name.trim(), site: v.site, namespace: v.namespace.trim(), configMap: v.configMap.trim() + }).then(r => { + const prof = (profiles || []).find(p => p.name === v.site); + if (!v.bind || !prof) return r; + const sp = prof.spec || {}, + name = dns63(v.name.trim()); + return drhub.patchSiteProfile(prof, { + dhcpServerRef: name, + guestNetworks: (sp.guestNetworks || []).map(g => Object.assign({}, g, g.dhcpServerRef ? {} : { + dhcpServerRef: name + })) + }).then(() => r); }) }); @@ -24061,6 +24580,11 @@ const newDHCPServerDialog = sites => ({ // create on the run kinds, override to the override verb, delete to delete. Object.assign(ACTIONS, { pplan: p => [{ + label: "Edit S3 stores", + icon: "cloud", + op: "update", + dialog: editPlanS3Dialog(p) + }, { label: "Delete plan", icon: "trash", danger: true, @@ -24115,6 +24639,11 @@ Object.assign(ACTIONS, { icon: "cloud", op: "drrestore", dialog: restoreDialog(a) + }, { + label: "Edit tiers & probes", + icon: "list", + op: "update", + dialog: editTiersDialog(a) }, { label: a.autoRestartOptOut ? "Enable automatic restart" : "Disable automatic restart", icon: "power", @@ -24218,7 +24747,12 @@ Object.assign(ACTIONS, { hint: "A running restore cannot be deleted", dialog: deleteDialog(r, "") }], - siteprofile: () => [], + siteprofile: s => [{ + label: "Edit bindings", + icon: "link", + op: "update", + dialog: editBindingsDialog(s) + }], dhcpserver: d => [{ label: "Delete server", icon: "trash", @@ -29188,7 +29722,7 @@ function OverviewView({ s: 12 }), "New recovery plan") : seg.t === "dhcpservers" ? /*#__PURE__*/React.createElement("button", { className: "btn primary", - onClick: () => drhub.siteProfiles().then(ss => window.__ui.dialog(newDHCPServerDialog(parent && parent.t === "siteprofile" && REG[parent.id] ? [REG[parent.id].name] : ss.map(s => s.name)), { + onClick: () => drhub.siteProfiles().then(ss => window.__ui.dialog(newDHCPServerDialog(parent && parent.t === "siteprofile" && REG[parent.id] ? [REG[parent.id].name] : ss.map(s => s.name), ss), { kind: "dhcpserver", id: "new" })) diff --git a/control-center/drhub-api.jsx b/control-center/drhub-api.jsx index 1f985eac5..f799dd7b4 100644 --- a/control-center/drhub-api.jsx +++ b/control-center/drhub-api.jsx @@ -367,6 +367,14 @@ const drhub = { }, {namespace}), suspendSchedule: (s, suspend) => k8s.patch("TestSchedule", s.name, {spec: {suspend: !!suspend}}, {namespace: s.namespace}), createPlan: spec => k8s.create("ProtectionPlan", {apiVersion: DR_API_GROUP, kind: "ProtectionPlan", metadata: {name: dns63(spec.name)}, spec: spec.spec}), + // Mutable parts of a plan's spec: the per-site S3 stores and the Velero + // namespace (Ramen keys on sites and methods, which stay). + patchPlan: (p, spec) => k8s.patch("ProtectionPlan", p.name, {spec}), + // Tiers (boot order) and health probes of an application; a merge patch + // replaces the lists wholesale. + patchApp: (a, spec) => k8s.patch("ProtectedApplication", a.name, {spec}, {namespace: a.namespace}), + // A site profile's bindings: logical networks, guest networks, DHCP server. + patchSiteProfile: (s, spec) => k8s.patch("SiteProfile", s.name, {spec}), createPath: spec => k8s.create("DRPath", {apiVersion: DR_API_GROUP, kind: "DRPath", metadata: {name: dns63(spec.name)}, spec: spec.spec}), createApp: ({name, namespace, spec}) => k8s.create("ProtectedApplication", {apiVersion: DR_API_GROUP, kind: "ProtectedApplication", metadata: {name: dns63(name), namespace}, spec}, {namespace}), createRPlan: ({name, namespace, spec}) => k8s.create("RecoveryPlan", {apiVersion: DR_API_GROUP, kind: "RecoveryPlan", metadata: {name: dns63(name), namespace}, spec}, {namespace}), diff --git a/control-center/drhub.jsx b/control-center/drhub.jsx index 8bdaaa30f..0fc3b289a 100644 --- a/control-center/drhub.jsx +++ b/control-center/drhub.jsx @@ -149,6 +149,91 @@ const parseSites = txt => String(txt || "").split(/[\n;]+/).map(l => l.trim()).f const [cluster, zone] = (clusterZone || "").split("/"); return Object.assign({name, cluster: cluster || name}, zone ? {zone} : {}, region ? {region} : {}); }); +// ---- form <-> spec helpers for the editable parts of the DR objects -------- +const S3_COLS = [ + {k: "site", label: "Site", placeholder: "site-a", flex: 1}, + {k: "bucket", label: "Bucket", placeholder: "dr-site-a", flex: 1.4}, + {k: "endpoint", label: "Endpoint", placeholder: "https://s3.eu-central-1.amazonaws.com", flex: 2}, + {k: "region", label: "Region", placeholder: "eu-central-1", flex: 1}, + {k: "secretRef", label: "Secret", placeholder: "ramen-s3-secret", flex: 1} +]; +const s3Rows = profiles => (profiles || []).map(p => ({site: p.site || "", bucket: p.bucket || "", endpoint: p.endpoint || "", region: p.region || "", secretRef: typeof p.secretRef === "string" ? p.secretRef : (p.secretRef || {}).name || ""})); +const s3Profiles = rows => (rows || []).filter(r => (r.site || "").trim() && (r.bucket || "").trim()).map(r => Object.assign( + {site: r.site.trim(), bucket: r.bucket.trim()}, r.endpoint && r.endpoint.trim() ? {endpoint: r.endpoint.trim()} : {}, + r.region && r.region.trim() ? {region: r.region.trim()} : {}, r.secretRef && r.secretRef.trim() ? {secretRef: r.secretRef.trim()} : {})); + +// Tiers: one row per tier. The selector is either labels (k=v, k2=v2) or +// resource types (configmaps, secrets); the ready gates are a short list: +// vmRunning | deploymentsReady | podsReady | exec(app=shop-tools; nc -z -w 3 db 3306; 900) +const READY_RE = /^exec\((.*)\)$/; +const tierRows = tiers => (tiers || []).map(t => { + const sel = t.selector || {}; + const byLabels = sel.matchLabels && Object.keys(sel.matchLabels).length; + return {name: t.name || "", by: byLabels ? "labels" : "resources", + selector: byLabels ? Object.entries(sel.matchLabels).map(([k, v]) => `${k}=${v}`).join(", ") : (sel.resourceTypes || []).join(", "), + ready: (t.ready || []).map(r => r.type === "exec" + ? `exec(${Object.entries(r.selector || {}).map(([k, v]) => `${k}=${v}`).join(",")}; ${(r.command || []).join(" ")}${r.timeoutSeconds ? `; ${r.timeoutSeconds}` : ""})` + : r.type).join(", ")}; +}); +const parseReady = s => (s || "").split(/,(?![^(]*\))/).map(x => x.trim()).filter(Boolean).map(x => { + const m = READY_RE.exec(x); + if (!m) return {type: x}; + const parts = m[1].split(";").map(p => p.trim()); + const sel = {}; + (parts[0] || "").split(",").map(p => p.trim()).filter(Boolean).forEach(kv => { const [k, v] = kv.split("="); if (k) sel[k.trim()] = (v || "").trim(); }); + const out = {type: "exec", selector: sel, command: (parts[1] || "").split(/\s+/).filter(Boolean)}; + if (parts[2] && Number(parts[2])) out.timeoutSeconds = Number(parts[2]); + return out; +}); +const tiersSpec = rows => (rows || []).filter(r => (r.name || "").trim()).map(r => { + const selector = r.by === "resources" + ? {resourceTypes: csv(r.selector)} + : {matchLabels: Object.fromEntries(csv(r.selector).map(kv => { const [k, v] = kv.split("="); return [k.trim(), (v || "").trim()]; }).filter(([k]) => k))}; + const ready = parseReady(r.ready); + return Object.assign({name: r.name.trim(), selector}, ready.length ? {ready} : {}); +}); +const TIER_COLS = [ + {k: "name", label: "Tier", placeholder: "db", flex: 0.8}, + {k: "by", label: "Select by", type: "select", options: [{v: "labels", l: "labels"}, {v: "resources", l: "resource types"}], flex: 0.9}, + {k: "selector", label: "Selector", placeholder: "dr.simplyblock.io/tier=db | configmaps, secrets", flex: 2}, + {k: "ready", label: "Ready when", placeholder: "vmRunning, exec(app=shop-tools; nc -z -w 3 db 3306; 900)", flex: 2.4} +]; +const probeRows = probes => (probes || []).map(p => ({name: p.name || "", type: p.type || "http", target: p.target || "", timeout: p.timeout || "", expectStatus: p.expectStatus || ""})); +const probesSpec = rows => (rows || []).filter(r => (r.target || "").trim() || r.type === "vmRunning").map(r => Object.assign( + {name: (r.name || "").trim() || r.type, type: r.type || "http"}, r.target && r.target.trim() ? {target: r.target.trim()} : {}, + r.timeout && String(r.timeout).trim() ? {timeout: String(r.timeout).trim()} : {}, Number(r.expectStatus) ? {expectStatus: Number(r.expectStatus)} : {})); +const PROBE_COLS = [ + {k: "name", label: "Probe", placeholder: "web", flex: 0.8}, + {k: "type", label: "Type", type: "select", options: [{v: "http", l: "http"}, {v: "tcp", l: "tcp"}], flex: 0.7}, + {k: "target", label: "Target (URL / host:port)", placeholder: "http://web.shop.svc.cluster.local/", flex: 2.4}, + {k: "timeout", label: "Timeout", placeholder: "15s", flex: 0.7}, + {k: "expectStatus", label: "HTTP status", type: "number", placeholder: "any 2xx", flex: 0.8} +]; +const TIER_HINT = "Ready gates: vmRunning, deploymentsReady, podsReady, or exec(; ; ) run in a pod of the tier's namespace. Tiers restore in order; the next starts when every gate of the previous holds."; + +// Site profile bindings (ADR 0020) +const LNET_COLS = [ + {k: "role", label: "Role", placeholder: "app", flex: 0.8}, + {k: "nad", label: "NetworkAttachmentDefinition (namespace/name)", placeholder: "app-net/vlan110", flex: 2.4} +]; +const GNET_COLS = [ + {k: "role", label: "Role", placeholder: "app", flex: 0.7}, + {k: "cidr", label: "Guest subnet", placeholder: "192.168.110.0/24", flex: 1.3}, + {k: "reservedHostIDs", label: "Reserved host ids", placeholder: "1, 2", flex: 0.9}, + {k: "dhcpServerRef", label: "DHCP server (name)", placeholder: "site-a", flex: 1.1} +]; +// The DHCP servers a profile already refers to, offered as the defaults. +const knownServers = sp => Array.from(new Set([sp.dhcpServerRef].concat((sp.guestNetworks || []).map(g => g.dhcpServerRef)).filter(Boolean))); +const lnetRows = sp => (sp.logicalNetworks || []).map(l => ({role: l.role || "", nad: l.nad || ""})); +const gnetRows = sp => (sp.guestNetworks || []).map(g => ({role: g.role || "", cidr: g.cidr || "", reservedHostIDs: (g.reservedHostIDs || []).join(", "), dhcpServerRef: g.dhcpServerRef || ""})); +const bindingsSpec = v => ({ + logicalNetworks: (v.lnets || []).filter(r => (r.role || "").trim() && (r.nad || "").trim()).map(r => ({role: r.role.trim(), nad: r.nad.trim()})), + guestNetworks: (v.gnets || []).filter(r => (r.role || "").trim() && (r.cidr || "").trim()).map(r => Object.assign({role: r.role.trim(), cidr: r.cidr.trim()}, + csv(r.reservedHostIDs).length ? {reservedHostIDs: csv(r.reservedHostIDs).map(Number).filter(n => !Number.isNaN(n))} : {}, + r.dhcpServerRef ? {dhcpServerRef: r.dhcpServerRef} : {})), + dhcpServerRef: v.dhcp || null +}); + const newPlanDialog = () => ({ title: "New protection plan", confirm: "Create plan", done: "ProtectionPlan created", desc: "A plan names the sites that take part in DR, the storage it protects and how it replicates. Ramen's DRCluster and DRPolicy objects and the replication classes are derived from it; directions are declared afterwards as DR paths.", @@ -163,21 +248,38 @@ const newPlanDialog = () => ({ /backup/.test(v.type || "") && {k: "bRetention", label: "Backups retained", type: "number", min: 1, def: 24}, {k: "sc", label: "Storage class selector (matchLabels)", type: "kv", max: 8}, {k: "cg", label: "Consistency groups", type: "checkbox", def: false}, - {k: "s3Profile", label: "Ramen S3 profile (single store)", type: "text", placeholder: "existing profile name; leave empty when using per-site stores"}, + {k: "s3", label: "S3 stores — one per site (Ramen's metadata store and Velero's backups)", type: "rows", cols: S3_COLS, max: 8, addLabel: "Add store", + add: rows => ({site: "", bucket: "", endpoint: rows.length ? rows[rows.length - 1].endpoint : "", region: rows.length ? rows[rows.length - 1].region : "", secretRef: rows.length ? rows[rows.length - 1].secretRef : "ramen-s3-secret"}), + hint: "The secret (access key id / secret access key) must exist in Ramen's namespace on the hub. Leave empty to name one existing profile below instead."}, + {k: "velero", label: "Velero namespace on the sites", type: "text", def: "velero", placeholder: "velero"}, + {k: "s3Profile", label: "Ramen S3 profile (single store, instead of per-site stores)", type: "text", placeholder: "existing profile name"}, {k: "autoRestart", label: "Restart applications in place after a storage recovery", type: "checkbox", def: false}, - {k: "n2", type: "note", label: "Per-site S3 stores, snapshot class selectors and replication parameters are written with kubectl for now: the plan's spec is editable afterwards except for the immutable fields Ramen keys on."} + {k: "n2", type: "note", label: "Snapshot class selectors and replication parameters are taken from the storage class and the method; the spec stays editable afterwards except for the fields Ramen keys on (sites, methods)."} ].filter(Boolean), run: v => { const type = v.type; const method = Object.assign({name: v.method.trim(), type}, /^async/.test(type) ? {schedulingInterval: v.interval.trim()} : {}, /backup/.test(type) ? {s3Backup: {interval: v.bInterval.trim(), retention: Number(v.bRetention) || 24}} : {}); const sc = kvToObj(v.sc); + const stores = s3Profiles(v.s3); const spec = Object.assign({sites: parseSites(v.sites), methods: [method], storageProfile: Object.assign({storageClassSelector: Object.keys(sc).length ? {matchLabels: sc} : {}}, {consistencyGroups: v.cg ? "Enabled" : "Disabled"})}, + stores.length ? {s3Profiles: stores} : {}, v.velero && v.velero.trim() ? {veleroNamespace: v.velero.trim()} : {}, v.s3Profile && v.s3Profile.trim() ? {s3Profile: {name: v.s3Profile.trim()}} : {}, v.autoRestart ? {autoRestart: {enabled: true}} : {}); return drhub.createPlan({name: v.name.trim(), spec}); } }); +const editPlanS3Dialog = p => ({ + title: `S3 stores of ${p.name}`, confirm: "Save", done: "ProtectionPlan updated", + desc: "Ramen keeps its metadata and Velero its backups in one S3 store per site. Changing a store re-derives the DRClusters; applications keep their protection.", + fields: [ + {k: "s3", label: "S3 stores — one per site", type: "rows", cols: S3_COLS, max: 8, addLabel: "Add store", def: s3Rows(p.s3Profiles), + add: rows => ({site: "", bucket: "", endpoint: rows.length ? rows[rows.length - 1].endpoint : "", region: rows.length ? rows[rows.length - 1].region : "", secretRef: rows.length ? rows[rows.length - 1].secretRef : "ramen-s3-secret"})}, + {k: "velero", label: "Velero namespace on the sites", type: "text", def: p.veleroNamespace || "velero"} + ], + run: v => drhub.patchPlan(p, {s3Profiles: s3Profiles(v.s3), veleroNamespace: v.velero && v.velero.trim() ? v.velero.trim() : null}) +}); + const newPathDialog = plans => ({ title: "Declare a DR path", confirm: "Create path", done: "DRPath created", desc: "A DR path is a declared direction between two sites of a plan, and the set of actions allowed along it. Nothing in the console offers a target cluster: it offers a path.", @@ -219,19 +321,36 @@ const protectAppDialogDR = (plans, cfg) => ({ v.appKind !== "managed" && {k: "namespaces", label: "Protected namespaces (comma-separated)", type: "text", required: true, placeholder: "shop"}, {k: "pvc", label: "PVC selector (matchLabels)", type: "kv", max: 8}, v.appKind !== "managed" && {k: "recipe", label: "Hand-written Recipe (name, optional)", type: "text", placeholder: "leave empty to let the hub generate one from tiers"}, - {k: "n1", type: "note", label: "Both directions between source and target must exist as DR paths for readiness to become Ready. Tiers, probes and hooks are edited on the object afterwards."} + {k: "tiers", label: "Tiers — the boot order the hub generates the Recipe from", type: "rows", cols: TIER_COLS, max: 12, addLabel: "Add tier", hint: TIER_HINT, + add: () => ({name: "", by: "labels", selector: "", ready: ""})}, + {k: "probes", label: "Health probes — what a move waits for on the target", type: "rows", cols: PROBE_COLS, max: 8, addLabel: "Add probe", + add: () => ({name: "", type: "http", target: "", timeout: "15s", expectStatus: ""})}, + {k: "n1", type: "note", label: "Both directions between source and target must exist as DR paths for readiness to become Ready. External hooks are edited on the object."} ].filter(Boolean); }, run: v => { const pvc = kvToObj(v.pvc); const sel = Object.keys(pvc).length ? {matchLabels: pvc} : {}; + const tiers = tiersSpec(v.tiers), probes = probesSpec(v.probes); const spec = Object.assign({planRef: {name: v.plan}, source: v.source, target: v.target, kind: v.appKind}, - v.method ? {method: v.method} : {}, + v.method ? {method: v.method} : {}, tiers.length ? {tiers} : {}, probes.length ? {health: {probes}} : {}, v.appKind === "managed" ? {managed: {placementRef: {name: v.placement.trim()}, pvcSelector: sel}} : {discovered: Object.assign({protectedNamespaces: csv(v.namespaces), pvcSelector: sel}, v.recipe && v.recipe.trim() ? {recipeRef: {name: v.recipe.trim()}} : {})}); return drhub.createApp({name: v.name.trim(), namespace: v.namespace.trim(), spec}); } }); +const editTiersDialog = a => ({ + title: `Tiers & probes of ${a.name}`, confirm: "Save", done: "ProtectedApplication updated", + desc: "The tiers are the boot order: the hub generates the Recipe Ramen restores by from them. The probes are what a Failover or Relocate waits for before it reports the application up on the target.", + fields: [ + {k: "tiers", label: "Tiers (boot order)", type: "rows", cols: TIER_COLS, max: 12, addLabel: "Add tier", hint: TIER_HINT, def: tierRows(a.tiers), + add: () => ({name: "", by: "labels", selector: "", ready: ""})}, + {k: "probes", label: "Health probes", type: "rows", cols: PROBE_COLS, max: 8, addLabel: "Add probe", def: probeRows(a.probes), + add: () => ({name: "", type: "http", target: "", timeout: "15s", expectStatus: ""})} + ], + run: v => drhub.patchApp(a, {tiers: tiersSpec(v.tiers), health: {probes: probesSpec(v.probes)}}) +}); + const newRPlanDialog = (paths, apps) => ({ title: "New recovery plan", confirm: "Create plan", done: "RecoveryPlan created", desc: "An ordered set of applications moved together along one DR path: priorities run in sequence, applications of one priority in parallel. A plan action fans out one RecoveryAction per application.", @@ -270,7 +389,21 @@ const newScheduleDialog = (target, nsHint) => ({ run: v => drhub.createSchedule({name: v.name, namespace: target ? target.namespace : nsHint, schedule: v.schedule.trim(), target, path: v.path, keepLast: v.keepLast, keepFor: v.keepFor && v.keepFor.trim(), suspend: v.suspend}) }); -const newDHCPServerDialog = sites => ({ +const editBindingsDialog = s => ({ + title: `Bindings of ${s.name}`, confirm: "Save", done: "SiteProfile updated", + desc: "How this site's networks map for recovered VMs (ADR 0020): the NAD each logical role is on here, the guest subnet of each role with the host ids never handed out, and the DHCP server the reservations are rendered to.", + fields: [ + {k: "lnets", label: "Logical networks — role → NAD on this site", type: "rows", cols: LNET_COLS, max: 8, addLabel: "Add network", def: lnetRows(s.spec || {}), + add: () => ({role: "app", nad: ""}), hint: s.nads && s.nads.length ? `NADs reported here: ${s.nads.map(n => n.namespace ? `${n.namespace}/${n.name}` : n.name || n).slice(0, 8).join(", ")}` : ""}, + {k: "gnets", label: "Guest networks — the subnet of each role here", type: "rows", cols: GNET_COLS, max: 8, addLabel: "Add subnet", def: gnetRows(s.spec || {}), + add: () => ({role: "app", cidr: "", reservedHostIDs: "1, 2", dhcpServerRef: knownServers(s.spec || {})[0] || ""}), + hint: "The DHCP server is the name of a registered DHCPServer of this site (Disaster recovery → DHCP servers)."}, + {k: "dhcp", label: "DHCP server of the site (default for every guest network)", type: "text", def: (s.spec || {}).dhcpServerRef || "", placeholder: knownServers(s.spec || {}).join(", ") || "name of a registered DHCPServer"} + ], + run: v => drhub.patchSiteProfile(s, bindingsSpec(Object.assign({}, v, {dhcp: v.dhcp && v.dhcp.trim() ? v.dhcp.trim() : ""}))) +}); + +const newDHCPServerDialog = (sites, profiles) => ({ title: "Register a DHCP server", confirm: "Create", done: "DHCPServer created", desc: "A DHCP server of one site that guest addresses are reserved on. dr-hub never talks to it: it renders the reservations (,,) into the server's ConfigMap on the site, and dnsmasq reads them from its --dhcp-hostsdir. A SiteProfile's guestNetworks[role].dhcpServerRef (or spec.dhcpServerRef) names it.", fields: [ @@ -279,9 +412,16 @@ const newDHCPServerDialog = sites => ({ {k: "type", label: "Type", type: "select", options: [{v: "dnsmasq", l: "dnsmasq — reservations ConfigMap mounted as --dhcp-hostsdir"}]}, {k: "namespace", label: "ConfigMap namespace (on the site)", type: "text", required: true, placeholder: "dhcp"}, {k: "configMap", label: "ConfigMap name", type: "text", required: true, placeholder: "sitemap-hosts"}, - {k: "n1", type: "note", label: "Then bind it on the site profile: spec.guestNetworks[].dhcpServerRef or spec.dhcpServerRef (kubectl in this phase). Guests need a pinned MAC and an address inside the role's CIDR to get a reservation."} + {k: "bind", label: "Bind it as the site's DHCP server (the site profile's default and every guest network without one)", type: "checkbox", def: true}, + {k: "n1", type: "note", label: "Guests need a pinned MAC and an address inside the role's guest subnet to get a reservation; the subnets are the site profile's bindings."} ], - run: v => drhub.createDHCPServer({name: v.name.trim(), site: v.site, namespace: v.namespace.trim(), configMap: v.configMap.trim()}) + run: v => drhub.createDHCPServer({name: v.name.trim(), site: v.site, namespace: v.namespace.trim(), configMap: v.configMap.trim()}).then(r => { + const prof = (profiles || []).find(p => p.name === v.site); + if (!v.bind || !prof) return r; + const sp = prof.spec || {}, name = dns63(v.name.trim()); + return drhub.patchSiteProfile(prof, {dhcpServerRef: name, + guestNetworks: (sp.guestNetworks || []).map(g => Object.assign({}, g, g.dhcpServerRef ? {} : {dhcpServerRef: name}))}).then(() => r); + }) }); // ---- command registry (kebab menus) ---------------------------------------- @@ -289,6 +429,7 @@ const newDHCPServerDialog = sites => ({ // create on the run kinds, override to the override verb, delete to delete. Object.assign(ACTIONS, { pplan: p => [ + {label: "Edit S3 stores", icon: "cloud", op: "update", dialog: editPlanS3Dialog(p)}, {label: "Delete plan", icon: "trash", danger: true, op: "delete", removes: true, dialog: deleteDialog(p, "Deleting a plan removes the derived DRClusters, DRPolicies and classes. Applications bound to it lose their protection.", true)} ], drpath: p => [ @@ -301,6 +442,7 @@ Object.assign(ACTIONS, { {label: "Test", icon: "camera", op: "test", dialog: runTestDialog(a), disabled: !a.paths.some(p => p.actions.includes("Test")), hint: "No declared path allows Test"}, {label: "Schedule tests", icon: "clock", op: "create", dialog: newScheduleDialog(a), disabled: !a.paths.some(p => p.actions.includes("Test")), hint: "No declared path allows Test"}, {label: "Restore from backup", icon: "cloud", op: "drrestore", dialog: restoreDialog(a)}, + {label: "Edit tiers & probes", icon: "list", op: "update", dialog: editTiersDialog(a)}, {label: a.autoRestartOptOut ? "Enable automatic restart" : "Disable automatic restart", icon: "power", op: "update", run: () => drhub.setAutoRestart(a, a.autoRestartOptOut), toast: "Auto-restart preference saved"}, {label: "Unprotect (delete)", icon: "trash", danger: true, op: "delete", removes: true, dialog: deleteDialog(a, "Removes the ProtectedApplication and the derived DRPlacementControl. The workload keeps running where it is; its volumes stop being replicated.")} ], @@ -326,7 +468,9 @@ Object.assign(ACTIONS, { restore: r => [ {label: "Delete record", icon: "trash", danger: true, op: "delete", removes: true, disabled: !r.terminal, hint: "A running restore cannot be deleted", dialog: deleteDialog(r, "")} ], - siteprofile: () => [], + siteprofile: s => [ + {label: "Edit bindings", icon: "link", op: "update", dialog: editBindingsDialog(s)} + ], dhcpserver: d => [ {label: "Delete server", icon: "trash", danger: true, op: "delete", removes: true, dialog: deleteDialog(d, "Reservations rendered for this server stay in its ConfigMap until the hub re-renders the site; guests whose role names it become Open.")} ], From abcb6402beda016179ddd3c52abe0bba4e4f4953 Mon Sep 17 00:00:00 2001 From: michael Date: Sat, 3 Oct 2026 15:42:52 +0300 Subject: [PATCH 2/3] Control Center: a managed site's storage is discovered, sized and deployed from the hub Disaster recovery -> Site storage lists the hub's StorageSiteDeployments (storage.simplyblock.io/v1alpha2, operator PR #620): one per managed site, with its phase, the nodes the discovery found and, once approved, the storage cluster with its id, pool and nodes. - "Deploy storage" picks a managed cluster (ManagedClusters, else the site profiles), the discovery scope and the sizing (name, vCPUs, hugepages, subsystems, stripe, drive format) and creates the request; the operator runs the discovery on the site through OCM. - The detail shows the draft as the site wrote it next to the requested sizing; "Size the draft" changes the sizing, "Approve and deploy" flips the one-way approval (typed confirmation). - The console's ClusterRole (chart and plain manifests) may manage the kind and read ManagedClusters; the mock serves two fixtures (Online, Drafted) and its personas carry the rule. Checked with a jsdom smoke run of the built bundle against the mock: the layer lists both fixtures, the drafted one prompts for approval, the deploy dialog opens, the detail renders the cluster and its nodes, no script error. dist/ rebuilt with the Dockerfile's pinned babel (7.29.7). Co-Authored-By: Claude Opus 5.5 --- control-center/app.jsx | 14 +- control-center/deploy/k8s/rbac.yaml | 8 + control-center/details-data.jsx | 2 +- control-center/dist/app.js | 488 +++++++++++++++++- control-center/dist/mock.js | 100 +++- control-center/drhub-api.jsx | 44 +- control-center/drhub.jsx | 121 ++++- control-center/k8s-client.jsx | 3 + control-center/mock-drhub.jsx | 19 +- control-center/mock-rbac.jsx | 4 +- control-center/rbac.jsx | 7 +- .../templates/control-center-rbac.yaml | 8 + 12 files changed, 788 insertions(+), 30 deletions(-) diff --git a/control-center/app.jsx b/control-center/app.jsx index 166f994e0..116d5b28f 100644 --- a/control-center/app.jsx +++ b/control-center/app.jsx @@ -32,6 +32,7 @@ const LAYER_META = { restores: {label: "Restores", icon: "cloud"}, restore: {icon: "cloud"}, siteprofiles: {label: "Site profiles", icon: "k8s"}, siteprofile: {icon: "k8s"}, dhcpservers: {label: "DHCP servers", icon: "link"}, dhcpserver: {icon: "link"}, + sitedeploys: {label: "Site storage", icon: "cluster"}, sitedeploy: {icon: "cluster"}, drconfig: {label: "DR configuration", icon: "gauge"}, slots: {label: "Replication slots", icon: "volume"}, slot: {icon: "volume"}, replops: {label: "Operations", icon: "clock"}, replop: {icon: "clock"}, @@ -67,6 +68,7 @@ const pTSched = id => [{t: "dr"}, {t: "tschedules"}, {t: "tsched", id}]; const pRestore = id => [{t: "dr"}, {t: "restores"}, {t: "restore", id}]; const pSProf = id => [{t: "dr"}, {t: "siteprofiles"}, {t: "siteprofile", id}]; const pDhcp = id => [{t: "dr"}, {t: "dhcpservers"}, {t: "dhcpserver", id}]; +const pSiteDeploy = id => [{t: "dr"}, {t: "sitedeploys"}, {t: "sitedeploy", id}]; const pPair = id => [{t: "dr"}, {t: "pairs"}, {t: "pair", id}]; const pSlot = id => [{t: "dr"}, {t: "slots"}, {t: "slot", id}]; const pReplOp = id => [{t: "dr"}, {t: "replops"}, {t: "replop", id}]; @@ -98,6 +100,7 @@ const detailPath = o => o.kind === "cluster" ? pC(o.id) : o.kind === "restore" ? pRestore(o.id) : o.kind === "siteprofile" ? pSProf(o.id) : o.kind === "dhcpserver" ? pDhcp(o.id) + : o.kind === "sitedeploy" ? pSiteDeploy(o.id) : o.kind === "pair" ? pPair(o.id) : o.kind === "slot" ? pSlot(o.id) : o.kind === "replops" ? pReplOp(o.id) @@ -160,7 +163,7 @@ const SORT_KEYS = { policy: ["name", "members"], pplan: ["health", "name", "newest"], drpath: ["health", "name", "newest"], papp: ["health", "name", "newest"], rplan: ["health", "name", "newest"], raction: ["newest", "health", "name", "oldest"], tbubble: ["newest", "health", "name", "oldest"], - tsched: ["health", "name", "newest"], restore: ["newest", "health", "name"], siteprofile: ["health", "name"], dhcpserver: ["health", "name"], + tsched: ["health", "name", "newest"], restore: ["newest", "health", "name"], siteprofile: ["health", "name"], dhcpserver: ["health", "name"], sitedeploy: ["health", "name", "newest"], pair: ["health", "name", "slots", "newest"], slot: ["health", "name", "newest"], replops: ["newest", "health", "name"], @@ -227,6 +230,7 @@ const VIEWS = { restores: {kind: "restore", load: p => p.t === "papp" ? drhub.appRestores(p.id) : drhub.restores(), api: () => drcrd("restoreactions", true)}, siteprofiles: {kind: "siteprofile", load: () => drhub.siteProfiles(), api: () => "GET /apis/sitemap.simplyblock.io/v1alpha1/siteprofiles"}, dhcpservers: {kind: "dhcpserver", load: p => p.t === "siteprofile" ? drhub.siteDHCPServers(p.id) : drhub.dhcpServers(), api: () => "GET /apis/sitemap.simplyblock.io/v1alpha1/dhcpservers"}, + sitedeploys: {kind: "sitedeploy", load: () => drhub.siteDeploys(), api: () => "GET /apis/storage.simplyblock.io/v1alpha2/storagesitedeployments"}, storageclasses: {kind: "storageclass", load: p => p.t === "pool" ? api.poolStorageClasses(p.id) : api.k8sStorageClasses(p.id), api: () => "GET /apis/storage.k8s.io/v1/storageclasses"}, @@ -276,6 +280,7 @@ const DETAIL_API = { rplan: drcrd("recoveryplans/{name}", true), raction: drcrd("recoveryactions/{name}", true), tbubble: drcrd("testbubbles/{name}", true), tsched: drcrd("testschedules/{name}", true), restore: drcrd("restoreactions/{name}", true), siteprofile: "GET /apis/sitemap.simplyblock.io/v1alpha1/siteprofiles/{name}", dhcpserver: "GET /apis/sitemap.simplyblock.io/v1alpha1/dhcpservers/{name}", + sitedeploy: "GET /apis/storage.simplyblock.io/v1alpha2/namespaces/{ns}/storagesitedeployments/{name}", pair: crd1("replicationpairs"), rpolicy: crd1("replicationpolicies"), slot: crd1("replicationslots"), replops: crd1("replicationops"), zone: prop("zones/{uuid}"), cgroup: prop("consistency-groups/{uuid}"), @@ -287,7 +292,7 @@ const DETAIL_API = { const KIND_LABEL = {cluster: "cluster", host: "host", node: "storage node", device: "device", pool: "storage pool", volume: "logical volume", snapshot: "snapshot", backup: "backup", policy: "backup policy", pplan: "protection plan", drpath: "DR path", papp: "protected application", rplan: "recovery plan", raction: "recovery action", - tbubble: "test", tsched: "test schedule", restore: "restore", siteprofile: "site profile", dhcpserver: "DHCP server", + tbubble: "test", tsched: "test schedule", restore: "restore", siteprofile: "site profile", dhcpserver: "DHCP server", sitedeploy: "site storage deployment", pair: "replication pair", rpolicy: "replication policy", slot: "replication slot", replops: "replication operation", zone: "zone", cgroup: "consistency group", cgsnapshot: "group snapshot", migration: "migration", @@ -394,12 +399,12 @@ function DiscoveryView({kid, nav}) { const TILE = {cluster: ClusterTile, host: HostTile, node: NodeTile, device: DeviceTile, pool: PoolTile, volume: VolumeTile, snapshot: SnapshotTile, backup: BackupTile, policy: PolicyTile, pplan: PPlanTile, drpath: DRPathTile, papp: PAppTile, rplan: RPlanTile, raction: RActionTile, tbubble: TBubbleTile, - tsched: TSchedTile, restore: RestoreTile, siteprofile: SiteProfileTile, dhcpserver: DHCPServerTile, pair: PairTile, rpolicy: RPolicyTile, + tsched: TSchedTile, restore: RestoreTile, siteprofile: SiteProfileTile, dhcpserver: DHCPServerTile, sitedeploy: SiteDeployTile, pair: PairTile, rpolicy: RPolicyTile, slot: SlotTile, replops: ReplOpsTile, zone: ZoneTile, cgroup: CgroupTile, cgsnapshot: CgSnapshotTile, migration: MigrationTile, k8sc: K8sTile, storageclass: StorageClassTile, pvc: PvcTile, bucket: BucketTile, deployconfig: DeployConfigTile, mpath: MPathTile, appgroup: AppGroupTile}; const TKEY = {cluster: "c", host: "h", node: "n", device: "d", pool: "p", volume: "v", snapshot: "s", - backup: "b", pplan: "o", drpath: "o", papp: "o", rplan: "o", raction: "o", tbubble: "o", tsched: "o", restore: "o", siteprofile: "o", dhcpserver: "o", policy: "p", pair: "p", rpolicy: "p", + backup: "b", pplan: "o", drpath: "o", papp: "o", rplan: "o", raction: "o", tbubble: "o", tsched: "o", restore: "o", siteprofile: "o", dhcpserver: "o", sitedeploy: "o", policy: "p", pair: "p", rpolicy: "p", slot: "s", replops: "o", zone: "s", cgroup: "g", cgsnapshot: "s", migration: "m", k8sc: "k", storageclass: "s", pvc: "p", bucket: "b", deployconfig: "d", mpath: "m", appgroup: "g"}; @@ -490,6 +495,7 @@ function OverviewView({seg, parent, nav, prefs, rev, up, upLabel}) { : seg.t === "paths" ? : seg.t === "protectedapps" ? : seg.t === "rplans" ? + : seg.t === "sitedeploys" ? : seg.t === "dhcpservers" ? : seg.t === "pairs" ? : seg.t === "rpolicies" ? diff --git a/control-center/deploy/k8s/rbac.yaml b/control-center/deploy/k8s/rbac.yaml index e29b4c0b4..3a7d7fb5e 100644 --- a/control-center/deploy/k8s/rbac.yaml +++ b/control-center/deploy/k8s/rbac.yaml @@ -156,6 +156,14 @@ rules: - apiGroups: ["sitemap.simplyblock.io"] resources: ["dhcpservers"] verbs: ["create", "update", "patch", "delete"] + # a managed site's storage deployment is requested, sized and approved + # from the hub console (StorageSiteDeployment, carried to the site by OCM) + - apiGroups: ["storage.simplyblock.io"] + resources: ["storagesitedeployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["cluster.open-cluster-management.io"] + resources: ["managedclusters"] + verbs: ["get", "list", "watch"] # the console asks the API server what its identity may do, to disable controls - apiGroups: ["authorization.k8s.io"] resources: ["selfsubjectaccessreviews", "selfsubjectrulesreviews"] diff --git a/control-center/details-data.jsx b/control-center/details-data.jsx index b73241a65..d82e1e041 100644 --- a/control-center/details-data.jsx +++ b/control-center/details-data.jsx @@ -334,7 +334,7 @@ const DETAIL_KIND = {pair: "PairDetail", rpolicy: "RPolicyDetail", zone: "ZoneDe k8sc: "K8sDetail", storageclass: "StorageClassDetail", pvc: "PvcDetail", bucket: "BucketDetail", // DR hub kinds live in drhub.jsx pplan: "PPlanDetail", drpath: "DRPathDetail", papp: "PAppDetail", rplan: "RPlanDetail", raction: "RActionDetail", - tbubble: "TBubbleDetail", tsched: "TSchedDetail", restore: "RestoreDetail", siteprofile: "SiteProfileDetail", dhcpserver: "DHCPServerDetail", + tbubble: "TBubbleDetail", tsched: "TSchedDetail", restore: "RestoreDetail", siteprofile: "SiteProfileDetail", dhcpserver: "DHCPServerDetail", sitedeploy: "SiteDeployDetail", deployconfig: "DeployConfigDetail", mpath: "MPathDetail", appgroup: "AppGroupDetail"}; const Detail = ({obj, nav}) => { const C = DETAILS[obj.kind] || (DETAIL_KIND[obj.kind] ? window[DETAIL_KIND[obj.kind]] : null); diff --git a/control-center/dist/app.js b/control-center/dist/app.js index 838d78dd5..6098eef42 100644 --- a/control-center/dist/app.js +++ b/control-center/dist/app.js @@ -255,6 +255,14 @@ const RESOURCES = { core: OCM_CLUSTER_API_GROUP, namespaced: false }, + // a managed site's storage deployment, requested from the hub (operator, + // storage.simplyblock.io/v1alpha2); the operator carries it to the site + StorageSiteDeployment: { + plural: "storagesitedeployments", + short: "sbsd", + core: "storage.simplyblock.io/v1alpha2", + namespaced: true + }, // access reviews: the API server answers what the caller may do SelfSubjectAccessReview: { plural: "selfsubjectaccessreviews", @@ -3347,7 +3355,8 @@ const DR_KINDS = { restore: "RestoreAction", siteprofile: "SiteProfile", drconfig: "DRConfig", - dhcpserver: "DHCPServer" + dhcpserver: "DHCPServer", + sitedeploy: "StorageSiteDeployment" }; const DR_ANN = { createdBy: "dr.simplyblock.io/created-by", @@ -4003,6 +4012,75 @@ function normDHCPServer(o) { } })); } + +// A managed site's storage deployment (StorageSiteDeployment): the hub-side +// request the operator carries to the site through OCM. The status projects +// the site's draft (the discovered nodes, for review) and, once approved, the +// StorageCluster it expanded into. +const SITE_DEPLOY_STATUS = { + Pending: { + c: "var(--idle)", + rank: 2, + label: "pending" + }, + Discovering: { + c: "var(--info)", + rank: 1, + label: "discovering", + blink: true + }, + Drafted: { + c: "var(--accent)", + rank: 3, + label: "awaiting approval" + }, + Deploying: { + c: "var(--info)", + rank: 1, + label: "deploying", + blink: true + }, + Online: { + c: "var(--ok)", + rank: 0, + label: "online" + }, + Failed: { + c: "var(--bad)", + rank: 4, + label: "failed" + } +}; +Object.entries(SITE_DEPLOY_STATUS).forEach(([k, v]) => { + if (!STATUS_META[k]) STATUS_META[k] = v; +}); +function normSiteDeploy(o) { + const sp = o.spec || {}, + st = o.status || {}; + const draft = st.draft || null, + sc = st.storageCluster || null; + const nodeSets = draft && draft.nodeSets || []; + const workers = nodeSets.flatMap(s => (s.groups || []).flatMap(g => g.workers || [])); + return reg(Object.assign(base(o, "sitedeploy"), { + status: st.phase || "Pending", + message: st.message || "", + site: sp.cluster || "", + siteNamespace: sp.siteNamespace || "simplyblock", + draftName: sp.draftName || "site-draft", + discover: sp.discover || {}, + sizing: sp.sizing || null, + approved: !!sp.approved, + draft, + nodeSets, + workers, + storageCluster: sc, + workName: st.workName || "", + counts: { + nodes: workers.length, + storageNodes: sc && sc.nodes ? sc.nodes.length : 0 + } + })); +} const NORM = { pplan: normPPlan, drpath: normDRPath, @@ -4014,7 +4092,8 @@ const NORM = { restore: normRestore, siteprofile: normSiteProfile, drconfig: normDRConfig, - dhcpserver: normDHCPServer + dhcpserver: normDHCPServer, + sitedeploy: normSiteDeploy }; // The resolution inbox (design 13.1): open findings of every application, @@ -4112,6 +4191,11 @@ const drhub = { dhcpServers: () => drList("dhcpserver"), dhcpServer: drById("dhcpserver"), siteDHCPServers: id => Promise.all([drhub.siteProfile(id), drhub.dhcpServers()]).then(([sp, ds]) => ds.filter(d => d.site === sp.name)), + siteDeploys: () => drList("sitedeploy").catch(e => { + if (e && e.status === 404) return []; + throw e; + }), + siteDeploy: drById("sitedeploy"), configs: () => drList("drconfig"), config: () => drList("drconfig").then(cs => cs.find(c => c.name === "default") || cs[0] || null), // derived Ramen objects, read-only @@ -4389,6 +4473,50 @@ const drhub = { } } }), + // A managed site's storage (StorageSiteDeployment): the operator runs the + // discovery on the site, writes the sizing onto the draft it produced and + // delivers the approval -- all through OCM; the console writes this object + // only. Approval is one-way. + createSiteDeploy: ({ + site, + namespace, + enableControlPlaneNodes, + workers, + sizing + }) => k8s.create("StorageSiteDeployment", { + apiVersion: "storage.simplyblock.io/v1alpha2", + kind: "StorageSiteDeployment", + metadata: { + name: dns63(site), + namespace + }, + spec: Object.assign({ + cluster: site, + discover: Object.assign({ + enableControlPlaneNodes: !!enableControlPlaneNodes + }, workers && workers.length ? { + workers + } : {}) + }, sizing && Object.keys(sizing).length ? { + sizing + } : {}) + }, { + namespace + }), + patchSiteDeploySizing: (d, sizing) => k8s.patch("StorageSiteDeployment", d.name, { + spec: { + sizing + } + }, { + namespace: d.namespace + }), + approveSiteDeploy: d => k8s.patch("StorageSiteDeployment", d.name, { + spec: { + approved: true + } + }, { + namespace: d.namespace + }), // Optional per-application knob: opt out of the automatic restart after a // storage recovery (ADR 0017). setAutoRestart: (a, on) => k8s.patch("ProtectedApplication", a.name, { @@ -4431,7 +4559,8 @@ Object.assign(GETTER, { restore: drhub.restoreAction, siteprofile: drhub.siteProfile, drconfig: drhub.config, - dhcpserver: drhub.dhcpServer + dhcpserver: drhub.dhcpServer, + sitedeploy: drhub.siteDeploy }); Object.assign(window, { drhub, @@ -4459,7 +4588,8 @@ Object.assign(window, { normRestore, normSiteProfile, normDRConfig, - normDHCPServer + normDHCPServer, + normSiteDeploy }); })(); // ---- agent.jsx ---- @@ -5008,7 +5138,8 @@ const KIND_ENTITY = { restore: "drhub", drconfig: "drhub", siteprofile: "drhub", - dhcpserver: "drhub" + dhcpserver: "drhub", + sitedeploy: "drhub" }; // UI kind -> the CRD resource the API server checks (§3.5, the console's column) const KIND_RESOURCE = { @@ -5055,7 +5186,8 @@ const KIND_RESOURCE = { restore: "restoreactions", drconfig: "drconfigs", siteprofile: "siteprofiles", - dhcpserver: "dhcpservers" + dhcpserver: "dhcpservers", + sitedeploy: "storagesitedeployments" }; // UI kind -> API group, where it is not the default simplyblock group const KIND_GROUP = { @@ -5069,7 +5201,8 @@ const KIND_GROUP = { restore: "dr.simplyblock.io", drconfig: "dr.simplyblock.io", siteprofile: "sitemap.simplyblock.io", - dhcpserver: "sitemap.simplyblock.io" + dhcpserver: "sitemap.simplyblock.io", + sitedeploy: "storage.simplyblock.io" }; const ENTITY_GROUP = { drhub: "dr.simplyblock.io" @@ -23558,7 +23691,8 @@ const KIND_LABEL_DR = { restore: "restore", siteprofile: "site profile", drconfig: "DR configuration", - dhcpserver: "DHCP server" + dhcpserver: "DHCP server", + sitedeploy: "site storage deployment" }; const METHOD_TYPES = [{ v: "async", @@ -24575,6 +24709,139 @@ const newDHCPServerDialog = (sites, profiles) => ({ }) }); +// ---- a managed site's storage (StorageSiteDeployment) ---------------------- +// The hub console cannot reach a site's API server; the operator on the hub +// carries the request there through OCM. The console writes the request, the +// sizing and the approval, and reads back the projected draft and cluster. +const sizingFields = z => [{ + k: "name", + label: "Storage cluster name", + type: "text", + def: z && z.name || "", + placeholder: "sb-site-a" +}, { + k: "vcpuCount", + label: "vCPUs per storage node", + type: "number", + def: z && z.vcpuCount != null ? z.vcpuCount : "", + min: 1, + placeholder: "8" +}, { + k: "minHugePagesSize", + label: "Hugepages per storage node", + type: "text", + def: z && z.minHugePagesSize || "", + placeholder: "8G" +}, { + k: "maxSubsystemCount", + label: "NVMe-oF subsystems per node", + type: "number", + def: z && z.maxSubsystemCount != null ? z.maxSubsystemCount : "", + min: 1, + placeholder: "30" +}, { + k: "dataChunks", + label: "Erasure coding: data chunks", + type: "number", + def: z && z.stripe && z.stripe.dataChunks != null ? z.stripe.dataChunks : "", + min: 1, + placeholder: "1" +}, { + k: "parityChunks", + label: "Erasure coding: parity chunks", + type: "number", + def: z && z.stripe && z.stripe.parityChunks != null ? z.stripe.parityChunks : "", + min: 0, + placeholder: "1" +}, { + k: "enableDriveFormat", + label: "Format the devices it takes (data on them is lost)", + type: "checkbox", + def: !!(z && z.enableDriveFormat) +}]; +const num = v => v === "" || v == null ? null : Number(v); +const sizingOf = v => { + const z = {}; + if (v.name && v.name.trim()) z.name = dns63(v.name.trim()); + if (num(v.vcpuCount) != null) z.vcpuCount = num(v.vcpuCount); + if (v.minHugePagesSize && v.minHugePagesSize.trim()) z.minHugePagesSize = v.minHugePagesSize.trim(); + if (num(v.maxSubsystemCount) != null) z.maxSubsystemCount = num(v.maxSubsystemCount); + if (num(v.dataChunks) != null || num(v.parityChunks) != null) z.stripe = Object.assign({}, num(v.dataChunks) != null ? { + dataChunks: num(v.dataChunks) + } : {}, num(v.parityChunks) != null ? { + parityChunks: num(v.parityChunks) + } : {}); + if (v.enableDriveFormat) z.enableDriveFormat = true; + return z; +}; +const deploySiteDialog = (sites, taken) => ({ + title: "Deploy storage on a managed site", + confirm: "Discover", + done: "StorageSiteDeployment created — discovery requested on the site", + desc: "The operator on this hub runs a discovery on the site through Open Cluster Management and writes a draft deployment document there. You review the draft here, with the sizing below applied, and approve it; nothing is configured on any node before the approval.", + fields: [{ + k: "site", + label: "Site (managed cluster)", + type: "select", + required: true, + options: sites.filter(s => !taken.includes(s)).map(s => ({ + v: s, + l: s + })), + empty: "Every managed cluster has a storage deployment already, or none has joined the hub." + }, { + k: "namespace", + label: "Namespace of the request on the hub", + type: "text", + required: true, + def: (window.SB_CONFIG || {}).namespace || "simplyblock" + }, { + k: "enableControlPlaneNodes", + label: "Include control-plane nodes in the discovery (every node of a small site is one)", + type: "checkbox", + def: true + }, { + k: "workers", + label: "Limit to these nodes (comma-separated; empty = every node)", + type: "text", + placeholder: "" + }, ...sizingFields(null), { + k: "n1", + type: "note", + label: "Approval is one-way and reboots the site's storage nodes to set hugepages and core isolation." + }], + run: v => drhub.createSiteDeploy({ + site: v.site, + namespace: v.namespace.trim(), + enableControlPlaneNodes: v.enableControlPlaneNodes, + workers: csv(v.workers), + sizing: sizingOf(v) + }) +}); +const resizeSiteDialog = d => ({ + title: `Size the draft of ${d.site}`, + confirm: "Apply sizing", + done: "Sizing sent to the site's draft", + desc: "Written onto the draft's cluster template on the site. Fields left empty keep what the discovery wrote.", + fields: sizingFields(d.sizing), + run: v => drhub.patchSiteDeploySizing(d, sizingOf(v)) +}); +const approveSiteDialog = d => ({ + title: `Approve the storage deployment of ${d.site}?`, + confirm: "Approve and deploy", + danger: true, + done: "Approved — the site's draft is expanding", + desc: `Approval is one-way. The site's ${d.counts.nodes} node(s) are configured (hugepages, core isolation; this reboots them), the storage nodes are added and the cluster ${((d.draft || {}).cluster || {}).name || (d.sizing || {}).name || ""} is activated in the control plane.`, + fields: [{ + k: "confirm", + label: `Type ${d.site} to confirm`, + type: "text", + required: true, + match: d.site + }], + run: () => drhub.approveSiteDeploy(d) +}); + // ---- command registry (kebab menus) ---------------------------------------- // `op` is what access.can() checks: failover/relocate/restart/test map to // create on the run kinds, override to the override verb, delete to delete. @@ -24761,7 +25028,29 @@ Object.assign(ACTIONS, { removes: true, dialog: deleteDialog(d, "Reservations rendered for this server stay in its ConfigMap until the hub re-renders the site; guests whose role names it become Open.") }], - drconfig: () => [] + drconfig: () => [], + sitedeploy: d => [{ + label: "Size the draft", + icon: "gauge", + op: "update", + dialog: resizeSiteDialog(d), + disabled: d.approved, + hint: "The deployment is approved" + }, { + label: "Approve and deploy", + icon: "check", + op: "update", + dialog: approveSiteDialog(d), + disabled: d.approved || d.status !== "Drafted", + hint: d.approved ? "Already approved" : "No draft with nodes to approve yet" + }, { + label: "Delete request", + icon: "trash", + danger: true, + op: "delete", + removes: true, + dialog: deleteDialog(d, "Deleting the request withdraws nothing on the site: the discovery, the draft and any storage cluster it produced stay.") + }] }); // ---- tiles ------------------------------------------------------------------ @@ -25247,6 +25536,139 @@ function DHCPServerTile({ }] })); } +function SiteDeployTile({ + o: d, + nav +}) { + const sc = d.storageCluster; + return /*#__PURE__*/React.createElement("div", { + className: "tile", + style: { + "--sc": STATUS_META[d.status].c + }, + onDoubleClick: () => nav.detail(d) + }, /*#__PURE__*/React.createElement(TileHead, { + obj: d, + left: /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(TrafficLight, { + status: d.status + }), /*#__PURE__*/React.createElement(Name, null, d.site)), + right: /*#__PURE__*/React.createElement("span", { + className: "badge" + }, d.approved ? "approved" : "draft") + }), /*#__PURE__*/React.createElement("div", { + className: "tsub", + style: { + marginTop: 2 + } + }, d.message || "—"), /*#__PURE__*/React.createElement(Uuid, { + value: d.id + }), /*#__PURE__*/React.createElement("div", { + className: "kv" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", null, "nodes found"), /*#__PURE__*/React.createElement("b", null, d.counts.nodes)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", null, "storage cluster"), /*#__PURE__*/React.createElement("b", null, sc ? sc.name : "—")), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", null, "storage nodes"), /*#__PURE__*/React.createElement("b", null, sc ? d.counts.storageNodes : "—")), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", null, "cluster id"), /*#__PURE__*/React.createElement("b", { + className: "mono" + }, sc && sc.uuid ? sc.uuid.slice(0, 8) : "—"))), d.status === "Drafted" && !d.approved && /*#__PURE__*/React.createElement("div", { + className: "prepbox" + }, "Review the draft and approve it. Nothing has been applied to any node yet."), /*#__PURE__*/React.createElement(Foot, { + items: [d.status === "Drafted" && !d.approved ? { + label: "Approve", + icon: "check", + onClick: () => window.__ui.dialog(approveSiteDialog(d), d) + } : null, { + label: "Details", + right: true, + onClick: () => nav.detail(d) + }] + })); +} +function SiteDeployDetail({ + o: d, + nav +}) { + const t = d.draft && d.draft.cluster || {}; + const z = d.sizing || {}; + const sc = d.storageCluster; + const str = v => v == null ? "" : String(v); + return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(DetailHead, { + obj: d, + title: `Storage of ${d.site}`, + sub: /*#__PURE__*/React.createElement("span", { + className: "mono", + style: { + color: "var(--dim)" + } + }, "StorageSiteDeployment ", d.namespace, "/", d.name, " \xB7 draft ", d.siteNamespace, "/", d.draftName, " on the site"), + badge: /*#__PURE__*/React.createElement("span", { + className: "badge" + }, d.approved ? "approved" : "not approved") + }), d.status === "Failed" && /*#__PURE__*/React.createElement("div", { + className: "banner" + }, /*#__PURE__*/React.createElement(Icon, { + n: "alert", + s: 15 + }), /*#__PURE__*/React.createElement("span", null, /*#__PURE__*/React.createElement("b", null, "The deployment failed."), " ", d.message)), /*#__PURE__*/React.createElement("div", { + className: "stats" + }, /*#__PURE__*/React.createElement(Stat, { + k: "State", + v: /*#__PURE__*/React.createElement(TrafficLight, { + status: d.status + }), + s: d.message + }), /*#__PURE__*/React.createElement(Stat, { + k: "Nodes in the draft", + v: d.counts.nodes, + s: d.discover.enableControlPlaneNodes ? "control-plane nodes included" : "" + }), /*#__PURE__*/React.createElement(Stat, { + k: "Draft", + v: d.draft && d.draft.phase || "—", + s: d.draft && d.draft.message ? d.draft.message : "" + }), /*#__PURE__*/React.createElement(Stat, { + k: "Storage cluster", + v: sc ? sc.name : "—", + s: sc ? `${sc.phase || "not reported"}${sc.uuid ? " · " + sc.uuid : ""}` : "after the approval" + })), /*#__PURE__*/React.createElement("div", { + className: "dcols" + }, /*#__PURE__*/React.createElement("div", { + className: "card" + }, /*#__PURE__*/React.createElement("h3", null, "Draft \u2014 what the discovery found"), /*#__PURE__*/React.createElement("div", { + className: "bd", + style: { + overflowX: "auto" + } + }, /*#__PURE__*/React.createElement(Table, { + cols: ["Node set", "Group", "Nodes"], + empty: "The site has not written a draft with nodes yet.", + rows: d.nodeSets.flatMap(s => (s.groups || []).map((g, i) => [/*#__PURE__*/React.createElement(Mono, null, s.name), /*#__PURE__*/React.createElement(Mono, { + dim: true + }, g.name || `#${i + 1}`), /*#__PURE__*/React.createElement(Mono, null, (g.workers || []).join(", "))])) + }))), /*#__PURE__*/React.createElement("div", { + className: "card" + }, /*#__PURE__*/React.createElement("h3", null, "Cluster template on the site"), /*#__PURE__*/React.createElement("div", { + className: "bd" + }, /*#__PURE__*/React.createElement(Table, { + cols: ["Field", "On the site", "Requested"], + empty: "No draft yet.", + rows: d.draft ? [["name", t.name, z.name], ["vCPUs per node", t.vcpuCount, z.vcpuCount], ["hugepages per node", t.minHugePagesSize, z.minHugePagesSize], ["subsystems per node", t.maxSubsystemCount, z.maxSubsystemCount], ["stripe", t.stripe ? `${str(t.stripe.dataChunks)}+${str(t.stripe.parityChunks)}` : "", z.stripe ? `${str(z.stripe.dataChunks)}+${str(z.stripe.parityChunks)}` : ""], ["format devices", t.enableDriveFormat, z.enableDriveFormat]].map(r => [/*#__PURE__*/React.createElement("b", null, r[0]), /*#__PURE__*/React.createElement(Mono, null, str(r[1])), /*#__PURE__*/React.createElement(Mono, { + dim: true + }, str(r[2]))]) : [] + })))), sc && /*#__PURE__*/React.createElement("div", { + className: "card" + }, /*#__PURE__*/React.createElement("h3", null, "Storage nodes"), /*#__PURE__*/React.createElement("div", { + className: "bd" + }, /*#__PURE__*/React.createElement(Table, { + cols: ["Storage node", "Kubernetes node", "Phase"], + empty: "No storage node reported yet.", + rows: (sc.nodes || []).map(n => [/*#__PURE__*/React.createElement(Mono, null, n.name), /*#__PURE__*/React.createElement(Mono, { + dim: true + }, n.hostname), /*#__PURE__*/React.createElement(Mono, null, n.phase || "—")]) + }), /*#__PURE__*/React.createElement("p", { + className: "mdesc", + style: { + margin: "9px 0 0" + } + }, "A StorageClass on the site names this cluster as cluster_id ", sc.uuid || "(not assigned yet)", " and pool ", sc.pool || "—", "."))), /*#__PURE__*/React.createElement(Conditions, { + o: d + })); +} // ---- site mapping (ADR 0020) ------------------------------------------------------ const MappingResult = ({ @@ -27421,6 +27843,12 @@ function DrHubHome({ sub: "guest address reservations per site", count: "\u2192", onClick: () => nav.drLayer("dhcpservers") + }), /*#__PURE__*/React.createElement(NavCard, { + icon: "cluster", + title: "Site storage", + sub: "discover, size and deploy a managed site's storage cluster", + count: "\u2192", + onClick: () => nav.drLayer("sitedeploys") }), /*#__PURE__*/React.createElement(NavCard, { icon: "gauge", title: "DR configuration", @@ -27442,6 +27870,9 @@ Object.assign(window, { RestoreTile, SiteProfileTile, DHCPServerTile, + SiteDeployTile, + SiteDeployDetail, + deploySiteDialog, PPlanDetail, DRPathDetail, PAppDetail, @@ -28264,6 +28695,7 @@ const DETAIL_KIND = { restore: "RestoreDetail", siteprofile: "SiteProfileDetail", dhcpserver: "DHCPServerDetail", + sitedeploy: "SiteDeployDetail", deployconfig: "DeployConfigDetail", mpath: "MPathDetail", appgroup: "AppGroupDetail" @@ -28447,6 +28879,13 @@ const LAYER_META = { dhcpserver: { icon: "link" }, + sitedeploys: { + label: "Site storage", + icon: "cluster" + }, + sitedeploy: { + icon: "cluster" + }, drconfig: { label: "DR configuration", icon: "gauge" @@ -28681,6 +29120,14 @@ const pDhcp = id => [{ t: "dhcpserver", id }]; +const pSiteDeploy = id => [{ + t: "dr" +}, { + t: "sitedeploys" +}, { + t: "sitedeploy", + id +}]; const pPair = id => [{ t: "dr" }, { @@ -28777,7 +29224,7 @@ const pAg = (pid, id) => [...pMp(pid), { t: "appgroup", id }]; -const detailPath = o => o.kind === "cluster" ? pC(o.id) : o.kind === "deployconfig" ? pDep(o.k8sClusterId, o.id) : o.kind === "host" ? pH(o.clusterId, o.id) : o.kind === "node" ? pN(o.clusterId, o.id) : o.kind === "device" ? pD(o.clusterId, o.nodeId, o.id) : o.kind === "pool" ? pP(o.clusterId, o.id) : o.kind === "volume" ? pV(o.clusterId, o.poolId, o.id) : o.kind === "pplan" ? pPPlan(o.id) : o.kind === "drpath" ? pDRPath(o.id) : o.kind === "papp" ? pPApp(o.id) : o.kind === "rplan" ? pRPlan(o.id) : o.kind === "raction" ? pRAction(o.id) : o.kind === "tbubble" ? pTBubble(o.id) : o.kind === "tsched" ? pTSched(o.id) : o.kind === "restore" ? pRestore(o.id) : o.kind === "siteprofile" ? pSProf(o.id) : o.kind === "dhcpserver" ? pDhcp(o.id) : o.kind === "pair" ? pPair(o.id) : o.kind === "slot" ? pSlot(o.id) : o.kind === "replops" ? pReplOp(o.id) : o.kind === "rpolicy" ? pRPol(o.id) : o.kind === "zone" ? pZone(o.id) : o.kind === "mpath" ? pMp(o.id) : o.kind === "appgroup" ? pAg(o.pathId, o.id) : o.kind === "bucket" ? pBucket(o.clusterId, o.id) : o.kind === "k8sc" ? pK(o.id) : o.kind === "storageclass" ? pSc(o.k8sClusterId, o.id) : o.kind === "pvc" ? pPvc(o.k8sClusterId, o.id) : o.kind === "migration" ? pMig(o.sourceClusterId || o.clusterId, o.id) : o.kind === "cgroup" ? pCg(o.clusterId, o.id) : o.kind === "cgsnapshot" ? [...pCg(o.clusterId, o.cgId), { +const detailPath = o => o.kind === "cluster" ? pC(o.id) : o.kind === "deployconfig" ? pDep(o.k8sClusterId, o.id) : o.kind === "host" ? pH(o.clusterId, o.id) : o.kind === "node" ? pN(o.clusterId, o.id) : o.kind === "device" ? pD(o.clusterId, o.nodeId, o.id) : o.kind === "pool" ? pP(o.clusterId, o.id) : o.kind === "volume" ? pV(o.clusterId, o.poolId, o.id) : o.kind === "pplan" ? pPPlan(o.id) : o.kind === "drpath" ? pDRPath(o.id) : o.kind === "papp" ? pPApp(o.id) : o.kind === "rplan" ? pRPlan(o.id) : o.kind === "raction" ? pRAction(o.id) : o.kind === "tbubble" ? pTBubble(o.id) : o.kind === "tsched" ? pTSched(o.id) : o.kind === "restore" ? pRestore(o.id) : o.kind === "siteprofile" ? pSProf(o.id) : o.kind === "dhcpserver" ? pDhcp(o.id) : o.kind === "sitedeploy" ? pSiteDeploy(o.id) : o.kind === "pair" ? pPair(o.id) : o.kind === "slot" ? pSlot(o.id) : o.kind === "replops" ? pReplOp(o.id) : o.kind === "rpolicy" ? pRPol(o.id) : o.kind === "zone" ? pZone(o.id) : o.kind === "mpath" ? pMp(o.id) : o.kind === "appgroup" ? pAg(o.pathId, o.id) : o.kind === "bucket" ? pBucket(o.clusterId, o.id) : o.kind === "k8sc" ? pK(o.id) : o.kind === "storageclass" ? pSc(o.k8sClusterId, o.id) : o.kind === "pvc" ? pPvc(o.k8sClusterId, o.id) : o.kind === "migration" ? pMig(o.sourceClusterId || o.clusterId, o.id) : o.kind === "cgroup" ? pCg(o.clusterId, o.id) : o.kind === "cgsnapshot" ? [...pCg(o.clusterId, o.cgId), { t: "cgsnapshots" }, { t: "cgsnapshot", @@ -28889,6 +29336,7 @@ const SORT_KEYS = { restore: ["newest", "health", "name"], siteprofile: ["health", "name"], dhcpserver: ["health", "name"], + sitedeploy: ["health", "name", "newest"], pair: ["health", "name", "slots", "newest"], slot: ["health", "name", "newest"], replops: ["newest", "health", "name"], @@ -29015,6 +29463,11 @@ const VIEWS = { load: p => p.t === "siteprofile" ? drhub.siteDHCPServers(p.id) : drhub.dhcpServers(), api: () => "GET /apis/sitemap.simplyblock.io/v1alpha1/dhcpservers" }, + sitedeploys: { + kind: "sitedeploy", + load: () => drhub.siteDeploys(), + api: () => "GET /apis/storage.simplyblock.io/v1alpha2/storagesitedeployments" + }, storageclasses: { kind: "storageclass", load: p => p.t === "pool" ? api.poolStorageClasses(p.id) : api.k8sStorageClasses(p.id), @@ -29110,6 +29563,7 @@ const DETAIL_API = { restore: drcrd("restoreactions/{name}", true), siteprofile: "GET /apis/sitemap.simplyblock.io/v1alpha1/siteprofiles/{name}", dhcpserver: "GET /apis/sitemap.simplyblock.io/v1alpha1/dhcpservers/{name}", + sitedeploy: "GET /apis/storage.simplyblock.io/v1alpha2/namespaces/{ns}/storagesitedeployments/{name}", pair: crd1("replicationpairs"), rpolicy: crd1("replicationpolicies"), slot: crd1("replicationslots"), @@ -29144,6 +29598,7 @@ const KIND_LABEL = { restore: "restore", siteprofile: "site profile", dhcpserver: "DHCP server", + sitedeploy: "site storage deployment", pair: "replication pair", rpolicy: "replication policy", slot: "replication slot", @@ -29445,6 +29900,7 @@ const TILE = { restore: RestoreTile, siteprofile: SiteProfileTile, dhcpserver: DHCPServerTile, + sitedeploy: SiteDeployTile, pair: PairTile, rpolicy: RPolicyTile, slot: SlotTile, @@ -29480,6 +29936,7 @@ const TKEY = { restore: "o", siteprofile: "o", dhcpserver: "o", + sitedeploy: "o", policy: "p", pair: "p", rpolicy: "p", @@ -29720,7 +30177,16 @@ function OverviewView({ }, /*#__PURE__*/React.createElement(Icon, { n: "plus", s: 12 - }), "New recovery plan") : seg.t === "dhcpservers" ? /*#__PURE__*/React.createElement("button", { + }), "New recovery plan") : seg.t === "sitedeploys" ? /*#__PURE__*/React.createElement("button", { + className: "btn primary", + onClick: () => Promise.all([drhub.managedClusters(), drhub.siteProfiles().catch(() => []), drhub.siteDeploys()]).then(([mcs, sps, sds]) => window.__ui.dialog(deploySiteDialog(mcs.length ? mcs.map(m => m.metadata.name) : sps.map(s => s.name), sds.map(d => d.site)), { + kind: "sitedeploy", + id: "new" + })) + }, /*#__PURE__*/React.createElement(Icon, { + n: "plus", + s: 12 + }), "Deploy storage") : seg.t === "dhcpservers" ? /*#__PURE__*/React.createElement("button", { className: "btn primary", onClick: () => drhub.siteProfiles().then(ss => window.__ui.dialog(newDHCPServerDialog(parent && parent.t === "siteprofile" && REG[parent.id] ? [REG[parent.id].name] : ss.map(s => s.name), ss), { kind: "dhcpserver", diff --git a/control-center/dist/mock.js b/control-center/dist/mock.js index 71df10f60..2df6012e5 100644 --- a/control-center/dist/mock.js +++ b/control-center/dist/mock.js @@ -5601,6 +5601,14 @@ const RESOURCES = { core: OCM_CLUSTER_API_GROUP, namespaced: false }, + // a managed site's storage deployment, requested from the hub (operator, + // storage.simplyblock.io/v1alpha2); the operator carries it to the site + StorageSiteDeployment: { + plural: "storagesitedeployments", + short: "sbsd", + core: "storage.simplyblock.io/v1alpha2", + namespaced: true + }, // access reviews: the API server answers what the caller may do SelfSubjectAccessReview: { plural: "selfsubjectaccessreviews", @@ -10561,7 +10569,8 @@ window.SB_DR = { RestoreAction: [], SiteProfile: [], DRConfig: [], - DHCPServer: [] + DHCPServer: [], + StorageSiteDeployment: [] }; const api = (kind, group) => ({ apiVersion: group || "dr.simplyblock.io/v1alpha1", @@ -11698,6 +11707,82 @@ window.SB_DR = { }); store.DHCPServer.push(dhcp("dhcp-cluster-a", "cluster-a", "dhcp", "sitemap-hosts", 3, "5e5e5e")); store.DHCPServer.push(dhcp("dhcp-cluster-b", "cluster-b", "dhcp", "sitemap-hosts", 3, "91ab00")); + + // ---- site storage (StorageSiteDeployment, storage.simplyblock.io/v1alpha2) ---- + const nodeSets = hosts => [{ + name: "default", + groups: [{ + name: "all", + workers: hosts + }] + }]; + const tpl = name => ({ + name, + vcpuCount: 8, + minHugePagesSize: "8G", + maxSubsystemCount: 30, + stripe: { + dataChunks: 1, + parityChunks: 1 + }, + enableDriveFormat: true + }); + const ssd = (site, spec, status) => Object.assign(api("StorageSiteDeployment", "storage.simplyblock.io/v1alpha2"), { + metadata: meta(site, "simplyblock"), + spec: Object.assign({ + cluster: site, + siteNamespace: "simplyblock", + draftName: "site-draft", + discover: { + enableControlPlaneNodes: true + }, + approved: false + }, spec), + status + }); + store.StorageSiteDeployment.push(ssd("cluster-a", { + sizing: tpl("sb-cluster-a"), + approved: true + }, { + phase: "Online", + message: "StorageCluster sb-cluster-a is Online (3 node(s))", + workName: "sbsd-1a2b3c", + draft: { + name: "site-draft", + phase: "Expanded", + approved: true, + cluster: tpl("sb-cluster-a"), + nodeSets: nodeSets(["a-1", "a-2", "a-3"]), + nodeRefs: ["sn-a-1", "sn-a-2", "sn-a-3"] + }, + storageCluster: { + name: "sb-cluster-a", + uuid: uid(), + phase: "Online", + pool: "sb-cluster-a-pool", + nodes: ["a-1", "a-2", "a-3"].map(h => ({ + name: "sn-" + h, + phase: "Online", + hostname: h + })) + }, + conditions: [cond("Delivered", true, "Applied", "the work is applied on the site"), cond("Discovered", true, "Nodes", "3 node(s) in the draft"), cond("Approved", true, "SiteDraft", "the site's draft approved=true"), cond("Ready", true, "Online", "the StorageCluster is Online")] + })); + store.StorageSiteDeployment.push(ssd("cluster-b", { + sizing: tpl("sb-cluster-b") + }, { + phase: "Drafted", + message: "the draft awaits approval", + workName: "sbsd-4d5e6f", + draft: { + name: "site-draft", + phase: "Draft", + approved: false, + cluster: tpl("sb-cluster-b"), + nodeSets: nodeSets(["b-1", "b-2", "b-3"]) + }, + conditions: [cond("Delivered", true, "Applied", "the work is applied on the site"), cond("Discovered", true, "Nodes", "3 node(s) in the draft"), cond("Approved", false, "SiteDraft", "the site's draft approved=false")] + })); store.SiteProfile.push(sprof("stretch", ["eu-central-1a", "eu-central-1c"])); store.DRConfig.push(Object.assign(api("DRConfig"), { metadata: meta("default"), @@ -11968,6 +12053,11 @@ window.SB_DR = { reservations: 0, conditions: [] }; + if (kind === "StorageSiteDeployment") obj.status = { + phase: "Discovering", + message: `waiting for site ${body.spec.cluster} to write draft simplyblock/site-draft`, + conditions: [cond("Delivered", false, "Pending", "the work is not applied on the site yet", 0)] + }; store[kind].push(obj); return { obj: strip(obj) @@ -11989,6 +12079,10 @@ window.SB_DR = { reason: "Invalid" }; Object.assign(o.spec, body.spec); + if (kind === "StorageSiteDeployment" && body.spec.approved && o.status.phase === "Drafted") { + o.status.phase = "Deploying"; + o.status.message = `draft Expanding, StorageCluster ${(o.spec.sizing || {}).name || o.spec.cluster} not reported yet`; + } } if (body.metadata && body.metadata.annotations) { o.metadata.annotations = o.metadata.annotations || {}; @@ -13526,6 +13620,8 @@ const RB_ROLES = [{ apiGroups: ["dr.simplyblock.io"] }), rbRule(["siteprofiles", "dhcpservers"], RB_RW, { apiGroups: ["sitemap.simplyblock.io"] + }), rbRule(["storagesitedeployments"], RB_RW, { + apiGroups: ["storage.simplyblock.io"] })] }] }, { @@ -13587,6 +13683,8 @@ const RB_ROLES = [{ apiGroups: ["dr.simplyblock.io"] }), rbRule(["siteprofiles", "dhcpservers"], RB_RW, { apiGroups: ["sitemap.simplyblock.io"] + }), rbRule(["storagesitedeployments"], RB_RO, { + apiGroups: ["storage.simplyblock.io"] })] }] }, { diff --git a/control-center/drhub-api.jsx b/control-center/drhub-api.jsx index f799dd7b4..c5dffc248 100644 --- a/control-center/drhub-api.jsx +++ b/control-center/drhub-api.jsx @@ -16,7 +16,7 @@ // --------------------------------------------------------------------------- const DR_KINDS = {pplan: "ProtectionPlan", drpath: "DRPath", papp: "ProtectedApplication", rplan: "RecoveryPlan", raction: "RecoveryAction", tbubble: "TestBubble", tsched: "TestSchedule", restore: "RestoreAction", - siteprofile: "SiteProfile", drconfig: "DRConfig", dhcpserver: "DHCPServer"}; + siteprofile: "SiteProfile", drconfig: "DRConfig", dhcpserver: "DHCPServer", sitedeploy: "StorageSiteDeployment"}; const DR_ANN = { createdBy: "dr.simplyblock.io/created-by", confirmDelete: "dr.simplyblock.io/confirm-delete", @@ -259,8 +259,31 @@ function normDHCPServer(o) { })); } +// A managed site's storage deployment (StorageSiteDeployment): the hub-side +// request the operator carries to the site through OCM. The status projects +// the site's draft (the discovered nodes, for review) and, once approved, the +// StorageCluster it expanded into. +const SITE_DEPLOY_STATUS = {Pending: {c: "var(--idle)", rank: 2, label: "pending"}, Discovering: {c: "var(--info)", rank: 1, label: "discovering", blink: true}, + Drafted: {c: "var(--accent)", rank: 3, label: "awaiting approval"}, Deploying: {c: "var(--info)", rank: 1, label: "deploying", blink: true}, + Online: {c: "var(--ok)", rank: 0, label: "online"}, Failed: {c: "var(--bad)", rank: 4, label: "failed"}}; +Object.entries(SITE_DEPLOY_STATUS).forEach(([k, v]) => { if (!STATUS_META[k]) STATUS_META[k] = v; }); +function normSiteDeploy(o) { + const sp = o.spec || {}, st = o.status || {}; + const draft = st.draft || null, sc = st.storageCluster || null; + const nodeSets = (draft && draft.nodeSets) || []; + const workers = nodeSets.flatMap(s => (s.groups || []).flatMap(g => g.workers || [])); + return reg(Object.assign(base(o, "sitedeploy"), { + status: st.phase || "Pending", message: st.message || "", + site: sp.cluster || "", siteNamespace: sp.siteNamespace || "simplyblock", draftName: sp.draftName || "site-draft", + discover: sp.discover || {}, sizing: sp.sizing || null, approved: !!sp.approved, + draft, nodeSets, workers, storageCluster: sc, workName: st.workName || "", + counts: {nodes: workers.length, storageNodes: sc && sc.nodes ? sc.nodes.length : 0} + })); +} + const NORM = {pplan: normPPlan, drpath: normDRPath, papp: normPApp, rplan: normRPlan, raction: normRAction, tbubble: normTBubble, - tsched: normTSched, restore: normRestore, siteprofile: normSiteProfile, drconfig: normDRConfig, dhcpserver: normDHCPServer}; + tsched: normTSched, restore: normRestore, siteprofile: normSiteProfile, drconfig: normDRConfig, dhcpserver: normDHCPServer, + sitedeploy: normSiteDeploy}; // The resolution inbox (design 13.1): open findings of every application, // grouped by (path, category, source value) so one decision clears every @@ -310,6 +333,7 @@ const drhub = { siteProfiles: () => drList("siteprofile"), siteProfile: drById("siteprofile"), dhcpServers: () => drList("dhcpserver"), dhcpServer: drById("dhcpserver"), siteDHCPServers: id => Promise.all([drhub.siteProfile(id), drhub.dhcpServers()]).then(([sp, ds]) => ds.filter(d => d.site === sp.name)), + siteDeploys: () => drList("sitedeploy").catch(e => { if (e && e.status === 404) return []; throw e; }), siteDeploy: drById("sitedeploy"), configs: () => drList("drconfig"), config: () => drList("drconfig").then(cs => cs.find(c => c.name === "default") || cs[0] || null), // derived Ramen objects, read-only drpcs: () => k8s.list("DRPlacementControl", {allNamespaces: true}).catch(() => []), @@ -382,6 +406,17 @@ const drhub = { // into its ConfigMap on the site; the hub never talks to the server. createDHCPServer: ({name, site, namespace, configMap}) => k8s.create("DHCPServer", {apiVersion: "sitemap.simplyblock.io/v1alpha1", kind: "DHCPServer", metadata: {name: dns63(name)}, spec: {site, type: "dnsmasq", dnsmasq: {namespace, configMap}}}), + // A managed site's storage (StorageSiteDeployment): the operator runs the + // discovery on the site, writes the sizing onto the draft it produced and + // delivers the approval -- all through OCM; the console writes this object + // only. Approval is one-way. + createSiteDeploy: ({site, namespace, enableControlPlaneNodes, workers, sizing}) => k8s.create("StorageSiteDeployment", { + apiVersion: "storage.simplyblock.io/v1alpha2", kind: "StorageSiteDeployment", metadata: {name: dns63(site), namespace}, + spec: Object.assign({cluster: site, discover: Object.assign({enableControlPlaneNodes: !!enableControlPlaneNodes}, workers && workers.length ? {workers} : {})}, + sizing && Object.keys(sizing).length ? {sizing} : {}) + }, {namespace}), + patchSiteDeploySizing: (d, sizing) => k8s.patch("StorageSiteDeployment", d.name, {spec: {sizing}}, {namespace: d.namespace}), + approveSiteDeploy: d => k8s.patch("StorageSiteDeployment", d.name, {spec: {approved: true}}, {namespace: d.namespace}), // Optional per-application knob: opt out of the automatic restart after a // storage recovery (ADR 0017). setAutoRestart: (a, on) => k8s.patch("ProtectedApplication", a.name, {metadata: {annotations: {[DR_ANN.autoRestart]: on ? null : "false"}}}, {namespace: a.namespace}), @@ -397,7 +432,8 @@ const drhub = { // The generic detail loader keys on the breadcrumb's layer name. Object.assign(GETTER, {pplan: drhub.plan, drpath: drhub.path, papp: drhub.app, rplan: drhub.rplan, raction: drhub.action, - tbubble: drhub.test, tsched: drhub.schedule, restore: drhub.restoreAction, siteprofile: drhub.siteProfile, drconfig: drhub.config, dhcpserver: drhub.dhcpServer}); + tbubble: drhub.test, tsched: drhub.schedule, restore: drhub.restoreAction, siteprofile: drhub.siteProfile, drconfig: drhub.config, dhcpserver: drhub.dhcpServer, + sitedeploy: drhub.siteDeploy}); Object.assign(window, {drhub, DR_KINDS, DR_ANN, VERDICT_RANK, ACTION_TERMINAL, TEST_TERMINAL, worstVerdict, fmtSecs, drCond, drCondOK, splitRef, kvToObj, csv, dns63, openFindings, - normPPlan, normDRPath, normPApp, normRPlan, normRAction, normTBubble, normTSched, normRestore, normSiteProfile, normDRConfig, normDHCPServer}); + normPPlan, normDRPath, normPApp, normRPlan, normRAction, normTBubble, normTSched, normRestore, normSiteProfile, normDRConfig, normDHCPServer, normSiteDeploy}); diff --git a/control-center/drhub.jsx b/control-center/drhub.jsx index 0fc3b289a..6d47ae415 100644 --- a/control-center/drhub.jsx +++ b/control-center/drhub.jsx @@ -138,7 +138,7 @@ const deleteDialog = (o, note, needsConfirm) => ({ run: () => drhub.remove(o, needsConfirm) }); const KIND_LABEL_DR = {pplan: "protection plan", drpath: "DR path", papp: "protected application", rplan: "recovery plan", raction: "recovery action", - tbubble: "test", tsched: "test schedule", restore: "restore", siteprofile: "site profile", drconfig: "DR configuration", dhcpserver: "DHCP server"}; + tbubble: "test", tsched: "test schedule", restore: "restore", siteprofile: "site profile", drconfig: "DR configuration", dhcpserver: "DHCP server", sitedeploy: "site storage deployment"}; const METHOD_TYPES = [{v: "async", l: "async — block replication per interval"}, {v: "sync", l: "sync — stretch cluster, RPO 0"}, {v: "s3-backup", l: "s3-backup — snapshot backups to S3 only"}, {v: "async-s3-backup", l: "async + s3-backup"}, {v: "sync-s3-backup", l: "sync + s3-backup"}]; @@ -424,6 +424,57 @@ const newDHCPServerDialog = (sites, profiles) => ({ }) }); +// ---- a managed site's storage (StorageSiteDeployment) ---------------------- +// The hub console cannot reach a site's API server; the operator on the hub +// carries the request there through OCM. The console writes the request, the +// sizing and the approval, and reads back the projected draft and cluster. +const sizingFields = z => [ + {k: "name", label: "Storage cluster name", type: "text", def: (z && z.name) || "", placeholder: "sb-site-a"}, + {k: "vcpuCount", label: "vCPUs per storage node", type: "number", def: z && z.vcpuCount != null ? z.vcpuCount : "", min: 1, placeholder: "8"}, + {k: "minHugePagesSize", label: "Hugepages per storage node", type: "text", def: (z && z.minHugePagesSize) || "", placeholder: "8G"}, + {k: "maxSubsystemCount", label: "NVMe-oF subsystems per node", type: "number", def: z && z.maxSubsystemCount != null ? z.maxSubsystemCount : "", min: 1, placeholder: "30"}, + {k: "dataChunks", label: "Erasure coding: data chunks", type: "number", def: z && z.stripe && z.stripe.dataChunks != null ? z.stripe.dataChunks : "", min: 1, placeholder: "1"}, + {k: "parityChunks", label: "Erasure coding: parity chunks", type: "number", def: z && z.stripe && z.stripe.parityChunks != null ? z.stripe.parityChunks : "", min: 0, placeholder: "1"}, + {k: "enableDriveFormat", label: "Format the devices it takes (data on them is lost)", type: "checkbox", def: !!(z && z.enableDriveFormat)} +]; +const num = v => v === "" || v == null ? null : Number(v); +const sizingOf = v => { + const z = {}; + if (v.name && v.name.trim()) z.name = dns63(v.name.trim()); + if (num(v.vcpuCount) != null) z.vcpuCount = num(v.vcpuCount); + if (v.minHugePagesSize && v.minHugePagesSize.trim()) z.minHugePagesSize = v.minHugePagesSize.trim(); + if (num(v.maxSubsystemCount) != null) z.maxSubsystemCount = num(v.maxSubsystemCount); + if (num(v.dataChunks) != null || num(v.parityChunks) != null) + z.stripe = Object.assign({}, num(v.dataChunks) != null ? {dataChunks: num(v.dataChunks)} : {}, num(v.parityChunks) != null ? {parityChunks: num(v.parityChunks)} : {}); + if (v.enableDriveFormat) z.enableDriveFormat = true; + return z; +}; +const deploySiteDialog = (sites, taken) => ({ + title: "Deploy storage on a managed site", confirm: "Discover", done: "StorageSiteDeployment created — discovery requested on the site", + desc: "The operator on this hub runs a discovery on the site through Open Cluster Management and writes a draft deployment document there. You review the draft here, with the sizing below applied, and approve it; nothing is configured on any node before the approval.", + fields: [ + {k: "site", label: "Site (managed cluster)", type: "select", required: true, options: sites.filter(s => !taken.includes(s)).map(s => ({v: s, l: s})), empty: "Every managed cluster has a storage deployment already, or none has joined the hub."}, + {k: "namespace", label: "Namespace of the request on the hub", type: "text", required: true, def: (window.SB_CONFIG || {}).namespace || "simplyblock"}, + {k: "enableControlPlaneNodes", label: "Include control-plane nodes in the discovery (every node of a small site is one)", type: "checkbox", def: true}, + {k: "workers", label: "Limit to these nodes (comma-separated; empty = every node)", type: "text", placeholder: ""}, + ...sizingFields(null), + {k: "n1", type: "note", label: "Approval is one-way and reboots the site's storage nodes to set hugepages and core isolation."} + ], + run: v => drhub.createSiteDeploy({site: v.site, namespace: v.namespace.trim(), enableControlPlaneNodes: v.enableControlPlaneNodes, workers: csv(v.workers), sizing: sizingOf(v)}) +}); +const resizeSiteDialog = d => ({ + title: `Size the draft of ${d.site}`, confirm: "Apply sizing", done: "Sizing sent to the site's draft", + desc: "Written onto the draft's cluster template on the site. Fields left empty keep what the discovery wrote.", + fields: sizingFields(d.sizing), + run: v => drhub.patchSiteDeploySizing(d, sizingOf(v)) +}); +const approveSiteDialog = d => ({ + title: `Approve the storage deployment of ${d.site}?`, confirm: "Approve and deploy", danger: true, done: "Approved — the site's draft is expanding", + desc: `Approval is one-way. The site's ${d.counts.nodes} node(s) are configured (hugepages, core isolation; this reboots them), the storage nodes are added and the cluster ${((d.draft || {}).cluster || {}).name || (d.sizing || {}).name || ""} is activated in the control plane.`, + fields: [{k: "confirm", label: `Type ${d.site} to confirm`, type: "text", required: true, match: d.site}], + run: () => drhub.approveSiteDeploy(d) +}); + // ---- command registry (kebab menus) ---------------------------------------- // `op` is what access.can() checks: failover/relocate/restart/test map to // create on the run kinds, override to the override verb, delete to delete. @@ -474,7 +525,12 @@ Object.assign(ACTIONS, { dhcpserver: d => [ {label: "Delete server", icon: "trash", danger: true, op: "delete", removes: true, dialog: deleteDialog(d, "Reservations rendered for this server stay in its ConfigMap until the hub re-renders the site; guests whose role names it become Open.")} ], - drconfig: () => [] + drconfig: () => [], + sitedeploy: d => [ + {label: "Size the draft", icon: "gauge", op: "update", dialog: resizeSiteDialog(d), disabled: d.approved, hint: "The deployment is approved"}, + {label: "Approve and deploy", icon: "check", op: "update", dialog: approveSiteDialog(d), disabled: d.approved || d.status !== "Drafted", hint: d.approved ? "Already approved" : "No draft with nodes to approve yet"}, + {label: "Delete request", icon: "trash", danger: true, op: "delete", removes: true, dialog: deleteDialog(d, "Deleting the request withdraws nothing on the site: the discovery, the draft and any storage cluster it produced stay.")} + ] }); // ---- tiles ------------------------------------------------------------------ @@ -677,6 +733,64 @@ function DHCPServerTile({o: d, nav}) { ); } +function SiteDeployTile({o: d, nav}) { + const sc = d.storageCluster; + return ( +
nav.detail(d)}> + {d.site}} right={{d.approved ? "approved" : "draft"}} /> +
{d.message || "—"}
+ +
+
nodes found{d.counts.nodes}
+
storage cluster{sc ? sc.name : "—"}
+
storage nodes{sc ? d.counts.storageNodes : "—"}
+
cluster id{sc && sc.uuid ? sc.uuid.slice(0, 8) : "—"}
+
+ {d.status === "Drafted" && !d.approved &&
Review the draft and approve it. Nothing has been applied to any node yet.
} + window.__ui.dialog(approveSiteDialog(d), d)} : null, + {label: "Details", right: true, onClick: () => nav.detail(d)} + ]} /> +
+ ); +} +function SiteDeployDetail({o: d, nav}) { + const t = (d.draft && d.draft.cluster) || {}; + const z = d.sizing || {}; + const sc = d.storageCluster; + const str = v => v == null ? "" : String(v); + return ( +
+ StorageSiteDeployment {d.namespace}/{d.name} · draft {d.siteNamespace}/{d.draftName} on the site} badge={{d.approved ? "approved" : "not approved"}} /> + {d.status === "Failed" &&
The deployment failed. {d.message}
} +
+ } s={d.message} /> + + + +
+
+

Draft — what the discovery found

+ (s.groups || []).map((g, i) => [{s.name}, {g.name || `#${i + 1}`}, {(g.workers || []).join(", ")}]))} /> + +

Cluster template on the site

+
[{r[0]}, {str(r[1])}, {str(r[2])}]) : []} /> + + + {sc &&

Storage nodes

+
[{n.name}, {n.hostname}, {n.phase || "—"}])} /> +

A StorageClass on the site names this cluster as cluster_id {sc.uuid || "(not assigned yet)"} and pool {sc.pool || "—"}.

+ } + + + ); +} + // ---- site mapping (ADR 0020) ------------------------------------------------------ const MappingResult = ({r}) => ; function FindingsTable({findings, nav}) { @@ -1292,12 +1406,13 @@ function DrHubHome({nav}) { nav.drLayer("restores")} /> nav.drLayer("siteprofiles")} /> nav.drLayer("dhcpservers")} /> + nav.drLayer("sitedeploys")} /> nav.drLayer("drconfig")} /> ); } -Object.assign(window, {DrHubHome, DRConfigView, PPlanTile, DRPathTile, PAppTile, RPlanTile, RActionTile, TBubbleTile, TSchedTile, RestoreTile, SiteProfileTile, DHCPServerTile, +Object.assign(window, {DrHubHome, DRConfigView, PPlanTile, DRPathTile, PAppTile, RPlanTile, RActionTile, TBubbleTile, TSchedTile, RestoreTile, SiteProfileTile, DHCPServerTile, SiteDeployTile, SiteDeployDetail, deploySiteDialog, PPlanDetail, DRPathDetail, PAppDetail, RPlanDetail, RActionDetail, TBubbleDetail, TSchedDetail, RestoreDetail, SiteProfileDetail, DHCPServerDetail, MappingPanel, runActionDialog, runTestDialog, restoreDialog, newPPlanDialog: newPlanDialog, newPathDialog, protectAppDialogDR, newRPlanDialog, newScheduleDialog, newDHCPServerDialog, ACTION_KIND_META, KIND_LABEL_DR}); diff --git a/control-center/k8s-client.jsx b/control-center/k8s-client.jsx index 5d6485c13..b21cbdfba 100644 --- a/control-center/k8s-client.jsx +++ b/control-center/k8s-client.jsx @@ -77,6 +77,9 @@ const RESOURCES = { DRCluster: {plural: "drclusters", core: RAMEN_API_GROUP, namespaced: false}, DRPlacementControl: {plural: "drplacementcontrols", core: RAMEN_API_GROUP, namespaced: true}, ManagedCluster: {plural: "managedclusters", core: OCM_CLUSTER_API_GROUP, namespaced: false}, + // a managed site's storage deployment, requested from the hub (operator, + // storage.simplyblock.io/v1alpha2); the operator carries it to the site + StorageSiteDeployment: {plural: "storagesitedeployments", short: "sbsd", core: "storage.simplyblock.io/v1alpha2", namespaced: true}, // access reviews: the API server answers what the caller may do SelfSubjectAccessReview: {plural: "selfsubjectaccessreviews", core: "authorization.k8s.io/v1", namespaced: false}, SelfSubjectRulesReview: {plural: "selfsubjectrulesreviews", core: "authorization.k8s.io/v1", namespaced: false}, diff --git a/control-center/mock-drhub.jsx b/control-center/mock-drhub.jsx index 9c0148338..c96e70b3d 100644 --- a/control-center/mock-drhub.jsx +++ b/control-center/mock-drhub.jsx @@ -19,7 +19,7 @@ const check = (name, status, blocking, reason, message) => ({name, status, blocking, reason, message}); const OPS = "ramen-ops"; - const store = {ProtectionPlan: [], DRPath: [], ProtectedApplication: [], RecoveryPlan: [], RecoveryAction: [], TestBubble: [], TestSchedule: [], RestoreAction: [], SiteProfile: [], DRConfig: [], DHCPServer: []}; + const store = {ProtectionPlan: [], DRPath: [], ProtectedApplication: [], RecoveryPlan: [], RecoveryAction: [], TestBubble: [], TestSchedule: [], RestoreAction: [], SiteProfile: [], DRConfig: [], DHCPServer: [], StorageSiteDeployment: []}; const api = (kind, group) => ({apiVersion: group || "dr.simplyblock.io/v1alpha1", kind}); // ---- plans --------------------------------------------------------------- @@ -176,6 +176,19 @@ status: {reservations: n, generation: gen, conditions: [cond("Rendered", true, "Rendered", `${n} reservations`, 30)]}}); store.DHCPServer.push(dhcp("dhcp-cluster-a", "cluster-a", "dhcp", "sitemap-hosts", 3, "5e5e5e")); store.DHCPServer.push(dhcp("dhcp-cluster-b", "cluster-b", "dhcp", "sitemap-hosts", 3, "91ab00")); + + // ---- site storage (StorageSiteDeployment, storage.simplyblock.io/v1alpha2) ---- + const nodeSets = hosts => [{name: "default", groups: [{name: "all", workers: hosts}]}]; + const tpl = name => ({name, vcpuCount: 8, minHugePagesSize: "8G", maxSubsystemCount: 30, stripe: {dataChunks: 1, parityChunks: 1}, enableDriveFormat: true}); + const ssd = (site, spec, status) => Object.assign(api("StorageSiteDeployment", "storage.simplyblock.io/v1alpha2"), {metadata: meta(site, "simplyblock"), + spec: Object.assign({cluster: site, siteNamespace: "simplyblock", draftName: "site-draft", discover: {enableControlPlaneNodes: true}, approved: false}, spec), status}); + store.StorageSiteDeployment.push(ssd("cluster-a", {sizing: tpl("sb-cluster-a"), approved: true}, {phase: "Online", message: "StorageCluster sb-cluster-a is Online (3 node(s))", workName: "sbsd-1a2b3c", + draft: {name: "site-draft", phase: "Expanded", approved: true, cluster: tpl("sb-cluster-a"), nodeSets: nodeSets(["a-1", "a-2", "a-3"]), nodeRefs: ["sn-a-1", "sn-a-2", "sn-a-3"]}, + storageCluster: {name: "sb-cluster-a", uuid: uid(), phase: "Online", pool: "sb-cluster-a-pool", nodes: ["a-1", "a-2", "a-3"].map(h => ({name: "sn-" + h, phase: "Online", hostname: h}))}, + conditions: [cond("Delivered", true, "Applied", "the work is applied on the site"), cond("Discovered", true, "Nodes", "3 node(s) in the draft"), cond("Approved", true, "SiteDraft", "the site's draft approved=true"), cond("Ready", true, "Online", "the StorageCluster is Online")]})); + store.StorageSiteDeployment.push(ssd("cluster-b", {sizing: tpl("sb-cluster-b")}, {phase: "Drafted", message: "the draft awaits approval", workName: "sbsd-4d5e6f", + draft: {name: "site-draft", phase: "Draft", approved: false, cluster: tpl("sb-cluster-b"), nodeSets: nodeSets(["b-1", "b-2", "b-3"])}, + conditions: [cond("Delivered", true, "Applied", "the work is applied on the site"), cond("Discovered", true, "Nodes", "3 node(s) in the draft"), cond("Approved", false, "SiteDraft", "the site's draft approved=false")]})); store.SiteProfile.push(sprof("stretch", ["eu-central-1a", "eu-central-1c"])); store.DRConfig.push(Object.assign(api("DRConfig"), {metadata: meta("default"), spec: {executor: {default: "inCluster"}, agent: {namespace: "simplyblock-dr-agent", statusInterval: "15s", hookImageAllowList: ["quay.io/simplyblock-io/*"]}, ramen: {namespace: "ramen-system", configMapName: "ramen-hub-operator-config", managed: true, veleroNamespace: "velero", opsNamespace: OPS}, @@ -236,6 +249,7 @@ if (kind === "ProtectedApplication") obj.status = {paths: [], conditions: [cond("Bound", false, "Binding", "waiting for the DRPC", 0), cond("Protected", false, "Binding", "", 0)]}; if (kind === "RecoveryPlan") obj.status = {readiness: {verdict: "Unknown", checks: []}, conditions: [cond("Valid", true, "Valid", "", 0)]}; if (kind === "DHCPServer") obj.status = {reservations: 0, conditions: []}; + if (kind === "StorageSiteDeployment") obj.status = {phase: "Discovering", message: `waiting for site ${body.spec.cluster} to write draft simplyblock/site-draft`, conditions: [cond("Delivered", false, "Pending", "the work is not applied on the site yet", 0)]}; store[kind].push(obj); return {obj: strip(obj)}; }; @@ -246,6 +260,9 @@ if (kind === "TestBubble" && Object.keys(body.spec).some(k => !["abort", "holdFor"].includes(k))) return {err: "only spec.abort and spec.holdFor may change after creation", reason: "Invalid"}; if (kind === "RecoveryAction") return {err: "the spec of a RecoveryAction is immutable", reason: "Invalid"}; Object.assign(o.spec, body.spec); + if (kind === "StorageSiteDeployment" && body.spec.approved && o.status.phase === "Drafted") { + o.status.phase = "Deploying"; o.status.message = `draft Expanding, StorageCluster ${(o.spec.sizing || {}).name || o.spec.cluster} not reported yet`; + } } if (body.metadata && body.metadata.annotations) { o.metadata.annotations = o.metadata.annotations || {}; Object.entries(body.metadata.annotations).forEach(([k, v]) => { if (v === null) delete o.metadata.annotations[k]; else o.metadata.annotations[k] = v; }); } o.metadata.generation = (o.metadata.generation || 1) + 1; diff --git a/control-center/mock-rbac.jsx b/control-center/mock-rbac.jsx index 15859b9c4..21b8ae313 100644 --- a/control-center/mock-rbac.jsx +++ b/control-center/mock-rbac.jsx @@ -27,7 +27,7 @@ const RB_ROLES = [ {name: "sb:infra-admin-allocations", rules: [rbRule(["nodepoolallocations", "managedclusters", "storageclusterclasses"], RB_RW)]}, {name: "sb:infra-admin-grants", rules: [rbRule(["accessgrants"], RB_RW), rbRule(["clusterroles"], ["bind"], {apiGroups: [RB_RBAC], resourceNames: RB_ROLE_NAMES})]}, // the DR hub (dr-simplyblock): its chart's dr-admin role, held here at cluster scope - {name: "sb:infra-admin-drhub", rules: [rbRule(["*"], RB_RW.concat("override"), {apiGroups: ["dr.simplyblock.io"]}), rbRule(["siteprofiles", "dhcpservers"], RB_RW, {apiGroups: ["sitemap.simplyblock.io"]})]}]}, + {name: "sb:infra-admin-drhub", rules: [rbRule(["*"], RB_RW.concat("override"), {apiGroups: ["dr.simplyblock.io"]}), rbRule(["siteprofiles", "dhcpservers"], RB_RW, {apiGroups: ["sitemap.simplyblock.io"]}), rbRule(["storagesitedeployments"], RB_RW, {apiGroups: ["storage.simplyblock.io"]})]}]}, {name: "sb:cluster-admin", boundAt: "sb-sc-", description: "Runs one storage cluster: nodes, devices, operations. Binds cluster and pool roles inside its own namespace only.", parts: [ {name: "sb:cluster-admin-storage", rules: [rbRule(RB_STORAGE, RB_RW)]}, @@ -41,7 +41,7 @@ const RB_ROLES = [ {name: "sb:dr-admin", boundAt: RB_NS_DR, description: "Defines DR between cluster pairs: DR policies, DR clusters, replication policies and protection plans.", parts: [{name: "sb:dr-admin-policies", rules: [rbRule(RB_DR, RB_RW)]}]}, {name: "sb:dr-reader", boundAt: RB_NS_DR, description: "Reads DR configuration and replication backlog.", - parts: [{name: "sb:dr-reader-policies", rules: [rbRule(RB_DR, RB_RO)]}, {name: "sb:dr-reader-hub", rules: [rbRule(["*"], RB_RO, {apiGroups: ["dr.simplyblock.io"]}), rbRule(["siteprofiles", "dhcpservers"], RB_RW, {apiGroups: ["sitemap.simplyblock.io"]})]}]}, + parts: [{name: "sb:dr-reader-policies", rules: [rbRule(RB_DR, RB_RO)]}, {name: "sb:dr-reader-hub", rules: [rbRule(["*"], RB_RO, {apiGroups: ["dr.simplyblock.io"]}), rbRule(["siteprofiles", "dhcpservers"], RB_RW, {apiGroups: ["sitemap.simplyblock.io"]}), rbRule(["storagesitedeployments"], RB_RO, {apiGroups: ["storage.simplyblock.io"]})]}]}, {name: "sb:app-admin", boundAt: "application namespace", description: "Protects an application and may fail it over — failover is create on applicationfailovers, so it is grantable without the right to rewrite the policy.", parts: [{name: "sb:app-admin-apps", rules: [rbRule(["protectedapplications", "recipes"], RB_RW), rbRule(["applicationfailovers"], ["create", "get", "list"])]}]} ]; diff --git a/control-center/rbac.jsx b/control-center/rbac.jsx index a843c20e1..dd1829288 100644 --- a/control-center/rbac.jsx +++ b/control-center/rbac.jsx @@ -41,7 +41,7 @@ const KIND_ENTITY = { // the DR hub's kinds (dr.simplyblock.io) — one entity, authorised by the // hub chart's dr-viewer / dr-operator / dr-admin roles pplan: "drhub", drpath: "drhub", papp: "drhub", rplan: "drhub", raction: "drhub", tbubble: "drhub", tsched: "drhub", - restore: "drhub", drconfig: "drhub", siteprofile: "drhub", dhcpserver: "drhub" + restore: "drhub", drconfig: "drhub", siteprofile: "drhub", dhcpserver: "drhub", sitedeploy: "drhub" }; // UI kind -> the CRD resource the API server checks (§3.5, the console's column) const KIND_RESOURCE = { @@ -53,12 +53,13 @@ const KIND_RESOURCE = { plan: "protectionplans", method: "protectionplans", site: "drclusters", mpath: "protectionplans", appgroup: "protectionplans", protectedapp: "protectedapplications", role: "clusterroles", binding: "accessgrants", grant: "accessgrants", pplan: "protectionplans", drpath: "drpaths", papp: "protectedapplications", rplan: "recoveryplans", raction: "recoveryactions", - tbubble: "testbubbles", tsched: "testschedules", restore: "restoreactions", drconfig: "drconfigs", siteprofile: "siteprofiles", dhcpserver: "dhcpservers" + tbubble: "testbubbles", tsched: "testschedules", restore: "restoreactions", drconfig: "drconfigs", siteprofile: "siteprofiles", dhcpserver: "dhcpservers", sitedeploy: "storagesitedeployments" }; // UI kind -> API group, where it is not the default simplyblock group const KIND_GROUP = {pplan: "dr.simplyblock.io", drpath: "dr.simplyblock.io", papp: "dr.simplyblock.io", rplan: "dr.simplyblock.io", raction: "dr.simplyblock.io", tbubble: "dr.simplyblock.io", tsched: "dr.simplyblock.io", restore: "dr.simplyblock.io", - drconfig: "dr.simplyblock.io", siteprofile: "sitemap.simplyblock.io", dhcpserver: "sitemap.simplyblock.io"}; + drconfig: "dr.simplyblock.io", siteprofile: "sitemap.simplyblock.io", dhcpserver: "sitemap.simplyblock.io", + sitedeploy: "storage.simplyblock.io"}; const ENTITY_GROUP = {drhub: "dr.simplyblock.io"}; const ENTITY_RESOURCE = {k8scluster: "managedclusters", storagecluster: "storageclusters", storagepool: "storagepools", backupop: "backups", replicationpolicy: "replicationpolicies", backuppolicy: "backuppolicies", drpolicy: "drpolicies", application: "protectedapplications", role: "clusterroles", binding: "accessgrants", diff --git a/helm-charts/charts/simplyblock-operator/templates/control-center-rbac.yaml b/helm-charts/charts/simplyblock-operator/templates/control-center-rbac.yaml index c1bd77089..4a638e58c 100644 --- a/helm-charts/charts/simplyblock-operator/templates/control-center-rbac.yaml +++ b/helm-charts/charts/simplyblock-operator/templates/control-center-rbac.yaml @@ -144,6 +144,14 @@ rules: - apiGroups: ["sitemap.simplyblock.io"] resources: ["dhcpservers"] verbs: ["create", "update", "patch", "delete"] + # a managed site's storage deployment is requested, sized and approved + # from the hub console (StorageSiteDeployment, carried to the site by OCM) + - apiGroups: ["storage.simplyblock.io"] + resources: ["storagesitedeployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["cluster.open-cluster-management.io"] + resources: ["managedclusters"] + verbs: ["get", "list", "watch"] # the console asks the API server what its identity may do, to disable controls - apiGroups: ["authorization.k8s.io"] resources: ["selfsubjectaccessreviews", "selfsubjectrulesreviews"] From f01d544aab111c0617f03c739eae316b5dd2c42c Mon Sep 17 00:00:00 2001 From: michael Date: Sat, 3 Oct 2026 15:52:25 +0300 Subject: [PATCH 3/3] Control Center: a new plan may have dr-hub create the replicated StorageClass The New plan form gains the plan's storageProfile.provision (simplyblock-dr feat/plan-storageclass-delivery): a class name, and optionally the pool and filesystem. dr-hub then writes the class on every site with the selector's labels and the site's storage cluster, so the workload deployment needs no StorageClass step. Co-Authored-By: Claude Opus 5.5 --- control-center/dist/app.js | 26 +++++++++++++++++++++++++- control-center/drhub.jsx | 7 ++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/control-center/dist/app.js b/control-center/dist/app.js index 6098eef42..f69587728 100644 --- a/control-center/dist/app.js +++ b/control-center/dist/app.js @@ -24023,6 +24023,22 @@ const newPlanDialog = () => ({ label: "Consistency groups", type: "checkbox", def: false + }, { + k: "scName", + label: "Create the replicated StorageClass on every site, named (empty: the classes exist already)", + type: "text", + placeholder: "simplyblock-dr", + hint: "dr-hub writes it on each site with the selector's labels, the site's storage cluster and pool; the selector needs at least one matchLabel." + }, { + k: "scPool", + label: "…from the pool (empty: the storage cluster's default pool)", + type: "text", + placeholder: "" + }, { + k: "scFs", + label: "…with the filesystem", + type: "text", + def: "xfs" }, { k: "s3", label: "S3 stores — one per site (Ramen's metadata store and Velero's backups)", @@ -24083,7 +24099,15 @@ const newPlanDialog = () => ({ } : {} }, { consistencyGroups: v.cg ? "Enabled" : "Disabled" - }) + }, v.scName && v.scName.trim() ? { + provision: Object.assign({ + name: v.scName.trim() + }, v.scPool && v.scPool.trim() ? { + pool: v.scPool.trim() + } : {}, v.scFs && v.scFs.trim() ? { + fsType: v.scFs.trim() + } : {}) + } : {}) }, stores.length ? { s3Profiles: stores } : {}, v.velero && v.velero.trim() ? { diff --git a/control-center/drhub.jsx b/control-center/drhub.jsx index 6d47ae415..00cf70e77 100644 --- a/control-center/drhub.jsx +++ b/control-center/drhub.jsx @@ -248,6 +248,10 @@ const newPlanDialog = () => ({ /backup/.test(v.type || "") && {k: "bRetention", label: "Backups retained", type: "number", min: 1, def: 24}, {k: "sc", label: "Storage class selector (matchLabels)", type: "kv", max: 8}, {k: "cg", label: "Consistency groups", type: "checkbox", def: false}, + {k: "scName", label: "Create the replicated StorageClass on every site, named (empty: the classes exist already)", type: "text", placeholder: "simplyblock-dr", + hint: "dr-hub writes it on each site with the selector's labels, the site's storage cluster and pool; the selector needs at least one matchLabel."}, + {k: "scPool", label: "…from the pool (empty: the storage cluster's default pool)", type: "text", placeholder: ""}, + {k: "scFs", label: "…with the filesystem", type: "text", def: "xfs"}, {k: "s3", label: "S3 stores — one per site (Ramen's metadata store and Velero's backups)", type: "rows", cols: S3_COLS, max: 8, addLabel: "Add store", add: rows => ({site: "", bucket: "", endpoint: rows.length ? rows[rows.length - 1].endpoint : "", region: rows.length ? rows[rows.length - 1].region : "", secretRef: rows.length ? rows[rows.length - 1].secretRef : "ramen-s3-secret"}), hint: "The secret (access key id / secret access key) must exist in Ramen's namespace on the hub. Leave empty to name one existing profile below instead."}, @@ -263,7 +267,8 @@ const newPlanDialog = () => ({ const sc = kvToObj(v.sc); const stores = s3Profiles(v.s3); const spec = Object.assign({sites: parseSites(v.sites), methods: [method], - storageProfile: Object.assign({storageClassSelector: Object.keys(sc).length ? {matchLabels: sc} : {}}, {consistencyGroups: v.cg ? "Enabled" : "Disabled"})}, + storageProfile: Object.assign({storageClassSelector: Object.keys(sc).length ? {matchLabels: sc} : {}}, {consistencyGroups: v.cg ? "Enabled" : "Disabled"}, + v.scName && v.scName.trim() ? {provision: Object.assign({name: v.scName.trim()}, v.scPool && v.scPool.trim() ? {pool: v.scPool.trim()} : {}, v.scFs && v.scFs.trim() ? {fsType: v.scFs.trim()} : {})} : {})}, stores.length ? {s3Profiles: stores} : {}, v.velero && v.velero.trim() ? {veleroNamespace: v.velero.trim()} : {}, v.s3Profile && v.s3Profile.trim() ? {s3Profile: {name: v.s3Profile.trim()}} : {}, v.autoRestart ? {autoRestart: {enabled: true}} : {}); return drhub.createPlan({name: v.name.trim(), spec});