Compare commits

..
44 Commits
Author SHA1 Message Date
yukkop 5b0ae6be5e feat: kanban cli
runner nix smoke / nix label and flake smoke (push) Failing after 1m20s
2026-09-28 09:25:04 +00:00
yukkop 54eb69d1c7 feat: clear unneccessary backend from kanban
runner nix smoke / nix label and flake smoke (push) Failing after 35s
2026-09-28 00:24:44 +00:00
yukkop 0cbf267474 feat: more crud for kanban
runner nix smoke / nix label and flake smoke (push) Failing after 2m6s
2026-09-28 00:08:44 +00:00
yukkop 335a74010d feat: kanban tui
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 15:42:51 +00:00
yukkop 4c9611a2d2 some
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 08:25:06 +00:00
yukkop 5681519b6f fix: global update
runner nix smoke / nix label and flake smoke (push) Failing after 30s
2026-09-26 22:01:43 +00:00
yukkop fd59158ed6 feat: update nixpkgs
runner nix smoke / nix label and flake smoke (push) Failing after 40s
2026-09-26 21:44:19 +00:00
yukkop d9b2a4e787 feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s
2026-09-26 21:18:24 +00:00
yukkop c439c1b948 fix: ssh extra debug
runner nix smoke / nix label and flake smoke (push) Failing after 57s
2026-09-26 19:11:41 +00:00
yukkop 0fe85c67d9 ci: staging server
runner nix smoke / nix label and flake smoke (push) Failing after 50s
2026-09-26 13:59:59 +00:00
yukkop 09eecf5969 feat: lfs
runner nix smoke / nix label and flake smoke (push) Failing after 1m59s
2026-09-24 23:15:43 +00:00
yukkop e444ea5936 fix: world-of-sosal
runner nix smoke / nix label and flake smoke (push) Failing after 59s
2026-09-23 19:26:19 +00:00
yukkop c917e4908c feat: zomboid backups
runner nix smoke / nix label and flake smoke (push) Failing after 54s
2026-09-22 17:15:18 +00:00
yukkop ef849b085f fix: wow-minecraft: finish configurations
runner nix smoke / nix label and flake smoke (push) Failing after 56s
2026-09-19 09:04:05 +00:00
yukkop a09f247290 fix: wow-minecraft: mirror on bfs.band
runner nix smoke / nix label and flake smoke (push) Failing after 22s
2026-09-19 08:27:33 +00:00
yukkop 3a52023082 fix: configure minecraft 2026-09-19 08:13:49 +00:00
yukkop 2bd466b652 feat: configure minecraft and zomboid
runner nix smoke / nix label and flake smoke (push) Failing after 1m13s
2026-09-19 07:37:19 +00:00
yukkop 83cf9ff32f .
runner nix smoke / nix label and flake smoke (push) Failing after 55s
2026-09-17 08:37:12 +00:00
yukkop a9e538fc76 feat: prism launcher link 2026-09-16 12:48:15 +00:00
yukkop 88ec8a59d8 feat: site lessons
runner nix smoke / nix label and flake smoke (push) Failing after 17s
2026-09-15 19:48:13 +00:00
yukkopandSisyphus 417820544e fix: raise runner monthly budget
runner nix smoke / nix label and flake smoke (push) Failing after 27s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-15 00:58:07 +00:00
yukkop 1cba36e76e feat: neuro: minecraft mods
runner nix smoke / nix label and flake smoke (push) Has been cancelled
2026-09-14 12:22:10 +00:00
yukkop 4f97fb3244 feat: neuro: new minecraft
runner nix smoke / nix label and flake smoke (push) Failing after 22s
2026-09-13 22:36:11 +00:00
yukkop 252d3418a5 fix: immitch 502
runner nix smoke / nix label and flake smoke (push) Failing after 1m15s
2026-09-13 21:49:57 +00:00
yukkop 96882fd8cd fix: immitch 502
runner nix smoke / nix label and flake smoke (push) Failing after 50s
2026-09-13 19:11:59 +00:00
yukkop 522010b7e6 fix: minecraft
runner nix smoke / nix label and flake smoke (push) Failing after 54s
2026-09-13 18:04:18 +00:00
yukkop 761120b211 fix: anitcheat in project zomboid
runner nix smoke / nix label and flake smoke (push) Failing after 1m21s
2026-09-13 12:58:01 +00:00
yukkop a04a58b792 fix: immich; feat: zomboid settings 2026-09-13 09:38:32 +00:00
yukkop 1e973e7b33 feat: configure immich
runner nix smoke / nix label and flake smoke (push) Failing after 25s
2026-09-12 22:21:33 +00:00
yukkop 66a502b1dd fix: project zomboid module
runner nix smoke / nix label and flake smoke (push) Failing after 1m24s
2026-09-12 21:56:31 +00:00
yukkopandSisyphus fce9ae07be fix: configure Dify Redis connection
runner nix smoke / nix label and flake smoke (push) Failing after 1m18s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 19:59:21 +00:00
yukkop a8c4a52a68 feat: olama fix
runner nix smoke / nix label and flake smoke (push) Failing after 1m22s
2026-09-12 18:57:21 +00:00
yukkop 078e01c87f feat: some
runner nix smoke / nix label and flake smoke (push) Failing after 55s
2026-09-11 20:20:57 +00:00
yukkop 6bc564de59 feat: some 2026-09-11 19:31:04 +00:00
yukkop cc8a7cf80e fix: reconcile healthy terminal runners
runner nix smoke / nix label and flake smoke (push) Failing after 28s
2026-09-11 09:19:04 +00:00
yukkop 279df769db fix: retain healthy terminal runners 2026-09-11 09:19:03 +00:00
yukkop 069b18daa3 fix: check runner health before reuse 2026-09-11 09:19:03 +00:00
yukkop 37bd69e90e fix: avoid Cargo metadata IFD 2026-09-11 09:19:03 +00:00
yukkopandSisyphus 572133a941 fix: avoid p4d init IFD
runner nix smoke / nix label and flake smoke (push) Failing after 59s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 08:36:59 +00:00
yukkopandSisyphus ed721dd961 fix: avoid smtp source IFD
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 08:36:46 +00:00
yukkop 4a88e7c6e8 fix: runners
runner nix smoke / nix label and flake smoke (push) Failing after 1m1s
2026-09-11 05:58:04 +00:00
yukkop f0317efb6e fix: runners not runs
runner nix smoke / nix label and flake smoke (push) Failing after 41s
2026-09-11 02:22:07 +00:00
yukkop cb62bd3044 feat: maintain server until hour
runner ubuntu smoke / ubuntu-latest label smoke (push) Has been cancelled
runner nix smoke / nix label and flake smoke (push) Has been cancelled
2026-09-11 02:05:39 +00:00
yukkop ee8a33c3b0 feat: move all workers to zero-idle
runner ubuntu smoke / ubuntu-latest label smoke (push) Successful in 0s
runner nix smoke / nix label and flake smoke (push) Failing after 7s
2026-09-10 22:30:58 +00:00
3624 changed files with 126440 additions and 44834 deletions
+2 -2
View File
@@ -98,13 +98,13 @@ jobs:
true) true)
nix run --refresh '.#with-attic-cache' -- -- \ nix run --refresh '.#with-attic-cache' -- -- \
nix run --refresh '.#deploy' -- \ nix run --refresh '.#deploy' -- \
push -- --flake '.#neuro|x86_64-linux' --target-host neuro --use-remote-sudo push -- --flake '.#neuro|x86_64-linux' --target-host neuro
;; ;;
false) false)
unset ATTIC_TOKEN unset ATTIC_TOKEN
timeout --kill-after=60s 21600s \ timeout --kill-after=60s 21600s \
nix run --refresh '.#deploy' -- \ nix run --refresh '.#deploy' -- \
push -- --flake '.#neuro|x86_64-linux' --target-host neuro --use-remote-sudo push -- --flake '.#neuro|x86_64-linux' --target-host neuro
;; ;;
*) *)
printf 'unsupported upload_cache value: %s\n' "$UPLOAD_CACHE" >&2 printf 'unsupported upload_cache value: %s\n' "$UPLOAD_CACHE" >&2
+8 -1
View File
@@ -6,12 +6,19 @@ keys:
- &bfs-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &bfs-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &bfs-pol-server age1fpytf05sg9n6ywpwkmn09lhpfvgtud9h75h76jhxha475zpnasqq952rpu - &bfs-pol-server age1fpytf05sg9n6ywpwkmn09lhpfvgtud9h75h76jhxha475zpnasqq952rpu
- &bfs-new-server age17yx98qk9gzgcf2q6zhhp05p6mmtrkgz66dvyk9gqclypvlr8rersxjy5v7 - &bfs-new-server age17yx98qk9gzgcf2q6zhhp05p6mmtrkgz66dvyk9gqclypvlr8rersxjy5v7
- &neuro-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &neuro-server age1ak7heljpr0pjr4m0rcwxgn3sp0jjxw03lxyf33r8lcemqh2u2sgqx0aplq
- &games-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &games-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &hectic-lab-server age13h8twnwvgxn04l5ywtru89a6psw5d0uckr2eghxsjp88a5augvsstq5ard - &hectic-lab-server age13h8twnwvgxn04l5ywtru89a6psw5d0uckr2eghxsjp88a5augvsstq5ard
- &umbriel-bfs age1jxntjca8q2vxvf2jaal4xyvm2ae6sh62fhv897694kuzawfrk5asj00zdt - &umbriel-bfs age1jxntjca8q2vxvf2jaal4xyvm2ae6sh62fhv897694kuzawfrk5asj00zdt
creation_rules: creation_rules:
- path_regex: sus/neuro-minecraft.yaml$
key_groups:
- age:
- age1r25zdeqq8nac6dgca9en28r57ffyz9u9d8z5yc25gc8xqz747vaqmdtk0h
- age1ak7heljpr0pjr4m0rcwxgn3sp0jjxw03lxyf33r8lcemqh2u2sgqx0aplq
- age1ev53mzse6rg4ffwtcwtq4e93c7x7s4d0eyu89jrsahrke8r4yamsseu8h4
- path_regex: sus/home.xray.yaml$ - path_regex: sus/home.xray.yaml$
key_groups: key_groups:
- age: - age:
+1
View File
@@ -5,6 +5,7 @@
postgres-c = import ./postgres-c.nix { inherit self system pkgs; }; postgres-c = import ./postgres-c.nix { inherit self system pkgs; };
pure-c = import ./pure-c.nix { inherit self system pkgs; }; pure-c = import ./pure-c.nix { inherit self system pkgs; };
rust = import ./rust.nix { inherit self system pkgs; }; rust = import ./rust.nix { inherit self system pkgs; };
ratatui = import ./ratatui.nix { inherit self system pkgs; };
haskell = import ./haskell.nix { inherit self system pkgs; }; haskell = import ./haskell.nix { inherit self system pkgs; };
neuro = import ./neuro.nix { inherit self system pkgs; }; neuro = import ./neuro.nix { inherit self system pkgs; };
xmpp = import ./xmpp.nix { inherit self system pkgs; }; xmpp = import ./xmpp.nix { inherit self system pkgs; };
+4 -7
View File
@@ -224,18 +224,15 @@ Environment expected before real deploy/apply:
S3 backend credentials and endpoint access S3 backend credentials and endpoint access
a matching SOPS age identity for sus/gitea-runners.yaml a matching SOPS age identity for sus/gitea-runners.yaml
kubectl access to the target cluster kubectl access to the target cluster
a concrete registry digest for the pushed Nix-capable runner image if enabling a valid Hetzner Nix image ID in the controller host configuration
the nix label
OpenTofu validation gate: OpenTofu validation gate:
tofu version tofu version
tofu -chdir=infra/gitea-runners/opentofu validate tofu -chdir=infra/gitea-runners/opentofu validate
Nix image build/publish/digest gate: Nix image build/publish gate:
nix build .#gitea-runner-nix-image nix build .#gitea-runner-nix-image
publish the archive, then pin the registry-reported digest in the runner label publish/import image, then set nixImageId / GCR_NIX_IMAGE_ID to its Hetzner ID
mapping
nix:docker://gitea.hectic-lab.com/hectic-lab/gitea-runner-nix-image@sha256:<registry-digest>
SOPS token Secret creation gate: SOPS token Secret creation gate:
kubectl apply -f infra/gitea-runners/k8s/namespace.yaml kubectl apply -f infra/gitea-runners/k8s/namespace.yaml
@@ -272,7 +269,7 @@ Verification commands:
Main blockers and gates: Main blockers and gates:
do not run tofu apply without all external inputs do not run tofu apply without all external inputs
do not apply the k8s overlay until the gitea-runner-token Secret exists do not apply the k8s overlay until the gitea-runner-token Secret exists
do not enable the nix label until the image has been published with a concrete digest do not dispatch nix jobs until nixImageId / GCR_NIX_IMAGE_ID is valid
do not print, load, or require secrets on shell entry do not print, load, or require secrets on shell entry
EOF EOF
''; '';
+19
View File
@@ -0,0 +1,19 @@
{
self,
pkgs,
system
}: let
rustToolchain =
if builtins.pathExists ./rust-toolchain.toml
then pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml
else pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default;
in
self.devShells.${system}.default
// (pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
pkgs.gcc
pkgs.gnumake
];
})
+11 -5
View File
@@ -1,13 +1,15 @@
# Documentation # Documentation
- [Using the `hectic` Attic Cache](./attic-cache.md) - [Using the `hectic` Attic Cache](./attic-cache.md)
- [Minecraft incident log](./minecraft-incidents.md)
- [Project Zomboid backups](./project-zomboid-backups.md)
## Gitea runner labels ## Gitea runner labels
Common labels for zero-idle runners: Common labels for controller-managed on-demand runners:
- `ubuntu-latest` — persistent Ubuntu 24.04 `cx23` worker - `ubuntu-latest` — on-demand alias for `gross-x86`
- `nix` — persistent Nix-capable Ubuntu `cx23` worker - `nix` — on-demand Nix alias with 480-minute TTL
- `gross-x86` — x86 fallback chain `cx53` / `cx43` / `cx33` - `gross-x86` — x86 fallback chain `cx53` / `cx43` / `cx33`
- `gross-arm` — ARM fallback chain `cax41` / `cax31` / `cax21` - `gross-arm` — ARM fallback chain `cax41` / `cax31` / `cax21`
- `gross-x86-perf` — x86 performance chain `cx53` / `cpx62` / `cpx52` - `gross-x86-perf` — x86 performance chain `cx53` / `cpx62` / `cpx52`
@@ -15,12 +17,16 @@ Common labels for zero-idle runners:
- `gross-nix-x86` — `gross-x86` + Nix bootstrap - `gross-nix-x86` — `gross-x86` + Nix bootstrap
- `gross-nix-arm` — `gross-arm` + Nix bootstrap - `gross-nix-arm` — `gross-arm` + Nix bootstrap
- `gross-nix-x86-perf` — `gross-x86-perf` + Nix bootstrap - `gross-nix-x86-perf` — `gross-x86-perf` + Nix bootstrap
- `gross-nix-x86-highmem` — CCX53-only Nix runner, 480-minute TTL
- `gross-nix-mixed-econ` — `gross-mixed-econ` + Nix bootstrap - `gross-nix-mixed-econ` — `gross-mixed-econ` + Nix bootstrap
Region order for fallback: `nbg1`, then `fsn1`, then `hel1`. Region order for fallback: `nbg1`, then `fsn1`, then `hel1`.
`nix` and `ubuntu-latest` are persistent workers; only `gross-*` labels use The legacy Kubernetes persistent pool is disabled (`replicas: 0`) and has no
zero-idle ephemeral VMs. registered labels. All listed labels are handled by the VM controller. After a
successful job, a bootstrapped VM remains running and idle until its next hourly
lifetime boundary, capped by label TTL. A queued job from same repository with
same label reuses it without another VM creation or budget reservation.
Operational details: `infra/gitea-runners/runbook.md` and Operational details: `infra/gitea-runners/runbook.md` and
`package/gitea-runner-controller/decide.sh`. `package/gitea-runner-controller/decide.sh`.
+3 -3
View File
@@ -201,9 +201,9 @@ build/deploy command, batches of at most 8 paths, and 600 seconds per upload att
upload deadline covers the **whole batch**, not each individual path. Its final upload deadline covers the **whole batch**, not each individual path. Its final
drain is bounded at 1 hour; the 435-minute job budget leaves 15 minutes for setup drain is bounded at 1 hour; the 435-minute job budget leaves 15 minutes for setup
and cleanup. The `gross-nix-x86-perf` runner limit and Gitea's endless-task and cleanup. The `gross-nix-x86-perf` runner limit and Gitea's endless-task
watchdog are 8 hours. The VM lifetime starts at allocation and includes the watchdog are 8 hours. VM hard lifetime starts at allocation and has no controller
controller's additional 10-minute grace. A prolonged cache outage can still destruction grace. A prolonged cache outage can still exhaust the drain before
exhaust the drain before every queued path is uploaded. every queued path is uploaded.
The build timeout covers the entire wrapped command, not each derivation. The build timeout covers the entire wrapped command, not each derivation.
Completed outputs can be reused from the cache, but an interrupted CUDA/Magma Completed outputs can be reused from the cache, but an interrupted CUDA/Magma
+101
View File
@@ -0,0 +1,101 @@
# Minecraft incident log
This file records only observed evidence, actions, and verification results.
An entity appearing in a stack trace is a trigger-path observation, not a
proven root cause.
## 2026-09-19 — WorldOfSosal crashes in Sable block-change handling
### Impact
- `minecraft-server-wowMineMap.service` terminates while a player is online.
- Public Minecraft endpoint is `store.hectic-lab.com:25568`.
- Server is intentionally stopped after the latest crash to prevent repeated
crash-save cycles while recovery is investigated.
### Observed evidence
All crash reports contain `sable@2.0.5` in
`LevelAccelerator.getBlockState`, followed by
`ArrayIndexOutOfBoundsException` where the requested section index exceeds
the world section array length of `24`.
| UTC timestamp | Crash report | Observed trigger path | Exception |
| --- | --- | --- | --- |
| 18:47:03 | `crash-2026-09-19_18.47.03-server.txt` | `EnderMan$EndermanTakeBlockGoal.tick` | index `38` / length `24` |
| 18:52:17 | `crash-2026-09-19_18.52.17-server.txt` | `GlowSquid.aiStep` → `RedStoneOreBlock.stepOn` | index `33` / length `24` |
| 19:14:46 | `crash-2026-09-19_19.14.46-server.txt` | `Skeleton.tick` → `RedStoneOreBlock.stepOn` | index `34` / length `24` |
Evidence locations on `neuro`:
```text
/srv/minecraft/wowMineMap/crash-reports/
/srv/minecraft/wowMineMap/logs/latest.log
```
### Actions performed
| UTC timestamp | Action | Result |
| --- | --- | --- |
| 17:51 | Archived current world before recovery | Archive checksum recorded |
| 18:08 | Set `randomTickSpeed=0` | Server started, but later crashed from an entity block change |
| 18:48 | Set `mobGriefing=false` | Prevented Enderman block pickup only; later crashes still occurred |
| 18:54 | Archived post-crash world | Archive checksum recorded |
| 19:00 | Moved Boss offline player NBT from `(3299.067, 142.630, 8613.742)` to `(3296, 500, 8608)` in `crafting_azeroth:azeroth` | Only `Pos` and `Dimension` changed; later crash still occurred |
| after 19:14 crash | Stopped `minecraft-server-wowMineMap.service` | Prevented further automatic crash/restart saves |
### Recovery artifacts
```text
/srv/minecraft/backups/wowMineMap-before-sable-recovery-20260919T175139Z.tar.zst
/srv/minecraft/backups/wowMineMap-after-sable-crashes-20260919T185445Z.tar.zst
/srv/minecraft/wowMineMap/world/playerdata/1c189af5-2713-3fa6-bcc4-893dfadedfa4.dat.before-relocation
```
### Conclusions supported by evidence
- Public proxy and reverse tunnel are not the failure point: server-list ping
succeeded before later in-world crashes.
- The failure is not limited to Endermen, random ticks, or one player
position.
- Sable's block-change callback is present in every captured crash.
### Not established
- Exact corrupt chunk, block, or mod data.
- Whether world data is corrupt, Sable itself is defective, or another mod is
supplying incompatible world state.
- Whether deleting any chunk, region, or Sable state would be safe.
### External research
No exact upstream match was found for Sable `2.0.5` on NeoForge `1.21.1` with
`LevelAccelerator.getBlockState` and a requested section index of `33`, `34`,
or `38` against a section array of length `24`.
Related but non-identical upstream reports:
- [Sable #776](https://github.com/ryanhcode/sable/issues/776) documents an
`ArrayIndexOutOfBoundsException` associated with unusual dimension height
bounds. This is relevant to section-coordinate handling, but is an older
version and different stack trace.
- [Sable #1087](https://github.com/ryanhcode/sable/issues/1087) documents a
`LevelAccelerator.getBlockState` recursion during block-shape processing.
The failure type differs.
- [Sable #820](https://github.com/ryanhcode/sable/issues/820) documents a
ticking-entity block-change crash. The reported downgrade to `1.1.3` helped
that distinct recursive-update failure; it is not evidence for this crash.
- [Sable #1223](https://github.com/ryanhcode/sable/issues/1223) documents a
different `ArrayIndexOutOfBoundsException` in voxel-neighborhood handling.
Its suggested Lithium setting only reduced crashes for some reporters and is
not a verified mitigation here.
Sable `2.0.4` and `2.0.5` release notes mention other block or contraption
crash fixes, but not this exception. No version upgrade or downgrade is
currently evidence-backed as a production fix.
### Next recovery step
Use a disposable full-world copy to test a supported Sable/physics integration
mitigation. Do not restart production, delete region files, or overwrite a
backup until that test gives reproducible evidence.
+231
View File
@@ -0,0 +1,231 @@
# WorldOfSosal: Prism automatic updates
The published client entry points are:
- https://store.bfs.band/minecraft/ (BFS / Element host)
- https://store.hectic-lab.com/minecraft/world-of-sosal/ (hectic-lab)
Each site provides its own Prism ZIP with that site's update URL and matching
server address. Both installs use the same Minecraft world and modpack release.
Players import `WorldOfSosal-Prism.zip` into Prism once and approve its pre-launch
command. Before each launch, packwiz-installer reconciles the client with the
published pack: it adds, replaces, and removes managed files, checking hashes.
`options.txt` is seeded once and preserved. Pack configuration files are managed
and can be replaced. Upstream mods do not update independently of your release.
Minecraft 1.21.1, NeoForge 21.1.250, Java 21; the instance reserves up to 8 GiB.
The original `.mrpack` alone does not provide this automatic update mechanism.
Official workflow: https://packwiz.infra.link/tutorials/installing/packwiz-installer/
## Publishing a tested update
Keep the authoritative `.mrpack` in Storage Box at
`minecraft/pack/WorldOfSosal.mrpack`. For a server update, replace that archive,
set its new SHA-256 in `nixos/system/neuro/minecraft/world-of-sosal.nix`,
and rebuild/switch neuro before publishing the corresponding client export. The server importer and the
client export must consume the same archive; publishing only the client can make
it incompatible with the running server.
```sh
# Test the client and deploy the matching server release first.
python3 script/publish-prism-mirrors.py WorldOfSosal.mrpack
```
The mirror publisher creates temporary build directories and sets each server
address and update URL automatically. The builder downloads a SHA-256-pinned bootstrap from the
packwiz project's release, or accepts it via `--bootstrap /path/to/file.jar`.
External mods retain their original URLs and SHA-512 checksums. Embedded mods and
configuration are hosted with the release. Both required and optional client mods
are included, matching the current server importer's optional-mod behavior.
Publishing uploads an immutable directory, checks it if it already exists, and
atomically switches `current`. Previous directories remain available for rollback.
Do not remove a release while clients may still be reading it. Hash checks cause
an overlapping update to fail safely rather than silently accept mixed contents;
retry the launch if a publication overlapped a download.
The files live under `/var/www/store/minecraft/world-of-sosal` on `hectic-lab`,
served by the existing `store.hectic-lab.com` nginx virtual host. No nginx reload
is needed for pack updates. Keep `current/pack.toml` as the stable client URL.
The index must be alongside pack.toml: putting a release prefix in `[index].file`
also prefixes client installation paths with that directory in packwiz-installer.
If Minecraft/NeoForge versions change, update and test both the server pin and
client pack. packwiz-installer 0.5.14 understands NeoForge components in Prism's
`mmc-pack.json`; a launcher restart/relaunch may be necessary after changing them.
## Verification on 2026-09-18
- Source archive SHA-256:
`f8c18acb9208e4592725632ae50dab4f9c308483b34fd43a6507c74fdbf8169f`.
- Public HTTPS installation into a clean Prism-format instance passed: all 141
client mods and all overrides match the original archive. A second launch
performed no downloads and preserved personal options.
- Direct probes of neuro public ports 25565, 25567, and 25568 timed out;
the configured relay now provides the public entry point.
- Live WoW server reached `Done` with all 135 server mod SHA-512 hashes
matching the same archive used for the Prism client.
- Public `store.hectic-lab.com:25568` status/ping succeeded (about 111 ms);
a login handshake reached the online authentication encryption request.
An authenticated Windows Prism session was subsequently verified on 2026-09-19 (see below).
- Server and tunnel are enabled at boot; relay and both NixOS configurations
are deployed. No failed systemd units remain on neuro.
- Loader package `neoforge-1.21.1-21.1.250` built successfully in Nix.
- Automatic updater add/remove/config-update and options-preservation behavior
tested with an actual packwiz-installer run against a controlled update fixture.
## WoW server and public entry point
The WoW map and WorldOfSosal mods share the `wowMineMap` server on neuro,
listening on 25567. There is no separate WorldOfSosal world/server on 25568.
The client pack and server both pin Minecraft 1.21.1 / NeoForge 21.1.250.
Map import runs before mod import, and both finish before Minecraft starts.
The public entry point is `store.hectic-lab.com:25568`:
```
Prism -> hectic-lab:25568 -> loopback:25577 -> SSH tunnel -> neuro:25567
```
`minecraft-wow-proxy.socket` and its socket-proxyd service run on hectic-lab.
`minecraft-wow-tunnel.service` on neuro establishes a reverse SSH forward and
reconnects after failures. A dedicated SSH identity may listen only on
127.0.0.1:25577 at the relay; it has no interactive shell or other forwarding.
Both services and firewall rules are in Nix and start on boot. The SSH client
uses an explicit AES-CTR / HMAC-SHA256-ETM / curve25519 transport profile with
IPQoS=none, tested on the neuro-to-lab route. The default profile stalled after
the handshake on this route. Both ends check peer liveness so stale listeners
are eventually released. Minecraft initially used `online-mode=true`. It now uses offline mode at the
owner's request; see the RCON and authentication section below.
For a temporary direct local tunnel, use:
```sh
ssh -NTL 0.0.0.0:25568:127.0.0.1:25567 \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 neuro
```
That command exposes the local 25568 listener on all interfaces, as requested.
Use 127.0.0.1 instead of the first 0.0.0.0 if only this computer should use it.
Credentials are encrypted in `sus/neuro-minecraft.yaml` with the actual neuro
host identity and owner keys. The existing `sus/neuro.yaml` is unchanged.
The source WoW archive remains untouched in Storage Box. Import is idempotent:
an existing world with level.dat is preserved. Never delete the world to update
mods; publish/deploy a matching modpack release instead.
Useful checks:
```sh
ssh neuro systemctl status minecraft-world-import-wowMineMap \
minecraft-modpack-import-worldOfSosal minecraft-server-wowMineMap \
minecraft-wow-tunnel --no-pager
ssh hectic-lab systemctl status minecraft-wow-proxy.socket --no-pager
ssh neuro journalctl -u minecraft-server-wowMineMap -n 80 --no-pager
```
The initial isolated server compatibility test reached `Done` and answered the
Minecraft status/ping protocol. Its logs also contain nonfatal recipe and class
function errors from the supplied modpack; successful startup does not imply that
every recipe or RPG class feature works correctly.
The imported map metadata is `wow mine`, DataVersion 3953 (Minecraft 1.21),
spawn 0 / 68 / -32; extracted size is approximately 11.7 GiB. The archive
SHA-256 was verified before extraction.
## Windows Prism GUI verification on 2026-09-19
- Downloaded the published ZIP through the browser and imported it in Prism 8.4.
- Fixed the generated instance.cfg: ConfigVersion=1.2 is required. Without it,
Prism selects its legacy INI parser and corrupts the quoted pre-launch command.
The corrected ZIP is published at the same URL. Previously imported copies
need the command corrected in Settings / Custom commands, or a fresh import.
- Used Java 21.0.4; the first packwiz download hit two transient timeouts.
Cancelled the incomplete launch and retried successfully. All 141 downloaded
client mod hashes match the original mrpack. NeoForge reports 202 mods when
bundled/internal mod components are included.
- Joined store.hectic-lab.com:25568 in the actual Minecraft GUI. The server
confirmed the authenticated join, and the client reached the Origins selection
screen. No character origin was selected during testing.
- Tested a separate copy of the pack manifest with an inert config text file:
launching from Prism added it; restoring the production manifest and launching
again automatically deleted it. Existing files were reused from cache, and
options.txt retained its checksum. The production pack contents were unchanged.
- Restored the instance's regular current/pack.toml update URL.
## Independent BFS entry point (2026-09-19)
- Server: `wow.bfs.band`; downloads: https://store.bfs.band/minecraft/.
- BFS is `bfs.poland.xray` (91.198.166.181), the host of Element.
- `minecraft-wow-tunnel-bfs` connects neuro directly to BFS. The BFS path does
not transit hectic-lab; both tunnels have independent reconnecting services.
- Shared proxy implementation: `nixos/module/generic/minecraft-public-relay.nix`.
Host settings remain in `minecraft-wow-proxy.nix` (hectic-lab) and
`minecraft-wow.nix` (BFS). A dedicated HTTPS virtual host serves `store.bfs.band`. The legacy
`bfs.band/minecraft/` URLs remain available for already imported instances.
- Downloaded BFS ZIP seeds `wow.bfs.band` and uses the stable manifest
`https://store.bfs.band/minecraft/world-of-sosal/current/pack.toml`. It does not
redirect installation metadata to hectic-lab. Upstream mod and Java/loader
downloads still use their original providers (e.g. Modrinth, GitHub, Mojang).
- Existing hectic-lab instances can be migrated without reinstalling mods:
in Edit / Settings / Custom commands, replace only the manifest URL in
Pre-launch command with the BFS URL above. Change the multiplayer server
address to wow.bfs.band. New users should import the ZIP from BFS.
- `script/publish-prism-mirrors.py` builds host-specific ZIPs from one archive
and publishes both mirrors. It checks that the running neuro server's cached
archive has the same SHA-256. Each host's switch is atomic; publication across
two hosts is sequential, so rerun the command if it exits unsuccessfully.
- Both configurations were deployed; public Minecraft status/ping succeeds
on BFS (~125 ms), HTTPS serves the pack, and Element/Matrix HTTP checks pass.
Clean installation through the BFS manifest passed: all 141 client mods and
all overrides match the source archive. A second updater run performed no
downloads and preserved options.txt. The public BFS login protocol reached
online authentication; the earlier full GUI login used hectic-lab.
## BFS DNS and dedicated download site (2026-09-19)
Porkbun DNS, TTL 600:
| Type | Name | Value |
| --- | --- | --- |
| A | store.bfs.band | 91.198.166.181 |
| A | wow.bfs.band | 91.198.166.181 |
| SRV | _minecraft._tcp.wow.bfs.band | 0 0 25568 wow.bfs.band |
Players enter `wow.bfs.band` without a port in Minecraft Java. In Porkbun,
SRV Priority is `0`, and Target is `0 25568 wow.bfs.band` (weight, port, host).
The root download URL https://store.bfs.band/ redirects to the WorldOfSosal page.
The NixOS virtual host obtains and renews its HTTPS certificate automatically.
The publication script now seeds this update URL and the port-free game address.
Existing BFS instances retain working legacy update URLs; switching their
pre-launch manifest to the new store host is optional. Root bfs.band remains
the existing Element entry point.
## RCON and authentication (2026-09-19)
The WoW server now has `online-mode=false`. Account authentication is disabled;
player names can be impersonated, and offline UUIDs differ from online UUIDs.
Existing inventory/permissions may require a separate UUID migration.
RCON listens on TCP 25575 on neuro; its port is not opened in the firewall or
forwarded through the public Minecraft relays. The server-specific automatic
firewall is disabled and only game port 25567 is explicitly permitted.
A random password is stored in SOPS as `minecraft/rcon-password`, injected into
server.properties at startup with mode 0600, and is absent from the Nix store.
Start a local-only SSH tunnel and leave it running:
```sh
ssh -NT -L 127.0.0.1:25575:127.0.0.1:25575 -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 neuro
```
Retrieve the password in another terminal (do not paste it into logs):
```sh
ssh neuro cat /run/secrets/minecraft/rcon-password
```
Configure the RCON client with host `127.0.0.1`, port `25575`, and that password.
There is no RCON username. These changes apply to wowMineMap only.
+96
View File
@@ -0,0 +1,96 @@
# Project Zomboid backups
`hectic.services."project-zomboid".backup` creates local backups without stopping
or pausing the server. The default schedule is every 30 minutes. Each run:
1. sends the local RCON `save` command and waits for the configured save grace
period;
2. rsyncs `Zomboid/Saves/Multiplayer/<serverName>` and non-secret server
settings (`SandboxVars`, spawn-points, and spawn-regions) from
`Zomboid/Server` into a private staging tree;
3. waits five seconds and repeats the rsync to narrow the live-write window;
4. publishes a timestamped `tar.zst` archive; and
5. deletes local archives older than `backup.retentionDays`.
The service lock prevents overlapping runs. Missing save or server-config paths
skip the run through systemd `ConditionPathExists` checks.
## Consistency and secrets
This is a best-effort backup. It does not stop Project Zomboid and does not use
an atomic filesystem snapshot. The RCON save command flushes the world before
copying, and the second rsync narrows the remaining live-write window, but
neither makes the filesystem copy an atomic snapshot.
Archives do not include the generated server INI, `admin-password`,
host-generated password files, or the S3 credentials file. The server INI is
generated again during service startup; provision secret-backed values separately
after a restore.
## hectic-lab
hectic-lab runs the timer every 30 minutes and keeps local archives for 14 days:
```text
/var/lib/project-zomboid/backups/archive/
```
Check it with:
```sh
systemctl list-timers project-zomboid-backup.timer
systemctl status project-zomboid-backup.service
journalctl -u project-zomboid-backup.service
```
RCON is enabled on localhost port `27015`; the firewall does not expose this
port. The password is generated at
`/var/lib/project-zomboid/rcon-password` with mode `0600`. The server also uses
`SaveWorldEveryMinutes=15` as a periodic persistence fallback.
## Optional S3 upload
S3 upload is disabled by default. Enabling it requires `bucket`, `endpoint`,
`region`, and an absolute runtime `credentialsFile` outside `/nix/store`. The
endpoint must use HTTPS. systemd reads the environment file without executing
it; this host keeps it owned by `project-zomboid` with mode `0400`:
```sh
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
```
Set `backup.s3.prefix` to choose the object-key prefix and
`backup.s3.remoteRetentionDays` to prune old archives from that prefix. Remote
deletion runs only after a successful upload and only matches this server's
archive name prefix. Configure bucket lifecycle expiration/versioning too when
available; it remains the stronger recovery and cleanup control.
## Restore
Restoring must be done while the server is stopped so it cannot modify files
during extraction:
The versioned helper creates a fresh current-state backup, stops the timer and
server, validates archive paths, restores the save, and starts both services:
```sh
sudo ./docs/project-zomboid-restore.sh \
/var/lib/project-zomboid/backups/archive/<archive>.tar.zst
```
It writes a rollback archive named
`project-zomboid-<serverName>-pre-restore-<timestamp>.tar.zst` before changing
the save.
```sh
systemctl stop project-zomboid.service
tar --zstd --no-same-owner --no-same-permissions \
-xf /var/lib/project-zomboid/backups/archive/<archive>.tar.zst \
-C /var/lib/project-zomboid
chown -R project-zomboid:project-zomboid /var/lib/project-zomboid/Zomboid
systemctl start project-zomboid.service
```
Re-provision password files and secret-backed INI values before starting.
Verify the restored save and server name before allowing players to reconnect.
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
set -eu
SERVER_NAME=${SERVER_NAME:-servertest}
DATA_DIR=${DATA_DIR:-/var/lib/project-zomboid}
ARCHIVE=${1:-}
usage() {
printf '%s\n' "Usage: $0 /path/to/project-zomboid-${SERVER_NAME}-<timestamp>.tar.zst"
printf '%s\n' "Environment: SERVER_NAME, DATA_DIR"
}
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'Run as root.' >&2
exit 1
fi
if [ -z "$ARCHIVE" ]; then
usage >&2
exit 2
fi
if [ ! -r "$ARCHIVE" ]; then
printf 'Backup archive is not readable: %s\n' "$ARCHIVE" >&2
exit 1
fi
ARCHIVE_DIR="$DATA_DIR/backups/archive"
SAVE_DIR="$DATA_DIR/Zomboid/Saves/Multiplayer/$SERVER_NAME"
SERVER_DIR="$DATA_DIR/Zomboid/Server"
TMP_LIST=$(mktemp)
ROLLBACK_ARCHIVE=''
SERVER_STOPPED=0
RESTORE_SUCCEEDED=0
cleanup() {
rm -f "$TMP_LIST"
}
on_exit() {
status=$?
if [ "$status" -ne 0 ] && [ "$SERVER_STOPPED" -eq 1 ] \
&& [ "$RESTORE_SUCCEEDED" -eq 0 ] && [ -n "$ROLLBACK_ARCHIVE" ]; then
set +e
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ROLLBACK_ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
systemctl start project-zomboid-backup.timer
printf '%s\n' "Restore failed; current state restored from $ROLLBACK_ARCHIVE" >&2
fi
cleanup
exit "$status"
}
trap on_exit EXIT
if ! tar --zstd -tf "$ARCHIVE" >"$TMP_LIST"; then
printf 'Archive integrity check failed: %s\n' "$ARCHIVE" >&2
exit 1
fi
while IFS= read -r member; do
case "$member" in
Zomboid/*) ;;
*)
printf 'Unsafe archive member: %s\n' "$member" >&2
exit 1
;;
esac
case "$member" in
/*|*../*)
printf 'Path traversal member: %s\n' "$member" >&2
exit 1
;;
esac
done <"$TMP_LIST"
if ! systemctl start project-zomboid-backup.service; then
printf '%s\n' 'Could not create fresh backup of current state.' >&2
exit 1
fi
CURRENT_ARCHIVE=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f \
-name "project-zomboid-$SERVER_NAME-*.tar.zst" \
-printf '%T@ %p\n' | sort -nr | awk 'NR == 1 {sub(/^[^ ]* /, ""); print}')
if [ -z "$CURRENT_ARCHIVE" ]; then
printf '%s\n' 'Fresh current-state backup was not found.' >&2
exit 1
fi
stamp=$(date -u +%Y%m%dT%H%M%SZ)
ROLLBACK_ARCHIVE="$ARCHIVE_DIR/project-zomboid-$SERVER_NAME-pre-restore-$stamp.tar.zst"
cp --reflink=auto "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE" 2>/dev/null \
|| cp "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE"
chmod 0600 "$ROLLBACK_ARCHIVE"
chown project-zomboid:project-zomboid "$ROLLBACK_ARCHIVE"
systemctl stop project-zomboid-backup.timer
systemctl stop project-zomboid.service
SERVER_STOPPED=1
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" != inactive ]; then
printf '%s\n' 'Project Zomboid did not stop; refusing to restore.' >&2
exit 1
fi
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
started=0
for _ in $(seq 1 90); do
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" = active ] \
&& [ "$(systemctl show project-zomboid --property=SubState --value)" = running ]; then
started=1
break
fi
sleep 2
done
if [ "$started" -ne 1 ]; then
printf 'Restore completed, but service did not become healthy. Rollback: %s\n' \
"$ROLLBACK_ARCHIVE" >&2
exit 1
fi
systemctl start project-zomboid-backup.timer
RESTORE_SUCCEEDED=1
printf 'Restore completed.\n'
printf 'Rollback archive: %s\n' "$ROLLBACK_ARCHIVE"
Generated
+62 -1
View File
@@ -625,6 +625,28 @@
"type": "github" "type": "github"
} }
}, },
"iana-angl": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1789498292,
"narHash": "sha256-bsrT7MWTXL+tpwDZmI5xWLPZZPYAzU1WjcGlNmqpePw=",
"ref": "refs/heads/master",
"rev": "2937d257d601b40de2437a51ebc3c7b61b40f679",
"revCount": 37,
"type": "git",
"url": "https://gitea.hectic-lab.com/yukkop/learning.git"
},
"original": {
"rev": "2937d257d601b40de2437a51ebc3c7b61b40f679",
"type": "git",
"url": "https://gitea.hectic-lab.com/yukkop/learning.git"
}
},
"impermanence": { "impermanence": {
"locked": { "locked": {
"lastModified": 1737831083, "lastModified": 1737831083,
@@ -893,6 +915,22 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-gitea": {
"locked": {
"lastModified": 1790323409,
"narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1779796641, "lastModified": 1779796641,
@@ -986,6 +1024,7 @@
"hectic-landing": "hectic-landing", "hectic-landing": "hectic-landing",
"home-manager": "home-manager", "home-manager": "home-manager",
"hyprland": "hyprland", "hyprland": "hyprland",
"iana-angl": "iana-angl",
"impermanence": "impermanence", "impermanence": "impermanence",
"mechabellum-replay-analysis": "mechabellum-replay-analysis", "mechabellum-replay-analysis": "mechabellum-replay-analysis",
"nix-darwin": "nix-darwin", "nix-darwin": "nix-darwin",
@@ -995,12 +1034,34 @@
"nixos-mailserver": "nixos-mailserver", "nixos-mailserver": "nixos-mailserver",
"nixos-wsl": "nixos-wsl", "nixos-wsl": "nixos-wsl",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",
"nixpkgs-gitea": "nixpkgs-gitea",
"nixvim": "nixvim", "nixvim": "nixvim",
"rust-overlay": "rust-overlay", "rust-overlay": "rust-overlay_2",
"sops-nix": "sops-nix" "sops-nix": "sops-nix"
} }
}, },
"rust-overlay": { "rust-overlay": {
"inputs": {
"nixpkgs": [
"iana-angl",
"nixpkgs"
]
},
"locked": {
"lastModified": 1789457514,
"narHash": "sha256-Aggle++fTyAifBy+QBPxjM+obO5iepKW/8MDxQtgGvI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "89e99bf0778a8f2cd18c9360c3f19c1ee47fc739",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
+5
View File
@@ -13,6 +13,7 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-gitea.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs = { inputs = {
@@ -72,6 +73,10 @@
url = "git+ssh://git@github.com/liquizz/hectic-landing.git"; url = "git+ssh://git@github.com/liquizz/hectic-landing.git";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
iana-angl = {
url = "git+https://gitea.hectic-lab.com/yukkop/learning.git?rev=2937d257d601b40de2437a51ebc3c7b61b40f679";
inputs.nixpkgs.follows = "nixpkgs";
};
mechabellum-replay-analysis = { mechabellum-replay-analysis = {
# NOTE(yukkop): private repo - SSH access required. # NOTE(yukkop): private repo - SSH access required.
# Only evaluated when nixosConfigurations."hectic-lab|x86_64-linux" is built. # Only evaluated when nixosConfigurations."hectic-lab|x86_64-linux" is built.
+12 -25
View File
@@ -24,43 +24,30 @@ Preferred registry:
gitea.hectic-lab.com/hectic-lab/gitea-runner-nix-image gitea.hectic-lab.com/hectic-lab/gitea-runner-nix-image
``` ```
Publish the archive without adding secrets to the image layers, then use the Publish the archive without adding secrets to the image layers. Controller-owned
registry-reported digest as the only final `nix` label image reference: zero-idle runners select this image through `nixImageId` / `GCR_NIX_IMAGE_ID`;
they do not use a Gitea label-to-container-image mapping:
```text ```text
nix:docker://gitea.hectic-lab.com/hectic-lab/gitea-runner-nix-image@sha256:<registry-digest> nixImageId = "<Hetzner-image-id>";
``` ```
The `2026-06-07` tag may be pushed as a human-readable companion tag, but the The `2026-06-07` tag may be pushed as a human-readable companion tag. The
runner label mapping must use the `@sha256:` reference above. Keep legacy Kubernetes rollback pool is currently disabled and has no labels.
`ubuntu-latest` on the `gitea/runner` default image unless a later runner If it is restored, its Nix-capable image must be configured separately and
configuration task explicitly changes it. Only the `nix` label should select digest-pinned before enabling a `nix` label.
this custom image.
If the Gitea container registry is unavailable, select a private registry that Do not use a tag-only image for a restored Kubernetes rollback pool.
is reachable from the runner Kubernetes cluster and requires authentication that
can be provided through Kubernetes image-pull secrets. Record the selected
registry and replace the host in the same digest-pinned form:
```text
nix:docker://<private-registry>/<namespace>/gitea-runner-nix-image@sha256:<registry-digest>
```
Do not fall back to `latest` or a tag-only mapping.
## Task 7 publication status ## Task 7 publication status
Local build evidence is recorded in Local build evidence is recorded in
`.sisyphus/evidence/task-7-image-digest.txt`. In this environment, Docker could `.sisyphus/evidence/task-7-image-digest.txt`. Kubernetes pull smoke is recorded in
load and tag the image, but pushing to the preferred registry failed with
`unauthorized: reqPackageAccess`, so no registry digest was available to pin as a
concrete final mapping. Kubernetes pull smoke is recorded in
`.sisyphus/evidence/task-7-image-pull.txt` and is blocked here because `kubectl` `.sisyphus/evidence/task-7-image-pull.txt` and is blocked here because `kubectl`
is not installed or not on `PATH`. is not installed or not on `PATH`.
Once registry credentials are available, rerun the push, capture the After importing the archive as a Hetzner image, record its image ID in the
registry-reported digest, and replace `<registry-digest>` in the mapping above controller host configuration before dispatching Nix jobs.
before Task 6/9 consumes the label configuration.
## Image contents ## Image contents
+2 -4
View File
@@ -19,10 +19,8 @@ data:
insecure: false insecure: false
fetch_timeout: 5s fetch_timeout: 5s
fetch_interval: 2s fetch_interval: 2s
labels: # Persistent pool is disabled; controller owns all runner labels.
- ubuntu-latest labels: []
# The nix label is intentionally disabled until the runner image has a
# concrete registry-reported digest; see ../runbook.md before deploy.
cache: cache:
enabled: true enabled: true
+1 -1
View File
@@ -45,7 +45,7 @@ variable "control_plane_server_type" {
} }
variable "worker_server_type" { variable "worker_server_type" {
description = "Default worker server type for the budget trusted DinD runner pool. One cpx22 worker keeps the idle baseline cheap; scale out later if job pressure requires it." description = "Default worker server type for the budget trusted DinD cluster. One cpx22 worker keeps the cluster baseline cheap; scale out later if rollback capacity requires it."
type = string type = string
default = "cpx22" default = "cpx22"
} }
+75 -58
View File
@@ -2,15 +2,13 @@
## Scope ## Scope
This directory is the repo-owned boundary for the first Gitea Actions runner This directory is the repo-owned boundary for the Gitea Actions runner pool.
pool. Task 1 only establishes the scaffold and immutable decision contract; The controller is the active on-demand path; Kubernetes manifests and the
downstream tasks will add OpenTofu backend/provider files, Kubernetes manifests, Nix-capable image are retained for manual rollback and maintenance.
and a Nix-capable runner image under the existing subdirectories.
The target service is `https://gitea.hectic-lab.com` for the Gitea organization The target service is `https://gitea.hectic-lab.com` for the Gitea organization
`hectic-lab`. The first pool is fixed-size and trusted-only. "Ephemeral" means `hectic-lab`. The pool is trusted-only. "Ephemeral" means each controller VM
workflow job containers are ephemeral, while each runner pod keeps its runner and workflow job is disposable; the Kubernetes StatefulSet is rollback-only.
identity in per-pod `/data/.runner` storage backed by a StatefulSet PVC.
## Immutable decisions ## Immutable decisions
@@ -27,21 +25,21 @@ identity in per-pod `/data/.runner` storage backed by a StatefulSet PVC.
`/data`, including `/data/.runner`. `/data`, including `/data/.runner`.
- Container builds run through privileged rootful DinD inside trusted runner - Container builds run through privileged rootful DinD inside trusted runner
pods; host Docker socket mounting is not an implementation path. pods; host Docker socket mounting is not an implementation path.
- The active runner label is `ubuntu-latest`. The `nix` label is not live until - `ubuntu-latest` and `nix` are controller-managed on-demand aliases for
the Nix-capable image has been pushed and a concrete registry-reported digest `gross-x86` and `gross-nix-x86`; the Kubernetes pool has no active labels.
is added to the runner ConfigMap.
- First scope is trusted internal workflows only, with no untrusted fork or PR - First scope is trusted internal workflows only, with no untrusted fork or PR
workflow support. workflow support.
- First scope has no autoscaling, no KEDA, and no dynamic runner controller. - On-demand allocation is handled by the repo-owned controller; Kubernetes is
not an active autoscaling path.
## Lifecycle boundaries ## Lifecycle boundaries
- `infra/gitea-runners/opentofu/`: downstream OpenTofu stack for the S3 backend - `infra/gitea-runners/opentofu/`: OpenTofu stack for the S3 backend
contract, Hetzner provider configuration, and kube-hetzner module wiring. contract, Hetzner provider configuration, and kube-hetzner module wiring.
- `infra/gitea-runners/k8s/`: downstream namespace, ConfigMap, Secret mount, - `infra/gitea-runners/k8s/`: rollback-only namespace, ConfigMap, Secret mount,
StatefulSet, PVC, DinD sidecar, cleanup, and operational manifest work. StatefulSet, PVC, DinD sidecar, cleanup, and operational manifest work.
- `infra/gitea-runners/image/`: downstream notes or sources for the runner image - `infra/gitea-runners/image/`: notes and handoff for the optional Kubernetes
handoff; package or flake output changes are outside Task 1. rollback image; active on-demand Nix image is selected by Hetzner image ID.
- `infra/gitea-runners/runbook.md`: this contract plus later operational - `infra/gitea-runners/runbook.md`: this contract plus later operational
commands, rollback notes, and acceptance evidence references. commands, rollback notes, and acceptance evidence references.
@@ -52,8 +50,8 @@ identity in per-pod `/data/.runner` storage backed by a StatefulSet PVC.
instructions, or GitHub Actions ARC assumptions. instructions, or GitHub Actions ARC assumptions.
- Untrusted fork/PR workflows are out of first scope; privileged DinD is only - Untrusted fork/PR workflows are out of first scope; privileged DinD is only
acceptable for trusted internal jobs. acceptable for trusted internal jobs.
- Autoscaling/KEDA is out of first scope; start with a fixed-size StatefulSet - The persistent StatefulSet is rollback-only and defaults to zero replicas;
runner pool. normal jobs use controller-managed on-demand VMs.
- No actual secrets are committed: no kubeconfig, runner token, Hetzner token, - No actual secrets are committed: no kubeconfig, runner token, Hetzner token,
S3 credentials, decrypted SOPS files, or SOPS age keys. S3 credentials, decrypted SOPS files, or SOPS age keys.
- OpenTofu must not manage plaintext Kubernetes Secrets containing the Gitea - OpenTofu must not manage plaintext Kubernetes Secrets containing the Gitea
@@ -248,8 +246,7 @@ These commands are executable only when the external inputs are available:
- S3 backend credentials and endpoint access - S3 backend credentials and endpoint access
- a matching SOPS age identity for `sus/gitea-runners.yaml` - a matching SOPS age identity for `sus/gitea-runners.yaml`
- `kubectl` access to the target cluster - `kubectl` access to the target cluster
- a concrete digest for the pushed Nix-capable runner image, if enabling the - a valid `GCR_NIX_IMAGE_ID` for controller-managed Nix jobs
`nix` label
If any input is missing, stop before `tofu apply`. Do not guess values or reuse If any input is missing, stop before `tofu apply`. Do not guess values or reuse
stale kubeconfig files. stale kubeconfig files.
@@ -260,15 +257,12 @@ Before production Kubernetes apply or rollout, satisfy both manifest gates:
Kustomize overlay intentionally does not include a placeholder Secret, but Kustomize overlay intentionally does not include a placeholder Secret, but
the StatefulSet still mounts `secretName: gitea-runner-token` as the StatefulSet still mounts `secretName: gitea-runner-token` as
`/runner-secrets/token` for `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`. `/runner-secrets/token` for `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`.
2. Keep the active ConfigMap on `ubuntu-latest` only unless the Nix-capable 2. Keep the persistent-pool ConfigMap labels empty. Runner labels belong to the
image has been pushed successfully. Enable the `nix` label only by adding a controller; Nix image readiness is governed by `GCR_NIX_IMAGE_ID`.
digest-pinned `docker://` mapping with the exact registry-reported sha256
digest from that push.
Use the same SOPS materialization pattern as token rotation before applying the Use the same SOPS materialization pattern as token rotation before applying the
Kubernetes overlay. Applying the namespace alone is allowed so the Secret has a Kubernetes overlay. Applying the namespace alone is allowed so the Secret has a
target namespace; the full overlay remains gated on the Secret and digest target namespace; the full overlay remains gated on the Secret.
decisions:
```sh ```sh
kubectl apply -f infra/gitea-runners/k8s/namespace.yaml kubectl apply -f infra/gitea-runners/k8s/namespace.yaml
@@ -283,8 +277,7 @@ kubectl -n gitea-runners create secret generic gitea-runner-token \
``` ```
Do not run `kubectl apply -k infra/gitea-runners/k8s` until the Secret command Do not run `kubectl apply -k infra/gitea-runners/k8s` until the Secret command
above succeeds. Do not claim or enable the `nix` runner label until the image above succeeds. The persistent pool ConfigMap must retain empty labels.
publication step has produced the concrete digest.
```sh ```sh
tofu -chdir=infra/gitea-runners/opentofu init tofu -chdir=infra/gitea-runners/opentofu init
@@ -309,14 +302,14 @@ Expected status after deploy:
- `kubectl config current-context` names the runner cluster context. - `kubectl config current-context` names the runner cluster context.
- `kubectl get nodes -o wide` shows all expected Hetzner nodes Ready. - `kubectl get nodes -o wide` shows all expected Hetzner nodes Ready.
- `kubectl get sc` shows the Hetzner CSI storage class used by runner PVCs. - `kubectl get sc` shows the Hetzner CSI storage class used by runner PVCs.
- `kubectl -n gitea-runners get statefulset gitea-runner` shows 5 desired and 5 ready replicas. - `kubectl -n gitea-runners get statefulset gitea-runner` shows 0 desired and 0 ready replicas.
- `kubectl -n gitea-runners get pvc` shows 5 Bound PVCs. - `kubectl -n gitea-runners get pvc` shows no active runner PVCs; retained PVCs are rollback-only.
- `kubectl -n gitea-runners logs statefulset/gitea-runner -c runner --tail=200` shows the runner daemon started and no token value. - The controller host reports healthy and owns runner registrations; no persistent runner claims `ubuntu-latest` or `nix`.
## Scale 5 to 10 to 5 ## Legacy rollback pool scaling (manual only)
Scaling is a temporary capacity exercise, not the steady-state setting. Scale up, Persistent-pool scaling is not part of normal operation. Use only after restoring
wait for readiness, run the concurrent smoke jobs, then scale back down to 5. its labels and disabling the zero-idle controller as described in `Rollback`.
```sh ```sh
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=10 kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=10
@@ -324,9 +317,9 @@ kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
# Run the concurrent smoke workflows now. # Run only workflows supported by restored persistent labels.
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=1 kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=5
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide kubectl -n gitea-runners get pods -l app.kubernetes.io/name=gitea-runner -o wide
kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide kubectl -n gitea-runners get pvc -l app.kubernetes.io/name=gitea-runner -o wide
@@ -405,7 +398,7 @@ Do not run the delete command for a runner that still has an active
`gitea-runner-*` pod or retained `data-gitea-runner-*` PVC unless that PVC is `gitea-runner-*` pod or retained `data-gitea-runner-*` PVC unless that PVC is
being intentionally reset for re-registration. being intentionally reset for re-registration.
## Application rollback ## Legacy Kubernetes application rollback
Rollback the app layer only. Do not use this section to destroy the cluster. Rollback the app layer only. Do not use this section to destroy the cluster.
@@ -504,7 +497,7 @@ as complete.
## Ephemeral VM runner cutover ## Ephemeral VM runner cutover
This section governs replacing the fixed K8s runner pool with the This section governs replacing the legacy persistent K8s runner pool with the
ephemeral-VM controller (`package/gitea-runner-controller`) on this host. ephemeral-VM controller (`package/gitea-runner-controller`) on this host.
The K8s pool above remains rollback-only until cutover is explicitly accepted. The K8s pool above remains rollback-only until cutover is explicitly accepted.
@@ -569,10 +562,11 @@ The `deploy-neuro` workflow uses these nested limits:
| `gross-nix-x86-perf` runner | 480 minutes | | `gross-nix-x86-perf` runner | 480 minutes |
| `gross-nix-x86-highmem` runner | 480 minutes | | `gross-nix-x86-highmem` runner | 480 minutes |
| Gitea `actions.ENDLESS_TASK_TIMEOUT` | 8 hours | | Gitea `actions.ENDLESS_TASK_TIMEOUT` | 8 hours |
| VM hard lifetime from allocation | 480 minutes plus 10-minute controller grace | | VM hard lifetime from allocation | 480 minutes; no destruction grace |
Other runner labels keep their existing 180-minute limits. Deploy the controller `ubuntu-latest` keeps a 180-minute limit; `nix` uses a 480-minute limit for
and Gitea watchdog settings before dispatching the longer workflow. Already long-running Nix deployments. Deploy the controller and Gitea watchdog settings
before dispatching the longer workflow. Already
allocated VMs retain the TTL and runner configuration assigned when they were allocated VMs retain the TTL and runner configuration assigned when they were
created; updating the controller does not extend a running job. created; updating the controller does not extend a running job.
@@ -582,10 +576,27 @@ only, never to a lower-RAM server type. Current Hetzner public pricing for
Germany/Finland CCX53 is 0.8550 EUR/hour excluding IPv4, so one 480-minute Germany/Finland CCX53 is 0.8550 EUR/hour excluding IPv4, so one 480-minute
allocation reserves 6.84 EUR against the controller budget before VM creation. allocation reserves 6.84 EUR against the controller budget before VM creation.
These are maximum lifetimes: terminal jobs still trigger immediate VM teardown. These are maximum lifetimes. Failed, cancelled, skipped, and unbootstrapped jobs
The controller's budget reservation uses the full label TTL, so a long-running still trigger immediate VM teardown; failed jobs retain pre-destroy diagnostics.
label reserves more of the existing monthly budget. Do not raise that budget or After a successful job, its bootstrapped VM stays running until next hourly
disable timeout safeguards just to bypass a refused allocation. boundary measured from original VM creation, capped by profile TTL. Same-repo,
same-label queued work can atomically claim that idle VM. Reuse preserves
original Hetzner labels and runner name, creates no server, fetches no new
registration token, and makes no second budget reservation. Idle VMs are still
billed: controller deletes them at slot expiry and never relies on stopping a
server to avoid charges.
Reuse retains runner host filesystem and registration identity. It is therefore
restricted to same repository and exact label inside this trusted-only pool;
allowed repositories must not run untrusted fork or pull-request code. A failed
or otherwise non-successful job is never reused.
Active and idle VMs are deleted at profile hard TTL without grace. Idle reuse is
allowed only when at least one configured reconcile interval remains before both
slot expiry and hard TTL. Controller budget reservation still uses full label TTL
on initial creation, so a long-running label reserves more of existing monthly
budget. Do not raise that budget or disable timeout safeguards just to bypass a
refused allocation.
After changing any timeout, verify the complete chain rather than only After changing any timeout, verify the complete chain rather than only
`timeout-minutes`; a shorter wrapper, runner, server watchdog, or VM TTL wins. `timeout-minutes`; a shorter wrapper, runner, server watchdog, or VM TTL wins.
@@ -598,7 +609,9 @@ journalctl -u gitea-runner-webhook -n 20 --no-pager
hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed")] | length' # expect 0 hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed")] | length' # expect 0
``` ```
Zero managed VMs at idle is the steady-state assertion. Zero managed VMs is expected after retained billing slots expire. Immediately
after successful work, one managed VM per retained profile may remain until its
recorded hourly boundary.
### End-to-end acceptance (Task 9) ### End-to-end acceptance (Task 9)
@@ -608,10 +621,10 @@ Trigger `.gitea/workflows/runner-nix-smoke.yaml` via workflow_dispatch, then:
watch_labels() { hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed") | {id,name,labels}]'; } watch_labels() { hcloud server list -o json | jq '[.[] | select(.labels["gitea-runner-controller"]=="managed") | {id,name,labels}]'; }
watch_labels # exactly one VM while queued/running watch_labels # exactly one VM while queued/running
journalctl -f -u gitea-runner-controller # vm-created / vm-destroyed events journalctl -f -u gitea-runner-controller # vm-created / vm-destroyed events
watch_labels # expect [] after completion watch_labels # VM may remain until next hourly boundary
curl -fsS -H "Authorization: token $ADMIN" \ curl -fsS -H "Authorization: token $ADMIN" \
https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners \ https://gitea.hectic-lab.com/api/v1/orgs/hectic-lab/actions/runners \
| jq '[.entries[] | select(.name | startswith("gcr-"))] | length' # expect 0 | jq '[.entries[] | select(.name | startswith("gcr-"))] | length' # may remain during retained slot
``` ```
Failure paths to verify identically: duplicate delivery (send same webhook twice Failure paths to verify identically: duplicate delivery (send same webhook twice
@@ -628,23 +641,27 @@ K8s rollback pool now defaults to deleted state:
Re-enable sequence: Re-enable sequence:
```sh ```sh
# 1. stop ephemeral path # 1. edit nixos/system/hectic-lab/hectic-lab.nix and set
sed -i 's/hectic.services.gitea-runner-controller = {.*}/\/* disabled *\//' \ # services.gitea-runner-controller.enable = false, then rebuild:
nixos/system/hectic-lab/hectic-lab.nix # or set enable = false
nixos-rebuild --target root@128.140.75.58 switch nixos-rebuild --target root@128.140.75.58 switch
# 2. reprovision old kube-hetzner nodes when they were deleted: # 2. reprovision old kube-hetzner nodes when they were deleted:
tofu -chdir=infra/gitea-runners/opentofu apply tofu -chdir=infra/gitea-runners/opentofu apply
# 3. restore kubeconfig / cluster access, then re-enable K8s runner pool: # 3. while controller is disabled, destroy every surviving managed VM and
# verify no gcr-* runner registration remains online:
hcloud server list -o json \
| jq -r '.[] | select(.labels["gitea-runner-controller"]=="managed") | .id' \
| xargs -r -n1 hcloud server delete
# 4. restore kubeconfig / cluster access, restore `ubuntu-latest` in the
# ConfigMap labels, then re-enable K8s runner pool. The legacy image does not
# provide `nix`; do not dispatch Nix workflows until a Nix-capable K8s image
# and label mapping are restored:
kubectl -n gitea-runners edit configmap/gitea-runner-config
kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=5 kubectl -n gitea-runners scale statefulset/gitea-runner --replicas=5
kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m kubectl -n gitea-runners rollout status statefulset/gitea-runner --timeout=10m
``` ```
Any surviving ephemeral VMs after step 1 must be destroyed manually once: Any managed VM or `gcr-*` registration found after step 3 must be removed before
restoring persistent labels; otherwise both pools can claim the same job.
```sh
hcloud server list -o json \
| jq -r '.[] | select(.labels["gitea-runner-controller"]=="managed") | .id' \
| xargs -r -n1 hcloud server delete
```
+1 -1
View File
@@ -126,7 +126,7 @@ in {
else throw (envErrorMessage varName); else throw (envErrorMessage varName);
# -- Cargo.toml -- # -- Cargo.toml --
cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml")); cargoToml = manifest: (builtins.fromTOML (builtins.readFile manifest));
# Consolidated SQL bundles for the `hectic` schema. Single source of truth # Consolidated SQL bundles for the `hectic` schema. Single source of truth
# for everything that creates objects in the `hectic` namespace, used by # for everything that creates objects in the `hectic` namespace, used by
@@ -0,0 +1,56 @@
{ ... }:
{ config, lib, pkgs, ... }:
let
cfg = config.services.minecraft-public-relay;
in {
options.services.minecraft-public-relay = {
enable = lib.mkEnableOption "restricted SSH relay for Minecraft";
publicPort = lib.mkOption { type = lib.types.port; default = 25568; };
tunnelPort = lib.mkOption { type = lib.types.port; default = 25577; };
publicKey = lib.mkOption {
type = lib.types.str;
description = "Public SSH key of the Minecraft tunnel client";
};
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [ cfg.publicPort ];
users.groups.mc-wow-relay = { };
users.users.mc-wow-relay = {
isSystemUser = true;
group = "mc-wow-relay";
openssh.authorizedKeys.keys = [
"restrict,port-forwarding,permitlisten=\"127.0.0.1:${toString cfg.tunnelPort}\" ${cfg.publicKey}"
];
};
services.openssh.extraConfig = ''
Match User mc-wow-relay
ClientAliveInterval 15
ClientAliveCountMax 3
AllowTcpForwarding remote
PermitListen 127.0.0.1:${toString cfg.tunnelPort}
AllowAgentForwarding no
X11Forwarding no
PermitTTY no
ForceCommand ${pkgs.coreutils}/bin/false
Match all
'';
systemd.sockets.minecraft-wow-proxy = {
description = "WorldOfSosal WoW public Minecraft port";
wantedBy = [ "sockets.target" ];
listenStreams = [ "0.0.0.0:${toString cfg.publicPort}" ];
};
systemd.services.minecraft-wow-proxy = {
description = "Forward Minecraft to the neuro reverse tunnel";
requires = [ "minecraft-wow-proxy.socket" ];
after = [ "minecraft-wow-proxy.socket" ];
serviceConfig = {
ExecStart = "${pkgs.systemd}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString cfg.tunnelPort}";
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
};
};
};
}
+7
View File
@@ -21,6 +21,13 @@
plugin_daemon: plugin_daemon:
ports: !override ports: !override
- "127.0.0.1:${toString cfg.pluginPort}:5003" - "127.0.0.1:${toString cfg.pluginPort}:5003"
environment:
DB_USERNAME: ''${DB_USERNAME:-postgres}
DB_HOST: ''${DB_HOST:-db_postgres}
DB_PORT: ''${DB_PORT:-5432}
REDIS_HOST: ''${REDIS_HOST:-redis}
REDIS_PORT: ''${REDIS_PORT:-6379}
REDIS_DB: ''${REDIS_DB:-0}
''; '';
in { in {
options.hectic.services.dify = { options.hectic.services.dify = {
@@ -92,12 +92,12 @@ in
concurrencyCap = lib.mkOption { concurrencyCap = lib.mkOption {
type = lib.types.int; type = lib.types.int;
default = 2; default = 2;
description = "Maximum simultaneously running ephemeral VMs (global)."; description = "Maximum simultaneously assigned runner VMs (global); retained idle VMs do not count.";
}; };
perRepoCap = lib.mkOption { perRepoCap = lib.mkOption {
type = lib.types.int; type = lib.types.int;
default = 1; default = 1;
description = "Maximum concurrent ephemeral VMs per repo."; description = "Maximum concurrently assigned runner VMs per repo; retained idle VMs do not count.";
}; };
reconcileIntervalSec = lib.mkOption { reconcileIntervalSec = lib.mkOption {
type = lib.types.int; type = lib.types.int;
+10 -10
View File
@@ -59,25 +59,25 @@ in
host = lib.mkOption { host = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9.-]*"; type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9.-]*";
default = "u666713.your-storagebox.de"; default = "u666713-sub1.your-storagebox.de";
description = "Hetzner Storage Box SMB hostname."; description = "Hetzner Storage Box SMB hostname.";
}; };
username = lib.mkOption { username = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*"; type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*";
default = "u666713"; default = "u666713-sub1";
description = "Storage Box SMB username."; description = "Storage Box SMB username.";
}; };
share = lib.mkOption { share = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*"; type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*";
default = "backup"; default = "u666713-sub1";
description = "SMB share exported by Storage Box."; description = "SMB share exported by Storage Box.";
}; };
subdirectory = lib.mkOption { subdirectory = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_./-]*"; type = lib.types.nullOr (lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_./-]*");
default = "immich"; default = null;
description = "Directory within the SMB share used by Immich."; description = "Directory within the SMB share used by Immich.";
}; };
@@ -146,8 +146,7 @@ in
options = [ options = [
"_netdev" "_netdev"
"nofail" "nofail"
"x-systemd.automount" "x-systemd.mount-timeout=60s"
"x-systemd.idle-timeout=600"
"vers=3.1.1" "vers=3.1.1"
"seal" "seal"
"cache=none" "cache=none"
@@ -157,12 +156,13 @@ in
"gid=${config.services.immich.group}" "gid=${config.services.immich.group}"
"file_mode=0660" "file_mode=0660"
"dir_mode=0770" "dir_mode=0770"
"prefixpath=${cfg.storageBox.subdirectory}" ] ++ lib.optional (cfg.storageBox.subdirectory != null)
]; "prefixpath=${cfg.storageBox.subdirectory}";
}; };
systemd.services.immich-server.serviceConfig.RequiresMountsFor = lib.mkIf cfg.storageBox.enable [ systemd.services.immich-server.unitConfig.RequiresMountsFor = lib.mkIf cfg.storageBox.enable [
cfg.mediaLocation cfg.mediaLocation
]; ];
systemd.services.immich-server.serviceConfig.Restart = lib.mkForce "always";
}; };
} }
+2 -2
View File
@@ -56,7 +56,7 @@
"-L" "${cfg.dataDir}/logs/bootstrap.log" "-L" "${cfg.dataDir}/logs/bootstrap.log"
] ++ lib.optional (!cfg.caseSensitive) "-C1"; ] ++ lib.optional (!cfg.caseSensitive) "-C1";
initScript = pkgs.writeShellScript "p4d-init" '' initScript = ''
set -eu set -eu
export P4ROOT=${lib.escapeShellArg cfg.dataDir} export P4ROOT=${lib.escapeShellArg cfg.dataDir}
@@ -334,7 +334,7 @@ in {
after = [ "network-online.target" ]; after = [ "network-online.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
preStart = builtins.readFile initScript; preStart = initScript;
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = serviceUser; User = serviceUser;
+394 -3
View File
@@ -16,7 +16,183 @@
name: value: name: value:
"${name}=${if builtins.isBool value then lib.boolToString value else toString value}" "${name}=${if builtins.isBool value then lib.boolToString value else toString value}"
) serverProperties; ) serverProperties;
sandboxValueType = lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.float
lib.types.str
(lib.types.attrsOf sandboxValueType)
];
luaValue = value:
if builtins.isBool value then
lib.boolToString value
else if builtins.isInt value || builtins.isFloat value then
toString value
else if builtins.isAttrs value then
"{ ${lib.concatStringsSep " " (lib.mapAttrsToList (name: child: "[${luaValue name}] = ${luaValue child},") value)} }"
else
"\"${lib.replaceStrings [ "\\" "\"" "\n" "\r" ] [ "\\\\" "\\\"" "\\n" "\\r" ] value}\"";
sandboxConfigLines = lib.mapAttrsToList (
name: value: "[${luaValue name}] = ${luaValue value},"
) cfg.sandboxProperties;
zomboidDir = "${cfg.dataDir}/Zomboid";
adminPasswordFile = "${cfg.dataDir}/admin-password"; adminPasswordFile = "${cfg.dataDir}/admin-password";
rconPasswordFile = cfg.rcon.passwordFile;
backupCfg = cfg.backup;
s3CredentialsFile = if backupCfg.s3.credentialsFile == null then "" else backupCfg.s3.credentialsFile;
s3Bucket = if backupCfg.s3.bucket == null then "" else backupCfg.s3.bucket;
s3Endpoint = if backupCfg.s3.endpoint == null then "" else backupCfg.s3.endpoint;
s3Region = if backupCfg.s3.region == null then "" else backupCfg.s3.region;
saveDir = "${zomboidDir}/Saves/Multiplayer/${cfg.serverName}";
serverConfigDir = "${zomboidDir}/Server";
backupScript = pkgs.writeShellScript "project-zomboid-backup" ''
set -eu
staging_dir=${lib.escapeShellArg backupCfg.stagingDir}
archive_dir=${lib.escapeShellArg backupCfg.archiveDir}
lock_file="$archive_dir/.backup.lock"
${pkgs.coreutils}/bin/install -d -m 0700 \
"$staging_dir/Zomboid/Saves/Multiplayer/${cfg.serverName}" \
"$staging_dir/Zomboid/Server" \
"$archive_dir"
exec 9>"$lock_file"
if ! ${pkgs.util-linux}/bin/flock -n 9; then
${pkgs.coreutils}/bin/printf '%s\n' 'Project Zomboid backup already running; skipping.' >&2
exit 0
fi
${lib.optionalString cfg.rcon.enable ''
rcon_password="$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile})"
if [ -z "$rcon_password" ]; then
${pkgs.coreutils}/bin/printf '%s\n' 'Project Zomboid RCON password file is empty.' >&2
exit 1
fi
${pkgs.rcon}/bin/rcon \
--host 127.0.0.1 \
--port ${toString cfg.rcon.port} \
--password "$rcon_password" \
save
${pkgs.coreutils}/bin/sleep ${toString backupCfg.saveWaitSeconds}
''}
sync_staging() {
${pkgs.rsync}/bin/rsync -a --delete \
${lib.escapeShellArg "${saveDir}/"} \
"$staging_dir/Zomboid/Saves/Multiplayer/${cfg.serverName}/"
${pkgs.rsync}/bin/rsync -a --delete --delete-excluded \
--include=${lib.escapeShellArg "/${cfg.serverName}_SandboxVars.lua"} \
--include=${lib.escapeShellArg "/${cfg.serverName}_spawnpoints.lua"} \
--include=${lib.escapeShellArg "/${cfg.serverName}_spawnregions.lua"} \
--exclude='*' \
${lib.escapeShellArg "${serverConfigDir}/"} \
"$staging_dir/Zomboid/Server/"
}
# Second pass narrows, but cannot eliminate, live-save inconsistency.
sync_staging
${pkgs.coreutils}/bin/sleep 5
sync_staging
timestamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%dT%H%M%SZ)"
archive_name="project-zomboid-${cfg.serverName}-$timestamp.tar.zst"
archive_tmp="$archive_dir/.$archive_name.tmp"
archive="$archive_dir/$archive_name"
trap '${pkgs.coreutils}/bin/rm -f "$archive_tmp"' EXIT
${pkgs.gnutar}/bin/tar \
--use-compress-program=${lib.escapeShellArg "${pkgs.zstd}/bin/zstd -T0"} \
-C "$staging_dir" -cf "$archive_tmp" Zomboid
${pkgs.coreutils}/bin/chmod 0600 "$archive_tmp"
${pkgs.coreutils}/bin/mv "$archive_tmp" "$archive"
trap - EXIT
${pkgs.findutils}/bin/find "$archive_dir" -maxdepth 1 -type f \
-name ${lib.escapeShellArg "project-zomboid-${cfg.serverName}-*.tar.zst"} \
-mmin +${toString (backupCfg.retentionDays * 1440)} -delete
${lib.optionalString backupCfg.s3.enable ''
if [ -z "''${AWS_ACCESS_KEY_ID:-}" ] || [ -z "''${AWS_SECRET_ACCESS_KEY:-}" ]; then
${pkgs.coreutils}/bin/printf '%s\n' \
'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY missing from Project Zomboid S3 credentials file.' >&2
exit 1
fi
s3_bucket=${lib.escapeShellArg s3Bucket}
s3_prefix=${lib.escapeShellArg backupCfg.s3.prefix}
s3_key="''${s3_prefix:+$s3_prefix/}$archive_name"
${pkgs.awscli2}/bin/aws s3 cp "$archive" \
"s3://$s3_bucket/$s3_key" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--cli-connect-timeout 30 \
--cli-read-timeout 300 \
--only-show-errors
remote_prefix="$s3_prefix"
if [ -n "$remote_prefix" ]; then
remote_prefix="$remote_prefix/"
fi
archive_prefix=${lib.escapeShellArg "project-zomboid-${cfg.serverName}-"}
remote_list="$staging_dir/.remote-objects.json"
remote_delete_dir="$staging_dir/.remote-delete"
${pkgs.awscli2}/bin/aws s3api list-objects-v2 \
--bucket "$s3_bucket" \
--prefix "$remote_prefix" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--output json > "$remote_list"
${pkgs.python3}/bin/python3 - "$remote_list" "$remote_delete_dir" \
"$(( $(${pkgs.coreutils}/bin/date +%s) - ${toString (backupCfg.s3.remoteRetentionDays * 86400)} ))" \
"$remote_prefix$archive_prefix" <<'PY'
import datetime
import json
import os
import re
import sys
objects_path, delete_dir, cutoff, key_prefix = sys.argv[1:]
cutoff = int(cutoff)
archive_pattern = re.compile(
re.escape(key_prefix) + r"\d{8}T\d{6}Z\.tar\.zst\Z"
)
with open(objects_path, encoding="utf-8") as stream:
objects = json.load(stream).get("Contents", [])
old_keys = []
for item in objects:
key = item.get("Key", "")
if not archive_pattern.fullmatch(key):
continue
modified = datetime.datetime.fromisoformat(
item["LastModified"].replace("Z", "+00:00")
)
if int(modified.timestamp()) < cutoff:
old_keys.append(key)
os.makedirs(delete_dir, exist_ok=True)
for batch_number in range(0, len(old_keys), 1000):
batch = old_keys[batch_number:batch_number + 1000]
manifest_path = os.path.join(
delete_dir, f"batch-{batch_number // 1000:04d}.json"
)
with open(manifest_path, "w", encoding="utf-8") as stream:
json.dump(
{"Objects": [{"Key": key} for key in batch], "Quiet": True},
stream,
)
PY
for remote_manifest in "$remote_delete_dir"/*.json; do
[ -f "$remote_manifest" ] || continue
${pkgs.awscli2}/bin/aws s3api delete-objects \
--bucket "$s3_bucket" \
--delete "file://$remote_manifest" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--only-show-errors
done
${pkgs.coreutils}/bin/rm -rf "$remote_list" "$remote_delete_dir"
''}
'';
startScript = pkgs.writeShellScript "project-zomboid-start" '' startScript = pkgs.writeShellScript "project-zomboid-start" ''
admin_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg adminPasswordFile}) admin_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg adminPasswordFile})
exec ${pkgs.steam-run}/bin/steam-run \ exec ${pkgs.steam-run}/bin/steam-run \
@@ -100,14 +276,160 @@ in {
description = "Runtime file with additional INI values, suitable for secrets."; description = "Runtime file with additional INI values, suitable for secrets.";
}; };
sandboxProperties = lib.mkOption {
type = lib.types.attrsOf sandboxValueType;
default = { };
description = "Values for the Project Zomboid SandboxVars.lua file.";
};
openFirewall = lib.mkOption { openFirewall = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = true; default = true;
description = "Open the Project Zomboid UDP ports in the firewall."; description = "Open the Project Zomboid UDP ports in the firewall.";
}; };
rcon = {
enable = lib.mkEnableOption "local RCON for Project Zomboid automation";
port = lib.mkOption {
type = lib.types.port;
default = 27015;
description = "RCON TCP port; not opened in the firewall by this module.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/rcon-password";
description = "Runtime file containing the generated RCON password.";
};
};
backup = {
enable = lib.mkEnableOption "no-stop Project Zomboid backups";
onCalendar = lib.mkOption {
type = lib.types.str;
default = "*:0/30";
description = "systemd calendar expression controlling backup frequency.";
};
stagingDir = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/backups/staging";
description = "Local directory containing the two-pass rsync staging tree.";
};
archiveDir = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/backups/archive";
description = "Local directory containing timestamped tar.zst archives.";
};
retentionDays = lib.mkOption {
type = lib.types.ints.positive;
default = 14;
description = "Delete local archives older than this many days.";
};
saveWaitSeconds = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
description = "Seconds to wait after the RCON save command before rsync.";
};
s3 = {
enable = lib.mkEnableOption "uploading Project Zomboid backups to S3-compatible storage";
credentialsFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Runtime env file containing AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY. Required when S3 upload is enabled.
'';
};
bucket = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3 bucket receiving backup archives.";
};
endpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3-compatible endpoint URL.";
};
region = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3 region passed to awscli2.";
};
prefix = lib.mkOption {
type = lib.types.str;
default = "project-zomboid";
description = "Optional object key prefix within the S3 bucket.";
};
remoteRetentionDays = lib.mkOption {
type = lib.types.ints.positive;
default = 14;
description = "Delete uploaded archives older than this many days.";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !cfg.rcon.enable || (
lib.hasPrefix "/" cfg.rcon.passwordFile
&& !lib.hasPrefix "/nix/store/" cfg.rcon.passwordFile
);
message = "hectic.services.project-zomboid.rcon.passwordFile must be a runtime path outside /nix/store.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.enable;
message = "hectic.services.project-zomboid.backup must be enabled before S3 upload.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.credentialsFile != null;
message = "hectic.services.project-zomboid.backup.s3.credentialsFile is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.bucket != null;
message = "hectic.services.project-zomboid.backup.s3.bucket is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.endpoint != null;
message = "hectic.services.project-zomboid.backup.s3.endpoint is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.region != null;
message = "hectic.services.project-zomboid.backup.s3.region is required when S3 upload is enabled.";
}
{
assertion =
!backupCfg.s3.enable
|| backupCfg.s3.credentialsFile == null
|| (
lib.hasPrefix "/" backupCfg.s3.credentialsFile
&& !lib.hasPrefix "/nix/store/" backupCfg.s3.credentialsFile
);
message = "hectic.services.project-zomboid.backup.s3.credentialsFile must be a runtime path outside /nix/store.";
}
{
assertion =
!backupCfg.s3.enable
|| backupCfg.s3.endpoint == null
|| lib.hasPrefix "https://" backupCfg.s3.endpoint;
message = "hectic.services.project-zomboid.backup.s3.endpoint must use HTTPS.";
}
];
users.groups.project-zomboid = { }; users.groups.project-zomboid = { };
users.users.project-zomboid = { users.users.project-zomboid = {
isSystemUser = true; isSystemUser = true;
@@ -119,6 +441,11 @@ in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 project-zomboid project-zomboid - -" "d ${cfg.dataDir} 0750 project-zomboid project-zomboid - -"
"d ${cfg.installDir} 0750 project-zomboid project-zomboid - -" "d ${cfg.installDir} 0750 project-zomboid project-zomboid - -"
] ++ lib.optionals backupCfg.enable [
"d ${cfg.dataDir}/backups 0700 project-zomboid project-zomboid - -"
"Z ${cfg.dataDir}/backups 0700 project-zomboid project-zomboid - -"
"d ${backupCfg.stagingDir} 0700 project-zomboid project-zomboid - -"
"d ${backupCfg.archiveDir} 0700 project-zomboid project-zomboid - -"
]; ];
systemd.services.project-zomboid = { systemd.services.project-zomboid = {
@@ -134,6 +461,22 @@ in {
umask 077 umask 077
${pkgs.openssl}/bin/openssl rand -base64 32 > ${lib.escapeShellArg adminPasswordFile} ${pkgs.openssl}/bin/openssl rand -base64 32 > ${lib.escapeShellArg adminPasswordFile}
fi fi
${lib.optionalString cfg.rcon.enable ''
if [ ! -s ${lib.escapeShellArg rconPasswordFile} ]; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 32 > ${lib.escapeShellArg rconPasswordFile}
else
rcon_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile})
case "$rcon_password" in
*[!0123456789abcdefABCDEF]*)
umask 077
${pkgs.openssl}/bin/openssl rand -hex 32 > ${lib.escapeShellArg rconPasswordFile}
;;
esac
fi
${pkgs.coreutils}/bin/chown project-zomboid:project-zomboid ${lib.escapeShellArg rconPasswordFile}
${pkgs.coreutils}/bin/chmod 0600 ${lib.escapeShellArg rconPasswordFile}
''}
${pkgs.steamcmd}/bin/steamcmd \ ${pkgs.steamcmd}/bin/steamcmd \
+force_install_dir ${lib.escapeShellArg cfg.installDir} \ +force_install_dir ${lib.escapeShellArg cfg.installDir} \
+login anonymous \ +login anonymous \
@@ -143,14 +486,33 @@ in {
's/"-Xmx[0-9]+[mMgG]"/"-Xmx${cfg.memory}"/' \ 's/"-Xmx[0-9]+[mMgG]"/"-Xmx${cfg.memory}"/' \
${lib.escapeShellArg "${cfg.installDir}/ProjectZomboid64.json"} ${lib.escapeShellArg "${cfg.installDir}/ProjectZomboid64.json"}
${pkgs.coreutils}/bin/install -d -m 0750 \ ${pkgs.coreutils}/bin/install -d -m 0750 \
${lib.escapeShellArg "${cfg.dataDir}/Server"} ${lib.escapeShellArg "${zomboidDir}/Server"}
{ {
${lib.concatMapStringsSep "\n " (line: ${lib.concatMapStringsSep "\n " (line:
"${pkgs.coreutils}/bin/printf '%s\\n' ${lib.escapeShellArg line};" "${pkgs.coreutils}/bin/printf '%s\\n' ${lib.escapeShellArg line};"
) configLines} ) configLines}
${lib.optionalString (cfg.serverPropertiesFile != null) ${lib.optionalString (cfg.serverPropertiesFile != null)
"${pkgs.coreutils}/bin/cat ${lib.escapeShellArg cfg.serverPropertiesFile};"} "${pkgs.coreutils}/bin/cat ${lib.escapeShellArg cfg.serverPropertiesFile};"}
} > ${lib.escapeShellArg "${cfg.dataDir}/Server/${cfg.serverName}.ini"} ${lib.optionalString cfg.rcon.enable ''
${pkgs.coreutils}/bin/printf '%s\n' ${lib.escapeShellArg "RCONPort=${toString cfg.rcon.port}"};
${pkgs.coreutils}/bin/printf '%s' 'RCONPassword=';
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile};
${pkgs.coreutils}/bin/printf '\n';
''}
} > ${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}.ini"}
${lib.optionalString (cfg.sandboxProperties != { }) ''
{
${pkgs.coreutils}/bin/printf '%s\n' 'SandboxVars = {';
${lib.concatMapStringsSep "\n " (line:
"${pkgs.coreutils}/bin/printf '%s\\n' ${lib.escapeShellArg line};"
) sandboxConfigLines}
${pkgs.coreutils}/bin/printf '%s\n' '};';
} > ${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}_SandboxVars.lua"}
''}
${lib.optionalString (cfg.sandboxProperties == { }) ''
${pkgs.coreutils}/bin/rm -f \
${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}_SandboxVars.lua"}
''}
''; '';
serviceConfig = { serviceConfig = {
@@ -159,16 +521,45 @@ in {
WorkingDirectory = cfg.dataDir; WorkingDirectory = cfg.dataDir;
Environment = [ Environment = [
"HOME=${cfg.dataDir}" "HOME=${cfg.dataDir}"
"SteamAppId=380870" "SteamAppId=108600"
]; ];
ExecStart = startScript; ExecStart = startScript;
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 5; RestartSec = 5;
TimeoutStartSec = "15min";
TimeoutStopSec = 30; TimeoutStopSec = 30;
UMask = "0077"; UMask = "0077";
}; };
}; };
systemd.services.project-zomboid-backup = lib.mkIf backupCfg.enable {
description = "No-stop Project Zomboid backup";
after = [ "project-zomboid.service" ];
unitConfig.ConditionPathExists = [
saveDir
serverConfigDir
];
serviceConfig = {
Type = "oneshot";
User = "project-zomboid";
Group = "project-zomboid";
ExecStart = backupScript;
TimeoutStartSec = "30min";
UMask = "0077";
} // lib.optionalAttrs backupCfg.s3.enable {
EnvironmentFile = s3CredentialsFile;
};
};
systemd.timers.project-zomboid-backup = lib.mkIf backupCfg.enable {
description = "Run Project Zomboid backup";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = backupCfg.onCalendar;
Persistent = true;
};
};
networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [ networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [
cfg.port cfg.port
cfg.udpPort cfg.udpPort
@@ -16,6 +16,7 @@
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml"; matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
in { in {
imports = [ imports = [
./minecraft-wow.nix
self.nixosModules.xray-system self.nixosModules.xray-system
self.nixosModules.matrix-cluster self.nixosModules.matrix-cluster
self.nixosModules.matrix-cluster-users self.nixosModules.matrix-cluster-users
@@ -0,0 +1,35 @@
{ ... }:
{
imports = [ (import ../../module/generic/minecraft-public-relay.nix { }) ];
services.minecraft-public-relay = {
enable = true;
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay";
};
systemd.tmpfiles.rules = [ "d /var/www/store/minecraft/world-of-sosal 0755 root root -" ];
services.nginx.virtualHosts."store.bfs.band" = {
enableACME = true;
forceSSL = true;
root = "/var/www/store";
locations."= /".return = "302 /minecraft/world-of-sosal/";
locations."= /minecraft".return = "302 /minecraft/world-of-sosal/";
locations."= /minecraft/".return = "302 /minecraft/world-of-sosal/";
locations."/".extraConfig = ''
autoindex off;
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
'';
};
# Keep old pack URLs working for already imported Prism instances.
services.nginx.virtualHosts."bfs.band".locations = {
"= /minecraft".return = "302 /minecraft/world-of-sosal/";
"= /minecraft/".return = "302 /minecraft/world-of-sosal/";
"^~ /minecraft/" = {
root = "/var/www/store";
extraConfig = ''
autoindex off;
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
'';
};
};
}
@@ -17,7 +17,7 @@ let
UsePAM yes UsePAM yes
AuthenticationMethods publickey AuthenticationMethods publickey
AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u
LogLevel DEBUG3 LogLevel INFO
VersionAddendum none VersionAddendum none
HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519 HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519
@@ -65,7 +65,8 @@ in
Type = "simple"; Type = "simple";
StateDirectory = "experimental-sshd"; StateDirectory = "experimental-sshd";
RuntimeDirectory = "experimental-sshd"; RuntimeDirectory = "experimental-sshd";
ExecStart = "${pkgs.openssh}/bin/sshd -D -e -f /etc/ssh/experimental-sshd_config"; ExecStart = "${pkgs.openssh}/bin/sshd -D -f /etc/ssh/experimental-sshd_config";
StandardError = "journal";
}; };
preStart = '' preStart = ''
${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config ${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config
+206 -9
View File
@@ -14,7 +14,7 @@ with builtins;
with lib; with lib;
let let
domain = "hectic-lab.com"; domain = "hectic-lab.com";
sshPort = 22; giteaSshPort = 22223;
mailUserNames = [ mailUserNames = [
"security" "security"
"founders" "founders"
@@ -48,6 +48,7 @@ let
giteaRunnerService = "gitea-runner-${giteaRunnerEscapedInstance}"; giteaRunnerService = "gitea-runner-${giteaRunnerEscapedInstance}";
giteaRunnerTokenEnvService = "${giteaRunnerService}-token-env"; giteaRunnerTokenEnvService = "${giteaRunnerService}-token-env";
giteaRunnerTokenEnv = "/run/gitea-runner-${giteaRunnerInstance}/token.env"; giteaRunnerTokenEnv = "/run/gitea-runner-${giteaRunnerInstance}/token.env";
worldOfSosalRoot = "/var/www/store/world-of-sosal";
in { in {
imports = [ imports = [
self.nixosModules.hectic self.nixosModules.hectic
@@ -58,11 +59,14 @@ in {
self.nixosModules."shadowsocks" # NOTE(nrv): usage/instance self.nixosModules."shadowsocks" # NOTE(nrv): usage/instance
inputs.hectic-landing.nixosModules.hectic-landing inputs.hectic-landing.nixosModules.hectic-landing
inputs.iana-angl.nixosModules.iana-angl
(import ./attic.nix { inherit flake self inputs domain; }) (import ./attic.nix { inherit flake self inputs domain; })
(import ./containers.nix { inherit flake self inputs; }) (import ./containers.nix { inherit flake self inputs; })
./experimental-sshd.nix ./experimental-sshd.nix
./minecraft-wow-proxy.nix
(import ./ente.nix { inherit domain; }) (import ./ente.nix { inherit domain; })
(import ./immich.nix { inherit domain; })
(import ./mechabellum.nix { inherit flake self inputs domain; }) (import ./mechabellum.nix { inherit flake self inputs domain; })
(import (./. + "/sentinèlla.nix") { inherit flake self inputs domain; }) (import (./. + "/sentinèlla.nix") { inherit flake self inputs domain; })
]; ];
@@ -75,6 +79,12 @@ in {
host = "127.0.0.1"; host = "127.0.0.1";
}; };
services.iana-angl = {
enable = true;
package = inputs.iana-angl.packages.${pkgs.stdenv.hostPlatform.system}.iana-angl;
domain = "lessons.${domain}";
};
# NOTE(yukkop): both nixos-mailserver and hectic-landing module set # NOTE(yukkop): both nixos-mailserver and hectic-landing module set
# security.acme.defaults.email. Force the mailserver-aligned address. # security.acme.defaults.email. Force the mailserver-aligned address.
security.acme.defaults.email = lib.mkForce "security@${domain}"; security.acme.defaults.email = lib.mkForce "security@${domain}";
@@ -93,11 +103,81 @@ in {
}; };
services."project-zomboid" = { services."project-zomboid" = {
enable = true; enable = true;
memory = "3g"; memory = "4g";
serverName = "servertest"; serverName = "servertest";
workshopItems = [ ]; serverPropertiesFile = /var/lib/project-zomboid/server-password.ini;
mods = [ ]; rcon.enable = true;
backup = {
enable = true;
onCalendar = "*:0/30";
retentionDays = 14;
s3 = {
enable = true;
bucket = "backup-hectic-lab";
endpoint = "https://hel1.your-objectstorage.com";
region = "hel1";
credentialsFile = "/var/lib/project-zomboid/s3-credentials";
};
};
serverProperties = {
Map = "Muldraugh, KY";
SaveWorldEveryMinutes = 15;
DoLuaChecksum = false;
Public = true;
AntiCheatSafety = 4;
AntiCheatMovement = 4;
AntiCheatSpeed = 4;
AntiCheatHit = 4;
AntiCheatPacket = 4;
AntiCheatPacketException = 4;
AntiCheatPermission = 4;
AntiCheatXP = 4;
AntiCheatFire = 4;
AntiCheatSafeHouse = 4;
AntiCheatRecipe = 4;
AntiCheatPlayer = 4;
AntiCheatChecksum = 4;
AntiCheatItem = 4;
AntiCheatNoClip = 4;
AntiCheatServerCustomization = 4;
};
workshopItems = [
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
];
mods = [
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
];
sandboxProperties = {
StartMonth = 12;
StartDay = 1;
WaterShut = 3;
WaterShutModifier = 150;
ElecShut = 3;
ElecShutModifier = 150;
MinutesPerPage = 0.5;
Zombies = 4;
ZombieConfig = {
PopulationMultiplier = 1.3;
PopulationStartMultiplier = 1.0;
PopulationPeakMultiplier = 1.0;
RespawnHours = 0.0;
RespawnUnseenHours = 0.0;
RespawnMultiplier = 0.0;
RedistributeHours = 0.0;
};
ZombieLore = {
Transmission = 4;
Mortality = 7;
Speed = 2;
SprinterPercentage = 0;
Strength = 2;
Cognition = 2;
DoorOpeningPercentage = 10;
};
}; };
};
services.p4d = { services.p4d = {
enable = true; enable = true;
package = pkgs.p4d; package = pkgs.p4d;
@@ -109,6 +189,7 @@ in {
# NOTE(yukkop): ephemeral Hetzner VM runners (1 VM = 1 job). # NOTE(yukkop): ephemeral Hetzner VM runners (1 VM = 1 job).
# Runbook: infra/gitea-runners/runbook.md "Ephemeral VM runner cutover". # Runbook: infra/gitea-runners/runbook.md "Ephemeral VM runner cutover".
enable = true; enable = true;
budgetEurMonthly = "30";
imageId = "429747473"; # MicroOS x86 + persistent controller SSH key and writable Nix mount imageId = "429747473"; # MicroOS x86 + persistent controller SSH key and writable Nix mount
armImageId = "423979717"; # OpenSUSE MicroOS ARM K3S 2026-08-24 snapshot armImageId = "423979717"; # OpenSUSE MicroOS ARM K3S 2026-08-24 snapshot
nixImageId = "161547269"; # Ubuntu 24.04 x86; Nix needs writable root nixImageId = "161547269"; # Ubuntu 24.04 x86; Nix needs writable root
@@ -174,7 +255,8 @@ in {
key = "init-postgresql"; key = "init-postgresql";
}; };
"atticd/environment" = {}; "atticd/environment" = {};
"wg-bfs/private-key" = {}; "immich/storage-box" = {};
"wg-bfs/private-key" = {};
"gitea-runner/org-registration-token" = { "gitea-runner/org-registration-token" = {
sopsFile = flake + "/sus/gitea-runners.yaml"; sopsFile = flake + "/sus/gitea-runners.yaml";
key = "gitea/hectic-lab/org-runner-registration-token"; key = "gitea/hectic-lab/org-runner-registration-token";
@@ -185,9 +267,50 @@ in {
"jwt-secret" "jwt-secret"
"s3-access-key" "s3-access-key"
"s3-secret-key" "s3-secret-key"
]); ]) // {
"project-zomboid/s3-access-key" = {
key = "ente/s3-access-key";
owner = "project-zomboid";
group = "project-zomboid";
};
"project-zomboid/s3-secret-key" = {
key = "ente/s3-secret-key";
owner = "project-zomboid";
group = "project-zomboid";
};
};
}; };
systemd.services.project-zomboid.preStart = lib.mkBefore ''
password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"}
properties_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password.ini"}
s3_credentials_file=${lib.escapeShellArg "/var/lib/project-zomboid/s3-credentials"}
s3_credentials_tmp="$(${pkgs.coreutils}/bin/mktemp "''${s3_credentials_file}.XXXXXX")"
trap '${pkgs.coreutils}/bin/rm -f "$s3_credentials_tmp"' EXIT
{
${pkgs.coreutils}/bin/printf 'AWS_ACCESS_KEY_ID='
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg config.sops.secrets."project-zomboid/s3-access-key".path}
${pkgs.coreutils}/bin/printf '\n'
${pkgs.coreutils}/bin/printf 'AWS_SECRET_ACCESS_KEY='
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg config.sops.secrets."project-zomboid/s3-secret-key".path}
${pkgs.coreutils}/bin/printf '\n'
} > "$s3_credentials_tmp"
${pkgs.coreutils}/bin/chmod 0400 "$s3_credentials_tmp"
${pkgs.coreutils}/bin/mv -f "$s3_credentials_tmp" "$s3_credentials_file"
if [ ! -s "$password_file" ] || ! ${pkgs.gnugrep}/bin/grep -Eq '^[0-9a-f]{48}$' "$password_file"; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 24 > "$password_file"
fi
${pkgs.coreutils}/bin/chmod 0600 "$password_file"
properties_file_tmp="$(${pkgs.coreutils}/bin/mktemp "$(dirname "$properties_file")/.server-password.ini.XXXXXX")"
${pkgs.coreutils}/bin/printf 'Password=%s\n' "$(<"$password_file")" > "$properties_file_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$properties_file_tmp"
${pkgs.coreutils}/bin/mv "$properties_file_tmp" "$properties_file"
'';
users.users.root.openssh.authorizedKeys.keys = [ users.users.root.openssh.authorizedKeys.keys = [
# neuro machine # neuro machine
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfqSROY+rp7amPPiArY3sZM7jTjYBS02csWxF/NeIr/ root@neuro" "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfqSROY+rp7amPPiArY3sZM7jTjYBS02csWxF/NeIr/ root@neuro"
@@ -219,7 +342,7 @@ in {
]; ];
}; };
services.openssh.ports = [ sshPort ]; services.openssh.ports = [ giteaSshPort ];
services.mailserver = { services.mailserver = {
enable = true; enable = true;
@@ -241,7 +364,7 @@ in {
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ allowedTCPPorts = [
sshPort # ssh giteaSshPort # gitea ssh
80 80
443 443
3306 # mysql 3306 # mysql
@@ -262,6 +385,8 @@ in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/www/store 0755 nginx nginx -" "d /var/www/store 0755 nginx nginx -"
"d ${worldOfSosalRoot} 0750 root nginx -"
"d ${worldOfSosalRoot}/releases 0750 root nginx -"
]; ];
systemd.services.${giteaRunnerTokenEnvService} = { systemd.services.${giteaRunnerTokenEnvService} = {
@@ -306,6 +431,69 @@ in {
autoindex on; autoindex on;
''; '';
}; };
locations."= /world-of-sosal/" = {
extraConfig = ''
return 302 /world-of-sosal/index.html;
'';
};
locations."= /world-of-sosal/index.html" = {
extraConfig = ''
alias ${./static/world-of-sosal/index.html};
default_type text/html;
add_header Cache-Control "no-cache" always;
limit_except GET {
deny all;
}
'';
};
locations."= /world-of-sosal/latest.mrpack" = {
extraConfig = ''
root /var/www/store;
default_type application/zip;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."= /world-of-sosal/SHA256SUMS" = {
extraConfig = ''
root /var/www/store;
default_type text/plain;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."= /world-of-sosal/releases/" = {
extraConfig = ''
return 404;
'';
};
locations."~ ^/world-of-sosal/releases/[A-Za-z0-9][A-Za-z0-9._-]*\\.mrpack$" = {
extraConfig = ''
root /var/www/store;
default_type application/zip;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."/world-of-sosal/" = {
extraConfig = ''
autoindex off;
limit_except GET {
deny all;
}
return 404;
'';
};
};
virtualHosts."lessons.${domain}" = {
enableACME = true;
forceSSL = true;
}; };
virtualHosts."snuff.${domain}" = { virtualHosts."snuff.${domain}" = {
enableACME = true; enableACME = true;
@@ -346,6 +534,9 @@ in {
extraConfig = '' extraConfig = ''
proxy_pass http://127.0.0.1:11011/; proxy_pass http://127.0.0.1:11011/;
proxy_redirect off; proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
''; '';
}; };
}; };
@@ -355,15 +546,21 @@ in {
gitea = { gitea = {
enable = true; enable = true;
package = pkgs.hectic.gitea-heatmap; package = pkgs.hectic.gitea-heatmap;
# Keep LFS storage limited to accounts provisioned by administrators.
settings.service.DISABLE_REGISTRATION = true; settings.service.DISABLE_REGISTRATION = true;
settings.session.COOKIE_SECURE = true;
settings.actions.ENABLED = true; settings.actions.ENABLED = true;
# Long CUDA builds must not hit Gitea's default three-hour task watchdog. # Long CUDA builds must not hit Gitea's default three-hour task watchdog.
settings.actions.ENDLESS_TASK_TIMEOUT = "8h"; settings.actions.ENDLESS_TASK_TIMEOUT = "8h";
settings.server = { settings.server = {
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = 11011; HTTP_PORT = 11011;
SSH_PORT = sshPort; ROOT_URL = "https://gitea.${domain}/";
SSH_PORT = giteaSshPort;
SSH_DOMAIN = "hectic-lab.com"; SSH_DOMAIN = "hectic-lab.com";
}; };
lfs.enable = true;
settings.lfs.LFS_MAX_FILE_SIZE = 536870912;
database = { database = {
createDatabase = true; createDatabase = true;
type = "postgres"; type = "postgres";
+16
View File
@@ -0,0 +1,16 @@
{ domain, ... }:
{
config,
...
}:
{
hectic.services.immich = {
enable = true;
domain = "immich.${domain}";
storageBox = {
enable = true;
credentialsFile = config.sops.secrets."immich/storage-box".path;
};
};
}
@@ -0,0 +1,8 @@
{ ... }:
{
imports = [ (import ../../module/generic/minecraft-public-relay.nix { }) ];
services.minecraft-public-relay = {
enable = true;
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay";
};
}
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WorldOfSosal</title>
</head>
<body>
<main>
<h1>WorldOfSosal</h1>
<p><a href="https://store.hectic-lab.com/minecraft/world-of-sosal/">Install in Prism with automatic mod updates</a></p>
<p><a href="https://store.hectic-lab.com/world-of-sosal/latest.mrpack">Download latest pack</a></p>
<p><a href="prismlauncher://import?url=https%3A%2F%2Fstore.hectic-lab.com%2Fworld-of-sosal%2Flatest.mrpack">Import latest pack in Prism Launcher</a></p>
<p><a href="https://store.hectic-lab.com/world-of-sosal/SHA256SUMS">SHA-256 checksums</a></p>
<p>Updates are manual. Packs imported from arbitrary URLs do not update automatically.</p>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
# WorldOfSosal pack publishing
The public endpoint is `https://store.hectic-lab.com/world-of-sosal/`. Nix
deploys only its landing page and nginx configuration. Pack files, the checksum
manifest, and `latest.mrpack` stay under `/var/www/store/world-of-sosal` on the
host and never enter Git or the Nix store.
## Publish an uploaded pack
Run these commands on `hectic-lab` as root after the Storage Box pack has
already been uploaded to a local staging path. Pick a stable version name; do
not replace an existing versioned release.
```sh
set -eu
source_pack=/path/to/already-uploaded/WorldOfSosal.mrpack
version=2026-09-16
root=/var/www/store/world-of-sosal
release_name="WorldOfSosal-${version}.mrpack"
release_path="$root/releases/$release_name"
printf '%s %s\n' \
f8c18acb9208e4592725632ae50dab4f9c308483b34fd43a6507c74fdbf8169f \
"$source_pack" | sha256sum --check --status
test ! -e "$release_path"
install -o root -g nginx -m 0640 "$source_pack" "$release_path.new"
mv -T "$release_path.new" "$release_path"
manifest="$root/.SHA256SUMS.$$"
(cd "$root/releases" && sha256sum -- *.mrpack) > "$manifest"
chown root:nginx "$manifest"
chmod 0640 "$manifest"
mv -Tf "$manifest" "$root/SHA256SUMS"
latest="$root/.latest.mrpack.$$"
ln -s "releases/$release_name" "$latest"
mv -Tf "$latest" "$root/latest.mrpack"
```
Versioned releases use a one-year immutable cache policy. `latest.mrpack` and
`SHA256SUMS` disable caching so an atomic replacement becomes visible quickly.
The manifest is available at
`https://store.hectic-lab.com/world-of-sosal/SHA256SUMS`.
Import the current pack in Prism Launcher with:
```text
prismlauncher://import?url=https%3A%2F%2Fstore.hectic-lab.com%2Fworld-of-sosal%2Flatest.mrpack
```
Direct URL imports do not auto-update. Repeat the publication and import steps
for each new pack version.
+16
View File
@@ -1,3 +1,17 @@
# Current Minecraft access (2026-09-18)
WorldOfSosal on the WoW map uses `store.hectic-lab.com:25568` publicly.
The game server is `neuro:25567`; a restricted persistent reverse SSH tunnel
connects it to the public relay. See `docs/minecraft-prism.md` and the Nix modules
`minecraft/public-tunnel.nix` / `hectic-lab/minecraft-wow-proxy.nix`.
Verified LAN: neuro is `192.168.88.10`, gateway `192.168.88.1`.
SSH access is `95.31.254.84:34457`. Direct external Minecraft TCP probes timed out,
and no UPnP IGD was discovered. The old TP-Link network and manual port-forward
instructions below describe the previous network, not the active configuration.
---
# Router Access (TP-Link) # Router Access (TP-Link)
The server `neuro` is behind a NAT router at `192.168.0.1`. The server `neuro` is behind a NAT router at `192.168.0.1`.
@@ -50,6 +64,8 @@ Ports that need to be forwarded from router to `192.168.0.10`:
| 5269 | 5269 | TCP | XMPP (s2s) | | 5269 | 5269 | TCP | XMPP (s2s) |
| 10000 | 10000 | UDP | Jitsi Videobridge | | 10000 | 10000 | UDP | Jitsi Videobridge |
| 25565 | 25565 | TCP | Minecraft | | 25565 | 25565 | TCP | Minecraft |
| 25567 | 25567 | TCP | Minecraft WoW Mine map |
| 25568 | 25568 | TCP | Minecraft World of Sosal |
## Troubleshooting ## Troubleshooting
+5 -3
View File
@@ -17,10 +17,12 @@ in self.lib.nixpkgs-lib.nixosSystem {
]; ];
config.allowUnfreePredicate = pkg: config.allowUnfreePredicate = pkg:
self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [ self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [
"minecraft-server" "minecraft-server"
"neoforge" "neoforge"
"steamcmd"
"steam-unwrapped"
"nvidia-x11" "nvidia-x11"
]; ];
# jitsi-meet depends on libolm which is marked insecure (CVE-2024-4519x) # jitsi-meet depends on libolm which is marked insecure (CVE-2024-4519x)
config.permittedInsecurePackages = [ config.permittedInsecurePackages = [
-61
View File
@@ -1,61 +0,0 @@
{
pkgs,
...
}:
{
services.minecraft-servers = {
enable = true;
eula = true;
openFirewall = true;
servers = {
vanilla = {
enable = true;
jvmOpts = "-Xmx6G -Xms2G";
package = pkgs.minecraftServers.vanilla-1_21_11;
serverProperties = {
server-port = 25565;
difficulty = "hard";
online-mode = true;
view-distance = 32;
level-seed = "8306359138650378643";
pause-when-empty-seconds = 0;
};
};
createAeronautics = {
enable = true;
jvmOpts = "-Xmx8G -Xms2G";
package = pkgs.minecraftServers.neoforge-1_21_1;
symlinks = {
mods = pkgs.linkFarmFromDrvs "mods" (
builtins.attrValues {
Sable = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar";
sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961";
};
Create = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar";
sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b";
};
CreateAeronautics = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar";
sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6";
};
}
);
};
serverProperties = {
server-port = 25566;
difficulty = "hard";
online-mode = true;
view-distance = 20;
pause-when-empty-seconds = 0;
};
};
};
};
}
@@ -0,0 +1,21 @@
{ pkgs, ... }:
{
services.minecraft-servers.servers.createAeronautics = {
enable = true;
jvmOpts = "-Xmx8G -Xms2G";
package = pkgs.minecraftServers.neoforge-1_21_1;
symlinks = {
mods = import ./mods.nix { inherit pkgs; };
};
serverProperties = {
server-port = 25566;
difficulty = "hard";
online-mode = true;
view-distance = 20;
pause-when-empty-seconds = 0;
};
};
}
+17
View File
@@ -0,0 +1,17 @@
{
imports = [
./vanilla.nix
./create-aeronautics.nix
./wow-mine-map.nix
./world-of-sosal.nix
./public-tunnel.nix
./world-import.nix
./modpack-import.nix
];
services.minecraft-servers = {
enable = true;
eula = true;
openFirewall = true;
};
}
@@ -0,0 +1,460 @@
{
config,
lib,
pkgs,
...
}: let
inherit (lib) mkOption types;
cfg = config.services.minecraft-modpack-imports;
enabledImports = lib.filterAttrs (_: import: import.enable) cfg;
dataDir = config.services.minecraft-servers.dataDir;
minecraftServers = config.services.minecraft-servers.servers;
targetServers = lib.mapAttrsToList (_: import: import.serverName) enabledImports;
importerUser = name: let
descriptiveName = "mc-pack-${name}";
in
if builtins.stringLength descriptiveName <= 31
then descriptiveName
else "mc-pack-${builtins.substring 0 16 (builtins.hashString "sha256" name)}";
stateDirectory = cacheDir:
if lib.hasPrefix "/var/lib/" cacheDir
then lib.removePrefix "/var/lib/" cacheDir
else null;
stateDirectoryCompatible = cacheDir: let
relative = stateDirectory cacheDir;
components = lib.splitString "/" (if relative == null then "" else relative);
in
relative != null
&& relative != ""
&& lib.all (component: component != "" && component != "." && component != "..") components;
escapeSftp = value:
"\"${lib.replaceStrings ["\\" "\""] ["\\\\" "\\\""] value}\"";
escapeTmpfiles = value:
lib.replaceStrings ["%" " " "\t"] ["%%" "\\x20" "\\x09"] value;
importerServices = lib.mkMerge (lib.mapAttrsToList (name: import: let
user = importerUser name;
unitName = "minecraft-modpack-import-${name}";
serverUnit = "minecraft-server-${import.serverName}.service";
cacheStateDirectory = stateDirectory import.cacheDir;
serverDir = "${dataDir}/${import.serverName}";
in {
${unitName} = {
description = "Import Minecraft Modrinth pack ${name}";
before = [ serverUnit ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [
pkgs.coreutils
pkgs.curl
pkgs.findutils
pkgs.jq
pkgs.openssh
pkgs.unar
];
serviceConfig = {
Type = "oneshot";
User = user;
Group = user;
RemainAfterExit = true;
TimeoutStartSec = import.timeout;
ProtectSystem = "strict";
ProtectHome = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
NoNewPrivileges = true;
RestrictSUIDSGID = true;
LockPersonality = true;
CapabilityBoundingSet = [ "" ];
ReadWritePaths = [ import.cacheDir serverDir ];
UMask = "0007";
} // lib.optionalAttrs (stateDirectoryCompatible import.cacheDir) {
StateDirectory = cacheStateDirectory;
};
script = ''
set -eu
umask 007
cache_dir=${lib.escapeShellArg import.cacheDir}
server_dir=${lib.escapeShellArg serverDir}
archive_name=${lib.escapeShellArg import.archiveName}
archive="$cache_dir/$archive_name"
temporary_archive="$cache_dir/.$archive_name.$$"
extraction_dir="$cache_dir/.extract-${name}.$$"
staging_dir="$cache_dir/.stage-${name}.$$"
managed_paths="$cache_dir/managed-paths"
new_managed_paths="$cache_dir/.managed-paths.$$"
key=${lib.escapeShellArg import.sshKeyFile}
cleanup() {
rm -f "$temporary_archive" "$new_managed_paths"
rm -rf "$extraction_dir" "$staging_dir"
}
trap cleanup EXIT
safe_relative_path() {
case "$1" in
""|/*|*\\*|.|..|./*|../*|*/./*|*/../*|*/.|*/..)
return 1
;;
esac
return 0
}
archive_valid() {
[ -f "$archive" ] && printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$archive" | sha256sum -c --status
}
archive_entries_valid() {
lsar -json "$archive" | jq -e '
(.lsarContents // .entries) as $entries
| ($entries | type == "array")
and ($entries | all(.[];
(.XADFileName // .XADPath) as $path
| ($path | type == "string")
and ($path | startswith("/") | not)
and ($path | contains("\\") | not)
and ($path | test("[[:cntrl:]]") | not)
and ([$path | split("/")[] | select(. == "" or . == "." or . == "..")] | length == 0)
and ((.XADIsSymbolicLink // false) | not)
and ((.XADIsHardLink // false) | not)
and ((.XADIsDevice // false) | not)
and ((.XADIsFIFO // false) | not)
and ((.XADIsSocket // false) | not)
)
)
' >/dev/null
}
mkdir -p "$cache_dir" "$server_dir"
chmod 0700 "$cache_dir"
if ! archive_valid; then
rm -f "$archive"
downloaded=false
attempt=1
while [ "$attempt" -le ${toString import.retries} ]; do
rm -f "$temporary_archive"
if sftp \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \
-i "$key" \
-b - \
${lib.escapeShellArg "${import.remoteUser}@${import.remoteHost}"} <<EOF
get ${escapeSftp import.remotePath} "$temporary_archive"
EOF
then
if printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$temporary_archive" | sha256sum -c --status; then
mv "$temporary_archive" "$archive"
downloaded=true
break
fi
fi
rm -f "$temporary_archive"
attempt=$((attempt + 1))
done
if [ "$downloaded" != true ]; then
echo "Unable to download verified Minecraft modpack ${name}" >&2
exit 1
fi
fi
if ! archive_entries_valid; then
echo "Modpack archive contains unsafe entries" >&2
exit 1
fi
mkdir -p "$extraction_dir" "$staging_dir"
unar -quiet -output-directory "$extraction_dir" "$archive"
find "$extraction_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
manifest=$(find "$extraction_dir" -type f -name modrinth.index.json -print)
if [ -z "$manifest" ] || [ "$(printf '%s\n' "$manifest" | wc -l)" -ne 1 ]; then
echo "Modpack must contain exactly one modrinth.index.json" >&2
exit 1
fi
pack_root=$(dirname "$manifest")
if ! jq -e \
--argjson expectedDependencies ${lib.escapeShellArg (builtins.toJSON import.expectedDependencies)} \
'
. as $manifest
| .formatVersion == 1
and ($expectedDependencies | to_entries | all(.[];
$manifest.dependencies[.key] == .value
))
and (.files | type == "array")
and all(.files[];
((.env.server // "required") == "unsupported")
or (
(.path | type == "string")
and (.path | length > 0)
and (.path | startswith("mods/"))
and (.path | startswith("/") | not)
and (.path | contains("\\") | not)
and (.path | test("[[:cntrl:]]") | not)
and ([.path | split("/")[] | select(. == "" or . == "." or . == "..")] | length == 0)
and (.hashes.sha512 | type == "string")
and (.hashes.sha512 | test("^[0-9a-fA-F]{128}$"))
and (.downloads | type == "array")
and (.downloads | length > 0)
and (.downloads[0] | type == "string")
and (.downloads[0] | startswith("https://"))
and (.downloads[0] | test("[[:cntrl:]]") | not)
)
)
' "$manifest" >/dev/null; then
echo "Modpack manifest contains unsafe or invalid server files" >&2
exit 1
fi
: > "$new_managed_paths"
jq -r '
.files[]
| select((.env.server // "required") != "unsupported")
| [.path, .hashes.sha512, .downloads[0]]
| @tsv
' "$manifest" |
while IFS="$(printf '\t')" read -r relative expected_hash url; do
safe_relative_path "$relative" || exit 1
destination="$staging_dir/$relative"
mkdir -p "$(dirname "$destination")"
curl --fail --location --silent --show-error \
--retry ${toString import.retries} \
--output "$destination" \
"$url"
if ! printf '%s %s\n' "$expected_hash" "$destination" |
sha512sum -c --status; then
echo "SHA-512 mismatch for $relative" >&2
exit 1
fi
printf '%s\n' "$relative" >> "$new_managed_paths"
done
overrides_dir="$pack_root/overrides"
if [ -d "$overrides_dir" ]; then
find "$overrides_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
cp -R "$overrides_dir/." "$staging_dir/"
find "$overrides_dir" -type f -printf '%P\n' |
while IFS= read -r relative; do
safe_relative_path "$relative" || exit 1
printf '%s\n' "$relative"
done >> "$new_managed_paths"
fi
# Nix Minecraft manages eula.txt via a symlink. Only reject symlinks
# in destinations we actually touch, including their parent directories.
safe_target_path() {
safe_relative_path "$1" || return 1
target="$server_dir/$1"
while [ "$target" != "$server_dir" ]; do
if [ -L "$target" ]; then
echo "Modpack destination contains symlink: $target" >&2
return 1
fi
target=$(dirname "$target")
done
}
while IFS= read -r relative; do
safe_target_path "$relative" || exit 1
done < "$new_managed_paths"
if [ -f "$managed_paths" ]; then
while IFS= read -r relative; do
safe_target_path "$relative" || exit 1
done < "$managed_paths"
fi
while IFS= read -r relative; do
safe_relative_path "$relative" || exit 1
source_file="$staging_dir/$relative"
target_file="$server_dir/$relative"
install -d -m 0770 -g minecraft "$(dirname "$target_file")"
install -m 0660 -g minecraft "$source_file" "$target_file"
done < "$new_managed_paths"
if [ -f "$managed_paths" ]; then
while IFS= read -r old_relative; do
safe_relative_path "$old_relative" || {
echo "Unsafe path in previous managed-paths file" >&2
exit 1
}
keep=false
while IFS= read -r relative; do
if [ "$old_relative" = "$relative" ]; then
keep=true
break
fi
done < "$new_managed_paths"
if [ "$keep" != true ]; then
rm -f "$server_dir/$old_relative"
fi
done < "$managed_paths"
fi
mv "$new_managed_paths" "$managed_paths"
'';
};
"minecraft-server-${import.serverName}" = {
requires = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
}) enabledImports);
in {
options.services.minecraft-modpack-imports = mkOption {
default = { };
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
enable = lib.mkEnableOption "Minecraft Modrinth pack import ${name}";
serverName = mkOption {
type = types.str;
description = "minecraft-servers server receiving imported pack";
};
remoteHost = mkOption {
type = types.str;
description = "SSH host serving Modrinth pack archive";
};
remoteUser = mkOption {
type = types.str;
description = "SSH user used to download Modrinth pack archive";
};
remotePath = mkOption {
type = types.str;
description = "Remote path to Modrinth pack archive";
};
archiveName = mkOption {
type = types.str;
description = "Archive file name inside cache directory";
};
cacheDir = mkOption {
type = types.str;
default = "/var/lib/minecraft-modpacks/${name}";
description = "Persistent Modrinth archive and importer state directory";
};
archiveSha256 = mkOption {
type = types.strMatching "[0-9a-fA-F]{64}";
description = "Expected SHA-256 digest of Modrinth pack archive";
};
expectedDependencies = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Required dependency versions in modrinth.index.json";
};
sshKeyFile = mkOption {
type = types.str;
description = "Runtime path to private SSH key";
};
hostPublicKey = mkOption {
type = types.str;
description = "Pinned SSH host public key";
};
retries = mkOption {
type = types.ints.positive;
default = 3;
description = "Maximum SFTP attempts and curl retry count";
};
timeout = mkOption {
type = types.str;
default = "30min";
description = "Importer service start timeout";
};
};
}));
description = "Modrinth packs imported before selected Minecraft servers start";
};
config = lib.mkIf (enabledImports != { }) {
assertions = lib.flatten (lib.mapAttrsToList (name: import: [
{
assertion = builtins.match "[A-Za-z0-9_-]+" name != null;
message = "services.minecraft-modpack-imports.${name}: name must contain only letters, digits, underscores, or hyphens";
}
{
assertion = builtins.match "/.*" import.cacheDir != null;
message = "services.minecraft-modpack-imports.${name}.cacheDir must be absolute";
}
{
assertion = builtins.match "/var/lib(/[A-Za-z0-9][A-Za-z0-9._-]*)+" import.cacheDir != null;
message = "services.minecraft-modpack-imports.${name}.cacheDir must be beneath /var/lib with safe path components";
}
{
assertion = builtins.match "[A-Za-z0-9_-]+" import.serverName != null;
message = "services.minecraft-modpack-imports.${name}.serverName must contain only letters, digits, underscores, or hyphens";
}
{
assertion = !lib.hasInfix "\n" import.remotePath && !lib.hasInfix "\r" import.remotePath;
message = "services.minecraft-modpack-imports.${name}.remotePath must not contain newlines";
}
{
assertion = builtins.hasAttr import.serverName minecraftServers
&& (builtins.getAttr import.serverName minecraftServers).enable;
message = "services.minecraft-modpack-imports.${name}.serverName must name an enabled Minecraft server";
}
{
assertion = builtins.match "[A-Za-z0-9._-]+" import.archiveName != null
&& import.archiveName != "."
&& import.archiveName != ".."
&& import.archiveName != "managed-paths";
message = "services.minecraft-modpack-imports.${name}.archiveName must be a file name";
}
{
assertion = lib.length (lib.unique targetServers) == lib.length targetServers;
message = "services.minecraft-modpack-imports: each server target must be unique";
}
]) enabledImports);
users.groups = lib.mapAttrs' (name: _: lib.nameValuePair (importerUser name) { }) enabledImports;
users.users = lib.mapAttrs' (name: _: let
user = importerUser name;
in lib.nameValuePair user {
description = "Minecraft modpack importer ${name}";
isSystemUser = true;
group = user;
extraGroups = [ "minecraft" ];
}) enabledImports;
programs.ssh.knownHosts = lib.mapAttrs' (name: import:
lib.nameValuePair "minecraft-modpack-import-${name}" {
hostNames = [ import.remoteHost ];
publicKey = import.hostPublicKey;
}) enabledImports;
systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: import:
lib.optional (!stateDirectoryCompatible import.cacheDir)
"d ${escapeTmpfiles import.cacheDir} 0700 ${importerUser name} ${importerUser name} -") enabledImports);
systemd.services = importerServices;
};
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs }:
pkgs.linkFarmFromDrvs "create-aeronautics-mods" (
builtins.attrValues {
Sable = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar";
sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961";
};
Create = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar";
sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b";
};
CreateAeronautics = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar";
sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6";
};
}
)
@@ -0,0 +1,363 @@
{
"build": {
"version": "21.1.250",
"src": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.250/neoforge-21.1.250-installer.jar",
"hash": "sha256-DkepG6ITmo20v3Ynrwgfe1eJtQi7A57o3qEnK3lpPWA="
},
"libraries": [
"net.neoforged.fancymodloader:earlydisplay:4.0.44",
"net.neoforged.fancymodloader:loader:4.0.44",
"net.neoforged.accesstransformers:at-modlauncher:10.0.1",
"net.neoforged:accesstransformers:10.0.1",
"net.neoforged:bus:8.0.5",
"net.neoforged:coremods:7.0.3",
"cpw.mods:modlauncher:11.0.5",
"net.neoforged:mergetool:2.0.0:api",
"com.electronwill.night-config:toml:3.8.3",
"com.electronwill.night-config:core:3.8.3",
"net.neoforged:JarJarSelector:0.4.1",
"net.neoforged:JarJarMetadata:0.4.1",
"org.apache.maven:maven-artifact:3.8.5",
"net.jodah:typetools:0.6.3",
"net.minecrell:terminalconsoleappender:1.3.0",
"net.fabricmc:sponge-mixin:0.15.2+mixin.0.8.7",
"org.openjdk.nashorn:nashorn-core:15.4",
"org.apache.commons:commons-lang3:3.14.0",
"cpw.mods:bootstraplauncher:2.0.2",
"cpw.mods:securejarhandler:3.0.8",
"org.ow2.asm:asm-commons:9.10.1",
"org.ow2.asm:asm-util:9.10.1",
"org.ow2.asm:asm-analysis:9.10.1",
"org.ow2.asm:asm-tree:9.10.1",
"org.ow2.asm:asm:9.10.1",
"net.neoforged:JarJarFileSystems:0.4.1",
"net.sf.jopt-simple:jopt-simple:5.0.4",
"org.slf4j:slf4j-api:2.0.9",
"org.antlr:antlr4-runtime:4.13.1",
"com.mojang:logging:1.2.7",
"org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1",
"org.apache.logging.log4j:log4j-core:2.22.1",
"org.apache.logging.log4j:log4j-api:2.22.1",
"org.jline:jline-reader:3.20.0",
"org.jline:jline-terminal:3.20.0",
"commons-io:commons-io:2.15.1",
"net.minecraftforge:srgutils:0.4.15",
"com.google.guava:guava:32.1.2-jre",
"com.google.guava:failureaccess:1.0.1",
"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava",
"com.google.code.findbugs:jsr305:3.0.2",
"org.checkerframework:checker-qual:3.33.0",
"com.google.errorprone:error_prone_annotations:2.18.0",
"com.google.j2objc:j2objc-annotations:2.8",
"com.google.code.gson:gson:2.10.1",
"org.codehaus.plexus:plexus-utils:3.3.0",
"com.machinezoo.noexception:noexception:1.7.1",
"net.neoforged:neoform:1.21.1-20240808.144430@zip",
"net.neoforged.installertools:binarypatcher:2.1.2:fatjar",
"net.neoforged:AutoRenamingTool:2.0.3:all",
"net.neoforged.installertools:installertools:2.1.2",
"net.neoforged:srgutils:1.0.0",
"net.md-5:SpecialSource:1.11.0",
"com.google.code.gson:gson:2.8.9",
"de.siegmar:fastcsv:2.0.0",
"org.ow2.asm:asm-commons:9.3",
"net.neoforged.installertools:cli-utils:2.1.2",
"com.google.guava:guava:20.0",
"com.opencsv:opencsv:4.4",
"org.ow2.asm:asm-analysis:9.3",
"org.ow2.asm:asm-tree:9.3",
"org.ow2.asm:asm:9.3",
"org.apache.commons:commons-text:1.3",
"org.apache.commons:commons-lang3:3.8.1",
"commons-beanutils:commons-beanutils:1.9.3",
"org.apache.commons:commons-collections4:4.2",
"commons-logging:commons-logging:1.2",
"commons-collections:commons-collections:3.2.2",
"net.neoforged.installertools:jarsplitter:2.1.2",
"net.neoforged:neoforge:21.1.250:universal"
]
},
"libraryLocks": {
"net.neoforged.fancymodloader:earlydisplay:4.0.44": {
"url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/4.0.44/earlydisplay-4.0.44.jar",
"hash": "sha1-yyr88VZyGA7KHEmxybEgiVHvMi4="
},
"net.neoforged.fancymodloader:loader:4.0.44": {
"url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/4.0.44/loader-4.0.44.jar",
"hash": "sha1-Y0M2CLaDAqRC49uoeIDlT0LqBYM="
},
"net.neoforged.accesstransformers:at-modlauncher:10.0.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar",
"hash": "sha1-WrpQICrO6tCGvAn7wnUcnwXLSJA="
},
"net.neoforged:accesstransformers:10.0.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar",
"hash": "sha1-/YO1cl926ukRXpNV+hxFampEFAA="
},
"net.neoforged:bus:8.0.5": {
"url": "https://maven.neoforged.net/releases/net/neoforged/bus/8.0.5/bus-8.0.5.jar",
"hash": "sha1-Wy0zKFq10VVOl5itmMQNbqOGi9U="
},
"net.neoforged:coremods:7.0.3": {
"url": "https://maven.neoforged.net/releases/net/neoforged/coremods/7.0.3/coremods-7.0.3.jar",
"hash": "sha1-CRR+b2OLQnKzvV/I+SrTeAJRLGw="
},
"cpw.mods:modlauncher:11.0.5": {
"url": "https://maven.neoforged.net/releases/cpw/mods/modlauncher/11.0.5/modlauncher-11.0.5.jar",
"hash": "sha1-uPDUkpT3M/22FzkxsmNVPpQ9yVA="
},
"net.neoforged:mergetool:2.0.0:api": {
"url": "https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar",
"hash": "sha1-Uv4ZSb5k4zA6q6qiHjFfVR25yfQ="
},
"com.electronwill.night-config:toml:3.8.3": {
"url": "https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.8.3/toml-3.8.3.jar",
"hash": "sha1-kLL9bvy0p9W5gQz3j52CSZTXF/I="
},
"com.electronwill.night-config:core:3.8.3": {
"url": "https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.8.3/core-3.8.3.jar",
"hash": "sha1-tEKpXwnjSZJ/WpRey1lEVYcPz08="
},
"net.neoforged:JarJarSelector:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.4.1/JarJarSelector-0.4.1.jar",
"hash": "sha1-+zzHpYryKtKICtuYr21RgSjEfa4="
},
"net.neoforged:JarJarMetadata:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.4.1/JarJarMetadata-0.4.1.jar",
"hash": "sha1-+NoDaD3IFpRVbcPhd8Xju3eub8s="
},
"org.apache.maven:maven-artifact:3.8.5": {
"url": "https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar",
"hash": "sha1-RDP1DAfevvrtBVO9AGj09I1EkxM="
},
"net.jodah:typetools:0.6.3": {
"url": "https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar",
"hash": "sha1-oBqqbdrqnsB+xPIJSHt6RqUmKDo="
},
"net.minecrell:terminalconsoleappender:1.3.0": {
"url": "https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar",
"hash": "sha1-tWLpu2EjXJUg4mKCze5x+PgC0fw="
},
"net.fabricmc:sponge-mixin:0.15.2+mixin.0.8.7": {
"url": "https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.15.2+mixin.0.8.7/sponge-mixin-0.15.2+mixin.0.8.7.jar",
"hash": "sha1-KvLwIdjgKgIg3CenpytGZtZtRMo="
},
"org.openjdk.nashorn:nashorn-core:15.4": {
"url": "https://maven.neoforged.net/releases/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar",
"hash": "sha1-9n9f+qX1Ewz2+5sTPaAMffO1MqU="
},
"org.apache.commons:commons-lang3:3.14.0": {
"url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
"hash": "sha1-HtRxGUsC8sbLc0oM1vbxB8Zzr64="
},
"cpw.mods:bootstraplauncher:2.0.2": {
"url": "https://maven.neoforged.net/releases/cpw/mods/bootstraplauncher/2.0.2/bootstraplauncher-2.0.2.jar",
"hash": "sha1-Gi0HbLwzsFIMus1ZEiRCeyogBH0="
},
"cpw.mods:securejarhandler:3.0.8": {
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/3.0.8/securejarhandler-3.0.8.jar",
"hash": "sha1-wO+Vzs2GmaBEkFOsfZwWB0jZAs0="
},
"org.ow2.asm:asm-commons:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar",
"hash": "sha1-QinkxV/Y4Bwj+f6YhAdcxiiqzFA="
},
"org.ow2.asm:asm-util:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar",
"hash": "sha1-e7nUUOjUy/n54ECWxEu/5/uoCxU="
},
"org.ow2.asm:asm-analysis:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar",
"hash": "sha1-jUnxTVH2Mssdh8iNHOr1DbDYrxs="
},
"org.ow2.asm:asm-tree:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar",
"hash": "sha1-4kQzKhdWTB0VckSTmahC3jWIG+I="
},
"org.ow2.asm:asm:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar",
"hash": "sha1-raIUHAzFLuj1xIzV+kzg55TyIjY="
},
"net.neoforged:JarJarFileSystems:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarFileSystems/0.4.1/JarJarFileSystems-0.4.1.jar",
"hash": "sha1-ePWfid780DLteIsVHKag1ArOeWo="
},
"net.sf.jopt-simple:jopt-simple:5.0.4": {
"url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar",
"hash": "sha1-T9rC++kt+thqpukwFzb2tDQqP1w="
},
"org.slf4j:slf4j-api:2.0.9": {
"url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar",
"hash": "sha1-fPJyb9z7yGEPmnH7PtY5hx8xU0A="
},
"org.antlr:antlr4-runtime:4.13.1": {
"url": "https://maven.neoforged.net/releases/org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar",
"hash": "sha1-FxJbrh2WViTiZe9JVS9kZaK/owc="
},
"com.mojang:logging:1.2.7": {
"url": "https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar",
"hash": "sha1-JMuV/7DjQz/W6ETATmgAnlBMocA="
},
"org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar",
"hash": "sha1-1+ZpPCYGy35zNQR9e7lt7FLbVmU="
},
"org.apache.logging.log4j:log4j-core:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar",
"hash": "sha1-cYOiVRCgKtAMxqldOz0qfTxajcQ="
},
"org.apache.logging.log4j:log4j-api:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar",
"hash": "sha1-vqb+3mMo+rr9fmg2MWGn6mYFq9E="
},
"org.jline:jline-reader:3.20.0": {
"url": "https://maven.neoforged.net/releases/org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar",
"hash": "sha1-jxVBWwIqJbRz6OFsKK6RMYb/ucQ="
},
"org.jline:jline-terminal:3.20.0": {
"url": "https://maven.neoforged.net/releases/org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar",
"hash": "sha1-0N3McI3fUno0VMlBt7kiXMg6Ff8="
},
"commons-io:commons-io:2.15.1": {
"url": "https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar",
"hash": "sha1-8RVg2hiatWOlyONRlBQVQw6TBOo="
},
"net.minecraftforge:srgutils:0.4.15": {
"url": "https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar",
"hash": "sha1-ykCLExdZR48WTgEPrg1zmX4SX7U="
},
"com.google.guava:guava:32.1.2-jre": {
"url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar",
"hash": "sha1-XmTsfgVkVr7zpLxMb9rvceirYxg="
},
"com.google.guava:failureaccess:1.0.1": {
"url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar",
"hash": "sha1-Hc8d44Kgv5Wj2LCElUbIi6wSksk="
},
"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": {
"url": "https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar",
"hash": "sha1-tCFSbF8pcpWt7xyIblJGw51Kxik="
},
"com.google.code.findbugs:jsr305:3.0.2": {
"url": "https://libraries.minecraft.net/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar",
"hash": "sha1-JeouiwwziodzE71GctP+BW6njw0="
},
"org.checkerframework:checker-qual:3.33.0": {
"url": "https://libraries.minecraft.net/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar",
"hash": "sha1-3itgti2kh2RPwR9zTnPIsLQxI48="
},
"com.google.errorprone:error_prone_annotations:2.18.0": {
"url": "https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.18.0/error_prone_annotations-2.18.0.jar",
"hash": "sha1-ibaEJXCW9Uj6Oaffn9qkCdTU35E="
},
"com.google.j2objc:j2objc-annotations:2.8": {
"url": "https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar",
"hash": "sha1-yFJw4wfnuCLxCGuTaJEkuJdo4nM="
},
"com.google.code.gson:gson:2.10.1": {
"url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar",
"hash": "sha1-s63UeNQ4K3jqILFnE5CoWAAv62w="
},
"org.codehaus.plexus:plexus-utils:3.3.0": {
"url": "https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar",
"hash": "sha1-z0O1OR3mI7Nv4GaiESe674LGQCI="
},
"com.machinezoo.noexception:noexception:1.7.1": {
"url": "https://maven.neoforged.net/releases/com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar",
"hash": "sha1-tlMwyY44ofkV+lSm5eykllBePwo="
},
"net.neoforged:neoform:1.21.1-20240808.144430@zip": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoform/1.21.1-20240808.144430/neoform-1.21.1-20240808.144430.zip",
"hash": "sha1-gR4r2G+izaKBLl6OUdcY6ovW0/Q="
},
"net.neoforged.installertools:binarypatcher:2.1.2:fatjar": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/binarypatcher/2.1.2/binarypatcher-2.1.2-fatjar.jar",
"hash": "sha1-dZtj7zk+2AQY7B6k0jPNYVLQJjc="
},
"net.neoforged:AutoRenamingTool:2.0.3:all": {
"url": "https://maven.neoforged.net/releases/net/neoforged/AutoRenamingTool/2.0.3/AutoRenamingTool-2.0.3-all.jar",
"hash": "sha1-2YkMcbQ2b4hsKxAGeCBDpqaBbrY="
},
"net.neoforged.installertools:installertools:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"hash": "sha1-clJMA2L4EtiqTNtMA+m0Xitxrjs="
},
"net.neoforged:srgutils:1.0.0": {
"url": "https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar",
"hash": "sha1-uf5s2rSUmDIXy8FMxvksjmxhZSY="
},
"net.md-5:SpecialSource:1.11.0": {
"url": "https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar",
"hash": "sha1-Q7hMS7jQHPkKKd/uwclYpLZLr0Y="
},
"com.google.code.gson:gson:2.8.9": {
"url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar",
"hash": "sha1-ikMsHWgleB4hoC2y4sM8X94oM7k="
},
"de.siegmar:fastcsv:2.0.0": {
"url": "https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar",
"hash": "sha1-thXybAPt6slmYYuTue5PTu1QquE="
},
"org.ow2.asm:asm-commons:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar",
"hash": "sha1-HypDLRIS9cNSrmB9e2HcriDCCvU="
},
"net.neoforged.installertools:cli-utils:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar",
"hash": "sha1-5aMRXrnimRF0cmdZRr6KcuSZCtY="
},
"com.google.guava:guava:20.0": {
"url": "https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar",
"hash": "sha1-iVB3ASSTiOHtXdz4xB9M4b54Me8="
},
"com.opencsv:opencsv:4.4": {
"url": "https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar",
"hash": "sha1-Ulkyoe30bJynWqnIPTHcGAwYaGU="
},
"org.ow2.asm:asm-analysis:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar",
"hash": "sha1-SwcfIRs3w44On1mYVQGXyFk/atg="
},
"org.ow2.asm:asm-tree:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar",
"hash": "sha1-eNLs1hMYtaWM0E+yN2NsDoa3fZc="
},
"org.ow2.asm:asm:9.3": {
"url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar",
"hash": "sha1-jmMA71HB2AGn7WLQfNIhrKOpBkA="
},
"org.apache.commons:commons-text:1.3": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar",
"hash": "sha1-mr9hcIpmq15V9haaIA2/xYS1Rtk="
},
"org.apache.commons:commons-lang3:3.8.1": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar",
"hash": "sha1-ZQWnKgl9knD3qee/QsQjgoMkd1U="
},
"commons-beanutils:commons-beanutils:1.9.3": {
"url": "https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar",
"hash": "sha1-yEVwPeM03ca0s80mg1RYyxy6Hz0="
},
"org.apache.commons:commons-collections4:4.2": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar",
"hash": "sha1-VOvqCltlPTxoATHnP+gHu494xO0="
},
"commons-logging:commons-logging:1.2": {
"url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar",
"hash": "sha1-S/wSrf5IQr8HtlfwNpxMtSKVVoY="
},
"commons-collections:commons-collections:3.2.2": {
"url": "https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar",
"hash": "sha1-itcv45+oyR6q8Sqtsh4MNmH+JtU="
},
"net.neoforged.installertools:jarsplitter:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar",
"hash": "sha1-inkWvgoOWJiXvqt8g5ByYxBn5I4="
},
"net.neoforged:neoforge:21.1.250:universal": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.250/neoforge-21.1.250-universal.jar",
"hash": "sha1-IUxSk8hZmwqq39v6kkU7ojkYohk="
}
}
}
@@ -0,0 +1,49 @@
{ config, pkgs, ... }:
let
mkTunnel = relay: {
description = "WorldOfSosal WoW reverse tunnel to ${relay.name}";
startLimitIntervalSec = 0;
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
User = "mc-wow-tunnel";
Group = "mc-wow-tunnel";
ExecStart = "${pkgs.openssh}/bin/ssh -NT -i ${config.sops.secrets."minecraft/wow-tunnel-key".path} -o IPQoS=none -o Ciphers=aes256-ctr -o MACs=hmac-sha2-256-etm@openssh.com -o KexAlgorithms=curve25519-sha256 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o ConnectTimeout=10 -R 127.0.0.1:25577:127.0.0.1:25567 mc-wow-relay@${relay.address}";
Restart = "always";
RestartSec = 10;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
};
};
in {
users.groups.mc-wow-tunnel = { };
users.users.mc-wow-tunnel = {
isSystemUser = true;
group = "mc-wow-tunnel";
};
sops.secrets."minecraft/wow-tunnel-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "mc-wow-tunnel";
group = "mc-wow-tunnel";
mode = "0400";
};
programs.ssh.knownHosts.minecraft-wow-relay = {
hostNames = [ "128.140.75.58" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAFpr4DPSaJt0xeuGIfcZBJD3LsJHTdIRIs2Tt9HF+CT";
};
programs.ssh.knownHosts.minecraft-wow-relay-bfs = {
hostNames = [ "91.198.166.181" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICcCn57nlWY5QyEz17kxuAbIX9PkjPwtlGzdJyhy+SQQ";
};
systemd.services.minecraft-wow-tunnel = mkTunnel {
name = "hectic-lab";
address = "128.140.75.58";
};
systemd.services.minecraft-wow-tunnel-bfs = mkTunnel {
name = "bfs.band";
address = "91.198.166.181";
};
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs, ... }:
{
services.minecraft-servers.servers.vanilla = {
enable = true;
jvmOpts = "-Xmx6G -Xms2G";
package = pkgs.minecraftServers.vanilla-1_21_11;
serverProperties = {
server-port = 25565;
difficulty = "hard";
online-mode = true;
view-distance = 32;
level-seed = "8306359138650378643";
pause-when-empty-seconds = 0;
};
};
}
@@ -0,0 +1,312 @@
{
config,
lib,
pkgs,
...
}: let
inherit (lib) mkOption types;
cfg = config.services.minecraft-world-imports;
enabledImports = lib.filterAttrs (_: import: import.enable) cfg;
dataDir = config.services.minecraft-servers.dataDir;
minecraftServers = config.services.minecraft-servers.servers;
targetPairs = lib.mapAttrsToList (_: import:
"${import.serverName}:${import.worldName}") enabledImports;
importerUser = name: let
descriptiveName = "minecraft-map-import-${name}";
in
if builtins.stringLength descriptiveName <= 31
then descriptiveName
else "mc-import-${builtins.substring 0 12 (builtins.hashString "sha256" name)}";
stateDirectory = cacheDir:
if lib.hasPrefix "/var/lib/" cacheDir
then lib.removePrefix "/var/lib/" cacheDir
else null;
stateDirectoryCompatible = cacheDir: let
relative = stateDirectory cacheDir;
components = lib.splitString "/" (if relative == null then "" else relative);
in
relative != null
&& relative != ""
&& lib.all (component: component != "" && component != "." && component != "..") components;
escapeSftp = value:
"\"${lib.replaceStrings ["\\" "\""] ["\\\\" "\\\""] value}\"";
escapeTmpfiles = value:
lib.replaceStrings ["%" " " "\t"] ["%%" "\\x20" "\\x09"] value;
importerServices = lib.mkMerge (lib.mapAttrsToList (name: import: let
user = importerUser name;
unitName = "minecraft-world-import-${name}";
serverUnit = "minecraft-server-${import.serverName}.service";
cacheStateDirectory = stateDirectory import.cacheDir;
serverDir = "${dataDir}/${import.serverName}";
worldDir = "${serverDir}/${import.worldName}";
in {
${unitName} = {
description = "Import Minecraft world ${name}";
before = [ serverUnit ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [ pkgs.coreutils pkgs.findutils pkgs.openssh pkgs.unar ];
serviceConfig = {
Type = "oneshot";
User = user;
Group = user;
RemainAfterExit = true;
TimeoutStartSec = import.timeoutStartSec;
ProtectSystem = "strict";
ProtectHome = true;
PrivateDevices = true;
PrivateTmp = true;
NoNewPrivileges = true;
CapabilityBoundingSet = [ "" ];
ReadWritePaths = [ import.cacheDir dataDir ];
UMask = "0077";
} // lib.optionalAttrs (stateDirectoryCompatible import.cacheDir) {
StateDirectory = cacheStateDirectory;
};
script = ''
set -eu
umask 077
cache_dir=${lib.escapeShellArg import.cacheDir}
server_dir=${lib.escapeShellArg serverDir}
world_dir=${lib.escapeShellArg worldDir}
archive_name=${lib.escapeShellArg import.archiveName}
world_name=${lib.escapeShellArg import.worldName}
archive="$cache_dir/$archive_name"
temporary_archive="$cache_dir/.$archive_name.$$"
extraction_dir="$cache_dir/.minecraft-world-import-${name}.$$"
staged_world="$server_dir/.$world_name.import.$$"
key=${lib.escapeShellArg import.sshKeyFile}
cleanup() {
rm -f "$temporary_archive"
rm -rf "$extraction_dir" "$staged_world"
}
trap cleanup EXIT
mkdir -p "$cache_dir" "$server_dir"
chmod 0700 "$cache_dir"
# The Minecraft module creates server_dir as minecraft:minecraft
# with group write access; this importer must not chmod another user's directory.
if [ -d "$world_dir" ]; then
if [ -f "$world_dir/level.dat" ]; then
exit 0
fi
echo "Minecraft world directory exists but has no level.dat" >&2
exit 1
fi
if [ ! -f "$archive" ]; then
downloaded=false
attempt=1
while [ "$attempt" -le ${toString import.downloadRetries} ]; do
if sftp \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \
-i "$key" \
-b - \
${lib.escapeShellArg "${import.remoteUser}@${import.remoteHost}"} <<EOF
get ${escapeSftp import.remotePath} "$temporary_archive"
EOF
then
downloaded=true
break
fi
rm -f "$temporary_archive"
sleep ${toString import.retryDelaySeconds}
attempt=$((attempt + 1))
done
if [ "$downloaded" != true ]; then
echo "Unable to download Minecraft world ${name}" >&2
exit 1
fi
mv "$temporary_archive" "$archive"
fi
if ! printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$archive" | sha256sum -c -; then
rm -f "$archive"
echo "Cached Minecraft world ${name} checksum mismatch" >&2
exit 1
fi
mkdir -p "$extraction_dir"
unar -quiet -output-directory "$extraction_dir" "$archive"
find "$extraction_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
world_level_dat=$(find "$extraction_dir" -type f -name level.dat -print -quit)
if [ -z "$world_level_dat" ]; then
echo "Minecraft world archive contains no level.dat" >&2
exit 1
fi
mv "$(dirname "$world_level_dat")" "$staged_world"
chgrp -R minecraft "$staged_world"
chmod -R u+rwX,g+rwX,o-rwx "$staged_world"
mv "$staged_world" "$world_dir"
'';
};
"minecraft-server-${import.serverName}" = {
requires = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
}) enabledImports);
in {
options.services.minecraft-world-imports = mkOption {
default = { };
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
enable = lib.mkEnableOption "Minecraft world import ${name}";
serverName = mkOption {
type = types.str;
description = "minecraft-servers server receiving imported world";
};
remoteHost = mkOption {
type = types.str;
description = "SSH host serving world archive";
};
remoteUser = mkOption {
type = types.str;
description = "SSH user used to download world archive";
};
remotePath = mkOption {
type = types.str;
description = "Remote path to world archive";
};
archiveName = mkOption {
type = types.str;
description = "Archive file name inside cache directory";
};
cacheDir = mkOption {
type = types.str;
default = "/var/lib/minecraft-world-imports/${name}";
description = "Persistent archive cache directory";
};
archiveSha256 = mkOption {
type = types.strMatching "[0-9a-fA-F]{64}";
description = "Expected SHA-256 digest of world archive";
};
sshKeyFile = mkOption {
type = types.str;
description = "Runtime path to private SSH key";
};
worldName = mkOption {
type = types.str;
default = "world";
description = "World directory name beneath server directory";
};
hostPublicKey = mkOption {
type = types.str;
description = "Pinned SSH host public key";
};
downloadRetries = mkOption {
type = types.ints.positive;
default = 3;
description = "Maximum SFTP download attempts";
};
retryDelaySeconds = mkOption {
type = types.ints.unsigned;
default = 10;
description = "Delay between SFTP download attempts";
};
timeoutStartSec = mkOption {
type = types.str;
default = "30min";
description = "Importer service start timeout";
};
};
}));
description = "Minecraft worlds imported before selected servers start";
};
config = lib.mkIf (enabledImports != { }) {
assertions = lib.flatten (lib.mapAttrsToList (name: import: [
{
assertion = builtins.match "[A-Za-z0-9_-]+" name != null;
message = "services.minecraft-world-imports.${name}: name must contain only letters, digits, underscores, or hyphens";
}
{
assertion = builtins.stringLength name <= 24;
message = "services.minecraft-world-imports.${name}: name must be at most 24 characters";
}
{
assertion = builtins.match "/.*" import.cacheDir != null;
message = "services.minecraft-world-imports.${name}.cacheDir must be absolute";
}
{
assertion = builtins.match "[A-Za-z0-9_-]+" import.serverName != null;
message = "services.minecraft-world-imports.${name}.serverName must contain only letters, digits, underscores, or hyphens";
}
{
assertion = !lib.hasInfix "\n" import.remotePath && !lib.hasInfix "\r" import.remotePath;
message = "services.minecraft-world-imports.${name}.remotePath must not contain newlines";
}
{
assertion = builtins.hasAttr import.serverName minecraftServers
&& (builtins.getAttr import.serverName minecraftServers).enable;
message = "services.minecraft-world-imports.${name}.serverName must name an enabled Minecraft server";
}
{
assertion = lib.length (lib.unique targetPairs) == lib.length targetPairs;
message = "services.minecraft-world-imports: each server/world target must be unique";
}
{
assertion = builtins.match "[^/]+" import.archiveName != null;
message = "services.minecraft-world-imports.${name}.archiveName must be a file name";
}
{
assertion = builtins.match "[^/]+" import.worldName != null;
message = "services.minecraft-world-imports.${name}.worldName must be a directory name";
}
]) enabledImports);
users.groups = lib.mapAttrs' (name: _: lib.nameValuePair (importerUser name) { }) enabledImports;
users.users = lib.mapAttrs' (name: _: let
user = importerUser name;
in lib.nameValuePair user {
description = "Minecraft world importer ${name}";
isSystemUser = true;
group = user;
extraGroups = [ "minecraft" ];
}) enabledImports;
programs.ssh.knownHosts = lib.mapAttrs' (name: import:
lib.nameValuePair "minecraft-world-import-${name}" {
hostNames = [ import.remoteHost ];
publicKey = import.hostPublicKey;
}) enabledImports;
systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: import:
lib.optional (!stateDirectoryCompatible import.cacheDir)
"d ${escapeTmpfiles import.cacheDir} 0700 ${importerUser name} ${importerUser name} -") enabledImports);
systemd.services = importerServices;
};
}
@@ -0,0 +1,33 @@
{ config, ... }:
{
sops.secrets."minecraft/storage-box-pack-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "mc-pack-worldOfSosal";
group = "mc-pack-worldOfSosal";
mode = "0400";
};
services.minecraft-modpack-imports.worldOfSosal = {
enable = true;
serverName = "wowMineMap";
remoteHost = "u664722.your-storagebox.de";
remoteUser = "u664722";
remotePath = "minecraft/pack/WorldOfSosal-v3.mrpack";
archiveName = "WorldOfSosal.mrpack";
cacheDir = "/var/lib/minecraft-modpacks/worldOfSosal";
archiveSha256 = "f97cf251b14f40590e97e7b39e8a8ec43dacfce6da1b02357d15e0eee10d3ade";
expectedDependencies = {
minecraft = "1.21.1";
neoforge = "21.1.250";
};
sshKeyFile = config.sops.secrets."minecraft/storage-box-pack-key".path;
hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw==";
};
# Import the map before writing modpack configuration into the same server.
systemd.services.minecraft-modpack-import-worldOfSosal = {
after = [ "minecraft-world-import-wowMineMap.service" ];
requires = [ "minecraft-world-import-wowMineMap.service" ];
};
}
@@ -0,0 +1,65 @@
{ config, pkgs, ... }:
{
sops.secrets."minecraft/storage-box-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "minecraft-map-import-wowMineMap";
group = "minecraft-map-import-wowMineMap";
mode = "0400";
};
services.minecraft-world-imports.wowMineMap = {
enable = true;
serverName = "wowMineMap";
remoteHost = "u664722.your-storagebox.de";
remoteUser = "u664722";
remotePath = "minecraft/map/wow mine map.rar";
archiveName = "wow mine map.rar";
cacheDir = "/var/lib/minecraft-maps";
archiveSha256 = "bc80084de10a06b0fc2cb1651c61936b9e2fd2288f3f0fe44c964d83a393aa30";
sshKeyFile = config.sops.secrets."minecraft/storage-box-key".path;
worldName = "world";
hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw==";
};
sops.secrets."minecraft/rcon-password" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "minecraft";
group = "minecraft";
mode = "0400";
restartUnits = [ "minecraft-server-wowMineMap.service" ];
};
# The module's automatic firewall would also expose RCON.
networking.firewall.allowedTCPPorts = [ 25567 ];
services.minecraft-servers.servers.wowMineMap = {
openFirewall = false;
extraStartPre = ''
chmod 600 server.properties
{
printf '\nrcon.password='
cat ${config.sops.secrets."minecraft/rcon-password".path}
printf '\n'
} >> server.properties
'';
enable = true;
jvmOpts = "-Xmx24G -Xms2G";
# WorldOfSosal client and server use the same pinned NeoForge.
package = pkgs.minecraftServers.neoforge-1_21_1.override (
builtins.fromJSON (builtins.readFile ./neoforge-21.1.250.json)
);
serverProperties = {
server-port = 25567;
difficulty = "hard";
online-mode = false;
enable-rcon = true;
"rcon.port" = 25575;
view-distance = 12;
simulation-distance = 8;
motd = "WorldOfSosal — World of Warcraft";
level-name = "world";
pause-when-empty-seconds = 0;
};
};
}
+69 -5
View File
@@ -24,11 +24,11 @@
ollamaPrebuilt = pkgs.stdenvNoCC.mkDerivation { ollamaPrebuilt = pkgs.stdenvNoCC.mkDerivation {
pname = "ollama"; pname = "ollama";
version = "0.22.1"; version = "0.24.0";
src = pkgs.fetchurl { src = pkgs.fetchurl {
url = "https://github.com/ollama/ollama/releases/download/v0.22.1/ollama-linux-amd64.tar.zst"; url = "https://github.com/ollama/ollama/releases/download/v0.24.0/ollama-linux-amd64.tar.zst";
hash = "sha256-4nwP6PYKgkFi+Bzge0v9p2fc5PNX12LhSbPQ3gq62fs="; hash = "sha256-FcX41mugbg07RxnfiGhhLb1m4U6CdgkpuzVS4WV83Ns=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [
@@ -70,7 +70,7 @@ in {
imports = [ imports = [
self.nixosModules.hectic self.nixosModules.hectic
inputs.sops-nix.nixosModules.sops inputs.sops-nix.nixosModules.sops
./minecraft.nix ./minecraft
./hardware.nix ./hardware.nix
]; ];
@@ -139,7 +139,7 @@ in {
}; };
services.nginx = { services.nginx = {
enable = true; enable = false;
virtualHosts."bfs.band" = let virtualHosts."bfs.band" = let
site = pkgs.runCommand "bfs-band-site" {} '' site = pkgs.runCommand "bfs-band-site" {} ''
mkdir -p $out mkdir -p $out
@@ -224,6 +224,70 @@ in {
archetype.dev.enable = true; archetype.dev.enable = true;
}; };
hectic.services."project-zomboid" = {
enable = true;
memory = "8g";
serverName = "servertest";
serverPropertiesFile = /var/lib/project-zomboid/server-password.ini;
serverProperties = {
Map = "Muldraugh, KY";
DoLuaChecksum = false;
Public = true;
AntiCheatSafety = 4;
AntiCheatMovement = 4;
AntiCheatSpeed = 4;
AntiCheatHit = 4;
AntiCheatPacket = 4;
AntiCheatPacketException = 4;
AntiCheatPermission = 4;
AntiCheatXP = 4;
AntiCheatFire = 4;
AntiCheatSafeHouse = 4;
AntiCheatRecipe = 4;
AntiCheatPlayer = 4;
AntiCheatChecksum = 4;
AntiCheatItem = 4;
AntiCheatNoClip = 4;
AntiCheatServerCustomization = 4;
};
workshopItems = [
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
];
mods = [
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
];
sandboxProperties = {
Zombies = 6;
ZombieConfig = {
PopulationMultiplier = 0.0;
PopulationStartMultiplier = 0.0;
PopulationPeakMultiplier = 0.0;
RespawnHours = 0.0;
RespawnUnseenHours = 0.0;
RespawnMultiplier = 0.0;
RedistributeHours = 0.0;
};
};
};
systemd.services.project-zomboid.preStart = lib.mkBefore ''
password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"}
properties_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password.ini"}
if [ ! -s "$password_file" ] || ! ${pkgs.gnugrep}/bin/grep -Eq '^[0-9a-f]{48}$' "$password_file"; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 24 > "$password_file"
fi
${pkgs.coreutils}/bin/chmod 0600 "$password_file"
properties_file_tmp="$( ${pkgs.coreutils}/bin/mktemp "$(dirname "$properties_file")/.server-password.ini.XXXXXX")"
${pkgs.coreutils}/bin/printf 'Password=%s\n' "$(<"$password_file")" > "$properties_file_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$properties_file_tmp"
${pkgs.coreutils}/bin/mv "$properties_file_tmp" "$properties_file"
'';
sops = { sops = {
gnupg.sshKeyPaths = [ ]; gnupg.sshKeyPaths = [ ];
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+14
View File
@@ -6,6 +6,20 @@ in final: prev: (
legacyPackages = self.legacyPackages.${prev.stdenv.hostPlatform.system}; legacyPackages = self.legacyPackages.${prev.stdenv.hostPlatform.system};
in { in {
hectic = packages // legacyPackages; hectic = packages // legacyPackages;
p4d = if final.stdenv.hostPlatform.system == "x86_64-linux" then prev.p4d.overrideAttrs (_: {
version = "2023.1/2797325";
src = final.fetchurl {
url = "https://ftp.perforce.com/pub/perforce/r23.1/bin.linux26x86_64/helix-core-server.tgz";
hash = "sha256-O8znAlq2XjrixG0FA4cfkgcI9t/w9QMHV0spUjYKl48=";
};
}) else prev.p4d;
p4 = prev.p4.overrideAttrs (_: {
version = "2024.1/3006289";
src = final.fetchurl {
url = "https://ftp.perforce.com/pub/perforce/r24.1/bin.tools/p4source.tgz";
hash = "sha256-z3I3cikbbSrmS7dUMMKi6edPnZk2BYAmdO+pfYRJUVQ=";
};
});
postgresql_17 = prev.postgresql_17 // {pkgs = prev.postgresql_17.pkgs // { postgresql_17 = prev.postgresql_17 // {pkgs = prev.postgresql_17.pkgs // {
http = packages.pg-17-ext-http; http = packages.pg-17-ext-http;
pg_smtp_client = packages.pg-17-ext-smtp-client; pg_smtp_client = packages.pg-17-ext-smtp-client;
+11 -5
View File
@@ -1,4 +1,8 @@
{ self, pkgs, inputs, ... }: let { self, pkgs, inputs, system, ... }: let
giteaPkgs = import inputs.nixpkgs-gitea {
inherit system;
config = pkgs.config;
};
rust = { rust = {
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default
@@ -25,11 +29,10 @@
rev = "6ff3b71e3705e0d4081a51c21ca0379e869ba5fb"; rev = "6ff3b71e3705e0d4081a51c21ca0379e869ba5fb";
hash = "sha256-wC/2rAsSDO83UITaFhtaf3do3aaOAko4gnKUOzwURc8="; hash = "sha256-wC/2rAsSDO83UITaFhtaf3do3aaOAko4gnKUOzwURc8=";
}; };
cargo = self.lib.cargoToml src;
in in
buildPgrxExtension pkgs { buildPgrxExtension pkgs {
pname = cargo.package.name; pname = "pg_smtp_client";
version = cargo.package.version; version = "0.2.0";
inherit src postgresql; inherit src postgresql;
@@ -136,7 +139,9 @@ in {
c-hectic = pkgs.callPackage ./c/hectic/default.nix {}; c-hectic = pkgs.callPackage ./c/hectic/default.nix {};
watch = pkgs.callPackage ./c/watch/default.nix {}; watch = pkgs.callPackage ./c/watch/default.nix {};
support-bot = pkgs.callPackage ./support-bot {}; support-bot = pkgs.callPackage ./support-bot {};
gitea-heatmap = pkgs.callPackage ./gitea {}; gitea-heatmap = giteaPkgs.callPackage ./gitea {
nixosTests = pkgs.nixosTests;
};
gitea-runner-nix-image = pkgs.callPackage ./gitea-runner-nix-image {}; gitea-runner-nix-image = pkgs.callPackage ./gitea-runner-nix-image {};
gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {}; gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {};
nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {}; nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {};
@@ -144,6 +149,7 @@ in {
deploy = pkgs.callPackage ./deploy { inherit inputs; }; deploy = pkgs.callPackage ./deploy { inherit inputs; };
element-web = pkgs.callPackage ./element-web {}; element-web = pkgs.callPackage ./element-web {};
shellplot = pkgs.callPackage ./shellplot {}; shellplot = pkgs.callPackage ./shellplot {};
gitea-kanban-tui = pkgs.callPackage ./gitea-kanban-tui rust.commonArgs;
which-country-rs = pkgs.callPackage ./which-country-rs {}; which-country-rs = pkgs.callPackage ./which-country-rs {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {}; onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; }; migrator = pkgs.callPackage ./migrator { inherit self; };
+1453
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "gitea-kanban-tui"
version = "0.1.0"
edition = "2021"
description = "Native-project Gitea Kanban terminal interface"
license = "MIT"
[dependencies]
clap = { version = "=4.5.20", features = ["derive"] }
crossterm = "=0.28.1"
encoding_rs = "=0.8.35"
indexmap = "=2.6.0"
instability = "=0.3.2"
ratatui = "=0.29.0"
reqwest = { version = "=0.11.27", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "=1.0.210", features = ["derive"] }
serde_json = "=1.0.128"
unicode-segmentation = "=1.12.0"
url = "=2.5.2"
+85
View File
@@ -0,0 +1,85 @@
# gitea-kanban
Native Projects Kanban tools for custom Gitea fork. `gitea-kanban-tui` provides
the keyboard interface; `gitea-kanban` provides the command-line interface.
## Configuration
Required configuration can come from flags or environment variables:
| Flag | Environment | Meaning |
| --- | --- | --- |
| `--url` | `GITEA_URL` | Gitea base URL |
| `--token-file` | `GITEA_TOKEN_FILE` | File containing API token |
| — | `GITEA_TOKEN` | API token fallback when no token file is configured |
| `--project` | `GITEA_PROJECT` | Exact native project name |
| `--project-id` | `GITEA_PROJECT_ID` | Native project ID instead of name |
| positional `OWNER` | `GITEA_OWNER` | Repository owner |
| positional `REPO` | `GITEA_REPO` | Repository name |
Native mode requires an enabled repository Projects unit, project read/write
repository permission, and a token with `read:issue`/`write:issue` scope. Issue
creation/editing follows Gitea issue permissions; deletion requires repository
admin permission in this fork. Token values are sent
only through Gitea's `Authorization` header and are never printed. Token-file
input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is
accepted only for loopback development.
```sh
export GITEA_URL=https://gitea.hectic-lab.com
export GITEA_TOKEN_FILE="$HOME/.config/gitea/token"
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \
--project Kanban yukkop util.nix
```
Alternatively:
```sh
nix develop .#ratatui
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \
--project-id 1 owner repo
```
## Keys
- Arrow keys or `h`/`j`/`k`/`l`: focus column/card
- `H`/`L`: move focused card left/right
- `r`: refresh project columns and issues
- `?`: toggle help
- `q`: quit
## CLI
Both binaries use same configuration and API client. CLI commands:
```sh
gitea-kanban --project Kanban owner repo board
gitea-kanban --project Kanban owner repo create --title "Fix issue" --body "Details"
gitea-kanban --project Kanban owner repo edit 42 --title "Updated" --body "Changed"
gitea-kanban --project Kanban owner repo move 123 --column-id 7
gitea-kanban --project Kanban owner repo delete 42 --yes
```
`delete` requires repository admin permission. `move` takes global issue and
project-column IDs.
Native mode reads `/projects`, project columns, and each column's issues. Moves
use the issue's global API `id` and destination column `id`; optional sorting is
supported by the server API. Project names are exact, case-sensitive matches;
use `--project-id` when duplicate names exist. Empty native boards render
normally. `n` creates an issue assigned to the selected project, `e` edits the
focused issue title/body, and `d` deletes it after confirmation. In the editor,
`Enter` switches from title to body, `Tab` switches fields, `Ctrl-S` saves, and
`Esc` cancels. Closed projects, archived repositories, disabled Projects units,
unassigned issues, and cross-repository IDs are rejected by the server.
## Development
```sh
nix develop .#ratatui
cargo fmt --manifest-path package/gitea-kanban-tui/Cargo.toml -- --check
cargo clippy --manifest-path package/gitea-kanban-tui/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path package/gitea-kanban-tui/Cargo.toml
nix build .#gitea-kanban-tui
```
+32
View File
@@ -0,0 +1,32 @@
{
cargoToml,
lib,
nativeBuildInputs,
pkgs,
...
}: let
cargo = cargoToml ./Cargo.toml;
in
pkgs.rustPlatform.buildRustPackage {
pname = cargo.package.name;
version = cargo.package.version;
src = ./.;
inherit nativeBuildInputs;
cargoLock.lockFile = ./Cargo.lock;
doCheck = true;
postBuild = ''
cargo build --release --offline --bin gitea-kanban
'';
postInstall = ''
install -Dm755 target/*/release/gitea-kanban $out/bin/gitea-kanban
'';
meta = {
description = cargo.package.description;
license = lib.licenses.mit;
mainProgram = "gitea-kanban-tui";
};
}
+542
View File
@@ -0,0 +1,542 @@
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use reqwest::{StatusCode, Url};
use serde::de::DeserializeOwned;
use crate::config::Config;
use crate::model::{
CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectBoard,
ProjectColumn,
};
pub trait GiteaApi {
fn list_projects(&self) -> Result<Vec<Project>, ApiError>;
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError>;
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError>;
fn move_project_issue(
&self,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError>;
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError>;
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>;
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>;
}
pub struct GiteaClient {
client: Client,
api_base: Url,
token: String,
}
#[derive(Debug)]
pub struct ApiError(String);
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ApiError {}
pub fn resolve_project(
projects: &[Project],
project_name: Option<&str>,
project_id: Option<u64>,
) -> Result<Project, ApiError> {
if let Some(id) = project_id {
return projects
.iter()
.find(|project| project.id == id)
.cloned()
.ok_or_else(|| ApiError(format!("project ID {id} was not found in this repository")));
}
let name = project_name.ok_or_else(|| ApiError("project selector is missing".to_owned()))?;
let matches = projects
.iter()
.filter(|project| project.title == name)
.cloned()
.collect::<Vec<_>>();
match matches.as_slice() {
[project] => Ok(project.clone()),
[] => Err(ApiError(format!(
"project named '{name}' was not found; names are matched exactly"
))),
_ => Err(ApiError(format!(
"multiple projects are named '{name}'; use --project-id"
))),
}
}
pub fn load_project_board(
client: &impl GiteaApi,
config: &Config,
) -> Result<ProjectBoard, ApiError> {
let projects = client.list_projects()?;
let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?;
let columns = client.list_project_columns(project.id)?;
let issues_by_column = columns
.iter()
.map(|column| client.list_project_column_issues(project.id, column.id))
.collect::<Result<Vec<_>, _>>()?;
Ok(ProjectBoard {
project,
columns,
issues_by_column,
})
}
impl GiteaClient {
pub fn new(config: &Config) -> Result<Self, ApiError> {
let mut api_base = Url::parse(&config.base_url)
.map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?;
if !api_base.username().is_empty() || api_base.password().is_some() {
return Err(ApiError(
"Gitea URL must not contain embedded username or password".to_owned(),
));
}
require_secure_transport(&api_base)?;
api_base.set_query(None);
api_base.set_fragment(None);
api_base
.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot be used as an API base".to_owned()))?
.pop_if_empty()
.extend(["api", "v1", "repos", &config.owner, &config.repo]);
let client = Client::builder()
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.user_agent(concat!("gitea-kanban-tui/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| ApiError(format!("cannot create HTTP client: {error}")))?;
Ok(Self {
client,
api_base,
token: config.token.clone(),
})
}
fn endpoint(&self, path: &str) -> Result<Url, ApiError> {
let mut url = self.api_base.clone();
url.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot contain API paths".to_owned()))?
.extend(path.split('/').filter(|part| !part.is_empty()));
Ok(url)
}
fn decode<T: DeserializeOwned>(
&self,
response: Response,
operation: &str,
) -> Result<T, ApiError> {
let response = check_response(response, operation)?;
response.json().map_err(|error| {
ApiError(format!(
"Gitea returned invalid JSON while {operation}: {error}"
))
})
}
fn get_all<T: DeserializeOwned>(
&self,
path: &str,
operation: &str,
query: &[(&str, &str)],
) -> Result<Vec<T>, ApiError> {
let mut items = Vec::new();
for page in 1..=10_000_u32 {
let page_value = page.to_string();
let mut page_query = query.to_vec();
page_query.extend([("limit", "100"), ("page", page_value.as_str())]);
let response = self
.client
.get(self.endpoint(path)?)
.query(&page_query)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!("cannot reach Gitea while {operation}: {error}"))
})?;
let page_items: Vec<T> = self.decode(response, operation)?;
if page_items.is_empty() {
return Ok(items);
}
items.extend(page_items);
}
Err(ApiError(format!(
"Gitea returned too many pages while {operation}; narrow repository data or check server pagination"
)))
}
}
impl GiteaApi for GiteaClient {
fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
self.get_all(
"projects",
"listing repository projects",
&[("state", "all")],
)
}
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError> {
let response = self
.client
.get(self.endpoint(&format!("projects/{project_id}/columns"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while listing project columns: {error}"
))
})?;
self.decode(response, "listing project columns")
}
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError> {
self.get_all(
&format!("projects/{project_id}/columns/{column_id}/issues"),
"listing project issues",
&[],
)
}
fn move_project_issue(
&self,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError> {
let response = self
.client
.post(self.endpoint(&format!("projects/{project_id}/issues/{issue_id}/move"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while moving project issue: {error}"
))
})?;
check_response(response, "moving project issue")?;
Ok(())
}
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError> {
let response = self
.client
.post(self.endpoint("issues")?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!("cannot reach Gitea while creating issue: {error}"))
})?;
self.decode(response, "creating issue")
}
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError> {
let response = self
.client
.patch(self.endpoint(&format!("issues/{issue_number}"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while editing issue #{issue_number}: {error}"
))
})?;
self.decode(response, &format!("editing issue #{issue_number}"))
}
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError> {
let response = self
.client
.delete(self.endpoint(&format!("issues/{issue_number}"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while deleting issue #{issue_number}: {error}"
))
})?;
check_response(response, &format!("deleting issue #{issue_number}"))?;
Ok(())
}
}
fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let guidance = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
" Check token validity and repository issue permissions."
}
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
" Check repository owner/name and whether this Gitea version supports native project APIs."
}
_ => "",
};
Err(ApiError(format!(
"Gitea API failed while {operation} ({status}).{guidance}"
)))
}
fn require_secure_transport(url: &Url) -> Result<(), ApiError> {
if url.scheme() == "https" {
return Ok(());
}
let loopback = url
.host_str()
.and_then(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.ok()
})
.is_some_and(|address| address.is_loopback());
if url.scheme() == "http" && loopback {
return Ok(());
}
Err(ApiError(
"Gitea URL must use HTTPS to protect the API token; plain HTTP is allowed only for loopback development"
.to_owned(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc::{self, Receiver};
use std::thread;
fn config(base_url: &str) -> Config {
Config {
base_url: base_url.to_owned(),
token: "secret".to_owned(),
owner: "owner name".to_owned(),
repo: "repo/name".to_owned(),
project: Some("Kanban".to_owned()),
project_id: None,
}
}
fn mock_server(responses: Vec<&'static str>) -> (String, Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let address = listener.local_addr().expect("mock address");
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4);
if let Some(header_end) = header_end {
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")?
.parse::<usize>()
.ok()
})
.unwrap_or(0);
if request.len() >= header_end + content_length {
break;
}
}
}
let _ = sender.send(String::from_utf8(request).expect("UTF-8 request"));
stream
.write_all(response.as_bytes())
.expect("write response");
}
});
(format!("http://{address}"), receiver)
}
#[test]
fn preserves_base_path_and_encodes_repository_segments() {
let client = GiteaClient::new(&config("https://gitea.example/subpath"))
.expect("client should build");
assert_eq!(
client.endpoint("projects").expect("endpoint").as_str(),
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/projects"
);
}
#[test]
fn rejects_remote_plain_http_but_allows_loopback() {
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
assert!(GiteaClient::new(&config("http://localhost:3000")).is_err());
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
assert!(GiteaClient::new(&config("http://[::1]:3000")).is_ok());
}
#[test]
fn resolves_exact_project_name_and_rejects_ambiguity() {
let projects = vec![
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
Project {
id: 2,
title: "kanban".to_owned(),
is_closed: false,
},
];
assert_eq!(
resolve_project(&projects, Some("Kanban"), None)
.expect("match")
.id,
1
);
assert!(resolve_project(&projects, Some("Missing"), None).is_err());
assert_eq!(
resolve_project(&projects, None, Some(2)).expect("id").title,
"kanban"
);
}
#[test]
fn lists_projects_with_pagination_and_reports_unsupported_api() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":4,\"title\":\"Kanban\",\"is_closed\":false}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let projects = client.list_projects().expect("projects");
assert_eq!(projects[0].title, "Kanban");
let first = requests.recv().expect("first request");
let second = requests.recv().expect("second request");
assert!(first.starts_with("GET /api/v1/repos/owner%20name/repo%2Fname/projects?"));
assert!(first.contains("state=all"));
assert!(first.contains("page=1"));
assert!(second.contains("page=2"));
assert!(first.contains("authorization: token secret"));
let (base_url, _) = mock_server(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable secret\"}",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let error = client
.list_project_columns(4)
.expect_err("unsupported API must fail");
assert!(error.to_string().contains("404 Not Found"));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn sends_global_issue_move_payload() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
client
.move_project_issue(
4,
99,
&MoveProjectIssuePayload {
column_id: 12,
sorting: Some(3),
},
)
.expect("move");
let request = requests.recv().expect("request");
assert!(request.starts_with(
"POST /api/v1/repos/owner%20name/repo%2Fname/projects/4/issues/99/move HTTP/1.1"
));
assert!(request.ends_with("{\"column_id\":12,\"sorting\":3}"));
}
#[test]
fn paginates_native_column_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":99,\"number\":7,\"title\":\"Fix\"}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let issues = client
.list_project_column_issues(4, 12)
.expect("column issues");
assert_eq!(issues[0].id, 99);
assert!(requests.recv().expect("page one").contains("page=1"));
assert!(requests.recv().expect("page two").contains("page=2"));
}
#[test]
fn creates_edits_and_deletes_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"New\"}",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"Updated\"}",
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let created = client
.create_issue(&CreateIssuePayload {
title: "New".to_owned(),
body: "Details".to_owned(),
projects: vec![4],
})
.expect("create");
assert_eq!(created.number, 7);
let edited = client
.edit_issue(
7,
&EditIssuePayload {
title: "Updated".to_owned(),
body: "Changed".to_owned(),
},
)
.expect("edit");
assert_eq!(edited.title, "Updated");
client.delete_issue(7).expect("delete");
assert!(requests
.recv()
.expect("create request")
.ends_with("{\"title\":\"New\",\"body\":\"Details\",\"projects\":[4]}"));
assert!(requests
.recv()
.expect("edit request")
.starts_with("PATCH /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
assert!(requests
.recv()
.expect("delete request")
.starts_with("DELETE /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
}
}
+502
View File
@@ -0,0 +1,502 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::{
ColumnSpec, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
};
#[derive(Clone, Debug)]
pub struct Column {
pub spec: ColumnSpec,
pub cards: Vec<Issue>,
}
pub struct App {
pub board_title: String,
pub columns: Vec<Column>,
pub focused_column: usize,
pub focused_cards: Vec<usize>,
pub status: String,
pub show_help: bool,
pub editor: Option<EditorState>,
project_id: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditorMode {
Create { project_id: u64 },
Edit { issue_number: u64 },
Delete { issue_number: u64, title: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorField {
Title,
Body,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorState {
pub mode: EditorMode,
pub field: EditorField,
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IssueAction {
Create {
project_id: u64,
title: String,
body: String,
},
Edit {
issue_number: u64,
payload: EditIssuePayload,
},
Delete {
issue_number: u64,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum MoveAction {
Project {
issue_id: u64,
project_id: u64,
payload: MoveProjectIssuePayload,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct MoveRequest {
pub source: usize,
pub target: usize,
pub action: MoveAction,
}
impl App {
pub fn new_project(
project: Project,
project_columns: Vec<ProjectColumn>,
issues_by_column: Vec<Vec<Issue>>,
) -> Result<Self, String> {
if project_columns.is_empty() {
return Err(format!("project '{}' has no columns", project.title));
}
if project_columns.len() != issues_by_column.len() {
return Err("project columns and issue lists do not match".to_owned());
}
let columns = project_columns
.into_iter()
.zip(issues_by_column)
.map(|(column, cards)| Column {
spec: ColumnSpec {
id: column.id,
title: column.title,
},
cards,
})
.collect::<Vec<_>>();
let focused_column = columns
.iter()
.position(|column| !column.cards.is_empty())
.unwrap_or(0);
let focused_cards = vec![0; columns.len()];
Ok(Self {
board_title: format!("{} (project {})", project.title, project.id),
columns,
focused_column,
focused_cards,
status: "Ready".to_owned(),
show_help: false,
editor: None,
project_id: project.id,
})
}
pub fn focus_left(&mut self) {
self.focused_column = self.focused_column.saturating_sub(1);
}
pub fn focus_right(&mut self) {
self.focused_column = (self.focused_column + 1).min(self.columns.len() - 1);
}
pub fn focus_up(&mut self) {
let selected = &mut self.focused_cards[self.focused_column];
*selected = selected.saturating_sub(1);
}
pub fn focus_down(&mut self) {
let column = &self.columns[self.focused_column];
if !column.cards.is_empty() {
let selected = &mut self.focused_cards[self.focused_column];
*selected = (*selected + 1).min(column.cards.len() - 1);
}
}
pub fn focused_card(&self) -> Option<&Issue> {
self.columns[self.focused_column]
.cards
.get(self.focused_cards[self.focused_column])
}
pub fn prepare_move(&self, offset: isize) -> Option<MoveRequest> {
let target = self.focused_column.checked_add_signed(offset)?;
if target >= self.columns.len() {
return None;
}
let issue = self.focused_card()?;
let action = MoveAction::Project {
issue_id: issue.id,
project_id: self.project_id,
payload: MoveProjectIssuePayload {
column_id: self.columns[target].spec.id,
sorting: None,
},
};
Some(MoveRequest {
source: self.focused_column,
target,
action,
})
}
pub fn apply_move(&mut self, request: MoveRequest) {
let selected = self.focused_cards[request.source];
let issue = self.columns[request.source].cards.remove(selected);
let issue_number = issue.number;
self.columns[request.target].cards.push(issue);
self.focused_cards[request.source] =
selected.min(self.columns[request.source].cards.len().saturating_sub(1));
self.focused_cards[request.target] = self.columns[request.target].cards.len() - 1;
self.focused_column = request.target;
self.status = format!("Moved issue #{issue_number}");
}
pub fn begin_create(&mut self) {
self.editor = Some(EditorState {
mode: EditorMode::Create {
project_id: self.project_id,
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn begin_edit(&mut self) {
let Some(issue) = self.focused_card().cloned() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Edit {
issue_number: issue.number,
},
field: EditorField::Title,
title: issue.title,
body: issue.body.unwrap_or_default(),
});
}
pub fn begin_delete(&mut self) {
let Some(issue) = self.focused_card() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Delete {
issue_number: issue.number,
title: issue.title.clone(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option<IssueAction> {
let mut editor = self.editor.take()?;
if matches!(editor.mode, EditorMode::Delete { .. }) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let EditorMode::Delete { issue_number, .. } = editor.mode {
return Some(IssueAction::Delete { issue_number });
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.status = "Delete cancelled".to_owned();
}
_ => {
self.editor = Some(editor);
}
}
return None;
}
if key.code == KeyCode::Esc {
self.status = "Edit cancelled".to_owned();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
return self.submit_editor(editor);
}
match key.code {
KeyCode::Tab => {
editor.field = match editor.field {
EditorField::Title => EditorField::Body,
EditorField::Body => EditorField::Title,
};
}
KeyCode::Enter if editor.field == EditorField::Title => {
editor.field = EditorField::Body;
}
KeyCode::Enter => editor.body.push('\n'),
KeyCode::Backspace => match editor.field {
EditorField::Title => {
editor.title.pop();
}
EditorField::Body => {
editor.body.pop();
}
},
KeyCode::Char(character) if !character.is_control() => match editor.field {
EditorField::Title => editor.title.push(character),
EditorField::Body => editor.body.push(character),
},
_ => {}
}
self.editor = Some(editor);
None
}
pub fn restore_issue_action(&mut self, action: IssueAction) {
self.editor = Some(match action {
IssueAction::Create {
project_id,
title,
body,
} => EditorState {
mode: EditorMode::Create { project_id },
field: EditorField::Body,
title,
body,
},
IssueAction::Edit {
issue_number,
payload,
} => EditorState {
mode: EditorMode::Edit { issue_number },
field: EditorField::Body,
title: payload.title,
body: payload.body,
},
IssueAction::Delete { issue_number } => EditorState {
mode: EditorMode::Delete {
issue_number,
title: self
.focused_card()
.map(|issue| issue.title.clone())
.unwrap_or_default(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
},
});
}
fn submit_editor(&mut self, editor: EditorState) -> Option<IssueAction> {
if editor.title.trim().is_empty() {
self.status = "Title cannot be empty".to_owned();
self.editor = Some(editor);
return None;
}
match editor.mode {
EditorMode::Create { project_id } => Some(IssueAction::Create {
project_id,
title: editor.title,
body: editor.body,
}),
EditorMode::Edit { issue_number } => Some(IssueAction::Edit {
issue_number,
payload: EditIssuePayload {
title: editor.title,
body: editor.body,
},
}),
EditorMode::Delete { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
fn app() -> App {
App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![
Issue {
id: 1,
number: 1,
title: "First".to_owned(),
body: None,
},
Issue {
id: 2,
number: 2,
title: "Second".to_owned(),
body: None,
},
],
Vec::new(),
],
)
.expect("board builds")
}
#[test]
fn navigation_stays_within_board() {
let mut app = app();
app.focus_left();
app.focus_up();
assert_eq!(app.focused_column, 0);
assert_eq!(app.focused_cards[0], 0);
app.focus_down();
app.focus_down();
app.focus_right();
app.focus_right();
assert_eq!(app.focused_cards[0], 1);
assert_eq!(app.focused_column, 1);
}
#[test]
fn cannot_move_past_board_edge() {
let app = app();
assert!(app.prepare_move(-1).is_none());
}
#[test]
fn empty_board_returns_actionable_error() {
let result = App::new_project(
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 1,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
);
assert!(result.is_ok());
}
#[test]
fn native_move_uses_global_issue_and_column_ids() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![Issue {
id: 99,
number: 7,
title: "Fix".to_owned(),
body: None,
}],
Vec::new(),
],
)
.expect("board");
let request = app.prepare_move(1).expect("move");
assert!(matches!(
request.action,
MoveAction::Project { issue_id: 99, ref payload, .. } if payload.column_id == 20
));
app.apply_move(request);
assert_eq!(app.columns[1].cards[0].id, 99);
}
#[test]
fn native_editor_creates_project_issue_action() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
)
.expect("board");
app.begin_create();
for character in "New issue".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
app.handle_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
for character in "Details".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
let action =
app.handle_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(matches!(
action,
Some(IssueAction::Create {
project_id: 8,
title,
body,
}) if title == "New issue" && body == "Details"
));
assert!(app.editor.is_none());
}
}
@@ -0,0 +1,145 @@
use std::error::Error;
use clap::{Parser, Subcommand};
use gitea_kanban_tui::api::{load_project_board, GiteaClient};
use gitea_kanban_tui::config::{Config, SharedArgs};
use gitea_kanban_tui::model::EditIssuePayload;
use gitea_kanban_tui::operations;
use gitea_kanban_tui::text::sanitize_terminal_text;
#[derive(Debug, Parser)]
#[command(
version,
about = "Control native Gitea project boards from the command line"
)]
struct Cli {
#[command(flatten)]
shared: SharedArgs,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Print project columns and their issues.
Board,
/// Create an issue assigned to selected project.
Create {
#[arg(long)]
title: String,
#[arg(long, default_value = "")]
body: String,
},
/// Replace issue title and body.
Edit {
issue: u64,
#[arg(long)]
title: String,
#[arg(long)]
body: String,
},
/// Delete an issue. Requires repository admin permission.
Delete {
issue: u64,
/// Confirm irreversible deletion.
#[arg(long)]
yes: bool,
},
/// Move an issue to a project column.
Move {
issue_id: u64,
#[arg(long)]
column_id: u64,
#[arg(long)]
sorting: Option<u64>,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
let config = Config::from_shared_args_with(cli.shared, |name| std::env::var(name).ok())?;
let client = GiteaClient::new(&config)?;
match cli.command {
Command::Board => {
let board = load_project_board(&client, &config)?;
println!(
"{} (project {})",
sanitize_terminal_text(&board.project.title),
board.project.id
);
for (column, issues) in board.columns.iter().zip(board.issues_by_column) {
println!(
"\n{} ({})",
sanitize_terminal_text(&column.title),
column.id
);
for issue in issues {
println!(
" #{} {}",
issue.number,
sanitize_terminal_text(&issue.title)
);
}
}
}
Command::Create { title, body } => {
let board = load_project_board(&client, &config)?;
let issue = operations::create_project_issue(&client, board.project.id, title, body)?;
println!("created issue #{}", issue.number);
}
Command::Edit { issue, title, body } => {
let board = load_project_board(&client, &config)?;
if !board
.issues_by_column
.iter()
.flatten()
.any(|candidate| candidate.number == issue)
{
return Err(format!("issue #{issue} is not assigned to selected project").into());
}
operations::edit_issue(&client, issue, &EditIssuePayload { title, body })?;
println!("updated issue #{issue}");
}
Command::Delete { issue, yes } => {
if !yes {
return Err("deletion requires --yes".into());
}
let board = load_project_board(&client, &config)?;
if !board
.issues_by_column
.iter()
.flatten()
.any(|candidate| candidate.number == issue)
{
return Err(format!("issue #{issue} is not assigned to selected project").into());
}
operations::delete_issue(&client, issue)?;
println!("deleted issue #{issue}");
}
Command::Move {
issue_id,
column_id,
sorting,
} => {
let board = load_project_board(&client, &config)?;
operations::move_project_issue(
&client,
board.project.id,
issue_id,
&gitea_kanban_tui::model::MoveProjectIssuePayload { column_id, sorting },
)?;
println!("moved issue {issue_id} to column {column_id}");
}
}
Ok(())
}
+303
View File
@@ -0,0 +1,303 @@
use std::env;
use std::fmt;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use clap::{Args as ClapArgs, Parser};
#[derive(Clone, Debug, ClapArgs)]
pub struct SharedArgs {
/// Gitea base URL; falls back to GITEA_URL
#[arg(long)]
pub url: Option<String>,
/// File containing API token; falls back to GITEA_TOKEN_FILE
#[arg(long, value_name = "PATH")]
pub token_file: Option<PathBuf>,
/// Exact native project name
#[arg(long)]
pub project: Option<String>,
/// Native project ID
#[arg(long)]
pub project_id: Option<u64>,
/// Repository owner; falls back to GITEA_OWNER
pub owner: Option<String>,
/// Repository name; falls back to GITEA_REPO
pub repo: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
name = "gitea-kanban-tui",
version,
about = "Browse and move Gitea issues using native projects"
)]
pub struct Args {
#[command(flatten)]
pub shared: SharedArgs,
}
#[derive(Debug)]
pub struct Config {
pub base_url: String,
pub token: String,
pub owner: String,
pub repo: String,
pub project: Option<String>,
pub project_id: Option<u64>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
Self::from_args_with(Args::parse(), |name| env::var(name).ok())
}
pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
Self::from_shared_args_with(args.shared, env_var)
}
pub fn from_shared_args_with<F>(args: SharedArgs, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let base_url = required(
args.url.or_else(|| env_var("GITEA_URL")),
"Gitea URL",
"--url or GITEA_URL",
)?;
let owner = required(
args.owner.or_else(|| env_var("GITEA_OWNER")),
"repository owner",
"OWNER argument or GITEA_OWNER",
)?;
let repo = required(
args.repo.or_else(|| env_var("GITEA_REPO")),
"repository name",
"REPO argument or GITEA_REPO",
)?;
let project = args
.project
.or_else(|| env_var("GITEA_PROJECT"))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
let project_id = args
.project_id
.or_else(|| env_var("GITEA_PROJECT_ID").and_then(|value| value.parse::<u64>().ok()));
if project.is_some() == project_id.is_some() {
return Err(ConfigError(
"projects mode requires exactly one of --project/GITEA_PROJECT or --project-id/GITEA_PROJECT_ID"
.to_owned(),
));
}
let token = if let Some(path) = args.token_file {
read_token_file(path)?
} else if let Some(path) = env_var("GITEA_TOKEN_FILE") {
read_token_file(PathBuf::from(path))?
} else if let Some(token) = env_var("GITEA_TOKEN") {
clean_token(token, "GITEA_TOKEN")?
} else {
return Err(ConfigError(
"missing Gitea token; use --token-file, GITEA_TOKEN, or GITEA_TOKEN_FILE"
.to_owned(),
));
};
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
token,
owner,
repo,
project,
project_id,
})
}
}
fn required(value: Option<String>, name: &str, source: &str) -> Result<String, ConfigError> {
match value.map(|value| value.trim().to_owned()) {
Some(value) if !value.is_empty() => Ok(value),
_ => Err(ConfigError(format!("missing {name}; use {source}"))),
}
}
fn clean_token(token: String, source: &str) -> Result<String, ConfigError> {
let token = token.trim().to_owned();
if token.is_empty() {
Err(ConfigError(format!("{source} contains an empty token")))
} else {
Ok(token)
}
}
fn read_token_file(path: PathBuf) -> Result<String, ConfigError> {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
ConfigError(format!(
"cannot inspect token file {}: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ConfigError(format!(
"token path {} is not a regular file",
path.display()
)));
}
#[cfg(unix)]
if metadata.permissions().mode() & 0o077 != 0 {
return Err(ConfigError(format!(
"token file {} must not be group- or world-readable",
path.display()
)));
}
let token = fs::read_to_string(&path).map_err(|error| {
ConfigError(format!(
"cannot read token file {}: {error}",
path.display()
))
})?;
clean_token(token, &format!("token file {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_env(_: &str) -> Option<String> {
None
}
fn token_env(name: &str) -> Option<String> {
(name == "GITEA_TOKEN").then(|| "secret".to_owned())
}
#[test]
fn parses_explicit_configuration() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example/",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.base_url, "https://gitea.example");
assert_eq!(config.owner, "owner");
assert_eq!(config.repo, "repo");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.token, "secret");
}
#[test]
fn parses_native_project_by_name() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.project_id, None);
}
#[test]
fn requires_project_selector() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error =
Config::from_args_with(args, token_env).expect_err("project selector is required");
assert!(error.to_string().contains("--project"));
}
#[test]
fn native_project_selector_is_unambiguous() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("selector is required");
assert!(error.to_string().contains("exactly one"));
}
#[test]
fn falls_back_to_environment() {
let args = Args::try_parse_from(["gitea-kanban-tui"]).expect("arguments parse");
let config = Config::from_args_with(args, |name| {
match name {
"GITEA_URL" => Some("https://gitea.example"),
"GITEA_TOKEN" => Some("secret"),
"GITEA_PROJECT" => Some("Kanban"),
"GITEA_OWNER" => Some("owner"),
"GITEA_REPO" => Some("repo"),
_ => None,
}
.map(str::to_owned)
})
.expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.owner, "owner");
}
#[test]
fn reports_missing_token_without_exposing_values() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let error = match Config::from_args_with(args, empty_env) {
Ok(_) => panic!("token should be required"),
Err(error) => error,
};
assert!(error.to_string().contains("GITEA_TOKEN_FILE"));
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod api;
pub mod app;
pub mod config;
pub mod model;
pub mod operations;
pub mod terminal;
pub mod text;
pub mod ui;
+125
View File
@@ -0,0 +1,125 @@
use std::error::Error;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use gitea_kanban_tui::api::{load_project_board, GiteaApi, GiteaClient};
use gitea_kanban_tui::app::{App, IssueAction, MoveAction};
use gitea_kanban_tui::config::Config;
use gitea_kanban_tui::operations;
use gitea_kanban_tui::terminal::TerminalGuard;
use gitea_kanban_tui::text::sanitize_terminal_text;
use gitea_kanban_tui::ui;
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let config = Config::load()?;
let client = GiteaClient::new(&config)?;
let mut app = load_board(&client, &config)?;
let repository = format!("{}/{}", config.owner, config.repo);
let mut terminal = TerminalGuard::enter()?;
loop {
terminal
.terminal()
.draw(|frame| ui::draw(frame, &app, &repository))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if app.editor.is_some() {
if let Some(action) = app.handle_editor_key(key) {
issue_action(&client, &config, &mut app, action);
}
continue;
}
match key.code {
KeyCode::Char('q') => break,
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
KeyCode::Right | KeyCode::Char('l') => app.focus_right(),
KeyCode::Up | KeyCode::Char('k') => app.focus_up(),
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
KeyCode::Char('H') => move_card(&client, &mut app, -1),
KeyCode::Char('L') => move_card(&client, &mut app, 1),
KeyCode::Char('n') => app.begin_create(),
KeyCode::Char('e') => app.begin_edit(),
KeyCode::Char('d') => app.begin_delete(),
KeyCode::Char('r') => match load_board(&client, &config) {
Ok(board) => app = board,
Err(error) => app.status = format!("Refresh failed: {error}"),
},
KeyCode::Char('?') => app.show_help = !app.show_help,
_ => {}
}
}
Ok(())
}
fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: IssueAction) {
let retry_action = action.clone();
let message = match action {
IssueAction::Create {
project_id,
title,
body,
} => operations::create_project_issue(client, project_id, title, body)
.map(|issue| format!("Created issue #{}", issue.number)),
IssueAction::Edit {
issue_number,
payload,
} => operations::edit_issue(client, issue_number, &payload)
.map(|_| format!("Updated issue #{issue_number}")),
IssueAction::Delete { issue_number } => operations::delete_issue(client, issue_number)
.map(|_| format!("Deleted issue #{issue_number}")),
};
match message {
Ok(message) => match load_board(client, config) {
Ok(mut board) => {
board.status = message;
*app = board;
}
Err(error) => app.status = format!("Saved, refresh failed: {error}"),
},
Err(error) => {
app.restore_issue_action(retry_action);
app.status = format!("Issue operation failed: {error}");
}
}
}
fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
let board = load_project_board(client, config)?;
App::new_project(board.project, board.columns, board.issues_by_column)
.map_err(|error| error.into())
}
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
let Some(request) = app.prepare_move(offset) else {
app.status = "Cannot move card beyond board edge".to_owned();
return;
};
let result = match &request.action {
MoveAction::Project {
issue_id,
project_id,
payload,
} => operations::move_project_issue(client, *project_id, *issue_id, payload),
};
match result {
Ok(()) => app.apply_move(request),
Err(error) => app.status = format!("Move failed: {error}"),
}
}
+120
View File
@@ -0,0 +1,120 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Issue {
pub id: u64,
pub number: u64,
pub title: String,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CreateIssuePayload {
pub title: String,
pub body: String,
pub projects: Vec<u64>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct EditIssuePayload {
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ColumnSpec {
pub id: u64,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Project {
pub id: u64,
pub title: String,
#[serde(default)]
pub is_closed: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ProjectColumn {
pub id: u64,
pub title: String,
#[serde(default)]
pub color: String,
#[serde(default)]
pub sorting: i8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBoard {
pub project: Project,
pub columns: Vec<ProjectColumn>,
pub issues_by_column: Vec<Vec<Issue>>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct MoveProjectIssuePayload {
pub column_id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub sorting: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_native_project_data_and_move_payload() {
let project: Project = serde_json::from_value(serde_json::json!({
"id": 4,
"title": "Kanban",
"is_closed": false
}))
.expect("project JSON");
let column: ProjectColumn = serde_json::from_value(serde_json::json!({
"id": 9,
"title": "Doing",
"sorting": 1
}))
.expect("column JSON");
let issue: Issue = serde_json::from_value(serde_json::json!({
"id": 70,
"number": 7,
"title": "Fix it"
}))
.expect("issue JSON");
let payload = MoveProjectIssuePayload {
column_id: column.id,
sorting: Some(3),
};
assert_eq!(project.title, "Kanban");
assert_eq!(issue.id, 70);
assert_eq!(
serde_json::to_value(payload).expect("move payload"),
serde_json::json!({"column_id": 9, "sorting": 3})
);
}
#[test]
fn serializes_issue_create_and_edit_payloads() {
let create = CreateIssuePayload {
title: "New issue".to_owned(),
body: "Details".to_owned(),
projects: vec![4],
};
let edit = EditIssuePayload {
title: "Updated".to_owned(),
body: "Changed".to_owned(),
};
assert_eq!(
serde_json::to_value(create).expect("create payload"),
serde_json::json!({"title": "New issue", "body": "Details", "projects": [4]})
);
assert_eq!(
serde_json::to_value(edit).expect("edit payload"),
serde_json::json!({"title": "Updated", "body": "Changed"})
);
}
}
@@ -0,0 +1,36 @@
use crate::api::{ApiError, GiteaApi};
use crate::model::{CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload};
pub fn create_project_issue(
client: &impl GiteaApi,
project_id: u64,
title: String,
body: String,
) -> Result<Issue, ApiError> {
client.create_issue(&CreateIssuePayload {
title,
body,
projects: vec![project_id],
})
}
pub fn edit_issue(
client: &impl GiteaApi,
issue_number: u64,
payload: &EditIssuePayload,
) -> Result<Issue, ApiError> {
client.edit_issue(issue_number, payload)
}
pub fn delete_issue(client: &impl GiteaApi, issue_number: u64) -> Result<(), ApiError> {
client.delete_issue(issue_number)
}
pub fn move_project_issue(
client: &impl GiteaApi,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError> {
client.move_project_issue(project_id, issue_id, payload)
}
+45
View File
@@ -0,0 +1,45 @@
use std::io::{self, Stdout};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
pub struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(error) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(error);
}
let backend = CrosstermBackend::new(stdout);
match Terminal::new(backend) {
Ok(terminal) => Ok(Self { terminal }),
Err(error) => {
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
Err(error)
}
}
}
pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
+46
View File
@@ -0,0 +1,46 @@
pub fn sanitize_terminal_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
pub fn sanitize_editor_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character == '\n' || !character.is_control() {
character
} else {
' '
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_control_characters() {
assert_eq!(
sanitize_terminal_text("safe\u{1b}[31m\ntext"),
"safe [31m text"
);
}
#[test]
fn editor_sanitizer_preserves_newlines() {
assert_eq!(
sanitize_editor_text("first\nsecond\u{1b}[31m"),
"first\nsecond [31m"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::{App, Column, EditorField, EditorMode, EditorState};
use crate::text::{sanitize_editor_text, sanitize_terminal_text};
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(6),
Constraint::Length(4),
Constraint::Length(if app.show_help { 3 } else { 1 }),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(format!(
"Gitea Kanban — {} — {}",
sanitize_terminal_text(repository),
sanitize_terminal_text(&app.board_title)
))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
areas[0],
);
draw_columns(frame, app, areas[1]);
draw_detail(frame, app, areas[2]);
let help = if app.show_help {
format!(
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move n: new e: edit d: delete r: refresh ?: help q: quit",
sanitize_terminal_text(&app.status)
)
} else {
format!(
"{} | ?: help q: quit",
sanitize_terminal_text(&app.status)
)
};
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
if let Some(editor) = &app.editor {
draw_editor(frame, editor);
}
}
fn draw_editor(frame: &mut Frame<'_>, editor: &EditorState) {
let area = centered_rect(frame.area(), 80, 45);
frame.render_widget(Clear, area);
let (title, content) = match &editor.mode {
EditorMode::Delete {
issue_number,
title,
} => (
"Delete issue".to_owned(),
format!(
"Delete issue #{issue_number} — {}?\n\n[y] confirm [n/Esc] cancel",
sanitize_terminal_text(title)
),
),
EditorMode::Create { .. } => (
"New issue".to_owned(),
editor_content(editor, "Create issue"),
),
EditorMode::Edit { issue_number } => (
format!("Edit issue #{issue_number}"),
editor_content(editor, "Edit issue"),
),
};
frame.render_widget(
Paragraph::new(content)
.block(
Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn editor_content(editor: &EditorState, action: &str) -> String {
let title_marker = if editor.field == EditorField::Title {
"▶ "
} else {
" "
};
let body_marker = if editor.field == EditorField::Body {
"▶ "
} else {
" "
};
format!(
"{title_marker}Title: {}\n{body_marker}Body: {}\n\nTab: switch field Enter: title → body Ctrl-S: save Esc: cancel\n{action}",
sanitize_terminal_text(&editor.title),
sanitize_editor_text(&editor.body)
)
}
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
let widths = vec![Constraint::Ratio(1, app.columns.len() as u32); app.columns.len()];
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(widths)
.split(area);
for (index, column) in app.columns.iter().enumerate() {
draw_column(frame, app, column, index, areas[index]);
}
}
fn draw_column(frame: &mut Frame<'_>, app: &App, column: &Column, index: usize, area: Rect) {
let focused = index == app.focused_column;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let items: Vec<ListItem<'_>> = column
.cards
.iter()
.map(|issue| {
ListItem::new(Line::from(vec![
Span::styled(
format!("#{} ", issue.number),
Style::default().fg(Color::DarkGray),
),
Span::raw(sanitize_terminal_text(&issue.title)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
" {} ({}) ",
sanitize_terminal_text(&column.spec.title),
column.cards.len()
))
.borders(Borders::ALL)
.border_style(border_style),
)
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
if focused && !column.cards.is_empty() {
state.select(Some(app.focused_cards[index]));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
let text = app
.focused_card()
.map(|issue| {
let body = sanitize_terminal_text(
&issue
.body
.as_deref()
.unwrap_or("No description")
.replace('\n', " "),
);
format!(
"#{} {}\n{}",
issue.number,
sanitize_terminal_text(&issue.title),
body
)
})
.unwrap_or_else(|| "No card in focused column".to_owned());
frame.render_widget(
Paragraph::new(text)
.block(Block::default().title(" Detail ").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
);
}
+358 -92
View File
@@ -3,13 +3,9 @@
# Owns: TTL sweep, orphan-VM sweep, deferred-job retry, stale-runner dereg, # Owns: TTL sweep, orphan-VM sweep, deferred-job retry, stale-runner dereg,
# startup convergence. Runs forever under systemd; webhook service is separate. # startup convergence. Runs forever under systemd; webhook service is separate.
gcr_ttl_grace_sec() {
printf '%s' "$((10 * 60))"
}
gcr_record_age_sec() { gcr_record_age_sec() {
created_at="$(gcr_record_field "$1" created_at)" created_at="$(gcr_record_field "$1" created_at)"
now="$(date -u '+%s')" now="$(gcr_now_epoch)"
case "$created_at" in case "$created_at" in
''|*[!0-9]*) printf '%s' 999999 ;; ''|*[!0-9]*) printf '%s' 999999 ;;
*) printf '%s' "$((now - created_at))" ;; *) printf '%s' "$((now - created_at))" ;;
@@ -20,33 +16,100 @@ gcr_sweep_ttl() {
for f in $(gcr_active_records); do for f in $(gcr_active_records); do
rec="$(cat "$f")" rec="$(cat "$f")"
status="$(gcr_record_field "$rec" status)" status="$(gcr_record_field "$rec" status)"
[ "$status" = "vm_active" ] || [ "$status" = "pending_vm" ] || continue
job_id="$(gcr_record_field "$rec" job_id)" job_id="$(gcr_record_field "$rec" job_id)"
attempt="$(gcr_record_field "$rec" run_attempt)" attempt="$(gcr_record_field "$rec" run_attempt)"
if [ "$status" = "idle_vm" ]; then
gcr_lock_acquire idle-pool || continue
rec="$(gcr_record_get "$job_id" "$attempt")"
if [ -z "$rec" ] || [ "$(gcr_record_field "$rec" status)" != "idle_vm" ]; then
gcr_lock_release idle-pool
continue
fi
if gcr_idle_record_unexpired "$rec"; then
gcr_lock_release idle-pool
continue
fi
vm_id="$(gcr_record_field "$rec" vm_id)"
if gcr_record_vm_owned_elsewhere "$vm_id" "$job_id" "$attempt"; then
gcr_log warn --ns=sweep "removing superseded idle record job=$job_id vm=$vm_id"
gcr_record_del "$job_id" "$attempt"
gcr_lock_release idle-pool
continue
fi
gcr_log info --ns=sweep "idle slot expired job=$job_id vm=$vm_id"
if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then
if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" idle-expired false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-expired\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-expired\"}"
fi
else
gcr_record_del "$job_id" "$attempt"
fi
gcr_lock_release idle-pool
continue
fi
[ "$status" = "vm_active" ] || [ "$status" = "pending_vm" ] || continue
ttl_min="$(gcr_record_field "$rec" ttl_min)" ttl_min="$(gcr_record_field "$rec" ttl_min)"
case "$ttl_min" in ''|*[!0-9]*) continue ;; esac case "$ttl_min" in ''|*[!0-9]*) continue ;; esac
max_sec="$((ttl_min * 60 + $(gcr_ttl_grace_sec)))" max_sec="$((ttl_min * 60))"
age="$(gcr_record_age_sec "$rec")" age="$(gcr_record_age_sec "$rec")"
if [ "$age" -gt "$max_sec" ]; then if [ "$age" -ge "$max_sec" ]; then
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || continue
rec="$(gcr_record_get "$job_id" "$attempt")"
case "$(gcr_record_field "$rec" status)" in
pending_vm|vm_active) ;;
*) gcr_lock_release "$key"; continue ;;
esac
ttl_min="$(gcr_record_field "$rec" ttl_min)"
case "$ttl_min" in
''|*[!0-9]*) gcr_lock_release "$key"; continue ;;
esac
max_sec="$((ttl_min * 60))"
age="$(gcr_record_age_sec "$rec")"
if [ "$age" -lt "$max_sec" ]; then
gcr_lock_release "$key"
continue
fi
vm_id="$(gcr_record_field "$rec" vm_id)" vm_id="$(gcr_record_field "$rec" vm_id)"
gcr_log warn --ns=sweep "TTL exceeded job=$job_id age=${age}s max=${max_sec}s" gcr_log warn --ns=sweep "TTL exceeded job=$job_id age=${age}s max=${max_sec}s"
if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then
ip="$(gcr_vm_public_ip "$vm_id" || true)" ip="$(gcr_vm_public_ip "$vm_id" || true)"
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" ttl || true gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" ttl || true
gcr_vm_destroy "$vm_id" || true if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" ttl false; then
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"ttl\"}" gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"ttl\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"ttl\"}"
fi
else
gcr_record_del "$job_id" "$attempt"
fi fi
gcr_event "job-ttl-expired" "$job_id" "{\"age\":$age}" gcr_event "job-ttl-expired" "$job_id" "{\"age\":$age}"
gcr_record_del "$job_id" "$attempt" gcr_lock_release "$key"
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")"
fi fi
done done
} }
gcr_sweep_orphan_vms() { gcr_sweep_orphan_vms() {
vms_json="$(gcr_vm_list_managed)" || return 0 gcr_lock_acquire admission || return 0
vms_json="$(gcr_vm_list_managed)" || {
gcr_lock_release admission
return 0
}
if ! gcr_lock_acquire idle-pool; then
gcr_lock_release admission
return 0
fi
count="$(printf '%s' "$vms_json" | jq 'length')" count="$(printf '%s' "$vms_json" | jq 'length')"
i=0 i=0
while [ "$i" -lt "$count" ]; do while [ "$i" -lt "$count" ]; do
@@ -55,74 +118,211 @@ gcr_sweep_orphan_vms() {
jid="$(printf '%s' "$vm" | jq -r '.labels["gcr.job-id"] // ""')" jid="$(printf '%s' "$vm" | jq -r '.labels["gcr.job-id"] // ""')"
att="$(printf '%s' "$vm" | jq -r '.labels["gcr.run-attempt"] // ""')" att="$(printf '%s' "$vm" | jq -r '.labels["gcr.run-attempt"] // ""')"
known="" if ! gcr_record_exists_for_vm_id "$vm_id"; then
if [ -n "$jid" ] && [ -n "$att" ]; then
rec="$(gcr_record_get "$jid" "$att")"
[ -n "$rec" ] && known=1
fi
if [ -z "$known" ]; then
gcr_log warn --ns=sweep "orphan VM $vm_id job=$jid attempt=$att -> destroy" gcr_log warn --ns=sweep "orphan VM $vm_id job=$jid attempt=$att -> destroy"
gcr_vm_destroy "$vm_id" || true cleanup_job="${jid:-orphan-$vm_id}"
gcr_event "orphan-vm-destroyed" "${jid:-unknown}" "{\"vm_id\":$vm_id}" cleanup_attempt="${att:-0}"
cleanup_rec="$(jq -n --arg j "$cleanup_job" --arg a "$cleanup_attempt" \
--arg v "$vm_id" --arg vn "$(printf '%s' "$vm" | jq -r '.name // ""')" \
'{job_id:$j, run_attempt:$a, repo:"", label:"", created_at:"0",
ttl_min:0, vm_id:($v|tonumber), vm_name:$vn,
bootstrapped:false, status:"cleanup_pending"}')"
if gcr_vm_cleanup_start "$cleanup_job" "$cleanup_attempt" \
"$cleanup_rec" orphan false; then
gcr_event "orphan-vm-destroyed" "$cleanup_job" "{\"vm_id\":$vm_id}"
else
gcr_event "vm-cleanup-pending" "$cleanup_job" \
"{\"vm_id\":$vm_id,\"reason\":\"orphan\"}"
fi
fi fi
i=$((i + 1)) i=$((i + 1))
done done
gcr_lock_release idle-pool
gcr_lock_release admission
} }
gcr_alloc_deferred() { gcr_alloc_deferred() {
job_id="$1"; attempt="$2" job_id="$1"; attempt="$2"
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || return 0
rec="$(gcr_record_get "$job_id" "$attempt")" rec="$(gcr_record_get "$job_id" "$attempt")"
[ -n "$rec" ] || return 0 if [ -z "$rec" ] || [ "$(gcr_record_field "$rec" status)" != "deferred" ]; then
[ "$(gcr_record_field "$rec" status)" = "deferred" ] || return 0 gcr_lock_release "$key"
return 0
fi
repo="$(gcr_record_field "$rec" repo)" repo="$(gcr_record_field "$rec" repo)"
label="$(gcr_record_field "$rec" label)" label="$(gcr_record_field "$rec" label)"
state="$(gcr_gitea_job_state "$repo" "$job_id")" || return 0 state="$(gcr_gitea_job_state "$repo" "$job_id")" || {
gcr_lock_release "$key"
return 0
}
case "$state" in case "$state" in
completed:*) completed:*)
gcr_log info --ns=alloc "deferred job=$job_id already terminal ($state), dropping record" gcr_log info --ns=alloc "deferred job=$job_id already terminal ($state), dropping record"
gcr_record_del "$job_id" "$attempt" gcr_record_del "$job_id" "$attempt"
gcr_lock_release "$key"
return 0 return 0
;; ;;
esac esac
profile="$(gcr_label_profile "$label")" || return 0 profile="$(gcr_label_profile "$label")" || {
gcr_lock_release "$key"
return 0
}
set -- $profile set -- $profile
server_type="$1"; ttl_min="$2"; rate="$3" server_type="$1"; ttl_min="$2"
gcr_lock_acquire admission || {
gcr_lock_release "$key"
return 0
}
active="$(gcr_count_active)" active="$(gcr_count_active)"
repo_active="$(gcr_count_active_repo "$repo")" repo_active="$(gcr_count_active_repo "$repo")"
[ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] && return 0 if [ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] \
[ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ] && return 0 || [ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ]; then
gcr_lock_release admission
gcr_lock_release "$key"
return 0
fi
gcr_budget_add "$rate" "$ttl_min" || return 0 claim_status=0
reg_token="$(gcr_gitea_registration_token "$repo")" || return 0 gcr_claim_idle "$job_id" "$attempt" "$repo" "$label" || claim_status="$?"
if [ "$claim_status" -eq 0 ]; then
reused="$(gcr_record_get "$job_id" "$attempt")"
vm_id="$(gcr_record_field "$reused" vm_id)"
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$(gcr_record_field "$reused" vm_name)" false; then
reused="$(gcr_record_get "$job_id" "$attempt")"
reused="$(printf '%s' "$reused" | jq -c '.bootstrapped = true | del(.reused_vm)')"
gcr_record_put "$job_id" "$attempt" "$reused"
else
gcr_gitea_runner_disabled "$repo" "$(gcr_record_field "$reused" vm_name)" true || true
gcr_event "vm-reuse-start-failed" "$job_id" "{\"vm_id\":$vm_id,\"via\":\"deferred-retry\"}"
fi
gcr_lock_release admission
gcr_lock_release "$key"
gcr_event "vm-reused" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"via\":\"deferred-retry\"}"
gcr_log info --ns=alloc "deferred job=$job_id reused vm=$vm_id"
return 0
fi
if [ "$claim_status" -eq 2 ]; then
gcr_lock_release admission
gcr_lock_release "$key"
return 0
fi
key="$(gcr_alloc_key "$job_id" "$attempt")" reg_token="$(gcr_gitea_registration_token "$repo")" || {
gcr_lock_acquire "$key" || return 0 gcr_lock_release admission
vm_name="gcr-${job_id}-${attempt}"
vm_id="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \
"$reg_token" "$job_id" "$attempt" "$repo")" && [ -n "$vm_id" ] || {
gcr_lock_release "$key" gcr_lock_release "$key"
return 0 return 0
} }
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \ vm_name="gcr-${job_id}-${attempt}"
--arg l "$label" --arg t "$(date -u '+%s')" --arg v "$vm_id" \ created_at="$(gcr_now_epoch)"
--arg vn "$vm_name" --arg ttl "$ttl_min" \ create_status=0
'{job_id:$j, run_attempt:$a, repo:$r, label:$l, created="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \
created_at:$t, ttl_min:($ttl|tonumber), vm_id:($v|tonumber), "$reg_token" "$job_id" "$attempt" "$repo")" || create_status="$?"
vm_name:$vn, bootstrapped:false, status:"pending_vm"}')" if [ "$create_status" -ne 0 ] || [ -z "$created" ]; then
gcr_record_put "$job_id" "$attempt" "$rec" gcr_lock_release admission
gcr_lock_release "$key"
return 0
fi
set -- $created
vm_id="$1"; actual_server_type="$2"; actual_rate="$3"
if ! gcr_vm_record_created "$job_id" "$attempt" "$repo" "$label" \
"$created_at" "$vm_id" "$vm_name" "$ttl_min" \
"$actual_server_type" "$actual_rate"; then
gcr_lock_release admission
gcr_lock_release "$key"
return 0
fi
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min,\"via\":\"deferred-retry\"}" gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min,\"via\":\"deferred-retry\"}"
gcr_log info --ns=alloc "deferred job=$job_id allocated vm=$vm_id" gcr_log info --ns=alloc "deferred job=$job_id allocated vm=$vm_id"
} }
gcr_sweep_cleanup_pending() {
for f in $(gcr_active_records); do
rec="$(cat "$f")"
[ "$(gcr_record_field "$rec" status)" = "cleanup_pending" ] || continue
job_id="$(gcr_record_field "$rec" job_id)"
attempt="$(gcr_record_field "$rec" run_attempt)"
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || continue
if ! gcr_lock_acquire admission; then
gcr_lock_release "$key"
continue
fi
rec="$(gcr_record_get "$job_id" "$attempt")"
if [ "$(gcr_record_field "$rec" status)" = "cleanup_pending" ]; then
gcr_vm_cleanup_pending "$job_id" "$attempt" "$rec" || true
fi
gcr_lock_release admission
gcr_lock_release "$key"
done
}
gcr_sweep_create_ambiguous() {
for f in $(gcr_active_records); do
rec="$(cat "$f")"
[ "$(gcr_record_field "$rec" status)" = "create_ambiguous" ] || continue
job_id="$(gcr_record_field "$rec" job_id)"
attempt="$(gcr_record_field "$rec" run_attempt)"
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || continue
if ! gcr_lock_acquire admission; then
gcr_lock_release "$key"
continue
fi
rec="$(gcr_record_get "$job_id" "$attempt")"
if [ "$(gcr_record_field "$rec" status)" != "create_ambiguous" ]; then
gcr_lock_release admission
gcr_lock_release "$key"
continue
fi
find_status=0
found_vm_id="$(gcr_vm_find_created \
"$(gcr_record_field "$rec" vm_name)" "$job_id" "$attempt" \
"$(gcr_record_field "$rec" label)" \
"$(gcr_record_field "$rec" server_type)" \
"$(gcr_record_field "$rec" candidate_location)" \
"$(gcr_record_field "$rec" candidate_arch)")" || find_status="$?"
case "$find_status" in
0)
rec="$(printf '%s' "$rec" | jq -c --arg vm "$found_vm_id" \
'.vm_id = ($vm | tonumber)
| .status = "pending_vm"
| .bootstrapped = false
| del(.create_http, .create_curl_status,
.candidate_location, .candidate_arch)')"
if gcr_record_put "$job_id" "$attempt" "$rec"; then
gcr_event "vm-create-recovered" "$job_id" \
"{\"vm_id\":$found_vm_id,\"label\":\"$(gcr_record_field "$rec" label)\"}"
fi
;;
1)
rec="$(printf '%s' "$rec" | jq -c \
'.status = "cleanup_pending"
| .cleanup_reason = "ambiguous-create-absent"
| .cleanup_refund_budget = true
| .cleanup_vm_destroyed = true
| .cleanup_budget_released = false')"
if gcr_record_put "$job_id" "$attempt" "$rec"; then
gcr_vm_cleanup_pending "$job_id" "$attempt" "$rec" || true
fi
;;
2) ;;
esac
gcr_lock_release admission
gcr_lock_release "$key"
done
}
gcr_retry_deferred() { gcr_retry_deferred() {
for f in $(gcr_active_records); do for f in $(gcr_active_records); do
rec="$(cat "$f")" rec="$(cat "$f")"
@@ -134,38 +334,56 @@ gcr_retry_deferred() {
} }
gcr_sweep_stale_runners() { gcr_sweep_stale_runners() {
runners="$(gcr_gitea_list_runners)" || return 0 gcr_lock_acquire idle-pool || return 0
# Here-doc instead of pipe: dash runs pipe tails in a subshell, which oldIFS="$IFS"
# would strand gcr_event/audit writes from the caller's perspective. IFS=,
while read -r rid rname; do for allowed_repo in ${GCR_ALLOWED_REPOS:-}; do
[ -n "${rid:-}" ] || continue IFS="$oldIFS"
case "$rname" in case "$allowed_repo" in
gcr-*) ;; */\*)
owner="${allowed_repo%/*}"
repos="$(gcr_gitea_list_org_repos "$owner")" || {
IFS=,
continue
}
;;
*/*) repos="$allowed_repo" ;;
*) continue ;; *) continue ;;
esac esac
while read -r repo; do
[ -n "${repo:-}" ] || continue
gcr_repo_allowed "$repo" || continue
runners="$(gcr_gitea_list_runners "$repo")" || continue
# Here-doc instead of pipe: dash runs pipe tails in a subshell, which
# would strand gcr_event/audit writes from the caller's perspective.
while read -r rid rname; do
[ -n "${rid:-}" ] || continue
case "$rname" in
gcr-*) ;;
*) continue ;;
esac
# gcr-<job>-<attempt>: alive iff a matching active/pending record exists. # Runner name stays tied to original VM across later job assignments.
rest="${rname#gcr-}" rest="${rname#gcr-}"
jid="${rest%-*}" jid="${rest%-*}"
att="${rest##*-}" if ! gcr_record_exists_for_vm_name "$rname"; then
rec="" gcr_log warn --ns=sweep "stale repo=$repo registration id=$rid name=$rname -> delete"
case "$jid" in *[!0-9]*|"") rec="" ;; if gcr_gitea_delete_runner "$repo" "$rid"; then
*) case "$att" in *[!0-9]*|"") rec="" ;; gcr_event "stale-runner-deleted" "${jid:-unknown}" "{\"repo\":\"$repo\",\"runner_id\":$rid,\"name\":\"$rname\"}"
*) rec="$(gcr_record_get "$jid" "$att")" ;; else
esac ;; gcr_log error --ns=sweep "failed deleting repo=$repo runner id=$rid"
esac fi
if [ -z "$rec" ]; then
gcr_log warn --ns=sweep "stale runner registration id=$rid name=$rname -> delete"
if gcr_gitea_delete_runner "$rid"; then
gcr_event "stale-runner-deleted" "${jid:-unknown}" "{\"runner_id\":$rid,\"name\":\"$rname\"}"
else
gcr_log error --ns=sweep "failed deleting runner id=$rid"
fi fi
fi done <<EOF
done <<EOF
$runners $runners
EOF EOF
done <<EOF
$repos
EOF
IFS=,
done
IFS="$oldIFS"
gcr_lock_release idle-pool
} }
# Runs SSH-push bootstrap for VMs that were created but not yet provisioned. # Runs SSH-push bootstrap for VMs that were created but not yet provisioned.
@@ -178,12 +396,36 @@ gcr_bootstrap_pending() {
job_id="$(gcr_record_field "$rec" job_id)" job_id="$(gcr_record_field "$rec" job_id)"
attempt="$(gcr_record_field "$rec" run_attempt)" attempt="$(gcr_record_field "$rec" run_attempt)"
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || continue
rec="$(gcr_record_get "$job_id" "$attempt")"
if [ "$(gcr_record_field "$rec" status)" != "pending_vm" ] \
|| [ "$(gcr_record_field "$rec" bootstrapped)" = "true" ]; then
gcr_lock_release "$key"
continue
fi
repo="$(gcr_record_field "$rec" repo)" repo="$(gcr_record_field "$rec" repo)"
label="$(gcr_record_field "$rec" label)" label="$(gcr_record_field "$rec" label)"
vm_id="$(gcr_record_field "$rec" vm_id)" vm_id="$(gcr_record_field "$rec" vm_id)"
runner_name="$(gcr_record_field "$rec" vm_name)" runner_name="$(gcr_record_field "$rec" vm_name)"
state="$(gcr_gitea_job_state "$repo" "$job_id")" || continue if [ "$(gcr_record_field "$rec" reused_vm)" = "true" ]; then
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$runner_name" false; then
rec="$(printf '%s' "$rec" | jq -c '.bootstrapped = true | del(.reused_vm)')"
gcr_record_put "$job_id" "$attempt" "$rec"
else
gcr_gitea_runner_disabled "$repo" "$runner_name" true || true
fi
gcr_lock_release "$key"
continue
fi
state="$(gcr_gitea_job_state "$repo" "$job_id")" || {
gcr_lock_release "$key"
continue
}
case "$state" in case "$state" in
completed:*) completed:*)
gcr_log info --ns=sweep "pending job=$job_id already terminal ($state), destroying vm=$vm_id" gcr_log info --ns=sweep "pending job=$job_id already terminal ($state), destroying vm=$vm_id"
@@ -195,19 +437,35 @@ gcr_bootstrap_pending() {
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$state" || true gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$state" || true
;; ;;
esac esac
gcr_vm_destroy "$vm_id" || true if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" \
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"pending-job-completed\",\"state\":\"$state\"}" pending-job-completed false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"pending-job-completed\",\"state\":\"$state\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"pending-job-completed\"}"
fi
else
gcr_record_del "$job_id" "$attempt"
fi fi
gcr_record_del "$job_id" "$attempt" gcr_lock_release "$key"
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")"
continue continue
;; ;;
esac esac
ip="$(gcr_vm_public_ip "$vm_id")" ip="$(gcr_vm_public_ip "$vm_id")" || {
[ -n "$ip" ] || continue gcr_lock_release "$key"
continue
}
if [ -z "$ip" ]; then
gcr_lock_release "$key"
continue
fi
reg_token="$(gcr_gitea_registration_token "$repo")" || continue reg_token="$(gcr_gitea_registration_token "$repo")" || {
gcr_lock_release "$key"
continue
}
ttl_min="$(gcr_record_field "$rec" ttl_min)" ttl_min="$(gcr_record_field "$rec" ttl_min)"
gcr_log info --ns=alloc "bootstrapping vm=$vm_id ip=$ip job=$job_id" gcr_log info --ns=alloc "bootstrapping vm=$vm_id ip=$ip job=$job_id"
@@ -218,6 +476,7 @@ gcr_bootstrap_pending() {
else else
gcr_log warn --ns=alloc "bootstrap failed vm=$vm_id (retry next tick)" gcr_log warn --ns=alloc "bootstrap failed vm=$vm_id (retry next tick)"
fi fi
gcr_lock_release "$key"
done done
} }
@@ -238,26 +497,33 @@ gcr_reap_finished_jobs() {
state="$(gcr_gitea_job_state "$repo" "$job_id")" || continue state="$(gcr_gitea_job_state "$repo" "$job_id")" || continue
case "$state" in case "$state" in
completed:*) completed:*)
gcr_log info --ns=sweep "job=$job_id terminal ($state), destroying vm=$vm_id" key="$(gcr_alloc_key "$job_id" "$attempt")"
if [ -n "$vm_id" ] && [ "$vm_id" != "0" ] && [ "$vm_id" != "null" ]; then gcr_lock_acquire "$key" || continue
case "$state" in rec="$(gcr_record_get "$job_id" "$attempt")"
completed:success|completed:cancelled|completed:skipped) ;; if [ -z "$rec" ]; then
*) gcr_lock_release "$key"
ip="$(gcr_vm_public_ip "$vm_id" || true)" continue
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$state" || true
;;
esac
gcr_vm_destroy "$vm_id" || true
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"job-completed\",\"state\":\"$state\"}"
fi fi
gcr_record_del "$job_id" "$attempt" case "$(gcr_record_field "$rec" status)" in
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")" pending_vm|vm_active) ;;
*) gcr_lock_release "$key"; continue ;;
esac
finish_status=0
gcr_vm_finish_terminal "$job_id" "$attempt" "$rec" "$state" reconcile \
|| finish_status="$?"
gcr_lock_release "$key"
case "$finish_status" in
0|2) ;;
*) return "$finish_status" ;;
esac
;; ;;
esac esac
done done
} }
gcr_tick() { gcr_tick() {
gcr_sweep_create_ambiguous
gcr_sweep_cleanup_pending
gcr_sweep_ttl gcr_sweep_ttl
gcr_reap_finished_jobs gcr_reap_finished_jobs
gcr_sweep_orphan_vms gcr_sweep_orphan_vms
+19 -4
View File
@@ -23,11 +23,12 @@ gcr_server_hourly_rate() {
gcr_label_ttl() { gcr_label_ttl() {
case "$1" in case "$1" in
gross-x86) printf '180' ;; ubuntu-latest|gross-x86) printf '180' ;;
gross-arm) printf '180' ;; gross-arm) printf '180' ;;
gross-x86-perf) printf '180' ;; gross-x86-perf) printf '180' ;;
gross-mixed-econ) printf '180' ;; gross-mixed-econ) printf '180' ;;
gross-nix-x86) printf '180' ;; nix) printf '480' ;;
gross-nix-x86) printf '180' ;;
gross-nix-arm) printf '180' ;; gross-nix-arm) printf '180' ;;
gross-nix-x86-perf) printf '480' ;; gross-nix-x86-perf) printf '480' ;;
gross-nix-x86-highmem) printf '480' ;; gross-nix-x86-highmem) printf '480' ;;
@@ -40,6 +41,13 @@ gcr_label_ttl() {
gcr_label_candidates() { gcr_label_candidates() {
label="$1" label="$1"
case "$label" in case "$label" in
ubuntu-latest)
printf '%s\n' \
'cx23 nbg1 amd64' 'cx23 fsn1 amd64' 'cx23 hel1 amd64' \
'cx33 nbg1 amd64' 'cx33 fsn1 amd64' 'cx33 hel1 amd64' \
'cx43 nbg1 amd64' 'cx43 fsn1 amd64' 'cx43 hel1 amd64' \
'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64'
;;
gross-x86) gross-x86)
printf '%s\n' \ printf '%s\n' \
'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64' \ 'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64' \
@@ -64,6 +72,13 @@ gcr_label_candidates() {
'cax41 nbg1 arm64' 'cax41 fsn1 arm64' 'cax41 hel1 arm64' \ 'cax41 nbg1 arm64' 'cax41 fsn1 arm64' 'cax41 hel1 arm64' \
'cx43 nbg1 amd64' 'cx43 fsn1 amd64' 'cx43 hel1 amd64' 'cx43 nbg1 amd64' 'cx43 fsn1 amd64' 'cx43 hel1 amd64'
;; ;;
nix)
printf '%s\n' \
'cx23 nbg1 amd64' 'cx23 fsn1 amd64' 'cx23 hel1 amd64' \
'cx33 nbg1 amd64' 'cx33 fsn1 amd64' 'cx33 hel1 amd64' \
'cx43 nbg1 amd64' 'cx43 fsn1 amd64' 'cx43 hel1 amd64' \
'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64'
;;
gross-nix-x86) gross-nix-x86)
printf '%s\n' \ printf '%s\n' \
'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64' \ 'cx53 nbg1 amd64' 'cx53 fsn1 amd64' 'cx53 hel1 amd64' \
@@ -147,7 +162,7 @@ gcr_count_active() {
for f in $(gcr_active_records); do for f in $(gcr_active_records); do
status="$(gcr_record_field "$(cat "$f")" status)" status="$(gcr_record_field "$(cat "$f")" status)"
case "$status" in case "$status" in
pending_vm|vm_active) count=$((count + 1)) ;; pending_vm|vm_active|cleanup_pending|create_ambiguous) count=$((count + 1)) ;;
esac esac
done done
printf '%s' "$count" printf '%s' "$count"
@@ -159,7 +174,7 @@ gcr_count_active_repo() {
for f in $(gcr_active_records); do for f in $(gcr_active_records); do
rec="$(cat "$f")" rec="$(cat "$f")"
case "$(gcr_record_field "$rec" status)" in case "$(gcr_record_field "$rec" status)" in
pending_vm|vm_active) ;; pending_vm|vm_active|cleanup_pending|create_ambiguous) ;;
*) continue ;; *) continue ;;
esac esac
[ "$(gcr_record_field "$rec" repo)" = "$repo" ] && count=$((count + 1)) [ "$(gcr_record_field "$rec" repo)" = "$repo" ] && count=$((count + 1))
+50 -5
View File
@@ -20,12 +20,30 @@ gcr_gitea_admin_token() {
tr -d '\n' < "$GITEA_ADMIN_TOKEN_FILE" tr -d '\n' < "$GITEA_ADMIN_TOKEN_FILE"
} }
# gcr_gitea_list_runners — prints "id name" lines for org hectic-lab. # gcr_gitea_list_runners REPO — prints "id name" lines for repo runners.
gcr_gitea_list_runners() { gcr_gitea_list_runners() {
repo="$1"
token="$(gcr_gitea_admin_token)" || return 1 token="$(gcr_gitea_admin_token)" || return 1
owner="${repo%%/*}"
name="${repo#*/}"
curl -fsS -H "Authorization: token $token" \ curl -fsS -H "Authorization: token $token" \
"$GCR_GITEA_URL/api/v1/orgs/hectic-lab/actions/runners?per_page=50" \ "$GCR_GITEA_URL/api/v1/repos/$owner/$name/actions/runners" \
| jq -r '.entries[]? | "\(.id) \(.name)"' | jq -r '.runners[]? | "\(.id) \(.name)"'
}
# gcr_gitea_list_org_repos OWNER — prints fully-qualified repository names.
gcr_gitea_list_org_repos() {
owner="$1"
token="$(gcr_gitea_admin_token)" || return 1
page=1
while :; do
repos="$(curl -fsS -H "Authorization: token $token" \
"$GCR_GITEA_URL/api/v1/orgs/$owner/repos?page=$page&limit=50")" || return 1
printf '%s' "$repos" | jq -r '.[]? | .full_name'
count="$(printf '%s' "$repos" | jq 'length')" || return 1
[ "$count" -lt 50 ] && return 0
page=$((page + 1))
done
} }
# gcr_gitea_job_state REPO JOB_ID — prints "<status>:<conclusion>". # gcr_gitea_job_state REPO JOB_ID — prints "<status>:<conclusion>".
@@ -40,8 +58,35 @@ gcr_gitea_job_state() {
} }
gcr_gitea_delete_runner() { gcr_gitea_delete_runner() {
id="$1" repo="$1"; id="$2"
token="$(gcr_gitea_admin_token)" || return 1 token="$(gcr_gitea_admin_token)" || return 1
owner="${repo%%/*}"
name="${repo#*/}"
curl -fsS -X DELETE -H "Authorization: token $token" \ curl -fsS -X DELETE -H "Authorization: token $token" \
"$GCR_GITEA_URL/api/v1/orgs/hectic-lab/actions/runners/$id" "$GCR_GITEA_URL/api/v1/repos/$owner/$name/actions/runners/$id"
}
gcr_gitea_set_runner_disabled() {
repo="$1"; id="$2"; disabled="$3"
case "$disabled" in true|false) ;; *) return 1 ;; esac
token="$(gcr_gitea_admin_token)" || return 1
owner="${repo%%/*}"
name="${repo#*/}"
curl -fsS -X PATCH -H "Authorization: token $token" \
-H 'Content-Type: application/json' --data "{\"disabled\":$disabled}" \
"$GCR_GITEA_URL/api/v1/repos/$owner/$name/actions/runners/$id" >/dev/null
}
# gcr_gitea_runner_disabled REPO RUNNER_NAME true|false
gcr_gitea_runner_disabled() {
repo="$1"; runner_name="$2"; disabled="$3"
runners="$(gcr_gitea_list_runners "$repo")" || return 1
while read -r id name; do
[ "$name" = "$runner_name" ] || continue
gcr_gitea_set_runner_disabled "$repo" "$id" "$disabled"
return "$?"
done <<EOF
$runners
EOF
return 1
} }
+279 -11
View File
@@ -36,6 +36,7 @@ gcr_hcloud_req() {
method="$1"; path="$2"; body="${3:-}" method="$1"; path="$2"; body="${3:-}"
token="$(gcr_hcloud_token)" || return 1 token="$(gcr_hcloud_token)" || return 1
GCR_LAST_BODY="$(mktemp "${TMPDIR:-/tmp}/gcr-resp.XXXXXX")" GCR_LAST_BODY="$(mktemp "${TMPDIR:-/tmp}/gcr-resp.XXXXXX")"
curl_status=0
if [ -n "$body" ]; then if [ -n "$body" ]; then
code="$(printf '%s' "$body" | curl -sS -X "$method" \ code="$(printf '%s' "$body" | curl -sS -X "$method" \
-H "Authorization: Bearer $token" \ -H "Authorization: Bearer $token" \
@@ -43,13 +44,20 @@ gcr_hcloud_req() {
--data-binary @- \ --data-binary @- \
-o "$GCR_LAST_BODY" \ -o "$GCR_LAST_BODY" \
-w '%{http_code}' \ -w '%{http_code}' \
"$GCR_API$path")" "$GCR_API$path")" || curl_status="$?"
else else
code="$(curl -sS -X "$method" \ code="$(curl -sS -X "$method" \
-H "Authorization: Bearer $token" \ -H "Authorization: Bearer $token" \
-o "$GCR_LAST_BODY" \ -o "$GCR_LAST_BODY" \
-w '%{http_code}' \ -w '%{http_code}' \
"$GCR_API$path")" "$GCR_API$path")" || curl_status="$?"
fi
case "$code" in ''|*[!0-9]*) code=000 ;; esac
GCR_LAST_CURL_STATUS="$curl_status"
GCR_LAST_HTTP="$code"
if [ "$curl_status" -ne 0 ]; then
gcr_log warn --ns=hcloud "transport failed path=$path curl=$curl_status http=$code"
return 1
fi fi
case "$code" in 2??) return 0 ;; esac case "$code" in 2??) return 0 ;; esac
gcr_log warn --ns=hcloud "request failed path=$path http=$code body=$(head -c 200 "$GCR_LAST_BODY" | gcr_redact)" gcr_log warn --ns=hcloud "request failed path=$path http=$code body=$(head -c 200 "$GCR_LAST_BODY" | gcr_redact)"
@@ -62,6 +70,64 @@ gcr_vm_list_managed() {
fi fi
} }
# Exact deterministic create identity. Exit 0 = one match (prints id),
# 1 = confirmed absent, 2 = lookup failed or identity invariant violated.
gcr_vm_find_created() {
find_name="$1"; find_job="$2"; find_attempt="$3"; find_label="$4"
find_type="$5"; find_location="$6"; find_arch="$7"
if ! gcr_hcloud_req GET "/servers?name=$find_name"; then
return 2
fi
find_matches="$(jq -c \
--arg name "$find_name" --arg job "$find_job" --arg attempt "$find_attempt" \
--arg label "$find_label" --arg type "$find_type" \
--arg location "$find_location" --arg arch "$find_arch" \
'[.servers[] | select(
.name == $name
and .labels["gitea-runner-controller"] == "managed"
and .labels["gcr.job-id"] == $job
and .labels["gcr.run-attempt"] == $attempt
and .labels["gcr.label"] == $label
and .labels["gcr.location"] == $location
and .labels["gcr.arch"] == $arch
and ((.server_type.name // .server_type) == $type))]' \
"$GCR_LAST_BODY")" || return 2
find_count="$(printf '%s' "$find_matches" | jq 'length')" || return 2
case "$find_count" in
0) return 1 ;;
1) printf '%s' "$find_matches" | jq -r '.[0].id' ;;
*) return 2 ;;
esac
}
gcr_create_explicitly_rejected() {
case "$1" in
400|401|403|404|405|409|412|422|423) return 0 ;;
*) return 1 ;;
esac
}
gcr_vm_record_create_ambiguous() {
ambiguous_name="$1"; ambiguous_label="$2"; ambiguous_ttl="$3"
ambiguous_job="$4"; ambiguous_attempt="$5"; ambiguous_repo="$6"
ambiguous_type="$7"; ambiguous_rate="$8"; ambiguous_location="$9"
shift 9; ambiguous_arch="$1"; ambiguous_http="$2"; ambiguous_curl="$3"
ambiguous_rec="$(jq -n --arg j "$ambiguous_job" --arg a "$ambiguous_attempt" \
--arg r "$ambiguous_repo" --arg l "$ambiguous_label" \
--arg t "$(gcr_now_epoch)" --arg vn "$ambiguous_name" \
--arg ttl "$ambiguous_ttl" --arg st "$ambiguous_type" \
--arg rate "$ambiguous_rate" --arg loc "$ambiguous_location" \
--arg arch "$ambiguous_arch" --arg http "$ambiguous_http" \
--arg curl "$ambiguous_curl" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
created_at:$t, ttl_min:($ttl|tonumber), vm_id:"", vm_name:$vn,
server_type:$st, budget_rate:$rate, candidate_location:$loc,
candidate_arch:$arch, create_http:$http, create_curl_status:$curl,
bootstrapped:false,
status:"create_ambiguous"}')"
gcr_record_put "$ambiguous_job" "$ambiguous_attempt" "$ambiguous_rec"
}
gcr_vm_build_userdata() { gcr_vm_build_userdata() {
vm_name="$1"; label="$2"; reg_token="$3" vm_name="$1"; label="$2"; reg_token="$3"
@@ -92,8 +158,8 @@ labels:
$GCR_DEBUG_SSH_PUBKEY" $GCR_DEBUG_SSH_PUBKEY"
fi fi
# NOTE(yukkop): token reaches only this VM's Hetzner metadata service; # NOTE(yukkop): token reaches only this VM's Hetzner metadata service and
# ephemeral registration makes it useless after the single job exits. # is used for initial registration, not for later idle-slot assignments.
printf '%s' "#cloud-config printf '%s' "#cloud-config
write_files: write_files:
$ssh_key_block $ssh_key_block
@@ -111,7 +177,7 @@ $(printf '%s\n' "$runner_config" | sed 's/^/ /')
- path: /etc/systemd/system/gitea-runner.service - path: /etc/systemd/system/gitea-runner.service
content: | content: |
[Unit] [Unit]
Description=Gitea ephemeral Actions runner Description=Gitea on-demand Actions runner
After=network-online.target gcr-bootstrap.service After=network-online.target gcr-bootstrap.service
Requires=gcr-bootstrap.service Requires=gcr-bootstrap.service
@@ -119,7 +185,7 @@ $(printf '%s\n' "$runner_config" | sed 's/^/ /')
Type=simple Type=simple
Environment=GITEA_INSTANCE_URL=$GCR_GITEA_URL Environment=GITEA_INSTANCE_URL=$GCR_GITEA_URL
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=$reg_token Environment=GITEA_RUNNER_REGISTRATION_TOKEN=$reg_token
ExecStart=/usr/local/bin/act_runner daemon --ephemeral --config /etc/gitea-runner/config.yaml ExecStart=/usr/local/bin/act_runner daemon --config /etc/gitea-runner/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
@@ -163,9 +229,10 @@ runcmd:
} }
# gcr_vm_create NAME LABEL SERVER_TYPE TTL_MIN REG_TOKEN JOB_ID ATTEMPT REPO # gcr_vm_create NAME LABEL SERVER_TYPE TTL_MIN REG_TOKEN JOB_ID ATTEMPT REPO
# Prints new server id. # Caller holds admission lock. Reserves each affordable candidate before its
# create request; prints new server id, actual server type, and reserved rate.
gcr_vm_create() { gcr_vm_create() {
vm_name="$1"; label="$2"; server_type="$3"; ttl_min="$4" vm_name="$1"; label="$2"; ttl_min="$4"
reg_token="$5"; job_id="$6"; attempt="$7"; repo="$8" reg_token="$5"; job_id="$6"; attempt="$7"; repo="$8"
ttl_min="$(gcr_label_ttl "$label")" || return 1 ttl_min="$(gcr_label_ttl "$label")" || return 1
@@ -174,8 +241,18 @@ gcr_vm_create() {
while read -r candidate_type candidate_loc candidate_arch; do while read -r candidate_type candidate_loc candidate_arch; do
[ -n "${candidate_type:-}" ] || continue [ -n "${candidate_type:-}" ] || continue
candidate_n=$((candidate_n + 1)) candidate_n=$((candidate_n + 1))
candidate_rate="$(gcr_server_hourly_rate "$candidate_type")" || continue
if ! gcr_budget_can_add "$candidate_rate" "$ttl_min"; then
gcr_log info --ns=hcloud "skip candidate[$candidate_n] label=$label type=$candidate_type over budget"
continue
fi
if ! gcr_budget_add "$candidate_rate" "$ttl_min"; then
gcr_log error --ns=hcloud "budget reservation write failed label=$label type=$candidate_type"
return 1
fi
image_id="$(gcr_image_id_for_arch "$candidate_arch" "$label")" || { image_id="$(gcr_image_id_for_arch "$candidate_arch" "$label")" || {
gcr_log warn --ns=hcloud "skip candidate[$candidate_n] label=$label arch=$candidate_arch no image" gcr_log warn --ns=hcloud "skip candidate[$candidate_n] label=$label arch=$candidate_arch no image"
gcr_budget_sub "$candidate_rate" "$ttl_min" || return 1
continue continue
} }
payload="$(jq -n \ payload="$(jq -n \
@@ -203,9 +280,34 @@ gcr_vm_create() {
"gcr.created-at":$ts, "gcr.ttl-min":$ttl}}')" "gcr.created-at":$ts, "gcr.ttl-min":$ttl}}')"
gcr_log info --ns=hcloud "try candidate[$candidate_n] label=$label type=$candidate_type arch=$candidate_arch loc=$candidate_loc" gcr_log info --ns=hcloud "try candidate[$candidate_n] label=$label type=$candidate_type arch=$candidate_arch loc=$candidate_loc"
if gcr_hcloud_req POST /servers "$payload"; then if gcr_hcloud_req POST /servers "$payload"; then
jq -r '.server.id' "$GCR_LAST_BODY" printf '%s %s %s\n' \
"$(jq -r '.server.id' "$GCR_LAST_BODY")" "$candidate_type" "$candidate_rate"
return 0 return 0
fi fi
create_http="${GCR_LAST_HTTP:-000}"
create_curl="${GCR_LAST_CURL_STATUS:-0}"
find_status=0
found_vm_id="$(gcr_vm_find_created "$vm_name" "$job_id" "$attempt" \
"$label" "$candidate_type" "$candidate_loc" "$candidate_arch")" \
|| find_status="$?"
if [ "$find_status" -eq 0 ]; then
printf '%s %s %s\n' "$found_vm_id" "$candidate_type" "$candidate_rate"
return 0
fi
if [ "$find_status" -eq 1 ] \
&& gcr_create_explicitly_rejected "$create_http"; then
if ! gcr_budget_sub "$candidate_rate" "$ttl_min"; then
gcr_log error --ns=hcloud "budget reservation rollback failed label=$label type=$candidate_type"
return 1
fi
else
if ! gcr_vm_record_create_ambiguous "$vm_name" "$label" "$ttl_min" \
"$job_id" "$attempt" "$repo" "$candidate_type" "$candidate_rate" \
"$candidate_loc" "$candidate_arch" "$create_http" "$create_curl"; then
gcr_log error --ns=hcloud "cannot persist ambiguous create job=$job_id type=$candidate_type"
fi
return 2
fi
if [ "$candidate_n" -le 3 ]; then if [ "$candidate_n" -le 3 ]; then
sleep 5 sleep 5
else else
@@ -217,7 +319,7 @@ EOF
return 1 return 1
} }
# gcr_vm_destroy SERVER_ID — idempotent best-effort destroy. # gcr_vm_destroy SERVER_ID — success means DELETE returned HTTP 2xx.
gcr_vm_destroy() { gcr_vm_destroy() {
if ! gcr_hcloud_req DELETE "/servers/$1"; then if ! gcr_hcloud_req DELETE "/servers/$1"; then
gcr_log warn --ns=hcloud "destroy failed or already gone: server $1" gcr_log warn --ns=hcloud "destroy failed or already gone: server $1"
@@ -225,6 +327,77 @@ gcr_vm_destroy() {
fi fi
} }
# Only cleanup records establish prior ownership, making DELETE 404 a
# confirmed-absent success rather than an ambiguous lookup failure.
gcr_vm_destroy_owned() {
gcr_vm_destroy "$1" && return 0
[ "${GCR_LAST_HTTP:-}" = "404" ]
}
# Caller holds the lifecycle path's existing ownership locks.
gcr_vm_cleanup_pending() {
cleanup_job="$1"; cleanup_attempt="$2"; cleanup_rec="$3"
cleanup_vm_id="$(gcr_record_field "$cleanup_rec" vm_id)"
if [ "$(gcr_record_field "$cleanup_rec" cleanup_vm_destroyed)" != "true" ]; then
gcr_vm_destroy_owned "$cleanup_vm_id" || return 1
cleanup_rec="$(printf '%s' "$cleanup_rec" | jq -c '.cleanup_vm_destroyed = true')"
gcr_record_put "$cleanup_job" "$cleanup_attempt" "$cleanup_rec" || return 1
fi
if [ "$(gcr_record_field "$cleanup_rec" cleanup_refund_budget)" = "true" ] \
&& [ "$(gcr_record_field "$cleanup_rec" cleanup_budget_released)" != "true" ]; then
cleanup_rate="$(gcr_record_field "$cleanup_rec" budget_rate)"
cleanup_ttl="$(gcr_record_field "$cleanup_rec" ttl_min)"
cleanup_refund_key="$(gcr_alloc_key "$cleanup_job" "$cleanup_attempt")"
gcr_budget_refund_once "$cleanup_refund_key" "$cleanup_rate" "$cleanup_ttl" \
|| return 1
cleanup_rec="$(printf '%s' "$cleanup_rec" | jq -c '.cleanup_budget_released = true')"
gcr_record_put "$cleanup_job" "$cleanup_attempt" "$cleanup_rec" || return 1
fi
gcr_record_del "$cleanup_job" "$cleanup_attempt"
}
# Persist intent before DELETE. Normal lifecycle teardown never changes budget;
# failed creation passes REFUND_BUDGET=true to release its unused reservation.
gcr_vm_cleanup_start() {
cleanup_job="$1"; cleanup_attempt="$2"; cleanup_source="$3"
cleanup_reason="$4"; cleanup_refund="$5"
cleanup_rec="$(printf '%s' "$cleanup_source" | jq -c \
--arg reason "$cleanup_reason" --argjson refund "$cleanup_refund" \
'.status = "cleanup_pending"
| .cleanup_reason = $reason
| .cleanup_refund_budget = $refund
| .cleanup_vm_destroyed = false
| .cleanup_budget_released = false')"
gcr_record_put "$cleanup_job" "$cleanup_attempt" "$cleanup_rec" || return 1
gcr_vm_cleanup_pending "$cleanup_job" "$cleanup_attempt" "$cleanup_rec"
}
# Caller holds allocation and admission locks. A failed primary write first
# persists cleanup ownership; reservation is released only after destroy.
gcr_vm_record_created() {
record_job="$1"; record_attempt="$2"; record_repo="$3"; record_label="$4"
record_created="$5"; record_vm_id="$6"; record_vm_name="$7"
record_ttl="$8"; record_type="$9"; shift 9; record_rate="$1"
record_rec="$(jq -n --arg j "$record_job" --arg a "$record_attempt" \
--arg r "$record_repo" --arg l "$record_label" --arg t "$record_created" \
--arg v "$record_vm_id" --arg vn "$record_vm_name" --arg ttl "$record_ttl" \
--arg st "$record_type" --arg rate "$record_rate" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
created_at:$t, ttl_min:($ttl|tonumber), vm_id:($v|tonumber),
vm_name:$vn, server_type:$st, budget_rate:$rate,
bootstrapped:false, status:"pending_vm"}')"
gcr_record_put "$record_job" "$record_attempt" "$record_rec" && return 0
if ! gcr_vm_cleanup_start "$record_job" "$record_attempt" "$record_rec" \
state-write-failed true; then
cleanup_rec="$(gcr_record_get "$record_job" "$record_attempt")"
[ "$(gcr_record_field "$cleanup_rec" status)" = "cleanup_pending" ] && return 1
gcr_log error --ns=alloc "cannot persist cleanup record job=$record_job vm=$record_vm_id"
return 1
fi
return 1
}
gcr_vm_public_ip() { gcr_vm_public_ip() {
# gcr_vm_public_ip SERVER_ID -> ipv4 or empty # gcr_vm_public_ip SERVER_ID -> ipv4 or empty
if gcr_hcloud_req GET "/servers/$1"; then if gcr_hcloud_req GET "/servers/$1"; then
@@ -232,6 +405,31 @@ gcr_vm_public_ip() {
fi fi
} }
# Stop idle runners so Gitea cannot schedule work before atomic reuse claim.
# The controller starts the service only after the claim record is written.
gcr_vm_runner_service() {
vm_id="$1"; action="$2"
case "$action" in
start|stop) service_command="systemctl $action gitea-runner.service" ;;
health) service_command="systemctl is-active --quiet gitea-runner.service" ;;
*) return 1 ;;
esac
ip="$(gcr_vm_public_ip "$vm_id")" || return 1
[ -n "$ip" ] || return 1
test -n "${GCR_SSH_PRIVKEY_FILE:-}" && test -r "$GCR_SSH_PRIVKEY_FILE" || return 1
key_tmp="$(mktemp "${TMPDIR:-/tmp}/gcr-runner-sshkey.XXXXXX")"
cat "$GCR_SSH_PRIVKEY_FILE" > "$key_tmp"
printf '\n' >> "$key_tmp"
chmod 0600 "$key_tmp"
ssh_opts="-i $key_tmp -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o BatchMode=yes"
if timeout 30 ssh $ssh_opts "root@$ip" "$service_command"; then
rm -f "$key_tmp"
return 0
fi
rm -f "$key_tmp"
return 1
}
gcr_vm_collect_diagnostics() { gcr_vm_collect_diagnostics() {
vm_id="$1"; ip="$2"; job_id="$3"; reason="$4" vm_id="$1"; ip="$2"; job_id="$3"; reason="$4"
@@ -274,6 +472,76 @@ gcr_vm_collect_diagnostics() {
return 0 return 0
} }
# Finish a terminal job under its allocation lock. Healthy bootstrapped VMs
# become idle until their existing billing boundary; every unsafe transition
# uses durable cleanup_pending teardown instead.
gcr_vm_finish_terminal() {
finish_job="$1"; finish_attempt="$2"; finish_rec="$3"; finish_state="$4"
finish_via="${5:-webhook}"
finish_vm_id="$(gcr_record_field "$finish_rec" vm_id)"
if [ -n "$finish_vm_id" ] && [ "$finish_vm_id" != "null" ] \
&& [ "$finish_vm_id" != "0" ]; then
case "$finish_state" in
completed:success|completed:cancelled|completed:skipped) ;;
completed:*)
finish_ip="$(gcr_vm_public_ip "$finish_vm_id" || true)"
gcr_vm_collect_diagnostics "$finish_vm_id" "$finish_ip" \
"$finish_job" "$finish_state" || true
;;
esac
fi
if finish_idle_rec="$(gcr_record_idle_json "$finish_rec")"; then
gcr_lock_acquire idle-pool || return 2
finish_repo="$(gcr_record_field "$finish_rec" repo)"
finish_runner="$(gcr_record_field "$finish_rec" vm_name)"
if ! gcr_vm_runner_service "$finish_vm_id" health; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-health-failed
elif ! gcr_gitea_runner_disabled "$finish_repo" "$finish_runner" true \
|| ! gcr_vm_runner_service "$finish_vm_id" stop; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-stop-failed
elif ! gcr_record_put "$finish_job" "$finish_attempt" "$finish_idle_rec"; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-state-write-failed
else
finish_expires="$(gcr_record_field "$finish_idle_rec" idle_expires_at)"
gcr_lock_release idle-pool
gcr_event "vm-idle" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"expires_at\":$finish_expires,\"via\":\"$finish_via\"}"
gcr_log info --ns=sweep \
"job=$finish_job terminal ($finish_state), retaining vm=$finish_vm_id until $finish_expires"
return 0
fi
if gcr_vm_cleanup_start "$finish_job" "$finish_attempt" "$finish_rec" \
"$finish_cleanup_reason" false; then
gcr_event "vm-destroyed" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_cleanup_reason\",\"via\":\"$finish_via\"}"
else
gcr_event "vm-cleanup-pending" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_cleanup_reason\",\"via\":\"$finish_via\"}"
fi
return 0
fi
if [ -n "$finish_vm_id" ] && [ "$finish_vm_id" != "null" ] \
&& [ "$finish_vm_id" != "0" ]; then
if gcr_vm_cleanup_start "$finish_job" "$finish_attempt" "$finish_rec" \
"$finish_state" false; then
gcr_event "vm-destroyed" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_state\",\"via\":\"$finish_via\"}"
else
gcr_event "vm-cleanup-pending" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_state\",\"via\":\"$finish_via\"}"
fi
else
gcr_record_del "$finish_job" "$finish_attempt"
fi
}
# Bootstrap delivery is SSH-push from the controller. The MicroOS snapshot's # Bootstrap delivery is SSH-push from the controller. The MicroOS snapshot's
# cloud-init cannot fetch user-data (Hetzner datasource DHCP failure), so the # cloud-init cannot fetch user-data (Hetzner datasource DHCP failure), so the
# controller drives provisioning over SSH using GCR_SSH_PRIVKEY_FILE, whose # controller drives provisioning over SSH using GCR_SSH_PRIVKEY_FILE, whose
@@ -321,7 +589,7 @@ STARTEOF
chmod 0700 /usr/local/sbin/gcr-runner-start chmod 0700 /usr/local/sbin/gcr-runner-start
cat > /etc/systemd/system/gitea-runner.service <<UNITEOF cat > /etc/systemd/system/gitea-runner.service <<UNITEOF
[Unit] [Unit]
Description=Gitea ephemeral Actions runner Description=Gitea on-demand Actions runner
After=network-online.target After=network-online.target
[Service] [Service]
+257 -7
View File
@@ -20,9 +20,54 @@ gcr_record_path() {
printf '%s/jobs/%s.json' "$GCR_STATE_DIR" "$(gcr_alloc_key "$1" "$2")" printf '%s/jobs/%s.json' "$GCR_STATE_DIR" "$(gcr_alloc_key "$1" "$2")"
} }
# mkdir(2) atomicity guard: succeeds exactly once per key until released. # mkdir(2) atomicity guard. Owner metadata lets a new controller process
# recover locks stranded by a crashed webhook or reconciler process.
gcr_lock_takeover() {
gcr_takeover_dir="$1"
gcr_takeover_old="$gcr_takeover_dir.reclaim.$$"
# Rename is atomic: exactly one reclaimer can move the observed stale
# directory. Never rm -rf the active lock pathname during recovery.
mv "$gcr_takeover_dir" "$gcr_takeover_old" 2>/dev/null || return 1
if mkdir "$gcr_takeover_dir" 2>/dev/null; then
printf '%s %s\n' "$$" "$(gcr_now_epoch)" > "$gcr_takeover_dir/owner"
rm -rf "$gcr_takeover_old"
return 0
fi
rm -rf "$gcr_takeover_old"
return 1
}
gcr_lock_acquire() { gcr_lock_acquire() {
mkdir "$(printf '%s/jobs/.lock.%s' "$GCR_STATE_DIR" "$1")" 2>/dev/null gcr_lock_key="$1"
gcr_lock_dir="$(printf '%s/jobs/.lock.%s' "$GCR_STATE_DIR" "$gcr_lock_key")"
if mkdir "$gcr_lock_dir" 2>/dev/null; then
printf '%s %s\n' "$$" "$(gcr_now_epoch)" > "$gcr_lock_dir/owner"
return 0
fi
gcr_lock_owner="$(cat "$gcr_lock_dir/owner" 2>/dev/null || true)"
if [ -z "$gcr_lock_owner" ]; then
# A crash between mkdir and owner write leaves no PID. Give a live
# creator a short initialization window, then unblock hard TTL work.
gcr_lock_mtime="$(stat -c %Y "$gcr_lock_dir" 2>/dev/null || true)"
gcr_lock_now="$(gcr_now_epoch)"
case "$gcr_lock_mtime:$gcr_lock_now" in
*[!0-9:]*|:*|*::*|*:) return 1 ;;
esac
[ "$((gcr_lock_now - gcr_lock_mtime))" -ge 30 ] || return 1
gcr_lock_takeover "$gcr_lock_dir"
return "$?"
fi
set -- $gcr_lock_owner
gcr_lock_pid="${1:-}"
case "$gcr_lock_pid" in ''|*[!0-9]*) return 1 ;; esac
if kill -0 "$gcr_lock_pid" 2>/dev/null; then
return 1
fi
# Dead PID means a process crash, not live contention. Atomically take
# over its directory; concurrent recovery cannot erase a new lock.
gcr_lock_takeover "$gcr_lock_dir"
} }
gcr_lock_release() { gcr_lock_release() {
@@ -46,7 +91,151 @@ gcr_record_del() {
} }
gcr_record_field() { gcr_record_field() {
printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // ""' printf '%s' "$1" | jq -r --arg f "$2" 'if has($f) then .[$f] else "" end'
}
gcr_now_epoch() {
date -u '+%s'
}
# Healthy bootstrapped VMs remain reusable until next billing-hour boundary,
# but never beyond profile hard TTL. Prints updated idle record when safe.
gcr_record_idle_json() {
gcr_idle_rec="$1"
[ "$(gcr_record_field "$gcr_idle_rec" bootstrapped)" = "true" ] || return 1
gcr_idle_vm_id="$(gcr_record_field "$gcr_idle_rec" vm_id)"
gcr_idle_created="$(gcr_record_field "$gcr_idle_rec" created_at)"
gcr_idle_ttl="$(gcr_record_field "$gcr_idle_rec" ttl_min)"
case "$gcr_idle_vm_id:$gcr_idle_created:$gcr_idle_ttl" in
*[!0-9:]*|0:*|:*|*::*|*:) return 1 ;;
esac
gcr_idle_now="$(gcr_now_epoch)"
gcr_idle_hard_expires="$((gcr_idle_created + gcr_idle_ttl * 60))"
[ "$gcr_idle_now" -lt "$gcr_idle_hard_expires" ] || return 1
gcr_idle_age="$((gcr_idle_now - gcr_idle_created))"
[ "$gcr_idle_age" -ge 0 ] || gcr_idle_age=0
gcr_idle_slots="$((gcr_idle_age / 3600))"
[ "$((gcr_idle_age % 3600))" -eq 0 ] || gcr_idle_slots=$((gcr_idle_slots + 1))
[ "$gcr_idle_slots" -gt 0 ] || gcr_idle_slots=1
gcr_idle_expires="$((gcr_idle_created + gcr_idle_slots * 3600))"
[ "$gcr_idle_expires" -le "$gcr_idle_hard_expires" ] \
|| gcr_idle_expires="$gcr_idle_hard_expires"
printf '%s' "$gcr_idle_rec" | jq -c \
--arg now "$gcr_idle_now" --arg expires "$gcr_idle_expires" \
'.status = "idle_vm"
| .idle_since = ($now | tonumber)
| .idle_expires_at = ($expires | tonumber)'
}
gcr_idle_record_unexpired() {
gcr_idle_rec="$1"
[ "$(gcr_record_field "$gcr_idle_rec" status)" = "idle_vm" ] || return 1
[ "$(gcr_record_field "$gcr_idle_rec" bootstrapped)" = "true" ] || return 1
gcr_idle_now="$(gcr_now_epoch)"
gcr_idle_vm_id="$(gcr_record_field "$gcr_idle_rec" vm_id)"
gcr_idle_vm_name="$(gcr_record_field "$gcr_idle_rec" vm_name)"
gcr_idle_expires="$(gcr_record_field "$gcr_idle_rec" idle_expires_at)"
gcr_idle_created="$(gcr_record_field "$gcr_idle_rec" created_at)"
gcr_idle_ttl="$(gcr_record_field "$gcr_idle_rec" ttl_min)"
case "$gcr_idle_expires:$gcr_idle_created:$gcr_idle_ttl" in
*[!0-9:]*|:*|*::*|*:) return 1 ;;
esac
case "$gcr_idle_vm_id" in ''|0|*[!0-9]*) return 1 ;; esac
[ -n "$gcr_idle_vm_name" ] || return 1
gcr_idle_hard_expires="$((gcr_idle_created + gcr_idle_ttl * 60))"
[ "$gcr_idle_now" -lt "$gcr_idle_expires" ] \
&& [ "$gcr_idle_now" -lt "$gcr_idle_hard_expires" ]
}
gcr_idle_record_usable() {
gcr_idle_rec="$1"
gcr_idle_record_unexpired "$gcr_idle_rec" || return 1
gcr_idle_min_remaining="${GCR_RECONCILE_INTERVAL_SEC:-60}"
case "$gcr_idle_min_remaining" in ''|*[!0-9]*) return 1 ;; esac
[ "$((gcr_idle_expires - gcr_idle_now))" -ge "$gcr_idle_min_remaining" ] \
&& [ "$((gcr_idle_hard_expires - gcr_idle_now))" -ge "$gcr_idle_min_remaining" ]
}
# Caller must hold destination allocation lock. Global pool lock ensures one
# queued job claims an idle VM; destination write precedes source deletion so
# orphan/stale sweeps always see an owner during transfer.
gcr_claim_idle() {
gcr_claim_job="$1"; gcr_claim_attempt="$2"
gcr_claim_repo="$3"; gcr_claim_label="$4"
# Exit 2 means pool is busy; callers must defer instead of charging for a
# new VM without knowing whether matching paid capacity is available.
gcr_lock_acquire idle-pool || return 2
for gcr_claim_file in $(gcr_active_records); do
gcr_claim_rec="$(cat "$gcr_claim_file")"
[ "$(gcr_record_field "$gcr_claim_rec" status)" = "idle_vm" ] || continue
[ "$(gcr_record_field "$gcr_claim_rec" repo)" = "$gcr_claim_repo" ] || continue
[ "$(gcr_record_field "$gcr_claim_rec" label)" = "$gcr_claim_label" ] || continue
gcr_idle_record_usable "$gcr_claim_rec" || continue
gcr_claim_old_job="$(gcr_record_field "$gcr_claim_rec" job_id)"
gcr_claim_old_attempt="$(gcr_record_field "$gcr_claim_rec" run_attempt)"
gcr_claim_vm_id="$(gcr_record_field "$gcr_claim_rec" vm_id)"
gcr_record_vm_owned_elsewhere "$gcr_claim_vm_id" \
"$gcr_claim_old_job" "$gcr_claim_old_attempt" && continue
gcr_claim_now="$(gcr_now_epoch)"
gcr_claim_new="$(printf '%s' "$gcr_claim_rec" | jq -c \
--arg job "$gcr_claim_job" --arg attempt "$gcr_claim_attempt" \
--arg repo "$gcr_claim_repo" --arg label "$gcr_claim_label" \
--arg now "$gcr_claim_now" \
'.job_id = $job | .run_attempt = $attempt
| .repo = $repo | .label = $label | .status = "pending_vm"
| .bootstrapped = false
| .reused_vm = true
| .assigned_at = ($now | tonumber)
| del(.idle_since, .idle_expires_at)')"
if ! gcr_record_put "$gcr_claim_job" "$gcr_claim_attempt" "$gcr_claim_new"; then
gcr_lock_release idle-pool
return 1
fi
gcr_record_del "$gcr_claim_old_job" "$gcr_claim_old_attempt"
gcr_lock_release idle-pool
return 0
done
gcr_lock_release idle-pool
return 1
}
gcr_record_exists_for_vm_id() {
gcr_lookup="$1"
for gcr_lookup_file in $(gcr_active_records); do
[ "$(gcr_record_field "$(cat "$gcr_lookup_file")" vm_id)" = "$gcr_lookup" ] \
&& return 0
done
return 1
}
gcr_record_exists_for_vm_name() {
gcr_lookup="$1"
for gcr_lookup_file in $(gcr_active_records); do
[ "$(gcr_record_field "$(cat "$gcr_lookup_file")" vm_name)" = "$gcr_lookup" ] \
&& return 0
done
return 1
}
gcr_record_vm_owned_elsewhere() {
gcr_lookup_vm="$1"; gcr_lookup_job="$2"; gcr_lookup_attempt="$3"
for gcr_lookup_file in $(gcr_active_records); do
gcr_lookup_rec="$(cat "$gcr_lookup_file")"
[ "$(gcr_record_field "$gcr_lookup_rec" vm_id)" = "$gcr_lookup_vm" ] || continue
if [ "$(gcr_record_field "$gcr_lookup_rec" job_id)" != "$gcr_lookup_job" ] \
|| [ "$(gcr_record_field "$gcr_lookup_rec" run_attempt)" != "$gcr_lookup_attempt" ]; then
return 0
fi
done
return 1
} }
gcr_event() { gcr_event() {
@@ -55,8 +244,8 @@ gcr_event() {
"$(printf '%s' "$3" | jq -Rs .)" >> "$GCR_STATE_DIR/events.jsonl" "$(printf '%s' "$3" | jq -Rs .)" >> "$GCR_STATE_DIR/events.jsonl"
} }
# Exit-code contract: 0 = recorded under budget, 1 = would exceed cap. # Exit-code contract: 0 = remains under budget, 1 = would exceed cap.
gcr_budget_add() { gcr_budget_can_add() {
rate="$1"; ttl_min="$2" rate="$1"; ttl_min="$2"
month="$(date -u '+%Y-%m')" month="$(date -u '+%Y-%m')"
file="$GCR_STATE_DIR/budget/$month" file="$GCR_STATE_DIR/budget/$month"
@@ -65,10 +254,71 @@ gcr_budget_add() {
if awk -v p="$projected" -v b="${GCR_BUDGET_EUR_MONTHLY:-15}" 'BEGIN {exit !(p > b)}'; then if awk -v p="$projected" -v b="${GCR_BUDGET_EUR_MONTHLY:-15}" 'BEGIN {exit !(p > b)}'; then
return 1 return 1
fi fi
printf '%s\n' "$projected" > "$file"
return 0 return 0
} }
# Atomic replacement preserves last valid total when a write fails.
gcr_budget_write() {
gcr_budget_file="$1"; gcr_budget_value="$2"
gcr_budget_tmp="$(mktemp "$(dirname "$gcr_budget_file")/.budget.XXXXXX")" \
|| return 1
if ! printf '%s\n' "$gcr_budget_value" > "$gcr_budget_tmp"; then
rm -f "$gcr_budget_tmp"
return 1
fi
if ! mv -f "$gcr_budget_tmp" "$gcr_budget_file"; then
rm -f "$gcr_budget_tmp"
return 1
fi
}
# Caller holds admission lock and has already checked gcr_budget_can_add.
gcr_budget_add() {
rate="$1"; ttl_min="$2"
month="$(date -u '+%Y-%m')"
file="$GCR_STATE_DIR/budget/$month"
current="$(cat "$file" 2>/dev/null || echo 0)"
projected="$(awk -v c="$current" -v r="$rate" -v t="$ttl_min" 'BEGIN {printf "%.4f", c + r * t / 60}')"
gcr_budget_write "$file" "$projected"
}
# Caller holds admission lock and is rolling back a matching budget addition.
gcr_budget_sub() {
rate="$1"; ttl_min="$2"
month="$(date -u '+%Y-%m')"
file="$GCR_STATE_DIR/budget/$month"
current="$(cat "$file" 2>/dev/null || echo 0)"
projected="$(awk -v c="$current" -v r="$rate" -v t="$ttl_min" \
'BEGIN {v = c - r * t / 60; if (v < 0) v = 0; printf "%.4f", v}')"
gcr_budget_write "$file" "$projected"
}
# Claim is durable before credit. Existing claim means credit is consumed:
# it may have completed, or it may have leaked fail-closed after a crash.
# Never subtract twice when outcome between aggregate and state writes is unknown.
gcr_budget_refund_once() {
refund_key="$1"; refund_rate="$2"; refund_ttl="$3"
refund_month="$(date -u '+%Y-%m')"
refund_root="$GCR_STATE_DIR/budget/refunds/$refund_month"
refund_claim="$refund_root/$refund_key"
mkdir -p "$refund_root" || return 1
if ! mkdir "$refund_claim" 2>/dev/null; then
if [ -d "$refund_claim" ]; then
if [ ! -f "$refund_claim/status" ]; then
gcr_log error --ns=budget \
"refund outcome uncertain key=$refund_key; retaining fail-closed claim"
fi
return 0
fi
return 1
fi
if ! printf '%s %s\n' "$refund_rate" "$refund_ttl" > "$refund_claim/intent"; then
return 1
fi
gcr_budget_sub "$refund_rate" "$refund_ttl" || return 1
printf 'refunded\n' > "$refund_claim/status" || return 1
}
gcr_active_records() { gcr_active_records() {
grep -El '"status"[[:space:]]*:[[:space:]]*"(pending_vm|vm_active|deferred)"' "$GCR_STATE_DIR"/jobs/*.json 2>/dev/null || true grep -El '"status"[[:space:]]*:[[:space:]]*"(pending_vm|vm_active|idle_vm|deferred|cleanup_pending|create_ambiguous)"' "$GCR_STATE_DIR"/jobs/*.json 2>/dev/null || true
} }
+123 -42
View File
@@ -94,6 +94,12 @@ gcr_alloc() {
RESPONSE_CODE=204 RESPONSE_CODE=204
return 0 return 0
fi fi
existing="$(gcr_record_get "$job_id" "$attempt")"
if [ -n "$existing" ]; then
gcr_lock_release "$key"
RESPONSE_CODE=204
return 0
fi
if [ "$label_count" -ne 1 ]; then if [ "$label_count" -ne 1 ]; then
gcr_lock_release "$key" gcr_lock_release "$key"
@@ -110,34 +116,77 @@ gcr_alloc() {
fi fi
set -- $profile set -- $profile
server_type="$1"; ttl_min="$2"; rate="$3" server_type="$1"; ttl_min="$2"
if ! gcr_lock_acquire admission; then
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
--arg l "$label" --arg t "$(gcr_now_epoch)" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
created_at:$t, ttl_min:null, vm_id:"", vm_name:"",
status:"deferred"}')"
gcr_record_put "$job_id" "$attempt" "$rec"
gcr_lock_release "$key"
gcr_event "deferred" "$job_id" "{\"reason\":\"admission-busy\"}"
RESPONSE_CODE=202; RESPONSE_BODY="deferred: admission busy"
return 0
fi
active="$(gcr_count_active)" active="$(gcr_count_active)"
repo_active="$(gcr_count_active_repo "$repo")" repo_active="$(gcr_count_active_repo "$repo")"
if [ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] \ if [ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] \
|| [ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ]; then || [ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ]; then
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \ rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
--arg l "$label" --arg t "$(date -u '+%s')" \ --arg l "$label" --arg t "$(gcr_now_epoch)" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l, '{job_id:$j, run_attempt:$a, repo:$r, label:$l,
created_at:$t, ttl_min:null, vm_id:"", vm_name:"", created_at:$t, ttl_min:null, vm_id:"", vm_name:"",
status:"deferred"}')" status:"deferred"}')"
gcr_record_put "$job_id" "$attempt" "$rec" gcr_record_put "$job_id" "$attempt" "$rec"
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "deferred" "$job_id" "{\"active\":$active,\"repo_active\":$repo_active}" gcr_event "deferred" "$job_id" "{\"active\":$active,\"repo_active\":$repo_active}"
RESPONSE_CODE=202; RESPONSE_BODY="deferred: capacity" RESPONSE_CODE=202; RESPONSE_BODY="deferred: capacity"
return 0 return 0
fi fi
if ! gcr_budget_add "$rate" "$ttl_min"; then claim_status=0
gcr_record_del "$job_id" "$attempt" gcr_claim_idle "$job_id" "$attempt" "$repo" "$label" || claim_status="$?"
if [ "$claim_status" -eq 0 ]; then
reused="$(gcr_record_get "$job_id" "$attempt")"
vm_id="$(gcr_record_field "$reused" vm_id)"
vm_name="$(gcr_record_field "$reused" vm_name)"
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$vm_name" false; then
reused="$(gcr_record_get "$job_id" "$attempt")"
reused="$(printf '%s' "$reused" | jq -c '.bootstrapped = true | del(.reused_vm)')"
gcr_record_put "$job_id" "$attempt" "$reused"
else
gcr_gitea_runner_disabled "$repo" "$vm_name" true || true
gcr_event "vm-reuse-start-failed" "$job_id" "{\"vm_id\":$vm_id}"
fi
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "budget-refused" "$job_id" "{\"rate\":$rate,\"ttl_min\":$ttl_min}" gcr_event "vm-reused" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\"}"
RESPONSE_CODE=202; RESPONSE_BODY="refused: monthly budget exhausted" RESPONSE_CODE=202; RESPONSE_BODY="reused $vm_name"
return 0
fi
if [ "$claim_status" -eq 2 ]; then
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
--arg l "$label" --arg t "$(gcr_now_epoch)" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
created_at:$t, ttl_min:null, vm_id:"", vm_name:"",
status:"deferred"}')"
gcr_record_put "$job_id" "$attempt" "$rec"
gcr_lock_release admission
gcr_lock_release "$key"
gcr_event "deferred" "$job_id" "{\"reason\":\"idle-pool-busy\"}"
RESPONSE_CODE=202; RESPONSE_BODY="deferred: idle pool busy"
return 0 return 0
fi fi
reg_token="$(gcr_gitea_registration_token "$repo")" || { reg_token="$(gcr_gitea_registration_token "$repo")" || {
gcr_record_del "$job_id" "$attempt" gcr_record_del "$job_id" "$attempt"
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "token-error" "$job_id" "{}" gcr_event "token-error" "$job_id" "{}"
RESPONSE_CODE=202; RESPONSE_BODY="registration token unavailable" RESPONSE_CODE=202; RESPONSE_BODY="registration token unavailable"
@@ -145,22 +194,34 @@ gcr_alloc() {
} }
vm_name="gcr-${job_id}-${attempt}" vm_name="gcr-${job_id}-${attempt}"
vm_id="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \ created_at="$(gcr_now_epoch)"
"$reg_token" "$job_id" "$attempt" "$repo")" || { create_status=0
gcr_record_del "$job_id" "$attempt" created="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \
"$reg_token" "$job_id" "$attempt" "$repo")" || create_status="$?"
if [ "$create_status" -ne 0 ]; then
if [ "$create_status" -eq 2 ]; then
gcr_event "vm-create-ambiguous" "$job_id" "{}"
RESPONSE_CODE=202; RESPONSE_BODY="VM creation pending recovery"
else
gcr_record_del "$job_id" "$attempt"
gcr_event "vm-create-failed" "$job_id" "{}"
RESPONSE_CODE=202; RESPONSE_BODY="VM creation failed"
fi
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "vm-create-failed" "$job_id" "{}"
RESPONSE_CODE=202; RESPONSE_BODY="VM creation failed"
return 0 return 0
} fi
set -- $created
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \ vm_id="$1"; actual_server_type="$2"; actual_rate="$3"
--arg l "$label" --arg t "$(date -u '+%s')" --arg v "$vm_id" \ if ! gcr_vm_record_created "$job_id" "$attempt" "$repo" "$label" \
--arg vn "$vm_name" --arg ttl "$ttl_min" \ "$created_at" "$vm_id" "$vm_name" "$ttl_min" \
'{job_id:$j, run_attempt:$a, repo:$r, label:$l, "$actual_server_type" "$actual_rate"; then
created_at:$t, ttl_min:($ttl|tonumber), vm_id:($v|tonumber), gcr_lock_release admission
vm_name:$vn, bootstrapped:false, status:"pending_vm"}')" gcr_lock_release "$key"
gcr_record_put "$job_id" "$attempt" "$rec" RESPONSE_CODE=202; RESPONSE_BODY="VM state write failed"
return 0
fi
gcr_lock_release admission
gcr_lock_release "$key" gcr_lock_release "$key"
gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min}" gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min}"
@@ -170,24 +231,45 @@ gcr_alloc() {
gcr_deallocate() { gcr_deallocate() {
job_id="$1"; attempt="$2"; new_status="$3" job_id="$1"; attempt="$2"; new_status="$3"
rec="$(gcr_record_get "$job_id" "$attempt")" key="$(gcr_alloc_key "$job_id" "$attempt")"
[ -n "$rec" ] || return 0 gcr_lock_acquire "$key" || return 0
vm_id="$(gcr_record_field "$rec" vm_id)" rec="$(gcr_record_get "$job_id" "$attempt")"
if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then if [ -z "$rec" ]; then
case "$new_status" in gcr_lock_release "$key"
completed:success|completed:cancelled|completed:skipped) ;; return 0
completed:*)
ip="$(gcr_vm_public_ip "$vm_id" || true)"
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$new_status" || true
;;
esac
gcr_vm_destroy "$vm_id" || true
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"$new_status\"}"
fi fi
gcr_record_del "$job_id" "$attempt" case "$(gcr_record_field "$rec" status)" in
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")" pending_vm|vm_active) ;;
*)
gcr_lock_release "$key"
return 0
;;
esac
finish_status=0
gcr_vm_finish_terminal "$job_id" "$attempt" "$rec" "$new_status" webhook \
|| finish_status="$?"
gcr_lock_release "$key"
case "$finish_status" in
0|2) return 0 ;;
*) return "$finish_status" ;;
esac
}
gcr_mark_in_progress() {
job_id="$1"; attempt="$2"
key="$(gcr_alloc_key "$job_id" "$attempt")"
gcr_lock_acquire "$key" || return 0
rec="$(gcr_record_get "$job_id" "$attempt")"
case "$(gcr_record_field "$rec" status)" in
pending_vm|vm_active)
rec="$(printf '%s' "$rec" | jq -c '.status = "vm_active"')"
gcr_record_put "$job_id" "$attempt" "$rec"
;;
esac
gcr_lock_release "$key"
} }
gcr_handle_webhook() { gcr_handle_webhook() {
@@ -210,8 +292,11 @@ gcr_handle_webhook() {
repo="$(printf '%s' "$gcr_body" | jq -r '.repository.full_name // ""')" repo="$(printf '%s' "$gcr_body" | jq -r '.repository.full_name // ""')"
labels_json="$(printf '%s' "$gcr_body" | jq -c '.workflow_job.labels // []')" labels_json="$(printf '%s' "$gcr_body" | jq -c '.workflow_job.labels // []')"
case "$action:$job_id" in [ -n "$action" ] || { gcr_respond 400 "malformed payload"; exit 0; }
:*|"queued:"|*":0") gcr_respond 400 "malformed payload"; exit 0 ;; case "$job_id:$attempt" in
*[!0-9:]*|:*|*::*|*:|0:*)
gcr_respond 400 "malformed payload"; exit 0
;;
esac esac
case "$action" in case "$action" in
@@ -220,11 +305,7 @@ gcr_handle_webhook() {
gcr_log info --ns=alloc "queued job=$job_id repo=$repo code=$RESPONSE_CODE $RESPONSE_BODY" gcr_log info --ns=alloc "queued job=$job_id repo=$repo code=$RESPONSE_CODE $RESPONSE_BODY"
;; ;;
in_progress) in_progress)
rec="$(gcr_record_get "$job_id" "$attempt")" gcr_mark_in_progress "$job_id" "$attempt"
if [ -n "$rec" ]; then
rec="$(printf '%s' "$rec" | jq -c '.status = "vm_active"')"
gcr_record_put "$job_id" "$attempt" "$rec"
fi
RESPONSE_CODE=204 RESPONSE_CODE=204
;; ;;
completed) completed)
+15 -12
View File
@@ -18,20 +18,26 @@
let let
pname = "gitea"; pname = "gitea";
version = "1.26.2"; version = "1.27.3";
src = ./source; src = ./source;
pnpm = pnpm_10; pnpm = pnpm_10;
pnpmPatches = [ ./pnpm-engine.patch ];
frontend = stdenv.mkDerivation { frontend = stdenv.mkDerivation {
pname = "gitea-frontend"; pname = "gitea-frontend";
inherit src version; inherit src version;
patches = pnpmPatches;
pnpmDeps = fetchPnpmDeps { pnpmDeps = fetchPnpmDeps {
pname = "gitea-frontend"; pname = "gitea-frontend";
inherit version src; inherit version src;
inherit pnpm; inherit pnpm;
patches = pnpmPatches;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-Qo0DLuZv+2GVLsBfCv/6CC9E/qhSE4HwV4StQL4HX4Y="; prePnpmInstall = ''
pnpm config set engine-strict false
'';
hash = "sha256-H1sNMKRkoPlkheJFJVaof4bJ4gQHnbhosJaUqj9X8Gg=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [
@@ -40,6 +46,10 @@ let
pnpm pnpm
]; ];
prePnpmInstall = ''
pnpm config set engine-strict false
'';
buildPhase = '' buildPhase = ''
make frontend make frontend
''; '';
@@ -54,7 +64,8 @@ buildGo126Module rec {
inherit pname version src; inherit pname version src;
proxyVendor = true; proxyVendor = true;
vendorHash = "sha256-7+M1n8RSgB3gZ/2na4RF9kYOf90H0bnsJZMDKpgAy64="; deleteVendor = true;
vendorHash = "sha256-YRBMGWKIZgMxOXaXG2bIBj1XzkhSwiMyfRy+yQGw+Bo=";
outputs = [ outputs = [
"out" "out"
@@ -63,17 +74,8 @@ buildGo126Module rec {
patches = [ ./static-root-path.patch ]; patches = [ ./static-root-path.patch ];
overrideModAttrs = _: {
postPatch = ''
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
};
postPatch = '' postPatch = ''
substituteInPlace modules/setting/server.go --subst-var data substituteInPlace modules/setting/server.go --subst-var data
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
''; '';
subPackages = [ "." ]; subPackages = [ "." ];
@@ -93,6 +95,7 @@ buildGo126Module rec {
]; ];
postInstall = '' postInstall = ''
mv "$out/bin/gitea.dev" "$out/bin/gitea"
mkdir $data mkdir $data
ln -s ${frontend}/public $data/public ln -s ${frontend}/public $data/public
cp -R ./{templates,options} $data cp -R ./{templates,options} $data
+13
View File
@@ -0,0 +1,13 @@
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
- "packageManager": "pnpm@11.9.0",
+ "packageManager": "pnpm@10.12.4",
"engines": {
"node": ">= 22.18.0",
- "pnpm": ">= 11.0.0"
+ "pnpm": ">= 10.0.0"
},
+1 -4
View File
@@ -37,10 +37,7 @@ groups:
name: BUGFIXES name: BUGFIXES
labels: labels:
- type/bug - type/bug
-
name: API
labels:
- modifies/api
- -
name: TESTING name: TESTING
labels: labels:
@@ -1,6 +1,6 @@
{ {
"name": "Gitea DevContainer", "name": "Gitea DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", "image": "mcr.microsoft.com/devcontainers/go:1.26-trixie",
"containerEnv": { "containerEnv": {
// override "local" from packaged version // override "local" from packaged version
"GOTOOLCHAIN": "auto" "GOTOOLCHAIN": "auto"
-8
View File
@@ -40,9 +40,7 @@ cpu.out
*.log *.log
/gitea /gitea
/gitea-vet
/debug /debug
/integrations.test
/bin /bin
/dist /dist
@@ -54,12 +52,6 @@ cpu.out
/indexers /indexers
/log /log
/tests/integration/gitea-integration-* /tests/integration/gitea-integration-*
/tests/integration/indexers-*
/tests/e2e/gitea-e2e-*
/tests/e2e/indexers-*
/tests/e2e/reports
/tests/e2e/test-artifacts
/tests/e2e/test-snapshots
/tests/*.ini /tests/*.ini
/node_modules /node_modules
/yarn.lock /yarn.lock
+1 -1
View File
@@ -18,7 +18,7 @@ indent_style = tab
[templates/custom/*.tmpl] [templates/custom/*.tmpl]
insert_final_newline = false insert_final_newline = false
[templates/swagger/v1_json.tmpl] [templates/swagger/*_json.tmpl]
indent_style = space indent_style = space
insert_final_newline = false insert_final_newline = false
+1
View File
@@ -4,6 +4,7 @@
/assets/*.json linguist-generated /assets/*.json linguist-generated
/public/assets/img/svg/*.svg linguist-generated /public/assets/img/svg/*.svg linguist-generated
/templates/swagger/v1_json.tmpl linguist-generated /templates/swagger/v1_json.tmpl linguist-generated
/templates/swagger/v1_openapi3_json.tmpl linguist-generated
/options/fileicon/** linguist-generated /options/fileicon/** linguist-generated
/vendor/** -text -eol linguist-vendored /vendor/** -text -eol linguist-vendored
/web_src/js/vendor/** -text -eol linguist-vendored /web_src/js/vendor/** -text -eol linguist-vendored
@@ -1,42 +0,0 @@
<!-- NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue -->
<!--
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
-->
- Gitea version (or commit ref):
- Git version:
- Operating system:
<!-- Please include information on whether you built gitea yourself, used one of our downloads or are using some other package -->
<!-- Please also tell us how you are running gitea, e.g. if it is being run from docker, a command-line, systemd etc. --->
<!-- If you are using a package or systemd tell us what distribution you are using -->
- Database (use `[x]`):
- [ ] PostgreSQL
- [ ] MySQL
- [ ] MSSQL
- [ ] SQLite
- Can you reproduce the bug at https://demo.gitea.com:
- [ ] Yes (provide example URL)
- [ ] No
- Log gist:
<!-- It really is important to provide pertinent logs -->
<!-- Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help -->
<!-- In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini -->
## Description
<!-- If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please
disable the proxy/CDN fully and connect to gitea directly to confirm
the issue still persists without those services. -->
...
## Screenshots
<!-- **If this issue involves the Web Interface, please include a screenshot** -->
+10 -73
View File
@@ -1,91 +1,28 @@
name: Bug Report name: Bug Report
description: Found something you weren't expecting? Report it here! description: Something isn't working as expected.
labels: ["type/bug"] labels: ["type/bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. - **Security issue?** Email security@gitea.io instead of opening a public issue.
- type: markdown - **Need help** with setup or configuration? Ask on [Discord](https://discord.gg/Gitea) or the [forum](https://forum.gitea.com).
attributes: - Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. It's really important to provide pertinent details and logs (https://docs.gitea.com/help/support),
incomplete details will be handled as an invalid report.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below)
If you are using a proxy or a CDN (e.g. Cloudflare) in front of Gitea, please disable the proxy/CDN fully and access Gitea directly to confirm the issue still persists without those services.
- type: input - type: input
id: gitea-ver id: gitea-ver
attributes: attributes:
label: Gitea Version label: Gitea Version
description: Gitea version (or commit reference) of your instance
validations: validations:
required: true required: true
- type: dropdown - type: textarea
id: can-reproduce id: description
attributes: attributes:
label: Can you reproduce the bug on the Gitea demo site? label: What happened?
description: | description: What you did, what you expected to happen, and what happened instead. Include logs if relevant.
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
validations: validations:
required: true required: true
- type: markdown
attributes:
value: |
It's really important to provide pertinent logs
Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help
In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini
- type: input
id: logs
attributes:
label: Log Gist
description: Please provide a gist URL of your logs, with any sensitive information (e.g. API keys) removed/hidden
- type: textarea - type: textarea
id: screenshots id: environment
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots
- type: input
id: git-ver
attributes:
label: Git Version
description: The version of git running on the server
- type: input
id: os-ver
attributes:
label: Operating System
description: The operating system you are using to run Gitea
- type: textarea
id: run-info
attributes: attributes:
label: How are you running Gitea? label: How are you running Gitea?
description: | description: Install method (binary, Docker, package), operating system, and database.
Please include information on whether you built Gitea yourself, used one of our downloads, are using https://demo.gitea.com or are using some other package
Please also tell us how you are running Gitea, e.g. if it is being run from docker, a command-line, systemd etc.
If you are using a package or systemd tell us what distribution you are using
validations:
required: true
- type: dropdown
id: database
attributes:
label: Database
description: What database system are you running?
options:
- PostgreSQL
- MySQL/MariaDB
- MSSQL
- SQLite
@@ -1,24 +1,20 @@
name: Feature Request name: Feature Request
description: Got an idea for a feature that Gitea doesn't have currently? Submit your idea here! description: Suggest an idea for Gitea.
labels: ["type/proposal"] labels: ["type/proposal"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
1. Please speak English, this is the language all maintainers can speak and write. Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your feature hasn't already been suggested.
- type: textarea - type: textarea
id: description id: problem
attributes: attributes:
label: Feature Description label: What problem would this solve?
placeholder: |
I think it would be great if Gitea had...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: screenshots id: proposal
attributes: attributes:
label: Screenshots label: What do you propose?
description: If you can, provide screenshots of an implementation on another site e.g. GitHub validations:
required: true
@@ -1,66 +0,0 @@
name: Web Interface Bug Report
description: Something doesn't look quite as it should? Report it here!
labels: ["type/bug", "topic/ui"]
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue.
- type: markdown
attributes:
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
6. In particular it's really important to provide pertinent logs. If you are certain that this is a javascript
error, show us the javascript console. If the error appears to relate to Gitea the server you must also give us
DEBUG level logs. (See https://docs.gitea.com/administration/logging-config#collecting-logs-for-help)
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below)
If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please disable the proxy/CDN fully and connect to gitea directly to confirm the issue still persists without those services.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please provide at least 1 screenshot showing the issue.
validations:
required: true
- type: input
id: gitea-ver
attributes:
label: Gitea Version
description: Gitea version (or commit reference) your instance is running
validations:
required: true
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Gitea demo site?
description: |
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
validations:
required: true
- type: input
id: os-ver
attributes:
label: Operating System
description: The operating system you are using to access Gitea
- type: input
id: browser-ver
attributes:
label: Browser Version
description: The browser and version that you are using to access Gitea
validations:
required: true
@@ -0,0 +1,29 @@
name: docker-dryrun
description: Composite action that performs the container build steps for a single platform.
inputs:
platform:
description: "The target platform: linux/amd64, linux/arm64, linux/riscv64."
required: true
runs:
using: composite
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build regular image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
@@ -0,0 +1,17 @@
name: free-disk-space
description: Free space on / before large cache restores
# Delete preinstalled toolchains which gitea doesn't use and show disk space usage
runs:
using: composite
steps:
- shell: bash
run: |
echo "free space before cleanup:"
df -h /
for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do
sudo rm -rf "$dir" &
done
wait
echo "free space after cleanup:"
df -h /
@@ -0,0 +1,50 @@
name: go-caches
description: Restore the go module, build, and golangci-lint caches. Save only on the cache-seeder workflow.
# Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml.
# The lint job restores but does not save the gobuild cache, so only one writer
# (the gobuild job) populates it and there is no contention on the cache key.
# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean
# cache and size stays bounded; do not add restore-keys here. PR runs keep them.
inputs:
lint-cache:
description: Restore (and save in cache-seeder) ~/.cache/golangci-lint
default: "false"
runs:
using: composite
steps:
- if: ${{ github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gomod-${{ runner.os }}-${{ runner.arch }}
- if: ${{ github.workflow == 'cache-seeder' && inputs.lint-cache != 'true' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
restore-keys: golint-${{ runner.os }}-${{ runner.arch }}
@@ -0,0 +1,24 @@
name: go-setup
description: Set up go and restore caches
inputs:
cache:
description: Restore go caches
default: "true"
lint-cache:
description: Also restore the golangci-lint cache
default: "false"
runs:
using: composite
steps:
- uses: ./.github/actions/free-disk-space
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
cache: false
- if: ${{ inputs.cache == 'true' }}
uses: ./.github/actions/go-cache
with:
lint-cache: ${{ inputs.lint-cache }}
@@ -0,0 +1,22 @@
name: node-setup
description: Set up pnpm and node and restore caches
inputs:
cache:
description: Cache pnpm downloads
default: "true"
runs:
using: composite
steps:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- if: ${{ inputs.cache == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- if: ${{ inputs.cache != 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
@@ -0,0 +1,40 @@
name: pgsql-shard
description: Run one pgsql integration test shard
inputs:
shard:
description: Shard index
required: true
total-shards:
description: Total shard count
required: true
run-migration:
description: Also run migration tests
default: "false"
runs:
using: composite
steps:
- name: Add hosts to /etc/hosts
shell: bash
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- shell: bash
run: make deps-backend
- shell: bash
run: make backend
env:
TAGS: bindata
- name: run migration tests
if: ${{ inputs.run-migration == 'true' }}
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-migration
- name: run tests
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-integration
env:
# pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower.
GOTEST_FLAGS: -race -timeout=40m
TAGS: bindata gogit
TEST_LDAP: 1
TEST_SHARD: ${{ inputs.shard }}
TEST_TOTAL_SHARDS: ${{ inputs.total-shards }}
-10
View File
@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
labels: [modifies/dependencies]
directory: /
schedule:
interval: daily
cooldown:
default-days: 5
-77
View File
@@ -1,80 +1,3 @@
modifies/docs:
- changed-files:
- any-glob-to-any-file:
- "**/*.md"
- "docs/**"
modifies/templates:
- changed-files:
- all-globs-to-any-file:
- "templates/**"
- "!templates/swagger/v1_json.tmpl"
modifies/api:
- changed-files:
- any-glob-to-any-file:
- "routers/api/**"
- "templates/swagger/v1_json.tmpl"
modifies/cli:
- changed-files:
- any-glob-to-any-file:
- "cmd/**"
modifies/translation:
- changed-files:
- any-glob-to-any-file:
- "options/locale/*.ini"
modifies/migrations:
- changed-files:
- any-glob-to-any-file:
- "models/migrations/**"
modifies/internal:
- changed-files:
- any-glob-to-any-file:
- ".air.toml"
- "Makefile"
- "Dockerfile"
- "Dockerfile.rootless"
- ".dockerignore"
- "docker/**"
- ".editorconfig"
- ".eslintrc.cjs"
- ".golangci.yml"
- ".markdownlint.yaml"
- ".spectral.yaml"
- "stylelint.config.*"
- ".yamllint.yaml"
- ".github/**"
- ".gitea/**"
- ".devcontainer/**"
- "build/**"
- "contrib/**"
modifies/dependencies:
- changed-files:
- any-glob-to-any-file:
- "package.json"
- "pnpm-lock.yaml"
- "pyproject.toml"
- "uv.lock"
- "go.mod"
- "go.sum"
modifies/go:
- changed-files:
- any-glob-to-any-file:
- "**/*.go"
modifies/frontend:
- changed-files:
- any-glob-to-any-file:
- "*.js"
- "*.ts"
- "web_src/**"
docs-update-needed: docs-update-needed:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
+9 -10
View File
@@ -1,10 +1,9 @@
<!-- start tips --> <!--
Please check the following: Before submitting:
1. Make sure you are targeting the `main` branch, pull requests on release branches are only allowed for backports. - Target the `main` branch; release branches are for backports only.
2. Make sure you have read contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md . - Use a Conventional Commits title, e.g. `fix(repo): handle empty branch names`.
3. For documentations contribution, please go to https://gitea.com/gitea/docs - Read the contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
4. Describe what your pull request does and which issue you're targeting (if any). - Documentation changes go to https://gitea.com/gitea/docs
5. It is recommended to enable "Allow edits by maintainers", so maintainers can help more easily.
6. Your input here will be included in the commit message when this PR has been merged. If you don't want some content to be included, please separate them with a line like `---`. Describe your change below and link any issue it fixes.
7. Delete all these tips before posting. -->
<!-- end tips -->
+72
View File
@@ -0,0 +1,72 @@
# Populates main's cache scope so PR runs warm-start from it. Saves the go
# module, go build (incl. test compile), and golangci-lint caches.
#
# Caches are ref-scoped: PR runs read their own scope then fall back to the
# base branch. Per .github/actions/go-cache/action.yml, PRs are restore-only,
# so push-to-main is the only opportunity to populate the fallback scope.
name: cache-seeder
on:
push:
branches:
- main
paths:
- "go.sum"
- ".golangci.yml"
- ".github/actions/go-cache/action.yml"
- ".github/actions/go-setup/action.yml"
- ".github/workflows/cache-seeder.yml"
concurrency:
group: cache-seeder
cancel-in-progress: true
permissions:
contents: read
jobs:
gobuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend deps-tools
- run: TAGS="bindata" make backend
- run: TAGS="bindata gogit" GOEXPERIMENT="" make backend
- name: warm test compile cache (bindata)
env:
TAGS: bindata
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm test compile cache (bindata gogit)
env:
TAGS: bindata gogit
GOEXPERIMENT:
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm integration compile cache
run: |
TAGS="bindata" make test-integration-compile
TAGS="bindata gogit" GOEXPERIMENT="" make test-integration-compile
TAGS="bindata gogit" GOTEST_FLAGS="-race" make test-integration-compile
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { tags: "bindata", target: "lint-backend" }
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
with:
lint-cache: "true"
- run: make deps-backend deps-tools
- run: make generate-go
env:
TAGS: ${{ matrix.tags }}
- run: make ${{ matrix.target }}
env:
TAGS: ${{ matrix.tags }}
@@ -1,22 +0,0 @@
name: cron-flake-updater
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # runs weekly on Sunday at 00:00
jobs:
nix-flake-update:
permissions:
contents: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@v3
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "Update Nix flake"
pr-labels: |
dependencies
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- run: make generate-gitignore - run: make generate-gitignore
timeout-minutes: 40 timeout-minutes: 40
- name: push translations to repo - name: push translations to repo
uses: appleboy/git-push-action@v1.2.0 uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with: with:
author_email: "teabot@gitea.io" author_email: "teabot@gitea.io"
author_name: GiteaBot author_name: GiteaBot
@@ -0,0 +1,32 @@
name: cron-renovate
on:
schedule:
- cron: "23 * * * *" # hourly at :23
workflow_dispatch:
concurrency:
group: cron-renovate
env:
RENOVATE_VERSION: 43.141.5 # renovate: datasource=docker depName=ghcr.io/renovatebot/renovate
permissions:
contents: read
jobs:
cron-renovate:
runs-on: ubuntu-latest
if: github.repository == 'go-gitea/gitea' # prevent running on forks
timeout-minutes: 30
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: renovatebot/github-action@6d859fc95779be83a0335ca704879b47e5d79641 # v46.1.16
with:
renovate-version: ${{ env.RENOVATE_VERSION }}
configurationFile: renovate.json5
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_BINARY_SOURCE: install # auto-install go/node toolchains needed by post-upgrade tasks.
RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg)$"]'
RENOVATE_REPOSITORIES: '["go-gitea/gitea"]'
@@ -12,8 +12,8 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: crowdin/github-action@v2 - uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with: with:
upload_sources: true upload_sources: true
upload_translations: false upload_translations: false
@@ -29,7 +29,7 @@ jobs:
- name: update locales - name: update locales
run: ./build/update-locales.sh run: ./build/update-locales.sh
- name: push translations to repo - name: push translations to repo
uses: appleboy/git-push-action@v1.2.0 uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with: with:
author_email: "teabot@gitea.io" author_email: "teabot@gitea.io"
author_name: GiteaBot author_name: GiteaBot
+43 -10
View File
@@ -15,19 +15,26 @@ on:
value: ${{ jobs.detect.outputs.templates }} value: ${{ jobs.detect.outputs.templates }}
docker: docker:
value: ${{ jobs.detect.outputs.docker }} value: ${{ jobs.detect.outputs.docker }}
dockerfile:
value: ${{ jobs.detect.outputs.dockerfile }}
swagger: swagger:
value: ${{ jobs.detect.outputs.swagger }} value: ${{ jobs.detect.outputs.swagger }}
yaml: yaml:
value: ${{ jobs.detect.outputs.yaml }} value: ${{ jobs.detect.outputs.yaml }}
json: json:
value: ${{ jobs.detect.outputs.json }} value: ${{ jobs.detect.outputs.json }}
e2e:
value: ${{ jobs.detect.outputs.e2e }}
shell:
value: ${{ jobs.detect.outputs.shell }}
permissions:
contents: read
jobs: jobs:
detect: detect:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
permissions:
contents: read
outputs: outputs:
backend: ${{ steps.changes.outputs.backend }} backend: ${{ steps.changes.outputs.backend }}
frontend: ${{ steps.changes.outputs.frontend }} frontend: ${{ steps.changes.outputs.frontend }}
@@ -35,12 +42,15 @@ jobs:
actions: ${{ steps.changes.outputs.actions }} actions: ${{ steps.changes.outputs.actions }}
templates: ${{ steps.changes.outputs.templates }} templates: ${{ steps.changes.outputs.templates }}
docker: ${{ steps.changes.outputs.docker }} docker: ${{ steps.changes.outputs.docker }}
dockerfile: ${{ steps.changes.outputs.dockerfile }}
swagger: ${{ steps.changes.outputs.swagger }} swagger: ${{ steps.changes.outputs.swagger }}
yaml: ${{ steps.changes.outputs.yaml }} yaml: ${{ steps.changes.outputs.yaml }}
json: ${{ steps.changes.outputs.json }} json: ${{ steps.changes.outputs.json }}
e2e: ${{ steps.changes.outputs.e2e }}
shell: ${{ steps.changes.outputs.shell }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dorny/paths-filter@v4 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes id: changes
with: with:
filters: | filters: |
@@ -54,49 +64,61 @@ jobs:
- ".golangci.yml" - ".golangci.yml"
- ".editorconfig" - ".editorconfig"
- "options/locale/locale_en-US.json" - "options/locale/locale_en-US.json"
- "models/fixtures/**"
- "tests/*.ini.tmpl"
- "tests/gitea-repositories-meta/**"
- "tests/testdata/**"
- "tools/test-integration.sh"
frontend: frontend:
- "*.js"
- "*.ts" - "*.ts"
- "web_src/**" - "web_src/**"
- "tools/*.js" - "tools/generate-svg.ts"
- "tools/*.ts" - "tools/generate-svg-vscode-extensions.json"
- "tsconfig.json"
- "assets/emoji.json" - "assets/emoji.json"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Makefile" - "Makefile"
- ".eslintrc.cjs"
- ".npmrc"
docs: docs:
- "**/*.md" - "**/*.md"
- ".markdownlint.yaml" - ".markdownlint.yaml"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
actions: actions:
- ".github/workflows/*" - ".github/workflows/*"
- ".github/actions/**"
- "Makefile" - "Makefile"
templates: templates:
- "tools/lint-templates-*.js" - "tools/lint-templates-*.ts"
- "templates/**/*.tmpl" - "templates/**/*.tmpl"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
docker: docker:
- ".github/workflows/pull-docker-dryrun.yml" - ".github/workflows/pull-docker-dryrun.yml"
- ".github/actions/docker-dryrun/**"
- "Dockerfile" - "Dockerfile"
- "Dockerfile.rootless" - "Dockerfile.rootless"
- "docker/**" - "docker/**"
- "Makefile" - "Makefile"
dockerfile:
- "Dockerfile"
- "Dockerfile.rootless"
swagger: swagger:
- "templates/swagger/v1_json.tmpl" - "templates/swagger/v1_json.tmpl"
- "templates/swagger/v1_input.json" - "templates/swagger/v1_input.json"
- "Makefile" - "Makefile"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".spectral.yaml" - ".spectral.yaml"
yaml: yaml:
@@ -107,3 +129,14 @@ jobs:
json: json:
- "**/*.json" - "**/*.json"
- "**/*.json5"
- "eslint.json.config.ts"
e2e:
- "tests/e2e/**"
- "tools/test-e2e.sh"
- "playwright.config.ts"
shell:
- "**/*.sh"
- ".shellcheckrc"
@@ -0,0 +1,26 @@
name: giteabot backport
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea
checks: backport
+64
View File
@@ -0,0 +1,64 @@
name: giteabot
on:
# When main advances, rerun merge queue maintenance so the oldest
# reviewed/wait-merge PR can be updated against the new base promptly.
push:
branches:
- main
# pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from
# forks, which the bot needs to write labels, statuses and comments. Safe here
# because the job only runs a pinned action and never checks out PR HEAD.
# These PR lifecycle events drive label maintenance, queue maintenance, and
# explicit bot actions triggered by relevant label changes.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- opened
- synchronize
- labeled
- unlabeled
- closed
- review_requested
- review_request_removed
# Review events keep review-derived state such as lgtm labels and status checks
# in sync after approvals, edits, or dismissals.
pull_request_review:
types:
- submitted
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly.
schedule:
- cron: "15 3 * * *"
# Allow maintainers to rerun selected checks manually when debugging bot
# behavior without waiting for another repository event.
workflow_dispatch:
inputs:
checks:
description: Comma-separated list of non-backport checks to run
required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
concurrency:
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+37 -174
View File
@@ -7,156 +7,63 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
files-changed: files-changed:
uses: ./.github/workflows/files-changed.yml uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
lint-backend: lint-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with: with:
go-version-file: go.mod lint-cache: "true"
check-latest: true
- run: make deps-backend deps-tools - run: make deps-backend deps-tools
- run: TAGS="bindata" make generate-go # lint-go also lints with "bindata" tags which requires "_bindata.go"
- run: make lint-backend - run: make lint-backend
env:
TAGS: bindata sqlite sqlite_unlock_notify
lint-templates: lint-on-demand:
if: needs.files-changed.outputs.templates == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@v8.0.0 - uses: ./.github/actions/go-setup
- run: uv python install 3.14
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with: with:
node-version: 24 cache: "false"
cache: pnpm - uses: ./.github/actions/node-setup
cache-dependency-path: pnpm-lock.yaml
- run: make deps-py
- run: make deps-frontend
- run: make lint-templates
lint-yaml:
if: needs.files-changed.outputs.yaml == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
- run: uv python install 3.14
- run: make deps-py
- run: make lint-yaml
lint-json:
if: needs.files-changed.outputs.json == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with: with:
node-version: 24 cache: "false"
- run: make deps-frontend
- run: make lint-json
lint-swagger:
if: needs.files-changed.outputs.swagger == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend
- run: make lint-swagger
lint-spell:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.templates == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make lint-spell - run: make lint-spell
lint-go-windows: - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' || needs.files-changed.outputs.actions == 'true'
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with: with:
go-version-file: go.mod python-version: 3.14
check-latest: true - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true'
- run: make deps-backend deps-tools run: make deps-py lint-templates lint-yaml
- run: make lint-go-windows lint-go-gitea-vet
env:
TAGS: bindata sqlite sqlite_unlock_notify
GOOS: windows
GOARCH: amd64
lint-go-gogit: - if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.swagger == 'true' || needs.files-changed.outputs.json == 'true'
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' run: make deps-frontend lint-md lint-swagger lint-json
needs: files-changed
runs-on: ubuntu-latest - if: needs.files-changed.outputs.actions == 'true'
permissions: run: make lint-actions
contents: read
steps: - if: needs.files-changed.outputs.shell == 'true'
- uses: actions/checkout@v6 run: make lint-shell
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend deps-tools
- run: make lint-go
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
checks-backend: checks-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend deps-tools - run: make deps-backend deps-tools
- run: make --always-make checks-backend # ensure the "go-licenses" make target runs - run: make --always-make checks-backend # ensure the "go-licenses" make target runs
@@ -164,16 +71,9 @@ jobs:
if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: pnpm/action-setup@v5 - uses: ./.github/actions/node-setup
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend - run: make deps-frontend
- run: make lint-frontend - run: make lint-frontend
- run: make checks-frontend - run: make checks-frontend
@@ -184,20 +84,14 @@ jobs:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with: - run: make deps-backend generate-go
go-version-file: go.mod # no frontend build here as backend should be able to build, even without any frontend files
check-latest: true # CGO is not used when cross-compile, so these steps also test if the code is compatible with CGO disabled
# no frontend build here as backend should be able to build
# even without any frontend files
- run: make deps-backend
- run: go build -o gitea_no_gcc # test if build succeeds without the sqlite tag
- name: build-backend-arm64 - name: build-backend-arm64
run: make backend # test cross compile run: go build -o gitea_linux_arm64
env: env:
GOOS: linux GOOS: linux
GOARCH: arm64 GOARCH: arm64
@@ -209,38 +103,7 @@ jobs:
GOARCH: amd64 GOARCH: amd64
TAGS: bindata gogit TAGS: bindata gogit
- name: build-backend-386 - name: build-backend-386
run: go build -o gitea_linux_386 # test if compatible with 32 bit run: go build -o gitea_linux_386
env: env:
GOOS: linux GOOS: linux
GOARCH: 386 GOARCH: 386
docs:
if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend
- run: make lint-md
actions:
if: needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make lint-actions
+99 -87
View File
@@ -7,18 +7,18 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
files-changed: files-changed:
uses: ./.github/workflows/files-changed.yml uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
test-pgsql: test-pgsql-shard-1:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: timeout-minutes: 50
contents: read
services: services:
pgsql: pgsql:
image: postgres:14 image: postgres:14
@@ -28,79 +28,100 @@ jobs:
ports: ports:
- "5432:5432" - "5432:5432"
ldap: ldap:
image: gitea/test-openldap:latest image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0
ports: ports:
- "389:389" - "389:389"
- "636:636" - "636:636"
minio: minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image # as github actions doesn't support "entrypoint", we need to use a non-official image
# that has a custom entrypoint set to "minio server /data" # that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2023.8.31 image: bitnamilegacy/minio:2025.7.23
env: env:
MINIO_ROOT_USER: 123456 MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678 MINIO_ROOT_PASSWORD: 12345678
ports: ports:
- "9000:9000" - "9000:9000"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
- uses: ./.github/actions/pgsql-shard
with: with:
go-version-file: go.mod shard: 1
check-latest: true total-shards: 2
- name: Add hosts to /etc/hosts run-migration: "true"
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- run: make deps-backend test-pgsql-shard-2:
- run: make backend if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
timeout-minutes: 50
services:
pgsql:
image: postgres:14
env: env:
TAGS: bindata POSTGRES_DB: test
- name: run migration tests POSTGRES_PASSWORD: postgres
run: make test-pgsql-migration ports:
- name: run tests - "5432:5432"
run: make test-pgsql ldap:
timeout-minutes: 50 image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0
ports:
- "389:389"
- "636:636"
minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image
# that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2025.7.23
env: env:
TAGS: bindata gogit MINIO_ROOT_USER: 123456
RACE_ENABLED: true MINIO_ROOT_PASSWORD: 12345678
TEST_TAGS: gogit ports:
TEST_LDAP: 1 - "9000:9000"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- uses: ./.github/actions/pgsql-shard
with:
shard: 2
total-shards: 2
test-sqlite: test-sqlite:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend - run: make deps-backend
- run: GOEXPERIMENT='' make backend - run: make backend
env: env:
TAGS: bindata gogit sqlite sqlite_unlock_notify TAGS: bindata gogit
- name: run migration tests GOEXPERIMENT:
run: make test-sqlite-migration - run: GITEA_TEST_DATABASE=sqlite make test-migration
env:
TAGS: bindata gogit
- name: run tests - name: run tests
run: GOEXPERIMENT='' make test-sqlite run: GITEA_TEST_DATABASE=sqlite make test-integration
timeout-minutes: 50 timeout-minutes: 50
env: env:
TAGS: bindata gogit sqlite sqlite_unlock_notify # sqlite driver can contain large amount of Golang code, so don't use race detector for it, otherwise, extremely slow
RACE_ENABLED: true GOTEST_FLAGS: -timeout=40m
TEST_TAGS: gogit sqlite sqlite_unlock_notify TAGS: bindata gogit
GOEXPERIMENT:
test-unit: test-unit:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
elasticsearch: elasticsearch:
image: elasticsearch:7.5.0 image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env: env:
discovery.type: single-node discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports: ports:
- "9200:9200" - "9200:9200"
meilisearch: meilisearch:
@@ -110,7 +131,7 @@ jobs:
ports: ports:
- "7700:7700" - "7700:7700"
redis: redis:
image: redis image: redis:latest@sha256:c904002d182255b6db3cbe3a1e8ce6c187d15390c39500b59fc07181aabff7bf
options: >- # wait until redis has started options: >- # wait until redis has started
--health-cmd "redis-cli ping" --health-cmd "redis-cli ping"
--health-interval 5s --health-interval 5s
@@ -119,51 +140,49 @@ jobs:
ports: ports:
- 6379:6379 - 6379:6379
minio: minio:
image: bitnamilegacy/minio:2021.3.17 image: bitnamilegacy/minio:2025.7.23
env: env:
MINIO_ACCESS_KEY: 123456 MINIO_ROOT_USER: 123456
MINIO_SECRET_KEY: 12345678 MINIO_ROOT_PASSWORD: 12345678
ports: ports:
- "9000:9000" - "9000:9000"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583
image: mcr.microsoft.com/azure-storage/azurite:latest image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:dae2a5f96553962901304b94e72ef87e299d0825e4b679673bcc527a25076fe4
ports: ports:
- 10000:10000 - 10000:10000
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts' run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts'
- run: make deps-backend - run: make deps-backend
- run: make backend - run: make generate-go
env: env:
TAGS: bindata TAGS: bindata
- name: unit-tests - name: unit-tests
run: make unit-test-coverage test-check run: make test-backend
env: env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata TAGS: bindata
RACE_ENABLED: true
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
- name: unit-tests-gogit - name: unit-tests-gogit
run: GOEXPERIMENT='' make unit-test-coverage test-check run: make test-backend
env: env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata gogit TAGS: bindata gogit
RACE_ENABLED: true GOEXPERIMENT:
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
GITEA_TEST_CI_SKIP_EXTERNAL: true
- run: make test-check
test-mysql: test-mysql:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
mysql: mysql:
# the bitnami mysql image has more options than the official one, it's easier to customize # the bitnami mysql image has more options than the official one, it's easier to customize
image: bitnamilegacy/mysql:8.0 image: bitnamilegacy/mysql:8.4
env: env:
ALLOW_EMPTY_PASSWORD: true ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: testgitea MYSQL_DATABASE: testgitea
@@ -172,46 +191,42 @@ jobs:
options: >- options: >-
--mount type=tmpfs,destination=/bitnami/mysql/data --mount type=tmpfs,destination=/bitnami/mysql/data
elasticsearch: elasticsearch:
image: elasticsearch:7.5.0 image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env: env:
discovery.type: single-node discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports: ports:
- "9200:9200" - "9200:9200"
smtpimap: smtpimap:
image: tabascoterrier/docker-imap-devel:latest image: tabascoterrier/docker-imap-devel:latest@sha256:3fb7cf50b47693e7b80f6f74abea2def4d7386016931d61359864de8a0aba551
ports: ports:
- "25:25" - "25:25"
- "143:143" - "143:143"
- "587:587" - "587:587"
- "993:993" - "993:993"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts' run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts'
- run: make deps-backend - run: make deps-backend
- run: make backend - run: make backend
env: env:
TAGS: bindata TAGS: bindata
- name: run migration tests - run: GITEA_TEST_DATABASE=mysql make test-migration
run: make test-mysql-migration
- name: run tests - name: run tests
# run: make integration-test-coverage (at the moment, no coverage is really handled) run: GITEA_TEST_DATABASE=mysql make test-integration
run: make test-mysql
env: env:
TAGS: bindata TAGS: bindata
RACE_ENABLED: true
TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200"
test-mssql: test-mssql:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
mssql: mssql:
image: mcr.microsoft.com/mssql/server:2019-latest image: mcr.microsoft.com/mssql/server:2019-latest
@@ -222,24 +237,21 @@ jobs:
ports: ports:
- "1433:1433" - "1433:1433"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583
image: mcr.microsoft.com/azure-storage/azurite:latest image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:dae2a5f96553962901304b94e72ef87e299d0825e4b679673bcc527a25076fe4
ports: ports:
- 10000:10000 - 10000:10000
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts' run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts'
- run: make deps-backend - run: make deps-backend
- run: make backend - run: make backend
env: env:
TAGS: bindata TAGS: bindata
- run: make test-mssql-migration - run: GITEA_TEST_DATABASE=mssql make test-migration
- name: run tests - name: run tests
run: make test-mssql run: GITEA_TEST_DATABASE=mssql make test-integration
timeout-minutes: 50 timeout-minutes: 50
env: env:
TAGS: bindata TAGS: bindata
+29 -22
View File
@@ -7,34 +7,41 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
files-changed: files-changed:
uses: ./.github/workflows/files-changed.yml uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
container: # QEMU-based build is slow (40-50 minutes), so run arm64 and riscv64 when dockerfile changes.
# Run amd64 when any docker-related files change, which is fast (4 minutes).
container-amd64:
if: needs.files-changed.outputs.docker == 'true' if: needs.files-changed.outputs.docker == 'true'
needs: files-changed needs: [files-changed]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: docker/setup-qemu-action@v4 - uses: ./.github/actions/docker-dryrun
- uses: docker/setup-buildx-action@v4
- name: Build regular container image
uses: docker/build-push-action@v7
with: with:
context: . platform: linux/amd64
platforms: linux/amd64,linux/arm64,linux/riscv64
push: false container-arm64:
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful if: needs.files-changed.outputs.dockerfile == 'true'
- name: Build rootless container image needs: [files-changed]
uses: docker/build-push-action@v7 runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with: with:
context: . platform: linux/arm64
push: false
platforms: linux/amd64,linux/arm64,linux/riscv64 container-riscv64:
file: Dockerfile.rootless if: needs.files-changed.outputs.dockerfile == 'true'
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with:
platform: linux/riscv64

Some files were not shown because too many files have changed in this diff Show More