diff --git a/doc/rustfs.md b/doc/rustfs.md new file mode 100644 index 00000000..dafe4fb4 --- /dev/null +++ b/doc/rustfs.md @@ -0,0 +1,37 @@ +# RustFS + +[RustFS](https://github.com/rustfs/rustfs) is a high-performance, distributed object storage system written in Rust. It is compatible with the Amazon S3 API and can be used as a drop-in alternative to MinIO. + +RustFS is not in nixpkgs. You must provide the package yourself +via [`package`](#options), e.g. from the +[`rustfs-flake`](https://github.com/rustfs/rustfs-flake) flake input. + +## Getting Started + +```nix +{ inputs, ... }: +{ + perSystem = { pkgs, system, ... }: { + process-compose."default" = { + services.rustfs."s3" = { + enable = true; + package = inputs.rustfs-flake.packages.${system}.default; + + server.port = 9000; # S3 API + console.enable = true; + console.port = 9001; # Web console + + accessKey = "rustfsadmin"; # 5 to 20 characters + secretKey = "rustfsadmin"; # 8 to 40 characters + }; + }; + }; +} +``` + +The S3 API is then available at `http://127.0.0.1:9000` and the web console at +`http://127.0.0.1:9001`. + +## Usage Example + + diff --git a/doc/services.md b/doc/services.md index 00a499bf..564a4d7d 100644 --- a/doc/services.md +++ b/doc/services.md @@ -34,6 +34,7 @@ short-title: Services - [[qdrant]]# - [[redis]]# - [[redis-cluster]] +- [[rustfs]]# - [[seaweedfs]]# - [[searxng]]# - [[tika]]# diff --git a/nix/services/default.nix b/nix/services/default.nix index 58c82bc9..63c531d0 100644 --- a/nix/services/default.nix +++ b/nix/services/default.nix @@ -38,6 +38,7 @@ in ./qdrant.nix ./chromadb.nix ./neo4j.nix + ./rustfs.nix ]) ++ [ ./devshell.nix ]; diff --git a/nix/services/rustfs.nix b/nix/services/rustfs.nix new file mode 100644 index 00000000..46db0a61 --- /dev/null +++ b/nix/services/rustfs.nix @@ -0,0 +1,289 @@ +{ pkgs +, config +, lib +, name +, ... +}: + +let + inherit (lib) types mkOption mkEnableOption; + + provisionEnable = + config.buckets != [ ] || config.iam.import.path != null || config.provisionScript != null; +in +{ + options = { + package = mkOption { + type = types.package; + description = '' + Which package of RustFS to use, + e.g. 'inputs.rustfs-flake.packages.''${pkgs.stdenv.hostPlatform.system}.default'. + ''; + }; + + server = { + host = mkOption { + type = types.nullOr types.str; + default = "127.0.0.1"; + description = '' + The IP interface to bind to. + `null` means "all interfaces". + ''; + }; + + port = mkOption { + type = types.port; + default = 9000; + description = "The TCP port for the S3 API."; + }; + }; + + console = { + port = mkOption { + type = types.port; + default = 9001; + description = "The TCP port for the web console."; + }; + + enable = mkOption { + type = types.bool; + default = true; # This is the default in RustFS. + description = "Enable the console."; + }; + }; + + accessKey = mkOption { + type = types.str; + default = "rustfsadmin"; + description = "Access key for authentication (5 to 20 characters)."; + }; + + secretKey = mkOption { + type = types.str; + default = "rustfsadmin"; + description = "Secret key for authentication (8 to 40 characters)."; + }; + + logLevel = mkOption { + type = types.str; + default = "info"; + description = "Log level (error, warn, info, debug, trace)."; + }; + + region = mkOption { + type = types.str; + default = "us-east-1"; + description = "The service region reported to clients."; + }; + + buckets = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Buckets to create on startup."; + example = [ + "uploads" + "assets" + ]; + }; + + iam = { + import = { + path = mkOption { + type = types.nullOr ( + types.either + (types.pathWith { + inStore = false; + absolute = false; + }) + # A nix store path. + (types.pathWith { inStore = true; }) + ); + default = null; + description = '' + Path to the folder from RustFS IAM export (unzipped) to restore via the admin `import-iam` endpoint on startup. + Produce it via the console IAM export tab. + Import is get-or-create, so it is safe to re-apply on an already-populated data dir. + ''; + }; + }; + + export = { + enable = mkEnableOption "export of IAM settings on a process '${name}-iam-export'."; + path = mkOption { + type = types.pathWith { + inStore = false; + absolute = false; + }; + default = "${config.dataDir}/export/iam-settings"; + description = '' + Path to the folder where to unzip the RustFS IAM export when the + manual process '${name}-iam-export runs'. + ''; + }; + }; + }; + + provisionScript = mkOption { + type = types.nullOr types.package; + default = null; + description = '' + Extra provision script with custom provisioning steps. + ''; + }; + + extraEnvironment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Additional environment variables to pass to RustFS. + See the RustFS documentation for available options + (e.g. `RUSTFS_CORS_ALLOWED_ORIGINS`, `RUSTFS_TLS_PATH`). + ''; + example = { + RUSTFS_OBS_LOGGER_LEVEL = "debug"; + RUSTFS_OBJECT_CACHE_ENABLE = "true"; + }; + }; + }; + + config.outputs.settings.processes = { + ${name} = { + environment = { + RUST_LOG = config.logLevel; + RUSTFS_ADDRESS = "${config.server.host}:${lib.toString config.server.port}"; + RUSTFS_CONSOLE_ENABLE = lib.boolToString config.console.enable; + RUSTFS_CONSOLE_ADDRESS = "${config.server.host}:${lib.toString config.console.port}"; + + RUSTFS_ACCESS_KEY = config.accessKey; + RUSTFS_SECRET_KEY = config.secretKey; + + RUSTFS_DATA_DIR = "${config.dataDir}/data"; + + RUSTFS_REGION = config.region; + } + // config.extraEnvironment; + + command = pkgs.writeShellApplication { + name = "rustfs"; + text = + # Bash + '' + mkdir -p "$RUSTFS_DATA_DIR" + exec ${config.package}/bin/rustfs server "$RUSTFS_DATA_DIR" + ''; + }; + + readiness_probe = { + http_get = { + host = config.server.host; + port = config.server.port; + path = "/health"; + }; + initial_delay_seconds = 1; + period_seconds = 2; + timeout_seconds = 2; + success_threshold = 1; + failure_threshold = 10; + }; + }; + } + // lib.optionalAttrs provisionEnable { + "${name}-provision" = { + command = pkgs.writeShellApplication { + name = "rustfs-provision"; + runtimeInputs = [ + pkgs.curl + pkgs.awscli2 + pkgs.zip + ]; + text = + # Bash + '' + # shellcheck disable=SC2034 + endpoint="${config.server.host}:${lib.toString config.server.port}" + + # Scratch dir (for the IAM zip); nothing is written to $HOME. + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + export AWS_ACCESS_KEY_ID="${config.accessKey}" + export AWS_SECRET_ACCESS_KEY="${config.secretKey}" + export AWS_DEFAULT_REGION="${config.region}" + '' + + lib.concatStringsSep "\n" ( + lib.map + ( + b: + # Bash + '' + echo "Provision: Ensuring bucket '${b}'." + aws --endpoint-url "http://$endpoint" s3 mb "s3://${b}" 2>/dev/null + echo "Provision: Bucket '${b}' created." + '' + ) + config.buckets + ) + + (lib.optionalString (config.iam.import.path != null) '' + if [ -d "${config.iam.import.path}" ]; then + src="${config.iam.import.path}" + echo "Provision: Importing IAM from zipping '$src'" + (cd "${config.iam.import.path}" && zip -rq "$tmp/iam.zip" .) + + curl -fsS -X PUT \ + --aws-sigv4 "aws:amz:${config.region}:s3" \ + -u "${config.accessKey}:${config.secretKey}" \ + --data-binary "@$tmp/iam.zip" \ + -H "Content-Type: application/zip" \ + "http://$endpoint/rustfs/admin/v3/import-iam" + + echo "Provision: IAM import done." + else + echo "Provision: IAM import: path '$src' does not exist." + fi + '') + + (lib.optionalString (config.provisionScript != null) "${lib.getExe config.provisionScript}") + + '' + echo "Provision: Done." + ''; + }; + + depends_on.${name}.condition = "process_healthy"; + availability.restart = "no"; + }; + } + // lib.optionalAttrs config.iam.export.enable { + "${name}-iam-export" = { + command = pkgs.writeShellApplication { + name = "${name}-iam-export"; + runtimeInputs = [ + pkgs.curl + pkgs.unzip + ]; + text = + # Bash + '' + endpoint="${config.server.host}:${lib.toString config.server.port}" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + # IAM export — SigV4-signed GET from the admin export endpoint. + echo "Export: Downloading IAM settings into '${config.iam.export.path}'." + curl -fsS -X GET \ + --aws-sigv4 "aws:amz:${config.region}:s3" \ + -u "${config.accessKey}:${config.secretKey}" \ + -H "Accept: application/zip" \ + -o "$tmp/iam.zip" \ + "http://$endpoint/rustfs/admin/v3/export-iam" + + echo "Unzipping into '${config.iam.export.path}'." + mkdir -p "${config.iam.export.path}" + unzip -oq "$tmp/iam.zip" -d "${config.iam.export.path}" + + echo "Export: IAM export done." + ''; + }; + disabled = true; + }; + }; +} diff --git a/nix/services/rustfs/test/iam-export/iam-assets/group_mappings.json b/nix/services/rustfs/test/iam-export/iam-assets/group_mappings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/group_mappings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/groups.json b/nix/services/rustfs/test/iam-export/iam-assets/groups.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/groups.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/policies.json b/nix/services/rustfs/test/iam-export/iam-assets/policies.json new file mode 100644 index 00000000..90278240 --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/policies.json @@ -0,0 +1 @@ +{"readwrite":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]},{"Effect":"Allow","Action":["sts:AssumeRole"]}]},"diagnostics":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:Profiling","admin:ServerTrace","admin:ConsoleLog","admin:ServerInfo","admin:TopLocksInfo","admin:OBDInfo","admin:Prometheus","admin:BandwidthMonitor"],"Resource":["arn:aws:s3:::*"]},{"Effect":"Allow","Action":["sts:AssumeRole"]}]},"writeonly":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::*"]},{"Effect":"Allow","Action":["sts:AssumeRole"]}]},"consoleAdmin":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["admin:*"]},{"Effect":"Allow","Action":["kms:*"]},{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*"]},{"Effect":"Allow","Action":["sts:AssumeRole"]}]},"readonly":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:GetObject","s3:GetBucketQuota"],"Resource":["arn:aws:s3:::*"]},{"Effect":"Allow","Action":["sts:AssumeRole"]}]}} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/stsuser_mappings.json b/nix/services/rustfs/test/iam-export/iam-assets/stsuser_mappings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/stsuser_mappings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/svcaccts.json b/nix/services/rustfs/test/iam-export/iam-assets/svcaccts.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/svcaccts.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/user_mappings.json b/nix/services/rustfs/test/iam-export/iam-assets/user_mappings.json new file mode 100644 index 00000000..742dd881 --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/user_mappings.json @@ -0,0 +1 @@ +{"test":{"version":1,"policy":"writeonly","updatedAt":"2026-07-22T14:58:06.912165643Z"}} \ No newline at end of file diff --git a/nix/services/rustfs/test/iam-export/iam-assets/users.json b/nix/services/rustfs/test/iam-export/iam-assets/users.json new file mode 100644 index 00000000..11700ee1 --- /dev/null +++ b/nix/services/rustfs/test/iam-export/iam-assets/users.json @@ -0,0 +1 @@ +{"test":{"secretKey":"testtest","status":"enabled"}} \ No newline at end of file diff --git a/nix/services/rustfs_test.nix b/nix/services/rustfs_test.nix new file mode 100644 index 00000000..e7940f2c --- /dev/null +++ b/nix/services/rustfs_test.nix @@ -0,0 +1,108 @@ +{ lib +, config +, pkgs +, ... +}: +let + cfg = config.services.rustfs.rsfs; + name = "rsfs"; + exportPath = config.services.rustfs.${name}.iam.export.path; +in +{ + services.rustfs."rsfs" = { + enable = true; + package = pkgs.rustfs; + + buckets = [ + "test-a" + "test-b" + ]; + + iam.import.path = ./rustfs/test/iam-export; + iam.export.enable = true; + }; + + settings.processes.${name}.environment = { + # The test needs CA certificates. + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + }; + + settings.processes.test = { + command = pkgs.writeShellApplication { + name = "rustfs-test"; + + runtimeInputs = [ + pkgs.curl + pkgs.gnugrep + pkgs.awscli2 + pkgs.jq + ]; + + text = '' + set -eu + echo "Checking if rustfs is up." + curl -fsS "http://${cfg.server.host}:${lib.toString cfg.server.port}/health" >/dev/null + echo "Rustfs is up." + + echo "Checking if rustfs console is up." + curl -fsS "http://${cfg.server.host}:${lib.toString cfg.console.port}/rustfs/console" >/dev/null + echo "Rustfs console is up." + + echo "Check buckets." + export AWS_ACCESS_KEY_ID="${cfg.accessKey}" + export AWS_SECRET_ACCESS_KEY="${cfg.secretKey}" + export AWS_DEFAULT_REGION="${cfg.region}" + + endpoint="${cfg.server.host}:${lib.toString cfg.server.port}" + out=$(aws --endpoint-url "http://$endpoint" s3api list-buckets --query 'Buckets[].Name' --output text) + for b in ${lib.escapeShellArgs cfg.buckets}; do + if ! grep -qw "$b" <<<"$out"; then + echo "!! Bucket '$b' not listed." + exit 1 + fi + done + echo "All buckets created." + + + export PC_SOCKET_PATH="${config.cli.options.unix-socket}" + # Silence process-compose not finding a config home. + mkdir -p "$(pwd)/.config/process-compose" + # shellcheck disable=SC2155 + export XDG_CONFIG_HOME="$(pwd)/.config" + + echo "Check export." + process-compose process start "${name}-iam-export" + + completed="false" + for _ in $(seq 1 30); do + if + [ "$( + process-compose process get "${name}-iam-export" \ + -o json | + jq -r ".[0].status" + )" = "Completed" ] + then + completed="true" + break + fi + + sleep 2 + done + + if [ "$completed" != "true" ]; then + echo "!! Blueprint export did not complete in time." + exit 1 + fi + + # shellcheck disable=SC2010 + if [ ! -d "${exportPath}/iam-assets" ]; then + echo "!! Export dir '${exportPath}' did not get created." + ls "${exportPath}" + exit 1 + fi + ''; + }; + depends_on."rsfs".condition = "process_healthy"; + depends_on."rsfs-provision".condition = "process_completed"; + }; +} diff --git a/test/flake.lock b/test/flake.lock index 122c7f3b..8411941f 100644 --- a/test/flake.lock +++ b/test/flake.lock @@ -49,6 +49,22 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1767379071, + "narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fb7944c166a3b630f177938e478f0378e64ce108", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "process-compose-flake": { "locked": { "lastModified": 1767863885, @@ -69,10 +85,29 @@ "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", "process-compose-flake": "process-compose-flake", + "rustfs-flake": "rustfs-flake", "services-flake": "services-flake", "systems": "systems" } }, + "rustfs-flake": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1784874492, + "narHash": "sha256-E4kC+rya1KfPpDGWtq+E3Ka4HoJGVIo5f9ZsHzYivUs=", + "owner": "rustfs", + "repo": "rustfs-flake", + "rev": "7dedccc7160410a325f922ed8591d90152a261b7", + "type": "github" + }, + "original": { + "owner": "rustfs", + "repo": "rustfs-flake", + "type": "github" + } + }, "services-flake": { "locked": { "lastModified": 1778980174, diff --git a/test/flake.nix b/test/flake.nix index bd104f51..e4fb9e6d 100644 --- a/test/flake.nix +++ b/test/flake.nix @@ -5,6 +5,7 @@ systems.url = "github:nix-systems/default"; process-compose-flake.url = "github:Platonic-Systems/process-compose-flake"; services-flake.url = "github:juspay/services-flake"; + rustfs-flake.url = "github:rustfs/rustfs-flake?rev=f5222f68c19bed705c619412827c4c0d3a33dcd6"; }; outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { @@ -62,6 +63,7 @@ "${inputs.services-flake}/nix/services/neo4j_test.nix" "${inputs.services-flake}/nix/services/redis_test.nix" "${inputs.services-flake}/nix/services/redis-cluster_test.nix" + "${inputs.services-flake}/nix/services/rustfs_test.nix" "${inputs.services-flake}/nix/services/searxng_test.nix" "${inputs.services-flake}/nix/services/pyroscope_test.nix" "${inputs.services-flake}/nix/services/tempo_test.nix" diff --git a/test/nix/pkgs.nix b/test/nix/pkgs.nix index 163a2661..914436d8 100644 --- a/test/nix/pkgs.nix +++ b/test/nix/pkgs.nix @@ -9,15 +9,19 @@ config.allowUnfree = true; overlays = [ - (self: super: lib.optionalAttrs super.stdenv.isDarwin { + (self: super: lib.optionalAttrs super.stdenv.isDarwin + { - # Disable tests, because they are failing on darwin: - # https://github.com/NixOS/nixpkgs/issues/281214 - pgadmin4 = super.pgadmin4.overrideAttrs (_: { - doInstallCheck = - false; - }); + # Disable tests, because they are failing on darwin: + # https://github.com/NixOS/nixpkgs/issues/281214 + pgadmin4 = super.pgadmin4.overrideAttrs (_: { + doInstallCheck = + false; + }); + } // + { + rustfs = inputs'.rustfs-flake.packages.default; }) ]; };