From 87d761a660c326c47ffa07848b02cb21a01ca540 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 05:42:52 +0000 Subject: [PATCH 1/2] Add R2 rclone mount apps under ~/darkmatter Introduces a flake-parts module exposing mount-darkmatter, unmount-darkmatter, and configure-darkmatter-r2 apps so team members can attach Cloudflare R2 buckets at ~/darkmatter/{public, team,personal} with a single rclone remote. https://claude.ai/code/session_01RX1FoRF99mdTXmX5VR5ruH --- README.md | 52 +++-- flake.nix | 4 + modules/flake-parts/default.nix | 1 + modules/flake-parts/r2.nix | 327 ++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 modules/flake-parts/r2.nix diff --git a/README.md b/README.md index 8c0e5b0..6172fd8 100644 --- a/README.md +++ b/README.md @@ -127,26 +127,54 @@ By default all shared Darkmatter secrets are installed. Limit the set with: This flake also exposes a few runnable utilities for the team. -### `rclone-s3` +### Cloudflare R2 mounts at `~/darkmatter` -Mount an S3 bucket locally with `rclone`: +Three Cloudflare R2 buckets are exposed as local FUSE mounts under `~/darkmatter`: -```/dev/null/example.sh#L1-1 -nix run github:darkmatter/nix#rclone-s3 -- ~/path/to/dir bucket-name +- `~/darkmatter/public` — bucket `darkmatter-public` +- `~/darkmatter/team` — bucket `darkmatter-team` +- `~/darkmatter/personal` — bucket `darkmatter-personal` + +One-time setup — create the rclone remote (`darkmatter-r2`): + +```bash +# Will prompt for account id / access key / secret if not in env. +R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... \ + nix run github:darkmatter/nix#configure-darkmatter-r2 ``` -This command expects: +Mount everything (or a single bucket): -- argument 1: local mount directory -- argument 2: S3 bucket name +```bash +nix run github:darkmatter/nix#mount-darkmatter +nix run github:darkmatter/nix#mount-darkmatter -- team +``` + +Unmount: + +```bash +nix run github:darkmatter/nix#unmount-darkmatter +nix run github:darkmatter/nix#unmount-darkmatter -- personal +``` -The wrapper also exports AWS environment variables before starting `rclone` so teammates do not need to remember the right profile settings manually. Fill in the placeholder values in `flake.nix` for your environment, for example: +Override the mount root for a single invocation with `DARKMATTER_BASE_DIR=/some/path`. -- `AWS_PROFILE` -- `AWS_REGION` -- `AWS_DEFAULT_REGION` +To customize bucket names, the rclone remote name, or the mount layout in another flake, import the module and override the options: -Once mounted, unmount it the usual way for your OS when you are done. +```nix +{ + imports = [ inputs.darkmatter.flakeModules.r2 ]; + + perSystem = { ... }: { + darkmatter.r2 = { + enable = true; + accountId = ""; + mounts.team.bucket = "my-team-bucket"; + mounts.archive = { bucket = "my-archive-bucket"; }; + }; + }; +} +``` ## Quick Start diff --git a/flake.nix b/flake.nix index e9d6fb8..0c04f7a 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ flakeModules = { default = ./modules/flake-parts; agenix-rekey = ./modules/flake-parts/ci/agenix-rekey.nix; + r2 = ./modules/flake-parts/r2.nix; }; homeManagerModules = { default = defaultHomeManagerModule; @@ -79,6 +80,9 @@ cachix.enable = true; cachix.name = "darkmatter"; }; + # Expose mount-darkmatter / unmount-darkmatter / configure-darkmatter-r2 + # apps that mount Cloudflare R2 buckets at ~/darkmatter/{public,team,personal}. + darkmatter.r2.enable = true; }; }; } diff --git a/modules/flake-parts/default.nix b/modules/flake-parts/default.nix index 8b3082f..2188840 100644 --- a/modules/flake-parts/default.nix +++ b/modules/flake-parts/default.nix @@ -16,5 +16,6 @@ {...}: { imports = [ ./ci + ./r2.nix ]; } diff --git a/modules/flake-parts/r2.nix b/modules/flake-parts/r2.nix new file mode 100644 index 0000000..83cb730 --- /dev/null +++ b/modules/flake-parts/r2.nix @@ -0,0 +1,327 @@ +# Cloudflare R2 mount module (flake-parts) +# +# Exposes apps that let team members mount Cloudflare R2 buckets locally via +# rclone at a configurable base directory (default `~/darkmatter`). The +# module generates one rclone mount per entry in `darkmatter.r2.mounts`, +# plus an interactive helper to bootstrap the rclone remote. +# +# Usage: +# imports = [ inputs.darkmatter.flakeModules.r2 ]; +# perSystem = { ... }: { +# darkmatter.r2 = { +# enable = true; +# # accountId can also be supplied at runtime via R2_ACCOUNT_ID +# accountId = ""; +# }; +# }; +# +# nix run .#configure-darkmatter-r2 # one-time rclone remote setup +# nix run .#mount-darkmatter # mount all configured buckets +# nix run .#mount-darkmatter -- team # mount a single bucket by name +# nix run .#unmount-darkmatter # unmount everything +{ + lib, + flake-parts-lib, + ... +}: +let + inherit (lib) mkOption mkEnableOption types; + inherit (flake-parts-lib) mkPerSystemOption; +in +{ + options.perSystem = mkPerSystemOption ( + { + pkgs, + config, + ... + }: + let + cfg = config.darkmatter.r2; + + mountModule = types.submodule ( + { name, ... }: + { + options = { + bucket = mkOption { + type = types.str; + description = "R2 bucket name to mount."; + }; + subdir = mkOption { + type = types.str; + default = name; + description = "Subdirectory under `baseDir` where the bucket is mounted."; + }; + extraMountArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Additional arguments forwarded to `rclone mount`."; + }; + }; + } + ); + + mountNames = lib.attrNames cfg.mounts; + + # Shell-quote a list of strings into a single space-separated argv. + shellArgs = args: lib.concatStringsSep " " (map lib.escapeShellArg args); + + mountEntry = name: m: '' + ${lib.escapeShellArg name}) + bucket=${lib.escapeShellArg m.bucket} + subdir=${lib.escapeShellArg m.subdir} + target="$base/$subdir" + extra_args=(${shellArgs m.extraMountArgs}) + ;; + ''; + + mountCases = lib.concatStringsSep "\n" ( + lib.mapAttrsToList mountEntry cfg.mounts + ); + + unmountEntry = name: m: '' + ${lib.escapeShellArg name}) + target=$base/${lib.escapeShellArg m.subdir} + ;; + ''; + + unmountCases = lib.concatStringsSep "\n" ( + lib.mapAttrsToList unmountEntry cfg.mounts + ); + + knownNames = shellArgs mountNames; + + mountScript = pkgs.writeShellApplication { + name = "mount-darkmatter"; + runtimeInputs = [ + pkgs.rclone + pkgs.coreutils + ]; + text = '' + set -euo pipefail + + remote=${lib.escapeShellArg cfg.remoteName} + base="''${DARKMATTER_BASE_DIR:-${cfg.baseDir}}" + base="''${base/#\~/$HOME}" + + if ! rclone listremotes | grep -qx "''${remote}:"; then + echo "rclone remote '$remote' not found." >&2 + echo "Run: nix run ${lib.escapeShellArg "."}#configure-darkmatter-r2" >&2 + exit 1 + fi + + targets=("$@") + if [ "''${#targets[@]}" -eq 0 ]; then + targets=(${knownNames}) + fi + + mkdir -p "$base" + + for name in "''${targets[@]}"; do + bucket="" + target="" + extra_args=() + case "$name" in + ${mountCases} + *) + echo "Unknown mount '$name'. Known: ${knownNames}" >&2 + exit 2 + ;; + esac + + mkdir -p "$target" + + if mount | grep -F " on $target " >/dev/null 2>&1; then + echo "[$name] already mounted at $target" + continue + fi + + echo "[$name] mounting $remote:$bucket -> $target" + rclone mount "$remote:$bucket" "$target" \ + --daemon \ + --vfs-cache-mode=${cfg.vfsCacheMode} \ + ${shellArgs cfg.extraMountArgs} \ + "''${extra_args[@]}" + done + ''; + }; + + unmountScript = pkgs.writeShellApplication { + name = "unmount-darkmatter"; + runtimeInputs = [ + pkgs.coreutils + ]; + text = '' + set -euo pipefail + + base="''${DARKMATTER_BASE_DIR:-${cfg.baseDir}}" + base="''${base/#\~/$HOME}" + + targets=("$@") + if [ "''${#targets[@]}" -eq 0 ]; then + targets=(${knownNames}) + fi + + unmount() { + local path="$1" + if ! mount | grep -F " on $path " >/dev/null 2>&1; then + echo "[$(basename "$path")] not mounted" + return 0 + fi + if command -v fusermount3 >/dev/null 2>&1; then + fusermount3 -u "$path" + elif command -v fusermount >/dev/null 2>&1; then + fusermount -u "$path" + else + umount "$path" + fi + echo "[$(basename "$path")] unmounted" + } + + for name in "''${targets[@]}"; do + target="" + case "$name" in + ${unmountCases} + *) + echo "Unknown mount '$name'. Known: ${knownNames}" >&2 + exit 2 + ;; + esac + unmount "$target" + done + ''; + }; + + configureScript = pkgs.writeShellApplication { + name = "configure-darkmatter-r2"; + runtimeInputs = [ + pkgs.rclone + pkgs.coreutils + ]; + text = '' + set -euo pipefail + + remote=${lib.escapeShellArg cfg.remoteName} + account_id="''${R2_ACCOUNT_ID:-${cfg.accountId}}" + access_key="''${R2_ACCESS_KEY_ID:-}" + secret_key="''${R2_SECRET_ACCESS_KEY:-}" + + if [ -z "$account_id" ]; then + read -r -p "Cloudflare account id: " account_id + fi + if [ -z "$access_key" ]; then + read -r -p "R2 access key id: " access_key + fi + if [ -z "$secret_key" ]; then + read -r -s -p "R2 secret access key: " secret_key + echo + fi + + endpoint="https://''${account_id}.r2.cloudflarestorage.com" + + rclone config create "$remote" s3 \ + provider Cloudflare \ + access_key_id "$access_key" \ + secret_access_key "$secret_key" \ + endpoint "$endpoint" \ + region auto \ + acl private \ + --non-interactive \ + >/dev/null + + echo "Configured rclone remote '$remote' against $endpoint" + echo "Mounts available: ${knownNames}" + ''; + }; + in + { + options.darkmatter.r2 = { + enable = mkEnableOption "Cloudflare R2 mount apps backed by rclone"; + + accountId = mkOption { + type = types.str; + default = ""; + description = '' + Cloudflare account id used to derive the R2 endpoint + (`https://.r2.cloudflarestorage.com`). Can be + overridden at runtime via the `R2_ACCOUNT_ID` env var. + ''; + }; + + remoteName = mkOption { + type = types.str; + default = "darkmatter-r2"; + description = "Name of the rclone remote to create and mount from."; + }; + + baseDir = mkOption { + type = types.str; + default = "~/darkmatter"; + description = '' + Base directory under which each mount's subdirectory is created. + Tilde is expanded against `$HOME` at runtime. Override per-invocation + via the `DARKMATTER_BASE_DIR` env var. + ''; + }; + + vfsCacheMode = mkOption { + type = types.enum [ + "off" + "minimal" + "writes" + "full" + ]; + default = "writes"; + description = "Value passed to `rclone mount --vfs-cache-mode`."; + }; + + extraMountArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra arguments appended to every `rclone mount` invocation."; + }; + + mounts = mkOption { + type = types.attrsOf mountModule; + default = { + public = { + bucket = "darkmatter-public"; + }; + team = { + bucket = "darkmatter-team"; + }; + personal = { + bucket = "darkmatter-personal"; + }; + }; + description = '' + Set of buckets to mount under `baseDir`. The attribute name is + used as the subdirectory name (overridable via `subdir`) and as + the argument accepted by `mount-darkmatter`/`unmount-darkmatter`. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + packages = { + mount-darkmatter = mountScript; + unmount-darkmatter = unmountScript; + configure-darkmatter-r2 = configureScript; + }; + apps = { + mount-darkmatter = { + type = "app"; + program = "${mountScript}/bin/mount-darkmatter"; + }; + unmount-darkmatter = { + type = "app"; + program = "${unmountScript}/bin/unmount-darkmatter"; + }; + configure-darkmatter-r2 = { + type = "app"; + program = "${configureScript}/bin/configure-darkmatter-r2"; + }; + }; + }; + } + ); +} From 9fdaa06cd1454a0c08405ba59126c45c22d24c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 07:54:34 +0000 Subject: [PATCH 2/2] Avoid IFD in agenix-rekey workflow generation Hercules CI evaluation was failing because the workflow file was built via builtins.readFile of yaml.generate's output, which forces an import-from-derivation for every system at eval time. Replace that with a runCommand that prepends the comment header at build time, eliminating the IFD without changing the rendered YAML. https://claude.ai/code/session_01RX1FoRF99mdTXmX5VR5ruH --- modules/flake-parts/ci/agenix-rekey.nix | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/flake-parts/ci/agenix-rekey.nix b/modules/flake-parts/ci/agenix-rekey.nix index b7c6183..47e8e26 100644 --- a/modules/flake-parts/ci/agenix-rekey.nix +++ b/modules/flake-parts/ci/agenix-rekey.nix @@ -97,10 +97,12 @@ in { }; }; - workflowFile = pkgs.writeText "agenix-rekey.yaml" ( - "# Generated by darkmatter flake - do not edit manually\n" - + (builtins.readFile (yaml.generate "agenix-rekey.yaml" workflow)) - ); + workflowFile = pkgs.runCommand "agenix-rekey.yaml" { } '' + { + echo '# Generated by darkmatter flake - do not edit manually' + cat ${yaml.generate "agenix-rekey.yaml" workflow} + } > $out + ''; installScript = pkgs.writeShellScriptBin "install-agenix-rekey-workflow" '' mkdir -p .github/workflows