🐸 Frogue(Fed)

~ololduck/Frogue

Raw

{
  description = "Frogue: a software forge implementing the ForgeFed protocol";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    nix2container = {
      url = "github:nlewo/nix2container";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    {
      self,
      nixpkgs,
      nix2container,
    }:
    let
      lib = nixpkgs.lib;

      systems = [
        "x86_64-linux"
        "aarch64-linux"
      ];

      pkgsFor = system: nixpkgs.legacyPackages.${system};

      # The workspace version is the single source of truth for the package and
      # the image tag.
      version = (lib.importTOML ./frogue/Cargo.toml).package.version;

      # Docker tags allow no `+`, and the Cargo version carries one as build
      # metadata.
      containerTag = lib.replaceStrings [ "+" ] [ "-" ] version;

      mkFrogue =
        system:
        let
          pkgs = pkgsFor system;
        in
        pkgs.rustPlatform.buildRustPackage {
          pname = "frogue";
          inherit version;

          # Only what Cargo needs: a plain `./.` would drag `target/` and the
          # repository metadata into the store.
          src = lib.fileset.toSource {
            root = ./.;
            fileset = lib.fileset.unions [
              ./Cargo.toml
              ./Cargo.lock
              ./frogue
              ./migration
            ];
          };

          cargoLock.lockFile = ./Cargo.lock;

          # `libsqlite3-sys` links against the system SQLite rather than
          # bundling it, and `zstd-sys` (pulled in by actix-web's compression)
          # builds its vendored C sources with the default compiler.
          #
          # `git` and `openssh` are here for `doCheck`, not for the binary on
          # its own: the server shells out to `git` for repositories, and the
          # integration tests drive it, and a real `ssh`, the way a developer
          # would. The container carries `git` for the same runtime reason.
          nativeBuildInputs = [
            pkgs.pkg-config
            pkgs.git
            pkgs.openssh
          ];
          buildInputs = [ pkgs.sqlite ];

          doCheck = true;

          meta = {
            description = "A software forge implementing the ForgeFed protocol";
            mainProgram = "frogue";
          };
        };

      mkContainer =
        system:
        let
          pkgs = pkgsFor system;
          frogue = mkFrogue system;

          # SQLite creates the database file but not its directory, and the
          # compiled-in default (/tmp) does not survive a container restart.
          # This is the directory a volume is expected to be mounted on.
          dataDir = pkgs.runCommand "frogue-data-dir" { } ''
            mkdir -p $out/data
          '';
        in
        nix2container.packages.${system}.nix2container.buildImage {
          name = "frogue";
          tag = containerTag;

          # The store closure of these paths becomes the image, so glibc and
          # the CA bundle come along. `cacert` is needed for the optional OTLP
          # export, which talks TLS to a collector.
          copyToRoot = pkgs.buildEnv {
            name = "frogue-image-root";
            paths = [
              frogue
              pkgs.cacert
              # The SSH server runs `git upload-pack`/`receive-pack` as child
              # processes, so the binary is not enough on its own.
              pkgs.git
              dataDir
            ];
            pathsToLink = [
              "/bin"
              "/etc/ssl/certs"
              "/data"
            ];
          };

          config = {
            Cmd = [ "/bin/frogue" ];
            Env = [
              # A container's loopback is not reachable from outside, unlike the
              # development default of 127.0.0.1:6565.
              "FROGUE_BIND_ADDR=0.0.0.0:6565"
              "FROGUE_SSH_BIND_ADDR=0.0.0.0:2222"
              # The bind address is a wildcard, which is not a host a client can
              # dial, so without this the clone URLs the webui shows would name
              # `0.0.0.0`. A deployment reachable under a real name sets it to
              # that name; a container run with `-p 2222:2222` on the same
              # machine is served by the `localhost` fallback.
              "FROGUE_SSH_HOST=localhost"
              "SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt"
              # Overridable, but point somewhere that a volume can be mounted
              # on rather than at /tmp.
              "DATABASE_URL=sqlite:///data/frogue.sqlite?mode=rwc"
              "FROGUE_REPO_ROOT=/data/repos"
            ];
            ExposedPorts."6565/tcp" = { };
            # Git over SSH, so that `docker run -p 2222:2222` is all a client
            # needs alongside the web port.
            ExposedPorts."2222/tcp" = { };
            # Documents where state lives: `docker run -v frogue-data:/data`.
            Volumes."/data" = { };
            Labels = {
              "org.opencontainers.image.title" = "frogue";
              "org.opencontainers.image.version" = version;
            };
          };
        };

      # Everything the development entry points need in order to build and run
      # the server. Shared, so that `nix develop` and `nix run .#dev` cannot
      # drift apart.
      #
      # podman is deliberately absent: the container has to be started by the
      # host's podman, which is already set up for rootless operation, not by a
      # second one from the store with its own idea of the storage location.
      # `nix run .#dev` refuses to start without it.
      devTools =
        system:
        let
          pkgs = pkgsFor system;
        in
        [
          pkgs.cargo
          pkgs.rustc
          pkgs.cargo-watch
          pkgs.process-compose
          pkgs.pkg-config
          pkgs.sqlite
          pkgs.git
          # Links the C sources zstd-sys vendors, which the Rust driver does not
          # bring along.
          pkgs.stdenv.cc
        ];

      # Nice to have when a human is driving, useless to the stack itself, and
      # large enough to be worth keeping out of the apps' runtime closures.
      devShellExtras =
        system:
        let
          pkgs = pkgsFor system;
        in
        [
          pkgs.rust-analyzer
          pkgs.clippy
          pkgs.rustfmt
          pkgs.curl
          pkgs.jq
          # Regenerates the SeaORM entities from a migrated database, so the
          # entities under `frogue/src/entities` are the tool's output rather
          # than hand-written. Shell-only: nothing at runtime needs it.
          pkgs.sea-orm-cli
        ];

      # The development topology, in the shape process-compose wants. Rendered
      # from this attribute set rather than kept as a checked-in YAML file so
      # that it stays part of the flake -- one place for the image, the ports
      # and the OTLP wiring -- and so that `checks.dev-config` has something
      # exact to validate.
      #
      # process-compose runs every command through a shell and does not expand
      # `${...}` itself, so the fallback in `${FROGUE_JAEGER_IMAGE:-...}` below
      # is resolved by that shell: export the variable to collect somewhere
      # else.
      devConfig =
        system:
        let
          pkgs = pkgsFor system;
        in
        pkgs.writeText "frogue-dev-process-compose.json" (
          builtins.toJSON {
            version = "0.5";

            # Reject unknown keys: a typo in here should fail loudly rather than
            # leave a process silently misconfigured.
            is_strict = true;

            processes = {
              jaeger = {
                command = "podman run --rm --replace --name frogue-dev-jaeger --publish 127.0.0.1:4318:4318 --publish 127.0.0.1:16686:16686 \${FROGUE_JAEGER_IMAGE:-docker.io/jaegertracing/jaeger:2.21.0}";

                # Jaeger 2 dropped the v1 query routes: /api/services answers 404
                # there, and probing it would call a healthy collector dead.
                readiness_probe = {
                  http_get = {
                    host = "127.0.0.1";
                    port = 16686;
                    path = "/api/v3/services";
                    scheme = "http";
                  };
                  initial_delay_seconds = 1;
                  period_seconds = 1;
                  timeout_seconds = 2;
                  failure_threshold = 60;
                };

                availability.restart = "on_failure";
              };

              frogue = {
                # Only the sources and the manifests are watched. The pages are
                # Rust code under `frogue/src` now, so editing one is a source
                # change and has to rebuild, not just reload.
                command = "cargo watch --watch frogue/src --watch frogue/static --watch frogue/Cargo.toml --watch Cargo.toml --exec 'run -p frogue'";

                # Held back until the collector answers, so that the spans of the
                # first requests are not fired into the void.
                depends_on.jaeger.condition = "process_healthy";

                # The console keeps its defaults and RUST_LOG is left alone, so
                # the development environment still obeys the caller's shell.
                # DATABASE_URL comes from the .env process-compose loads by
                # itself.
                environment = [
                  "OTEL_TRACES_EXPORTER=otlp"
                  "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318"
                  "OTEL_SERVICE_NAME=frogue"
                  "OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=develop"
                  "FROGUE_SESSION_SECRET=changemechangemechangemechangemechangemechangemechangemechangeme"
                  "FROGUE_PUBLIC_URL=http://localhost:6565/"
                  "RUST_LOG=info,sqlx=warn,frogue=debug"
                ];
              };
            };
          }
        );

      mkDevApp =
        system:
        let
          pkgs = pkgsFor system;
        in
        pkgs.writeShellApplication {
          name = "frogue-dev";
          runtimeInputs = devTools system;
          text = ''
            # Run from wherever the caller is, but always inside the checkout:
            # the watcher needs a writable working copy, and process-compose
            # picks up .env from its working directory.
            root="''${FROGUE_DEV_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
            cd "$root"

            if [ ! -f frogue/Cargo.toml ]; then
              echo "frogue-dev: $root is not a frogue checkout (no frogue/Cargo.toml)" >&2
              exit 1
            fi

            if ! command -v podman >/dev/null; then
              echo "frogue-dev: podman is needed to run the jaeger container" >&2
              exit 1
            fi

            printf '%s\n' \
              'Jaeger UI: http://localhost:16686' \
              'Frogue:    http://localhost:6565, traced as service "frogue"' \
              'Reload:    editing frogue/src rebuilds and restarts the server' \
              'Quit:      Ctrl-C' >&2

            # --ordered-shutdown stops the server before the collector it
            # exports to.
            #
            # --use-uds keeps process-compose's own API off TCP. Its default is
            # port 8080, which is contended often enough that starting the dev
            # environment would fail outright on a machine where something else
            # already has it -- which is not the user's problem to solve. The
            # socket it uses instead is named after the process, so two
            # checkouts running side by side do not collide either.
            #
            # The remaining arguments are forwarded, so `nix run .#dev --
            # --tui=false` is available for a line-oriented run.
            exec process-compose --config ${devConfig system} --ordered-shutdown --use-uds "$@"
          '';
        };

      mkDevTestApp =
        system:
        let
          pkgs = pkgsFor system;
        in
        pkgs.writeShellApplication {
          name = "frogue-dev-test";
          runtimeInputs = (devTools system) ++ [
            # curl and jq drive the two APIs the test asserts on.
            pkgs.curl
            pkgs.jq
            # The test starts the stack through the real entry point rather
            # than assembling a process-compose command of its own, so it also
            # covers the app's arguments.
            (mkDevApp system)
          ];
          text = builtins.readFile ./dev/dev-test.sh;
        };

      mkPublishApp =
        system:
        let
          pkgs = pkgsFor system;
          container = mkContainer system;
        in
        pkgs.writeShellApplication {
          name = "frogue-publish";
          runtimeInputs = [ pkgs.skopeo ];
          text = ''
            # Push the versioned image to the registry
            skopeo copy "docker-archive:${container}" "docker://oci.vit.am/frogue:${containerTag}"
            # Retag as latest
            skopeo copy "docker://oci.vit.am/frogue:${containerTag}" "docker://oci.vit.am/frogue:latest"
          '';
        };
    in
    {
      packages = lib.genAttrs systems (system: rec {
        default = mkFrogue system;
        frogue = default;
        container = mkContainer system;
      });

      checks = lib.genAttrs systems (system: {
        inherit (self.packages.${system}) default;

        # The dev environment's topology is generated, so nothing else would
        # notice a typo in it until someone tried to run it. process-compose
        # validates the config without starting anything, and needs no
        # container runtime to do so.
        dev-config =
          let
            pkgs = pkgsFor system;
          in
          pkgs.runCommand "frogue-dev-config-check"
            {
              nativeBuildInputs = [ pkgs.process-compose ];
            }
            ''
              export HOME="$TMPDIR"
              process-compose --dry-run --config ${devConfig system} \
                --disable-dotenv --tui=false --no-server
              touch "$out"
            '';
      });

      apps = lib.genAttrs systems (system: {
        dev = {
          type = "app";
          program = "${mkDevApp system}/bin/frogue-dev";
          meta.description = "Start the development stack: Jaeger, then the server under a watcher";
        };

        dev-test = {
          type = "app";
          program = "${mkDevTestApp system}/bin/frogue-dev-test";
          meta.description = "Check that the development stack sends its traces to Jaeger and restarts on change";
        };

        publish = {
          type = "app";
          program = "${mkPublishApp system}/bin/frogue-publish";
          meta.description = "Push the container image to oci.vit.am/frogue and retag as latest";
        };
      });

      devShells = lib.genAttrs systems (system: {
        default = (pkgsFor system).mkShell {
          # Same tools as the packaged apps, plus the shell-only extras and the
          # two entry points themselves, so `frogue-dev` and `frogue-dev-test`
          # can be run straight from the shell.
          packages =
            (devTools system)
            ++ (devShellExtras system)
            ++ [
              (mkDevApp system)
              (mkDevTestApp system)
            ];

          env.RUST_BACKTRACE = "1";
        };
      });

      formatter = lib.genAttrs systems (system: (pkgsFor system).nixfmt);
    };
}