Compare commits
44
Commits
0a2b96abc2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0ae6be5e | ||
|
|
54eb69d1c7 | ||
|
|
0cbf267474 | ||
|
|
335a74010d | ||
|
|
4c9611a2d2 | ||
|
|
5681519b6f | ||
|
|
fd59158ed6 | ||
|
|
d9b2a4e787 | ||
|
|
c439c1b948 | ||
|
|
0fe85c67d9 | ||
|
|
09eecf5969 | ||
|
|
e444ea5936 | ||
|
|
c917e4908c | ||
|
|
ef849b085f | ||
|
|
a09f247290 | ||
|
|
3a52023082 | ||
|
|
2bd466b652 | ||
|
|
83cf9ff32f | ||
|
|
a9e538fc76 | ||
|
|
88ec8a59d8 | ||
|
|
417820544e | ||
|
|
1cba36e76e | ||
|
|
4f97fb3244 | ||
|
|
252d3418a5 | ||
|
|
96882fd8cd | ||
|
|
522010b7e6 | ||
|
|
761120b211 | ||
|
|
a04a58b792 | ||
|
|
1e973e7b33 | ||
|
|
66a502b1dd | ||
|
|
fce9ae07be | ||
|
|
a8c4a52a68 | ||
|
|
078e01c87f | ||
|
|
6bc564de59 | ||
|
|
cc8a7cf80e | ||
|
|
279df769db | ||
|
|
069b18daa3 | ||
|
|
37bd69e90e | ||
|
|
572133a941 | ||
|
|
ed721dd961 | ||
|
|
4a88e7c6e8 | ||
|
|
f0317efb6e | ||
|
|
cb62bd3044 | ||
|
|
ee8a33c3b0 |
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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; };
|
||||||
|
|||||||
@@ -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
|
||||||
'';
|
'';
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+231
@@ -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.
|
||||||
@@ -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.
|
||||||
Executable
+143
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" ];
|
||||||
|
|||||||
@@ -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
@@ -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; };
|
||||||
|
|||||||
Generated
+1453
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
},
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1,10 +0,0 @@
|
|||||||
version: 2
|
|
||||||
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
labels: [modifies/dependencies]
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: daily
|
|
||||||
cooldown:
|
|
||||||
default-days: 5
|
|
||||||
-77
@@ -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
@@ -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 -->
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user