Skip to content
612 linesCodeBlameRaw
1//! `g1t-runner` on your own machine: a self-hosted runner for g1t.
2//!
3//! ```text
4//! g1t-runner register --url https://g1t.sh --token g1trt_… [--name build-01]
5//! [--labels gpu,cuda] [--group Default] [--ephemeral] [--work-dir DIR]
6//! [--no-docker] [--image IMAGE] [--agent-image IMAGE] [--replace]
7//! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
8//! g1t-runner run [--once]
9//! g1t-runner service install|start|stop|status|uninstall
10//! g1t-runner remove
11//! g1t-runner update
12//! g1t-runner version
13//! ```
14//!
15//! It only ever calls out, to `api.g1t.sh` over HTTPS: it polls for work
16//! (a poll waits up to 20 seconds for some, and is the runner's heartbeat),
17//! runs what it is given with the same harness g1t's sandboxes run (this
18//! program in another mode, see `exec`), and says how it ended. Nothing
19//! listens on the machine. Every command takes `--dir` (default
20//! `~/.g1t-runner`, or `G1T_RUNNER_DIR`), where its configuration is kept.
21//!
22//! How it keeps jobs from the machine is `sandbox`: in its image under
23//! Sysbox (`G1T_RUNNER_SANDBOX=nested`) it runs its own Docker Engine
24//! (`engine`) and refuses to start if it can reach the machine's.
25
26mod api;
27mod config;
28#[cfg(unix)]
29mod engine;
30mod exec;
31mod sandbox;
32mod service;
33mod update;
34
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::time::{Duration, Instant};
38
39use anyhow::{Context, Result, bail};
40use serde_json::json;
41
42use api::{Api, Failure};
43use config::Config;
44
45pub const VERSION: &str = env!("CARGO_PKG_VERSION");
46
47/// Set to stop the loop between polls: a Windows service's stop request.
48pub static STOP: AtomicBool = AtomicBool::new(false);
49
50/// How long a busy runner waits between heartbeats.
51const HEARTBEAT: Duration = Duration::from_secs(10);
52/// How long an idle poll waits for work.
53const IDLE_WAIT_MS: u64 = 20_000;
54/// How often an idle runner checks for a new version.
55const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
56
57const COMMANDS: &[&str] = &["register", "register-and-run", "run", "service", "remove", "update", "version", "--version", "help", "--help", "-h"];
58
59/// Whether `args` (without the program) are a command of this one, rather
60/// than the harness's `MODE`.
61pub fn is_command(args: &[String]) -> bool {
62 args.first().is_some_and(|first| COMMANDS.contains(&first.as_str()))
63}
64
65pub fn log(message: &str) {
66 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
67 println!("{} {message}", g1t_now(now));
68}
69
70/// RFC 3339 for the log, without pulling in a date library.
71fn g1t_now(ms: u64) -> String {
72 let seconds = ms / 1000;
73 let (days, rest) = (seconds / 86_400, seconds % 86_400);
74 let z = days as i64 + 719_468;
75 let era = z.div_euclid(146_097);
76 let doe = z.rem_euclid(146_097);
77 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
78 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
79 let mp = (5 * doy + 2) / 153;
80 let day = doy - (153 * mp + 2) / 5 + 1;
81 let month = if mp < 10 { mp + 3 } else { mp - 9 };
82 let year = yoe + era * 400 + i64::from(month <= 2);
83 format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
84}
85
86/// `--name value` and `--flag` options.
87struct Options {
88 values: Vec<(String, String)>,
89 flags: Vec<String>,
90 rest: Vec<String>,
91}
92
93impl Options {
94 fn parse(args: &[String], takes_value: &[&str]) -> Result<Options> {
95 let mut options = Options { values: Vec::new(), flags: Vec::new(), rest: Vec::new() };
96 let mut i = 0;
97 while i < args.len() {
98 let arg = &args[i];
99 if let Some(name) = arg.strip_prefix("--") {
100 if let Some((name, value)) = name.split_once('=') {
101 options.values.push((name.to_owned(), value.to_owned()));
102 } else if takes_value.contains(&name) {
103 let Some(value) = args.get(i + 1) else { bail!("--{name} needs a value") };
104 options.values.push((name.to_owned(), value.clone()));
105 i += 1;
106 } else {
107 options.flags.push(name.to_owned());
108 }
109 } else {
110 options.rest.push(arg.clone());
111 }
112 i += 1;
113 }
114 Ok(options)
115 }
116
117 fn value(&self, name: &str) -> Option<&str> {
118 self.values.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
119 }
120
121 fn flag(&self, name: &str) -> bool {
122 self.flags.iter().any(|f| f == name)
123 }
124
125 /// For `register-and-run` in a container: each option may come from
126 /// `G1T_RUNNER_<NAME>` instead (`G1T_RUNNER_TOKEN`, `G1T_RUNNER_LABELS`,
127 /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token.
128 fn with_environment(mut self) -> Options {
129 let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_"));
130 for name in VALUE_OPTIONS {
131 if self.value(name).is_none()
132 && let Ok(value) = std::env::var(env_name(name))
133 && !value.is_empty()
134 {
135 self.values.push((name.to_owned(), value));
136 }
137 }
138 for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker"] {
139 if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
140 self.flags.push(name.to_owned());
141 }
142 }
143 self
144 }
145}
146
147/// The options that take a value.
148const VALUE_OPTIONS: [&str; 14] =
149 ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"];
150
151const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
152
153 g1t-runner register --url https://g1t.sh --token <registration token>
154 [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral]
155 [--work-dir DIR] [--no-docker] [--image IMAGE] [--agent-image IMAGE]
156 [--api URL] [--harness PATH] [--replace] [--no-auto-update]
157 [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
158 [--job-docker-image IMAGE]
159 g1t-runner run [--once] poll for work and run it
160 g1t-runner register-and-run ... both, for containers; options may be
161 G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL…
162 g1t-runner service install|start|stop|status|uninstall
163 g1t-runner remove unregister this runner
164 g1t-runner update update to the newest release
165 g1t-runner version
166
167In a workflow step (the g1t/deploy and g1t/detect actions use these):
168 g1t-runner deploy build this checkout and put it on g1t.page
169 g1t-runner detect [--root-dir DIR] what that build would find
170
171Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
172Run it as a container under Sysbox so neither it nor its jobs reach the
173machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner.
174--no-docker (host mode) is deprecated.
175Make a registration token under Settings, Runners on g1t.sh.
176Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
177
178/// Runs a command. Returns the process's exit code.
179pub fn main(args: Vec<String>) -> i32 {
180 let mut takes = VALUE_OPTIONS.to_vec();
181 takes.push("dir");
182 let options = match Options::parse(&args[1..], &takes) {
183 Ok(options) => options,
184 Err(error) => {
185 eprintln!("g1t-runner: {error:#}");
186 return 2;
187 }
188 };
189 let folder = config::folder(options.value("dir"));
190 let outcome = match args[0].as_str() {
191 "register" => register(&options, &folder),
192 // A container's one command: register unless it already is, then run.
193 "register-and-run" => {
194 let options = options.with_environment();
195 let ready = if config::load(&folder).is_ok() { Ok(()) } else { register(&options, &folder) };
196 ready.and_then(|()| run(&folder, options.flag("once")))
197 }
198 "run" => run(&folder, options.flag("once")),
199 "service" => match options.rest.first().map(String::as_str) {
200 Some(action) => config::load(&folder).and_then(|config| service::main(action, &config, &folder)),
201 None => Err(anyhow::anyhow!("service takes install, uninstall, start, stop or status")),
202 },
203 "remove" => remove(&folder),
204 "update" => config::load(&folder).and_then(|config| match update::update(&config)? {
205 Some(version) => {
206 log(&format!("Updated to {version}. Restart the runner (or its service) to use it."));
207 Ok(())
208 }
209 None => {
210 log(&format!("{VERSION} is the newest release."));
211 Ok(())
212 }
213 }),
214 "version" | "--version" => {
215 println!("g1t-runner {VERSION} ({})", update::platform());
216 Ok(())
217 }
218 _ => {
219 println!("{HELP}");
220 Ok(())
221 }
222 };
223 match outcome {
224 Ok(()) => 0,
225 Err(error) => {
226 eprintln!("g1t-runner: {error:#}");
227 1
228 }
229 }
230}
231
232fn hostname() -> String {
233 for name in ["COMPUTERNAME", "HOSTNAME"] {
234 if let Ok(value) = std::env::var(name).map(|v| v.trim().to_owned())
235 && !value.is_empty()
236 {
237 return value;
238 }
239 }
240 std::process::Command::new("hostname")
241 .output()
242 .ok()
243 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
244 .filter(|name| !name.is_empty())
245 .unwrap_or_else(|| "runner".to_owned())
246}
247
248/// A runner's name from a machine's: what g1t accepts.
249fn runner_name(given: &str) -> String {
250 let name: String = given
251 .chars()
252 .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
253 .take(64)
254 .collect();
255 if name.is_empty() { "runner".into() } else { name }
256}
257
258fn register(options: &Options, folder: &Path) -> Result<()> {
259 let Some(url) = options.value("url") else { bail!("--url is needed: the g1t you register with, such as https://g1t.sh") };
260 let Some(token) = options.value("token") else { bail!("--token is needed: make a registration token under Settings, Runners") };
261 let url = url.trim_end_matches('/').to_owned();
262 let api = options.value("api").map(|a| a.trim_end_matches('/').to_owned()).unwrap_or_else(|| config::api_for(&url));
263 let name = runner_name(options.value("name").map(str::to_owned).unwrap_or_else(hostname).as_str());
264 let labels: Vec<String> = options
265 .value("labels")
266 .unwrap_or_default()
267 .split(',')
268 .map(|l| l.trim().to_owned())
269 .filter(|l| !l.is_empty())
270 .collect();
271 let docker = !options.flag("no-docker");
272 let isolation = isolation(docker)?;
273 if isolation == sandbox::Isolation::Host {
274 log(sandbox::HOST_MODE_AT_START);
275 } else if !sandbox::nested_requested() && !exec::docker_ready() {
276 log("Docker does not answer here. Jobs will fail until it does.");
277 }
278 let pids_limit = match options.value("pids-limit") {
279 Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?),
280 None => None,
281 };
282 let existing = config::load(folder).ok();
283 if existing.is_some() && !options.flag("replace") {
284 bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display());
285 }
286 // The OS jobs run on: in Docker that is Linux, whatever the machine
287 // (Docker Desktop runs Linux containers on macOS and Windows).
288 let os = match std::env::consts::OS {
289 _ if docker => "linux",
290 "macos" => "macos",
291 "windows" => "windows",
292 _ => "linux",
293 };
294 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
295 let registered = Api::register(
296 &api,
297 &json!({
298 "token": token,
299 "name": name,
300 "labels": labels,
301 "os": os,
302 "arch": arch,
303 "version": VERSION,
304 "ephemeral": options.flag("ephemeral"),
305 "group": options.value("group"),
306 "replace": options.flag("replace"),
307 "isolation": isolation.as_str(),
308 }),
309 )?;
310 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
311 let config = Config {
312 url,
313 api,
314 runner: registered.runner.id.clone(),
315 name: registered.runner.name.clone(),
316 credential: registered.credential,
317 workspace: registered.runner.workspace.clone(),
318 repo: registered.runner.repo.clone(),
319 group: registered.runner.group.clone(),
320 labels: registered.runner.labels.clone(),
321 ephemeral: options.flag("ephemeral"),
322 work_dir,
323 docker,
324 image: options.value("image").map(str::to_owned),
325 agent_image: options.value("agent-image").map(str::to_owned),
326 harness: options.value("harness").map(PathBuf::from),
327 auto_update: !options.flag("no-auto-update"),
328 memory: options.value("memory").map(str::to_owned),
329 cpus: options.value("cpus").map(str::to_owned),
330 pids_limit,
331 job_docker: !options.flag("no-job-docker"),
332 job_docker_image: options.value("job-docker-image").map(str::to_owned),
333 };
334 config::save(folder, &config)?;
335 let place = match &config.repo {
336 Some(repo) => format!("for {repo}"),
337 None => format!("in {}{}", config.workspace, config.group.as_ref().map(|g| format!(", group {g}")).unwrap_or_default()),
338 };
339 log(&format!("Registered {} {place}, with labels {}.", config.name, config.labels.join(", ")));
340 log("Start it with `g1t-runner run`, or `g1t-runner service install` to keep it running.");
341 Ok(())
342}
343
344/// How this runner keeps work from its machine; refuses a nested runner
345/// that can reach the machine (sandbox.rs).
346fn isolation(docker: bool) -> Result<sandbox::Isolation> {
347 use sandbox::{Isolation, SandboxDriver};
348 if !docker {
349 // What is coming for jobs native to this OS.
350 if let Some(driver) = sandbox::native_driver()
351 && let Err(why) = driver.preflight()
352 {
353 log(&format!("Sandboxed {} jobs, in {}: {why}.", std::env::consts::OS, driver.name()));
354 }
355 return Ok(Isolation::Host);
356 }
357 if !sandbox::nested_requested() {
358 return sandbox::SharedDocker.preflight();
359 }
360 if !cfg!(target_os = "linux") {
361 bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV);
362 }
363 let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?;
364 if isolation != Isolation::NestedContainer {
365 for problem in sandbox::Findings::read().problems() {
366 log(&format!("UNSAFE: {problem}"));
367 }
368 log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV));
369 }
370 Ok(isolation)
371}
372
373/// A nested runner's own Docker Engine, which it uses whatever else it
374/// can see (with the override, it starts despite that).
375fn prepare(docker: bool) -> Result<()> {
376 if !docker || !sandbox::nested_requested() {
377 return Ok(());
378 }
379 #[cfg(unix)]
380 {
381 let version = engine::start()?;
382 log(&format!("Started this runner's own Docker Engine {version}, inside its container."));
383 }
384 Ok(())
385}
386
387fn remove(folder: &Path) -> Result<()> {
388 let config = config::load(folder)?;
389 let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
390 match api.remove() {
391 Ok(()) | Err(Failure::Refused(401, _)) | Err(Failure::Refused(404, _)) => {}
392 Err(failure) => bail!("{failure}; the runner is still registered"),
393 }
394 config::forget(folder);
395 log(&format!("Removed {}.", config.name));
396 Ok(())
397}
398
399/// The loop, for a Windows service: its exit code.
400#[cfg_attr(not(windows), allow(dead_code))]
401pub fn serve_from_service() -> i32 {
402 let folder = config::folder(None);
403 let folder = std::env::args()
404 .collect::<Vec<_>>()
405 .windows(2)
406 .find(|pair| pair[0] == "--dir")
407 .map(|pair| PathBuf::from(&pair[1]))
408 .unwrap_or(folder);
409 match run(&folder, false) {
410 Ok(()) => 0,
411 Err(error) => {
412 eprintln!("g1t-runner: {error:#}");
413 1
414 }
415 }
416}
417
418/// Sleeps up to `duration`, waking early when `stop` says so.
419fn nap(duration: Duration, mut stop: impl FnMut() -> bool) {
420 let until = Instant::now() + duration;
421 while Instant::now() < until {
422 if STOP.load(Ordering::SeqCst) || stop() {
423 return;
424 }
425 std::thread::sleep(Duration::from_millis(250));
426 }
427}
428
429fn run(folder: &Path, once: bool) -> Result<()> {
430 let mut config = config::load(folder)?;
431 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
432 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
433 let isolation = isolation(config.docker)?;
434 prepare(config.docker)?;
435 if isolation != sandbox::Isolation::Host {
436 exec::sweep(&config);
437 }
438 log(&format!(
439 "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.",
440 config.name,
441 config.labels.join(", "),
442 config.url,
443 isolation.describe()
444 ));
445 if isolation == sandbox::Isolation::Host {
446 log(sandbox::HOST_MODE_AT_START);
447 }
448 let mut current: Option<exec::Running> = None;
449 let mut ran_one = false;
450 let mut backoff = Duration::from_secs(2);
451 let mut last_update_check = Instant::now().checked_sub(UPDATE_EVERY).unwrap_or_else(Instant::now);
452 let mut said_no_key = false;
453 loop {
454 if STOP.load(Ordering::SeqCst) {
455 if let Some(mut running) = current.take() {
456 log(&format!("Stopping: {} is cut short.", running.name));
457 running.stop();
458 let _ = api.finished(&running.id, 1, Some("The self-hosted runner was stopped."));
459 running.clean();
460 }
461 return Ok(());
462 }
463 // What was running has ended: say how.
464 if let Some(running) = current.as_mut()
465 && let Some(code) = running.ended()
466 {
467 let mut running = current.take().expect("checked above");
468 log(&format!("{} {} ended ({code}).", running.kind, running.name));
469 if let Err(failure) = api.finished(&running.id, code, None) {
470 log(&format!("Could not say how it ended: {failure}"));
471 }
472 running.clean();
473 ran_one = true;
474 if config.ephemeral || once {
475 if config.ephemeral {
476 let _ = api.remove();
477 config::forget(folder);
478 log("The ephemeral runner ran its job and removed itself.");
479 }
480 return Ok(());
481 }
482 }
483 // Idle: a newer release, now and then.
484 if current.is_none() && config.auto_update && last_update_check.elapsed() >= UPDATE_EVERY {
485 last_update_check = Instant::now();
486 if update::can_update() {
487 match update::update(&config) {
488 Ok(Some(version)) => {
489 log(&format!("Updated to {version}; starting it."));
490 return restart();
491 }
492 Ok(None) => {}
493 Err(error) => log(&format!("Could not check for a new version: {error:#}")),
494 }
495 } else if !said_no_key {
496 said_no_key = true;
497 log("This build cannot check releases, so it will not update itself.");
498 }
499 }
500 let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect();
501 let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS };
502 let poll = match api.poll(&running, wait) {
503 Ok(poll) => {
504 backoff = Duration::from_secs(2);
505 poll
506 }
507 Err(Failure::Refused(401, message)) => {
508 if let Some(mut running) = current.take() {
509 running.stop();
510 running.clean();
511 }
512 bail!("g1t no longer knows this runner ({message}). Register it again.");
513 }
514 Err(failure) => {
515 log(&format!("{failure}; trying again in {}s", backoff.as_secs()));
516 nap(backoff, || false);
517 backoff = (backoff * 2).min(Duration::from_secs(60));
518 continue;
519 }
520 };
521 if let Some(fresh) = poll.credential {
522 config.credential = fresh.clone();
523 api.credential = fresh;
524 config::save(folder, &config)?;
525 }
526 if poll.removed {
527 config::forget(folder);
528 log("This runner was removed from g1t; stopping.");
529 return Ok(());
530 }
531 if let Some(running) = current.as_mut()
532 && poll.cancel.contains(&running.id)
533 {
534 log(&format!("{} was cancelled; stopping it.", running.name));
535 running.stop();
536 }
537 if let Some(running) = current.as_mut()
538 && running.overdue()
539 {
540 log(&format!("{} ran past its time limit; stopping it.", running.name));
541 running.stop();
542 }
543 if let Some(work) = poll.assignment {
544 log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo));
545 match exec::start(&config, isolation, folder, &work) {
546 Ok(running) => current = Some(running),
547 Err(error) => {
548 log(&format!("Could not start it: {error:#}"));
549 let _ = api.finished(&work.id, 1, Some(&format!("The self-hosted runner {} could not start it: {error:#}", config.name)));
550 if config.ephemeral || once {
551 return Ok(());
552 }
553 }
554 }
555 continue;
556 }
557 if once && ran_one {
558 return Ok(());
559 }
560 // Busy: a heartbeat every few seconds until it ends.
561 if current.is_some() {
562 let mut check = || current.as_mut().is_some_and(|r| r.ended().is_some());
563 nap(HEARTBEAT, &mut check);
564 }
565 }
566}
567
568/// Runs the updated program in this one's place, with the same arguments.
569fn restart() -> Result<()> {
570 let me = std::env::current_exe()?;
571 let status = std::process::Command::new(me).args(std::env::args().skip(1)).status()?;
572 std::process::exit(status.code().unwrap_or(1));
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 fn args(items: &[&str]) -> Vec<String> {
580 items.iter().map(|s| s.to_string()).collect()
581 }
582
583 #[test]
584 fn commands_are_told_apart_from_the_harness() {
585 assert!(is_command(&args(&["register", "--url", "x"])));
586 assert!(is_command(&args(&["run"])));
587 assert!(!is_command(&args(&[])));
588 assert!(!is_command(&args(&["something"])));
589 }
590
591 #[test]
592 fn options_read_both_ways() {
593 let options = Options::parse(&args(&["--url", "https://g1t.sh", "--labels=gpu,cuda", "--ephemeral", "install"]), &["url", "labels"]).unwrap();
594 assert_eq!(options.value("url"), Some("https://g1t.sh"));
595 assert_eq!(options.value("labels"), Some("gpu,cuda"));
596 assert!(options.flag("ephemeral"));
597 assert_eq!(options.rest, vec!["install".to_owned()]);
598 assert!(Options::parse(&args(&["--url"]), &["url"]).is_err());
599 }
600
601 #[test]
602 fn a_machines_name_becomes_a_runners() {
603 assert_eq!(runner_name("Build Box #1"), "Build-Box--1");
604 assert_eq!(runner_name(""), "runner");
605 assert_eq!(runner_name(&"x".repeat(80)).len(), 64);
606 }
607
608 #[test]
609 fn the_log_says_when() {
610 assert_eq!(g1t_now(1_790_918_179_123), "2026-10-02T05:16:19Z");
611 }
612}