Skip to main content

pingap_core/
identity.rs

1// Copyright 2024-2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Who a process is, for what it says about itself.
16//!
17//! With one deployment the hostname says it. With several - a cluster per
18//! region, production next to staging - a notification from `pingap` on
19//! `pingap-7d9f5c6b8-x2k4q` says nothing about which of them it is from.
20//! The name (`basic.name`) and the labels (`basic.labels`) of a deployment
21//! are what tells: they go into notifications, into the access log where
22//! its format asks for them, and into the traces.
23
24use std::sync::{LazyLock, OnceLock};
25
26/// What a deployment is called when its configuration does not say.
27pub const DEFAULT_IDENTITY_NAME: &str = "pingap";
28
29/// The most labels a deployment has. They go into every notification.
30const MAX_LABELS: usize = 16;
31const MAX_LABEL_KEY: usize = 32;
32const MAX_LABEL_VALUE: usize = 128;
33const MAX_NAME: usize = 64;
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct Identity {
37    /// What the deployment is called (`basic.name`).
38    pub name: String,
39    /// Its labels (`basic.labels`), in the order they are written:
40    /// `cluster` and `sh-prod`, `env` and `prod`.
41    pub labels: Vec<(String, String)>,
42}
43
44impl Default for Identity {
45    fn default() -> Self {
46        Self {
47            name: DEFAULT_IDENTITY_NAME.to_string(),
48            labels: vec![],
49        }
50    }
51}
52
53impl Identity {
54    /// The identity of a deployment with `name` and `labels`. No name, or
55    /// one that is empty, is the default one.
56    pub fn new(name: Option<&str>, labels: Vec<(String, String)>) -> Self {
57        let name = name
58            .map(str::trim)
59            .filter(|name| !name.is_empty())
60            .unwrap_or(DEFAULT_IDENTITY_NAME);
61        Self {
62            name: name.to_string(),
63            labels,
64        }
65    }
66    /// Whether the deployment was given a name of its own.
67    pub fn is_named(&self) -> bool {
68        self.name != DEFAULT_IDENTITY_NAME
69    }
70    /// The value of the label `key`.
71    pub fn label(&self, key: &str) -> Option<&str> {
72        self.labels
73            .iter()
74            .find(|(name, _)| name == key)
75            .map(|(_, value)| value.as_str())
76    }
77}
78
79/// What a deployment may be called (`basic.name`): one line of at most
80/// 64 bytes. It is put ahead of the title of a message and into the lines
81/// of an access log.
82pub fn parse_identity_name(name: Option<&str>) -> Result<(), String> {
83    let name = name.unwrap_or_default().trim();
84    if name.len() > MAX_NAME || name.chars().any(char::is_control) {
85        return Err(format!(
86            "name should be one line of at most {MAX_NAME} bytes"
87        ));
88    }
89    Ok(())
90}
91
92static IDENTITY: OnceLock<Identity> = OnceLock::new();
93static DEFAULT_IDENTITY: LazyLock<Identity> = LazyLock::new(Identity::default);
94
95/// Notes who this process is. Once, when it starts: what is told with it
96/// is put together from then on, and a second call changes nothing.
97pub fn set_identity(identity: Identity) {
98    IDENTITY.get_or_init(|| identity);
99}
100
101/// Who this process is: what [`set_identity`] was given, `pingap` without
102/// labels before that.
103pub fn get_identity() -> &'static Identity {
104    IDENTITY.get().unwrap_or(&DEFAULT_IDENTITY)
105}
106
107/// The labels of a deployment, as the configuration has them
108/// (`basic.labels`): each `key:value`. The key is made of letters, digits,
109/// `_` and `-`, which is what every place that names one can write: a
110/// template, a placeholder of the access log, a tag.
111pub fn parse_labels(
112    entries: &[String],
113) -> Result<Vec<(String, String)>, String> {
114    if entries.len() > MAX_LABELS {
115        return Err(format!(
116            "labels: {} of them, the most is {MAX_LABELS}",
117            entries.len()
118        ));
119    }
120    let mut labels: Vec<(String, String)> = Vec::with_capacity(entries.len());
121    for item in entries {
122        let invalid = |what: &str| {
123            let key = item.split(':').next().unwrap_or_default().trim();
124            format!("labels: {key:?} {what}")
125        };
126        let (key, value) = item
127            .split_once(':')
128            .ok_or_else(|| invalid("should be `key:value`"))?;
129        let (key, value) = (key.trim(), value.trim());
130        let named = !key.is_empty()
131            && key.len() <= MAX_LABEL_KEY
132            && key
133                .chars()
134                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
135        if !named {
136            return Err(invalid(
137                "should be a key of letters, digits, _ and -, at most 32",
138            ));
139        }
140        if value.is_empty() {
141            return Err(invalid("has no value"));
142        }
143        // `$ENV:NAME` has been replaced by now. What is left with a `$`
144        // in front was meant to be read from somewhere and is not: a
145        // `$FILE:`, a `$NAME`.
146        if value.starts_with('$') {
147            return Err(invalid(
148                "has a value that starts with $, which is read as nothing: the value of a label comes from the environment as $ENV:NAME",
149            ));
150        }
151        if value.len() > MAX_LABEL_VALUE || value.chars().any(char::is_control)
152        {
153            return Err(invalid(
154                "has a value that is longer than 128 bytes, or not one line of text",
155            ));
156        }
157        if labels.iter().any(|(name, _)| name == key) {
158            return Err(invalid("is there twice"));
159        }
160        labels.push((key.to_string(), value.to_string()));
161    }
162    Ok(labels)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use pretty_assertions::assert_eq;
169
170    #[test]
171    fn test_identity() {
172        let identity = Identity::default();
173        assert_eq!("pingap", identity.name);
174        assert_eq!(false, identity.is_named());
175        assert_eq!(None, identity.label("cluster"));
176        // No name, or none to speak of, is the default one.
177        assert_eq!("pingap", Identity::new(None, vec![]).name);
178        assert_eq!("pingap", Identity::new(Some("  "), vec![]).name);
179
180        let labels = parse_labels(&[
181            "cluster:sh-prod".to_string(),
182            " env : prod ".to_string(),
183        ])
184        .unwrap();
185        let identity = Identity::new(Some(" edge-sh "), labels);
186        assert_eq!("edge-sh", identity.name);
187        assert_eq!(true, identity.is_named());
188        assert_eq!(Some("sh-prod"), identity.label("cluster"));
189        assert_eq!(Some("prod"), identity.label("env"));
190        assert_eq!(None, identity.label("region"));
191
192        assert_eq!(Ok(()), parse_identity_name(None));
193        assert_eq!(Ok(()), parse_identity_name(Some("edge-sh 上海")));
194        assert_eq!(true, parse_identity_name(Some("a\nb")).is_err());
195        assert_eq!(true, parse_identity_name(Some(&"n".repeat(65))).is_err());
196        // Before anything is noted: the default.
197        assert_eq!("pingap", get_identity().name);
198    }
199
200    #[test]
201    fn test_parse_labels() {
202        let parse = |entries: &[&str]| {
203            let entries: Vec<String> =
204                entries.iter().map(|item| item.to_string()).collect();
205            parse_labels(&entries)
206        };
207        assert_eq!(Ok(vec![]), parse(&[]));
208        // A value may have a colon of its own.
209        assert_eq!(
210            Ok(vec![("zone".to_string(), "cn-east:a".to_string())]),
211            parse(&["zone:cn-east:a"])
212        );
213        for (entries, reason) in [
214            (vec!["cluster"], "\"cluster\" should be `key:value`"),
215            (vec![":sh"], "\"\" should be a key of"),
216            (vec!["k8s.cluster:sh"], "\"k8s.cluster\" should be a key of"),
217            (vec!["my cluster:sh"], "should be a key of"),
218            (vec!["cluster:"], "\"cluster\" has no value"),
219            (vec!["cluster: "], "\"cluster\" has no value"),
220            (vec!["cluster:a\nb"], "not one line of text"),
221            (vec!["cluster:$FILE:/etc/cluster"], "starts with $"),
222            (vec!["cluster:$CLUSTER"], "starts with $"),
223            (vec!["cluster:a", "cluster:b"], "\"cluster\" is there twice"),
224        ] {
225            let error = parse(&entries).unwrap_err();
226            assert_eq!(true, error.contains(reason), "{entries:?}: {error}");
227        }
228        let long = format!("cluster:{}", "a".repeat(129));
229        assert_eq!(true, parse(&[&long]).is_err());
230        let key = format!("{}:a", "k".repeat(33));
231        assert_eq!(true, parse(&[&key]).is_err());
232        let many: Vec<String> = (0..17).map(|i| format!("k{i}:v")).collect();
233        let many: Vec<&str> = many.iter().map(String::as_str).collect();
234        assert_eq!(true, parse(&many).unwrap_err().contains("the most is 16"));
235    }
236}