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::text::strip_prefix_ignore_ascii_case;
7use crate::{StackLabel, labels};
8
9pub const EXACT_IMAGE_RULES: &[(&str, StackLabel)] = &[
10 ("mongo", labels::MONGODB),
11 ("httpd", labels::APACHE),
12 ("node", labels::NODE),
13 ("python", labels::PYTHON),
14 ("python3", labels::PYTHON),
15 ("ruby", labels::RUBY),
16 ("golang", labels::GO),
17 ("go", labels::GO),
18 ("rust", labels::RUST),
19 ("bun", labels::BUN),
20 ("deno", labels::DENO),
21 ("php", labels::PHP),
22 ("elixir", labels::ELIXIR),
23];
24
25/// Prefix rules match when the base name equals the prefix or continues with a
26/// separator (`-`, `_`, `.`), so `postgrest` is not `postgres`. The image tag
27/// (`:16`) is removed before matching.
28pub const PREFIX_IMAGE_RULES: &[(&str, StackLabel)] = &[
29 ("postgres", labels::POSTGRESQL),
30 ("postgresql", labels::POSTGRESQL),
31 ("postgis", labels::POSTGRESQL),
32 ("timescaledb", labels::POSTGRESQL),
33 ("pgvector", labels::POSTGRESQL),
34 ("mysql", labels::MYSQL),
35 ("mariadb", labels::MARIADB),
36 ("mongodb", labels::MONGODB),
37 ("redis", labels::REDIS),
38 ("valkey", labels::VALKEY),
39 ("memcached", labels::MEMCACHED),
40 ("nginx", labels::NGINX),
41 ("apache", labels::APACHE),
42 ("rabbitmq", labels::RABBITMQ),
43 ("kafka", labels::KAFKA),
44 ("cp-kafka", labels::KAFKA),
45 ("localstack", labels::LOCALSTACK),
46 ("elasticsearch", labels::ELASTICSEARCH),
47 ("opensearch", labels::OPENSEARCH),
48 ("clickhouse", labels::CLICKHOUSE),
49 ("caddy", labels::CADDY),
50 ("traefik", labels::TRAEFIK),
51 ("openjdk", labels::JAVA),
52 ("eclipse-temurin", labels::JAVA),
53 ("amazoncorretto", labels::JAVA),
54 ("dotnet", labels::DOTNET),
55];
56
57/// Name segments that mark a companion image (a metrics exporter, admin UI,
58/// Kubernetes operator, backup job, client, auth sidecar, or connector worker)
59/// rather than the service itself: `postgres-exporter`,
60/// `nginx-prometheus-exporter`, `opensearch-dashboards`, `mysql-workbench`,
61/// `traefik-forward-auth`, `cp-kafka-connect`.
62///
63/// `proxy` is deliberately absent: `nginx-proxy` and `nginx-proxy-manager` run
64/// Nginx.
65const COMPANION_SEGMENTS: &[&str] = &[
66 "exporter",
67 "dashboard",
68 "dashboards",
69 "operator",
70 "admin",
71 "ui",
72 "gui",
73 "backup",
74 "client",
75 "cli",
76 "commander",
77 "workbench",
78 "shell",
79 "curator",
80 "auth",
81 "connect",
82];
83
84/// Registry namespaces whose images all run one stack, whatever the base
85/// name: `mcr.microsoft.com/dotnet/aspnet` and `mcr.microsoft.com/mssql/server`.
86const NAMESPACE_IMAGE_RULES: &[(&str, StackLabel)] =
87 &[("dotnet", labels::DOTNET), ("mssql", labels::SQL_SERVER)];
88
89/// Detect a stack label from a container or artifact image name.
90///
91/// The detector matches the base image name after removing any registry,
92/// namespace, tag, or digest. Matching is ASCII case-insensitive.
93///
94/// Most language runtime images, such as `node`, `python`, and `php`, match
95/// only their exact name, so `python-linter` or `rubygems-mirror` are not
96/// runtimes. Database and service images, plus the `openjdk`,
97/// `eclipse-temurin`, `amazoncorretto`, and `dotnet` runtime images, match a
98/// prefix followed by
99/// the end of the name or a separator (`-`, `_`, `.`): `mysql-server` is
100/// `MySQL` and `redis-sentinel` is `Redis`, while `postgrest` is not
101/// `PostgreSQL` and `redisinsight` is not `Redis`.
102/// Prefix matches are rejected when a later name segment marks a companion
103/// image such as `exporter`, `dashboards`, `operator`, `admin`, or `ui`, so
104/// `postgres-exporter` and `opensearch-dashboards` produce no label.
105/// Images under the `dotnet` or `mssql` registry namespaces are `.NET` and
106/// `SQL Server` whatever their base name.
107///
108/// # Examples
109///
110/// ```
111/// use what_stack::detect_from_image;
112///
113/// assert_eq!(detect_from_image("postgres:16").expect("known image"), "PostgreSQL");
114/// assert_eq!(
115/// detect_from_image("mcr.microsoft.com/dotnet/aspnet:8.0").expect("known image"),
116/// ".NET",
117/// );
118/// assert_eq!(detect_from_image("prom/node-exporter:latest"), None);
119/// assert_eq!(detect_from_image("prometheuscommunity/postgres-exporter"), None);
120/// assert_eq!(detect_from_image("postgrest/postgrest"), None);
121/// ```
122///
123/// # Limits
124///
125/// This is intentionally a built-in rule set. The crate does not read custom
126/// image rules or inspect image manifests.
127#[must_use]
128pub fn detect_from_image(image: &str) -> Option<StackLabel> {
129 let last_segment = image.rsplit('/').next().unwrap_or(image);
130 let base = last_segment
131 .split([':', '@'])
132 .next()
133 .unwrap_or(last_segment);
134
135 detect_exact_base(base)
136 .or_else(|| detect_prefixed_base(base))
137 .or_else(|| detect_namespace(image))
138}
139
140fn detect_exact_base(base: &str) -> Option<StackLabel> {
141 EXACT_IMAGE_RULES
142 .iter()
143 .find(|(name, _)| base.eq_ignore_ascii_case(name))
144 .map(|(_, label)| label.clone())
145}
146
147fn detect_prefixed_base(base: &str) -> Option<StackLabel> {
148 PREFIX_IMAGE_RULES
149 .iter()
150 .find(|(prefix, _)| matches_service_prefix(base, prefix))
151 .map(|(_, label)| label.clone())
152}
153
154/// Match the namespace segments of `image` (every `/` segment but the last,
155/// which holds the base name) against [`NAMESPACE_IMAGE_RULES`].
156fn detect_namespace(image: &str) -> Option<StackLabel> {
157 image.rsplit('/').skip(1).find_map(|segment| {
158 NAMESPACE_IMAGE_RULES
159 .iter()
160 .find(|(namespace, _)| segment.eq_ignore_ascii_case(namespace))
161 .map(|(_, label)| label.clone())
162 })
163}
164
165const NAME_SEPARATORS: [char; 3] = ['-', '_', '.'];
166
167fn matches_service_prefix(base: &str, prefix: &str) -> bool {
168 let Some(rest) = strip_prefix_ignore_ascii_case(base, prefix) else {
169 return false;
170 };
171
172 if rest.is_empty() {
173 return true;
174 }
175
176 rest.strip_prefix(NAME_SEPARATORS).is_some_and(|rest| {
177 !rest.split(NAME_SEPARATORS).any(|segment| {
178 COMPANION_SEGMENTS
179 .iter()
180 .any(|companion| segment.eq_ignore_ascii_case(companion))
181 })
182 })
183}