Skip to content

Commit 5ab3c8c

Browse files
authored
feat(orion-scheduler): support multi-VM keyed by server_ws domain (#2146)
* feat(orion-scheduler): support multi-VM keyed by server_ws domain Allow concurrent runners for different domains with same-domain idempotency/409/replace, and wire mono client + OC Start Runner through the updated scheduler APIs and docs. * fix eslint
1 parent fd38395 commit 5ab3c8c

20 files changed

Lines changed: 1263 additions & 474 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ceres/src/model/orion_runner.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use utoipa::ToSchema;
55
pub struct StartRunnerRequest {
66
#[serde(default, skip_serializing_if = "Option::is_none")]
77
pub target: Option<String>,
8+
/// Force recreate when a Running VM already exists for this mono's domain.
9+
#[serde(default)]
10+
pub replace: bool,
811
#[serde(default, skip_serializing_if = "Option::is_none")]
912
pub image_path: Option<String>,
1013
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -23,13 +26,17 @@ pub struct StartRunnerRequest {
2326
pub struct StartRunnerResponse {
2427
pub vm_id: String,
2528
pub phase: String,
29+
#[serde(default, skip_serializing_if = "Option::is_none")]
30+
pub domain: Option<String>,
2631
}
2732

2833
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
2934
pub struct RunnerStatusResponse {
3035
pub vm_id: String,
3136
pub phase: String,
3237
#[serde(default, skip_serializing_if = "Option::is_none")]
38+
pub domain: Option<String>,
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
3340
pub vm_ip: Option<String>,
3441
#[serde(default, skip_serializing_if = "Option::is_none")]
3542
pub log_file: Option<String>,

clients/orion-scheduler-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ common = { workspace = true }
1313
reqwest = { workspace = true, features = ["json"] }
1414
anyhow = { workspace = true }
1515
serde = { workspace = true }
16+
serde_json = { workspace = true }
1617
tracing = { workspace = true }

clients/orion-scheduler-client/src/http_client.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ impl OrionSchedulerHttpClient {
5959
let res = self.auth_headers(req).send().await?;
6060
let status = res.status();
6161
let body: StartRunnerSchedulerResponse = res.json().await?;
62-
if status.is_success() || status.as_u16() == 202 {
62+
// 200 OK (idempotent), 202 Accepted (provisioning), 409 Conflict
63+
if status.is_success() || status.as_u16() == 202 || status.as_u16() == 409 {
6364
Ok(body)
6465
} else {
6566
Err(anyhow::anyhow!(
@@ -70,12 +71,56 @@ impl OrionSchedulerHttpClient {
7071
}
7172
}
7273

74+
pub async fn get_vm_status(&self, vm_id: &str) -> anyhow::Result<SchedulerStatusResponse> {
75+
let url = format!("{}/vms/{}", self.base_url, vm_id);
76+
let req = self.client.get(&url).timeout(Duration::from_secs(30));
77+
let res = self.auth_headers(req).send().await?;
78+
let status = res.status();
79+
if status.as_u16() == 404 {
80+
return Ok(SchedulerStatusResponse {
81+
status: "no_vm".to_string(),
82+
phase: Some("no_vm".to_string()),
83+
vm_id: Some(vm_id.to_string()),
84+
domain: None,
85+
vm_ip: None,
86+
uptime_secs: None,
87+
log_file: None,
88+
error: Some("VM not found".to_string()),
89+
});
90+
}
91+
if status.is_success() {
92+
Ok(res.json().await?)
93+
} else {
94+
Err(anyhow::anyhow!(
95+
"Scheduler get_vm_status failed: {}",
96+
status
97+
))
98+
}
99+
}
100+
73101
pub async fn get_status(&self) -> anyhow::Result<SchedulerStatusResponse> {
74102
let url = format!("{}/status", self.base_url);
75103
let req = self.client.get(&url).timeout(Duration::from_secs(30));
76104
let res = self.auth_headers(req).send().await?;
77105
if res.status().is_success() {
78-
Ok(res.json().await?)
106+
// List form — not used by mono GET by id path anymore.
107+
let v: serde_json::Value = res.json().await?;
108+
if let Some(vms) = v.get("vms").and_then(|x| x.as_array()) {
109+
if let Some(first) = vms.first() {
110+
return Ok(serde_json::from_value(first.clone())?);
111+
}
112+
return Ok(SchedulerStatusResponse {
113+
status: "no_vm".to_string(),
114+
phase: Some("no_vm".to_string()),
115+
vm_id: None,
116+
domain: None,
117+
vm_ip: None,
118+
uptime_secs: None,
119+
log_file: None,
120+
error: None,
121+
});
122+
}
123+
Ok(serde_json::from_value(v)?)
79124
} else {
80125
Err(anyhow::anyhow!(
81126
"Scheduler get_status failed: {}",

clients/orion-scheduler-client/src/lib.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! HTTP client for orion-scheduler VM provisioning (`/webhook`, `/status`).
1+
//! HTTP client for orion-scheduler VM provisioning (`/webhook`, `/status`, `/vms/{id}`).
22
33
mod http_client;
44

@@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
1111
pub struct StartRunnerPayload {
1212
#[serde(skip_serializing_if = "Option::is_none")]
1313
pub target: Option<String>,
14+
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
15+
pub replace: bool,
1416
pub server_ws: String,
1517
pub scorpio_base_url: String,
1618
pub scorpio_lfs_url: String,
@@ -28,24 +30,30 @@ pub struct StartRunnerPayload {
2830
pub image_memory_mb: Option<u32>,
2931
}
3032

31-
/// Response from scheduler `POST /webhook` (async 202 or sync 200).
33+
/// Response from scheduler `POST /webhook` (async 202, sync 200, conflict 409).
3234
#[derive(Debug, Clone, Deserialize)]
3335
pub struct StartRunnerSchedulerResponse {
3436
pub status: String,
3537
pub vm_id: Option<String>,
38+
#[serde(default)]
39+
pub domain: Option<String>,
40+
#[serde(default)]
41+
pub phase: Option<String>,
3642
pub error: Option<String>,
3743
#[serde(default)]
3844
pub orion_log_file: Option<String>,
3945
}
4046

41-
/// Response from scheduler `GET /status`.
47+
/// Response from scheduler `GET /vms/{id}` or filtered `/status`.
4248
#[derive(Debug, Clone, Deserialize)]
4349
pub struct SchedulerStatusResponse {
4450
pub status: String,
4551
#[serde(default)]
4652
pub phase: Option<String>,
4753
pub vm_id: Option<String>,
4854
#[serde(default)]
55+
pub domain: Option<String>,
56+
#[serde(default)]
4957
pub vm_ip: Option<String>,
5058
#[serde(default)]
5159
pub uptime_secs: Option<u64>,
@@ -79,6 +87,11 @@ impl OrionSchedulerClient {
7987
self.http.start_runner(payload).await
8088
}
8189

90+
pub async fn get_vm_status(&self, vm_id: &str) -> anyhow::Result<SchedulerStatusResponse> {
91+
self.http.get_vm_status(vm_id).await
92+
}
93+
94+
/// Backward-compatible list/status endpoint.
8295
pub async fn get_status(&self) -> anyhow::Result<SchedulerStatusResponse> {
8396
self.http.get_status().await
8497
}

mono/src/api/router/orion_runner_router.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async fn start_runner(
140140

141141
let payload = StartRunnerPayload {
142142
target: req.target,
143+
replace: req.replace,
143144
server_ws: env.server_ws,
144145
scorpio_base_url: env.scorpio_base_url,
145146
scorpio_lfs_url: env.scorpio_lfs_url,
@@ -158,6 +159,17 @@ async fn start_runner(
158159
)
159160
})?;
160161

162+
if sched_resp.status == "conflict" {
163+
return Err(ApiError::with_status(
164+
StatusCode::CONFLICT,
165+
anyhow!(
166+
"Runner already provisioning for domain {:?}: {}",
167+
sched_resp.domain,
168+
sched_resp.error.unwrap_or_else(|| "conflict".to_string())
169+
),
170+
));
171+
}
172+
161173
let vm_id = sched_resp.vm_id.ok_or_else(|| {
162174
ApiError::with_status(
163175
StatusCode::BAD_GATEWAY,
@@ -170,17 +182,20 @@ async fn start_runner(
170182
)
171183
})?;
172184

173-
let phase = if sched_resp.status == "provisioning" {
174-
"provisioning".to_string()
175-
} else if sched_resp.status == "ok" {
176-
"running".to_string()
177-
} else {
178-
sched_resp.status
179-
};
185+
let phase = sched_resp.phase.unwrap_or_else(|| {
186+
if sched_resp.status == "provisioning" {
187+
"provisioning".to_string()
188+
} else if sched_resp.status == "ok" {
189+
"running".to_string()
190+
} else {
191+
sched_resp.status
192+
}
193+
});
180194

181195
Ok(Json(CommonResult::success(Some(StartRunnerResponse {
182196
vm_id,
183197
phase,
198+
domain: sched_resp.domain,
184199
}))))
185200
}
186201

@@ -209,25 +224,27 @@ async fn get_runner_status(
209224
ensure_admin(&state, &user).await?;
210225
let client = scheduler_client(&state)?;
211226

212-
let sched = client.get_status().await.map_err(|e| {
227+
let sched = client.get_vm_status(&id).await.map_err(|e| {
213228
ApiError::with_status(
214229
StatusCode::BAD_GATEWAY,
215230
anyhow!("Scheduler request failed: {}", e),
216231
)
217232
})?;
218233

219-
if sched.vm_id.as_deref() != Some(id.as_str()) {
220-
return Err(ApiError::not_found(anyhow!("Runner VM '{}' not found", id)));
221-
}
222-
223234
let phase = sched
224235
.phase
236+
.clone()
225237
.or_else(|| Some(sched.status.clone()))
226238
.unwrap_or_else(|| "unknown".to_string());
227239

240+
if phase == "no_vm" || sched.vm_id.as_deref() != Some(id.as_str()) {
241+
return Err(ApiError::not_found(anyhow!("Runner VM '{}' not found", id)));
242+
}
243+
228244
Ok(Json(CommonResult::success(Some(RunnerStatusResponse {
229245
vm_id: id,
230246
phase,
247+
domain: sched.domain,
231248
vm_ip: sched.vm_ip,
232249
log_file: sched.log_file,
233250
error: sched.error,

moon/apps/web/hooks/OrionClient/usePostStartRunner.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import { useMutation } from '@tanstack/react-query'
22
import { toast } from 'react-hot-toast'
33

4-
import type { StartRunnerResponse } from '@gitmono/types/generated'
4+
import type { StartRunnerRequest, StartRunnerResponse } from '@gitmono/types/generated'
55

66
import { legacyApiClient } from '@/utils/queryClient'
77

88
export function usePostStartRunner() {
99
const mutation = legacyApiClient.v1.postApiOrionRunners()
1010

11-
return useMutation<StartRunnerResponse, Error, void>({
12-
mutationFn: async () => {
13-
const result = await mutation.request({})
11+
return useMutation<StartRunnerResponse, Error, StartRunnerRequest | void>({
12+
mutationFn: async (body) => {
13+
const result = await mutation.request(body ?? {})
1414
if (!result.req_result || !result.data) {
1515
throw new Error(result.err_message || 'Failed to start runner')
1616
}
1717
return result.data
1818
},
19-
onSuccess: () => {
20-
toast.success('Runner provisioning started')
19+
onSuccess: (data) => {
20+
if (data.phase === 'running') {
21+
toast.success(data.domain ? `Runner already running (${data.domain})` : 'Runner already running')
22+
} else {
23+
toast.success(data.domain ? `Runner provisioning started (${data.domain})` : 'Runner provisioning started')
24+
}
2125
},
2226
onError: (error) => {
2327
toast.error(error?.message || 'Failed to start runner')

moon/apps/web/pages/[org]/oc/index.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const OrionClientPage: PageWithLayout<any> = () => {
2929
const [currentPage, setCurrentPage] = React.useState<number>(1)
3030
const [activeVmId, setActiveVmId] = React.useState<string | null>(null)
3131
const [activePhase, setActivePhase] = React.useState<string | null>(null)
32+
const [activeDomain, setActiveDomain] = React.useState<string | null>(null)
3233

3334
const perPage = 8
3435

@@ -90,6 +91,9 @@ const OrionClientPage: PageWithLayout<any> = () => {
9091
React.useEffect(() => {
9192
if (!runnerStatus) return
9293
setActivePhase(runnerStatus.phase)
94+
if (runnerStatus.domain) {
95+
setActiveDomain(runnerStatus.domain)
96+
}
9397
}, [runnerStatus])
9498

9599
React.useEffect(() => {
@@ -98,14 +102,21 @@ const OrionClientPage: PageWithLayout<any> = () => {
98102
}
99103
}, [runnerStatus?.phase, handleRefresh])
100104

101-
const handleStartRunner = React.useCallback(() => {
102-
startRunner(undefined, {
103-
onSuccess: (data) => {
104-
setActiveVmId(data.vm_id)
105-
setActivePhase(data.phase)
106-
}
107-
})
108-
}, [startRunner])
105+
const handleStartRunner = React.useCallback(
106+
(replace = false) => {
107+
startRunner(
108+
{ replace },
109+
{
110+
onSuccess: (data) => {
111+
setActiveVmId(data.vm_id)
112+
setActivePhase(data.phase)
113+
setActiveDomain(data.domain ?? null)
114+
}
115+
}
116+
)
117+
},
118+
[startRunner]
119+
)
109120

110121
React.useEffect(() => {
111122
mutate(requestPayload, {
@@ -159,7 +170,7 @@ const OrionClientPage: PageWithLayout<any> = () => {
159170
{isAdmin ? (
160171
<Button
161172
variant='primary'
162-
onClick={handleStartRunner}
173+
onClick={() => handleStartRunner(false)}
163174
disabled={isStartingRunner || activePhase === 'provisioning'}
164175
>
165176
{isStartingRunner ? 'Starting…' : 'Start Runner'}
@@ -182,6 +193,11 @@ const OrionClientPage: PageWithLayout<any> = () => {
182193
Runner {activeVmId}
183194
</UIText>
184195
<div className='mt-1 flex flex-col gap-1'>
196+
{(runnerStatus?.domain ?? activeDomain) ? (
197+
<UIText size='text-sm' color='text-muted'>
198+
Domain: {runnerStatus?.domain ?? activeDomain}
199+
</UIText>
200+
) : null}
185201
<UIText size='text-sm'>
186202
Phase:{' '}
187203
<span className='font-medium capitalize'>{runnerStatus?.phase ?? activePhase ?? 'unknown'}</span>
@@ -197,7 +213,7 @@ const OrionClientPage: PageWithLayout<any> = () => {
197213
</UIText>
198214
) : null}
199215
{runnerStatus?.phase === 'failed' ? (
200-
<Button variant='primary' size='sm' className='mt-1 w-fit' onClick={handleStartRunner}>
216+
<Button variant='primary' size='sm' className='mt-1 w-fit' onClick={() => handleStartRunner(true)}>
201217
Retry
202218
</Button>
203219
) : null}

moon/packages/types/generated.ts

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)