{
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);
};
}