diff --git a/.github/workflows/mono-engine-deploy.yml b/.github/workflows/mono-engine-deploy.yml index 6824d1d0a..923ab5c9a 100644 --- a/.github/workflows/mono-engine-deploy.yml +++ b/.github/workflows/mono-engine-deploy.yml @@ -128,7 +128,7 @@ jobs: deploy-aws: needs: manifest - if: ${{ github.repository == 'gitmono-dev/mega' }} + if: false # disabled runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/orion-server-deploy.yml b/.github/workflows/orion-server-deploy.yml index d1932920c..c9f2bbb2e 100644 --- a/.github/workflows/orion-server-deploy.yml +++ b/.github/workflows/orion-server-deploy.yml @@ -118,7 +118,7 @@ jobs: deploy-aws: needs: manifest - if: ${{ github.repository == 'gitmono-dev/mega' }} + if: false # disabled runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index b9f695d57..52c398864 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -128,7 +128,7 @@ jobs: deploy-aws: needs: manifest - if: ${{ github.repository == 'gitmono-dev/mega' }} + if: false # disabled runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/web-sync-server-deploy.yml b/.github/workflows/web-sync-server-deploy.yml index 73fcca023..c4c93dad9 100644 --- a/.github/workflows/web-sync-server-deploy.yml +++ b/.github/workflows/web-sync-server-deploy.yml @@ -68,7 +68,7 @@ jobs: deploy-aws: needs: build-and-push - if: ${{ github.repository == 'gitmono-dev/mega' }} + if: false # disabled runs-on: ubuntu-latest strategy: matrix: diff --git a/Cargo.lock b/Cargo.lock index 651b79d51..58007b01e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10027,9 +10027,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index c8b2eef95..87c8b8301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ clap = "4.6.4" #==== tokio = "1.53.1" -tokio-stream = "0.1.18" +tokio-stream = "0.1.19" tokio-util = "0.7.19" async-trait = "0.1.89" async-stream = "0.3.6" diff --git a/ceres/src/model/admin.rs b/ceres/src/model/admin.rs index 848a7d16a..7a9a1aada 100644 --- a/ceres/src/model/admin.rs +++ b/ceres/src/model/admin.rs @@ -11,9 +11,10 @@ pub struct AdminListResponse { pub admins: Vec, } -/// Request body for generating `.mega_cedar.json` content from admin usernames. +/// Request body for generating `.mega_cedar.json` content from admin GitHub logins. #[derive(Debug, Deserialize, ToSchema)] pub struct GenerateCedarRequest { + /// GitHub login names used as Cedar `User` euids (e.g. `octocat`). pub admins: Vec, } diff --git a/clients/orion-scheduler-client/Cargo.toml b/clients/orion-scheduler-client/Cargo.toml index 5ddaf1eb9..7e22ce311 100644 --- a/clients/orion-scheduler-client/Cargo.toml +++ b/clients/orion-scheduler-client/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [dependencies] common = { workspace = true } -reqwest = { workspace = true, features = ["json"] } +reqwest = { workspace = true, features = ["json", "stream"] } anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/clients/orion-scheduler-client/src/http_client.rs b/clients/orion-scheduler-client/src/http_client.rs index 91a4bb988..32e59b7b7 100644 --- a/clients/orion-scheduler-client/src/http_client.rs +++ b/clients/orion-scheduler-client/src/http_client.rs @@ -128,6 +128,60 @@ impl OrionSchedulerHttpClient { )) } } + + /// Open an SSE stream of live Orion runner logs (`GET /logs/orion/stream`). + /// + /// Prefer `vm_id`; when absent, `domain` is used. At least one must be set. + /// The returned response body is a long-lived `text/event-stream` — do not + /// apply a short request timeout. + pub async fn stream_orion_logs( + &self, + vm_id: Option<&str>, + domain: Option<&str>, + ) -> anyhow::Result { + if vm_id.is_none() && domain.is_none() { + return Err(anyhow::anyhow!( + "stream_orion_logs requires vm_id or domain" + )); + } + + let mut url = format!("{}/logs/orion/stream?", self.base_url); + let mut params: Vec = Vec::new(); + if let Some(id) = vm_id { + params.push(format!("vm_id={}", urlencoding_query(id))); + } + if let Some(d) = domain { + params.push(format!("domain={}", urlencoding_query(d))); + } + url.push_str(¶ms.join("&")); + + let req = self.client.get(&url); + let res = self.auth_headers(req).send().await?; + let status = res.status(); + if status.is_success() { + Ok(res) + } else { + let body = res.text().await.unwrap_or_default(); + Err(anyhow::anyhow!( + "Scheduler stream_orion_logs failed ({}): {}", + status, + body + )) + } + } +} + +fn urlencoding_query(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for b in value.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out } #[cfg(test)] @@ -139,4 +193,10 @@ mod tests { let client = OrionSchedulerHttpClient::new("http://127.0.0.1:8080/", ""); assert_eq!(client.base_url, "http://127.0.0.1:8080"); } + + #[test] + fn urlencoding_query_encodes_reserved_bytes() { + assert_eq!(urlencoding_query("a b"), "a%20b"); + assert_eq!(urlencoding_query("orion.gitmega.com"), "orion.gitmega.com"); + } } diff --git a/clients/orion-scheduler-client/src/lib.rs b/clients/orion-scheduler-client/src/lib.rs index fc5bcf323..5e0580f53 100644 --- a/clients/orion-scheduler-client/src/lib.rs +++ b/clients/orion-scheduler-client/src/lib.rs @@ -1,4 +1,4 @@ -//! HTTP client for orion-scheduler VM provisioning (`/webhook`, `/status`, `/vms/{id}`). +//! HTTP client for orion-scheduler VM provisioning (`/webhook`, `/status`, `/vms/{id}`, logs SSE). mod http_client; @@ -95,4 +95,13 @@ impl OrionSchedulerClient { pub async fn get_status(&self) -> anyhow::Result { self.http.get_status().await } + + /// Proxy-friendly SSE stream of runner / orion-client startup logs. + pub async fn stream_orion_logs( + &self, + vm_id: Option<&str>, + domain: Option<&str>, + ) -> anyhow::Result { + self.http.stream_orion_logs(vm_id, domain).await + } } diff --git a/docs/architecture.md b/docs/architecture.md index ede036d96..e63ad898d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ When the `mono` HTTP server is running (default port `8000`): | Swagger UI | `http://localhost:8000/swagger-ui` | | OpenAPI JSON | `http://localhost:8000/api/openapi.json` | -REST handlers are defined in `mono/src/api/` with `utoipa` annotations. Do not maintain a separate hand-written API catalog. +REST handlers are defined in `mono/src/api/` with `utoipa` annotations. Do not maintain a separate hand-written API catalog. Frontend typed clients are generated from those specs — do not hand-edit `moon/api/gen/gitmono.json` or `moon/packages/types/generated.ts`; see [moon/api/README.md](../moon/api/README.md). ### Git LFS diff --git a/docs/development.md b/docs/development.md index 0e0d27dfd..4614aca9f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -38,6 +38,10 @@ Database migrations apply automatically on first `Storage::new` when the `migrat Swagger UI: `http://localhost:8000/swagger-ui` (default HTTP port). +## Frontend OpenAPI client types + +Do **not** hand-edit `moon/api/gen/gitmono.json` or `moon/packages/types/generated.ts`. Export OpenAPI from the running services and regenerate with `moon/script/gen-client`. See [moon/api/README.md](../moon/api/README.md). + ## Post-start initialization (optional) To seed Buckal bundles and third-party imports via API: diff --git a/mono/src/api/api_common/group_permission.rs b/mono/src/api/api_common/group_permission.rs index f0ad8d0f8..a5287336b 100644 --- a/mono/src/api/api_common/group_permission.rs +++ b/mono/src/api/api_common/group_permission.rs @@ -8,17 +8,21 @@ use http::StatusCode; use crate::api::{MonoApiServiceState, error::ApiError, oauth::model::LoginUser}; pub async fn ensure_admin(state: &MonoApiServiceState, user: &LoginUser) -> Result<(), ApiError> { - if state - .services() - .admin() - .check_is_admin(&user.username) - .await? + let admin = state.services().admin(); + let cedar_id = user.cedar_user_id(); + + // Prefer GitHub login (cedar_user_id). Also accept Campsite username while + // `.mega_cedar.json` is still migrating from username → github_login. + if admin.check_is_admin(cedar_id).await? + || (cedar_id != user.username.as_str() && admin.check_is_admin(&user.username).await?) { return Ok(()); } tracing::warn!( actor = %user.username, + cedar_user_id = %cedar_id, + github_login = ?user.github_login, "admin check failed: access forbidden" ); diff --git a/mono/src/api/guard/cedar_guard.rs b/mono/src/api/guard/cedar_guard.rs index efd1a5570..001b29d32 100644 --- a/mono/src/api/guard/cedar_guard.rs +++ b/mono/src/api/guard/cedar_guard.rs @@ -140,7 +140,7 @@ pub async fn cedar_guard( if let Ok(bot) = BotAuth::from_request_parts(&mut parts, &state).await { ("Bot".to_string(), bot.bot_id.to_string()) } else if let Ok(user) = LoginUser::from_request_parts(&mut parts, &state).await { - ("User".to_string(), user.username.clone()) + ("User".to_string(), user.cedar_user_id().to_string()) } else { ("User".to_string(), "reader".to_string()) }; @@ -219,12 +219,12 @@ mod tests { let patterns: HashMap = HashMap::from([ ( "/{link}/approve".to_string(), - "approveMergeRequest".to_string(), + "approveChangeList".to_string(), ), - ("/{link}/close".to_string(), "editMergeRequest".to_string()), + ("/{link}/close".to_string(), "editChangeList".to_string()), ( "/{link}/review/delete".to_string(), - "editMergeRequest".to_string(), + "editChangeList".to_string(), ), ]); @@ -232,14 +232,14 @@ mod tests { let result = match_operation(suffix, &patterns); assert_eq!( result, - Some((ActionEnum::ApproveMergeRequest, "my-cl-link".to_string())) + Some((ActionEnum::ApproveChangeList, "my-cl-link".to_string())) ); let suffix = "/another-cl-link/close"; let result = match_operation(suffix, &patterns); assert_eq!( result, - Some((ActionEnum::EditMergeRequest, "another-cl-link".to_string())) + Some((ActionEnum::EditChangeList, "another-cl-link".to_string())) ); let suffix = "/no-match-link/delete"; @@ -250,7 +250,7 @@ mod tests { let result = match_operation(suffix, &patterns); assert_eq!( result, - Some((ActionEnum::EditMergeRequest, "path/subpath".to_string())) + Some((ActionEnum::EditChangeList, "path/subpath".to_string())) ); } } diff --git a/mono/src/api/guard/guarded_endpoints.json b/mono/src/api/guard/guarded_endpoints.json index 69eb42fbf..2ab2dcf6e 100644 --- a/mono/src/api/guard/guarded_endpoints.json +++ b/mono/src/api/guard/guarded_endpoints.json @@ -1,25 +1,25 @@ { "/cl": { "/list": "unprotectedRequest", - "/{link}/reopen": "editMergeRequest", - "/{link}/close": "editMergeRequest", - "/{link}/merge": "approveMergeRequest", - "/{link}/comment": "editMergeRequest", - "/{link}/title": "editMergeRequest", - "/{link}/labels": "editMergeRequest", - "/{link}/assignees": "editMergeRequest", - "/{link}/reviewers": "editMergeRequest", - "/{link}/approve": "approveMergeRequest", - "/{link}/resolve": "editMergeRequest", + "/{link}/reopen": "editChangeList", + "/{link}/close": "editChangeList", + "/{link}/merge": "approveChangeList", + "/{link}/comment": "editChangeList", + "/{link}/title": "editChangeList", + "/{link}/labels": "editChangeList", + "/{link}/assignees": "editChangeList", + "/{link}/reviewers": "editChangeList", + "/{link}/approve": "approveChangeList", + "/{link}/resolve": "editChangeList", "/{link}/detail": "viewRepo", "/{link}/mui-tree": "viewRepo", "/{link}/files-changed": "viewRepo", "/{link}/files-list": "viewRepo", "/{link}/merge-box": "viewRepo", - "/{link}/status": "editMergeRequest", + "/{link}/status": "editChangeList", "/{link}/update-status": "viewRepo", - "/{link}/update-branch": "editMergeRequest", + "/{link}/update-branch": "editChangeList", "/reviewer/{link}": "viewRepo", - "/reviewer": "editMergeRequest" + "/reviewer": "editChangeList" } -} \ No newline at end of file +} diff --git a/mono/src/api/oauth/model.rs b/mono/src/api/oauth/model.rs index b829da3f7..0d720322c 100644 --- a/mono/src/api/oauth/model.rs +++ b/mono/src/api/oauth/model.rs @@ -6,6 +6,8 @@ pub struct CampsiteUserJson { pub id: String, pub avatar_url: String, pub email: Option, + #[serde(default)] + pub github_login: Option, } impl From for LoginUser { @@ -15,6 +17,7 @@ impl From for LoginUser { email: value.email.unwrap_or_default(), avatar_url: value.avatar_url, campsite_user_id: value.id, + github_login: value.github_login.filter(|s| !s.trim().is_empty()), } } } @@ -49,6 +52,7 @@ impl From for LoginUser { username: value.name, email: value.email.unwrap_or_default(), avatar_url: value.image.unwrap_or_default(), + github_login: None, } } } @@ -57,6 +61,20 @@ impl From for LoginUser { pub struct LoginUser { pub campsite_user_id: String, pub username: String, + /// GitHub login when Campsite authenticated via GitHub; used as Cedar User euid. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github_login: Option, pub avatar_url: String, pub email: String, } + +impl LoginUser { + /// Identity for Cedar / `.mega_cedar.json` admin checks: GitHub login when present. + pub fn cedar_user_id(&self) -> &str { + self.github_login + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(self.username.as_str()) + } +} diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index 80d9903b0..e6fe3e56b 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -60,11 +60,10 @@ async fn is_admin_me( user: LoginUser, State(state): State, ) -> Result>, ApiError> { - let is_admin = state - .services() - .admin() - .check_is_admin(&user.username) - .await?; + let admin = state.services().admin(); + let cedar_id = user.cedar_user_id(); + let is_admin = admin.check_is_admin(cedar_id).await? + || (cedar_id != user.username.as_str() && admin.check_is_admin(&user.username).await?); Ok(Json(CommonResult::success(Some(IsAdminResponse { is_admin, @@ -100,7 +99,7 @@ async fn admin_list( /// POST /api/v1/admin/cedar/generate /// -/// Generate `.mega_cedar.json` content from a list of admin usernames. +/// Generate `.mega_cedar.json` content from a list of admin GitHub logins. /// Only admins can access this endpoint. Does not write to the repository. #[utoipa::path( post, diff --git a/mono/src/api/router/orion_runner_router.rs b/mono/src/api/router/orion_runner_router.rs index 15db2850a..71e310478 100644 --- a/mono/src/api/router/orion_runner_router.rs +++ b/mono/src/api/router/orion_runner_router.rs @@ -2,11 +2,14 @@ use anyhow::anyhow; use api_model::common::CommonResult; use axum::{ Json, + body::Body, extract::{Path, State}, - http::StatusCode, + http::{HeaderValue, StatusCode, header}, + response::Response, }; use ceres::model::orion_runner::{RunnerStatusResponse, StartRunnerRequest, StartRunnerResponse}; use common::config::BuildConfig; +use futures::TryStreamExt; use orion_scheduler_client::{OrionSchedulerClient, StartRunnerPayload}; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -20,6 +23,7 @@ pub fn routers() -> OpenApiRouter { "/orion/runners", OpenApiRouter::new() .routes(routes!(start_runner)) + .routes(routes!(stream_runner_logs)) .routes(routes!(get_runner_status)), ) } @@ -252,6 +256,55 @@ async fn get_runner_status( })))) } +/// Proxy live Orion runner / client startup logs from orion-scheduler as SSE. +#[utoipa::path( + get, + path = "/{id}/logs/stream", + params( + ("id" = String, Path, description = "VM ID returned by start_runner") + ), + responses( + (status = 200, description = "SSE log stream (text/event-stream)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + (status = 503, description = "Scheduler not configured"), + (status = 502, description = "Scheduler unreachable"), + ), + tag = ORION_RUNNER_TAG +)] +async fn stream_runner_logs( + user: LoginUser, + State(state): State, + Path(id): Path, +) -> Result { + ensure_admin(&state, &user).await?; + let client = scheduler_client(&state)?; + + let upstream = client + .stream_orion_logs(Some(id.as_str()), None) + .await + .map_err(|e| { + ApiError::with_status( + StatusCode::BAD_GATEWAY, + anyhow!("Scheduler log stream failed: {}", e), + ) + })?; + + let byte_stream = upstream.bytes_stream().map_err(std::io::Error::other); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header( + header::HeaderName::from_static("x-accel-buffering"), + HeaderValue::from_static("no"), + ) + .body(Body::from_stream(byte_stream)) + .map_err(|e| ApiError::with_status(StatusCode::INTERNAL_SERVER_ERROR, anyhow!(e))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/moon/api/README.md b/moon/api/README.md new file mode 100644 index 000000000..be1ae98c8 --- /dev/null +++ b/moon/api/README.md @@ -0,0 +1,39 @@ +# moon/api — OpenAPI specs and typed client + +Frontend API types live in `@gitmono/types` (`packages/types/generated.ts`). They are **generated** from the OpenAPI JSON files under `api/gen/`. Do not treat those outputs as hand-edited sources of truth. + +## Do not edit these by hand + +| File | Why | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [`gen/gitmono.json`](gen/gitmono.json) | Exported from the **mono** OpenAPI (`utoipa`). Refresh from a running mono server. | +| [`gen/1schema_swagger.json`](gen/1schema_swagger.json) | Exported from **Campsite** Apigen (`rake apigen:…`). Owns types like `SyncUser` / `CurrentUser`. | +| [`../packages/types/generated.ts`](../packages/types/generated.ts) | Produced by [`script/gen-client`](../script/gen-client) via `swagger-typescript-api`. | + +Also avoid hand-editing other checked-in OpenAPI dumps that feed the same pipeline (for example [`gen/orion.json`](gen/orion.json) from **orion-server**). Change the Rust `utoipa` annotations (mono/orion) or Campsite serializers + Apigen export, then re-export and regenerate. + +**Note:** Refreshing only `gitmono.json` from mono will **not** pick up Campsite fields such as `SyncUser.github_login`. Those live in `1schema_swagger.json`. + +## Regenerate workflow + +From the `moon/` directory: + +```bash +# 1) Refresh mono OpenAPI (mono HTTP must be running; default port 8000) +curl -sS http://localhost:8000/api/openapi.json -o api/gen/gitmono.json + +# Optional: refresh orion OpenAPI when orion-server routes/DTOs changed (default port 8004) +curl -sS http://localhost:8004/api-doc/openapi.json -o api/gen/orion.json + +# 2) Merge api/gen/*.json → api/gen/merged_swagger.json and write packages/types/generated.ts +./script/gen-client +``` + +`script/gen-client` runs [`merge-swagger.js`](merge-swagger.js) (merges every `api/gen/*.json` except `merged_swagger.json` / `openapi_schema.json`), then regenerates `packages/types/generated.ts`. + +## Related endpoints + +| Service | Swagger UI | OpenAPI JSON | +| ------------ | ---------------------------------- | -------------------------------------------- | +| mono | `http://localhost:8000/swagger-ui` | `http://localhost:8000/api/openapi.json` | +| orion-server | `http://localhost:8004/swagger-ui` | `http://localhost:8004/api-doc/openapi.json` | diff --git a/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx b/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx index ec9e920a1..b53c7a739 100644 --- a/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx +++ b/moon/apps/web/components/CodeView/BlobView/MegaCedarAdminPicker.tsx @@ -127,7 +127,7 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled
System admins
- Select users to regenerate .mega_cedar.json + Select GitHub logins to regenerate .mega_cedar.json
{generateCedar.isPending && ( @@ -171,8 +171,8 @@ export function MegaCedarAdminPicker({ fileContent, onContentGenerated, disabled ) : (
{members.map((member) => { - const username = member.user.username - const isSelected = selectedAdmins.includes(username) + const cedarId = member.user.github_login || member.user.username + const isSelected = selectedAdmins.includes(cedarId) return (
+ +
+
+ + Startup logs + + + {runnerLogsStatus === 'connecting' + ? 'Connecting…' + : runnerLogsStatus === 'streaming' + ? 'Live' + : runnerLogsStatus === 'error' + ? 'Disconnected' + : 'Idle'} + +
+ {runnerLogsError ? ( + + {runnerLogsError} + + ) : null} +
+                  {runnerLogs ||
+                    (runnerLogsStatus === 'connecting'
+                      ? 'Waiting for log stream…'
+                      : 'No log lines yet. Logs appear while the runner provisions.')}
+                
+
) : null} diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index ec620b396..9bd8e9a1c 100644 --- a/moon/packages/types/generated.ts +++ b/moon/packages/types/generated.ts @@ -36,6 +36,7 @@ export type User = { cover_photo_url: string | null email: string username: string + github_login?: string | null display_name: string system: boolean integration: boolean @@ -2569,6 +2570,7 @@ export type SyncUser = { avatar_urls: AvatarUrls display_name: string username: string + github_login?: string | null email: string integration: boolean notifications_paused: boolean @@ -2769,6 +2771,7 @@ export type CurrentUser = { cover_photo_url: string | null email: string username: string + github_login?: string | null display_name: string onboarded_at: string | null channel_name: string @@ -5022,8 +5025,9 @@ export type FilesChangedPage = { page: CommonPageClFilesChangedItemSchema } -/** Request body for generating `.mega_cedar.json` content from admin usernames. */ +/** Request body for generating `.mega_cedar.json` content from admin GitHub logins. */ export type GenerateCedarRequest = { + /** GitHub login names used as Cedar `User` euids (e.g. `octocat`). */ admins: string[] } @@ -7647,6 +7651,8 @@ export type PostApiOrionRunnersData = CommonResultStartRunnerResponse export type GetApiOrionRunnersByIdData = CommonResultRunnerStatusResponse +export type GetApiOrionRunnersLogsStreamData = any + export type GetApiPermissionsMeByResourceIdData = CommonResultUserEffectivePermissionResponse export type PostApiRepoCloneData = CommonResultString @@ -7927,7 +7933,7 @@ export type GetTargetsByTaskIdV2Error = MessageResponse export type PostTaskV2Data = any -export type GetTaskByClV2Data = OrionTaskDTO +export type GetTaskByClV2Data = OrionTaskDTO[] export type GetTaskByClV2Error = MessageResponse @@ -16261,7 +16267,7 @@ supporting either retrieving the entire log at once or segmenting it by line cou } v1 = { /** - * @description Generate `.mega_cedar.json` content from a list of admin usernames. Only admins can access this endpoint. Does not write to the repository. + * @description Generate `.mega_cedar.json` content from a list of admin GitHub logins. Only admins can access this endpoint. Does not write to the repository. * * @tags User Management * @name PostApiAdminCedarGenerate @@ -18890,6 +18896,29 @@ It's for local testing purposes. } }, + /** + * No description + * + * @tags Automation & Integrations + * @name GetApiOrionRunnersLogsStream + * @summary Proxy live Orion runner / client startup logs from orion-scheduler as SSE. + * @request GET:/api/v1/orion/runners/{id}/logs/stream + */ + getApiOrionRunnersLogsStream: () => { + const base = 'GET:/api/v1/orion/runners/{id}/logs/stream' as const + + return { + baseKey: dataTaggedQueryKey([base]), + requestKey: (id: string) => dataTaggedQueryKey([base, id]), + request: (id: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/orion/runners/${id}/logs/stream`, + method: 'GET', + ...params + }) + } + }, + /** * No description * diff --git a/orion-scheduler/DESIGN.md b/orion-scheduler/DESIGN.md index 9762ca4f4..4b0baa7e9 100644 --- a/orion-scheduler/DESIGN.md +++ b/orion-scheduler/DESIGN.md @@ -364,7 +364,7 @@ flowchart TD "default_image": { "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", - "image_disk_gb": 30, + "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 } @@ -383,7 +383,8 @@ flowchart TD | `default_image` | object | 见模板 | 默认 VM 镜像五参数;webhook 未传 `image_*` 时使用 | | `default_image.image_path` | string | — | 本地 qcow2 路径 | | `default_image.image_digest` | string | — | SHA256 校验和 | -| `default_image.image_disk_gb` | u32 | 30 | 磁盘 GB | +| `default_image.image_disk_gb` | u32 | 50 | 磁盘 GB(长生命周期 VM 建议 ≥50;已部署的 `/etc/.../target_config.json` 需人工改) | + | `default_image.image_cpus` | u32 | 8 | vCPU 数 | | `default_image.image_memory_mb` | u32 | 16000 | 内存 MB | @@ -531,10 +532,23 @@ tracing::error!("[orion-deploy] Orion start failed: {}", error); | `.env.prod` | `/home/orion/orion-runner/.env` | Orion 运行时的环境变量,包括 `SERVER_WS`(WebSocket 服务器地址)、`BUCK_PROJECT_ROOT`(Buck 项目路径)等 | | `run.sh` | `/home/orion/orion-runner/run.sh` | Orion 启动脚本,加载 `.env` 环境变量,执行 `preflight.sh` 前置检查,然后启动 orion 进程 | | `preflight.sh` | `/home/orion/orion-runner/preflight.sh` | 前置检查脚本,验证 FUSE 能力和设备访问权限,确保环境满足 Orion 运行要求 | -| `cleanup.sh` | `/home/orion/orion-runner/cleanup.sh` | 清理脚本,在 Orion 启动前杀死旧进程并卸载 FUSE 文件系统 | +| `cleanup.sh` | `/home/orion/orion-runner/cleanup.sh` | 启动前杀旧进程、卸 FUSE、清理孤儿 Antares upper/cl、轮转 orion.log、磁盘水位告警 | | `orion-runner.service` | `/etc/systemd/system/orion-runner.service` | systemd 服务单元定义,负责配置 Orion 服务的启动参数、运行环境、权限和能力、停止超时等 | | `orion` | `/home/orion/orion-runner/orion` | Orion 主程序二进制文件,Buck 构建任务的 WebSocket 客户端,接收并执行构建任务 | +### 6.1 Guest 磁盘水位(防打满) + +长生命周期 VM 上 Antares `upper/`/`cl/` 与 `orion.log` 会占满默认盘,导致 WS 心跳失败、worker 被标 Lost。 + +| 机制 | 行为 | +|------|------| +| 构建结束 umount | 默认删除对应 `upper_dir` / `cl_dir`(`ORION_RETAIN_ANTARES_MOUNTS=1` 时保留) | +| `cleanup.sh` | 无活跃挂载时 prune 孤儿 overlay;`orion.log` > 200MB 轮转 | +| Orion 接 `TaskBuild` | `df /` ≥ `ORION_DISK_REJECT_PCT`(默认 92)则拒绝任务并尝试 prune;心跳继续 | +| 默认盘 | `image_disk_gb` 默认 **50**(已有 `/etc/orion-scheduler/target_config.json` 需手动改) | + +排障:`GET /scorpio/status?domain=` 响应中的 `disk.df_root` / `disk.du`。 + ## 7. 部署与运行 ### 7.1 资源回收 diff --git a/orion-scheduler/README.md b/orion-scheduler/README.md index 1cb09b4b5..3c3758718 100644 --- a/orion-scheduler/README.md +++ b/orion-scheduler/README.md @@ -40,7 +40,7 @@ flowchart LR "default_image": { "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", - "image_disk_gb": 30, + "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 } diff --git a/orion-scheduler/TESTING.md b/orion-scheduler/TESTING.md index 9c61436b6..faeb5c681 100644 --- a/orion-scheduler/TESTING.md +++ b/orion-scheduler/TESTING.md @@ -33,7 +33,7 @@ curl -i -X POST http://localhost:8080/webhook \ "scorpio_lfs_url": "https://git.gitmega.com", "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", - "image_disk_gb": 30, + "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 }' @@ -248,6 +248,7 @@ aws s3 cp ~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2 \ | QEMU 桥接失败 | `/etc/qemu/bridge.conf` 是否 `allow qlbr0` | | VM 启动超时 | cloud-init、SSH 是否可达 | | Orion 启动失败 | `curl -N '.../logs/orion/stream?domain=...'` | -| Scorpio 挂载问题 | `curl '.../scorpio/status?domain=...'` | +| Scorpio 挂载问题 | `curl '.../scorpio/status?domain=...'`(看 `disk.df_root` / `disk.du`) | +| Guest 磁盘打满 / worker Lost | VM 内 `df -h /`;清 `/data/scorpio/antares/{upper,cl}` 或 `systemctl restart orion-runner`;新盘建议 `image_disk_gb: 50` | | 重启后状态丢了 | 内存 map;磁盘 qemu 靠启动 reap;重新 POST webhook | | 进 VM 调试 | [SSH 进入 VM](#ssh-进入-vm) | diff --git a/orion-scheduler/src/config.rs b/orion-scheduler/src/config.rs index 0768751e9..86996b614 100644 --- a/orion-scheduler/src/config.rs +++ b/orion-scheduler/src/config.rs @@ -32,7 +32,7 @@ impl Default for DefaultImageConfig { .to_string(), image_digest: "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5" .to_string(), - image_disk_gb: 30, + image_disk_gb: 50, image_cpus: 8, image_memory_mb: 16000, } @@ -232,7 +232,7 @@ mod tests { #[test] fn default_image_has_expected_defaults() { let d = DefaultImageConfig::default(); - assert_eq!(d.image_disk_gb, 30); + assert_eq!(d.image_disk_gb, 50); assert_eq!(d.image_cpus, 8); assert_eq!(d.image_memory_mb, 16000); assert!(d.image_digest.starts_with("sha256:")); diff --git a/orion-scheduler/src/orion_deployer.rs b/orion-scheduler/src/orion_deployer.rs index 4d947f462..c94ca4ede 100644 --- a/orion-scheduler/src/orion_deployer.rs +++ b/orion-scheduler/src/orion_deployer.rs @@ -437,9 +437,30 @@ pub async fn get_scorpio_status( let network_test = machine.exec("curl -sI --connect-timeout 5 https://git.gitmega.com 2>&1 | head -5 || echo 'Connection failed'").await?; let network_info = String::from_utf8_lossy(&network_test.stdout); + let df_output = machine + .exec("df -h / 2>/dev/null || echo 'df failed'") + .await?; + let df_info = String::from_utf8_lossy(&df_output.stdout) + .trim() + .to_string(); + + let du_output = machine + .exec( + "du -sh /data/scorpio/store /data/scorpio/antares/upper /data/scorpio/antares/cl \ + /data/scorpio/antares/mnt /home/orion/orion-runner/log 2>/dev/null || true", + ) + .await?; + let du_info = String::from_utf8_lossy(&du_output.stdout) + .trim() + .to_string(); + let status = serde_json::json!({ "status": "ok", "directories": results, + "disk": { + "df_root": df_info, + "du": du_info + }, "mounts": mount_info, "orion_process": orion_info, "scorpio_process": scorpio_info, diff --git a/orion-scheduler/target_config.json.template b/orion-scheduler/target_config.json.template index ae1b306d0..b17532d94 100644 --- a/orion-scheduler/target_config.json.template +++ b/orion-scheduler/target_config.json.template @@ -7,7 +7,7 @@ "default_image": { "image_path": "~/.local/share/qlean/images/debian-13-buck2/debian-13-buck2.qcow2", "image_digest": "sha256:753c28888c9d30fe4baef55c1d1dfa9a39431595eca940b7ad85d78d84f3d7a5", - "image_disk_gb": 30, + "image_disk_gb": 50, "image_cpus": 8, "image_memory_mb": 16000 } diff --git a/orion-server/Dockerfile b/orion-server/Dockerfile index eeb58544d..da6289dec 100644 --- a/orion-server/Dockerfile +++ b/orion-server/Dockerfile @@ -41,6 +41,7 @@ COPY orion/buck/Cargo.toml orion/buck/ COPY orion-scheduler/Cargo.toml orion-scheduler/ COPY orion-server/Cargo.toml orion-server/ COPY clients/orion-client/Cargo.toml clients/orion-client/ +COPY clients/orion-scheduler-client/Cargo.toml clients/orion-scheduler-client/ COPY saturn/Cargo.toml saturn/ COPY vault/Cargo.toml vault/ diff --git a/orion-server/src/api.rs b/orion-server/src/api.rs index 593fbc011..76909a8e1 100644 --- a/orion-server/src/api.rs +++ b/orion-server/src/api.rs @@ -292,16 +292,14 @@ pub async fn build_retry_handler( tag = "Task", params(("cl" = String, Path, description = "Change List")), responses( - (status = 200, description = "Get task successfully", body = OrionTaskDTO), - (status = 400, description = "Multiple tasks", body = MessageResponse), - (status = 404, description = "Not found task", body = MessageResponse), + (status = 200, description = "Get tasks by CL successfully", body = Vec), (status = 500, description = "Database error", body = MessageResponse), ) )] pub async fn task_get_handler( State(state): State, Path(cl): Path, -) -> Result, (StatusCode, Json)> { +) -> Result>, (StatusCode, Json)> { api_v2_service::task_get(&state, &cl).await } diff --git a/orion-server/src/repository/orion_tasks_repo.rs b/orion-server/src/repository/orion_tasks_repo.rs index fd17ac2f5..085e1b7c8 100644 --- a/orion-server/src/repository/orion_tasks_repo.rs +++ b/orion-server/src/repository/orion_tasks_repo.rs @@ -1,7 +1,7 @@ use api_model::buck2::{status::Status, types::ProjectRelativePath}; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, IntoActiveModel, - QueryFilter as _, QuerySelect as _, + QueryFilter as _, QueryOrder as _, QuerySelect as _, }; use serde_json::to_value; use uuid::Uuid; @@ -39,6 +39,7 @@ impl OrionTasksRepo { ) -> Result, DbErr> { callisto::orion_tasks::Entity::find() .filter(callisto::orion_tasks::Column::Cl.eq(cl)) + .order_by_asc(callisto::orion_tasks::Column::CreatedAt) .all(conn) .await } diff --git a/orion-server/src/scheduler.rs b/orion-server/src/scheduler.rs index f751e1f6a..38916cbca 100644 --- a/orion-server/src/scheduler.rs +++ b/orion-server/src/scheduler.rs @@ -678,6 +678,27 @@ impl TaskScheduler { } } + /// Register `build_info` on `chosen_id` and send `TaskBuild`. + /// + /// The `workers` DashMap write guard is dropped before this returns. Callers + /// must not hold that guard across `.await` — doing so can stall the entire + /// tokio runtime when heartbeats / health checks / clients-info contend on + /// the same shard (seen as HTTP hang on `/mega/oc` while the process is up). + pub fn claim_worker_for_build( + &self, + chosen_id: &str, + build_info: BuildInfo, + msg: WSMessage, + ) -> Result<(), String> { + claim_worker_for_build( + &self.workers, + &self.active_builds, + chosen_id, + build_info, + msg, + ) + } + /// Try to dispatch queued task-bound builds (concurrent safe) pub async fn process_pending_tasks(&self) { // Get available workers @@ -794,30 +815,16 @@ impl TaskScheduler { // Register the build *before* sending TaskBuild so early TaskBuildOutput lines // from the worker are not dropped (ws_handler only publishes logs when - // active_builds contains the build_id). - let build_key = pending_build_event.event_payload.build_event_id.to_string(); - if let Some(mut worker) = self.workers.get_mut(&chosen_id) { - self.active_builds.insert(build_key.clone(), build_info); - worker.status = WorkerStatus::Busy { - build_id: build_key.clone(), - phase: None, - }; - if worker.sender.send(msg).is_err() { - self.active_builds.remove(&build_key); - worker.status = WorkerStatus::Idle; - return Err(format!("Failed to send task to worker {chosen_id}")); - } - - tracing::info!( - "Queued task {}/{} dispatched to worker {}", - pending_build_event.event_payload.task_id, - pending_build_event.event_payload.build_event_id, - chosen_id - ); - Ok(()) - } else { - Err(format!("Worker {chosen_id} not found")) - } + // active_builds contains the build_id). Guard is dropped inside the helper + // before any further awaits in the caller. + self.claim_worker_for_build(&chosen_id, build_info, msg)?; + tracing::info!( + "Queued task {}/{} dispatched to worker {}", + pending_build_event.event_payload.task_id, + pending_build_event.event_payload.build_event_id, + chosen_id + ); + Ok(()) } /// Notify about new task or available worker @@ -894,6 +901,35 @@ impl TaskScheduler { } } +/// Register `build_info` on `chosen_id` and send `msg`. +/// +/// Must drop the `workers` DashMap write guard before returning (no `.await` +/// while holding it). See [`TaskScheduler::claim_worker_for_build`]. +pub(crate) fn claim_worker_for_build( + workers: &DashMap, + active_builds: &DashMap, + chosen_id: &str, + build_info: BuildInfo, + msg: WSMessage, +) -> Result<(), String> { + let key = build_info.event_payload.build_event_id.to_string(); + let Some(mut worker) = workers.get_mut(chosen_id) else { + return Err(format!("Worker {chosen_id} not found")); + }; + active_builds.insert(key.clone(), build_info); + worker.status = WorkerStatus::Busy { + build_id: key.clone(), + phase: None, + }; + if worker.sender.send(msg).is_err() { + active_builds.remove(&key); + worker.status = WorkerStatus::Idle; + return Err(format!("Failed to send task to worker {chosen_id}")); + } + Ok(()) + // RefMut dropped here — safe for callers to `.await` afterward. +} + #[cfg(test)] mod tests { use super::*; @@ -972,4 +1008,79 @@ mod tests { // Should fail when full assert!(queue.enqueue_v2(task).is_err()); } + + /// Regression: after claim+send, the workers DashMap shard must be free so + /// concurrent heartbeats / health checks / clients-info cannot block the + /// runtime (the old retry path held `get_mut` across DB `.await`). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn claim_worker_for_build_releases_guard_before_return() { + let workers = Arc::new(DashMap::new()); + let active_builds = Arc::new(DashMap::new()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let worker_id = "worker-1".to_string(); + workers.insert( + worker_id.clone(), + WorkerInfo { + sender: tx, + status: WorkerStatus::Idle, + last_heartbeat: chrono::Utc::now(), + start_time: chrono::Utc::now(), + hostname: "test-host".to_string(), + orion_version: "0.0.0".to_string(), + }, + ); + + let build_event_id = Uuid::now_v7(); + let task_id = Uuid::now_v7(); + let build_info = BuildInfo { + event_payload: BuildEventPayload::new( + build_event_id, + task_id, + "cl".to_string(), + "/repo".to_string(), + 1, + ), + target_id: Uuid::now_v7(), + target_path: "//".to_string(), + changes: vec![], + started_at: chrono::Utc::now(), + worker_id: worker_id.clone(), + }; + let msg = WSMessage::TaskBuild { + build_id: build_event_id.to_string(), + repo: "/repo".to_string(), + cl_link: "cl".to_string(), + changes: vec![], + }; + + claim_worker_for_build(&workers, &active_builds, &worker_id, build_info, msg) + .expect("claim should succeed"); + + // Simulate the DB awaits that follow claim on the retry path. + let workers_for_contended = workers.clone(); + let contended = tokio::spawn(async move { + // Would hang forever if claim still held the shard lock across an await. + let guard = workers_for_contended + .get_mut("worker-1") + .expect("worker must remain reachable"); + matches!( + guard.status, + WorkerStatus::Busy { + build_id: _, + phase: None + } + ) + }); + + let ok = tokio::time::timeout(Duration::from_millis(500), contended) + .await + .expect("workers map must stay accessible after claim returns") + .expect("join"); + assert!(ok, "worker should be Busy after claim"); + assert!(active_builds.contains_key(&build_event_id.to_string())); + assert!(matches!( + rx.try_recv(), + Ok(WSMessage::TaskBuild { build_id, .. }) if build_id == build_event_id.to_string() + )); + } } diff --git a/orion-server/src/service/api_v2_service.rs b/orion-server/src/service/api_v2_service.rs index fe5ad61dc..db4d3d49d 100644 --- a/orion-server/src/service/api_v2_service.rs +++ b/orion-server/src/service/api_v2_service.rs @@ -215,7 +215,7 @@ async fn task_exists_by_id( pub async fn task_get( state: &AppState, cl: &str, -) -> Result, JsonValueErrorResponse> { +) -> Result>, JsonValueErrorResponse> { let tasks = OrionTasksRepo::find_by_cl(&state.conn, cl) .await .map_err(|e| { @@ -223,11 +223,7 @@ pub async fn task_get( value_error(StatusCode::INTERNAL_SERVER_ERROR, "Database error") })?; - match tasks.len() { - 0 => Err(value_error(StatusCode::NOT_FOUND, "Not found task")), - 1 => Ok(Json(OrionTaskDTO::from(&tasks[0]))), - _ => Err(value_error(StatusCode::BAD_REQUEST, "Multiple tasks")), - } + Ok(Json(tasks.iter().map(OrionTaskDTO::from).collect())) } pub async fn build_event_get( @@ -785,30 +781,14 @@ pub async fn build_retry( cl_link, }; - let Some(mut worker) = state.scheduler.workers.get_mut(&chosen_id) else { - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"message": "Worker not found"})), - ) - .into_response(); - }; - let key = new_build_id.to_string(); - state + // Claim + send under DashMap write guard, then drop it BEFORE DB awaits. + // Holding `workers.get_mut` across `.await` can stall the tokio runtime + // (heartbeats / health checks / `/orion-clients-info` block on the shard). + if let Err(e) = state .scheduler - .active_builds - .insert(key.clone(), build_info); - worker.status = WorkerStatus::Busy { - build_id: key.clone(), - phase: None, - }; - if worker.sender.send(msg).is_err() { - state.scheduler.active_builds.remove(&key); - worker.status = WorkerStatus::Idle; - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"message": "Failed to dispatch build retry to worker"})), - ) - .into_response(); + .claim_worker_for_build(&chosen_id, build_info, msg) + { + return (StatusCode::BAD_GATEWAY, Json(json!({"message": e}))).into_response(); } let now_tz = started_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()); @@ -846,24 +826,15 @@ async fn activate_worker( cl_link: build_info.event_payload.cl_link.clone(), }; let key = build_info.event_payload.build_event_id.to_string(); - if let Some(mut worker) = scheduler.workers.get_mut(&build_info.worker_id) { - scheduler - .active_builds - .insert(key.clone(), build_info.clone()); - worker.status = WorkerStatus::Busy { - build_id: key.clone(), - phase: None, + if scheduler + .claim_worker_for_build(&build_info.worker_id, build_info.clone(), msg) + .is_ok() + { + return OrionBuildResult { + build_id: key, + status: "dispatched".to_string(), + message: format!("Build dispatched to worker {}", build_info.worker_id), }; - if worker.sender.send(msg).is_err() { - scheduler.active_builds.remove(&key); - worker.status = WorkerStatus::Idle; - } else { - return OrionBuildResult { - build_id: key, - status: "dispatched".to_string(), - message: format!("Build dispatched to worker {}", build_info.worker_id), - }; - } } scheduler.release_worker(&build_info.worker_id).await; OrionBuildResult { diff --git a/orion/runner-config/cleanup.sh b/orion/runner-config/cleanup.sh index 86c762d26..3768cdf8e 100755 --- a/orion/runner-config/cleanup.sh +++ b/orion/runner-config/cleanup.sh @@ -5,12 +5,18 @@ # # 说明: # - 在服务启动前执行,清理旧进程和 FUSE 挂载 -# - 即使失败也不会阻止服务启动 +# - 清理 Antares 孤儿 upper/cl(无活跃挂载时) +# - 轮转过大的 orion.log +# - 即使失败也不会阻止服务启动(始终 exit 0) # ============================================================================== set +e # 允许命令失败,不中断脚本 SCORPIO_TOML="${SCORPIO_CONFIG:-/home/orion/orion-runner/scorpio.toml}" +ORION_LOG="${ORION_LOG_PATH:-/home/orion/orion-runner/log/orion.log}" +ORION_LOG_MAX_BYTES="${ORION_LOG_MAX_BYTES:-209715200}" # 200 MiB +DISK_WARN_PCT="${ORION_DISK_WARN_PCT:-90}" +DISK_CRIT_PCT="${ORION_DISK_CRIT_PCT:-95}" if [ ! -f "./config.toml" ]; then echo "==> [清理] 未发现 ./config.toml,正在创建..." @@ -26,6 +32,10 @@ read_scorpio_path() { fi } +disk_used_pct() { + df -P / 2>/dev/null | awk 'NR==2 { gsub(/%/,"",$5); print $5 }' +} + echo "==> [清理] 停止旧进程..." if command -v buck2 &>/dev/null; then echo " - 正在执行 'buck2 killall'..." @@ -44,6 +54,12 @@ fi echo "==> [清理] 卸载 FUSE 挂载点..." MOUNT_DIR="$(read_scorpio_path "workspace" "${SCORPIO_TOML}")" MOUNT_DIR="${MOUNT_DIR:-/workspace/mount}" +UPPER_ROOT="$(read_scorpio_path "antares_upper_root" "${SCORPIO_TOML}")" +UPPER_ROOT="${UPPER_ROOT:-/data/scorpio/antares/upper}" +CL_ROOT="$(read_scorpio_path "antares_cl_root" "${SCORPIO_TOML}")" +CL_ROOT="${CL_ROOT:-/data/scorpio/antares/cl}" +MNT_ROOT="$(read_scorpio_path "antares_mount_root" "${SCORPIO_TOML}")" +MNT_ROOT="${MNT_ROOT:-/data/scorpio/antares/mnt}" echo " - 正在卸载 ${MOUNT_DIR}..." fusermount -uz "${MOUNT_DIR}" 2>/dev/null || true @@ -58,5 +74,85 @@ else echo " - ${MOUNT_DIR} 仍是挂载点,跳过删除" fi +echo "==> [清理] Antares mount_root 残留..." +if [ -d "${MNT_ROOT}" ]; then + for mp in "${MNT_ROOT}"/*; do + [ -e "$mp" ] || continue + if mountpoint -q "$mp" 2>/dev/null; then + echo " - fusermount -uz $mp" + fusermount -uz "$mp" 2>/dev/null || true + umount -lf "$mp" 2>/dev/null || true + fi + if ! mountpoint -q "$mp" 2>/dev/null; then + rm -rf "$mp" 2>/dev/null || true + fi + done +fi + +prune_overlay_root() { + local root="$1" + local kind="$2" + local removed=0 + if [ ! -d "$root" ]; then + return 0 + fi + # Only prune when no Antares mounts remain active. + local active=0 + if [ -d "${MNT_ROOT}" ]; then + for mp in "${MNT_ROOT}"/*; do + [ -e "$mp" ] || continue + if mountpoint -q "$mp" 2>/dev/null; then + active=1 + break + fi + done + fi + if [ "$active" -eq 1 ]; then + echo " - 跳过 ${kind} 清理:仍有活跃 Antares 挂载" + return 0 + fi + for d in "${root}"/*; do + [ -d "$d" ] || continue + rm -rf "$d" 2>/dev/null && removed=$((removed + 1)) + done + echo " - 已删除 ${removed} 个孤儿 ${kind} 目录 (${root})" +} + +echo "==> [清理] Antares 孤儿 overlay..." +prune_overlay_root "${UPPER_ROOT}" "upper" +prune_overlay_root "${CL_ROOT}" "cl" + +echo "==> [清理] orion.log 轮转..." +if [ -f "${ORION_LOG}" ]; then + size=$(wc -c < "${ORION_LOG}" 2>/dev/null || echo 0) + if [ "${size}" -gt "${ORION_LOG_MAX_BYTES}" ]; then + echo " - ${ORION_LOG} 为 ${size} bytes (> ${ORION_LOG_MAX_BYTES}), rotating" + mv -f "${ORION_LOG}" "${ORION_LOG}.1" 2>/dev/null || true + : > "${ORION_LOG}" 2>/dev/null || true + # Keep ownership usable for the runner user when present. + chown orion:orion "${ORION_LOG}" 2>/dev/null || true + else + echo " - ${ORION_LOG} size=${size} bytes (ok)" + fi +else + echo " - 无 orion.log" +fi + +USED_PCT="$(disk_used_pct)" +USED_PCT="${USED_PCT:-0}" +echo "==> [清理] 磁盘用量: ${USED_PCT}%" + +if [ "${USED_PCT}" -ge "${DISK_WARN_PCT}" ] 2>/dev/null; then + echo " - 用量 ≥ ${DISK_WARN_PCT}%,再次 prune orphan overlays" + prune_overlay_root "${UPPER_ROOT}" "upper" + prune_overlay_root "${CL_ROOT}" "cl" +fi + +USED_PCT="$(disk_used_pct)" +USED_PCT="${USED_PCT:-0}" +if [ "${USED_PCT}" -ge "${DISK_CRIT_PCT}" ] 2>/dev/null; then + echo "ERROR: disk usage still ${USED_PCT}% (≥ ${DISK_CRIT_PCT}%). Orion will refuse new builds until space is freed." +fi + echo "==> [清理] 完成" exit 0 # 总是返回成功 diff --git a/orion/src/antares.rs b/orion/src/antares.rs index e6cddf107..cbcd12fc2 100644 --- a/orion/src/antares.rs +++ b/orion/src/antares.rs @@ -935,6 +935,128 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to .map_err(Into::into) } + /// Best-effort delete of Antares COW layers left after FUSE unmount. + /// + /// Scorpio's `umount_job` only tears down the mount; `upper/` and `cl/` dirs + /// otherwise accumulate across builds on long-lived VMs and fill the disk. + pub async fn reclaim_overlay_dirs(config: &AntaresConfig) { + best_effort_remove_dir("upper", &config.upper_dir).await; + if let Some(cl_dir) = config.cl_dir.as_ref() { + best_effort_remove_dir("cl", cl_dir).await; + } + } + + async fn best_effort_remove_dir(kind: &str, path: &Path) { + if !path.exists() { + return; + } + match fs::remove_dir_all(path).await { + Ok(()) => tracing::info!( + kind = kind, + path = %path.display(), + "Reclaimed Antares overlay directory" + ), + Err(e) => tracing::warn!( + kind = kind, + path = %path.display(), + error = %e, + "Failed to reclaim Antares overlay directory" + ), + } + } + + /// When no Antares FUSE mounts are active, delete all `upper/` and `cl/` + /// children (they are orphans after umount). Returns `(upper_removed, cl_removed)`. + pub async fn prune_orphan_overlay_dirs() -> Result<(usize, usize), DynError> { + // Ensure scorpio config / paths are available. + let _ = get_manager().await?; + let paths = AntaresPaths::from_global_config(); + + // Best-effort: tear down leftover mountpoints under mount_root. + if let Ok(mut entries) = fs::read_dir(&paths.mount_root).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let mp = entry.path(); + if is_mountpoint(&mp).await { + let _ = Command::new("fusermount") + .args(["-uz", &mp.to_string_lossy()]) + .status() + .await; + let _ = Command::new("umount") + .args(["-lf", &mp.to_string_lossy()]) + .status() + .await; + } + let _ = fs::remove_dir_all(&mp).await; + } + } + + if has_any_mountpoint(&paths.mount_root).await { + tracing::warn!( + "Skipping Antares overlay prune: active mountpoints still present under {}", + paths.mount_root.display() + ); + return Ok((0, 0)); + } + + let upper_removed = prune_all_child_dirs(&paths.upper_root, "upper").await; + let cl_removed = prune_all_child_dirs(&paths.cl_root, "cl").await; + Ok((upper_removed, cl_removed)) + } + + async fn prune_all_child_dirs(root: &Path, kind: &str) -> usize { + let Ok(mut entries) = fs::read_dir(root).await else { + return 0; + }; + let mut removed = 0usize; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); + if !is_dir { + continue; + } + match fs::remove_dir_all(&path).await { + Ok(()) => { + tracing::info!( + kind = kind, + path = %path.display(), + "Pruned orphan Antares overlay directory" + ); + removed += 1; + } + Err(e) => tracing::warn!( + kind = kind, + path = %path.display(), + error = %e, + "Failed to prune orphan Antares overlay directory" + ), + } + } + removed + } + + async fn has_any_mountpoint(root: &Path) -> bool { + let Ok(mut entries) = fs::read_dir(root).await else { + return false; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + if is_mountpoint(&entry.path()).await { + return true; + } + } + false + } + + async fn is_mountpoint(path: &Path) -> bool { + let Ok(output) = Command::new("mountpoint") + .args(["-q", &path.to_string_lossy()]) + .status() + .await + else { + return false; + }; + output.success() + } + /// Convert panics within scorpiofs calls into regular errors. async fn run_with_panic_guard(context: String, future: F) -> Result where @@ -1067,6 +1189,8 @@ mod imp { pub struct AntaresConfig { pub mountpoint: PathBuf, pub job_id: String, + pub upper_dir: PathBuf, + pub cl_dir: Option, } /// Mounting Antares requires `scorpiofs` (Linux-only in this repository). @@ -1088,6 +1212,14 @@ mod imp { ))) } + #[allow(dead_code)] + pub async fn reclaim_overlay_dirs(_config: &AntaresConfig) {} + + #[allow(dead_code)] + pub async fn prune_orphan_overlay_dirs() -> Result<(usize, usize), DynError> { + Ok((0, 0)) + } + #[allow(dead_code)] pub(crate) async fn warmup_dicfuse() -> Result<(), DynError> { Ok(()) diff --git a/orion/src/buck_controller.rs b/orion/src/buck_controller.rs index 5d310158f..556b4fe26 100644 --- a/orion/src/buck_controller.rs +++ b/orion/src/buck_controller.rs @@ -229,19 +229,35 @@ pub async fn mount_antares_fs( Ok((mountpoint, mount_id)) } -/// Unmount an Antares overlay filesystem. +/// Unmount an Antares overlay filesystem and reclaim COW dirs unless retain is set. /// /// # Arguments /// * `mount_id` - The job/mount ID to unmount /// /// # Returns -/// * `Ok(true)` - Unmount succeeded +/// * `Ok(true)` - Unmount succeeded (dirs reclaimed when not retaining) +/// * `Ok(false)` - No mount was found for `mount_id` /// * `Err` - Unmount failed #[allow(dead_code)] pub async fn unmount_antares_fs(mount_id: &str) -> Result> { tracing::info!("Starting unmount for mount_id: {}", mount_id); - crate::antares::unmount_job(mount_id).await?; + let config = crate::antares::unmount_job(mount_id).await?; + let Some(config) = config else { + tracing::warn!("Unmount reported no-op for mount_id: {}", mount_id); + return Ok(false); + }; + + if retain_antares_mounts() { + tracing::info!( + "ORION_RETAIN_ANTARES_MOUNTS set — keeping overlay dirs for mount_id={} upper={} cl={:?}", + mount_id, + config.upper_dir.display(), + config.cl_dir.as_ref().map(|p| p.display().to_string()) + ); + } else { + crate::antares::reclaim_overlay_dirs(&config).await; + } tracing::info!("Unmount succeeded for mount_id: {}", mount_id); Ok(true) diff --git a/orion/src/disk.rs b/orion/src/disk.rs new file mode 100644 index 000000000..006d4b2e2 --- /dev/null +++ b/orion/src/disk.rs @@ -0,0 +1,101 @@ +//! Guest filesystem pressure helpers for long-lived Orion VMs. + +use std::path::Path; + +/// Default warn threshold (percent used). Overridable via `ORION_DISK_WARN_PCT`. +const DEFAULT_WARN_PCT: u64 = 85; +/// Default reject-new-builds threshold. Overridable via `ORION_DISK_REJECT_PCT`. +const DEFAULT_REJECT_PCT: u64 = 92; +/// Default critical threshold (still reject; prefer only heartbeats). `ORION_DISK_CRIT_PCT`. +const DEFAULT_CRIT_PCT: u64 = 98; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiskPressure { + Ok { used_pct: u64 }, + Warn { used_pct: u64 }, + Reject { used_pct: u64 }, + Critical { used_pct: u64 }, +} + +impl DiskPressure { + pub fn used_pct(self) -> u64 { + match self { + Self::Ok { used_pct } + | Self::Warn { used_pct } + | Self::Reject { used_pct } + | Self::Critical { used_pct } => used_pct, + } + } + + pub fn should_reject_builds(self) -> bool { + matches!(self, Self::Reject { .. } | Self::Critical { .. }) + } +} + +fn env_pct(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v.min(100)) + .unwrap_or(default) +} + +/// Return used percentage of the filesystem containing `path` (0–100). +pub fn disk_used_pct(path: impl AsRef) -> Option { + let path = path.as_ref(); + let output = std::process::Command::new("df") + .args(["-P", &path.to_string_lossy()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + // Filesystem 1024-blocks Used Available Capacity Mounted on + let line = stdout.lines().nth(1)?; + let capacity = line.split_whitespace().nth(4)?; + capacity.trim_end_matches('%').parse().ok() +} + +pub fn assess_root_disk() -> DiskPressure { + let used = disk_used_pct("/").unwrap_or(0); + let warn = env_pct("ORION_DISK_WARN_PCT", DEFAULT_WARN_PCT); + let reject = env_pct("ORION_DISK_REJECT_PCT", DEFAULT_REJECT_PCT); + let crit = env_pct("ORION_DISK_CRIT_PCT", DEFAULT_CRIT_PCT); + + if used >= crit { + DiskPressure::Critical { used_pct: used } + } else if used >= reject { + DiskPressure::Reject { used_pct: used } + } else if used >= warn { + DiskPressure::Warn { used_pct: used } + } else { + DiskPressure::Ok { used_pct: used } + } +} + +/// Best-effort orphan Antares overlay prune when under disk pressure. +pub async fn reclaim_under_pressure() { + match crate::antares::prune_orphan_overlay_dirs().await { + Ok((upper, cl)) => { + if upper + cl > 0 { + tracing::warn!( + upper_removed = upper, + cl_removed = cl, + "Pruned Antares overlay dirs due to disk pressure" + ); + } + } + Err(e) => tracing::warn!("Disk-pressure overlay prune failed: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assess_root_disk_returns_a_variant() { + let _ = assess_root_disk(); + } +} diff --git a/orion/src/lib.rs b/orion/src/lib.rs index cea91deb3..b1f212348 100644 --- a/orion/src/lib.rs +++ b/orion/src/lib.rs @@ -1,6 +1,7 @@ mod antares; mod api; mod buck_controller; +mod disk; pub mod repo; mod util; pub mod ws; diff --git a/orion/src/main.rs b/orion/src/main.rs index 1ea72e60e..2347f1d1d 100644 --- a/orion/src/main.rs +++ b/orion/src/main.rs @@ -2,6 +2,7 @@ mod antares; mod api; mod buck_controller; +mod disk; pub mod repo; mod util; mod ws; diff --git a/orion/src/ws.rs b/orion/src/ws.rs index df2904d2f..ebce6e57d 100644 --- a/orion/src/ws.rs +++ b/orion/src/ws.rs @@ -376,6 +376,43 @@ async fn process_server_message( changes, } => { tracing::info!("Received task: id={}", build_id); + + let pressure = crate::disk::assess_root_disk(); + if pressure.should_reject_builds() { + tracing::error!( + used_pct = pressure.used_pct(), + "Rejecting task {} due to disk pressure", + build_id + ); + crate::disk::reclaim_under_pressure().await; + // Re-check after prune — may have recovered. + let after = crate::disk::assess_root_disk(); + if after.should_reject_builds() { + if let Err(e) = sender.send(WSMessage::TaskAck { + build_id, + success: false, + message: format!( + "disk pressure: root filesystem {}% used (threshold {}%). Free space under /data/scorpio or restart orion-runner after cleanup.", + after.used_pct(), + std::env::var("ORION_DISK_REJECT_PCT").unwrap_or_else(|_| "92".into()) + ), + }) { + tracing::error!("Failed to send TaskAck: {}", e); + } + return ControlFlow::Continue(()); + } + tracing::warn!( + used_pct = after.used_pct(), + "Disk pressure cleared after prune; accepting task" + ); + } else if matches!(pressure, crate::disk::DiskPressure::Warn { .. }) { + tracing::warn!( + used_pct = pressure.used_pct(), + "Disk usage elevated before accepting task {}", + build_id + ); + } + tokio::spawn(async move { let task_id_uuid = match Uuid::parse_str(&build_id) { Ok(uuid) => uuid, diff --git a/saturn/mega.cedarschema b/saturn/mega.cedarschema index a450680ee..5efefe051 100644 --- a/saturn/mega.cedarschema +++ b/saturn/mega.cedarschema @@ -8,7 +8,7 @@ entity Repository = { "readers": UserGroup, }; -entity MergeRequest = { +entity ChangeList = { "repo": Repository, }; @@ -21,7 +21,7 @@ action "deleteRepo", "viewRepo", "forkRepo", "pullRepo", "pushRepo" appliesTo { resource: [Repository], }; -action "createMergeRequest", "editMergeRequest", "deleteMergeRequest", "approveMergeRequest" appliesTo { +action "createChangeList", "editChangeList", "deleteChangeList", "approveChangeList" appliesTo { principal: [User], resource: [Repository], }; @@ -34,4 +34,4 @@ action "openIssue", "assignIssue", "deleteIssue", "editIssue" appliesTo { action "addMaintainer", "addAdmin" appliesTo { principal: [User], resource: [Repository], -}; \ No newline at end of file +}; diff --git a/saturn/mega_policies.cedar b/saturn/mega_policies.cedar index bb69a2c60..9aa32ff07 100644 --- a/saturn/mega_policies.cedar +++ b/saturn/mega_policies.cedar @@ -7,7 +7,7 @@ permit ( Action::"forkRepo", Action::"pushRepo", Action::"openIssue", - Action::"createMergeRequest"], + Action::"createChangeList"], resource ) unless { resource.is_private }; @@ -21,7 +21,7 @@ permit ( Action::"forkRepo", Action::"pushRepo", Action::"openIssue", - Action::"createMergeRequest"], + Action::"createChangeList"], resource ) when { principal in resource.readers }; @@ -31,9 +31,9 @@ permit ( principal, action in [Action::"editIssue", - Action::"editMergeRequest", + Action::"editChangeList", Action::"assignIssue", - Action::"approveMergeRequest"], + Action::"approveChangeList"], resource ) when { principal in resource.maintainers }; @@ -46,11 +46,11 @@ permit ( Action::"addAdmin", Action::"deleteRepo", Action::"deleteIssue", - Action::"deleteMergeRequest"], + Action::"deleteChangeList"], resource ) when { principal in resource.admins }; // root admin can do anything permit (principal, action, resource) -when { principal == User::"genedna" || principal == User::"benjamin-747" }; \ No newline at end of file +when { principal == User::"genedna" || principal == User::"benjamin-747" }; diff --git a/saturn/src/entitystore.rs b/saturn/src/entitystore.rs index 72e513fb7..70327d34c 100644 --- a/saturn/src/entitystore.rs +++ b/saturn/src/entitystore.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, to_string_pretty}; use crate::{ - objects::{Issue, MergeRequest, Repo, User, UserGroup}, + objects::{ChangeList, Issue, Repo, User, UserGroup}, util::SaturnEUid, }; @@ -14,7 +14,8 @@ use crate::{ pub struct EntityStore { users: HashMap, repos: HashMap, - merge_requests: HashMap, + #[serde(alias = "merge_requests")] + change_lists: HashMap, issues: HashMap, user_groups: HashMap, } @@ -24,7 +25,7 @@ impl EntityStore { Self { users: HashMap::new(), repos: HashMap::new(), - merge_requests: HashMap::new(), + change_lists: HashMap::new(), issues: HashMap::new(), user_groups: HashMap::new(), } @@ -33,13 +34,13 @@ impl EntityStore { pub fn as_entities(&self, schema: &Schema) -> Entities { let users = self.users.values().map(|user| user.clone().into()); let repos = self.repos.values().map(|repo| repo.clone().into()); - let merge_requests = self.merge_requests.values().map(|user| user.clone().into()); + let change_lists = self.change_lists.values().map(|cl| cl.clone().into()); let issues = self.issues.values().map(|repo| repo.clone().into()); let user_groups = self.user_groups.values().map(|group| group.clone().into()); let all = users .chain(repos) .chain(user_groups) - .chain(merge_requests) + .chain(change_lists) .chain(issues); Entities::from_entities(all, Some(schema)).unwrap() } @@ -47,7 +48,7 @@ impl EntityStore { pub fn merge(&mut self, other: EntityStore) { self.users.extend(other.users); self.repos.extend(other.repos); - self.merge_requests.extend(other.merge_requests); + self.change_lists.extend(other.change_lists); self.issues.extend(other.issues); self.user_groups.extend(other.user_groups); } @@ -94,7 +95,7 @@ pub fn generate_entity( "parents": [] } }, - "merge_requests": { + "change_lists": { }, "issues": { } diff --git a/saturn/src/lib.rs b/saturn/src/lib.rs index 58c02d986..8eb896810 100644 --- a/saturn/src/lib.rs +++ b/saturn/src/lib.rs @@ -17,17 +17,17 @@ pub enum ActionEnum { // PushRepo, // OpenIssue, // ** Maintainer - CreateMergeRequest, + CreateChangeList, EditIssue, - EditMergeRequest, + EditChangeList, AssignIssue, - ApproveMergeRequest, + ApproveChangeList, // ** Admin AddMaintainer, AddAdmin, DeleteRepo, DeleteIssue, - DeleteMergeRequest, + DeleteChangeList, } impl Display for ActionEnum { @@ -35,16 +35,16 @@ impl Display for ActionEnum { let s = match self { ActionEnum::UnprotectedRequest => "unprotectedRequest", ActionEnum::ViewRepo => "viewRepo", - ActionEnum::CreateMergeRequest => "createMergeRequest", + ActionEnum::CreateChangeList => "createChangeList", ActionEnum::EditIssue => "editIssue", - ActionEnum::EditMergeRequest => "editMergeRequest", + ActionEnum::EditChangeList => "editChangeList", ActionEnum::AssignIssue => "assignIssue", - ActionEnum::ApproveMergeRequest => "approveMergeRequest", + ActionEnum::ApproveChangeList => "approveChangeList", ActionEnum::AddMaintainer => "addMaintainer", ActionEnum::AddAdmin => "addAdmin", ActionEnum::DeleteRepo => "deleteRepo", ActionEnum::DeleteIssue => "deleteIssue", - ActionEnum::DeleteMergeRequest => "deleteMergeRequest", + ActionEnum::DeleteChangeList => "deleteChangeList", }; write!(f, "{s}") } @@ -54,16 +54,16 @@ impl From<&str> for ActionEnum { fn from(s: &str) -> Self { match s { "viewRepo" => ActionEnum::ViewRepo, - "createMergeRequest" => ActionEnum::CreateMergeRequest, + "createChangeList" | "createMergeRequest" => ActionEnum::CreateChangeList, "editIssue" => ActionEnum::EditIssue, - "editMergeRequest" => ActionEnum::EditMergeRequest, + "editChangeList" | "editMergeRequest" => ActionEnum::EditChangeList, "assignIssue" => ActionEnum::AssignIssue, - "approveMergeRequest" => ActionEnum::ApproveMergeRequest, + "approveChangeList" | "approveMergeRequest" => ActionEnum::ApproveChangeList, "addMaintainer" => ActionEnum::AddMaintainer, "addAdmin" => ActionEnum::AddAdmin, "deleteRepo" => ActionEnum::DeleteRepo, "deleteIssue" => ActionEnum::DeleteIssue, - "deleteMergeRequest" => ActionEnum::DeleteMergeRequest, + "deleteChangeList" | "deleteMergeRequest" => ActionEnum::DeleteChangeList, _ => ActionEnum::UnprotectedRequest, } } @@ -197,7 +197,7 @@ mod test { app_context .is_authorized( &anyone, - r#"Action::"approveMergeRequest""#.parse::().unwrap(), + r#"Action::"approveChangeList""#.parse::().unwrap(), &resource, Context::empty() ) @@ -208,7 +208,7 @@ mod test { app_context .is_authorized( &maintainer, - r#"Action::"approveMergeRequest""#.parse::().unwrap(), + r#"Action::"approveChangeList""#.parse::().unwrap(), &resource, Context::empty() ) diff --git a/saturn/src/objects.rs b/saturn/src/objects.rs index 95c46cb84..a297f55f2 100644 --- a/saturn/src/objects.rs +++ b/saturn/src/objects.rs @@ -88,14 +88,14 @@ impl From for Entity { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MergeRequest { +pub struct ChangeList { euid: SaturnEUid, repo: SaturnEUid, parents: HashSet, } -impl From for Entity { - fn from(value: MergeRequest) -> Entity { +impl From for Entity { + fn from(value: ChangeList) -> Entity { let attrs = [("repo", format!("{}", value.repo.as_ref()).parse().unwrap())] .into_iter() .map(|(x, v)| (x.into(), v)) diff --git a/saturn/test/project/.mega.json b/saturn/test/project/.mega.json index ebe8ab730..30d6c2fc3 100644 --- a/saturn/test/project/.mega.json +++ b/saturn/test/project/.mega.json @@ -47,7 +47,7 @@ "parents": [] } }, - "merge_requests": { + "change_lists": { }, "issues": { diff --git a/saturn/test/project/private/.mega.json b/saturn/test/project/private/.mega.json index a91a1dcc1..b8b4b0b59 100644 --- a/saturn/test/project/private/.mega.json +++ b/saturn/test/project/private/.mega.json @@ -35,7 +35,7 @@ "parents": [] } }, - "merge_requests": { + "change_lists": { }, "issues": {