Skip to content

Commit 15d90ee

Browse files
NiccoloFeigbartolinimnencia
authored
feat: add reusable GitHub Action to generate ImageCatalogs (#323)
Introduces a composite action that wraps `catalogs_generator.py` to generate CloudNativePG ImageCatalog YAMLs from a container registry. Supports multiple image types, distributions, and custom family prefixes. Generates a `kustomization.yaml` for easy deployment of all catalogs. Related to cloudnative-pg/postgis-containers#100 Closes #324 Signed-off-by: Niccolò Fei <[email protected]> Signed-off-by: Gabriele Bartolini <[email protected]> Signed-off-by: Marco Nenciarini <[email protected]> Co-authored-by: Gabriele Bartolini <[email protected]> Co-authored-by: Marco Nenciarini <[email protected]>
1 parent 607f425 commit 15d90ee

File tree

4 files changed

+296
-29
lines changed

4 files changed

+296
-29
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Image Catalogs Generator Action
2+
3+
This composite GitHub Action generates [CloudNativePG ImageCatalogs](https://cloudnative-pg.io/documentation/current/image_catalog/)
4+
from a container registry.
5+
It wraps the [`catalogs_generator.py`](./catalogs_generator.py) script and makes it easy to
6+
run inside CI pipelines.
7+
8+
---
9+
10+
## How it works
11+
12+
1. The script retrieves all image tags from a container registry.
13+
2. A regular expression is applied to select the tags to include in the ImageCatalog.
14+
3. Matching tags are sorted using [semantic versioning](https://semver.org/).
15+
4. For each PostgreSQL major version, the latest matching tag is chosen.
16+
5. The action generates:
17+
18+
- One `ClusterImageCatalog` YAML file per requested distribution and image
19+
type
20+
- A `kustomization.yaml` to install/update all cluster catalogs at once
21+
22+
---
23+
24+
## Inputs
25+
26+
| Name | Required | Description | Example |
27+
| --------------- | --------- | ------------------------------------------------------------------------------- | ----------------------------------- |
28+
| `registry` | ✅ yes | The container registry to query. | `ghcr.io/cloudnative-pg/postgresql` |
29+
| `image-types` | ✅ yes | Comma-separated list of image types. | `minimal,standard` |
30+
| `distributions` | ✅ yes | Comma-separated list of supported OS distributions. | `bookworm,trixie` |
31+
| `regex` | ✅ yes | Regular expression used to match image tags. | *See [Regex](#regex)* |
32+
| `output-dir` | ✅ yes | Directory where generated catalogs will be written. | `./` |
33+
| `family` | ❌ no | Family name for generated catalogs (filename prefix). Defaults to `postgresql`. | `my-custom-family` |
34+
35+
---
36+
37+
## Regex
38+
39+
The `regex` input defines which tags are added to the `ClusterImageCatalog`.
40+
41+
- The **first capturing group** must be the PostgreSQL major version:
42+
43+
- `(\d+)` → e.g. `18`
44+
45+
- Subsequent capturing groups are optional and may include:
46+
47+
- an additional version: `(\d+(?:\.\d+)+)` → e.g. `1.2.3`
48+
- a 12 digit timestamp: `(\d{12})` → e.g. `202509161052`
49+
50+
**Examples:**
51+
52+
```regex
53+
# Matches '18-202509161052', '18.1-202509161052', etc.
54+
'(\d+)(?:\.\d+|beta\d+|rc\d+|alpha\d+)-(\d{12})'
55+
56+
# Matches '18-3.0.6-202509161052', '18.1-3.0.6-202509161052', etc.
57+
'(\d+)(?:\.\d+|beta\d+|rc\d+|alpha\d+)-(\d+(?:\.\d+){1,3})-(\d{12})'
58+
```
59+
60+
> **Note:** Each `image-types` and `distributions` will be combined together
61+
> to form a suffix, `-<img_type>-<distribution>`, which will internally be
62+
> appended to the `regex` provided. Tags that do not contain explicit
63+
> image type and distribution as a suffix are currently not supported.
64+
65+
---
66+
67+
### Family
68+
69+
The optional `family` input customizes:
70+
71+
1. **File prefix**: `<family>-minimal-trixie.yaml`
72+
2. **`metadata.name`** in the ImageCatalog: `<family>-minimal-trixie`
73+
3. **`images.cnpg.io/family` label** on the ImageCatalog object
74+
75+
If not specified it defaults to `postgresql`.
76+
77+
---
78+
79+
## Usage
80+
81+
Example workflow:
82+
83+
```
84+
jobs:
85+
generate-catalogs:
86+
runs-on: ubuntu-latest
87+
steps:
88+
- name: Generate image catalogs
89+
uses: cloudnative-pg/postgres-containers/.github/actions/generate-catalogs@main
90+
with:
91+
registry: ghcr.io/cloudnative-pg/postgresql
92+
image-types: minimal,standard
93+
distributions: bookworm,trixie
94+
regex: '(\d+)(?:\.\d+|beta\d+|rc\d+|alpha\d+)-(\d{12})'
95+
output-dir: .
96+
```
97+
98+
This generates:
99+
100+
```
101+
./catalog-minimal-bookworm.yaml
102+
./catalog-standard-bookworm.yaml
103+
./catalog-minimal-trixie.yaml
104+
./catalog-standard-trixie.yaml
105+
```
106+
107+
The generated `kustomization.yaml` will look like:
108+
109+
```
110+
apiVersion: kustomize.config.k8s.io/v1beta1
111+
kind: Kustomization
112+
resources:
113+
- catalog-minimal-bookworm.yaml
114+
- catalog-standard-bookworm.yaml
115+
- catalog-minimal-trixie.yaml
116+
- catalog-standard-trixie.yaml
117+
```
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Generate Image Catalogs
2+
description: Generate Image Catalogs
3+
inputs:
4+
registry:
5+
description: "The registry to interrogate"
6+
required: true
7+
image-types:
8+
description: "Image types to retrieve - comma separated values"
9+
required: true
10+
distributions:
11+
description: "OS distributions to retrieve - comma separated values"
12+
required: true
13+
regex:
14+
description: "The regular expression used to retrieve container images"
15+
required: true
16+
output-dir:
17+
description: "The path to output directory"
18+
required: true
19+
family:
20+
description: "The family name to assign to the catalogs"
21+
required: false
22+
23+
runs:
24+
using: composite
25+
steps:
26+
- name: Set up Python
27+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
28+
with:
29+
python-version: 3.13
30+
31+
- name: Install Python dependencies
32+
shell: bash
33+
run: |
34+
pip install packaging==25.0 PyYAML==6.0.2
35+
36+
- name: Generate catalogs
37+
shell: bash
38+
env:
39+
REGISTRY: ${{ inputs.registry }}
40+
IMAGE_TYPES: ${{ inputs.image-types }}
41+
DISTRIBUTIONS: ${{ inputs.distributions }}
42+
REGEX: ${{ inputs.regex }}
43+
OUTPUT_DIR: ${{ inputs.output-dir }}
44+
FAMILY: ${{ inputs.family }}
45+
run: |
46+
set -euo pipefail
47+
ARGS=()
48+
49+
if [[ -n "${REGISTRY:-}" ]]; then
50+
ARGS+=( --registry "$REGISTRY" )
51+
fi
52+
53+
if [[ -n "${IMAGE_TYPES:-}" ]]; then
54+
IFS=',' read -r -a image_types <<< "$IMAGE_TYPES"
55+
ARGS+=( --image-types "${image_types[@]}" )
56+
fi
57+
58+
if [[ -n "${DISTRIBUTIONS:-}" ]]; then
59+
IFS=',' read -r -a distributions <<< "$DISTRIBUTIONS"
60+
ARGS+=( --distributions "${distributions[@]}" )
61+
fi
62+
63+
if [[ -n "${REGEX:-}" ]]; then
64+
ARGS+=( --regex "$REGEX" )
65+
fi
66+
67+
if [[ -n "${FAMILY:-}" ]]; then
68+
ARGS+=( --family "$FAMILY" )
69+
fi
70+
71+
ARGS+=( --output-dir "$OUTPUT_DIR" )
72+
73+
echo "Running: python $GITHUB_ACTION_PATH/catalogs_generator.py ${ARGS[*]}"
74+
python "$GITHUB_ACTION_PATH/catalogs_generator.py" "${ARGS[@]}"

.github/catalogs_generator.py renamed to .github/actions/generate-catalogs/catalogs_generator.py

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,50 @@
3131
supported_os_names = ["bullseye", "bookworm", "trixie"]
3232
min_supported_major = 13
3333

34-
repo_name = "cloudnative-pg/postgresql"
35-
full_repo_name = f"ghcr.io/{repo_name}"
36-
pg_regexp = r"(\d+)(?:\.\d+|beta\d+|rc\d+|alpha\d+)-(\d{12})"
34+
default_registry = "ghcr.io/cloudnative-pg/postgresql"
35+
default_family = "postgresql"
36+
default_regex = r"(\d+)(?:\.\d+|beta\d+|rc\d+|alpha\d+)-(\d{12})"
3737
_token_cache = {"value": None, "expires_at": 0}
3838

39+
normalized_pattern = re.compile(
40+
r"""
41+
^(?P<pg_version>\d+(?:\.\d+|beta\d+|rc\d+|alpha\d+)) # A mandatory PostgreSQL version
42+
(?:-(?P<extension_version>\d+(?:\.\d+)+))? # An optional extension version
43+
(?:-(?P<timestamp>\d{12}))? # An optional timestamp
44+
$
45+
""",
46+
re.VERBOSE,
47+
)
48+
49+
50+
# Normalize a tag to make it a valid PEP 440 version.
51+
# Optional capture groups after the Postgres version will
52+
# be appended using a "+"" as a local version segment, and
53+
# concatenated with "." in case there's more then one.
54+
def normalize_tag(tag):
55+
match = normalized_pattern.match(tag)
56+
if not match:
57+
raise ValueError(f"Unrecognized tag format: {tag}")
58+
59+
pg_version = match.group("pg_version")
60+
extension_version = match.group("extension_version")
61+
timestamp = match.group("timestamp")
62+
63+
# Build PEP 440 compliant version
64+
# e.g 17.6, 17.6+202509161052, 17.6+3.6.0.202509161052
65+
extra_match = []
66+
if extension_version:
67+
extra_match.append(extension_version)
68+
if timestamp:
69+
extra_match.append(timestamp)
70+
71+
if extra_match:
72+
normalized_tag = f"{pg_version}+{'.'.join(extra_match)}"
73+
else:
74+
normalized_tag = pg_version
75+
76+
return version.Version(normalized_tag)
77+
3978

4079
def get_json(image_name):
4180
data = check_output(
@@ -52,14 +91,14 @@ def get_json(image_name):
5291
return repo_json
5392

5493

55-
def get_token(repository_name):
94+
def get_token(image_name):
5695
global _token_cache
5796
now = time.time()
5897

5998
if _token_cache["value"] and now < _token_cache["expires_at"]:
6099
return _token_cache["value"]
61100

62-
url = "https://ghcr.io/token?scope=repository:{}:pull".format(repository_name)
101+
url = "https://ghcr.io/token?scope=repository:{}:pull".format(image_name)
63102
with urllib.request.urlopen(url) as response:
64103
data = json.load(response)
65104
token = data["token"]
@@ -70,13 +109,14 @@ def get_token(repository_name):
70109

71110

72111
def get_digest(repository_name, tag):
73-
token = get_token(repository_name)
112+
image_name = repository_name.removeprefix("ghcr.io/")
113+
token = get_token(image_name)
74114
media_types = [
75115
"application/vnd.oci.image.index.v1+json",
76116
"application/vnd.oci.image.manifest.v1+json",
77117
"application/vnd.docker.distribution.manifest.v2+json",
78118
]
79-
url = f"https://ghcr.io/v2/{repository_name}/manifests/{tag}"
119+
url = f"https://ghcr.io/v2/{image_name}/manifests/{tag}"
80120
req = urllib.request.Request(url)
81121
req.add_header("Authorization", "Bearer {}".format(token))
82122
req.add_header("Accept", ",".join(media_types))
@@ -85,6 +125,14 @@ def get_digest(repository_name, tag):
85125
return digest
86126

87127

128+
def get_filename(family, img_type, os_name):
129+
filename_prefix = "catalog"
130+
if family != default_family:
131+
filename_prefix = family
132+
133+
return f"{filename_prefix}-{img_type}-{os_name}.yaml"
134+
135+
88136
def write_catalog(tags, version_re, img_type, os_name, output_dir="."):
89137
image_suffix = f"-{img_type}-{os_name}"
90138
version_re = re.compile(rf"^{version_re}{re.escape(image_suffix)}$")
@@ -97,7 +145,7 @@ def write_catalog(tags, version_re, img_type, os_name, output_dir="."):
97145
tags = [item for item in tags if not exclude_preview.search(item)]
98146

99147
# Sort the tags according to semantic versioning
100-
tags.sort(key=lambda v: version.Version(v.removesuffix(image_suffix)), reverse=True)
148+
tags.sort(key=lambda v: normalize_tag(v.removesuffix(image_suffix)), reverse=True)
101149

102150
results = {}
103151
for item in tags:
@@ -112,16 +160,19 @@ def write_catalog(tags, version_re, img_type, os_name, output_dir="."):
112160
continue
113161

114162
if major not in results:
115-
digest = get_digest(repo_name, item)
116-
results[major] = [f"{full_repo_name}:{item}@{digest}"]
163+
digest = get_digest(args.registry, item)
164+
results[major] = [f"{args.registry}:{item}@{digest}"]
165+
166+
if not results:
167+
raise RuntimeError("No results have been found!")
117168

118169
catalog = {
119170
"apiVersion": "postgresql.cnpg.io/v1",
120171
"kind": "ClusterImageCatalog",
121172
"metadata": {
122-
"name": f"postgresql{image_suffix}",
173+
"name": f"{args.family}{image_suffix}",
123174
"labels": {
124-
"images.cnpg.io/family": "postgresql",
175+
"images.cnpg.io/family": args.family,
125176
"images.cnpg.io/type": img_type,
126177
"images.cnpg.io/os": os_name,
127178
"images.cnpg.io/date": time.strftime("%Y%m%d"),
@@ -137,7 +188,7 @@ def write_catalog(tags, version_re, img_type, os_name, output_dir="."):
137188
}
138189

139190
os.makedirs(output_dir, exist_ok=True)
140-
output_file = os.path.join(output_dir, f"catalog{image_suffix}.yaml")
191+
output_file = os.path.join(output_dir, get_filename(args.family, img_type, os_name))
141192
with open(output_file, "w") as f:
142193
yaml.dump(catalog, f, sort_keys=False)
143194

@@ -146,20 +197,47 @@ def write_catalog(tags, version_re, img_type, os_name, output_dir="."):
146197
parser = argparse.ArgumentParser(
147198
description="CloudNativePG ClusterImageCatalog YAML generator"
148199
)
200+
parser.add_argument(
201+
"--registry",
202+
default=default_registry,
203+
help=f"The registry to interrogate (default: {default_registry})",
204+
)
149205
parser.add_argument(
150206
"--output-dir", default=".", help="Directory to save the YAML files"
151207
)
208+
parser.add_argument(
209+
"--regex",
210+
default=default_regex,
211+
help=f"The regular expression used to retrieve container image. The first capturing group must be the PostgreSQL major version. (default: {default_regex})",
212+
)
213+
parser.add_argument(
214+
"--image-types",
215+
nargs="+",
216+
default=supported_img_types,
217+
help=f"Image types to retrieve (default: {supported_img_types})",
218+
)
219+
parser.add_argument(
220+
"--distributions",
221+
nargs="+",
222+
default=supported_os_names,
223+
help=f"Distributions to retrieve (default: {supported_os_names})",
224+
)
225+
parser.add_argument(
226+
"--family",
227+
default=default_family,
228+
help=f"The family name to assign to the catalogs (default: {default_family})",
229+
)
152230
args = parser.parse_args()
153231

154-
repo_json = get_json(full_repo_name)
232+
repo_json = get_json(args.registry)
155233
tags = repo_json["Tags"]
156234

157235
catalogs = []
158-
for img_type in supported_img_types:
159-
for os_name in supported_os_names:
160-
filename = f"catalog-{img_type}-{os_name}.yaml"
236+
for img_type in args.image_types:
237+
for os_name in args.distributions:
238+
filename = get_filename(args.family, img_type, os_name)
161239
print(f"Generating {filename}")
162-
write_catalog(tags, pg_regexp, img_type, os_name, args.output_dir)
240+
write_catalog(tags, args.regex, img_type, os_name, args.output_dir)
163241
catalogs.append(filename)
164242

165243
kustomization = {

0 commit comments

Comments
 (0)