Skip to content

Commit c1ece81

Browse files
authored
admin: add /env.json endpoint (#1867)
We have a need to see the current environment variables for running Linkerd proxies: while a pod spec includes most of the proxy's environment variables, this environment may include references that are not rendered until the container actually starts. For example: ```yaml - name: _pod_sa valueFrom: fieldRef: apiVersion: v1 fieldPath: spec.serviceAccountName - name: LINKERD2_PROXY_IDENTITY_DIR value: /var/run/linkerd/identity/end-entity - name: LINKERD2_PROXY_IDENTITY_TRUST_ANCHORS valueFrom: configMapKeyRef: key: ca-bundle.crt name: linkerd-identity-trust-roots ``` There's no reliable, non-racey way to know which value a pod is actually running with except to have the proxy expose its environment values. See linkerd/linkerd2#9067 for details. This branch implements the proposal described in [this comment][1]: we add an `/env.json` endpoint to the proxy's admin server that responds to GET requests with a JSON object representing that proxy's environment variables. If the request has query parameters, the response will only contain the value of the environment variables with those names (or JSON `null`s if a requested env var name is unset). Closes linkerd/linkerd2#9067 [1]: linkerd/linkerd2#9067 (comment)
1 parent f336b2a commit c1ece81

File tree

7 files changed

+186
-22
lines changed

7 files changed

+186
-22
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ dependencies = [
761761
"linkerd-app-core",
762762
"linkerd-app-inbound",
763763
"linkerd-tracing",
764+
"serde",
764765
"serde_json",
765766
"thiserror",
766767
"tokio",

linkerd/app/admin/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ futures = { version = "0.3", default-features = false }
1919
linkerd-app-core = { path = "../core" }
2020
linkerd-app-inbound = { path = "../inbound" }
2121
linkerd-tracing = { path = "../../tracing" }
22+
serde = "1"
2223
serde_json = "1"
2324
thiserror = "1"
2425
tokio = { version = "1", features = ["macros", "sync", "parking_lot"] }

linkerd/app/admin/src/server.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use std::{
2828
};
2929
use tokio::sync::mpsc;
3030

31+
mod json;
3132
mod log;
3233
mod readiness;
3334

@@ -82,6 +83,52 @@ impl<M> Admin<M> {
8283
.expect("builder with known status code must not fail")
8384
}
8485

86+
fn env_rsp<B>(req: Request<B>) -> Response<Body> {
87+
use std::{collections::HashMap, env, ffi::OsString};
88+
89+
if req.method() != http::Method::GET {
90+
return Self::method_not_allowed();
91+
}
92+
93+
if let Err(not_acceptable) = json::accepts_json(&req) {
94+
return not_acceptable;
95+
}
96+
97+
fn unicode(s: OsString) -> String {
98+
s.to_string_lossy().into_owned()
99+
}
100+
101+
let query = req
102+
.uri()
103+
.path_and_query()
104+
.and_then(http::uri::PathAndQuery::query);
105+
let env = if let Some(query) = query {
106+
if query.contains('=') {
107+
return json::json_error_rsp(
108+
"env.json query parameters may not contain key-value pairs",
109+
StatusCode::BAD_REQUEST,
110+
);
111+
}
112+
query
113+
.split('&')
114+
.map(|qparam| {
115+
let var = match std::env::var(qparam) {
116+
Err(env::VarError::NotPresent) => None,
117+
Err(env::VarError::NotUnicode(bad)) => Some(unicode(bad)),
118+
Ok(var) => Some(var),
119+
};
120+
(qparam.to_string(), var)
121+
})
122+
.collect::<HashMap<String, Option<String>>>()
123+
} else {
124+
std::env::vars_os()
125+
.map(|(key, var)| (unicode(key), Some(unicode(var))))
126+
.collect::<HashMap<String, Option<String>>>()
127+
};
128+
129+
json::json_rsp(&env)
130+
}
131+
85132
fn shutdown(&self) -> Response<Body> {
86133
if self.shutdown_tx.send(()).is_ok() {
87134
Response::builder()
@@ -193,6 +240,8 @@ where
193240
)
194241
}
195242

243+
"/env.json" => Box::pin(future::ok(Self::env_rsp(req))),
244+
196245
"/shutdown" => {
197246
if req.method() == http::Method::POST {
198247
if Self::client_is_localhost(&req) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
static JSON_MIME: &str = "application/json";
2+
pub(in crate::server) static JSON_HEADER_VAL: HeaderValue = HeaderValue::from_static(JSON_MIME);
3+
4+
use hyper::{
5+
header::{self, HeaderValue},
6+
Body, StatusCode,
7+
};
8+
pub(crate) fn json_error_rsp(
9+
error: impl ToString,
10+
status: http::StatusCode,
11+
) -> http::Response<Body> {
12+
mk_rsp(
13+
status,
14+
&serde_json::json!({
15+
"error": error.to_string(),
16+
"status": status.as_u16(),
17+
}),
18+
)
19+
}
20+
21+
pub(crate) fn json_rsp(val: &impl serde::Serialize) -> http::Response<Body> {
22+
mk_rsp(StatusCode::OK, val)
23+
}
24+
25+
pub(crate) fn accepts_json<B>(req: &http::Request<B>) -> Result<(), http::Response<Body>> {
26+
if let Some(accept) = req.headers().get(header::ACCEPT) {
27+
let accept = match std::str::from_utf8(accept.as_bytes()) {
28+
Ok(accept) => accept,
29+
Err(_) => {
30+
tracing::warn!("Accept header is not valid UTF-8");
31+
return Err(json_error_rsp(
32+
"Accept header must be UTF-8",
33+
StatusCode::BAD_REQUEST,
34+
));
35+
}
36+
};
37+
let will_accept_json = accept.contains(JSON_MIME)
38+
|| accept.contains("application/*")
39+
|| accept.contains("*/*");
40+
if !will_accept_json {
41+
tracing::warn!(?accept, "Accept header will not accept 'application/json'");
42+
return Err(http::Response::builder()
43+
.status(StatusCode::NOT_ACCEPTABLE)
44+
.body(JSON_MIME.into())
45+
.expect("builder with known status code must not fail"));
46+
}
47+
}
48+
49+
Ok(())
50+
}
51+
52+
fn mk_rsp(status: StatusCode, val: &impl serde::Serialize) -> http::Response<Body> {
53+
match serde_json::to_vec(val) {
54+
Ok(json) => http::Response::builder()
55+
.status(status)
56+
.header(header::CONTENT_TYPE, JSON_HEADER_VAL.clone())
57+
.body(json.into())
58+
.expect("builder with known status code must not fail"),
59+
Err(error) => {
60+
tracing::warn!(?error, "failed to serialize JSON value");
61+
http::Response::builder()
62+
.status(StatusCode::INTERNAL_SERVER_ERROR)
63+
.body(format!("failed to serialize JSON value: {error}").into())
64+
.expect("builder with known status code must not fail")
65+
}
66+
}
67+
}

linkerd/app/admin/src/server/log/stream.rs

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::server::json;
12
use futures::FutureExt;
23
use hyper::{
34
body::{Buf, Bytes},
@@ -10,20 +11,13 @@ use linkerd_app_core::{
1011
use trace::EnvFilter;
1112
use tracing::instrument::WithSubscriber;
1213

13-
static JSON_MIME: &str = "application/json";
14-
static JSON_HEADER_VAL: http::HeaderValue = http::HeaderValue::from_static(JSON_MIME);
15-
1614
macro_rules! recover {
1715
($thing:expr, $msg:literal, $status:expr $(,)?) => {
1816
match $thing {
1917
Ok(val) => val,
2018
Err(error) => {
2119
tracing::warn!(%error, status = %$status, message = %$msg);
22-
let json = serde_json::to_vec(&serde_json::json!({
23-
"error": error.to_string(),
24-
"status": $status.as_u16(),
25-
}))?;
26-
return Ok(mk_rsp($status, json));
20+
return Ok(json::json_error_rsp(error, $status));
2721
}
2822
}
2923
}
@@ -40,19 +34,8 @@ where
4034
{
4135
let handle = handle.into_stream();
4236

43-
if let Some(accept) = req.headers().get(header::ACCEPT) {
44-
let accept = recover!(
45-
std::str::from_utf8(accept.as_bytes()),
46-
"Accept header should be UTF-8",
47-
StatusCode::BAD_REQUEST
48-
);
49-
let will_accept_json = accept.contains(JSON_MIME)
50-
|| accept.contains("application/*")
51-
|| accept.contains("*/*");
52-
if !will_accept_json {
53-
tracing::warn!(?accept, "Accept header will not accept 'application/json'");
54-
return Ok(mk_rsp(StatusCode::NOT_ACCEPTABLE, "application/json"));
55-
}
37+
if let Err(not_acceptable) = json::accepts_json(&req) {
38+
return Ok(not_acceptable);
5639
}
5740

5841
let try_filter = match req.method() {
@@ -154,7 +137,7 @@ fn parse_filter(filter_str: &str) -> Result<EnvFilter, impl std::error::Error> {
154137
fn mk_rsp(status: StatusCode, body: impl Into<Body>) -> http::Response<Body> {
155138
http::Response::builder()
156139
.status(status)
157-
.header(header::CONTENT_TYPE, JSON_HEADER_VAL.clone())
140+
.header(header::CONTENT_TYPE, json::JSON_HEADER_VAL.clone())
158141
.body(body.into())
159142
.expect("builder with known status code must not fail")
160143
}

linkerd/app/integration/src/tests/telemetry.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// particular appears to do nothing... T_T
44
#![allow(unused_imports)]
55

6+
mod env;
67
mod log_stream;
78
mod tcp_errors;
89

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use super::*;
2+
3+
#[tokio::test]
4+
async fn returns_env_var_json() {
5+
let _trace = trace_init();
6+
let Fixture {
7+
metrics,
8+
proxy: _proxy,
9+
_profile,
10+
dst_tx: _dst_tx,
11+
..
12+
} = Fixture::outbound().await;
13+
14+
let json = get_env_json(&metrics, "/env.json").await;
15+
let actual = std::env::vars()
16+
.map(|(name, val)| (name, Some(val)))
17+
.collect::<HashMap<_, Option<String>>>();
18+
19+
assert_eq!(actual, json);
20+
}
21+
22+
#[tokio::test]
23+
async fn filters_on_query_params() {
24+
let _trace = trace_init();
25+
let Fixture {
26+
metrics,
27+
proxy: _proxy,
28+
_profile,
29+
dst_tx: _dst_tx,
30+
..
31+
} = Fixture::outbound().await;
32+
33+
// we can be relatively confident this env var is set by `cargo test`
34+
const REAL_VAR: &str = "CARGO_PKG_NAME";
35+
36+
// and, we can _hope_ nothing ever sets this one... :)
37+
const FAKE_VAR: &str = "ELIZAS_OBVIOUSLY_FAKE_ENV_VAR_THAT_SHOULD_NEVER_BE_SET";
38+
39+
let expected = [
40+
(REAL_VAR.to_string(), std::env::var(REAL_VAR).ok()),
41+
(FAKE_VAR.to_string(), None),
42+
]
43+
.into_iter()
44+
.collect::<HashMap<_, _>>();
45+
46+
let json = get_env_json(&metrics, &format!("/env.json?{REAL_VAR}&{FAKE_VAR}")).await;
47+
assert_eq!(expected, json);
48+
49+
// now flip it
50+
let json = get_env_json(&metrics, &format!("/env.json?{FAKE_VAR}&{REAL_VAR}")).await;
51+
assert_eq!(expected, json);
52+
}
53+
54+
#[tracing::instrument(level = "info", skip(client))]
55+
async fn get_env_json(client: &client::Client, path: &str) -> HashMap<String, Option<String>> {
56+
let json = client.get(path).await;
57+
tracing::info!(json);
58+
59+
let deserialized = serde_json::from_str(json.as_str()).expect("response should be valid JSON");
60+
tracing::info!(deserialized = ?format_args!("{deserialized:#?}"));
61+
deserialized
62+
}

0 commit comments

Comments
 (0)