blob: 177e0dcb2db473526530bd495d3a5f472a003956 [file] [log] [blame] [edit]
# Copyright 2022 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
Test to prevent integrators from having future breakages caused
by instability of the Fuchsia SDK package directory.
"""
import argparse
import os
import sys
import tarfile
import re
from pathlib import Path
class GoldenFileGenerationError(Exception):
"""Exception raised when the SDK Tarball input
contains a file which is not a regular file or directory."""
def __init__(self, tarfile, invalid):
self.tarfile = tarfile
self.invalid = invalid
def __str__(self):
return (
f"Detected invalid file within the SDK archive "
f"tarfile:\n{self.invalid}\n"
f"Golden file will not be generated if the tarfile contains this file type.\n"
f"Please update\n"
f" {self.tarfile}\n"
f" accordingly.\n"
f"If you're seeing this in CQ but not in your local build, you will need\n"
f" to rebuild locally with the same CPU architecture (x64/arm64) as this\n"
f" builder and add '//sdk/ctf:verify_idk_deps' into your local build args.\n")
class MissingInputError(Exception):
"""Exception raised when a missing golden file
or current archive tarball is detected."""
def __init__(self, missing, is_tar=False, stamp=False):
self.missing = missing
self.is_tar = is_tar
self.stamp = stamp
def __str__(self):
input = "golden file"
if self.is_tar:
input = "current archive tarball"
elif self.stamp:
input = "verification file path"
return_str = f"The {input} appears to be missing:\n{self.missing}\n"
if not (self.is_tar or self.stamp):
return_str += (
f"Please consult with sdk-dev@fuchsia.dev to"
f" locate and resolve this missing golden file.\n")
return return_str
class NotifyOnAdditions(Exception):
"""Exception raised to notify developers of non-breaking changes.
Args:
idk: SDK archive tarball dependency target name.
additions: Paths found in the archive but not in the golden file.
path: Path to create a temporary golden file for the archive.
golden: Path to the golden file.
"""
def __init__(self, idk, additions, path, golden):
self.idk = idk
self.additions = additions
self.path = path
self.golden = golden
def __str__(self):
cmd = update_golden(self.path, self.golden)
return (
f"Detected additions to the {self.idk} SDK's directory layout.\n"
f"The current archive tarball contains these additional "
f"paths not found in the golden file.\n{self.additions}\n"
f"If this is an intentional change, follow the instructions"
f" below based on where you encountered this message:\n"
f" - in CQ: rebuild locally with the same CPU architecture (x64/arm64)\n"
f" as this builder and add '//sdk/ctf:verify_idk_deps' into\n"
f" your local build args.\n"
f" - in your local build: run the following command: {cmd}\n")
class SdkCompatibilityError(Exception):
"""Exception raised when breaking changes are detected.
Args:
idk: SDK archive tarball dependency target name.
missing_goldens: Paths expected to be in the archive, but not found.
path: Path to create a temporary golden file for the archive.
golden: Path to the golden file.
"""
def __init__(self, idk, missing_goldens, path, golden):
self.idk = idk
self.missing_goldens = missing_goldens
self.path = path
self.golden = golden
def __str__(self):
cmd = update_golden(self.path, self.golden)
return (
f"Detected breaking changes to the {self.idk} SDK's directory layout.\n"
f"If possible, please make a soft transition"
f" to prevent breaking SDK users.\nThe following paths are missing"
f" from the SDK:\n{self.missing_goldens}\n"
f"If you have approval to make this change, follow the instructions"
f" below based on where you encountered this message:\n"
f" - in CQ: rebuild locally with the same CPU architecture (x64/arm64)\n"
f" as this builder and add '//sdk/ctf:verify_idk_deps' into\n"
f" your local build args.\n"
f" - in your local build: run the following command: {cmd}\n")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--golden', help='Path to the golden file')
parser.add_argument(
'--current', help='Path to the SDK archive tarball', required=True)
parser.add_argument('--stamp', help='Verification output file path')
parser.add_argument(
'--generate_golden',
help='Generate SDK directory layout golden file.',
action='store_true')
parser.add_argument(
'--gen_golden_path',
help='Path to generate SDK directory layout golden file.')
parser.add_argument(
'--update_golden',
help='Path to the updated golden file for when changes are made.')
parser.add_argument(
'--warn_only', help='Treat failures as warnings', action='store_true')
args = parser.parse_args()
err = None
notify = None
if args.generate_golden:
try:
golden_sdk_set = generate_sdk_layout_golden_file(args.current)
except (MissingInputError, GoldenFileGenerationError) as e:
err = e
golden_sdk_layout = sorted(golden_sdk_set)
try:
with open(args.gen_golden_path, 'w') as golden_sdk_layout_file:
layout = "{}\n".format("\n".join(golden_sdk_layout or []))
golden_sdk_layout_file.write(layout)
except FileNotFoundError:
err = str(MissingInputError(missing=args.gen_golden_path))
else:
try:
fail_on_breaking_changes(
args.current, args.golden, args.update_golden)
except (SdkCompatibilityError, MissingInputError) as e:
err = e
except NotifyOnAdditions as n:
notify = n
try:
with open(args.stamp, 'w') as stamp_file:
stamp_file.write('Verified!\n')
except FileNotFoundError:
err = str(MissingInputError(missing=args.stamp, stamp=True))
if not (err or notify):
return 0
elif notify:
print("NOTICE: ", notify)
else:
print("ERROR: ", err)
return 0 if args.warn_only else 1
def fail_on_breaking_changes(current_archive, golden_file, update_golden):
"""Fails if current is not compatible with golden."""
gold_set = set()
try:
with open(golden_file, 'r') as gold_lines:
for line in gold_lines:
gold_set.add(line.strip())
except FileNotFoundError:
raise MissingInputError(missing=golden_file, is_tar=False)
curr_set = generate_sdk_layout_golden_file(current_archive)
set_diff = gold_set.difference(curr_set)
# See if any golden paths are not in the current archive.
if len(set_diff) != 0:
raise SdkCompatibilityError(
idk=Path(current_archive).with_suffix('').stem,
missing_goldens=set_diff,
path=update_golden,
golden=golden_file)
# Notify developer of any additions to the SDK directory layout.
if len(gold_set) != len(curr_set):
raise NotifyOnAdditions(
idk=Path(current_archive).with_suffix('').stem,
additions=curr_set.difference(gold_set),
path=update_golden,
golden=golden_file)
return None
def generate_sdk_layout_golden_file(current_archive):
try:
with tarfile.open(current_archive, "r:gz") as sdk_file:
layout = sdk_file.getmembers()
except (tarfile.ReadError, FileNotFoundError):
raise MissingInputError(missing=current_archive, is_tar=True)
# Ignore golden file checks for paths ending in .debug as they change frequently.
path_regex = "\.build-id" + re.escape(os.sep) + "\w{2}" + re.escape(
os.sep) + "\w{14}\.debug"
ignored_path = re.compile(path_regex, re.IGNORECASE)
# If directories in the list below exist, they're added to the set of golden paths.
# Files in such directories will not be compatibility tested.
dirs_exist = [
os.path.join('tools', 'x64', 'aemu_internal', ''),
os.path.join('tools', 'x64', 'qemu_internal', ''),
]
golden_set = set()
for path in layout:
if ignored_path.match(path.name):
continue
# Set to True if the file in question is not going to be compatibility tested.
ignoring_file = False
# Add the directory into the golden set if it exists.
for dir in dirs_exist:
if path.name.startswith(dir):
golden_set.add(dir)
ignoring_file = True
break
if ignoring_file:
continue
gold_dir = path.name
if path.isdir() and not path.name.endswith(os.sep):
# Ensure directory type paths end in a "/".
gold_dir += os.sep
elif not (path.isfile() or path.isdir()):
raise GoldenFileGenerationError(
tarfile=current_archive, invalid=path)
golden_set.add(gold_dir)
return golden_set
def update_golden(updated_path, golden):
"""For presentation only. Never execute the cmd output programmatically because
it may present a security exploit."""
return "cp \"{}\" \"{}\"".format(
os.path.abspath(updated_path), os.path.abspath(golden))
if __name__ == '__main__':
sys.exit(main())