feat: release Dever 0.1.5 with direct workers and long transactions
This commit is contained in:
1 parent
89629c2197
commit
a900d7e8db
153 files changed
+3676
-3708
No files matched your search
@@ -25,7 +25,8 @@ fn main() {
|
||||
|
||||
fn run() -> Result<i32, Box<dyn std::error::Error>> {
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
match arguments.next().as_deref() {
|
||||
let mode = arguments.next();
|
||||
match mode.as_deref() {
|
||||
Some("echo") => {
|
||||
for argument in arguments {
|
||||
std::io::stdout().write_all(argument.as_bytes())?;
|
||||
@@ -63,7 +64,7 @@ fn run() -> Result<i32, Box<dyn std::error::Error>> {
|
||||
stdout.join().map_err(|_| "stdout thread panicked")??;
|
||||
stderr.join().map_err(|_| "stderr thread panicked")??;
|
||||
}
|
||||
Some("wait") | Some("tree") => {
|
||||
Some("wait") | Some("tree") | Some("tree-exit") => {
|
||||
let directory = arguments.next().ok_or("missing marker directory")?;
|
||||
let directory = Path::new(&directory);
|
||||
let mut starts = std::fs::OpenOptions::new()
|
||||
@@ -71,13 +72,23 @@ fn run() -> Result<i32, Box<dyn std::error::Error>> {
|
||||
.append(true)
|
||||
.open(directory.join("starts"))?;
|
||||
starts.write_all(b"started\n")?;
|
||||
// Every lifecycle case gets a real descendant. PID namespace exit
|
||||
// must stop its heartbeat before the runtime returns cancellation.
|
||||
let mut child = std::process::Command::new(std::env::current_exe()?)
|
||||
.arg("heartbeat")
|
||||
.arg(directory)
|
||||
.spawn()?;
|
||||
std::fs::write(directory.join("ready"), b"ready")?;
|
||||
if mode.as_deref() == Some("tree-exit") {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
while !directory.join("heartbeat").exists() {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("descendant did not start".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let status = child.wait()?;
|
||||
return Ok(status.code().unwrap_or(92));
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
cmd fixture_member = app.fixture_member
|
||||
long cmd fixture_member = app.fixture_member
|
||||
@@ -4,8 +4,17 @@ fixture_member(
|
||||
display_name: Text,
|
||||
password: Secret
|
||||
) (result: BootstrapView) {
|
||||
input = domain.prepare(email, display_name)
|
||||
hash = blocking(dever.crypto.password_hash(password))
|
||||
result = fixture_store_member(selected_tenant_id, email, display_name, hash)
|
||||
}
|
||||
|
||||
transaction fixture_store_member(
|
||||
selected_tenant_id: platform.tenant.model.id,
|
||||
email: Text,
|
||||
display_name: Text,
|
||||
hash: Text
|
||||
) (result: BootstrapView) {
|
||||
input = domain.prepare(email, display_name)
|
||||
principal = model.principal.create(
|
||||
{
|
||||
display_name = input.name
|
||||
|
||||
@@ -40,6 +40,155 @@ fn reject(files: &[(&str, &str)], expected: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_and_long_entries_have_explicit_transaction_policies() {
|
||||
for entry in ["public post create = app.create", "cmd create = app.create"] {
|
||||
let files = [
|
||||
(
|
||||
"content/item/model.dever",
|
||||
"global type Item { name: Text }",
|
||||
),
|
||||
(
|
||||
"content/item/app.dever",
|
||||
"create(name: Text) (value: Text) { saved = model.create({ name = name })\nvalue = saved.name }",
|
||||
),
|
||||
("content/item/api.dever", entry),
|
||||
];
|
||||
check(&files);
|
||||
let long_entry = if entry.starts_with("public ") {
|
||||
entry.replace("public ", "public long ")
|
||||
} else {
|
||||
format!("long {entry}")
|
||||
};
|
||||
let mut long = files;
|
||||
long[2].1 = &long_entry;
|
||||
reject(
|
||||
&long,
|
||||
"long entry database writes must be enclosed in an explicit transaction",
|
||||
);
|
||||
let mut transactional = long;
|
||||
let app = format!("transaction {}", files[1].1);
|
||||
transactional[1].1 = &app;
|
||||
check(&transactional);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_orchestration_can_wait_between_explicit_transactions() {
|
||||
let files = [
|
||||
(
|
||||
"content/item/model.dever",
|
||||
"global type Item { name: Text }",
|
||||
),
|
||||
(
|
||||
"content/item/app.dever",
|
||||
"create() (count: Int) { first = store(\"first\")\ndever.task.sleep(1)\nsecond = store(\"second\")\ncount = first + second }\ntransaction store(name: Text) (count: Int) { saved = model.create({ name = name })\ncount = model.count() }",
|
||||
),
|
||||
(
|
||||
"content/item/api.dever",
|
||||
"public long post create = app.create\nlong cmd create = app.create",
|
||||
),
|
||||
];
|
||||
let source_map = sources(&files);
|
||||
let program = dever_core::check(&source_map).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let native = dever_core::native::emit_project(
|
||||
&program,
|
||||
&source_map,
|
||||
dever_runtime::config::RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!native.contains("let transaction = transaction_database.begin()"));
|
||||
assert!(native.contains("let transaction_owner = if _database.is_none()"));
|
||||
let (_root, settings) = auth_settings();
|
||||
let mut configured = identity_sources().to_vec();
|
||||
configured.extend([
|
||||
files[0],
|
||||
files[1],
|
||||
("content/item/api/admin/create.dever", files[2].1),
|
||||
]);
|
||||
let configured_sources = sources(&configured);
|
||||
let configured_program = dever_core::check_with_settings(&configured_sources, &settings)
|
||||
.unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
dever_core::llvm::emit_application(&configured_program, &configured_sources).unwrap();
|
||||
let ordinary_entry = files[2].1.replace("long ", "");
|
||||
let mut ordinary = files;
|
||||
ordinary[2].1 = &ordinary_entry;
|
||||
reject(&ordinary, "automatic entry transactions cannot contain");
|
||||
let mut invalid = files;
|
||||
let app = files[1].1.replace("create()", "transaction create()");
|
||||
invalid[1].1 = &app;
|
||||
reject(
|
||||
&invalid,
|
||||
"transaction functions cannot start concurrent work",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_entries_require_long_before_using_staged_transactions() {
|
||||
let model = "global type Item { name: Text }";
|
||||
let ordinary = "apply() () { saved = model.create({ name = \"job\" }) }";
|
||||
let transactional = "transaction apply() () { saved = model.create({ name = \"job\" }) }";
|
||||
for long in [false, true] {
|
||||
let prefix = if long { "long " } else { "" };
|
||||
let job = format!(
|
||||
"database default\n{prefix}job apply() () retry(3) timeout(30000) {{ app.apply() }}"
|
||||
);
|
||||
let mut files = [
|
||||
("content/item/model.dever", model),
|
||||
("content/item/app.dever", ordinary),
|
||||
("content/item/job.dever", job.as_str()),
|
||||
];
|
||||
if long {
|
||||
reject(&files, "long entry database writes must be enclosed");
|
||||
files[1].1 = transactional;
|
||||
}
|
||||
check(&files);
|
||||
}
|
||||
let files = [
|
||||
("content/item/model.dever", model),
|
||||
(
|
||||
"content/item/app.dever",
|
||||
"apply() () { save()\ndever.task.sleep(1) }\ntransaction save() () { saved = model.create({ name = \"job\" }) }",
|
||||
),
|
||||
(
|
||||
"content/item/job.dever",
|
||||
"database default\njob apply() () retry(3) timeout(30000) { app.apply() }",
|
||||
),
|
||||
];
|
||||
reject(&files, "declare the entry 'long'");
|
||||
let mut long = files;
|
||||
long[2].1 = "database default\nlong job apply() () retry(3) timeout(30000) { app.apply() }";
|
||||
check(&long);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_is_an_entry_modifier_not_an_app_function_modifier() {
|
||||
for (path, text) in [
|
||||
("content/item/api.dever", "long rest"),
|
||||
(
|
||||
"content/item/api.dever",
|
||||
"long public post create = app.create",
|
||||
),
|
||||
("content/item/app.dever", "long create() () {}"),
|
||||
("content/item/app.dever", "long transaction create() () {}"),
|
||||
(
|
||||
"content/item/api.dever",
|
||||
"public long cmd create = app.create",
|
||||
),
|
||||
] {
|
||||
let mut source = SourceMap::default();
|
||||
let id = source.add(path, text);
|
||||
assert!(dever_core::parse(source.get(id)).is_err());
|
||||
}
|
||||
check(&[(
|
||||
"content/item/app.dever",
|
||||
"long() (value: Int) { value = 1 }",
|
||||
)]);
|
||||
}
|
||||
|
||||
fn auth_settings() -> (temp::TemporaryDirectory, Settings) {
|
||||
let root = temp::TemporaryDirectory::new();
|
||||
fs::create_dir(root.path().join("config")).unwrap();
|
||||
@@ -698,7 +847,7 @@ fn sql_projection_files(sqlite: &str, postgres: &str, method: &str) -> [(&'stati
|
||||
(
|
||||
"news/article/app.dever",
|
||||
"type View { name: Text }\n\
|
||||
read(name: Text, size: Int) (rows: List<View>) {\n\
|
||||
transaction read(name: Text, size: Int) (rows: List<View>) {\n\
|
||||
rows = model.lookup(name, size)\n}\n"
|
||||
.into(),
|
||||
),
|
||||
@@ -767,7 +916,7 @@ fn unproven_sql_keeps_its_write_effect_in_each_dialect() {
|
||||
|
||||
#[test]
|
||||
fn get_rejects_writes_reached_through_cross_domain_app_calls() {
|
||||
let writer = "create(name: Text) (value: Text) { account = model.create({ name = text.trim(name) })\nvalue = account.name }";
|
||||
let writer = "transaction create(name: Text) (value: Text) { account = model.create({ name = text.trim(name) })\nvalue = account.name }";
|
||||
reject(
|
||||
&[
|
||||
(
|
||||
|
||||
@@ -37,54 +37,20 @@ fn project(setting: &str) -> support::temp::TemporaryDirectory {
|
||||
root
|
||||
}
|
||||
|
||||
#[cfg(feature = "external")]
|
||||
#[test]
|
||||
fn worker_file_grants_are_explicit_and_cannot_write_executable_cache() {
|
||||
let setting = |path: &str, write: bool| {
|
||||
serde_json::json!({
|
||||
"adapter": {"sample.worker": {"files": {"content": {"path": path, "write": write}}}}
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
let capabilities = vec!["file".into()];
|
||||
let root = project(&setting("data/upload", true));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
let grants = settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap();
|
||||
assert_eq!(grants.len(), 1);
|
||||
assert_eq!(grants[0].source, root.path().join("data/upload"));
|
||||
assert_eq!(grants[0].destination, std::path::Path::new("/data/content"));
|
||||
assert!(grants[0].writable);
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &[])
|
||||
.unwrap_err()
|
||||
.contains("file capability")
|
||||
);
|
||||
for path in [
|
||||
"data",
|
||||
"data/cache",
|
||||
"data/cache/lib",
|
||||
"data/cache/lib/worker",
|
||||
] {
|
||||
let root = project(&setting(path, true));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap_err()
|
||||
.contains("executable resource cache")
|
||||
fn obsolete_worker_mount_and_gpu_grants_are_rejected() {
|
||||
for field in ["files", "gpu"] {
|
||||
let root = project(
|
||||
&serde_json::json!({
|
||||
"adapter": {"sample.worker": {field: {}}}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
for path in ["../private", "/etc", ""] {
|
||||
let root = project(&setting(path, false));
|
||||
let settings = Settings::load_project(root.path()).unwrap();
|
||||
assert!(
|
||||
settings
|
||||
.external_grants("sample.worker", &capabilities)
|
||||
.unwrap_err()
|
||||
.contains("relative to the application")
|
||||
Settings::load_project(root.path())
|
||||
.unwrap()
|
||||
.select_adapter("sample.worker", &["default"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1060,8 +1060,13 @@ deliver(input: Work) () {
|
||||
);
|
||||
|
||||
assert!(
|
||||
std::process::Command::new("kill")
|
||||
.args(["-TERM", &worker.0.id().to_string()])
|
||||
std::process::Command::new("/bin/sh")
|
||||
.args([
|
||||
"-c",
|
||||
"kill -TERM \"$1\"",
|
||||
"dever-test-signal",
|
||||
&worker.0.id().to_string()
|
||||
])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
@@ -1202,8 +1207,13 @@ fn one_native_application_gates_api_worker_and_all_modes() {
|
||||
panic!("{mode} did not reach its service state: {stderr}");
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("kill")
|
||||
.args(["-TERM", &child.0.id().to_string()])
|
||||
std::process::Command::new("/bin/sh")
|
||||
.args([
|
||||
"-c",
|
||||
"kill -TERM \"$1\"",
|
||||
"dever-test-signal",
|
||||
&child.0.id().to_string()
|
||||
])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
@@ -1269,8 +1279,13 @@ fn worker_signal_stops_claiming_and_drains_or_cancels_before_database_close() {
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("kill")
|
||||
.args(["-TERM", &child.id().to_string()])
|
||||
std::process::Command::new("/bin/sh")
|
||||
.args([
|
||||
"-c",
|
||||
"kill -TERM \"$1\"",
|
||||
"dever-test-signal",
|
||||
&child.id().to_string()
|
||||
])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
|
||||
@@ -9,8 +9,8 @@ use dever_runtime::wire::Encoder;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[path = "support/sandbox.rs"]
|
||||
mod sandbox;
|
||||
#[path = "support/process_runtime.rs"]
|
||||
mod process_runtime;
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
@@ -35,7 +35,6 @@ impl Fixture {
|
||||
fs::create_dir(directory.path().join("markers")).unwrap();
|
||||
fs::create_dir(directory.path().join("config")).unwrap();
|
||||
let settings = json!({"adapter": {PORT: {
|
||||
"files": {"markers": {"path":"markers", "write":true}},
|
||||
"command": {"timeout_ms":timeout_ms, "output_limit":output_limit}
|
||||
}}});
|
||||
fs::write(
|
||||
@@ -44,13 +43,13 @@ impl Fixture {
|
||||
)
|
||||
.unwrap();
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let mut assets = sandbox::assets(&workspace);
|
||||
assets.push(sandbox::Asset {
|
||||
let mut assets = process_runtime::assets(&workspace);
|
||||
assets.push(process_runtime::Asset {
|
||||
path: "commands/fixture/tool".into(),
|
||||
bytes: program,
|
||||
executable: true,
|
||||
});
|
||||
assets.push(sandbox::Asset {
|
||||
assets.push(process_runtime::Asset {
|
||||
path: "tool.dever-command.json".into(),
|
||||
bytes: json!({"format":"dever-command-launch-v1", "ecosystem":"command", "entry":"tool",
|
||||
"executable":"commands/fixture/tool", "arguments":[], "working_directory":"commands/fixture"})
|
||||
@@ -104,6 +103,12 @@ struct Output {
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn tree(directory: std::path::PathBuf) -> Result<(), String> {
|
||||
invoke(&["tree", directory.to_str().unwrap()], None)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn invoke(args: &[&str], stdin: Option<&[u8]>) -> Result<Output, String> {
|
||||
let mut writer = Encoder::default();
|
||||
writer.begin_object()?;
|
||||
@@ -255,18 +260,12 @@ async fn heartbeat(fixture: &Fixture) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_exec_failure_is_a_fault_not_a_program_exit_code() {
|
||||
fn invalid_executable_is_rejected_before_a_program_exit_code_exists() {
|
||||
let fixture = Fixture::with_program(5000, 1024 * 1024, b"not an ELF executable\n".to_vec());
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let error = invoke(&[], None)
|
||||
.await
|
||||
.err()
|
||||
.ok_or("exec failure was returned as an exit code")?;
|
||||
assert!(
|
||||
error.contains("startup failed") && error.contains("cannot execute"),
|
||||
"{error}"
|
||||
);
|
||||
let error = fixture.start().await.unwrap_err();
|
||||
assert!(error.contains("invalid executable ELF"), "{error}");
|
||||
assert!(!fixture.marker("starts").exists());
|
||||
component::shutdown().await
|
||||
})
|
||||
.unwrap();
|
||||
@@ -294,15 +293,26 @@ async fn assert_reaped(fixture: &Fixture) {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_root_exit_stops_descendants_holding_output_pipes() {
|
||||
let fixture = Fixture::new(5000, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let directory = fixture.marker("");
|
||||
let output = invoke(&["tree-exit", directory.to_str().unwrap()], None).await?;
|
||||
assert_eq!(output.code, 0);
|
||||
assert_reaped(&fixture).await;
|
||||
component::shutdown().await
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_reaps_descendants_and_keeps_the_adapter_available() {
|
||||
let fixture = Fixture::new(800, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let call = dever_runtime::task::run(async {
|
||||
invoke(&["tree", "/data/markers"], None).await.map(|_| ())
|
||||
})
|
||||
.await?;
|
||||
let call = dever_runtime::task::run(tree(fixture.marker(""))).await?;
|
||||
heartbeat(&fixture).await?;
|
||||
let error = dever_runtime::task::wait(call)
|
||||
.await
|
||||
@@ -321,10 +331,7 @@ fn cancellation_and_root_close_reap_descendants_without_replay() {
|
||||
let fixture = Fixture::new(5000, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let call = dever_runtime::task::run(async {
|
||||
invoke(&["tree", "/data/markers"], None).await.map(|_| ())
|
||||
})
|
||||
.await?;
|
||||
let call = dever_runtime::task::run(tree(fixture.marker(""))).await?;
|
||||
heartbeat(&fixture).await?;
|
||||
if root_close {
|
||||
// Return while the call is active: the invocation closes its
|
||||
@@ -361,10 +368,7 @@ fn queued_cancellation_does_not_wait_for_an_unrelated_running_command() {
|
||||
let fixture = Fixture::new(5000, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let active = dever_runtime::task::run(async {
|
||||
invoke(&["tree", "/data/markers"], None).await.map(|_| ())
|
||||
})
|
||||
.await?;
|
||||
let active = dever_runtime::task::run(tree(fixture.marker(""))).await?;
|
||||
heartbeat(&fixture).await?;
|
||||
let (entered, admitted) = tokio::sync::oneshot::channel();
|
||||
let queued = dever_runtime::task::run(async move {
|
||||
@@ -395,10 +399,7 @@ fn task_deadline_waits_for_command_process_cleanup() {
|
||||
let fixture = Fixture::new(5000, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let call = dever_runtime::task::run(async {
|
||||
invoke(&["tree", "/data/markers"], None).await.map(|_| ())
|
||||
})
|
||||
.await?;
|
||||
let call = dever_runtime::task::run(tree(fixture.marker(""))).await?;
|
||||
heartbeat(&fixture).await?;
|
||||
assert_eq!(dever_runtime::task::wait_timeout(call, 10).await?, None);
|
||||
assert_reaped(&fixture).await;
|
||||
@@ -412,10 +413,7 @@ fn explicit_shutdown_drains_active_and_queued_commands() {
|
||||
let fixture = Fixture::new(5000, 1024 * 1024);
|
||||
dever_runtime::task::run_entry(async {
|
||||
fixture.start().await?;
|
||||
let active = dever_runtime::task::run(async {
|
||||
invoke(&["tree", "/data/markers"], None).await.map(|_| ())
|
||||
})
|
||||
.await?;
|
||||
let active = dever_runtime::task::run(tree(fixture.marker(""))).await?;
|
||||
heartbeat(&fixture).await?;
|
||||
let queued =
|
||||
dever_runtime::task::run(async { invoke(&["echo", "queued"], None).await.map(|_| ()) })
|
||||
|
||||
@@ -5,8 +5,8 @@ use dever_runtime::component::{self, Definition, Reply};
|
||||
use dever_runtime::wire::{Encoded, Encoder};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[path = "support/sandbox.rs"]
|
||||
mod sandbox;
|
||||
#[path = "support/process_runtime.rs"]
|
||||
mod process_runtime;
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
@@ -28,8 +28,6 @@ fn component_protocol_and_lifecycle_are_bounded() {
|
||||
let fixture = install_fixture();
|
||||
let result = dever_runtime::task::run_entry(async {
|
||||
fixture.prepare()?;
|
||||
// One invocation keeps the same deployment grants even if settings are
|
||||
// replaced between Worker startup, cancellation and restart.
|
||||
fs::write(
|
||||
fixture.directory.path().join("config/setting.json"),
|
||||
"invalid",
|
||||
@@ -252,6 +250,12 @@ fn definition(
|
||||
mode: &'static str,
|
||||
timeout_ms: u64,
|
||||
) -> Definition {
|
||||
let (_, setting) = component::adapter_settings()
|
||||
.unwrap()
|
||||
.select_adapter("notification.delivery", &["fixture"])
|
||||
.unwrap();
|
||||
let mut setting: serde_json::Value = serde_json::from_str(&setting.unwrap()).unwrap();
|
||||
setting["mode"] = serde_json::json!(mode);
|
||||
Definition {
|
||||
ecosystem: "exec".into(),
|
||||
key: key.into(),
|
||||
@@ -261,9 +265,7 @@ fn definition(
|
||||
schema: "a19ca3e615ce5e71".into(),
|
||||
capabilities: vec!["network".into(), "file".into()],
|
||||
operations: OPERATIONS.iter().map(|name| (*name).into()).collect(),
|
||||
setting: Some(format!(
|
||||
r#"{{"mode":"{mode}","marker":"/data/lifecycle/worker"}}"#
|
||||
)),
|
||||
setting: Some(setting.to_string()),
|
||||
timeout_ms,
|
||||
}
|
||||
}
|
||||
@@ -295,15 +297,15 @@ fn install_fixture() -> InstalledFixture {
|
||||
let directory = temp::TemporaryDirectory::new();
|
||||
fs::create_dir(directory.path().join("lifecycle")).unwrap();
|
||||
fs::create_dir(directory.path().join("config")).unwrap();
|
||||
fs::write(directory.path().join("config/setting.json"), r#"{"adapter":{"notification.delivery":{"files":{"lifecycle":{"path":"lifecycle","write":true}}}}}"#).unwrap();
|
||||
fs::write(directory.path().join("config/setting.json"), serde_json::json!({"adapter":{"notification.delivery":{"setting":{"marker":directory.path().join("lifecycle/worker")}}}}).to_string()).unwrap();
|
||||
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let mut assets = sandbox::assets(&workspace);
|
||||
assets.push(sandbox::Asset {
|
||||
let mut assets = process_runtime::assets(&workspace);
|
||||
assets.push(process_runtime::Asset {
|
||||
path: "workers/fixture/worker".into(),
|
||||
bytes: fs::read(env!("CARGO_BIN_EXE_component-fixture")).unwrap(),
|
||||
executable: true,
|
||||
});
|
||||
assets.push(sandbox::Asset {
|
||||
assets.push(process_runtime::Asset {
|
||||
path: "worker.dever-worker.json".into(),
|
||||
bytes: br#"{"format":"dever-worker-launch-v1","ecosystem":"exec","entry":"worker","executable":"workers/fixture/worker","arguments":[],"working_directory":"workers/fixture"}"#.to_vec(), executable: false,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#[path = "support/sandbox.rs"]
|
||||
mod sandbox;
|
||||
#[path = "support/process_runtime.rs"]
|
||||
mod process_runtime;
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
@@ -385,7 +385,7 @@ fn existing_cache_permissions_are_checked_before_reuse_and_launch() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires explicitly prepared sandbox assets and the private Rust bootstrap compiler"]
|
||||
#[ignore = "requires explicitly prepared process_runtime assets and the private Rust bootstrap compiler"]
|
||||
fn packaged_exec_runs_without_source_worker_or_inherited_environment() {
|
||||
let root = TemporaryDirectory::new();
|
||||
let mut sources = dever_core::source::SourceMap::default();
|
||||
@@ -424,7 +424,9 @@ fn packaged_exec_runs_without_source_worker_or_inherited_environment() {
|
||||
bytes: manifest,
|
||||
executable: false,
|
||||
});
|
||||
for asset in sandbox::assets(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")) {
|
||||
for asset in
|
||||
process_runtime::assets(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
|
||||
{
|
||||
resources.push(dever_core::native::EmbeddedResource {
|
||||
path: asset.path,
|
||||
sha256: digest(&asset.bytes),
|
||||
@@ -446,7 +448,7 @@ fn packaged_exec_runs_without_source_worker_or_inherited_environment() {
|
||||
fs::create_dir(root.path().join("lifecycle")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("config/setting.json"),
|
||||
r#"{"adapter":{"notification.mail":{"use":"default","setting":{"mode":"normal","marker":"/data/lifecycle/worker"},"files":{"lifecycle":{"path":"lifecycle","write":true}}}}}"#,
|
||||
serde_json::json!({"adapter":{"notification.mail":{"use":"default","setting":{"mode":"normal","marker":root.path().join("lifecycle/worker")}}}}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!root.path().join(&entry).exists());
|
||||
|
||||
@@ -56,6 +56,18 @@ fn assert_roundtrip_at(path: &FilePath, source: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_entry_modifiers_roundtrip_without_changing_visibility_or_job_policy() {
|
||||
let api = "public long post generate=app.generate\nlong cmd generate=app.generate\npost create=app.create\n";
|
||||
let formatted = assert_roundtrip_at(FilePath::new("content/item/api.dever"), api);
|
||||
assert!(formatted.contains("public long post generate = app.generate"));
|
||||
assert!(formatted.contains("long cmd generate = app.generate"));
|
||||
assert!(formatted.contains("post create = app.create"));
|
||||
let job = "long job generate()()retry(3)timeout(30000){app.generate()}";
|
||||
let formatted = assert_roundtrip_at(FilePath::new("content/item/job.dever"), job);
|
||||
assert!(formatted.contains("long job generate() () retry(3) timeout(30000)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_layout_preserves_field_evaluation_order_and_groups() {
|
||||
let source = "type Pair{a:Int\nb:Int}\npublic main( )(result:Pair){result=Pair{b=second()\na=first()}}\n";
|
||||
|
||||
@@ -6,6 +6,9 @@ use dever_runtime::http::{Header, Request};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[path = "support/process.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod process;
|
||||
#[path = "support/temp.rs"]
|
||||
mod temp;
|
||||
|
||||
@@ -17,6 +20,81 @@ fn source(files: &[(&str, &str)]) -> SourceMap {
|
||||
sources
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
#[test]
|
||||
fn command_releases_short_transactions_before_waiting_and_preserves_committed_stages() {
|
||||
let sources = source(&[
|
||||
(
|
||||
"content/item/model.dever",
|
||||
"global type Item { name: Text unique }",
|
||||
),
|
||||
(
|
||||
"content/item/app.dever",
|
||||
"transaction store(name: Text) (value: Text) { saved = model.create({ name = name })\nvalue = saved.name }\ncount() (total: Int) { total = model.count() }\nperform() (total: Int) { saved = store(\"first\")\ntask = run(count())\ntotal = wait(task) }\nfail_later() (value: Text) { committed = store(\"second\")\nvalue = store(committed) }",
|
||||
),
|
||||
(
|
||||
"content/item/api.dever",
|
||||
"long cmd perform = app.perform\nlong cmd fail_later = app.fail_later\ncmd fail_atomic = app.fail_later\ncmd count = app.count",
|
||||
),
|
||||
]);
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
|
||||
let native = dever_core::native::compile_project(
|
||||
&program,
|
||||
&sources,
|
||||
&rustc,
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let root = native.executable().parent().unwrap();
|
||||
fs::create_dir_all(root.join("config")).unwrap();
|
||||
fs::write(
|
||||
root.join("config/setting.json"),
|
||||
r#"{"database":{"default":{"type":"sqlite","path":"data/db/app.db","max_connections":1}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let invoke = |operation: &str| {
|
||||
let mut child = Command::new(native.executable())
|
||||
.current_dir(root)
|
||||
.args([operation, "{}"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
process::wait(&mut child, std::time::Duration::from_secs(10)).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
};
|
||||
let first = invoke("content.item.perform");
|
||||
assert!(
|
||||
first.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&first.stderr)
|
||||
);
|
||||
let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap();
|
||||
assert_eq!(first["data"], 1);
|
||||
let atomic = invoke("content.item.fail_atomic");
|
||||
assert!(!atomic.status.success());
|
||||
assert!(String::from_utf8_lossy(&atomic.stderr).contains("UNIQUE"));
|
||||
let retained = invoke("content.item.count");
|
||||
assert!(retained.status.success());
|
||||
let retained: serde_json::Value = serde_json::from_slice(&retained.stdout).unwrap();
|
||||
assert_eq!(retained["data"], 1);
|
||||
let failed = invoke("content.item.fail_later");
|
||||
assert!(!failed.status.success());
|
||||
assert!(String::from_utf8_lossy(&failed.stderr).contains("UNIQUE"));
|
||||
let count = invoke("content.item.count");
|
||||
assert!(
|
||||
count.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&count.stderr)
|
||||
);
|
||||
let count: serde_json::Value = serde_json::from_slice(&count.stdout).unwrap();
|
||||
assert_eq!(count["data"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_entry_collects_api_and_command_without_main() {
|
||||
let sources = source(&[
|
||||
@@ -175,13 +253,13 @@ fn generated_write_entry_rejects_detached_concurrency() {
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
rendered.contains("transaction cannot start concurrent work"),
|
||||
rendered.contains("automatic entry transactions cannot contain"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_write_entry_allows_structured_blocking_work() {
|
||||
fn generated_long_entry_keeps_password_work_outside_its_write_transaction() {
|
||||
let sources = source(&[
|
||||
(
|
||||
"user/account/model.dever",
|
||||
@@ -201,7 +279,10 @@ transaction persist(hash: Text) (id: model.id) {
|
||||
id = stored.id
|
||||
}"#,
|
||||
),
|
||||
("user/account/api.dever", "post register = app.register"),
|
||||
(
|
||||
"user/account/api.dever",
|
||||
"long post register = app.register",
|
||||
),
|
||||
]);
|
||||
let program = dever_core::check(&sources).unwrap_or_else(|errors| {
|
||||
panic!(
|
||||
|
||||
@@ -11,6 +11,76 @@ const ADAPTER: &str = "port.read() (value: Int) { value = 7 }";
|
||||
const COMMAND_PORT: &str = "execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error";
|
||||
const COMMAND_APP: &str = "read() (value: Int) { output = port.execute([\"argument with spaces\"], null)\n value = output.code }";
|
||||
|
||||
#[test]
|
||||
fn external_worker_orchestration_requires_an_explicit_long_entry() {
|
||||
for adapter in [
|
||||
"external exec \"worker/mail\" {}",
|
||||
"external exec \"worker/mail\" { allow gpu }",
|
||||
] {
|
||||
for long in [false, true] {
|
||||
let entry = if long {
|
||||
"long cmd read = app.read"
|
||||
} else {
|
||||
"cmd read = app.read"
|
||||
};
|
||||
let source = sources(
|
||||
PORT,
|
||||
adapter,
|
||||
&[
|
||||
("notification/mail/model.dever", "type Mail { value: Int }"),
|
||||
(
|
||||
"notification/mail/app.dever",
|
||||
"type ReadResult { Read(value: Int)\n error Unavailable(message: Text) }\nread() (value: Int) { value = port.read()\nstore(value) }\ntransaction store(value: Int) () { stored = model.create({ value = value }) }",
|
||||
),
|
||||
("notification/mail/api.dever", entry),
|
||||
],
|
||||
);
|
||||
if long {
|
||||
let program =
|
||||
dever_core::check(&source).unwrap_or_else(|errors| panic!("{errors:?}"));
|
||||
let native = dever_core::native::emit_project(
|
||||
&program,
|
||||
&source,
|
||||
dever_runtime::config::RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!native.contains("let transaction = transaction_database.begin()"));
|
||||
dever_core::llvm::emit_application(&program, &source).unwrap();
|
||||
} else {
|
||||
rejected(&source, "declare the entry 'long'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactions_reject_external_workers_even_without_declared_io_capabilities() {
|
||||
for adapter in [
|
||||
"external exec \"worker/mail\" {}",
|
||||
"external exec \"worker/mail\" { allow gpu }",
|
||||
] {
|
||||
let source = sources(
|
||||
PORT,
|
||||
adapter,
|
||||
&[
|
||||
("notification/mail/model.dever", "type Mail { value: Int }"),
|
||||
(
|
||||
"notification/mail/app.dever",
|
||||
"type ReadResult { Read(value: Int)\n error Unavailable(message: Text) }\ntransaction save() (value: Int) { value = port.read()\nstored = model.create({ value = value }) }",
|
||||
),
|
||||
("notification/mail/api.dever", "cmd save = app.save"),
|
||||
],
|
||||
);
|
||||
rejected(
|
||||
&source,
|
||||
"transaction functions cannot start concurrent work or external I/O",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn command_sources(port: &str, adapter: &str, app: &str) -> SourceMap {
|
||||
sources(port, adapter, &[("notification/mail/app.dever", app)])
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ fn diagnostics(sources: &SourceMap) -> String {
|
||||
|
||||
// Code-generation assertions may inspect unbound HIR; an executable API must
|
||||
// use the same configured site/auth contract as the CLI.
|
||||
fn compile_authenticated_rest(mut sources: SourceMap) {
|
||||
fn compile_authenticated_rest(mut sources: SourceMap, user_id_type: &str) {
|
||||
sources.add("access/user/model.dever", "global type User { name: Text }");
|
||||
sources.add(
|
||||
"access/tenant/model.dever",
|
||||
@@ -30,16 +30,18 @@ fn compile_authenticated_rest(mut sources: SourceMap) {
|
||||
);
|
||||
sources.add(
|
||||
"access/session/app.dever",
|
||||
r#"type Identity {
|
||||
format!(
|
||||
r#"type Identity {{
|
||||
id: Text
|
||||
user_id: access.user.model.id?
|
||||
user_id: {user_id_type}?
|
||||
tenant_id: access.tenant.model.id?
|
||||
}
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity { id = claims.subject
|
||||
}}
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {{
|
||||
identity = Identity {{ id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null }
|
||||
}"#,
|
||||
tenant_id = null }}
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
let directory = TemporaryDirectory::new();
|
||||
std::fs::create_dir(directory.path().join("config")).unwrap();
|
||||
@@ -109,7 +111,7 @@ fn rest_derives_the_public_model_without_an_app_wrapper() {
|
||||
assert!(generated.contains("transaction.commit().await"));
|
||||
assert!(generated.contains("rest_rollback(transaction).await"));
|
||||
assert!(generated.contains("dever_runtime::api::text_bounds(\"name\""));
|
||||
compile_authenticated_rest(sources);
|
||||
compile_authenticated_rest(sources, "access.user.model.id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,7 +186,7 @@ fn rest_owner_is_server_bound_and_scopes_every_database_statement() {
|
||||
] {
|
||||
assert!(generated.contains(sql), "missing {sql}: {generated}");
|
||||
}
|
||||
compile_authenticated_rest(sources);
|
||||
compile_authenticated_rest(sources, "user.account.model.id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -378,7 +380,7 @@ fn rest_field_bindings_are_checked_and_emit_static_search_sql() {
|
||||
generated.contains("SELECT COUNT(*) FROM \\\"profile\\\" WHERE \\\"user_id\\\" = ?1"),
|
||||
"{generated}"
|
||||
);
|
||||
compile_authenticated_rest(sources);
|
||||
compile_authenticated_rest(sources, "user.account.model.id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,7 +67,7 @@ transaction store(credential_id: model.id) (id: model.id) {
|
||||
),
|
||||
(
|
||||
"user/account/api/front/account.dever",
|
||||
"public post login = app.login",
|
||||
"public long post login = app.login",
|
||||
),
|
||||
]);
|
||||
let program = checked_platform(&sources);
|
||||
@@ -106,7 +106,7 @@ login(credential_id: model.id, password: Secret) (id: model.id) {
|
||||
),
|
||||
(
|
||||
"user/account/api/front/account.dever",
|
||||
"public post login = app.login",
|
||||
"public long post login = app.login",
|
||||
),
|
||||
],
|
||||
"explicit transaction function",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Asset {
|
||||
pub path: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub executable: bool,
|
||||
}
|
||||
|
||||
pub fn assets(_workspace: &Path) -> Vec<Asset> {
|
||||
let prepared = std::path::PathBuf::from(
|
||||
std::env::var_os("DEVER_PROCESS_RUNTIME")
|
||||
.expect("set DEVER_PROCESS_RUNTIME to explicit native runtime assets"),
|
||||
);
|
||||
assert!(prepared.is_absolute());
|
||||
let mut assets = Vec::new();
|
||||
for entry in fs::read_dir(prepared.join("lib")).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
assets.push(Asset {
|
||||
path: format!("process-runtime/lib/{name}"),
|
||||
bytes: fs::read(entry.path()).unwrap(),
|
||||
executable: name.starts_with("ld-linux"),
|
||||
});
|
||||
}
|
||||
assets.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
assets
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Explicit private fixture assets; never consult PATH or download a tool.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub struct Asset {
|
||||
pub path: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub executable: bool,
|
||||
}
|
||||
|
||||
pub fn assets(workspace: &Path) -> Vec<Asset> {
|
||||
let prepared = workspace.join("target/sandbox-inputs/assets");
|
||||
let mut assets = Vec::new();
|
||||
for directory in ["bin", "lib"] {
|
||||
for entry in fs::read_dir(prepared.join(directory))
|
||||
.expect("prepare target/sandbox-inputs assets explicitly")
|
||||
{
|
||||
let entry = entry.unwrap();
|
||||
assert!(entry.file_type().unwrap().is_file());
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
if directory == "bin" && name == "guard" {
|
||||
continue;
|
||||
}
|
||||
assets.push(Asset {
|
||||
path: format!("sandbox/{directory}/{name}"),
|
||||
bytes: fs::read(entry.path()).unwrap(),
|
||||
executable: directory == "bin" || name.starts_with("ld-linux"),
|
||||
});
|
||||
}
|
||||
}
|
||||
assets.push(Asset {
|
||||
path: "sandbox/bin/guard".into(),
|
||||
bytes: fs::read(workspace.join("target/debug/dever-sandbox-guard"))
|
||||
.expect("build the current dever-sandbox-guard fixture explicitly"),
|
||||
executable: true,
|
||||
});
|
||||
assets.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
assets
|
||||
}
|
||||
@@ -267,7 +267,7 @@ fn upload_route_native_compiles_with_owned_resource() {
|
||||
let native = dever_core::native::compile_project(
|
||||
&program,
|
||||
&sources,
|
||||
std::ffi::OsStr::new("/root/.cargo/bin/rustc"),
|
||||
std::ffi::OsStr::new("rustc"),
|
||||
dever_runtime::config::RuntimeProfile::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Reference in new issue
Block a user