| #!/usr/bin/env python3 | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| r"""Point oplugins/runtimes.json and oplugins/opsroot.json at the latest images. | |
| The remote counterpart of `task sync-opsroot` / `task sync-runtimes`: those | |
| read whatever tags happen to be **local** in the subrepos, and so hit the | |
| gotcha documented in DEVEL.md - a freshly cloned subrepo has no tags and | |
| you silently get a tagless `...openserverless-wsk-controller:` reference. | |
| This reads the tags from the remotes instead, so a clone without tags and | |
| a clone that never fetched give the same, correct answer. | |
| Takes a `<user>` (default `apache`), which selects both where to look for | |
| the tags and what registry the images are pulled from. This mirrors the | |
| publication rule of DEVEL.md ("Where the images go"): a tag pushed to the | |
| Apache repository builds to `docker.io/apache`, a tag pushed to any fork | |
| builds to `ghcr.io/<the fork owner>`. So a contributor syncs their own | |
| published builds with | |
| ./syncimages.py myuser | |
| Tags are read with `git ls-remote` - nothing is cloned, and no credentials | |
| are needed for public repos. | |
| **Nothing is written unless you pass `--write=yes`.** The default run reports | |
| the images it resolved, each line prefixed `[dry-run]`, and exits without | |
| touching the files. This is deliberate: the tags come from whatever has | |
| been published at this moment, so you want to see what you are about to | |
| pin before you pin it. | |
| Both arguments tolerate an empty value, so a task file can pass them | |
| through without having to branch on whether they are set - `{{.USER}}` | |
| and `--write={{.WRITE}}` interpolate to the empty string when undefined: | |
| ./syncimages.py # dry run, apache | |
| ./syncimages.py "" --write= # the same, spelled by a task file | |
| ./syncimages.py myuser --write=yes | |
| `--write` also accepts a bare `--write` (meaning yes), and no/false/0/off | |
| as well as yes/true/1/on. Anything else is an error rather than a silent | |
| no, because writing is the half of this script that changes your files. | |
| A repo that cannot be read, or that carries no publication tag, is | |
| **warned about and skipped**, not fatal - a fork usually has only some of | |
| the components, and the useful answer is the tags of the ones it does | |
| have. Those images keep whatever they already reference, every warning is | |
| repeated in a summary at the end, and the exit status is non-zero so a | |
| script chaining this notices that the sync was partial. | |
| # The tag format | |
| Per DEVEL.md ("The tag format"), every component tags a publication build | |
| with a timestamp in a compacted, sortable form: | |
| 0.9.0-incubating.26i07r51-snapshot | |
| └────┬─────────┘ ││││││└┬┘└───┬───┘ | |
| BASETAG ││││││ │ └───── SUFFIX: -snapshot marks a non-release build | |
| ││││││ └─────────── minute | |
| │││││└───────────── hour, a letter: a=00 … x=23 | |
| │││└┴────────────── day of month | |
| ││└──────────────── month, a letter: a=January … l=December | |
| └┴───────────────── year, two digits | |
| so `26i07r51` is 2026, September (`i`, 9th letter), day 07, hour 17 (`r`, | |
| 18th letter, zero-based), minute 51. | |
| DEVEL.md notes the encoding is lexicographically sortable, which is what | |
| makes `--sort=-creatordate` and "the newest tag" agree. We do not rely on | |
| that: `ls-remote` gives no dates to sort by, and the repos still carry | |
| older tags in a plain-decimal `YYMMDDHHMM` form that does *not* sort | |
| against the letter form. So the timestamp is decoded into its real date | |
| before comparing, both forms are understood, and a tag that decodes as | |
| neither is skipped rather than guessed at. | |
| # runtimes.json | |
| The runtimes are the exception DEVEL.md calls out: their tag carries a | |
| prefix naming the family to build, which CI parses to decide what work to | |
| do. They are built and published together under a single `all_<timestamp>` | |
| tag, so one tag drives every entry. Each `image` subsection becomes | |
| { | |
| "prefix": "<registry>/<user>", | |
| "name": "openserverless-runtime-java", | |
| "tag": "<version>-<timestamp>" | |
| } | |
| keeping the existing `<version>-` prefix of the tag - that is the language | |
| version (`v1.27-`, `sys-`), it names what is inside the image and is not | |
| ours to change. | |
| # opsroot.json | |
| Each image under `config.images` that we build ourselves is named by the | |
| whole tag of its own repo - `BASETAG.<timestamp><SUFFIX>`, exactly the | |
| string the component was tagged with, which is what `task sync-opsroot` | |
| writes and what makes an image traceable back to its tag: | |
| <origin>-operator -> operator | |
| <origin>-admin-api -> systemapi | |
| <origin>-build -> controller, invoker | |
| <origin>-streamer -> streamer | |
| <origin>-devcontainer -> devcontainer | |
| Only the `:<tag>` part of the value is replaced, so third-party images | |
| (couchdb, redis, ingress, ...) keep their own registry and tag - those are | |
| not ours to retag. | |
| Every resolved image is printed, one per line, as `<image>:<tag>`. | |
| """ | |
| import argparse | |
| import json | |
| import re | |
| import subprocess | |
| import sys | |
| # month, a=January ... l=December - and hour, a=00 ... x=23 | |
| MONTHS = "abcdefghijkl" | |
| HOURS = "abcdefghijklmnopqrstuvwx" | |
| # 26i07r51, the form documented in DEVEL.md | |
| LETTER_TS_RE = re.compile( | |
| r"^(?P<yy>\d\d)(?P<month>[a-l])(?P<dd>\d\d)(?P<hour>[a-x])(?P<mm>\d\d)$" | |
| ) | |
| # 2409121919, the plain-decimal form of the older tags | |
| DIGIT_TS_RE = re.compile(r"^(?P<yy>\d\d)(?P<month>\d\d)(?P<dd>\d\d)(?P<hour>\d\d)(?P<mm>\d\d)$") | |
| # everything skipped, to be summarised at the end: a warning scrolled past | |
| # half an hour ago is a warning nobody acts on | |
| WARNINGS = [] | |
| # whether we are actually writing; only `report` needs to know, and | |
| # threading it through every call site would not make it clearer | |
| WRITING = False | |
| # the repos we build ourselves, and the opsroot.json keys they feed | |
| OPSROOT_REPOS = [ | |
| ("operator", ["operator"]), | |
| ("admin-api", ["systemapi"]), | |
| ("build", ["controller", "invoker"]), | |
| ("streamer", ["streamer"]), | |
| ("devcontainer", ["devcontainer"]), | |
| ] | |
| # what counts as yes and no for --write, so that `--write=$(VAR)` works | |
| # whatever spelling the caller's shell or task file happens to use | |
| TRUE = ("yes", "y", "true", "1", "on") | |
| FALSE = ("", "no", "n", "false", "0", "off") | |
| def parse_flag(value): | |
| """Read --write, rejecting anything that is not plainly yes or no. | |
| An unrecognised value is an error rather than a silent false: writing | |
| is the destructive half of this script, and `--write=maybe` quietly | |
| doing nothing is worse than a message. | |
| """ | |
| normalised = value.strip().lower() | |
| if normalised in TRUE: | |
| return True | |
| if normalised in FALSE: | |
| return False | |
| raise SystemExit( | |
| f"--write: expected one of {', '.join(TRUE)} or {', '.join(FALSE[1:])} " | |
| f"(or empty, meaning no), not {value!r}" | |
| ) | |
| def report(path, image): | |
| """Print a resolved image, saying plainly whether it is being written.""" | |
| print(f"{'' if WRITING else '[dry-run] '}{path}: {image}") | |
| def warn(message): | |
| """Report something we are skipping, on stderr so stdout stays pipeable.""" | |
| print(f"warning: {message}", file=sys.stderr) | |
| WARNINGS.append(message) | |
| def decode_timestamp(stamp): | |
| """Return the date a build timestamp encodes, or None if it is not one. | |
| The result is a tuple, to be compared rather than read: it is the real | |
| (year, month, day, hour, minute) of the build, so tags in the letter | |
| form and in the older decimal form order correctly against each other. | |
| The SUFFIX (`-snapshot`, `-RC`, ...) is not part of the timestamp but | |
| does distinguish two tags of the same minute, so it is kept as the | |
| last element to make the order total rather than arbitrary. | |
| """ | |
| base, _, suffix = stamp.partition("-") | |
| match = LETTER_TS_RE.match(base) | |
| if match: | |
| month = MONTHS.index(match.group("month")) + 1 | |
| hour = HOURS.index(match.group("hour")) | |
| else: | |
| match = DIGIT_TS_RE.match(base) | |
| if not match: | |
| return None | |
| month = int(match.group("month")) | |
| hour = int(match.group("hour")) | |
| return ( | |
| int(match.group("yy")), | |
| month, | |
| int(match.group("dd")), | |
| hour, | |
| int(match.group("mm")), | |
| suffix, | |
| ) | |
| def remote_tags(origin): | |
| """The tag names of a remote repo, or None if it cannot be read. | |
| A repo we cannot reach is reported and skipped rather than fatal: a | |
| fork typically has only some of the components, and the useful answer | |
| is the tags of the ones it does have, not nothing at all. | |
| """ | |
| try: | |
| out = subprocess.run( | |
| ["git", "ls-remote", "--tags", origin], | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| ).stdout | |
| except FileNotFoundError: | |
| raise SystemExit("git not found in PATH: it is needed to read the remote tags") | |
| except subprocess.CalledProcessError as exc: | |
| # git is chatty on failure and the last line is the useful one | |
| detail = (exc.stderr.strip().splitlines() or ["?"])[-1] | |
| warn(f"cannot list the tags of {origin}: {detail}") | |
| return None | |
| tags = set() | |
| for line in out.splitlines(): | |
| _, _, ref = line.partition("\t") | |
| if ref.startswith("refs/tags/"): | |
| # annotated tags are listed twice, the second as <tag>^{} | |
| tags.add(ref[len("refs/tags/"):].removesuffix("^{}")) | |
| return tags | |
| def latest_tag(origin, timestamp_of): | |
| """The most recent publication tag of `origin`, and its timestamp. | |
| `timestamp_of` locates the timestamp inside a tag name, and returns | |
| None for the tags that are not publication tags at all - the runtimes | |
| repo also carries `common1.18.4`, `experimental-...` and friends. | |
| Returns the tag whose timestamp is the latest, paired with that | |
| timestamp: opsroot.json wants the whole tag, runtimes.json rebuilds | |
| its own tags around the timestamp. Returns None, after warning, when | |
| the repo cannot be read or carries no publication tag - the image | |
| then keeps whatever it already references. | |
| """ | |
| tags = remote_tags(origin) | |
| if tags is None: | |
| return None | |
| candidates = {} | |
| for tag in tags: | |
| stamp = timestamp_of(tag) | |
| if stamp and decode_timestamp(stamp): | |
| candidates[tag] = stamp | |
| if not candidates: | |
| warn( | |
| f"no publication tag found in {origin}" | |
| + (f" (it has {len(tags)} tag(s), none timestamped)" if tags else " (it has no tags)") | |
| ) | |
| return None | |
| tag = max(candidates, key=lambda tag: decode_timestamp(candidates[tag])) | |
| return tag, candidates[tag] | |
| def sync_runtimes(path, prefix, timestamp, write): | |
| """Retag every runtime image in runtimes.json, keeping its version prefix.""" | |
| if timestamp is None: | |
| warn(f"{path} left alone: no runtimes tag to sync to") | |
| return | |
| with open(path) as file: | |
| runtimes = json.load(file) | |
| for kinds in runtimes["runtimes"].values(): | |
| for kind in kinds: | |
| image = kind["image"] | |
| # the tag is <language version>-<timestamp>: keep the version | |
| version, sep, _ = image["tag"].partition("-") | |
| if not sep: | |
| warn( | |
| f"{path}: the tag of {image['name']} ({image['tag']}) has no " | |
| "<version>- prefix to keep, leaving it alone" | |
| ) | |
| continue | |
| image["prefix"] = prefix | |
| image["tag"] = f"{version}-{timestamp}" | |
| report(path, f"{image['prefix']}/{image['name']}:{image['tag']}") | |
| if write: | |
| write_json(path, runtimes) | |
| def sync_opsroot(path, prefix, tags, write): | |
| """Repoint, in opsroot.json, each image we build ourselves. | |
| Both halves move: the tag, and the `<registry>/<user>` the image is | |
| pulled from. A fork publishes to its own registry, so pinning its tag | |
| onto `docker.io/apache` would name an image that does not exist. | |
| Third-party images (couchdb, redis, ...) are not in OPSROOT_REPOS and | |
| so keep both their registry and their tag. | |
| """ | |
| with open(path) as file: | |
| opsroot = json.load(file) | |
| images = opsroot["config"]["images"] | |
| for repo, keys in OPSROOT_REPOS: | |
| tag = tags[repo] | |
| for key in keys: | |
| if tag is None: | |
| # the warning naming the repo has already been issued | |
| warn(f"{path}: config.images.{key} left at {images.get(key, '?')}") | |
| continue | |
| if key not in images: | |
| warn(f"{path}: no config.images.{key} to update, skipping") | |
| continue | |
| image, sep, _ = images[key].rpartition(":") | |
| if not sep: | |
| warn( | |
| f"{path}: config.images.{key} ({images[key]}) has no :<tag> " | |
| "to replace, leaving it alone" | |
| ) | |
| continue | |
| images[key] = f"{prefix}/{image.rpartition('/')[2]}:{tag}" | |
| report(path, images[key]) | |
| if write: | |
| write_json(path, opsroot) | |
| def write_json(path, data): | |
| """Rewrite a json file in place, preserving its indentation.""" | |
| with open(path) as file: | |
| # the two files do not agree on the indent, and reindenting one of | |
| # them would bury the retag in a diff nobody can review | |
| indent = 4 if file.read().startswith("{\n ") else 2 | |
| with open(path, "w") as file: | |
| json.dump(data, file, indent=indent) | |
| file.write("\n") | |
| def main(argv=None): | |
| parser = argparse.ArgumentParser( | |
| description=__doc__.splitlines()[0], | |
| epilog="By default nothing is written: the run only reports what it would do. " | |
| "Pass --write=yes to apply it. Both arguments accept an empty value, so a " | |
| "task file can pass them through unconditionally: an empty <user> means " | |
| "apache, and an empty --write means no.", | |
| ) | |
| parser.add_argument( | |
| "user", | |
| nargs="?", | |
| default="", | |
| help="the github user and registry namespace to sync from " | |
| "(default, and for an empty value: apache)", | |
| ) | |
| parser.add_argument( | |
| "--write", | |
| nargs="?", | |
| const="yes", | |
| default="", | |
| metavar="yes|no", | |
| help="rewrite the json files (default: dry run, write nothing). " | |
| "Accepts yes/no/true/false/1/0, and an empty value meaning no", | |
| ) | |
| parser.add_argument( | |
| "--runtimes-json", | |
| default="oplugins/runtimes.json", | |
| help="path to runtimes.json (default: %(default)s)", | |
| ) | |
| parser.add_argument( | |
| "--opsroot-json", | |
| default="oplugins/opsroot.json", | |
| help="path to opsroot.json (default: %(default)s)", | |
| ) | |
| args = parser.parse_args(argv) | |
| # an empty value is how a task file passes "not set": `{{.USER}}` and | |
| # `--write={{.WRITE}}` both interpolate to the empty string when the | |
| # variable is undefined, and neither should be an error | |
| user = args.user or "apache" | |
| write = parse_flag(args.write) | |
| global WRITING | |
| WRITING = write | |
| registry = "docker.io" if user == "apache" else "ghcr.io" | |
| origin = f"https://github.com/{user}/openserverless" | |
| prefix = f"{registry}/{user}" | |
| # every tag is resolved before anything is written, so that a repo we | |
| # cannot reach cannot leave the files half synced | |
| # | |
| # the runtimes tag is the family prefix plus the timestamp, and only | |
| # the timestamp is reused - each entry rebuilds its own tag around it | |
| runtimes = latest_tag( | |
| f"{origin}-runtimes", | |
| lambda tag: tag[len("all_"):] if tag.startswith("all_") else None, | |
| ) | |
| # the others are tagged BASETAG.<timestamp><SUFFIX>, and the whole tag | |
| # is what names the image | |
| tags = {} | |
| for repo, _ in OPSROOT_REPOS: | |
| found = latest_tag( | |
| f"{origin}-{repo}", lambda tag: tag.rpartition(".")[2] or None | |
| ) | |
| tags[repo] = found[0] if found else None | |
| sync_runtimes(args.runtimes_json, prefix, runtimes[1] if runtimes else None, write) | |
| sync_opsroot(args.opsroot_json, prefix, tags, write) | |
| # stdout is block-buffered when piped, so without this the stderr | |
| # summary below would appear *before* the lines it summarises | |
| sys.stdout.flush() | |
| if WARNINGS: | |
| print( | |
| f"\n{len(WARNINGS)} warning(s), the images above are all that could be " | |
| "resolved:", | |
| file=sys.stderr, | |
| ) | |
| for message in WARNINGS: | |
| print(f" - {message}", file=sys.stderr) | |
| if not write: | |
| print("\ndry run: nothing written, pass --write=yes to apply", file=sys.stderr) | |
| # something unresolved is a real failure even though we carried on, so | |
| # a caller chaining this in a script notices | |
| return 1 if WARNINGS else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |