diff --git a/Tests/cli.py b/Tests/cli.py new file mode 100755 index 000000000..3e53b085b --- /dev/null +++ b/Tests/cli.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +import datetime +import logging +import os +import os.path +import re +import sys +import tempfile +import uuid + +import click +import yaml + +from scs_cert_lib import load_spec, annotate_validity, add_period + + +DEFAULT_SPECPATH = { + '50393e6f-2ae1-4c5c-a62c-3b75f2abef3f': './scs-compatible-iaas.yaml', + '1fffebe6-fd4b-44d3-a36c-fc58b4bb0180': './scs-compatible-kaas.yaml', +} + +logger = logging.getLogger(__name__) + + +def select_valid(versions: list) -> list: + return [version for version in versions if version['_explicit_validity']] + + +@click.group() +def cli(): + return + + +@cli.command() +@click.option('--version', '-V', 'version', type=str, default=None) +@click.option('--tests', '-t', 'tests', type=str, default=None) +@click.option('--section', '-S', 'sections', type=str, multiple=True) +@click.argument('specpath', type=click.Path(exists=True)) +def select(specpath, version, sections, tests): + with open(specpath, "r", encoding="UTF-8") as specfile: + spec = load_spec(yaml.load(specfile, Loader=yaml.SafeLoader)) + checkdate = datetime.date.today() + annotate_validity(spec['timeline'], spec['versions'], checkdate) + if version is None: + versions = select_valid(spec['versions'].values()) + else: + versions = [spec['versions'].get(version)] + if versions[0] is None: + raise RuntimeError(f"Requested version '{version}' not found") + if not versions: + raise RuntimeError(f"No valid version found for {checkdate}") + title = spec['name'] + if sections: + title += f" [sections: {', '.join(sections)}]" + if tests: + title += f" [tests: '{tests}']" + tests_re = re.compile(tests) + # collect all testcases we need + testcase_lookup = spec['testcases'] + all_testcase_ids = set() + for version in versions: + for tc_id in version['testcase_ids']: + if sections and testcase_lookup.get(tc_id, {}).get('section') not in sections: + continue + if tests and not tests_re.match(tc_id): + continue + all_testcase_ids.add(tc_id) + logger.info(f"{title}: {len(all_testcase_ids)} testcases") + if all_testcase_ids: + print('\n'.join(sorted(all_testcase_ids))) + + +def _update_scorecard(scorecard, report, testcase_lookup): + scores = scorecard.setdefault('tests', {}) + checked_at = report['checked_at'] + checked_at_str = str(checked_at)[:19] + for tc_id, result in report.get('tests', {}).items(): + score = scores.get(tc_id) + if score is None or score['checked_at'] < checked_at_str: + testcase = testcase_lookup.get(tc_id) + lifetime = testcase.get('lifetime') # leave None if not present; to be handled by add_period + expires_at = add_period(checked_at, lifetime) + scores[tc_id] = {'checked_at': checked_at_str, 'expires_at': str(expires_at), **result} + + +def _prune_scorecard(scorecard): + scores = scorecard.setdefault('tests', {}) + now_str = str(datetime.datetime.now())[:19] + for tc_id in list(scores): + score = scores[tc_id] + if score['expires_at'] >= now_str: + del scores[tc_id] + + +def _atomic_write(path, text): + with tempfile.NamedTemporaryFile( + mode='w', encoding='UTF-8', + dir=os.path.dirname(path) or '.', + delete=False, delete_on_close=False, + ) as fileobj: + fileobj.write(text) + os.rename(fileobj.name, path) + + +def _dump(o): + return yaml.safe_dump(o, default_flow_style=False, sort_keys=False, explicit_start=True) + + +@cli.command() +@click.option('--subject', '-s', 'subject', type=str) +@click.option('--score', '-S', 'score_yaml', type=click.Path(exists=False), default=None) +@click.option('-o', '--output', 'report_yaml', type=click.Path(exists=False), default=None) +@click.option('--spec', 'specpath', type=click.Path(exists=True)) +def score(specpath, subject, score_yaml, report_yaml): + scorecard = {} + if score_yaml and os.path.exists(score_yaml): + with open(score_yaml, "r", encoding="UTF-8") as fileobj: + scorecard = yaml.load(fileobj, Loader=yaml.SafeLoader) + if not subject: + subject = scorecard['subject'] + if not specpath: + specpath = DEFAULT_SPECPATH[scorecard['scope']] + elif not subject: + raise click.UsageError('need to supply at least one of -s or -S') + elif not specpath: + raise click.UsageError('need to supply at least one of --spec or -S') + with open(specpath, "r", encoding="UTF-8") as specfile: + spec = load_spec(yaml.load(specfile, Loader=yaml.SafeLoader)) + scopeuuid = spec['uuid'] + snippet = yaml.load(sys.stdin.read(), Loader=yaml.SafeLoader) + if not snippet: + logger.warning('Empty report snippet. Bailing') + return + report = { + 'uuid': str(uuid.uuid4()), + 'subject': subject, + 'scope': scopeuuid, + **snippet + } + if report_yaml is None: + ts = str(report['checked_at'])[:19] + ts = ts.replace(':', '').replace('-', '').replace(' ', 'T') + report_yaml = f'report-{ts}-{subject}.yaml' + _atomic_write(report_yaml, _dump(report)) + if score_yaml: + scorecard.setdefault('subject', subject) + scorecard.setdefault('scope', scopeuuid) + if subject != scorecard['subject']: + raise RuntimeError('subjects do not match') + if scopeuuid != scorecard['scope']: + raise RuntimeError('scopes do not match') + _prune_scorecard(scorecard) + _update_scorecard(scorecard, report, spec['testcases']) + _atomic_write(score_yaml, _dump(scorecard)) + + +@cli.command() +@click.option('--subject', '-s', 'subject', type=str) +@click.option('--spec', 'specpath', type=click.Path(exists=True)) +@click.argument('score_yaml', type=click.Path(exists=False)) +def init(specpath, subject, score_yaml): + with open(specpath, "r", encoding="UTF-8") as specfile: + spec = load_spec(yaml.load(specfile, Loader=yaml.SafeLoader)) + scorecard = { + 'subject': subject, + 'scope': spec['uuid'], + 'tests': {}, + } + _atomic_write(score_yaml, _dump(scorecard)) + + +if __name__ == "__main__": + logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO) + cli() diff --git a/Tests/iaas/openstack_test.py b/Tests/iaas/openstack_test.py index dd5cef2ef..dfbca75a7 100755 --- a/Tests/iaas/openstack_test.py +++ b/Tests/iaas/openstack_test.py @@ -7,12 +7,14 @@ SPDX-License-Identifier: CC-BY-SA 4.0 """ +from datetime import datetime import getopt import logging import os import sys import openstack +import yaml from scs_0100_flavor_naming.flavor_names import compute_flavor_spec from scs_0100_flavor_naming.flavor_names_check import \ @@ -55,6 +57,8 @@ def usage(rcode=1, file=sys.stderr): print("Options: [-c/--os-cloud OS_CLOUD] sets cloud environment (default from OS_CLOUD env)", file=file) print("Runs specified testcases against the OpenStack cloud OS_CLOUD", file=file) print("and reports inconsistencies, errors etc. It returns 0 on success.", file=file) + print("Instead of listing testcase-ids, you can supply a single dash (-)", file=file) + print("to have them read from stdin, one testcase-id per line.", file=file) sys.exit(rcode) @@ -179,7 +183,8 @@ def __init__(self): def __getattr__(self, key): val = self._values.get(key) if val is None: - logger.debug(f'... {key}') + # uncomment for super serious debugging + # logger.log(f'... {key}') try: ret = self._functions[key](self) except BaseException as e: @@ -203,28 +208,24 @@ def add_value(self, name, value): self._values[name] = value -def harness(name, *check_fns): +def harness(name, results, *check_fns): """Harness for evaluating testcase `name`. - Logs beginning of computation. + Logs beginning and end of computation. Calls each fn in `check_fns`. - Prints (to stdout) 'name: RESULT', where RESULT is one of - - - 'ABORT' if an exception occurs during the function calls - - 'FAIL' if one of the functions has a falsy result - - 'PASS' otherwise + Records result to `results`. """ - logger.debug(f'** {name}') + logger.info(f'*** {name}') try: result = all(check_fn() for check_fn in check_fns) except BaseException: logger.debug('exception during check', exc_info=True) - result = 'ABORT' + value = 0 else: - result = ['FAIL', 'PASS'][min(1, result)] - # this is quite redundant - # logger.debug(f'** computation end for {name}') - print(f"{name}: {result}") + value = 1 if result else -1 + result = ['FAIL', 'ABORT', 'PASS'][value + 1] + logger.info(f'+++ {name}: {result}') + results[name] = value def run_preflight_checks(container): @@ -241,6 +242,15 @@ def run_preflight_checks(container): raise RuntimeError("OpenStack user is missing member role.") +class _LogHandler(logging.Handler): + def __init__(self, level=logging.NOTSET, log=None): + super().__init__(level=level) + self.log = [] if log is None else log + + def handle(self, record): + self.log.append(f'{record.levelname}: {record.msg}') + + def main(argv): # configure logging, disable verbose library logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) @@ -264,6 +274,9 @@ def main(argv): else: usage(2) + if len(args) == 1 and args[0] == '-': + args = sys.stdin.read().splitlines() + testcases = [t for t in args if t.startswith('scs-')] if len(testcases) != len(args): unknown = [a for a in args if a not in testcases] @@ -281,8 +294,24 @@ def main(argv): for testcase in testcases: print(f"{testcase}: ABORT") raise + + results = {} + log = [] + logging.root.addHandler(_LogHandler(level=logging.DEBUG, log=log)) for testcase in testcases: - harness(testcase, lambda: getattr(c, testcase.replace('-', '_'))) + harness(testcase, results, lambda: getattr(c, testcase.replace('-', '_'))) + report = { + 'creator': 'openstack_test.py v0.1.0', + 'checked_at': datetime.now(), + 'tests': { + key: {'result': value} + for key, value in results.items() + }, + 'log': log, + } + # don't do explicit_start here because that can easily be done by the caller using "echo ---", + # and then the caller can even add fields such as uuid, subject, and scope + yaml.safe_dump(report, sys.stdout, default_flow_style=False, sort_keys=False, explicit_start=False) return 0 diff --git a/Tests/scs-compatible-iaas.yaml b/Tests/scs-compatible-iaas.yaml index 42f68be0d..d74513a08 100644 --- a/Tests/scs-compatible-iaas.yaml +++ b/Tests/scs-compatible-iaas.yaml @@ -1,10 +1,11 @@ # -- informal edit log -- # whenever old content is removed as per scs-0003-v1, add a line of the form # - YYYY-MM-DD pruned old content; affected versions: vN, ... +# - 2026-06-19 pruned old content; affected versions: v3, v4 # - 2025-05-22 pruned old content; affected versions: v1, v2, v3-orig, v5 name: SCS-compatible IaaS uuid: 50393e6f-2ae1-4c5c-a62c-3b75f2abef3f -url: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/scs-compatible-iaas.yaml +url: https://docs.scs.community/standards/scopes/scs-0501 variables: - os_cloud scripts: @@ -291,31 +292,29 @@ scripts: description: >- Manual check: Must fulfill all requirements of scs-0302-v1. url: https://docs.scs.community/standards/scs-0302-v1-domain-manager-role#conformance-tests -modules: +groups: - id: opc-v2022.11 name: >- scs-0128-v1: SCS end-to-end testing (formerly OpenStack-powered Compute) url: https://docs.scs.community/standards/scs-0128-v1-e2e-testing + include: [] - id: scs-0100-v3.1 name: Flavor naming v3.1 url: https://docs.scs.community/standards/scs-0100-v3-flavor-naming - targets: - main: + include: - scs-0100-syntax-check - scs-0100-semantics-check - id: scs-0101-v1 # leap from v1 to v1.1 in place because it's a very minor relaxation; all essential testcases are kept name: Entropy v1.1 url: https://docs.scs.community/standards/scs-0101-v1-entropy - targets: - main: + include: - scs-0101-entropy-avail - scs-0101-fips-test - id: scs-0102-v1 name: Image metadata v1 url: https://docs.scs.community/standards/scs-0102-v1-image-metadata - targets: - main: + include: - scs-0102-prop-architecture - scs-0102-prop-min_disk - scs-0102-prop-min_ram @@ -333,8 +332,7 @@ modules: - id: scs-0102-v1-rec name: Image metadata v1 url: https://docs.scs.community/standards/scs-0102-v1-image-metadata - targets: - recommended: + include: - scs-0102-prop-hash_algo - scs-0102-prop-os_purpose - scs-0102-os_purpose-uniqueness @@ -344,8 +342,7 @@ modules: - id: scs-0102-v2 name: Image metadata v2 url: https://docs.scs.community/standards/scs-0102-v2-image-metadata - targets: - main: + include: - scs-0102-prop-architecture - scs-0102-prop-min_disk - scs-0102-prop-min_ram @@ -365,8 +362,7 @@ modules: - id: scs-0102-v2-rec name: Image metadata v2 url: https://docs.scs.community/standards/scs-0102-v2-image-metadata - targets: - recommended: + include: - scs-0102-prop-hash_algo - scs-0102-prop-hypervisor_type - scs-0102-prop-hw_rng_model @@ -374,8 +370,7 @@ modules: - id: scs-0103-v1 name: Standard flavors url: https://docs.scs.community/standards/scs-0103-v1-standard-flavors - targets: - main: + include: - scs-0103-flavor-1v-4 - scs-0103-flavor-2v-8 - scs-0103-flavor-4v-16 @@ -394,8 +389,7 @@ modules: - id: scs-0103-v1-rec name: Standard flavors url: https://docs.scs.community/standards/scs-0103-v1-standard-flavors - targets: - recommended: + include: - scs-0103-flavor-1v-4-10 - scs-0103-flavor-2v-8-20 - scs-0103-flavor-4v-16-50 @@ -412,34 +406,11 @@ modules: - scs-0103-flavor-16v-64 - scs-0103-flavor-8v-64 - scs-0103-flavor-16v-128 - - id: scs-0104-v1-1 - name: Standard images - url: https://docs.scs.community/standards/scs-0104-v1-standard-images - parameters: - image_spec: address (URL) of an image-spec (YAML) file - targets: - main: - - scs-0104-source-capi-1 - - scs-0104-source-ubuntu-2204 - - scs-0104-source-ubuntu-2004 - - scs-0104-source-debian-12 - - scs-0104-source-debian-11 - - scs-0104-image-ubuntu-2204 - - id: scs-0104-v1-1-rec - name: Standard images - url: https://docs.scs.community/standards/scs-0104-v1-standard-images - parameters: - image_spec: address (URL) of an image-spec (YAML) file - targets: - recommended: - - scs-0104-image-capi-1 - id: scs-0104-v1-2 name: Standard images url: https://docs.scs.community/standards/scs-0104-v1-standard-images - parameters: - image_spec: address (URL) of an image-spec (YAML) file - targets: - main: + # image_spec: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/iaas/scs-0104-v1-images-v5.yaml + include: - scs-0104-source-capi-1 - scs-0104-source-capi-2 - scs-0104-source-ubuntu-2404 @@ -452,70 +423,59 @@ modules: - id: scs-0104-v1-2-rec name: Standard images url: https://docs.scs.community/standards/scs-0104-v1-standard-images - parameters: - image_spec: address (URL) of an image-spec (YAML) file - targets: - recommended: + # image_spec: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/iaas/scs-0104-v1-images-v5.yaml + include: - scs-0104-image-capi-2 - scs-0104-image-debian-12 - id: scs-0114-v1 name: Volume Types url: https://docs.scs.community/standards/scs-0114-v1-volume-type-standard - targets: - main: + include: - scs-0114-syntax-check - id: scs-0114-v1-rec name: Volume Types url: https://docs.scs.community/standards/scs-0114-v1-volume-type-standard - targets: - recommended: + include: - scs-0114-encrypted-type - scs-0114-replicated-type - id: scs-0115-v1 name: Default rules for security groups url: https://docs.scs.community/standards/scs-0115-v1-default-rules-for-security-groups - targets: - main: + include: - scs-0115-default-rules - id: scs-0116-v1 name: Key manager url: https://docs.scs.community/standards/scs-0116-v1-key-manager-standard - targets: - main: + include: - scs-0116-permissions - id: scs-0116-v1-rec name: Key manager url: https://docs.scs.community/standards/scs-0116-v1-key-manager-standard - targets: - recommended: + include: - scs-0116-presence - id: scs-0116-v1-manual name: Key manager url: https://docs.scs.community/standards/scs-0116-v1-key-manager-standard - targets: - preview: + include: - key-manager-docs-check - id: scs-0117-v1 name: Volume backup url: https://docs.scs.community/standards/scs-0117-v1-volume-backup-service - targets: - main: + include: - scs-0117-test-backup - id: scs-0121-v1 name: Availability Zones url: https://docs.scs.community/standards/scs-0121-v1-Availability-Zones-Standard - targets: {} + include: [] - id: scs-0121-v1-manual name: Availability Zones url: https://docs.scs.community/standards/scs-0121-v1-Availability-Zones-Standard - targets: - preview: + include: - availability-zones-check - id: scs-0123-v1 name: Mandatory and Supported IaaS Services url: https://docs.scs.community/standards/scs-0123-v1-mandatory-and-supported-IaaS-services - targets: - main: + include: - scs-0123-service-compute - scs-0123-service-identity - scs-0123-service-image @@ -527,8 +487,7 @@ modules: - id: scs-0123-v2 name: Mandatory and Supported IaaS Services url: https://docs.scs.community/standards/scs-0123-v2-services - targets: - main: + include: - scs-0123-service-compute - scs-0123-service-identity - scs-0123-service-image @@ -539,68 +498,39 @@ modules: - id: scs-0302-v1 name: Domain Manager Role url: https://docs.scs.community/standards/scs-0302-v1-domain-manager-role - targets: {} + include: [] - id: scs-0302-v1-manual name: Domain Manager Role url: https://docs.scs.community/standards/scs-0302-v1-domain-manager-role - targets: - preview: + include: - domain-manager-check -timeline: - - date: 2026-06-11 - versions: - next: draft - v5.1: effective - - date: 2025-09-09 - versions: - next: draft - v5.1: effective - v4: deprecated - v3: deprecated - - date: 2025-07-01 - versions: - v5.1: effective - v4: deprecated - v3: deprecated - - date: 2025-02-01 - versions: - v5.1: effective - v4: warn - v3: deprecated - - date: 2024-12-19 - versions: - v5.1: effective - v4: effective - v3: deprecated -versions: - - version: next + - id: scs-compatible-iaas-next + name: SCS-compatible IaaS next + url: https://docs.scs.community/standards/scs-0501-v6-scs-compatible-iaas include: - opc-v2022.11 - scs-0100-v3.1 - scs-0101-v1 - scs-0102-v2 # instead of scs-0102-v1 - scs-0103-v1 - - ref: scs-0104-v1-2 - parameters: - image_spec: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/iaas/scs-0104-v1-images-v5.yaml + - scs-0104-v1-2 - scs-0114-v1 - scs-0115-v1 - scs-0116-v1 - scs-0117-v1 - scs-0121-v1 - - scs-0123-v2 + - scs-0123-v2 # instead of scs-0123-v1 - scs-0302-v1 - - version: v5.1 # copy of v5, but with include "scs-0123-v1", which had simply been forgotten - stabilized_at: 2024-12-19 + - id: scs-compatible-iaas-v5.1 + name: SCS-compatible IaaS v5.1 + url: https://docs.scs.community/standards/scs-0501-v5-scs-compatible-iaas include: - opc-v2022.11 - scs-0100-v3.1 - scs-0101-v1 - scs-0102-v1 - scs-0103-v1 - - ref: scs-0104-v1-2 - parameters: - image_spec: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/iaas/scs-0104-v1-images-v5.yaml + - scs-0104-v1-2 - scs-0114-v1 - scs-0115-v1 - scs-0116-v1 @@ -608,23 +538,19 @@ versions: - scs-0121-v1 - scs-0123-v1 - scs-0302-v1 - - version: v4 - stabilized_at: 2024-02-28 - include: - - opc-v2022.11 - - scs-0100-v3.1 - - scs-0101-v1 - - scs-0102-v1 - - scs-0103-v1 - - ref: scs-0104-v1-1 - parameters: - image_spec: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/iaas/scs-0104-v1-images.yaml - - version: v3 - # comment: > - # This is what our documentation wrongly stated as being v3 when we introduced v4. - # What was originally v3 (and what we actually continued to test) can be found as v3-orig. - stabilized_at: 2024-02-28 - include: - - opc-v2022.11 - - scs-0100-v3.1 - - scs-0102-v1 +timeline: + - date: 2026-06-11 + versions: + next: draft + v5.1: effective + # pruned: + # v4 has been deprecated since 2025-07-01 + # v4 has been 'warn' since 2025-02-01 + # v5 has been effective since 2024-12-19 + # v3 has been deprecated since 2024-12-19 +versions: + - version: next + test: scs-compatible-iaas-next + - version: v5.1 # copy of v5, but with include "scs-0123-v1", which had simply been forgotten + attn: 1 + test: scs-compatible-iaas-v5.1 diff --git a/Tests/scs-compatible-kaas.yaml b/Tests/scs-compatible-kaas.yaml index 1834d146d..a27fa314f 100644 --- a/Tests/scs-compatible-kaas.yaml +++ b/Tests/scs-compatible-kaas.yaml @@ -3,7 +3,7 @@ # - YYYY-MM-DD pruned old content; affected versions: vN, ... name: SCS-compatible KaaS uuid: 1fffebe6-fd4b-44d3-a36c-fc58b4bb0180 -url: https://raw.githubusercontent.com/SovereignCloudStack/standards/main/Tests/scs-compatible-kaas.yaml +url: https://docs.scs.community/standards/scopes/scs-0502 variables: - subject_root # working directory for the subject under test @@ -53,36 +53,39 @@ scripts: section: heavy description: Must pass all testcases of upstream CNCF Kubernetes e2e-suite focus 'NetworkPolicy'. url: https://docs.scs.community/standards/scs-0219-w1-kaas-networking#automated-tests -modules: - - id: cncf-k8s-conformance - name: >- - scs-0201-v1: CNCF Kubernetes conformance +groups: + - id: scs-0201-v1 + name: CNCF Kubernetes conformance url: https://docs.scs.community/standards/scs-0201-v1-cncf-conformance - targets: - main: + include: - cncf-k8s-conformance - id: scs-0210-v2 name: Kubernetes version policy url: https://docs.scs.community/standards/scs-0210-v2-k8s-version-policy - targets: - main: + include: - version-policy-check - id: scs-0214-v2 name: Kubernetes node distribution and availability url: https://docs.scs.community/standards/scs-0214-v2-k8s-node-distribution - targets: {} + include: [] - id: scs-0214-v2-manual name: Kubernetes node distribution and availability url: https://docs.scs.community/standards/scs-0214-v2-k8s-node-distribution - targets: - preview: + include: - node-distribution-check - id: scs-0219-v1 name: KaaS networking url: https://docs.scs.community/standards/scs-0219-v1-kaas-networking - targets: - main: + include: - kaas-networking-check + - id: scs-compatible-kaas-v1 + name: SCS-compatible KaaS v1 + url: https://docs.scs.community/standards/scs-0502-v1-scs-compatible-kaas + include: + - scs-0201-v1 + - scs-0210-v2 + - scs-0214-v2 + - scs-0219-v1 timeline: - date: 2024-11-26 versions: @@ -92,9 +95,5 @@ timeline: v1: draft versions: - version: v1 - stabilized_at: 2024-11-26 - include: - - cncf-k8s-conformance - - scs-0210-v2 - - scs-0214-v2 - - scs-0219-v1 + attn: 1 + test: scs-compatible-kaas-v1 diff --git a/Tests/scs-compliance-check.py b/Tests/scs-compliance-check.py index e218bdecc..4dc319f7f 100755 --- a/Tests/scs-compliance-check.py +++ b/Tests/scs-compliance-check.py @@ -19,18 +19,17 @@ import os import os.path -import uuid import re import sys import shlex import getopt import datetime import subprocess -from itertools import chain import logging import yaml -from scs_cert_lib import load_spec, annotate_validity, eval_buckets, TESTCASE_VERDICTS +from scs_cert_lib import load_spec, annotate_validity, eval_buckets, TESTCASE_VERDICTS, \ + make_report logger = logging.getLogger(__name__) @@ -52,7 +51,7 @@ def usage(file=sys.stdout): -s/--subject SUBJECT: Name of the subject (cloud) under test, for the report -S/--sections SECTION_LIST: comma-separated list of sections to test (default: all sections) -t/--tests REGEX: regular expression to select individual testcases based on their ids - -o/--output REPORT_FILEPATH: Generate yaml report of compliance check in given filepath + -o/--output REPORT_FILEPATH: Generate yaml report in given filepath (implies -C) -C/--critical-only: Only return critical errors in return code -a/--assign KEY=VALUE: assign variable to be used for the run (as required by yaml file) @@ -60,7 +59,7 @@ def usage(file=sys.stdout): """, file=file) -def run_check_tool(executable, args, env=None, cwd=None): +def run_check_tool(executable, args, cwd=None): """Run executable and return `CompletedProcess` instance""" if executable.startswith("http://") or executable.startswith("https://"): # TODO: When we start supporting this, consider security concerns @@ -80,7 +79,7 @@ def run_check_tool(executable, args, env=None, cwd=None): exe.insert(0, sys.executable) return subprocess.run( exe, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - encoding='UTF-8', check=False, env=env, cwd=cwd, + encoding='UTF-8', check=False, cwd=cwd, ) @@ -129,6 +128,7 @@ def apply_argv(self, argv): self.subject = opt[1] elif opt[0] == "-o" or opt[0] == "--output": self.output = opt[1] + self.critical_only = True elif opt[0] == "-S" or opt[0] == "--sections": self.sections = [x.strip() for x in opt[1].split(",")] elif opt[0] == "-C" or opt[0] == "--critical-only": @@ -152,34 +152,8 @@ def select_valid(versions: list) -> list: return [version for version in versions if version['_explicit_validity']] -def invoke_check_tool(exe, args, env, cwd): - """run check tool and return invokation dict to use in the report""" - try: - compl = run_check_tool(exe, args, env, cwd) - except Exception as e: - invokation = { - "rc": 127, - "stdout": [], - "stderr": [f"CRITICAL: {e!s}"], - } - else: - invokation = { - "rc": compl.returncode, - "stdout": compl.stdout.splitlines(), - "stderr": compl.stderr.splitlines(), - } - for signal in ('info', 'warning', 'error', 'critical'): - invokation[signal] = len([ - line - for line in chain(invokation["stderr"], invokation["stdout"]) - if line.lower().startswith(signal) - ]) - return invokation - - -def compute_results(stdout, permissible_ids=()): +def compute_results(stdout, results, permissible_ids=()): """pick out test results from stdout lines""" - result = {} for line in stdout: parts = line.rsplit(':', 1) if len(parts) != 2: @@ -191,106 +165,71 @@ def compute_results(stdout, permissible_ids=()): if permissible_ids and testcase_id not in permissible_ids: logger.warning(f"ignoring invalid result id: {testcase_id}") continue - result[testcase_id] = value - return result + results[testcase_id] = value class CheckRunner: - def __init__(self, cwd, assignment, verbosity=0): + def __init__(self, cwd, assignment, results, log, verbose=False): self.cwd = cwd self.assignment = assignment self.num_abort = 0 - self.num_error = 0 - self.verbosity = verbosity + self.verbose = verbose self.spamminess = 0 + self.results = results + self.log = log - def run(self, check, testcases=()): - parameters = check.get('parameters', {}) - assignment = {'testcases': ' '.join(testcases), **self.assignment, **parameters} + def run(self, check, testcases): + assignment = {'testcases': ' '.join(testcases), **self.assignment} args = check.get('args', '').format(**assignment) - env = {key: value.format(**assignment) for key, value in check.get('env', {}).items()} - env_str = " ".join(f"{key}={value}" for key, value in env.items()) - cmd = f"{env_str} {check['executable']} {args}".strip() + cmd = f"{check['executable']} {args}".strip() logger.debug(f"running {cmd!r}...") - check_env = {**os.environ, **env} - invocation = invoke_check_tool(check["executable"], args, check_env, self.cwd) - invocation = { - 'id': str(uuid.uuid4()), - 'cmd': cmd, - 'results': compute_results(invocation['stdout'], permissible_ids=testcases), - **invocation - } - if self.verbosity > 1 and invocation["stdout"]: - print("\n".join(invocation["stdout"])) - self.spamminess += 1 - # the following check used to be "> 0", but this is quite verbose... - if invocation['rc'] or self.verbosity > 1 and invocation["stderr"]: - print("\n".join(invocation["stderr"])) + self.log.append(f"$ {cmd}") + try: + compl = run_check_tool(check["executable"], args, self.cwd) + except Exception as e: + err_log = f"CRITICAL: {e!s}" + rc = 1 + else: + compute_results(compl.stdout.splitlines(), self.results, permissible_ids=testcases) + err_log = compl.stderr.strip() + rc = compl.returncode + self.num_abort += rc + if rc or self.verbose and err_log: + print(err_log, file=sys.stderr) self.spamminess += 1 - logger.debug(f".. rc {invocation['rc']}, {invocation['critical']} critical, {invocation['error']} error") - self.num_abort += invocation["critical"] - self.num_error += invocation["error"] - # count failed testcases because they need not be reported redundantly on the error channel - self.num_error += len([value for value in invocation['results'].values() if value < 0]) - return invocation - - -def print_report(testcase_lookup: dict, targets: dict, results: dict, partial=False, verbose=False): - for tname, tc_ids in targets.items(): - by_value = eval_buckets(results, tc_ids) - missing, failed, aborted, passed = by_value[None], by_value[-1], by_value[0], by_value[1] - verdict = 'FAIL' if failed or aborted else 'TENTATIVE pass' if missing else 'PASS' - summary_parts = [f"{len(passed)} passed"] - if failed: - summary_parts.append(f"{len(failed)} failed") - if aborted: - summary_parts.append(f"{len(aborted)} aborted") - if missing: - summary_parts.append(f"{len(missing)} missing") - verdict += f" ({', '.join(summary_parts)})" - print(f"- {tname}: {verdict}") - reportcateg = [(failed, 'FAILED'), (aborted, 'ABORTED'), (missing, 'MISSING')] - if verbose: - reportcateg.append((passed, 'PASSED')) - for offenders, category in reportcateg: - if category == 'MISSING' and partial: - continue # do not report each missing testcase if a filter was used - if not offenders: - continue - print(f" - {category}:") - for tc_id in offenders: - print(f" - {tc_id}:") - testcase = testcase_lookup[tc_id] - if 'description' in testcase: - print(f" > {testcase['description']}") - if 'url' in testcase: - print(f" > {testcase['url']}") - - -def create_report(argv, config, spec, invocations): - return { - # these fields are essential: - # results are no longer specific to version! - # omit the field `version` because it's just redundant; simply parse invocations (see below) - "spec": { - "uuid": spec['uuid'], - "name": spec['name'], - "url": spec['url'], - }, - "checked_at": datetime.datetime.now(), - "reference_date": config.checkdate, - "subject": config.subject, - # this field is mostly for debugging: - "run": { - "uuid": str(uuid.uuid4()), - "argv": argv, - "assignment": config.assignment, - "sections": config.sections, - "forced_version": config.version or None, - "forced_tests": None if config.tests is None else config.tests.pattern, - "invocations": {invocation['id']: invocation for invocation in invocations}, - }, - } + logger.debug(f'.. rc {rc}') + self.log.extend(err_log.splitlines()) + + +def print_report(testcase_lookup: dict, tc_ids: list, results: dict, partial=False, verbose=False): + by_value = eval_buckets(results, tc_ids) + missing, failed, aborted, passed = by_value[None], by_value[-1], by_value[0], by_value[1] + verdict = 'FAIL' if failed or aborted else 'TENTATIVE pass' if missing else 'PASS' + summary_parts = [f"{len(passed)} passed"] + if failed: + summary_parts.append(f"{len(failed)} failed") + if aborted: + summary_parts.append(f"{len(aborted)} aborted") + if missing: + summary_parts.append(f"{len(missing)} missing") + verdict += f" ({', '.join(summary_parts)})" + print(verdict) + reportcateg = [(failed, 'FAILED'), (aborted, 'ABORTED'), (missing, 'MISSING')] + if verbose: + reportcateg.append((passed, 'PASSED')) + for offenders, category in reportcateg: + if category == 'MISSING' and partial: + continue # do not report each missing testcase if a filter was used + if not offenders: + continue + print(f" - {category}:") + for tc_id in offenders: + print(f" - {tc_id}") + testcase = testcase_lookup[tc_id] + if verbose and 'description' in testcase: + print(f" > {testcase['description']}") + if verbose and 'url' in testcase: + print(f" > {testcase['url']}") def main(argv): @@ -298,6 +237,7 @@ def main(argv): logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO) config = Config() config.apply_argv(argv) + check_cwd = os.path.dirname(config.arg0) or os.getcwd() if not config.subject: raise RuntimeError("You need pass --subject=SUBJECT.") with open(config.arg0, "r", encoding="UTF-8") as specfile: @@ -317,8 +257,6 @@ def main(argv): raise RuntimeError(f"Requested version '{config.version}' not found") if not versions: raise RuntimeError(f"No valid version found for {config.checkdate}") - check_cwd = os.path.dirname(config.arg0) or os.getcwd() - runner = CheckRunner(check_cwd, assignment, verbosity=config.verbose and 2 or not config.quiet) title, partial = spec['name'], False if config.sections: title += f" [sections: {', '.join(config.sections)}]" @@ -329,8 +267,7 @@ def main(argv): # collect all testcases we need all_testcase_ids = set() for version in versions: - for testcase_ids in version['targets'].values(): - all_testcase_ids.update(testcase_ids) + all_testcase_ids.update(version['testcase_ids']) # collect scripts to be run testcase_lookup = spec['testcases'] tc_script_lookup = spec['tc_scripts'] @@ -346,27 +283,28 @@ def main(argv): idx = script['_idx'] script_tc_ids[idx].append(tc_id) # run scripts - invocations = [ - runner.run(script, testcases=sorted(tc_ids)) - for script, tc_ids in zip(spec['scripts'], script_tc_ids) - if tc_ids - ] + log = ['running: ' + shlex.join(sys.argv)] results = {} - for invocation in invocations: - results.update(invocation['results']) - # now report: to console if requested, and likewise for yaml output - if not config.quiet: + runner = CheckRunner(check_cwd, assignment, results, log, verbose=config.verbose) + for script, tc_ids in zip(spec['scripts'], script_tc_ids): + if not tc_ids: + continue + runner.run(script, sorted(tc_ids)) + # now report: + if config.verbose or not config.output: # print a horizontal line if we had any script output if runner.spamminess: print("********" * 10) # 80 characters + print(f"{config.subject} {title}:") for version in versions: - print(f"{config.subject} {title} {version['version']}:") - print_report(testcase_lookup, version['targets'], results, partial, config.verbose) + print(f"- {version['version']}:", end=' ') + print_report(testcase_lookup, version['testcase_ids'], results, partial, config.verbose) if config.output: - report = create_report(argv, config, spec, invocations) + report = make_report(spec['uuid'], config.subject, results, log) with open(config.output, 'w', encoding='UTF-8') as fileobj: yaml.safe_dump(report, fileobj, default_flow_style=False, sort_keys=False, explicit_start=True) - return min(127, runner.num_abort + (0 if config.critical_only else runner.num_error)) + num_error = len([tc_id for tc_id, value in results.items() if value != 1]) + return min(127, runner.num_abort + (0 if config.critical_only else num_error)) if __name__ == "__main__": diff --git a/Tests/scs-test-runner.py b/Tests/scs-test-runner.py index 619ec3dfc..69b7f4d9e 100755 --- a/Tests/scs-test-runner.py +++ b/Tests/scs-test-runner.py @@ -159,18 +159,18 @@ def _run_commands(commands, num_workers=5): def _concat_files(source_paths, target_path): with open(target_path, 'wb') as tfileobj: for path in source_paths: - with open(path, 'rb') as sfileobj: - shutil.copyfileobj(sfileobj, tfileobj) + try: + with open(path, 'rb') as sfileobj: + shutil.copyfileobj(sfileobj, tfileobj) + except FileNotFoundError: + logger.warning(f"Skipping non-extant {path}") -def _move_file(source_path, target_path): - # for Windows people, remove target first, but don't try too hard (Windows is notoriously bad at this) - # this two-stage delete-rename approach does have a tiny (irrelevant) race condition (thx Windows) +def _remove_file(path): try: - os.remove(target_path) + os.remove(path) except FileNotFoundError: pass - os.rename(source_path, target_path) @cli.command() @@ -213,18 +213,17 @@ def run(cfg, scopes, subjects, sections, preset, num_workers, monitor_url, repor logger.debug(f'running tests for scope(s) {", ".join(scopes)} and subject(s) {", ".join(subjects)}') logger.debug(f'monitor url: {monitor_url}, num_workers: {num_workers}, output: {report_yaml}') with tempfile.TemporaryDirectory(dir=cfg.cwd) as tdirname: - report_yaml_tmp = os.path.join(tdirname, 'report.yaml') jobs = [(scope, subject) for scope in scopes for subject in subjects] outputs = [os.path.join(tdirname, f'report-{idx}.yaml') for idx in range(len(jobs))] commands = [cfg.build_check_command(job[0], job[1], sections, output) for job, output in zip(jobs, outputs)] _run_commands(commands, num_workers=num_workers) - _concat_files(outputs, report_yaml_tmp) if report_yaml is None: - report_yaml = report_yaml_tmp - else: - _move_file(report_yaml_tmp, report_yaml) + report_yaml = os.path.join(tdirname, 'report.yaml') + _concat_files(outputs, report_yaml) + _remove_file(report_yaml + '.sig') subprocess.run(**cfg.build_sign_command(report_yaml)) subprocess.run(**cfg.build_upload_command(report_yaml, monitor_url)) + sys.stdout.write('\n') # curl output does not end in newline return 0 diff --git a/Tests/scs_cert_lib.py b/Tests/scs_cert_lib.py index 3a8bae0b5..804dba8c6 100644 --- a/Tests/scs_cert_lib.py +++ b/Tests/scs_cert_lib.py @@ -9,18 +9,19 @@ from collections import defaultdict from datetime import datetime, date, timedelta import logging +import uuid logger = logging.getLogger(__name__) +__VERSION__ = "20260623" # valid keywords for various parts of the spec, to be checked using `check_keywords` KEYWORDS = { - 'spec': ('uuid', 'name', 'url', 'versions', 'prerequisite', 'variables', 'scripts', 'modules', 'timeline'), + 'spec': ('uuid', 'name', 'url', 'versions', 'prerequisite', 'variables', 'scripts', 'groups', 'timeline'), 'scripts': ('executable', 'env', 'args', 'testcases'), - 'versions': ('version', 'include', 'targets', 'stabilized_at'), - 'modules': ('id', 'targets', 'url', 'name', 'parameters'), + 'versions': ('version', 'test', 'attn'), + 'groups': ('id', 'url', 'name', 'include'), 'testcases': ('lifetime', 'section', 'id', 'description', 'url'), - 'include': ('ref', 'parameters'), } # The canonical result values are -1, 0, and 1, for FAIL, ABORT, and PASS, respectively; # -- in addition, None is used to encode a missing value, but must not be included in a formal report! -- @@ -49,6 +50,21 @@ def _check_keywords(ctx, d, keywords=KEYWORDS): return len(invalid) + sum(_check_keywords(k, v, keywords=keywords) for k, v in d.items()) +def _collect_testcases(test, result_list): + if 'attn' in test: + result_list.append(test) + for child in test.get('include', ()): + _collect_testcases(child, result_list) + return result_list + + +def collect_testcases(*tests): + result_list = [] + for test in tests: + _collect_testcases(test, result_list) + return result_list + + def _resolve_spec(spec: dict): """rewire `spec` so as to make most lookups via name unnecessary, and to find name errors early""" if isinstance(spec['versions'], dict): @@ -68,42 +84,37 @@ def _resolve_spec(spec: dict): testcase['attn'] = 0 # count: how many stable versions list this in target 'main'? testcase_lookup[id_] = testcase tc_script_lookup[id_] = script - module_lookup = {module['id']: module for module in spec['modules']} + group_lookup = {group['id']: group for group in spec['groups']} + test_lookup = dict(**group_lookup, **testcase_lookup) version_lookup = {version['version']: version for version in spec['versions']} # step 2. check for duplicates: - if len(module_lookup) != len(spec['modules']): + if len(group_lookup) != len(spec['groups']): raise RuntimeError("spec contains duplicate module ids") if len(version_lookup) != len(spec['versions']): raise RuntimeError("spec contains duplicate version ids") - # step 3. replace fields 'modules' and 'versions' by respective lookups - spec['modules'] = module_lookup + # step 3. replace fields 'groups' and 'versions' by respective lookups + spec['groups'] = group_lookup spec['versions'] = version_lookup # step 3a. add testcase lookup spec['testcases'] = testcase_lookup spec['tc_scripts'] = tc_script_lookup # step 4. resolve references - # step 4a. resolve references to modules in includes - # in this step, we also normalize the include form + # step 4a. resolve 'include' in groups + for group in spec['groups'].values(): + # empty group need not specify include. + # next to resolving references, also ensure here that attribute exists + include = group.get('include', ()) + group['include'] = [test_lookup[inc] for inc in include] + # step 4b. resolve 'test' in versions for idx, version in enumerate(spec['versions'].values()): version['_idx'] = idx - version['include'] = [ - {'module': module_lookup[inc], 'parameters': {}} if isinstance(inc, str) else - {'module': module_lookup[inc['ref']], 'parameters': inc.get('parameters', {})} - for inc in version['include'] - ] - targets = defaultdict(set) - for inc in version['include']: - for target, tc_ids in inc['module'].get('targets', {}).items(): - targets[target].update(tc_ids) - tc_target = {} - for target, tc_ids in targets.items(): - for tc_id in tc_ids: - tc_target[tc_id] = target - if version.get('stabilized_at'): - for tc_id in targets.get('main', ()): - testcase_lookup[tc_id]['attn'] += 1 - version['targets'] = {target: sorted(tc_ids) for target, tc_ids in targets.items()} - version['tc_target'] = tc_target + test = test_lookup[version['test']] + testcases = collect_testcases(test) + attn = version.get('attn', 0) + for testcase in testcases: + testcase['attn'] += attn + version['test'] = test + version['testcase_ids'] = [testcase['id'] for testcase in testcases] # step 4b. resolve references to versions in timeline # on second thought, let's not go there: it's a canonical extension map, and it should remain that way. # however, we still have to look for name errors @@ -211,3 +222,22 @@ def evaluate(results, testcase_ids) -> int: if buckets[value]: return value return 1 + + +def make_report(scopeuuid, subject, results, log, creator=None, checked_at=None): + if creator is None: + creator = f"SCS test suite; version={__VERSION__}" + return { + "uuid": str(uuid.uuid4()), + "creator": creator, + "scope": scopeuuid, + "checked_at": checked_at or datetime.now(), + "subject": subject, + "tests": { + testcase_id: { + "result": value, + } + for testcase_id, value in results.items() + }, + "log": log, + } diff --git a/compliance-monitor/monitor.py b/compliance-monitor/monitor.py index 0dd2ef865..c018ef435 100755 --- a/compliance-monitor/monitor.py +++ b/compliance-monitor/monitor.py @@ -19,6 +19,7 @@ import logging import os import os.path +import shlex from shutil import which import signal from subprocess import run @@ -121,12 +122,7 @@ class ViewType(Enum): ViewType.fragment: 'overview.md', ViewType.page: 'overview.html', } -VIEW_SCOPE = { - ViewType.markdown: 'scope.md', - ViewType.fragment: 'scope.md', - ViewType.page: 'overview.html', -} -REQUIRED_TEMPLATES = tuple(set(fn for view in (VIEW_REPORT, VIEW_DETAIL, VIEW_TABLE, VIEW_SCOPE) for fn in view.values())) +REQUIRED_TEMPLATES = tuple(set(fn for view in (VIEW_REPORT, VIEW_DETAIL, VIEW_TABLE) for fn in view.values())) # do I hate these globals, but I don't see another way with these frameworks @@ -249,18 +245,10 @@ def import_bootstrap(bootstrap_path, conn): def _evaluate_version(version, scope_results): """evaluate the results for `version` and return the canonical JSON output""" - target_results = { - tname: { - 'testcases': tc_ids, - 'result': evaluate(scope_results, tc_ids), - } - for tname, tc_ids in version['targets'].items() - } return { '_idx': version['_idx'], - 'result': target_results['main']['result'], - 'targets': target_results, - 'tc_target': version['tc_target'], + 'result': evaluate(scope_results, version['testcase_ids']), + 'testcases': version['testcase_ids'], 'validity': version['validity'], } @@ -293,8 +281,7 @@ def _evaluate_scope(spec, scope_results, include_drafts=False): # only list testcases that occur in any relevant version relevant_testcases = set() for vname in relevant: - for tc_ids in versions[vname]['targets'].values(): - relevant_testcases.update(tc_ids) + relevant_testcases.update(versions[vname]['testcase_ids']) return { 'name': spec['name'], 'testcases': testcases, @@ -426,6 +413,45 @@ async def get_report( return Response(content=json.dumps(spec, indent=2), media_type="application/json") +def _patch_report(report): + """convert old report format into new format""" + if 'creator' in report: + return # new format already + report['creator'] = 'SCS test suite; version=n/a' + tests = report.setdefault('tests', {}) + run = report.pop('run') + report['uuid'] = run['uuid'] + spec = report.pop('spec') + report['scope'] = spec['uuid'] + log = report.setdefault('log', []) + log.append('arguments: ' + shlex.join(run['argv'])) + for invdata in run['invocations'].values(): + log.append('$ ' + invdata['cmd']) + for line in invdata['stderr']: + if line.startswith('DEBUG: ** '): + testcase_id = line.split()[2] + line = f'INFO: *** {testcase_id}' + log.append(line) + for tc_id, value in invdata['results'].items(): + tests[tc_id] = {'result': value} + + +def _augment_report(report): + tests = report.get('tests', {}) + log = [] + for line in report.get('log', ()): + if line.startswith('INFO: *** '): + testcase_id = line.split()[2] + result = tests.get(testcase_id) + if result is not None: + result['_inlog'] = True + val = result.get('result', None) + verdict = {1: 'PASS', 0: 'DNF', -1: 'FAIL'}.get(val, 'N/A') + line = f'INFO: *** {testcase_id} ({verdict})' + log.append(line) + report['log'] = log + + @app.post("/reports") async def post_report( request: Request, @@ -481,28 +507,18 @@ async def post_report( with conn.cursor() as cur: for document, json_text in zip(documents, json_texts): - rundata = document['run'] - uuid, subject, checked_at = rundata['uuid'], document['subject'], document['checked_at'] - scopeuuid = document['spec']['uuid'] + _patch_report(document) # modification is okay, because only `json_text` will be stored + subject, checked_at = document['subject'], document['checked_at'] + uuid, scopeuuid = document['uuid'], document['scope'] try: reportid = db_insert_report(cur, uuid, checked_at, subject, json_text) except UniqueViolation: raise HTTPException(status_code=409, detail="Conflict: report already present") - if 'versions' not in document: - # If this key is missing, this means we have a newer-style report that doesn't redundantly list - # results per version. One reason for this change is that the meaning of a testcase identifier - # no longer depends on the scope version, and we can quite simply read off the results from the - # invocations. -- Use the dummy version '*' as long as the db schema still expects a version. - document['versions'] = {'*': { - tc_id: {'result': result, 'invocation': inv_id} - for inv_id, invocation in document['run']['invocations'].items() - for tc_id, result in invocation['results'].items() - }} - for version, vdata in document['versions'].items(): - for check, rdata in vdata.items(): - result = rdata['result'] - approval = 1 == result # pre-approve good result - db_insert_result2(cur, checked_at, subject, scopeuuid, version, check, result, approval, reportid) + version = '*' # unused column TODO remove this + for testcase_id, result_data in document['tests'].items(): + value = result_data['result'] + approval = 1 == value # pre-approve good result TODO remove this + db_insert_result2(cur, checked_at, subject, scopeuuid, version, testcase_id, value, approval, reportid) conn.commit() @@ -581,10 +597,9 @@ def render_view(view, view_type, detail_page='detail', base_url='/', title=None, stage1 = stage2 = view[view_type] if view_type is ViewType.page: stage1 = view[ViewType.fragment] - def scope_url(uuid): return f"{base_url}page/scope/{uuid}" # noqa: E306,E704 def detail_url(subject, scope): return f"{base_url}page/{detail_page}/{subject}/{scope}" # noqa: E306,E704 def report_url(report, *args, **kwargs): return _build_report_url(base_url, report, *args, **kwargs) # noqa: E306,E704 - fragment = templates_map[stage1].render(base_url=base_url, detail_url=detail_url, report_url=report_url, scope_url=scope_url, **kwargs) + fragment = templates_map[stage1].render(base_url=base_url, detail_url=detail_url, report_url=report_url, scope_url=_scope_url, **kwargs) if view_type != ViewType.markdown and stage1.endswith('.md'): fragment = markdown(fragment, extensions=['extra']) if stage1 != stage2: @@ -594,21 +609,33 @@ def report_url(report, *args, **kwargs): return _build_report_url(base_url, repo def _redact_report(report): """remove all lines from script output in `report` that are not directly linked to any testcase""" - if 'run' not in report or 'invocations' not in report['run']: - return - for invdata in report['run']['invocations'].values(): - stdout = invdata.get('stdout', []) - redacted = [line for line in stdout if line.rsplit(': ', 1)[-1] in ('PASS', 'ABORT', 'FAIL')] - if len(redacted) != len(stdout): - redacted.insert(0, '(the following has been redacted)') - invdata['stdout'] = redacted - invdata['redacted'] = True - stderr = invdata.get('stderr', []) - redacted = [line for line in stderr if line[:6] in ('WARNIN', 'ERROR:')] - if len(redacted) != len(stderr): - redacted.insert(0, '(the following has been redacted)') - invdata['stderr'] = redacted - invdata['redacted'] = True + log = report['log'] + # don't do list comprehension here because it would restrict the logic + # (e.g. hard to replace a batch of lines by a different batch of lines) + redacted = [] + for line in log: + parts = line.split(': ', 1) + if len(parts) != 2: + continue + # don't redact 'official' error messages + # this can still leak information and should be changed + if parts[0] in ('WARNING', 'ERROR', 'CRITICAL'): + redacted.append(line) + # don't redact the line that states what testcase is now being tested + elif parts[0] in ('INFO', ) and parts[1].startswith('***'): + redacted.append(line) + report['log'] = redacted + return len(log) != len(redacted) + + +def _scope_name(uuid): + scope = get_scopes().get(uuid) + return scope['name'] if scope else '(n/a)' + + +def _scope_url(uuid): + scope = get_scopes().get(_resolve_scope(uuid)) + return scope['url'] if scope else '(n/a)' @app.get("/{view_type}/report/{report_uuid}") @@ -619,14 +646,17 @@ async def get_report_view( report_uuid: str, ): with conn.cursor() as cur: - specs = db_get_report(cur, report_uuid) - if not specs: + reports = db_get_report(cur, report_uuid) + if not reports: raise HTTPException(status_code=404) - spec = specs[0] - _redact_report(spec) + report = reports[0] + _patch_report(report) + _augment_report(report) + redacted = _redact_report(report) return render_view( - VIEW_REPORT, view_type, report=spec, base_url=settings.base_url, - title=f'Report {report_uuid} (redacted)', + VIEW_REPORT, view_type, report=report, base_url=settings.base_url, + title=f'Report {report_uuid} (redacted)', scope_name=_scope_name, + redacted=redacted, ) @@ -639,14 +669,16 @@ async def get_report_view_full( report_uuid: str, ): with conn.cursor() as cur: - specs = db_get_report(cur, report_uuid) - if not specs: + reports = db_get_report(cur, report_uuid) + if not reports: raise HTTPException(status_code=404) - spec = specs[0] - check_role(account, spec['subject'], ROLES['read_any']) + report = reports[0] + _patch_report(report) + _augment_report(report) + check_role(account, report['subject'], ROLES['read_any']) return render_view( - VIEW_REPORT, view_type, report=spec, base_url=settings.base_url, - title=f'Report {report_uuid} (full)', + VIEW_REPORT, view_type, report=report, base_url=settings.base_url, + title=f'Report {report_uuid} (full)', scope_name=_scope_name, ) @@ -741,34 +773,6 @@ async def get_table_full( return _make_table_view(conn, view_type, detail_page='detail_full', include_drafts=True) -@app.get("/{view_type}/scope/{scopeuuid}") -async def get_scope( - request: Request, - conn: Annotated[connection, Depends(get_conn)], - view_type: ViewType, - scopeuuid: str, -): - scopeuuid = _resolve_scope(scopeuuid) - spec = get_scopes()[scopeuuid] - versions = spec['versions'] - # use same order as in details view - relevant = [ - name - for name, version in versions.items() - if version['_explicit_validity'] - ] - modules_chart = {} - for name in relevant: - for include in versions[name]['include']: - module_id = include['module']['id'] - row = modules_chart.get(module_id) - if row is None: - row = modules_chart[module_id] = {'module': include['module'], 'columns': {}} - row['columns'][name] = include - rows = sorted(list(modules_chart.values()), key=lambda row: row['module']['id']) - return render_view(VIEW_SCOPE, view_type, spec=spec, relevant=relevant, rows=rows, base_url=settings.base_url, title=spec['name']) - - @app.get("/results") async def get_results( request: Request, diff --git a/compliance-monitor/templates/details.md.j2 b/compliance-monitor/templates/details.md.j2 index 24e8a406b..cd7f1656f 100644 --- a/compliance-monitor/templates/details.md.j2 +++ b/compliance-monitor/templates/details.md.j2 @@ -39,9 +39,7 @@ No recent test results available. {% set sym = sym0 -%} {% endif -%} | {{sym}} {{cat}}{% if res.report %} ([{{ res.checked_at | short_isodate }}]({{ report_url(res.report, version, testcase_id) }})) { title="{{ res.report }} {{ res.checked_at }}" }{% endif %} {# -#} -{% for version in scope_result.relevant %}| {% -set tgt = scope_result.versions[version].tc_target[testcase_id] -%}{% if tgt == 'main' %}X { title="main" }{% endif %} {% endfor -%} +{% for version in scope_result.relevant %}| {% if testcase.id in scope_result.versions[version].testcases %}X{% endif %} {% endfor -%} | {{ testcase.description | trim }} | {% endfor %}{# testcase -#} {% endfor %}{# categories #} diff --git a/compliance-monitor/templates/overview.html.j2 b/compliance-monitor/templates/overview.html.j2 index 830b94121..0b7fd56b7 100644 --- a/compliance-monitor/templates/overview.html.j2 +++ b/compliance-monitor/templates/overview.html.j2 @@ -11,7 +11,9 @@ table th {font-weight:700;background-color:#00000008;} html {text-rendering:optimizelegibility;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";} li {margin:0.33em 0;} - pre.line {margin: 0;} + #log + ul {padding-left:0;} + #log + ul > li {margin:0;display:block;text-indent:1em hanging;} + #log + ul > li strong em {background-color:yellow;font-style:normal;} {% if title %}

{{title}}

{% endif %}{{fragment}} diff --git a/compliance-monitor/templates/report.md.j2 b/compliance-monitor/templates/report.md.j2 index 5b27fabe4..3711a4bb4 100644 --- a/compliance-monitor/templates/report.md.j2 +++ b/compliance-monitor/templates/report.md.j2 @@ -1,48 +1,32 @@ ## General info -- uuid: [{{ report.run.uuid }}]({{ report_url(report.run.uuid, download=True) }}) +{% if report.run %}{% set uuid = report.run.uuid %}{% set scopeuuid = report.spec.uuid %}{% +else %}{% set uuid = report.uuid %}{% set scopeuuid = report.scope %}{% endif -%} +- uuid: [{{ uuid }}]({{ report_url(uuid, download=True) }}) +- creator: {{ report.creator }} - subject: {{ report.subject }} -- scope: [{{ report.spec.name }}]({{ scope_url(report.spec.uuid) }}) +- scope: [{{ scope_name(scopeuuid) }}]({{ scope_url(scopeuuid) }}) - checked at: {{ report.checked_at }} -- variable assignment: {% set comma = joiner(", ") %}{% for key, value in report.run.assignment.items() -%}{{comma()}}`{{ key }}`=`{{ value }}`{% endfor %} -{% for invid, invdata in report.run.invocations.items() %} -## Invocation {{invid}} {: #{{ invid }} } +{% if report.tests -%} +## Tests +{% for resultid, resultdata in report.tests.items() | sort %} +- {{ resultid }}: {{ resultdata.result | verdict_check }}{% if not resultdata._inlog %} + {: #{{ resultid }} } {% endif %} +{%- endfor -%} +{% endif %} +{% if report.log %} +## Log{%if redacted %} (redacted){%endif%} {: #log } -- cmd: `{{ invdata.cmd }}` -- rc: {{ invdata.rc }} -- channel summary -{%- for channel in ('critical', 'error', 'warning') %} -{%- if invdata[channel] %} - - **{{ channel }}: {{ invdata[channel] }}** -{%- else %} - - {{ channel }}: โ€“ -{%- endif %} +{% for line in report.log %} +- {% if line.split(':', 1)[0].lower() in ('warning', 'error', 'critical') %}**_`{{line}}`_**{% + elif line.startswith('INFO: *** ') %}{% if 'PASS' in line %} `{{line}}` {% else %} **`{{line}}`** {% endif %} + {: #{{line.split()[2]}} }{% + else %}`{{line}}`{% endif %} {%- endfor %} -- results -{%- for resultid, result in invdata.results.items() %} - - {{ resultid }}: {{ result | verdict_check }} - {: #{{ resultid }} } -{%- endfor %} - -{% if invdata.stdout -%} -
Captured stdout -```text -{{ '\n'.join(invdata.stdout) }} -``` -
-{%- endif %} - -{% if invdata.stderr -%} -
Captured stderr -{%- for line in invdata.stderr %} -
{% if line.split(':', 1)[0].lower() in ('warning', 'error', 'critical') %}{{ '' + line + '' }}{% else %}{{ line }}{% endif %}
-{%- endfor %} -
-{%- endif %} -{% if invdata.redacted -%} -ยป [show unredacted (requires login) ๐Ÿ”’]({{report_url(report.run.uuid, full=True)}}#{{ invid }}) -{%- endif %} +{% endif %} -{% endfor %} +{% if redacted %} +ยป [show unredacted (requires login) ๐Ÿ”’]({{report_url(uuid, full=True)}}#{{ invid }}) +{% endif %}