flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/actions.rs

606 lines21,245 bytesCodeBlame
1//! The actions service: GitHub Actions workflows, run on g1t as they are.
2//!
3//! A repository's `.g1t/workflows/*.yml`, in GitHub's format, are read
4//! from the commit an
5//! event is about (the default branch for issues, schedules and manual
6//! runs). Each workflow an event starts becomes a run; each job of the run
7//! (one per matrix combination) runs in a sandbox once the jobs it needs
8//! have finished. Jobs report their steps and logs back as they go, and a
9//! run on a pull request's head is a status on that pull request.
10//!
11//! Secrets and variables belong to a repository or to its workspace; a
12//! repository's override its workspace's of the same name. Secret values
13//! are sealed at rest and never returned.
14//!
15//! Mirrors `packages/contracts/src/actions.ts`.
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::repos::RepoPath;
21use crate::{User, Viewer};
22
23/// A note on something in a workflow that runs differently on g1t.
24#[derive(Clone, Debug, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct WorkflowNote {
27 /// `info`, `warning` or `unsupported`.
28 pub severity: String,
29 pub job: Option<String>,
30 pub message: String,
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct Workflow {
36 pub id: String,
37 /// `.g1t/workflows/ci.yml`.
38 pub path: String,
39 pub name: String,
40 /// The events that start it, such as `push` and `pull_request`.
41 pub events: Vec<String>,
42 /// `active`, or `disabled` when a member turned it off.
43 pub state: String,
44 /// Why the file cannot be used, if it cannot.
45 pub error: Option<String>,
46 pub notes: Vec<WorkflowNote>,
47 /// `on.workflow_dispatch.inputs` as written, when it can be run by hand.
48 pub dispatch: Option<Value>,
49 pub last_run: Option<WorkflowRun>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct WorkflowRun {
55 pub id: String,
56 pub workflow_id: String,
57 pub path: String,
58 /// The workflow's name.
59 pub name: String,
60 /// `run-name`, or what started it: a commit's subject, a pull request's title.
61 pub title: String,
62 /// Counts the workflow's runs: 1, 2, 3…
63 pub number: u64,
64 pub attempt: u64,
65 /// The GitHub event: `push`, `pull_request`, `schedule`…
66 pub event: String,
67 #[serde(rename = "ref")]
68 pub git_ref: String,
69 pub sha: String,
70 /// The pull request it ran for, if any.
71 pub pull: Option<u32>,
72 /// `queued`, `in_progress` or `completed`.
73 pub status: String,
74 /// When completed: `success`, `failure`, `cancelled` or `skipped`.
75 pub conclusion: Option<String>,
76 /// Why it could not start, such as a workflow file that does not read.
77 pub error: Option<String>,
78 /// Username of whoever caused it.
79 pub actor: Option<String>,
80 pub created_at: String,
81 pub started_at: Option<String>,
82 pub finished_at: Option<String>,
83}
84
85#[derive(Clone, Debug, Default, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct StepState {
88 /// From 1.
89 pub number: u32,
90 pub name: String,
91 /// `queued`, `in_progress` or `completed`.
92 pub status: String,
93 /// `success`, `failure`, `cancelled` or `skipped`.
94 pub conclusion: Option<String>,
95 pub started_at: Option<String>,
96 pub finished_at: Option<String>,
97}
98
99/// A message a step left with `::error::`, `::warning::` or `::notice::`.
100#[derive(Clone, Debug, Default, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Annotation {
103 /// `error`, `warning` or `notice`.
104 pub level: String,
105 pub message: String,
106 pub title: Option<String>,
107 pub file: Option<String>,
108 pub line: Option<u32>,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct Job {
114 pub id: String,
115 pub run_id: String,
116 /// Its key under `jobs:`.
117 pub key: String,
118 /// With its matrix combination: `test (ubuntu-latest, 20)`.
119 pub name: String,
120 pub needs: Vec<String>,
121 /// `queued`, `waiting` (for the jobs it needs), `in_progress` or `completed`.
122 pub status: String,
123 pub conclusion: Option<String>,
124 pub steps: Vec<StepState>,
125 pub annotations: Vec<Annotation>,
126 /// Why it did not run, or what stopped it.
127 pub reason: Option<String>,
128 pub started_at: Option<String>,
129 pub finished_at: Option<String>,
130 /// Its `runs-on` names self-hosted runners (see `runners`).
131 #[serde(default)]
132 pub self_hosted: bool,
133 /// The self-hosted runner that took it, by name.
134 #[serde(default)]
135 pub runner: Option<String>,
136}
137
138#[derive(Clone, Debug, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct RunDetail {
141 pub run: WorkflowRun,
142 pub jobs: Vec<Job>,
143 /// The workflow's notes, as of the run's commit.
144 pub notes: Vec<WorkflowNote>,
145}
146
147#[derive(Clone, Debug, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct LogChunk {
150 pub seq: u64,
151 /// The step it belongs to, from 1; 0 for the job's setup.
152 pub step: u32,
153 pub text: String,
154}
155
156#[derive(Clone, Debug, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct JobLog {
159 pub chunks: Vec<LogChunk>,
160 /// Whether the job has finished, so no more will come.
161 pub done: bool,
162}
163
164/// Who may read a secret or variable: workflows (`secrets.*` and `vars.*`
165/// in GitHub Actions) and deployments (a deploy build's environment and the
166/// running app's bindings). Agents, checks and the merge queue read none.
167pub const CONSUMERS: [&str; 2] = ["workflows", "deployments"];
168
169/// One row of a repository's or workspace's secrets and variables, as
170/// Vercel lists environment variables: a key, its type, the environments
171/// it applies to and who reads it. A key may have one row per environment.
172/// Secrets' values are never returned.
173#[derive(Clone, Debug, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct Setting {
176 #[serde(default)]
177 pub id: String,
178 pub name: String,
179 /// `secret`, or `variable` (shown as Config).
180 #[serde(default)]
181 pub kind: String,
182 /// A variable's value; secrets' are never returned.
183 pub value: Option<String>,
184 /// `project` (a repository's, which belong to its project) or
185 /// `workspace`.
186 pub scope: String,
187 pub updated_at: String,
188 /// `workflows` and/or `deployments`.
189 #[serde(default)]
190 pub available_to: Vec<String>,
191 /// The environments it applies to; empty is every environment.
192 #[serde(default)]
193 pub environments: Vec<String>,
194 /// A workspace's row: the projects it reaches, by slug; empty is every
195 /// project.
196 #[serde(default)]
197 pub projects: Vec<String>,
198 #[serde(default)]
199 pub note: Option<String>,
200 #[serde(default)]
201 pub updated_by: Option<String>,
202}
203
204// --- Methods ---------------------------------------------------------------
205
206/// `workflows`. Returns `Outcome<Vec<Workflow>>`.
207#[derive(Debug, Serialize, Deserialize)]
208pub struct WorkflowsArgs {
209 pub repo: RepoPath,
210 pub viewer: Viewer,
211}
212
213/// `runs`: newest first. Returns `Outcome<Vec<WorkflowRun>>`.
214#[derive(Debug, Serialize, Deserialize)]
215pub struct RunsArgs {
216 pub repo: RepoPath,
217 pub viewer: Viewer,
218 /// A workflow's id or file name.
219 #[serde(default)]
220 pub workflow: Option<String>,
221 #[serde(default)]
222 pub branch: Option<String>,
223 #[serde(default)]
224 pub event: Option<String>,
225 /// The pull request's number.
226 #[serde(default)]
227 pub pull: Option<u32>,
228 #[serde(default)]
229 pub sha: Option<String>,
230 #[serde(default)]
231 pub limit: Option<u32>,
232}
233
234/// `run`. Returns `Outcome<RunDetail>`.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct RunArgs {
237 pub repo: RepoPath,
238 pub viewer: Viewer,
239 pub id: String,
240}
241
242/// `logs`: a job's log after `after`. Returns `Outcome<JobLog>`.
243#[derive(Debug, Serialize, Deserialize)]
244pub struct LogsArgs {
245 pub repo: RepoPath,
246 pub viewer: Viewer,
247 pub job: String,
248 #[serde(default)]
249 pub after: u64,
250}
251
252/// `dispatch`: run a workflow that has `workflow_dispatch`. Members only.
253/// Returns `Outcome<WorkflowRun>`.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct DispatchArgs {
256 pub actor: User,
257 pub repo: RepoPath,
258 /// A workflow's id or file name.
259 pub workflow: String,
260 /// A branch or tag; the default branch when absent.
261 #[serde(default, rename = "ref")]
262 pub git_ref: Option<String>,
263 #[serde(default)]
264 pub inputs: serde_json::Map<String, Value>,
265}
266
267/// `cancel` and `rerun` (all jobs, or with `failed_only` the ones that did
268/// not succeed). Members only. Returns `Outcome<WorkflowRun>`.
269#[derive(Debug, Serialize, Deserialize)]
270pub struct RunActionArgs {
271 pub actor: User,
272 pub repo: RepoPath,
273 pub id: String,
274 #[serde(default)]
275 pub failed_only: bool,
276}
277
278/// `set_workflow_enabled`. Members only. Returns `Outcome<Workflow>`.
279#[derive(Debug, Serialize, Deserialize)]
280pub struct SetWorkflowEnabledArgs {
281 pub actor: User,
282 pub repo: RepoPath,
283 pub workflow: String,
284 pub enabled: bool,
285}
286
287/// Whose secrets or variables: a repository's, or with only `workspace`,
288/// a workspace's.
289#[derive(Clone, Debug, Serialize, Deserialize)]
290pub struct SettingsOwner {
291 #[serde(default)]
292 pub repo: Option<RepoPath>,
293 #[serde(default)]
294 pub workspace: Option<String>,
295}
296
297/// `settings`: the secrets (`kind: secret`) or variables (`kind: variable`)
298/// of a repository, with its workspace's, or of a workspace. Members only.
299/// Returns `Outcome<Vec<Setting>>`.
300#[derive(Debug, Serialize, Deserialize)]
301pub struct SettingsArgs {
302 pub actor: User,
303 #[serde(flatten)]
304 pub owner: SettingsOwner,
305 pub kind: String,
306}
307
308/// `set_setting`: add or replace one. A repository's need a member; a
309/// workspace's an owner. Returns `Outcome<Setting>`.
310#[derive(Debug, Serialize, Deserialize)]
311pub struct SetSettingArgs {
312 pub actor: User,
313 #[serde(flatten)]
314 pub owner: SettingsOwner,
315 /// `secret` or `variable`. Changing a variable's row to `secret` seals
316 /// it; a secret cannot become a variable.
317 pub kind: String,
318 pub name: String,
319 /// The row to change. Left out, the key's row for every environment, as
320 /// GitHub's API addresses a secret by name alone.
321 #[serde(default)]
322 pub id: Option<String>,
323 /// Needed for a new row; left out, an existing row keeps its value.
324 #[serde(default)]
325 pub value: Option<String>,
326 /// `workflows` and/or `deployments`; left out, unchanged (both, for a
327 /// new row).
328 // Named as callers send it: an `alias` is not honoured beside the
329 // flattened owner in the Worker's build.
330 #[serde(default, rename = "availableTo")]
331 pub available_to: Option<Vec<String>>,
332 /// The environments it applies to; empty is every one. Left out,
333 /// unchanged.
334 #[serde(default)]
335 pub environments: Option<Vec<String>>,
336 /// A workspace's row: project slugs; empty for every one.
337 #[serde(default)]
338 pub projects: Option<Vec<String>>,
339 #[serde(default)]
340 pub note: Option<String>,
341}
342
343/// `resolve_settings`: the secrets and variables one reader gets, for the
344/// services that hand them out (the deployments service). Returns
345/// `ResolvedSettings`.
346#[derive(Debug, Serialize, Deserialize)]
347#[serde(rename_all = "camelCase")]
348pub struct ResolveSettingsArgs {
349 pub repo_id: String,
350 pub repo: RepoPath,
351 /// The project being read for; its repository's primary project if left
352 /// out.
353 #[serde(default)]
354 pub project_id: Option<String>,
355 #[serde(default)]
356 pub project_slug: Option<String>,
357 /// `workflows` or `deployments`.
358 pub consumer: String,
359 /// The environment being read for, such as `production` or `preview`.
360 #[serde(default)]
361 pub environment: Option<String>,
362 /// Whether the run is trusted; an untrusted one gets no secrets.
363 pub trusted: bool,
364}
365
366#[derive(Debug, Default, Serialize, Deserialize)]
367pub struct ResolvedSettings {
368 pub secrets: serde_json::Map<String, serde_json::Value>,
369 pub variables: serde_json::Map<String, serde_json::Value>,
370}
371
372/// `delete_setting`. Returns `Outcome<bool>`.
373#[derive(Debug, Serialize, Deserialize)]
374pub struct DeleteSettingArgs {
375 pub actor: User,
376 #[serde(flatten)]
377 pub owner: SettingsOwner,
378 pub kind: String,
379 pub name: String,
380 /// One row; left out, every row of the key.
381 #[serde(default)]
382 pub id: Option<String>,
383}
384
385/// `job_spec` and `job_report`: the sandbox running a job, with the job's
386/// own token. `report` is one of:
387/// `{"kind": "step", "number", "status", "conclusion"}`,
388/// `{"kind": "log", "step", "text"}`,
389/// `{"kind": "annotation", "level", "message", "title", "file", "line"}`,
390/// `{"kind": "done", "conclusion", "outputs", "reason"}`.
391#[derive(Debug, Serialize, Deserialize)]
392pub struct JobCallArgs {
393 pub job: String,
394 pub token: String,
395 #[serde(default)]
396 pub report: Value,
397}
398
399/// What the runner needs to start a job's sandbox.
400#[derive(Debug, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct StartJobArgs {
403 pub job: String,
404 pub token: String,
405 pub repo: RepoPath,
406 /// Minutes before the job is stopped.
407 pub timeout_minutes: u32,
408 /// The workflow file the job is in (`.g1t/workflows/deploy.yml`), for
409 /// the guardrails' workflow-only domains.
410 #[serde(default)]
411 pub workflow: Option<String>,
412 /// The environment the job names with `environment:`, when it names
413 /// one plainly (not with an expression).
414 #[serde(default)]
415 pub environment: Option<String>,
416 /// Whether its run is trusted: not a pull request from a fork. Only a
417 /// trusted run's jobs reach workflow-only domains.
418 #[serde(default)]
419 pub trusted: bool,
420 /// The machine its `runs-on` asked for, by label (`instance_for`):
421 /// `g1t-2core` or `g1t-4core`; absent, the standard one.
422 #[serde(default)]
423 pub instance: Option<String>,
424}
425
426/// A size of machine g1t runs workflow jobs on, asked for by a label in
427/// `runs-on`. Each is a Cloudflare Containers instance type; it costs what
428/// that instance costs g1t, plus the margin, like any sandbox time.
429#[derive(Clone, Copy, Debug, PartialEq)]
430pub struct InstanceType {
431 /// The `runs-on` label, or `standard` for the default.
432 pub label: &'static str,
433 /// The Containers instance type.
434 pub container: &'static str,
435 pub vcpu: f64,
436 pub memory_gib: f64,
437 pub disk_gb: f64,
438 /// What a second of it costs g1t as a multiple of the standard
439 /// machine's, with its vCPUs as busy (Cloudflare's list prices:
440 /// memory $0.0000025 a GiB-second, disk $0.00000007 a GB-second, vCPU
441 /// $0.00002 a second). Used to reserve before a job starts, and to
442 /// price a job that did not report its own CPU.
443 pub price_scale: f64,
444}
445
446/// The default: what `ubuntu-latest` and every other hosted label get.
447pub const STANDARD_INSTANCE: InstanceType =
448 InstanceType { label: "standard", container: "standard-1", vcpu: 0.5, memory_gib: 4.0, disk_gb: 8.0, price_scale: 1.0 };
449
450/// Every machine a workflow job can ask for, the default first.
451pub const INSTANCE_TYPES: [InstanceType; 3] = [
452 STANDARD_INSTANCE,
453 InstanceType { label: "g1t-2core", container: "standard-3", vcpu: 2.0, memory_gib: 8.0, disk_gb: 16.0, price_scale: 2.8 },
454 InstanceType { label: "g1t-4core", container: "standard-4", vcpu: 4.0, memory_gib: 12.0, disk_gb: 20.0, price_scale: 5.1 },
455];
456
457/// The machine a job's `runs-on` labels ask for: the largest named, or the
458/// standard one. Labels compare without regard to case.
459pub fn instance_for(labels: &[String]) -> InstanceType {
460 INSTANCE_TYPES
461 .iter()
462 .rev()
463 .find(|instance| instance.label != STANDARD_INSTANCE.label && labels.iter().any(|label| label.trim().eq_ignore_ascii_case(instance.label)))
464 .copied()
465 .unwrap_or(STANDARD_INSTANCE)
466}
467
468/// An instance type by its label, if it is one.
469pub fn instance_named(label: &str) -> Option<InstanceType> {
470 INSTANCE_TYPES.iter().find(|instance| instance.label.eq_ignore_ascii_case(label.trim())).copied()
471}
472
473// ── The cache (actions/cache) ─────────────────────────────────────────────
474//
475// Entries are kept in R2 by the API (the ACTIONS_CACHE bucket) and listed
476// here, by the actions service, which decides what is found, what fits and
477// what is evicted. A sandbox reaches these through the API with its job's
478// token: `/actions/jobs/{job}/cache` (see apps/api/src/blobs.rs).
479
480/// The largest one cache entry may be, compressed.
481pub const CACHE_MAX_ENTRY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
482/// What one repository's entries may hold together. Saving past it evicts
483/// the entries restored longest ago.
484pub const CACHE_REPO_QUOTA_BYTES: u64 = 10 * 1024 * 1024 * 1024;
485/// An entry not restored for this long is deleted.
486pub const CACHE_UNUSED_DAYS: u64 = 7;
487/// An entry is deleted this long after it was saved, however often it is
488/// restored (the bucket's own lifecycle rule deletes objects at 30 days).
489pub const CACHE_MAX_AGE_DAYS: u64 = 28;
490/// An upload is sent in parts of this size (the last may be smaller).
491pub const CACHE_PART_BYTES: u64 = 32 * 1024 * 1024;
492/// What R2 charges g1t to store a GB for a month, in millionths of a
493/// dollar ($0.015): what the cache's storage is charged at, plus the margin.
494pub const CACHE_MICROS_PER_GB_MONTH: i64 = 15_000;
495
496/// `cache_lookup`: the entry a job restores: its key exactly, else the
497/// newest whose key starts with one of `restore`, in order.
498/// Returns `Outcome<Option<CacheHit>>`.
499#[derive(Debug, Serialize, Deserialize)]
500pub struct CacheLookupArgs {
501 pub job: String,
502 pub token: String,
503 pub key: String,
504 #[serde(default)]
505 pub restore: Vec<String>,
506}
507
508#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
509pub struct CacheHit {
510 pub key: String,
511 pub object: String,
512 pub size: u64,
513}
514
515/// `cache_reserve`: a job about to save `size` bytes under `key`. Refused
516/// when the key is taken (`conflict`: keys are written once) or the entry
517/// is too large. Returns `Outcome<CacheReservation>`.
518#[derive(Debug, Serialize, Deserialize)]
519pub struct CacheReserveArgs {
520 pub job: String,
521 pub token: String,
522 pub key: String,
523 pub size: u64,
524}
525
526#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
527pub struct CacheReservation {
528 pub id: String,
529 /// Where the API puts it in R2.
530 pub object: String,
531}
532
533/// `cache_commit`: the upload of `id` is complete, at `size` bytes. Returns
534/// `Outcome<CacheCommitted>`: the objects of entries it evicted, which the
535/// API deletes from R2.
536#[derive(Debug, Serialize, Deserialize)]
537pub struct CacheCommitArgs {
538 pub job: String,
539 pub token: String,
540 pub id: String,
541 pub size: u64,
542}
543
544#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
545pub struct CacheCommitted {
546 pub evicted: Vec<String>,
547}
548
549/// `cache_abort`: an upload that will not finish; its reservation goes.
550/// Returns `Outcome<bool>`.
551#[derive(Debug, Serialize, Deserialize)]
552pub struct CacheAbortArgs {
553 pub job: String,
554 pub token: String,
555 pub id: String,
556}
557
558#[cfg(test)]
559mod instance_tests {
560 use super::*;
561
562 fn labels(given: &[&str]) -> Vec<String> {
563 given.iter().map(|l| (*l).to_owned()).collect()
564 }
565
566 #[test]
567 fn runs_on_picks_the_machine() {
568 assert_eq!(instance_for(&labels(&["ubuntu-latest"])).container, "standard-1");
569 assert_eq!(instance_for(&labels(&[])).label, "standard");
570 assert_eq!(instance_for(&labels(&["g1t-4core"])).container, "standard-4");
571 assert_eq!(instance_for(&labels(&["ubuntu-latest", "G1T-2Core"])).container, "standard-3");
572 // Both named: the larger.
573 assert_eq!(instance_for(&labels(&["g1t-2core", "g1t-4core"])).label, "g1t-4core");
574 assert_eq!(instance_named("g1t-4core").map(|i| i.vcpu), Some(4.0));
575 assert_eq!(instance_named("standard"), Some(STANDARD_INSTANCE));
576 assert_eq!(instance_named("g1t-64core"), None);
577 }
578
579 #[test]
580 fn start_args_from_older_callers_read() {
581 let args: StartJobArgs = serde_json::from_value(serde_json::json!({
582 "job": "job_1", "token": "t", "repo": { "namespace": "acme", "name": "web" }, "timeoutMinutes": 30
583 }))
584 .unwrap();
585 assert!(args.workflow.is_none() && args.environment.is_none() && !args.trusted && args.instance.is_none());
586 }
587}
588
589#[cfg(test)]
590mod setting_args_tests {
591 use super::*;
592
593 #[test]
594 fn who_reads_a_row_is_read_as_the_site_and_api_send_it() {
595 let args: SetSettingArgs = serde_json::from_value(serde_json::json!({
596 "actor": { "id": "usr_1", "username": "a" },
597 "repo": { "namespace": "acme", "name": "web" },
598 "kind": "secret",
599 "name": "STRIPE_KEY",
600 "availableTo": ["deployments"],
601 "environments": ["production"],
602 }))
603 .unwrap();
604 assert_eq!(args.available_to, Some(vec!["deployments".to_owned()]));
605 }
606}