70 Commits

Author SHA1 Message Date
GW_MC
d21459802c Add total upstream count retrieval to UpstreamService
All checks were successful
Test / lint-frontend (pull_request) Successful in 28s
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 30s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m9s
Verify / verify-openapi-spec (pull_request) Successful in 1m8s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m1s
Test / lint-crates (pull_request) Successful in 1m8s
2026-01-01 10:40:44 +08:00
GW_MC
5e1a8364c7 Fix: update test database query results to include upstream target models
All checks were successful
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m5s
Verify / verify-generated-agent-code (pull_request) Successful in 1m8s
Verify / verify-openapi-spec (pull_request) Successful in 1m5s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m5s
2025-12-31 20:26:20 +08:00
GW_MC
3be9ecc4c1 Refactor: improve config formatting, clean up imports
Some checks failed
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 31s
Verify / verify-generated-database-code (pull_request) Successful in 1m9s
Verify / verify-generated-agent-code (pull_request) Successful in 1m12s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 22s
Test / test-crates (pull_request) Failing after 58s
Test / lint-crates (pull_request) Successful in 1m8s
2025-12-31 20:10:47 +08:00
GW_MC
545bc66f8c Fix: invalid config when all are backup 2025-12-31 19:17:14 +08:00
GW_MC
75097a661b Add filtering options for upstream targets in get_upstreams 2025-12-31 19:16:55 +08:00
GW_MC
9860dddf60 Fix incorrect upstream_target config 2025-12-31 18:09:30 +08:00
GW_MC
c4634b18f9 Fix CORS method not allowed 2025-12-31 18:09:19 +08:00
GW_MC
a0b4df745e fix upstream does not contain a target when init 2025-12-31 18:03:54 +08:00
GW_MC
46801fba99 improve error logging 2025-12-31 18:03:42 +08:00
GW_MC
cb65d4e9f7 fix incorrect Extension 2025-12-31 18:03:19 +08:00
GW_MC
10cc8f9d97 Fix incorrect path 2025-12-31 18:02:45 +08:00
GW_MC
d184261027 feat: added openapi doc
All checks were successful
Test / lint-frontend (pull_request) Successful in 45s
Test / test-frontend (pull_request) Successful in 44s
Test / frontend-build (pull_request) Successful in 47s
Verify / verify-generated-agent-code (pull_request) Successful in 1m15s
Verify / verify-openapi-spec (pull_request) Successful in 2m29s
Verify / verify-generated-database-code (pull_request) Successful in 2m35s
Verify / verify-frontend-api-client (pull_request) Successful in 19s
Test / lint-crates (pull_request) Successful in 59s
Test / test-crates (pull_request) Successful in 2m44s
2025-12-31 16:44:18 +08:00
GW_MC
6a30a03e59 feat: enhance socket path validation for reqwest client configuration 2025-12-31 16:11:03 +08:00
GW_MC
9c3f775a67 refactor: remove unused Filters struct and clean up imports in create_upstream tests 2025-12-31 16:05:28 +08:00
GW_MC
331b4e1e96 feat: implement transaction handling for upstream and target operations
- Added transaction support in `add_upstream_target`, `remove_upstream`, `remove_upstream_target`, `update_upstream`, and `update_upstream_target` functions to ensure atomicity of operations.
- Updated the `NginxService` to include methods for validating and applying configurations using the agent service.
- Enhanced error handling in agent service interactions, returning appropriate internal server errors when agent communication fails.
- Introduced mock agent service for testing, allowing for simulation of agent interactions without actual network calls.
- Refactored tests to cover scenarios where agent operations fail, ensuring that internal server errors are returned as expected.
2025-12-31 15:57:29 +08:00
GW_MC
4f85d88380 feat: implement conversion from DbErr to ApiError 2025-12-31 14:51:22 +08:00
GW_MC
d81e5fe48d refactor: clean up test module imports in health info endpoint 2025-12-31 14:48:51 +08:00
GW_MC
dff560019f revert editing session 2025-12-31 12:01:08 +08:00
GW_MC
b2a322ed79 chore: added trait for upstream service 2025-12-30 18:22:18 +08:00
GW_MC
f05544267c feat: add remove upstream and remove upstream target handlers 2025-12-30 18:02:46 +08:00
GW_MC
f4db47daf2 feat: implement update handlers for upstream and upstream target management 2025-12-30 15:09:49 +08:00
GW_MC
b43f9fcb00 Refactor database schema: Remove access list and related entities, add editing session and edit operation entities
- Deleted `access_list_entry`, `location`, `proxy_host`, `proxy_host_access_list`, `session`, `stream_service`, `stream_service_access_list` entities and their corresponding migration files.
- Introduced `editing_session` and `edit_operation` entities with appropriate fields and relationships.
- Updated `mod.rs` and `prelude.rs` to reflect the changes in the entity structure.
- Adjusted migration files to remove obsolete migrations and include new migrations for the editing session and edit operation tables.
2025-12-30 15:09:33 +08:00
GW_MC
1b251fd89f feat: add add_upstream_target handler and response struct for upstream target management 2025-12-29 21:34:40 +08:00
GW_MC
dd79cbe0bb feat: add create_upstream handler for upstream creation 2025-12-29 20:12:01 +08:00
GW_MC
37f4163870 added mock for require_auth middleware 2025-12-29 19:30:31 +08:00
GW_MC
481abd7f26 fix: AuthenticatedRequestInfo support any state 2025-12-29 18:58:00 +08:00
GW_MC
7a557d6e00 feat: update UpstreamCreateInfo conversion to include upstream targets 2025-12-29 18:45:03 +08:00
GW_MC
0cbc223b4e feat: implement AuthenticatedRequestInfo for user authentication handling 2025-12-29 18:44:52 +08:00
GW_MC
fdfd1c98e0 feat: introduce ApiError for improved error handling in upstream routes 2025-12-29 18:03:09 +08:00
GW_MC
abeea4fad7 refactor: upstream info response structures and module 2025-12-29 18:01:57 +08:00
GW_MC
fea205cba8 chore: delegate path prefix to router 2025-12-29 17:52:29 +08:00
GW_MC
3cc6b40e61 Fix: missing upstream in From 2025-12-29 17:48:44 +08:00
GW_MC
41023939db feat: add tests for upstream and upstream target handlers 2025-12-29 17:44:41 +08:00
GW_MC
6eb0d9060b fix: path not starting with / 2025-12-29 17:24:50 +08:00
GW_MC
7d83838da3 feat: add get_upstream_target endpoint and update router for upstream targets 2025-12-29 16:38:43 +08:00
GW_MC
921165476c feat: enhance UpstreamTargetInfo with upstream details and refactor imports 2025-12-29 16:38:33 +08:00
GW_MC
a0621e2407 test: added test cases for upstream service 2025-12-29 16:37:57 +08:00
GW_MC
f8b13be650 feat: add Nginx upstream read management routes and handlers 2025-12-29 15:27:03 +08:00
GW_MC
238c3db92b feat: implement Nginx service with upstream management and configuration generation 2025-12-29 15:21:02 +08:00
GW_MC
814f76291c fix: update test cases to use mock 2025-12-29 15:19:06 +08:00
GW_MC
7ac3368715 feat: added intoResponse 2025-12-29 15:18:40 +08:00
GW_MC
35fadb46f6 feat: add pagination helper and integrate serde_urlencoded for query extraction 2025-12-29 12:16:09 +08:00
GW_MC
91d0e1cd7c init nginx related database schema 2025-12-29 12:05:22 +08:00
c33e3aa0ca Merge pull request 'feature/agent-client' (#12) from feature/agent-client into master
All checks were successful
Test / lint-frontend (push) Successful in 26s
Test / test-frontend (push) Successful in 25s
Test / frontend-build (push) Successful in 32s
Verify / verify-generated-database-code (push) Successful in 1m8s
Verify / verify-generated-agent-code (push) Successful in 1m11s
Verify / verify-openapi-spec (push) Successful in 1m7s
Verify / verify-frontend-api-client (push) Successful in 8s
Test / test-crates (push) Successful in 52s
Test / lint-crates (push) Successful in 1m8s
Reviewed-on: #12
2025-12-28 19:25:34 +08:00
GW_MC
6f5596dc69 Enforce deny unwrap_used
All checks were successful
Test / test-frontend (pull_request) Successful in 26s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m10s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 7s
Test / test-crates (pull_request) Successful in 54s
Test / lint-crates (pull_request) Successful in 1m10s
2025-12-28 19:07:17 +08:00
GW_MC
96e7f36731 feat: integrate AgentService into app service and enhance configuration handling
All checks were successful
Test / test-frontend (pull_request) Successful in 25s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m11s
Verify / verify-openapi-spec (pull_request) Successful in 1m13s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m9s
2025-12-28 18:35:53 +08:00
GW_MC
410328a2af refactor app service 2025-12-28 18:28:28 +08:00
GW_MC
9f122566d0 feat: add agent settings configuration and update agent client service 2025-12-28 18:08:55 +08:00
GW_MC
c65dc3af47 feat: Basic agent-client service
Some checks failed
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 33s
Verify / verify-openapi-spec (pull_request) Successful in 7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m7s
Verify / verify-generated-database-code (pull_request) Successful in 2m14s
Verify / verify-frontend-api-client (pull_request) Successful in 6s
Test / lint-crates (pull_request) Failing after 1m28s
Test / test-crates (pull_request) Successful in 2m40s
Test / lint-frontend (pull_request) Successful in 29s
2025-12-28 17:53:27 +08:00
GW_MC
6f395ed1ae rename workflows jobs 2025-12-28 16:42:12 +08:00
GW_MC
e6e85564e7 fix: incorrect pnpm cache
All checks were successful
Test / test-frontend (pull_request) Successful in 1m17s
Test / lint-frontend (pull_request) Successful in 1m19s
Test / frontend-build (pull_request) Successful in 1m55s
Verify / verify-openapi-spec (pull_request) Successful in 31s
Verify / verify-generated-agent-code (pull_request) Successful in 2m54s
Verify / verify-generated-code (pull_request) Successful in 3m57s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / lint (pull_request) Successful in 1m25s
Test / test (pull_request) Successful in 3m26s
2025-12-28 16:29:31 +08:00
GW_MC
c7a090f78a fix: frontend deps not installed 2025-12-28 16:23:10 +08:00
GW_MC
8cc2775fe4 feat: auto format generated code, and ignore clippy lint in agent-client 2025-12-28 16:22:41 +08:00
GW_MC
0b64538754 added verify for untracked generated files 2025-12-28 15:56:04 +08:00
GW_MC
8009ca20ff feat: added verify generated agent code in action workflows 2025-12-28 15:55:30 +08:00
GW_MC
7a5b9471e0 feat: added agent rust client generation 2025-12-28 15:16:47 +08:00
GW_MC
bb55e37b49 feat: added openapi generation for agent 2025-12-28 15:15:42 +08:00
9a264a61ac Merge pull request 'feature/agent' (#11) from feature/agent into master
All checks were successful
Test / test-frontend (push) Successful in 20s
Test / lint-frontend (push) Successful in 24s
Verify / verify-openapi-spec (push) Successful in 4s
Test / frontend-build (push) Successful in 29s
Verify / verify-frontend-api-client (push) Successful in 5s
Test / test (push) Successful in 53s
Verify / verify-generated-code (push) Successful in 1m6s
Test / lint (push) Successful in 1m10s
Reviewed-on: #11
2025-12-22 18:29:26 +08:00
GW_MC
0eafd6a264 feat: upgrade actions/cache to v4 and clean up imports in main.rs
All checks were successful
Test / test-frontend (pull_request) Successful in 23s
Test / lint-frontend (pull_request) Successful in 26s
Verify / verify-openapi-spec (pull_request) Successful in 4s
Test / frontend-build (pull_request) Successful in 29s
Test / test (pull_request) Successful in 55s
Verify / verify-generated-code (pull_request) Successful in 1m10s
Verify / verify-frontend-api-client (pull_request) Successful in 7s
Test / lint (pull_request) Successful in 1m10s
2025-12-22 18:26:19 +08:00
GW_MC
c14af00c08 feat: update dependencies and refactor command line argument handling for yanpm-agent
Some checks failed
Test / test-frontend (pull_request) Successful in 23s
Test / lint-frontend (pull_request) Successful in 27s
Verify / verify-openapi-spec (pull_request) Successful in 4s
Test / frontend-build (pull_request) Successful in 30s
Verify / verify-frontend-api-client (pull_request) Has been cancelled
Verify / verify-generated-code (pull_request) Has been cancelled
Test / test (pull_request) Has been cancelled
Test / lint (pull_request) Has been cancelled
2025-12-22 18:16:26 +08:00
GW_MC
dce8203322 feat: add comprehensive documentation for yanpm-agent, including API reference, configuration, deployment, usage examples, and troubleshooting 2025-12-22 17:56:18 +08:00
GW_MC
5cffb0a519 feat: add nginx reload and validation wrappers with sudo permissions 2025-12-22 17:18:36 +08:00
GW_MC
6e85bda13f Refactor container definitions 2025-12-22 14:32:57 +08:00
GW_MC
7db23b01df Add testcontainer for agent image with nginx 2025-12-22 12:54:14 +08:00
GW_MC
61ecd91219 feat: add nix dependency and enhance socket permissions handling 2025-12-21 19:32:48 +08:00
GW_MC
b823fe6281 feat: Fix permission and env errors, add loggings, socket perm args 2025-12-21 18:52:26 +08:00
GW_MC
7781878c2d feat: implement Dockerfile and service scripts for yanpm-agent 2025-12-21 17:51:43 +08:00
GW_MC
4ca59d2bb6 feat: add agent module with Nginx service commands and routes
- Introduced a new agent module with commands for managing Nginx configurations.
- Implemented `NginxService` for handling reload, validation, and configuration writing.
- Added routes for status, validation, and configuration writing using Axum.
- Created necessary command files: `reload.rs`, `run.rs`, `validate.rs`, `write_config.rs`.
- Updated `Cargo.toml` and `Cargo.lock` to include new dependencies.
- Added `.gitignore` for the agent module.
- Updated `justfile` to include OpenAPI generation for the agent.
2025-12-21 15:32:42 +08:00
8334da8cf1 Merge pull request 'feature/frontend-login' (#10) from feature/frontend-login into master
All checks were successful
Test / test-frontend (push) Successful in 20s
Test / lint-frontend (push) Successful in 23s
Test / frontend-build (push) Successful in 27s
Test / test (push) Successful in 45s
Verify / verify-generated-code (push) Successful in 58s
Verify / verify-openapi-spec (push) Successful in 58s
Verify / verify-frontend-api-client (push) Successful in 19s
Test / lint (push) Successful in 1m1s
Reviewed-on: #10
2025-12-20 19:01:04 +08:00
GW_MC
dc7b70e039 Fix trailing whitespace
All checks were successful
Test / test-frontend (pull_request) Successful in 23s
Test / lint-frontend (pull_request) Successful in 25s
Test / frontend-build (pull_request) Successful in 29s
Test / test (pull_request) Successful in 46s
Verify / verify-generated-code (pull_request) Successful in 59s
Verify / verify-openapi-spec (pull_request) Successful in 1m1s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / lint (pull_request) Successful in 1m3s
2025-12-20 18:48:35 +08:00
135 changed files with 13656 additions and 1656 deletions

View File

@@ -22,7 +22,7 @@ runs:
fetch-depth: 0 fetch-depth: 0
- name: Cache cargo registry - name: Cache cargo registry
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cargo/registry path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
@@ -30,7 +30,7 @@ runs:
${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index - name: Cache cargo index
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: ~/.cargo/index path: ~/.cargo/index
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
@@ -51,7 +51,7 @@ runs:
${{ runner.os }}-rustup- ${{ runner.os }}-rustup-
- name: Cache cargo build (target) - name: Cache cargo build (target)
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: target path: target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}

View File

@@ -12,11 +12,8 @@ on:
jobs: jobs:
# setup is now handled by a composite action used by downstream jobs to keep
# the workflow DRY. The composite action performs checkout, cache restore and
# toolchain setup.
test: test-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -39,7 +36,7 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --all-features run: cargo test --all-features
lint: lint-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -83,7 +80,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
@@ -114,7 +111,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
@@ -142,12 +139,12 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
cd apps/frontend cd apps/frontend
pnpm install pnpm install --frozen-lockfile
- name: Build frontend - name: Build frontend
run: | run: |

View File

@@ -11,11 +11,8 @@ on:
jobs: jobs:
# setup is now handled by a composite action used by downstream jobs to keep
# the workflow DRY. The composite action performs checkout, cache restore and
# toolchain setup.
verify-generated-code: verify-generated-database-code:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -32,14 +29,62 @@ jobs:
cargo run -- db:migrate_and_generate --output-path ../../public/database/src/generated/entities cargo run -- db:migrate_and_generate --output-path ../../public/database/src/generated/entities
- name: Check for uncommitted changes in /generated/ - name: Check for uncommitted changes in /generated/
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M .*\/generated\/') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep '/generated/') ]]; then
echo "Generated code is not up to date. Please run the code generation locally and commit the changes." echo "Generated code is not up to date. Please run the code generation locally and commit the changes."
git status --porcelain | grep '^ M .*\/generated\/' git status --porcelain --untracked-files=all | grep '/generated/'
exit 1 exit 1
else else
echo "Generated code is up to date." echo "Generated code is up to date."
fi fi
verify-generated-agent-code:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install root dependencies
run: |
pnpm install --frozen-lockfile
- name: generate agent client code
run: |
pnpm just generate-agent-client
- name: Check for uncommitted changes in agent client code
run: |
if [[ -n $(git status --porcelain --untracked-files=all | grep 'public/agent-client/') ]]; then
echo "Agent client code is not up to date. Please run the agent client code generation locally and commit the changes."
git status --porcelain --untracked-files=all | grep 'public/agent-client/'
exit 1
else
echo "Agent client code is up to date."
fi
verify-openapi-spec: verify-openapi-spec:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -99,9 +144,9 @@ jobs:
- name: Check for uncommitted changes in swagger.json - name: Check for uncommitted changes in swagger.json
if: steps.check_changes.outputs.changed == 'true' if: steps.check_changes.outputs.changed == 'true'
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M apps/api/swagger.json') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/api/swagger.json') ]]; then
echo "OpenAPI spec is not up to date. Please run the OpenAPI generation locally and commit the changes." echo "OpenAPI spec is not up to date. Please run the OpenAPI generation locally and commit the changes."
git status --porcelain | grep '^ M apps/api/swagger.json' git status --porcelain --untracked-files=all | grep 'apps/api/swagger.json'
exit 1 exit 1
else else
echo "OpenAPI spec is up to date." echo "OpenAPI spec is up to date."
@@ -165,7 +210,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
if: steps.check_swagger_changes.outputs.changed == 'true' if: steps.check_swagger_changes.outputs.changed == 'true'
@@ -182,9 +227,9 @@ jobs:
- name: Check for uncommitted changes in frontend API client - name: Check for uncommitted changes in frontend API client
if: steps.check_swagger_changes.outputs.changed == 'true' if: steps.check_swagger_changes.outputs.changed == 'true'
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M apps/frontend/app/generated/api-client') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/frontend/app/generated/api-client') ]]; then
echo "Frontend API client is not up to date. Please run the API client generation locally and commit the changes." echo "Frontend API client is not up to date. Please run the API client generation locally and commit the changes."
git status --porcelain | grep '^ M apps/frontend/app/generated/api-client' git status --porcelain --untracked-files=all | grep 'apps/frontend/app/generated/api-client'
exit 1 exit 1
else else
echo "Frontend API client is up to date." echo "Frontend API client is up to date."

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ target
.env.generated .env.generated
generated-config.yaml generated-config.yaml
node_modules/

1159
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ members = [
"apps/api", "apps/api",
"apps/container", "apps/container",
"apps/cli", "apps/cli",
"apps/agent",
"public/agent-client",
"public/shared", "public/shared",
"public/database", "public/database",
"public/migration" "public/migration"

1
apps/agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sock

16
apps/agent/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "yanpm-agent"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.7", features = ["form", "http1", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] }
tokio = { version = "1", features = ["fs", "io-util", "io-std", "macros", "net", "parking_lot", "process", "rt", "rt-multi-thread", "signal", "sync", "time", "tracing"] }
tracing = { version = "0.1.41", features = ["std", "attributes"] }
tracing-subscriber = { version = "0.3.20", features = ["smallvec", "fmt", "ansi", "tracing-log", "std", "json", "serde", "serde_json", "time", "tracing"] }
serde_json = { version = "1.0.145", features = ["std"] }
serde = { version = "1.0.228", features = ["std", "derive"] }
tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] }
clap = { version = "4", features = ["derive", "env"] }
nix = { version = "0.30.1", features = ["user", "fs"] }
utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] }

56
apps/agent/Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
FROM rust:1.92-alpine3.23 AS builder
# Install build deps and binutils (for strip)
RUN apk add --no-cache build-base musl-dev openssl-dev pkgconfig ca-certificates curl binutils
WORKDIR /app
# Copy manifest first to leverage Docker layer caching for dependencies
COPY ./Cargo.toml ./
RUN cargo fetch --locked || true
COPY ./src ./src
# Build the release binary and strip it to reduce size
RUN cargo build --release --bin yanpm-agent && \
strip target/release/yanpm-agent || true
FROM nginx:mainline-alpine3.23 AS base
# Expose typical HTTP ports used by nginx
EXPOSE 80 443
ENV S6_KEEP_ENV=1
ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock
ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d
ENV YANPM_AGENT_SOCK_PERM=660
ENV YANPM_AGENT_SOCK_GID=""
ENV YANPM_AGENT_UID=1000
ENV YANPM_AGENT_GID=1000
WORKDIR /app
# Install ca-certificates for TLS and minimal tools
RUN apk add --no-cache ca-certificates curl
# Install s6-overlay
ENV S6_OVERLAY_VERSION=v3.2.1.0
ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && rm /tmp/s6-overlay-noarch.tar.xz
ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp/s6-overlay.tar.xz
RUN tar -C / -Jxpf /tmp/s6-overlay.tar.xz && rm /tmp/s6-overlay.tar.xz
# Runtime user creation handled by s6 cont-init (see /etc/cont-init.d)
# create directory for yanpm agent socket; ownership will be fixed at container start
RUN mkdir -p /var/run/yanpm
# Copy s6 service definitions (created in repo under s6/) into image
COPY ./docker/s6/services.d /etc/services.d
COPY ./docker/s6/cont-init.d /etc/cont-init.d
RUN chmod +x /etc/services.d/*/run && chmod +x /etc/cont-init.d/*
COPY --from=builder /app/target/release/yanpm-agent ./yanpm-agent
RUN chmod +x /app/yanpm-agent
# s6-overlay provides /init as the init process
ENTRYPOINT ["/init"]

19
apps/agent/doc/README.md Normal file
View File

@@ -0,0 +1,19 @@
# yanpm-agent Documentation
This directory contains in-depth documentation for the yanpm agent daemon (the binary built from `apps/agent`). The agent exposes a unix-socket HTTP API for writing nginx configuration fragments, validating them, and reloading nginx safely.
Docs included:
- `architecture.md` — Detailed explanation of the program flow and components.
- `configuration.md` — CLI flags, environment variables, defaults, and permission handling.
- `usage.md` — How to run the agent, curl examples, and systemd/docker hints.
- `api.md` — HTTP API endpoints, request and response schemas, examples.
- `deployment.md` — Deployment considerations, permissions, and systemd socket/unit examples.
- `troubleshooting.md` — Common errors and solutions.
For implementation details, see the source in `apps/agent/src` (notably `main.rs`, `routes.rs`, and the `commands/` submodule).
Integration notes
- The agent is intended to run as a companion agent for the API service in `apps/api`. The API service calls the agent over the unix-domain socket to write nginx fragments, validate them, and trigger reloads.
- A production Docker image is provided by `apps/agent/Dockerfile`. That Dockerfile packages nginx + the `yanpm-agent` binary and s6-overlay service scripts so a single container can run nginx and the agent alongside each other.

68
apps/agent/doc/api.md Normal file
View File

@@ -0,0 +1,68 @@
# HTTP API Reference
Base: HTTP over a unix-domain socket. Example using curl: `curl --unix-socket /path/to/socket -X POST http://localhost/<path>`
1) GET /status
- Response: 200 OK
- Body: JSON `{ "ok": true }`
2) POST /validate
- Request JSON:
```json
{
"config_name": "example",
"timestamp": 1234567890
}
```
- Behavior: validates the fragment file named by `config_name` and `timestamp` under the agent's internal subdirectory inside the configured nginx config directory. Delegates to `ValidateCommand::validate`.
- Success: 200 OK, body is `[rc, output]` tuple serialized as JSON (actual shape is `(i32, String)` returned from the command; examine responses for exact formatting).
- Error cases:
- 400 Bad Request: invalid or malformed JSON
- 500 Internal Server Error: validation error or missing fragment file
- Request JSON:
```json
{
"config_name": "example",
"timestamp": 1234567890
}
```
- Behavior: validates the fragment file named by `config_name` and `timestamp` under the agent's internal subdirectory inside the configured nginx config directory. Delegates to `ValidateCommand::validate`.
- Success: 200 OK, body is a JSON array `[rc, output]` where `rc` is the integer return code and `output` is the combined stdout/stderr string from the validation command (the command returns an `(i32, String)` tuple).
- Error cases:
- 400 Bad Request: invalid or malformed JSON
- 500 Internal Server Error: validation error or missing fragment file
3) POST /validate_and_reload
- Request JSON same as `/validate`.
- Behavior: runs validation and, on success, attempts to reload nginx. Returns an object with `rc` and `ro` (return code and combined stdout/stderr output).
- Success: 200 OK with body: `{ "rc": <int>, "ro": "<output>" }`
- Errors: 400 for malformed JSON, 500 if the validate-and-reload command fails (body presents error text).
4) POST /write_config
- Request JSON:
```json
{
"config_name": "example",
"timestamp": 1234567890,
"content": "server { ... }"
}
```
- Behavior: writes the provided `content` into an agent-managed fragment file named from `config_name` and `timestamp` in the internal subdirectory under `nginx_config_dir`.
- Success: 200 OK with empty body
- Error: 400 for malformed JSON, 500 if writing the file fails
Notes
- The agent expects callers to choose a `config_name` and `timestamp` that together form a unique filename. The concrete filename encoding is performed by `commands::run::to_file_name` in source.
- On validation failures the returned output often contains the full `nginx -t` output; inspect `ro` or the returned JSON error messages.

View File

@@ -0,0 +1,34 @@
# Architecture and Runtime Flow
Overview
- The agent is an async HTTP server (axum) listening on a Unix domain socket and exposes a small JSON API to manage nginx configuration fragments.
- Core lifecycle is implemented in `apps/agent/src/main.rs`:
- parse CLI args and environment variables
- ensure the socket path and directory exist and have permissive but secure defaults
- bind a `tokio::net::UnixListener` to the socket
- create an `NginxService` (shared state) and an in-process cron `JobScheduler`
- mount axum routes (`/status`, `/validate`, `/validate_and_reload`, `/write_config`) and serve HTTP over the Unix socket
Key components
- `main.rs` — Bootstrapping, argument handling, socket setup and permission handling, scheduler start, and axum server startup.
- `routes.rs` — axum handlers for the HTTP API. It deserializes JSON payloads and delegates to `NginxService` methods. Handlers return appropriate HTTP status codes and JSON on error or success.
- `commands/` — Implementation of lower-level actions (writing fragment files, running `nginx -t`, validating, reloads). The `validate.rs` command contains sophisticated behavior to handle permission-limited environments by:
- creating wrapper nginx configs that include a single fragment
- trying `nginx -t` directly, attempting a privileged wrapper via `sudo` if available, and finally passing a writable PID override via `-g pid ...;` to avoid permission failures
Concurrency and state
- A single shared `NginxService` instance is stored in axum `State` and cloned into handlers; it holds the scheduler and the configured nginx config directory path.
- The JobScheduler is created with `tokio_cron_scheduler::JobScheduler` and started before serving requests.
Error handling and best-effort behavior
- Socket permission changes, GID changes, and directory creations are best-effort and log warnings on failure rather than failing hard.
- Most command failures are converted into JSON errors with appropriate HTTP status codes so callers can inspect command output.
Integration and packaging
- The agent is intended to run as a companion to the API server in `apps/api`. The API calls the agent over the unix socket to write fragments, validate them, and trigger reloads.
- `apps/agent/Dockerfile` builds a runtime image that includes `nginx` and the `yanpm-agent` binary (the Dockerfile uses s6-overlay to run multiple services). This image is suitable for deployments that prefer nginx and the agent colocated in a single container.

View File

@@ -0,0 +1,27 @@
# Configuration and Environment
CLI flags and environment variables
- `--sock` / `YANPM_AGENT_SOCK` (default: `./yanpm-agent.sock`)
- Path to the Unix socket file the agent will bind to.
- If the socket directory does not exist the agent attempts to create it and set mode `0770`.
- `--nginx-config-dir` / `YANPM_NGINX_CONFIG_DIR` (default: `/etc/nginx/conf.d`)
- Directory where nginx fragments are written. The agent writes fragments into a subdirectory named by the agent (internal use).
- `--sock-perm` / `YANPM_AGENT_SOCK_PERM` (default: `660`)
- A 3-digit octal permission string applied to the socket file (best-effort). The program validates this is a 3-digit octal string.
- If the final digit is greater than `0` a warning is logged because that allows "others" access.
- `--sock-gid` / `YANPM_AGENT_SOCK_GID` (default: current user's primary group)
- GID to set on the socket file (best-effort).
Validation rules and behavior
- `sock_perm` must be exactly 3 octal digits (characters 0-7). The agent rejects invalid values at startup.
- When an existing path exists at the socket location the agent verifies it is a unix socket; if so it removes it before binding. If the path exists and is not a socket, startup fails.
- Setting permissions (`set_permissions`) and changing GID (`chown`) are attempted but non-fatal: failures are logged as warnings and the agent continues.
Notes about nginx config directory
- The agent writes fragments into a subdirectory (internal) of the configured `nginx_config_dir`. Ensure nginx is configured to include that subdirectory so fragments are picked up, or use `write_config` then trigger a reload.

View File

@@ -0,0 +1,62 @@
# Deployment and Permissions
Socket location and permissions
- The agent binds a unix socket at the path given by `--sock` or `YANPM_AGENT_SOCK`. The agent will:
- create the parent directory (best-effort) and attempt to set its permissions to `0770`
- remove an existing socket file if it is a socket, or fail if the path exists and is not a socket
- apply the `sock_perm` (3-digit octal) to the socket file and optionally change its GID to `sock_gid`
Systemd socket/unit example
Create a `yanpm-agent.socket` unit that creates and owns the unix socket, and a `yanpm-agent.service` that runs the agent. Ensure the socket path used by systemd matches `--sock`.
Docker / container notes
- If running the agent inside a container and writing to host nginx config, bind-mount the host nginx config directory into the container at the path provided to `--nginx-config-dir`.
- Consider running the agent as a user with permission to write the nginx config directory or use a shared group and `sock_gid` so clients can access the socket.
- The repository provides a runtime image built by `apps/agent/Dockerfile` which packages `nginx` together with the `yanpm-agent` binary and s6-overlay service scripts. This image runs nginx and the agent in one container which is useful when the agent is acting as the runtime companion for the API (`apps/api`).
Privilege escalation for validation
- In many systems `nginx -t` may fail due to inability to access `/run/nginx.pid` or other privileged files. The agent attempts a best-effort sequence:
1. Run `nginx -t` directly.
2. If that fails with permission errors, try a privileged wrapper (e.g. `/usr/local/sbin/yanpm-nginx-validate` or `yanpm-nginx-validate-file`) via `sudo -n`.
3. If wrapper is unavailable or fails, retry `nginx -t` with a writable PID override via `-g 'pid /tmp/yanpm-validate-<pid>.pid;'`.
Security considerations
- Avoid setting `sock_perm` to allow world access unless explicitly intended.
- Prefer controlling socket group membership via `sock_gid` rather than making the socket world-writable.
s6 init scripts, wrappers and sudoers (runtime)
- Purpose: The image built by `apps/agent/Dockerfile` uses `s6-overlay` as PID 1 (the Dockerfile sets `ENTRYPOINT ["/init"]`). The repository includes `docker/s6/cont-init.d` scripts that run at container startup (one-shot) and `docker/s6/services.d` entries to run long-lived services (nginx and the agent). The cont-init scripts prepare runtime users, permissions, and helper wrappers the agent uses for privileged operations.
- Key cont-init scripts (in the repo):
- `docker/s6/cont-init.d/10-create-app-user` — ensures the `yanpm-agent` user and group exist (honoring `YANPM_AGENT_UID`, `YANPM_AGENT_GID`, and `YANPM_AGENT_SOCK_GID`), adds the user to the `nginx` group, and attempts to chown runtime directories like `/var/run/yanpm` and `/app/yanpm-agent` (logs warnings if chown fails for bind mounts or rootless containers).
- `docker/s6/cont-init.d/20-install-reload-wrapper` — installs three helper wrappers and a sudoers entry so the `yanpm-agent` user can perform narrowly-scoped privileged operations without a password.
- Wrapper scripts installed by `20-install-reload-wrapper`:
- `/usr/local/sbin/yanpm-nginx-reload` — runs `nginx -c /etc/nginx/nginx.conf -s reload` (used for reloading the running nginx master process).
- `/usr/local/sbin/yanpm-nginx-validate` — runs `nginx -c /etc/nginx/nginx.conf -t` (validates the main nginx config).
- `/usr/local/sbin/yanpm-nginx-validate-file` — securely validates a single nginx config file: it resolves the absolute path, ensures the target is a regular file (not a symlink), checks the file is owned by the `yanpm-agent` user, enforces it's not world-writable, then runs `nginx -c <file> -t`. This defends against symlink and race attacks when an unprivileged agent requests privileged validation.
- Sudoers entry:
- The init script writes `/etc/sudoers.d/yanpm-agent` with a rule allowing the configured agent user (default `yanpm-agent`) to run only the three wrappers with `NOPASSWD`. This gives the agent a limited, auditable privilege escalation surface; the agent code attempts to use these wrappers via `sudo -n` before falling back to less privileged strategies.
- Relevant environment variables (settable in the Dockerfile or at runtime):
- `YANPM_AGENT_SOCK` — unix socket path (default set in Dockerfile: `/var/run/yanpm/yanpm-agent.sock`).
- `YANPM_NGINX_CONFIG_DIR` — nginx config dir (default `/etc/nginx/conf.d`).
- `YANPM_AGENT_SOCK_PERM` — socket permissions (octal string, default `660`).
- `YANPM_AGENT_SOCK_GID` — desired GID for the socket (optional).
- `YANPM_AGENT_UID`, `YANPM_AGENT_GID` — runtime UID/GID used to create the `yanpm-agent` user in the container.
- How the agent uses these runtime helpers:
- `ValidateCommand` and `ReloadCommand` in the agent code try `nginx` operations directly; when permission problems occur they attempt the privileged wrappers via `sudo -n /usr/local/sbin/yanpm-nginx-validate` or `...-validate-file` and `...-reload`. The cont-init script's wrappers plus the sudoers entry implement that intended secure upgrade path.
- Notes and recommendations:
- The `validate-file` wrapper performs ownership and permission checks; ensure written fragments are created by the `yanpm-agent` user (the agent writes files as that user when running inside the container due to `10-create-app-user`).
- The cont-init scripts attempt to install `sudo` if missing; in minimal images you may prefer providing `sudo` at build time to avoid runtime installation attempts.
- If you bind-mount host directories (e.g., `/etc/nginx/conf.d`) into the container, ensure ownership and permissions are compatible with the agent user and `YANPM_AGENT_SOCK_GID` so the socket and files are accessible as intended.

View File

@@ -0,0 +1,27 @@
# Troubleshooting
Common issues and how to resolve them
- Socket path exists but is not a socket
- Symptom: startup fails with an error that the socket path exists and is not a socket.
- Fix: remove the file at the socket path or choose a different `--sock` path.
- Permission denied on socket directory or socket
- Symptom: socket creation or permission setting logs warnings; clients cannot connect.
- Fix: ensure the socket directory exists and has correct ownership/group and that `sock_perm` and `sock_gid` are configured appropriately. Consider using `chown`/`chmod` from a privileged context.
- `nginx -t` fails with `/run/nginx.pid: Permission denied`
- Symptom: validation fails; output contains permission denied for `/run/nginx.pid`.
- Fixes (tried by the agent):
1. If available, provide a privileged validation wrapper (e.g. `/usr/local/sbin/yanpm-nginx-validate`) that runs `nginx -t` with appropriate privileges.
2. Ensure the agent-runner has permission to read the main nginx configuration and `/run/nginx.pid` or allow the agent to use a writable PID override.
- Fragment file not found during validation
- Symptom: validate returns 500 with message `Config file not found`.
- Fix: make sure the fragment has been written via `/write_config` to the agent's internal subdirectory under `NGINX_CONFIG_DIR`, using the same `config_name` and `timestamp` as the validate call.
- Wrapper or sudo not available
- Symptom: attempts to run `sudo -n /usr/local/sbin/yanpm-nginx-validate` fail.
- Fix: install a wrapper script that allows unprivileged `sudo -n` validation or configure proper permissions on nginx state files.
If none of the above solves the problem, collect the logs produced by the agent (it uses `tracing`/`tracing_subscriber`) and include the exact command outputs from the validation steps when asking for help.

61
apps/agent/doc/usage.md Normal file
View File

@@ -0,0 +1,61 @@
# Usage and Examples
Running locally (development)
1. Build the agent (from repository root):
```sh
cargo build -p agent
```
2. Run the agent with defaults (socket in current directory):
```sh
./target/debug/yanpm-agent
```
3. Run with explicit socket and nginx config directory:
```sh
./target/debug/yanpm-agent --sock /run/yanpm/yanpm-agent.sock --nginx-config-dir /etc/nginx/conf.d
```
HTTP over unix-socket examples (using `socat` / `curl` helper)
If you want to call the API from the shell, you can use `socat` to convert the unix socket to an HTTP stream, or use tools that support unix sockets directly (e.g. `curl --unix-socket`). Examples below use `curl --unix-socket`.
Validate a fragment by name and timestamp:
```sh
curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/validate \
-H 'Content-Type: application/json' \
-d '{"config_name":"example","timestamp":1234567890}'
```
Validate and reload (returns `rc` and `ro`):
```sh
curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/validate_and_reload \
-H 'Content-Type: application/json' \
-d '{"config_name":"example","timestamp":1234567890}'
```
Write a fragment (create or update):
```sh
curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/write_config \
-H 'Content-Type: application/json' \
-d '{"config_name":"example","timestamp":1234567890,"content":"server { listen 80; server_name example.local; }"}'
```
Status endpoint (health)
```sh
curl --unix-socket ./yanpm-agent.sock http://localhost/status
```
Notes
- Use the `config_name` and `timestamp` fields consistently: `timestamp` is typically a monotonic update ID from the caller ensuring unique file names.
- When running in containers, mount the host nginx config dir if you want the agent to write directly to host nginx configuration.
- The repository includes a runtime Docker image built by `apps/agent/Dockerfile` which bundles `nginx` and the `yanpm-agent` binary (via s6-overlay). Use that image when you want nginx and the agent colocated (the agent is intended as a runtime companion to `apps/api`).

View File

@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
YANPM_AGENT_UID="${YANPM_AGENT_UID:-1000}"
YANPM_AGENT_GID="${YANPM_AGENT_GID:-1000}"
# If a specific socket GID is requested, prefer that for the app group
YANPM_AGENT_GID_EFFECTIVE="${YANPM_AGENT_SOCK_GID:-${YANPM_AGENT_GID}}"
YANPM_AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}"
YANPM_AGENT_GROUP="${YANPM_AGENT_GROUP:-yanpm-agent}"
# Ensure group exists with desired GID
if grep -qE "^${YANPM_AGENT_GROUP}:" /etc/group 2>/dev/null; then
existing_gid=$(awk -F: -v g="${YANPM_AGENT_GROUP}" '$1==g{print $3}' /etc/group)
if [ "${existing_gid}" != "${YANPM_AGENT_GID_EFFECTIVE}" ]; then
delgroup "${YANPM_AGENT_GROUP}" || true
addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}"
fi
else
addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}"
fi
# Ensure user exists with desired UID and primary group
if grep -qE "^${YANPM_AGENT_USER}:" /etc/passwd 2>/dev/null; then
existing_uid=$(awk -F: -v u="${YANPM_AGENT_USER}" '$1==u{print $3}' /etc/passwd)
if [ "${existing_uid}" != "${YANPM_AGENT_UID}" ]; then
deluser "${YANPM_AGENT_USER}" || true
adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}"
fi
else
adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}"
fi
# Add app user to nginx group to allow reading configs
addgroup "${YANPM_AGENT_USER}" nginx || true
# Ensure runtime directories exist and fix ownership
mkdir -p /var/run/yanpm /app
if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /var/run/yanpm 2>/dev/null; then
echo "chown: /var/run/yanpm -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}"
else
echo "Warning: failed to chown /var/run/yanpm to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. This is common for bind-mounted host volumes or rootless Docker." >&2
fi
if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app/yanpm-agent 2>/dev/null; then
echo "chown: /app/yanpm-agent -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}"
else
echo "Warning: failed to chown /app/yanpm-agent to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. Binary will still be used if permissions allow." >&2
fi
if chown "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app 2>/dev/null; then
echo "chown: /app -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}"
else
echo "Warning: failed to chown /app to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}." >&2
fi
echo "App user and group setup complete. UID:${YANPM_AGENT_UID} GID:${YANPM_AGENT_GID_EFFECTIVE}"
exit 0

View File

@@ -0,0 +1,170 @@
#!/bin/sh
set -eu
# This init script installs a minimal nginx reload wrapper and a sudoers
# entry so the `yanpm-agent` user can perform a controlled reload via sudo.
WRAPPER_PATH="/usr/local/sbin/yanpm-nginx-reload"
SUDOERS_PATH="/etc/sudoers.d/yanpm-agent"
AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}"
# validate wrapper
VALIDATE_PATH="/usr/local/sbin/yanpm-nginx-validate"
# validate file wrapper
VALIDATE_FILE_PATH="/usr/local/sbin/yanpm-nginx-validate-file"
echo "[cont-init.d] install-reload-wrapper: setting up nginx reload helper"
# find nginx binary
NGINX_BIN="$(command -v nginx || true)"
if [ -z "${NGINX_BIN}" ]; then
echo "Warning: nginx binary not found in PATH; wrapper will still be created but may fail at runtime." >&2
NGINX_BIN="/usr/sbin/nginx"
fi
# Create wrapper
mkdir -p /usr/local/sbin /etc/sudoers.d
cat > "${WRAPPER_PATH}" <<- 'EOF'
#!/bin/sh
exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -s reload
EOF
# Replace placeholder with actual path
sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${WRAPPER_PATH}" || true
chmod 0750 "${WRAPPER_PATH}"
chown root:root "${WRAPPER_PATH}" || true
#
#
#
# Create validate wrapper
cat > "${VALIDATE_PATH}" <<- 'EOF'
#!/bin/sh
exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -t
EOF
# Replace placeholder with actual path in validate wrapper
sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_PATH}" || true
chmod 0750 "${VALIDATE_PATH}"
chown root:root "${VALIDATE_PATH}" || true
#
#
#
# Create validate file wrapper (secure)
cat > "${VALIDATE_FILE_PATH}" <<-'EOF'
#!/bin/sh
set -eu
if [ $# -ne 1 ]; then
echo "Usage: $0 <nginx-config-file>" >&2
exit 2
fi
INPUT="$1"
# Resolve absolute path
if command -v readlink >/dev/null 2>&1; then
TARGET="$(readlink -f -- "$INPUT" 2>/dev/null || true)"
elif command -v realpath >/dev/null 2>&1; then
TARGET="$(realpath -- "$INPUT" 2>/dev/null || true)"
else
echo "Error: no path resolver (readlink/realpath) available" >&2
exit 3
fi
if [ -z "$TARGET" ]; then
echo "Error: cannot resolve path: $INPUT" >&2
exit 4
fi
# Must be a regular file and not a symlink
if [ ! -f "$TARGET" ] || [ -L "$TARGET" ]; then
echo "Error: ${TARGET} is not a regular file" >&2
exit 5
fi
# must be created by agent user
AGENT_UID="$(id -u yanpm-agent 2>/dev/null || true)"
if [ -z "$AGENT_UID" ]; then
echo "Error: yanpm-agent user not found" >&2
exit 6
fi
FILE_UID="$(stat -c %u -- "$TARGET" 2>/dev/null || true)"
if [ "$FILE_UID" != "$AGENT_UID" ]; then
echo "Error: ${TARGET} not owned by yanpm-agent user" >&2
exit 7
fi
# Ensure file is not world-writable; allow typical 664 (rw-rw-r--)
if command -v stat >/dev/null 2>&1; then
MODE="$(stat -c %a -- "$TARGET" 2>/dev/null || true)"
if [ -n "$MODE" ]; then
OTHERS=$(( MODE % 10 ))
if [ $(( OTHERS & 2 )) -ne 0 ]; then
echo "Error: ${TARGET} is world-writable" >&2
exit 8
fi
fi
elif command -v find >/dev/null 2>&1; then
if find "$TARGET" -maxdepth 0 -perm /002 -print -quit >/dev/null 2>&1; then
echo "Error: ${TARGET} is world-writable" >&2
exit 8
fi
fi
exec "@NGINX_BIN@" -c "$TARGET" -t
EOF
# Replace placeholder with actual path in validate file wrapper
sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_FILE_PATH}" || true
chmod 0750 "${VALIDATE_FILE_PATH}"
chown root:root "${VALIDATE_FILE_PATH}" || true
echo "Created wrapper: ${WRAPPER_PATH} (owned by root, mode 750)"
#
#
#
# Ensure sudoers entry exists allowing the agent to run only this wrapper as root
if command -v sudo >/dev/null 2>&1; then
echo "sudo present; creating sudoers entry"
cat > "${SUDOERS_PATH}" <<- EOF
# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password
${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH}
EOF
chmod 0440 "${SUDOERS_PATH}" || true
echo "Wrote sudoers entry: ${SUDOERS_PATH}"
else
echo "sudo not found; attempting to install"
if command -v apk >/dev/null 2>&1; then
apk add --no-cache sudo || true
elif command -v apt-get >/dev/null 2>&1; then
apt-get update || true
apt-get install -y sudo || true
elif command -v yum >/dev/null 2>&1; then
yum install -y sudo || true
else
echo "No known package manager to install sudo; please ensure sudo is available in the image." >&2
fi
if command -v sudo >/dev/null 2>&1; then
cat > "${SUDOERS_PATH}" <<- EOF
# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password
${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH}
EOF
chmod 0440 "${SUDOERS_PATH}" || true
echo "Installed sudo and wrote sudoers entry: ${SUDOERS_PATH}"
else
echo "Failed to install sudo; the agent will not be able to reload nginx via sudo." >&2
fi
fi
exit 0

View File

@@ -0,0 +1,5 @@
#!/bin/sh
# Run the agent as the unprivileged 'yanpm-agent' user
cd /app
echo "Starting yanpm-agent..."
exec s6-setuidgid yanpm-agent ./yanpm-agent

View File

@@ -0,0 +1,3 @@
#!/bin/sh
# Run nginx in foreground (s6 will supervise it)
exec nginx -g 'daemon off;'

2
apps/agent/justfile Normal file
View File

@@ -0,0 +1,2 @@
build-docker:
docker build -t yanpm/agent:latest .

292
apps/agent/src/commands.rs Normal file
View File

@@ -0,0 +1,292 @@
mod reload;
mod run;
mod validate;
mod write_config;
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use tokio::sync::{Mutex, RwLock};
use tokio_cron_scheduler::{Job, JobScheduler};
use tracing::{error, info};
use crate::commands::write_config::INTERNAL_CONFIG_FOLDER_NAME;
const OLD_CONFIG_CLEANUP_THRESHOLD: u64 = 3600;
pub struct NginxService {
// lock for nginx reload, and timestamp tracking
nginx_lock: Mutex<()>,
last_applied: AtomicU64,
// lock for write_config per (config_name, timestamp)
#[allow(clippy::type_complexity)]
write_config_lock: RwLock<HashMap<(String, u64), Arc<RwLock<()>>>>,
// commands
reload_cmd: Arc<reload::ReloadCommand>,
validate_cmd: Arc<validate::ValidateCommand>,
write_config_cmd: Arc<write_config::WriteConfigCommand>,
}
impl NginxService {
pub async fn new(
scheduler: Arc<JobScheduler>,
nginx_config_dir: std::path::PathBuf,
) -> Result<Arc<Self>, Box<dyn std::error::Error + Send + Sync>> {
let nginx_service = Arc::new(NginxService {
nginx_lock: Mutex::new(()),
last_applied: AtomicU64::new(0),
write_config_lock: RwLock::new(HashMap::new()),
// commands
reload_cmd: Arc::new(reload::ReloadCommand::default()),
validate_cmd: Arc::new(validate::ValidateCommand::new(nginx_config_dir.clone())),
write_config_cmd: Arc::new(write_config::WriteConfigCommand::new(nginx_config_dir)),
});
let mut nginx_service_clone = nginx_service.clone();
scheduler
.clone()
// cleanup every 10 minutes
.add(Job::new_async("0 */10 * * * *", move |_uuid, _l| {
info!("Running nginx_service cleanup job");
let nginx_service_clone = nginx_service_clone.clone();
let job = Box::pin(async move {
nginx_service_clone.cleanup_unused_lock().await;
});
info!("NginxService cleanup job completed");
job
})?)
.await?;
nginx_service_clone = nginx_service.clone();
scheduler
.clone()
// cleanup every hour
.add(Job::new_async("0 0 */1 * * *", move |_uuid, _l| {
info!("Running nginx_service old config cleanup job");
let nginx_service_clone = nginx_service_clone.clone();
let job = Box::pin(async move {
nginx_service_clone.cleanup_old_configs().await;
});
info!("NginxService old config cleanup job completed");
job
})?)
.await?;
Ok(nginx_service)
}
pub async fn validate_and_reload(
&self,
config_name: &str,
timestamp: u64,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
let cur = self.last_applied.load(Ordering::SeqCst);
if cur > timestamp {
return Err("Another operation is in progress with higher timestamp value".into());
}
// acquire write lock to update nginx_lock
let _nginx_guard = self.nginx_lock.lock().await;
// acquire write lock for this config+timestamp
let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await;
let _guard = rw_lock.write().await;
match self
.reload_cmd
.validate_and_reload(config_name, timestamp, self.validate_cmd.clone())
.await
{
Ok((code, output)) => {
// update last_applied
self.last_applied.store(timestamp, Ordering::SeqCst);
Ok((code, output))
}
Err(e) => Err(e),
}
}
pub async fn write_config(
&self,
config_name: &str,
timestamp: u64,
content: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await;
let _guard = rw_lock.write().await;
// call the write_config command
self.write_config_cmd
.write_config(config_name, timestamp, content)
.await
}
pub async fn validate(
&self,
config_name: &str,
timestamp: u64,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
self.validate_cmd.validate(config_name, timestamp).await
}
async fn cleanup_unused_lock(&self) {
let mut _write_lock = self.write_config_lock.write().await;
(*_write_lock).retain(|_, lock| {
// retain only locks that are currently held (readers or writers)
lock.try_write().is_err()
});
}
async fn cleanup_old_configs(&self) {
// list all files within nginx_config_dir/YANPM that is older than now - OLD_CONFIG_CLEANUP_THRESHOLD
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- OLD_CONFIG_CLEANUP_THRESHOLD;
let nginx_config_dir = self.validate_cmd.nginx_config_dir();
let yanpm_dir = nginx_config_dir.join(INTERNAL_CONFIG_FOLDER_NAME);
let read_dir = match tokio::fs::read_dir(&yanpm_dir).await {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// directory does not exist, nothing to clean up
return;
}
Err(e) => {
error!(
"Error reading {} config directory {}: {}",
INTERNAL_CONFIG_FOLDER_NAME,
yanpm_dir.display(),
e
);
return;
}
};
tokio::pin!(read_dir);
while let Some(entry) = read_dir.next_entry().await.unwrap_or(None) {
let metadata = match entry.metadata().await {
Ok(md) => md,
Err(e) => {
error!(
"Error getting metadata for file {}: {}",
entry.path().display(),
e
);
continue;
}
};
if let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
{
let mtime_secs = duration.as_secs();
if mtime_secs < cutoff {
// file is older than cutoff, remove it
if let Err(e) = tokio::fs::remove_file(entry.path()).await {
error!(
"Error removing old config file {}: {}",
entry.path().display(),
e
);
} else {
info!("Removed old config file {}", entry.path().display());
}
}
}
}
}
async fn acquire_file_write_lock(&self, config_name: &str, timestamp: u64) -> Arc<RwLock<()>> {
let mut write_lock = self.write_config_lock.write().await;
write_lock
.entry((config_name.to_string(), timestamp))
.or_insert_with(|| Arc::new(RwLock::new(())))
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
use std::sync::Arc as StdArc;
use tokio::time::{Duration, sleep};
impl NginxService {
// Test helper that simulates a long-running reload without invoking external commands.
pub async fn test_simulated_reload(
&self,
config_name: &str,
timestamp: u64,
delay_ms: u64,
) -> Result<(), Box<dyn Error + Send + Sync>> {
// pre-check
let cur = self.last_applied.load(Ordering::SeqCst);
if cur >= timestamp {
return Err("stale".into());
}
// acquire exclusive lock and re-check
let _nginx_guard = self.nginx_lock.lock().await;
let cur2 = self.last_applied.load(Ordering::SeqCst);
if cur2 >= timestamp {
return Err("stale".into());
}
// per-file lock
let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await;
let _guard = rw_lock.write().await;
// simulate operation
sleep(Duration::from_millis(delay_ms)).await;
// on success update last_applied
let mut prev = self.last_applied.load(Ordering::SeqCst);
while prev < timestamp {
match self.last_applied.compare_exchange(
prev,
timestamp,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => break,
Err(next) => prev = next,
}
}
Ok(())
}
}
#[tokio::test]
async fn concurrent_stale_is_rejected() {
let scheduler = StdArc::new(JobScheduler::new().await.unwrap());
let svc = NginxService::new(scheduler.clone(), std::env::temp_dir())
.await
.unwrap();
let s1 = svc.clone();
let h1 = tokio::spawn(async move { s1.test_simulated_reload("cfg", 2, 200).await });
// let second start shortly after first so it will wait for the mutex
sleep(Duration::from_millis(20)).await;
let s2 = svc.clone();
let h2 = tokio::spawn(async move { s2.test_simulated_reload("cfg", 1, 10).await });
let r1 = h1.await.unwrap();
assert!(r1.is_ok(), "first (newer) task should succeed");
let r2 = h2.await.unwrap();
assert!(
r2.is_err(),
"second (older) task should be rejected as stale"
);
}
}

View File

@@ -0,0 +1,109 @@
use std::path::Path;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex;
use tracing::error;
use crate::commands::write_config::INTERNAL_CONFIG_FOLDER_NAME;
use crate::commands::{run::run_cmd, validate::ValidateCommand};
pub struct ReloadCommand {
is_reloading: Mutex<bool>,
}
struct ReloadResetGuard<'a> {
guard: tokio::sync::MutexGuard<'a, bool>,
}
impl<'a> Drop for ReloadResetGuard<'a> {
fn drop(&mut self) {
*self.guard = false;
}
}
impl Default for ReloadCommand {
fn default() -> Self {
Self {
is_reloading: Mutex::new(false),
}
}
}
impl ReloadCommand {
pub async fn validate_and_reload(
&self,
config_name: &str,
timestamp: u64,
validate_cmd: Arc<ValidateCommand>,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
// ensure the written fragment exists
validate_cmd.validate(config_name, timestamp).await?;
// Now atomically swap the YANPM.conf symlink to point to the new fragment
// so nginx -t validates the composed main config. If validation fails,
// attempt to restore the previous symlink.
let filename = crate::commands::run::to_file_name(config_name, timestamp)?;
let nginx_dir = validate_cmd.nginx_config_dir();
let symlink_path = nginx_dir.join("YANPM.conf");
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let tmp_name = format!("YANPM.conf.tmp.{}.{}", std::process::id(), now);
let tmp_path = nginx_dir.join(&tmp_name);
// prepare relative target: INTERNAL_CONFIG_FOLDER_NAME/<filename>
let rel_target = Path::new(INTERNAL_CONFIG_FOLDER_NAME).join(&filename);
// read previous target if exists
let previous_target = std::fs::read_link(&symlink_path).ok();
// Acquire reload guard before mutating the symlink to avoid races
let reloading_lock = self.is_reloading.lock().await;
if *reloading_lock {
return Err("Reload already in progress".into());
}
// set flag to true and ensure it is reset on drop
let mut mut_guard = reloading_lock;
*mut_guard = true;
let _reset_guard = ReloadResetGuard { guard: mut_guard };
// create temporary symlink and atomically rename into place
std::os::unix::fs::symlink(&rel_target, &tmp_path)?;
tokio::fs::rename(&tmp_path, &symlink_path).await?;
// validate composed main config now that symlink points to new fragment
if let Err(e) = validate_cmd.validate_all().await {
// restore previous symlink state while still holding the guard
if let Some(prev) = previous_target {
let restore_tmp =
nginx_dir.join(format!("YANPM.conf.restore.{}.{}", std::process::id(), now));
std::os::unix::fs::symlink(&prev, &restore_tmp)?;
if let Err(err) = tokio::fs::rename(&restore_tmp, &symlink_path).await {
error!(
"Failed to restore previous YANPM.conf symlink after validation error: {}",
err
);
}
} else if let Err(err) = tokio::fs::remove_file(&symlink_path).await {
error!(
"Failed to remove YANPM.conf symlink after validation error: {}",
err
);
}
return Err(e);
}
// reload the running nginx master process (no -c) so it reloads its configured main config
// Prefer the restricted sudo wrapper if available, fall back to direct nginx reload.
// TODO: allow configuring the path to the wrapper
match run_cmd("sudo", &["-n", "/usr/local/sbin/yanpm-nginx-reload"], 10).await {
Ok(res) => Ok(res),
Err(e) => {
error!(
"sudo reload wrapper failed, falling back to direct nginx reload: {}",
e
);
run_cmd("nginx", &["-s", "reload"], 10).await
}
}
}
}

View File

@@ -0,0 +1,85 @@
use std::time::Duration;
use tokio::{process::Command, time::timeout};
use tracing::error;
pub fn to_file_name(
config_name: &str,
timestamp: u64,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// reject empty or unsafe names to avoid path traversal or invalid filesystem chars
if config_name.is_empty() {
return Err("config_name is empty".into());
}
if config_name.len() > 255 {
return Err("config_name too long".into());
}
if config_name.contains('/') || config_name.contains('\\') || config_name.contains("..") {
return Err("config_name contains invalid path characters".into());
}
if !config_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-._".contains(c))
{
return Err("config_name contains invalid characters".into());
}
Ok(format!("{}_{}.conf", timestamp, config_name))
}
pub async fn run_cmd(
cmd: &str,
args: &[&str],
dur_s: u64,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
let mut c = Command::new(cmd);
c.args(args);
let res = timeout(Duration::from_secs(dur_s), c.output()).await;
let out = match res {
Ok(Ok(out)) => out,
Ok(Err(e)) => return Err(Box::new(e)),
Err(_) => {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"command timeout",
)));
}
};
let code = out.status.code().unwrap_or(-1);
let output = String::from_utf8_lossy(&[out.stdout, out.stderr].concat()).to_string();
if code != 0 {
error!("command failed ({}): {}", code, output);
return Err(format!("command failed ({}): {}", code, output).into());
}
Ok((code, output))
}
#[cfg(test)]
mod tests {
use super::to_file_name;
#[test]
fn to_file_name_valid() {
let res = to_file_name("myconf", 1234).expect("should succeed");
assert_eq!(res, "1234_myconf.conf");
}
#[test]
fn to_file_name_empty() {
assert!(to_file_name("", 1).is_err());
}
#[test]
fn to_file_name_invalid_chars() {
assert!(to_file_name("bad/name", 1).is_err());
assert!(to_file_name("bad\\name", 1).is_err());
assert!(to_file_name("bad..name", 1).is_err());
assert!(to_file_name("bad$name", 1).is_err());
}
#[test]
fn to_file_name_too_long() {
let long = "a".repeat(300);
assert!(to_file_name(&long, 1).is_err());
}
}

View File

@@ -0,0 +1,166 @@
use tracing::{info, warn};
use crate::commands::{run::run_cmd, write_config::INTERNAL_CONFIG_FOLDER_NAME};
use std::path::PathBuf;
pub struct ValidateCommand {
nginx_config_dir: PathBuf,
}
impl ValidateCommand {
pub fn new(nginx_config_dir: PathBuf) -> Self {
Self { nginx_config_dir }
}
pub fn nginx_config_dir(&self) -> PathBuf {
self.nginx_config_dir.clone()
}
pub async fn validate_all(
&self,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
// Try a normal config test first. If it fails due to pid permission
// errors (common when running unprivileged against /run/nginx.pid),
// retry with a writable pid override so validation can succeed.
match run_cmd("nginx", &["-t"], 10).await {
Ok(res) => Ok(res),
Err(e) => {
info!(
"nginx -t failed: {}. Trying with privileged wrapper or writable pid override.",
e
);
let es = e.to_string();
if es.contains("/run/nginx.pid") && es.contains("Permission denied") {
// Try privileged validate wrapper if available (allows the agent to run
// nginx -t via sudo without modifying the main config).
match run_cmd(
"sudo",
// TODO: allow configuring the path to the wrapper
&["-n", "/usr/local/sbin/yanpm-nginx-validate"],
10,
)
.await
{
Ok(res) => return Ok(res),
Err(e) => {
warn!(
"Privileged validate wrapper failed: {}. Falling back to writable pid override.",
e
);
// Fallback to the existing writable-pid override if sudo wrapper
// isn't available or fails.
let pid_path = format!(
"{}/yanpm-validate-{}.pid",
std::env::temp_dir().display(),
std::process::id()
);
let g_arg = format!("pid {};", pid_path);
let args_vec = ["-t".to_string(), "-g".to_string(), g_arg];
let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
return run_cmd("nginx", args_ref.as_slice(), 10).await;
}
}
}
Err(e)
}
}
}
pub async fn validate(
&self,
config_name: &str,
timestamp: u64,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
let filename = crate::commands::run::to_file_name(config_name, timestamp)?;
// fragments are written into the YANPM subdirectory
let full_path = self
.nginx_config_dir
.join(INTERNAL_CONFIG_FOLDER_NAME)
.join(&filename);
// ensure the fragment file exists
if tokio::fs::metadata(&full_path).await.is_err() {
return Err(format!("Config file not found: {}", full_path.display()).into());
}
// Create a temporary wrapper nginx config that provides the required
// top-level sections (`events` and `http`) and includes the fragment.
let fragment_path = full_path.to_str().ok_or("invalid config path")?.to_string();
let mut tmp_path = std::env::temp_dir();
let tmp_name = format!("yanpm-validate-{}-{}.conf", timestamp, std::process::id());
tmp_path.push(tmp_name);
let wrapper = format!(
"worker_processes 1;\nevents {{ worker_connections 1024; }}\nhttp {{\n include {};\n}}\n",
fragment_path
);
// Write the temporary wrapper file
tokio::fs::write(&tmp_path, wrapper).await?;
let tmp_path_str = tmp_path
.to_str()
.ok_or("invalid temp config path")?
.to_string();
// Run the test against the wrapper, telling nginx to place its pid
// somewhere writable so the config test doesn't fail with permission
// errors when running as an unprivileged user.
let result = match run_cmd("nginx", &["-t", "-c", &tmp_path_str], 10).await {
Ok(res) => Ok(res),
Err(e) => {
info!(
"nginx -t failed: {}. Trying with privileged wrapper or writable pid override.",
e
);
let es = e.to_string();
if es.contains("/run/nginx.pid") && es.contains("Permission denied") {
// Try privileged validate wrapper if available (allows the agent to run
// nginx -t via sudo without modifying the main config).
match run_cmd(
"sudo",
// TODO: allow configuring the path to the wrapper
&[
"-n",
"/usr/local/sbin/yanpm-nginx-validate-file",
&tmp_path_str,
],
10,
)
.await
{
Ok(res) => return Ok(res),
Err(e) => {
warn!(
"Privileged validate wrapper failed: {}. Falling back to writable pid override.",
e
);
let pid_path = format!(
"{}/yanpm-validate-{}.pid",
std::env::temp_dir().display(),
std::process::id()
);
let g_arg = format!("pid {};", pid_path);
let args_vec = [
"-t".to_string(),
"-c".to_string(),
tmp_path_str.clone(),
"-g".to_string(),
g_arg,
];
let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
return run_cmd("nginx", args_ref.as_slice(), 10).await;
}
}
}
Err(e)
}
};
let _ = tokio::fs::remove_file(&tmp_path).await;
result
}
}

View File

@@ -0,0 +1,133 @@
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::AsyncWriteExt;
use tracing::info;
use crate::commands::run::to_file_name;
pub const INTERNAL_CONFIG_FOLDER_NAME: &str = "YANPM";
const FILE_SIZE_LIMIT: usize = 10 * 1024 * 1024; // 10MB
pub struct WriteConfigCommand {
nginx_config_dir: PathBuf,
}
impl WriteConfigCommand {
pub fn new(nginx_config_dir: PathBuf) -> Self {
Self { nginx_config_dir }
}
pub async fn write_config(
&self,
config_name: &str,
timestamp: u64,
content: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let filename = to_file_name(config_name, timestamp)?;
let path = self.nginx_config_dir.clone();
// ensure main config dir exists
tokio::fs::create_dir_all(&path).await?;
info!("Writing config to {:?}", path.join(&filename));
// create YANPM subdir where fragment files live
let yanpm_dir = path.join(INTERNAL_CONFIG_FOLDER_NAME);
tokio::fs::create_dir_all(&yanpm_dir).await?;
let final_path = yanpm_dir.join(&filename);
// limit size to 10MB
if content.len() > FILE_SIZE_LIMIT {
return Err(format!(
"content exceeds {}MB size limit",
FILE_SIZE_LIMIT / (1024 * 1024)
)
.into());
}
// create a temporary filename in the same directory for atomic replace
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let tmp_filename = format!("{}.tmp.{}.{}", filename, std::process::id(), now);
// create tmp file in the same directory as final file to ensure atomic rename
let tmp_path = yanpm_dir.join(tmp_filename);
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.await?;
file.write_all(content.as_bytes()).await?;
// ensure data is flushed to disk; propagate errors
file.sync_all().await?;
// atomically move the tmp file into the YANPM dir
tokio::fs::rename(&tmp_path, &final_path).await?;
// set explicit permissions (rw-r-----)
tokio::fs::set_permissions(&final_path, std::fs::Permissions::from_mode(0o640)).await?;
info!("Config written and permissions set for {:?}", final_path);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{INTERNAL_CONFIG_FOLDER_NAME, WriteConfigCommand};
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
#[tokio::test]
async fn write_config_success_and_cleanup() {
let base = std::env::temp_dir().join(format!(
"yanpm_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
// ensure clean
let _ = tokio::fs::remove_dir_all(&base).await;
let cmd = WriteConfigCommand::new(base.clone());
let config_name = "unittest";
let timestamp = 42u64;
let content = "hello world";
cmd.write_config(config_name, timestamp, content)
.await
.expect("write should succeed");
let filename = super::to_file_name(config_name, timestamp).unwrap();
let final_path = base.join(INTERNAL_CONFIG_FOLDER_NAME).join(&filename);
let data = tokio::fs::read_to_string(&final_path)
.await
.expect("file should exist");
assert_eq!(data, content);
// cleanup
tokio::fs::remove_dir_all(&base).await.expect("cleanup");
}
#[tokio::test]
async fn write_config_size_limit() {
let base = std::env::temp_dir().join(format!(
"yanpm_test_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = tokio::fs::remove_dir_all(&base).await;
let cmd = WriteConfigCommand::new(base.clone());
// exceed 10MB limit
let large = vec![b'a'; 10 * 1024 * 1024 + 1];
let large_str = String::from_utf8_lossy(&large).to_string();
let res = cmd.write_config("big", 1, &large_str).await;
assert!(res.is_err());
let _ = tokio::fs::remove_dir_all(&base).await;
}
}

221
apps/agent/src/main.rs Normal file
View File

@@ -0,0 +1,221 @@
#![forbid(unsafe_code)]
mod commands;
mod openapi;
mod routes;
use axum::routing::get;
use axum::{Router, routing::post};
use clap::Parser;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::UnixListener;
use tracing::{error, info, warn};
use crate::commands::NginxService;
use crate::openapi::{GenerateOpenapiArgs, generate_openapi_doc};
use crate::routes::{status, validate, validate_and_reload, write_config};
const SOCK_ENV: &str = "YANPM_AGENT_SOCK";
const SOCK_PERM_ENV: &str = "YANPM_AGENT_SOCK_PERM";
const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR";
const SOCK_GID_ENV: &str = "YANPM_AGENT_SOCK_GID";
const SOCK_DEFAULT: &str = "./yanpm-agent.sock";
const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d";
const SOCK_PERM_DEFAULT: &str = "660";
const SOCK_GID_DEFAULT: &str = "";
/// Command line arguments
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Unix socket path to bind the agent daemon to
#[arg(short = 's', long, default_value_t = String::from(SOCK_DEFAULT), env = SOCK_ENV)]
sock: String,
/// Directory where generated nginx config files will be written
#[arg(short = 'd', long, default_value_t = String::from(NGINX_CONFIG_DIR_DEFAULT), env = NGINX_CONFIG_DIR_ENV)]
nginx_config_dir: String,
/// Permissions to set on the unix socket (in octal), e.g. 660
#[arg(long, default_value_t = String::from(SOCK_PERM_DEFAULT), env = SOCK_PERM_ENV)]
sock_perm: String,
/// GID to set on the unix socket, default: current user's primary group
#[arg(long, default_value_t = String::from(SOCK_GID_DEFAULT), env = SOCK_GID_ENV)]
sock_gid: String,
#[command(subcommand)]
command: Option<SubCommand>,
}
#[derive(clap::Subcommand, Debug)]
pub enum SubCommand {
/// Generate OpenAPI spec to file or stdout
GenerateOpenapi {
/// Output file path.
#[arg(short = 'o', long)]
output: String,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.with_level(true)
.with_timer(tracing_subscriber::fmt::time::SystemTime)
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("Failed to set global default subscriber");
let args = Args::parse();
if let Some(cmd) = &args.command {
match cmd {
SubCommand::GenerateOpenapi { output } => {
generate_openapi_doc(&GenerateOpenapiArgs {
output: output.clone(),
})
.await?;
return Ok(());
}
}
}
let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?;
let path = PathBuf::from(&sock);
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await.unwrap_or_else(|err| {
error!(
"Warning: failed to create socket directory {}: {}",
dir.display(),
err
)
});
// permissive; set tighter perms in production via image/build steps
tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770))
.await
.unwrap_or_else(|err| {
error!(
"Warning: failed to set permissions on socket directory {}: {}",
dir.display(),
err
)
});
}
// If an existing path exists at the socket location, ensure it's a socket
match tokio::fs::metadata(&path).await {
Ok(md) => {
use std::os::unix::fs::FileTypeExt;
if md.file_type().is_socket() {
tokio::fs::remove_file(&path).await.unwrap_or_else(|err| {
error!(
"Warning: failed to remove existing socket file {}: {}",
path.display(),
err
)
});
} else {
return Err(
format!("Socket path {} exists and is not a socket", path.display()).into(),
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(format!("Failed to stat socket path {}: {}", path.display(), e).into());
}
}
// bind using tokio's UnixListener (avoids converting a blocking std listener)
let listener = UnixListener::bind(&path).expect("Failed to bind to unix socket");
// set socket perms to sock_perm (best-effort)
if let Err(err) =
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(sock_perm)).await
{
error!(
"Warning: failed to set permissions on socket {}: {}",
path.display(),
err
);
}
// set socket gid to sock_gid (best-effort)
if !sock_gid.is_empty() {
use nix::unistd::{Gid, chown};
if let Err(err) = chown(
&path,
None,
Some(Gid::from_raw(
sock_gid
.parse()
.map_err(|e| format!("Failed to parse socket GID {}: {}", sock_gid, e))
.unwrap_or_else(|_| nix::unistd::getgid().as_raw()),
)),
) {
error!(
"Warning: failed to set GID on socket {}: {}",
path.display(),
err
);
}
}
let scheduler = Arc::new(tokio_cron_scheduler::JobScheduler::new().await?);
let app = Router::new()
.route("/status", get(status))
.route("/validate_and_reload", post(validate_and_reload))
.route("/validate", post(validate))
.route("/write_config", post(write_config))
.with_state(NginxService::new(scheduler.clone(), PathBuf::from(nginx_config_dir)).await?);
scheduler.clone().start().await?;
info!("Starting yanpm-daemon on unix socket: {}", sock);
axum::serve::serve(listener, app)
.await
.expect("Failed to start axum server");
info!("Shutting down yanpm-daemon");
Ok(())
}
async fn get_args(
args: &Args,
) -> Result<(String, String, u32, String), Box<dyn std::error::Error + Send + Sync>> {
let sock = args.sock.clone();
let nginx_config_dir = args.nginx_config_dir.clone();
let sock_perm = args.sock_perm.clone();
let sock_gid = args.sock_gid.clone();
if sock_perm.len() != 3 || !sock_perm.chars().all(|c| ('0'..='7').contains(&c)) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Invalid socket permission string: {}. Must be a 3-digit octal number.",
sock_perm
),
)
.into());
}
if sock_perm.chars().last().unwrap() > '0' {
warn!(
"Socket permission string {} allows others to access the socket. This may be a security risk. Consider setting {} to a desired group and using a socket permission string that does not allow others to access the socket.",
sock_perm, SOCK_GID_ENV
);
};
Ok((
sock,
nginx_config_dir,
u32::from_str_radix(&sock_perm, 8).expect("Failed to parse socket permission string"),
sock_gid,
))
}

45
apps/agent/src/openapi.rs Normal file
View File

@@ -0,0 +1,45 @@
use tracing::info;
use utoipa::OpenApi;
pub mod tag {
/// nginx
pub const NGINX_TAG: &str = "Nginx Agent";
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::status,
crate::routes::validate,
crate::routes::validate_and_reload,
crate::routes::write_config,
),
components(
schemas(crate::routes::StatusResp),
schemas(crate::routes::ValidateAndReloadResp),
schemas(crate::routes::ValidateBody),
schemas(crate::routes::WriteConfigBody),
schemas(crate::routes::ValidateAndReloadBody),
),
tags(
(name = tag::NGINX_TAG, description = "Nginx Agent API"),
)
)]
struct ApiDoc;
pub struct GenerateOpenapiArgs {
pub output: String,
}
pub async fn generate_openapi_doc(
args: &GenerateOpenapiArgs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Generating OpenAPI documentation...");
let doc = ApiDoc::openapi();
let json = doc
.to_pretty_json()
.expect("Failed to serialize OpenAPI doc to JSON");
std::fs::write(&args.output, json).expect("Failed to write OpenAPI doc to file");
info!("OpenAPI documentation generated at {}", args.output);
Ok(())
}

167
apps/agent/src/routes.rs Normal file
View File

@@ -0,0 +1,167 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use serde_json::{Value, from_value};
use std::sync::Arc;
use tracing::warn;
use crate::commands::NginxService;
#[derive(Serialize, utoipa::ToSchema)]
pub struct StatusResp {
pub ok: bool,
}
/// Health check endpoint
#[utoipa::path(
get,
path = "/status",
responses(
(status = 200, description = "Status response", body = StatusResp)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn status() -> impl IntoResponse {
let resp = StatusResp { ok: true };
(axum::http::StatusCode::OK, axum::Json(resp))
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ValidateAndReloadResp {
pub rc: i32,
pub ro: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ValidateBody {
config_name: String,
timestamp: u64,
}
#[utoipa::path(
post,
path = "/validate",
request_body = ValidateBody,
responses(
(status = 200, description = "Validation response", body = serde_json::Value)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn validate(
State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
let params: ValidateBody = match from_value(payload) {
Ok(req) => req,
Err(e) => {
warn!("Invalid validate request: {}", e);
return (StatusCode::BAD_REQUEST).into_response();
}
};
let resp = match nginx_controller
.validate(&params.config_name, params.timestamp)
.await
{
Ok(res) => res,
Err(e) => {
let resp = serde_json::json!({ "error": e.to_string() });
return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response();
}
};
(axum::http::StatusCode::OK, axum::Json(resp)).into_response()
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ValidateAndReloadBody {
config_name: String,
timestamp: u64,
}
#[utoipa::path(
post,
path = "/validate_and_reload",
request_body = ValidateAndReloadBody,
responses(
(status = 200, description = "Validate and reload response", body = ValidateAndReloadResp)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn validate_and_reload(
State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
let params: ValidateAndReloadBody = match from_value(payload) {
Ok(req) => req,
Err(e) => {
warn!("Invalid validate_and_reload request: {}", e);
return (StatusCode::BAD_REQUEST).into_response();
}
};
let (code, output) = match nginx_controller
.validate_and_reload(&params.config_name, params.timestamp)
.await
{
Ok(res) => res,
Err(e) => {
let resp = ValidateAndReloadResp {
rc: -1,
ro: e.to_string(),
};
return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response();
}
};
let resp = ValidateAndReloadResp {
rc: code,
ro: output,
};
(axum::http::StatusCode::OK, axum::Json(resp)).into_response()
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct WriteConfigBody {
config_name: String,
timestamp: u64,
content: String,
}
#[utoipa::path(
post,
path = "/write_config",
request_body = WriteConfigBody,
responses(
(status = 200, description = "Write config response"),
(status = 500, description = "Internal server error", body = serde_json::Value)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn write_config(
State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
let body: WriteConfigBody = match from_value(payload) {
Ok(req) => req,
Err(e) => {
warn!("Invalid write_config request: {}", e);
return (StatusCode::BAD_REQUEST).into_response();
}
};
match nginx_controller
.write_config(&body.config_name, body.timestamp, &body.content)
.await
{
Ok(_) => (),
Err(e) => {
let resp = serde_json::json!({ "error": e.to_string() });
return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response();
}
};
(axum::http::StatusCode::OK,).into_response()
}

215
apps/agent/swagger.json Normal file
View File

@@ -0,0 +1,215 @@
{
"openapi": "3.1.0",
"info": {
"title": "yanpm-agent",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {
"/status": {
"get": {
"tags": [
"Nginx Agent"
],
"summary": "Health check endpoint",
"operationId": "status",
"responses": {
"200": {
"description": "Status response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResp"
}
}
}
}
}
}
},
"/validate": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "validate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Validation response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/validate_and_reload": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "validate_and_reload",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateAndReloadBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Validate and reload response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateAndReloadResp"
}
}
}
}
}
}
},
"/write_config": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "write_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WriteConfigBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Write config response"
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
},
"components": {
"schemas": {
"StatusResp": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
}
},
"ValidateAndReloadBody": {
"type": "object",
"required": [
"config_name",
"timestamp"
],
"properties": {
"config_name": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"ValidateAndReloadResp": {
"type": "object",
"required": [
"rc",
"ro"
],
"properties": {
"rc": {
"type": "integer",
"format": "int32"
},
"ro": {
"type": "string"
}
}
},
"ValidateBody": {
"type": "object",
"required": [
"config_name",
"timestamp"
],
"properties": {
"config_name": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"WriteConfigBody": {
"type": "object",
"required": [
"config_name",
"timestamp",
"content"
],
"properties": {
"config_name": {
"type": "string"
},
"content": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
}
}
},
"tags": [
{
"name": "Nginx Agent",
"description": "Nginx Agent API"
}
]
}

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
database = { path = "../../public/database" } database = { path = "../../public/database" }
migration = { path = "../../public/migration" } migration = { path = "../../public/migration" }
agent_client = { path = "../../public/agent-client" }
axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] } axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] }
axum-extra = { version = "0.12.2", features = ["cookie"] } axum-extra = { version = "0.12.2", features = ["cookie"] }
@@ -28,4 +29,14 @@ argon2 = { version = "0.5.3", features = ["std"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
tower-http = { version = "0.6.8", features = ["cors"] } tower-http = { version = "0.6.8", features = ["cors"] }
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
serde_urlencoded = { version = "0.7.1" }
[dev-dependencies]
tempfile = "3"
axum-test = "18.4.1"
agent_client = { path = "../../public/agent-client", features = ["mockall"] }
mockall = { version = "0.14.0", features = [] }
[lints.clippy]
unwrap_used = "deny"

View File

@@ -28,7 +28,7 @@ fn action(
_matches: &clap::ArgMatches, _matches: &clap::ArgMatches,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
let output_path = _matches.get_one::<String>("output_path"); let output_path = _matches.get_one::<String>("output_path");
let output_path = output_path.unwrap().to_string(); let output_path = output_path.expect("output_path is required").to_string();
Box::pin(async move { Box::pin(async move {
tracing::subscriber::with_default(log::make_temporary_subscriber(), || { tracing::subscriber::with_default(log::make_temporary_subscriber(), || {

View File

@@ -11,15 +11,8 @@ use crate::{
cmd::CliCommand, cmd::CliCommand,
configs::{ProgramSettings, get_program_settings, logging::LoggingSettings}, configs::{ProgramSettings, get_program_settings, logging::LoggingSettings},
log, log,
routes::{self, AppService, AppState}, routes::{self, AppState},
services::{ services::get_app_service,
auth::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
tasks, tasks,
}; };
@@ -148,19 +141,7 @@ fn get_app_state(
AppState { AppState {
database_connection: db_connection.clone(), database_connection: db_connection.clone(),
config: Arc::new(settings.clone()), config: Arc::new(settings.clone()),
service: Arc::new(AppService { service: Arc::new(get_app_service(db_connection, settings)),
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
}),
} }
} }

View File

@@ -1,3 +1,4 @@
pub mod agent;
pub mod auth; pub mod auth;
pub mod database; pub mod database;
pub mod logging; pub mod logging;
@@ -21,6 +22,7 @@ pub struct ProgramSettings {
pub database: database::DatabaseSettings, pub database: database::DatabaseSettings,
pub server: server::ServerSettings, pub server: server::ServerSettings,
pub auth: auth::AuthSettings, pub auth: auth::AuthSettings,
pub agent: agent::AgentSettings,
} }
impl FromConfig for ProgramSettings { impl FromConfig for ProgramSettings {
@@ -30,6 +32,7 @@ impl FromConfig for ProgramSettings {
database: database::DatabaseSettings::from_config(_config)?, database: database::DatabaseSettings::from_config(_config)?,
server: server::ServerSettings::from_config(_config)?, server: server::ServerSettings::from_config(_config)?,
auth: auth::AuthSettings::from_config(_config)?, auth: auth::AuthSettings::from_config(_config)?,
agent: agent::AgentSettings::from_config(_config)?,
}; };
config.validate()?; config.validate()?;
Ok(config) Ok(config)
@@ -50,6 +53,7 @@ impl FromConfig for ProgramSettings {
database: database::DatabaseSettings::mock(), database: database::DatabaseSettings::mock(),
server: server::ServerSettings::mock(), server: server::ServerSettings::mock(),
auth: auth::AuthSettings::mock(), auth: auth::AuthSettings::mock(),
agent: agent::AgentSettings::mock(),
} }
} }
} }

View File

@@ -0,0 +1,58 @@
use config::Config;
use tracing::error;
use crate::configs::key::AGENT_SOCK_PATH_KEY;
use super::FromConfig;
#[derive(Debug, Clone)]
pub struct AgentSettings {
pub socket_path: String,
}
impl FromConfig for AgentSettings {
fn from_config(_config: &Config) -> Result<Self, String> {
Ok(AgentSettings {
socket_path: _config.get_string(AGENT_SOCK_PATH_KEY).map_err(|err| {
format!(
"Failed to get {} from configuration. Err: {}",
AGENT_SOCK_PATH_KEY, err
)
})?,
})
}
fn validate(&self) -> Result<(), String> {
// ensure socket_path exists and is readable and writable
if !std::path::Path::new(&self.socket_path).exists() {
let msg = format!("Agent socket path '{}' does not exist", self.socket_path);
error!("{}", msg);
return Err(msg);
}
if std::path::Path::new(&self.socket_path)
.metadata()
.map(|meta| {
let permissions = meta.permissions();
// Check read and write permissions for the owner
!permissions.readonly()
})
.unwrap_or(false)
{
Ok(())
} else {
let msg = format!(
"Agent socket path '{}' is not readable/writable",
self.socket_path
);
error!("{}", msg);
Err(msg)
}
}
#[cfg(test)]
fn mock() -> Self {
AgentSettings {
socket_path: "/tmp/agent.sock".to_string(),
}
}
}

View File

@@ -14,3 +14,5 @@ pub(crate) const DATABASE_MIGRATE_ON_STARTUP_KEY: &str = "DATABASE.MIGRATION.MIG
pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET"; pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET";
pub(crate) const AUTH_DEFAULT_ADMIN_USERNAME_KEY: &str = "AUTH.DEFAULT_ADMIN_USERNAME"; pub(crate) const AUTH_DEFAULT_ADMIN_USERNAME_KEY: &str = "AUTH.DEFAULT_ADMIN_USERNAME";
pub(crate) const AUTH_DEFAULT_ADMIN_PASSWORD_KEY: &str = "AUTH.DEFAULT_ADMIN_PASSWORD"; pub(crate) const AUTH_DEFAULT_ADMIN_PASSWORD_KEY: &str = "AUTH.DEFAULT_ADMIN_PASSWORD";
//
pub(crate) const AGENT_SOCK_PATH_KEY: &str = "AGENT.SOCK.PATH";

View File

@@ -3,7 +3,9 @@ use std::net::IpAddr;
use config::{Config, ConfigError}; use config::{Config, ConfigError};
use tracing::warn; use tracing::warn;
use crate::configs::key::{SERVER_COOKIES_SECURE_KEY, SERVER_CORS_ALLOWED_ORIGINS_KEY, SERVER_SERVE_OPENAPI_KEY}; use crate::configs::key::{
SERVER_COOKIES_SECURE_KEY, SERVER_CORS_ALLOWED_ORIGINS_KEY, SERVER_SERVE_OPENAPI_KEY,
};
use super::{ use super::{
FromConfig, FromConfig,
@@ -119,15 +121,13 @@ impl FromConfig for ServerSettings {
#[cfg(test)] #[cfg(test)]
fn mock() -> Self { fn mock() -> Self {
ServerSettings { ServerSettings {
address: "0.0.0.0".parse().unwrap(), address: "0.0.0.0".parse().expect("Failed to parse mock IP address"),
port: 8080, port: 8080,
serve_openapi: false, serve_openapi: false,
cors: CORSSettings { cors: CORSSettings {
allowed_origins: vec![], allowed_origins: vec![],
}, },
cookies: CookiesSettings { cookies: CookiesSettings { secure: true },
secure: true,
},
} }
} }
} }

View File

@@ -1 +1,2 @@
pub mod api_error;
pub mod service_error; pub mod service_error;

View File

@@ -0,0 +1,32 @@
use axum::response::IntoResponse;
use sea_orm::DbErr;
use tracing::error;
use crate::errors::service_error::ServiceError;
#[derive(Debug)]
pub enum ApiError {
ServiceError(ServiceError),
}
impl From<ServiceError> for ApiError {
fn from(err: ServiceError) -> Self {
error!("Service error occurred: {:?}", err);
ApiError::ServiceError(err)
}
}
impl From<DbErr> for ApiError {
fn from(err: DbErr) -> Self {
ServiceError::from(err).into()
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
error!("API error occurred: {:?}", self);
match self {
ApiError::ServiceError(service_error) => service_error.into_response(),
}
}
}

View File

@@ -1,3 +1,4 @@
use axum::response::IntoResponse;
use sea_orm::DbErr; use sea_orm::DbErr;
#[derive(Debug)] #[derive(Debug)]
@@ -37,3 +38,23 @@ impl From<DbErr> for ServiceError {
} }
} }
} }
impl IntoResponse for ServiceError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
ServiceError::NotFound(msg) => (axum::http::StatusCode::NOT_FOUND, msg.clone()),
ServiceError::DatabaseError(msg) => {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
ServiceError::Unauthorized(msg) => (axum::http::StatusCode::UNAUTHORIZED, msg.clone()),
ServiceError::InternalError(msg) => {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
ServiceError::BadRequest(msg) => (axum::http::StatusCode::BAD_REQUEST, msg.clone()),
};
let body = axum::Json(serde_json::json!({
"error": message,
}));
(status, body).into_response()
}
}

View File

@@ -1,2 +1,3 @@
pub mod constants; pub mod constants;
pub mod database; pub mod database;
pub mod macros;

View File

@@ -11,3 +11,16 @@ macro_rules! with_conn {
} }
}}; }};
} }
pub struct PaginationFilter {
pub page: u64,
pub per_page: u64,
}
impl PaginationFilter {
pub fn get_offset_limit(&self) -> (u64, u64) {
let offset = (self.page - 1) * self.per_page;
let limit = self.per_page;
(offset, limit)
}
}

View File

@@ -0,0 +1,9 @@
#[macro_export]
macro_rules! set_if_some {
($field:expr) => {
match $field {
Some(value) => sea_orm::ActiveValue::Set(value),
None => sea_orm::ActiveValue::NotSet,
}
};
}

View File

@@ -9,7 +9,7 @@ use axum::{
http::{HeaderValue, Method, StatusCode, Uri}, http::{HeaderValue, Method, StatusCode, Uri},
}; };
use tower::{ServiceBuilder, timeout::TimeoutLayer}; use tower::{ServiceBuilder, timeout::TimeoutLayer};
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use tracing::warn; use tracing::warn;
use crate::{configs::server::CORSSettings, routes::AppState}; use crate::{configs::server::CORSSettings, routes::AppState};
@@ -34,6 +34,7 @@ pub fn apply_root_middleware(
pub fn get_cors_layer(cors_settings: Arc<CORSSettings>) -> CorsLayer { pub fn get_cors_layer(cors_settings: Arc<CORSSettings>) -> CorsLayer {
let mut cors_layer = CorsLayer::new() let mut cors_layer = CorsLayer::new()
.allow_credentials(true) .allow_credentials(true)
.allow_methods(AllowMethods::mirror_request())
.allow_headers(AllowHeaders::mirror_request()); .allow_headers(AllowHeaders::mirror_request());
let allowed_origins = &cors_settings.allowed_origins; let allowed_origins = &cors_settings.allowed_origins;

View File

@@ -1,6 +1,34 @@
use axum::{extract::FromRequestParts, http::StatusCode};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestInfo { pub struct RequestInfo {
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
pub struct AuthenticatedRequestInfo {
pub user_id: Uuid,
}
impl<S> FromRequestParts<S> for AuthenticatedRequestInfo
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let request_info = parts
.extensions
.get::<RequestInfo>()
.ok_or(StatusCode::UNAUTHORIZED)?;
if let Some(user_id) = request_info.user_id {
Ok(AuthenticatedRequestInfo { user_id })
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
}

View File

@@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result<Response, StatusCode> {
// TODO: log unauthenticated access attempts // TODO: log unauthenticated access attempts
Err(StatusCode::UNAUTHORIZED) Err(StatusCode::UNAUTHORIZED)
} }
#[cfg(test)]
pub mod mock {
use super::*;
pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id";
pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid";
pub async fn mock_require_auth(
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let mut req = req;
let invalid_present = req
.headers()
.get(REQUEST_AUTH_USER_INVALID_HEADER)
.is_some();
let user_id_header = req.headers().get(REQUEST_AUTH_USER_ID_HEADER).cloned();
if invalid_present {
return handle_unauthenticated().await;
}
let user = req
.extensions_mut()
.get_or_insert_with(|| RequestInfo { user_id: None });
user.user_id = Some(if let Some(user_id_header) = user_id_header {
let user_id_str = user_id_header
.to_str()
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Uuid::parse_str(user_id_str).map_err(|_| StatusCode::UNAUTHORIZED)?
} else {
Uuid::new_v4()
});
Ok(next.run(req).await)
}
}

View File

@@ -12,12 +12,8 @@ use crate::{
configs::{ProgramSettings, server::CORSSettings}, configs::{ProgramSettings, server::CORSSettings},
middlewares, middlewares,
services::{ services::{
auth::{ AppService, ServiceState,
authentication::{AuthenticationService, strategies::password::PasswordStrategy}, auth::authentication::{AuthenticationService, strategies::password::PasswordStrategy},
user::UserService,
},
server_state::ServerStateStore,
settings::SettingsStore,
}, },
}; };
@@ -28,8 +24,6 @@ pub struct AppState {
pub config: Arc<ProgramSettings>, pub config: Arc<ProgramSettings>,
} }
pub type ServiceState<T> = Arc<T>;
pub struct AuthStrategy { pub struct AuthStrategy {
pub password: ServiceState<PasswordStrategy>, pub password: ServiceState<PasswordStrategy>,
} }
@@ -39,13 +33,6 @@ pub struct AuthState {
pub authentication: ServiceState<dyn AuthenticationService>, pub authentication: ServiceState<dyn AuthenticationService>,
} }
pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
pub user: ServiceState<dyn UserService>,
pub server_state: ServiceState<dyn ServerStateStore>,
}
pub fn get_root_router( pub fn get_root_router(
state: impl Into<Arc<AppState>>, state: impl Into<Arc<AppState>>,
cors_settings: Arc<CORSSettings>, cors_settings: Arc<CORSSettings>,

View File

@@ -1,5 +1,6 @@
mod auth; mod auth;
mod health; mod health;
mod helper;
mod openapi; mod openapi;
mod restricted; mod restricted;

View File

@@ -78,22 +78,12 @@ pub async fn get_health_info(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::configs::FromConfig;
use crate::{
routes::{AppState, api::health::state::HealthState},
services::{
auth::{
authentication::{
AuthenticationServiceImpl, strategies::password::PasswordStrategy,
},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
};
use super::*; use super::*;
use crate::configs::FromConfig;
use crate::routes::{AppState, api::health::state::HealthState};
use crate::services::get_app_service;
use axum::body::to_bytes; use axum::body::to_bytes;
use axum::{ use axum::{
Router, Router,
@@ -114,17 +104,10 @@ mod test {
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
database_connection: db.clone(), database_connection: db.clone(),
config: Arc::new(crate::configs::ProgramSettings::mock()), config: Arc::new(crate::configs::ProgramSettings::mock()),
service: Arc::new(crate::routes::AppService { service: Arc::new(get_app_service(
settings: Arc::new(SettingsService::new(db.clone())), &db.clone(),
auth_state: crate::routes::AuthState { &crate::configs::ProgramSettings::mock(),
strategy: crate::routes::AuthStrategy { )),
password: Arc::new(PasswordStrategy::new(db.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
},
user: Arc::new(UserServiceImpl::new(db.clone())),
server_state: Arc::new(ServerStateService::new(db.clone())),
}),
}); });
let app = Router::new() let app = Router::new()
@@ -135,13 +118,21 @@ mod test {
})); }));
let response = app let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/info")
.body(Body::empty())
.expect("Failed to build request"),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB let body = to_bytes(response.into_body(), 1024 * 1024)
let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); .await
.expect("Failed to read response body"); // Set limit to 1 MB
let health_info: HealthInfo =
serde_json::from_slice(&body).expect("Failed to deserialize response body");
assert_eq!(health_info.status, STATUS_HEALTHY); assert_eq!(health_info.status, STATUS_HEALTHY);
assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); assert_eq!(health_info.version, env!("CARGO_PKG_VERSION"));
assert!(health_info.errors.is_none()); assert!(health_info.errors.is_none());

View File

@@ -0,0 +1 @@
pub mod pagination;

View File

@@ -0,0 +1,76 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use serde::{Deserialize, Serialize};
use crate::helpers::database::PaginationFilter;
#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)]
/// Pagination parameters for API requests
pub struct Pagination {
/// Page number (1-based)
pub page: u32,
/// Items per page
pub per_page: u32,
}
impl Default for Pagination {
fn default() -> Self {
Self {
page: 1,
per_page: 20,
}
}
}
impl From<Pagination> for PaginationFilter {
fn from(pagination: Pagination) -> Self {
Self {
page: pagination.page as u64,
per_page: pagination.per_page as u64,
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
/// Pagination information included in API responses
pub struct PaginationInfo {
/// Total number of items
pub total_items: u64,
/// Total number of pages
pub total_pages: u32,
/// Current page number
pub current_page: u32,
/// Items per page
pub per_page: u32,
}
/// Extractor for pagination parameters from query string
pub struct ExtractPagination(pub Pagination);
impl<S> FromRequestParts<S> for ExtractPagination
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or("");
let pagination: Pagination = serde_urlencoded::from_str(query).unwrap_or_default();
// validation
if pagination.page == 0 {
return Err((StatusCode::BAD_REQUEST, "page must be greater than 0"));
}
if pagination.per_page < 1 || pagination.per_page > 100 {
return Err((
StatusCode::BAD_REQUEST,
"per_page must be between 1 and 100",
));
}
Ok(ExtractPagination(pagination))
}
}

View File

@@ -3,6 +3,7 @@ pub mod tag {
pub const HEALTH_TAG: &str = "Health"; pub const HEALTH_TAG: &str = "Health";
pub const AUTH_TAG: &str = "Authentication"; pub const AUTH_TAG: &str = "Authentication";
pub const USER_TAG: &str = "User"; pub const USER_TAG: &str = "User";
pub const NGINX_TAG: &str = "Nginx";
} }
#[derive(utoipa::OpenApi)] #[derive(utoipa::OpenApi)]
@@ -14,6 +15,16 @@ pub mod tag {
crate::routes::api::auth::init_admin::init_admin, crate::routes::api::auth::init_admin::init_admin,
// User management paths // User management paths
crate::routes::api::restricted::user::me::get_user_info, crate::routes::api::restricted::user::me::get_user_info,
// Nginx upstream management
crate::routes::api::restricted::nginx::upstream::create_upstream::create_upstream,
crate::routes::api::restricted::nginx::upstream::create_upstream_target::add_upstream_target,
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream_list,
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream,
crate::routes::api::restricted::nginx::upstream::get_upstream_target::get_upstream_target,
crate::routes::api::restricted::nginx::upstream::update_upstream::update_upstream,
crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target,
crate::routes::api::restricted::nginx::upstream::remove_upstream::remove_upstream,
crate::routes::api::restricted::nginx::upstream::remove_upstream_target::remove_upstream_target,
), ),
components( components(
schemas(crate::routes::api::health::info::HealthInfo), schemas(crate::routes::api::health::info::HealthInfo),
@@ -22,11 +33,25 @@ pub mod tag {
schemas(crate::routes::api::auth::init_admin::AdminInitRequest), schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
// User management schemas // User management schemas
schemas(crate::routes::api::restricted::user::me::UserInfo), schemas(crate::routes::api::restricted::user::me::UserInfo),
// Nginx upstream schemas
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream::CreateUpstreamRequestBody),
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream_target::CreateUpstreamTargetInfo),
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream::GetUpstreamParams),
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream_target::GetUpstreamTargetsParams),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfo),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamListResponse),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse),
schemas(crate::routes::api::restricted::nginx::upstream::update_upstream::UpdateUpstreamRequestBody),
schemas(crate::routes::api::restricted::nginx::upstream::update_upstream_target::UpdateUpstreamTargetRequestBody),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse),
), ),
tags( tags(
(name = tag::HEALTH_TAG, description = "Health information API"), (name = tag::HEALTH_TAG, description = "Health information API"),
(name = tag::AUTH_TAG, description = "Authentication API"), (name = tag::AUTH_TAG, description = "Authentication API"),
(name = tag::USER_TAG, description = "User management API") (name = tag::USER_TAG, description = "User management API"),
(name = tag::NGINX_TAG, description = "Nginx management API")
) )
)] )]
pub struct ApiDoc; pub struct ApiDoc;

View File

@@ -1,3 +1,4 @@
pub mod nginx;
pub mod user; pub mod user;
use std::sync::Arc; use std::sync::Arc;
@@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
pub fn get_restricted_router(state: Arc<AppState>) -> Router { pub fn get_restricted_router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.nest("/user", user::get_user_router(state.clone())) .nest("/user", user::get_user_router(state.clone()))
.nest("/nginx", nginx::get_nginx_router(state.clone()))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
state.clone(), state.clone(),
require_auth, require_auth,

View File

@@ -0,0 +1,11 @@
pub mod upstream;
use std::sync::Arc;
use axum::Router;
use crate::routes::AppState;
pub fn get_nginx_router(state: Arc<AppState>) -> Router {
Router::new().merge(upstream::get_upstream_router(state.clone()))
}

View File

@@ -0,0 +1,43 @@
pub mod create_upstream;
pub mod create_upstream_target;
pub mod get_upstream;
pub mod get_upstream_target;
pub mod info;
pub mod remove_upstream;
pub mod remove_upstream_target;
pub mod update_upstream;
pub mod update_upstream_target;
use std::sync::Arc;
use axum::{
Router,
routing::{get, post},
};
use crate::routes::AppState;
pub fn get_upstream_router(state: Arc<AppState>) -> Router {
Router::new()
.route(
"/upstreams",
get(get_upstream::get_upstream_list).post(create_upstream::create_upstream),
)
.route(
"/upstreams/{upstream_id}",
get(get_upstream::get_upstream)
.patch(update_upstream::update_upstream)
.delete(remove_upstream::remove_upstream),
)
.route(
"/upstreams/{upstream_id}/targets",
post(create_upstream_target::add_upstream_target),
)
.route(
"/upstream_targets/{upstream_target_id}",
get(get_upstream_target::get_upstream_target)
.patch(update_upstream_target::update_upstream_target)
.delete(remove_upstream_target::remove_upstream_target),
)
.with_state(state)
}

View File

@@ -0,0 +1,367 @@
use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamInfoResponse,
},
},
services::nginx::info::upstream::UpstreamCreateInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct UpstreamTargetInfo {
pub host: String,
pub port: i64,
pub weight: Option<i64>,
pub is_backup: Option<bool>,
pub enabled: Option<bool>,
}
pub struct ConcreteUpstreamTargetInfo {
pub host: String,
pub port: i64,
pub weight: i64,
pub is_backup: bool,
pub enabled: bool,
}
impl From<UpstreamTargetInfo> for ConcreteUpstreamTargetInfo {
fn from(info: UpstreamTargetInfo) -> Self {
Self {
host: info.host,
port: info.port,
weight: info.weight.unwrap_or(1),
is_backup: info.is_backup.unwrap_or(false),
enabled: info.enabled.unwrap_or(true),
}
}
}
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct CreateUpstreamRequestBody {
pub name: String,
pub protocol: String,
pub algorithm: Option<String>,
pub sticky_session: Option<bool>,
pub upstream_targets: Vec<UpstreamTargetInfo>,
}
struct ConcreteCreateUpstreamRequestBody {
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub upstream_targets: Vec<ConcreteUpstreamTargetInfo>,
}
impl From<CreateUpstreamRequestBody> for ConcreteCreateUpstreamRequestBody {
fn from(payload: CreateUpstreamRequestBody) -> Self {
Self {
name: payload.name,
protocol: payload.protocol,
algorithm: payload
.algorithm
.unwrap_or_else(|| "round_robin".to_string()),
sticky_session: payload.sticky_session.unwrap_or(false),
upstream_targets: payload
.upstream_targets
.into_iter()
.map(|target| target.into())
.collect(),
}
}
}
#[axum::debug_handler]
#[utoipa::path(
post,
path = "/api/nginx/upstreams",
request_body = CreateUpstreamRequestBody,
responses(
(status = 200, description = "Upstream created successfully", body = UpstreamInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn create_upstream(
request_info: AuthenticatedRequestInfo,
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateUpstreamRequestBody>,
) -> AxumResult<Json<UpstreamInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let concrete_payload: ConcreteCreateUpstreamRequestBody = payload.into();
let create_info = UpstreamCreateInfo {
name: concrete_payload.name,
protocol: concrete_payload.protocol,
algorithm: concrete_payload.algorithm,
sticky_session: concrete_payload.sticky_session,
created_by: Some(request_info.user_id),
upstream_targets: concrete_payload
.upstream_targets
.into_iter()
.map(
|target| crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo {
target_host: target.host,
target_port: target.port,
weight: target.weight,
is_backup: target.is_backup,
enabled: target.enabled,
upstream_id: uuid::Uuid::nil(), // Placeholder, will be set in service
},
)
.collect(),
};
let mut tx = state.database_connection.begin().await?;
let upstream_info = upstream_service
.create_upstream(create_info, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(upstream_info.into()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::{
create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget},
get_upstream_router,
},
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_create_upstream_succeeds_returns_created() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// service will likely perform an insert and then query to return created models
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![up_model.clone()]])
.append_query_results(vec![vec![target_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
// `find_with_related` returns rows of `(upstream, Option<target>)` which
// the mock DB expects as `(Model, Option<Model>)` per row.
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamRequestBody {
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: None,
sticky_session: None,
upstream_targets: vec![ReqTarget {
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
}],
};
let res = server.post("/upstreams").json(&payload).await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, up_id);
assert_eq!(body.name, "new_upstream");
assert_eq!(body.protocol, "http");
assert_eq!(body.upstream_targets.len(), 1);
assert_eq!(body.upstream_targets[0].id, target_id);
}
#[tokio::test]
async fn handler_create_upstream_invalid_payload_returns_bad_request() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
// missing required fields -> send empty object
let res = server.post("/upstreams").json(&serde_json::json!({})).await;
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn handler_create_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// configure mock agent to error on apply
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![up_model.clone()]])
.append_query_results(vec![vec![target_model.clone()]])
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamRequestBody {
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: None,
sticky_session: None,
upstream_targets: vec![ReqTarget {
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
}],
};
let res = server.post("/upstreams").json(&payload).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_create_upstream_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamRequestBody {
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: None,
sticky_session: None,
upstream_targets: vec![ReqTarget {
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
}],
};
let res = server
.post("/upstreams")
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,310 @@
use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse,
},
},
services::nginx::info::upstream_target::UpstreamTargetCreateInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct CreateUpstreamTargetInfo {
pub upstream_id: uuid::Uuid,
pub host: String,
pub port: i64,
pub weight: Option<i64>,
pub is_backup: Option<bool>,
pub enabled: Option<bool>,
}
pub struct ConcreteCreateUpstreamTargetInfo {
pub upstream_id: uuid::Uuid,
pub host: String,
pub port: i64,
pub weight: i64,
pub is_backup: bool,
pub enabled: bool,
}
impl From<CreateUpstreamTargetInfo> for ConcreteCreateUpstreamTargetInfo {
fn from(info: CreateUpstreamTargetInfo) -> Self {
Self {
upstream_id: info.upstream_id,
host: info.host,
port: info.port,
weight: info.weight.unwrap_or(1),
is_backup: info.is_backup.unwrap_or(false),
enabled: info.enabled.unwrap_or(true),
}
}
}
#[axum::debug_handler]
#[utoipa::path(
post,
path = "/api/nginx/upstreams/{upstream_id}/targets",
request_body = CreateUpstreamTargetInfo,
responses(
(status = 200, description = "Upstream target created successfully", body = UpstreamTargetInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn add_upstream_target(
_request_info: AuthenticatedRequestInfo,
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateUpstreamTargetInfo>,
) -> AxumResult<Json<UpstreamTargetInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let concrete_payload: ConcreteCreateUpstreamTargetInfo = payload.into();
let create_info = UpstreamTargetCreateInfo {
weight: concrete_payload.weight,
is_backup: concrete_payload.is_backup,
enabled: concrete_payload.enabled,
target_host: concrete_payload.host,
target_port: concrete_payload.port,
upstream_id: concrete_payload.upstream_id,
};
let mut tx = state.database_connection.begin().await?;
let upstream_info = upstream_service
.create_upstream_target(create_info, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(upstream_info.into()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::{
create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router,
},
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_add_upstream_target_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: up_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// configure mock agent to return an error on apply
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![target_model.clone()]])
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamTargetInfo {
upstream_id: up_id,
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
};
let res = server
.post(&format!("/upstreams/{}/targets", up_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_add_upstream_target_succeeds_returns_created() {
let up_id = uuid::Uuid::new_v4();
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: up_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![target_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamTargetInfo {
upstream_id: up_id,
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
};
let res = server
.post(&format!("/upstreams/{}/targets", up_id))
.json(&payload)
.await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, target_id);
assert_eq!(body.host, "127.0.0.1");
assert_eq!(body.port, 8080);
assert_eq!(body.upstream_id, up_id);
}
#[tokio::test]
async fn handler_add_upstream_target_invalid_payload_returns_bad_request() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.post(&format!("/upstreams/{}/targets", uuid::Uuid::new_v4()))
.json(&serde_json::json!({}))
.await;
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn handler_add_upstream_target_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamTargetInfo {
upstream_id: uuid::Uuid::new_v4(),
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
};
let res = server
.post(&format!("/upstreams/{}/targets", payload.upstream_id))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,343 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
response::Result as AxumResult,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::{api_error::ApiError, service_error::ServiceError},
routes::{
AppState,
api::{
helper::pagination::{ExtractPagination, PaginationInfo},
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::{
UpstreamInfoResponse, UpstreamListResponse,
},
},
},
services::nginx::upstream::GetUpstreamOptions,
};
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct GetUpstreamParams {
pub include_targets: Option<bool>,
}
pub struct ConcreteGetUpstreamParams {
pub include_targets: bool,
}
impl From<GetUpstreamParams> for ConcreteGetUpstreamParams {
fn from(params: GetUpstreamParams) -> Self {
Self {
include_targets: params.include_targets.unwrap_or(false),
}
}
}
#[utoipa::path(
get,
path = "/api/nginx/upstreams",
responses(
(status = 200, description = "List upstreams", body = UpstreamListResponse),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream_list(
ExtractPagination(pagination): ExtractPagination,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<UpstreamListResponse>, ServiceError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let (upstreams_res, upstream_count_res) = tokio::join!(
upstream_service.get_upstreams(
Some(pagination.clone().into()),
Some(GetUpstreamOptions {
include_targets: true,
filter_by_enabled: false,
}),
None,
),
upstream_service.get_total_upstreams(None, None),
);
let upstreams = upstreams_res?;
let upstream_count = upstream_count_res?;
//
Ok(Json(UpstreamListResponse {
items: upstreams.into_iter().map(|u| u.into()).collect(),
pagination: PaginationInfo {
total_items: upstream_count,
total_pages: if upstream_count == 0 {
0
} else {
(upstream_count as f32 / pagination.per_page as f32).ceil() as u32
},
current_page: pagination.page,
per_page: pagination.per_page,
},
}))
}
#[utoipa::path(
get,
path = "/api/nginx/upstreams/{upstream_id}",
responses(
(status = 200, description = "Get upstream info", body = UpstreamInfoResponse),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream(
Path(upstream_id): Path<Uuid>,
Query(params): Query<GetUpstreamParams>,
State(_state): State<Arc<AppState>>,
) -> AxumResult<Json<UpstreamInfoResponse>, ApiError> {
let concrete_params: ConcreteGetUpstreamParams = params.into();
let upstream_service = &_state.service.nginx.get_upstream_service();
let upstream_info = if concrete_params.include_targets {
upstream_service
.get_upstream(
upstream_id,
Some(GetUpstreamOptions {
include_targets: true,
filter_by_enabled: false,
}),
None,
)
.await?
} else {
upstream_service
.get_upstream(upstream_id, None, None)
.await?
};
//
Ok(Json(upstream_info.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::BTreeMap, sync::Arc};
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
use database::generated::entities::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
routes::api::restricted::nginx::upstream::{
get_upstream_router, info::response::UpstreamInfoResponse,
},
services::get_app_service,
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state)
}
#[tokio::test]
async fn handler_get_upstream_list_returns_list() {
let u1 = upstream::Model {
id: uuid::Uuid::new_v4(),
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let u2 = upstream::Model {
id: uuid::Uuid::new_v4(),
name: "u2".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![
(u1.clone(), None::<upstream_target::Model>),
(u2.clone(), None::<upstream_target::Model>),
]])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(2)),
)])]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.get("/upstreams").await;
res.assert_status_ok();
let body = res.json::<UpstreamListResponse>();
assert_eq!(body.items.len(), 2);
assert_eq!(body.pagination.current_page, 1u32);
assert_eq!(body.pagination.total_pages, 1u32);
}
#[tokio::test]
async fn handler_get_upstream_with_targets_returns_targets() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "with_targets".to_string(),
protocol: "http".to_string(),
algorithm: "least_conn".to_string(),
sticky_session: true,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// find_by_id -> returns upstream model
.append_query_results(vec![vec![up_model.clone()]])
// find targets -> returns the target(s)
.append_query_results(vec![vec![target_model.clone()]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let url = format!("/upstreams/{}?include_targets=true", up_id);
let res = server.get(&url).await;
res.assert_status_ok();
let body = res.json::<UpstreamInfoResponse>();
assert_eq!(body.id, up_id);
assert_eq!(body.upstream_targets.len(), 1);
assert_eq!(body.upstream_targets[0].target_host, "127.0.0.1");
}
#[tokio::test]
async fn extractor_pagination_validation_rejects_bad_values() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(0)),
)])]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
// page = 0 should be rejected
let res = server.get("/upstreams?page=0&per_page=10").await;
res.assert_status(StatusCode::BAD_REQUEST);
// per_page out of range should be rejected
let res = server.get("/upstreams?page=1&per_page=0").await;
res.assert_status(StatusCode::BAD_REQUEST);
// valid values accepted
let res = server.get("/upstreams?page=2&per_page=5").await;
res.assert_status_ok();
let body = res.json::<UpstreamListResponse>();
assert_eq!(body.pagination.current_page, 2u32);
assert_eq!(body.pagination.per_page, 5u32);
}
#[tokio::test]
async fn handler_get_upstream_not_found_returns_service_error() {
let up_id = uuid::Uuid::new_v4();
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let url = format!("/upstreams/{}?include_targets=false", up_id);
let res = server.get(&url).await;
res.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn handler_get_upstream_without_targets_returns_info() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "simple_up".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// find_by_id -> returns upstream model
.append_query_results(vec![vec![up_model.clone()]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
// include_targets omitted -> should not include targets
let url = format!("/upstreams/{}", up_id);
let res = server.get(&url).await;
res.assert_status_ok();
let body = res.json::<UpstreamInfoResponse>();
assert_eq!(body.id, up_id);
assert!(body.upstream_targets.is_empty());
}
#[tokio::test]
async fn handler_get_upstream_list_empty_returns_empty_items() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(0)),
)])]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.get("/upstreams?page=3&per_page=10").await;
res.assert_status_ok();
let body = res.json::<UpstreamListResponse>();
assert_eq!(body.items.len(), 0);
assert_eq!(body.pagination.current_page, 3u32);
assert_eq!(body.pagination.per_page, 10u32);
}
}

View File

@@ -0,0 +1,193 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
response::Result as AxumResult,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamTargetInfo,
},
},
};
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct GetUpstreamTargetsParams {
pub include_upstream: Option<bool>,
}
pub struct ConcreteGetUpstreamTargetsParams {
pub include_upstream: bool,
}
impl From<GetUpstreamTargetsParams> for ConcreteGetUpstreamTargetsParams {
fn from(params: GetUpstreamTargetsParams) -> Self {
Self {
include_upstream: params.include_upstream.unwrap_or(false),
}
}
}
#[utoipa::path(
get,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
responses(
(status = 200, description = "Get upstream target info", body = UpstreamTargetInfo),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream_target(
Path(upstream_target_id): Path<Uuid>,
Query(params): Query<GetUpstreamTargetsParams>,
State(_state): State<Arc<AppState>>,
) -> AxumResult<Json<UpstreamTargetInfo>, ApiError> {
let concrete_params: ConcreteGetUpstreamTargetsParams = params.into();
let upstream_service = &_state.service.nginx.get_upstream_service();
let upstream_target_info = upstream_service
.get_upstream_target(
upstream_target_id,
if concrete_params.include_upstream {
Some(crate::services::nginx::upstream::GetUpstreamTargetOptions {
include_upstream: true,
})
} else {
None
},
None,
)
.await?;
Ok(Json(upstream_target_info.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use crate::configs::{FromConfig, ProgramSettings};
use crate::routes::api::restricted::nginx::upstream::get_upstream_router;
use crate::services::get_app_service;
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state)
}
#[tokio::test]
async fn handler_get_upstream_target_with_upstream_returns_upstream() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "with_targets".to_string(),
protocol: "http".to_string(),
algorithm: "least_conn".to_string(),
sticky_session: true,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// query returns joined (upstream_target, upstream)
.append_query_results(vec![vec![(target_model.clone(), Some(up_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let url = format!("/upstream_targets/{}?include_upstream=true", target_id);
let res = server.get(&url).await;
res.assert_status_ok();
let text = res.text();
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.upstream_id, up_id);
assert!(body.upstream.is_some());
let upstream = body.upstream.expect("upstream to be present");
assert_eq!(upstream.id, up_id);
assert_eq!(upstream.name, "with_targets");
}
#[tokio::test]
async fn handler_get_upstream_target_without_upstream_returns_info() {
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "10.0.0.1".to_string(),
target_port: 9090,
weight: 5,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![target_model.clone()]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let url = format!("/upstream_targets/{}", target_id);
let res = server.get(&url).await;
res.assert_status_ok();
let text = res.text();
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, target_id);
assert!(body.upstream.is_none());
}
#[tokio::test]
async fn handler_get_upstream_target_not_found_returns_service_error() {
let target_id = uuid::Uuid::new_v4();
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let url = format!("/upstream_targets/{}?include_upstream=false", target_id);
let res = server.get(&url).await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1 @@
pub mod response;

View File

@@ -0,0 +1,232 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::routes::api::helper::pagination::PaginationInfo;
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamTargetInfo {
pub id: uuid::Uuid,
pub target_host: String,
pub target_port: i64,
pub enabled: bool,
pub is_backup: bool,
pub weight: i32,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_id: Uuid,
pub upstream: Option<UpstreamBasicInfo>,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamBasicInfo {
pub id: Uuid,
pub name: String,
pub protocol: String,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
for UpstreamTargetInfo
{
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
Self {
id: info.id,
target_host: info.target_host,
target_port: info.target_port,
enabled: info.enabled,
is_backup: info.is_backup,
weight: info.weight as i32,
//
created_at: info.created_at,
updated_at: info.updated_at,
//
upstream_id: info.upstream_id,
upstream: info.upstream.map(|u| UpstreamBasicInfo {
id: u.id,
name: u.name,
protocol: u.protocol,
created_at: u.created_at,
updated_at: u.updated_at,
}),
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamTargetBasicInfo {
pub id: uuid::Uuid,
pub target_host: String,
pub target_port: i64,
pub enabled: bool,
pub is_backup: bool,
pub weight: i32,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
for UpstreamTargetBasicInfo
{
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
Self {
id: info.id,
target_host: info.target_host,
target_port: info.target_port,
enabled: info.enabled,
is_backup: info.is_backup,
weight: info.weight as i32,
//
created_at: info.created_at,
updated_at: info.updated_at,
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamInfoResponse {
pub id: uuid::Uuid,
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<uuid::Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_targets: Vec<UpstreamTargetBasicInfo>,
}
impl From<crate::services::nginx::info::upstream::UpstreamInfo> for UpstreamInfoResponse {
fn from(info: crate::services::nginx::info::upstream::UpstreamInfo) -> Self {
Self {
id: info.id,
name: info.name,
protocol: info.protocol,
algorithm: info.algorithm,
sticky_session: info.sticky_session,
created_by: info.created_by,
created_at: info.created_at,
updated_at: info.updated_at,
upstream_targets: info
.upstream_targets
.into_iter()
.map(|t| t.into())
.collect(),
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamListResponse {
pub items: Vec<UpstreamInfoResponse>,
pub pagination: PaginationInfo,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpstreamTargetInfoResponse {
pub id: uuid::Uuid,
pub host: String,
pub port: i64,
pub enabled: bool,
pub is_backup: bool,
pub weight: i32,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_id: Uuid,
}
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
for UpstreamTargetInfoResponse
{
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
Self {
id: info.id,
host: info.target_host,
port: info.target_port,
enabled: info.enabled,
is_backup: info.is_backup,
weight: info.weight as i32,
//
created_at: info.created_at,
updated_at: info.updated_at,
//
upstream_id: info.upstream_id,
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateUpstreamInfoResponse {
pub id: uuid::Uuid,
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<uuid::Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_targets: Vec<UpstreamTargetBasicInfo>,
}
impl From<crate::services::nginx::info::upstream::UpstreamInfo> for UpdateUpstreamInfoResponse {
fn from(info: crate::services::nginx::info::upstream::UpstreamInfo) -> Self {
Self {
id: info.id,
name: info.name,
protocol: info.protocol,
algorithm: info.algorithm,
sticky_session: info.sticky_session,
created_by: info.created_by,
created_at: info.created_at,
updated_at: info.updated_at,
upstream_targets: info
.upstream_targets
.into_iter()
.map(|t| t.into())
.collect(),
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateUpstreamTargetInfoResponse {
pub id: uuid::Uuid,
pub host: String,
pub port: i64,
pub enabled: bool,
pub is_backup: bool,
pub weight: i32,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_id: Uuid,
}
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
for UpdateUpstreamTargetInfoResponse
{
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
Self {
id: info.id,
host: info.target_host,
port: info.target_port,
enabled: info.enabled,
is_backup: info.is_backup,
weight: info.weight as i32,
//
created_at: info.created_at,
updated_at: info.updated_at,
upstream_id: info.upstream_id,
}
}
}

View File

@@ -0,0 +1,238 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::NGINX_TAG},
};
#[utoipa::path(
delete,
path = "/api/nginx/upstreams/{upstream_id}",
responses(
(status = 200, description = "Upstream removed successfully", body = ()),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn remove_upstream(
_request_info: AuthenticatedRequestInfo,
Path(upstream_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let mut tx = state.database_connection.begin().await?;
upstream_service
.delete_upstream(upstream_id, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult};
use database::generated::entities::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_upstream_succeeds_returns_ok() {
let up_id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id: up_id,
name: "todelete".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstreams/{}", up_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id: up_id,
name: "todelete".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
])
.append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstreams/{}", up_id)).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_remove_upstream_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.delete(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_remove_upstream_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::Model>::new()];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(empty_results)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.delete(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,230 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::NGINX_TAG},
};
#[utoipa::path(
delete,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
responses(
(status = 200, description = "Upstream target removed successfully", body = ()),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn remove_upstream_target(
_request_info: AuthenticatedRequestInfo,
Path(upstream_target_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let mut tx = state.database_connection.begin().await?;
upstream_service
.delete_upstream_target(upstream_target_id, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult};
use database::generated::entities::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_upstream_target_succeeds_returns_ok() {
let ut_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: ut_id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// first find_by_id, then delete (delete typically doesn't return models)
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_exec_results(vec![MockExecResult {
rows_affected: 1,
last_insert_id: 0,
}])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_upstream_target_agent_error_returns_internal() {
let ut_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: ut_id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_exec_results(vec![MockExecResult {
rows_affected: 1,
last_insert_id: 0,
}])
.append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_remove_upstream_target_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_remove_upstream_target_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream_target::Model>> =
vec![Vec::<upstream_target::Model>::new()];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(empty_results)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,318 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState, api::openapi::tag::NGINX_TAG,
api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse,
},
services::nginx::info::upstream::UpdateUpstreamInfo,
};
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpstreamTargetBasicUpdateInfo {
pub id: i64,
pub enabled: bool,
}
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpdateUpstreamRequestBody {
pub name: Option<String>,
pub protocol: Option<String>,
pub algorithm: Option<String>,
pub sticky_session: Option<bool>,
// only updates upstream targets' enabled status for now
pub upstream_targets: Option<Vec<UpstreamTargetBasicUpdateInfo>>,
}
impl From<UpdateUpstreamRequestBody> for UpdateUpstreamInfo {
fn from(val: UpdateUpstreamRequestBody) -> Self {
Self {
name: val.name,
protocol: val.protocol,
algorithm: val.algorithm,
sticky_session: val.sticky_session,
//
upstream_targets: None,
}
}
}
#[utoipa::path(
patch,
path = "/api/nginx/upstreams/{upstream_id}",
request_body = UpdateUpstreamRequestBody,
responses(
(status = 200, description = "Upstream updated successfully", body = UpdateUpstreamInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn update_upstream(
_request_info: AuthenticatedRequestInfo,
Path(upstream_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateUpstreamRequestBody>,
) -> AxumResult<Json<UpdateUpstreamInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let update_info: UpdateUpstreamInfo = payload.into();
let mut tx = state.database_connection.begin().await?;
let r = upstream_service
.update_upstream(upstream_id, update_info, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(r.into()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use super::UpdateUpstreamRequestBody;
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_update_upstream_succeeds_returns_ok() {
let up_id = uuid::Uuid::new_v4();
let current_model = upstream::Model {
id: up_id,
name: "old_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream::Model {
id: up_id,
name: "updated_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// first find_by_id, then update returns updated model
let up_model = current_model.clone();
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(
up_model.clone(),
Option::<upstream_target::Model>::None,
)]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", up_id))
.json(&payload)
.await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, up_id);
assert_eq!(body.name, "updated_upstream");
}
#[tokio::test]
async fn handler_update_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let current_model = upstream::Model {
id: up_id,
name: "old_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream::Model {
id: up_id,
name: "updated_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = current_model.clone();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(vec![vec![(
up_model.clone(),
Option::<upstream_target::Model>::None,
)]])
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", up_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_update_upstream_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_update_upstream_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::Model>::new()];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(empty_results)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
.json(&payload)
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,348 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState, api::openapi::tag::NGINX_TAG,
api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse,
},
services::nginx::info::upstream_target::UpdateUpstreamTargetInfo,
};
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpdateUpstreamTargetRequestBody {
pub host: Option<String>,
pub port: Option<i64>,
pub enabled: Option<bool>,
pub is_backup: Option<bool>,
pub weight: Option<i32>,
}
impl From<UpdateUpstreamTargetRequestBody> for UpdateUpstreamTargetInfo {
fn from(val: UpdateUpstreamTargetRequestBody) -> Self {
Self {
target_host: val.host,
target_port: val.port,
enabled: val.enabled,
is_backup: val.is_backup,
weight: val.weight.map(|w| w as i64),
}
}
}
#[utoipa::path(
patch,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
request_body = UpdateUpstreamTargetRequestBody,
responses(
(status = 200, description = "Upstream target updated successfully", body = UpdateUpstreamTargetInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn update_upstream_target(
_request_info: AuthenticatedRequestInfo,
Path(upstream_target_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateUpstreamTargetRequestBody>,
) -> AxumResult<Json<UpdateUpstreamTargetInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let update_info: UpdateUpstreamTargetInfo = payload.into();
let mut tx = state.database_connection.begin().await?;
let r = upstream_service
.update_upstream_target(upstream_target_id, update_info, Some(&mut tx))
.await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(r.into()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum::routing::patch;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use super::UpdateUpstreamTargetRequestBody;
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
axum::Router::new()
.route(
"/upstream_targets/{upstream_target_id}",
patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
)
.with_state(state)
.layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_update_upstream_target_succeeds_returns_ok() {
let target_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: target_id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream_target::Model {
id: target_id,
upstream_id: current_model.upstream_id,
target_host: "127.0.0.1".to_string(),
target_port: 8081,
weight: 2,
is_backup: false,
enabled: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
// additional query result for regenerate_and_apply_config -> generate_config
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(third)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", target_id))
.json(&payload)
.await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, target_id);
assert_eq!(body.port, 8081);
assert!(!body.enabled);
}
#[tokio::test]
async fn handler_update_upstream_target_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_update_upstream_target_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream_target::Model>> =
vec![Vec::<upstream_target::Model>::new()];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(empty_results)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.json(&payload)
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn handler_update_upstream_target_agent_error_returns_internal() {
let target_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: target_id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream_target::Model {
id: target_id,
upstream_id: current_model.upstream_id,
target_host: "127.0.0.1".to_string(),
target_port: 8081,
weight: 2,
is_backup: false,
enabled: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(third)
.into_connection();
let program_settings = ProgramSettings::mock();
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
let router = axum::Router::new()
.route(
"/upstream_targets/{upstream_target_id}",
axum::routing::patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
)
.with_state(state)
.layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", target_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Extension, Json, Json,
extract::State, extract::State,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use crate::{ use crate::{
middlewares::request_info::RequestInfo, middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::USER_TAG}, routes::{AppState, api::openapi::tag::USER_TAG},
}; };
@@ -38,15 +38,9 @@ pub struct UserInfo {
)] )]
pub async fn get_user_info( pub async fn get_user_info(
State(app_state): State<Arc<AppState>>, State(app_state): State<Arc<AppState>>,
request_info: Extension<Arc<RequestInfo>>, request_info: AuthenticatedRequestInfo,
) -> Response { ) -> Response {
let user_id = match request_info.user_id { let user_id = request_info.user_id;
Some(id) => id,
None => {
error!("User ID not found in request info");
return (StatusCode::UNAUTHORIZED).into_response();
}
};
match app_state.service.user.get_user_by_id(user_id, None).await { match app_state.service.user.get_user_by_id(user_id, None).await {
Ok(user) => { Ok(user) => {

View File

@@ -1,3 +1,85 @@
pub mod agent_client;
pub mod auth; pub mod auth;
pub mod nginx;
pub mod server_state; pub mod server_state;
pub mod settings; pub mod settings;
use std::sync::Arc;
use ::agent_client::apis::configuration::Configuration;
#[cfg(test)]
use crate::services::agent_client::MockAgentService;
use crate::{
configs::ProgramSettings,
routes::{self, AuthState},
services::{
agent_client::{AgentService, AgentServiceImpl},
auth::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::{UserService, UserServiceImpl},
},
nginx::NginxService,
server_state::{ServerStateService, ServerStateStore},
settings::{SettingsService, SettingsStore},
},
};
pub type ServiceState<T> = Arc<T>;
pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
pub user: ServiceState<dyn UserService>,
pub server_state: ServiceState<dyn ServerStateStore>,
#[allow(dead_code)]
pub nginx: ServiceState<NginxService>,
#[allow(dead_code)]
pub agent_client: ServiceState<dyn AgentService>,
}
pub fn get_app_service(
db_connection: &Arc<sea_orm::DatabaseConnection>,
settings: &ProgramSettings,
) -> AppService {
AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
nginx: Arc::new(NginxService::new(db_connection.clone())),
agent_client: Arc::new(AgentServiceImpl::new(Configuration::from(
settings.agent.clone(),
))),
}
}
#[cfg(test)]
pub fn get_mock_app_service(
db_connection: &Arc<sea_orm::DatabaseConnection>,
settings: &ProgramSettings,
mock_agent: Arc<MockAgentService>,
) -> AppService {
AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
nginx: Arc::new(NginxService::new(db_connection.clone())),
agent_client: mock_agent,
}
}

View File

@@ -0,0 +1,263 @@
use std::{os::unix::fs::FileTypeExt, sync::Arc};
use agent_client::{
apis::{
Api, ApiClient, Error as ApiError, ResponseContent,
configuration::Configuration,
nginx_agent_api::{ValidateAndReloadParams, ValidateParams, WriteConfigParams},
},
models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody},
};
use tracing::{error, warn};
use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError};
#[derive(Debug)]
pub enum AgentError {
// (internal messages, user-facing messages)
#[allow(dead_code)]
ValidationFailed(String, String),
// (internal messages, user-facing messages)
ApplicationFailed(String, String),
}
impl From<AgentError> for ServiceError {
fn from(err: AgentError) -> Self {
error!("Agent error occurred: {:?}", err);
match err {
AgentError::ValidationFailed(_internal, user) => ServiceError::InternalError(user),
AgentError::ApplicationFailed(_internal, user) => ServiceError::InternalError(user),
}
}
}
impl<T: std::fmt::Debug> From<ResponseContent<T>> for AgentError {
fn from(err: ResponseContent<T>) -> Self {
let ResponseContent {
status,
content,
entity,
} = err;
{
let entity_str = entity
.map(|e| format!("{:?}", e))
.unwrap_or_else(|| "<empty>".to_string());
AgentError::ApplicationFailed(
format!(
"Agent responded with error status {}: {}, entity: {}",
status, content, entity_str
),
"Agent reported an error during operation.".to_string(),
)
}
}
}
impl<T: std::fmt::Debug> From<ApiError<T>> for AgentError {
fn from(err: ApiError<T>) -> Self {
match err {
ApiError::ResponseError(resp) => AgentError::from(resp),
ApiError::Io(err) => AgentError::ApplicationFailed(
format!("IO error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
ApiError::Reqwest(err) => AgentError::ApplicationFailed(
format!("Reqwest error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
ApiError::Serde(err) => AgentError::ApplicationFailed(
format!("Serialization error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
}
}
}
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait AgentService: Send + Sync {
#[allow(dead_code)]
fn get_client(&self) -> Arc<ApiClient>;
// TODO: improve error handling and reporting, error reasons
// validate configurations that has been created/updated before the given timestamp
#[allow(dead_code)]
async fn validate(&self, config: &str) -> Result<(), AgentError>;
// validate and apply configurations that has been created/updated before the given timestamp
async fn apply(&self, config: &str) -> Result<(), AgentError>;
}
pub struct AgentServiceImpl {
client: Arc<ApiClient>,
}
impl AgentServiceImpl {
pub fn new(config: impl Into<Arc<Configuration>>) -> Self {
let client = ApiClient::new(config.into());
AgentServiceImpl {
client: Arc::new(client),
}
}
}
impl From<AgentSettings> for Configuration {
fn from(settings: AgentSettings) -> Self {
let mut config = Configuration::default();
let mut builder = reqwest::Client::builder();
let url = settings.socket_path;
// check if the url is a unix socket path
let is_socket = std::fs::metadata(&url).is_ok_and(|m| m.file_type().is_socket());
if is_socket || url.starts_with("unix://") {
builder = builder.unix_socket(url.to_string());
config.client = builder.build().expect("Failed to build reqwest client");
} else {
warn!("AgentSettings contains a non-unix socket path: {}", url);
config.base_path = url;
}
config
}
}
#[async_trait::async_trait]
impl AgentService for AgentServiceImpl {
fn get_client(&self) -> Arc<ApiClient> {
Arc::clone(&self.client)
}
async fn validate(&self, config: &str) -> Result<(), AgentError> {
let timestamp = chrono::Utc::now().timestamp_millis();
let name = Self::get_config_name(true);
self._validate(&name, timestamp, config).await
}
async fn apply(&self, config: &str) -> Result<(), AgentError> {
let timestamp = chrono::Utc::now().timestamp_millis();
let name = Self::get_config_name(false);
self._validate(&name, timestamp, config).await?;
self._apply(&name, timestamp).await
}
}
impl AgentServiceImpl {
fn get_config_name(is_validate_only: bool) -> String {
format!(
"nginx_config_{}{}",
if is_validate_only {
"validation_"
} else {
"application_"
},
uuid::Uuid::new_v4()
)
}
async fn _validate(&self, name: &str, timestamp: i64, config: &str) -> Result<(), AgentError> {
let api = self.client.nginx_agent_api();
api.write_config(WriteConfigParams {
write_config_body: WriteConfigBody {
config_name: name.to_string(),
content: config.to_string(),
timestamp,
},
})
.await?;
api.validate(ValidateParams {
validate_body: ValidateBody {
config_name: name.to_string(),
timestamp,
},
})
.await?;
Ok(())
}
async fn _apply(&self, name: &str, timestamp: i64) -> Result<(), AgentError> {
let api = self.client.nginx_agent_api();
api.validate_and_reload(ValidateAndReloadParams {
validate_and_reload_body: ValidateAndReloadBody {
config_name: name.to_string(),
timestamp,
},
})
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client::{
apis::{Api, nginx_agent_api::StatusSuccess},
models::StatusResp,
};
use axum::{http::StatusCode, response::Json};
use std::time::Duration;
use tempfile::tempdir;
use tokio::time::sleep;
#[test]
fn test_agent_service_creation() {
let config = Configuration::default();
let service = AgentServiceImpl::new(config);
let client = service.get_client();
assert!(Arc::ptr_eq(&client, &service.client));
}
#[tokio::test]
async fn test_agent_socket_support() {
// create temporary socket path
let dir = tempdir().expect("Failed to create temp dir");
let socket_path = dir.path().join("agent.sock");
// create axum app with a simple /status route
let app = axum::Router::new().route(
"/status",
axum::routing::get(|| async {
let result: (StatusCode, StatusResp) = (StatusCode::OK, StatusResp { ok: true });
(result.0, Json(result.1))
}),
);
// bind tokio unix listener and serve in background
let listener =
tokio::net::UnixListener::bind(&socket_path).expect("Failed to bind to unix socket");
let server_fut = axum::serve::serve(listener, app);
let _srv = tokio::spawn(async move {
let _ = server_fut.await;
});
// give server a moment to start
sleep(Duration::from_millis(50)).await;
let client: ApiClient = ApiClient::new(Arc::new(Configuration {
base_path: "http://localhost".to_string(),
client: reqwest::Client::builder()
.unix_socket(socket_path.clone())
.build()
.expect("Failed to build reqwest client"),
..Default::default()
}));
let res = client
.nginx_agent_api()
.status()
.await
.expect("Failed to get status");
let body = res.entity.expect("Response entity is missing");
assert!(res.status.is_success());
if let StatusSuccess::Status200(body) = body {
assert!(body.ok);
} else {
panic!("Unexpected response body");
}
}
}

View File

@@ -197,14 +197,17 @@ mod tests {
let (token, _) = service let (token, _) = service
.generate_jwt(user_id, 60) .generate_jwt(user_id, 60)
.await .await
.expect("generate jwt"); .expect("Failed to generate jwt");
let valid = service let valid = service
.is_valid_jwt(&token, None) .is_valid_jwt(&token, None)
.await .await
.expect("validate jwt"); .expect("Failed to validate jwt");
assert!(valid.is_some(), "Generated token should be valid"); assert!(valid.is_some(), "Generated token should be valid");
let claims = service.parse_jwt(&token).await.expect("parse jwt"); let claims = service
.parse_jwt(&token)
.await
.expect("Failed to parse jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
} }
@@ -213,10 +216,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let other_sub = Uuid::new_v4().to_string(); let other_sub = Uuid::new_v4().to_string();
let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap(); let valid = service
.is_valid_jwt(&token, Some(other_sub))
.await
.expect("jwt is not valid");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be invalid for a different subject" "Token should be invalid for a different subject"
@@ -236,10 +245,19 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
let new_token = service.refresh_jwt(&token, 120).await.unwrap(); .generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let new_token = service
.refresh_jwt(&token, 120)
.await
.expect("Failed to refresh jwt");
let claims = service.parse_jwt(&new_token).await.unwrap(); let claims = service
.parse_jwt(&new_token)
.await
.expect("Failed to parse refreshed jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.exp - claims.iat, 120); assert_eq!(claims.exp - claims.iat, 120);
} }
@@ -249,10 +267,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, claims) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, claims) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
let valid = service.is_valid_jwt(&token, None).await.unwrap(); let valid = service
.is_valid_jwt(&token, None)
.await
.expect("Failed to validate jwt");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be expired and thus invalid. Current time: {:?}. Diff: {}", "Token should be expired and thus invalid. Current time: {:?}. Diff: {}",
@@ -266,9 +290,15 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
service.invalidate_jwt(&token).await.unwrap(); service
.invalidate_jwt(&token)
.await
.expect("Failed to invalidate jwt");
// ensure entry is present // ensure entry is present
{ {

View File

@@ -236,7 +236,7 @@ mod test {
"CorrectPassword".as_bytes(), "CorrectPassword".as_bytes(),
&SaltString::generate(&mut OsRng), &SaltString::generate(&mut OsRng),
) )
.unwrap() .expect("Failed to hash password")
.to_string(); .to_string();
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![vec![user::Model { .append_query_results(vec![vec![user::Model {
@@ -281,7 +281,7 @@ mod test {
"CorrectPassword".as_bytes(), "CorrectPassword".as_bytes(),
&SaltString::generate(&mut OsRng), &SaltString::generate(&mut OsRng),
) )
.unwrap() .expect("Failed to hash password")
.to_string(); .to_string();
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![vec![user::Model { .append_query_results(vec![vec![user::Model {

View File

@@ -0,0 +1,86 @@
pub mod builder;
pub mod info;
pub mod traits;
pub mod upstream;
use std::sync::Arc;
use sea_orm::{DatabaseConnection, DatabaseTransaction};
use crate::{
errors::service_error::ServiceError,
services::{
agent_client::AgentService,
nginx::{
builder::{NginxConfigBuilder, NginxConfigProvider},
upstream::{UpstreamService, UpstreamServiceImpl},
},
},
};
pub struct NginxService {
#[allow(dead_code)]
connection: Arc<DatabaseConnection>,
//
upstream_service: Arc<dyn UpstreamService>,
}
impl NginxService {
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
Self {
connection: connection.clone(),
//
upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())),
}
}
pub fn get_upstream_service(&self) -> Arc<dyn UpstreamService> {
self.upstream_service.clone()
}
#[allow(dead_code)]
pub async fn validate_config(
&self,
agent: Arc<dyn AgentService>,
config: &str,
) -> Result<(), ServiceError> {
agent.validate(config).await?;
Ok(())
}
pub async fn apply_changes(
&self,
agent: Arc<dyn AgentService>,
config: &str,
) -> Result<(), ServiceError> {
agent.apply(config).await?;
Ok(())
}
pub async fn generate_config(
&self,
tx: Option<&mut DatabaseTransaction>,
) -> Result<String, ServiceError> {
let mut builder = NginxConfigBuilder::default();
self.upstream_service
.generate_config(&mut builder, tx)
.await?;
Ok(builder.to_nginx_config(None))
}
pub async fn regenerate_and_apply_config(
&self,
agent: Arc<dyn AgentService>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
let config = self.generate_config(tx).await?;
self.apply_changes(agent, &config).await?;
Ok(())
}
}

View File

@@ -0,0 +1,43 @@
use crate::services::nginx::info::upstream::UpstreamInfo;
pub const INDENT_SIZE: usize = 2;
pub trait NginxConfigProvider {
fn to_nginx_config(&self, indent: Option<usize>) -> String;
}
#[derive(Default)]
pub struct NginxConfigBuilder {
upstreams: Vec<UpstreamInfo>,
}
impl NginxConfigBuilder {
pub fn add_upstream(&mut self, upstream: UpstreamInfo) {
self.upstreams.push(upstream);
}
pub fn add_upstreams(&mut self, upstreams: Vec<UpstreamInfo>) {
for upstream in upstreams {
self.add_upstream(upstream);
}
}
}
impl NginxConfigProvider for NginxConfigBuilder {
fn to_nginx_config(&self, indent: Option<usize>) -> String {
let mut config = format!(
"# Nginx Config Generated by YANPM at {}",
chrono::Utc::now()
);
for upstream in &self.upstreams {
config.push('\n');
config.push_str(&upstream.to_nginx_config(indent));
}
// TODO: Add other sections like servers, locations, etc.
// trailing newline for file ending
config.push('\n');
config
}
}

View File

@@ -0,0 +1,2 @@
pub mod upstream;
pub mod upstream_target;

View File

@@ -0,0 +1,219 @@
use chrono::{DateTime, Utc};
use database::generated::entities::{upstream, upstream_target};
use sea_orm::ActiveValue::{Set, Unchanged};
use tracing::warn;
use uuid::Uuid;
use crate::{
services::nginx::{
builder::{INDENT_SIZE, NginxConfigProvider},
info::upstream_target as upstream_target_info,
traits::indentable::Indentable,
},
set_if_some,
};
const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target";
#[derive(Clone)]
pub struct UpstreamInfo {
pub id: Uuid,
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_targets: Vec<upstream_target_info::UpstreamTargetInfo>,
}
pub struct UpstreamCreateInfo {
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<Uuid>,
//
pub upstream_targets: Vec<upstream_target_info::UpstreamTargetCreateInfo>,
}
#[derive(Clone)]
pub struct UpdateUpstreamInfo {
pub name: Option<String>,
pub protocol: Option<String>,
pub algorithm: Option<String>,
pub sticky_session: Option<bool>,
//
pub upstream_targets: Option<Vec<(Uuid, bool)>>,
}
impl NginxConfigProvider for UpstreamInfo {
fn to_nginx_config(&self, indent: Option<usize>) -> String {
let targets_config: Vec<String> = self
.upstream_targets
.iter()
.map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE)))
.collect();
let mut targets_config_str = {
let config_str = match self.algorithm.as_str() {
"least-conn" => "least_conn",
"ip-hash" => "ip_hash",
"round-robin" => "",
v => {
// TODO: allow arbitrary algorithms via config extensions/plugins
warn!(
"Unknown upstream algorithm '{}', defaulting to 'round-robin'",
v
);
""
}
}
.to_string();
// TODO: add support for sticky session / checking for nginx sticky module existence
// if self.sticky_session {
// config_str.push_str("sticky")
// }
if config_str.trim().is_empty() {
String::new()
} else {
config_str + ";"
}
}
.indent(indent.unwrap_or(0) + INDENT_SIZE * 2);
targets_config_str.push('\n');
targets_config_str.push_str(
&(if targets_config.is_empty() {
// add placeholder if no targets
PLACEHOLDER_TARGET.to_string()
} else {
// normal targets
targets_config.join("\n")
}
.indent(indent.unwrap_or(0) + INDENT_SIZE)),
);
// add placeholder if all targets are backup
if self.upstream_targets.iter().all(|v| v.is_backup) {
targets_config_str.push('\n');
targets_config_str
.push_str(&PLACEHOLDER_TARGET.indent(indent.unwrap_or(0) + INDENT_SIZE));
}
format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0))
}
}
impl From<UpstreamCreateInfo> for (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
fn from(val: UpstreamCreateInfo) -> (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
let upstream_uuid = Uuid::new_v4();
let upstream = upstream::ActiveModel {
id: sea_orm::ActiveValue::Set(upstream_uuid),
name: sea_orm::ActiveValue::Set(val.name),
protocol: sea_orm::ActiveValue::Set(val.protocol),
algorithm: sea_orm::ActiveValue::Set(val.algorithm),
sticky_session: sea_orm::ActiveValue::Set(val.sticky_session),
created_by: sea_orm::ActiveValue::Set(val.created_by),
created_at: sea_orm::ActiveValue::Set(chrono::Utc::now()),
updated_at: sea_orm::ActiveValue::Set(chrono::Utc::now()),
};
let upstream_targets = val
.upstream_targets
.into_iter()
.map(|target| {
let mut active_model: upstream_target::ActiveModel = target.into();
active_model.upstream_id = sea_orm::ActiveValue::Set(upstream_uuid);
active_model
})
.collect();
(upstream, upstream_targets)
}
}
impl From<upstream::Model> for UpstreamInfo {
fn from(model: upstream::Model) -> Self {
Self {
id: model.id,
name: model.name,
protocol: model.protocol,
algorithm: model.algorithm,
sticky_session: model.sticky_session,
created_by: model.created_by,
created_at: model.created_at,
updated_at: model.updated_at,
upstream_targets: Vec::new(),
}
}
}
impl From<(upstream::Model, Option<Vec<upstream_target::Model>>)> for UpstreamInfo {
fn from(data: (upstream::Model, Option<Vec<upstream_target::Model>>)) -> Self {
let (upstream_model, upstream_target_models) = data;
if let Some(targets) = upstream_target_models {
UpstreamInfo::from((upstream_model, targets))
} else {
UpstreamInfo::from(upstream_model)
}
}
}
impl From<(upstream::Model, Vec<upstream_target::Model>)> for UpstreamInfo {
fn from(data: (upstream::Model, Vec<upstream_target::Model>)) -> Self {
let (upstream_model, upstream_target_models) = data;
Self {
id: upstream_model.id,
name: upstream_model.name,
protocol: upstream_model.protocol,
algorithm: upstream_model.algorithm,
sticky_session: upstream_model.sticky_session,
created_by: upstream_model.created_by,
created_at: upstream_model.created_at,
updated_at: upstream_model.updated_at,
upstream_targets: upstream_target_models
.into_iter()
.map(upstream_target_info::UpstreamTargetInfo::from)
.collect(),
}
}
}
impl From<UpstreamInfo> for (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
fn from(val: UpstreamInfo) -> Self {
(
upstream::ActiveModel {
id: sea_orm::ActiveValue::Set(val.id),
name: sea_orm::ActiveValue::Set(val.name),
protocol: sea_orm::ActiveValue::Set(val.protocol),
algorithm: sea_orm::ActiveValue::Set(val.algorithm),
sticky_session: sea_orm::ActiveValue::Set(val.sticky_session),
created_by: sea_orm::ActiveValue::Set(val.created_by),
created_at: sea_orm::ActiveValue::Set(val.created_at),
updated_at: sea_orm::ActiveValue::Set(val.updated_at),
},
val.upstream_targets
.into_iter()
.map(|target| target.into())
.collect(),
)
}
}
impl UpdateUpstreamInfo {
pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel {
upstream::ActiveModel {
id: Unchanged(current_model.id),
name: set_if_some!(self.name),
protocol: set_if_some!(self.protocol),
algorithm: set_if_some!(self.algorithm),
sticky_session: set_if_some!(self.sticky_session),
created_by: Unchanged(current_model.created_by),
created_at: Unchanged(current_model.created_at),
updated_at: Set(chrono::Utc::now()),
}
}
}

View File

@@ -0,0 +1,161 @@
use chrono::{DateTime, Utc};
use sea_orm::ActiveValue::{Set, Unchanged};
use uuid::Uuid;
use database::generated::entities::{upstream, upstream_target};
use crate::{
services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable},
set_if_some,
};
#[derive(Clone)]
pub struct UpstreamTargetInfo {
pub id: uuid::Uuid,
pub target_host: String,
pub target_port: i64,
pub weight: i64,
pub is_backup: bool,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_id: uuid::Uuid,
pub upstream: Option<UpstreamBasicInfo>,
}
#[derive(Clone)]
pub struct UpdateUpstreamTargetInfo {
pub target_host: Option<String>,
pub target_port: Option<i64>,
pub weight: Option<i64>,
pub is_backup: Option<bool>,
pub enabled: Option<bool>,
}
#[derive(Clone)]
pub struct UpstreamBasicInfo {
pub id: uuid::Uuid,
pub name: String,
pub protocol: String,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct UpstreamTargetCreateInfo {
pub target_host: String,
pub target_port: i64,
pub weight: i64,
pub is_backup: bool,
pub enabled: bool,
//
pub upstream_id: uuid::Uuid,
}
impl From<upstream_target::Model> for UpstreamTargetInfo {
fn from(model: upstream_target::Model) -> Self {
Self {
id: model.id,
target_host: model.target_host,
target_port: model.target_port,
weight: model.weight,
is_backup: model.is_backup,
enabled: model.enabled,
created_at: model.created_at,
updated_at: model.updated_at,
upstream_id: model.upstream_id,
upstream: None,
}
}
}
impl From<(upstream_target::Model, upstream::Model)> for UpstreamTargetInfo {
fn from(data: (upstream_target::Model, upstream::Model)) -> Self {
let (target_model, up_model) = data;
Self {
id: target_model.id,
target_host: target_model.target_host,
target_port: target_model.target_port,
weight: target_model.weight,
is_backup: target_model.is_backup,
enabled: target_model.enabled,
created_at: target_model.created_at,
updated_at: target_model.updated_at,
upstream_id: target_model.upstream_id,
upstream: Some(UpstreamBasicInfo {
id: up_model.id,
name: up_model.name,
protocol: up_model.protocol,
created_at: up_model.created_at,
updated_at: up_model.updated_at,
}),
}
}
}
impl From<UpstreamTargetInfo> for upstream_target::ActiveModel {
fn from(val: UpstreamTargetInfo) -> Self {
upstream_target::ActiveModel {
id: Set(val.id),
target_host: Set(val.target_host),
target_port: Set(val.target_port),
weight: Set(val.weight),
is_backup: Set(val.is_backup),
enabled: Set(val.enabled),
created_at: Set(val.created_at),
updated_at: Set(val.updated_at),
upstream_id: Set(val.upstream_id),
}
}
}
impl From<UpstreamTargetCreateInfo> for upstream_target::ActiveModel {
fn from(val: UpstreamTargetCreateInfo) -> Self {
upstream_target::ActiveModel {
id: Set(Uuid::new_v4()),
target_host: Set(val.target_host),
target_port: Set(val.target_port),
weight: Set(val.weight),
is_backup: Set(val.is_backup),
enabled: Set(val.enabled),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
upstream_id: Set(val.upstream_id),
}
}
}
impl NginxConfigProvider for UpstreamTargetInfo {
fn to_nginx_config(&self, indent: Option<usize>) -> String {
format!(
"server {}:{} weight={}{}{};",
self.target_host,
self.target_port,
self.weight,
if self.is_backup { " backup" } else { "" },
if !self.enabled { " down" } else { "" },
)
.indent(indent.unwrap_or(0))
}
}
impl UpdateUpstreamTargetInfo {
pub fn apply_to_model(
self,
current_model: upstream_target::Model,
) -> upstream_target::ActiveModel {
upstream_target::ActiveModel {
id: Unchanged(current_model.id),
target_host: set_if_some!(self.target_host),
target_port: set_if_some!(self.target_port),
weight: set_if_some!(self.weight),
is_backup: set_if_some!(self.is_backup),
enabled: set_if_some!(self.enabled),
created_at: Unchanged(current_model.created_at),
updated_at: Set(chrono::Utc::now()),
upstream_id: Unchanged(current_model.upstream_id),
}
}
}

View File

@@ -0,0 +1 @@
pub mod indentable;

View File

@@ -0,0 +1,31 @@
pub trait Indentable<T> {
fn indent(&self, spaces: T) -> String;
}
impl Indentable<usize> for &str {
fn indent(&self, spaces: usize) -> String {
let indent_str = " ".repeat(spaces);
self.lines()
.map(|line| format!("{}{}", indent_str, line))
.collect::<Vec<String>>()
.join("\n")
}
}
impl Indentable<Option<usize>> for String {
fn indent(&self, spaces: Option<usize>) -> String {
self.as_str().indent(spaces.unwrap_or(0))
}
}
impl Indentable<usize> for String {
fn indent(&self, spaces: usize) -> String {
self.as_str().indent(spaces)
}
}
impl Indentable<Option<usize>> for &str {
fn indent(&self, spaces: Option<usize>) -> String {
self.indent(spaces.unwrap_or(0))
}
}

View File

@@ -0,0 +1,881 @@
use std::sync::Arc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait,
FromQueryResult, ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait,
};
use database::generated::entities::{upstream, upstream_target};
use crate::{
errors::service_error::ServiceError,
helpers::database::PaginationFilter,
services::nginx::{
builder::NginxConfigBuilder,
info::{
upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo},
upstream_target::{
UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo,
},
},
},
with_conn,
};
#[async_trait::async_trait]
pub trait UpstreamService: Send + Sync {
async fn create_upstream(
&self,
create_info: UpstreamCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn get_total_upstreams(
&self,
options: Option<UpstreamTotalCountOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError>;
async fn get_upstream(
&self,
upstream_id: uuid::Uuid,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn get_upstreams(
&self,
pagination: Option<PaginationFilter>,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamInfo>, ServiceError>;
async fn update_upstream(
&self,
id: uuid::Uuid,
upstream: UpdateUpstreamInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn delete_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
async fn create_upstream_target(
&self,
create_info: UpstreamTargetCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
async fn get_upstream_target(
&self,
target_id: uuid::Uuid,
options: Option<GetUpstreamTargetOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
#[allow(dead_code)]
async fn get_upstream_targets_by_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamTargetInfo>, ServiceError>;
async fn update_upstream_target(
&self,
id: uuid::Uuid,
target: UpdateUpstreamTargetInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
async fn delete_upstream_target(
&self,
target_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
async fn generate_config(
&self,
builder: &mut NginxConfigBuilder,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
}
pub struct UpstreamServiceImpl {
connection: Arc<DatabaseConnection>,
}
#[derive(Default)]
pub struct GetUpstreamOptions {
pub include_targets: bool,
pub filter_by_enabled: bool,
}
#[allow(dead_code)]
pub struct UpstreamTotalCountOptions {}
#[derive(Default)]
pub struct GetUpstreamTargetOptions {
pub include_upstream: bool,
}
impl UpstreamServiceImpl {
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
Self { connection }
}
}
#[async_trait::async_trait]
impl UpstreamService for UpstreamServiceImpl {
async fn create_upstream(
&self,
create_info: UpstreamCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError> {
let (upstream_model, upstream_target_models): (
upstream::ActiveModel,
Vec<upstream_target::ActiveModel>,
) = create_info.into();
// If a transaction was provided use it, otherwise create and own one here.
let mut maybe_owned_tx: Option<DatabaseTransaction> = None;
let tx_ref: Option<&mut DatabaseTransaction> = if let Some(tx) = tx {
Some(tx)
} else {
maybe_owned_tx = Some(self.connection.begin().await?);
maybe_owned_tx.as_mut()
};
let r = with_conn!(&*self.connection, tx_ref, conn, {
let created_upstream = upstream_model.insert(*conn).await?;
let created_targets = upstream_target::Entity::insert_many(
upstream_target_models
.into_iter()
.map(|mut model| {
model.upstream_id = sea_orm::ActiveValue::Set(created_upstream.id);
model
})
.collect::<Vec<upstream_target::ActiveModel>>(),
)
.exec_with_returning(*conn)
.await?;
(created_upstream, created_targets)
});
// Commit only if we created the transaction here (we own it).
if let Some(t) = maybe_owned_tx.take() {
t.commit().await?;
}
Ok(r.into())
}
async fn get_total_upstreams(
&self,
_options: Option<UpstreamTotalCountOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError> {
#[derive(Debug, FromQueryResult)]
struct CountResult {
// The field name must match the column alias in the query
count: i64,
}
let count_info = with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find()
.select_only()
.column_as(upstream::Column::Id, "count")
.into_model::<CountResult>()
.one(*conn)
.await?
});
Ok(count_info.map_or(0, |c| c.count) as u64)
}
async fn get_upstream(
&self,
upstream_id: uuid::Uuid,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError> {
let concrete_options = options.unwrap_or_default();
let info: UpstreamInfo = if concrete_options.include_targets {
let (up_model, targets) = with_conn!(&*self.connection, tx, conn, {
let up = upstream::Entity::find_by_id(upstream_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream with id {} not found",
upstream_id
)))?;
let targets = upstream_target::Entity::find()
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
.apply_if(Some(concrete_options.filter_by_enabled), |query, _v| {
query.filter(upstream_target::Column::Enabled.eq(true))
})
.all(*conn)
.await?;
(up, targets)
});
(up_model, targets).into()
} else {
with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find_by_id(upstream_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream with id {} not found",
upstream_id
)))?
})
.into()
};
Ok(info)
}
async fn get_upstreams(
&self,
pagination: Option<PaginationFilter>,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamInfo>, ServiceError> {
let r = with_conn!(&*self.connection, tx, conn, {
let find_query = upstream::Entity::find();
let find_query = if let Some(pagination) = pagination {
let (offset, limit) = pagination.get_offset_limit();
find_query.offset(offset).limit(limit)
} else {
find_query
};
let find_query = match options {
Some(opts) => {
if opts.include_targets && opts.filter_by_enabled {
find_query.filter(
upstream_target::Column::Enabled
.eq(true)
.or(upstream_target::Column::Id.is_null()),
)
} else {
find_query
}
}
_ => find_query,
};
find_query
.find_with_related(upstream_target::Entity)
.all(*conn)
.await?
});
Ok(r.into_iter().map(|m| m.into()).collect())
}
async fn update_upstream(
&self,
id: uuid::Uuid,
upstream: UpdateUpstreamInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError> {
// If a transaction was provided use it, otherwise create and own one here.
let mut maybe_owned_tx: Option<DatabaseTransaction> = None;
let tx_ref: Option<&mut DatabaseTransaction> = if let Some(tx) = tx {
Some(tx)
} else {
maybe_owned_tx = Some(self.connection.begin().await?);
maybe_owned_tx.as_mut()
};
let current_model = with_conn!(&*self.connection, tx_ref, conn, {
upstream::Entity::find_by_id(id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream with id {} not found",
id
)))?
});
let upstream_active_model = upstream.clone().apply_to_model(current_model);
let r = with_conn!(&*self.connection, tx_ref, conn, {
let updated_upstream_model = upstream_active_model.update(*conn).await?;
// update upstream targets if any
if let Some(targets) = upstream.upstream_targets {
for (target_id, enabled) in targets.into_iter() {
let target_model = upstream_target::Entity::find_by_id(target_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
target_id
)))?;
let mut target_active_model: upstream_target::ActiveModel = target_model.into();
target_active_model.enabled = sea_orm::ActiveValue::Set(enabled);
target_active_model.update(*conn).await?;
Ok::<(), ServiceError>(())?;
}
}
updated_upstream_model
});
// Commit
if let Some(t) = maybe_owned_tx.take() {
t.commit().await?;
}
Ok(r.into())
}
async fn delete_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
let model = with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find_by_id(upstream_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream with id {} not found",
upstream_id
)))?
});
with_conn!(&*self.connection, tx, conn, {
// delete all targets belonging to the upstream
upstream_target::Entity::delete_many()
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
.exec(*conn)
.await?;
model.delete(*conn).await?;
Ok(())
})
}
//
//
async fn create_upstream_target(
&self,
create_info: UpstreamTargetCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError> {
let model: upstream_target::ActiveModel = create_info.into();
let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? });
Ok(r.into())
}
async fn get_upstream_target(
&self,
target_id: uuid::Uuid,
options: Option<GetUpstreamTargetOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError> {
let concrete_options = options.unwrap_or_default();
let info: UpstreamTargetInfo = if concrete_options.include_upstream {
match with_conn!(&*self.connection, tx, conn, {
upstream_target::Entity::find_by_id(target_id)
.find_also_related(upstream::Entity)
.one(*conn)
.await?
}) {
Some((target_model, Some(upstream_model))) => (target_model, upstream_model).into(),
Some((_target_model, None)) => {
return Err(ServiceError::InternalError(format!(
"Inconsistent data: Upstream target with id {} has no associated upstream",
target_id
)));
}
None => {
return Err(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
target_id
)));
}
}
} else {
with_conn!(&*self.connection, tx, conn, {
upstream_target::Entity::find_by_id(target_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
target_id
)))?
})
.into()
};
Ok(info)
}
async fn get_upstream_targets_by_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamTargetInfo>, ServiceError> {
let r = with_conn!(&*self.connection, tx, conn, {
upstream_target::Entity::find()
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
.all(*conn)
.await?
});
Ok(r.into_iter().map(|m| m.into()).collect())
}
async fn update_upstream_target(
&self,
id: uuid::Uuid,
target: UpdateUpstreamTargetInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError> {
let current_model = with_conn!(&*self.connection, tx, conn, {
upstream_target::Entity::find_by_id(id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
id
)))?
});
let active_model = target.apply_to_model(current_model);
let r = with_conn!(&*self.connection, tx, conn, {
active_model.update(*conn).await?
});
Ok(r.into())
}
async fn delete_upstream_target(
&self,
target_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
let model = with_conn!(&*self.connection, tx, conn, {
upstream_target::Entity::find_by_id(target_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
target_id
)))?
});
with_conn!(&*self.connection, tx, conn, {
model.delete(*conn).await?;
Ok(())
})
}
async fn generate_config(
&self,
builder: &mut NginxConfigBuilder,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
// get all upstreams and their targets
let upstreams = with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find()
.find_with_related(upstream_target::Entity)
.all(*conn)
.await?
});
let upstreams_info = upstreams
.into_iter()
.map(|(up_model, target_models)| (up_model, target_models).into())
.collect::<Vec<UpstreamInfo>>();
builder.add_upstreams(upstreams_info);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use sea_orm::MockExecResult;
use sea_orm::{DatabaseBackend, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
#[tokio::test]
async fn create_upstream_returns_info() {
let up_model = upstream::Model {
id: uuid::Uuid::new_v4(),
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![up_model.clone()]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo {
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
upstream_targets: Vec::new(),
};
let res = svc.create_upstream(create_info, None).await;
assert!(res.is_ok());
let info = res.expect("Failed to create upstream");
assert_eq!(info.name, "test_upstream");
}
#[tokio::test]
async fn get_upstream_with_targets_returns_targets() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "with_targets".to_string(),
protocol: "http".to_string(),
algorithm: "least_conn".to_string(),
sticky_session: true,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// find_by_id -> returns upstream model
.append_query_results(vec![vec![up_model.clone()]])
// find targets -> returns the target(s)
.append_query_results(vec![vec![target_model.clone()]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc
.get_upstream(
up_id,
Some(GetUpstreamOptions {
include_targets: true,
filter_by_enabled: false,
}),
None,
)
.await;
assert!(res.is_ok());
let info = res.expect("Failed to get upstream with targets");
assert_eq!(info.id, up_id);
assert_eq!(info.upstream_targets.len(), 1);
assert_eq!(info.upstream_targets[0].target_host, "127.0.0.1");
}
#[tokio::test]
async fn get_upstream_not_found_returns_not_found() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await;
assert!(matches!(res, Err(ServiceError::NotFound(_))));
}
#[tokio::test]
async fn get_upstreams_returns_list() {
let u1 = upstream::Model {
id: uuid::Uuid::new_v4(),
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let u2 = upstream::Model {
id: uuid::Uuid::new_v4(),
name: "u2".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![
(u1.clone(), None::<upstream_target::Model>),
(u2.clone(), None::<upstream_target::Model>),
]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstreams(None, None, None).await;
assert!(res.is_ok());
let list = res.expect("Failed to get upstreams");
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn get_upstream_targets_by_upstream_returns_targets() {
let up_id = uuid::Uuid::new_v4();
let t = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "10.0.0.1".to_string(),
target_port: 80,
weight: 10,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![t.clone()]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstream_targets_by_upstream(up_id, None).await;
assert!(res.is_ok());
let targets = res.expect("Failed to get upstream targets");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].target_host, "10.0.0.1");
}
#[tokio::test]
async fn update_upstream_success() {
let id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id,
name: "old".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated = upstream::Model {
id,
name: "new".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: existing.created_at,
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]]) // find_by_id
.append_query_results(vec![vec![updated.clone()]]) // update result
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo {
name: None,
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = svc.update_upstream(id, update_info, None).await;
assert!(res.is_ok());
let got = res.expect("Failed to update upstream");
assert_eq!(got.name, "new");
}
#[tokio::test]
async fn update_upstream_not_found() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc
.update_upstream(
uuid::Uuid::new_v4(),
crate::services::nginx::info::upstream::UpdateUpstreamInfo {
name: None,
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
},
None,
)
.await;
assert!(matches!(res, Err(ServiceError::NotFound(_))));
}
#[tokio::test]
async fn delete_upstream_success() {
let id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id,
name: "todelete".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream(id, None).await;
assert!(res.is_ok());
}
#[tokio::test]
async fn delete_upstream_not_found() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await;
assert!(matches!(res, Err(ServiceError::NotFound(_))));
}
#[tokio::test]
async fn create_upstream_target_success() {
let id = uuid::Uuid::new_v4();
let upstream_id = uuid::Uuid::new_v4();
let created = upstream_target::Model {
id,
upstream_id,
target_host: "1.2.3.4".to_string(),
target_port: 8080,
weight: 5,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![created.clone()]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo {
target_host: "1.2.3.4".to_string(),
target_port: 8080,
weight: 5,
is_backup: false,
enabled: true,
upstream_id,
};
let res = svc.create_upstream_target(create_info, None).await;
assert!(res.is_ok());
let t = res.expect("Failed to create target");
assert_eq!(t.target_host, "1.2.3.4");
}
#[tokio::test]
async fn update_upstream_target_success() {
let id = uuid::Uuid::new_v4();
let existing = upstream_target::Model {
id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "old".to_string(),
target_port: 80,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated = upstream_target::Model {
id,
upstream_id: existing.upstream_id,
target_host: "new".to_string(),
target_port: 80,
weight: 1,
is_backup: false,
enabled: true,
created_at: existing.created_at,
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_query_results(vec![vec![updated.clone()]])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo {
target_host: None,
target_port: None,
weight: None,
is_backup: None,
enabled: None,
};
let res = svc.update_upstream_target(id, update_info, None).await;
assert!(res.is_ok());
let got = res.expect("Failed to update target");
assert_eq!(got.target_host, "new");
}
#[tokio::test]
async fn delete_upstream_target_success() {
let id = uuid::Uuid::new_v4();
let existing = upstream_target::Model {
id,
upstream_id: uuid::Uuid::new_v4(),
target_host: "del".to_string(),
target_port: 80,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![MockExecResult {
rows_affected: 1,
last_insert_id: 0,
}])
.into_connection();
let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream_target(id, None).await;
assert!(res.is_ok());
}
}

View File

@@ -106,6 +106,357 @@
} }
} }
}, },
"/api/nginx/upstream_targets/{upstream_target_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get upstream target info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamTargetInfo"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Upstream target removed successfully",
"content": {
"application/json": {
"schema": {
"default": null
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"patch": {
"tags": [
"Nginx"
],
"operationId": "update_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamTargetRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream target updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamTargetInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream_list",
"responses": {
"200": {
"description": "List upstreams",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamListResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
},
"post": {
"tags": [
"Nginx"
],
"operationId": "create_upstream",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUpstreamRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams/{upstream_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get upstream info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Upstream removed successfully",
"content": {
"application/json": {
"schema": {
"default": null
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"patch": {
"tags": [
"Nginx"
],
"operationId": "update_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams/{upstream_id}/targets": {
"post": {
"tags": [
"Nginx"
],
"operationId": "add_upstream_target",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUpstreamTargetInfo"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream target created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamTargetInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/user/me": { "/api/user/me": {
"get": { "get": {
"tags": [ "tags": [
@@ -157,6 +508,102 @@
} }
} }
}, },
"CreateUpstreamRequestBody": {
"type": "object",
"required": [
"name",
"protocol",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": [
"boolean",
"null"
]
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetInfo"
}
}
}
},
"CreateUpstreamTargetInfo": {
"type": "object",
"required": [
"upstream_id",
"host",
"port"
],
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
},
"host": {
"type": "string"
},
"is_backup": {
"type": [
"boolean",
"null"
]
},
"port": {
"type": "integer",
"format": "int64"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": [
"integer",
"null"
],
"format": "int64"
}
}
},
"GetUpstreamParams": {
"type": "object",
"properties": {
"include_targets": {
"type": [
"boolean",
"null"
]
}
}
},
"GetUpstreamTargetsParams": {
"type": "object",
"properties": {
"include_upstream": {
"type": [
"boolean",
"null"
]
}
}
},
"HealthInfo": { "HealthInfo": {
"type": "object", "type": "object",
"description": "System health information", "description": "System health information",
@@ -212,6 +659,486 @@
} }
} }
}, },
"PaginationInfo": {
"type": "object",
"description": "Pagination information included in API responses",
"required": [
"total_items",
"total_pages",
"current_page",
"per_page"
],
"properties": {
"current_page": {
"type": "integer",
"format": "int32",
"description": "Current page number",
"minimum": 0
},
"per_page": {
"type": "integer",
"format": "int32",
"description": "Items per page",
"minimum": 0
},
"total_items": {
"type": "integer",
"format": "int64",
"description": "Total number of items",
"minimum": 0
},
"total_pages": {
"type": "integer",
"format": "int32",
"description": "Total number of pages",
"minimum": 0
}
}
},
"UpdateUpstreamInfoResponse": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"algorithm",
"sticky_session",
"created_at",
"updated_at",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicInfo"
}
}
}
},
"UpdateUpstreamRequestBody": {
"type": "object",
"properties": {
"algorithm": {
"type": [
"string",
"null"
]
},
"name": {
"type": [
"string",
"null"
]
},
"protocol": {
"type": [
"string",
"null"
]
},
"sticky_session": {
"type": [
"boolean",
"null"
]
},
"upstream_targets": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicUpdateInfo"
}
}
}
},
"UpdateUpstreamTargetInfoResponse": {
"type": "object",
"required": [
"id",
"host",
"port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"host": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpdateUpstreamTargetRequestBody": {
"type": "object",
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
},
"host": {
"type": [
"string",
"null"
]
},
"is_backup": {
"type": [
"boolean",
"null"
]
},
"port": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"weight": {
"type": [
"integer",
"null"
],
"format": "int32"
}
}
},
"UpstreamBasicInfo": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"UpstreamInfoResponse": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"algorithm",
"sticky_session",
"created_at",
"updated_at",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicInfo"
}
}
}
},
"UpstreamListResponse": {
"type": "object",
"required": [
"items",
"pagination"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationInfo"
}
}
},
"UpstreamTargetBasicInfo": {
"type": "object",
"required": [
"id",
"target_host",
"target_port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"target_host": {
"type": "string"
},
"target_port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpstreamTargetBasicUpdateInfo": {
"type": "object",
"required": [
"id",
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"id": {
"type": "integer",
"format": "int64"
}
}
},
"UpstreamTargetInfo": {
"type": "object",
"required": [
"id",
"target_host",
"target_port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"target_host": {
"type": "string"
},
"target_port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpstreamBasicInfo"
}
]
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpstreamTargetInfoResponse": {
"type": "object",
"required": [
"id",
"host",
"port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"host": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UserInfo": { "UserInfo": {
"type": "object", "type": "object",
"description": "System health information", "description": "System health information",
@@ -245,6 +1172,10 @@
{ {
"name": "User", "name": "User",
"description": "User management API" "description": "User management API"
},
{
"name": "Nginx",
"description": "Nginx management API"
} }
] ]
} }

View File

@@ -8,7 +8,7 @@ async-trait = "0.1.89"
container-simulate = { path = "../container" } container-simulate = { path = "../container" }
migration = {path = "../../public/migration"} migration = {path = "../../public/migration"}
shared = {path = "../../public/shared"} shared = {path = "../../public/shared"}
testcontainers = "0.24.0" testcontainers = "0.26.0"
tokio = { version = "1.47.0", features = ["full"] } tokio = { version = "1.47.0", features = ["full"] }
url = "2.5.7" url = "2.5.7"
clap = { version = "4.5.48", features = ["derive", "env"] } clap = { version = "4.5.48", features = ["derive", "env"] }

View File

@@ -1,7 +1,7 @@
use clap::{Arg, Command}; use clap::{Arg, Command};
use container::{ use container::containers::{
ConfigInfoType,
db::{DBInfo, sqlite::SQLiteContainer}, db::{DBInfo, sqlite::SQLiteContainer},
types::ConfigInfoType,
}; };
use migration::{generate_entity, migrate_database}; use migration::{generate_entity, migrate_database};
use shared::db_type::DBType; use shared::db_type::DBType;
@@ -54,6 +54,7 @@ fn action(
for db_config in database_configs { for db_config in database_configs {
let config = container::Config { let config = container::Config {
database: db_config, database: db_config,
agent: None,
}; };
let mut detached_handler = container::start_detached(&config).await; let mut detached_handler = container::start_detached(&config).await;
match migrate_and_generate_entity(&config, &output_path).await { match migrate_and_generate_entity(&config, &output_path).await {

View File

@@ -9,7 +9,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-trait = "0.1.89" async-trait = "0.1.89"
testcontainers = "0.24.0" testcontainers = { version = "0.26.0" }
shared = { path = "../../public/shared" } shared = { path = "../../public/shared" }
tokio = { version = "1.47.0", features = ["full"] } tokio = { version = "1.47.0", features = ["full"] }
url = "2.5.7" url = "2.5.7"

View File

@@ -0,0 +1,40 @@
pub mod agent;
pub mod db;
use std::{pin::Pin, sync::Arc};
use testcontainers::{ContainerAsync, GenericImage, TestcontainersError};
use crate::containers::{
agent::AgentContainerInfo,
db::{ContainerizedDBInfo, PreExistingDBInfo},
};
pub type UnStartedContainer =
Pin<Box<dyn Future<Output = Result<ContainerAsync<GenericImage>, TestcontainersError>> + Send>>;
pub type AgentConfigInfoType = ConfigInfoType<AgentContainerInfo, ()>;
pub type DBConfigInfoType = ConfigInfoType<ContainerizedDBInfo, PreExistingDBInfo>;
pub trait WithContainer {
fn container(&self) -> &Arc<ContainerAsync<GenericImage>>;
}
pub trait WithoutContainer {
fn on_delete(&self);
}
impl WithoutContainer for () {
fn on_delete(&self) {}
}
#[derive(Clone)]
pub enum ConfigInfoType<T, U>
where
T: WithContainer,
U: WithoutContainer,
{
Containerized(T),
PreExisting(U),
}

View File

@@ -0,0 +1,115 @@
use std::{error::Error, sync::Arc};
use testcontainers::{
ContainerAsync, GenericBuildableImage, GenericImage, ImageExt,
core::{AccessMode, BuildImageOptions, ContainerPort, Mount, WaitFor},
runners::{AsyncBuilder, AsyncRunner},
};
use crate::{WithContainer, containers::UnStartedContainer};
pub const SOCK_NAME: &str = "yanpm-agent.sock";
const SOCK_FOLDER: &str = "/var/run/yanpm";
const NGINX_CONFIG_DIR: &str = "/etc/nginx/conf.d";
#[derive(Clone)]
pub struct AgentContainerConfig {
pub image: String,
pub tag: String,
pub container_name: String,
pub dockerfile_path: String,
pub force_build: bool,
pub agent_config: AgentConfig,
pub nginx_config: NginxConfig,
}
#[derive(Clone)]
pub struct AgentContainerInfo {
pub container: Arc<ContainerAsync<GenericImage>>,
pub config: AgentContainerConfig,
}
impl WithContainer for AgentContainerInfo {
fn container(&self) -> &Arc<ContainerAsync<GenericImage>> {
&self.container
}
}
#[derive(Clone)]
pub struct AgentConfig {
pub sock_folder: String, // path to be mounted to host for unix socket
pub nginx_config_dir: String, // path to be mounted to host for nginx config files, only the agent generated folder will be mounted
pub sock_perm: u32, // permissions to set on the unix socket
pub sock_gid: String, // GID to set on the unix socket
}
#[derive(Clone)]
pub struct NginxConfig {
pub expose_http: bool,
pub expose_https: bool,
}
impl AgentContainerConfig {
pub fn new(
image: String,
tag: String,
container_name: String,
dockerfile_path: String,
force_build: bool,
// agent configs
agent_config: AgentConfig,
nginx_config: NginxConfig,
) -> Self {
AgentContainerConfig {
image,
tag,
container_name,
dockerfile_path,
force_build,
// default agent configs
agent_config,
nginx_config,
}
}
pub async fn get_unstarted_container(&self) -> Result<UnStartedContainer, Box<dyn Error>> {
let mut image = GenericBuildableImage::new(&self.image, &self.tag)
.with_dockerfile(&self.dockerfile_path)
.build_image_with(BuildImageOptions::new().with_skip_if_exists(!self.force_build))
.await?;
if self.nginx_config.expose_http {
image = image.with_exposed_port(ContainerPort::Tcp(80));
}
if self.nginx_config.expose_https {
image = image.with_exposed_port(ContainerPort::Tcp(443));
}
image = image.with_wait_for(WaitFor::message_on_either_std("Starting yanpm-daemon on"));
Ok(image
.with_container_name(self.container_name.clone())
.with_env_var("YANPM_AGENT_SOCK", format!("{}/{}", SOCK_FOLDER, SOCK_NAME))
.with_env_var("YANPM_NGINX_CONFIG_DIR", NGINX_CONFIG_DIR.to_string())
.with_env_var(
"YANPM_AGENT_SOCK_PERM",
self.agent_config.sock_perm.to_string(),
)
.with_env_var("YANPM_AGENT_SOCK_GID", self.agent_config.sock_gid.clone())
.with_mount(
Mount::bind_mount(
self.agent_config.sock_folder.clone(),
SOCK_FOLDER.to_string(),
)
.with_access_mode(AccessMode::ReadWrite),
)
.with_mount(
Mount::bind_mount(
self.agent_config.nginx_config_dir.clone(),
NGINX_CONFIG_DIR.to_string(),
)
.with_access_mode(AccessMode::ReadWrite),
)
.start())
}
}

View File

@@ -5,18 +5,15 @@ pub mod sqlite;
use async_trait::async_trait; use async_trait::async_trait;
use shared::db_type::DBType; use shared::db_type::DBType;
use std::error::Error; use std::error::Error;
use std::future::Future; use std::sync::Arc;
use std::{pin::Pin, sync::Arc};
use url::Host; use url::Host;
use testcontainers::{ContainerAsync, GenericImage, TestcontainersError}; use testcontainers::{ContainerAsync, GenericImage};
use crate::{ConfigInfoType, WithContainer, WithoutContainer}; use crate::{
WithContainer, WithoutContainer,
pub type UnStartedContainer = containers::{DBConfigInfoType, UnStartedContainer},
Pin<Box<dyn Future<Output = Result<ContainerAsync<GenericImage>, TestcontainersError>> + Send>>; };
pub type DBConfigInfoType = ConfigInfoType<ContainerizedDBInfo, PreExistingDBInfo>;
#[derive(Clone)] #[derive(Clone)]
pub struct PreExistingDBInfo { pub struct PreExistingDBInfo {

View File

@@ -9,7 +9,7 @@ pub struct OptionalContainerConfig {
} }
#[derive(Clone)] #[derive(Clone)]
pub struct ContainerConfig { pub struct DatabaseContainerConfig {
pub image: String, pub image: String,
pub tag: String, pub tag: String,
pub container_name: String, pub container_name: String,
@@ -19,8 +19,8 @@ pub struct ContainerConfig {
} }
impl OptionalContainerConfig { impl OptionalContainerConfig {
pub fn fill_with(&self, other: &ContainerConfig) -> ContainerConfig { pub fn fill_with(&self, other: &DatabaseContainerConfig) -> DatabaseContainerConfig {
ContainerConfig { DatabaseContainerConfig {
image: self.image.clone().unwrap_or_else(|| other.image.clone()), image: self.image.clone().unwrap_or_else(|| other.image.clone()),
tag: self.tag.clone().unwrap_or_else(|| other.tag.clone()), tag: self.tag.clone().unwrap_or_else(|| other.tag.clone()),
container_name: self container_name: self

View File

@@ -9,14 +9,17 @@ use testcontainers::{
use crate::{ use crate::{
ConfigInfoType, ConfigInfoType,
containers::{
UnStartedContainer,
db::{ db::{
ContainerizedDBInfo, DBConfigInfoType, DBInfo, UnStartedContainer, ContainerizedDBInfo, DBConfigInfoType, DBInfo,
config::{ContainerConfig, OptionalContainerConfig}, config::{DatabaseContainerConfig, OptionalContainerConfig},
},
}, },
}; };
pub fn get_default_config() -> ContainerConfig { pub fn get_default_config() -> DatabaseContainerConfig {
ContainerConfig { DatabaseContainerConfig {
container_name: "yanpm-postgres".to_string(), container_name: "yanpm-postgres".to_string(),
database_name: "postgres".to_string(), database_name: "postgres".to_string(),
user: "postgres".to_string(), user: "postgres".to_string(),
@@ -27,7 +30,7 @@ pub fn get_default_config() -> ContainerConfig {
} }
pub struct PostgreSQLContainer { pub struct PostgreSQLContainer {
pub config: ContainerConfig, pub config: DatabaseContainerConfig,
} }
#[async_trait] #[async_trait]
@@ -53,7 +56,7 @@ impl DBInfo<OptionalContainerConfig> for PostgreSQLContainer {
); );
ConfigInfoType::Containerized(ContainerizedDBInfo { ConfigInfoType::Containerized(ContainerizedDBInfo {
db_type: crate::db::DBType::PostgreSQL, db_type: crate::containers::db::DBType::PostgreSQL,
container: Arc::new(pg_container), container: Arc::new(pg_container),
container_name: self.config.container_name.clone(), container_name: self.config.container_name.clone(),
database_name: self.config.database_name.clone(), database_name: self.config.database_name.clone(),

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use crate::{ use crate::{
ConfigInfoType, ConfigInfoType,
db::{DBConfigInfoType, DBInfo, PreExistingDBInfo, UnStartedContainer}, containers::db::{DBConfigInfoType, DBInfo, PreExistingDBInfo, UnStartedContainer},
util::to_absolute_path, util::to_absolute_path,
}; };
@@ -69,7 +69,7 @@ impl DBInfo<OptionalContainerConfig> for SQLiteContainer {
.expect("Failed to create SQLite database file"); .expect("Failed to create SQLite database file");
// //
ConfigInfoType::PreExisting(PreExistingDBInfo { ConfigInfoType::PreExisting(PreExistingDBInfo {
db_type: crate::db::DBType::SQLite, db_type: crate::containers::db::DBType::SQLite,
url: sqlite_url, url: sqlite_url,
on_delete: { on_delete: {
let db_path = self.get_db_absolute_path(); let db_path = self.get_db_absolute_path();

View File

@@ -1,7 +1,5 @@
use std::io::Write; use std::io::Write;
use shared::db_type::DBType;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum EnvFileType { pub enum EnvFileType {
DotEnv, DotEnv,
@@ -11,25 +9,20 @@ pub enum EnvFileType {
#[derive(Clone)] #[derive(Clone)]
pub struct EnvFile { pub struct EnvFile {
pub file_type: EnvFileType, pub file_type: EnvFileType,
pub db_type: DBType,
pub db_url: String,
// //
buffer: serde_json::Value, buffer: serde_json::Value,
} }
impl EnvFile { impl EnvFile {
pub fn new(file_type: EnvFileType, db_type: DBType, db_url: String) -> Self { pub fn new(file_type: EnvFileType) -> Self {
let mut env_file = EnvFile { EnvFile {
file_type, file_type,
db_type,
db_url,
buffer: serde_json::Value::Object(serde_json::Map::new()), buffer: serde_json::Value::Object(serde_json::Map::new()),
}; }
}
env_file._write_line_buffer("DATABASE__TYPE", &env_file.db_type.to_string()); pub fn write_line(&mut self, key: &str, value: &str) {
env_file._write_line_buffer("DATABASE__URL", &env_file.db_url.to_string()); self._write_line_buffer(key, value);
env_file
} }
pub fn write(&mut self, stream: &mut dyn Write, with_prefix: bool) { pub fn write(&mut self, stream: &mut dyn Write, with_prefix: bool) {
@@ -127,12 +120,10 @@ mod tests {
#[test] #[test]
fn test_env_file_write_yaml() { fn test_env_file_write_yaml() {
let mut env_file_nested = EnvFile::new( let mut env_file_nested = EnvFile::new(EnvFileType::Yaml);
EnvFileType::Yaml,
DBType::SQLite,
"mysql://user:pass@localhost/db".to_string(),
);
env_file_nested.write_line("DATABASE__TYPE", "SQLite");
env_file_nested.write_line("DATABASE__URL", "mysql://user:pass@localhost/db");
let mut output_stream = Vec::new(); let mut output_stream = Vec::new();
env_file_nested.write(&mut output_stream, false); env_file_nested.write(&mut output_stream, false);
let output_string = String::from_utf8(output_stream).unwrap(); let output_string = String::from_utf8(output_stream).unwrap();
@@ -146,11 +137,9 @@ DATABASE:
#[test] #[test]
fn test_env_file_write_env() { fn test_env_file_write_env() {
let mut env_file_nested = EnvFile::new( let mut env_file_nested = EnvFile::new(EnvFileType::DotEnv);
EnvFileType::DotEnv, env_file_nested.write_line("DATABASE__TYPE", "PostgreSQL");
DBType::PostgreSQL, env_file_nested.write_line("DATABASE__URL", "postgres://user:pass@localhost/db");
"postgres://user:pass@localhost/db".to_string(),
);
let mut output_stream = Vec::new(); let mut output_stream = Vec::new();
env_file_nested.write(&mut output_stream, true); env_file_nested.write(&mut output_stream, true);
let output_string = String::from_utf8(output_stream).unwrap(); let output_string = String::from_utf8(output_stream).unwrap();

View File

@@ -1,11 +1,11 @@
pub mod db; pub mod containers;
mod env; mod env;
pub mod types;
mod util; mod util;
use crate::{ use crate::{
db::DBConfigInfoType, containers::{
types::{ConfigInfoType, WithContainer, WithoutContainer}, AgentConfigInfoType, ConfigInfoType, DBConfigInfoType, WithContainer, WithoutContainer,
},
util::{ util::{
await_termination_signal, remove_file_if_exists, stop_container, to_absolute_path, await_termination_signal, remove_file_if_exists, stop_container, to_absolute_path,
write_env_files, write_env_files,
@@ -15,6 +15,7 @@ use crate::{
#[derive(Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
pub database: DBConfigInfoType, pub database: DBConfigInfoType,
pub agent: Option<AgentConfigInfoType>,
} }
// relative to the pwd // relative to the pwd
@@ -56,26 +57,29 @@ impl<'a> Drop for DetachedHandle<'a> {
} }
async fn start(config: &Config) { async fn start(config: &Config) {
let db_config = &config.database;
//
// write the config files for the api server and database client // write the config files for the api server and database client
println!("Writing config files..."); println!("Writing config files...");
write_env_files(db_config); write_env_files(&config.database, &config.agent);
println!("Config files written to:"); println!("Config files written to:");
println!(" - {}", to_absolute_path(API_CONFIG_PATH).display()); println!(" - {}", to_absolute_path(API_CONFIG_PATH).display());
println!(" - {}", to_absolute_path(DB_CONFIG_PATH).display()); println!(" - {}", to_absolute_path(DB_CONFIG_PATH).display());
} }
async fn stop(config: &Config) { async fn stop(config: &Config) {
let db_config = &config.database;
// stop the container // stop the container
println!("Stopping container..."); println!("Stopping container...");
stop_container(db_config, "database".to_string()).await; println!("Stopping database container...");
stop_container(&config.database, "database".to_string()).await;
if let Some(agent) = &config.agent {
println!("Stopping agent container...");
stop_container(agent, "agent".to_string()).await;
}
println!("Container stopped.");
// remove the generated config file // remove the generated config file
println!("Removing generated config file..."); println!("Removing generated config file...");
remove_file_if_exists(DB_CONFIG_PATH); remove_file_if_exists(DB_CONFIG_PATH);
remove_file_if_exists(API_CONFIG_PATH); remove_file_if_exists(API_CONFIG_PATH);
println!("Container stopped."); println!("Generated config files removed.");
} }
pub async fn start_attached(config: &Config) { pub async fn start_attached(config: &Config) {

View File

@@ -1,8 +1,15 @@
use clap::Parser; use std::sync::Arc;
use container::Config;
use container::start_attached;
use container::db::DBInfo; use clap::Parser;
use container::{
Config,
containers::{
ConfigInfoType,
agent::{AgentConfig, AgentContainerConfig, AgentContainerInfo, NginxConfig},
db::DBInfo,
},
start_attached,
};
/// Command line arguments /// Command line arguments
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -11,17 +18,63 @@ struct Args {
/// Database type to use: 'postgres' or 'sqlite'. Can also be set with DB_TYPE env var. /// Database type to use: 'postgres' or 'sqlite'. Can also be set with DB_TYPE env var.
#[arg(long, default_value = "sqlite", env = "DB_TYPE")] #[arg(long, default_value = "sqlite", env = "DB_TYPE")]
db_type: String, db_type: String,
// agent related
/// agent image name
#[arg(long, default_value = "yanpm/agent", env = "AGENT_IMAGE_NAME")]
agent_image: String,
/// agent image tag
#[arg(long, default_value = "latest", env = "AGENT_IMAGE_TAG")]
agent_image_tag: String,
/// force build agent image
#[arg(long, default_value_t = false, env = "AGENT_FORCE_BUILD")]
agent_force_build: bool,
/// dockerfile path for building agent image
#[arg(long, env = "AGENT_DOCKERFILE_PATH", required = false)]
agent_dockerfile_path: Option<String>,
/// host's location to mount nginx config files folder generated by the agent
#[arg(long, env = "AGENT_NGINX_CONFIG_DIR", required = false)]
agent_nginx_config_dir: Option<String>,
/// host's location folder to mount the unix socket files
#[arg(long, env = "AGENT_SOCK_PATH", required = false)]
agent_sock_path: Option<String>,
/// socket permissions to set on the unix socket
#[arg(long, default_value = "660", env = "AGENT_SOCK_PERM", required = false)]
agent_sock_perm: u32,
/// socket GID to set on the unix socket
#[arg(long, default_value = "", env = "AGENT_SOCK_GID", required = false)]
agent_sock_gid: String,
/// nginx expose http port
#[arg(
long,
default_value_t = true,
env = "AGENT_NGINX_EXPOSE_HTTP",
required = false
)]
agent_nginx_expose_http: bool,
/// nginx expose https port
#[arg(
long,
default_value_t = false,
env = "AGENT_NGINX_EXPOSE_HTTPS",
required = false
)]
agent_nginx_expose_https: bool,
}
struct ParsedArgs {
db_type: String,
agent_container_config: Option<AgentContainerConfig>,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Parse command line arguments and environment variables let args = parse_args().await;
let args = Args::parse();
println!("Starting container with database type: {}", args.db_type); println!("Starting container with database type: {}", args.db_type);
let db_config = match args.db_type.to_lowercase().as_str() { let db_config = match args.db_type.to_lowercase().as_str() {
"postgres" | "pg" | "pgsql" => { "postgres" | "pg" | "pgsql" => {
use container::db::postgresql::PostgreSQLContainer; use container::containers::db::postgresql::PostgreSQLContainer;
println!("Using PostgreSQL database"); println!("Using PostgreSQL database");
PostgreSQLContainer::new(None) PostgreSQLContainer::new(None)
.await .await
@@ -30,7 +83,7 @@ async fn main() {
} }
"sqlite" | "sql" => { "sqlite" | "sql" => {
println!("Using SQLite database"); println!("Using SQLite database");
use container::db::sqlite::SQLiteContainer; use container::containers::db::sqlite::SQLiteContainer;
SQLiteContainer::new(None) SQLiteContainer::new(None)
.await .await
.get_db_container_config_info() .get_db_container_config_info()
@@ -43,11 +96,98 @@ async fn main() {
}; };
println!("Database configuration obtained."); println!("Database configuration obtained.");
let agent_container = if let Some(agent_config) = &args.agent_container_config {
println!(
"Agent container will be used with socket folder: {} and nginx config dir: {}",
agent_config.agent_config.sock_folder, agent_config.agent_config.nginx_config_dir
);
Some(agent_config.get_unstarted_container().await)
} else {
println!("No agent container configuration provided, skipping agent setup.");
None
};
let config = Config { let config = Config {
database: db_config, database: db_config,
agent: match agent_container {
Some(Ok(container)) => Some(ConfigInfoType::Containerized(AgentContainerInfo {
container: Arc::new(container.await.expect("Failed to start agent container")),
config: args.agent_container_config.expect("Invalid config state"),
})),
Some(Err(e)) => {
eprintln!("Failed to set up agent container: {}", e);
std::process::exit(1);
}
None => None,
},
}; };
println!("Starting container..."); println!("Starting container...");
start_attached(&config).await; start_attached(&config).await;
println!("Container stopped. Exiting..."); println!("Container stopped. Exiting...");
} }
async fn parse_args() -> ParsedArgs {
// Parse command line arguments and environment variables
let args = Args::parse();
// if any required args are missing, do not start agent
let dockerfile_path = match args.agent_dockerfile_path {
None => {
println!("Agent dockerfile path not provided, skipping agent setup.");
return ParsedArgs {
db_type: args.db_type,
agent_container_config: None,
};
}
Some(path) => path,
};
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let agent_config = AgentConfig {
sock_folder: match args.agent_sock_path {
None => {
// create a temp dir for the socket path
let temp_dir = std::env::temp_dir().join(format!("yanpm-agent-sock-{}", time));
std::fs::create_dir_all(&temp_dir)
.expect("Failed to create temp dir for agent socket");
temp_dir.to_string_lossy().to_string()
}
Some(path) => path,
},
nginx_config_dir: match args.agent_nginx_config_dir {
None => {
// create a temp dir for the nginx config dir
let temp_dir =
std::env::temp_dir().join(format!("yanpm-agent-nginx-configs-{}", time));
std::fs::create_dir_all(&temp_dir)
.expect("Failed to create temp dir for agent nginx configs");
temp_dir.to_string_lossy().to_string()
}
Some(path) => path,
},
sock_perm: args.agent_sock_perm,
sock_gid: args.agent_sock_gid.clone(),
};
ParsedArgs {
db_type: args.db_type,
agent_container_config: Some(AgentContainerConfig {
// TODO: allow customization of these fields via CLI args
image: args.agent_image,
tag: args.agent_image_tag,
container_name: format!("yanpm-agent-container-{}", time),
dockerfile_path,
force_build: args.agent_force_build,
agent_config,
nginx_config: NginxConfig {
expose_http: args.agent_nginx_expose_http,
expose_https: args.agent_nginx_expose_https,
},
}),
}
}

View File

@@ -1,21 +0,0 @@
use std::sync::Arc;
use testcontainers::{ContainerAsync, GenericImage};
pub trait WithContainer {
fn container(&self) -> &Arc<ContainerAsync<GenericImage>>;
}
pub trait WithoutContainer {
fn on_delete(&self);
}
#[derive(Clone)]
pub enum ConfigInfoType<T, U>
where
T: WithContainer,
U: WithoutContainer,
{
Containerized(T),
PreExisting(U),
}

View File

@@ -4,9 +4,11 @@ use tokio::signal::unix::{SignalKind, signal};
use crate::{ use crate::{
API_CONFIG_PATH, DB_CONFIG_PATH, API_CONFIG_PATH, DB_CONFIG_PATH,
db::DBConfigInfoType, containers::{
AgentConfigInfoType, ConfigInfoType, DBConfigInfoType, WithContainer, WithoutContainer,
agent::SOCK_NAME,
},
env::{self, EnvFile}, env::{self, EnvFile},
types::{ConfigInfoType, WithContainer, WithoutContainer},
}; };
// relative to the current working directory // relative to the current working directory
@@ -20,7 +22,7 @@ pub fn to_absolute_path(path: &str) -> PathBuf {
.clean() .clean()
} }
pub fn write_env_files(db_config: &DBConfigInfoType) { pub fn write_env_files(db_config: &DBConfigInfoType, agent_config: &Option<AgentConfigInfoType>) {
let api_config_path_absolute = to_absolute_path(API_CONFIG_PATH); let api_config_path_absolute = to_absolute_path(API_CONFIG_PATH);
let db_config_path_absolute = to_absolute_path(DB_CONFIG_PATH); let db_config_path_absolute = to_absolute_path(DB_CONFIG_PATH);
@@ -29,10 +31,27 @@ pub fn write_env_files(db_config: &DBConfigInfoType) {
DBConfigInfoType::PreExisting(config) => (config.db_type.clone(), config.url.clone()), DBConfigInfoType::PreExisting(config) => (config.db_type.clone(), config.url.clone()),
}; };
let mut api_env = EnvFile::new(env::EnvFileType::Yaml, db_type, db_url); let mut api_env = EnvFile::new(env::EnvFileType::Yaml);
api_env.write_line("DATABASE__TYPE", db_type.to_string().as_str());
api_env.write_line("DATABASE__URL", db_url.as_str());
let mut db_env = api_env.clone(); let mut db_env = api_env.clone();
db_env.file_type = env::EnvFileType::DotEnv; db_env.file_type = env::EnvFileType::DotEnv;
// agent related env vars
if let Some(agent) = agent_config
&& let ConfigInfoType::Containerized(agent) = agent
{
api_env.write_line(
"AGENT__SOCK__PATH",
format!("{}/{}", &agent.config.agent_config.sock_folder, SOCK_NAME).as_str(),
);
api_env.write_line(
"AGENT__NGINX__CONFIG__DIR",
&agent.config.agent_config.nginx_config_dir,
);
}
let mut api_file = let mut api_file =
std::fs::File::create(&api_config_path_absolute).expect("Failed to create API config file"); std::fs::File::create(&api_config_path_absolute).expect("Failed to create API config file");

View File

@@ -1,6 +1,42 @@
export namespace Schemas { export namespace Schemas {
// <Schemas> // <Schemas>
export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type AdminInitRequest = { password: string; setup_secret: string; username: string };
export type UpstreamBasicInfo = {
created_at: string;
id: string;
name: string;
protocol: string;
updated_at: string;
};
export type UpstreamTargetInfo = {
created_at: string;
enabled: boolean;
id: string;
is_backup: boolean;
target_host: string;
target_port: number;
updated_at: string;
upstream?: (null | UpstreamBasicInfo) | undefined;
upstream_id: string;
weight: number;
};
export type CreateUpstreamRequestBody = {
algorithm?: (string | null) | undefined;
name: string;
protocol: string;
sticky_session?: (boolean | null) | undefined;
upstream_targets: Array<UpstreamTargetInfo>;
};
export type CreateUpstreamTargetInfo = {
enabled?: (boolean | null) | undefined;
host: string;
is_backup?: (boolean | null) | undefined;
port: number;
upstream_id: string;
weight?: (number | null) | undefined;
};
export type GetUpstreamParams = Partial<{ include_targets: boolean | null }>;
export type GetUpstreamTargetsParams = Partial<{ include_upstream: boolean | null }>;
export type HealthInfo = { export type HealthInfo = {
errors?: (Array<string> | null) | undefined; errors?: (Array<string> | null) | undefined;
is_initialized: boolean; is_initialized: boolean;
@@ -9,6 +45,77 @@ export namespace Schemas {
version: string; version: string;
}; };
export type LoginRequest = { password: string; username: string }; export type LoginRequest = { password: string; username: string };
export type PaginationInfo = { current_page: number; per_page: number; total_items: number; total_pages: number };
export type UpstreamTargetBasicInfo = {
created_at: string;
enabled: boolean;
id: string;
is_backup: boolean;
target_host: string;
target_port: number;
updated_at: string;
weight: number;
};
export type UpdateUpstreamInfoResponse = {
algorithm: string;
created_at: string;
created_by?: (string | null) | undefined;
id: string;
name: string;
protocol: string;
sticky_session: boolean;
updated_at: string;
upstream_targets: Array<UpstreamTargetBasicInfo>;
};
export type UpdateUpstreamRequestBody = Partial<{
algorithm: string | null;
name: string | null;
protocol: string | null;
sticky_session: boolean | null;
upstream_targets: Array<UpstreamTargetBasicUpdateInfo> | null;
}>;
export type UpdateUpstreamTargetInfoResponse = {
created_at: string;
enabled: boolean;
host: string;
id: string;
is_backup: boolean;
port: number;
updated_at: string;
upstream_id: string;
weight: number;
};
export type UpdateUpstreamTargetRequestBody = Partial<{
enabled: boolean | null;
host: string | null;
is_backup: boolean | null;
port: number | null;
weight: number | null;
}>;
export type UpstreamInfoResponse = {
algorithm: string;
created_at: string;
created_by?: (string | null) | undefined;
id: string;
name: string;
protocol: string;
sticky_session: boolean;
updated_at: string;
upstream_targets: Array<UpstreamTargetBasicInfo>;
};
export type UpstreamListResponse = { items: Array<UpstreamInfoResponse>; pagination: PaginationInfo };
export type UpstreamTargetBasicUpdateInfo = { enabled: boolean; id: number };
export type UpstreamTargetInfoResponse = {
created_at: string;
enabled: boolean;
host: string;
id: string;
is_backup: boolean;
port: number;
updated_at: string;
upstream_id: string;
weight: number;
};
export type UserInfo = { id: string; username: string }; export type UserInfo = { id: string; username: string };
// </Schemas> // </Schemas>
@@ -42,6 +149,95 @@ export namespace Endpoints {
parameters: never; parameters: never;
responses: { 200: Schemas.HealthInfo; 404: unknown }; responses: { 200: Schemas.HealthInfo; 404: unknown };
}; };
export type get_Get_upstream_target = {
method: "GET";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
};
responses: { 200: Schemas.UpstreamTargetInfo; 404: unknown; 500: unknown };
};
export type delete_Remove_upstream_target = {
method: "DELETE";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
};
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
};
export type patch_Update_upstream_target = {
method: "PATCH";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
body: Schemas.UpdateUpstreamTargetRequestBody;
};
responses: {
200: Schemas.UpdateUpstreamTargetInfoResponse;
401: unknown;
404: unknown;
422: unknown;
500: unknown;
};
};
export type get_Get_upstream_list = {
method: "GET";
path: "/api/nginx/upstreams";
requestFormat: "json";
parameters: never;
responses: { 200: Schemas.UpstreamListResponse; 500: unknown };
};
export type post_Create_upstream = {
method: "POST";
path: "/api/nginx/upstreams";
requestFormat: "json";
parameters: {
body: Schemas.CreateUpstreamRequestBody;
};
responses: { 200: Schemas.UpstreamInfoResponse; 401: unknown; 422: unknown; 500: unknown };
};
export type get_Get_upstream = {
method: "GET";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
};
responses: { 200: Schemas.UpstreamInfoResponse; 404: unknown; 500: unknown };
};
export type delete_Remove_upstream = {
method: "DELETE";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
};
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
};
export type patch_Update_upstream = {
method: "PATCH";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
body: Schemas.UpdateUpstreamRequestBody;
};
responses: { 200: Schemas.UpdateUpstreamInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown };
};
export type post_Add_upstream_target = {
method: "POST";
path: "/api/nginx/upstreams/{upstream_id}/targets";
requestFormat: "json";
parameters: {
body: Schemas.CreateUpstreamTargetInfo;
};
responses: { 200: Schemas.UpstreamTargetInfoResponse; 401: unknown; 422: unknown; 500: unknown };
};
export type get_Get_user_info = { export type get_Get_user_info = {
method: "GET"; method: "GET";
path: "/api/user/me"; path: "/api/user/me";
@@ -58,11 +254,24 @@ export type EndpointByMethod = {
post: { post: {
"/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/init_admin": Endpoints.post_Init_admin;
"/api/auth/login": Endpoints.post_Login; "/api/auth/login": Endpoints.post_Login;
"/api/nginx/upstreams": Endpoints.post_Create_upstream;
"/api/nginx/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target;
}; };
get: { get: {
"/api/health/info": Endpoints.get_Get_health_info; "/api/health/info": Endpoints.get_Get_health_info;
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target;
"/api/nginx/upstreams": Endpoints.get_Get_upstream_list;
"/api/nginx/upstreams/{upstream_id}": Endpoints.get_Get_upstream;
"/api/user/me": Endpoints.get_Get_user_info; "/api/user/me": Endpoints.get_Get_user_info;
}; };
delete: {
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target;
"/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream;
};
patch: {
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target;
"/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream;
};
}; };
// </EndpointByMethod> // </EndpointByMethod>
@@ -70,6 +279,8 @@ export type EndpointByMethod = {
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type PostEndpoints = EndpointByMethod["post"]; export type PostEndpoints = EndpointByMethod["post"];
export type GetEndpoints = EndpointByMethod["get"]; export type GetEndpoints = EndpointByMethod["get"];
export type DeleteEndpoints = EndpointByMethod["delete"];
export type PatchEndpoints = EndpointByMethod["patch"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -364,6 +575,68 @@ export class ApiClient {
} }
// </ApiClient.get> // </ApiClient.get>
// <ApiClient.delete>
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
delete<Path extends keyof DeleteEndpoints, _TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("delete", path, ...params);
}
// </ApiClient.delete>
// <ApiClient.patch>
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
patch<Path extends keyof PatchEndpoints, _TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("patch", path, ...params);
}
// </ApiClient.patch>
// <ApiClient.request> // <ApiClient.request>
/** /**
* Generic request method with full type-safety for any endpoint * Generic request method with full type-safety for any endpoint

View File

@@ -43,6 +43,8 @@ const createQueryKey = <TOptions extends EndpointParameters>(
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type PostEndpoints = EndpointByMethod["post"]; export type PostEndpoints = EndpointByMethod["post"];
export type GetEndpoints = EndpointByMethod["get"]; export type GetEndpoints = EndpointByMethod["get"];
export type DeleteEndpoints = EndpointByMethod["delete"];
export type PatchEndpoints = EndpointByMethod["patch"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -130,6 +132,66 @@ export class TanstackQueryApiClient {
} }
// </ApiClient.get> // </ApiClient.get>
// <ApiClient.delete>
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<TEndpoint["parameters"]>
) {
const queryKey = createQueryKey(path as string, params[0]);
const query = {
/** type-only property if you need easy access to the endpoint params */
"~endpoint": {} as TEndpoint,
queryKey,
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
queryOptions: queryOptions({
queryFn: async ({ queryKey, signal }) => {
const requestParams = {
...(params[0] || {}),
...(queryKey[0] || {}),
overrides: { signal },
withResponse: false as const,
};
const res = await this.client.delete(path, requestParams as never);
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
},
queryKey: queryKey,
}),
};
return query;
}
// </ApiClient.delete>
// <ApiClient.patch>
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<TEndpoint["parameters"]>
) {
const queryKey = createQueryKey(path as string, params[0]);
const query = {
/** type-only property if you need easy access to the endpoint params */
"~endpoint": {} as TEndpoint,
queryKey,
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
queryOptions: queryOptions({
queryFn: async ({ queryKey, signal }) => {
const requestParams = {
...(params[0] || {}),
...(queryKey[0] || {}),
overrides: { signal },
withResponse: false as const,
};
const res = await this.client.patch(path, requestParams as never);
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
},
queryKey: queryKey,
}),
};
return query;
}
// </ApiClient.patch>
// <ApiClient.request> // <ApiClient.request>
/** /**
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially * Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially

View File

@@ -2,6 +2,8 @@ set dotenv-load := true
# development environment file # development environment file
set dotenv-filename := "./public/database/.env.generated" set dotenv-filename := "./public/database/.env.generated"
DEFAULT_SIMULATE_ARGS := "--agent-dockerfile-path=../agent/Dockerfile"
cli *args: cli *args:
cd apps/cli && \ cd apps/cli && \
if [ -n "{{args}}" ]; then \ if [ -n "{{args}}" ]; then \
@@ -13,9 +15,9 @@ cli *args:
simulate *args: simulate *args:
cd apps/container && \ cd apps/container && \
if [ -n "{{args}}" ]; then \ if [ -n "{{args}}" ]; then \
cargo run --bin container-simulate -- --db-type={{args}}; \ cargo run --bin container-simulate -- {{args}}; \
else \ else \
cargo run --bin container-simulate; \ cargo run --bin container-simulate -- {{DEFAULT_SIMULATE_ARGS}}; \
fi fi
# Usage: (following SeaORM migration commands) # Usage: (following SeaORM migration commands)
@@ -48,8 +50,31 @@ generate-openapi:
# Generate API client for frontend # Generate API client for frontend
cd apps/frontend && \ cd apps/frontend && \
pnpm generate:openapi pnpm generate:openapi
# Generate API client for agent
cd apps/agent && \
cargo run -- generate-openapi --output swagger.json
generate-all: generate-entity generate-openapi generate-agent-client:
# Generate API client for agent
pnpm openapi-generator-cli generate \
-g rust --skip-validate-spec \
-o ./public/agent-client -i ./apps/agent/swagger.json \
--additional-properties=library=reqwest-trait \
--additional-properties=mockall=true \
--additional-properties=packageName=agent_client \
--additional-properties=packageVersion=0.1.0 \
--additional-properties=supportAsync=true \
--additional-properties=supportMultipleResponses=true \
--additional-properties=topLevelApiClient=true \
--additional-properties=useSingleRequestParameter=true
# format generated code
cd public/agent-client && \
cargo fmt
# append lint allows/forbids to the end of Cargo.toml to disable warnings in generated code and forbid unsafe code
cd public/agent-client && \
echo '\n[lints.clippy]\nall = "allow"\n[lints.rust]\nunsafe_code = "forbid"\n' >> Cargo.toml
generate-all: generate-entity generate-openapi generate-agent-client
build-frontend: build-frontend:
# build frontend assets # build frontend assets
@@ -61,6 +86,10 @@ build-backend:
cd apps/api && \ cd apps/api && \
cargo build --release cargo build --release
build-docker:
cd apps/agent && \
docker build -t yanpm/agent:latest .
build-apps: build-frontend build-backend build-apps: build-frontend build-backend
act *args: act *args:

7
openapitools.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.18.0"
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.26.0",
"rust-just": "^1.44.0"
}
}

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
packages:
- apps/frontend
onlyBuiltDependencies:
- '@nestjs/core'
- '@openapitools/openapi-generator-cli'
- esbuild

Some files were not shown because too many files have changed in this diff Show More