Skip to content

Commit 8cec12f

Browse files
authored
Merge pull request #723 from obeli-sk/exec-stdin-contract-change
exec: Move secrets to "secrets", allow passing params to stdin
2 parents f18f2ca + e4f52f8 commit 8cec12f

14 files changed

Lines changed: 162 additions & 37 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- *(deployment)* Verify a user-supplied `content_digest` at submit time, in addition to runtime.
1313
- *(deployment)* Reject at submit time deployments where two distinct deployment-owned sources (inline/owned scripts or backtrace sources) resolve to the same `file_name`, since `deployment get` could not recreate both on disk.
1414
- *(cli)* `deployment active` prints the ID of the currently active deployment.
15+
- *(deployment)* `activity_exec` components gained `params_via_stdin` (default `false`). When enabled,
16+
parameters are passed to the program via the stdin JSON `params` array instead of argv, allowing
17+
payloads larger than the `execve` argument-size limit.
1518

1619
### Changed
1720

21+
- *(deployment)* [**breaking**] **`activity_exec` secrets stdin format changed.** Secrets are now nested under a
22+
`secrets` key (`{"secrets":{"KEY":"value",...}}`) instead of being the top-level object, so they can
23+
coexist with the new `params` key. Scripts that parsed `.KEY` from stdin must now read `.secrets.KEY`.
24+
1825
- *(cli)* `deployment show <ID>` now prints the reconstructed `deployment.toml` (with local file
1926
references, the same TOML `deployment get` writes) instead of the raw canonical config. Pass a
2027
FILE argument to print a single deployment-owned source file as `deployment get` would serialize

assets/schemas/deployment-canonical.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,10 @@
772772
"type": "null"
773773
}
774774
]
775+
},
776+
"params_via_stdin": {
777+
"type": "boolean",
778+
"default": false
775779
}
776780
},
777781
"additionalProperties": false,

assets/schemas/oci-metadata-annotation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@
194194
],
195195
"default": null
196196
},
197+
"params_via_stdin": {
198+
"type": "boolean",
199+
"default": false
200+
},
197201
"component_type": {
198202
"type": "string",
199203
"const": "activity_exec"

assets/schemas/toml/deployment.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,11 @@
838838
}
839839
],
840840
"default": null
841+
},
842+
"params_via_stdin": {
843+
"description": "Pass parameters to the program via the stdin JSON `parameters` array instead\nof argv. Use this for large payloads that would exceed the `execve` argument-size\nlimit. Defaults to `false` (parameters passed as command-line arguments).",
844+
"type": "boolean",
845+
"default": false
841846
}
842847
},
843848
"additionalProperties": false,

crates/deployment-config/src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,8 @@ pub struct ActivityExecComponentConfigResolved {
570570
pub env_vars: Vec<EnvVarConfig>,
571571
pub max_output_bytes: u64,
572572
pub secrets: Option<ExecSecretsToml>,
573+
#[serde(default)]
574+
pub params_via_stdin: bool,
573575
}
574576

575577
#[derive(Debug, Deserialize, Serialize, Clone, Copy, JsonSchema, PartialEq)]

crates/wasm-workers/src/activity/activity_exec_worker.rs

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use concepts::{
1717
use executor::worker::{
1818
FatalError, RunFinished, Worker, WorkerContext, WorkerError, WorkerResult, WorkerResultOk,
1919
};
20+
use indexmap::IndexMap;
2021
use secrecy::{ExposeSecret, SecretString};
2122
use std::path::PathBuf;
2223
use std::sync::Arc;
@@ -31,7 +32,7 @@ pub enum ExecProgram {
3132
/// Inline script content. Written to a temp file at each execution.
3233
Inline(String),
3334
/// Path to an immutable cached script file (from OCI). Executed directly.
34-
CachedFile(PathBuf),
35+
CachedFile(PathBuf), // TODO: Use for CAS as well
3536
}
3637

3738
/// Compiled exec activity. No WASM engine needed.
@@ -44,8 +45,12 @@ pub struct ActivityExecWorkerCompiled {
4445
max_output_bytes: u64,
4546
forward_stdout: Option<StdOutputConfig>,
4647
forward_stderr: Option<StdOutputConfig>,
47-
/// Pre-computed stdin content from resolved secrets. Written to the child's stdin pipe.
48-
stdin_content: Option<SecretString>,
48+
/// Resolved secrets, nested under the `secrets` key of the stdin JSON.
49+
/// `None` when no secrets are configured.
50+
secrets: Option<IndexMap<String, SecretString>>,
51+
/// When `true`, parameters are passed via the stdin JSON `params` array
52+
/// instead of argv, sidestepping the `execve` argument-size limit.
53+
params_via_stdin: bool,
4954
user_wasm_component: WasmComponent,
5055
}
5156

@@ -60,7 +65,8 @@ impl ActivityExecWorkerCompiled {
6065
max_output_bytes: u64,
6166
forward_stdout: Option<StdOutputConfig>,
6267
forward_stderr: Option<StdOutputConfig>,
63-
stdin_content: Option<SecretString>,
68+
secrets: Option<IndexMap<String, SecretString>>,
69+
params_via_stdin: bool,
6470
) -> Result<Self, utils::wasm_tools::DecodeError> {
6571
let user_wasm_component = WasmComponent::new_from_fn_signature(
6672
&user_ffqn,
@@ -78,7 +84,8 @@ impl ActivityExecWorkerCompiled {
7884
max_output_bytes,
7985
forward_stdout,
8086
forward_stderr,
81-
stdin_content,
87+
secrets,
88+
params_via_stdin,
8289
user_wasm_component,
8390
})
8491
}
@@ -124,7 +131,8 @@ impl ActivityExecWorkerCompiled {
124131
max_output_bytes: self.max_output_bytes,
125132
forward_stdout: stdout_config,
126133
forward_stderr: stderr_config,
127-
stdin_content: self.stdin_content,
134+
secrets: self.secrets,
135+
params_via_stdin: self.params_via_stdin,
128136
cancel_registry,
129137
user_exports_noext: self.user_wasm_component.exported_functions(false).to_vec(),
130138
}
@@ -141,7 +149,8 @@ pub struct ActivityExecWorker {
141149
max_output_bytes: u64,
142150
forward_stdout: Option<StdOutputConfigWithSender>,
143151
forward_stderr: Option<StdOutputConfigWithSender>,
144-
stdin_content: Option<SecretString>,
152+
secrets: Option<IndexMap<String, SecretString>>,
153+
params_via_stdin: bool,
145154
cancel_registry: CancelRegistry,
146155
user_exports_noext: Vec<FunctionMetadata>,
147156
}
@@ -241,17 +250,53 @@ impl Worker for ActivityExecWorker {
241250
}
242251
};
243252

253+
let json_params = ctx
254+
.params
255+
.as_json_values()
256+
.expect("params come from database, not wasmtime");
257+
assert_eq!(
258+
self.user_params.len(),
259+
json_params.len(),
260+
"type checked in Params::from_json_values"
261+
);
262+
263+
// Assemble the stdin JSON document `{ "secrets": {...}, "params": [...] }`.
264+
// The `secrets` key is included when secrets are configured; the `params`
265+
// key is included when `params_via_stdin` is set (otherwise params go to argv).
266+
let stdin_content: Option<SecretString> = if self.params_via_stdin || self.secrets.is_some()
244267
{
268+
let mut obj = serde_json::Map::new();
269+
if let Some(secrets) = &self.secrets {
270+
let secrets_obj = secrets
271+
.iter()
272+
.map(|(name, value)| {
273+
(
274+
name.clone(),
275+
serde_json::Value::String(value.expose_secret().to_string()),
276+
)
277+
})
278+
.collect();
279+
obj.insert(
280+
"secrets".to_string(),
281+
serde_json::Value::Object(secrets_obj),
282+
);
283+
}
284+
if self.params_via_stdin {
285+
obj.insert(
286+
"params".to_string(),
287+
serde_json::Value::Array(json_params.to_vec()),
288+
);
289+
}
290+
Some(SecretString::from(
291+
serde_json::to_string(&obj).expect("JSON map serialization cannot fail"),
292+
))
293+
} else {
294+
None
295+
};
296+
297+
// When params are passed via stdin, argv carries no parameters.
298+
if !self.params_via_stdin {
245299
// Serialize each user parameter as a JSON string for command-line args.
246-
let json_params = ctx
247-
.params
248-
.as_json_values()
249-
.expect("params come from database, not wasmtime");
250-
assert_eq!(
251-
self.user_params.len(),
252-
json_params.len(),
253-
"type checked in Params::from_json_values"
254-
);
255300
param_args.extend(json_params.iter().map(|v| {
256301
serde_json::to_string(v).expect("serde_json::Value must be serializable")
257302
}));
@@ -272,7 +317,7 @@ impl Worker for ActivityExecWorker {
272317
// Capture stdout/stderr, optionally pipe stdin.
273318
cmd.stdout(std::process::Stdio::piped());
274319
cmd.stderr(std::process::Stdio::piped());
275-
if self.stdin_content.is_some() {
320+
if stdin_content.is_some() {
276321
cmd.stdin(std::process::Stdio::piped());
277322
}
278323

@@ -288,8 +333,8 @@ impl Worker for ActivityExecWorker {
288333
)
289334
})?;
290335

291-
// Write stdin content if configured (e.g. resolved secrets).
292-
if let Some(ref stdin_content) = self.stdin_content {
336+
// Write stdin content if configured (resolved secrets and/or parameters).
337+
if let Some(ref stdin_content) = stdin_content {
293338
use tokio::io::AsyncWriteExt;
294339
let mut child_stdin = child.stdin.take().expect("stdin was piped");
295340
child_stdin

obelisk-help-deployment.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,13 @@
159159
## Not used when return_type is result (default), since the response carries no data.
160160
## Default: 4096.
161161
# max_output_bytes = 4096
162+
## Pass parameters via the stdin JSON `params` array instead of argv.
163+
## Use for large payloads that would exceed the `execve` argument-size limit.
164+
## The child receives `{"params":[...]}` on stdin (alongside `secrets`, if configured).
165+
## Default: false.
166+
# params_via_stdin = false
162167
## Secrets: resolved from host environment variables at startup and piped to the child's stdin as JSON.
163-
## The child receives `{"KEY":"value",...}` on stdin. Use `jq .KEY /dev/stdin` or equivalent to parse.
168+
## The child receives `{"secrets":{"KEY":"value",...}}` on stdin. Use `jq .secrets.KEY /dev/stdin` or equivalent to parse.
164169
# [activity_exec.secrets]
165170
# env_vars = [{name = "MY_SECRET", value = "${HOST_SECRET_VAR}"}] # Values support interpolation.
166171

src/command/component.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ fn find_component_for_push(
256256
return_type: cfg.return_type.clone(),
257257
max_output_bytes: cfg.max_output_bytes,
258258
secrets: cfg.secrets.clone(),
259+
params_via_stdin: cfg.params_via_stdin,
259260
},
260261
})
261262
}
@@ -486,6 +487,7 @@ fn build_component_table(
486487
return_type,
487488
max_output_bytes,
488489
secrets,
490+
params_via_stdin,
489491
} = metadata
490492
{
491493
t["location"] = value(location_raw);
@@ -505,6 +507,9 @@ fn build_component_table(
505507
t["env_vars"] = Item::Value(toml_edit::Value::Array(arr));
506508
}
507509
t["max_output_bytes"] = value(i64::try_from(*max_output_bytes).unwrap_or(i64::MAX));
510+
if *params_via_stdin {
511+
t["params_via_stdin"] = value(true);
512+
}
508513
if let Some(secrets) = secrets
509514
&& !secrets.env_vars.is_empty()
510515
{
@@ -948,6 +953,7 @@ mod tests {
948953
return_type: Some("result<string>".to_string()),
949954
max_output_bytes: 1024,
950955
secrets: None,
956+
params_via_stdin: false,
951957
};
952958
let content_digest: ContentDigest =
953959
"sha256:1111111111111111111111111111111111111111111111111111111111111111"

src/command/integration_tests.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,21 @@ params = [
490490
]
491491
return_type = "result<record {{ a: u32, b: u32 }}, string>"
492492
493+
[[activity_exec]]
494+
ffqn = "testing:integration/exec-stdin-args.echo-args"
495+
content = '''#!/usr/bin/env bash
496+
set -euo pipefail
497+
# Receives params via the stdin JSON `params` array instead of argv.
498+
jq -c '{{a: .params[0], b: .params[1]}}' /dev/stdin
499+
'''
500+
params = [
501+
{{ name = "a", type = "u32" }},
502+
{{ name = "b", type = "u32" }},
503+
]
504+
return_type = "result<record {{ a: u32, b: u32 }}, string>"
505+
env_vars = ["PATH"] # for jq
506+
params_via_stdin = true
507+
493508
[[activity_exec]]
494509
ffqn = "testing:integration/exec-stream.stream-test"
495510
content = '''#!/usr/bin/env bash
@@ -3927,10 +3942,13 @@ async fn activity_exec_stdin_secrets() {
39273942
.await;
39283943
assert_eq!(resp.status().as_u16(), 201);
39293944
let body: Value = resp.json().await.unwrap();
3930-
// Secrets are serialized as a JSON object to stdin; the script wraps it as a JSON string.
3945+
// Secrets are serialized as a JSON object under the `secrets` key to stdin; the script wraps it as a JSON string.
39313946
let ok_val = body["ok"].as_str().expect("expected ok string");
39323947
let parsed: Value = serde_json::from_str(ok_val).expect("inner value must be valid JSON");
3933-
assert_eq!(parsed, json!({ "MY_SECRET": "s3cret_value" }));
3948+
assert_eq!(
3949+
parsed,
3950+
json!({ "secrets": { "MY_SECRET": "s3cret_value" } })
3951+
);
39343952
server.shutdown().await;
39353953
}
39363954

@@ -3975,6 +3993,23 @@ async fn activity_exec_args_passthrough() {
39753993
server.shutdown().await;
39763994
}
39773995

3996+
#[tokio::test]
3997+
async fn activity_exec_args_via_stdin() {
3998+
let server = TestServer::start(test_addr!(82)).await;
3999+
let resp = server
4000+
.submit_follow(
4001+
"testing:integration/exec-stdin-args.echo-args",
4002+
vec![json!(10), json!(20)],
4003+
)
4004+
.await;
4005+
assert_eq!(resp.status().as_u16(), 201);
4006+
let body: Value = resp.json().await.unwrap();
4007+
// With params_via_stdin, params arrive in the stdin JSON `params` array
4008+
// (argv carries none); the script echoes them back as {"a": 10, "b": 20}.
4009+
assert_eq!(body, json!({ "ok": { "a": 10, "b": 20 } }));
4010+
server.shutdown().await;
4011+
}
4012+
39784013
#[tokio::test]
39794014
async fn activity_exec_env_vars() {
39804015
let server = TestServer::start(test_addr!(59)).await;

src/command/server.rs

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ use grpc::extractor::accept_trace;
103103
use grpc::grpc_gen;
104104
use hashbrown::HashMap;
105105
use indexmap::IndexMap;
106-
use secrecy::SecretString;
107106
use serde_json::json;
108107
use sha2::{Digest as _, Sha256};
109108
use std::fmt::Debug;
@@ -3823,19 +3822,9 @@ fn prespawn_activity_exec(
38233822

38243823
let program = activity_exec.program;
38253824

3826-
// Compute stdin_content from resolved secrets.
3827-
// Secrets are serialized as a JSON object and piped to the child's stdin.
3828-
let stdin_content = activity_exec.secrets.map(|secrets| {
3829-
use secrecy::ExposeSecret;
3830-
let mut obj = serde_json::Map::new();
3831-
for (name, secret_val) in &secrets.env_vars {
3832-
obj.insert(
3833-
name.clone(),
3834-
serde_json::Value::String(secret_val.expose_secret().to_string()),
3835-
);
3836-
}
3837-
SecretString::from(serde_json::to_string(&obj).expect("JSON map serialization cannot fail"))
3838-
});
3825+
// The worker nests these secrets under the `secrets` key of the stdin JSON document
3826+
// at execution time.
3827+
let secrets = activity_exec.secrets.map(|secrets| secrets.env_vars);
38393828

38403829
let worker = ActivityExecWorkerCompiled::new(
38413830
program,
@@ -3846,7 +3835,8 @@ fn prespawn_activity_exec(
38463835
activity_exec.max_output_bytes,
38473836
activity_exec.forward_stdout,
38483837
activity_exec.forward_stderr,
3849-
stdin_content,
3838+
secrets,
3839+
activity_exec.params_via_stdin,
38503840
)
38513841
.with_context(|| format!("cannot create exec activity worker for {component_id}"))?;
38523842
let wit = worker.wit();

0 commit comments

Comments
 (0)