Rust CLI Architecture
Complete architecture documentation for NoETL's Rust-based CLI and its integration with Python components.
Migration Status: ✅ Complete (All 3 Phases Implemented)
Quick Overview
NoETL uses a hybrid architecture where a Rust binary (fast, native) handles CLI operations and spawns Python processes (rich ecosystem) for server and worker functionality.
┌─────────────────────────────────────────────────────┐
│ User Command: noetl server start │
└─────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Rust CLI Binary (noetlctl/src/main.rs) │
│ - Argument parsing (Clap) │
│ - PID management │
│ - Process spawning │
│ - Signal handling │
└─────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Python Subprocess: python -m noetl.server │
│ - FastAPI server (noetl/server/__main__.py) │
│ - Uvicorn ASGI server │
│ - Database operations │
│ - API endpoints │
└─────────────────────────────────────────────────────┘
Deployment Targets
1. Docker Containers
Multi-Stage Build (docker/noetl/dev/Dockerfile):
# Stage 1: UI Build (Node.js)
FROM node:20-alpine AS ui-builder
COPY ui-src/ ./ui-src/
RUN cd ui-src && npm ci && npm run build
# Stage 2: Rust Binary (Cargo)
FROM rust:1.83-slim AS rust-builder
COPY noetlctl/ ./
RUN cargo build --release
# Output: target/release/noetl (5.5MB)
# Stage 3: Python Environment (uv)
FROM python:3.12-slim AS builder
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY noetl/ ./noetl/
RUN uv pip install -e .
# Stage 4: Production Image
FROM python:3.12-slim AS production
COPY --from=builder /opt/noetl/.venv /opt/noetl/.venv
COPY --from=rust-builder /build/target/release/noetl /usr/local/bin/noetl
COPY --from=ui-builder /ui/ui-src/dist ./noetl/core/ui
ENV PATH="/opt/noetl/.venv/bin:$PATH"
CMD ["python", "-m", "noetl.server"]
Key Points:
- Separate build stages for isolation and caching
- Rust binary at
/usr/local/bin/noetl - Python environment at
/opt/noetl/.venv - Can override CMD with
command: [noetl, server, start]
Platform Support:
The CLI supports building Docker images for different platforms via the --platform argument:
# Build for Linux AMD64 (default, for Kubernetes/Kind)
./bin/noetl build # Uses linux/amd64
./bin/noetl build --platform linux/amd64
# Build for Mac Silicon (local development)
./bin/noetl build --platform linux/arm64
# K8s commands also support platform
./bin/noetl k8s redeploy --platform linux/arm64
./bin/noetl k8s reset --platform linux/amd64
Why This Matters:
- Mac Silicon (M1/M2/M3): Docker Desktop defaults to
linux/arm64but Kubernetes Kind clusters runlinux/amd64 - Cross-Compilation: Building for the wrong platform causes containers to fail silently or OOMKill in K8s
- Local Testing: Use
linux/arm64for faster local Docker runs on Mac Silicon - Production: Always use
linux/amd64for Kubernetes deployments
Default Behavior:
- All build commands default to
linux/amd64for Kind/K8s compatibility - On Mac Silicon, this triggers Docker's cross-compilation (slower but correct)
- Override with
--platformflag when needed for local-only images
2. Kubernetes
Server Deployment:
containers:
- name: server
image: ghcr.io/noetl/noetl:latest
command: [noetl]
args: [server, start]
Worker Deployment:
containers:
- name: worker
command: [noetl]
args: [worker, start]
Flow: Pod starts → Rust CLI → Spawns python -m noetl.worker → Connects to NATS
3. Local Development
# Build and install
cd noetlctl && cargo build --release
mkdir -p ../bin
cp target/release/noetl ../bin/noetl
# Usage
./bin/noetl server start --init-db
./bin/noetl worker start
./bin/noetl build
./bin/noetl k8s deploy
4. PyPI Distribution
Wheel Contents:
noetl-2.4.0-py3-none-any.whl/
├── noetl/
│ ├── cli_wrapper.py # Entry point wrapper
│ ├── server/__main__.py # Server entry
│ ├── worker/__main__.py # Worker entry
│ ├── bin/noetl # Rust binary (5.7MB)
│ └── ... (Python modules)
└── noetl-2.4.0.dist-info/
└── entry_points.txt # noetl = noetl.cli_wrapper:main
Installation Flow:
pip install noetl
# Creates: ~/.local/bin/noetl → cli_wrapper.py → noetl/bin/noetl
Wrapper (noetl/cli_wrapper.py):
def main():
binary_path = Path(noetl.__file__).parent / 'bin' / 'noetl'
subprocess.run([str(binary_path)] + sys.argv[1:])
Implementation Details
Server Management
Start Server (Rust):
async fn start_server(init_db: bool) -> Result<()> {
// 1. Check PID file (~/.noetl/noetl_server.pid)
if pid_file.exists() && process_exists(read_pid()?) {
return Err("Already running");
}
// 2. Check port availability
let port = env::var("NOETL_PORT").unwrap_or("8082");
if TcpStream::connect(format!("0.0.0.0:{}", port)).is_ok() {
return Err("Port in use");
}
// 3. Spawn Python subprocess
let child = Command::new("python")
.args(&["-m", "noetl.server", "--port", &port])
.arg(if init_db { "--init-db" } else { "" })
.spawn()?;
// 4. Write PID file
fs::write(pid_file, child.id().to_string())?;
Ok(())
}
Server Entry Point (Python):
# noetl/server/__main__.py
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8082)
parser.add_argument("--init-db", action="store_true")
args = parser.parse_args()
if args.init_db:
asyncio.run(initialize_db())
from noetl.server.app import create_app
uvicorn.run(create_app(), host=args.host, port=args.port)
Stop Server (Rust):
async fn stop_server(force: bool) -> Result<()> {
let pid = read_pid_file()?;
// Send SIGTERM for graceful shutdown
send_signal(pid, Signal::SIGTERM)?;
// Wait 10 seconds
for _ in 0..20 {
if !process_exists(pid)? {
fs::remove_file(pid_file)?;
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Force kill if requested
if force {
send_signal(pid, Signal::SIGKILL)?;
}
Ok(())
}
Worker Management
Start Worker (Rust):
async fn start_worker(_max_workers: Option<usize>) -> Result<()> {
let child = Command::new("python")
.args(&["-m", "noetl.worker"])
.spawn()?;
fs::write(pid_file, child.id().to_string())?;
Ok(())
}
Worker Entry Point (Python):
# noetl/worker/__main__.py
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--nats-url", default="nats://...")
parser.add_argument("--server-url", default=None)
args = parser.parse_args()
from noetl.worker.v2_worker_nats import run_worker_v2_sync
run_worker_v2_sync(nats_url=args.nats_url, server_url=args.server_url)
Worker Architecture (Python):
# noetl/worker/v2_worker_nats.py
async def run_v2_worker(worker_id, nats_url, server_url):
# 1. Connect to NATS
subscriber = NATSCommandSubscriber(nats_url, worker_id)
await subscriber.connect()
# 2. Subscribe to commands
async def handle_command(msg):
command = await fetch_command(msg['queue_id'])
result = await execute_command(command)
await report_event(msg['execution_id'], result)
await subscriber.subscribe(handle_command)
# 3. Keep running
while running:
await asyncio.sleep(1)
Build Commands
Docker Build (Rust):
async fn build_docker_image(no_cache: bool) -> Result<()> {
let tag = format!("local/noetl:{}", timestamp());
Command::new("docker")
.args(&["build", "-f", "docker/noetl/dev/Dockerfile"])
.arg("-t").arg(&tag)
.arg(if no_cache { "--no-cache" } else { "" })
.status()?;
fs::write(".noetl_last_build_tag.txt", &tag)?;
Ok(())
}
Kubernetes Deploy (Rust):
async fn k8s_deploy() -> Result<()> {
let tag = fs::read_to_string(".noetl_last_build_tag.txt")?;
// Load image into kind cluster
run_command(&["kind", "load", "docker-image", &tag])?;
// Update manifests
update_manifest_image("ci/manifests/noetl/server-deployment.yaml", &tag)?;
// Apply manifests
run_command(&["kubectl", "apply", "-f", "ci/manifests/noetl/"])?;
// Wait for rollout
run_command(&["kubectl", "rollout", "status", "deployment/noetl-server"])?;
Ok(())
}
Migration History
Phase 1: Docker & Kubernetes (Complete)
Commit: 58ab80f3
- Updated Dockerfile to Rust 1.83 with multi-stage build
- Installed binary to
/usr/local/bin/noetl - Updated K8s manifests:
command: [noetl] - Created
./bin/noetlfor local development - Added kind load to k8s deploy
- Renamed all references: noetlctl → noetl
Result: Rust CLI in Docker and Kubernetes
Phase 2: PyPI Bundling (Complete)
Commit: 059a2d35
- Created
noetl/cli_wrapper.py(executes bundled binary) - Updated
pyproject.toml:- Scripts:
noetl = noetl.cli_wrapper:main - Package data:
noetl/bin/noetl
- Scripts:
- Updated GitHub workflow to compile binary before packaging
- Added
noetl/bin/to.gitignore
Build Process:
cargo build --release # Compile
cp noetlctl/target/release/noetl noetl/bin/ # Copy
uv build # Package (5.7MB wheel)
uv publish # Upload to PyPI
Result: pip install noetl includes working noetl command
Phase 3: Python CLI Removal (Complete)
Commit: 6823d3d5
- Deleted:
noetl/cli/ctl.py(1,031 lines) - Deleted:
noetl/cli/__init__.py - Removed:
typer>=0.15.3dependency - Created:
noetl/server/__main__.py - Created:
noetl/worker/__main__.py - Updated: Rust CLI to call Python modules directly
Before: Rust → python -m noetl.cli.ctl worker start
After: Rust → python -m noetl.worker
Result: Python CLI completely removed
Technical Specifications
Performance
- Binary size: 5.5MB (release)
- Startup time: ~10ms (CLI parsing)
- Memory: ~2MB idle
- Python subprocess: ~300-500ms startup, 50-100MB memory
PID Management
- Location:
~/.noetl/ - Files:
noetl_server.pid,noetl_worker_{name}.pid - Format: Plain text with PID
- Cleanup: Removed on shutdown, validated on startup
Signal Handling
- SIGTERM: Graceful shutdown (10s timeout)
- SIGKILL: Force termination (after timeout or with
--force)
Environment Variables
Server:
NOETL_HOST(default: 0.0.0.0)NOETL_PORT(default: 8082)NOETL_ENABLE_UI(default: false)
Worker:
NATS_URL(NATS connection string)NOETL_SERVER_URL(API endpoint)
Database:
POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DBPOSTGRES_USER,POSTGRES_PASSWORD
Platform Support
Current:
- Linux x86_64 ✅
- macOS arm64 (Apple Silicon) ✅
- macOS x86_64 (Intel) ✅
Future (with cibuildwheel):
- Linux aarch64 (ARM)
- Windows x86_64
Dependencies
Rust (noetlctl/Cargo.toml)
clap = { version = "4.5", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full", "process"] }
dirs = "5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
chrono = "0.4"
sysinfo = "0.31" # Process management
nix = "0.29" # Unix signals
Python (Removed)
typer >= 0.15.3 ❌ Removed in Phase 3
Troubleshooting
Command not found
# Check installation
pip show noetl
python -c "import noetl; import shutil; print(shutil.which('noetl'))"
# Manual execution
python -m noetl.cli_wrapper --version
Port already in use
lsof -i :8082
./bin/noetl server stop --force
NOETL_PORT=8083 ./bin/noetl server start
Worker not receiving jobs
kubectl logs -n noetl deployment/noetl-worker
kubectl describe pod -n noetl -l app=noetl-worker
Docker build fails
./bin/noetl build --no-cache
cd noetlctl && cargo update
Performance Benchmarks
CLI Startup:
$ time ./bin/noetl --version
noetl 2.1.2
real 0m0.012s
Docker Build Time:
- First build: ~6 minutes
- Incremental: ~30 seconds (with cache)
Binary Distribution:
- Rust binary: 5.5 MB
- Python wheel: 12.3 MB (with binary)
- Docker image: 450 MB (compressed)
References
Internal Documentation
External Resources
Related Commits
58ab80f3- Phase 1: Docker & K8s213cd01e- Documentation updates24e8266d- AI instructions059a2d35- Phase 2: PyPI bundling6823d3d5- Phase 3: Python CLI removalb9699641- Documentation (this file)