OXYGENHUB
◆ v0.2.0 oxygenhub / crates / hub / src / db / runners.rs
10.7 KiB · Rust · last changed Settings → Runners for repository admins and organisation owners (refs #41, part 2b) 1 month ago source blame history raw
b6b7e4cf Andy McCall ↶ 1
//! OxygenFlow runners (hub spec §13.2): who may claim jobs, what they are,
2
//! whether they are taking work, and what they have run.
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, FromRow)]
8
pub struct Runner {
9
    pub id: i64,
10
    pub name: String,
11
    pub token_hash: String,
12
    pub source: String,
13
    pub key: String,
14
    pub key_name: String,
15
    pub arch: String,
16
    pub os: String,
17
    pub version: String,
18
    pub labels: String,
19
    pub max_jobs: i64,
20
    pub memory_mib: Option<i64>,
21
    pub cpus: Option<i64>,
22
    pub state: String,
23
    pub created_ms: i64,
24
    pub created_by: String,
25
    pub last_seen_ms: Option<i64>,
74be9796 Andy McCall ↶ 26
    /// The owner whose repositories it serves; empty for the whole site.
27
    pub scope_owner: String,
28
    /// The one repository it serves, when it serves one.
29
    pub scope_repo_id: Option<i64>,
b6b7e4cf Andy McCall ↶ 30
}
31
74be9796 Andy McCall ↶ 32
/// Which repositories' jobs a runner may take (oxygenhub#41).
33
#[derive(Clone, Debug, Default, PartialEq, Eq)]
34
pub struct Scope {
35
    /// Empty: any owner.
36
    pub owner: String,
37
    /// `None`: any of the owner's repositories.
38
    pub repo_id: Option<i64>,
39
}
40
41
impl Scope {
42
    pub fn is_site(&self) -> bool {
43
        self.owner.is_empty() && self.repo_id.is_none()
44
    }
45
}
46
b6b7e4cf Andy McCall ↶ 47
/// A runner that has not polled for this long is shown offline.
48
pub const ONLINE_MS: i64 = 90_000;
49
50
impl Runner {
51
    pub fn online(&self) -> bool {
52
        self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS)
53
    }
54
55
    /// `online`, `offline`, `draining` or `disabled`.
56
    pub fn status(&self) -> &'static str {
57
        match self.state.as_str() {
58
            "disabled" => "disabled",
59
            "draining" => "draining",
60
            _ if self.online() => "online",
61
            _ => "offline",
62
        }
63
    }
64
65
    pub fn label_list(&self) -> Vec<&str> {
66
        self.labels.split(',').filter(|l| !l.is_empty()).collect()
67
    }
74be9796 Andy McCall ↶ 68
69
    pub fn scope(&self) -> Scope {
70
        Scope { owner: self.scope_owner.clone(), repo_id: self.scope_repo_id }
71
    }
b6b7e4cf Andy McCall ↶ 72
}
73
74
pub async fn by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<Runner>> {
75
    sqlx::query_as("SELECT * FROM flow_runners WHERE token_hash = ?").bind(token_hash).fetch_optional(pool).await
76
}
77
78
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Runner>> {
79
    sqlx::query_as("SELECT * FROM flow_runners WHERE id = ?").bind(id).fetch_optional(pool).await
80
}
81
82
pub async fn list(pool: &Pool) -> sqlx::Result<Vec<Runner>> {
83
    sqlx::query_as("SELECT * FROM flow_runners ORDER BY name, id").fetch_all(pool).await
84
}
85
86
/// The row of a runner configured with a hub.toml token, made on first sight.
87
pub async fn configured(pool: &Pool, token_hash: &str, name: &str) -> sqlx::Result<Runner> {
88
    sqlx::query("INSERT OR IGNORE INTO flow_runners (name, token_hash, source, created_ms) VALUES (?, ?, 'config', ?)")
89
        .bind(name)
90
        .bind(token_hash)
91
        .bind(now_ms())
92
        .execute(pool)
93
        .await?;
94
    Ok(by_token(pool, token_hash).await?.expect("just made"))
95
}
96
97
/// What a claim says about the runner, and that it was seen now. A
98
/// configured runner takes the name it claims with.
99
pub async fn seen(pool: &Pool, id: i64, name: Option<&str>, info: Option<&flow_core::api::RunnerInfo>) -> sqlx::Result<()> {
100
    let now = now_ms();
101
    match info {
102
        Some(i) => {
103
            sqlx::query(
104
                "UPDATE flow_runners SET arch = ?, os = ?, version = ?, labels = ?, max_jobs = ?, memory_mib = ?, cpus = ?, last_seen_ms = ? WHERE id = ?",
105
            )
106
            .bind(&i.arch)
107
            .bind(&i.os)
108
            .bind(&i.version)
109
            .bind(i.labels.join(","))
110
            .bind(i.max_jobs as i64)
111
            .bind(i.memory_mib.map(i64::from))
112
            .bind(i.cpus.map(i64::from))
113
            .bind(now)
114
            .bind(id)
115
            .execute(pool)
116
            .await?;
117
        }
118
        None => {
119
            sqlx::query("UPDATE flow_runners SET last_seen_ms = ? WHERE id = ?").bind(now).bind(id).execute(pool).await?;
120
        }
121
    }
122
    if let Some(n) = name {
123
        sqlx::query("UPDATE flow_runners SET name = ? WHERE id = ? AND source = 'config'").bind(n).bind(id).execute(pool).await?;
124
    }
125
    Ok(())
126
}
127
128
pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
129
    sqlx::query("UPDATE flow_runners SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
130
    Ok(())
131
}
132
133
/// Forget a runner: its credential stops working. Its jobs keep its name.
134
pub async fn remove(pool: &Pool, id: i64) -> sqlx::Result<()> {
135
    sqlx::query("DELETE FROM flow_runners WHERE id = ?").bind(id).execute(pool).await?;
136
    Ok(())
137
}
138
139
/// The key a runner signs receipts with: the one bound, or `key` (named
140
/// `name`) when none is yet.
141
pub async fn bind_key(pool: &Pool, id: i64, key: &str, name: &str) -> sqlx::Result<String> {
142
    sqlx::query("UPDATE flow_runners SET key = ?, key_name = ? WHERE id = ? AND key = ''")
143
        .bind(key)
144
        .bind(name)
145
        .bind(id)
146
        .execute(pool)
147
        .await?;
148
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE id = ?").bind(id).fetch_one(pool).await
149
}
150
151
/// Keys that runners of this hub sign with.
152
pub async fn keys(pool: &Pool) -> sqlx::Result<Vec<String>> {
153
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE key != ''").fetch_all(pool).await
154
}
155
74be9796 Andy McCall ↶ 156
/// A one-time registration token, valid for a day, for a runner of `scope`.
157
pub async fn add_registration(pool: &Pool, token_hash: &str, by: &str, scope: &Scope) -> sqlx::Result<()> {
b6b7e4cf Andy McCall ↶ 158
    let now = now_ms();
74be9796 Andy McCall ↶ 159
    sqlx::query(
160
        "INSERT INTO flow_runner_registrations (token_hash, created_by, created_ms, expires_ms, scope_owner, scope_repo_id) VALUES (?, ?, ?, ?, ?, ?)",
161
    )
162
    .bind(token_hash)
163
    .bind(by)
164
    .bind(now)
165
    .bind(now + 24 * 3_600_000)
166
    .bind(&scope.owner)
167
    .bind(scope.repo_id)
168
    .execute(pool)
169
    .await?;
170
    Ok(())
171
}
172
7974adb0 Andy McCall ↶ 173
/// The runners that may take a repository's jobs: the site's, its owner's
174
/// and its own (oxygenhub#41).
175
pub async fn serving(pool: &Pool, owner: &str, repo_id: i64) -> sqlx::Result<Vec<Runner>> {
176
    sqlx::query_as(
177
        "SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) \
178
         OR (scope_owner = ? AND scope_repo_id IS NULL) OR scope_repo_id = ? ORDER BY name",
179
    )
180
    .bind(owner)
181
    .bind(repo_id)
182
    .fetch_all(pool)
183
    .await
184
}
185
186
/// The runners that serve an owner: the site's, and those scoped to the
187
/// owner or one of its repositories.
188
pub async fn serving_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Runner>> {
189
    sqlx::query_as("SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) OR scope_owner = ? ORDER BY name")
190
        .bind(owner)
191
        .fetch_all(pool)
192
        .await
193
}
194
74be9796 Andy McCall ↶ 195
/// Change the repositories a runner serves.
196
pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> {
197
    sqlx::query("UPDATE flow_runners SET scope_owner = ?, scope_repo_id = ? WHERE id = ?")
198
        .bind(&scope.owner)
199
        .bind(scope.repo_id)
200
        .bind(id)
b6b7e4cf Andy McCall ↶ 201
        .execute(pool)
202
        .await?;
203
    Ok(())
204
}
205
206
/// Registration tokens not used yet and not expired: (created by, created, expires).
207
pub async fn pending_registrations(pool: &Pool) -> sqlx::Result<Vec<(String, i64, i64)>> {
208
    sqlx::query_as("SELECT created_by, created_ms, expires_ms FROM flow_runner_registrations WHERE used_ms IS NULL AND expires_ms > ? ORDER BY created_ms DESC")
209
        .bind(now_ms())
210
        .fetch_all(pool)
211
        .await
212
}
213
214
/// Use registration token `token_hash` to register a runner: `None` when
215
/// the token is unknown, used or expired.
216
#[allow(clippy::too_many_arguments)]
217
pub async fn register(
218
    pool: &Pool,
219
    registration: &str,
220
    name: &str,
221
    token_hash: &str,
222
    key: &str,
223
    key_name: &str,
224
    by_default: &str,
225
) -> sqlx::Result<Option<Runner>> {
226
    let mut tx = super::begin_write(pool).await?;
227
    let now = now_ms();
74be9796 Andy McCall ↶ 228
    let found: Option<(String, String, Option<i64>)> = sqlx::query_as(
229
        "SELECT created_by, scope_owner, scope_repo_id FROM flow_runner_registrations WHERE token_hash = ? AND used_ms IS NULL AND expires_ms > ?",
230
    )
231
    .bind(registration)
232
    .bind(now)
233
    .fetch_optional(&mut *tx)
234
    .await?;
235
    let Some((who, scope_owner, scope_repo_id)) = found else { return Ok(None) };
b6b7e4cf Andy McCall ↶ 236
    let id: i64 = sqlx::query_scalar(
74be9796 Andy McCall ↶ 237
        "INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms, created_by, scope_owner, scope_repo_id) VALUES (?, ?, 'registered', ?, ?, ?, ?, ?, ?) RETURNING id",
b6b7e4cf Andy McCall ↶ 238
    )
239
    .bind(name)
240
    .bind(token_hash)
241
    .bind(key)
242
    .bind(key_name)
243
    .bind(now)
244
    .bind(if who.is_empty() { by_default } else { &who })
74be9796 Andy McCall ↶ 245
    .bind(scope_owner)
246
    .bind(scope_repo_id)
b6b7e4cf Andy McCall ↶ 247
    .fetch_one(&mut *tx)
248
    .await?;
249
    sqlx::query("UPDATE flow_runner_registrations SET used_ms = ?, runner_id = ? WHERE token_hash = ?")
250
        .bind(now)
251
        .bind(id)
252
        .bind(registration)
253
        .execute(&mut *tx)
254
        .await?;
255
    tx.commit().await?;
256
    get(pool, id).await
257
}
258
259
/// A job a runner ran, for its history.
260
#[derive(Clone, Debug, FromRow)]
261
pub struct RunnerJob {
262
    pub owner: String,
263
    pub repo: String,
264
    pub run_number: i64,
265
    pub job: String,
266
    pub status: String,
267
    pub started_ms: Option<i64>,
268
    pub finished_ms: Option<i64>,
269
    pub receipt_attest: String,
270
}
271
272
/// Jobs runner `id` ran or is running, newest first.
273
pub async fn history(pool: &Pool, id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<RunnerJob>> {
274
    sqlx::query_as(
275
        "SELECT p.owner AS owner, p.name AS repo, r.number AS run_number, j.name AS job, j.status AS status, j.started_ms AS started_ms, \
276
         j.finished_ms AS finished_ms, j.receipt_attest AS receipt_attest \
277
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
278
         WHERE j.runner_id = ? AND (? IS NULL OR j.started_ms < ?) ORDER BY j.started_ms DESC LIMIT ?",
279
    )
280
    .bind(id)
281
    .bind(before)
282
    .bind(before)
283
    .bind(limit)
284
    .fetch_all(pool)
285
    .await
286
}
287
288
/// (jobs run, passed, busy milliseconds) of runner `id`.
289
pub async fn totals(pool: &Pool, id: i64) -> sqlx::Result<(i64, i64, i64)> {
290
    sqlx::query_as(
291
        "SELECT COUNT(*), COALESCE(SUM(status = 'passed'), 0), COALESCE(SUM(finished_ms - started_ms), 0) \
292
         FROM flow_jobs WHERE runner_id = ? AND finished_ms IS NOT NULL",
293
    )
294
    .bind(id)
295
    .fetch_one(pool)
296
    .await
297
}
298
299
/// Jobs running on each runner now, by runner id.
300
pub async fn running(pool: &Pool) -> sqlx::Result<Vec<(i64, i64)>> {
301
    sqlx::query_as("SELECT runner_id, COUNT(*) FROM flow_jobs WHERE status = 'running' AND runner_id IS NOT NULL GROUP BY runner_id")
302
        .fetch_all(pool)
303
        .await
304
}
305
306
pub async fn set_job_runner(pool: &Pool, job_id: i64, runner_id: i64) -> sqlx::Result<()> {
307
    sqlx::query("UPDATE flow_jobs SET runner_id = ? WHERE id = ?").bind(runner_id).bind(job_id).execute(pool).await?;
308
    Ok(())
309
}