Skip to content

Commit 7395b77

Browse files
committed
put it live
1 parent aa99ac4 commit 7395b77

3 files changed

Lines changed: 40 additions & 9 deletions

File tree

content/blog/2026/03-30-gitea-ci-autoscaler/index.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ title = "Autoscaling CI for Gitea in Rust"
33
date = 2026-03-30
44
[extra]
55
tags = ["rust","infra"]
6-
hidden = true
76
custom_summary = "Scaling Hetzner CI nodes for Gitea Actions on demand with Rust and Kubernetes"
87
+++
98

@@ -83,19 +82,28 @@ pub trait HetznerClient: Send + Sync {
8382

8483
`GiteaClient` and `KubeClient` follow the same pattern. In production we use HTTP-backed implementations. In tests we swap in mocks that track calls and can simulate failures.
8584

86-
This lets us validate the full teardown lifecycle without spinning up a single server:
85+
This lets us validate the full teardown lifecycle without spinning up a single server. The following pseudo-code illustrates the approach — seed an idle node and assert both the state transition and the external API call:
8786

8887
```rust
8988
#[tokio::test]
9089
async fn teardown_order() {
9190
let mock_gitea = MockGiteaClient::new();
9291
let mock_kube = MockKubeClient::new();
9392
let mock_hetzner = MockHetznerClient::new();
94-
// ... set up an idle node ...
93+
let mut mgr = Manager::with_node(ManagedNode {
94+
server_id: 42,
95+
state: NodeState::Idle {
96+
k8s_node_name: "ci-42".into(),
97+
gitea_runner_id: 7,
98+
gitea_runner_name: "ci-42".into(),
99+
idle_since: Utc::now() - chrono::Duration::minutes(10),
100+
},
101+
});
95102

96103
// Step 1: deregister the Gitea runner
97104
mgr.teardown_step(0, &mock_gitea, &mock_kube, &mock_hetzner, &metrics).await;
98105
assert!(matches!(mgr.nodes[0].state, NodeState::Deregistering { .. }));
106+
assert_eq!(mock_gitea.calls(), vec![GiteaCall::DeregisterRunner(7)]);
99107

100108
// Step 2: drain the K8s node
101109
mgr.teardown_step(0, &mock_gitea, &mock_kube, &mock_hetzner, &metrics).await;
@@ -107,7 +115,7 @@ async fn teardown_order() {
107115
}
108116
```
109117

110-
Each call to `teardown_step` advances the node exactly one state forward. The test verifies the exact ordering of deregister, drain and remove, and the mocks let us assert which API calls were made along the way.
118+
Each call to `teardown_step` advances the node exactly one state forward. More importantly, the test verifies both the ordering of deregister, drain and remove and the side effects at each step. That is the practical benefit of the trait-based split: we can assert which external API call happened without touching real cloud resources.
111119

112120
# Further steps
113121

docs/blog/2026/03-30-gitea-ci-autoscaler/index.html

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
<div id="blogpage">
2828
<div class="date">2026-03-30</div>
2929

30-
<div class="hidden">hidden</div>
31-
3230
<h1 class="title">
3331
Autoscaling CI for Gitea in Rust
3432
</h1>
@@ -88,17 +86,26 @@ <h1 id="testing-without-cloud-resources">Testing without cloud resources</h1>
8886
</span><span style="color:#89ddff;">}
8987
</span></code></pre>
9088
<p><code>GiteaClient</code> and <code>KubeClient</code> follow the same pattern. In production we use HTTP-backed implementations. In tests we swap in mocks that track calls and can simulate failures.</p>
91-
<p>This lets us validate the full teardown lifecycle without spinning up a single server:</p>
89+
<p>This lets us validate the full teardown lifecycle without spinning up a single server. The following pseudo-code illustrates the approach — seed an idle node and assert both the state transition and the external API call:</p>
9290
<pre data-lang="rust" style="background-color:#212121;color:#eeffff;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#89ddff;">#[</span><span>tokio::test</span><span style="color:#89ddff;">]
9391
</span><span>async </span><span style="font-style:italic;color:#c792ea;">fn </span><span style="color:#82aaff;">teardown_order</span><span style="color:#89ddff;">() {
9492
</span><span> </span><span style="font-style:italic;color:#c792ea;">let</span><span> mock_gitea </span><span style="color:#89ddff;">= </span><span>MockGiteaClient</span><span style="color:#89ddff;">::</span><span>new</span><span style="color:#89ddff;">();
9593
</span><span> </span><span style="font-style:italic;color:#c792ea;">let</span><span> mock_kube </span><span style="color:#89ddff;">= </span><span>MockKubeClient</span><span style="color:#89ddff;">::</span><span>new</span><span style="color:#89ddff;">();
9694
</span><span> </span><span style="font-style:italic;color:#c792ea;">let</span><span> mock_hetzner </span><span style="color:#89ddff;">= </span><span>MockHetznerClient</span><span style="color:#89ddff;">::</span><span>new</span><span style="color:#89ddff;">();
97-
</span><span> </span><span style="font-style:italic;color:#4a4a4a;">// ... set up an idle node ...
95+
</span><span> </span><span style="font-style:italic;color:#c792ea;">let </span><span style="color:#c792ea;">mut</span><span> mgr </span><span style="color:#89ddff;">= </span><span>Manager</span><span style="color:#89ddff;">::</span><span>with_node</span><span style="color:#89ddff;">(</span><span>ManagedNode </span><span style="color:#89ddff;">{
96+
</span><span> server_id</span><span style="color:#89ddff;">: </span><span style="color:#f78c6c;">42</span><span style="color:#89ddff;">,
97+
</span><span> state</span><span style="color:#89ddff;">: </span><span>NodeState</span><span style="color:#89ddff;">::</span><span>Idle </span><span style="color:#89ddff;">{
98+
</span><span> k8s_node_name</span><span style="color:#89ddff;">: &quot;</span><span style="color:#c3e88d;">ci-42</span><span style="color:#89ddff;">&quot;.</span><span style="color:#82aaff;">into</span><span style="color:#89ddff;">(),
99+
</span><span> gitea_runner_id</span><span style="color:#89ddff;">: </span><span style="color:#f78c6c;">7</span><span style="color:#89ddff;">,
100+
</span><span> gitea_runner_name</span><span style="color:#89ddff;">: &quot;</span><span style="color:#c3e88d;">ci-42</span><span style="color:#89ddff;">&quot;.</span><span style="color:#82aaff;">into</span><span style="color:#89ddff;">(),
101+
</span><span> idle_since</span><span style="color:#89ddff;">: </span><span>Utc</span><span style="color:#89ddff;">::</span><span>now</span><span style="color:#89ddff;">() - </span><span>chrono</span><span style="color:#89ddff;">::</span><span>Duration</span><span style="color:#89ddff;">::</span><span>minutes</span><span style="color:#89ddff;">(</span><span style="color:#f78c6c;">10</span><span style="color:#89ddff;">),
102+
</span><span> </span><span style="color:#89ddff;">},
103+
</span><span> </span><span style="color:#89ddff;">});
98104
</span><span>
99105
</span><span> </span><span style="font-style:italic;color:#4a4a4a;">// Step 1: deregister the Gitea runner
100106
</span><span> mgr</span><span style="color:#89ddff;">.</span><span style="color:#82aaff;">teardown_step</span><span style="color:#89ddff;">(</span><span style="color:#f78c6c;">0</span><span style="color:#89ddff;">, &amp;</span><span>mock_gitea</span><span style="color:#89ddff;">, &amp;</span><span>mock_kube</span><span style="color:#89ddff;">, &amp;</span><span>mock_hetzner</span><span style="color:#89ddff;">, &amp;</span><span>metrics</span><span style="color:#89ddff;">).</span><span>await</span><span style="color:#89ddff;">;
101107
</span><span> assert!</span><span style="color:#89ddff;">(</span><span>matches!</span><span style="color:#89ddff;">(</span><span>mgr</span><span style="color:#89ddff;">.</span><span>nodes</span><span style="color:#89ddff;">[</span><span style="color:#f78c6c;">0</span><span style="color:#89ddff;">].</span><span>state</span><span style="color:#89ddff;">, </span><span>NodeState</span><span style="color:#89ddff;">::</span><span>Deregistering </span><span style="color:#89ddff;">{ .. }));
108+
</span><span> assert_eq!</span><span style="color:#89ddff;">(</span><span>mock_gitea</span><span style="color:#89ddff;">.</span><span style="color:#82aaff;">calls</span><span style="color:#89ddff;">(), </span><span>vec!</span><span style="color:#89ddff;">[</span><span>GiteaCall</span><span style="color:#89ddff;">::</span><span>DeregisterRunner</span><span style="color:#89ddff;">(</span><span style="color:#f78c6c;">7</span><span style="color:#89ddff;">)]);
102109
</span><span>
103110
</span><span> </span><span style="font-style:italic;color:#4a4a4a;">// Step 2: drain the K8s node
104111
</span><span> mgr</span><span style="color:#89ddff;">.</span><span style="color:#82aaff;">teardown_step</span><span style="color:#89ddff;">(</span><span style="color:#f78c6c;">0</span><span style="color:#89ddff;">, &amp;</span><span>mock_gitea</span><span style="color:#89ddff;">, &amp;</span><span>mock_kube</span><span style="color:#89ddff;">, &amp;</span><span>mock_hetzner</span><span style="color:#89ddff;">, &amp;</span><span>metrics</span><span style="color:#89ddff;">).</span><span>await</span><span style="color:#89ddff;">;
@@ -109,7 +116,7 @@ <h1 id="testing-without-cloud-resources">Testing without cloud resources</h1>
109116
</span><span> assert!</span><span style="color:#89ddff;">(</span><span>mgr</span><span style="color:#89ddff;">.</span><span>nodes</span><span style="color:#89ddff;">.</span><span style="color:#82aaff;">is_empty</span><span style="color:#89ddff;">());
110117
</span><span style="color:#89ddff;">}
111118
</span></code></pre>
112-
<p>Each call to <code>teardown_step</code> advances the node exactly one state forward. The test verifies the exact ordering of deregister, drain and remove, and the mocks let us assert which API calls were made along the way.</p>
119+
<p>Each call to <code>teardown_step</code> advances the node exactly one state forward. More importantly, the test verifies both the ordering of deregister, drain and remove and the side effects at each step. That is the practical benefit of the trait-based split: we can assert which external API call happened without touching real cloud resources.</p>
113120
<h1 id="further-steps">Further steps</h1>
114121
<p>The current version is built around our stack: Hetzner and K3s. But the trait-based architecture also lends itself to a few obvious extensions:</p>
115122
<ul>

docs/blog/index.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ <h1>Blog</h1>
2929
<div class="posts">
3030

3131

32+
<div class="post">
33+
<div class="date">2026-03-30</div>
34+
<a href="https://rustunit.com/blog/2026/03-30-gitea-ci-autoscaler/">
35+
<div class="title">Autoscaling CI for Gitea in Rust</div>
36+
</a>
37+
<div class="tags">
38+
39+
<div class="tag">rust</div>
40+
41+
<div class="tag">infra</div>
42+
43+
</div>
44+
<div class="summary">Scaling Hetzner CI nodes for Gitea Actions on demand with Rust and Kubernetes</div>
45+
<hr />
46+
</div>
47+
3248

3349

3450
<div class="post">

0 commit comments

Comments
 (0)