Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,28 @@ ignite run . --verbose
| `ignite init <name>` | Generate a new service scaffold |
| `ignite run <path>` | Build + execute service in a microVM |
| `ignite preflight <path>` | Run safety validator checks |
| `ignite serve` | Start HTTP REST API server |
| `ignite serve` | Start HTTP REST API server (listens on `9847` by default) |

## TypeScript SDK

A Bun-native client for the REST API lives in [`sdk/ts`](sdk/ts) and targets the
daemon's default port, so no configuration is needed on either side:

```bash
bun install # from the repository root
ignite serve --services ./examples & # listens on 9847
```

```typescript
import { IgniteClient } from '@ignite/sdk';

const client = new IgniteClient();
const result = await client.executeService('hello-bun', { input: { count: 2 } });
console.log(result.metrics?.stdout);
```

See [`sdk/ts/README.md`](sdk/ts/README.md) for timeouts, cancellation, and error
handling.

## Runtime Support

Expand Down
30 changes: 30 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ ignite serve [options]

Options:

- `-p, --port <port>`: API port (default `3000`)
- `-h, --host <host>`: host or IP to bind (default `localhost`)
- `-p, --port <port>`: API port (default `9847`)
- `-H, --host <host>`: host or IP to bind (default `localhost`). Note the capital
`H`: `-h` is reserved for `--help`.
- `-s, --services <path>`: path to services root folder (default `./services`)

Environment:
Expand Down
6 changes: 4 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ ignite preflight .
Start the REST API:

```bash
ignite serve --services ./services --port 3000
ignite serve --services ./services
```

This listens on port `9847` by default; pass `--port` to change it.

Execute a service over REST:

```bash
curl -X POST http://localhost:3000/services/hello-world/execute \
curl -X POST http://localhost:9847/services/hello-world/execute \
-H 'Content-Type: application/json' \
-d '{"input":{"message":"Hello Ignite"}}'
```
2 changes: 1 addition & 1 deletion docs/man/ignite.1
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Show details for a specific template.
Start the HTTP REST API server.
.TP
.BR \-p ", " \-\-port " " \fIport\fR
Port to bind API (default: 3000).
Port to bind API (default: 9847).
.TP
.BR \-H ", " \-\-host " " \fIhost\fR
Host to bind API (default: localhost).
Expand Down
6 changes: 3 additions & 3 deletions docs/walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ Run the server pointing to the folder containing the services:

```bash
cd ..
ignite serve --services ./services --port 3000
ignite serve --services ./services
```

Now, invoke execution from a client using curl:
Now, invoke execution from a client using curl (the daemon listens on `9847`):

```bash
curl -X POST http://localhost:3000/services/data-processor/execute \
curl -X POST http://localhost:9847/services/data-processor/execute \
-H 'Content-Type: application/json' \
-d '{"input":{"data":[5,10,15],"operation":"max"}}'
```
Expand Down
52 changes: 48 additions & 4 deletions ignite-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,15 @@ enum Commands {
},
/// Start HTTP REST API server
Serve {
/// Port to bind API (default 3000)
#[arg(short, long, default_value = "3000")]
/// Port to bind API (default 9847)
#[arg(short, long, default_value = "9847")]
port: u16,
/// Host to bind API (default localhost)
#[arg(short, long, default_value = "localhost")]
/// Host to bind API (default localhost).
///
/// Short flag is `-H`: `-h` is reserved by clap for `--help`, and
/// claiming it here makes clap panic on every `serve` invocation under
/// debug assertions.
#[arg(short = 'H', long, default_value = "localhost")]
host: String,
/// Path to services folder root
#[arg(short, long, default_value = "./services")]
Expand Down Expand Up @@ -1086,3 +1090,43 @@ async fn main() -> Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;

/// `serve` previously gave `--host` an auto-derived `-h`, which collides
/// with clap's generated `--help`. Under debug assertions that is a panic
/// on every `ignite serve` invocation, and in release it silently rebinds
/// `-h` to `--host`. `debug_assert` walks the whole command tree, so this
/// also covers every other subcommand.
#[test]
fn cli_definition_has_no_conflicting_flags() {
Cli::command().debug_assert();
}

#[test]
fn serve_defaults_to_the_uncommon_port() {
let cli = Cli::parse_from(["ignite", "serve"]);
match cli.command {
Commands::Serve { port, host, .. } => {
assert_eq!(port, 9847, "serve must default to the documented port");
assert_eq!(host, "localhost");
}
_ => panic!("expected the serve subcommand"),
}
}

#[test]
fn serve_accepts_capital_h_for_host() {
let cli = Cli::parse_from(["ignite", "serve", "-H", "0.0.0.0", "-p", "9999"]);
match cli.command {
Commands::Serve { port, host, .. } => {
assert_eq!(host, "0.0.0.0");
assert_eq!(port, 9999);
}
_ => panic!("expected the serve subcommand"),
}
}
}
4 changes: 4 additions & 0 deletions ignite-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true

[dev-dependencies]
tempfile.workspace = true
tower = { version = "0.5", features = ["util"] }


2 changes: 1 addition & 1 deletion ignite-http/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async fn main() {
let port = std::env::var("PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(3000);
.unwrap_or(9847);

let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());

Expand Down
168 changes: 156 additions & 12 deletions ignite-http/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,20 +143,47 @@ async fn list_services(
_auth: RequireAuth,
State(state): State<Arc<ServerState>>,
) -> impl IntoResponse {
// A misconfigured `--services` path must not look like an empty directory.
// Swallowing the error here returns `{"services": []}` with HTTP 200, which
// is indistinguishable from "no services installed".
let entries = match fs::read_dir(&state.services_path) {
Ok(entries) => entries,
Err(e) => {
tracing::error!(
path = %state.services_path.display(),
error = %e,
"cannot read the configured services directory"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!(
"Cannot read services directory {}: {}",
state.services_path.display(),
e
)
})),
);
}
};

let mut services = Vec::new();
if let Ok(entries) = fs::read_dir(&state.services_path) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.filter(|_| path.is_dir())
{
services.push(name.to_string());
}
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.filter(|_| path.is_dir())
{
services.push(name.to_string());
}
}
Json(serde_json::json!({ "services": services }))
// Directory order is filesystem-dependent; sort so the API is deterministic.
services.sort();
Comment on lines 170 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return an error for directory iteration failures.

Line 171 discards ReadDir errors with flatten(). If iteration fails after the directory opens, this endpoint returns a partial service list with HTTP 200. Handle each Result<DirEntry, io::Error> and return the same HTTP 500 response used for the initial read_dir failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ignite-http/src/server.rs` around lines 170 - 182, Update the directory
iteration in the service-listing handler to process each ReadDir result
explicitly instead of using entries.flatten(). On any iteration io::Error,
return the same HTTP 500 response path used for the initial read_dir failure;
otherwise preserve the existing directory filtering and deterministic
services.sort() behavior.

(
StatusCode::OK,
Json(serde_json::json!({ "services": services })),
)
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -229,7 +256,34 @@ async fn execute_service_handler(
..ExecuteOptions::default()
};

match execute_service(&service_dir, options, None, None) {
// `execute_service` is synchronous and runs for the entire lifetime of the
// microVM. Calling it directly from an async handler parks a Tokio worker
// thread for that whole duration, so enough concurrent executions starve
// the runtime and stall every other route, including `/health`.
let result =
tokio::task::spawn_blocking(move || execute_service(&service_dir, options, None, None))
.await;

let result = match result {
Ok(result) => result,
Err(join_err) => {
// The blocking task panicked or was cancelled. Surface it rather
// than reporting success with no metrics.
tracing::error!(error = %join_err, "service execution task failed to complete");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ExecuteResponse {
success: false,
service_name,
metrics: None,
preflight: None,
error: Some(format!("Execution task failed to complete: {join_err}")),
}),
);
}
};

match result {
Comment on lines +259 to +286

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the declared Tokio version and all execution-admission controls.
rg -n -C 4 \
  -g 'Cargo.toml' -g 'Cargo.lock' -g '*.rs' \
  'tokio|spawn_blocking|Semaphore|execute_service_handler|struct ServerState' .

Repository: dev-dami/ignite

Length of output: 12060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Cargo workspace Tokio dependency =="
sed -n '1,30p' Cargo.toml
echo

echo "== ServerState definition and execute handler outline =="
sed -n '1,45p' ignite-http/src/server.rs
echo
sed -n '200,295p' ignite-http/src/server.rs
echo

echo "== Existing Semaphore/Concurrency controls =="
rg -n '\bSemaphore\b|semaphore|try_acquire|acquire_owned|tokio::sync' ignite-http/src/server.rs ignition-http ignite-shared ignite-core ignite-cli || true
echo

echo "== Runtime/blocking-task settings around Tokio main and serve =="
sed -n '1,90p' ignite-http/src/main.rs
echo

echo "== Static probe: count spawn_blocking under execute_service_handler and ServerState fields =="
python3 - <<'PY'
from pathlib import Path
p=Path('ignite-http/src/server.rs')
text=p.read_text()
start=text.find('    async fn execute_service_handler')
end=text.find('\n    pub fn create_router', start)
body=text[start:end] if start!=-1 and end!=-1 else ''
print("spawn_blocking_count=", body.count('spawn_blocking'))
print("ServerState fields in file:")
for i,line in enumerate(Path('ignite-http/src/server.rs').read_text().splitlines(),1):
    if 'pub struct ServerState' in line or 'Semaphore' in line or 'RateLimiter' in line:
        print(f"{i}: {line}")
PY

Repository: dev-dami/ignite

Length of output: 8973


Bound concurrent service executions before spawn_blocking.

ServerState only keeps the request rate limiter and has no execution semaphore. execute_service_handler queues a blocking microVM task for every accepted request, with no Tokio maximum-blocking limit configured. Add a shared bounded execution semaphore, use try_acquire_owned() before scheduling work, return 429 or 503 when capacity is full, and keep the permit until execute_service completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ignite-http/src/server.rs` around lines 259 - 286, Extend ServerState with a
shared semaphore sized to the allowed concurrent service executions, and
initialize it wherever the state is constructed. In execute_service_handler,
call try_acquire_owned() before spawn_blocking; return the established 429 or
503 response when no permit is available. Move the owned permit into the
blocking closure so it remains held through execute_service completion,
including error and panic paths.

Ok((preflight, metrics)) => (
StatusCode::OK,
Json(ExecuteResponse {
Expand Down Expand Up @@ -344,4 +398,94 @@ mod tests {
// Previously hardcoded to 0.1.0 while the crate was 0.9.0.
assert_eq!(env!("CARGO_PKG_VERSION"), "0.9.0");
}

fn test_state(services_path: PathBuf) -> Arc<ServerState> {
Arc::new(ServerState {
services_path,
api_key: None,
rate_limiter: RateLimiter::new(1000, 60),
kernel_path: None,
rootfs_path: None,
runtimes_root: None,
allowed_origins: Vec::new(),
})
}

async fn get(state: Arc<ServerState>, uri: &str) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;

let response = create_router(state)
.oneshot(
axum::http::Request::builder()
.uri(uri)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();

let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, json)
}

#[tokio::test]
async fn list_services_returns_directory_names_sorted() {
let dir = tempfile::tempdir().unwrap();
for name in ["zebra", "alpha", "middle"] {
fs::create_dir(dir.path().join(name)).unwrap();
}
// A stray file is not a service and must not be listed.
fs::write(dir.path().join("notes.txt"), b"x").unwrap();

let (status, body) = get(test_state(dir.path().to_path_buf()), "/services").await;

assert_eq!(status, StatusCode::OK);
assert_eq!(
body["services"],
serde_json::json!(["alpha", "middle", "zebra"])
);
}

#[tokio::test]
async fn list_services_reports_an_unreadable_directory_instead_of_an_empty_list() {
// Previously this swallowed the error and returned `{"services": []}`
// with HTTP 200, making a misconfigured path look like an empty one.
let missing = PathBuf::from("/nonexistent/ignite-services-should-not-exist");

let (status, body) = get(test_state(missing), "/services").await;

assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
body["error"].as_str().unwrap_or("").contains("Cannot read"),
"expected a read failure message, got {body}"
);
assert!(
body.get("services").is_none(),
"a failed listing must not report a services array"
);
}
Comment on lines +453 to +470

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a temporary path for the missing-directory test.

Line 457 embeds an absolute host path. The path can exist on a test host and makes the test depend on host filesystem layout. Create a missing child under tempfile::tempdir() instead.

As per coding guidelines: “Never introduce secrets, tokens, or host-specific paths into committed Rust code.”

Proposed fix
-        let missing = PathBuf::from("/nonexistent/ignite-services-should-not-exist");
+        let dir = tempfile::tempdir().unwrap();
+        let missing = dir.path().join("missing");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[tokio::test]
async fn list_services_reports_an_unreadable_directory_instead_of_an_empty_list() {
// Previously this swallowed the error and returned `{"services": []}`
// with HTTP 200, making a misconfigured path look like an empty one.
let missing = PathBuf::from("/nonexistent/ignite-services-should-not-exist");
let (status, body) = get(test_state(missing), "/services").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
body["error"].as_str().unwrap_or("").contains("Cannot read"),
"expected a read failure message, got {body}"
);
assert!(
body.get("services").is_none(),
"a failed listing must not report a services array"
);
}
#[tokio::test]
async fn list_services_reports_an_unreadable_directory_instead_of_an_empty_list() {
// Previously this swallowed the error and returned `{"services": []}`
// with HTTP 200, making a misconfigured path look like an empty one.
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing");
let (status, body) = get(test_state(missing), "/services").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
body["error"].as_str().unwrap_or("").contains("Cannot read"),
"expected a read failure message, got {body}"
);
assert!(
body.get("services").is_none(),
"a failed listing must not report a services array"
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ignite-http/src/server.rs` around lines 453 - 470, Update the test
list_services_reports_an_unreadable_directory_instead_of_an_empty_list to create
a tempfile::tempdir() and derive a guaranteed-missing child path from it,
replacing the hard-coded absolute PathBuf while preserving the existing
assertions.

Source: Coding guidelines


#[tokio::test]
async fn execute_rejects_invalid_service_names_before_touching_the_filesystem() {
use tower::ServiceExt;

let dir = tempfile::tempdir().unwrap();
let response = create_router(test_state(dir.path().to_path_buf()))
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/services/..%2f..%2fetc/execute")
.header("content-type", "application/json")
.body(axum::body::Body::from("{}"))
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}
Loading
Loading