Skip to content

Commit ac159fa

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 --------- Co-authored-by: Pavlos Rontidis <[email protected]> Co-authored-by: Thomas <[email protected]>
1 parent 031c279 commit ac159fa

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

0 commit comments

Comments
 (0)