diff --git a/configuration.nix b/configuration.nix index 83f84f5..ae0f600 100644 --- a/configuration.nix +++ b/configuration.nix @@ -4,12 +4,17 @@ lib, hostInfo, customPkgs, + flakeRev, ... }: let inherit (hostInfo) paths; in { + imports = [ ./modules/autodeploy.nix ]; + + services.server-autodeploy.enable = true; + assertions = let isMounted = @@ -47,6 +52,11 @@ in time.timeZone = "UTC"; system.stateVersion = "26.05"; + system.nixos.label = + let + labelFile = ./.nixos-label; + in + if builtins.pathExists labelFile then lib.removeSuffix "\n" (builtins.readFile labelFile) else flakeRev; nix = { settings = { diff --git a/flake.nix b/flake.nix index 4eff366..f3b804d 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,7 @@ outputs = { + self, nixpkgs, mtce, microvm, @@ -21,6 +22,8 @@ inherit (nixpkgs) lib; pkgs = import nixpkgs { inherit system; }; + flakeRev = self.shortRev or self.dirtyShortRev or "unknown"; + customPkgs = { hopper-rs = pkgs.callPackage ./packages/hopper-rs.nix { }; sshpiper = pkgs.callPackage ./packages/sshpiper.nix { }; @@ -29,6 +32,7 @@ server-rebuild = pkgs.callPackage ./packages/server-rebuild.nix { }; server-status = pkgs.callPackage ./packages/server-status.nix { }; server-kexec = pkgs.callPackage ./packages/server-kexec.nix { }; + server-autodeploy = pkgs.callPackage ./packages/server-autodeploy.nix { }; }; ci = import ./ci.nix { inherit lib pkgs; }; @@ -51,7 +55,7 @@ specialArgsFor = hostName: { hostInfo = hostInfoFor hostName; - inherit inputs customPkgs; + inherit inputs customPkgs flakeRev; }; hostDefFor = @@ -147,6 +151,7 @@ server-rebuild server-status server-kexec + server-autodeploy ; }; }; diff --git a/modules/autodeploy.nix b/modules/autodeploy.nix new file mode 100644 index 0000000..fb9e439 --- /dev/null +++ b/modules/autodeploy.nix @@ -0,0 +1,89 @@ +{ + config, + lib, + pkgs, + hostInfo, + customPkgs, + ... +}: +let + cfg = config.services.server-autodeploy; +in +{ + options.services.server-autodeploy = { + enable = lib.mkEnableOption "automatic deployment from signed git tags"; + + interval = lib.mkOption { + type = lib.types.str; + default = "15min"; + }; + + branch = lib.mkOption { + type = lib.types.str; + default = "master"; + }; + + giteaUrl = lib.mkOption { + type = lib.types.str; + default = "https://git.floweytf.com"; + }; + + giteaRepo = lib.mkOption { + type = lib.types.str; + default = "server/server-config"; + }; + + tokenFile = lib.mkOption { + type = lib.types.str; + default = "${hostInfo.paths.secrets}/gitea-autodeploy-token"; + }; + + allowedSignersFile = lib.mkOption { + type = lib.types.str; + default = "${hostInfo.paths.secrets}/autodeploy-allowed-signers"; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/persist/@autodeploy"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.server-autodeploy = { + description = "Deploy latest signed tag"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = { + GITEA_URL = cfg.giteaUrl; + GITEA_REPO = cfg.giteaRepo; + TOKEN_FILE = cfg.tokenFile; + ALLOWED_SIGNERS = cfg.allowedSignersFile; + DEPLOY_BRANCH = cfg.branch; + STATE_DIR = cfg.stateDir; + REPO_DIR = "/persist/@configuration"; + }; + + serviceConfig = { + Type = "oneshot"; + ExecStart = lib.getExe customPkgs.server-autodeploy; + TimeoutStartSec = "30min"; + }; + + path = [ + config.nix.package + pkgs.nixos-rebuild + ]; + }; + + systemd.timers.server-autodeploy = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = cfg.interval; + RandomizedDelaySec = "2min"; + }; + }; + }; +} diff --git a/packages/server-autodeploy.nix b/packages/server-autodeploy.nix new file mode 100644 index 0000000..9023c97 --- /dev/null +++ b/packages/server-autodeploy.nix @@ -0,0 +1,14 @@ +{ + writeShellApplication, + git, + curl, + jq, +}: +import ./mk-script.nix { inherit writeShellApplication; } { + name = "server-autodeploy"; + runtimeInputs = [ + git + curl + jq + ]; +} diff --git a/scripts/server-autodeploy.sh b/scripts/server-autodeploy.sh new file mode 100644 index 0000000..63ca5a6 --- /dev/null +++ b/scripts/server-autodeploy.sh @@ -0,0 +1,58 @@ +REPO_DIR="${REPO_DIR:-/persist/@configuration}" +STATE_DIR="${STATE_DIR:-/persist/@autodeploy}" +BRANCH="origin/${DEPLOY_BRANCH:-master}" + +mkdir -p "$STATE_DIR" +exec 9>"$STATE_DIR/lock" +flock -n 9 || { echo "another deploy running"; exit 0; } + +report_status() { + [[ -f "$TOKEN_FILE" ]] || return 0 + curl -sf -X POST \ + -H "Authorization: token $(<"$TOKEN_FILE")" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg s "$1" --arg d "$2" --arg c "deploy/$(hostname)" \ + '{state:$s,description:$d,context:$c}')" \ + "$GITEA_URL/api/v1/repos/$GITEA_REPO/statuses/$3" >/dev/null +} + +cd "$REPO_DIR" + +export GIT_CONFIG_COUNT=2 +export GIT_CONFIG_KEY_0=gpg.format +export GIT_CONFIG_VALUE_0=ssh +export GIT_CONFIG_KEY_1=gpg.ssh.allowedSignersFile +export GIT_CONFIG_VALUE_1="$ALLOWED_SIGNERS" + +git fetch origin --tags --prune-tags + +latest=$(git describe --abbrev=0 "$BRANCH" 2>/dev/null) || { + echo "no tags on $BRANCH" + exit 0 +} + +current=$(cat "$STATE_DIR/tag" 2>/dev/null) || true +[[ "$latest" != "$current" ]] || { echo "already at $latest"; exit 0; } + +echo "new tag: $latest (current: ${current:-none})" + +git tag -v "$latest" || { + sha=$(git rev-list -1 "$latest") + report_status "failure" "tag signature verification failed" "$sha" + exit 1 +} + +sha=$(git rev-list -1 "$latest") +report_status "pending" "deploying $latest" "$sha" + +# build from remote, pinned to the verified rev - no local checkout needed +flake_url="git+${GITEA_URL}/${GITEA_REPO}?ref=refs/tags/${latest}&rev=${sha}#$(hostname)" + +if nixos-rebuild switch --flake "$flake_url"; then + echo "$latest" > "$STATE_DIR/tag" + report_status "success" "deployed $latest" "$sha" + echo "deployed $latest" +else + report_status "failure" "nixos-rebuild failed" "$sha" + exit 1 +fi