Skip to content

Commit cb08d02

Browse files
graphcarefulprontthomasqueirozb
authored
feat(config): Add support for v1.1 protocol of secrets exec backend (#23655)
* feat(config): Add support for v1.1 of datadog secrets manager * Update docs generator to generate unconstrained types - This is necessary as the secrets exec config now contains a member of type `Value` * Introduce new unit tests for the exec backend * Add changelog file * Stray line to format * Rename mock exec script and make it executable - That way its implementation can change without modifying the executables call site in the code. * Update src/secrets/exec.rs * Install python 3.10 on windows CI runs * Invoke python mock exec program via the python command - Shebang invocation at top of script not working on windows builds where python is installed and located at the path * Revert "Install python 3.10 on windows CI runs" This reverts commit c4f9570. * Reapply "Install python 3.10 on windows CI runs" This reverts commit fb51201. --------- Co-authored-by: Pavlos Rontidis <[email protected]> Co-authored-by: Thomas <[email protected]>
1 parent 515dd9f commit cb08d02

File tree

5 files changed

+257
-10
lines changed

5 files changed

+257
-10
lines changed

.github/workflows/unit_windows.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ permissions:
77
statuses: write
88

99
jobs:
10-
1110
test-windows:
1211
runs-on: windows-2025-8core
1312
timeout-minutes: 60
@@ -31,6 +30,9 @@ jobs:
3130
if: ${{ github.event_name != 'pull_request_review' }}
3231
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
3332

33+
- uses: actions/setup-python@v5
34+
with:
35+
python-version: "3.10"
3436
- run: .\scripts\environment\bootstrap-windows-2025.ps1
3537
- run: make test
3638

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support for v1.1 of the secrets manager protocol
2+
3+
authors: graphcareful

scripts/generate-component-docs.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,13 @@ def resolve_bare_schema(root_schema, schema)
928928
fix_grouped_enums_if_numeric!(grouped)
929929
grouped.transform_values! { |values| { 'enum' => values } }
930930
grouped
931+
when nil
932+
# Unconstrained/empty schema (e.g., Value without constraints).
933+
# Represent it as accepting any JSON type so downstream code can render it
934+
# and attach defaults/examples based on actual values.
935+
@logger.debug 'Resolving unconstrained schema (any type).'
936+
937+
{ '*' => {} }
931938
else
932939
@logger.error "Failed to resolve the schema. Schema: #{schema}"
933940
exit 1

src/secrets/exec.rs

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,59 @@ use serde::{Deserialize, Serialize};
77
use tokio::{io::AsyncWriteExt, process::Command, time};
88
use tokio_util::codec;
99
use vector_lib::configurable::{component::GenerateConfig, configurable_component};
10+
use vrl::value::Value;
1011

1112
use crate::{config::SecretBackend, signal};
1213

14+
/// Configuration for the command that will be `exec`ed
15+
#[configurable_component(secrets("exec"))]
16+
#[configurable(metadata(docs::enum_tag_description = "The protocol version."))]
17+
#[derive(Clone, Debug)]
18+
#[serde(rename_all = "snake_case", tag = "version")]
19+
pub enum ExecVersion {
20+
/// Expect the command to fetch the configuration options itself.
21+
V1,
22+
23+
/// Configuration options to the command are to be curried upon each request.
24+
V1_1 {
25+
/// The name of the backend. This is `type` field in the backend request.
26+
backend_type: String,
27+
/// The configuration to pass to the secrets executable. This is the `config` field in the
28+
/// backend request. Refer to the documentation of your `backend_type `to see which options
29+
/// are required to be set.
30+
backend_config: Value,
31+
},
32+
}
33+
34+
impl ExecVersion {
35+
fn new_query(&self, secrets: HashSet<String>) -> ExecQuery {
36+
match &self {
37+
ExecVersion::V1 => ExecQuery {
38+
version: "1.0".to_string(),
39+
secrets,
40+
r#type: None,
41+
config: None,
42+
},
43+
ExecVersion::V1_1 {
44+
backend_type,
45+
backend_config,
46+
..
47+
} => ExecQuery {
48+
version: "1.1".to_string(),
49+
secrets,
50+
r#type: Some(backend_type.clone()),
51+
config: Some(backend_config.clone()),
52+
},
53+
}
54+
}
55+
}
56+
57+
impl GenerateConfig for ExecVersion {
58+
fn generate_config() -> toml::Value {
59+
toml::Value::try_from(ExecVersion::V1).unwrap()
60+
}
61+
}
62+
1363
/// Configuration for the `exec` secrets backend.
1464
#[configurable_component(secrets("exec"))]
1565
#[derive(Clone, Debug)]
@@ -22,13 +72,18 @@ pub struct ExecBackend {
2272
/// The timeout, in seconds, to wait for the command to complete.
2373
#[serde(default = "default_timeout_secs")]
2474
pub timeout: u64,
75+
76+
/// Settings for the protocol between Vector and the secrets executable.
77+
#[serde(default = "default_protocol_version")]
78+
pub protocol: ExecVersion,
2579
}
2680

2781
impl GenerateConfig for ExecBackend {
2882
fn generate_config() -> toml::Value {
2983
toml::Value::try_from(ExecBackend {
3084
command: vec![String::from("/path/to/script")],
3185
timeout: 5,
86+
protocol: ExecVersion::V1,
3287
})
3388
.unwrap()
3489
}
@@ -38,17 +93,20 @@ const fn default_timeout_secs() -> u64 {
3893
5
3994
}
4095

41-
#[derive(Clone, Debug, Deserialize, Serialize)]
96+
const fn default_protocol_version() -> ExecVersion {
97+
ExecVersion::V1
98+
}
99+
100+
#[derive(Clone, Debug, Serialize)]
42101
struct ExecQuery {
102+
// Fields in all versions starting from v1
43103
version: String,
44104
secrets: HashSet<String>,
45-
}
46-
47-
fn new_query(secrets: HashSet<String>) -> ExecQuery {
48-
ExecQuery {
49-
version: "1.0".to_string(),
50-
secrets,
51-
}
105+
// Fields added in v1.1
106+
#[serde(skip_serializing_if = "Option::is_none")]
107+
r#type: Option<String>,
108+
#[serde(skip_serializing_if = "Option::is_none")]
109+
config: Option<Value>,
52110
}
53111

54112
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -66,7 +124,7 @@ impl SecretBackend for ExecBackend {
66124
let mut output = executor::block_on(async {
67125
query_backend(
68126
&self.command,
69-
new_query(secret_keys.clone()),
127+
self.protocol.new_query(secret_keys.clone()),
70128
self.timeout,
71129
signal_rx,
72130
)
@@ -159,3 +217,79 @@ async fn query_backend(
159217
let response = serde_json::from_slice::<HashMap<String, ExecResponse>>(&output)?;
160218
Ok(response)
161219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use crate::{
224+
config::SecretBackend,
225+
secrets::exec::{ExecBackend, ExecVersion},
226+
};
227+
use rstest::rstest;
228+
use std::{
229+
collections::{HashMap, HashSet},
230+
path::PathBuf,
231+
};
232+
use tokio::sync::broadcast;
233+
use vrl::value;
234+
235+
fn make_test_backend(protocol: ExecVersion) -> ExecBackend {
236+
let command_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
237+
.join("tests/behavior/secrets/mock_secrets_exec.py");
238+
ExecBackend {
239+
command: ["python", command_path.to_str().unwrap()]
240+
.map(String::from)
241+
.to_vec(),
242+
timeout: 5,
243+
protocol,
244+
}
245+
}
246+
247+
#[tokio::test(flavor = "multi_thread")]
248+
#[rstest(
249+
protocol,
250+
case(ExecVersion::V1),
251+
case(ExecVersion::V1_1 {
252+
backend_type: "file.json".to_string(),
253+
backend_config: value!({"file_path": "/abc.json"}),
254+
})
255+
)]
256+
async fn test_exec_backend(protocol: ExecVersion) {
257+
let mut backend = make_test_backend(protocol);
258+
let (_tx, mut rx) = broadcast::channel(1);
259+
// These fake secrets are statically contained in mock_secrets_exec.py
260+
let fake_secret_values: HashMap<String, String> = [
261+
("fake_secret_1", "123456"),
262+
("fake_secret_2", "123457"),
263+
("fake_secret_3", "123458"),
264+
("fake_secret_4", "123459"),
265+
("fake_secret_5", "123460"),
266+
]
267+
.into_iter()
268+
.map(|(k, v)| (k.to_string(), v.to_string()))
269+
.collect();
270+
// Calling the mock_secrets_exec.py program with the expected secret keys should provide
271+
// the values expected above in `fake_secret_values`
272+
let fetched_keys = backend
273+
.retrieve(fake_secret_values.keys().cloned().collect(), &mut rx)
274+
.await
275+
.unwrap();
276+
// Assert response is as expected
277+
assert_eq!(fetched_keys.len(), 5);
278+
for (fake_secret_key, fake_secret_value) in fake_secret_values {
279+
assert_eq!(fetched_keys.get(&fake_secret_key), Some(&fake_secret_value));
280+
}
281+
}
282+
283+
#[tokio::test(flavor = "multi_thread")]
284+
async fn test_exec_backend_missing_secrets() {
285+
let mut backend = make_test_backend(ExecVersion::V1);
286+
let (_tx, mut rx) = broadcast::channel(1);
287+
let query_secrets: HashSet<String> =
288+
["fake_secret_900"].into_iter().map(String::from).collect();
289+
let fetched_keys = backend.retrieve(query_secrets.clone(), &mut rx).await;
290+
assert_eq!(
291+
format!("{}", fetched_keys.unwrap_err()),
292+
"secret for key 'fake_secret_900' was not retrieved: backend does not provide secret key"
293+
);
294+
}
295+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Mock secret manager implementation used for testing the secrets.exec backend type.
3+
This program is meant to be exec'ed by a unit test so that the implementation can be tested.
4+
"""
5+
6+
import sys
7+
import json
8+
from typing import Any
9+
10+
11+
class Request:
12+
def __init__(
13+
self,
14+
version: str,
15+
secrets: list[str],
16+
type: str | None = None,
17+
config: dict[str, Any] | None = None,
18+
):
19+
self.version = version
20+
self.secrets = secrets
21+
self.type = type
22+
self.config = config
23+
24+
25+
class Response:
26+
def __init__(self, contents: dict[str, dict[str, str | None]]):
27+
self.contents = contents
28+
29+
30+
def parse_request(req: dict[str, Any]) -> Request:
31+
"""
32+
Validate the request by ensuring the correct keys exist per version, and that the types of the
33+
respective keys are as expected as well
34+
"""
35+
v1_args = set(["version", "secrets"])
36+
v1_1_args = set(["version", "secrets", "type", "config"])
37+
if "version" not in req.keys():
38+
raise RuntimeError("version key missing from request")
39+
version = req["version"]
40+
if version == "1.0":
41+
if v1_args != set(req.keys()):
42+
raise RuntimeError(f"Invalid required keys in 1.0 request: {req.keys()}")
43+
if not isinstance(req["secrets"], list):
44+
raise RuntimeError("key 'secrets' should be a list")
45+
return Request(version, req["secrets"])
46+
elif version == "1.1":
47+
if v1_1_args != set(req.keys()):
48+
raise RuntimeError(f"Invalid required keys in 1.1 request: {req.keys()}")
49+
if not isinstance(req["secrets"], list):
50+
raise RuntimeError("key 'secrets' should be a list")
51+
if not isinstance(req["type"], str):
52+
raise RuntimeError("key 'type' should be a str")
53+
if not isinstance(req["config"], dict):
54+
raise RuntimeError("key 'config' should be a dict")
55+
return Request(version, req["secrets"], req["type"], req["config"])
56+
else:
57+
raise RuntimeError(f"Invalid version detected: {version}")
58+
59+
60+
def handle_request(req: Request) -> Response:
61+
"""
62+
Handle the request by looking up the requested secret with a value that is in a static fake
63+
secret cache below. Any values not contained will return the appropriate error message.
64+
"""
65+
static_fake_secrets_cache = {
66+
"fake_secret_1": "123456",
67+
"fake_secret_2": "123457",
68+
"fake_secret_3": "123458",
69+
"fake_secret_4": "123459",
70+
"fake_secret_5": "123460",
71+
}
72+
supported_fake_backends = ["file.json"]
73+
if req.version == "1.1":
74+
if req.type not in supported_fake_backends:
75+
raise RuntimeError(f"Requested backend: {req.type} not supported")
76+
if req.config is not None and "file_path" not in req.config:
77+
raise RuntimeError("File backend option file_path must be supplied")
78+
79+
def get_secret(fake_secret_name: str) -> dict[str, str | None]:
80+
if fake_secret_name in static_fake_secrets_cache:
81+
return {"value": static_fake_secrets_cache[fake_secret_name], "error": None}
82+
else:
83+
return {"value": None, "error": "backend does not provide secret key"}
84+
85+
return Response(dict([(s, get_secret(s)) for s in req.secrets]))
86+
87+
88+
def main():
89+
data = sys.stdin.buffer.read()
90+
req = json.loads(data)
91+
req = parse_request(req)
92+
resp = handle_request(req)
93+
sys.stdout.write(json.dumps(resp.contents))
94+
95+
96+
if __name__ == "__main__":
97+
try:
98+
main()
99+
except Exception as e:
100+
sys.stderr.write(str(e))
101+
sys.exit(1)

0 commit comments

Comments
 (0)