From 395cda2cc789f7d4cc64b7d6e769b20585c86c6c Mon Sep 17 00:00:00 2001
From: Gerald Pinder <gmpinder@gmail.com>
Date: Sat, 18 Jan 2025 12:42:31 -0500
Subject: [PATCH] feat: Create a Nushell script for 'files' module

---
 .github/workflows/build-individual.yml |  2 +
 .github/workflows/build-unified.yml    |  2 +
 modules/files/files.tsp                | 38 ++++++++++----
 modules/files/{ => v1}/README.md       |  0
 modules/files/{ => v1}/files.sh        |  0
 modules/files/v2/README.md             | 46 ++++++++++++++++
 modules/files/v2/files.nu              | 72 ++++++++++++++++++++++++++
 7 files changed, 150 insertions(+), 10 deletions(-)
 rename modules/files/{ => v1}/README.md (100%)
 rename modules/files/{ => v1}/files.sh (100%)
 create mode 100644 modules/files/v2/README.md
 create mode 100644 modules/files/v2/files.nu

diff --git a/.github/workflows/build-individual.yml b/.github/workflows/build-individual.yml
index 8fc3ffb6..a01cd00d 100644
--- a/.github/workflows/build-individual.yml
+++ b/.github/workflows/build-individual.yml
@@ -1,6 +1,8 @@
 name: build-individual
 on:
   push:
+    branches:
+      - main
     paths-ignore: # don't rebuild if only documentation has changed
       - "**.md"
   pull_request:
diff --git a/.github/workflows/build-unified.yml b/.github/workflows/build-unified.yml
index 15a4c721..547056c1 100644
--- a/.github/workflows/build-unified.yml
+++ b/.github/workflows/build-unified.yml
@@ -1,6 +1,8 @@
 name: build-unified
 on:
   push:
+    branches:
+      - main
     paths-ignore: # don't rebuild if only documentation has changed
       - "**.md"
   pull_request:
diff --git a/modules/files/files.tsp b/modules/files/files.tsp
index f6a826a0..93279f9f 100644
--- a/modules/files/files.tsp
+++ b/modules/files/files.tsp
@@ -2,15 +2,33 @@ import "@typespec/json-schema";
 using TypeSpec.JsonSchema;
 
 @jsonSchema("/modules/files.json")
-model FilesModule {
-    /** Copy files to your image at build time
-     * https://blue-build.org/reference/modules/files/
-     */
-    type: "files";
+union FilesModule {
+  FilesV1,
+  FilesV2,
+}
+
+model FilesV1 {
+  /** Copy files to your image at build time
+   * https://blue-build.org/reference/modules/files/
+   */
+  type: "files@v1";
+
+  /** List of files / folders to copy. */
+  files: Array<Record<string>> | Array<{
+    source: string;
+    destination: string;
+  }>;
+}
+
+model FilesV2 {
+  /** Copy files to your image at build time
+   * https://blue-build.org/reference/modules/files/
+   */
+  type: "files@v2" | "files@latest" | "files";
 
-    /** List of files / folders to copy. */
-    files: Array<Record<string>> | Array<{
-        source: string;
-        destination: string;
-    }>;
+  /** List of files / folders to copy. */
+  files: Array<{
+    source: string;
+    destination: string;
+  }>;
 }
diff --git a/modules/files/README.md b/modules/files/v1/README.md
similarity index 100%
rename from modules/files/README.md
rename to modules/files/v1/README.md
diff --git a/modules/files/files.sh b/modules/files/v1/files.sh
similarity index 100%
rename from modules/files/files.sh
rename to modules/files/v1/files.sh
diff --git a/modules/files/v2/README.md b/modules/files/v2/README.md
new file mode 100644
index 00000000..70e9b854
--- /dev/null
+++ b/modules/files/v2/README.md
@@ -0,0 +1,46 @@
+# `files`
+
+The `files` module can be used to copy directories from `files/` to
+any location in your image at build-time, as long as the location exists at
+build-time (e.g. you can't put files in `/home/<username>/`, because users
+haven't been created yet prior to first boot).
+
+:::note
+In run-time, `/usr/etc/` is the directory for "system"
+configuration templates on atomic Fedora distros, whereas `/etc/` is meant for
+manual overrides and editing by the machine's admin *after* installation.
+
+In build-time, as a custom-image maintainer, you want to copy files to `/etc/`,
+as those are automatically moved to system directory `/usr/etc/` during atomic Fedora image deployment.
+Check out this blog post for more details about this:  
+https://blue-build.org/blog/preferring-system-etc/
+:::
+
+:::caution
+The `files` module **cannot write to directories that will later be symlinked
+to point to other places (typically `/var/`) by `rpm-ostree`**.
+
+This is because it doesn't make sense for a directory to be both a symlink and
+a real directory that has had actual files directly copied to it, so the
+`files` module copying files to one of those directories (thereby instantiating
+it as a real directory) and `rpm-ostree`'s behavior regarding them will
+necessarily conflict.
+
+For reference, according to the [official Fedora
+documentation](https://docs.fedoraproject.org/en-US/fedora-silverblue/technical-information/#filesystem-layout),
+here is a list of the directories that `rpm-ostree` symlinks to other
+locations:
+
+- `/home/` → `/var/home/`
+- `/opt/` → `/var/opt/`
+- `/srv/` → `/var/srv/`
+- `/root/` → `/var/roothome/`
+- `/usr/local/` → `/var/usrlocal/`
+- `/mnt/` → `/var/mnt/`
+- `/tmp/` → `/sysroot/tmp/`
+
+So don't use `files` to copy any files to any of the directories on the left,
+because at runtime `rpm-ostree` will want to link them to the ones on the
+right, which will cause a conflict as explained above.
+
+:::
diff --git a/modules/files/v2/files.nu b/modules/files/v2/files.nu
new file mode 100644
index 00000000..8bd728b9
--- /dev/null
+++ b/modules/files/v2/files.nu
@@ -0,0 +1,72 @@
+#!/usr/bin/env nu
+
+def determine_file_dir []: nothing -> string {
+  let config_dir = $env.CONFIG_DIRECTORY
+
+  if $config_dir == '/tmp/config' {
+    $config_dir | path join 'files' 
+  } else {
+    $config_dir
+  }
+}
+
+def main [config: string]: nothing -> nothing {
+  let config = $config | from json
+  let files: list = $config.files
+  let list_is_empty = $files | is-empty
+
+  if $list_is_empty {
+    return (error make {
+      msg: $"(ansi red_bold)At least one entry is required in property(ansi reset) `(ansi cyan)files(ansi reset)`:\n($config | to yaml)"
+      label: {
+        text: 'Checks for empty list'
+        span: (metadata $list_is_empty).span
+      }
+    })
+  }
+
+  let config_dir = determine_file_dir
+
+  for $file in $files {
+    let file = $file | merge { source: ($config_dir | path join $file.source) }
+    let source = $file.source
+    let destination = $file.destination
+    let source_exists = not ($source | path exists)
+    let is_dir = ($destination | path exists) and ($destination | path type) == 'file'
+
+    if $source_exists {
+      return (error make {
+        msg: $"(ansi red_bold)The path (ansi cyan)`($source)`(ansi reset) (ansi red_bold)does not exist(ansi reset):\n($config | to yaml)"
+        label: {
+          text: 'Checks for source'
+          span: (metadata $source_exists).span
+        }
+      })
+    }
+
+    if $is_dir {
+      return (error make {
+        msg: $"(ansi red_bold)The destination path (ansi cyan)`($destination)`(ansi reset) (ansi red_bold)should be a directory(ansi reset):\n($config | to yaml)"
+        label: {
+          text: 'Checks destination is directory'
+          span: (metadata $is_dir).span
+        }
+      })
+    }
+
+    print $'Copying (ansi cyan)($source)(ansi reset) to (ansi cyan)($destination)(ansi reset)'
+    mkdir $destination
+
+    if ($source | path type) == 'dir' {
+      cp -rfv ($source | path join * | into glob) $destination
+    } else {
+      cp -fv $source $destination
+    }
+
+    let git_keep = $destination | path join '.gitkeep'
+
+    if ($git_keep | path exists) {
+      rm -f $git_keep
+    }
+  }
+}