This is an automated email from the ASF dual-hosted git repository.

sciabarracom pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/openserverless.git

commit ade3ddef1a4ae351e91ff3a2049b8b5ac82fd8da
Author: Michele Sciabarra <[email protected]>
AuthorDate: Thu Sep 10 12:21:28 2026 +0200

    adding syncimages to update references
---
 syncimages.py | 465 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 465 insertions(+)

diff --git a/syncimages.py b/syncimages.py
new file mode 100755
index 0000000..68c69cc
--- /dev/null
+++ b/syncimages.py
@@ -0,0 +1,465 @@
+#!/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())

Reply via email to