Skip to main content

what_stack/
image.rs

1//! Image-name based stack detection.
2//!
3//! This module handles only image-string parsing and label matching. It does
4//! not talk to a container daemon or validate that an image exists.
5
6use crate::StackLabel;
7
8pub const EXACT_IMAGE_RULES: &[(&str, StackLabel)] = &[
9    ("mongo", StackLabel::database("MongoDB")),
10    ("httpd", StackLabel::service("Apache")),
11    ("node", StackLabel::runtime("Node.js")),
12    ("python", StackLabel::runtime("Python")),
13    ("python3", StackLabel::runtime("Python")),
14    ("ruby", StackLabel::runtime("Ruby")),
15    ("golang", StackLabel::runtime("Go")),
16    ("go", StackLabel::runtime("Go")),
17    ("rust", StackLabel::runtime("Rust")),
18    ("bun", StackLabel::runtime("Bun")),
19    ("deno", StackLabel::runtime("Deno")),
20    ("php", StackLabel::runtime("PHP")),
21];
22
23/// Prefix rules match when the base name equals the prefix or continues with a
24/// separator (`-`, `_`, `.`), so `postgrest` is not `postgres`. The image tag
25/// (`:16`) is removed before matching.
26pub const PREFIX_IMAGE_RULES: &[(&str, StackLabel)] = &[
27    ("postgres", StackLabel::database("PostgreSQL")),
28    ("postgresql", StackLabel::database("PostgreSQL")),
29    ("postgis", StackLabel::database("PostgreSQL")),
30    ("timescaledb", StackLabel::database("PostgreSQL")),
31    ("mysql", StackLabel::database("MySQL")),
32    ("mariadb", StackLabel::database("MariaDB")),
33    ("mongodb", StackLabel::database("MongoDB")),
34    ("redis", StackLabel::database("Redis")),
35    ("valkey", StackLabel::database("Valkey")),
36    ("memcached", StackLabel::database("Memcached")),
37    ("nginx", StackLabel::service("Nginx")),
38    ("apache", StackLabel::service("Apache")),
39    ("rabbitmq", StackLabel::service("RabbitMQ")),
40    ("kafka", StackLabel::service("Kafka")),
41    ("localstack", StackLabel::service("LocalStack")),
42    ("elasticsearch", StackLabel::database("Elasticsearch")),
43    ("opensearch", StackLabel::database("OpenSearch")),
44    ("clickhouse", StackLabel::database("ClickHouse")),
45    ("caddy", StackLabel::service("Caddy")),
46    ("traefik", StackLabel::service("Traefik")),
47    ("openjdk", StackLabel::runtime("Java")),
48    ("eclipse-temurin", StackLabel::runtime("Java")),
49    ("dotnet", StackLabel::runtime(".NET")),
50];
51
52/// Name segments that mark a companion image (a metrics exporter, admin UI,
53/// Kubernetes operator, backup job, client, or auth sidecar) rather than the
54/// service itself: `postgres-exporter`, `nginx-prometheus-exporter`,
55/// `opensearch-dashboards`, `mysql-workbench`, `traefik-forward-auth`.
56///
57/// `proxy` is deliberately absent: `nginx-proxy` and `nginx-proxy-manager` run
58/// Nginx.
59const COMPANION_SEGMENTS: &[&str] = &[
60    "exporter",
61    "dashboard",
62    "dashboards",
63    "operator",
64    "admin",
65    "ui",
66    "gui",
67    "backup",
68    "client",
69    "cli",
70    "commander",
71    "workbench",
72    "shell",
73    "curator",
74    "auth",
75];
76
77/// Label for images under a `dotnet` registry namespace, such as
78/// `mcr.microsoft.com/dotnet/aspnet`.
79pub const DOTNET_NAMESPACE_LABEL: StackLabel = StackLabel::runtime(".NET");
80
81/// Detect a stack label from a container or artifact image name.
82///
83/// The detector matches the base image name after removing any registry,
84/// namespace, tag, or digest. Matching is ASCII case-insensitive.
85///
86/// Most language runtime images, such as `node`, `python`, and `php`, match
87/// only their exact name, so `python-linter` or `rubygems-mirror` are not
88/// runtimes. Database and service images, plus the `openjdk`,
89/// `eclipse-temurin`, and `dotnet` runtime images, match a prefix followed by
90/// the end of the name or a separator (`-`, `_`, `.`): `mysql-server` is
91/// `MySQL` and `redis-sentinel` is `Redis`, while `postgrest` is not
92/// `PostgreSQL` and `redisinsight` is not `Redis`.
93/// Prefix matches are rejected when a later name segment marks a companion
94/// image such as `exporter`, `dashboards`, `operator`, `admin`, or `ui`, so
95/// `postgres-exporter` and `opensearch-dashboards` produce no label.
96///
97/// # Examples
98///
99/// ```
100/// use what_stack::detect_from_image;
101///
102/// assert_eq!(detect_from_image("postgres:16").expect("known image"), "PostgreSQL");
103/// assert_eq!(
104///     detect_from_image("mcr.microsoft.com/dotnet/aspnet:8.0").expect("known image"),
105///     ".NET",
106/// );
107/// assert_eq!(detect_from_image("prom/node-exporter:latest"), None);
108/// assert_eq!(detect_from_image("prometheuscommunity/postgres-exporter"), None);
109/// assert_eq!(detect_from_image("postgrest/postgrest"), None);
110/// ```
111///
112/// # Limits
113///
114/// This is intentionally a built-in rule set. The crate does not read custom
115/// image rules or inspect image manifests.
116#[must_use]
117pub fn detect_from_image(image: &str) -> Option<StackLabel> {
118    let last_segment = image.rsplit('/').next().unwrap_or(image);
119    let base = last_segment
120        .split([':', '@'])
121        .next()
122        .unwrap_or(last_segment);
123
124    detect_exact_base(base)
125        .or_else(|| detect_prefixed_base(base))
126        .or_else(|| image_has_dotnet_namespace(image).then_some(DOTNET_NAMESPACE_LABEL))
127}
128
129fn detect_exact_base(base: &str) -> Option<StackLabel> {
130    EXACT_IMAGE_RULES
131        .iter()
132        .find(|(name, _)| base.eq_ignore_ascii_case(name))
133        .map(|(_, label)| label.clone())
134}
135
136fn detect_prefixed_base(base: &str) -> Option<StackLabel> {
137    PREFIX_IMAGE_RULES
138        .iter()
139        .find(|(prefix, _)| matches_service_prefix(base, prefix))
140        .map(|(_, label)| label.clone())
141}
142
143fn image_has_dotnet_namespace(image: &str) -> bool {
144    image
145        .split('/')
146        .any(|segment| segment.eq_ignore_ascii_case("dotnet"))
147}
148
149const NAME_SEPARATORS: [char; 3] = ['-', '_', '.'];
150
151fn matches_service_prefix(base: &str, prefix: &str) -> bool {
152    let Some(rest) = strip_prefix_ascii_case(base, prefix) else {
153        return false;
154    };
155
156    if rest.is_empty() {
157        return true;
158    }
159
160    rest.strip_prefix(NAME_SEPARATORS).is_some_and(|rest| {
161        !rest.split(NAME_SEPARATORS).any(|segment| {
162            COMPANION_SEGMENTS
163                .iter()
164                .any(|companion| segment.eq_ignore_ascii_case(companion))
165        })
166    })
167}
168
169fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
170    value
171        .get(..prefix.len())
172        .filter(|head| head.eq_ignore_ascii_case(prefix))
173        .and_then(|_| value.get(prefix.len()..))
174}