diff --git a/.github/workflows/local-binary-release.yml b/.github/workflows/local-binary-release.yml index 513ff51112..461be29760 100644 --- a/.github/workflows/local-binary-release.yml +++ b/.github/workflows/local-binary-release.yml @@ -383,3 +383,52 @@ jobs: subject-name: ${{ steps.tags.outputs.image }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + + # Points the flake's `maple` package (nix/release.json) at the published bundles. + # Runs after every platform finished, even if one failed: the script leaves out a + # platform whose bundle never made it to the release. Pre-releases keep the pin. + nix-release: + name: Nix release pin + needs: [preflight, build] + if: ${{ always() && needs.preflight.outputs.publish == 'true' && !contains(needs.preflight.outputs.version, '-') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + actions: write # dispatch the Nix check on the pin branch + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + ref: main + + - name: Open the pin PR + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + # Never pin a re-run of an older tag over a newer one. + newest="$(git ls-remote --tags --refs origin 'v*' | sed 's|.*refs/tags/||' | { grep -ve '-' || true; } | sort -V | tail -1)" + if [ "$TAG" != "$newest" ]; then + echo "::notice::$TAG is not the newest stable tag ($newest); keeping the pin." + exit 0 + fi + scripts/update-nix-release.sh "$TAG" + if git diff --quiet -- nix/release.json; then + echo "::notice::nix/release.json already pins $TAG." + exit 0 + fi + branch="nix/release-$TAG" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git commit -m "chore(nix): pin maple $TAG" -- nix/release.json + git push --force origin "$branch" + if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then + gh pr create --base main --head "$branch" \ + --title "chore(nix): pin maple $TAG" \ + --body "Points the flake's \`maple\` package at the $TAG bundles (\`scripts/update-nix-release.sh $TAG\`)." + fi + # A push made with this token starts no workflows, so run the check explicitly. + gh workflow run nix.yml --ref "$branch" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..d555d15aad --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,50 @@ +name: Nix + +# Builds the flake's `maple` package and runs the NixOS VM test (nix/test.nix): +# the service starts, enforces its secrets, ingests a span and serves it back. + +on: + pull_request: + paths: + - flake.nix + - flake.lock + - nix/** + - .github/workflows/nix.yml + push: + branches: [main] + paths: + - flake.nix + - flake.lock + - nix/** + - .github/workflows/nix.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + + # The VM test needs KVM, which the runner has but does not expose to users. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 + with: + extra_nix_config: system-features = nixos-test benchmark big-parallel kvm + + - name: Check the flake + run: nix flake check -L --show-trace diff --git a/apps/landing/src/content/docs/self-hosting.md b/apps/landing/src/content/docs/self-hosting.md index c4fcf49eb7..facd457472 100644 --- a/apps/landing/src/content/docs/self-hosting.md +++ b/apps/landing/src/content/docs/self-hosting.md @@ -58,5 +58,6 @@ This serves plain HTTP on every interface, which is fine on a private network. O - [Deploy](/docs/self-hosting/deploy): Docker Compose, container platforms, and TLS with a reverse proxy. - [Render](/docs/self-hosting/platforms/render): one-click deploy from a blueprint. - [Railway](/docs/self-hosting/platforms/railway): one-click deploy from a template. +- [NixOS](/docs/self-hosting/platforms/nixos): a NixOS module from the Maple flake. - [Connect](/docs/self-hosting/connect): send telemetry, sign in, and query from the CLI. - [Operations](/docs/self-hosting/operations): backups, upgrades, crash recovery and rotating secrets. diff --git a/apps/landing/src/content/docs/self-hosting/platforms/nixos.md b/apps/landing/src/content/docs/self-hosting/platforms/nixos.md new file mode 100644 index 0000000000..4c4f4ba444 --- /dev/null +++ b/apps/landing/src/content/docs/self-hosting/platforms/nixos.md @@ -0,0 +1,76 @@ +--- +title: "NixOS" +description: "Run self-hosted Maple as a NixOS service from the flake in the Maple repository, with its secrets read from files." +group: "Platforms" +order: 3 +--- + +The Maple repository is a Nix flake. It packages the released `maple` binary and ships a NixOS module, `services.maple`, which runs the same server as the [container image](/docs/self-hosting) under systemd. + +## Add the module + +In your system flake: + +```nix +{ + inputs.maple.url = "github:MapleTechLabs/maple"; + + outputs = { nixpkgs, maple, ... }: { + nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + maple.nixosModules.maple + ./configuration.nix + ]; + }; + }; +} +``` + +Then in `configuration.nix`: + +```nix +services.maple = { + enable = true; + host = "127.0.0.1"; + ingestKeyFile = "/run/secrets/maple-ingest-key"; + accessTokenFile = "/run/secrets/maple-access-token"; +}; + +services.caddy = { + enable = true; + virtualHosts."maple.example.com".extraConfig = "reverse_proxy localhost:4318"; +}; +``` + +Each secret file holds one of the [two secrets](/docs/self-hosting#the-two-secrets), at least 16 characters. Create them with `openssl rand -hex 32`, or manage them with sops-nix or agenix. The service reads them through systemd credentials, so they never enter the Nix store. Listening on anything other than loopback requires both files. + +## Options + +| Option | Default | Meaning | +| -------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- | +| `host` | `127.0.0.1` | Address to listen on. Use `0.0.0.0` without a proxy on a private network | +| `port` | `4318` | OTLP ingest, the dashboard and the query API | +| `advertiseHost` | none | The hostname exporters and browsers use, when it differs from `host` | +| `openFirewall` | `false` | Open `port` in the firewall | +| `ingestKeyFile` | none | File holding the ingest key | +| `accessTokenFile` | none | File holding the access token | +| `checkpointInterval` | `30m` | How often to refresh the [recovery checkpoint](/docs/self-hosting/operations), or `off` | +| `onDirtyStore` | automatic | After an unclean stop: restore the last checkpoint once one exists, start fresh before that (as the image) | +| `extraArgs` | `[]` | Extra arguments for `maple start` | + +Telemetry and checkpoints live in `/var/lib/maple`, owned by the `maple` system user. Back it up as described in [Operations](/docs/self-hosting/operations), and run commands that change the store as that user: + +```bash +sudo -u maple HOME=/var/lib/maple maple checkpoint +``` + +Read-only commands such as `maple services` can run as any user with `MAPLE_LOCAL_URL=http://127.0.0.1:4318` and `MAPLE_LOCAL_ACCESS_TOKEN` set. + +## Upgrades + +The flake follows Maple releases. Run `nix flake update maple` and rebuild. `maple update` is disabled in a Nix install. Roll back with your previous generation like any other NixOS change, but note that a store opened by a newer version may refuse to start on an older one. + +## Without NixOS + +`nix run github:MapleTechLabs/maple -- start` runs Maple Local on any Linux machine with Nix, and on Apple Silicon macOS when the pinned release has a bundle for it. Maple's license is the [FSL](https://fsl.software), which nixpkgs counts as unfree: the flake's own package builds without any setting, but the `overlays.default` overlay needs `maple` allowed in your `allowUnfreePredicate`. diff --git a/docs/local-mode.md b/docs/local-mode.md index b1cff91565..a4c4e60f18 100644 --- a/docs/local-mode.md +++ b/docs/local-mode.md @@ -815,6 +815,23 @@ MAPLE_TARGET=linux-arm64 scripts/build-local-binary.sh # cross-compile (linux- apps/cli/docker/build-image.sh maple-local:dev && apps/cli/docker/image-smoke.sh maple-local:dev ``` +## Nix flake + +`flake.nix` packages the released bundle (`nix/package.nix`, pinned by +`nix/release.json`) and exports `nixosModules.maple` (`nix/module.nix`), the +systemd counterpart of the container entrypoint: a static `maple` user, +secrets from files via `LoadCredential`, `HOME=/var/lib/maple`, `--offline`, the same dirty-store +policy and `RestartPreventExitStatus=78`. The package wraps `maple` with +`MAPLE_NO_UPDATE_CHECK=1` and refuses `maple update`; +autoPatchelf keeps Bun's appended payload intact, but stripping would not, so +`dontStrip` stays. User docs: `apps/landing/src/content/docs/self-hosting/platforms/nixos.md`. + +- `nix/test.nix` is a NixOS VM test (ingest key, access token, a span through + OTLP and back through the CLI, a restart). The `Nix` workflow runs + `nix flake check` on changes to the flake. +- The release workflow's `nix-release` job runs `scripts/update-nix-release.sh ` + after a stable release and opens a PR with the new pin. Run it by hand to re-pin. + ## Release signing A `.sha256` published next to a bundle only proves the download is intact: the diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..14c839eb70 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1794042290, + "narHash": "sha256-ziZn2PvCJE5JEEpqTdxXwJpPB+L3YqoCpTAMCzyaP3U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f4a03c008967841f2a0b3cb7c436fc6fc31671f6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..efe029cf92 --- /dev/null +++ b/flake.nix @@ -0,0 +1,61 @@ +{ + description = "Maple: self-hosted OpenTelemetry ingest, storage and dashboard (Maple Local)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + lib = nixpkgs.lib; + release = lib.importJSON ./nix/release.json; + # Platforms the pinned release has a bundle for, among those nixpkgs still + # supports (unstable dropped x86_64-darwin in 26.11). + systems = lib.intersectLists [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ] (builtins.attrNames release.assets); + linuxSystems = lib.filter (lib.hasSuffix "-linux") systems; + forAll = list: f: lib.genAttrs list (system: f system); + # Maple is FSL-1.1-ALv2, which nixpkgs counts as unfree; building it from + # this flake should not require a global allowUnfree. + mapleFor = pkgs: pkgs.callPackage ./nix/package.nix { }; + pkgsFor = + system: + import nixpkgs { + inherit system; + config.allowUnfreePredicate = pkg: lib.getName pkg == "maple"; + }; + in + { + packages = forAll systems ( + system: + let + maple = mapleFor (pkgsFor system); + in + { + inherit maple; + default = maple; + } + ); + + # Builds from the consumer's nixpkgs, so it needs maple allowed there: + # nixpkgs.config.allowUnfreePredicate = p: lib.getName p == "maple"; + overlays.default = final: _prev: { maple = mapleFor final; }; + + nixosModules.maple = import ./nix/module.nix { + defaultPackage = pkgs: self.packages.${pkgs.stdenv.hostPlatform.system}.maple; + }; + nixosModules.default = self.nixosModules.maple; + + checks = forAll linuxSystems (system: { + package = self.packages.${system}.maple; + nixos = import ./nix/test.nix { + pkgs = pkgsFor system; + module = self.nixosModules.maple; + }; + }); + + formatter = forAll systems (system: (pkgsFor system).nixfmt); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000000..b52683b3a4 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,247 @@ +# services.maple: self-hosted Maple Local as a systemd service, the NixOS +# counterpart of the container image (apps/cli/docker/entrypoint.sh). +{ defaultPackage }: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.maple; + loopback = lib.elem cfg.host [ + "127.0.0.1" + "::1" + "localhost" + ]; + # The image's rule: `--in-memory` has no store to recover and refuses the policy flag. + inMemory = lib.elem "--in-memory" cfg.extraArgs; + start = pkgs.writeShellScript "maple-start" '' + set -eu + # A configured secret must be non-empty: maple treats an empty one as unset, + # which would serve without auth. 78 tells systemd not to retry. + load_secret() { + value="$(cat "$CREDENTIALS_DIRECTORY/$1")" + if [ -z "$(printf '%s' "$value" | tr -d '[:space:]')" ]; then + echo "maple: the $1 file is empty" >&2 + exit 78 + fi + export "$2=$value" + } + ${lib.optionalString (cfg.ingestKeyFile != null) "load_secret ingest-key MAPLE_LOCAL_INGEST_KEY"} + ${lib.optionalString ( + cfg.accessTokenFile != null + ) "load_secret access-token MAPLE_LOCAL_ACCESS_TOKEN"} + # After an unclean stop the store restores its last checkpoint. Before the + # first one exists there is nothing to recover, so it starts fresh instead + # of refusing (the image's policy). + on_dirty_store=${if cfg.onDirtyStore == null then "wipe" else cfg.onDirtyStore} + ${lib.optionalString (cfg.onDirtyStore == null) '' + if [ -f "$HOME/.maple/data/backups/state.json" ]; then + on_dirty_store=restore-checkpoint + fi + ''} + exec ${lib.getExe cfg.package} start \ + --host ${lib.escapeShellArg cfg.host} \ + --port ${toString cfg.port} \ + --offline \ + ${lib.optionalString (!inMemory) "--on-dirty-store \"$on_dirty_store\""} \ + ${ + lib.optionalString ( + cfg.advertiseHost != null + ) "--advertise-host ${lib.escapeShellArg cfg.advertiseHost}" + } \ + ${ + lib.optionalString ( + cfg.checkpointInterval != null + ) "--checkpoint-interval ${lib.escapeShellArg cfg.checkpointInterval}" + } \ + ${lib.escapeShellArgs cfg.extraArgs} + ''; +in +{ + options.services.maple = { + enable = lib.mkEnableOption "Maple, self-hosted OpenTelemetry ingest and dashboard (Maple Local)"; + + package = lib.mkOption { + type = lib.types.package; + default = defaultPackage pkgs; + defaultText = lib.literalExpression "maple.packages.\${system}.maple"; + description = "The maple package to run."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + example = "0.0.0.0"; + description = '' + Address to listen on. Off loopback, both `ingestKeyFile` and + `accessTokenFile` are required. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 4318; + description = "Port for OTLP/HTTP ingest, the dashboard and the query API."; + }; + + advertiseHost = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "maple.example.com"; + description = "Hostname browsers and exporters use to reach the server, when it differs from `host`."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Open `port` in the firewall."; + }; + + ingestKeyFile = lib.mkOption { + # A string, not a path: a path literal would copy the secret into the store. + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/maple-ingest-key"; + description = '' + File holding `MAPLE_LOCAL_INGEST_KEY` (at least 16 characters), which + guards OTLP ingest. Read through systemd credentials, so it never + enters the Nix store. + ''; + }; + + accessTokenFile = lib.mkOption { + # A string, not a path: a path literal would copy the secret into the store. + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/maple-access-token"; + description = '' + File holding `MAPLE_LOCAL_ACCESS_TOKEN` (at least 16 characters), which + guards the dashboard and the query API. + ''; + }; + + checkpointInterval = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "15m"; + description = "How often to refresh the store checkpoint (`off` disables it). Null keeps maple's default (30m)."; + }; + + onDirtyStore = lib.mkOption { + type = lib.types.nullOr ( + lib.types.enum [ + "fail" + "restore-checkpoint" + "wipe" + ] + ); + default = null; + description = '' + Recovery after an unclean stop. Null restores the last checkpoint once + one exists and starts fresh before that, as the container image does. + ''; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "--minimum-raw-telemetry-retention-days" + "14" + ]; + description = '' + Extra arguments for `maple start`. The store lives in + `/var/lib/maple/.maple/data`; do not pass `--data-dir`. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = loopback || (cfg.ingestKeyFile != null && cfg.accessTokenFile != null); + message = "services.maple: listening on ${cfg.host} needs both ingestKeyFile and accessTokenFile."; + } + { + assertion = lib.all (f: f == null || !lib.hasPrefix builtins.storeDir f) [ + cfg.ingestKeyFile + cfg.accessTokenFile + ]; + message = "services.maple: secret files must live outside the Nix store, which is world-readable."; + } + { + assertion = !lib.any (arg: arg == "--data-dir" || lib.hasPrefix "--data-dir=" arg) cfg.extraArgs; + message = "services.maple: the store lives under /var/lib/maple; remove --data-dir from extraArgs."; + } + ]; + + # A static user, so store-changing commands can run as it: + # `sudo -u maple HOME=/var/lib/maple maple checkpoint`. + users.users.maple = { + isSystemUser = true; + group = "maple"; + home = "/var/lib/maple"; + }; + users.groups.maple = { }; + + environment.systemPackages = [ cfg.package ]; + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + + systemd.services.maple = { + description = "Maple (self-hosted OpenTelemetry ingest and dashboard)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + # The store, its sidecars and maple's config live under HOME. + environment.HOME = "/var/lib/maple"; + serviceConfig = { + ExecStart = start; + User = "maple"; + Group = "maple"; + StateDirectory = "maple"; + StateDirectoryMode = "0700"; + WorkingDirectory = "/var/lib/maple"; + LoadCredential = + lib.optional (cfg.ingestKeyFile != null) "ingest-key:${cfg.ingestKeyFile}" + ++ lib.optional (cfg.accessTokenFile != null) "access-token:${cfg.accessTokenFile}"; + Restart = "on-failure"; + RestartSec = 5; + # Exit 78 is a refusal retrying cannot fix (dirty or incompatible store). + RestartPreventExitStatus = 78; + # A clean stop waits for an in-flight checkpoint, then closes chDB; a kill + # leaves a dirty store. Matches `maple stop`'s checkpoint budget. + TimeoutStopSec = "15min"; + LimitNOFILE = 65536; + + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + ProtectHostname = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + # getifaddrs(), which the startup banner uses to list addresses. + "AF_NETLINK" + ]; + CapabilityBoundingSet = if cfg.port < 1024 then [ "CAP_NET_BIND_SERVICE" ] else ""; + AmbientCapabilities = lib.optionals (cfg.port < 1024) [ "CAP_NET_BIND_SERVICE" ]; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000000..7f8533a708 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,71 @@ +# The released `maple` bundle (the Bun-compiled binary plus libchdb), the same +# artifact the installer and the container image ship. nix/release.json pins the +# version and each platform's sha256; scripts/update-nix-release.sh rewrites it. +{ + lib, + stdenv, + fetchurl, + autoPatchelfHook, +}: +let + release = lib.importJSON ./release.json; + asset = + release.assets.${stdenv.hostPlatform.system} + or (throw "maple: no release bundle for ${stdenv.hostPlatform.system}"); + name = "maple-v${release.version}-${asset.target}"; +in +stdenv.mkDerivation { + pname = "maple"; + inherit (release) version; + + src = fetchurl { + url = "https://github.com/MapleTechLabs/maple/releases/download/v${release.version}/${name}.tar.gz"; + inherit (asset) sha256; + }; + + nativeBuildInputs = lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ]; + buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ stdenv.cc.cc.lib ]; + + # Stripping drops the JS payload Bun appends to the executable. + dontStrip = true; + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + install -Dm755 maple $out/libexec/maple/maple + install -Dm755 libchdb.so $out/libexec/maple/libchdb.so + mkdir -p $out/bin + # Nix owns upgrades: no update notice, and `maple update` would write into the store. + cat > $out/bin/maple <<'EOF' + #!${stdenv.shell} + if [ "''${1:-}" = "update" ]; then + echo "maple was installed by Nix; upgrade it through your flake or channel." >&2 + exit 1 + fi + export MAPLE_NO_UPDATE_CHECK=1 + # maple finds the libchdb.so beside it. + exec ${placeholder "out"}/libexec/maple/maple "$@" + EOF + chmod 755 $out/bin/maple + runHook postInstall + ''; + + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + HOME="$TMPDIR" $out/bin/maple --version | grep -F "${release.version}" + refusal="$(HOME="$TMPDIR" $out/bin/maple update 2>&1 || true)" + [[ "$refusal" == *"installed by Nix"* ]] + runHook postInstallCheck + ''; + + meta = { + description = "Maple Local: OpenTelemetry ingest, an embedded ClickHouse store, a query API and the dashboard in one binary"; + homepage = "https://maple.dev/docs/self-hosting"; + license = lib.licenses.unfree; + sourceProvenance = [ lib.sourceTypes.binaryNativeCode ]; + platforms = builtins.attrNames release.assets; + mainProgram = "maple"; + }; +} diff --git a/nix/release.json b/nix/release.json new file mode 100644 index 0000000000..ad674de8ac --- /dev/null +++ b/nix/release.json @@ -0,0 +1,17 @@ +{ + "version": "0.3.5", + "assets": { + "x86_64-linux": { + "target": "x86_64-unknown-linux-gnu", + "sha256": "30fa0f024a86aead170536e382c28e3b353f5036a918754933d0324842d1c408" + }, + "aarch64-linux": { + "target": "aarch64-unknown-linux-gnu", + "sha256": "c0e5550d50ab53c76dc93fd007c5f6eb02617f3efb1e1e7d93b09e345da74add" + }, + "x86_64-darwin": { + "target": "x86_64-apple-darwin", + "sha256": "9daaf32f9d68afff479de09158e9afb7032d03ff583dbfeb5217ab73b1a1a399" + } + } +} diff --git a/nix/test.nix b/nix/test.nix new file mode 100644 index 0000000000..40138c767d --- /dev/null +++ b/nix/test.nix @@ -0,0 +1,93 @@ +# End-to-end NixOS VM test: the service starts, refuses unauthenticated reads, +# accepts an OTLP span with the ingest key, serves it back to the access token, +# and survives a restart. +{ pkgs, module }: +let + ingestKey = "nixos-test-ingest-key-0123456789"; + accessToken = "nixos-test-access-token-0123456789"; + span = pkgs.writeText "span.json" ( + builtins.toJSON { + resourceSpans = [ + { + resource.attributes = [ + { + key = "service.name"; + value.stringValue = "nixos-test"; + } + ]; + scopeSpans = [ + { + spans = [ + { + traceId = "5b8efff798038103d269b633813fc60c"; + spanId = "eee19b7ec3c1b174"; + name = "nixos-test-span"; + kind = 2; + startTimeUnixNano = "@START@"; + endTimeUnixNano = "@END@"; + } + ]; + } + ]; + } + ]; + } + ); +in +pkgs.testers.runNixOSTest { + name = "maple"; + nodes.machine = { + imports = [ module ]; + virtualisation.memorySize = 4096; + virtualisation.diskSize = 4096; + environment.etc."maple/ingest-key".text = ingestKey; + environment.etc."maple/access-token".text = accessToken; + environment.systemPackages = [ pkgs.curl ]; + services.maple = { + enable = true; + host = "0.0.0.0"; + ingestKeyFile = "/etc/maple/ingest-key"; + accessTokenFile = "/etc/maple/access-token"; + }; + }; + + testScript = '' + machine.wait_for_unit("maple.service") + machine.wait_until_succeeds("curl -fsS http://127.0.0.1:4318/health", timeout=180) + + # Off loopback without a token, reads are refused. + machine.succeed("test \"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4318/local/status)\" = 401") + + machine.succeed( + "now=$(date +%s%N); " + "sed -e \"s/@START@/$now/\" -e \"s/@END@/$((now + 1000000))/\" ${span} > /tmp/span.json" + ) + # The ingest key writes, and only with it. + machine.succeed("test \"$(curl -s -o /dev/null -w '%{http_code}' -H 'content-type: application/json' --data @/tmp/span.json http://127.0.0.1:4318/v1/traces)\" = 401") + machine.succeed("curl -fsS -H 'authorization: Bearer ${ingestKey}' -H 'content-type: application/json' --data @/tmp/span.json http://127.0.0.1:4318/v1/traces") + + # The CLI reads the span back over HTTP with the access token. + query = "env HOME=/tmp/cli MAPLE_LOCAL_URL=http://127.0.0.1:4318 MAPLE_LOCAL_ACCESS_TOKEN=${accessToken} maple services" + machine.wait_until_succeeds(query + " | grep -q nixos-test", timeout=60) + + # Store-changing commands run as the service user, as the docs say. + machine.succeed("runuser -u maple -- env HOME=/var/lib/maple maple checkpoint") + + machine.systemctl("restart maple.service") + machine.wait_until_succeeds("curl -fsS http://127.0.0.1:4318/health", timeout=180) + machine.wait_until_succeeds(query + " | grep -q nixos-test", timeout=60) + + # An empty secret file must not serve without auth: the unit exits 78 and stays down. + machine.succeed( + "touch /run/empty-token", + "mkdir -p /run/systemd/system/maple.service.d", + "printf '[Service]\\nLoadCredential=\\nLoadCredential=ingest-key:/etc/maple/ingest-key\\nLoadCredential=access-token:/run/empty-token\\n' > /run/systemd/system/maple.service.d/empty.conf", + "systemctl daemon-reload", + ) + machine.systemctl("restart maple.service") + machine.wait_until_succeeds("test \"$(systemctl show -P ExecMainStatus maple.service)\" = 78", timeout=60) + machine.sleep(10) + machine.succeed("test \"$(systemctl show -P ActiveState maple.service)\" = failed") + machine.fail("curl -fsS http://127.0.0.1:4318/health") + ''; +} diff --git a/scripts/update-nix-release.sh b/scripts/update-nix-release.sh new file mode 100755 index 0000000000..c5d9789ee9 --- /dev/null +++ b/scripts/update-nix-release.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Point nix/release.json (the flake's `maple` package) at a published release: +# reads each bundle's `.sha256` asset, so nothing is downloaded but checksums. +# A platform is pinned only when both its bundle and checksum finished uploading; +# one missing from the release is left out, and any gh failure aborts. +# +# Usage: scripts/update-nix-release.sh v0.3.6 +set -euo pipefail + +TAG="${1:?usage: $0 }" +TAG="v${TAG#v}" +REPO="${REPO:-MapleTechLabs/maple}" +OUT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/nix/release.json" + +uploaded="$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[] | select(.state == "uploaded") | .name')" + +assets="" +for pair in x86_64-linux:x86_64-unknown-linux-gnu aarch64-linux:aarch64-unknown-linux-gnu \ + x86_64-darwin:x86_64-apple-darwin aarch64-darwin:aarch64-apple-darwin; do + system="${pair%%:*}" + target="${pair#*:}" + name="maple-${TAG}-${target}.tar.gz" + if ! grep -Fqx "$name" <<<"$uploaded" || ! grep -Fqx "$name.sha256" <<<"$uploaded"; then + echo "warning: $TAG has no complete $name, leaving $system out" >&2 + continue + fi + line="$(gh release download "$TAG" --repo "$REPO" --pattern "${name}.sha256" --output -)" + hash="${line%% *}" + [[ "$hash" =~ ^[0-9a-f]{64}$ ]] || { echo "error: bad checksum line for $name: $line" >&2; exit 1; } + assets+="$(printf '"%s":{"target":"%s","sha256":"%s"},' "$system" "$target" "$hash")" +done + +# NixOS hosts are Linux: a release without both Linux bundles is not worth pinning. +for system in x86_64-linux aarch64-linux; do + [[ "$assets" == *"\"$system\""* ]] || { echo "error: $TAG has no $system bundle" >&2; exit 1; } +done +printf '{"version":"%s","assets":{%s}}' "${TAG#v}" "${assets%,}" | jq --tab . > "$OUT" +echo "nix/release.json -> ${TAG}"