5 Commits

Author SHA1 Message Date
GW_MC
7fde3533d9 fix: use custom image for crate test and lint
All checks were successful
Test / get-ci-image (pull_request) Successful in 6s
Test / test-frontend (pull_request) Successful in 11s
Test / lint-frontend (pull_request) Successful in 13s
Test / frontend-build (pull_request) Successful in 13s
Verify / get-ci-image (pull_request) Successful in 5s
Test / lint-crates (pull_request) Successful in 2m1s
Test / test-crates (pull_request) Successful in 2m12s
Verify / verify-generated-db-entities (pull_request) Successful in 2m40s
2026-04-16 04:56:52 +00:00
GW_MC
b5e42f2f30 feat: Add CI environment setup and verification workflows with Docker support
Some checks failed
Test / test-frontend (pull_request) Successful in 43s
Test / lint-frontend (pull_request) Successful in 47s
Verify / get-ci-image (pull_request) Successful in 47s
Test / frontend-build (pull_request) Successful in 1m29s
Verify / verify-generated-db-entities (pull_request) Has been cancelled
Test / test-crates (pull_request) Has been cancelled
Test / lint-crates (pull_request) Has been cancelled
2026-04-16 04:47:04 +00:00
GW_MC
50f17fd69b refactor: Add installation of protobuf compiler to Rust setup action 2026-04-11 09:11:24 +00:00
GW_MC
84808832dd feat: Add setup for Rust environment and implement test workflows 2026-04-11 09:04:09 +00:00
GW_MC
7fd150ea4a feat: Add 'act' feature to devcontainer configuration 2026-04-11 07:33:26 +00:00
21 changed files with 591 additions and 343 deletions

View File

@@ -30,7 +30,8 @@
"ghcr.io/guiyomh/features/just:0": {},
"ghcr.io/devcontainers-extra/features/bun": {
"version": "latest"
}
},
"ghcr.io/devcontainers-extra/features/act": {}
},
"customizations": {

1
.github/.gitignore vendored Normal file
View File

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

4
.github/.secrets.env.template vendored Normal file
View File

@@ -0,0 +1,4 @@
# This is an example environment variable file for GitHub Actions. You can copy this file to .github/.secrets.env and fill in the values to override the default registry and GitHub token used in the CI workflow. This is useful for testing with a private registry or using a different GitHub account for authentication.
OVERRIDE_REGISTRY=<your-registry-url>
OVERRIDE_GITHUB_TOKEN=<your-github-token>
GITHUB_USERNAME=<your-github-username>

View File

@@ -0,0 +1,70 @@
name: 'Setup CI metadata'
description: 'Composite action to derive the registry and CI image tag for the current repository.'
inputs:
registry:
description: 'Container registry derived from the current GitHub server URL'
required: false
default: ''
repository:
description: 'GitHub repository in the format owner/repo'
required: false
default: ${{ github.repository }}
image_tag:
description: 'Tag for the CI image'
required: false
default: 'latest'
outputs:
registry:
description: 'Container registry derived from the current GitHub server URL'
value: ${{ steps.setup.outputs.registry }}
image_tag:
description: 'Fully qualified CI image tag'
value: ${{ steps.setup.outputs.image_tag }}
latest_tag:
description: 'Fully qualified latest CI image tag'
value: ${{ steps.setup.outputs.latest_tag }}
runs:
using: 'composite'
steps:
- name: Setup Dynamic Metadata
id: setup
shell: bash
run: |
# Extract the domain from server_url, handling both https:// and ssh:// schemes
SERVER_URL="${{ github.server_url }}"
if [[ "$SERVER_URL" =~ ^ssh:// ]]; then
# For SSH URLs like ssh://git@host:port/path, extract just the hostname
SERVER_DOMAIN=$(echo "$SERVER_URL" | sed -e 's|^ssh://||' -e 's|^[^@]*@||' -e 's|:[0-9]*.*||')
else
# For HTTPS URLs, extract domain without scheme
SERVER_DOMAIN=$(echo "$SERVER_URL" | sed -e 's|^[^/]*//||' -e 's|/.*$||')
fi
echo "Extracted server domain: $SERVER_DOMAIN"
if [[ -n "${{ inputs.registry }}" ]]; then
REGISTRY="${{ inputs.registry }}"
elif [[ "$SERVER_DOMAIN" == "github.com" ]]; then
REGISTRY="ghcr.io"
else
REGISTRY="$SERVER_DOMAIN"
fi
# Extract owner/repo from github.repository, handling SSH URLs
REPO="${{ inputs.repository }}"
if [[ "$REPO" =~ ^ssh:// ]] || [[ "$REPO" =~ ^https:// ]]; then
# Extract owner/repo from URLs like ssh://git@host/owner/repo.git or https://host/owner/repo.git
REPO=$(echo "$REPO" | sed -e 's|^[^/]*/||' -e 's|\.git$||' | rev | cut -d'/' -f1,2 | rev)
fi
# Docker image names must be lowercase
REGISTRY="${REGISTRY,,}"
REPO="${REPO,,}"
IMAGE_TAG="${REGISTRY}/${REPO}/ci:${{ inputs.image_tag }}"
LATEST_TAG="${REGISTRY}/${REPO}/ci:latest"
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"

74
.github/actions/setup-rust/action.yaml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: 'Setup Rust environment'
description: 'Composite action to checkout the repo, restore cargo caches and set up the Rust toolchain. Use this from job steps to keep setup DRY across jobs.'
inputs:
toolchain:
description: 'Rust toolchain to install'
required: false
default: 'stable'
override:
description: 'Whether to override the default toolchain'
required: false
default: 'true'
components:
description: 'Comma-separated list of additional rust components to install'
required: false
default: 'clippy, rustfmt'
skip_cache:
description: 'Whether to skip restoring and uploading caches (useful for testing the workflow without cache interference)'
required: false
default: 'false'
runs:
using: 'composite'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache cargo registry
uses: actions/cache@v4
if: inputs.skip_cache != 'true'
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
if: inputs.skip_cache != 'true'
with:
path: ~/.cargo/index
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Sanitize components input
shell: bash
run: echo "SANITIZED_COMPONENTS=${{ inputs.components }}" | sed -E 's/, ?| /-/g' >> $GITHUB_ENV
- name: Cache Rust toolchain
uses: actions/cache@v3
if: inputs.skip_cache != 'true'
with:
path: ~/.rustup
# Key includes the OS and the toolchain version (e.g., 'stable')
key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml') }}-v1-${{ inputs.toolchain }}-${{ env.SANITIZED_COMPONENTS }}
restore-keys: |
${{ runner.os }}-rustup-
- name: Cache cargo build (target)
uses: actions/cache@v3
if: inputs.skip_cache != 'true'
with:
path: target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.toolchain }}
override: ${{ inputs.override }}
components: ${{ inputs.components }}

31
.github/docker/ci.Dockerfile vendored Normal file
View File

@@ -0,0 +1,31 @@
FROM node:24-bookworm-slim
# Install necessary dependencies for building Rust projects and running tests
RUN apt-get update && apt-get install -y \
curl \
git \
zstd \
build-essential \
pkg-config \
libssl-dev \
gnupg \
unzip \
tar \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
postgresql-client \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# install bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
# install rust and cargo
RUN apt-get update && apt-get install -y curl build-essential
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Set the working directory
WORKDIR /app

54
.github/workflows/build-ci.yaml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Build CI Environment
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Tag for the CI image (e.g., latest)'
required: true
default: 'latest'
env:
# OVERRIDE_REGISTRY can be set as a secret to override the default registry (e.g., for testing with a private registry). Else '' will be used, which defaults to ghcr.io for github.com and the GitHub server domain for self-hosted GitHub instances.
OVERRIDE_REGISTRY: ${{ secrets.OVERRIDE_REGISTRY }}
permissions:
contents: read
packages: write
concurrency:
group: build-ci
cancel-in-progress: true
jobs:
build-ci-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup CI metadata
id: setup
uses: ./.github/actions/setup-ci-metadata
with:
registry: ${{ env.OVERRIDE_REGISTRY }}
image_tag: ${{ github.event.inputs.image_tag }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: ${{ steps.setup.outputs.registry }}
username: ${{ secrets.GITHUB_USERNAME || github.actor }}
password: ${{ secrets.OVERRIDE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- name: Build and push Docker image for CI
uses: docker/build-push-action@v3
with:
context: .
file: .github/docker/ci.Dockerfile
push: true
tags: |
${{ steps.setup.outputs.image_tag }}
${{ steps.setup.outputs.latest_tag }}

153
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,153 @@
# this workflow runs tests on pull request and push events targeting master branch
# it also verify the generated code is up to date and valid
name: Test
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
get-ci-image:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.setup.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup CI metadata
id: setup
uses: ./.github/actions/setup-ci-metadata
with:
registry: ${{ secrets.OVERRIDE_REGISTRY }}
image_tag: latest
test-crates:
runs-on: ubuntu-latest
needs:
- frontend-build
- get-ci-image
container:
image: ${{ needs.get-ci-image.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust
- name: Restore frontend build cache
uses: actions/cache@v4
with:
path: apps/nxmesh-frontend/build
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
restore-keys: |
frontend-build-${{ runner.os }}-
- name: Run tests
run: cargo test --all-features
lint-crates:
runs-on: ubuntu-latest
needs:
- frontend-build
- get-ci-image
container:
image: ${{ needs.get-ci-image.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust
with:
components: clippy, rustfmt
- name: Restore frontend build cache
uses: actions/cache@v4
with:
path: apps/nxmesh-frontend/build
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
restore-keys: |
frontend-build-${{ runner.os }}-
- name: Run clippy
run: cargo clippy --all-features
- name: Check code formatting
run: cargo fmt --all -- --check
lint-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v2
name: Install Bun
- name: Install frontend dependencies
run: |
cd apps/nxmesh-frontend
bun install
- name: Run frontend linter
run: |
cd apps/nxmesh-frontend
bun run lint
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
name: Install Bun
- name: Install frontend dependencies
run: |
cd apps/nxmesh-frontend
bun install
- name: Run frontend tests
run: |
cd apps/nxmesh-frontend
bun run test
frontend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v2
name: Install Bun
- name: Install frontend dependencies
run: |
cd apps/nxmesh-frontend
bun install
- name: Build frontend
run: |
cd apps/nxmesh-frontend
bun run build
- name: Cache frontend build
uses: actions/cache@v4
with:
path: apps/nxmesh-frontend/build
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
restore-keys: |
frontend-build-${{ runner.os }}-

139
.github/workflows/verify.yaml vendored Normal file
View File

@@ -0,0 +1,139 @@
# this workflow verifies the generated code is up to date and valid
name: Verify
on:
pull_request:
branches:
- master
push:
branches:
- master
env:
# OVERRIDE_REGISTRY can be set as a secret to override the default registry (e.g., for testing with a private registry). Else '' will be used, which defaults to ghcr.io for github.com and the GitHub server domain for self-hosted GitHub instances.
OVERRIDE_REGISTRY: ${{ secrets.OVERRIDE_REGISTRY }}
ACTIONS_STEP_DEBUG: true
jobs:
get-ci-image:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.setup.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup CI metadata
id: setup
uses: ./.github/actions/setup-ci-metadata
with:
registry: ${{ secrets.OVERRIDE_REGISTRY }}
image_tag: latest
verify-generated-db-entities:
runs-on: ubuntu-latest
needs:
- get-ci-image
container:
image: ${{ needs.get-ci-image.outputs.image_tag }}
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nxmesh
# ! do not set a fixed port to avoid conflicts when running multiple jobs in parallel, use Docker's internal networking instead
# ports:
# - 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d nxmesh"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/nxmesh
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check whether migrations/entities changed
id: check_changes
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
else
BASE_SHA=${{ github.event.before }}
HEAD_SHA=${{ github.sha }}
fi
if [ -z "$HEAD_SHA" ]; then
HEAD_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "")
fi
if [ -z "$BASE_SHA" ]; then
PREV=$(git rev-parse --verify "${HEAD_SHA}^" 2>/dev/null || true)
if [ -n "$PREV" ]; then
BASE_SHA=$PREV
else
BASE_SHA=$HEAD_SHA
fi
fi
echo "Comparing $BASE_SHA..$HEAD_SHA"
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)
echo "$CHANGED_FILES"
echo "$CHANGED_FILES" | grep -E '^(crates/migration/src/|apps/nxmesh-master/src/db/entities/)' >/dev/null 2>&1 \
&& echo "changed=true" >> $GITHUB_OUTPUT \
|| echo "changed=true" >> $GITHUB_OUTPUT
# || echo "changed=false" >> $GITHUB_OUTPUT
- name: Setup Rust, checkout and restore caches
if: steps.check_changes.outputs.changed == 'true'
uses: ./.github/actions/setup-rust
with:
skip_cache: ${{ vars.SKIP_CACHE }}
- name: Install SeaORM CLI
if: steps.check_changes.outputs.changed == 'true'
run: |
cargo install sea-orm-cli@^2.0.0-rc --features "sqlx-postgres runtime-tokio-rustls"
- name: Apply migrations
if: steps.check_changes.outputs.changed == 'true'
run: |
cargo run -p nxmesh-migration -- up
- name: Regenerate entities
if: steps.check_changes.outputs.changed == 'true'
run: |
sea-orm-cli generate entity \
--database-url "$DATABASE_URL" \
--output-dir apps/nxmesh-master/src/db/entities \
--with-serde both \
--with-copy-enums \
--date-time-crate chrono
- name: Check for uncommitted changes in entities
if: steps.check_changes.outputs.changed == 'true'
shell: bash
run: |
if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/nxmesh-master/src/db/entities/') ]]; then
echo "Generated SeaORM entities are not up to date."
echo "Run 'just db-generate' after applying migrations and commit the result."
git status --porcelain --untracked-files=all | grep 'apps/nxmesh-master/src/db/entities/'
exit 1
else
echo "Generated SeaORM entities are up to date."
fi
- name: Skip entity generation (no relevant changes)
if: steps.check_changes.outputs.changed == 'false'
run: echo "No changes in migrations/entities, skipping SeaORM entity verification."

1
.gitignore vendored
View File

@@ -68,6 +68,7 @@ web_modules/
# dotenv environment variable files
.env
.env.*
*.env
!.env.example
# parcel-bundler cache (https://parceljs.org/)

22
Cargo.lock generated
View File

@@ -1343,17 +1343,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "fs4"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix",
"tokio",
"windows-sys 0.59.0",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -2435,12 +2424,10 @@ dependencies = [
name = "nxmesh-agent"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"clap",
"config",
"fs4",
"futures",
"hex",
"hostname",
@@ -5136,15 +5123,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"

View File

@@ -56,6 +56,9 @@ futures = "0.3"
toml = "0.9"
config = "0.15"
# HTTP client
reqwest = { version = "0.13.2", default-features = false, features = ["json"] }
# Crypto
sha2 = "0.10"
hex = "0.4"

View File

@@ -56,8 +56,6 @@ zip = { workspace = true }
# CLI
clap = { workspace = true, features = ["derive"] }
anyhow = "1.0.102"
fs4 = { version = "0.13.1", features = ["tokio"] }
[dev-dependencies]
tokio-test.workspace = true

View File

@@ -14,7 +14,6 @@ use crate::connector::master::{MasterConnector, MasterConnectorTrait, ssh::SshMa
mod cli;
mod config;
mod connector;
mod service;
#[tokio::main]
async fn main() {
@@ -61,13 +60,13 @@ async fn main() {
}
// send a dummy heartbeat to verify the connection is working
let mut client = master_connector.get_client().lock().await.clone();
let client = master_connector.get_client();
let request = nxmesh_proto::HealthReport {
..Default::default()
};
match client.report_health(request).await {
match client.lock().await.report_health(request).await {
Ok(_) => info!("Successfully sent health report to master."),
Err(e) => {
error!("Failed to send health report to master: {}", e);

View File

@@ -0,0 +1,38 @@
use std::sync::Arc;
use nxmesh_proto::ConfigUpdate;
use tracing::info;
use crate::connector::master::MasterConnector;
#[async_trait::async_trait]
pub trait MasterHandler {
async fn on_config_update(
&self,
config_info: ConfigUpdate,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
}
pub struct MasterHandlerImpl {
settings: Arc<crate::config::settings::Settings>,
}
impl MasterHandlerImpl {
pub fn new(settings: impl Into<Arc<crate::config::settings::Settings>>) -> Self {
Self {
settings: settings.into(),
}
}
}
#[async_trait::async_trait]
impl MasterHandler for MasterHandlerImpl {
async fn on_config_update(
&self,
config_info: ConfigUpdate,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Received config update from master: {:?}", config_info);
Ok(())
}
}

View File

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

View File

@@ -1,308 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use fs4::tokio::AsyncFileExt;
use tokio::{io::AsyncWriteExt, process::Command};
use tracing::{debug, warn};
use crate::config::settings::NginxSettings;
// TODO: custom error type
#[async_trait::async_trait]
pub trait NginxHandler {
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
async fn reload(&self, config_path: Option<&str>) -> Result<()>;
async fn stop(&self) -> Result<()>;
async fn validate(&self, config_path: Option<&str>) -> Result<()>;
async fn get_version(&self) -> Result<String>;
async fn get_status(&self) -> Result<String>;
// Write a new config file for nginx.
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()>;
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()>;
// clean up old config files that are applied to nginx
// keep only latest n deployments.
async fn cleanup_config(&self, n: usize) -> Result<()>;
}
pub struct NginxHandlerImpl {
settings: Arc<NginxSettings>,
}
impl NginxHandlerImpl {
pub fn new(settings: Arc<NginxSettings>) -> Self {
Self { settings }
}
fn get_nginx_command(&self) -> String {
// TODO: rename the setting for better clarity, it can be a binary path or a custom command
self.settings
.nginx_binary_path
.clone()
.unwrap_or_else(|| "nginx".to_string())
}
fn validate_config_path(config_path: &str) -> Result<()> {
if !std::path::Path::new(config_path).exists() {
anyhow::bail!("Config file not found at path: {}", config_path);
}
if !std::path::Path::new(config_path).is_file() {
anyhow::bail!("Config path is not a file: {}", config_path);
}
Ok(())
}
fn apply_config_path_to_command_vecs<'a>(
command: &'a mut Vec<String>,
config_path: &str,
) -> Result<&'a mut Vec<String>> {
// if given a config path, add it to the end of the command arguments to override the default config path used
Self::validate_config_path(config_path)?;
let parent_dir = match std::path::Path::new(config_path).parent() {
Some(dir) => dir,
// return root
None => std::path::Path::new("/"),
};
// set prefix path to the parent directory of the config file to ensure nginx can find all related files (e.g. certs, conf.d, etc.)
command.push("-p".to_string());
command.push(parent_dir.to_string_lossy().to_string());
// add the config file path to the command arguments to override the default config path used by nginx
command.push("-c".to_string());
command.push(config_path.to_string());
Ok(command)
}
fn get_deployment_dir(&self) -> std::path::PathBuf {
std::path::Path::new(&self.settings.nginx_config_path).join("deployments")
}
fn get_deployment_dir_path(&self, deployment_id: &str) -> std::path::PathBuf {
self.get_deployment_dir().join(deployment_id)
}
async fn get_deployment_config_path(
&self,
deployment_id: &str,
output_path: &str,
create_dir_if_not_exists: bool,
) -> Result<std::path::PathBuf> {
let output_path_obj = std::path::Path::new(output_path);
if output_path_obj.is_absolute() {
anyhow::bail!("Output path must be a relative path");
}
if output_path_obj
.components()
.any(|comp| comp == std::path::Component::ParentDir)
{
anyhow::bail!("Output path must not contain parent directory traversal");
}
let deployment_config_dir = self.get_deployment_dir_path(deployment_id);
if create_dir_if_not_exists {
tokio::fs::create_dir_all(&deployment_config_dir).await?;
}
Ok(deployment_config_dir.join(output_path))
}
}
#[async_trait::async_trait]
impl NginxHandler for NginxHandlerImpl {
async fn reload(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let reload_command_str = self.settings.override_nginx_reload_command.clone();
let program = match reload_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut reload_command_vec = reload_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut reload_command_vec, path)?;
}
let output = Command::new(program)
.args(&reload_command_vec)
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to reload nginx: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx reloaded successfully: {}", success_info.trim());
Ok(())
}
async fn stop(&self) -> Result<()> {
let output = Command::new(self.get_nginx_command())
.arg("-s")
.arg("stop")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stop nginx: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx stopped successfully: {}", success_info.trim());
Ok(())
}
async fn validate(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let validate_command_str = self.settings.override_nginx_test_command.clone();
let program = match validate_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut validate_args = validate_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut validate_args, path)?;
}
let output = Command::new(program).args(&validate_args).output().await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Nginx config validation failed: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx config validation succeeded: {}", success_info.trim());
Ok(())
}
async fn get_version(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-v")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get nginx version: {}", error_info.trim());
}
let version_info = String::from_utf8_lossy(&output.stderr);
Ok(version_info.trim().to_string())
}
async fn get_status(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-t")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get nginx status: {}", error_info.trim());
}
let status_info = String::from_utf8_lossy(&output.stderr);
Ok(status_info.trim().to_string())
}
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(full_output_path)
.await?;
// lock the file for writing to prevent concurrent write issue
file.allocate(config_content.len() as u64).await?;
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(())
}
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open(full_output_path)
.await?;
// lock the file for writing to prevent concurrent write issue
file.allocate(file.metadata().await?.len() + config_content.len() as u64)
.await?;
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(())
}
async fn cleanup_config(&self, n: usize) -> Result<()> {
let deployment_dir = self.get_deployment_dir();
// loop through all files in the deployment dir and delete them
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
let mut deployment_ids = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
if file_type.is_dir()
&& let Some(deployment_id) = entry.file_name().to_str()
{
deployment_ids.push(deployment_id.to_string());
}
}
// sort the deployment ids by modified time in descending order and keep the latest n deployments, delete the rest
deployment_ids.sort_by_key(|id| {
let path = self.get_deployment_dir_path(id);
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
for deployment_id in deployment_ids.into_iter().skip(n) {
let path = self.get_deployment_dir_path(&deployment_id);
// ensure path is within the deplyment and nginx directory to prevent accidental deletion of other files
if !path.starts_with(&deployment_dir)
|| !path.starts_with(&self.settings.nginx_config_path)
{
warn!(
"Skipping deletion of path outside of deployment or nginx config directory: {:?}",
path
);
continue;
}
tokio::fs::remove_dir_all(path).await?;
}
Ok(())
}
}

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "echo \"No test specified\" && exit 0"
},
"dependencies": {
"react": "^19.2.0",
@@ -27,4 +28,4 @@
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -10,6 +10,7 @@ pub mod agent {
pub use agent::*;
pub mod auth;
#[allow(ambiguous_glob_reexports)]
pub use tonic_async_interceptor::*;
#[cfg(test)]

View File

@@ -25,7 +25,6 @@ setup-rust-tools:
cargo install sea-orm-cli@^2.0.0-rc --features "sqlx-postgres runtime-tokio-rustls"
cargo install cargo-watch
# Setup frontend dependencies
setup-frontend:
@echo "📦 Installing frontend dependencies..."
@@ -35,6 +34,12 @@ setup-frontend:
# Development Commands
# =============================================================================
# act
act *ARGS:
# run act with custom secret-file
@echo "🎬 Running act with custom secrets file..."
act --env-file .github/.env --secret-file .github/.secrets.env --var-file .github/.var.env --network host {{ ARGS }}
# Start all services for development
dev:
@echo "🚀 Starting all development services..."
@@ -45,11 +50,11 @@ dev:
# Start Rust backend with hot reload
dev-master *ARGS:
@echo "🔧 Starting Rust backend..."
cargo watch -w apps/nxmesh-master -x 'run --bin nxmesh-master -- {{ARGS}}'
cargo watch -w apps/nxmesh-master -x 'run --bin nxmesh-master -- {{ ARGS }}'
dev-agent *ARGS:
@echo "🔧 Starting Rust agent..."
cargo watch -w apps/nxmesh-agent -x 'run --bin nxmesh-agent -- {{ARGS}}'
cargo watch -w apps/nxmesh-agent -x 'run --bin nxmesh-agent -- {{ ARGS }}'
# Start Vite frontend development server
dev-frontend:
@@ -89,7 +94,7 @@ build-frontend:
# =============================================================================
db *ARGS:
cd crates && sea-orm-cli {{ARGS}}
cd crates && sea-orm-cli {{ ARGS }}
# Setup database
db-setup:
@@ -205,6 +210,11 @@ docker-run:
@echo "🐳 Running Docker container..."
docker run -p 8080:8080 --env-file .env nxmesh:latest
# Build Docker image for CI
docker-build-ci REGISTRY="ghcr.io/nxmesh":
@echo "🐳 Building Docker image for CI..."
docker build -t {{ REGISTRY }}/ci:latest -f ./.github/docker/ci.Dockerfile .
# =============================================================================
# Nginx Commands (Shared PID Namespace + Docker Fallback)
# =============================================================================