diff --git a/.github/workflows/delete-merged-branch.yml b/.github/workflows/delete-merged-branch.yml index 7d183eee..85bcc2e7 100644 --- a/.github/workflows/delete-merged-branch.yml +++ b/.github/workflows/delete-merged-branch.yml @@ -7,7 +7,7 @@ on: jobs: delete-branch: - if: github.event.pull_request.merged == true && github.event.pull_request.head.ref != 'develop' + if: github.event.pull_request.merged == true runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..560db809 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,53 @@ +name: Format + +on: + push: + branches: + - "feat/**" + - "fix/**" + - "chore/**" + +permissions: + contents: write + +jobs: + format: + name: Auto-format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libpq-dev protobuf-compiler + + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: cargo fmt + run: cargo fmt --all + + - name: cargo clippy --fix + run: cargo clippy --fix --allow-dirty --allow-staged --workspace --all-targets + + - name: Commit and push fixes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if ! git diff --quiet; then + git add -A + git commit -m "style: apply cargo fmt and clippy fixes" + git push origin HEAD:${{ github.ref_name }} + fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 622d56a2..60607534 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,12 +6,42 @@ on: push: branches: - main + workflow_run: + workflows: ["Format"] + types: [completed] workflow_dispatch: +permissions: + contents: read + pull-requests: write + checks: write + actions: read + jobs: - clippy: + wait-for-format: + name: Wait for auto-format + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Poll for in-flight format run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + REPO="${{ github.repository }}" + for _ in $(seq 1 30); do + STATUS=$(gh run list --repo "$REPO" --workflow Format --branch "$BRANCH" \ + --limit 1 --json status -q '.[0].status' 2>/dev/null || echo "") + if [ "$STATUS" != "in_progress" ] && [ "$STATUS" != "queued" ]; then + exit 0 + fi + sleep 5 + done + + clippy-push: name: Clippy runs-on: ubuntu-latest + if: github.event_name != 'pull_request' steps: - uses: actions/checkout@v6 @@ -34,11 +64,49 @@ jobs: - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings + clippy-pr: + name: Clippy + runs-on: ubuntu-latest + needs: wait-for-format + if: github.event_name == 'pull_request' && always() && (needs.wait-for-format.result == 'success' || needs.wait-for-format.result == 'skipped') + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev libpq-dev protobuf-compiler + + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Clippy with PR annotations + uses: giraffate/clippy-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-review + fail_on_error: true + clippy_flags: --workspace --all-targets -- -D warnings + rustfmt: name: rustfmt runs-on: ubuntu-latest + needs: wait-for-format + if: always() && (needs.wait-for-format.result == 'success' || needs.wait-for-format.result == 'skipped') steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} - uses: dtolnay/rust-toolchain@stable with: @@ -59,4 +127,16 @@ jobs: run: cargo install cargo-audit --locked - name: Audit - run: cargo audit + id: audit + run: cargo audit --json > audit-report.json || echo "failed=true" >> "$GITHUB_OUTPUT" + + - name: Post audit findings to PR + if: github.event_name == 'pull_request' && steps.audit.outputs.failed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SUMMARY=$(jq -r '.vulnerabilities.list[] | "- **\(.advisory.id)** \(.package.name)@\(.package.version): \(.advisory.title)"' audit-report.json) + gh pr comment "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ + --body "$(printf 'cargo audit found vulnerabilities:\n\n%s' "$SUMMARY")" + exit 1 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 24a6593a..c0df19b1 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -3,7 +3,7 @@ name: Pre-release Build on: push: branches: - - develop + - main workflow_dispatch: inputs: version: @@ -38,7 +38,7 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT else - BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + BASE=$(grep -m1 '^version' Cargo.toml | sed 's/[^"]*"\([^"]*\)".*/\1/') COUNT=$(git rev-list --count HEAD) echo "version=${BASE}-alpha.${COUNT}" >> $GITHUB_OUTPUT fi @@ -262,6 +262,7 @@ jobs: with: repository: ${{ github.repository_owner }}/CSFX-Infra token: ${{ secrets.INFRA_REPO_TOKEN }} + ref: main path: infra - uses: actions/download-artifact@v8 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/release-build.yml similarity index 93% rename from .github/workflows/docker-build.yml rename to .github/workflows/release-build.yml index 37f6570f..73a06f04 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/release-build.yml @@ -1,9 +1,6 @@ name: Release Build & Push on: - workflow_run: - workflows: ["Release Please"] - types: [completed] workflow_dispatch: inputs: version: @@ -19,32 +16,13 @@ jobs: prepare: name: Prepare Build Context runs-on: ubuntu-latest - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') outputs: version: ${{ steps.version.outputs.version }} - should_build: ${{ steps.version.outputs.should_build }} + should_build: "true" steps: - - uses: actions/checkout@v6 - - name: Resolve version id: version - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT - echo "should_build=true" >> $GITHUB_OUTPUT - else - TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' 2>/dev/null || echo "") - if [ -z "$TAG" ]; then - echo "should_build=false" >> $GITHUB_OUTPUT - else - echo "version=${TAG#v}" >> $GITHUB_OUTPUT - echo "should_build=true" >> $GITHUB_OUTPUT - fi - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT build-binaries: name: Build ${{ matrix.binary }} (${{ matrix.arch }}) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7b2244ab..2ff69371 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -25,3 +25,12 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Trigger full release build + if: steps.release.outputs.release_created == 'true' + env: + GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + run: | + gh workflow run release-build.yml \ + --repo "${{ github.repository }}" \ + --field version="${{ steps.release.outputs.version }}" diff --git a/Cargo.lock b/Cargo.lock index f5019f8b..6ecc057e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "api-gateway" @@ -174,6 +174,7 @@ dependencies = [ "dotenvy", "entity", "etcd-client", + "futures-util", "http", "hyper", "hyper-util", @@ -186,7 +187,7 @@ dependencies = [ "opentelemetry_sdk", "prometheus", "qrcode", - "rand 0.10.1", + "rand 0.10.2", "rcgen", "reqwest", "ring", @@ -201,6 +202,7 @@ dependencies = [ "sysinfo", "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "totp-rs", "tower-http 0.7.0", "tower_governor", @@ -223,9 +225,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -243,9 +245,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-slice" @@ -305,6 +307,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -387,6 +401,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-std" version = "1.13.2" @@ -525,6 +550,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -543,8 +569,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1 0.10.6", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -603,6 +631,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base32" version = "0.5.1" @@ -683,9 +717,9 @@ dependencies = [ [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -779,9 +813,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", @@ -790,9 +824,9 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate", @@ -855,15 +889,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -891,9 +925,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1132,6 +1166,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1157,6 +1203,8 @@ name = "csfx-agent" version = "0.2.2" dependencies = [ "anyhow", + "axum", + "base64", "bollard", "chrono", "futures-util", @@ -1171,6 +1219,9 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x25519-dalek", + "x509-parser", + "zbus", ] [[package]] @@ -1212,6 +1263,33 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.20.11" @@ -1373,6 +1451,44 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -1382,6 +1498,33 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "entity" version = "0.2.2" @@ -1394,6 +1537,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equator" version = "0.4.2" @@ -1551,6 +1715,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1762,6 +1942,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1862,6 +2043,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -2048,9 +2240,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -2466,9 +2658,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2482,11 +2674,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", "pem", + "rand 0.8.6", + "rsa", "serde", "serde_json", + "sha2 0.10.9", "signature", "simple_asn1", "zeroize", @@ -2540,14 +2739,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -2589,9 +2788,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" dependencies = [ "value-bag", ] @@ -2979,9 +3178,9 @@ dependencies = [ [[package]] name = "opentelemetry-semantic-conventions" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ca2f98a0437b427b4b08f19f1caa3c44db885a202bc12cfea13d6c702243d68" +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" [[package]] name = "opentelemetry_sdk" @@ -3010,6 +3209,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -3034,6 +3243,30 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -3275,6 +3508,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3563,9 +3805,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3611,9 +3853,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3768,9 +4010,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ "bitflags", ] @@ -3810,6 +4052,7 @@ version = "0.2.2" dependencies = [ "anyhow", "axum", + "base64", "chrono", "dotenvy", "entity", @@ -3855,6 +4098,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3873,16 +4117,28 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.8", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rgb" version = "0.8.53" @@ -4050,9 +4306,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "log", "once_cell", @@ -4086,9 +4342,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -4179,7 +4435,9 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "serde_yaml", "shared", + "thiserror 2.0.18", "tokio", "tracing", "tracing-opentelemetry", @@ -4393,6 +4651,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4499,6 +4771,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4558,13 +4843,16 @@ version = "0.2.2" dependencies = [ "anyhow", "async-trait", + "chrono", "dotenvy", + "entity", "sea-orm", "serde", "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -5069,9 +5357,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -5089,9 +5377,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -5136,6 +5424,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -5171,6 +5460,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5297,9 +5598,9 @@ dependencies = [ [[package]] name = "totp-rs" -version = "5.7.1" +version = "5.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba" +checksum = "50e69a15e21b2ff22c415446983978bded3244195f17d59cb113551c1e806f91" dependencies = [ "base32", "constant_time_eq", @@ -5487,12 +5788,39 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror 2.0.18", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5542,6 +5870,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -5623,9 +5957,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5741,9 +6075,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5755,9 +6089,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5765,9 +6099,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5775,9 +6109,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -5788,18 +6122,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6254,6 +6601,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -6317,6 +6676,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -6427,9 +6842,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" @@ -6472,3 +6887,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index b3f8233a..8c1be5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,17 +38,19 @@ tower-http = { version = "0.7.0", features = ["cors", "trace", "fs"] } hyper = "1.5" hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "http2", "tokio"] } http = "1.1" +tokio-tungstenite = "0.29" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" +serde_yaml = "0.9" # Logging & Tracing tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "registry"] } # Security -jsonwebtoken = "10.0.0" +jsonwebtoken = { version = "10.0.0", features = ["rust_crypto"] } bcrypt = "0.19" rsa = { version = "0.9", features = ["sha2"] } totp-rs = { version = "5.6", features = ["qr", "otpauth"] } @@ -88,6 +90,7 @@ tracing-opentelemetry = { version = "0.33" } # Other rand = "0.10" +x25519-dalek = { version = "2", features = ["static_secrets"] } aes-gcm = "0.10" base64 = "0.22" sha1 = "0.11" @@ -98,6 +101,7 @@ image = "0.25" sysinfo = "0.32" bollard = "0.21" futures-util = "0.3" +zbus = { version = "5", default-features = false, features = ["tokio"] } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "webpki-roots"] } [profile.release] diff --git a/agent/Cargo.toml b/agent/Cargo.toml index fe7acd4c..85ecbafc 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -24,3 +24,8 @@ sysinfo = { workspace = true } rcgen = { workspace = true } bollard = { workspace = true } futures-util = { workspace = true } +zbus = { workspace = true } +x25519-dalek = { workspace = true } +base64 = { workspace = true } +axum = { workspace = true, features = ["ws"] } +x509-parser = { workspace = true } diff --git a/agent/src/client.rs b/agent/src/client.rs index d87cfce3..0bd04849 100644 --- a/agent/src/client.rs +++ b/agent/src/client.rs @@ -1,5 +1,7 @@ use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; use reqwest::Client; +use ring::rand::{SecureRandom, SystemRandom}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; @@ -48,6 +50,10 @@ struct HeartbeatRequest { network_rx_bytes: Option, network_tx_bytes: Option, uptime_seconds: Option, + wg_public_key: Option, + wg_endpoint: Option, + wg_tunnel_ip: Option, + agent_version: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -63,6 +69,12 @@ pub struct HeartbeatResponse { pub post_update_heartbeats: Option, } +#[derive(Debug, Deserialize, Clone)] +pub struct VolumeMount { + pub volume_id: String, + pub mount_path: String, +} + #[derive(Debug, Deserialize)] pub struct AssignedWorkload { pub id: String, @@ -73,18 +85,42 @@ pub struct AssignedWorkload { pub disk_bytes: i64, pub env_vars: Option>, pub ports: Option>, + pub volume_mounts: Option>, pub status: String, pub container_id: Option, + pub stack_id: Option, + pub service_name: Option, } pub struct ApiClient { client: Client, gateway_url: String, cert_pem: Option, + wg_public_key: String, + wg_endpoint: Option, + wg_tunnel_ip: Option, +} + +pub fn generate_wg_keypair() -> (String, String) { + let rng = SystemRandom::new(); + let mut private_bytes = [0u8; 32]; + rng.fill(&mut private_bytes) + .expect("failed to generate WireGuard key"); + private_bytes[0] &= 248; + private_bytes[31] &= 127; + private_bytes[31] |= 64; + let secret = x25519_dalek::StaticSecret::from(private_bytes); + let public = x25519_dalek::PublicKey::from(&secret); + (B64.encode(private_bytes), B64.encode(public.to_bytes())) } impl ApiClient { - pub fn new(gateway_url: String) -> Result { + pub fn new( + gateway_url: String, + wg_public_key: String, + wg_endpoint: Option, + wg_tunnel_ip: Option, + ) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .danger_accept_invalid_certs(true) @@ -95,6 +131,9 @@ impl ApiClient { client, gateway_url, cert_pem: None, + wg_public_key, + wg_endpoint, + wg_tunnel_ip, }) } @@ -214,6 +253,10 @@ impl ApiClient { network_rx_bytes, network_tx_bytes, uptime_seconds, + wg_public_key: Some(self.wg_public_key.clone()), + wg_endpoint: self.wg_endpoint.clone(), + wg_tunnel_ip: self.wg_tunnel_ip.clone(), + agent_version: Some(env!("CARGO_PKG_VERSION").to_string()), }); if let Some(ref cert_pem) = self.cert_pem { diff --git a/agent/src/docker.rs b/agent/src/docker.rs index 7ca8262d..d67bfb1c 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -1,19 +1,39 @@ use anyhow::{Context, Result}; -use bollard::models::{ContainerCreateBody, HostConfig}; +use axum::body::Bytes; +use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults}; +use bollard::models::{ + ContainerCreateBody, ContainerStateStatusEnum, EndpointSettings, HostConfig, + NetworkCreateRequest, NetworkingConfig, +}; use bollard::query_parameters::{ - CreateContainerOptionsBuilder, CreateImageOptionsBuilder, StartContainerOptionsBuilder, + CreateContainerOptionsBuilder, CreateImageOptionsBuilder, InspectContainerOptionsBuilder, + ListNetworksOptionsBuilder, LogsOptionsBuilder, StartContainerOptionsBuilder, }; use bollard::Docker; -use futures_util::StreamExt; +use futures_util::{Stream, StreamExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; use tracing::{info, warn}; +use zbus::Connection; + +const DOCKER_SOCKET_PATH: &str = "/var/run/docker.sock"; +const DOCKER_UNIT_NAME: &str = "docker.service"; +const DOCKER_START_TIMEOUT: Duration = Duration::from_secs(30); +const DOCKER_SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(200); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { - pub host_port: u16, pub container_port: u16, pub protocol: Option, + pub node_port: Option, +} + +#[derive(Debug, Clone)] +pub struct VolumeMount { + pub volume_id: String, + pub mount_path: String, } #[derive(Debug, Clone)] @@ -23,6 +43,9 @@ pub struct WorkloadSpec { pub image: String, pub env_vars: Option>, pub ports: Option>, + pub volume_mounts: Option>, + pub stack_id: Option, + pub service_name: Option, } pub struct DockerManager { @@ -30,7 +53,12 @@ pub struct DockerManager { } impl DockerManager { - pub fn new() -> Result { + pub async fn ensure_running() -> Result { + if !Path::new(DOCKER_SOCKET_PATH).exists() { + start_docker_unit().await?; + wait_for_socket().await?; + } + let docker = Docker::connect_with_unix_defaults().context("Failed to connect to Docker socket")?; Ok(Self { docker }) @@ -69,6 +97,44 @@ impl DockerManager { Ok(()) } + pub async fn ensure_stack_network(&self, stack_id: &str) -> Result { + let network_name = format!("csfx-stack-{}", stack_id); + + let filters = HashMap::from([("name".to_string(), vec![network_name.clone()])]); + let list_options = ListNetworksOptionsBuilder::default() + .filters(&filters) + .build(); + + let existing = self + .docker + .list_networks(Some(list_options)) + .await + .context("Failed to list networks")?; + + if let Some(network) = existing + .into_iter() + .find(|n| n.name.as_deref() == Some(&network_name)) + { + return Ok(network.id.unwrap_or(network_name)); + } + + let config = NetworkCreateRequest { + name: network_name.clone(), + driver: Some("bridge".to_string()), + ..Default::default() + }; + + let response = self + .docker + .create_network(config) + .await + .context("Failed to create stack network")?; + + info!(stack_id = %stack_id, network = %network_name, "Stack network ready"); + + Ok(response.id) + } + pub async fn start_container(&self, spec: &WorkloadSpec) -> Result { let container_name = format!("csfx-{}", spec.workload_id); @@ -79,15 +145,46 @@ impl DockerManager { let (port_bindings, exposed_ports) = build_port_config(spec.ports.as_deref()); + let binds = spec.volume_mounts.as_deref().map(|mounts| { + mounts + .iter() + .map(|m| { + format!( + "{}:{}", + crate::rbd::mount_point_for(&m.volume_id), + m.mount_path + ) + }) + .collect::>() + }); + let host_config = HostConfig { port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, + binds, ..Default::default() }; + let networking_config = match &spec.stack_id { + Some(stack_id) => { + let network_name = self.ensure_stack_network(stack_id).await?; + let aliases = spec.service_name.clone().map(|name| vec![name]); + Some(NetworkingConfig { + endpoints_config: Some(HashMap::from([( + network_name, + EndpointSettings { + aliases, + ..Default::default() + }, + )])), + }) + } + None => None, + }; + let config = ContainerCreateBody { image: Some(spec.image.clone()), env, @@ -97,6 +194,7 @@ impl DockerManager { Some(exposed_ports) }, host_config: Some(host_config), + networking_config, labels: Some(HashMap::from([ ("csfx.workload_id".to_string(), spec.workload_id.clone()), ("csfx.managed".to_string(), "true".to_string()), @@ -130,6 +228,82 @@ impl DockerManager { Ok(container.id) } + pub async fn inspect_status(&self, container_id: &str) -> Result { + let options = InspectContainerOptionsBuilder::default().build(); + + let inspect = self + .docker + .inspect_container(container_id, Some(options)) + .await + .context("Failed to inspect container")?; + + let state = inspect.state.unwrap_or_default(); + let status = state.status.unwrap_or(ContainerStateStatusEnum::EMPTY); + let exit_code = state.exit_code.unwrap_or(0); + + Ok(match status { + ContainerStateStatusEnum::CREATED => "creating".to_string(), + ContainerStateStatusEnum::RUNNING => "running".to_string(), + ContainerStateStatusEnum::RESTARTING | ContainerStateStatusEnum::PAUSED => { + "running".to_string() + } + ContainerStateStatusEnum::EXITED if exit_code == 0 => "stopped".to_string(), + ContainerStateStatusEnum::EXITED | ContainerStateStatusEnum::DEAD => { + "failed".to_string() + } + ContainerStateStatusEnum::REMOVING | ContainerStateStatusEnum::STOPPING => { + "stopped".to_string() + } + ContainerStateStatusEnum::EMPTY => "failed".to_string(), + }) + } + + pub fn logs( + &self, + container_id: &str, + ) -> impl Stream> + Send + 'static { + let options = LogsOptionsBuilder::default() + .stdout(true) + .stderr(true) + .follow(true) + .tail("200") + .build(); + + self.docker + .logs(container_id, Some(options)) + .map(|item| match item { + Ok(log_output) => Ok(log_output.into_bytes()), + Err(e) => Err(std::io::Error::other(e.to_string())), + }) + } + + pub async fn exec(&self, container_id: &str) -> Result { + let create_options = CreateExecOptions { + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + tty: Some(true), + cmd: Some(vec!["/bin/sh".to_string()]), + ..Default::default() + }; + + let created = self + .docker + .create_exec(container_id, create_options) + .await + .context("Failed to create exec session")?; + + let start_options = StartExecOptions { + tty: true, + ..Default::default() + }; + + self.docker + .start_exec(&created.id, Some(start_options)) + .await + .context("Failed to start exec session") + } + pub async fn stop_container(&self, container_id: &str) -> Result<()> { self.docker .stop_container(container_id, None) @@ -146,6 +320,47 @@ impl DockerManager { } } +async fn start_docker_unit() -> Result<()> { + info!(unit = DOCKER_UNIT_NAME, "Starting Docker via systemd"); + + let connection = Connection::system() + .await + .context("Failed to connect to system D-Bus")?; + + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + ) + .await + .context("Failed to build systemd D-Bus proxy")?; + + proxy + .call_method("StartUnit", &(DOCKER_UNIT_NAME, "replace")) + .await + .context("Failed to call StartUnit for docker.service")?; + + Ok(()) +} + +async fn wait_for_socket() -> Result<()> { + let deadline = tokio::time::Instant::now() + DOCKER_START_TIMEOUT; + + while tokio::time::Instant::now() < deadline { + if Path::new(DOCKER_SOCKET_PATH).exists() { + info!(socket = DOCKER_SOCKET_PATH, "Docker socket available"); + return Ok(()); + } + tokio::time::sleep(DOCKER_SOCKET_POLL_INTERVAL).await; + } + + anyhow::bail!( + "Docker socket did not appear within {}s", + DOCKER_START_TIMEOUT.as_secs() + ) +} + fn build_port_config( ports: Option<&[PortMapping]>, ) -> ( @@ -160,16 +375,17 @@ fn build_port_config( for p in ports { let proto = p.protocol.as_deref().unwrap_or("tcp"); let container_key = format!("{}/{}", p.container_port, proto); + exposed_ports.push(container_key.clone()); - port_bindings.insert( - container_key.clone(), - Some(vec![bollard::models::PortBinding { - host_ip: Some("0.0.0.0".to_string()), - host_port: Some(p.host_port.to_string()), - }]), - ); - - exposed_ports.push(container_key); + if let Some(node_port) = p.node_port { + port_bindings.insert( + container_key, + Some(vec![bollard::models::PortBinding { + host_ip: Some("0.0.0.0".to_string()), + host_port: Some(node_port.to_string()), + }]), + ); + } } } diff --git a/agent/src/main.rs b/agent/src/main.rs index 9a37e110..86585492 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -3,6 +3,7 @@ mod config; mod docker; mod pki; mod rbd; +mod server; mod ssh_keys; mod system; mod update_watch; @@ -51,8 +52,17 @@ async fn main() -> Result<()> { .and_then(|v| v.parse().ok()) .unwrap_or(60); - let api_client = - client::ApiClient::new(gateway_url.clone()).context("Failed to initialize API client")?; + let wg_endpoint = std::env::var("CSFX_WG_ENDPOINT").ok(); + let wg_tunnel_ip = std::env::var("CSFX_WG_TUNNEL_IP").ok(); + let (_wg_private_key, wg_public_key) = client::generate_wg_keypair(); + + let api_client = client::ApiClient::new( + gateway_url.clone(), + wg_public_key, + wg_endpoint, + wg_tunnel_ip, + ) + .context("Failed to initialize API client")?; let agent_pki = pki::AgentPki::load_or_generate().context("Failed to initialize PKI")?; @@ -89,22 +99,33 @@ async fn main() -> Result<()> { info!(agent_id = %agent_id, "Agent registered, starting heartbeat loop"); - let docker_manager = match docker::DockerManager::new() { - Ok(dm) => { - info!("Docker socket connected"); - Some(Arc::new(dm)) - } - Err(e) => { - warn!(error = %e, "Docker unavailable, container management disabled"); - None - } - }; + let docker_manager: Arc>> = Arc::new(Mutex::new(None)); let running_containers: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let workload_phases: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let mounted_volumes: Arc>> = Arc::new(Mutex::new(HashMap::new())); + if let Some(port) = std::env::var("CSFX_AGENT_PORT") + .ok() + .and_then(|v| v.parse::().ok()) + { + let server_state = server::ServerState { + docker: docker_manager.clone(), + running_containers: running_containers.clone(), + agent_id, + }; + tokio::spawn(async move { + if let Err(e) = server::run(server_state, port).await { + error!(error = %e, "agent inbound server stopped"); + } + }); + } else { + info!("CSFX_AGENT_PORT not set, agent inbound server disabled"); + } + run_heartbeat_loop( &api_client, agent_id, @@ -112,6 +133,7 @@ async fn main() -> Result<()> { heartbeat_interval_secs, docker_manager, running_containers, + workload_phases, mounted_volumes, ) .await; @@ -187,8 +209,9 @@ async fn run_heartbeat_loop( agent_id: uuid::Uuid, api_key: &str, interval_secs: u64, - docker: Option>, + docker: Arc>>, running_containers: Arc>>, + workload_phases: Arc>>, mounted_volumes: Arc>>, ) { let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); @@ -199,12 +222,9 @@ async fn run_heartbeat_loop( tokio::select! { _ = interval.tick() => { process_volumes(client, agent_id, api_key, &mounted_volumes).await; + process_workloads(client, api_key, &docker, &running_containers, &workload_phases, &mounted_volumes).await; - if let Some(ref dm) = docker { - process_workloads(client, api_key, dm, &running_containers).await; - } - - let statuses = build_container_statuses(&running_containers).await; + let statuses = build_container_statuses(&docker, &running_containers, &workload_phases).await; let metrics = system::collect_metrics(); match client.heartbeat(agent_id, api_key, Some(statuses), Some(metrics)).await { @@ -322,8 +342,10 @@ async fn process_volumes( async fn process_workloads( client: &client::ApiClient, api_key: &str, - docker: &docker::DockerManager, + docker: &Arc>>, running_containers: &Arc>>, + workload_phases: &Arc>>, + mounted_volumes: &Arc>>, ) { let workloads = match client.fetch_assigned_workloads(api_key).await { Ok(w) => w, @@ -333,6 +355,27 @@ async fn process_workloads( } }; + if workloads.is_empty() { + return; + } + + let mut docker_guard = docker.lock().await; + if docker_guard.is_none() { + match docker::DockerManager::ensure_running().await { + Ok(dm) => { + info!("Docker ready"); + *docker_guard = Some(dm); + } + Err(e) => { + warn!(error = %e, "Docker unavailable, deferring workloads"); + return; + } + } + } + let docker = docker_guard + .as_ref() + .expect("docker manager initialized above"); + for workload in workloads { let already_running = running_containers.lock().await.contains_key(&workload.id); @@ -342,21 +385,57 @@ async fn process_workloads( info!(workload_id = %workload.id, image = %workload.image, "Starting workload"); + workload_phases + .lock() + .await + .insert(workload.id.clone(), "pulling".to_string()); + if let Err(e) = docker.pull_image(&workload.image).await { warn!(workload_id = %workload.id, error = %e, "Failed to pull image"); + workload_phases.lock().await.remove(&workload.id); continue; } + if let Some(ref mounts) = workload.volume_mounts { + let locked = mounted_volumes.lock().await; + let all_ready = mounts.iter().all(|m| locked.contains_key(&m.volume_id)); + drop(locked); + if !all_ready { + info!(workload_id = %workload.id, "Waiting for volumes to be mounted, deferring workload"); + continue; + } + } + + workload_phases + .lock() + .await + .insert(workload.id.clone(), "creating".to_string()); + let spec = docker::WorkloadSpec { workload_id: workload.id.clone(), name: workload.name.clone(), image: workload.image.clone(), env_vars: workload.env_vars, ports: workload.ports, + volume_mounts: workload.volume_mounts.map(|mounts| { + mounts + .into_iter() + .map(|m| docker::VolumeMount { + volume_id: m.volume_id, + mount_path: m.mount_path, + }) + .collect() + }), + stack_id: workload.stack_id, + service_name: workload.service_name, }; match docker.start_container(&spec).await { Ok(container_id) => { + workload_phases + .lock() + .await + .insert(workload.id.clone(), "starting".to_string()); running_containers .lock() .await @@ -368,6 +447,7 @@ async fn process_workloads( ); } Err(e) => { + workload_phases.lock().await.remove(&workload.id); warn!(workload_id = %workload.id, error = %e, "Failed to start container"); } } @@ -375,16 +455,48 @@ async fn process_workloads( } async fn build_container_statuses( + docker: &Arc>>, running_containers: &Arc>>, + workload_phases: &Arc>>, ) -> Vec { - running_containers - .lock() - .await - .iter() - .map(|(workload_id, container_id)| client::ContainerStatus { + let containers = running_containers.lock().await.clone(); + let mut statuses = Vec::with_capacity(containers.len()); + + let docker_guard = docker.lock().await; + for (workload_id, container_id) in containers.iter() { + let status = match docker_guard.as_ref() { + Some(dm) => match dm.inspect_status(container_id).await { + Ok(s) => s, + Err(e) => { + warn!(workload_id = %workload_id, container_id = %container_id, error = %e, "Failed to inspect container"); + "failed".to_string() + } + }, + None => "failed".to_string(), + }; + + if status != "creating" { + workload_phases.lock().await.remove(workload_id); + } + + statuses.push(client::ContainerStatus { workload_id: workload_id.clone(), container_id: container_id.clone(), - status: "running".to_string(), - }) - .collect() + status, + }); + } + drop(docker_guard); + + let phases = workload_phases.lock().await; + for (workload_id, phase) in phases.iter() { + if !containers.contains_key(workload_id) { + statuses.push(client::ContainerStatus { + workload_id: workload_id.clone(), + container_id: String::new(), + status: phase.clone(), + }); + } + } + + statuses } diff --git a/agent/src/server.rs b/agent/src/server.rs new file mode 100644 index 00000000..eab38338 --- /dev/null +++ b/agent/src/server.rs @@ -0,0 +1,334 @@ +use anyhow::{anyhow, Context, Result}; +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::{ConnectInfo, Path, State}, + http::{HeaderMap, StatusCode}, + routing::get, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use bollard::exec::StartExecResults; +use futures_util::{SinkExt, StreamExt}; +use ring::signature::{UnparsedPublicKey, ECDSA_P256_SHA256_ASN1}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +use crate::docker::DockerManager; +use crate::pki::AgentPki; +use crate::system::LiveMetricsCollector; + +const TICKET_HEADER: &str = "X-Csfx-Proxy-Ticket"; +pub const METRICS_TICKET_SCOPE: &str = "__node_metrics__"; + +#[derive(Clone)] +pub struct ServerState { + pub docker: Arc>>, + pub running_containers: Arc>>, + pub agent_id: uuid::Uuid, +} + +pub async fn run(state: ServerState, port: u16) -> Result<()> { + let app = Router::new() + .route("/logs/{workload_id}", get(logs_handler)) + .route("/exec/{workload_id}", get(exec_handler)) + .route("/metrics/stream", get(metrics_stream_handler)) + .with_state(state); + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); + let listener = tokio::net::TcpListener::bind(addr) + .await + .context("Failed to bind agent inbound server")?; + + info!(port = port, "csfx-agent inbound server listening"); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .context("Agent inbound server stopped unexpectedly") +} + +fn is_internal_source(addr: &SocketAddr) -> bool { + let ip = addr.ip(); + ip.is_loopback() + || match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + octets[0] == 10 + || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] == 192 && octets[1] == 168) + } + IpAddr::V6(_) => false, + } +} + +struct TicketClaims { + agent_id: String, + workload_id: String, + expires_at: i64, +} + +fn verify_ticket(headers: &HeaderMap, workload_id: &str, expected_agent_id: &str) -> Result<()> { + let raw = headers + .get(TICKET_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("missing {} header", TICKET_HEADER))?; + + let (payload_b64, signature_b64) = raw + .split_once('.') + .ok_or_else(|| anyhow!("malformed proxy ticket"))?; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .context("invalid ticket payload encoding")?; + let signature = URL_SAFE_NO_PAD + .decode(signature_b64) + .context("invalid ticket signature encoding")?; + + let ca_pem = AgentPki::load_ca_pem().context("failed to load trusted CA certificate")?; + let public_key_bytes = extract_ca_public_key(&ca_pem)?; + + let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, &public_key_bytes); + verifier + .verify(&payload_bytes, &signature) + .map_err(|_| anyhow!("proxy ticket signature verification failed"))?; + + let claims = parse_claims(&payload_bytes)?; + + if claims.workload_id != workload_id { + return Err(anyhow!("proxy ticket workload_id mismatch")); + } + if claims.agent_id != expected_agent_id { + return Err(anyhow!("proxy ticket agent_id mismatch")); + } + if claims.expires_at < chrono::Utc::now().timestamp() { + return Err(anyhow!("proxy ticket expired")); + } + + Ok(()) +} + +fn parse_claims(payload_bytes: &[u8]) -> Result { + let payload = std::str::from_utf8(payload_bytes).context("proxy ticket payload not utf8")?; + let mut parts = payload.splitn(3, '.'); + + let agent_id = parts + .next() + .ok_or_else(|| anyhow!("proxy ticket missing agent_id"))? + .to_string(); + let workload_id = parts + .next() + .ok_or_else(|| anyhow!("proxy ticket missing workload_id"))? + .to_string(); + let expires_at = parts + .next() + .ok_or_else(|| anyhow!("proxy ticket missing expiry"))? + .parse::() + .context("proxy ticket expiry not a valid timestamp")?; + + Ok(TicketClaims { + agent_id, + workload_id, + expires_at, + }) +} + +fn extract_ca_public_key(ca_pem: &str) -> Result> { + let (_, pem) = + x509_parser::pem::parse_x509_pem(ca_pem.as_bytes()).context("failed to parse CA PEM")?; + let cert = pem.parse_x509().context("failed to parse CA certificate")?; + Ok(cert + .tbs_certificate + .subject_pki + .subject_public_key + .data + .to_vec()) +} + +async fn logs_handler( + State(state): State, + Path(workload_id): Path, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result { + if !is_internal_source(&addr) { + warn!(source = %addr, "rejected agent inbound request from non-internal source"); + return Err((StatusCode::FORBIDDEN, "source not allowed".to_string())); + } + + verify_ticket(&headers, &workload_id, &state.agent_id.to_string()) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + let container_id = state + .running_containers + .lock() + .await + .get(&workload_id) + .cloned() + .ok_or(( + StatusCode::NOT_FOUND, + "workload not running here".to_string(), + ))?; + + let docker_guard = state.docker.lock().await; + let docker = docker_guard.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "docker unavailable".to_string(), + ))?; + + let stream = docker.logs(&container_id); + Ok(axum::body::Body::from_stream(stream)) +} + +async fn exec_handler( + State(state): State, + Path(workload_id): Path, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Result { + if !is_internal_source(&addr) { + warn!(source = %addr, "rejected agent inbound request from non-internal source"); + return Err((StatusCode::FORBIDDEN, "source not allowed".to_string())); + } + + verify_ticket(&headers, &workload_id, &state.agent_id.to_string()) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + let container_id = state + .running_containers + .lock() + .await + .get(&workload_id) + .cloned() + .ok_or(( + StatusCode::NOT_FOUND, + "workload not running here".to_string(), + ))?; + + Ok(ws.on_upgrade(move |socket| handle_exec_socket(socket, state, container_id))) +} + +async fn handle_exec_socket(socket: WebSocket, state: ServerState, container_id: String) { + let docker_guard = state.docker.lock().await; + let docker = match docker_guard.as_ref() { + Some(d) => d, + None => { + warn!("docker unavailable for exec session"); + return; + } + }; + + let exec_result = match docker.exec(&container_id).await { + Ok(r) => r, + Err(e) => { + warn!(container_id = %container_id, error = %e, "failed to start exec session"); + return; + } + }; + drop(docker_guard); + + let StartExecResults::Attached { + mut output, + mut input, + } = exec_result + else { + warn!(container_id = %container_id, "exec session detached unexpectedly"); + return; + }; + + let (mut ws_sink, mut ws_stream) = socket.split(); + + let output_task = tokio::spawn(async move { + while let Some(item) = output.next().await { + match item { + Ok(log_output) => { + let bytes = log_output.into_bytes(); + if ws_sink.send(Message::Binary(bytes)).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let input_task = tokio::spawn(async move { + while let Some(Ok(msg)) = ws_stream.next().await { + match msg { + Message::Binary(data) => { + if input.write_all(&data).await.is_err() { + break; + } + } + Message::Text(text) => { + if input.write_all(text.as_bytes()).await.is_err() { + break; + } + } + Message::Close(_) => break, + _ => {} + } + } + }); + + tokio::select! { + _ = output_task => {} + _ = input_task => {} + } + + info!(container_id = %container_id, "exec session closed"); +} + +async fn metrics_stream_handler( + State(state): State, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Result { + if !is_internal_source(&addr) { + warn!(source = %addr, "rejected agent inbound request from non-internal source"); + return Err((StatusCode::FORBIDDEN, "source not allowed".to_string())); + } + + verify_ticket(&headers, METRICS_TICKET_SCOPE, &state.agent_id.to_string()) + .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?; + + Ok(ws.on_upgrade(handle_metrics_socket)) +} + +async fn handle_metrics_socket(socket: WebSocket) { + let (mut sink, mut stream) = socket.split(); + let mut collector = LiveMetricsCollector::new(); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + + loop { + tokio::select! { + _ = interval.tick() => { + let metrics = collector.sample(); + let payload = match serde_json::to_string(&metrics) { + Ok(p) => p, + Err(e) => { + warn!(error = %e, "failed to serialize live metrics"); + continue; + } + }; + if sink.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + msg = stream.next() => match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + } + + info!("live metrics session closed"); +} diff --git a/agent/src/system.rs b/agent/src/system.rs index 2ae27cae..94ad02d2 100644 --- a/agent/src/system.rs +++ b/agent/src/system.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use sysinfo::{Disks, Networks, System}; pub struct SystemInfo { @@ -7,6 +8,7 @@ pub struct SystemInfo { pub architecture: String, } +#[derive(Serialize)] pub struct SystemMetrics { pub cpu_usage_percent: f32, pub cpu_cores: u32, @@ -19,11 +21,60 @@ pub struct SystemMetrics { pub uptime_seconds: u64, } +pub struct LiveMetricsCollector { + sys: System, +} + +impl LiveMetricsCollector { + pub fn new() -> Self { + let mut sys = System::new_all(); + sys.refresh_cpu_usage(); + Self { sys } + } + + pub fn sample(&mut self) -> SystemMetrics { + self.sys.refresh_cpu_usage(); + self.sys.refresh_memory(); + + let cpu_usage_percent = self.sys.global_cpu_usage(); + let cpu_cores = self.sys.cpus().len() as u32; + let memory_total_bytes = self.sys.total_memory(); + let memory_used_bytes = self.sys.used_memory(); + + let disks = Disks::new_with_refreshed_list(); + let (disk_total_bytes, disk_used_bytes) = + disks.iter().fold((0u64, 0u64), |(total, used), d| { + ( + total + d.total_space(), + used + (d.total_space() - d.available_space()), + ) + }); + + let networks = Networks::new_with_refreshed_list(); + let (network_rx_bytes, network_tx_bytes) = + networks.iter().fold((0u64, 0u64), |(rx, tx), (_, data)| { + (rx + data.total_received(), tx + data.total_transmitted()) + }); + + SystemMetrics { + cpu_usage_percent, + cpu_cores, + memory_total_bytes, + memory_used_bytes, + disk_total_bytes, + disk_used_bytes, + network_rx_bytes, + network_tx_bytes, + uptime_seconds: System::uptime(), + } + } +} + fn parse_os_release_field(content: &str, field: &str) -> Option { content .lines() .find(|l| l.starts_with(field)) - .and_then(|l| l.splitn(2, '=').nth(1)) + .and_then(|l| l.split_once('=').map(|x| x.1)) .map(|v| v.trim_matches('"').to_string()) } diff --git a/app/package-lock.json b/app/package-lock.json index 9a580648..0dba6c85 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@iconify/svelte": "^5.2.1", "@jis3r/icons": "^2.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "geist": "^1.7.0" }, "devDependencies": { "@fontsource-variable/inter": "^5.2.8", "@internationalized/date": "^3.12.1", - "@lucide/svelte": "^1.17.0", + "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", @@ -23,9 +25,11 @@ "@tailwindcss/vite": "^4.2.2", "bits-ui": "^2.18.0", "clsx": "^2.1.1", + "mode-watcher": "^1.1.0", "shadcn-svelte": "^1.2.7", "svelte": "^5.55.2", "svelte-check": "^4.4.6", + "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", @@ -682,9 +686,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.17.0.tgz", - "integrity": "sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz", + "integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==", "dev": true, "license": "ISC", "peerDependencies": { @@ -1544,6 +1548,21 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2129,6 +2148,73 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mode-watcher": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", + "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.27.0" + } + }, + "node_modules/mode-watcher/node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2637,6 +2723,36 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-sonner": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", + "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", diff --git a/app/package.json b/app/package.json index 333e55a8..330b4695 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@fontsource-variable/inter": "^5.2.8", "@internationalized/date": "^3.12.1", - "@lucide/svelte": "^1.17.0", + "@lucide/svelte": "^1.23.0", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.57.0", @@ -22,9 +22,11 @@ "@tailwindcss/vite": "^4.2.2", "bits-ui": "^2.18.0", "clsx": "^2.1.1", + "mode-watcher": "^1.1.0", "shadcn-svelte": "^1.2.7", "svelte": "^5.55.2", "svelte-check": "^4.4.6", + "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", @@ -35,6 +37,8 @@ "dependencies": { "@iconify/svelte": "^5.2.1", "@jis3r/icons": "^2.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "geist": "^1.7.0" } } diff --git a/app/src/lib/api/logs.ts b/app/src/lib/api/logs.ts new file mode 100644 index 00000000..033a215c --- /dev/null +++ b/app/src/lib/api/logs.ts @@ -0,0 +1,83 @@ +const API_BASE = '/api'; + +export interface LogEntry { + id: string; + service: string; + level: string; + classification: string; + message: string; + agent_id: string | null; + workload_id: string | null; + organization_id: string | null; + created_at: string; +} + +export interface LogsResponse { + entries: LogEntry[]; + total: number; + has_more: boolean; +} + +export interface LogsFilter { + service?: string; + level?: string; + classification?: string; + agent_id?: string; + workload_id?: string; + organization_id?: string; + from?: string; + to?: string; + q?: string; + limit?: number; + offset?: number; +} + +export interface LogsRetention { + retention_days: number; +} + +function buildQuery(filter: LogsFilter): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filter)) { + if (value !== undefined && value !== null && value !== '') { + params.set(key, String(value)); + } + } + const query = params.toString(); + return query ? `?${query}` : ''; +} + +export async function listLogs(token: string, filter: LogsFilter): Promise { + const res = await fetch(`${API_BASE}/logs${buildQuery(filter)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to list logs: ${res.status}`); + return res.json(); +} + +export async function getLogsRetention(token: string): Promise { + const res = await fetch(`${API_BASE}/admin/settings/logs-retention`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to get logs retention: ${res.status}`); + return res.json(); +} + +export async function updateLogsRetention( + token: string, + retentionDays: number, +): Promise { + const res = await fetch(`${API_BASE}/admin/settings/logs-retention`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ retention_days: retentionDays }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to update logs retention: ${res.status}`); + } + return res.json(); +} diff --git a/app/src/lib/api/nodes.ts b/app/src/lib/api/nodes.ts index f48afaaa..131174ef 100644 --- a/app/src/lib/api/nodes.ts +++ b/app/src/lib/api/nodes.ts @@ -1,4 +1,4 @@ -const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api'; +const API_BASE = '/api'; export interface Node { id: string; @@ -92,6 +92,34 @@ export async function getNodeMetricsLatest(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/agents/${agentId}/metrics/ticket`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to issue metrics ticket: ${res.status}`); + } + const { ticket } = await res.json(); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProtocol}//${window.location.host}${API_BASE}/agents/${agentId}/metrics/stream?ticket=${encodeURIComponent(ticket)}`; + return new WebSocket(url); +} + export interface HealthHistoryPoint { bucket: string; online_count: number; diff --git a/app/src/lib/api/resource-groups.ts b/app/src/lib/api/resource-groups.ts new file mode 100644 index 00000000..8d79a55f --- /dev/null +++ b/app/src/lib/api/resource-groups.ts @@ -0,0 +1,249 @@ +const API_BASE = '/api'; + +export interface ResourceGroup { + id: string; + organization_id: string; + name: string; + description: string | null; + internal_cidr: string; + status: string; + created_at: string; + updated_at: string | null; +} + +export interface CreateResourceGroupRequest { + name: string; + description?: string; + internal_cidr: string; +} + +export interface PortMapping { + container_port: number; + protocol: string | null; + node_port: number | null; +} + +export interface VolumeMount { + volume_id: string; + mount_path: string; +} + +export interface Volume { + id: string; + name: string; + size_gb: number; + pool: string; + status: string; + attached_to_workload: string | null; + resource_group_id: string | null; + created_at: string; +} + +export interface Workload { + id: string; + name: string; + image: string; + cpu_millicores: number; + memory_bytes: number; + disk_bytes: number; + status: string; + assigned_agent_id: string | null; + container_id: string | null; + volume_mounts: VolumeMount[] | null; + resource_group_id: string | null; + stack_id: string | null; + service_name: string | null; + created_at: string; + updated_at: string | null; +} + +export interface CreateWorkloadRequest { + name: string; + image: string; + cpu_millicores: number; + memory_bytes: number; + disk_bytes: number; + env_vars: Record | null; + ports: PortMapping[] | null; + volume_mounts: VolumeMount[] | null; + resource_group_id: string; +} + +export interface CreateVolumeRequest { + name: string; + size_gb: number; + resource_group_id: string; +} + +export interface CreateStackRequest { + name: string; + resource_group_id: string; + compose_yaml: string; +} + +export interface CreateStackWorkloadResult { + workload_id: string; + status: string; + assigned_agent_id: string | null; + message: string; +} + +export interface CreateStackResponse { + stack_id: string; + workloads: CreateStackWorkloadResult[]; +} + +export async function listResourceGroups(token: string): Promise { + const res = await fetch(`${API_BASE}/resource-groups`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to list resource groups: ${res.status}`); + return res.json(); +} + +export async function getResourceGroup(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/resource-groups/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to get resource group: ${res.status}`); + return res.json(); +} + +export async function createResourceGroup( + token: string, + req: CreateResourceGroupRequest, +): Promise { + const res = await fetch(`${API_BASE}/resource-groups`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to create resource group: ${res.status}`); + return res.json(); +} + +export async function deleteResourceGroup(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/resource-groups/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to delete resource group: ${res.status}`); +} + +export async function listResourceGroupWorkloads(token: string, rgId: string): Promise { + const res = await fetch(`${API_BASE}/resource-groups/${rgId}/workloads`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to list workloads: ${res.status}`); + return res.json(); +} + +export async function createWorkload(token: string, req: CreateWorkloadRequest): Promise<{ workload_id: string; status: string; assigned_agent_id: string | null; message: string }> { + const res = await fetch(`${API_BASE}/workloads`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to create workload: ${res.status}`); + } + return res.json(); +} + +export async function createWorkloadStack( + token: string, + req: CreateStackRequest, +): Promise { + const res = await fetch(`${API_BASE}/workload-stacks`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to create stack: ${res.status}`); + } + return res.json(); +} + +export async function deleteWorkload(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/workloads/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to delete workload: ${res.status}`); +} + +export async function streamWorkloadLogs( + token: string, + id: string, + signal: AbortSignal, +): Promise> { + const res = await fetch(`${API_BASE}/workloads/${id}/logs`, { + headers: { Authorization: `Bearer ${token}` }, + signal, + }); + if (!res.ok || !res.body) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to stream logs: ${res.status}`); + } + return res.body; +} + +export async function openWorkloadExecSocket(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/workloads/${id}/exec/ticket`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to issue exec ticket: ${res.status}`); + } + const { ticket } = await res.json(); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProtocol}//${window.location.host}${API_BASE}/workloads/${id}/exec?ticket=${encodeURIComponent(ticket)}`; + return new WebSocket(url); +} + +export async function listResourceGroupVolumes(token: string, rgId: string): Promise { + const res = await fetch(`${API_BASE}/resource-groups/${rgId}/volumes`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to list volumes: ${res.status}`); + return res.json(); +} + +export async function createVolume(token: string, req: CreateVolumeRequest): Promise { + const res = await fetch(`${API_BASE}/volumes`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.status })); + throw new Error(err.error ?? `Failed to create volume: ${res.status}`); + } + return res.json(); +} + +export async function deleteVolume(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/volumes/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to delete volume: ${res.status}`); +} diff --git a/app/src/lib/api/system.ts b/app/src/lib/api/system.ts index ddc2818a..216676cd 100644 --- a/app/src/lib/api/system.ts +++ b/app/src/lib/api/system.ts @@ -1,4 +1,4 @@ -const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api'; +const API_BASE = '/api'; export interface UpdateStatus { current_version: string; diff --git a/app/src/lib/auth/api.ts b/app/src/lib/auth/api.ts index 36bdff22..6554eb23 100644 --- a/app/src/lib/auth/api.ts +++ b/app/src/lib/auth/api.ts @@ -1,4 +1,4 @@ -const API_BASE = (import.meta.env.VITE_API_URL || '') + '/api'; +const API_BASE = '/api'; export interface AuthResponse { token: string; diff --git a/app/src/lib/auth/right-panel.svelte b/app/src/lib/auth/right-panel.svelte index b1b6a029..b49014e9 100644 --- a/app/src/lib/auth/right-panel.svelte +++ b/app/src/lib/auth/right-panel.svelte @@ -7,12 +7,12 @@ class="relative w-full rounded-3xl overflow-hidden flex flex-col justify-between p-10" > diff --git a/app/src/lib/components/nodes/NodeDetailSheet.svelte b/app/src/lib/components/nodes/NodeDetailSheet.svelte index 85f6d9ee..9d72aed9 100644 --- a/app/src/lib/components/nodes/NodeDetailSheet.svelte +++ b/app/src/lib/components/nodes/NodeDetailSheet.svelte @@ -1,6 +1,6 @@ + +{#if error} +
+ {error} +
+{/if} + +
+ + + Log retention + + Logs older than this many days are deleted automatically every hour. + + + + {#if loading} +
+ {:else} +
+
+ + +
+ + {#if saved && !saving} + Saved + {/if} +
+ {/if} +
+
+
diff --git a/app/src/lib/components/sidebar/app-sidebar.svelte b/app/src/lib/components/sidebar/app-sidebar.svelte index f46ba238..2959d605 100644 --- a/app/src/lib/components/sidebar/app-sidebar.svelte +++ b/app/src/lib/components/sidebar/app-sidebar.svelte @@ -3,7 +3,14 @@ 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, Settings } 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"; @@ -25,7 +32,7 @@ }, { title: "Logs", - url: "#", + url: "/logs", icon: IconLogs, }, ], @@ -43,7 +50,7 @@ }, { title: "Resource groups", - url: "#", + url: "/resource-groups", icon: Layers, }, ], diff --git a/app/src/lib/components/ui/sonner/index.ts b/app/src/lib/components/ui/sonner/index.ts new file mode 100644 index 00000000..1ad9f4a2 --- /dev/null +++ b/app/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/app/src/lib/components/ui/sonner/sonner.svelte b/app/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 00000000..c263526e --- /dev/null +++ b/app/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,35 @@ + + + + {#snippet loadingIcon()} + + {/snippet} + {#snippet successIcon()} + + {/snippet} + {#snippet errorIcon()} + + {/snippet} + {#snippet infoIcon()} + + {/snippet} + {#snippet warningIcon()} + + {/snippet} + diff --git a/app/src/lib/components/ui/spinner/index.ts b/app/src/lib/components/ui/spinner/index.ts new file mode 100644 index 00000000..f8b1cedd --- /dev/null +++ b/app/src/lib/components/ui/spinner/index.ts @@ -0,0 +1 @@ +export { default as Spinner } from "./spinner.svelte"; diff --git a/app/src/lib/components/ui/spinner/spinner.svelte b/app/src/lib/components/ui/spinner/spinner.svelte new file mode 100644 index 00000000..b469ded5 --- /dev/null +++ b/app/src/lib/components/ui/spinner/spinner.svelte @@ -0,0 +1,18 @@ + + + diff --git a/app/src/lib/utils/compose-preview.ts b/app/src/lib/utils/compose-preview.ts new file mode 100644 index 00000000..a44328d1 --- /dev/null +++ b/app/src/lib/utils/compose-preview.ts @@ -0,0 +1,80 @@ +export interface ComposePreviewService { + serviceName: string; + image: string | null; + ports: string[]; +} + +export interface ComposePreviewResult { + services: ComposePreviewService[]; + volumes: string[]; + error: string | null; +} + +function stripInlineComment(line: string): string { + const hashIndex = line.indexOf('#'); + return hashIndex === -1 ? line : line.slice(0, hashIndex); +} + +function indentOf(line: string): number { + return line.length - line.trimStart().length; +} + +function parseTopLevelBlockKeys(lines: string[], blockName: string): string[] { + const blockLineIndex = lines.findIndex((line) => line.trim() === `${blockName}:`); + if (blockLineIndex === -1) return []; + + const keys: string[] = []; + for (let i = blockLineIndex + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = indentOf(line); + if (indent === 0) break; + if (indent === 2 && line.trim().endsWith(':')) { + keys.push(line.trim().slice(0, -1)); + } + } + return keys; +} + +export function parseComposePreview(yaml: string): ComposePreviewResult { + const lines = yaml.split('\n').map(stripInlineComment); + const volumes = parseTopLevelBlockKeys(lines, 'volumes'); + const servicesLineIndex = lines.findIndex((line) => line.trim() === 'services:'); + if (servicesLineIndex === -1) { + return { services: [], volumes, error: null }; + } + + const services: ComposePreviewService[] = []; + let current: ComposePreviewService | null = null; + let inPorts = false; + + for (let i = servicesLineIndex + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = indentOf(line); + const trimmed = line.trim(); + + if (indent === 0) break; + + if (indent === 2 && trimmed.endsWith(':')) { + current = { serviceName: trimmed.slice(0, -1), image: null, ports: [] }; + services.push(current); + inPorts = false; + continue; + } + if (indent <= 0 || !current) continue; + + if (trimmed.startsWith('image:')) { + current.image = trimmed.slice('image:'.length).trim().replace(/^["']|["']$/g, ''); + inPorts = false; + } else if (trimmed === 'ports:') { + inPorts = true; + } else if (inPorts && trimmed.startsWith('-')) { + current.ports.push(trimmed.slice(1).trim().replace(/^["']|["']$/g, '')); + } else if (trimmed.endsWith(':')) { + inPorts = false; + } + } + + return { services, volumes, error: null }; +} diff --git a/app/src/lib/utils/image-icon.ts b/app/src/lib/utils/image-icon.ts new file mode 100644 index 00000000..6a3e95b3 --- /dev/null +++ b/app/src/lib/utils/image-icon.ts @@ -0,0 +1,27 @@ +const KNOWN_IMAGE_ICONS: Record = { + redis: 'logos:redis', + postgres: 'logos:postgresql', + postgresql: 'logos:postgresql', + nginx: 'logos:nginx', + mysql: 'logos:mysql', + mariadb: 'logos:mariadb', + mongo: 'logos:mongodb', + mongodb: 'logos:mongodb', + rabbitmq: 'logos:rabbitmq-icon', + memcached: 'logos:memcached', + traefik: 'logos:traefikproxy-icon', + caddy: 'logos:caddy-icon', + elasticsearch: 'logos:elasticsearch', + grafana: 'logos:grafana', + prometheus: 'logos:prometheus', +}; + +const DOCKER_FALLBACK_ICON = 'logos:docker-icon'; + +export function resolveImageIcon(image: string): string { + const withoutTag = image.split('@')[0].split(':')[0]; + const segments = withoutTag.split('/'); + const bareName = segments[segments.length - 1].toLowerCase(); + + return KNOWN_IMAGE_ICONS[bareName] ?? DOCKER_FALLBACK_ICON; +} diff --git a/app/src/lib/utils/yaml-highlight.ts b/app/src/lib/utils/yaml-highlight.ts new file mode 100644 index 00000000..62a315e6 --- /dev/null +++ b/app/src/lib/utils/yaml-highlight.ts @@ -0,0 +1,55 @@ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function highlightLine(line: string): string { + const commentIndex = line.indexOf('#'); + const code = commentIndex === -1 ? line : line.slice(0, commentIndex); + const comment = commentIndex === -1 ? '' : line.slice(commentIndex); + + const leadingSpaces = code.match(/^\s*/)?.[0] ?? ''; + const rest = code.slice(leadingSpaces.length); + + const dashMatch = rest.match(/^-\s*/); + const dashPrefix = dashMatch ? dashMatch[0] : ''; + const afterDash = rest.slice(dashPrefix.length); + + const keyMatch = afterDash.match(/^([A-Za-z0-9_.-]+)(:)(\s|$)/); + let highlighted: string; + + if (keyMatch) { + const key = keyMatch[1]; + const colon = keyMatch[2]; + const separator = keyMatch[3]; + const value = afterDash.slice(keyMatch[0].length); + highlighted = `${escapeHtml(key)}${colon}${separator}${highlightValue(value)}`; + } else { + highlighted = highlightValue(afterDash); + } + + const dashHtml = dashPrefix ? `${escapeHtml(dashPrefix)}` : ''; + const commentHtml = comment ? `${escapeHtml(comment)}` : ''; + + return `${escapeHtml(leadingSpaces)}${dashHtml}${highlighted}${commentHtml}`; +} + +function highlightValue(value: string): string { + if (!value) return ''; + if (/^["'].*["']$/.test(value)) { + return `${escapeHtml(value)}`; + } + if (/^-?\d+(\.\d+)?$/.test(value)) { + return `${escapeHtml(value)}`; + } + if (value === 'true' || value === 'false' || value === 'null') { + return `${escapeHtml(value)}`; + } + return `${escapeHtml(value)}`; +} + +export function highlightYaml(yaml: string): string { + return yaml.split('\n').map(highlightLine).join('\n'); +} diff --git a/app/src/routes/(app)/+page.svelte b/app/src/routes/(app)/+page.svelte index ae2319b4..497f86ca 100644 --- a/app/src/routes/(app)/+page.svelte +++ b/app/src/routes/(app)/+page.svelte @@ -1,11 +1,11 @@
- +
-

Dashboard

+

Dashboard

diff --git a/app/src/routes/(app)/admin/settings/+page.svelte b/app/src/routes/(app)/admin/settings/+page.svelte index 88017e9b..7fa275f4 100644 --- a/app/src/routes/(app)/admin/settings/+page.svelte +++ b/app/src/routes/(app)/admin/settings/+page.svelte @@ -1,8 +1,9 @@ @@ -31,6 +32,14 @@ > Update +
@@ -38,6 +47,8 @@

No general settings configured.

{:else if activeTab === "update"} + {:else if activeTab === "logs"} + {/if}
diff --git a/app/src/routes/(app)/logs/+page.svelte b/app/src/routes/(app)/logs/+page.svelte new file mode 100644 index 00000000..07d86c91 --- /dev/null +++ b/app/src/routes/(app)/logs/+page.svelte @@ -0,0 +1,360 @@ + + +
+ + / + Logs + +
+ +
+ + +
+ {#if error} +

{error}

+ {/if} + +
+ + + + + + + + + + + + + {#if loading} + + {:else if entries.length === 0} + + {:else} + {#each entries as entry (entry.id)} + openDetail(entry)} + > + + + + + + + + {/each} + {/if} + +
SeverityServiceLevelClassificationMessageDate / Time
Loading...
No log entries.
+
+ {#each [1, 2, 3] as bar (bar)} + + {/each} +
+
{entry.service}{entry.level}{entry.classification}{entry.message}{formatDateTime(entry.created_at)}
+
+ +
+ {entries.length === 0 ? 0 : offset + 1}-{offset + entries.length} of {total} logs +
+ + +
+
+
+
+ + + + {#if selectedEntry} + + {formatDateTime(selectedEntry.created_at)} + +
+
+ Service + {selectedEntry.service} +
+
+ Level + {selectedEntry.level} +
+
+ Classification + {selectedEntry.classification} +
+ {#if selectedEntry.agent_id} +
+ Agent ID + {selectedEntry.agent_id} +
+ {/if} + {#if selectedEntry.workload_id} +
+ Workload ID + {selectedEntry.workload_id} +
+ {/if} + {#if selectedEntry.organization_id} +
+ Organization ID + {selectedEntry.organization_id} +
+ {/if} +
+ Message +

{selectedEntry.message}

+
+
+ {/if} +
+
diff --git a/app/src/routes/(app)/nodes/+page.svelte b/app/src/routes/(app)/nodes/+page.svelte index 2f429736..ab6a4794 100644 --- a/app/src/routes/(app)/nodes/+page.svelte +++ b/app/src/routes/(app)/nodes/+page.svelte @@ -81,11 +81,12 @@ } } - onMount(async () => { - if (!auth.token) return; + let loadStarted = false; + + async function loadInitial() { try { [nodes] = await Promise.all([ - listNodes(auth.token), + listNodes(auth.token!), fetchStats(), ]); } catch (e) { @@ -93,6 +94,16 @@ } finally { loading = false; } + } + + $effect(() => { + if (auth.token && !loadStarted) { + loadStarted = true; + loadInitial(); + } + }); + + onMount(() => { pollInterval = setInterval(fetchStats, 10_000); }); diff --git a/app/src/routes/(app)/resource-groups/+page.svelte b/app/src/routes/(app)/resource-groups/+page.svelte new file mode 100644 index 00000000..42d37fcc --- /dev/null +++ b/app/src/routes/(app)/resource-groups/+page.svelte @@ -0,0 +1,224 @@ + + + { createError = null; }} +> +
+
+

New Resource Group

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ {#if createError} +

{createError}

+ {/if} +
+ + +
+
+
+ +
+ + / + Resource Groups +
+ +
+
+
+

Resource Groups

+

+ Isolated namespaces with dedicated internal networks +

+
+ +
+ + {#if error} +

{error}

+ {/if} + +
+ + + + + + + + + + + + + + {#if loading} + + + + {:else if groups.length === 0} + + + + {:else} + {#each groups as group (group.id)} + goto(`/resource-groups/${group.id}`)} + > + + + + + + + + + {/each} + {/if} + +
IDNameCIDRDescriptionStatusCreated
Loading...
+ No resource groups. Create one to get started. +
{group.id.slice(0, 8)}{group.name}{group.internal_cidr}{group.description ?? "-"} + {group.status} + {group.created_at.slice(0, 10)} + +
+
+
diff --git a/app/src/routes/(app)/resource-groups/[id]/+page.svelte b/app/src/routes/(app)/resource-groups/[id]/+page.svelte new file mode 100644 index 00000000..708e5c3b --- /dev/null +++ b/app/src/routes/(app)/resource-groups/[id]/+page.svelte @@ -0,0 +1,1106 @@ + + + resetDeployForm()} +> +
+
+

Deploy Container

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {#if volumes.length > 0} +
+ + +

Available: {volumes.map(v => v.name).join(", ")}

+
+ {/if} +
+ + +
+ {#if deployError} +

{deployError}

+ {/if} +
+ + +
+
+
+ + +
+
+

Add Resource

+ +
+
+ {#each RESOURCE_TYPES as type} + + {/each} +
+
+
+ + resetComposeForm()} +> +
+
+

Deploy Docker Compose Stack

+ +
+
+ + +
+
+
+ +
+ {#if composePreview.services.length === 0 && composePreview.volumes.length === 0} +

No services detected yet.

+ {:else} + {#each composePreview.services as service (service.serviceName)} +
+ +
+

{service.serviceName}

+

{service.image ?? "no image"}

+ {#if service.ports.length > 0} +

{service.ports.join(", ")}

+ {/if} +
+
+ {/each} + {#if composePreview.volumes.length > 0} +

Volumes

+ {#each composePreview.volumes as volumeName (volumeName)} +
+ +

{volumeName}

+
+ {/each} + {/if} + {/if} +
+
+
+
+ + +
+
+
+ {#each Array(composeLineCount) as _, i} +
{i + 1}
+ {/each} +
+
{@html composeHighlighted}
+ +
+
+
+ {#if composeError} +

{composeError}

+ {/if} +
+ + +
+
+
+ + { volumeError = null; }} +> +
+
+

Create Volume

+ +
+
+
+ + +
+
+ + +
+
+ {#if volumeError} +

{volumeError}

+ {/if} +
+ + +
+
+
+ + closeLogs()} +> +
+
+

{logsWorkloadName}

+ +
+
+ {#if logsError} +

{logsError}

+ {/if} + {#each logsLines as line} +

{line}

+ {/each} +
+
+
+ + closeExec()} +> +
+
+

{execWorkloadName}

+ +
+ {#if execError} +

{execError}

+ {/if} +
+
+
+ +
+ + / + + / + {group?.name ?? rgId.slice(0, 8)} +
+ +
+ {#if loading} +

Loading...

+ {:else if error && !group} +

{error}

+ {:else if group} +
+
+ {initials(group.name)} +
+
+
+

{group.name}

+ {group.status} +
+ {#if group.description} +

{group.description}

+ {/if} +

{group.internal_cidr}

+
+
+ + + +
+
+ +
+
+

Containers

+

{workloads.length}

+

{workloads.filter(w => w.status === 'running').length} running

+
+
+

Volumes

+

{volumes.length}

+

{totalDisk} GB total

+
+
+

CPU Requested

+

{(totalCpu / 1000).toFixed(1)}

+

vCPU

+
+
+

Memory Requested

+

{fmtBytes(totalMem)}

+

across containers

+
+
+ + {#if error} +

{error}

+ {/if} + +
+
+
+ {#each [["all", `All ${allResources.length}`], ["container", `Container ${workloads.length}`], ["volume", `Volume ${volumes.length}`]] as [tab, label]} + + {/each} +
+
+ + +
+
+ + + + + + + + + + + + + + {#if filteredResources.length === 0} + + + + {:else} + {#snippet workloadActions(w: Workload)} + + + + {/snippet} + {#snippet workloadRow(w: Workload, indented: boolean)} + + + + + + + + + {/snippet} + {#each filteredResources as item (item.kind + (item.kind === "stack" ? item.data.stack_id : item.data.id))} + {#if item.kind === "container"} + {@render workloadRow(item.data, false)} + {:else if item.kind === "stack"} + {@const stack = item.data} + {@const expanded = expandedStacks.has(stack.stack_id)} + + + + + + + + + {#if expanded} + {#each stack.children as child (child.id)} + {@render workloadRow(child, true)} + {/each} + {/if} + {:else} + {@const v = item.data} + + + + + + + + + {/if} + {/each} + {/if} + +
ResourceKindHost / SizeLoadStatus
+ {allResources.length === 0 ? "No resources yet. Deploy a container or create a volume." : "No resources match filter."} +
+
+
+ +
+
+

{w.service_name ?? w.name}

+

{w.image}

+
+
+
+ Container + + {w.assigned_agent_id ? w.assigned_agent_id.slice(0, 8) : "-"} + +
+ {w.cpu_millicores}m +
+
+
+
+
+ + {statusLabel(w.status)} + + + {@render workloadActions(w)} +
+ + + Compose Stack + + {stack.children[0]?.assigned_agent_id ? stack.children[0].assigned_agent_id!.slice(0, 8) : "-"} + - + + {stack.children.filter((c) => c.status === 'running').length}/{stack.children.length} running + +
+
+
+ +
+
+

{v.name}

+

{v.size_gb} GB · {v.pool}

+
+
+
+ Volume + + {v.size_gb} GB + +
+ {v.size_gb}G +
+
+
+
+
+ + {v.status} + + + +
+
+ {/if} +
diff --git a/app/src/routes/(auth)/login/+page.svelte b/app/src/routes/(auth)/login/+page.svelte index 80b04130..0e769e58 100644 --- a/app/src/routes/(auth)/login/+page.svelte +++ b/app/src/routes/(auth)/login/+page.svelte @@ -2,35 +2,39 @@ import { Button } from "$lib/components/ui/button/index.js"; import { Input } from "$lib/components/ui/input/index.js"; import { Label } from "$lib/components/ui/label/index.js"; - import { Lock, User } from "@lucide/svelte"; + import { Lock, User, CircleCheck } from "@lucide/svelte"; import { goto } from "$app/navigation"; import { login, TwoFactorRequiredError } from "$lib/auth/api"; import { auth } from "$lib/auth/store.svelte"; + import Spinner from "$lib/components/ui/spinner/spinner.svelte"; + import { toast } from "svelte-sonner"; + + type Status = "idle" | "loading" | "success"; let username = $state(""); let password = $state(""); - let loading = $state(false); - let error = $state(null); + let status = $state("idle"); async function handleSubmit() { - error = null; - loading = true; + status = "loading"; try { const response = await login(username, password); auth.setSession(response); - if (response.force_password_change) { - goto("/pw_change"); - } else { - goto("/"); - } + status = "success"; + const target = response.force_password_change ? "/pw_change" : "/"; + setTimeout(() => goto(target), 600); } catch (err) { + status = "idle"; if (err instanceof TwoFactorRequiredError) { - goto(`/otp?username=${encodeURIComponent(err.username)}&password=${encodeURIComponent(err.password)}`); + goto( + `/otp?username=${encodeURIComponent(err.username)}&password=${encodeURIComponent(err.password)}`, + ); } else { - error = "Invalid credentials"; + toast.error("Invalid credentials", { + description: + "Check your username and password and try again", + }); } - } finally { - loading = false; } } @@ -42,11 +46,19 @@ />

Sign in

-
{ e.preventDefault(); handleSubmit(); }} class="flex flex-col"> + { + e.preventDefault(); + handleSubmit(); + }} + class="flex flex-col" +>
- +
- +
- {#if error} -
{error}
- {/if} - - diff --git a/app/src/routes/(auth)/otp/+page.svelte b/app/src/routes/(auth)/otp/+page.svelte index e63240a7..98fcaab1 100644 --- a/app/src/routes/(auth)/otp/+page.svelte +++ b/app/src/routes/(auth)/otp/+page.svelte @@ -1,36 +1,38 @@ @@ -72,11 +76,13 @@ {/if}
- {#if error} -
{error}
- {/if} - diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index 316fbb7e..e7ceddb3 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -1,17 +1,22 @@ - + + + + {@render children()} + diff --git a/app/src/routes/layout.css b/app/src/routes/layout.css index cf53eeb0..704e1533 100644 --- a/app/src/routes/layout.css +++ b/app/src/routes/layout.css @@ -136,4 +136,7 @@ html { @apply font-sans; } + dialog { + @apply bg-background text-foreground; + } } diff --git a/app/static/gradients/Gradient-dark.svg b/app/static/gradients/Gradient-dark.svg deleted file mode 100644 index af49adeb..00000000 --- a/app/static/gradients/Gradient-dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/static/gradients/Gradient-dark.webp b/app/static/gradients/Gradient-dark.webp new file mode 100644 index 00000000..124c7564 Binary files /dev/null and b/app/static/gradients/Gradient-dark.webp differ diff --git a/app/static/gradients/Gradient-light.svg b/app/static/gradients/Gradient-light.svg deleted file mode 100755 index 263eda97..00000000 --- a/app/static/gradients/Gradient-light.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/static/gradients/Gradient-light.webp b/app/static/gradients/Gradient-light.webp new file mode 100644 index 00000000..4067b7d3 Binary files /dev/null and b/app/static/gradients/Gradient-light.webp differ diff --git a/app/vite.config.ts b/app/vite.config.ts index e402b69e..6042d847 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + optimizeDeps: { + include: ['@xterm/xterm', '@xterm/addon-fit'], + }, server: { watch: { usePolling: true, diff --git a/control-plane/api-gateway/Cargo.toml b/control-plane/api-gateway/Cargo.toml index 9adb4c58..222d966b 100644 --- a/control-plane/api-gateway/Cargo.toml +++ b/control-plane/api-gateway/Cargo.toml @@ -22,7 +22,7 @@ async-trait = { workspace = true } sea-orm = { workspace = true } # Web framework -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } axum-server = { workspace = true } tower-http = { workspace = true } hyper = { workspace = true } @@ -67,7 +67,9 @@ cookie = { workspace = true } qrcode = { workspace = true } image = { workspace = true } sysinfo = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["stream"] } +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } prometheus = { workspace = true } etcd-client = { workspace = true } tower_governor = { workspace = true } diff --git a/control-plane/api-gateway/src/auth/crypto.rs b/control-plane/api-gateway/src/auth/crypto.rs index 42e08d02..19f466ba 100644 --- a/control-plane/api-gateway/src/auth/crypto.rs +++ b/control-plane/api-gateway/src/auth/crypto.rs @@ -1,6 +1,6 @@ use base64::Engine; use bcrypt::{hash, verify, DEFAULT_COST}; -use rsa::rand_core::{OsRng, RngCore}; +use rsa::rand_core::OsRng; use rsa::{ pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}, RsaPrivateKey, RsaPublicKey, @@ -78,65 +78,6 @@ pub fn verify_password(password: &str, salt: &str, hashed: &str) -> CryptoResult Ok(verify(salted_password, hashed)?) } -pub fn encrypt_secret(plaintext: &str, key_b64: &str) -> CryptoResult { - use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, - }; - let key_bytes = base64::engine::general_purpose::STANDARD - .decode(key_b64) - .map_err(|_| CryptoError::InvalidEncryptedData)?; - if key_bytes.len() != 32 { - return Err(CryptoError::InvalidEncryptedData); - } - - let cipher = - Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CryptoError::InvalidEncryptedData)?; - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, plaintext.as_bytes()) - .map_err(|_| CryptoError::InvalidEncryptedData)?; - - let mut combined = nonce_bytes.to_vec(); - combined.extend_from_slice(&ciphertext); - Ok(base64::engine::general_purpose::STANDARD.encode(combined)) -} - -pub fn decrypt_secret(encoded: &str, key_b64: &str) -> CryptoResult { - use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, - }; - - let key_bytes = base64::engine::general_purpose::STANDARD - .decode(key_b64) - .map_err(|_| CryptoError::InvalidEncryptedData)?; - if key_bytes.len() != 32 { - return Err(CryptoError::InvalidEncryptedData); - } - - let combined = base64::engine::general_purpose::STANDARD - .decode(encoded) - .map_err(|_| CryptoError::InvalidEncryptedData)?; - if combined.len() < 12 { - return Err(CryptoError::InvalidEncryptedData); - } - - let (nonce_bytes, ciphertext) = combined.split_at(12); - let cipher = - Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CryptoError::InvalidEncryptedData)?; - let nonce = Nonce::from_slice(nonce_bytes); - - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|_| CryptoError::InvalidEncryptedData)?; - - String::from_utf8(plaintext).map_err(|_| CryptoError::InvalidEncryptedData) -} - pub fn generate_salt() -> String { const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ abcdefghijklmnopqrstuvwxyz\ diff --git a/control-plane/api-gateway/src/auth/jwt.rs b/control-plane/api-gateway/src/auth/jwt.rs index 4385308a..928593c5 100644 --- a/control-plane/api-gateway/src/auth/jwt.rs +++ b/control-plane/api-gateway/src/auth/jwt.rs @@ -50,3 +50,109 @@ pub fn verify_jwt(token: &str) -> Result, jsonwebtoken::errors fn get_jwt_secret() -> String { env::var("JWT_SECRET").unwrap_or_else(|_| "your-secret-key".to_string()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecTicketClaims { + pub workload_id: Uuid, + pub user_id: Uuid, + pub exp: i64, + pub iat: i64, + pub purpose: String, +} + +impl ExecTicketClaims { + pub fn new(workload_id: Uuid, user_id: Uuid) -> Self { + let now = Utc::now(); + ExecTicketClaims { + workload_id, + user_id, + exp: (now + Duration::seconds(30)).timestamp(), + iat: now.timestamp(), + purpose: "workload-exec".to_string(), + } + } +} + +pub fn create_exec_ticket( + workload_id: Uuid, + user_id: Uuid, +) -> Result { + let claims = ExecTicketClaims::new(workload_id, user_id); + let secret = get_jwt_secret(); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) +} + +pub fn verify_exec_ticket( + token: &str, + workload_id: Uuid, +) -> Result { + let secret = get_jwt_secret(); + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + )?; + + if data.claims.purpose != "workload-exec" || data.claims.workload_id != workload_id { + return Err(jsonwebtoken::errors::ErrorKind::InvalidToken.into()); + } + + Ok(data.claims) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeMetricsTicketClaims { + pub agent_id: Uuid, + pub user_id: Uuid, + pub exp: i64, + pub iat: i64, + pub purpose: String, +} + +impl NodeMetricsTicketClaims { + pub fn new(agent_id: Uuid, user_id: Uuid) -> Self { + let now = Utc::now(); + NodeMetricsTicketClaims { + agent_id, + user_id, + exp: (now + Duration::seconds(30)).timestamp(), + iat: now.timestamp(), + purpose: "node-metrics".to_string(), + } + } +} + +pub fn create_node_metrics_ticket( + agent_id: Uuid, + user_id: Uuid, +) -> Result { + let claims = NodeMetricsTicketClaims::new(agent_id, user_id); + let secret = get_jwt_secret(); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) +} + +pub fn verify_node_metrics_ticket( + token: &str, + agent_id: Uuid, +) -> Result { + let secret = get_jwt_secret(); + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + )?; + + if data.claims.purpose != "node-metrics" || data.claims.agent_id != agent_id { + return Err(jsonwebtoken::errors::ErrorKind::InvalidToken.into()); + } + + Ok(data.claims) +} diff --git a/control-plane/api-gateway/src/auth/mod.rs b/control-plane/api-gateway/src/auth/mod.rs index 95cde4b9..29a8be56 100644 --- a/control-plane/api-gateway/src/auth/mod.rs +++ b/control-plane/api-gateway/src/auth/mod.rs @@ -2,4 +2,5 @@ pub mod agent; pub mod crypto; pub mod jwt; pub mod middleware; +pub mod rate_limit; pub mod rbac; diff --git a/control-plane/api-gateway/src/auth/rate_limit.rs b/control-plane/api-gateway/src/auth/rate_limit.rs new file mode 100644 index 00000000..3edab764 --- /dev/null +++ b/control-plane/api-gateway/src/auth/rate_limit.rs @@ -0,0 +1,45 @@ +use axum::http::Request; +use tower_governor::key_extractor::{KeyExtractor, PeerIpKeyExtractor}; +use tower_governor::GovernorError; +use uuid::Uuid; + +use super::jwt::verify_jwt; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum RateLimitKey { + User(Uuid), + Ip(std::net::IpAddr), +} + +#[derive(Clone)] +pub struct JwtOrIpKeyExtractor { + fallback: PeerIpKeyExtractor, +} + +impl JwtOrIpKeyExtractor { + pub fn new() -> Self { + Self { + fallback: PeerIpKeyExtractor, + } + } +} + +impl KeyExtractor for JwtOrIpKeyExtractor { + type Key = RateLimitKey; + + fn extract(&self, req: &Request) -> Result { + if let Some(user_id) = extract_user_id(req) { + return Ok(RateLimitKey::User(user_id)); + } + + self.fallback.extract(req).map(RateLimitKey::Ip) + } +} + +fn extract_user_id(req: &Request) -> Option { + let header = req.headers().get(axum::http::header::AUTHORIZATION)?; + let value = header.to_str().ok()?; + let token = value.strip_prefix("Bearer ")?; + let claims = verify_jwt(token).ok()?; + Some(claims.claims.user_id) +} diff --git a/control-plane/api-gateway/src/auth/rbac.rs b/control-plane/api-gateway/src/auth/rbac.rs index 69e42bbc..ce94fd9b 100644 --- a/control-plane/api-gateway/src/auth/rbac.rs +++ b/control-plane/api-gateway/src/auth/rbac.rs @@ -8,17 +8,11 @@ use axum::{ http::{request::Parts, StatusCode}, }; use chrono::Utc; -use entity::{organization, InvalidJwt, Organization}; +use entity::InvalidJwt; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; -pub struct RequirePermission { - pub claims: Claims, - pub organization_id: Uuid, -} - pub struct CanViewAgents(pub Claims); -pub struct CanManageAgents(pub Claims); pub struct CanViewWorkloads(pub Claims); pub struct CanManageWorkloads(pub Claims); pub struct CanViewVolumes(pub Claims); @@ -26,6 +20,10 @@ pub struct CanManageVolumes(pub Claims); pub struct CanViewNetworks(pub Claims); pub struct CanManageNetworks(pub Claims); pub struct CanManageSystem(pub Claims); +pub struct CanViewResourceGroups(pub Claims); +pub struct CanManageResourceGroups(pub Claims); +pub struct CanViewLogs(pub Claims); +pub struct CanManageLogs(pub Claims); async fn extract_claims(parts: &mut Parts, state: &AppState) -> Result { let token = parts @@ -70,13 +68,9 @@ async fn extract_claims(parts: &mut Parts, state: &AppState) -> Result Result { - Organization::find() - .filter(organization::Column::Name.eq("Default Organization")) - .one(&state.db_conn) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map(|o| o.id) +fn get_default_org(state: &AppState) -> Result { + state + .default_org_id .ok_or(StatusCode::INTERNAL_SERVER_ERROR) } @@ -87,7 +81,7 @@ async fn check( action: &str, ) -> Result { let claims = extract_claims(parts, state).await?; - let org_id = get_default_org(state).await?; + let org_id = get_default_org(state)?; let rbac = RbacService::new(state.db_conn.clone()); let allowed = rbac .has_permission(claims.user_id, org_id, resource, action) @@ -116,7 +110,6 @@ macro_rules! impl_extractor { } impl_extractor!(CanViewAgents, "agents", "view"); -impl_extractor!(CanManageAgents, "agents", "manage"); impl_extractor!(CanViewWorkloads, "workloads", "view"); impl_extractor!(CanManageWorkloads, "workloads", "manage"); impl_extractor!(CanViewVolumes, "volumes", "view"); @@ -124,3 +117,7 @@ impl_extractor!(CanManageVolumes, "volumes", "manage"); impl_extractor!(CanViewNetworks, "networks", "view"); impl_extractor!(CanManageNetworks, "networks", "manage"); impl_extractor!(CanManageSystem, "system", "manage"); +impl_extractor!(CanViewResourceGroups, "resource_groups", "view"); +impl_extractor!(CanManageResourceGroups, "resource_groups", "manage"); +impl_extractor!(CanViewLogs, "logs", "view"); +impl_extractor!(CanManageLogs, "logs", "manage"); diff --git a/control-plane/api-gateway/src/init.rs b/control-plane/api-gateway/src/init.rs index 9d3e785e..dcc0f819 100644 --- a/control-plane/api-gateway/src/init.rs +++ b/control-plane/api-gateway/src/init.rs @@ -9,7 +9,7 @@ use crate::auth::crypto::{generate_salt, hash_password, RsaKeyPair}; pub async fn initialize_database( db: &DatabaseConnection, -) -> Result<(), Box> { +) -> Result> { tracing::info!("Initializing database with default data..."); // 1. Create RSA key pair if not exists @@ -148,6 +148,25 @@ pub async fn initialize_database( "manage", "Trigger control plane updates", ), + ( + "resource_groups.view", + "resource_groups", + "view", + "View resource groups and their resources", + ), + ( + "resource_groups.manage", + "resource_groups", + "manage", + "Create, update, and delete resource groups", + ), + ("logs.view", "logs", "view", "View system and service logs"), + ( + "logs.manage", + "logs", + "manage", + "Manage log retention settings", + ), ]; let mut permission_map = std::collections::HashMap::new(); @@ -201,22 +220,39 @@ pub async fn initialize_database( created_at: ActiveValue::Set(now), }; Role::insert(new_role).exec_without_returning(db).await?; - - // Assign all permissions to Admin role - for perm_id in permission_map.values() { - let role_perm = role_permission::ActiveModel { - role_id: ActiveValue::Set(role_id), - permission_id: ActiveValue::Set(*perm_id), - }; - RolePermission::insert(role_perm) - .exec_without_returning(db) - .await?; - } - - tracing::info!("Admin role created with all permissions"); + tracing::info!("Admin role created"); role_id }; + let admin_existing_perm_ids: std::collections::HashSet = RolePermission::find() + .filter(role_permission::Column::RoleId.eq(admin_role_id)) + .all(db) + .await? + .into_iter() + .map(|rp| rp.permission_id) + .collect(); + + let mut admin_granted = 0; + for perm_id in permission_map.values() { + if admin_existing_perm_ids.contains(perm_id) { + continue; + } + let role_perm = role_permission::ActiveModel { + role_id: ActiveValue::Set(admin_role_id), + permission_id: ActiveValue::Set(*perm_id), + }; + RolePermission::insert(role_perm) + .exec_without_returning(db) + .await?; + admin_granted += 1; + } + if admin_granted > 0 { + tracing::info!( + count = admin_granted, + "granted missing permissions to Admin role" + ); + } + // 4b. Create Operator role let operator_role_exists = Role::find() .filter(role::Column::Name.eq("Operator")) @@ -248,6 +284,9 @@ pub async fn initialize_database( "networks.view", "networks.manage", "members.view", + "resource_groups.view", + "resource_groups.manage", + "logs.view", ]; for perm_name in operator_perms { if let Some(perm_id) = permission_map.get(perm_name) { @@ -291,6 +330,8 @@ pub async fn initialize_database( "networks.view", "organization.view", "members.view", + "resource_groups.view", + "logs.view", ]; for perm_name in viewer_perms { if let Some(perm_id) = permission_map.get(perm_name) { @@ -360,5 +401,5 @@ pub async fn initialize_database( } tracing::info!("Database initialization completed"); - Ok(()) + Ok(default_org_id) } diff --git a/control-plane/api-gateway/src/main.rs b/control-plane/api-gateway/src/main.rs index 585ecd72..bc256352 100644 --- a/control-plane/api-gateway/src/main.rs +++ b/control-plane/api-gateway/src/main.rs @@ -8,6 +8,7 @@ mod auth_service; mod db; mod init; mod metrics; +mod prune; mod rbac_service; mod routes; mod self_monitor; @@ -125,6 +126,7 @@ impl utoipa::Modify for SecurityAddon { pub struct AppState { pub db_conn: DbConn, pub service_client: service_client::ServiceClient, + pub default_org_id: Option, } #[tokio::main] @@ -136,7 +138,7 @@ async fn main() { .expect("failed to install rustls crypto provider"); metrics::init(); - telemetry::init_tracing(); + let log_receiver = telemetry::init_tracing(); let db_conn = match db::establish_connection().await { Ok(conn) => { @@ -148,17 +150,25 @@ async fn main() { std::process::exit(1); } }; + shared::spawn_log_writer(log_receiver, db_conn.clone()); - if let Err(e) = init::initialize_database(&db_conn).await { - tracing::error!(error = %e, "failed to initialize database"); - std::process::exit(1); - } + let default_org_id = match init::initialize_database(&db_conn).await { + Ok(id) => id, + Err(e) => { + tracing::error!(error = %e, "failed to initialize database"); + std::process::exit(1); + } + }; let state = AppState { db_conn: db_conn.clone(), service_client: service_client::ServiceClient::new(), + default_org_id: Some(default_org_id), }; + tracing::info!("starting log prune job"); + prune::spawn_log_prune_job(state.clone()); + tracing::info!("starting self-monitoring service"); self_monitor::start_self_monitoring(std::sync::Arc::new(db_conn)).await; diff --git a/control-plane/api-gateway/src/metrics.rs b/control-plane/api-gateway/src/metrics.rs index 4cbde29a..69bf4797 100644 --- a/control-plane/api-gateway/src/metrics.rs +++ b/control-plane/api-gateway/src/metrics.rs @@ -27,19 +27,6 @@ pub fn init() { }); } -pub fn record_request(method: &str, path: &str, status: u16, duration_secs: f64) { - if let Some(counter) = HTTP_REQUESTS_TOTAL.get() { - counter - .with_label_values(&[method, path, &status.to_string()]) - .inc(); - } - if let Some(histogram) = HTTP_REQUEST_DURATION_SECONDS.get() { - histogram - .with_label_values(&[method, path]) - .observe(duration_secs); - } -} - pub async fn metrics_handler() -> impl IntoResponse { let encoder = TextEncoder::new(); let metric_families = prometheus::gather(); diff --git a/control-plane/api-gateway/src/prune.rs b/control-plane/api-gateway/src/prune.rs new file mode 100644 index 00000000..18a45fd6 --- /dev/null +++ b/control-plane/api-gateway/src/prune.rs @@ -0,0 +1,55 @@ +use chrono::Utc; +use entity::{entities::logs, Logs}; +use sea_orm::{ColumnTrait, DbConn, EntityTrait, QueryFilter}; +use tokio::time::{interval, Duration}; + +use crate::routes::settings::load_retention_days; +use crate::AppState; + +const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); + +pub fn spawn_log_prune_job(state: AppState) { + tokio::spawn(async move { + let mut ticker = interval(PRUNE_INTERVAL); + loop { + ticker.tick().await; + prune_once(&state).await; + } + }); +} + +async fn prune_once(state: &AppState) { + let retention_days = match load_retention_days(state).await { + Ok(days) => days, + Err(_) => { + tracing::warn!("skipping log prune, retention setting unavailable"); + return; + } + }; + + let cutoff = Utc::now() - chrono::Duration::days(retention_days); + + match delete_logs_older_than(&state.db_conn, cutoff).await { + Ok(rows_deleted) => { + tracing::info!( + rows_deleted = rows_deleted, + retention_days = retention_days, + "pruned expired logs" + ); + } + Err(error) => { + tracing::error!(error = %error, "failed to prune expired logs"); + } + } +} + +async fn delete_logs_older_than( + db: &DbConn, + cutoff: chrono::DateTime, +) -> Result { + let result = Logs::delete_many() + .filter(logs::Column::CreatedAt.lt(cutoff)) + .exec(db) + .await?; + Ok(result.rows_affected) +} diff --git a/control-plane/api-gateway/src/routes/agent_proxy.rs b/control-plane/api-gateway/src/routes/agent_proxy.rs new file mode 100644 index 00000000..678c546d --- /dev/null +++ b/control-plane/api-gateway/src/routes/agent_proxy.rs @@ -0,0 +1,423 @@ +use axum::{ + body::Body, + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Json}, + routing::get, + Router, +}; +use entity::entities::{agents, workloads}; +use futures_util::{SinkExt, StreamExt}; +use sea_orm::EntityTrait; +use serde::Deserialize; +use serde_json::json; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use uuid::Uuid; + +use crate::{ + auth::jwt::{ + create_exec_ticket, create_node_metrics_ticket, verify_exec_ticket, + verify_node_metrics_ticket, + }, + auth::rbac::{CanManageWorkloads, CanViewAgents, CanViewWorkloads}, + AppState, +}; + +const CSFX_AGENT_PORT_ENV: &str = "CSFX_AGENT_PORT"; +const METRICS_TICKET_SCOPE: &str = "__node_metrics__"; + +async fn resolve_agent_tunnel_ip( + state: &AppState, + agent_id: Uuid, +) -> Result)> { + let agent = agents::Entity::find_by_id(agent_id) + .one(&state.db_conn) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("database error: {}", e) })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "agent not found" })), + ) + })?; + + agent.wg_tunnel_ip.ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "agent has no known tunnel address" })), + ) + }) +} + +async fn resolve_agent_target( + state: &AppState, + workload_id: Uuid, +) -> Result<(String, Uuid), (StatusCode, Json)> { + let workload = workloads::Entity::find_by_id(workload_id) + .one(&state.db_conn) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("database error: {}", e) })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "workload not found" })), + ) + })?; + + let agent_id = workload.assigned_agent_id.ok_or_else(|| { + ( + StatusCode::CONFLICT, + Json(json!({ "error": "workload has no assigned agent" })), + ) + })?; + + let agent = agents::Entity::find_by_id(agent_id) + .one(&state.db_conn) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("database error: {}", e) })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "agent not found" })), + ) + })?; + + let tunnel_ip = agent.wg_tunnel_ip.ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "agent has no known tunnel address" })), + ) + })?; + + Ok((tunnel_ip, agent_id)) +} + +async fn fetch_proxy_ticket( + state: &AppState, + agent_id: Uuid, + workload_id: &str, +) -> Result)> { + let (status, body) = state + .service_client + .forward_to_registry( + reqwest::Method::POST, + "/internal/agent-proxy-ticket", + Some(json!({ "agent_id": agent_id, "workload_id": workload_id })), + None, + ) + .await + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("failed to reach registry: {}", e) })), + ) + })?; + + if !status.is_success() { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "registry refused to issue proxy ticket" })), + )); + } + + let body = body.ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "registry returned empty proxy ticket response" })), + ) + })?; + + let payload = body["payload"].as_str().ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "malformed proxy ticket payload" })), + ) + })?; + let signature = body["signature"].as_str().ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "malformed proxy ticket signature" })), + ) + })?; + + Ok(format!("{}.{}", payload, signature)) +} + +pub async fn stream_workload_logs( + CanViewWorkloads(_claims): CanViewWorkloads, + State(state): State, + Path(workload_id): Path, +) -> Result)> { + let (tunnel_ip, agent_id) = resolve_agent_target(&state, workload_id).await?; + let ticket = fetch_proxy_ticket(&state, agent_id, &workload_id.to_string()).await?; + + let agent_port: u16 = std::env::var(CSFX_AGENT_PORT_ENV) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(7443); + + let url = format!("http://{}:{}/logs/{}", tunnel_ip, agent_port, workload_id); + + let client = reqwest::Client::new(); + let resp = client + .get(&url) + .header("X-Csfx-Proxy-Ticket", ticket) + .send() + .await + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("failed to reach agent: {}", e) })), + ) + })?; + + if !resp.status().is_success() { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + return Err(( + status, + Json(json!({ "error": "agent refused log stream request" })), + )); + } + + let stream = resp.bytes_stream(); + Ok(Body::from_stream(stream)) +} + +pub async fn issue_exec_ticket( + CanManageWorkloads(claims): CanManageWorkloads, + Path(workload_id): Path, +) -> Result)> { + let ticket = create_exec_ticket(workload_id, claims.user_id).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("failed to issue exec ticket: {}", e) })), + ) + })?; + + Ok(Json(json!({ "ticket": ticket }))) +} + +#[derive(Deserialize)] +pub struct ExecQuery { + ticket: String, +} + +pub async fn exec_workload( + State(state): State, + Path(workload_id): Path, + Query(query): Query, + ws: WebSocketUpgrade, +) -> Result)> { + verify_exec_ticket(&query.ticket, workload_id).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid or expired exec ticket" })), + ) + })?; + + let (tunnel_ip, agent_id) = resolve_agent_target(&state, workload_id).await?; + let proxy_ticket = fetch_proxy_ticket(&state, agent_id, &workload_id.to_string()).await?; + + let agent_port: u16 = std::env::var(CSFX_AGENT_PORT_ENV) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(7443); + + let agent_url = format!("ws://{}:{}/exec/{}", tunnel_ip, agent_port, workload_id); + + let mut request = agent_url.into_client_request().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("invalid agent exec url: {}", e) })), + ) + })?; + request.headers_mut().insert( + "X-Csfx-Proxy-Ticket", + proxy_ticket.parse().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "invalid proxy ticket header" })), + ) + })?, + ); + + let (agent_socket, _) = tokio_tungstenite::connect_async(request) + .await + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("failed to connect to agent exec socket: {}", e) })), + ) + })?; + + Ok(ws.on_upgrade(move |browser_socket| bridge_exec_sockets(browser_socket, agent_socket))) +} + +async fn bridge_exec_sockets( + browser_socket: WebSocket, + agent_socket: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + let (mut browser_sink, mut browser_stream) = browser_socket.split(); + let (mut agent_sink, mut agent_stream) = agent_socket.split(); + + let browser_to_agent = tokio::spawn(async move { + while let Some(Ok(msg)) = browser_stream.next().await { + let forwarded = match msg { + Message::Binary(data) => TungsteniteMessage::Binary(data), + Message::Text(text) => TungsteniteMessage::Text(text.as_str().into()), + Message::Close(_) => break, + _ => continue, + }; + if agent_sink.send(forwarded).await.is_err() { + break; + } + } + }); + + let agent_to_browser = tokio::spawn(async move { + while let Some(Ok(msg)) = agent_stream.next().await { + let forwarded = match msg { + TungsteniteMessage::Binary(data) => Message::Binary(data), + TungsteniteMessage::Text(text) => Message::Text(text.as_str().into()), + TungsteniteMessage::Close(_) => break, + _ => continue, + }; + if browser_sink.send(forwarded).await.is_err() { + break; + } + } + }); + + tokio::select! { + _ = browser_to_agent => {} + _ = agent_to_browser => {} + } +} + +pub async fn issue_node_metrics_ticket( + CanViewAgents(claims): CanViewAgents, + Path(agent_id): Path, +) -> Result)> { + let ticket = create_node_metrics_ticket(agent_id, claims.user_id).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("failed to issue node metrics ticket: {}", e) })), + ) + })?; + + Ok(Json(json!({ "ticket": ticket }))) +} + +#[derive(Deserialize)] +pub struct NodeMetricsQuery { + ticket: String, +} + +pub async fn stream_node_metrics( + State(state): State, + Path(agent_id): Path, + Query(query): Query, + ws: WebSocketUpgrade, +) -> Result)> { + verify_node_metrics_ticket(&query.ticket, agent_id).map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid or expired node metrics ticket" })), + ) + })?; + + let tunnel_ip = resolve_agent_tunnel_ip(&state, agent_id).await?; + let proxy_ticket = fetch_proxy_ticket(&state, agent_id, METRICS_TICKET_SCOPE).await?; + + let agent_port: u16 = std::env::var(CSFX_AGENT_PORT_ENV) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(7443); + + let agent_url = format!("ws://{}:{}/metrics/stream", tunnel_ip, agent_port); + + let mut request = agent_url.into_client_request().map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("invalid agent metrics url: {}", e) })), + ) + })?; + request.headers_mut().insert( + "X-Csfx-Proxy-Ticket", + proxy_ticket.parse().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "invalid proxy ticket header" })), + ) + })?, + ); + + let (agent_socket, _) = tokio_tungstenite::connect_async(request) + .await + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json( + json!({ "error": format!("failed to connect to agent metrics socket: {}", e) }), + ), + ) + })?; + + Ok(ws.on_upgrade(move |browser_socket| bridge_metrics_socket(browser_socket, agent_socket))) +} + +async fn bridge_metrics_socket( + browser_socket: WebSocket, + agent_socket: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + let (mut browser_sink, _browser_stream) = browser_socket.split(); + let (_agent_sink, mut agent_stream) = agent_socket.split(); + + while let Some(Ok(msg)) = agent_stream.next().await { + let forwarded = match msg { + TungsteniteMessage::Text(text) => Message::Text(text.as_str().into()), + TungsteniteMessage::Close(_) => break, + _ => continue, + }; + if browser_sink.send(forwarded).await.is_err() { + break; + } + } +} + +pub fn agent_proxy_routes() -> Router { + Router::new() + .route("/workloads/{id}/logs", get(stream_workload_logs)) + .route( + "/workloads/{id}/exec/ticket", + axum::routing::post(issue_exec_ticket), + ) + .route("/workloads/{id}/exec", get(exec_workload)) + .route( + "/agents/{id}/metrics/ticket", + axum::routing::post(issue_node_metrics_ticket), + ) + .route("/agents/{id}/metrics/stream", get(stream_node_metrics)) +} diff --git a/control-plane/api-gateway/src/routes/agents.rs b/control-plane/api-gateway/src/routes/agents.rs index fbfa1474..f82f60e3 100644 --- a/control-plane/api-gateway/src/routes/agents.rs +++ b/control-plane/api-gateway/src/routes/agents.rs @@ -166,6 +166,9 @@ pub async fn register_agent( tags: ActiveValue::Set(registration.tags), capabilities: ActiveValue::Set(None), public_key_pem: ActiveValue::Set(None), + wg_public_key: ActiveValue::Set(None), + wg_endpoint: ActiveValue::Set(None), + wg_tunnel_ip: ActiveValue::Set(None), }; new_agent.insert(&state.db_conn).await.map_err(|e| { diff --git a/control-plane/api-gateway/src/routes/logs.rs b/control-plane/api-gateway/src/routes/logs.rs new file mode 100644 index 00000000..11ec9113 --- /dev/null +++ b/control-plane/api-gateway/src/routes/logs.rs @@ -0,0 +1,124 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Json}, + routing::get, + Router, +}; +use entity::{entities::logs, Logs}; +use sea_orm::{ + ColumnTrait, EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use crate::{auth::rbac::CanViewLogs, AppState}; + +const DEFAULT_LIMIT: u64 = 100; +const MAX_LIMIT: u64 = 500; + +#[derive(Debug, Deserialize)] +pub struct LogsQuery { + pub service: Option, + pub level: Option, + pub classification: Option, + pub agent_id: Option, + pub workload_id: Option, + pub organization_id: Option, + pub from: Option>, + pub to: Option>, + pub q: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Serialize)] +pub struct LogsResponse { + pub entries: Vec, + pub total: u64, + pub has_more: bool, +} + +fn apply_filters(mut query: sea_orm::Select, params: &LogsQuery) -> sea_orm::Select { + if let Some(service) = ¶ms.service { + query = query.filter(logs::Column::Service.eq(service)); + } + if let Some(level) = ¶ms.level { + query = query.filter(logs::Column::Level.eq(level)); + } + if let Some(classification) = ¶ms.classification { + query = query.filter(logs::Column::Classification.eq(classification)); + } + if let Some(agent_id) = params.agent_id { + query = query.filter(logs::Column::AgentId.eq(agent_id)); + } + if let Some(workload_id) = params.workload_id { + query = query.filter(logs::Column::WorkloadId.eq(workload_id)); + } + if let Some(organization_id) = params.organization_id { + query = query.filter(logs::Column::OrganizationId.eq(organization_id)); + } + if let Some(from) = params.from { + query = query.filter(logs::Column::CreatedAt.gte(from)); + } + if let Some(to) = params.to { + query = query.filter(logs::Column::CreatedAt.lte(to)); + } + if let Some(q) = ¶ms.q { + query = query.filter(logs::Column::Message.contains(q)); + } + query +} + +pub async fn list_logs( + CanViewLogs(_claims): CanViewLogs, + State(state): State, + Query(params): Query, +) -> Result)> { + let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let offset = params.offset.unwrap_or(0); + + let base_query = apply_filters(Logs::find(), ¶ms); + + let total = base_query + .clone() + .count(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to count logs"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + let entries = base_query + .order_by(logs::Column::CreatedAt, Order::Desc) + .offset(offset) + .limit(limit) + .all(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list logs"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + let has_more = offset + (entries.len() as u64) < total; + + Ok(( + StatusCode::OK, + Json(json!(LogsResponse { + entries, + total, + has_more, + })), + )) +} + +pub fn logs_routes() -> Router { + Router::new().route("/logs", get(list_logs)) +} diff --git a/control-plane/api-gateway/src/routes/mod.rs b/control-plane/api-gateway/src/routes/mod.rs index fe2338a9..288c6488 100644 --- a/control-plane/api-gateway/src/routes/mod.rs +++ b/control-plane/api-gateway/src/routes/mod.rs @@ -1,4 +1,5 @@ use super::AppState; +use crate::auth::rate_limit::JwtOrIpKeyExtractor; use crate::metrics; use crate::utils::router_ext::RouterExt; use axum::body::Body; @@ -8,18 +9,24 @@ use axum::http::{HeaderValue, Request, Response}; use axum::routing::get; use axum::Router; use std::sync::Arc; -use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; +use tower_governor::{ + governor::GovernorConfigBuilder, key_extractor::PeerIpKeyExtractor, GovernorLayer, +}; use tower_http::cors::CorsLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::{info_span, Span}; +pub mod agent_proxy; pub mod agents; pub mod events; +pub mod logs; pub mod networks; pub mod organizations; pub mod registry; pub mod releases; +pub mod resource_groups; +pub mod settings; pub mod ssh_keys; pub mod system; pub mod update; @@ -32,21 +39,41 @@ pub fn create_router() -> Router { let rate_limit_per_second: u64 = std::env::var("RATE_LIMIT_PER_SECOND") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(100); + .unwrap_or(500); let burst_size: u32 = std::env::var("RATE_LIMIT_BURST") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(200); + .unwrap_or(1000); let governor_config = Arc::new( GovernorConfigBuilder::default() + .key_extractor(JwtOrIpKeyExtractor::new()) .per_second(rate_limit_per_second) .burst_size(burst_size) .finish() .expect("invalid rate limit configuration"), ); + let login_rate_limit_per_second: u64 = std::env::var("LOGIN_RATE_LIMIT_PER_SECOND") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + + let login_burst_size: u32 = std::env::var("LOGIN_RATE_LIMIT_BURST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + + let login_governor_config = Arc::new( + GovernorConfigBuilder::default() + .key_extractor(PeerIpKeyExtractor) + .per_second(login_rate_limit_per_second) + .burst_size(login_burst_size) + .finish() + .expect("invalid login rate limit configuration"), + ); + let frontend_url = std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); @@ -84,6 +111,7 @@ pub fn create_router() -> Router { .merge(ssh_keys::ssh_keys_internal_routes()); let rate_limited_router = Router::new() + .merge(agent_proxy::agent_proxy_routes()) .merge(agents::agents_routes()) .merge(networks::networks_routes()) .merge(organizations::routes()) @@ -93,10 +121,18 @@ pub fn create_router() -> Router { .merge(volumes::volumes_routes()) .merge(workloads::workloads_routes()) .merge(events::events_routes()) + .merge(resource_groups::resource_groups_routes()) + .merge(logs::logs_routes()) + .merge(settings::settings_routes()) .layer(GovernorLayer::new(governor_config)); + let login_rate_limited_router = Router::new() + .merge(users::public_users_routes()) + .layer(GovernorLayer::new(login_governor_config)); + let api_router = Router::new() .merge(rate_limited_router) + .merge(login_rate_limited_router) .merge(update::routes()) .merge(releases::routes()); @@ -121,14 +157,33 @@ pub fn create_router() -> Router { ) }) .on_request(|_request: &Request, _span: &Span| { - tracing::info!("started processing request") + tracing::info!(target: "csfx::http_access", "started processing request") }) .on_response( - |_response: &Response, latency: std::time::Duration, _span: &Span| { - tracing::info!( - latency_ms = latency.as_millis(), - "finished processing request" - ) + |response: &Response, latency: std::time::Duration, _span: &Span| { + let status = response.status(); + if status.is_server_error() { + tracing::error!( + target: "csfx::http_access", + status = status.as_u16(), + latency_ms = latency.as_millis(), + "request failed" + ); + } else if status.is_client_error() { + tracing::warn!( + target: "csfx::http_access", + status = status.as_u16(), + latency_ms = latency.as_millis(), + "request rejected" + ); + } else { + tracing::info!( + target: "csfx::http_access", + status = status.as_u16(), + latency_ms = latency.as_millis(), + "finished processing request" + ); + } }, ), ) diff --git a/control-plane/api-gateway/src/routes/networks.rs b/control-plane/api-gateway/src/routes/networks.rs index d9301db4..45d947a5 100644 --- a/control-plane/api-gateway/src/routes/networks.rs +++ b/control-plane/api-gateway/src/routes/networks.rs @@ -215,5 +215,8 @@ pub fn networks_routes() -> Router { get(list_policies).post(create_policy), ) .route("/networks/{id}/members", get(list_members).post(add_member)) - .route("/networks/{id}/members/{workload_id}", delete(remove_member)) + .route( + "/networks/{id}/members/{workload_id}", + delete(remove_member), + ) } diff --git a/control-plane/api-gateway/src/routes/organizations.rs b/control-plane/api-gateway/src/routes/organizations.rs index 2e3eb203..0fd01469 100644 --- a/control-plane/api-gateway/src/routes/organizations.rs +++ b/control-plane/api-gateway/src/routes/organizations.rs @@ -19,7 +19,10 @@ pub fn routes() -> Router { // User management endpoints .route("/organization/users", axum::routing::get(list_users)) .route("/organization/users", axum::routing::post(create_user)) - .route("/organization/users/{user_id}", axum::routing::get(get_user)) + .route( + "/organization/users/{user_id}", + axum::routing::get(get_user), + ) .route( "/organization/users/{user_id}", axum::routing::put(update_user), diff --git a/control-plane/api-gateway/src/routes/releases.rs b/control-plane/api-gateway/src/routes/releases.rs index 1f6e2dc1..c0453ae6 100644 --- a/control-plane/api-gateway/src/routes/releases.rs +++ b/control-plane/api-gateway/src/routes/releases.rs @@ -116,16 +116,26 @@ fn semver_is_newer(candidate: &str, current: &str) -> bool { .unwrap_or(false) } -fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { +fn parse_semver(v: &str) -> Option<(u32, u32, u32, u32)> { let v = v.trim_start_matches('v'); - let base = v.split('-').next().unwrap_or(v); + let mut segments = v.splitn(2, '-'); + let base = segments.next().unwrap_or(v); + let pre_release = segments.next(); + let parts: Vec<&str> = base.split('.').collect(); if parts.len() != 3 { return None; } + + let pre_release_number = pre_release + .and_then(|p| p.rsplit('.').next()) + .and_then(|n| n.parse().ok()) + .unwrap_or(u32::MAX); + Some(( parts[0].parse().ok()?, parts[1].parse().ok()?, parts[2].parse().ok()?, + pre_release_number, )) } diff --git a/control-plane/api-gateway/src/routes/resource_groups.rs b/control-plane/api-gateway/src/routes/resource_groups.rs new file mode 100644 index 00000000..36cd0cf4 --- /dev/null +++ b/control-plane/api-gateway/src/routes/resource_groups.rs @@ -0,0 +1,496 @@ +use axum::{ + extract::{Path, State}, + http::{header, StatusCode}, + response::{IntoResponse, Json, Response}, + routing::get, + Router, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chrono::Utc; +use entity::{ + entities::{agents, networks, resource_groups, volumes, workloads}, + Agents, Networks, ResourceGroups, Volumes, Workloads, +}; +use ring::rand::{SecureRandom, SystemRandom}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + auth::rbac::{CanManageResourceGroups, CanViewResourceGroups}, + AppState, +}; + +#[derive(Debug, Deserialize)] +pub struct CreateResourceGroupRequest { + pub name: String, + pub description: Option, + pub internal_cidr: String, +} + +#[derive(Debug, Serialize)] +pub struct ResourceGroupResponse { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub description: Option, + pub internal_cidr: String, + pub status: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: Option, +} + +impl From for ResourceGroupResponse { + fn from(m: resource_groups::Model) -> Self { + Self { + id: m.id, + organization_id: m.organization_id, + name: m.name, + description: m.description, + internal_cidr: m.internal_cidr, + status: m.status, + created_at: m.created_at, + updated_at: m.updated_at, + } + } +} + +fn get_org_id(state: &AppState) -> Uuid { + state + .default_org_id + .expect("default organization not initialized") +} + +pub async fn list_resource_groups( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, +) -> Result)> { + let org_id = get_org_id(&state); + + let groups = ResourceGroups::find() + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .all(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list resource groups"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + let resp: Vec = groups.into_iter().map(Into::into).collect(); + Ok((StatusCode::OK, Json(json!(resp)))) +} + +pub async fn create_resource_group( + CanManageResourceGroups(_claims): CanManageResourceGroups, + State(state): State, + Json(req): Json, +) -> Result)> { + let org_id = get_org_id(&state); + + let existing = ResourceGroups::find() + .filter(resource_groups::Column::InternalCidr.eq(&req.internal_cidr)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to check cidr uniqueness"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + if existing.is_some() { + return Err(( + StatusCode::CONFLICT, + Json( + json!({ "error": format!("CIDR {} is already in use by another resource group", req.internal_cidr) }), + ), + )); + } + + let now = Utc::now().naive_utc(); + let model = resource_groups::ActiveModel { + id: Set(Uuid::new_v4()), + organization_id: Set(org_id), + name: Set(req.name), + description: Set(req.description), + internal_cidr: Set(req.internal_cidr), + status: Set("active".to_string()), + created_at: Set(now), + updated_at: Set(None), + }; + + let inserted = model.insert(&state.db_conn).await.map_err(|e| { + tracing::error!(error = %e, "failed to create resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok(( + StatusCode::CREATED, + Json(json!(ResourceGroupResponse::from(inserted))), + )) +} + +pub async fn get_resource_group( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + let group = ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to get resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + Ok(( + StatusCode::OK, + Json(json!(ResourceGroupResponse::from(group))), + )) +} + +pub async fn delete_resource_group( + CanManageResourceGroups(_claims): CanManageResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + let group = ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + let active_workloads = Workloads::find() + .filter(workloads::Column::ResourceGroupId.eq(id)) + .filter(workloads::Column::Status.is_in(["pending", "scheduled", "running"])) + .count(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to count workloads"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + if active_workloads > 0 { + return Err(( + StatusCode::CONFLICT, + Json(json!({ "error": "resource group has active workloads" })), + )); + } + + let mut active: resource_groups::ActiveModel = group.into(); + active.status = Set("deleting".to_string()); + active.updated_at = Set(Some(Utc::now().naive_utc())); + active.update(&state.db_conn).await.map_err(|e| { + tracing::error!(error = %e, "failed to update resource group status"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + ResourceGroups::delete_by_id(id) + .exec(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to delete resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn list_resource_group_workloads( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + let workloads = Workloads::find() + .filter(workloads::Column::ResourceGroupId.eq(id)) + .all(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list workloads"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok((StatusCode::OK, Json(json!(workloads)))) +} + +pub async fn list_resource_group_volumes( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + let vols = Volumes::find() + .filter(volumes::Column::ResourceGroupId.eq(id)) + .all(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list volumes"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok((StatusCode::OK, Json(json!(vols)))) +} + +pub async fn list_resource_group_networks( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + let nets = Networks::find() + .filter(networks::Column::ResourceGroupId.eq(id)) + .all(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list networks"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok((StatusCode::OK, Json(json!(nets)))) +} + +pub async fn get_vpn_config( + CanViewResourceGroups(_claims): CanViewResourceGroups, + State(state): State, + Path(id): Path, +) -> Result)> { + let org_id = get_org_id(&state); + + let group = ResourceGroups::find_by_id(id) + .filter(resource_groups::Column::OrganizationId.eq(org_id)) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to get resource group"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "resource group not found" })), + ) + })?; + + let gateway_agent = Agents::find() + .filter(agents::Column::Status.eq("Online")) + .filter(agents::Column::WgPublicKey.is_not_null()) + .filter(agents::Column::WgEndpoint.is_not_null()) + .order_by_asc(agents::Column::RegisteredAt) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to find gateway agent"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })? + .ok_or_else(|| { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "no WireGuard-enabled agent online" })), + ) + })?; + + let server_pubkey = gateway_agent.wg_public_key.as_deref().unwrap_or(""); + let endpoint = gateway_agent.wg_endpoint.as_deref().unwrap_or(""); + + let client_private_key = generate_wg_key().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to generate keypair" })), + ) + })?; + + let dns = first_host_ip(&group.internal_cidr).unwrap_or_else(|| "1.1.1.1".to_string()); + + let config = format!( + "[Interface]\nPrivateKey = {client_private_key}\nAddress = {dns}/32\nDNS = {dns}\n\n[Peer]\nPublicKey = {server_pubkey}\nEndpoint = {endpoint}\nAllowedIPs = {cidr}\nPersistentKeepalive = 25\n", + client_private_key = client_private_key, + dns = dns, + server_pubkey = server_pubkey, + endpoint = endpoint, + cidr = group.internal_cidr, + ); + + let filename = format!("csfx-{}.conf", group.name.to_lowercase().replace(' ', "-")); + + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "text/plain; charset=utf-8"), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{}\"", filename), + ), + ], + config, + ) + .into_response()) +} + +fn generate_wg_key() -> Result { + let rng = SystemRandom::new(); + let mut key_bytes = [0u8; 32]; + rng.fill(&mut key_bytes)?; + key_bytes[0] &= 248; + key_bytes[31] &= 127; + key_bytes[31] |= 64; + Ok(B64.encode(key_bytes)) +} + +fn first_host_ip(cidr: &str) -> Option { + let parts: Vec<&str> = cidr.split('/').collect(); + if parts.len() != 2 { + return None; + } + let octets: Vec = parts[0].split('.').filter_map(|o| o.parse().ok()).collect(); + if octets.len() != 4 { + return None; + } + let n = u32::from_be_bytes([octets[0], octets[1], octets[2], octets[3]]); + let host = n + 1; + let [a, b, c, d] = host.to_be_bytes(); + Some(format!("{}.{}.{}.{}", a, b, c, d)) +} + +pub fn resource_groups_routes() -> Router { + Router::new() + .route( + "/resource-groups", + get(list_resource_groups).post(create_resource_group), + ) + .route( + "/resource-groups/{id}", + get(get_resource_group).delete(delete_resource_group), + ) + .route( + "/resource-groups/{id}/workloads", + get(list_resource_group_workloads), + ) + .route( + "/resource-groups/{id}/volumes", + get(list_resource_group_volumes), + ) + .route( + "/resource-groups/{id}/networks", + get(list_resource_group_networks), + ) + .route("/resource-groups/{id}/vpn-config", get(get_vpn_config)) +} diff --git a/control-plane/api-gateway/src/routes/settings.rs b/control-plane/api-gateway/src/routes/settings.rs new file mode 100644 index 00000000..5598b4bf --- /dev/null +++ b/control-plane/api-gateway/src/routes/settings.rs @@ -0,0 +1,121 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Json}, + routing::get, + Router, +}; +use entity::{entities::system_settings, SystemSettings}; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{auth::rbac::CanManageLogs, AppState}; + +const RETENTION_KEY: &str = "logs.retention_days"; +const MIN_RETENTION_DAYS: i64 = 1; +const MAX_RETENTION_DAYS: i64 = 365; + +#[derive(Debug, Serialize)] +pub struct LogsRetentionResponse { + pub retention_days: i64, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateLogsRetentionRequest { + pub retention_days: i64, +} + +pub async fn get_logs_retention( + CanManageLogs(_claims): CanManageLogs, + State(state): State, +) -> Result)> { + let retention_days = load_retention_days(&state).await?; + Ok(( + StatusCode::OK, + Json(json!(LogsRetentionResponse { retention_days })), + )) +} + +pub async fn update_logs_retention( + CanManageLogs(_claims): CanManageLogs, + State(state): State, + Json(req): Json, +) -> Result)> { + if req.retention_days < MIN_RETENTION_DAYS || req.retention_days > MAX_RETENTION_DAYS { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!( + "retention_days must be between {} and {}", + MIN_RETENTION_DAYS, MAX_RETENTION_DAYS + ) })), + )); + } + + let existing = SystemSettings::find_by_id(RETENTION_KEY) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to load logs retention setting"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + let now = chrono::Utc::now().into(); + let model = match existing { + Some(setting) => { + let mut active: system_settings::ActiveModel = setting.into(); + active.value = Set(json!(req.retention_days)); + active.updated_at = Set(now); + active + } + None => system_settings::ActiveModel { + key: Set(RETENTION_KEY.to_string()), + value: Set(json!(req.retention_days)), + updated_at: Set(now), + }, + }; + + model.save(&state.db_conn).await.map_err(|e| { + tracing::error!(error = %e, "failed to save logs retention setting"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok(( + StatusCode::OK, + Json(json!(LogsRetentionResponse { + retention_days: req.retention_days + })), + )) +} + +pub async fn load_retention_days( + state: &AppState, +) -> Result)> { + let setting = SystemSettings::find_by_id(RETENTION_KEY) + .one(&state.db_conn) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to load logs retention setting"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "database error" })), + ) + })?; + + Ok(setting + .and_then(|s| s.value.as_i64()) + .unwrap_or(MAX_RETENTION_DAYS.min(30))) +} + +pub fn settings_routes() -> Router { + Router::new().route( + "/admin/settings/logs-retention", + get(get_logs_retention).put(update_logs_retention), + ) +} diff --git a/control-plane/api-gateway/src/routes/ssh_keys.rs b/control-plane/api-gateway/src/routes/ssh_keys.rs index 96ea05b7..5b2e48a0 100644 --- a/control-plane/api-gateway/src/routes/ssh_keys.rs +++ b/control-plane/api-gateway/src/routes/ssh_keys.rs @@ -144,7 +144,7 @@ pub async fn get_keys_for_agent( let keys: Vec = all_keys .into_iter() - .filter(|k| k.expires_at.map_or(true, |exp| exp > now)) + .filter(|k| k.expires_at.is_none_or(|exp| exp > now)) .map(|k| k.public_key) .collect(); @@ -161,5 +161,8 @@ pub fn ssh_keys_routes() -> Router { } pub fn ssh_keys_internal_routes() -> Router { - Router::new().route("/agents/{agent_id}/authorized-keys", get(get_keys_for_agent)) + Router::new().route( + "/agents/{agent_id}/authorized-keys", + get(get_keys_for_agent), + ) } diff --git a/control-plane/api-gateway/src/routes/update.rs b/control-plane/api-gateway/src/routes/update.rs index 124928b1..9cf6a899 100644 --- a/control-plane/api-gateway/src/routes/update.rs +++ b/control-plane/api-gateway/src/routes/update.rs @@ -18,6 +18,7 @@ const ETCD_DESIRED_FLAKE_REV_KEY: &str = "/csfx/config/desired_flake_rev"; const ETCD_BUILD_STATUS_KEY: &str = "/csfx/config/cp_build_status"; const ETCD_RESULT_KEY: &str = "/csfx/config/last_build_result"; const ETCD_PAUSED_KEY: &str = "/csfx/config/update_paused"; +const ETCD_FORCE_UPDATE_KEY: &str = "/csfx/config/update_force"; #[derive(Debug, Deserialize)] pub struct UpdateRequest { @@ -79,6 +80,14 @@ async fn trigger_update( StatusCode::INTERNAL_SERVER_ERROR })?; + client + .put(ETCD_FORCE_UPDATE_KEY, b"true", None) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to write force update flag to etcd"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + tracing::info!(version = %req.version, "update requested"); Ok(Json(UpdateResponse { diff --git a/control-plane/api-gateway/src/routes/users.rs b/control-plane/api-gateway/src/routes/users.rs index d65bea6a..6f86bce6 100644 --- a/control-plane/api-gateway/src/routes/users.rs +++ b/control-plane/api-gateway/src/routes/users.rs @@ -65,9 +65,9 @@ pub struct UserProfileResponse { pub force_password_change: bool, } -// Define the routes for the users module +// Define the authenticated routes for the users module pub fn users_routes() -> Router { - let auth_routes = Router::new() + Router::new() .route("/profile", get(get_user_profile)) .route("/validate-session", get(validate_session)) .route("/logout", post(logout_user)) @@ -75,13 +75,15 @@ pub fn users_routes() -> Router { .route("/2fa/enable", post(enable_2fa)) .route("/2fa/disable", post(disable_2fa)) .route("/change-password", post(change_password)) - .route("/change-email", post(change_email)); + .route("/change-email", post(change_email)) +} +// Define the public, unauthenticated routes for the users module +pub fn public_users_routes() -> Router { Router::new() .route("/register", post(register_user)) .route("/login", post(login_user)) .route("/public-key", get(get_public_key)) - .merge(auth_routes) } /// Register a new user diff --git a/control-plane/api-gateway/src/routes/workloads.rs b/control-plane/api-gateway/src/routes/workloads.rs index df1e0a81..80d61e73 100644 --- a/control-plane/api-gateway/src/routes/workloads.rs +++ b/control-plane/api-gateway/src/routes/workloads.rs @@ -96,6 +96,24 @@ pub async fn delete_workload( .await } +pub async fn create_stack( + CanManageWorkloads(_claims): CanManageWorkloads, + State(state): State, + headers: HeaderMap, + body: String, +) -> Result)> { + let body_json: Option = serde_json::from_str(&body).ok(); + let header_map = header_vec(&headers); + proxy_to_scheduler( + &state, + reqwest::Method::POST, + "/workload-stacks", + body_json, + Some(header_map), + ) + .await +} + fn header_vec(headers: &HeaderMap) -> Vec<(String, String)> { headers .iter() @@ -108,4 +126,5 @@ pub fn workloads_routes() -> Router { .route("/workloads", post(create_workload)) .route("/workloads", get(list_workloads)) .route("/workloads/{id}", delete(delete_workload)) + .route("/workload-stacks", post(create_stack)) } diff --git a/control-plane/api-gateway/src/self_monitor.rs b/control-plane/api-gateway/src/self_monitor.rs index 9c9604d7..8551e506 100644 --- a/control-plane/api-gateway/src/self_monitor.rs +++ b/control-plane/api-gateway/src/self_monitor.rs @@ -90,6 +90,9 @@ impl SelfMonitor { "self-monitor".to_string(), )]))), public_key_pem: ActiveValue::Set(None), + wg_public_key: ActiveValue::Set(None), + wg_endpoint: ActiveValue::Set(None), + wg_tunnel_ip: ActiveValue::Set(None), }; let agent = new_agent.insert(db_conn.as_ref()).await?; diff --git a/control-plane/api-gateway/src/service_client.rs b/control-plane/api-gateway/src/service_client.rs index dad81e33..b2aa704a 100644 --- a/control-plane/api-gateway/src/service_client.rs +++ b/control-plane/api-gateway/src/service_client.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result}; use reqwest::{Client, StatusCode}; -use serde::{Deserialize, Serialize}; use std::time::Duration; /// HTTP Client for internal service-to-service communication @@ -335,29 +334,3 @@ impl ServiceClient { Ok((status, json_body)) } } - -// Response types for common registry operations -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateTokenRequest { - pub description: Option, - pub created_by: String, - pub ttl_hours: i64, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct CreateTokenResponse { - pub token_id: String, - pub token: String, - pub expires_at: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ValidateTokenRequest { - pub token: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ValidateTokenResponse { - pub valid: bool, - pub agent_id: Option, -} diff --git a/control-plane/api-gateway/src/telemetry.rs b/control-plane/api-gateway/src/telemetry.rs index 0614fc30..f172e1f9 100644 --- a/control-plane/api-gateway/src/telemetry.rs +++ b/control-plane/api-gateway/src/telemetry.rs @@ -28,11 +28,15 @@ fn build_otlp_provider() -> Option { Some(provider) } -pub fn init_tracing() { +pub fn init_tracing() -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new("api-gateway"); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider() { Some(provider) => { @@ -45,4 +49,6 @@ pub fn init_tracing() { registry.init(); } } + + db_receiver } diff --git a/control-plane/csfx-updater/src/etcd.rs b/control-plane/csfx-updater/src/etcd.rs index 7052752e..0cf40f64 100644 --- a/control-plane/csfx-updater/src/etcd.rs +++ b/control-plane/csfx-updater/src/etcd.rs @@ -8,6 +8,7 @@ pub const DESIRED_FLAKE_REV_KEY: &str = "/csfx/config/desired_flake_rev"; pub const BUILD_STATUS_KEY: &str = "/csfx/config/cp_build_status"; pub const RESULT_KEY: &str = "/csfx/config/last_build_result"; pub const PAUSED_KEY: &str = "/csfx/config/update_paused"; +pub const FORCE_UPDATE_KEY: &str = "/csfx/config/update_force"; pub const NODE_HEARTBEAT_PREFIX: &str = "/csfx/nodes/"; pub struct Client { @@ -35,6 +36,11 @@ impl Client { Ok(()) } + pub async fn delete(&mut self, key: &str) -> Result<()> { + self.inner.delete(key, None).await?; + Ok(()) + } + pub async fn delete_prefix(&mut self, prefix: &str) -> Result<()> { use etcd_client::DeleteOptions; self.inner diff --git a/control-plane/csfx-updater/src/main.rs b/control-plane/csfx-updater/src/main.rs index 8f57875d..224cc40d 100644 --- a/control-plane/csfx-updater/src/main.rs +++ b/control-plane/csfx-updater/src/main.rs @@ -102,8 +102,10 @@ async fn run_executor_loop(cfg: &config::Config) { async fn execute_once(cfg: &config::Config, last_applied: &str) -> anyhow::Result> { let mut etcd = etcd::Client::connect(cfg).await?; - if etcd.get(etcd::PAUSED_KEY).await?.as_deref() == Some("true") { - tracing::debug!("updater paused, skipping cycle"); + let forced = etcd.get(etcd::FORCE_UPDATE_KEY).await?.as_deref() == Some("true"); + + if !forced && etcd.get(etcd::PAUSED_KEY).await?.as_deref() == Some("true") { + tracing::warn!("updater paused, skipping cycle"); return Ok(None); } @@ -125,6 +127,11 @@ async fn execute_once(cfg: &config::Config, last_applied: &str) -> anyhow::Resul return Ok(None); } + if forced { + etcd.delete(etcd::FORCE_UPDATE_KEY).await?; + info!(flake_rev = %desired, "forced update requested, bypassing pause"); + } + if !is_valid_sha(&desired) { tracing::warn!(flake_rev = %desired, "rejected invalid flake rev"); etcd.put(etcd::RESULT_KEY, "failed").await?; diff --git a/control-plane/failover-controller/src/logger.rs b/control-plane/failover-controller/src/logger.rs index 727fe564..7d8b6529 100644 --- a/control-plane/failover-controller/src/logger.rs +++ b/control-plane/failover-controller/src/logger.rs @@ -50,15 +50,21 @@ fn build_otlp_provider(service_name: &str) -> Option { Some(provider) } -pub fn init_logger() { - init_logger_with_service(env!("CARGO_PKG_NAME")); +pub fn init_logger() -> tokio::sync::mpsc::UnboundedReceiver { + init_logger_with_service(env!("CARGO_PKG_NAME")) } -pub fn init_logger_with_service(service_name: &'static str) { +pub fn init_logger_with_service( + service_name: &'static str, +) -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false).with_thread_ids(true); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new(service_name); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider(service_name) { Some(provider) => { @@ -71,6 +77,8 @@ pub fn init_logger_with_service(service_name: &'static str) { registry.init(); } } + + db_receiver } pub fn log_message(level: LogLevel, module: &str, location: &str, description: &str) { diff --git a/control-plane/failover-controller/src/main.rs b/control-plane/failover-controller/src/main.rs index 65c17d05..988cf090 100644 --- a/control-plane/failover-controller/src/main.rs +++ b/control-plane/failover-controller/src/main.rs @@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - logger::init_logger(); + let log_receiver = logger::init_logger(); metrics::init(); log_info!("main", "CSFX Failover Controller starting..."); @@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> { .await .expect("Failed to connect to database"); log_info!("main", "Database connection established"); + shared::spawn_log_writer(log_receiver, db.clone()); let state = server::AppState { db: db.clone() }; diff --git a/control-plane/registry/Cargo.toml b/control-plane/registry/Cargo.toml index 22b5d361..28a21091 100644 --- a/control-plane/registry/Cargo.toml +++ b/control-plane/registry/Cargo.toml @@ -46,6 +46,7 @@ rustls = { workspace = true } sha2 = { workspace = true } rcgen = { workspace = true } x509-parser = { workspace = true } +base64 = { workspace = true } prometheus = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } diff --git a/control-plane/registry/src/db/agents.rs b/control-plane/registry/src/db/agents.rs index c23ce15b..6166d7ae 100644 --- a/control-plane/registry/src/db/agents.rs +++ b/control-plane/registry/src/db/agents.rs @@ -78,6 +78,9 @@ pub async fn create( tags: Set(tags), capabilities: Set(capabilities), public_key_pem: Set(public_key_pem), + wg_public_key: Set(None), + wg_endpoint: Set(None), + wg_tunnel_ip: Set(None), }; Ok(model.insert(db).await?) @@ -95,6 +98,10 @@ pub async fn update_heartbeat( db: &DatabaseConnection, agent_id: Uuid, status: String, + wg_public_key: Option, + wg_endpoint: Option, + wg_tunnel_ip: Option, + agent_version: Option, ) -> Result<()> { let mut agent: agents::ActiveModel = agents::Entity::find_by_id(agent_id) .one(db) @@ -105,6 +112,18 @@ pub async fn update_heartbeat( agent.last_heartbeat = Set(Some(chrono::Utc::now().naive_utc())); agent.status = Set(status); agent.updated_at = Set(Some(chrono::Utc::now().naive_utc())); + if wg_public_key.is_some() { + agent.wg_public_key = Set(wg_public_key); + } + if wg_endpoint.is_some() { + agent.wg_endpoint = Set(wg_endpoint); + } + if wg_tunnel_ip.is_some() { + agent.wg_tunnel_ip = Set(wg_tunnel_ip); + } + if let Some(agent_version) = agent_version { + agent.agent_version = Set(agent_version); + } agent.update(db).await?; Ok(()) diff --git a/control-plane/registry/src/db/api_keys.rs b/control-plane/registry/src/db/api_keys.rs index 1f1f1456..ffea9a10 100644 --- a/control-plane/registry/src/db/api_keys.rs +++ b/control-plane/registry/src/db/api_keys.rs @@ -7,7 +7,11 @@ use uuid::Uuid; pub fn hash_key(key: &str) -> String { let mut hasher = Sha256::new(); hasher.update(key.as_bytes()); - hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect() + hasher + .finalize() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() } pub async fn create( diff --git a/control-plane/registry/src/handlers/agent.rs b/control-plane/registry/src/handlers/agent.rs index c6992bd7..7cf14472 100644 --- a/control-plane/registry/src/handlers/agent.rs +++ b/control-plane/registry/src/handlers/agent.rs @@ -208,7 +208,17 @@ pub async fn heartbeat( } } - match state.agent_registry.update_heartbeat(agent_id).await { + match state + .agent_registry + .update_heartbeat( + agent_id, + request.wg_public_key.clone(), + request.wg_endpoint.clone(), + request.wg_tunnel_ip.clone(), + request.agent_version.clone(), + ) + .await + { Ok(_) => { forward_metrics(&state, agent_id, &request).await; diff --git a/control-plane/registry/src/handlers/pki.rs b/control-plane/registry/src/handlers/pki.rs index b087147b..7dc686cf 100644 --- a/control-plane/registry/src/handlers/pki.rs +++ b/control-plane/registry/src/handlers/pki.rs @@ -1,9 +1,11 @@ use axum::{ - extract::{Path, State}, + extract::{ConnectInfo, Path, State}, http::{HeaderMap, StatusCode}, response::Json, }; use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; use uuid::Uuid; use crate::{ @@ -176,6 +178,62 @@ pub async fn get_crl( })) } +#[derive(Deserialize)] +pub struct ProxyTicketRequest { + pub agent_id: Uuid, + pub workload_id: String, +} + +#[derive(Serialize)] +pub struct ProxyTicketResponse { + pub payload: String, + pub signature: String, +} + +pub async fn issue_proxy_ticket( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + let ip = addr.ip(); + let allowed = ip.is_loopback() + || match ip { + std::net::IpAddr::V4(v4) => { + let octets = v4.octets(); + octets[0] == 10 + || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] == 192 && octets[1] == 168) + } + std::net::IpAddr::V6(_) => false, + }; + + if !allowed { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "internal endpoint not accessible from this network".to_string(), + }), + )); + } + + let ticket = state + .pki_service + .sign_proxy_ticket(request.agent_id, &request.workload_id) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to sign proxy ticket: {}", e), + }), + ) + })?; + + Ok(Json(ProxyTicketResponse { + payload: ticket.payload, + signature: ticket.signature, + })) +} + pub async fn get_agent_endpoint( State(state): State, Path(agent_id): Path, diff --git a/control-plane/registry/src/logger.rs b/control-plane/registry/src/logger.rs index 727fe564..7d8b6529 100644 --- a/control-plane/registry/src/logger.rs +++ b/control-plane/registry/src/logger.rs @@ -50,15 +50,21 @@ fn build_otlp_provider(service_name: &str) -> Option { Some(provider) } -pub fn init_logger() { - init_logger_with_service(env!("CARGO_PKG_NAME")); +pub fn init_logger() -> tokio::sync::mpsc::UnboundedReceiver { + init_logger_with_service(env!("CARGO_PKG_NAME")) } -pub fn init_logger_with_service(service_name: &'static str) { +pub fn init_logger_with_service( + service_name: &'static str, +) -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false).with_thread_ids(true); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new(service_name); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider(service_name) { Some(provider) => { @@ -71,6 +77,8 @@ pub fn init_logger_with_service(service_name: &'static str) { registry.init(); } } + + db_receiver } pub fn log_message(level: LogLevel, module: &str, location: &str, description: &str) { diff --git a/control-plane/registry/src/main.rs b/control-plane/registry/src/main.rs index 4b341cd4..d850973f 100644 --- a/control-plane/registry/src/main.rs +++ b/control-plane/registry/src/main.rs @@ -17,7 +17,7 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - logger::init_logger(); + let log_receiver = logger::init_logger(); metrics::init(); log_info!("main", "CSFX Registry Service starting..."); @@ -28,6 +28,7 @@ async fn main() -> anyhow::Result<()> { .await .expect("Failed to connect to database"); log_info!("main", "Database connection established"); + shared::spawn_log_writer(log_receiver, db_conn.clone()); let cert_ttl_hours: i64 = std::env::var("CSFX_CERT_TTL_HOURS") .ok() @@ -112,7 +113,12 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(addr).await?; let server_handle = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); }); tokio::select! { diff --git a/control-plane/registry/src/models/agent.rs b/control-plane/registry/src/models/agent.rs index e38537be..52cc8913 100644 --- a/control-plane/registry/src/models/agent.rs +++ b/control-plane/registry/src/models/agent.rs @@ -128,6 +128,10 @@ pub struct HeartbeatRequest { pub network_rx_bytes: Option, pub network_tx_bytes: Option, pub uptime_seconds: Option, + pub wg_public_key: Option, + pub wg_endpoint: Option, + pub wg_tunnel_ip: Option, + pub agent_version: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/control-plane/registry/src/server.rs b/control-plane/registry/src/server.rs index be89018d..7d6eeacf 100644 --- a/control-plane/registry/src/server.rs +++ b/control-plane/registry/src/server.rs @@ -71,6 +71,10 @@ pub fn create_router(state: AppState) -> Router { get(pki::get_agent_endpoint), ) .route("/pki/crl", get(pki::get_crl)) + .route( + "/internal/agent-proxy-ticket", + post(pki::issue_proxy_ticket), + ) .route("/agents/register", post(agent::register_agent)) .route("/agents/{agent_id}/heartbeat", post(agent::heartbeat)) .route( diff --git a/control-plane/registry/src/services/pki.rs b/control-plane/registry/src/services/pki.rs index 8f6ba565..700cae06 100644 --- a/control-plane/registry/src/services/pki.rs +++ b/control-plane/registry/src/services/pki.rs @@ -1,8 +1,9 @@ use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::{DateTime, Utc}; use rcgen::{ BasicConstraints, CertificateParams, CertificateSigningRequestParams, DnType, IsCa, Issuer, - KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, + KeyPair, KeyUsagePurpose, SigningKey, PKCS_ECDSA_P256_SHA256, }; use sea_orm::DatabaseConnection; use std::sync::atomic::{AtomicI64, Ordering}; @@ -15,6 +16,13 @@ pub struct IssuedCertificate { pub expires_at: DateTime, } +pub struct ProxyTicket { + pub payload: String, + pub signature: String, +} + +const PROXY_TICKET_TTL_SECS: i64 = 60; + struct CaState { ca_cert_pem: String, ca_key_pem: String, @@ -208,4 +216,21 @@ impl PkiService { .await .map_err(|e| anyhow!("Failed to build CRL: {}", e)) } + + pub fn sign_proxy_ticket(&self, agent_id: Uuid, workload_id: &str) -> Result { + let expires_at = Utc::now() + chrono::Duration::seconds(PROXY_TICKET_TTL_SECS); + let payload = format!("{}.{}.{}", agent_id, workload_id, expires_at.timestamp()); + + let key_pair = KeyPair::from_pem(&self.ca.ca_key_pem) + .map_err(|e| anyhow!("Failed to load CA key: {}", e))?; + + let signature = key_pair + .sign(payload.as_bytes()) + .map_err(|e| anyhow!("Failed to sign proxy ticket: {}", e))?; + + Ok(ProxyTicket { + payload: URL_SAFE_NO_PAD.encode(payload.as_bytes()), + signature: URL_SAFE_NO_PAD.encode(signature), + }) + } } diff --git a/control-plane/registry/src/services/registry.rs b/control-plane/registry/src/services/registry.rs index 3df0ec69..82851fc0 100644 --- a/control-plane/registry/src/services/registry.rs +++ b/control-plane/registry/src/services/registry.rs @@ -209,10 +209,25 @@ impl AgentRegistry { Ok((agent, false)) } - pub async fn update_heartbeat(&self, agent_id: Uuid) -> Result<(), String> { - crate::db::agents::update_heartbeat(&self.db, agent_id, "Online".to_string()) - .await - .map_err(|e| format!("Failed to update heartbeat: {}", e))?; + pub async fn update_heartbeat( + &self, + agent_id: Uuid, + wg_public_key: Option, + wg_endpoint: Option, + wg_tunnel_ip: Option, + agent_version: Option, + ) -> Result<(), String> { + crate::db::agents::update_heartbeat( + &self.db, + agent_id, + "Online".to_string(), + wg_public_key, + wg_endpoint, + wg_tunnel_ip, + agent_version, + ) + .await + .map_err(|e| format!("Failed to update heartbeat: {}", e))?; crate::log_debug!( "agent_registry", diff --git a/control-plane/scheduler/Cargo.toml b/control-plane/scheduler/Cargo.toml index 1a8ef34d..e867f558 100644 --- a/control-plane/scheduler/Cargo.toml +++ b/control-plane/scheduler/Cargo.toml @@ -20,8 +20,10 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } dotenvy = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } chrono = { workspace = true, features = ["serde"] } sea-orm = { workspace = true } diff --git a/control-plane/scheduler/src/db/agents.rs b/control-plane/scheduler/src/db/agents.rs index fba9b0f7..f29eb4a4 100644 --- a/control-plane/scheduler/src/db/agents.rs +++ b/control-plane/scheduler/src/db/agents.rs @@ -1,4 +1,4 @@ -use entity::entities::{agent_metrics, agents}; +use entity::entities::{agent_metrics, agents, volumes}; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; use uuid::Uuid; @@ -69,3 +69,11 @@ pub async fn get_assigned_workload_resources( Ok((cpu, mem, disk)) } + +pub async fn get_volume_agent( + db: &DatabaseConnection, + volume_id: Uuid, +) -> Result, sea_orm::DbErr> { + let vol = volumes::Entity::find_by_id(volume_id).one(db).await?; + Ok(vol.and_then(|v| v.attached_to_agent)) +} diff --git a/control-plane/scheduler/src/db/mod.rs b/control-plane/scheduler/src/db/mod.rs index c94899ea..1e7c9165 100644 --- a/control-plane/scheduler/src/db/mod.rs +++ b/control-plane/scheduler/src/db/mod.rs @@ -1,2 +1,3 @@ pub mod agents; +pub mod workload_stacks; pub mod workloads; diff --git a/control-plane/scheduler/src/db/workload_stacks.rs b/control-plane/scheduler/src/db/workload_stacks.rs new file mode 100644 index 00000000..d5f56c84 --- /dev/null +++ b/control-plane/scheduler/src/db/workload_stacks.rs @@ -0,0 +1,41 @@ +use chrono::Utc; +use entity::entities::workload_stacks; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait}; +use uuid::Uuid; + +pub async fn create( + db: &DatabaseConnection, + resource_group_id: Uuid, + name: &str, + compose_source: &str, +) -> Result { + let model = workload_stacks::ActiveModel { + id: Set(Uuid::new_v4()), + resource_group_id: Set(resource_group_id), + name: Set(name.to_string()), + compose_source: Set(Some(compose_source.to_string())), + status: Set("pending".to_string()), + created_at: Set(Utc::now().naive_utc()), + updated_at: Set(None), + }; + + model.insert(db).await +} + +pub async fn update_status( + db: &DatabaseConnection, + stack_id: Uuid, + status: &str, +) -> Result<(), sea_orm::DbErr> { + let stack = workload_stacks::Entity::find_by_id(stack_id) + .one(db) + .await? + .ok_or(sea_orm::DbErr::RecordNotFound(stack_id.to_string()))?; + + let mut active: workload_stacks::ActiveModel = stack.into(); + active.status = Set(status.to_string()); + active.updated_at = Set(Some(Utc::now().naive_utc())); + + active.update(db).await?; + Ok(()) +} diff --git a/control-plane/scheduler/src/db/workloads.rs b/control-plane/scheduler/src/db/workloads.rs index 9b8c6218..91141337 100644 --- a/control-plane/scheduler/src/db/workloads.rs +++ b/control-plane/scheduler/src/db/workloads.rs @@ -17,6 +17,10 @@ pub async fn create( .ports .as_ref() .and_then(|p| serde_json::to_value(p).ok()); + let volume_mounts = req + .volume_mounts + .as_ref() + .and_then(|v| serde_json::to_value(v).ok()); let model = workloads::ActiveModel { id: Set(Uuid::new_v4()), @@ -27,11 +31,15 @@ pub async fn create( disk_bytes: Set(req.disk_bytes), env_vars: Set(env_vars), ports: Set(ports), + volume_mounts: Set(volume_mounts), status: Set(WorkloadStatus::Pending.as_str().to_string()), assigned_agent_id: Set(None), container_id: Set(None), created_by: Set(None), organization_id: Set(None), + resource_group_id: Set(req.resource_group_id), + stack_id: Set(req.stack_id), + service_name: Set(req.service_name.clone()), created_at: Set(Utc::now().naive_utc()), updated_at: Set(None), }; @@ -74,7 +82,9 @@ pub async fn update_container_status( .ok_or(sea_orm::DbErr::RecordNotFound(workload_id.to_string()))?; let mut active: workloads::ActiveModel = workload.into(); - active.container_id = Set(Some(container_id.to_string())); + if !container_id.is_empty() { + active.container_id = Set(Some(container_id.to_string())); + } active.status = Set(status.to_string()); active.updated_at = Set(Some(Utc::now().naive_utc())); @@ -93,6 +103,11 @@ pub async fn delete(db: &DatabaseConnection, workload_id: Uuid) -> Result<(), se } fn into_response(m: workloads::Model) -> WorkloadResponse { + let volume_mounts = m + .volume_mounts + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + WorkloadResponse { id: m.id, name: m.name, @@ -103,6 +118,10 @@ fn into_response(m: workloads::Model) -> WorkloadResponse { status: WorkloadStatus::from_str(&m.status), assigned_agent_id: m.assigned_agent_id, container_id: m.container_id, + volume_mounts, + resource_group_id: m.resource_group_id, + stack_id: m.stack_id, + service_name: m.service_name, created_at: m.created_at.and_utc(), updated_at: m.updated_at.map(|dt| dt.and_utc()), } diff --git a/control-plane/scheduler/src/handlers/mod.rs b/control-plane/scheduler/src/handlers/mod.rs index 474d2676..3f038ecf 100644 --- a/control-plane/scheduler/src/handlers/mod.rs +++ b/control-plane/scheduler/src/handlers/mod.rs @@ -1,2 +1,3 @@ pub mod internal; +pub mod stacks; pub mod workloads; diff --git a/control-plane/scheduler/src/handlers/stacks.rs b/control-plane/scheduler/src/handlers/stacks.rs new file mode 100644 index 00000000..7fefacfe --- /dev/null +++ b/control-plane/scheduler/src/handlers/stacks.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; + +use crate::{models::compose::CreateStackRequest, server::AppState}; + +pub async fn create_stack( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + match state.scheduler.schedule_stack(req).await { + Ok(resp) => (StatusCode::CREATED, Json(serde_json::json!(resp))).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ) + .into_response(), + } +} diff --git a/control-plane/scheduler/src/logger.rs b/control-plane/scheduler/src/logger.rs index 727fe564..7d8b6529 100644 --- a/control-plane/scheduler/src/logger.rs +++ b/control-plane/scheduler/src/logger.rs @@ -50,15 +50,21 @@ fn build_otlp_provider(service_name: &str) -> Option { Some(provider) } -pub fn init_logger() { - init_logger_with_service(env!("CARGO_PKG_NAME")); +pub fn init_logger() -> tokio::sync::mpsc::UnboundedReceiver { + init_logger_with_service(env!("CARGO_PKG_NAME")) } -pub fn init_logger_with_service(service_name: &'static str) { +pub fn init_logger_with_service( + service_name: &'static str, +) -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false).with_thread_ids(true); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new(service_name); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider(service_name) { Some(provider) => { @@ -71,6 +77,8 @@ pub fn init_logger_with_service(service_name: &'static str) { registry.init(); } } + + db_receiver } pub fn log_message(level: LogLevel, module: &str, location: &str, description: &str) { diff --git a/control-plane/scheduler/src/main.rs b/control-plane/scheduler/src/main.rs index 743d285f..e821ea86 100644 --- a/control-plane/scheduler/src/main.rs +++ b/control-plane/scheduler/src/main.rs @@ -14,7 +14,7 @@ mod services; async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - logger::init_logger(); + let log_receiver = logger::init_logger(); metrics::init(); log_info!("main", "CSFX Scheduler Service starting..."); @@ -25,6 +25,7 @@ async fn main() -> anyhow::Result<()> { .await .expect("Failed to connect to database"); log_info!("main", "Database connection established"); + shared::spawn_log_writer(log_receiver, db.clone()); let etcd_endpoints = std::env::var("ETCD_ENDPOINTS").unwrap_or_else(|_| "http://localhost:2379".to_string()); diff --git a/control-plane/scheduler/src/models/compose.rs b/control-plane/scheduler/src/models/compose.rs new file mode 100644 index 00000000..abd47fe3 --- /dev/null +++ b/control-plane/scheduler/src/models/compose.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use uuid::Uuid; + +use crate::models::workload::PortMapping; + +#[derive(Debug, Clone)] +pub struct ComposeServiceSpec { + pub service_name: String, + pub image: String, + pub env_vars: Option>, + pub ports: Option>, + pub cpu_millicores: i32, + pub memory_bytes: i64, + pub disk_bytes: i64, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct CreateStackRequest { + pub name: String, + pub resource_group_id: Uuid, + pub compose_yaml: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CreateStackResponse { + pub stack_id: Uuid, + pub workloads: Vec, +} diff --git a/control-plane/scheduler/src/models/mod.rs b/control-plane/scheduler/src/models/mod.rs index 8580f7df..748da2a6 100644 --- a/control-plane/scheduler/src/models/mod.rs +++ b/control-plane/scheduler/src/models/mod.rs @@ -1 +1,2 @@ +pub mod compose; pub mod workload; diff --git a/control-plane/scheduler/src/models/workload.rs b/control-plane/scheduler/src/models/workload.rs index 6ab22942..fdc5888b 100644 --- a/control-plane/scheduler/src/models/workload.rs +++ b/control-plane/scheduler/src/models/workload.rs @@ -8,6 +8,9 @@ use uuid::Uuid; pub enum WorkloadStatus { Pending, Scheduled, + Pulling, + Creating, + Starting, Running, Failed, Stopped, @@ -18,6 +21,9 @@ impl WorkloadStatus { match self { WorkloadStatus::Pending => "pending", WorkloadStatus::Scheduled => "scheduled", + WorkloadStatus::Pulling => "pulling", + WorkloadStatus::Creating => "creating", + WorkloadStatus::Starting => "starting", WorkloadStatus::Running => "running", WorkloadStatus::Failed => "failed", WorkloadStatus::Stopped => "stopped", @@ -27,6 +33,9 @@ impl WorkloadStatus { pub fn from_str(s: &str) -> Self { match s { "scheduled" => WorkloadStatus::Scheduled, + "pulling" => WorkloadStatus::Pulling, + "creating" => WorkloadStatus::Creating, + "starting" => WorkloadStatus::Starting, "running" => WorkloadStatus::Running, "failed" => WorkloadStatus::Failed, "stopped" => WorkloadStatus::Stopped, @@ -35,6 +44,12 @@ impl WorkloadStatus { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeMount { + pub volume_id: Uuid, + pub mount_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateWorkloadRequest { pub name: String, @@ -44,13 +59,19 @@ pub struct CreateWorkloadRequest { pub disk_bytes: i64, pub env_vars: Option>, pub ports: Option>, + pub volume_mounts: Option>, + pub resource_group_id: Option, + #[serde(default)] + pub stack_id: Option, + #[serde(default)] + pub service_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { - pub host_port: u16, pub container_port: u16, pub protocol: Option, + pub node_port: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -72,6 +93,10 @@ pub struct WorkloadResponse { pub status: WorkloadStatus, pub assigned_agent_id: Option, pub container_id: Option, + pub volume_mounts: Option>, + pub resource_group_id: Option, + pub stack_id: Option, + pub service_name: Option, pub created_at: DateTime, pub updated_at: Option>, } diff --git a/control-plane/scheduler/src/server.rs b/control-plane/scheduler/src/server.rs index 0b85206e..b03e0795 100644 --- a/control-plane/scheduler/src/server.rs +++ b/control-plane/scheduler/src/server.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use crate::{ - handlers::{internal, workloads}, + handlers::{internal, stacks, workloads}, metrics, services::scheduler::SchedulerService, }; @@ -34,6 +34,10 @@ pub fn create_router(state: AppState) -> Router { "/workloads/{id}", axum::routing::delete(workloads::delete_workload), ) + .route( + "/workload-stacks", + axum::routing::post(stacks::create_stack), + ) .route( "/internal/workloads/status", axum::routing::post(internal::update_container_statuses), diff --git a/control-plane/scheduler/src/services/compose_parser.rs b/control-plane/scheduler/src/services/compose_parser.rs new file mode 100644 index 00000000..a05a8ffc --- /dev/null +++ b/control-plane/scheduler/src/services/compose_parser.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +use crate::models::compose::ComposeServiceSpec; +use crate::models::workload::PortMapping; + +const DEFAULT_CPU_MILLICORES: i32 = 500; +const DEFAULT_MEMORY_BYTES: i64 = 512 * 1024 * 1024; +const DEFAULT_DISK_BYTES: i64 = 1024 * 1024 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum ComposeParseError { + #[error("invalid compose yaml: {0}")] + InvalidYaml(String), + #[error("compose file defines no services")] + NoServicesDefined, + #[error("service '{0}' uses build, only image is supported")] + UnsupportedBuildDirective(String), + #[error("service '{0}' has no image")] + MissingImage(String), +} + +#[derive(Debug, serde::Deserialize)] +struct RawCompose { + services: HashMap, +} + +#[derive(Debug, serde::Deserialize)] +struct RawService { + image: Option, + build: Option, + ports: Option>, + environment: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum RawEnvironment { + List(Vec), + Map(HashMap), +} + +pub fn parse_compose(yaml: &str) -> Result, ComposeParseError> { + let raw: RawCompose = + serde_yaml::from_str(yaml).map_err(|e| ComposeParseError::InvalidYaml(e.to_string()))?; + + if raw.services.is_empty() { + return Err(ComposeParseError::NoServicesDefined); + } + + raw.services + .into_iter() + .map(|(service_name, service)| build_service_spec(service_name, service)) + .collect() +} + +fn build_service_spec( + service_name: String, + service: RawService, +) -> Result { + if service.build.is_some() { + return Err(ComposeParseError::UnsupportedBuildDirective(service_name)); + } + let image = service + .image + .ok_or_else(|| ComposeParseError::MissingImage(service_name.clone()))?; + + Ok(ComposeServiceSpec { + service_name, + image, + env_vars: normalize_environment(service.environment), + ports: service.ports.map(|raw| parse_compose_ports(&raw)), + cpu_millicores: DEFAULT_CPU_MILLICORES, + memory_bytes: DEFAULT_MEMORY_BYTES, + disk_bytes: DEFAULT_DISK_BYTES, + }) +} + +fn normalize_environment(raw: Option) -> Option> { + match raw? { + RawEnvironment::Map(map) => Some(map), + RawEnvironment::List(list) => Some( + list.into_iter() + .filter_map(|entry| { + let (key, value) = entry.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(), + ), + } +} + +fn parse_compose_ports(raw: &[String]) -> Vec { + raw.iter() + .filter_map(|entry| parse_compose_port(entry)) + .collect() +} + +fn parse_compose_port(entry: &str) -> Option { + let (node_port, container_port) = match entry.split_once(':') { + Some((host, container)) => (host.parse().ok(), container), + None => (None, entry), + }; + + Some(PortMapping { + container_port: container_port.parse().ok()?, + protocol: None, + node_port, + }) +} diff --git a/control-plane/scheduler/src/services/etcd.rs b/control-plane/scheduler/src/services/etcd.rs index ec826a65..1eefdeaa 100644 --- a/control-plane/scheduler/src/services/etcd.rs +++ b/control-plane/scheduler/src/services/etcd.rs @@ -13,6 +13,10 @@ pub struct PlacementRecord { pub memory_bytes: i64, pub disk_bytes: i64, pub scheduled_at: String, + #[serde(default)] + pub stack_id: Option, + #[serde(default)] + pub service_name: Option, } pub async fn put_placement( diff --git a/control-plane/scheduler/src/services/mod.rs b/control-plane/scheduler/src/services/mod.rs index d5016dca..9655cca1 100644 --- a/control-plane/scheduler/src/services/mod.rs +++ b/control-plane/scheduler/src/services/mod.rs @@ -1,2 +1,3 @@ +pub mod compose_parser; pub mod etcd; pub mod scheduler; diff --git a/control-plane/scheduler/src/services/scheduler.rs b/control-plane/scheduler/src/services/scheduler.rs index 1835c5fe..80a5463c 100644 --- a/control-plane/scheduler/src/services/scheduler.rs +++ b/control-plane/scheduler/src/services/scheduler.rs @@ -5,9 +5,11 @@ use std::sync::Arc; use tokio::sync::Mutex; use uuid::Uuid; +use crate::models::compose::{ComposeServiceSpec, CreateStackRequest, CreateStackResponse}; use crate::models::workload::{ AgentResources, CreateWorkloadRequest, CreateWorkloadResponse, WorkloadStatus, }; +use crate::services::compose_parser::{self, ComposeParseError}; use crate::services::etcd::{delete_placement, put_placement, PlacementRecord}; pub struct SchedulerService { @@ -43,6 +45,12 @@ impl SchedulerService { agent.free_disk_bytes -= reserved_disk; } + let volume_pinned_agent = self.resolve_volume_affinity(&req).await?; + + if let Some(required_agent) = volume_pinned_agent { + agents.retain(|a| a.agent_id == required_agent); + } + match self.first_fit(&req, &agents) { Some(agent_id) => { crate::db::workloads::assign(&self.db, workload.id, agent_id) @@ -57,6 +65,8 @@ impl SchedulerService { memory_bytes: req.memory_bytes, disk_bytes: req.disk_bytes, scheduled_at: Utc::now().to_rfc3339(), + stack_id: req.stack_id, + service_name: req.service_name.clone(), }; put_placement(&self.etcd, &record).await?; @@ -92,7 +102,183 @@ impl SchedulerService { } } + pub async fn schedule_stack( + &self, + req: CreateStackRequest, + ) -> Result { + let services = compose_parser::parse_compose(&req.compose_yaml) + .map_err(|e| format_compose_error(&e))?; + + let stack = crate::db::workload_stacks::create( + &self.db, + req.resource_group_id, + &req.name, + &req.compose_yaml, + ) + .await + .map_err(|e| format!("Failed to persist stack: {}", e))?; + + let workloads = self + .place_stack_workloads(stack.id, req.resource_group_id, &services) + .await?; + + crate::db::workload_stacks::update_status( + &self.db, + stack.id, + if workloads.iter().all(|w| w.assigned_agent_id.is_some()) { + "active" + } else { + "pending" + }, + ) + .await + .map_err(|e| format!("Failed to update stack status: {}", e))?; + + Ok(CreateStackResponse { + stack_id: stack.id, + workloads, + }) + } + + async fn place_stack_workloads( + &self, + stack_id: Uuid, + resource_group_id: Uuid, + services: &[ComposeServiceSpec], + ) -> Result, String> { + let mut agents = crate::db::agents::get_online_agents_with_resources(&self.db) + .await + .map_err(|e| format!("Failed to fetch agent resources: {}", e))?; + + for agent in agents.iter_mut() { + let (reserved_cpu, reserved_mem, reserved_disk) = + crate::db::agents::get_assigned_workload_resources(&self.db, agent.agent_id) + .await + .map_err(|e| format!("Failed to fetch reserved resources: {}", e))?; + + agent.free_cpu_millicores -= reserved_cpu; + agent.free_memory_bytes -= reserved_mem; + agent.free_disk_bytes -= reserved_disk; + } + + let total_cpu: i32 = services.iter().map(|s| s.cpu_millicores).sum(); + let total_memory: i64 = services.iter().map(|s| s.memory_bytes).sum(); + let total_disk: i64 = services.iter().map(|s| s.disk_bytes).sum(); + + let agent_id = self.first_fit_resources(total_cpu, total_memory, total_disk, &agents); + + let mut responses = Vec::with_capacity(services.len()); + for service in services { + let req = CreateWorkloadRequest { + name: format!("{}-{}", stack_id, service.service_name), + image: service.image.clone(), + cpu_millicores: service.cpu_millicores, + memory_bytes: service.memory_bytes, + disk_bytes: service.disk_bytes, + env_vars: service.env_vars.clone(), + ports: service.ports.clone(), + volume_mounts: None, + resource_group_id: Some(resource_group_id), + stack_id: Some(stack_id), + service_name: Some(service.service_name.clone()), + }; + + let workload = crate::db::workloads::create(&self.db, &req) + .await + .map_err(|e| format!("Failed to persist workload: {}", e))?; + + responses.push(match agent_id { + Some(agent_id) => { + crate::db::workloads::assign(&self.db, workload.id, agent_id) + .await + .map_err(|e| format!("Failed to assign workload: {}", e))?; + + let record = PlacementRecord { + workload_id: workload.id, + agent_id, + image: req.image.clone(), + cpu_millicores: req.cpu_millicores, + memory_bytes: req.memory_bytes, + disk_bytes: req.disk_bytes, + scheduled_at: Utc::now().to_rfc3339(), + stack_id: Some(stack_id), + service_name: Some(service.service_name.clone()), + }; + put_placement(&self.etcd, &record).await?; + + CreateWorkloadResponse { + workload_id: workload.id, + status: WorkloadStatus::Scheduled, + assigned_agent_id: Some(agent_id), + message: format!("Workload assigned to agent {}", agent_id), + } + } + None => CreateWorkloadResponse { + workload_id: workload.id, + status: WorkloadStatus::Pending, + assigned_agent_id: None, + message: "No agent with sufficient resources available".to_string(), + }, + }); + } + + crate::log_info!( + "scheduler", + &format!( + "Stack scheduled stack_id={} agent_id={:?} services={}", + stack_id, + agent_id, + services.len() + ) + ); + + Ok(responses) + } + + async fn resolve_volume_affinity( + &self, + req: &CreateWorkloadRequest, + ) -> Result, String> { + let mounts = match &req.volume_mounts { + Some(m) if !m.is_empty() => m, + _ => return Ok(None), + }; + + let mut pinned: Option = None; + + for mount in mounts { + let agent_id = crate::db::agents::get_volume_agent(&self.db, mount.volume_id) + .await + .map_err(|e| format!("Failed to check volume affinity: {}", e))?; + + if let Some(aid) = agent_id { + match pinned { + None => pinned = Some(aid), + Some(existing) if existing != aid => { + return Err(format!( + "Volume mounts require conflicting agents: {} vs {}", + existing, aid + )); + } + _ => {} + } + } + } + + Ok(pinned) + } + fn first_fit(&self, req: &CreateWorkloadRequest, agents: &[AgentResources]) -> Option { + self.first_fit_resources(req.cpu_millicores, req.memory_bytes, req.disk_bytes, agents) + } + + fn first_fit_resources( + &self, + cpu_millicores: i32, + memory_bytes: i64, + disk_bytes: i64, + agents: &[AgentResources], + ) -> Option { let mut sorted: Vec<&AgentResources> = agents.iter().collect(); sorted.sort_by(|a, b| { let score_a = a.free_cpu_millicores as i64 + a.free_memory_bytes / (1024 * 1024); @@ -103,9 +289,9 @@ impl SchedulerService { sorted .into_iter() .find(|a| { - a.free_cpu_millicores >= req.cpu_millicores - && a.free_memory_bytes >= req.memory_bytes - && a.free_disk_bytes >= req.disk_bytes + a.free_cpu_millicores >= cpu_millicores + && a.free_memory_bytes >= memory_bytes + && a.free_disk_bytes >= disk_bytes }) .map(|a| a.agent_id) } @@ -133,3 +319,16 @@ impl SchedulerService { Ok(()) } } + +fn format_compose_error(error: &ComposeParseError) -> String { + match error { + ComposeParseError::InvalidYaml(detail) => format!("invalid compose yaml: {}", detail), + ComposeParseError::NoServicesDefined => "compose file defines no services".to_string(), + ComposeParseError::UnsupportedBuildDirective(service) => { + format!("service '{}' uses build, only image is supported", service) + } + ComposeParseError::MissingImage(service) => { + format!("service '{}' has no image", service) + } + } +} diff --git a/control-plane/sdn-controller/src/db/networks.rs b/control-plane/sdn-controller/src/db/networks.rs index 78cee66e..55ab1a88 100644 --- a/control-plane/sdn-controller/src/db/networks.rs +++ b/control-plane/sdn-controller/src/db/networks.rs @@ -19,6 +19,7 @@ pub async fn create_network( overlay_type: Set(req.overlay_type), status: Set("active".to_string()), organization_id: Set(None), + resource_group_id: Set(req.resource_group_id), created_at: Set(Utc::now().naive_utc()), updated_at: Set(None), }; diff --git a/control-plane/sdn-controller/src/logger.rs b/control-plane/sdn-controller/src/logger.rs index 727fe564..7d8b6529 100644 --- a/control-plane/sdn-controller/src/logger.rs +++ b/control-plane/sdn-controller/src/logger.rs @@ -50,15 +50,21 @@ fn build_otlp_provider(service_name: &str) -> Option { Some(provider) } -pub fn init_logger() { - init_logger_with_service(env!("CARGO_PKG_NAME")); +pub fn init_logger() -> tokio::sync::mpsc::UnboundedReceiver { + init_logger_with_service(env!("CARGO_PKG_NAME")) } -pub fn init_logger_with_service(service_name: &'static str) { +pub fn init_logger_with_service( + service_name: &'static str, +) -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false).with_thread_ids(true); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new(service_name); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider(service_name) { Some(provider) => { @@ -71,6 +77,8 @@ pub fn init_logger_with_service(service_name: &'static str) { registry.init(); } } + + db_receiver } pub fn log_message(level: LogLevel, module: &str, location: &str, description: &str) { diff --git a/control-plane/sdn-controller/src/main.rs b/control-plane/sdn-controller/src/main.rs index 5d127850..c397a715 100644 --- a/control-plane/sdn-controller/src/main.rs +++ b/control-plane/sdn-controller/src/main.rs @@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - logger::init_logger(); + let log_receiver = logger::init_logger(); metrics::init(); log_info!("main", "CSFX SDN Controller starting..."); @@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> { .await .expect("Failed to connect to database"); log_info!("main", "Database connection established"); + shared::spawn_log_writer(log_receiver, db.clone()); let etcd_url = std::env::var("ETCD_URL").unwrap_or_else(|_| "http://localhost:2379".to_string()); diff --git a/control-plane/sdn-controller/src/models.rs b/control-plane/sdn-controller/src/models.rs index 2526c0fe..22b1f231 100644 --- a/control-plane/sdn-controller/src/models.rs +++ b/control-plane/sdn-controller/src/models.rs @@ -5,6 +5,7 @@ pub struct CreateNetworkRequest { pub name: String, pub cidr: String, pub overlay_type: String, + pub resource_group_id: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/control-plane/shared/entity/src/entities/agents.rs b/control-plane/shared/entity/src/entities/agents.rs index fe64aeb9..79a12948 100644 --- a/control-plane/shared/entity/src/entities/agents.rs +++ b/control-plane/shared/entity/src/entities/agents.rs @@ -21,6 +21,9 @@ pub struct Model { pub tags: Option, pub capabilities: Option, pub public_key_pem: Option, + pub wg_public_key: Option, + pub wg_endpoint: Option, + pub wg_tunnel_ip: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/control-plane/shared/entity/src/entities/logs.rs b/control-plane/shared/entity/src/entities/logs.rs new file mode 100644 index 00000000..2a8987b2 --- /dev/null +++ b/control-plane/shared/entity/src/entities/logs.rs @@ -0,0 +1,65 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "logs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub service: String, + pub level: String, + pub classification: String, + pub message: String, + pub agent_id: Option, + pub workload_id: Option, + pub organization_id: Option, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::agents::Entity", + from = "Column::AgentId", + to = "super::agents::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Agent, + #[sea_orm( + belongs_to = "super::workloads::Entity", + from = "Column::WorkloadId", + to = "super::workloads::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Workload, + #[sea_orm( + belongs_to = "super::organization::Entity", + from = "Column::OrganizationId", + to = "super::organization::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Organization, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Agent.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workload.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Organization.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/mod.rs b/control-plane/shared/entity/src/entities/mod.rs index d3ea77fd..8b1b761b 100644 --- a/control-plane/shared/entity/src/entities/mod.rs +++ b/control-plane/shared/entity/src/entities/mod.rs @@ -4,23 +4,26 @@ pub mod agent_metrics; pub mod agents; pub mod bootstrap_tokens; pub mod certificate_revocations; -pub mod config; pub mod failover_events; pub mod invalid_jwt; pub mod key; +pub mod logs; pub mod network_members; pub mod network_policies; pub mod networks; pub mod organization; pub mod permission; pub mod registry_tokens; +pub mod resource_groups; pub mod role; pub mod role_permission; +pub mod system_settings; pub mod user; pub mod user_organization; pub mod user_ssh_keys; pub mod volume_snapshots; pub mod volumes; +pub mod workload_stacks; pub mod workloads; pub use agent_api_keys::Entity as AgentApiKeys; @@ -29,21 +32,24 @@ pub use agent_metrics::Entity as AgentMetrics; pub use agents::Entity as Agents; pub use bootstrap_tokens::Entity as BootstrapTokens; pub use certificate_revocations::Entity as CertificateRevocations; -pub use config::Entity as Config; pub use failover_events::Entity as FailoverEvents; pub use invalid_jwt::Entity as InvalidJwt; pub use key::Entity as Key; +pub use logs::Entity as Logs; pub use network_members::Entity as NetworkMembers; pub use network_policies::Entity as NetworkPolicies; pub use networks::Entity as Networks; pub use organization::Entity as Organization; pub use permission::Entity as Permission; pub use registry_tokens::Entity as RegistryTokens; +pub use resource_groups::Entity as ResourceGroups; pub use role::Entity as Role; pub use role_permission::Entity as RolePermission; +pub use system_settings::Entity as SystemSettings; pub use user::Entity as User; pub use user_organization::Entity as UserOrganization; pub use user_ssh_keys::Entity as UserSshKeys; pub use volume_snapshots::Entity as VolumeSnapshots; pub use volumes::Entity as Volumes; +pub use workload_stacks::Entity as WorkloadStacks; pub use workloads::Entity as Workloads; diff --git a/control-plane/shared/entity/src/entities/networks.rs b/control-plane/shared/entity/src/entities/networks.rs index 32c38c7d..afd6e57d 100644 --- a/control-plane/shared/entity/src/entities/networks.rs +++ b/control-plane/shared/entity/src/entities/networks.rs @@ -11,6 +11,7 @@ pub struct Model { pub overlay_type: String, pub status: String, pub organization_id: Option, + pub resource_group_id: Option, pub created_at: chrono::NaiveDateTime, pub updated_at: Option, } @@ -21,6 +22,14 @@ pub enum Relation { Policies, #[sea_orm(has_many = "super::network_members::Entity")] Members, + #[sea_orm( + belongs_to = "super::resource_groups::Entity", + from = "Column::ResourceGroupId", + to = "super::resource_groups::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + ResourceGroup, } impl Related for Entity { @@ -35,4 +44,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ResourceGroup.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/resource_groups.rs b/control-plane/shared/entity/src/entities/resource_groups.rs new file mode 100644 index 00000000..1b8febac --- /dev/null +++ b/control-plane/shared/entity/src/entities/resource_groups.rs @@ -0,0 +1,67 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "resource_groups")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub description: Option, + pub internal_cidr: String, + pub status: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::organization::Entity", + from = "Column::OrganizationId", + to = "super::organization::Column::Id", + on_delete = "Cascade" + )] + Organization, + #[sea_orm(has_many = "super::workloads::Entity")] + Workloads, + #[sea_orm(has_many = "super::volumes::Entity")] + Volumes, + #[sea_orm(has_many = "super::networks::Entity")] + Networks, + #[sea_orm(has_many = "super::workload_stacks::Entity")] + WorkloadStacks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Organization.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workloads.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Volumes.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Networks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WorkloadStacks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/config.rs b/control-plane/shared/entity/src/entities/system_settings.rs similarity index 63% rename from control-plane/shared/entity/src/entities/config.rs rename to control-plane/shared/entity/src/entities/system_settings.rs index 9233c1f2..74b56bca 100644 --- a/control-plane/shared/entity/src/entities/config.rs +++ b/control-plane/shared/entity/src/entities/system_settings.rs @@ -3,11 +3,12 @@ use sea_orm::JsonValue; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "config")] +#[sea_orm(table_name = "system_settings")] pub struct Model { - #[sea_orm(primary_key)] - pub id: Uuid, - pub config: JsonValue, + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub value: JsonValue, + pub updated_at: DateTimeWithTimeZone, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/control-plane/shared/entity/src/entities/volumes.rs b/control-plane/shared/entity/src/entities/volumes.rs index c29f7498..2eb6a6d6 100644 --- a/control-plane/shared/entity/src/entities/volumes.rs +++ b/control-plane/shared/entity/src/entities/volumes.rs @@ -15,6 +15,7 @@ pub struct Model { pub attached_to_workload: Option, pub mapped_device: Option, pub organization_id: Option, + pub resource_group_id: Option, pub created_at: chrono::NaiveDateTime, pub updated_at: Option, } @@ -29,6 +30,14 @@ pub enum Relation { Agent, #[sea_orm(has_many = "super::volume_snapshots::Entity")] Snapshots, + #[sea_orm( + belongs_to = "super::resource_groups::Entity", + from = "Column::ResourceGroupId", + to = "super::resource_groups::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + ResourceGroup, } impl Related for Entity { @@ -43,4 +52,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ResourceGroup.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/workload_stacks.rs b/control-plane/shared/entity/src/entities/workload_stacks.rs new file mode 100644 index 00000000..74b87305 --- /dev/null +++ b/control-plane/shared/entity/src/entities/workload_stacks.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "workload_stacks")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub resource_group_id: Uuid, + pub name: String, + pub compose_source: Option, + pub status: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::resource_groups::Entity", + from = "Column::ResourceGroupId", + to = "super::resource_groups::Column::Id", + on_delete = "Cascade" + )] + ResourceGroup, + #[sea_orm(has_many = "super::workloads::Entity")] + Workloads, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ResourceGroup.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workloads.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/entity/src/entities/workloads.rs b/control-plane/shared/entity/src/entities/workloads.rs index d86d7d34..ee82536f 100644 --- a/control-plane/shared/entity/src/entities/workloads.rs +++ b/control-plane/shared/entity/src/entities/workloads.rs @@ -13,11 +13,15 @@ pub struct Model { pub disk_bytes: i64, pub env_vars: Option, pub ports: Option, + pub volume_mounts: Option, pub status: String, pub assigned_agent_id: Option, pub container_id: Option, pub created_by: Option, pub organization_id: Option, + pub resource_group_id: Option, + pub stack_id: Option, + pub service_name: Option, pub created_at: DateTime, pub updated_at: Option, } @@ -32,6 +36,22 @@ pub enum Relation { on_delete = "SetNull" )] Agent, + #[sea_orm( + belongs_to = "super::resource_groups::Entity", + from = "Column::ResourceGroupId", + to = "super::resource_groups::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + ResourceGroup, + #[sea_orm( + belongs_to = "super::workload_stacks::Entity", + from = "Column::StackId", + to = "super::workload_stacks::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Stack, } impl Related for Entity { @@ -40,4 +60,16 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ResourceGroup.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Stack.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/control-plane/shared/migration/src/lib.rs b/control-plane/shared/migration/src/lib.rs index 5bf75d02..111dbc3a 100644 --- a/control-plane/shared/migration/src/lib.rs +++ b/control-plane/shared/migration/src/lib.rs @@ -18,6 +18,12 @@ mod m20260307_000000_add_networks; mod m20260308_000000_add_org_scoping; mod m20260309_000000_add_bootstrap_tokens; mod m20260523_000000_add_ssh_keys; +mod m20260625_000000_add_resource_groups; +mod m20260628_000000_add_volume_mounts; +mod m20260628_100000_rg_cidr_unique_agent_wg; +mod m20260701_000000_add_logs_and_settings; +mod m20260702_000000_add_agent_wg_tunnel_ip; +mod m20260704_000000_add_workload_stacks; pub struct Migrator; @@ -43,6 +49,12 @@ impl MigratorTrait for Migrator { Box::new(m20260308_000000_add_org_scoping::Migration), Box::new(m20260309_000000_add_bootstrap_tokens::Migration), Box::new(m20260523_000000_add_ssh_keys::Migration), + Box::new(m20260625_000000_add_resource_groups::Migration), + Box::new(m20260628_000000_add_volume_mounts::Migration), + Box::new(m20260628_100000_rg_cidr_unique_agent_wg::Migration), + Box::new(m20260701_000000_add_logs_and_settings::Migration), + Box::new(m20260702_000000_add_agent_wg_tunnel_ip::Migration), + Box::new(m20260704_000000_add_workload_stacks::Migration), ] } } diff --git a/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs b/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs new file mode 100644 index 00000000..62068db2 --- /dev/null +++ b/control-plane/shared/migration/src/m20260625_000000_add_resource_groups.rs @@ -0,0 +1,224 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ResourceGroups::Table) + .if_not_exists() + .col( + ColumnDef::new(ResourceGroups::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ResourceGroups::OrganizationId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(ResourceGroups::Name).string().not_null()) + .col(ColumnDef::new(ResourceGroups::Description).string().null()) + .col( + ColumnDef::new(ResourceGroups::InternalCidr) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ResourceGroups::Status) + .string() + .not_null() + .default("active"), + ) + .col( + ColumnDef::new(ResourceGroups::CreatedAt) + .date_time() + .not_null(), + ) + .col(ColumnDef::new(ResourceGroups::UpdatedAt).date_time().null()) + .foreign_key( + ForeignKey::create() + .from(ResourceGroups::Table, ResourceGroups::OrganizationId) + .to(Organization::Table, Organization::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Workloads::Table) + .add_column(ColumnDef::new(Workloads::ResourceGroupId).uuid().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Volumes::Table) + .add_column(ColumnDef::new(Volumes::ResourceGroupId).uuid().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Networks::Table) + .add_column(ColumnDef::new(Networks::ResourceGroupId).uuid().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(ResourceGroups::Table) + .col(ResourceGroups::OrganizationId) + .name("idx_resource_groups_organization_id") + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(Workloads::Table) + .col(Workloads::ResourceGroupId) + .name("idx_workloads_resource_group_id") + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(Volumes::Table) + .col(Volumes::ResourceGroupId) + .name("idx_volumes_resource_group_id") + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(Networks::Table) + .col(Networks::ResourceGroupId) + .name("idx_networks_resource_group_id") + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_networks_resource_group_id") + .to_owned(), + ) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_volumes_resource_group_id") + .to_owned(), + ) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_workloads_resource_group_id") + .to_owned(), + ) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_resource_groups_organization_id") + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Networks::Table) + .drop_column(Networks::ResourceGroupId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Volumes::Table) + .drop_column(Volumes::ResourceGroupId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Workloads::Table) + .drop_column(Workloads::ResourceGroupId) + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(ResourceGroups::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum ResourceGroups { + Table, + Id, + OrganizationId, + Name, + Description, + InternalCidr, + Status, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum Organization { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Workloads { + Table, + ResourceGroupId, +} + +#[derive(DeriveIden)] +enum Volumes { + Table, + ResourceGroupId, +} + +#[derive(DeriveIden)] +enum Networks { + Table, + ResourceGroupId, +} diff --git a/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs b/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs new file mode 100644 index 00000000..027878c4 --- /dev/null +++ b/control-plane/shared/migration/src/m20260628_000000_add_volume_mounts.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("workloads")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("volume_mounts")).json().null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("workloads")) + .drop_column(Alias::new("volume_mounts")) + .to_owned(), + ) + .await + } +} diff --git a/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs b/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs new file mode 100644 index 00000000..fce0a25d --- /dev/null +++ b/control-plane/shared/migration/src/m20260628_100000_rg_cidr_unique_agent_wg.rs @@ -0,0 +1,59 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_index( + Index::create() + .name("idx_resource_groups_cidr_unique") + .table(Alias::new("resource_groups")) + .col(Alias::new("internal_cidr")) + .unique() + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("wg_public_key")).text().null(), + ) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("wg_endpoint")).string().null(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_resource_groups_cidr_unique") + .table(Alias::new("resource_groups")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .drop_column(Alias::new("wg_public_key")) + .drop_column(Alias::new("wg_endpoint")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/control-plane/shared/migration/src/m20260701_000000_add_logs_and_settings.rs b/control-plane/shared/migration/src/m20260701_000000_add_logs_and_settings.rs new file mode 100644 index 00000000..d7b79e87 --- /dev/null +++ b/control-plane/shared/migration/src/m20260701_000000_add_logs_and_settings.rs @@ -0,0 +1,176 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Config::Table).if_exists().to_owned()) + .await?; + + manager + .create_table( + Table::create() + .table(SystemSettings::Table) + .if_not_exists() + .col(string(SystemSettings::Key).primary_key()) + .col(json_binary(SystemSettings::Value)) + .col( + timestamp_with_time_zone(SystemSettings::UpdatedAt) + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "INSERT INTO system_settings (key, value, updated_at) \ + VALUES ('logs.retention_days', '30', now())", + ) + .await?; + + manager + .create_table( + Table::create() + .table(Logs::Table) + .if_not_exists() + .col(pk_uuid(Logs::Id)) + .col(string(Logs::Service)) + .col(string(Logs::Level)) + .col(string(Logs::Classification)) + .col(text(Logs::Message)) + .col(uuid_null(Logs::AgentId)) + .col(uuid_null(Logs::WorkloadId)) + .col(uuid_null(Logs::OrganizationId)) + .col( + timestamp_with_time_zone(Logs::CreatedAt) + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_logs_agent") + .from(Logs::Table, Logs::AgentId) + .to(Agents::Table, Agents::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_logs_workload") + .from(Logs::Table, Logs::WorkloadId) + .to(Workloads::Table, Workloads::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_logs_organization") + .from(Logs::Table, Logs::OrganizationId) + .to(Organization::Table, Organization::Id) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + for (index_name, column) in [ + ("idx_logs_service", Logs::Service), + ("idx_logs_level", Logs::Level), + ("idx_logs_classification", Logs::Classification), + ("idx_logs_agent_id", Logs::AgentId), + ("idx_logs_workload_id", Logs::WorkloadId), + ("idx_logs_organization_id", Logs::OrganizationId), + ] { + manager + .create_index( + Index::create() + .name(index_name) + .table(Logs::Table) + .col(column) + .to_owned(), + ) + .await?; + } + + manager + .get_connection() + .execute_unprepared("CREATE INDEX idx_logs_created_at ON logs (created_at DESC)") + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Logs::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(SystemSettings::Table).to_owned()) + .await?; + + manager + .create_table( + Table::create() + .table(Config::Table) + .if_not_exists() + .col(pk_uuid(Config::Id)) + .col(json_binary(Config::Config)) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Config { + Table, + Id, + #[allow(clippy::enum_variant_names)] + Config, +} + +#[derive(DeriveIden)] +enum SystemSettings { + Table, + Key, + Value, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum Logs { + Table, + Id, + Service, + Level, + Classification, + Message, + AgentId, + WorkloadId, + OrganizationId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Agents { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Workloads { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Organization { + Table, + Id, +} diff --git a/control-plane/shared/migration/src/m20260702_000000_add_agent_wg_tunnel_ip.rs b/control-plane/shared/migration/src/m20260702_000000_add_agent_wg_tunnel_ip.rs new file mode 100644 index 00000000..2cf4edb1 --- /dev/null +++ b/control-plane/shared/migration/src/m20260702_000000_add_agent_wg_tunnel_ip.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .add_column_if_not_exists( + ColumnDef::new(Alias::new("wg_tunnel_ip")).string().null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("agents")) + .drop_column(Alias::new("wg_tunnel_ip")) + .to_owned(), + ) + .await + } +} diff --git a/control-plane/shared/migration/src/m20260704_000000_add_workload_stacks.rs b/control-plane/shared/migration/src/m20260704_000000_add_workload_stacks.rs new file mode 100644 index 00000000..6cbb3664 --- /dev/null +++ b/control-plane/shared/migration/src/m20260704_000000_add_workload_stacks.rs @@ -0,0 +1,155 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(WorkloadStacks::Table) + .if_not_exists() + .col( + ColumnDef::new(WorkloadStacks::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(WorkloadStacks::ResourceGroupId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(WorkloadStacks::Name).string().not_null()) + .col(ColumnDef::new(WorkloadStacks::ComposeSource).text().null()) + .col( + ColumnDef::new(WorkloadStacks::Status) + .string() + .not_null() + .default("pending"), + ) + .col( + ColumnDef::new(WorkloadStacks::CreatedAt) + .date_time() + .not_null(), + ) + .col(ColumnDef::new(WorkloadStacks::UpdatedAt).date_time().null()) + .foreign_key( + ForeignKey::create() + .from(WorkloadStacks::Table, WorkloadStacks::ResourceGroupId) + .to(ResourceGroups::Table, ResourceGroups::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Workloads::Table) + .add_column(ColumnDef::new(Workloads::StackId).uuid().null()) + .add_column(ColumnDef::new(Workloads::ServiceName).string().null()) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_workloads_stack_id") + .from(Workloads::Table, Workloads::StackId) + .to(WorkloadStacks::Table, WorkloadStacks::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(WorkloadStacks::Table) + .col(WorkloadStacks::ResourceGroupId) + .name("idx_workload_stacks_resource_group_id") + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(Workloads::Table) + .col(Workloads::StackId) + .name("idx_workloads_stack_id") + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index(Index::drop().name("idx_workloads_stack_id").to_owned()) + .await?; + manager + .drop_index( + Index::drop() + .name("idx_workload_stacks_resource_group_id") + .to_owned(), + ) + .await?; + + manager + .drop_foreign_key( + ForeignKey::drop() + .name("fk_workloads_stack_id") + .table(Workloads::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Workloads::Table) + .drop_column(Workloads::ServiceName) + .drop_column(Workloads::StackId) + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(WorkloadStacks::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum WorkloadStacks { + Table, + Id, + ResourceGroupId, + Name, + ComposeSource, + Status, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum ResourceGroups { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Workloads { + Table, + StackId, + ServiceName, +} diff --git a/control-plane/shared/shared/Cargo.toml b/control-plane/shared/shared/Cargo.toml index 8853909c..fde950cd 100644 --- a/control-plane/shared/shared/Cargo.toml +++ b/control-plane/shared/shared/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] # Database sea-orm = { workspace = true } +entity = { path = "../entity" } # Async tokio = { workspace = true } @@ -20,6 +21,8 @@ tracing-subscriber = { workspace = true } dotenvy = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } # Serialization serde = { workspace = true } diff --git a/control-plane/shared/shared/src/db_log_layer.rs b/control-plane/shared/shared/src/db_log_layer.rs new file mode 100644 index 00000000..ce5f103d --- /dev/null +++ b/control-plane/shared/shared/src/db_log_layer.rs @@ -0,0 +1,234 @@ +use entity::logs; +use sea_orm::{ActiveValue::Set, DbConn, EntityTrait}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tokio::time::{interval, Duration}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; +use uuid::Uuid; + +const FLUSH_INTERVAL: Duration = Duration::from_secs(1); +const FLUSH_BATCH_SIZE: usize = 100; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogClassification { + Security, + Performance, + Audit, + System, + Network, + Storage, +} + +impl LogClassification { + fn as_str(self) -> &'static str { + match self { + Self::Security => "security", + Self::Performance => "performance", + Self::Audit => "audit", + Self::System => "system", + Self::Network => "network", + Self::Storage => "storage", + } + } + + fn from_target(target: &str) -> Self { + if target.contains("auth") || target.contains("rbac") || target.contains("jwt") { + Self::Security + } else if target.contains("scheduler") || target.contains("metrics") { + Self::Performance + } else if target.contains("network") + || target.contains("sdn") + || target.contains("wireguard") + { + Self::Network + } else if target.contains("volume") || target.contains("storage") { + Self::Storage + } else if target.contains("registry") { + Self::Audit + } else { + Self::System + } + } +} + +pub struct LogRecord { + pub service: &'static str, + pub level: &'static str, + pub classification: &'static str, + pub message: String, + pub agent_id: Option, + pub workload_id: Option, + pub organization_id: Option, +} + +#[derive(Default)] +struct EventVisitor { + message: String, + agent_id: Option, + workload_id: Option, + organization_id: Option, +} + +impl Visit for EventVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.record_field(field.name(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.record_field(field.name(), value.to_string()); + } +} + +impl EventVisitor { + fn record_field(&mut self, name: &str, value: String) { + match name { + "message" => self.message = trim_quotes(value), + "agent_id" => self.agent_id = Uuid::parse_str(&trim_quotes(value)).ok(), + "workload_id" => self.workload_id = Uuid::parse_str(&trim_quotes(value)).ok(), + "organization_id" => self.organization_id = Uuid::parse_str(&trim_quotes(value)).ok(), + _ => {} + } + } +} + +fn trim_quotes(value: String) -> String { + value.trim_matches('"').to_string() +} + +fn level_as_str(level: &Level) -> &'static str { + match *level { + Level::TRACE | Level::DEBUG => "DEBUG", + Level::INFO => "INFO", + Level::WARN => "WARN", + Level::ERROR => "ERROR", + } +} + +pub struct DbLogLayer { + service: &'static str, + sender: UnboundedSender, +} + +impl DbLogLayer { + pub fn new(service: &'static str) -> (Self, UnboundedReceiver) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + (Self { service, sender }, receiver) + } +} + +const NOISY_ACCESS_TARGET: &str = "csfx::http_access"; +const NOISY_TARGET_PREFIXES: [&str; 2] = ["sea_orm", "sqlx"]; +const NOISY_MESSAGE_SUBSTRINGS: [&str; 1] = ["heartbeat processed"]; + +fn is_routine_access_event(event: &Event<'_>) -> bool { + event.metadata().target() == NOISY_ACCESS_TARGET && *event.metadata().level() == Level::INFO +} + +fn is_sql_trace_event(event: &Event<'_>) -> bool { + let target = event.metadata().target(); + NOISY_TARGET_PREFIXES + .iter() + .any(|prefix| target.starts_with(prefix)) +} + +fn is_performance_event(event: &Event<'_>) -> bool { + *event.metadata().level() == Level::INFO + && LogClassification::from_target(event.metadata().target()) + == LogClassification::Performance +} + +fn is_noisy_message_event(visitor: &EventVisitor) -> bool { + NOISY_MESSAGE_SUBSTRINGS + .iter() + .any(|needle| visitor.message.contains(needle)) +} + +impl Layer for DbLogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if *event.metadata().level() == Level::TRACE { + return; + } + + if is_routine_access_event(event) + || is_sql_trace_event(event) + || is_performance_event(event) + { + return; + } + + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + if is_noisy_message_event(&visitor) { + return; + } + + let record = LogRecord { + service: self.service, + level: level_as_str(event.metadata().level()), + classification: LogClassification::from_target(event.metadata().target()).as_str(), + message: visitor.message, + agent_id: visitor.agent_id, + workload_id: visitor.workload_id, + organization_id: visitor.organization_id, + }; + + let _ = self.sender.send(record); + } +} + +pub fn spawn_log_writer(mut receiver: UnboundedReceiver, db: DbConn) { + tokio::spawn(async move { + let mut buffer = Vec::with_capacity(FLUSH_BATCH_SIZE); + let mut ticker = interval(FLUSH_INTERVAL); + + loop { + tokio::select! { + record = receiver.recv() => { + match record { + Some(record) => { + buffer.push(record); + if buffer.len() >= FLUSH_BATCH_SIZE { + flush(&db, &mut buffer).await; + } + } + None => { + flush(&db, &mut buffer).await; + break; + } + } + } + _ = ticker.tick() => { + flush(&db, &mut buffer).await; + } + } + } + }); +} + +async fn flush(db: &DbConn, buffer: &mut Vec) { + if buffer.is_empty() { + return; + } + + let models: Vec = buffer + .drain(..) + .map(|record| logs::ActiveModel { + id: Set(Uuid::new_v4()), + service: Set(record.service.to_string()), + level: Set(record.level.to_string()), + classification: Set(record.classification.to_string()), + message: Set(record.message), + agent_id: Set(record.agent_id), + workload_id: Set(record.workload_id), + organization_id: Set(record.organization_id), + created_at: Set(chrono::Utc::now().into()), + }) + .collect(); + + if let Err(error) = logs::Entity::insert_many(models).exec(db).await { + tracing::warn!(target: "shared::db_log_layer", error = %error, "failed to flush logs to database"); + } +} diff --git a/control-plane/shared/shared/src/lib.rs b/control-plane/shared/shared/src/lib.rs index 42ab76b1..cb23860c 100644 --- a/control-plane/shared/shared/src/lib.rs +++ b/control-plane/shared/shared/src/lib.rs @@ -1,5 +1,7 @@ pub mod db; +pub mod db_log_layer; pub mod logger; pub use db::establish_connection; +pub use db_log_layer::{spawn_log_writer, DbLogLayer}; pub use logger::init_logger; diff --git a/control-plane/volume-manager/src/ceph/core/mod.rs b/control-plane/volume-manager/src/ceph/core/mod.rs index 3fcb51a1..614cd952 100644 --- a/control-plane/volume-manager/src/ceph/core/mod.rs +++ b/control-plane/volume-manager/src/ceph/core/mod.rs @@ -4,4 +4,3 @@ pub mod error; pub use client::CephClient; pub use config::CephConfig; -pub use error::{CephError, Result}; diff --git a/control-plane/volume-manager/src/ceph/ops/mod.rs b/control-plane/volume-manager/src/ceph/ops/mod.rs index 14f353d7..7314f87a 100644 --- a/control-plane/volume-manager/src/ceph/ops/mod.rs +++ b/control-plane/volume-manager/src/ceph/ops/mod.rs @@ -1,3 +1,3 @@ pub mod init; -pub use init::{create_postgres_volumes, init_ceph, CephManager}; +pub use init::{init_ceph, CephManager}; diff --git a/control-plane/volume-manager/src/db/volumes.rs b/control-plane/volume-manager/src/db/volumes.rs index 7e6921a3..52733ae1 100644 --- a/control-plane/volume-manager/src/db/volumes.rs +++ b/control-plane/volume-manager/src/db/volumes.rs @@ -31,6 +31,7 @@ pub async fn create( attached_to_workload: Set(None), mapped_device: Set(None), organization_id: Set(None), + resource_group_id: Set(req.resource_group_id), created_at: Set(Utc::now().naive_utc()), updated_at: Set(None), }; @@ -140,6 +141,7 @@ pub fn into_response(m: volumes::Model) -> VolumeResponse { attached_to_agent: m.attached_to_agent, attached_to_workload: m.attached_to_workload, mapped_device: m.mapped_device, + resource_group_id: m.resource_group_id, created_at: m.created_at.and_utc(), updated_at: m.updated_at.map(|dt| dt.and_utc()), } diff --git a/control-plane/volume-manager/src/etcd/core/client.rs b/control-plane/volume-manager/src/etcd/core/client.rs index 07e3ff24..57a4cf8f 100644 --- a/control-plane/volume-manager/src/etcd/core/client.rs +++ b/control-plane/volume-manager/src/etcd/core/client.rs @@ -180,7 +180,7 @@ impl EtcdClient { let txn = Txn::new().when([compare]).and_then([put]).or_else([get]); - let txn_resp = client.txn(txn).await.map_err(|e| EtcdError::Client(e))?; + let txn_resp = client.txn(txn).await.map_err(EtcdError::Client)?; // Wenn succeeded = true, war die Transaction erfolgreich (Key nicht vorhanden) Ok(txn_resp.succeeded()) @@ -219,7 +219,7 @@ impl EtcdClient { let resp = client .lease_grant(ttl, None) .await - .map_err(|e| EtcdError::Client(e))?; + .map_err(EtcdError::Client)?; Ok(resp.id()) } @@ -230,12 +230,9 @@ impl EtcdClient { let (mut keeper, _stream) = client .lease_keep_alive(lease_id) .await - .map_err(|e| EtcdError::Client(e))?; + .map_err(EtcdError::Client)?; - keeper - .keep_alive() - .await - .map_err(|e| EtcdError::Client(e))?; + keeper.keep_alive().await.map_err(EtcdError::Client)?; Ok(()) } @@ -245,7 +242,7 @@ impl EtcdClient { client .lease_revoke(lease_id) .await - .map_err(|e| EtcdError::Client(e))?; + .map_err(EtcdError::Client)?; Ok(()) } diff --git a/control-plane/volume-manager/src/etcd/ha/leader_election.rs b/control-plane/volume-manager/src/etcd/ha/leader_election.rs index f424074b..21db6a1f 100644 --- a/control-plane/volume-manager/src/etcd/ha/leader_election.rs +++ b/control-plane/volume-manager/src/etcd/ha/leader_election.rs @@ -16,7 +16,7 @@ pub struct LeaderElection { impl LeaderElection { pub fn new(client: Arc, node_id: String) -> Self { - let election_key = format!("election/leader"); + let election_key = "election/leader".to_string(); Self { client, node_id, @@ -184,7 +184,7 @@ impl LeaderElection { } /// Wartet auf Leadership Changes (Watch) - pub async fn watch_leadership(&self, callback: F) -> Result<(), EtcdError> + pub async fn watch_leadership(&self, _callback: F) -> Result<(), EtcdError> where F: FnMut(Option) + Send + 'static, { diff --git a/control-plane/volume-manager/src/etcd/ha/mod.rs b/control-plane/volume-manager/src/etcd/ha/mod.rs index 59d7759c..31724a6a 100644 --- a/control-plane/volume-manager/src/etcd/ha/mod.rs +++ b/control-plane/volume-manager/src/etcd/ha/mod.rs @@ -3,5 +3,5 @@ pub mod health; pub mod leader_election; -pub use health::{HealthChecker, NodeHealthStatus}; +pub use health::HealthChecker; pub use leader_election::LeaderElection; diff --git a/control-plane/volume-manager/src/etcd/sync/watcher.rs b/control-plane/volume-manager/src/etcd/sync/watcher.rs index 05606bf9..46116a96 100644 --- a/control-plane/volume-manager/src/etcd/sync/watcher.rs +++ b/control-plane/volume-manager/src/etcd/sync/watcher.rs @@ -13,7 +13,7 @@ impl StateWatcher { } /// Beobachtet einen Key-Prefix für Änderungen - pub async fn watch_prefix(&self, prefix: &str, callback: F) -> Result<(), EtcdError> + pub async fn watch_prefix(&self, prefix: &str, _callback: F) -> Result<(), EtcdError> where F: FnMut(WatchEvent) + Send + 'static, { @@ -31,7 +31,7 @@ impl StateWatcher { } /// Beobachtet einen spezifischen Key - pub async fn watch_key(&self, key: &str, callback: F) -> Result<(), EtcdError> + pub async fn watch_key(&self, key: &str, _callback: F) -> Result<(), EtcdError> where F: FnMut(WatchEvent) + Send + 'static, { diff --git a/control-plane/volume-manager/src/logger.rs b/control-plane/volume-manager/src/logger.rs index 727fe564..7d8b6529 100644 --- a/control-plane/volume-manager/src/logger.rs +++ b/control-plane/volume-manager/src/logger.rs @@ -50,15 +50,21 @@ fn build_otlp_provider(service_name: &str) -> Option { Some(provider) } -pub fn init_logger() { - init_logger_with_service(env!("CARGO_PKG_NAME")); +pub fn init_logger() -> tokio::sync::mpsc::UnboundedReceiver { + init_logger_with_service(env!("CARGO_PKG_NAME")) } -pub fn init_logger_with_service(service_name: &'static str) { +pub fn init_logger_with_service( + service_name: &'static str, +) -> tokio::sync::mpsc::UnboundedReceiver { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false).with_thread_ids(true); + let (db_layer, db_receiver) = shared::db_log_layer::DbLogLayer::new(service_name); - let registry = tracing_subscriber::registry().with(filter).with(fmt_layer); + let registry = tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(db_layer); match build_otlp_provider(service_name) { Some(provider) => { @@ -71,6 +77,8 @@ pub fn init_logger_with_service(service_name: &'static str) { registry.init(); } } + + db_receiver } pub fn log_message(level: LogLevel, module: &str, location: &str, description: &str) { diff --git a/control-plane/volume-manager/src/main.rs b/control-plane/volume-manager/src/main.rs index d380e0df..f26a1b87 100644 --- a/control-plane/volume-manager/src/main.rs +++ b/control-plane/volume-manager/src/main.rs @@ -20,7 +20,7 @@ async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); - logger::init_logger(); + let log_receiver = logger::init_logger(); metrics::init(); log_info!("main", "CSFX Volume Manager starting"); @@ -30,6 +30,7 @@ async fn main() -> anyhow::Result<()> { .await .expect("Failed to connect to database"); log_info!("main", "Database connection established"); + shared::spawn_log_writer(log_receiver, db.clone()); let etcd_endpoints = std::env::var("ETCD_ENDPOINTS").unwrap_or_else(|_| "http://localhost:2379".to_string()); diff --git a/control-plane/volume-manager/src/models/volume.rs b/control-plane/volume-manager/src/models/volume.rs index 9e3d8434..2192cd2b 100644 --- a/control-plane/volume-manager/src/models/volume.rs +++ b/control-plane/volume-manager/src/models/volume.rs @@ -62,6 +62,7 @@ pub struct CreateVolumeRequest { pub name: String, pub size_gb: i32, pub pool: Option, + pub resource_group_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -86,6 +87,7 @@ pub struct VolumeResponse { pub attached_to_agent: Option, pub attached_to_workload: Option, pub mapped_device: Option, + pub resource_group_id: Option, pub created_at: DateTime, pub updated_at: Option>, } diff --git a/control-plane/volume-manager/src/patroni/mod.rs b/control-plane/volume-manager/src/patroni/mod.rs index 665b95a7..a6773e3d 100644 --- a/control-plane/volume-manager/src/patroni/mod.rs +++ b/control-plane/volume-manager/src/patroni/mod.rs @@ -1,6 +1,3 @@ pub mod client; pub mod monitor; pub mod types; - -pub use client::PatroniClient; -pub use monitor::PatroniMonitor; diff --git a/docker-compose.yml b/docker-compose.yml index da71700e..d3707687 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -293,7 +293,7 @@ services: dockerfile: Dockerfile container_name: csfx-app-dev environment: - VITE_API_URL: https://api-gateway:8000 + VITE_API_URL: http://api-gateway:8000 ports: - "5173:5173" volumes: