Skip to main content

pg_ephemeral/
image.rs

1/// Postgresql images supported, references images from <https://hub.docker.com/_/postgres>
2#[derive(Clone, Debug, PartialEq)]
3pub enum Image {
4    /// Official release
5    OfficialRelease {
6        major: Major,
7        minor: Minor,
8        os: OS,
9        digest: Option<Digest>,
10    },
11    /// OfficialRelease candidate
12    OfficialReleaseCandidate {
13        major: Major,
14        number: ReleaseCandidateNumber,
15        os: OS,
16        digest: Option<Digest>,
17    },
18    /// Latest image on docker.com
19    ///
20    /// Only use that one for quick and dirty testing, it's recommended to always pin
21    /// specific images in config files. Also note that pg-ephemeral currently never refreshes
22    /// `latest` once cached in the local registry it's never refreshed.
23    OfficialLatest { os: OS, digest: Option<Digest> },
24    /// Explicit OCI image reference, bypassing the official postgres image naming
25    Explicit(ociman::image::Reference),
26}
27
28impl std::default::Default for Image {
29    fn default() -> Self {
30        Self::OfficialLatest {
31            os: OS::Default,
32            digest: None,
33        }
34    }
35}
36
37impl std::fmt::Display for Image {
38    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
39        match self {
40            Self::OfficialRelease {
41                major,
42                minor,
43                os,
44                digest,
45            } => {
46                write!(formatter, "{major}{minor}{os}")?;
47                if let Some(digest) = digest {
48                    write!(formatter, "@{digest}")?;
49                }
50                Ok(())
51            }
52            Self::OfficialReleaseCandidate {
53                major,
54                number,
55                os,
56                digest,
57            } => {
58                write!(formatter, "{major}rc{number}{os}")?;
59                if let Some(digest) = digest {
60                    write!(formatter, "@{digest}")?;
61                }
62                Ok(())
63            }
64            Self::OfficialLatest { os, digest } => {
65                match os {
66                    OS::Default => write!(formatter, "latest")?,
67                    OS::Explicit(value) => write!(formatter, "{value}")?,
68                }
69                if let Some(digest) = digest {
70                    write!(formatter, "@{digest}")?;
71                }
72                Ok(())
73            }
74            Self::Explicit(image) => write!(formatter, "{image}"),
75        }
76    }
77}
78
79impl std::str::FromStr for Image {
80    type Err = String;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        use nom::{
84            Finish, IResult, Parser,
85            branch::alt,
86            bytes::complete::{tag, take_while_m_n, take_while1},
87            character::complete::digit1,
88            combinator::{cut, opt, recognize},
89            error::context,
90            sequence::{pair, preceded},
91        };
92        use nom_language::error::VerboseError;
93
94        type ParseResult<'a, O> = IResult<&'a str, O, VerboseError<&'a str>>;
95
96        fn os_name(input: &str) -> ParseResult<'_, &str> {
97            context(
98                "OS name",
99                recognize(pair(
100                    take_while_m_n(1, 1, |ch: char| ch.is_ascii_lowercase()),
101                    take_while1(|ch: char| {
102                        ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.'
103                    }),
104                )),
105            )
106            .parse(input)
107        }
108
109        fn os_suffix(input: &str) -> ParseResult<'_, OS> {
110            context(
111                "OS suffix",
112                preceded(tag("-"), os_name).map(|name: &str| OS::Explicit(name.to_string())),
113            )
114            .parse(input)
115        }
116
117        fn digest(input: &str) -> ParseResult<'_, Digest> {
118            context(
119                "digest",
120                preceded(
121                    tag("@sha256:"),
122                    cut(take_while_m_n(64, 64, |ch: char| ch.is_ascii_hexdigit())),
123                )
124                .map_res(|hash: &str| {
125                    hex::decode(hash)
126                        .map_err(|err| format!("invalid hex: {err}"))
127                        .and_then(|bytes| {
128                            bytes
129                                .try_into()
130                                .map(Digest)
131                                .map_err(|_| "hash must be exactly 32 bytes".to_string())
132                        })
133                }),
134            )
135            .parse(input)
136        }
137
138        fn latest(input: &str) -> ParseResult<'_, Image> {
139            context(
140                "latest image",
141                (tag("latest"), opt(digest)).map(|(_, digest)| Image::OfficialLatest {
142                    os: OS::Default,
143                    digest,
144                }),
145            )
146            .parse(input)
147        }
148
149        fn os_only(input: &str) -> ParseResult<'_, Image> {
150            context(
151                "OS-only image",
152                (os_name, opt(digest)).map(|(os, digest)| Image::OfficialLatest {
153                    os: OS::Explicit(os.to_string()),
154                    digest,
155                }),
156            )
157            .parse(input)
158        }
159
160        fn release_candidate(input: &str) -> ParseResult<'_, Image> {
161            context(
162                "release candidate image",
163                (
164                    digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
165                    preceded(
166                        tag("rc"),
167                        digit1.map_res(|digits: &str| {
168                            digits
169                                .parse::<std::num::NonZero<u8>>()
170                                .map(ReleaseCandidateNumber)
171                        }),
172                    ),
173                    opt(os_suffix),
174                    opt(digest),
175                )
176                    .map(|(major, number, os, digest)| {
177                        Image::OfficialReleaseCandidate {
178                            major,
179                            number,
180                            os: os.unwrap_or(OS::Default),
181                            digest,
182                        }
183                    }),
184            )
185            .parse(input)
186        }
187
188        fn official_release(input: &str) -> ParseResult<'_, Image> {
189            context(
190                "official release image",
191                (
192                    digit1.map_res(|digits: &str| digits.parse::<u8>().map(Major)),
193                    opt(preceded(
194                        tag("."),
195                        digit1.map_res(|digits: &str| digits.parse::<u8>().map(Minor::Explicit)),
196                    )),
197                    opt(os_suffix),
198                    opt(digest),
199                )
200                    .map(|(major, minor, os, digest)| Image::OfficialRelease {
201                        major,
202                        minor: minor.unwrap_or(Minor::Latest),
203                        os: os.unwrap_or(OS::Default),
204                        digest,
205                    }),
206            )
207            .parse(input)
208        }
209
210        fn image(input: &str) -> ParseResult<'_, Image> {
211            alt((latest, release_candidate, official_release, os_only)).parse(input)
212        }
213
214        match image(value).finish() {
215            Ok(("", result)) => Ok(result),
216            Ok((remaining, _)) => Err(format!("unexpected trailing input: '{remaining}'")),
217            Err(error) => Err(nom_language::error::convert_error(value, error)),
218        }
219    }
220}
221
222impl<'de> serde::Deserialize<'de> for Image {
223    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
224        <String as serde::de::Deserialize<'de>>::deserialize(deserializer)
225            .and_then(|value| value.parse().map_err(serde::de::Error::custom))
226    }
227}
228
229/// ```
230/// let explicit = pg_ephemeral::Image::Explicit("my-registry.com/postgres:17".parse().unwrap());
231/// let reference: ociman::image::Reference = (&explicit).into();
232/// assert_eq!(reference.to_string(), "my-registry.com/postgres:17");
233/// ```
234impl From<&Image> for ociman::image::Reference {
235    fn from(image: &Image) -> Self {
236        match image {
237            Image::Explicit(reference) => reference.clone(),
238            Image::OfficialRelease { .. }
239            | Image::OfficialReleaseCandidate { .. }
240            | Image::OfficialLatest { .. } => {
241                format!("registry.hub.docker.com/library/postgres:{image}")
242                    .parse()
243                    .unwrap()
244            }
245        }
246    }
247}
248
249#[derive(Clone, Debug, PartialEq)]
250pub struct Major(u8);
251
252impl std::fmt::Display for Major {
253    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
254        write!(formatter, "{}", self.0)
255    }
256}
257
258impl Major {
259    #[must_use]
260    pub const fn new(value: u8) -> Self {
261        Self(value)
262    }
263}
264
265#[derive(Clone, Debug, PartialEq)]
266pub enum Minor {
267    Explicit(u8),
268    Latest,
269}
270
271impl std::fmt::Display for Minor {
272    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
273        match self {
274            Self::Explicit(number) => write!(formatter, ".{number}"),
275            Self::Latest => write!(formatter, ""),
276        }
277    }
278}
279
280#[derive(Clone, Debug, PartialEq)]
281pub struct ReleaseCandidateNumber(std::num::NonZero<u8>);
282
283impl ReleaseCandidateNumber {
284    #[must_use]
285    pub const fn new(value: std::num::NonZero<u8>) -> Self {
286        Self(value)
287    }
288}
289
290impl std::fmt::Display for ReleaseCandidateNumber {
291    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
292        write!(formatter, "{}", self.0)
293    }
294}
295
296/// Operating system variant for PostgreSQL Docker images
297#[derive(Clone, Debug, PartialEq)]
298pub enum OS {
299    Default,
300    Explicit(String),
301}
302
303impl std::fmt::Display for OS {
304    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
305        match self {
306            Self::Default => write!(formatter, ""),
307            Self::Explicit(value) => write!(formatter, "-{value}"),
308        }
309    }
310}
311
312/// Docker image digest for pinning images to specific SHA256 hashes
313#[derive(Clone, Debug, PartialEq)]
314pub struct Digest([u8; 32]);
315
316impl std::fmt::Display for Digest {
317    fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
318        write!(formatter, "sha256:{}", hex::encode(self.0))
319    }
320}
321
322#[cfg(test)]
323mod test {
324    use super::*;
325
326    #[test]
327    fn test_image_string() {
328        assert_image(
329            "latest",
330            &Image::OfficialLatest {
331                os: OS::Default,
332                digest: None,
333            },
334        );
335
336        assert_image(
337            "trixie",
338            &Image::OfficialLatest {
339                os: OS::Explicit("trixie".to_string()),
340                digest: None,
341            },
342        );
343
344        assert_image(
345            "18rc1",
346            &Image::OfficialReleaseCandidate {
347                major: Major(18),
348                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
349                os: OS::Default,
350                digest: None,
351            },
352        );
353
354        assert_image(
355            "18rc1-trixie",
356            &Image::OfficialReleaseCandidate {
357                major: Major(18),
358                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
359                os: OS::Explicit("trixie".to_string()),
360                digest: None,
361            },
362        );
363
364        assert_image(
365            "18rc1-bookworm",
366            &Image::OfficialReleaseCandidate {
367                major: Major(18),
368                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
369                os: OS::Explicit("bookworm".to_string()),
370                digest: None,
371            },
372        );
373
374        assert_image(
375            "18rc1-alpine3.22",
376            &Image::OfficialReleaseCandidate {
377                major: Major(18),
378                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
379                os: OS::Explicit("alpine3.22".to_string()),
380                digest: None,
381            },
382        );
383
384        assert_image(
385            "18rc1-alpine3.21",
386            &Image::OfficialReleaseCandidate {
387                major: Major(18),
388                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
389                os: OS::Explicit("alpine3.21".to_string()),
390                digest: None,
391            },
392        );
393
394        assert_image(
395            "18rc1-alpine",
396            &Image::OfficialReleaseCandidate {
397                major: Major(18),
398                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
399                os: OS::Explicit("alpine".to_string()),
400                digest: None,
401            },
402        );
403
404        assert_image(
405            "17",
406            &Image::OfficialRelease {
407                major: Major(17),
408                minor: Minor::Latest,
409                os: OS::Default,
410                digest: None,
411            },
412        );
413
414        assert_image(
415            "17-trixie",
416            &Image::OfficialRelease {
417                major: Major(17),
418                minor: Minor::Latest,
419                os: OS::Explicit("trixie".to_string()),
420                digest: None,
421            },
422        );
423
424        assert_image(
425            "17.6",
426            &Image::OfficialRelease {
427                major: Major(17),
428                minor: Minor::Explicit(6),
429                os: OS::Default,
430                digest: None,
431            },
432        );
433
434        assert_image(
435            "17.6-trixie",
436            &Image::OfficialRelease {
437                major: Major(17),
438                minor: Minor::Explicit(6),
439                os: OS::Explicit("trixie".to_string()),
440                digest: None,
441            },
442        );
443    }
444
445    fn assert_image(syntax: &str, expected: &Image) {
446        assert_eq!(syntax.parse().as_ref(), Ok(expected), "parses: {syntax:#?}");
447        assert_eq!(format!("{expected}"), syntax, "generates: {syntax:#?}");
448    }
449
450    #[test]
451    fn test_image_with_digest() {
452        let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
453        let parsed_digest = Some(Digest(hex::decode(hash).unwrap().try_into().unwrap()));
454
455        // Test OfficialRelease with digest
456        assert_image(
457            "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
458            &Image::OfficialRelease {
459                major: Major(17),
460                minor: Minor::Explicit(6),
461                os: OS::Default,
462                digest: parsed_digest.clone(),
463            },
464        );
465
466        assert_image(
467            "17.6-trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
468            &Image::OfficialRelease {
469                major: Major(17),
470                minor: Minor::Explicit(6),
471                os: OS::Explicit("trixie".to_string()),
472                digest: parsed_digest.clone(),
473            },
474        );
475
476        assert_image(
477            "17@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
478            &Image::OfficialRelease {
479                major: Major(17),
480                minor: Minor::Latest,
481                os: OS::Default,
482                digest: parsed_digest.clone(),
483            },
484        );
485
486        // Test OfficialReleaseCandidate with digest
487        assert_image(
488            "18rc1@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
489            &Image::OfficialReleaseCandidate {
490                major: Major(18),
491                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
492                os: OS::Default,
493                digest: parsed_digest.clone(),
494            },
495        );
496
497        assert_image(
498            "18rc1-alpine@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
499            &Image::OfficialReleaseCandidate {
500                major: Major(18),
501                number: ReleaseCandidateNumber(1u8.try_into().unwrap()),
502                os: OS::Explicit("alpine".to_string()),
503                digest: parsed_digest.clone(),
504            },
505        );
506
507        // Test OfficialLatest with digest
508        assert_image(
509            "latest@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
510            &Image::OfficialLatest {
511                os: OS::Default,
512                digest: parsed_digest.clone(),
513            },
514        );
515
516        assert_image(
517            "trixie@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
518            &Image::OfficialLatest {
519                os: OS::Explicit("trixie".to_string()),
520                digest: parsed_digest.clone(),
521            },
522        );
523    }
524
525    #[test]
526    fn test_ociman_image_conversion_with_digest() {
527        let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
528        let image = Image::OfficialRelease {
529            major: Major(17),
530            minor: Minor::Explicit(6),
531            os: OS::Default,
532            digest: Some(Digest(hex::decode(hash).unwrap().try_into().unwrap())),
533        };
534
535        let reference: ociman::image::Reference = (&image).into();
536        let expected = "registry.hub.docker.com/library/postgres:17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
537
538        assert_eq!(reference.to_string(), expected);
539    }
540
541    #[test]
542    fn test_parse_error_uppercase() {
543        let error = "LATEST".parse::<Image>().unwrap_err();
544        let expected = indoc::indoc! {"
545            0: at line 1, in TakeWhileMN:
546            LATEST
547            ^
548
549            1: at line 1, in OS name:
550            LATEST
551            ^
552
553            2: at line 1, in OS-only image:
554            LATEST
555            ^
556
557            3: at line 1, in Alt:
558            LATEST
559            ^
560
561        "};
562        assert_eq!(error, expected);
563    }
564
565    #[test]
566    fn test_parse_error_invalid_rc() {
567        let error = "17rc".parse::<Image>().unwrap_err();
568        let expected = "unexpected trailing input: 'rc'";
569        assert_eq!(error, expected);
570    }
571
572    #[test]
573    fn test_parse_error_short_digest() {
574        let error = "17@sha256:abc".parse::<Image>().unwrap_err();
575        let expected = indoc::indoc! {"
576            0: at line 1, in TakeWhileMN:
577            17@sha256:abc
578                      ^
579
580            1: at line 1, in digest:
581            17@sha256:abc
582              ^
583
584            2: at line 1, in official release image:
585            17@sha256:abc
586            ^
587
588        "};
589        assert_eq!(error, expected);
590    }
591
592    #[test]
593    fn test_parse_error_trailing_dash() {
594        let error = "17-".parse::<Image>().unwrap_err();
595        let expected = "unexpected trailing input: '-'";
596        assert_eq!(error, expected);
597    }
598
599    #[test]
600    fn test_parse_error_trailing_content() {
601        let error = "17.6.5".parse::<Image>().unwrap_err();
602        let expected = "unexpected trailing input: '.5'";
603        assert_eq!(error, expected);
604    }
605
606    #[test]
607    fn test_parse_error_invalid_os_name() {
608        let error = "17-9invalid".parse::<Image>().unwrap_err();
609        let expected = "unexpected trailing input: '-9invalid'";
610        assert_eq!(error, expected);
611    }
612}