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::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}