blob: 68c69cc241322ef79fb9f80ac7d7948cfdc9609f [file]
#!/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())