Parcourir la source

Add the OS-morphing phase model used by per-phase user scripts

Add the phase model and the extended UserScriptValue format

Signed-off-by: Mihaela Balutoiu <mbalutoiu@cloudbasesolutions.com>
Mihaela Balutoiu il y a 1 semaine
Parent
commit
deb0ac1a28

+ 38 - 0
src/@types/Instance.ts

@@ -51,11 +51,49 @@ export type InstanceBase = {
   id: string;
   id: string;
 } & Partial<Instance>;
 } & Partial<Instance>;
 
 
+export type UserScriptPhase =
+  | "osmorphing_pre_os_mount"
+  | "osmorphing_post_os_mount"
+  | "replica_first_boot";
+
+export const USER_SCRIPT_PHASES: UserScriptPhase[] = [
+  "osmorphing_pre_os_mount",
+  "osmorphing_post_os_mount",
+  "replica_first_boot",
+];
+
+export const DEFAULT_USER_SCRIPT_PHASE: UserScriptPhase =
+  "osmorphing_post_os_mount";
+
+export const USER_SCRIPT_PHASE_OPTIONS: {
+  label: string;
+  value: UserScriptPhase;
+}[] = [
+  { label: "OS morphing: before mount", value: "osmorphing_pre_os_mount" },
+  { label: "OS morphing: after mount", value: "osmorphing_post_os_mount" },
+  { label: "VM first boot script", value: "replica_first_boot" },
+];
+
+export const USER_SCRIPT_PHASE_DESCRIPTIONS: Record<UserScriptPhase, string> = {
+  osmorphing_pre_os_mount:
+    "Runs before the OS partition is mounted during OS morphing, e.g. to unlock encrypted disks.",
+  osmorphing_post_os_mount:
+    "Runs after the OS partition is mounted during OS morphing (the default).",
+  replica_first_boot:
+    "Injected during OS morphing and executed when the VM boots for the first time.",
+};
+
 export type InstanceScript = {
 export type InstanceScript = {
   global?: "windows" | "linux" | null;
   global?: "windows" | "linux" | null;
   instanceId?: string | null;
   instanceId?: string | null;
   scriptContent: string | null;
   scriptContent: string | null;
   fileName: string | null;
   fileName: string | null;
+  phase?: UserScriptPhase;
+};
+
+export type UserScriptTarget = {
+  global: "windows" | "linux" | null;
+  instanceId: string | null;
 };
 };
 
 
 export const InstanceUtils = {
 export const InstanceUtils = {

+ 11 - 4
src/@types/MainItem.ts

@@ -13,7 +13,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 */
 
 
 import type { Execution } from "./Execution";
 import type { Execution } from "./Execution";
-import type { Instance, InstanceScript } from "./Instance";
+import type { Instance, InstanceScript, UserScriptPhase } from "./Instance";
 import type { NetworkMap } from "./Network";
 import type { NetworkMap } from "./Network";
 import type { StorageMap } from "./Endpoint";
 import type { StorageMap } from "./Endpoint";
 import { Task } from "./Task";
 import { Task } from "./Task";
@@ -100,13 +100,20 @@ export type TransferItem = BaseItem & {
   scenario: string;
   scenario: string;
 };
 };
 
 
+export type UserScriptItem = {
+  phase: UserScriptPhase;
+  payload: string;
+};
+
+export type UserScriptValue = string | UserScriptItem[] | null;
+
 export type UserScriptData = {
 export type UserScriptData = {
   global?: {
   global?: {
-    linux?: string | null;
-    windows?: string | null;
+    linux?: UserScriptValue;
+    windows?: UserScriptValue;
   };
   };
   instances?: {
   instances?: {
-    [instanceName: string]: string | null;
+    [instanceName: string]: UserScriptValue;
   };
   };
 };
 };
 
 

+ 196 - 0
src/plugins/default/OptionsSchemaPlugin.spec.tsx

@@ -0,0 +1,196 @@
+/*
+Copyright (C) 2026  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import type { InstanceScript } from "@src/@types/Instance";
+import { OptionsSchemaPlugin } from "@src/plugins";
+
+describe("OptionsSchemaPlugin.getUserScripts", () => {
+  let parser: ReturnType<typeof OptionsSchemaPlugin.for>;
+
+  beforeEach(() => {
+    parser = OptionsSchemaPlugin.for("default" as any);
+  });
+
+  it("defaults to osmorphing_post_os_mount when no phase is provided", () => {
+    const uploaded: InstanceScript[] = [
+      { global: "linux", scriptContent: "echo hi", fileName: "s.sh" },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.global.linux).toEqual([
+      { phase: "osmorphing_post_os_mount", payload: "echo hi" },
+    ]);
+  });
+
+  it.each([
+    "osmorphing_pre_os_mount",
+    "osmorphing_post_os_mount",
+    "replica_first_boot",
+  ] as const)("serializes the explicit %s phase", phase => {
+    const uploaded: InstanceScript[] = [
+      {
+        global: "windows",
+        scriptContent: "Write-Host hi",
+        fileName: "s.ps1",
+        phase,
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.global.windows).toEqual([
+      { phase, payload: "Write-Host hi" },
+    ]);
+  });
+
+  it("serializes global linux and windows scripts with their phases", () => {
+    const uploaded: InstanceScript[] = [
+      {
+        global: "linux",
+        scriptContent: "echo linux",
+        fileName: "l.sh",
+        phase: "osmorphing_pre_os_mount",
+      },
+      {
+        global: "windows",
+        scriptContent: "Write-Host win",
+        fileName: "w.ps1",
+        phase: "replica_first_boot",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.global).toEqual({
+      linux: [{ phase: "osmorphing_pre_os_mount", payload: "echo linux" }],
+      windows: [{ phase: "replica_first_boot", payload: "Write-Host win" }],
+    });
+  });
+
+  it("serializes an instance script with its phase", () => {
+    const uploaded: InstanceScript[] = [
+      {
+        instanceId: "instance-1",
+        scriptContent: "echo instance",
+        fileName: "i.sh",
+        phase: "replica_first_boot",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.instances["instance-1"]).toEqual([
+      { phase: "replica_first_boot", payload: "echo instance" },
+    ]);
+  });
+
+  it("emits null for removed scripts to unregister them", () => {
+    const removed: InstanceScript[] = [
+      { global: "linux", scriptContent: null, fileName: null },
+    ];
+    const payload = parser.getUserScripts([], removed, null);
+    expect(payload.global.linux).toBeNull();
+  });
+
+  it("preserves pre-existing (e.g. legacy string) scripts in userScriptData", () => {
+    const existing = {
+      global: { windows: "legacy string script" },
+    };
+    const uploaded: InstanceScript[] = [
+      {
+        global: "linux",
+        scriptContent: "echo new",
+        fileName: "n.sh",
+        phase: "osmorphing_post_os_mount",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], existing as any);
+    expect(payload.global.windows).toBe("legacy string script");
+    expect(payload.global.linux).toEqual([
+      { phase: "osmorphing_post_os_mount", payload: "echo new" },
+    ]);
+  });
+
+  it("groups multiple scripts for one target into a single phase list", () => {
+    const uploaded: InstanceScript[] = [
+      {
+        global: "linux",
+        scriptContent: "echo pre",
+        fileName: null,
+        phase: "osmorphing_pre_os_mount",
+      },
+      {
+        global: "linux",
+        scriptContent: "echo post",
+        fileName: null,
+        phase: "osmorphing_post_os_mount",
+      },
+      {
+        global: "linux",
+        scriptContent: "echo boot",
+        fileName: null,
+        phase: "replica_first_boot",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.global.linux).toEqual([
+      { phase: "osmorphing_pre_os_mount", payload: "echo pre" },
+      { phase: "osmorphing_post_os_mount", payload: "echo post" },
+      { phase: "replica_first_boot", payload: "echo boot" },
+    ]);
+  });
+
+  it("groups multiple scripts for one instance with different phases", () => {
+    const uploaded: InstanceScript[] = [
+      {
+        instanceId: "instance-1",
+        scriptContent: "echo pre",
+        fileName: null,
+        phase: "osmorphing_pre_os_mount",
+      },
+      {
+        instanceId: "instance-1",
+        scriptContent: "echo boot",
+        fileName: null,
+        phase: "replica_first_boot",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.instances["instance-1"]).toEqual([
+      { phase: "osmorphing_pre_os_mount", payload: "echo pre" },
+      { phase: "replica_first_boot", payload: "echo boot" },
+    ]);
+  });
+
+  it("never emits empty-string payloads and nulls an empty target", () => {
+    const uploaded: InstanceScript[] = [
+      {
+        global: "linux",
+        scriptContent: "",
+        fileName: null,
+        phase: "osmorphing_pre_os_mount",
+      },
+      {
+        global: "linux",
+        scriptContent: "   ",
+        fileName: null,
+        phase: "osmorphing_post_os_mount",
+      },
+      {
+        global: "windows",
+        scriptContent: "Write-Host hi",
+        fileName: null,
+        phase: "osmorphing_post_os_mount",
+      },
+    ];
+    const payload = parser.getUserScripts(uploaded, [], null);
+    expect(payload.global.linux).toBeNull();
+    expect(payload.global.windows).toEqual([
+      { phase: "osmorphing_post_os_mount", payload: "Write-Host hi" },
+    ]);
+  });
+});

+ 31 - 7
src/plugins/default/OptionsSchemaPlugin.ts

@@ -19,6 +19,7 @@ import type { OptionValues, StorageMap } from "@src/@types/Endpoint";
 import type { SchemaProperties, SchemaDefinitions } from "@src/@types/Schema";
 import type { SchemaProperties, SchemaDefinitions } from "@src/@types/Schema";
 import type { NetworkMap } from "@src/@types/Network";
 import type { NetworkMap } from "@src/@types/Network";
 import type { InstanceScript } from "@src/@types/Instance";
 import type { InstanceScript } from "@src/@types/Instance";
+import { DEFAULT_USER_SCRIPT_PHASE } from "@src/@types/Instance";
 import { executionOptions } from "@src/constants";
 import { executionOptions } from "@src/constants";
 import { UserScriptData } from "@src/@types/MainItem";
 import { UserScriptData } from "@src/@types/MainItem";
 import { defaultSchemaToFields } from "./ConnectionSchemaPlugin";
 import { defaultSchemaToFields } from "./ConnectionSchemaPlugin";
@@ -383,10 +384,22 @@ export default class OptionsSchemaParserBase {
       scriptProp: "global" | "instanceId",
       scriptProp: "global" | "instanceId",
       payloadProp: "global" | "instances",
       payloadProp: "global" | "instances",
     ) => {
     ) => {
-      if (!scripts.length) {
-        return;
-      }
-      payload[payloadProp] = payload[payloadProp] || {};
+      scripts.forEach(script => {
+        const scriptValue = script[scriptProp];
+        if (!scriptValue) {
+          return;
+        }
+        payload[payloadProp] = payload[payloadProp] || {};
+        payload[payloadProp][scriptValue] = null;
+      });
+    };
+
+    const setPayloadUploaded = (
+      scripts: InstanceScript[],
+      scriptProp: "global" | "instanceId",
+      payloadProp: "global" | "instances",
+    ) => {
+      const byTarget: { [target: string]: InstanceScript[] } = {};
       scripts.forEach(script => {
       scripts.forEach(script => {
         const scriptValue = script[scriptProp];
         const scriptValue = script[scriptProp];
         if (!scriptValue) {
         if (!scriptValue) {
@@ -394,7 +407,18 @@ export default class OptionsSchemaParserBase {
             `The uploaded script structure is missing the '${scriptProp}' property`,
             `The uploaded script structure is missing the '${scriptProp}' property`,
           );
           );
         }
         }
-        payload[payloadProp][scriptValue] = script.scriptContent;
+        byTarget[scriptValue] = byTarget[scriptValue] || [];
+        byTarget[scriptValue].push(script);
+      });
+      Object.keys(byTarget).forEach(scriptValue => {
+        payload[payloadProp] = payload[payloadProp] || {};
+        const entries = byTarget[scriptValue]
+          .filter(s => s.scriptContent != null && s.scriptContent.trim() !== "")
+          .map(s => ({
+            phase: s.phase || DEFAULT_USER_SCRIPT_PHASE,
+            payload: s.scriptContent,
+          }));
+        payload[payloadProp][scriptValue] = entries.length ? entries : null;
       });
       });
     };
     };
 
 
@@ -408,12 +432,12 @@ export default class OptionsSchemaParserBase {
       "instanceId",
       "instanceId",
       "instances",
       "instances",
     );
     );
-    setPayload(
+    setPayloadUploaded(
       uploadedUserScripts.filter(s => s.global),
       uploadedUserScripts.filter(s => s.global),
       "global",
       "global",
       "global",
       "global",
     );
     );
-    setPayload(
+    setPayloadUploaded(
       uploadedUserScripts.filter(s => s.instanceId),
       uploadedUserScripts.filter(s => s.instanceId),
       "instanceId",
       "instanceId",
       "instances",
       "instances",