Repository: osbuild/osbuild
Pull request #2094 (2025-05-13 11:29:38)

stages/kickstart: post-installation scripts
Add a new %post option to the kickstart stage that supports adding multiple post blocks to a kickstart file, with all the options supported by the directive.

In out images, we often need to use `%post` to run things that aren't supported by kickstart commands [1,2].  We work around this by injecting `org.osbuild.inline` files into the ISO and in order to combine them with the kickstart file created by the kickstart stage, we `%include` one from the other.  This makes it hard to document, trace, and troubleshoot the ISO itself.  It also makes it harder to read the manifest itself.  Adding these options to the stage will make everything nicer :rainbow:.

Once this is merged, I will replace all hard-coded `%post` lines in osbuild/images with the new stage option, which will remove the kickstart `%include` redirection in all default cases.  We will still need to use `%include` when users add their own kickstart content.

[1] https://github.com/osbuild/images/blob/7b9e2898e0fc32e4deb244a9fb0e2b106a06c937/pkg/manifest/anaconda_installer_iso_tree.go#L607
[2] https://github.com/osbuild/images/blob/7b9e2898e0fc32e4deb244a9fb0e2b106a06c937/pkg/manifest/anaconda_installer_iso_tree.go#L806-L811

achilleas-k: Had to fix the messages on older python versions for the schema errors.

Patch:
diff --git a/stages/org.osbuild.kickstart b/stages/org.osbuild.kickstart
index f8d867ed..01b24152 100755
--- a/stages/org.osbuild.kickstart
+++ b/stages/org.osbuild.kickstart
@@ -170,6 +170,25 @@ def make_network(options: Dict) -> List[str]:
     return res
 
 
+def make_post(post_list):
+    res = []
+    for post in post_list:
+        start = ["%post"]
+        if post.get("erroronfail"):
+            start.append("--erroronfail")
+        if post.get("nochroot"):
+            start.append("--nochroot")
+        log = post.get("log")
+        if log:
+            start.extend(["--log", f'"{log}"'])
+        interpreter = post.get("interpreter")
+        if interpreter:
+            start.extend(["--interpreter", f'"{interpreter}"'])
+
+        res.extend([" ".join(start), *post["commands"], "%end"])
+    return res
+
+
 def main(tree, options):  # pylint: disable=too-many-branches
     path = options["path"].lstrip("/")
     ostree = options.get("ostree")
@@ -242,6 +261,9 @@ def main(tree, options):  # pylint: disable=too-many-branches
     kargs_append = options.get("bootloader", {}).get("append")
     if kargs_append:
         config += [f"bootloader --append='{kargs_append}'"]
+    post = options.get("%post", [])
+    if post:
+        config += make_post(post)
 
     target = os.path.join(tree, path)
     base = os.path.dirname(target)
diff --git a/stages/org.osbuild.kickstart.meta.json b/stages/org.osbuild.kickstart.meta.json
index bd9bd9d0..d69dbd85 100644
--- a/stages/org.osbuild.kickstart.meta.json
+++ b/stages/org.osbuild.kickstart.meta.json
@@ -555,6 +555,43 @@
             "type": "string"
           }
         }
+      },
+      "%post": {
+        "type": "array",
+        "minItems": 1,
+        "items": {
+          "type": "object",
+          "description": "Adds arbitrary commands to run on the system once the installation is complete.",
+          "additionalProperties": false,
+          "required": [
+            "commands"
+          ],
+          "properties": {
+            "erroronfail": {
+              "description": "Stop the installation on script failure",
+              "type": "boolean"
+            },
+            "interpreter": {
+              "description": "Allows specifying a different scripting language",
+              "type": "string"
+            },
+            "log": {
+              "description": "Log all messages from the script to the given log file",
+              "type": "string"
+            },
+            "nochroot": {
+              "description": "Run outside of the chroot environment",
+              "type": "boolean"
+            },
+            "commands": {
+              "type": "array",
+              "minItems": 1,
+              "items": {
+                "type": "string"
+              }
+            }
+          }
+        }
       }
     }
   }


Test patch:
diff --git a/stages/test/test_kickstart.py b/stages/test/test_kickstart.py
index a635973d..7ccd848c 100644
--- a/stages/test/test_kickstart.py
+++ b/stages/test/test_kickstart.py
@@ -241,6 +241,47 @@ TEST_INPUT = [
     ({"ostreecontainer": {"transport": "dir", "url": "/run/install/repo/container", }, },
      "ostreecontainer --url=/run/install/repo/container --transport=dir",),
     ({"bootloader": {"append": "karg1 karg2=0"}}, "bootloader --append='karg1 karg2=0'"),
+
+    # %post
+    ({"%post": [{"commands": ["mkdir /scratch"]}]}, "%post\nmkdir /scratch\n%end"),
+    (
+        {
+            "%post": [
+                {"commands": ["mkdir /scratch"]},
+                {"commands": ["print('DONE!!!')"], "interpreter": "/usr/bin/python3"},
+            ]
+        },
+        "%post\nmkdir /scratch\n%end\n" +
+        "%post --interpreter \"/usr/bin/python3\"\nprint('DONE!!!')\n%end"
+    ),
+    (
+        {
+            "%post": [
+                {"commands": ["mkdir /scratch"]},
+                {
+                    "erroronfail": True,
+                    "nochroot": True,
+                    "interpreter": "/usr/bin/bash",
+                    "log": "/mnt/sysimage/var/log/ks-p2.log",
+                    "commands": [
+                        "echo 'Starting post2'",
+                        "if [ ! -e /mnt/sysimage/etc/resolv.conf ]; then",
+                        "  cp /etc/resolv.conf /mnt/sysimage/etc/resolv.conf",
+                        "fi",
+                    ]
+                },
+                {"commands": ["print('DONE!!!')"], "interpreter": "/usr/bin/python3"},
+            ]
+        },
+        "%post\nmkdir /scratch\n%end\n" +
+        "%post --erroronfail --nochroot --log \"/mnt/sysimage/var/log/ks-p2.log\" --interpreter \"/usr/bin/bash\"\n" +
+        "echo 'Starting post2'\n" +
+        "if [ ! -e /mnt/sysimage/etc/resolv.conf ]; then\n" +
+        "  cp /etc/resolv.conf /mnt/sysimage/etc/resolv.conf\n" +
+        "fi\n" +
+        "%end\n" +
+        "%post --interpreter \"/usr/bin/python3\"\nprint('DONE!!!')\n%end"
+    )
 ]
 
 
@@ -387,6 +428,10 @@ def test_kickstart_valid(tmp_path, stage_module, test_input, expected):  # pylin
             {"rootpw": {"iscrypted": True, "plaintext": True, "password": "pass"}},
             "is not valid under any of the given schemas"
         ),
+        # bad %post blocks
+        ({"%post": []}, re.compile(r"\[\] should be non-empty|\[\] is too short")),
+        ({"%post": [{}]}, "'commands' is a required property"),
+        ({"%post": [{"commands": []}]}, re.compile(r"\[\] should be non-empty|\[\] is too short")),
     ],
 )
 @pytest.mark.parametrize("stage_schema", ["1"], indirect=True)