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..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,7 +495,8 @@ function OverviewView({seg, parent, nav, prefs, rev, up, upLabel}) { : seg.t === "paths" ? : seg.t === "protectedapps" ? : seg.t === "rplans" ? - : seg.t === "dhcpservers" ? + : seg.t === "sitedeploys" ? + : seg.t === "dhcpservers" ? : seg.t === "pairs" ? : seg.t === "rpolicies" ? : seg.t === "__pairs_old" ? 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 817edb9d2..f69587728 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 @@ -4313,6 +4397,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", @@ -4373,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, { @@ -4415,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, @@ -4443,7 +4588,8 @@ Object.assign(window, { normRestore, normSiteProfile, normDRConfig, - normDHCPServer + normDHCPServer, + normSiteDeploy }); })(); // ---- agent.jsx ---- @@ -4992,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 = { @@ -5039,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 = { @@ -5053,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" @@ -9155,6 +9304,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, { @@ -23471,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", @@ -23503,6 +23724,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 +24023,48 @@ 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)", + 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 +24073,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 +24089,7 @@ const newPlanDialog = () => ({ } } : {}); const sc = kvToObj(v.sc); + const stores = s3Profiles(v.s3); const spec = Object.assign({ sites: parseSites(v.sites), methods: [method], @@ -23604,8 +24099,20 @@ const newPlanDialog = () => ({ } : {} }, { consistencyGroups: v.cg ? "Enabled" : "Disabled" - }) - }, v.s3Profile && v.s3Profile.trim() ? { + }, 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() } @@ -23620,6 +24127,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 +24350,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 +24389,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 +24400,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 +24430,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 +24619,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,24 +24704,178 @@ 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); }) }); +// ---- 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. 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 +24930,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 +25038,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", @@ -24227,7 +25052,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 ------------------------------------------------------------------ @@ -24713,6 +25560,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 = ({ @@ -26887,6 +27867,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", @@ -26908,6 +27894,9 @@ Object.assign(window, { RestoreTile, SiteProfileTile, DHCPServerTile, + SiteDeployTile, + SiteDeployDetail, + deploySiteDialog, PPlanDetail, DRPathDetail, PAppDetail, @@ -27730,6 +28719,7 @@ const DETAIL_KIND = { restore: "RestoreDetail", siteprofile: "SiteProfileDetail", dhcpserver: "DHCPServerDetail", + sitedeploy: "SiteDeployDetail", deployconfig: "DeployConfigDetail", mpath: "MPathDetail", appgroup: "AppGroupDetail" @@ -27913,6 +28903,13 @@ const LAYER_META = { dhcpserver: { icon: "link" }, + sitedeploys: { + label: "Site storage", + icon: "cluster" + }, + sitedeploy: { + icon: "cluster" + }, drconfig: { label: "DR configuration", icon: "gauge" @@ -28147,6 +29144,14 @@ const pDhcp = id => [{ t: "dhcpserver", id }]; +const pSiteDeploy = id => [{ + t: "dr" +}, { + t: "sitedeploys" +}, { + t: "sitedeploy", + id +}]; const pPair = id => [{ t: "dr" }, { @@ -28243,7 +29248,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", @@ -28355,6 +29360,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"], @@ -28481,6 +29487,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), @@ -28576,6 +29587,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"), @@ -28610,6 +29622,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", @@ -28911,6 +29924,7 @@ const TILE = { restore: RestoreTile, siteprofile: SiteProfileTile, dhcpserver: DHCPServerTile, + sitedeploy: SiteDeployTile, pair: PairTile, rpolicy: RPolicyTile, slot: SlotTile, @@ -28946,6 +29960,7 @@ const TKEY = { restore: "o", siteprofile: "o", dhcpserver: "o", + sitedeploy: "o", policy: "p", pair: "p", rpolicy: "p", @@ -29186,9 +30201,18 @@ 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)), { + 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/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 1f985eac5..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(() => []), @@ -367,6 +391,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}), @@ -374,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}), @@ -389,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 8bdaaa30f..00cf70e77 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"}]; @@ -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,43 @@ 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: "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."}, + {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"})}, + 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}); } }); +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 +326,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 +394,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 +417,67 @@ 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); + }) +}); + +// ---- 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) ---------------------------------------- @@ -289,6 +485,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 +498,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,11 +524,18 @@ 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.")} ], - 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 ------------------------------------------------------------------ @@ -533,6 +738,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}) { @@ -1148,12 +1411,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"]