diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 070ec9c..84616b5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -111,6 +111,44 @@ jobs: ${{ matrix.binary }}-${{ matrix.arch }}.sha256 retention-days: 7 + build-frontend: + name: Build frontend + runs-on: ubuntu-latest + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: app/package-lock.json + + - name: Install dependencies + working-directory: app + run: npm ci + + - name: Build + working-directory: app + env: + VITE_API_URL: "" + run: npm run build + + - name: Archive + run: tar -czf csfx-frontend.tar.gz -C app/build . + + - name: Checksum + run: sha256sum csfx-frontend.tar.gz > csfx-frontend.tar.gz.sha256 + + - uses: actions/upload-artifact@v4 + with: + name: csfx-frontend + path: | + csfx-frontend.tar.gz + csfx-frontend.tar.gz.sha256 + retention-days: 7 + build-cp-binaries: name: Build CP ${{ matrix.binary }} (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} @@ -184,7 +222,7 @@ jobs: attach-binaries-release: name: Attach binaries to release runs-on: ubuntu-latest - needs: [prepare, build-binaries, build-cp-binaries] + needs: [prepare, build-binaries, build-cp-binaries, build-frontend] steps: - uses: actions/checkout@v4 @@ -214,6 +252,8 @@ jobs: csfx-agent-amd64.sha256 \ csfx-agent-arm64 \ csfx-agent-arm64.sha256 \ + csfx-frontend.tar.gz \ + csfx-frontend.tar.gz.sha256 \ ${CP_BINS} \ --clobber env: @@ -222,7 +262,7 @@ jobs: update-infra: name: Update CSFX-Infra versions.nix runs-on: ubuntu-latest - needs: [prepare, build-binaries, build-cp-binaries, attach-binaries-release] + needs: [prepare, build-binaries, build-cp-binaries, build-frontend, attach-binaries-release] steps: - uses: actions/checkout@v4 with: @@ -248,6 +288,11 @@ jobs: path: /tmp/cp-binaries merge-multiple: true + - uses: actions/download-artifact@v4 + with: + name: csfx-frontend + path: /tmp/frontend + - name: Write versions.nix run: | VERSION="${{ needs.prepare.outputs.version }}" @@ -264,6 +309,8 @@ jobs: awk '{print $1}' "/tmp/cp-binaries/${file}.sha256" 2>/dev/null } + FRONTEND_SHA256=$(awk '{print $1}' /tmp/frontend/csfx-frontend.tar.gz.sha256 2>/dev/null) + cat > infra/versions.nix < { + const res = await fetch(`${API_BASE}/system/update/status`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`update status fetch failed: ${res.status}`); + return res.json(); +} + +export async function triggerUpdate(token: string, version: string): Promise { + const res = await fetch(`${API_BASE}/system/update`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ version }), + }); + if (!res.ok) throw new Error(`trigger update failed: ${res.status}`); +} + +export async function pauseUpdate(token: string): Promise { + const res = await fetch(`${API_BASE}/system/update/pause`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`pause update failed: ${res.status}`); +} + +export async function resumeUpdate(token: string): Promise { + const res = await fetch(`${API_BASE}/system/update/resume`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`resume update failed: ${res.status}`); +} + +export interface ReleaseEntry { + version: string; + tag: string; + prerelease: boolean; + html_url: string; + name: string | null; + is_current: boolean; + is_newer: boolean; +} + +export interface ReleasesResponse { + current_version: string; + update_available: boolean; + latest_stable: string | null; + releases: ReleaseEntry[]; +} + +export async function getReleases(token: string, includePre = false): Promise { + const url = `${API_BASE}/system/releases${includePre ? '?include_pre=true' : ''}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`releases fetch failed: ${res.status}`); + return res.json(); +} diff --git a/app/src/lib/components/settings/update-settings.svelte b/app/src/lib/components/settings/update-settings.svelte new file mode 100644 index 0000000..c4df2fa --- /dev/null +++ b/app/src/lib/components/settings/update-settings.svelte @@ -0,0 +1,299 @@ + + +{#if error} +
+ {error} +
+{/if} + +
+ + +
+
+ System version + Current installation and update state +
+ {#if status} + + {status.paused ? "paused" : "active"} + + {/if} +
+
+ + {#if statusLoading} +
+ {#each [1, 2] as _} +
+ {/each} +
+ {:else if status} +
+
+

Installed

+

{status.current_version}

+
+ + {#if status.desired_version && status.desired_version !== status.current_version} +
+

Scheduled

+

{status.desired_version}

+
+ {:else} +
+

Target

+

up to date

+
+ {/if} + + {#if status.build_status} +
+
+

Build status

+ {#if isActive(status.build_status)} + + + running + + {/if} +
+

+ {buildStatusLabel(status.build_status)} +

+
+ {/if} + + {#if status.last_result} +
+

Last result

+

+ {buildStatusLabel(status.last_result)} +

+
+ {/if} +
+ {/if} +
+ {#if status} + +

+ {status.paused ? "Updates are paused. No automatic upgrades will run." : "Updates are enabled. The system checks for new releases automatically."} +

+ {#if !status.paused} + + {:else} + + {/if} +
+ {/if} +
+ + + +
+
+ Available releases + Select a version to update or downgrade +
+ +
+
+ + {#if releasesLoading} +
+ {#each [1, 2, 3] as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if releases && releases.releases.length > 0} +
+ {#each releases.releases as release, i (release.tag)} +
+
+
+ + {release.version} + + {#if release.is_current} + + installed + + {:else if release.is_newer} + + newer + + {/if} + {#if release.prerelease} + + pre-release + + {/if} +
+ {#if release.name} + {release.name} + {/if} +
+ {#if !release.is_current} + + {:else} + current + {/if} +
+ {/each} +
+ {:else} +
+

No releases found

+
+ {/if} +
+
+
diff --git a/app/src/lib/components/sidebar/app-sidebar.svelte b/app/src/lib/components/sidebar/app-sidebar.svelte index 3844484..f46ba23 100644 --- a/app/src/lib/components/sidebar/app-sidebar.svelte +++ b/app/src/lib/components/sidebar/app-sidebar.svelte @@ -3,13 +3,20 @@ import ChartPieIcon from "@lucide/svelte/icons/chart-pie"; import MapIcon from "@lucide/svelte/icons/map"; import BookOpenIcon from "@lucide/svelte/icons/book-open"; - import { Server, Layers, ShipWheel, Container, LaptopMinimal } from "@lucide/svelte"; + import { Server, Layers, ShipWheel, Container, LaptopMinimal, Settings } from "@lucide/svelte"; import IconBucket from "./icon-bucket.svelte"; import IconDashboard from "./icon-dashboard.svelte"; import IconActivity from "./icon-activity.svelte"; import IconLogs from "./icon-logs.svelte"; const data = { + navAdmin: [ + { + title: "Settings", + url: "/admin/settings", + icon: Settings, + }, + ], navBottom: [ { title: "Monitoring", @@ -66,11 +73,15 @@ @@ -97,9 +119,9 @@ class="flex flex-col leading-tight group-data-[collapsible=icon]:hidden" > CSFX - Hypervisor v0.1 + + {version ? `v${version}` : "Hypervisor"} + @@ -108,7 +130,10 @@ + + + diff --git a/app/src/lib/components/sidebar/update-card.svelte b/app/src/lib/components/sidebar/update-card.svelte new file mode 100644 index 0000000..bbfb412 --- /dev/null +++ b/app/src/lib/components/sidebar/update-card.svelte @@ -0,0 +1,34 @@ + + +{#if latestStable} + +{/if} diff --git a/app/src/routes/(app)/admin/settings/+page.svelte b/app/src/routes/(app)/admin/settings/+page.svelte new file mode 100644 index 0000000..88017e9 --- /dev/null +++ b/app/src/routes/(app)/admin/settings/+page.svelte @@ -0,0 +1,43 @@ + + +
+ + Admin / Settings +
+ +
+

Settings

+ +
+ + +
+ +
+ {#if activeTab === "general"} +

No general settings configured.

+ {:else if activeTab === "update"} + + {/if} +
+
diff --git a/app/src/routes/(app)/system/update/+page.svelte b/app/src/routes/(app)/system/update/+page.svelte new file mode 100644 index 0000000..de42484 --- /dev/null +++ b/app/src/routes/(app)/system/update/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/app/svelte.config.js b/app/svelte.config.js index ba04497..ed778a4 100644 --- a/app/svelte.config.js +++ b/app/svelte.config.js @@ -1,14 +1,19 @@ -import adapter from "@sveltejs/adapter-auto"; +import adapter from "@sveltejs/adapter-static"; /** @type {import('@sveltejs/kit').Config} */ const config = { compilerOptions: { - // Force runes mode for the project, except for libraries. Can be removed in svelte 6. runes: ({ filename }) => filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, kit: { - adapter: adapter(), + adapter: adapter({ + pages: "build", + assets: "build", + fallback: "index.html", + precompress: false, + strict: false, + }), alias: { $components: "src/components", $lib: "src/lib", diff --git a/control-plane/api-gateway/src/routes/mod.rs b/control-plane/api-gateway/src/routes/mod.rs index b8e0cad..ab9a394 100644 --- a/control-plane/api-gateway/src/routes/mod.rs +++ b/control-plane/api-gateway/src/routes/mod.rs @@ -10,6 +10,7 @@ use axum::Router; use std::sync::Arc; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::cors::CorsLayer; +use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::{info_span, Span}; @@ -18,6 +19,7 @@ pub mod events; pub mod networks; pub mod organizations; pub mod registry; +pub mod releases; pub mod ssh_keys; pub mod system; pub mod update; @@ -98,12 +100,20 @@ pub fn create_router() -> Router { let api_router = Router::new() .merge(rate_limited_router) - .merge(update::routes()); + .merge(update::routes()) + .merge(releases::routes()); + + let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "app/build".to_string()); + let index_path = format!("{}/index.html", static_dir); + + let serve_dir = ServeDir::new(&static_dir) + .not_found_service(ServeFile::new(&index_path)); Router::new() .route("/metrics", get(metrics::metrics_handler)) .logged_nest("/api", api_router) .logged_nest("/api", internal_api_router) + .fallback_service(serve_dir) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { diff --git a/control-plane/api-gateway/src/routes/releases.rs b/control-plane/api-gateway/src/routes/releases.rs new file mode 100644 index 0000000..1f6e2dc --- /dev/null +++ b/control-plane/api-gateway/src/routes/releases.rs @@ -0,0 +1,131 @@ +use axum::{extract::Query, http::StatusCode, response::Json, routing::get, Router}; +use serde::{Deserialize, Serialize}; + +use crate::auth::rbac::CanManageSystem; +use crate::AppState; + +const RELEASES_API: &str = "https://api.github.com/repos/CSFX-cloud/CSF-Core/releases"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + html_url: String, + name: Option, +} + +#[derive(Debug, Serialize)] +pub struct ReleaseEntry { + pub version: String, + pub tag: String, + pub prerelease: bool, + pub html_url: String, + pub name: Option, + pub is_current: bool, + pub is_newer: bool, +} + +#[derive(Debug, Serialize)] +pub struct ReleasesResponse { + pub current_version: String, + pub update_available: bool, + pub latest_stable: Option, + pub releases: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ReleasesQuery { + #[serde(default)] + pub include_pre: bool, +} + +pub fn routes() -> Router { + Router::new().route("/system/releases", get(list_releases)) +} + +async fn list_releases( + _auth: CanManageSystem, + Query(query): Query, +) -> Result, StatusCode> { + let github_releases = fetch_github_releases().await?; + + let releases: Vec = github_releases + .iter() + .filter(|r| query.include_pre || !r.prerelease) + .take(10) + .map(|r| { + let version = r.tag_name.trim_start_matches('v').to_string(); + let is_current = version == CURRENT_VERSION; + let is_newer = semver_is_newer(&version, CURRENT_VERSION); + ReleaseEntry { + version, + tag: r.tag_name.clone(), + prerelease: r.prerelease, + html_url: r.html_url.clone(), + name: r.name.clone(), + is_current, + is_newer, + } + }) + .collect(); + + let latest_stable = releases + .iter() + .find(|r| !r.prerelease && r.is_newer) + .map(|r| r.version.clone()); + + let update_available = latest_stable.is_some(); + + Ok(Json(ReleasesResponse { + current_version: CURRENT_VERSION.to_string(), + update_available, + latest_stable, + releases, + })) +} + +async fn fetch_github_releases() -> Result, StatusCode> { + let client = reqwest::Client::new(); + let resp = client + .get(RELEASES_API) + .header("User-Agent", "csfx-api-gateway") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + tracing::error!(error = %e, "github releases fetch failed"); + StatusCode::BAD_GATEWAY + })?; + + if !resp.status().is_success() { + tracing::error!(status = %resp.status(), "github api returned error"); + return Err(StatusCode::BAD_GATEWAY); + } + + resp.json::>().await.map_err(|e| { + tracing::error!(error = %e, "failed to deserialize github releases"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +fn semver_is_newer(candidate: &str, current: &str) -> bool { + parse_semver(candidate) + .zip(parse_semver(current)) + .map(|(c, cur)| c > cur) + .unwrap_or(false) +} + +fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { + let v = v.trim_start_matches('v'); + let base = v.split('-').next().unwrap_or(v); + let parts: Vec<&str> = base.split('.').collect(); + if parts.len() != 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].parse().ok()?, + )) +} diff --git a/renovate.json b/renovate.json index 0c0983f..a4493f3 100644 --- a/renovate.json +++ b/renovate.json @@ -23,14 +23,9 @@ "matchUpdateTypes": ["minor", "patch"] }, { - "matchManagers": ["dockerfile"], + "matchManagers": ["dockerfile", "docker-compose"], "groupName": "Docker base images", - "automerge": false - }, - { - "matchDepNames": ["postgres"], - "groupName": "PostgreSQL", - "automerge": false + "enabled": false }, { "matchDepNames": ["rust"],