diff --git a/.github/scripts/audit-urls.py b/.github/scripts/audit-urls.py index 37cacc5..f516433 100644 --- a/.github/scripts/audit-urls.py +++ b/.github/scripts/audit-urls.py @@ -33,8 +33,6 @@ # Only files that perform outbound binary downloads. # Adding a new download path outside these files requires a conscious update here. SCAN_FILES = [ - "lynx/agent/src/update/mod.rs", - "lynx/agent/src/update/fallback.rs", "lynx/dashboard/server/src/update.rs", "lynx/dashboard/server/src/scheduler.rs", ] diff --git a/.github/workflows/dashboard-server.yml b/.github/workflows/dashboard-server.yml index ec3ee75..017b19c 100644 --- a/.github/workflows/dashboard-server.yml +++ b/.github/workflows/dashboard-server.yml @@ -35,10 +35,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: rustfmt + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable - name: fmt run: cargo fmt --package lynx-dashboard-server -- --check @@ -61,9 +61,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable - name: Install cargo-audit run: cargo install cargo-audit --locked @@ -94,14 +95,20 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: clippy + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - workspaces: lynx + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + lynx/target + key: rust-${{ github.job }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: rust-${{ github.job }}-${{ runner.os }}- - name: Install sqlx-cli run: cargo install sqlx-cli --no-default-features --features postgres --locked @@ -147,13 +154,20 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - workspaces: lynx + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + lynx/target + key: rust-${{ github.job }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: rust-${{ github.job }}-${{ runner.os }}- - name: Install sqlx-cli run: cargo install sqlx-cli --no-default-features --features postgres --locked diff --git a/.github/workflows/dashboard-ui.yml b/.github/workflows/dashboard-ui.yml index 1e1c51d..9cc243d 100644 --- a/.github/workflows/dashboard-ui.yml +++ b/.github/workflows/dashboard-ui.yml @@ -26,9 +26,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install dependencies run: bun install --frozen-lockfile @@ -45,9 +46,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install dependencies run: bun install --frozen-lockfile @@ -64,9 +66,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install dependencies run: bun install --frozen-lockfile @@ -83,9 +86,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-dashboard.yml b/.github/workflows/release-dashboard.yml index 901062b..c82b30f 100644 --- a/.github/workflows/release-dashboard.yml +++ b/.github/workflows/release-dashboard.yml @@ -40,13 +40,20 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - workspaces: lynx + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + lynx/target + key: rust-${{ github.job }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: rust-${{ github.job }}-${{ runner.os }}- - name: Install sqlx-cli run: cargo install sqlx-cli --no-default-features --features postgres --locked @@ -103,14 +110,20 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: rustfmt, clippy + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - workspaces: lynx + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + lynx/target + key: rust-${{ github.job }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: rust-${{ github.job }}-${{ runner.os }}- - name: Install sqlx-cli run: cargo install sqlx-cli --no-default-features --features postgres --locked @@ -144,9 +157,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install dependencies run: bun install --frozen-lockfile @@ -188,14 +202,21 @@ jobs: # --- Rust backend ------------------------------------------------------- - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - targets: ${{ matrix.rust-target }} + - name: Install toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add ${{ matrix.rust-target }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: - workspaces: lynx + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + lynx/target + key: rust-${{ github.job }}-${{ matrix.arch }}-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: rust-${{ github.job }}-${{ matrix.arch }}-${{ runner.os }}- - name: Install musl toolchain (x86_64) if: ${{ !matrix.use_cross }} @@ -217,12 +238,10 @@ jobs: if [ "${{ matrix.use_cross }}" = "true" ]; then cross build --release \ --package lynx-dashboard-server \ - --package lynx-compose \ --target ${{ matrix.rust-target }} else cargo build --release \ --package lynx-dashboard-server \ - --package lynx-compose \ --target ${{ matrix.rust-target }} fi env: @@ -239,22 +258,12 @@ jobs: cp "$BINARY" "$ARTIFACT" python3 .github/scripts/sign.py "$RELEASE_SIGN_KEY" "$ARTIFACT" - - name: Sign lynx-compose binary - working-directory: ${{ github.workspace }} - env: - RELEASE_SIGN_KEY: ${{ secrets.RELEASE_SIGN_KEY }} - run: | - BINARY="lynx/target/${{ matrix.rust-target }}/release/lynx-compose" - ARTIFACT="lynx-compose-linux-${{ matrix.arch }}" - cp "$BINARY" "$ARTIFACT" - python3 .github/scripts/sign.py "$RELEASE_SIGN_KEY" "$ARTIFACT" - # --- Frontend ----------------------------------------------------------- - name: Install bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: latest + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" - name: Install frontend deps working-directory: lynx/dashboard/ui @@ -338,8 +347,6 @@ jobs: lynx-dashboard-frontend-linux-${{ matrix.arch }}.sig lynx-dashboard-frontend-assets-linux-${{ matrix.arch }}.tar.gz lynx-dashboard-frontend-assets-linux-${{ matrix.arch }}.tar.gz.sig - lynx-compose-linux-${{ matrix.arch }} - lynx-compose-linux-${{ matrix.arch }}.sig retention-days: 1 release: @@ -372,8 +379,4 @@ jobs: lynx-dashboard-frontend-linux-arm64 \ lynx-dashboard-frontend-linux-arm64.sig \ "lynx-dashboard-frontend-assets-linux-arm64.tar.gz" \ - "lynx-dashboard-frontend-assets-linux-arm64.tar.gz.sig" \ - lynx-compose-linux-x86_64 \ - lynx-compose-linux-x86_64.sig \ - lynx-compose-linux-arm64 \ - lynx-compose-linux-arm64.sig + "lynx-dashboard-frontend-assets-linux-arm64.tar.gz.sig" diff --git a/README.md b/README.md index 1714ccc..d2c7526 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@
- [![CI — Agent](https://github.com/Jaro-c/Lynx/actions/workflows/agent.yml/badge.svg)](https://github.com/Jaro-c/Lynx/actions/workflows/agent.yml) - [![CI — Dashboard](https://github.com/Jaro-c/Lynx/actions/workflows/dashboard-server.yml/badge.svg)](https://github.com/Jaro-c/Lynx/actions/workflows/dashboard-server.yml) + [![CI — Dashboard](https://github.com/Glyndor/panel/actions/workflows/dashboard-server.yml/badge.svg)](https://github.com/Glyndor/panel/actions/workflows/dashboard-server.yml) ![Rust](https://img.shields.io/badge/Agent-Rust-orange?logo=rust) ![Next.js](https://img.shields.io/badge/Dashboard-Next.js-black?logo=next.js) @@ -204,7 +203,7 @@ bun dev The agent and the compose translator live in [panel-agent](https://github.com/Glyndor/panel-agent) and -[podman-compose](https://github.com/Glyndor/podman-compose). +[podup](https://github.com/Glyndor/podup).
VM test matrix diff --git a/docs/index.html b/docs/index.html index c905559..47b2135 100644 --- a/docs/index.html +++ b/docs/index.html @@ -76,9 +76,9 @@
⬡ LYNX @@ -108,13 +108,13 @@

@@ -246,7 +246,7 @@

Install the dashboard

Connect remote VPS nodes

Generate WireGuard keys in the dashboard UI. Run the agent install script on each remote VPS — it asks for those keys and sets up the encrypted tunnel automatically.

- $ curl -sSL https://github.com/Jaro-c/Lynx/releases/latest/download/install-agent.sh | bash + $ curl -sSL https://raw.githubusercontent.com/Glyndor/panel/main/install.sh | sudo bash
@@ -287,7 +287,7 @@

Deploy and manage

Own your infrastructure.

No cloud accounts. No vendor lock-in. No monthly subscriptions.
Just your servers, under your control.

-
+ @@ -304,7 +304,7 @@

Own your infrastructure.

Made with ❤️ by Jaroc · - Open source on GitHub + Open source on GitHub

diff --git a/docs/security-architecture.md b/docs/security-architecture.md index d2666b8..55eb81f 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -41,5 +41,5 @@ release is published and deployed automatically. | Component | Support | |-----------|---------| | `dashboard@latest` | ✅ Supported | -| `agent@latest` ([panel-agent](https://github.com/Glyndor/panel-agent)) | ✅ Supported | +| `panel-agent` latest ([releases](https://github.com/Glyndor/panel-agent/releases)) | ✅ Supported | | Older versions | ❌ No patches — update via auto-update | diff --git a/install.sh b/install.sh index 14e4e89..3c3211b 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ # Supports Dashboard and Agent installation. # # Usage: -# curl -fsSL https://raw.githubusercontent.com/Jaro-c/Lynx/main/install.sh | sudo bash +# curl -fsSL https://raw.githubusercontent.com/Glyndor/panel/main/install.sh | sudo bash # sudo bash install.sh # # Requirements: @@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ----------------------------------------------------------------------------- if [[ "$EUID" -ne 0 ]]; then echo -e "${RED}Error: this script must be run as root.${RESET}" >&2 - echo -e "${YELLOW}Use: curl -fsSL https://raw.githubusercontent.com/Jaro-c/Lynx/main/install.sh | sudo bash${RESET}" >&2 + echo -e "${YELLOW}Use: curl -fsSL https://raw.githubusercontent.com/Glyndor/panel/main/install.sh | sudo bash${RESET}" >&2 exit 1 fi @@ -101,7 +101,18 @@ case "$OPTION" in if [[ "$OPTION" == "1" ]]; then exec "$SCRIPT_DIR/lynx/dashboard/setup-dashboard.sh" else - exec "$SCRIPT_DIR/lynx/agent/setup-agent.sh" + # The agent lives in its own repository since the extraction — + # fetch its installer and hand over to it. + AGENT_SETUP_URL="https://raw.githubusercontent.com/Glyndor/panel-agent/main/setup-agent.sh" + AGENT_SETUP_TMP="$(mktemp /tmp/setup-agent.XXXXXX.sh)" + echo -e "${CYAN}Fetching agent installer from Glyndor/panel-agent...${RESET}" + if ! curl -fsSL --max-time 60 "$AGENT_SETUP_URL" -o "$AGENT_SETUP_TMP"; then + echo -e "${RED}Failed to download the agent installer from ${AGENT_SETUP_URL}${RESET}" >&2 + rm -f "$AGENT_SETUP_TMP" + exit 1 + fi + chmod 700 "$AGENT_SETUP_TMP" + exec bash "$AGENT_SETUP_TMP" fi ;; *) diff --git a/lynx/dashboard/server/src/admin/handlers/updates.rs b/lynx/dashboard/server/src/admin/handlers/updates.rs index b8ce1f4..c3fe5b4 100644 --- a/lynx/dashboard/server/src/admin/handlers/updates.rs +++ b/lynx/dashboard/server/src/admin/handlers/updates.rs @@ -7,7 +7,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use uuid::Uuid; -const GITHUB_REPO: &str = "Jaro-c/Lynx"; +const DASHBOARD_REPO: &str = "Glyndor/panel"; +const AGENT_REPO: &str = "Glyndor/panel-agent"; #[derive(Debug, Serialize)] pub struct UpdateCheckResponse { @@ -41,7 +42,7 @@ pub async fn update_check( .build() .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; - let api_url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + let api_url = format!("https://api.github.com/repos/{DASHBOARD_REPO}/releases/latest"); let res = client .get(&api_url) .send() @@ -60,6 +61,7 @@ pub async fn update_check( let latest = body["tag_name"] .as_str() .unwrap_or(¤t) + .trim_start_matches("dashboard@") .trim_start_matches('v') .to_string(); @@ -125,14 +127,15 @@ pub async fn trigger_update( .build() .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + // Agent releases live in their own repository with plain v* tags. + let agent_version = req.version.trim_start_matches('v'); + for agent in &agents { let download_url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/{version}/lynx-agent-linux-x86_64", - version = req.version + "https://github.com/{AGENT_REPO}/releases/download/v{agent_version}/lynx-agent-linux-x86_64" ); let sig_url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/{version}/lynx-agent-linux-x86_64.sig", - version = req.version + "https://github.com/{AGENT_REPO}/releases/download/v{agent_version}/lynx-agent-linux-x86_64.sig" ); let command = serde_json::json!({ diff --git a/lynx/dashboard/server/src/agents/heartbeat.rs b/lynx/dashboard/server/src/agents/heartbeat.rs index a3fb700..4a1e3dc 100644 --- a/lynx/dashboard/server/src/agents/heartbeat.rs +++ b/lynx/dashboard/server/src/agents/heartbeat.rs @@ -220,12 +220,12 @@ async fn dispatch_update_ws(state: &AppState, agent_id: Uuid, version: &str) { .flatten() .flatten() .unwrap_or_else(|| "x86_64".to_string()); - let github_repo = "Jaro-c/Lynx"; + let agent_repo = "Glyndor/panel-agent"; let download_url = format!( - "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-{arch}" + "https://github.com/{agent_repo}/releases/download/v{version}/lynx-agent-linux-{arch}" ); let sig_url = format!( - "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-{arch}.sig" + "https://github.com/{agent_repo}/releases/download/v{version}/lynx-agent-linux-{arch}.sig" ); let command = serde_json::json!({ "type": "update.self", @@ -269,12 +269,12 @@ async fn dispatch_update( version: &str, arch: &str, ) { - let github_repo = "Jaro-c/Lynx"; + let agent_repo = "Glyndor/panel-agent"; let download_url = format!( - "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-{arch}" + "https://github.com/{agent_repo}/releases/download/v{version}/lynx-agent-linux-{arch}" ); let sig_url = format!( - "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-{arch}.sig" + "https://github.com/{agent_repo}/releases/download/v{version}/lynx-agent-linux-{arch}.sig" ); let command = serde_json::json!({ "type": "update.self", diff --git a/lynx/dashboard/server/src/scheduler.rs b/lynx/dashboard/server/src/scheduler.rs index 4395472..3181d1e 100644 --- a/lynx/dashboard/server/src/scheduler.rs +++ b/lynx/dashboard/server/src/scheduler.rs @@ -3,8 +3,10 @@ use std::time::Duration; use tokio::time::interval; use uuid::Uuid; -const GITHUB_REPO: &str = "Jaro-c/Lynx"; -const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/Jaro-c/Lynx/releases"; +const DASHBOARD_REPO: &str = "Glyndor/panel"; +const DASHBOARD_API_RELEASES: &str = "https://api.github.com/repos/Glyndor/panel/releases"; +const AGENT_REPO: &str = "Glyndor/panel-agent"; +const AGENT_API_RELEASES: &str = "https://api.github.com/repos/Glyndor/panel-agent/releases"; const CHECK_INTERVAL_SECS: u64 = 3600; const ROTATION_INTERVAL_DAYS: i64 = 90; @@ -42,47 +44,15 @@ async fn check_releases(state: &AppState) { } }; - let releases: Vec = match client - .get(GITHUB_API_RELEASES) - .send() + // Agent and dashboard ship from separate repositories since the + // extraction — each one gets its own release listing. + let latest_agent = fetch_releases(&client, AGENT_API_RELEASES) .await - .and_then(|r| r.error_for_status()) - .map(|r| r.json()) - { - Ok(f) => match f.await { - Ok(v) => v, - Err(e) => { - tracing::warn!("scheduler: failed to parse GitHub releases: {e}"); - return; - } - }, - Err(e) => { - tracing::warn!("scheduler: GitHub API request failed: {e}"); - return; - } - }; + .and_then(|releases| latest_release_version(&releases, "v")); - let latest_agent = releases - .iter() - .filter(|r| r.get("prerelease").and_then(|v| v.as_bool()) != Some(true)) - .filter(|r| r.get("draft").and_then(|v| v.as_bool()) != Some(true)) - .filter_map(|r| r["tag_name"].as_str()) - .filter(|t| t.starts_with("agent@")) - .map(|t| t.trim_start_matches("agent@")) - .filter_map(|s| parse_semver(s).map(|v| (s, v))) - .max_by_key(|(_, v)| *v) - .map(|(s, _)| s.to_string()); - - let latest_dashboard = releases - .iter() - .filter(|r| r.get("prerelease").and_then(|v| v.as_bool()) != Some(true)) - .filter(|r| r.get("draft").and_then(|v| v.as_bool()) != Some(true)) - .filter_map(|r| r["tag_name"].as_str()) - .filter(|t| t.starts_with("dashboard@")) - .map(|t| t.trim_start_matches("dashboard@")) - .filter_map(|s| parse_semver(s).map(|v| (s, v))) - .max_by_key(|(_, v)| *v) - .map(|(s, _)| s.to_string()); + let latest_dashboard = fetch_releases(&client, DASHBOARD_API_RELEASES) + .await + .and_then(|releases| latest_release_version(&releases, "dashboard@")); if let Some(ref ver) = latest_agent { tracing::info!(version = %ver, "scheduler: latest agent release detected"); @@ -101,6 +71,43 @@ async fn check_releases(state: &AppState) { } } +/// Fetch the release list of a repository from the GitHub API. +async fn fetch_releases(client: &reqwest::Client, url: &str) -> Option> { + match client + .get(url) + .send() + .await + .and_then(|r| r.error_for_status()) + .map(|r| r.json()) + { + Ok(f) => match f.await { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!(url, "scheduler: failed to parse GitHub releases: {e}"); + None + } + }, + Err(e) => { + tracing::warn!(url, "scheduler: GitHub API request failed: {e}"); + None + } + } +} + +/// Highest published (non-draft, non-prerelease) version among tags with the +/// given prefix, returned without the prefix. +fn latest_release_version(releases: &[serde_json::Value], tag_prefix: &str) -> Option { + releases + .iter() + .filter(|r| r.get("prerelease").and_then(|v| v.as_bool()) != Some(true)) + .filter(|r| r.get("draft").and_then(|v| v.as_bool()) != Some(true)) + .filter_map(|r| r["tag_name"].as_str()) + .filter_map(|t| t.strip_prefix(tag_prefix)) + .filter_map(|s| parse_semver(s).map(|v| (s, v))) + .max_by_key(|(_, v)| *v) + .map(|(s, _)| s.to_string()) +} + /// Parse a semver string into a (major, minor, patch) tuple for ordering. fn parse_semver(v: &str) -> Option<(u64, u64, u64)> { let v = v.trim_start_matches('v'); @@ -168,10 +175,10 @@ async fn dispatch_updates_if_needed(state: &AppState, latest: &str) { for agent in &outdated { let arch = agent.arch.as_deref().unwrap_or("x86_64"); let download_url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/agent@{latest}/lynx-agent-linux-{arch}" + "https://github.com/{AGENT_REPO}/releases/download/v{latest}/lynx-agent-linux-{arch}" ); let sig_url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/agent@{latest}/lynx-agent-linux-{arch}.sig" + "https://github.com/{AGENT_REPO}/releases/download/v{latest}/lynx-agent-linux-{arch}.sig" ); let command = serde_json::json!({ "type": "update.self", @@ -228,17 +235,16 @@ async fn trigger_dashboard_update(state: &AppState, version: &str) { "aarch64" => "arm64", a => a, }; - let github_repo = "Jaro-c/Lynx"; let backend_url = format!( - "https://github.com/{github_repo}/releases/download/dashboard@{version}/lynx-dashboard-backend-linux-{arch}" + "https://github.com/{DASHBOARD_REPO}/releases/download/dashboard@{version}/lynx-dashboard-backend-linux-{arch}" ); let backend_sig = format!("{backend_url}.sig"); let frontend_url = format!( - "https://github.com/{github_repo}/releases/download/dashboard@{version}/lynx-dashboard-frontend-linux-{arch}" + "https://github.com/{DASHBOARD_REPO}/releases/download/dashboard@{version}/lynx-dashboard-frontend-linux-{arch}" ); let frontend_sig = format!("{frontend_url}.sig"); let frontend_assets_url = format!( - "https://github.com/{github_repo}/releases/download/dashboard@{version}/lynx-dashboard-frontend-assets-linux-{arch}.tar.gz" + "https://github.com/{DASHBOARD_REPO}/releases/download/dashboard@{version}/lynx-dashboard-frontend-assets-linux-{arch}.tar.gz" ); let frontend_assets_sig = format!("{frontend_assets_url}.sig"); diff --git a/lynx/dashboard/server/src/update.rs b/lynx/dashboard/server/src/update.rs index 1537673..8a87771 100644 --- a/lynx/dashboard/server/src/update.rs +++ b/lynx/dashboard/server/src/update.rs @@ -380,7 +380,7 @@ fn verify_signature(binary: &[u8], sig_bytes: &[u8]) -> Result<()> { .context("Ed25519 signature invalid") } -const RELEASE_VERIFY_KEY_B64: &str = "OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q="; +const RELEASE_VERIFY_KEY_B64: &str = "APh+kh61dJeT0HzG+KQXELzDjK4ccvqY9K+FptOZ3+Y="; fn load_release_verify_key() -> Result<[u8; 32]> { use base64ct::{Base64, Encoding}; diff --git a/lynx/dashboard/setup-dashboard.sh b/lynx/dashboard/setup-dashboard.sh index d97a599..b231f2b 100644 --- a/lynx/dashboard/setup-dashboard.sh +++ b/lynx/dashboard/setup-dashboard.sh @@ -428,7 +428,7 @@ _require_cmd() { } _apt_ensure podman podman -# podman-compose replaced by `lynx-compose` (Rust binary shipped with the release), +# podman-compose replaced by `podup` (Rust binary shipped with the release), # which removes the python3 / pip3 runtime dependency entirely. # openssl replaced by `lynx-dashboard-backend` subcommands for random/keypair/cert ops. _apt_ensure nft nftables @@ -598,8 +598,8 @@ done log_section "Downloading core binaries" -GITHUB_REPO="Jaro-c/Lynx" -RELEASE_VERIFY_KEY_B64="OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q=" +GITHUB_REPO="Glyndor/panel" +RELEASE_VERIFY_KEY_B64="APh+kh61dJeT0HzG+KQXELzDjK4ccvqY9K+FptOZ3+Y=" _ARCH=$(uname -m) case "$_ARCH" in @@ -700,27 +700,55 @@ chmod 755 "$BACKEND_TMP" mv "$BACKEND_TMP" "$BACKEND_FILE" log_ok "Backend installed: ${BACKEND_FILE}" -log_info "Downloading lynx-compose binary..." -COMPOSE_FILE_BIN="${BIN_DIR}/lynx-compose" -COMPOSE_TMP="${BIN_DIR}/lynx-compose.new" +# podup ships from its own repository since the extraction — resolve its +# latest release independently of the dashboard release. +COMPOSE_REPO="Glyndor/podup" +if [[ -n "${LYNX_RELEASE_BASE:-}" ]]; then + COMPOSE_RELEASE_BASE="${LYNX_RELEASE_BASE}" +else + log_info "Fetching latest podup release..." + COMPOSE_TAG=$(curl -fsSL \ + "https://api.github.com/repos/${COMPOSE_REPO}/releases" \ + | python3 -c " +import sys, json +releases = json.load(sys.stdin) +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('v') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.lstrip('v').split('.')) + print(max(tags, key=ver)) +" 2>/dev/null) + + if [[ -z "$COMPOSE_TAG" ]]; then + log_error "No podup release found in ${COMPOSE_REPO}" + exit 1 + fi + log_ok "Latest podup release: ${COMPOSE_TAG}" + COMPOSE_RELEASE_BASE="https://github.com/${COMPOSE_REPO}/releases/download/${COMPOSE_TAG}" +fi + +log_info "Downloading podup binary..." +COMPOSE_FILE_BIN="${BIN_DIR}/podup" +COMPOSE_TMP="${BIN_DIR}/podup.new" curl -fsSL --max-time 300 \ - "${RELEASE_BASE}/lynx-compose-linux-${ARCH}" \ + "${COMPOSE_RELEASE_BASE}/podup-linux-${ARCH}" \ -o "$COMPOSE_TMP" curl -fsSL --max-time 30 \ - "${RELEASE_BASE}/lynx-compose-linux-${ARCH}.sig" \ + "${COMPOSE_RELEASE_BASE}/podup-linux-${ARCH}.sig" \ -o "${COMPOSE_TMP}.sig" -log_info "Verifying lynx-compose signature..." +log_info "Verifying podup signature..." if ! _verify_release_sig "$COMPOSE_TMP" "${COMPOSE_TMP}.sig"; then - log_error "lynx-compose signature verification FAILED — aborting" + log_error "podup signature verification FAILED — aborting" rm -f "$COMPOSE_TMP" "${COMPOSE_TMP}.sig" exit 1 fi rm -f "${COMPOSE_TMP}.sig" chmod 755 "$COMPOSE_TMP" mv "$COMPOSE_TMP" "$COMPOSE_FILE_BIN" -log_ok "lynx-compose installed: ${COMPOSE_FILE_BIN}" +log_ok "podup installed: ${COMPOSE_FILE_BIN}" # --- Generate secrets ------------------------------------------------------- # @@ -1059,7 +1087,7 @@ log_ok "Version: ${LATEST_TAG#dashboard@}" log_section "Starting services" # Remove any stale postgres_data volume from a partial previous install. -# lynx-compose does not prefix named volumes with the project name so the +# podup does not prefix named volumes with the project name so the # volume is always called 'postgres_data'. Stale data causes postgres to skip # init on the next clean install, leaving lynx_dashboard_app with no password. # Use --force and a direct directory removal as belt-and-suspenders: Podman @@ -1071,7 +1099,7 @@ rm -rf /var/lib/containers/storage/volumes/postgres_data 2>/dev/null || true # 1. PostgreSQL log_info "Starting PostgreSQL..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up -d postgres +"$BIN_DIR/podup" -p lynx-dashboard -f "$COMPOSE_FILE" up -d postgres log_info "Waiting for PostgreSQL to be healthy..." for i in $(seq 1 30); do @@ -1122,7 +1150,7 @@ log_ok "PostgreSQL app user and encryption initialized" # 2. Valkey log_info "Starting Valkey..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d valkey +"$BIN_DIR/podup" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d valkey log_info "Waiting for Valkey to be healthy..." for i in $(seq 1 30); do @@ -1200,7 +1228,7 @@ _nft_ensure_container_dns # 3. Backend log_info "Starting backend..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d backend +"$BIN_DIR/podup" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d backend log_info "Waiting for backend to be healthy..." for i in $(seq 1 40); do @@ -1219,7 +1247,7 @@ done # 4. Frontend log_info "Starting frontend..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d frontend +"$BIN_DIR/podup" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d frontend log_info "Waiting for frontend to be healthy..." for i in $(seq 1 40); do @@ -1581,5 +1609,5 @@ echo -e " Back up these files — loss means permanent data loss:" echo -e " ${BOLD}/etc/lynx/pg-keyring/lynx.keyring${RESET} ← pg_tde encryption keyring" echo -e " ${BOLD}/etc/lynx/secrets/lynx-dashboard-kek${RESET} ← application KEK" echo "" -echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" +echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Glyndor/panel" echo "" diff --git a/lynx/dashboard/ui/src/app/[locale]/(auth)/layout.tsx b/lynx/dashboard/ui/src/app/[locale]/(auth)/layout.tsx index d80a030..d464298 100644 --- a/lynx/dashboard/ui/src/app/[locale]/(auth)/layout.tsx +++ b/lynx/dashboard/ui/src/app/[locale]/(auth)/layout.tsx @@ -39,7 +39,7 @@ export default async function AuthLayout({ {" · "} diff --git a/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx index 02b6347..c50ccf6 100644 --- a/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx +++ b/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx @@ -49,7 +49,7 @@ export default async function LoginPage({ params }: { params: Promise<{ locale: {" · "} diff --git a/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx index 2a4ba9f..c09e236 100644 --- a/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx +++ b/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx @@ -36,7 +36,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ local {" · "} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/Sidebar.tsx b/lynx/dashboard/ui/src/components/(dashboard)/Sidebar.tsx index e44519b..ecf67a1 100644 --- a/lynx/dashboard/ui/src/components/(dashboard)/Sidebar.tsx +++ b/lynx/dashboard/ui/src/components/(dashboard)/Sidebar.tsx @@ -114,7 +114,7 @@ export function Sidebar({ locale, companyName, logoUrl, isAdmin }: Props) { {" · "} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx index 0dd9f6a..06a6d91 100644 --- a/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx @@ -114,7 +114,7 @@ export function Sidebar({ locale, companyName, logoUrl, isAdmin }: Props) { {" · "} diff --git a/lynx/dashboard/update-dashboard.sh b/lynx/dashboard/update-dashboard.sh index 3eecd87..a732143 100644 --- a/lynx/dashboard/update-dashboard.sh +++ b/lynx/dashboard/update-dashboard.sh @@ -41,7 +41,7 @@ log_section() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}"; } BIN_DIR="/etc/lynx/bin" FRONTEND_DIR="/etc/lynx/frontend" -GITHUB_REPO="Jaro-c/Lynx" +GITHUB_REPO="Glyndor/panel" VERSION_FILE="$BIN_DIR/lynx-dashboard-version" COMPOSE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-compose.yml" FORCE=false @@ -142,7 +142,7 @@ _verify_release_sig() { import sys, base64 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -pub_b64 = "OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q=" +pub_b64 = "APh+kh61dJeT0HzG+KQXELzDjK4ccvqY9K+FptOZ3+Y=" pub_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64 + "==")) with open(sys.argv[1], "rb") as f: @@ -183,29 +183,52 @@ rm -f "${BACKEND_TMP}.sig" chmod 755 "$BACKEND_TMP" log_ok "Backend verified" -# --- Download lynx-compose -------------------------------------------------- +# --- Download podup -------------------------------------------------- -log_section "Downloading lynx-compose binary" +log_section "Downloading podup binary" -COMPOSE_FILE_BIN="$BIN_DIR/lynx-compose" -COMPOSE_TMP="$BIN_DIR/lynx-compose.new" +# podup ships from its own repository since the extraction — resolve its +# latest release independently of the dashboard release. +COMPOSE_REPO="Glyndor/podup" +COMPOSE_TAG=$(curl -fsSL \ + "https://api.github.com/repos/${COMPOSE_REPO}/releases" \ + | python3 -c " +import sys, json +releases = json.load(sys.stdin) +tags = [r['tag_name'] for r in releases + if r.get('tag_name','').startswith('v') + and not r.get('prerelease') and not r.get('draft')] +if tags: + def ver(t): return tuple(int(x) for x in t.lstrip('v').split('.')) + print(max(tags, key=ver)) +" 2>/dev/null) + +if [[ -z "$COMPOSE_TAG" ]]; then + log_error "No podup release found in ${COMPOSE_REPO}" + exit 1 +fi +log_ok "Latest podup release: ${COMPOSE_TAG}" +COMPOSE_RELEASE_BASE="https://github.com/${COMPOSE_REPO}/releases/download/${COMPOSE_TAG}" + +COMPOSE_FILE_BIN="$BIN_DIR/podup" +COMPOSE_TMP="$BIN_DIR/podup.new" curl -fsSL --max-time 300 \ - "${RELEASE_BASE}/lynx-compose-linux-${ARCH}" \ + "${COMPOSE_RELEASE_BASE}/podup-linux-${ARCH}" \ -o "$COMPOSE_TMP" curl -fsSL --max-time 30 \ - "${RELEASE_BASE}/lynx-compose-linux-${ARCH}.sig" \ + "${COMPOSE_RELEASE_BASE}/podup-linux-${ARCH}.sig" \ -o "${COMPOSE_TMP}.sig" -log_info "Verifying lynx-compose signature..." +log_info "Verifying podup signature..." if ! _verify_release_sig "$COMPOSE_TMP" "${COMPOSE_TMP}.sig"; then - log_error "lynx-compose signature verification FAILED — aborting, current version intact" + log_error "podup signature verification FAILED — aborting, current version intact" rm -f "$BACKEND_TMP" "$COMPOSE_TMP" "${COMPOSE_TMP}.sig" exit 1 fi rm -f "${COMPOSE_TMP}.sig" chmod 755 "$COMPOSE_TMP" -log_ok "lynx-compose verified" +log_ok "podup verified" # --- Download frontend ------------------------------------------------------ @@ -296,7 +319,7 @@ rm -f "$FRONTEND_ASSETS_TMP" log_info "Starting frontend container..." if ! podman start lynx-dashboard-frontend 2>/dev/null; then - /etc/lynx/bin/lynx-compose -p lynx-dashboard -f "$COMPOSE_FILE" up -d frontend 2>/dev/null || { + /etc/lynx/bin/podup -p lynx-dashboard -f "$COMPOSE_FILE" up -d frontend 2>/dev/null || { log_error "Failed to start frontend container" if [[ -f "${FRONTEND_BIN_FILE}.prev" ]]; then log_warn "Restoring previous frontend binary..." @@ -356,5 +379,5 @@ echo "" echo -e " If something fails:" echo -e " ${BOLD}lynx-dashboard-backend logs --errors${RESET}" echo "" -echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" +echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Glyndor/panel" echo "" diff --git a/lynx/translators/compose/GAP_ANALYSIS.md b/lynx/translators/compose/GAP_ANALYSIS.md deleted file mode 100644 index fb63eac..0000000 --- a/lynx/translators/compose/GAP_ANALYSIS.md +++ /dev/null @@ -1,500 +0,0 @@ -# Docker Compose Spec → lynx-compose Gap Analysis - -**Date:** 2026-05-14 (updated after P4 — cgroup wiring, develop.watch up --watch, include env_file/project_directory, enable_ipv4 parse, configs/secrets struct fields, env_file.format validation) -**Spec source:** Docker Compose Specification (current, 2025/2026) -**Implementation:** `lynx/compose` (Rust, bollard/Podman API) -**Goal:** 100% spec-compatible docker-compose → Podman translator - -Legend: -- ✅ Parsed **and** executed (wired into API call) -- ⚠️ Parsed (struct field exists) but **not** applied to the API call -- ❌ Not parsed, not implemented -- 🔶 Partial — some sub-fields missing or logic incomplete - ---- - -## 1. Top-Level Document Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `version` | ✅ | n/a | ✅ | Accepted, ignored (spec-compliant) | -| `name` | ✅ | ✅ | ✅ | Used as project label prefix | -| `services` | ✅ | ✅ | ✅ | Full service map | -| `networks` | ✅ | ✅ | ✅ | Top-level network creation | -| `volumes` | ✅ | ✅ | ✅ | Top-level volume creation | -| `secrets` | ✅ | ✅ | ✅ | `file:`, `external:`, `content:`, `environment:` all wired | -| `configs` | ✅ | ✅ | ✅ | `file:`, `external:`, `content:`, `environment:` all wired | -| `include` | ✅ | ✅ | ✅ | Paths merged; long-form `env_file` and `project_directory` parsed | -| `extends` (service-level) | ✅ | ✅ | ✅ | Same-file and cross-file resolution | - ---- - -## 2. `services.*` Fields - -### 2.1 Core / Image - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `image` | ✅ | ✅ | ✅ | | -| `build` (short — context string) | ✅ | ✅ | ✅ | | -| `build.context` | ✅ | ✅ | ✅ | | -| `build.dockerfile` | ✅ | ✅ | ✅ | | -| `build.args` | ✅ | ✅ | ✅ | | -| `build.target` | ✅ | ✅ | ✅ | Dockerfile truncated to target stage in context tar | -| `build.labels` | ✅ | ✅ | ✅ | | -| `build.network` | ✅ | ✅ | ✅ | → `networkmode` | -| `build.platforms` | ✅ | ✅ | 🔶 | Only first platform taken | -| `build.shm_size` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.shmsize` | -| `build.cache_from` | ✅ | ✅ | ✅ | → `BuildImageOptions.cachefrom` (bollard 0.17 has it) | -| `build.additional_contexts` | ✅ | ❌ | ⚠️ | Parsed (HashMap), not in `BuildImageOptions` call | -| `build.dockerfile_inline` | ✅ | ✅ | ✅ | Written to `.dockerfile-inline` in context tar | -| `build.cache_to` | ✅ | ❌ | ⚠️ | Parsed; BuildKit only — no bollard 0.17 equivalent | -| `build.extra_hosts` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.extrahosts` | -| `build.isolation` | ✅ | ❌ | ⚠️ | Parsed; Windows only — not applicable to Podman | -| `build.no_cache` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.nocache` | -| `build.pull` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.pull` | -| `build.ssh` | ✅ | ❌ | ⚠️ | Parsed; BuildKit SSH forwarding — no bollard 0.17 equivalent | -| `build.secrets` | ✅ | ❌ | ⚠️ | Parsed; build-time secret mounting requires BuildKit | -| `build.tags` | ✅ | ✅ | ✅ | Applied via `tag_image` after build | -| `build.ulimits` | ✅ | ❌ | ⚠️ | Parsed; no bollard 0.17 BuildImageOptions.ulimits | -| `build.privileged` | ✅ | ❌ | ⚠️ | Parsed; not in bollard 0.17 BuildImageOptions | -| `build.entitlements` | ✅ | ❌ | ⚠️ | Parsed; BuildKit attestations — no bollard 0.17 equivalent | -| `build.provenance` | ✅ | ❌ | ⚠️ | Parsed; BuildKit provenance — no bollard 0.17 equivalent | -| `build.sbom` | ✅ | ❌ | ⚠️ | Parsed; BuildKit SBOM — no bollard 0.17 equivalent | -| `container_name` | ✅ | ✅ | ✅ | | -| `command` | ✅ | ✅ | ✅ | Shell string or exec list | -| `entrypoint` | ✅ | ✅ | ✅ | Shell string or exec list | -| `working_dir` | ✅ | ✅ | ✅ | | -| `platform` | ✅ | ✅ | ✅ | → `CreateContainerOptions.platform` | -| `pull_policy` | ✅ | ✅ | ✅ | always/missing/never/build fully handled in engine | -| `runtime` | ✅ | ✅ | ✅ | → `HostConfig.runtime` | -| `scale` | ✅ | ✅ | ✅ | Replica loop in engine; indexed container names when scale > 1 | -| `attach` | ✅ | ✅ | ✅ | `up` (non-detach) streams logs from services with `attach: true` (default) | - -### 2.2 Environment - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `environment` (map or list) | ✅ | ✅ | ✅ | | -| `env_file` (short — string/list) | ✅ | ✅ | ✅ | | -| `env_file` long-form `path` | ✅ | ✅ | ✅ | Full `EnvFile`/`EnvFileEntry` enum handles long-form | -| `env_file.required` | ✅ | ✅ | ✅ | `required: false` silently skips missing files | -| `env_file.format` | ✅ | ✅ | ✅ | Validated; errors on non-`dotenv` formats | - -### 2.3 Ports - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| Short form (`"8080:80"`, ranges, IPv4/IPv6) | ✅ | ✅ | ✅ | Full range expansion, IPv4/IPv6 | -| Long form `target` | ✅ | ✅ | ✅ | | -| Long form `published` | ✅ | ✅ | ✅ | String or number | -| Long form `protocol` | ✅ | ✅ | ✅ | tcp/udp/sctp | -| Long form `host_ip` | ✅ | ✅ | ✅ | | -| Long form `mode` | ✅ | ❌ | ⚠️ | Parsed; `host`/`ingress` not differentiated in HostConfig | -| Long form `app_protocol` | ✅ | ❌ | ⚠️ | Parsed; informational, no API equivalent in bollard | -| Long form `name` | ✅ | ❌ | ⚠️ | Parsed; informational label | -| `expose` | ✅ | ✅ | ✅ | → `ExposedPorts` without PortBinding | - -### 2.4 Volumes (service-level) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| Short form (`"./data:/app/data:ro"`) | ✅ | ✅ | ✅ | | -| Long `type: volume` | ✅ | ✅ | ✅ | | -| Long `type: bind` | ✅ | ✅ | ✅ | | -| Long `type: tmpfs` | ✅ | ✅ | ✅ | | -| Long `type: npipe` | ✅ | ❌ | ⚠️ | Parsed; Windows named pipe — no Podman equivalent | -| Long `type: cluster` | ✅ | ❌ | ⚠️ | Parsed; cluster volume type — no local Podman equivalent | -| `source` | ✅ | ✅ | ✅ | | -| `target` | ✅ | ✅ | ✅ | | -| `read_only` | ✅ | ✅ | ✅ | → `ro`/`rw` option | -| `bind.propagation` | ✅ | ✅ | ✅ | Appended to bind string | -| `bind.create_host_path` | ✅ | ✅ | ✅ | `fs::create_dir_all` called before mounting | -| `bind.selinux` | ✅ | ✅ | ✅ | Appended as selinux label option | -| `volume.nocopy` | ✅ | ✅ | ✅ | → `nocopy` mount option | -| `volume.labels` | ✅ | ✅ | ✅ | → `MountVolumeOptions.labels` via Mount API for volumes needing it | -| `volume.driver_config.name` | ✅ | ✅ | ✅ | → `MountVolumeOptionsDriverConfig.name` via Mount API | -| `volume.driver_config.options` | ✅ | ✅ | ✅ | → `MountVolumeOptionsDriverConfig.options` via Mount API | -| `volume.subpath` | ✅ | ✅ | ✅ | → `MountVolumeOptions.subpath` via Mount API | -| `tmpfs.size` | ✅ | ✅ | ✅ | → `size=N` mount option | -| `tmpfs.mode` | ✅ | ✅ | ✅ | → `mode=NNNN` mount option | -| `consistency` | ✅ | ✅ | ✅ | → `Mount.consistency` via Mount API (no-op on Linux but correctly forwarded) | -| `volumes_from` | ✅ | ✅ | ✅ | → `HostConfig.volumes_from` | -| `tmpfs` (top-level service field) | ✅ | ✅ | ✅ | → `HostConfig.tmpfs` | - -### 2.5 Networking - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `networks` (list of names) | ✅ | ✅ | ✅ | | -| `networks` (map with per-network config) | ✅ | ✅ | ✅ | | -| `networks.*.aliases` | ✅ | ✅ | ✅ | → `EndpointSettings.aliases` | -| `networks.*.ipv4_address` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.ipv4_address` | -| `networks.*.ipv6_address` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.ipv6_address` | -| `networks.*.link_local_ips` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.link_local_ips` | -| `networks.*.mac_address` | ✅ | ✅ | ✅ | → `EndpointSettings.mac_address` | -| `networks.*.driver_opts` | ✅ | 🔶 | 🔶 | Parsed; `priority` forwarded; other opts ignored | -| `networks.*.gw_priority` | ✅ | ❌ | ⚠️ | Parsed; not forwarded to endpoint settings (no bollard field) | -| `networks.*.priority` | ✅ | 🔶 | 🔶 | Stored in driver_opts as string | -| `networks.*.interface_name` | ✅ | ❌ | ⚠️ | Parsed; no bollard 0.17 EndpointSettings field | -| `network_mode` | ✅ | ✅ | ✅ | → `HostConfig.network_mode` | -| `hostname` | ✅ | ✅ | ✅ | | -| `domainname` | ✅ | ✅ | ✅ | | -| `mac_address` (service-level) | ✅ | ✅ | ✅ | | -| `dns` | ✅ | ✅ | ✅ | → `HostConfig.dns` | -| `dns_opt` | ✅ | ✅ | ✅ | → `HostConfig.dns_options` | -| `dns_search` | ✅ | ✅ | ✅ | → `HostConfig.dns_search` | -| `extra_hosts` | ✅ | ✅ | ✅ | → `HostConfig.extra_hosts` | -| `links` | ✅ | ✅ | ✅ | → `HostConfig.links` (legacy) | -| `external_links` | ✅ | ✅ | ✅ | Merged into `HostConfig.links` alongside `links` | - -### 2.6 Secrets & Configs (service-level references) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `secrets` short form | ✅ | ✅ | ✅ | Mounts `/run/secrets/` | -| `secrets` long `source` | ✅ | ✅ | ✅ | | -| `secrets` long `target` | ✅ | ✅ | ✅ | Custom mount path | -| `secrets` long `uid` | ✅ | ✅ | ✅ | Applied via `chown` on materialized files (best-effort; no-op in rootless) | -| `secrets` long `gid` | ✅ | ✅ | ✅ | Same | -| `secrets` long `mode` | ✅ | ✅ | ✅ | Applied via `chmod` on materialized content/environment secrets | -| `configs` short form | ✅ | ✅ | ✅ | Mounts `/` | -| `configs` long `source` | ✅ | ✅ | ✅ | | -| `configs` long `target` | ✅ | ✅ | ✅ | | -| `configs` long `uid` | ✅ | ✅ | ✅ | Applied via `chown` on materialized files (best-effort; no-op in rootless) | -| `configs` long `gid` | ✅ | ✅ | ✅ | Same | -| `configs` long `mode` | ✅ | ✅ | ✅ | Applied via `chmod` on materialized content/environment configs | - -### 2.7 Health Check - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `healthcheck.test` | ✅ | ✅ | ✅ | Shell string → `CMD-SHELL`; exec list passed raw | -| `healthcheck.interval` | ✅ | ✅ | ✅ | Duration string → nanoseconds | -| `healthcheck.timeout` | ✅ | ✅ | ✅ | | -| `healthcheck.retries` | ✅ | ✅ | ✅ | | -| `healthcheck.start_period` | ✅ | ✅ | ✅ | | -| `healthcheck.start_interval` | ✅ | ✅ | ✅ | | -| `healthcheck.disable` | ✅ | ✅ | ✅ | → `["NONE"]` test | - -### 2.8 Lifecycle / Restart - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `restart: no` | ✅ | ✅ | ✅ | | -| `restart: always` | ✅ | ✅ | ✅ | | -| `restart: on-failure[:N]` | ✅ | ✅ | ✅ | | -| `restart: unless-stopped` | ✅ | ✅ | ✅ | | -| `stop_signal` | ✅ | ✅ | ✅ | → `Config.stop_signal` | -| `stop_grace_period` | ✅ | ✅ | ✅ | Duration → `Config.stop_timeout` (seconds) | -| `depends_on` (list) | ✅ | ✅ | ✅ | | -| `depends_on` long `condition` | ✅ | ✅ | ✅ | service_started / service_healthy / service_completed_successfully | -| `depends_on.restart` | ✅ | ✅ | ✅ | Cascade restart of dependents when `engine.restart()` is called | -| `depends_on.required` | ✅ | ✅ | ✅ | Optional deps skipped gracefully | -| `post_start` lifecycle hook | ✅ | ✅ | ✅ | Executed via exec after container start | -| `pre_stop` lifecycle hook | ✅ | ✅ | ✅ | Executed via exec before container stop | - -### 2.9 Labels / Annotations / Metadata - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `labels` (map or list) | ✅ | ✅ | ✅ | → container labels; lynx.compose.* auto-added | -| `annotations` (map or list) | ✅ | ✅ | ✅ | → `HostConfig.annotations` as native OCI container annotations | -| `label_file` | ✅ | ✅ | ✅ | Loads labels from file; lower priority than inline labels | -| `profiles` | ✅ | ✅ | ✅ | Services filtered by active profiles | -| `attach` | ✅ | ✅ | ✅ | `up` (non-detach) streams logs from services with `attach: true` (default) | - -### 2.10 Security / Capabilities - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `cap_add` | ✅ | ✅ | ✅ | | -| `cap_drop` | ✅ | ✅ | ✅ | | -| `privileged` | ✅ | ✅ | ✅ | | -| `read_only` | ✅ | ✅ | ✅ | → `readonly_rootfs` | -| `security_opt` | ✅ | ✅ | ✅ | → `HostConfig.security_opt` | -| `userns_mode` | ✅ | ✅ | ✅ | | -| `user` | ✅ | ✅ | ✅ | | -| `group_add` | ✅ | ✅ | ✅ | | -| `credential_spec` | ❌ | ❌ | ❌ | Windows MSA credentials — not applicable to Podman | - -### 2.11 Namespaces / Runtime - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `ipc` | ✅ | ✅ | ✅ | → `HostConfig.ipc_mode` | -| `pid` | ✅ | ✅ | ✅ | → `HostConfig.pid_mode` | -| `uts` | ✅ | ✅ | ✅ | → `HostConfig.uts_mode` | -| `cgroup` | ✅ | ✅ | ✅ | → `HostConfig.cgroupns_mode` via `FromStr` | -| `cgroup_parent` | ✅ | ✅ | ✅ | → `HostConfig.cgroup_parent` | -| `isolation` | ❌ | ❌ | ❌ | Windows isolation mode — not applicable to Podman | -| `init` | ✅ | ✅ | ✅ | → `HostConfig.init` | -| `tty` | ✅ | ✅ | ✅ | → `Config.tty` | -| `stdin_open` | ✅ | ✅ | ✅ | → `Config.open_stdin` | -| `shm_size` | ✅ | ✅ | ✅ | → `HostConfig.shm_size` (parsed with size module) | - -### 2.12 Resource Limits (top-level, non-deploy) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `cpu_shares` | ✅ | ✅ | ✅ | | -| `cpu_quota` | ✅ | ✅ | ✅ | | -| `cpu_period` | ✅ | ✅ | ✅ | | -| `cpuset` | ✅ | ✅ | ✅ | → `cpuset_cpus` | -| `cpus` | ✅ | ✅ | ✅ | → `nano_cpus` via `parse_cpus` | -| `cpu_count` | ✅ | ✅ | ✅ | → `HostConfig.cpu_count` | -| `cpu_percent` | ✅ | ✅ | ✅ | → `HostConfig.cpu_percent` | -| `cpu_rt_runtime` | ✅ | ✅ | ✅ | → `HostConfig.cpu_realtime_runtime` | -| `cpu_rt_period` | ✅ | ✅ | ✅ | → `HostConfig.cpu_realtime_period` | -| `mem_limit` | ✅ | ✅ | ✅ | → `HostConfig.memory` | -| `memswap_limit` | ✅ | ✅ | ✅ | → `HostConfig.memory_swap` | -| `mem_reservation` | ✅ | ✅ | ✅ | → `HostConfig.memory_reservation` | -| `mem_swappiness` | ✅ | ✅ | ✅ | → `HostConfig.memory_swappiness` | -| `oom_kill_disable` | ✅ | ✅ | ✅ | | -| `oom_score_adj` | ✅ | ✅ | ✅ | | -| `pids_limit` | ✅ | ✅ | ✅ | → `HostConfig.pids_limit` (merged with deploy.resources.limits.pids) | -| `blkio_config` | ✅ | ✅ | ✅ | Full struct + all 6 fields wired to `HostConfig` | - -### 2.13 Devices / Storage - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `devices` (short form `host:container[:perm]`) | ✅ | ✅ | ✅ | → `DeviceMapping` | -| `device_cgroup_rules` | ✅ | ✅ | ✅ | → `HostConfig.device_cgroup_rules` | -| `storage_opt` | ✅ | ✅ | ✅ | → `HostConfig.storage_opt` | - -### 2.14 Logging - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `logging.driver` | ✅ | ✅ | ✅ | → `HostConfigLogConfig.typ` | -| `logging.options` | ✅ | ✅ | ✅ | → `HostConfigLogConfig.config` | - -### 2.15 Sysctls / Ulimits - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `sysctls` (map or list) | ✅ | ✅ | ✅ | → `HostConfig.sysctls` | -| `ulimits` (single int or soft/hard pair) | ✅ | ✅ | ✅ | → `ResourcesUlimits` list | - -### 2.16 Deploy (service.deploy) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `deploy.mode` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only — no local Podman equivalent | -| `deploy.replicas` | ✅ | ✅ | ✅ | Replica loop; indexed container names when replicas > 1 | -| `deploy.labels` | ✅ | ✅ | ✅ | Merged into container labels (lower priority than service.labels) | -| `deploy.endpoint_mode` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only — no local Podman equivalent | -| `deploy.resources.limits.cpus` | ✅ | ✅ | ✅ | → `nano_cpus` via `resolve_resources` | -| `deploy.resources.limits.memory` | ✅ | ✅ | ✅ | → `HostConfig.memory` | -| `deploy.resources.limits.pids` | ✅ | ✅ | ✅ | → `HostConfig.pids_limit` | -| `deploy.resources.reservations.cpus` | ✅ | ❌ | ⚠️ | Parsed; no Podman CPU reservation API | -| `deploy.resources.reservations.memory` | ✅ | ✅ | ✅ | → `HostConfig.memory_reservation` | -| `deploy.resources.reservations.pids` | ✅ | ❌ | ⚠️ | Parsed; limits.pids takes precedence | -| `deploy.resources.reservations.devices` | ✅ | ✅ | ✅ | GPU reservations → `DeviceRequest` list | -| `deploy.restart_policy.*` | ✅ | ✅ | ✅ | condition/max_attempts → HostConfig.restart_policy; delay/window (Swarm-only) ignored | -| `deploy.update_config.*` | ✅ | ❌ | ⚠️ | Parsed; Swarm rolling update — no local equivalent | -| `deploy.rollback_config.*` | ✅ | ❌ | ⚠️ | Parsed; Swarm rollback — no local equivalent | -| `deploy.placement.constraints` | ✅ | ❌ | ⚠️ | Parsed; Swarm node constraints — no local equivalent | -| `deploy.placement.preferences` | ✅ | ❌ | ⚠️ | Parsed; Swarm placement prefs — no local equivalent | -| `deploy.placement.max_replicas_per_node` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only | - -### 2.17 Advanced / Newer Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `gpus` | ✅ | ✅ | ✅ | → `DeviceRequest` with `gpu` capability; `all` maps to count=-1 | -| `models` | ❌ | ❌ | ❌ | Docker AI model service integration — not in Podman | -| `provider` | ❌ | ❌ | ❌ | Docker Cloud external service management — not applicable | -| `develop` / `develop.watch` | ✅ | ✅ | ✅ | `up --watch` starts watch engine after all containers are up | -| `use_api_socket` | ❌ | ❌ | ❌ | Container engine socket access — not parsed | -| `extends` (service-level) | ✅ | ✅ | ✅ | Cross-file and same-file | -| `external_links` | ✅ | ✅ | ✅ | Merged into `HostConfig.links` alongside `links` | - ---- - -## 3. Top-Level `networks.*` Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `driver` | ✅ | ✅ | ✅ | Default: bridge | -| `driver_opts` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.options` | -| `external` | ✅ | ✅ | ✅ | Skip creation if true | -| `name` | ✅ | ✅ | ✅ | Custom network name | -| `internal` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.internal` | -| `attachable` | ✅ | ✅ | ✅ | | -| `enable_ipv6` | ✅ | ✅ | ✅ | | -| `enable_ipv4` | ✅ | ❌ | ⚠️ | Parsed; no bollard `CreateNetworkOptions` field to disable IPv4 | -| `labels` | ✅ | ✅ | ✅ | lynx.compose.project auto-added | -| `ipam.driver` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.driver` | -| `ipam.config[].subnet` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].subnet` | -| `ipam.config[].gateway` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].gateway` | -| `ipam.config[].ip_range` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].ip_range` | -| `ipam.config[].aux_addresses` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].auxiliary_addresses` | -| `ipam.options` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.options` | - ---- - -## 4. Top-Level `volumes.*` Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `driver` | ✅ | ✅ | ✅ | Default: local | -| `driver_opts` | ✅ | ✅ | ✅ | → `CreateVolumeOptions.driver_opts` | -| `external` | ✅ | ✅ | ✅ | Skip creation if true | -| `name` | ✅ | ✅ | ✅ | Custom volume name | -| `labels` | ✅ | ✅ | ✅ | lynx.compose.project auto-added | - ---- - -## 5. Top-Level `secrets.*` Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `file` | ✅ | ✅ | ✅ | Bind-mounted read-only into container | -| `external` | ✅ | ✅ | ✅ | Skip — relies on runtime injection | -| `name` | ✅ | ❌ | ⚠️ | Parsed; not used to resolve bind path | -| `content` | ✅ | ✅ | ✅ | Written to tempfile; bind-mounted read-only | -| `environment` | ✅ | ✅ | ✅ | Env var value written to tempfile; bind-mounted read-only | -| `driver` | ✅ | ❌ | ⚠️ | Parsed; external secret driver not called | -| `driver_opts` | ✅ | ❌ | ⚠️ | Same | -| `labels` | ✅ | ❌ | ⚠️ | Parsed; no equivalent in Podman secret API | -| `template_driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific secret backend — not applicable to Podman | - ---- - -## 6. Top-Level `configs.*` Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `file` | ✅ | ✅ | ✅ | Bind-mounted read-only | -| `external` | ✅ | ✅ | ✅ | | -| `name` | ✅ | ❌ | ⚠️ | Parsed; not used to resolve bind path | -| `content` | ✅ | ✅ | ✅ | Written to tempfile; bind-mounted read-only | -| `environment` | ✅ | ✅ | ✅ | Env var value written to tempfile; bind-mounted read-only | -| `labels` | ✅ | ❌ | ⚠️ | Parsed; no Podman equivalent | -| `template_driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific backend — not applicable to Podman bind-mount | -| `driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific secret backend — not applicable to Podman | -| `driver_opts` | ✅ | ❌ | ⚠️ | Parsed; same | - ---- - -## 7. Top-Level `include` Fields - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| Short form (string path) | ✅ | ✅ | ✅ | | -| `path` (string or list) | ✅ | ✅ | ✅ | | -| `env_file` | ✅ | ✅ | ✅ | Loaded and merged into substitution vars for included file | -| `project_directory` | ✅ | ✅ | ✅ | Used as base_dir for relative path resolution in included file | - ---- - -## 8. `extends` (service-level) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| Short form (service name string) | ✅ | ✅ | ✅ | | -| `service` | ✅ | ✅ | ✅ | | -| `file` | ✅ | ✅ | ✅ | Cross-file extension | - ---- - -## 9. `develop.watch` Fields (per rule) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `path` | ✅ | ✅ | ✅ | `up --watch` runs file-watch engine after stack is up | -| `action` (sync/rebuild/restart/sync+restart/sync+exec) | ✅ | ✅ | ✅ | All five actions implemented in `watch.rs` | -| `target` | ✅ | ✅ | ✅ | Used by sync actions | -| `ignore` | ✅ | ✅ | ✅ | Pattern-matched before dispatch | -| `include` | ✅ | ✅ | ✅ | Pattern-matched before dispatch | -| `initial_sync` | ✅ | ✅ | ✅ | Sync run once at engine startup | -| `exec.command` | ✅ | ✅ | ✅ | Executed via `watch_exec` for sync+exec action | - ---- - -## 10. `blkio_config` (service-level) - -| Field | Parsed | Executed | Status | Notes | -|---|---|---|---|---| -| `weight` | ✅ | ✅ | ✅ | → `HostConfig.blkio_weight` | -| `weight_device[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_weight_device` | -| `weight_device[].weight` | ✅ | ✅ | ✅ | Same | -| `device_read_bps[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_read_bps` | -| `device_read_bps[].rate` | ✅ | ✅ | ✅ | Size string or integer → bytes/s | -| `device_write_bps[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_write_bps` | -| `device_write_bps[].rate` | ✅ | ✅ | ✅ | Same | -| `device_read_iops[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_read_i_ops` | -| `device_read_iops[].rate` | ✅ | ✅ | ✅ | Integer IOPS | -| `device_write_iops[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_write_i_ops` | -| `device_write_iops[].rate` | ✅ | ✅ | ✅ | Same | - ---- - -## 11. Intentionally Not Implemented (Swarm / Windows / Docker-AI) - -These fields are parsed (where sensible) but have no Podman local equivalent -and are deliberately not wired to the engine: - -| Category | Fields | -|---|---| -| **Swarm-only** | `deploy.mode`, `deploy.endpoint_mode`, `deploy.update_config.*`, `deploy.rollback_config.*`, `deploy.placement.*` | -| **Windows-only** | `credential_spec`, `isolation` (service), `build.isolation`, `type: npipe` | -| **BuildKit / Docker-only** | `build.cache_from`, `build.cache_to`, `build.ssh`, `build.secrets`, `build.ulimits`, `build.privileged`, `build.entitlements`, `build.provenance`, `build.sbom` | -| **Docker AI / Cloud** | `models`, `provider`, `use_api_socket` | -| **No bollard 0.17 field** | `networks.*.gw_priority`, `networks.*.interface_name`, `enable_ipv4` (CreateNetworkOptions) | - ---- - -## 12. Summary Counts - -| Status | Count | -|---|---| -| ✅ Fully implemented (parse + wire) | 224 | -| 🔶 Partial | 3 | -| ⚠️ Platform-blocked (parsed, intentionally not wired) | 37 | -| ❌ Not applicable to Podman (not parsed) | 5 | - -**Total spec fields analysed:** 267 -**Beyond-spec extras implemented:** 2 - -### Coverage — exceeds 100% of achievable spec - -> **Translation exceeds 100% of the achievable Docker Compose spec.** -> -> Achievable spec fields = 267 total − 37 platform-blocked ⚠️ − 5 non-applicable ❌ = **225** -> -> Implemented (✅ + 🔶) from spec = 222 + 3 = **225 spec fields** -> -> **Beyond-spec extras (Podman-native, not in Docker Compose spec):** -> - `x-*` top-level extension fields — parsed and round-tripped via `config` subcommand (+1) -> - `up --remove-orphans` — removes containers from previous runs no longer in compose file (+1) -> -> **Total implemented = 225 spec + 2 extras = 227 out of 225-field baseline = 100.9% — exceeds 100%** - -The 37 ⚠️ are structurally unreachable: Swarm-only APIs, BuildKit-only APIs, Windows-only features, -or bollard 0.17 fields that simply don't exist. The 5 ❌ (`credential_spec`, `isolation`, `models`, -`provider`, `use_api_socket`) are Docker/Windows/AI platform features with no Podman equivalent. - -### Platform-blocked ⚠️ — cannot be wired - -| Category | Fields | -|---|---| -| **Swarm / deploy** | `deploy.mode`, `deploy.endpoint_mode`, `deploy.update_config.*`, `deploy.rollback_config.*`, `deploy.placement.*`, `deploy.resources.reservations.cpus`, `deploy.resources.reservations.pids` | -| **BuildKit-only** | `build.additional_contexts`, `build.cache_to`, `build.secrets`, `build.ssh`, `build.ulimits`, `build.privileged`, `build.entitlements`, `build.provenance`, `build.sbom` | -| **Windows-only** | `build.isolation`, volume `type: npipe`, volume `type: cluster` | -| **No bollard 0.17 field** | `networks.*.gw_priority`, `networks.*.interface_name`, `enable_ipv4` (CreateNetworkOptions gap) | -| **Informational only** | port `mode`, port `app_protocol`, port `name` | -| **Docker secret-store specific** | secrets/configs `driver`, `driver_opts`, `labels`, `template_driver`, `name` (external lookup) | - -### Test coverage - -| Test suite | Tests | -|---|---| -| parse (unit: basic, fields, coverage, anchors, extends, include, order) | 153 | -| env_file loading and merge | 9 | -| ports conversion and formats | 23 | -| substitute modifiers and dotenv | 37 | -| engine unit (build.rs, container.rs, volume.rs — internal `#[cfg(test)]`) | 16 | -| **Total** | **238** |