| #!/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. |
| """Check a deployed BE plugin tree against the rules the plugin isolation depends on. |
| |
| Run it on a built output tree: |
| |
| tools/be-java-plugins/check_plugin_layout.py [output/be/lib/jni/spi] [output/be/plugins/jni] |
| |
| Six checks, four of which have caught a real regression during the plugin migration: |
| |
| spi-jar-purity doris-jni-spi.jar carries only the SPI packages. Everything in that jar |
| is shared by BE and by every plugin at once, so a class that slips in |
| can never be upgraded by one plugin alone. |
| plugin-ships-no-spi No jar inside a plugin directory carries an SPI class. A plugin that |
| brings its own copy gets a second, incompatible identity for the same |
| type; the loader rejects it, but only when someone runs a query. |
| duplicate-classes Within one plugin directory, a class name that resolves to different |
| bytes in different jars. One directory is one flat classloader, so the |
| winner is jar-name order - not a decision anyone made. The one decided |
| case is a SHADOWING jar: one whose manifest names, in |
| Doris-Shadows-Classes, the classes it exists to replace (hadoop-deps, |
| carrying the Doris-patched org.apache.hadoop.fs.FileSystem). PluginRuntime |
| searches such a jar first whatever its name, so those classes - nested |
| ones included - may duplicate a sibling's. |
| shadowing-jar A jar that declares Doris-Shadows-Classes carries every class it names |
| and nothing else. The loader trusts the attribute for search order; this |
| is what keeps it honest, so a jar cannot ride to the front of the |
| classpath with more than the classes it declares. |
| closure-self-contained |
| Every class referenced from a plugin's ROOT jars resolves inside that |
| plugin directory (plus the SPI jar and the JDK). The roots are Doris's own |
| jars AND every jar holding a class Doris hands to hadoop as a string - |
| the fs.*.impl and credentials-provider values in fe/fe-filesystem/**, see |
| _NAMED_BY_DORIS. This is the check that fires when a dependency is |
| switched to `provided` and its jar stops being deployed while the code |
| still calls into it, and - because of that second root set - when a |
| bundled filesystem is missing a library of its OWN. |
| api-version-stamp The jar declaring the DorisPlugin service carries the |
| Doris-Jni-Plugin-Api-Version this build serves. That attribute is the only |
| thing PluginRuntime's version gate reads, and until this check existed |
| nothing verified it reached the artifact. |
| |
| WHAT THIS DOES NOT PROVE. The last check is static, so it says nothing about anything reached by |
| a ServiceLoader or by a reflective lookup Doris does not make itself - JDBC drivers, logging |
| backends, jackson modules. A jar can disappear from a plugin directory, pass every check here, and |
| still fail at runtime. The only thing that proves a closure complete is loading the plugin out of |
| the deployed directory for real. Treat a green run as "no STATICALLY visible hole", nothing more. |
| |
| BUNDLED THIRD-PARTY FILESYSTEMS used to be the sharpest edge of that and have drawn blood twice. A |
| plugin packages hadoop-aws or hadoop-huaweicloud for one URI scheme; hadoop reaches it by class |
| NAME out of a Configuration, so no Doris class references it and a walk rooted at Doris's own jars |
| never entered it. Its own dependencies were then invisible too - hadoop-huaweicloud calls |
| commons-lang 2.x from seven classes and hadoop-aws's AssumedRoleCredentialProvider calls |
| software.amazon.awssdk.services.sts from twenty-three, and neither jar carries a copy. Both used to |
| come from the shared preload classpath and now have to be declared by each plugin that bundles |
| them; both shipped broken before this was checked. |
| |
| _NAMED_BY_DORIS closes that particular hole by making those jars roots. Note what it is NOT: it |
| does not follow a Class.forName and it is not reflection analysis. It is a hand-maintained list of |
| the class names Doris itself writes into a Configuration, and every reference it then finds is an |
| ordinary constant-pool entry. A filesystem Doris reaches some other way is still invisible, and so |
| is anything those roots reach reflectively - the per-plugin |
| resolvesEveryFilesystemSchemeAScanCanArriveOn tests do not close that either, because |
| getFileSystemClass returns a Class without linking it. |
| |
| The shared filesystem directory (plugins/jni_fs, appended to every plugin classloader for |
| jindofs/juicefs) is outside this check entirely: it is not a plugin directory, so nothing here |
| looks at it - not the duplicate scan, and not the closure walk, which will happily report a class |
| as missing that a BE resolves out of there at runtime. |
| |
| Exit status: 0 if every check passes, 1 if any fails, 2 if the tree or the tools are unusable. |
| """ |
| import collections |
| import functools |
| import os |
| import subprocess |
| import sys |
| import zipfile |
| |
| SPI_PREFIX = "org/apache/doris/jni/spi/" |
| DORIS_PREFIX = "org/apache/doris/" |
| |
| _OBS = ("Two builds of the Huawei OBS SDK: the standalone esdk-obs-java-optimised and the copy " |
| "shaded into the hadoop-huaweicloud fat jar. Both are present on BE's system classpath " |
| "today with the same overlap, and PluginRuntime sorts jar URLs, so the copy that wins " |
| "here is the copy that wins today.") |
| |
| # Classes allowed to appear with DIFFERING bytes in two jars of the same plugin directory. |
| # Adding an entry means claiming the arbitrary winner is harmless - write down why. |
| # Match kinds: "basename" compares the last path segment, "prefix" compares the whole path. |
| DUPLICATE_ALLOWLIST = { |
| "*": [ |
| ("basename", "module-info.class", |
| "A plugin directory is a classpath, not a module path, so module descriptors are never " |
| "read. Multi-release jars additionally carry one per version directory."), |
| ("basename", "package-info.class", |
| "Holds package annotations and no code. The copies that collide here are hadoop's, " |
| "which carry only its InterfaceAudience/InterfaceStability documentation annotations, " |
| "so which one wins changes nothing that executes."), |
| ], |
| # No JindoFS entries any more. This build no longer copies the JindoFS jars into the iceberg |
| # and paimon directories - plugins/jni_fs is appended to every plugin classloader instead - so |
| # the ten class paths those copies used to collide on (org/apache/hadoop/fs/StreamCapabilities, |
| # PositionedReadable, four javax/annotation Checker types and their siblings) are back under |
| # this check. A real collision on any of them must fail the build rather than be waved through |
| # by an exemption whose reason no longer exists. |
| "iceberg": [("prefix", "com/obs/", _OBS), ("prefix", "com/oef/", _OBS)], |
| "paimon": [("prefix", "com/obs/", _OBS), ("prefix", "com/oef/", _OBS)], |
| "hudi": [("prefix", "com/obs/", _OBS), ("prefix", "com/oef/", _OBS)], |
| } |
| |
| # Classes that Doris's own code in a plugin references but that the plugin deliberately does not |
| # ship. Every entry is a claim that the referencing code is never executed in BE - the class is |
| # in the jar because it came with a shared Doris artifact, not because a plugin path reaches it. |
| # Prefix match on the unresolved class name. |
| _TRIMMED = ("Reached only from {} inside a shared Doris jar that the plugin needs for other " |
| "reasons. {} was excluded from this closure on purpose; the referencing code is not " |
| "on any BE path, so the class is never loaded and the missing library never resolves.") |
| _FASTUTIL = _TRIMMED.format( |
| "fe-foundation's ConcurrentLong2LongHashMap / ConcurrentLong2ObjectHashMap", "fastutil") |
| # Holes INSIDE the bundled cloud filesystems, surfaced by the widened root set (_NAMED_BY_DORIS). |
| # These are not Doris's code and not Doris's choices; each entry says why the reference is never |
| # linked on a BE path, and every one of them was checked with javap against the deployed jar |
| # rather than assumed. Adding hadoop-aws or hadoop-huaweicloud to a plugin brings the whole list. |
| _S3A_CSE = ("hadoop-aws's EncryptionS3ClientFactory, the S3 client-side-encryption path " |
| "(fs.s3a.encryption.algorithm=CSE-KMS). It needs the Amazon S3 Encryption Client " |
| "(software.amazon.encryption.s3), which is not a transitive dependency of anything " |
| "here and was not on the shared preload classpath this plugin layout replaced either " |
| "- CSE-KMS therefore did not work before this change and does not now. Shipping the " |
| "kms module alone would not fix it. Doris never writes that property.") |
| _SDK_BUNDLE = ("the AWS SDK's own shaded HTTP client, which ships only in its `bundle` uber-jar; " |
| "the plugins deploy the modular `apache-client` instead. The two reference sites " |
| "are STSClientFactory.getSTSEndpoint (private, called only when " |
| "fs.s3a.assumed.role.sts.endpoint is set - Doris never writes it) and " |
| "ConfigureShadedAWSSocketFactory (reached only for fs.s3a.ssl.channel.mode values " |
| "naming the shaded OpenSSL path, which Doris never writes either).") |
| _SDK_V1 = ("hadoop-aws's V1ToV2AwsCredentialProviderAdapter, the shim for credential providers " |
| "written against AWS SDK v1. It is instantiated only for a provider CLASS NAME under " |
| "com.amazonaws.*, and every provider Doris names is either hadoop's own or an SDK v2 " |
| "one (see _NAMED_BY_DORIS). No plugin ships SDK v1 at all.") |
| _LOG4J1 = ("hadoop-aws's Log4JController, an optional log4j 1.x bridge that LogControllerFactory " |
| "loads reflectively and skips when it is absent. Doris runs log4j2.") |
| _OKHTTP_PLATFORM = ("the OBS SDK's shaded okhttp, whose Platform.findPlatform() probes for an " |
| "Android or Conscrypt runtime and catches ClassNotFoundException. Neither is " |
| "present in a BE, which is what the probe is for.") |
| _OBS_BASE64 = ("the OBS SDK's shaded XML builder, which prefers net.iharder.Base64 when it is on " |
| "the classpath and falls back to its own encoder when it is not.") |
| _GUAVA_CHECKED_FUTURE = ("Futures.immediateFailedCheckedFuture, removed in Guava 26; " |
| "hadoop-huaweicloud 3.1.1-hw-46 was built against an older one. Reached " |
| "only from SemaphoredDelegatingExecutor.submit()'s InterruptedException " |
| "branch. Pre-existing and byte-identical to what BE's system classpath " |
| "carried before this layout - no plugin can fix it without shipping a " |
| "Guava that the rest of the tree has moved past.") |
| _S3A_COMMITTER = ("hadoop-aws's S3A output committers, which extend hadoop-mapreduce-client-core " |
| "types. BE reads table formats; it never runs a MapReduce job, and nothing " |
| "here resolves a committer. NOT in _BUNDLED_S3A on purpose: the prefix is far " |
| "wider than the committers and covers " |
| "org.apache.hadoop.mapreduce.lib.input.FileInputFormat, which paimon and hudi " |
| "DO reach - see the per-plugin note in CLOSURE_ALLOWLIST.") |
| _BUNDLED_S3A = [ |
| ("software.amazon.encryption.s3.", _S3A_CSE), |
| ("software.amazon.awssdk.services.kms.", _S3A_CSE), |
| ("software.amazon.awssdk.thirdparty.", _SDK_BUNDLE), |
| ("com.amazonaws.", _SDK_V1), |
| ("org.apache.log4j.", _LOG4J1), |
| ] |
| _BUNDLED_OBS = [ |
| ("android.", _OKHTTP_PLATFORM), |
| ("org.conscrypt.", _OKHTTP_PLATFORM), |
| ("net.iharder.", _OBS_BASE64), |
| ("com.google.common.util.concurrent.CheckedFuture", _GUAVA_CHECKED_FUTURE), |
| ] |
| CLOSURE_ALLOWLIST = { |
| # The mapreduce exemption is iceberg's ALONE. iceberg-metadata-scanner is the only plugin that |
| # bundles hadoop-aws WITHOUT hadoop-mapreduce-client-core, so it is the only one where the S3A |
| # committers' mapreduce references are genuinely unresolvable. paimon-scanner and |
| # hadoop-hudi-scanner declare that artifact on purpose (commit d50394e645d: every parquet read |
| # from a paimon catalog failed with NoClassDefFoundError on |
| # org.apache.hadoop.mapreduce.lib.input.FileInputFormat, which this prefix would wave through), |
| # so on those two the prefix stays guarded and dropping the jar again fails the build. |
| "iceberg": [("it.unimi.dsi.fastutil.", _FASTUTIL), |
| ("org.apache.hadoop.mapreduce.", _S3A_COMMITTER)] + _BUNDLED_S3A + _BUNDLED_OBS, |
| "paimon": [("it.unimi.dsi.fastutil.", _FASTUTIL)] + _BUNDLED_S3A + _BUNDLED_OBS, |
| "hudi": [("it.unimi.dsi.fastutil.", _FASTUTIL)] + _BUNDLED_S3A + _BUNDLED_OBS, |
| "java-udf": [ |
| ("it.unimi.dsi.fastutil.", _FASTUTIL), |
| ("org.roaringbitmap.", _TRIMMED.format("fe-common's org.apache.doris.common.io codecs", |
| "RoaringBitmap")), |
| ("com.fasterxml.jackson.", _TRIMMED.format("fe-common's org.apache.doris.common.util", |
| "jackson")), |
| ("com.google.protobuf.", _TRIMMED.format("fe-common's org.apache.doris.persist.gson", |
| "protobuf")), |
| ("io.netty.", _TRIMMED.format("fe-common's org.apache.doris.common", "netty")), |
| ("org.apache.commons.io.", _TRIMMED.format("fe-common's org.apache.doris.common", |
| "commons-io")), |
| ("org.apache.commons.codec.", _TRIMMED.format("fe-common's org.apache.doris.common.io", |
| "commons-codec")), |
| ("org.apache.logging.log4j.core.", _TRIMMED.format( |
| "fe-common's log4j plugin classes", "log4j-core")), |
| ], |
| } |
| |
| |
| def _allowed(rules, path): |
| for kind, pattern, _reason in rules: |
| if kind == "basename" and path.rsplit("/", 1)[-1] == pattern: |
| return True |
| if kind == "prefix" and path.startswith(pattern): |
| return True |
| return False |
| |
| |
| def _class_entries(jar): |
| with zipfile.ZipFile(jar) as zf: |
| return [(i.filename, i.CRC) for i in zf.infolist() if i.filename.endswith(".class")] |
| |
| |
| # Memoized because the same jar is read by four of the five checks - the duplicate scan, the |
| # sole-provider scan, the api-version scan and the root selection below - and a lake-format |
| # plugin directory holds a hundred of them, one of which is a 180 MB fat jar. A tuple rather than |
| # the list zipfile hands back, so a caller cannot mutate what the next one reads. |
| @functools.lru_cache(maxsize=None) |
| def _names(jar): |
| with zipfile.ZipFile(jar) as zf: |
| return tuple(zf.namelist()) |
| |
| |
| def check_spi_jar_purity(spi_dir, fail): |
| jar = os.path.join(spi_dir, "doris-jni-spi.jar") |
| # Both jars, because the shared layer is both: without doris-jni-bootstrap.jar there is no |
| # loader at all and every Java feature fails at runtime with a FindClass error naming none of |
| # this. Only the SPI jar's contents are checked below - the loader may carry whatever it needs. |
| loader = os.path.join(spi_dir, "doris-jni-bootstrap.jar") |
| if not os.path.isfile(loader): |
| fail("spi-jar-purity", |
| "%s is missing; without the loader no plugin can be loaded at all" % loader) |
| if not os.path.isfile(jar): |
| fail("spi-jar-purity", "%s is missing; the shared layer was not deployed" % jar) |
| return |
| stray = [n for n in _names(jar) |
| if not n.endswith("/") |
| and not n.startswith(SPI_PREFIX) |
| and not n.startswith("META-INF/")] |
| for n in sorted(stray): |
| fail("spi-jar-purity", |
| "doris-jni-spi.jar contains %s. Only %s and META-INF/ may be in this jar - it is " |
| "loaded once and shared by BE and every plugin." % (n, SPI_PREFIX)) |
| |
| |
| def check_plugin_ships_no_spi(plugin, jars, fail): |
| for jar in jars: |
| carried = [n for n in _names(jar) if n.startswith(SPI_PREFIX) and n.endswith(".class")] |
| if carried: |
| fail("plugin-ships-no-spi", |
| "plugin '%s': %s carries %d SPI class(es), e.g. %s. Declare jni-spi as " |
| "<scope>provided</scope> so the SPI comes from lib/jni/spi instead." |
| % (plugin, os.path.basename(jar), len(carried), carried[0])) |
| |
| |
| SHADOWS_CLASSES_ATTRIBUTE = "Doris-Shadows-Classes" |
| |
| |
| def _shadowed(cls_paths, name): |
| """Whether class-file entry `name` is one of `cls_paths` or nested inside one of them.""" |
| for cls in cls_paths: |
| if name == cls + ".class" or name.startswith(cls + "$"): |
| return True |
| return False |
| |
| |
| def shadowed_classes(plugin, jars, fail): |
| """ |
| The class paths (org/apache/hadoop/fs/FileSystem, without .class) that the shadowing jars of |
| this directory declare, after holding each jar to its declaration. PluginRuntime moves a jar |
| carrying the attribute to the front of the classpath on the attribute's presence alone, so |
| this is the only place the VALUE is checked: every named class must be in the jar (a |
| declaration that shadows nothing is a build mistake, most likely a rename), and the jar must |
| hold nothing beyond the named classes and their nested classes (anything else would shadow a |
| sibling silently, which is the accident duplicate-classes exists to catch). |
| """ |
| shadowed = [] |
| for jar in jars: |
| declared = _manifest_attribute(jar, SHADOWS_CLASSES_ATTRIBUTE) |
| if declared is None: |
| continue |
| cls_paths = [c.strip().replace(".", "/") for c in declared.split(",") if c.strip()] |
| entries = [n for n in _names(jar) if n.endswith(".class")] |
| for cls in cls_paths: |
| if cls + ".class" not in entries: |
| fail("shadowing-jar", |
| "plugin '%s': %s declares %s: %s but carries no %s.class; the attribute " |
| "names classes the jar replaces, and this one is not in it." |
| % (plugin, os.path.basename(jar), SHADOWS_CLASSES_ATTRIBUTE, declared, cls)) |
| stray = sorted(n for n in entries if not _shadowed(cls_paths, n)) |
| if stray: |
| fail("shadowing-jar", |
| "plugin '%s': %s declares %s: %s but also carries %d undeclared class(es), e.g. %s. " |
| "A shadowing jar is searched before every other jar in the directory, so it may hold " |
| "only the classes it declares (and their nested classes)." |
| % (plugin, os.path.basename(jar), SHADOWS_CLASSES_ATTRIBUTE, declared, len(stray), |
| stray[0])) |
| shadowed.extend(cls_paths) |
| return shadowed |
| |
| |
| def check_duplicate_classes(plugin, jars, fail): |
| rules = DUPLICATE_ALLOWLIST.get("*", []) + DUPLICATE_ALLOWLIST.get(plugin, []) |
| shadowed = shadowed_classes(plugin, jars, fail) |
| by_name = collections.defaultdict(lambda: collections.defaultdict(list)) |
| for jar in jars: |
| for name, crc in _class_entries(jar): |
| by_name[name][crc].append(os.path.basename(jar)) |
| for name in sorted(by_name): |
| by_crc = by_name[name] |
| if len(by_crc) < 2 or _allowed(rules, name) or _shadowed(shadowed, name): |
| continue |
| where = "; ".join("%08x in %s" % (crc, ", ".join(sorted(set(js)))) |
| for crc, js in sorted(by_crc.items())) |
| fail("duplicate-classes", |
| "plugin '%s': %s resolves to different bytes depending on jar order (%s). Keep one " |
| "copy, or add it to DUPLICATE_ALLOWLIST with the reason the winner does not matter." |
| % (plugin, name, where)) |
| |
| |
| SERVICE_ENTRY = "META-INF/services/org.apache.doris.jni.spi.DorisPlugin" |
| API_VERSION_ATTRIBUTE = "Doris-Jni-Plugin-Api-Version" |
| API_VERSION_RESOURCE = "META-INF/doris/jni-plugin-api-version.properties" |
| |
| |
| def _manifest_attribute(jar, attribute): |
| with zipfile.ZipFile(jar) as zf: |
| try: |
| manifest = zf.read("META-INF/MANIFEST.MF").decode("utf-8", "replace") |
| except KeyError: |
| return None |
| # Manifest continuation lines start with a single space; unfold before matching. |
| manifest = manifest.replace("\r\n", "\n").replace("\r", "\n").replace("\n ", "") |
| for line in manifest.split("\n"): |
| name, sep, value = line.partition(":") |
| if sep and name.strip() == attribute: |
| return value.strip() |
| return None |
| |
| |
| def served_api_version(spi_jar): |
| """The major.minor this build serves, out of the resource maven filtering writes.""" |
| with zipfile.ZipFile(spi_jar) as zf: |
| try: |
| text = zf.read(API_VERSION_RESOURCE).decode("utf-8", "replace") |
| except KeyError: |
| return None |
| for line in text.splitlines(): |
| key, sep, value = line.partition("=") |
| if sep and key.strip() == "api.version": |
| return value.strip() |
| return None |
| |
| |
| def check_api_version_stamp(plugin, jars, served, fail): |
| """ |
| The manifest attribute PluginRuntime.checkApiVersion compares against, checked on the artifact |
| rather than in the pom that is supposed to produce it. A plugin jar built without the parent's |
| <archive> configuration - a module that overrides maven-jar-plugin, say - carries no stamp or a |
| stale one, and nothing else notices: the loader is the first to look, at which point the failure |
| is a deployment-time "built against plugin API 2.0 but this BE serves 3.0" on a jar that was |
| never built against 2.0 at all. |
| |
| Only the jar declaring the service is FAILED on, because that is the only one the loader reads. |
| |
| Every other stamped jar in the directory is reported as a warning instead. Nothing at runtime |
| reads those stamps, so a mismatch there is not a defect in the deployed tree - but it is a |
| reliable sign of a jar that was not rebuilt, which nothing else in this script can see (this |
| check reads every jar's entry list, which _names memoizes; the closure check does not care |
| when a class was compiled). |
| That is not hypothetical: a tree where every jar was stamped 3.0 except one left over at 2.0 |
| passed all five checks, and the leftover was a stale build artifact, not a deliberate one. |
| """ |
| if served is None: |
| fail("api-version-stamp", |
| "the SPI jar carries no %s; the version gate has nothing to compare against" |
| % API_VERSION_RESOURCE) |
| return |
| _warn_stale_stamps(plugin, jars, served) |
| for jar in jars: |
| if SERVICE_ENTRY not in _names(jar): |
| continue |
| declared = _manifest_attribute(jar, API_VERSION_ATTRIBUTE) |
| if declared is None: |
| fail("api-version-stamp", |
| "plugin '%s': %s declares the DorisPlugin service but its manifest carries no %s. " |
| "It comes from <jni.plugin.api.version> in fe/be-java-extensions/pom.xml; a module " |
| "that redefines maven-jar-plugin's <archive> loses it." |
| % (plugin, os.path.basename(jar), API_VERSION_ATTRIBUTE)) |
| elif declared != served: |
| fail("api-version-stamp", |
| "plugin '%s': %s is stamped %s=%s but this build serves %s. Rebuild the module; a " |
| "mismatch here is what PluginRuntime.checkApiVersion rejects at deployment." |
| % (plugin, os.path.basename(jar), API_VERSION_ATTRIBUTE, declared, served)) |
| return |
| # No provider jar at all is the sole-provider check's verdict to give, not this one's. |
| |
| |
| def _warn_stale_stamps(plugin, jars, served): |
| """Stamped jars that do NOT declare the service and disagree with the served version. |
| |
| A warning, never a failure: the loader reads the service jar's stamp and nothing else, so |
| asserting on these would be asserting on something no runtime consults - and a plugin is |
| free to bundle a third-party jar that carries its own unrelated build stamps. |
| """ |
| for jar in jars: |
| if SERVICE_ENTRY in _names(jar): |
| continue |
| declared = _manifest_attribute(jar, API_VERSION_ATTRIBUTE) |
| if declared is not None and declared != served: |
| print(" WARN plugin '%s': %s is stamped %s=%s while this build serves %s. Nothing " |
| "reads that stamp, so this is not a failure - but it usually means the jar was " |
| "not rebuilt." % (plugin, os.path.basename(jar), API_VERSION_ATTRIBUTE, |
| declared, served)) |
| |
| |
| def _doris_owned(jars): |
| owned = [] |
| for jar in jars: |
| if any(n.startswith(DORIS_PREFIX) and n.endswith(".class") for n in _names(jar)): |
| owned.append(jar) |
| return owned |
| |
| |
| # Implementation classes Doris hands to hadoop AS A STRING, so that no Doris class references |
| # them and the walk from Doris's own jars never enters the jar they live in. Every one of these |
| # is written into a Configuration by fe/fe-filesystem/**; the list is short because the write |
| # points are - fs.*.impl and the two credentials-provider keys, nothing else. |
| # |
| # Why this matters more than it looks: hadoop-aws's AssumedRoleCredentialProvider resolves |
| # software.amazon.awssdk.services.sts.* from its constant pool, and hadoop-huaweicloud's |
| # OBSFileSystem resolves org.apache.commons.lang.*. Both are ordinary static references that jdeps |
| # reports the moment the jar holding them is a root - and both were real, shipped holes that every |
| # other check here passed over. Treating the jar as a root is not the same as trusting reflection |
| # analysis: nothing here follows a Class.forName, it just starts the walk somewhere Doris pointed. |
| # |
| # Entries are class file paths. A name absent from a plugin costs nothing: that plugin simply |
| # bundles no such filesystem, and the root set is what it was. |
| _NAMED_BY_DORIS = ( |
| # fs.s3.impl / fs.s3a.impl / fs.cos.impl / fs.cosn.impl / fs.gs.impl, and the fallback |
| # fs.obs.impl - one jar, hadoop-aws, and the S3A credential providers live in it too. |
| "org/apache/hadoop/fs/s3a/S3AFileSystem.class", |
| "org/apache/hadoop/fs/s3a/SimpleAWSCredentialsProvider.class", |
| "org/apache/hadoop/fs/s3a/auth/AssumedRoleCredentialProvider.class", |
| # fs.obs.impl and fs.AbstractFileSystem.obs.impl - hadoop-huaweicloud. |
| "org/apache/hadoop/fs/obs/OBSFileSystem.class", |
| "org/apache/hadoop/fs/obs/OBS.class", |
| # fs.s3a.aws.credentials.provider and fs.s3a.assumed.role.credentials.provider, when the |
| # catalog names a provider type rather than a key pair - the AWS SDK's own auth module. |
| "software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.class", |
| "software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.class", |
| "software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.class", |
| # fs.oss.impl / fs.AbstractFileSystem.oss.impl. Deployed to plugins/jni_fs rather than into a |
| # plugin directory, so normally absent here; listed so that a plugin which does bundle |
| # JindoFS gets the same treatment as the rest. |
| "com/aliyun/jindodata/oss/JindoOssFileSystem.class", |
| ) |
| |
| |
| def _named_by_doris(jars): |
| """Jars holding a class Doris names by string. See _NAMED_BY_DORIS.""" |
| wanted = set(_NAMED_BY_DORIS) |
| return [jar for jar in jars if wanted.intersection(_names(jar))] |
| |
| |
| def check_closure_self_contained(plugin, jars, spi_jar, fail): |
| owned = _doris_owned(jars) |
| if not owned: |
| fail("closure-self-contained", |
| "plugin '%s' has no jar containing %s classes; it cannot implement the SPI." |
| % (plugin, DORIS_PREFIX)) |
| return |
| roots = owned + [jar for jar in _named_by_doris(jars) if jar not in owned] |
| classpath = os.pathsep.join(jars + [spi_jar]) |
| proc = subprocess.run(["jdeps", "--multi-release", "17", "-verbose:class", "-cp", classpath] |
| + roots, |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, |
| universal_newlines=True) |
| if proc.returncode != 0: |
| # Never treat this as a pass. An earlier version of this script analysed every jar in the |
| # directory; jdeps then built a module graph, died on a module-info requiring something |
| # absent, printed nothing, and the run looked clean. |
| fail("closure-self-contained", |
| "plugin '%s': jdeps exited %d, so nothing was checked.\n%s" |
| % (plugin, proc.returncode, proc.stderr.strip()[:600])) |
| return |
| rules = CLOSURE_ALLOWLIST.get(plugin, []) |
| missing = collections.defaultdict(set) |
| for line in proc.stdout.splitlines(): |
| if "not found" not in line: |
| continue |
| parts = line.split("->") |
| if len(parts) != 2: |
| continue |
| src, target = parts[0].strip(), parts[1].strip().split()[0] |
| if "." not in src or "." not in target: |
| continue |
| if any(target.startswith(prefix) for prefix, _reason in rules): |
| continue |
| missing[target].add(src) |
| for target in sorted(missing): |
| fail("closure-self-contained", |
| "plugin '%s': %s is referenced by %s but is in no jar of this plugin directory. " |
| "Either ship the jar it lives in, or add the package to CLOSURE_ALLOWLIST with the " |
| "reason that code never runs." |
| % (plugin, target, sorted(missing[target])[0])) |
| |
| |
| def main(argv): |
| if len(argv) > 1 and argv[1] in ("-h", "--help"): |
| print(__doc__) |
| return 0 |
| home = os.environ.get("DORIS_HOME", ".") |
| spi_dir = argv[1] if len(argv) > 1 else os.path.join( |
| home, "output", "be", "lib", "jni", "spi") |
| plugins_dir = argv[2] if len(argv) > 2 else os.path.join( |
| home, "output", "be", "plugins", "jni") |
| if not os.path.isdir(spi_dir): |
| sys.stderr.write("%s is not a deployed spi directory\n" % spi_dir) |
| return 2 |
| if not os.path.isdir(plugins_dir): |
| sys.stderr.write("%s is not a deployed plugin family root\n" % plugins_dir) |
| return 2 |
| try: |
| subprocess.run(["jdeps", "--version"], stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL) |
| except OSError: |
| sys.stderr.write("jdeps not on PATH; it ships with the JDK this build already needs\n") |
| return 2 |
| |
| failures = [] |
| |
| def fail(check, message): |
| failures.append((check, message)) |
| |
| check_spi_jar_purity(spi_dir, fail) |
| spi_jar = os.path.join(spi_dir, "doris-jni-spi.jar") |
| served = served_api_version(spi_jar) if os.path.isfile(spi_jar) else None |
| plugins = sorted(d for d in os.listdir(plugins_dir) |
| if os.path.isdir(os.path.join(plugins_dir, d))) |
| if not plugins: |
| sys.stderr.write("no plugin directories under %s\n" % plugins_dir) |
| # Not "could not run" when the shared layer has already been found broken: 2 is mapped to a |
| # WARN by build.sh, which would report a real SPI failure as a check that did not happen. |
| # When there ARE failures this falls through to the reporting tail instead of returning |
| # here, because this is the only place they get printed and build.sh says "see above". |
| if not failures: |
| return 2 |
| plugins = [] |
| for plugin in plugins: |
| pdir = os.path.join(plugins_dir, plugin) |
| jars = sorted(os.path.join(pdir, f) for f in os.listdir(pdir) if f.endswith(".jar")) |
| if not jars: |
| fail("plugin-directory-nonempty", "plugin directory '%s' holds no jar" % plugin) |
| continue |
| print("checking %-18s %3d jars" % (plugin, len(jars))) |
| check_plugin_ships_no_spi(plugin, jars, fail) |
| check_duplicate_classes(plugin, jars, fail) |
| check_closure_self_contained(plugin, jars, spi_jar, fail) |
| check_api_version_stamp(plugin, jars, served, fail) |
| |
| if not failures: |
| print("\nOK: %d plugins pass all six checks. This does NOT prove the closures are " |
| "complete - see the note at the top of this file." % len(plugins)) |
| return 0 |
| print("\n%d problem(s):" % len(failures)) |
| for check, message in failures: |
| print("\n[%s] %s" % (check, message)) |
| return 1 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main(sys.argv)) |