diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml new file mode 100644 index 0000000..b34d052 --- /dev/null +++ b/.github/workflows/build-ci.yml @@ -0,0 +1,80 @@ +name: build-ci + +on: + pull_request: + paths: + - "**/Dockerfile" + - "**/entrypoint.py" + - "**/PLATFORMS" + - "tests/" + - "tools/genmatrix.js" + - ".github/workflows/build-ci.yml" + +jobs: + gen-matrix: + name: generate-matrix + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Get changed files + id: get-changed-files + uses: jitterbit/get-changed-files@v1 + with: + format: 'json' + + - name: Generate testing matrix + uses: actions/github-script@v4.1 + id: generator + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require(`${process.env.GITHUB_WORKSPACE}/tools/genmatrix.js`) + return script(process.env.GITHUB_WORKSPACE, ${{ steps.get-changed-files.outputs.all }}); + outputs: + matrix: ${{ steps.generator.outputs.result }} + + build: + if: ${{ fromJson(needs.gen-matrix.outputs.matrix) }} + needs: gen-matrix + name: build + env: + image_tag: local/ci:${{ github.run_id }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.gen-matrix.outputs.matrix) }} + + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build image + uses: docker/build-push-action@v2 + with: + builder: ${{ steps.buildx.outputs.name }} + push: false + load: true + tags: ${{ env.image_tag }} + platforms: ${{ matrix.platform }} + context: ./${{ matrix.version }}/${{ matrix.variant }} + file: ./${{ matrix.version }}/${{ matrix.variant }}/Dockerfile + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run integration tests + run: | + python3 -m tests.integration_runner \ + --platform ${{ matrix.platform }} \ + --image ${{ env.image_tag }} \ + --version ${{ matrix.version }} diff --git a/1.14.5/bullseye/PLATFORMS b/1.14.5/bullseye/PLATFORMS new file mode 100644 index 0000000..743dafe --- /dev/null +++ b/1.14.5/bullseye/PLATFORMS @@ -0,0 +1,4 @@ +linux/amd64 +linux/arm64 +linux/arm/v7 +linux/386 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/framework/__init__.py b/tests/integration/framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/framework/docker_runner.py b/tests/integration/framework/docker_runner.py new file mode 100644 index 0000000..c46a535 --- /dev/null +++ b/tests/integration/framework/docker_runner.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Test framework for end-to-end docker tests +""" + +import subprocess +import sys + +class DockerRunner: + """Run docker containers for testing""" + + def __init__(self, platform, image, verbose): + """Sets platform and image for all tests ran with this instance""" + self.platform = platform + self.image = image + self.verbose = verbose + + def construct_docker_command(self, envs, args): + """ + Construct a docker command with env and args + """ + command = ["docker", "run", "--platform", self.platform] + + for env in envs: + command.append("-e") + command.append(env) + + command.append(self.image) + + for arg in args: + command.append(arg) + + return command + + def run_interactive_command(self, envs, args): + """ + Run our target docker image with a list of + environment variables and a list of arguments + """ + command = self.construct_docker_command(envs, args) + + if self.verbose: + print(f"Running command: { ' '.join(command) }") + + try: + output = subprocess.run(command, capture_output=True, check=True) + except subprocess.CalledProcessError as docker_err: + print(f"Error while running command: { ' '.join(command) }", file=sys.stderr) + print(docker_err, file=sys.stderr) + print(docker_err.stderr.decode("utf-8"), file=sys.stderr) + print(docker_err.stdout.decode("utf-8"), file=sys.stdout) + + raise docker_err + + return output diff --git a/tests/integration/framework/test_runner.py b/tests/integration/framework/test_runner.py new file mode 100644 index 0000000..daa2906 --- /dev/null +++ b/tests/integration/framework/test_runner.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Base class to define and run Dogecoin Core Docker tests with +""" + +import argparse +import sys + +from .docker_runner import DockerRunner + +class TestConfigurationError(Exception): + """Raised when the test is configured inconsistently""" + +class TestRunner: + """Base class to define and run Dogecoin Core Docker tests with""" + def __init__(self): + """Make sure there is an options object""" + self.options = {} + + def add_options(self, parser): + """Allow adding options in tests""" + + def run_test(self): + """Actual test, must be implemented by the final class""" + raise NotImplementedError + + def run_command(self, envs, args): + """Run a docker command with env and args""" + assert self.options.platform is not None + assert self.options.image is not None + + runner = DockerRunner(self.options.platform, + self.options.image, self.options.verbose) + + return runner.run_interactive_command(envs, args) + + def main(self): + """main loop""" + parser = argparse.ArgumentParser() + parser.add_argument("--platform", dest="platform", required=True, + help="The platform to use for testing, eg: 'linux/amd64'") + parser.add_argument("--image", dest="image", required=True, + help="The image or tag to execute tests against, eg: 'verywowimage'") + parser.add_argument("--verbose", dest="verbose", default=False, action="store_true", + help="Verbosely output actions taken and print docker logs, regardless of outcome") + + self.add_options(parser) + self.options = parser.parse_args() + + self.run_test() + print("Tests successful") + sys.exit(0) diff --git a/tests/integration/version.py b/tests/integration/version.py new file mode 100644 index 0000000..b8be289 --- /dev/null +++ b/tests/integration/version.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Test the version installed to be the expected version +""" + +import re + +from .framework.test_runner import TestRunner + +class VersionTest(TestRunner): + """Versions test""" + + def __init__(self): + """Constructor""" + TestRunner.__init__(self) + self.version_expr = None + + def add_options(self, parser): + """Add test-specific --version option""" + parser.add_argument("--version", dest="version", required=True, + help="The version that is expected to be installed, eg: '1.14.5'") + + def run_test(self): + """Check the version of each executable""" + + self.version_expr = re.compile(f".*{ self.options.version }.*") + + # check dogecoind with only env + dogecoind = self.run_command(["VERSION=1"], []) + self.ensure_version_on_first_line(dogecoind.stdout) + + # check dogecoin-cli + dogecoincli = self.run_command([], ["dogecoin-cli", "-?"]) + self.ensure_version_on_first_line(dogecoincli.stdout) + + # check dogecoin-tx + dogecointx = self.run_command([], ["dogecoin-tx", "-?"]) + self.ensure_version_on_first_line(dogecointx.stdout) + + # make sure that we find version errors + caught_error = False + try: + self.ensure_version_on_first_line("no version here".encode('utf-8')) + except AssertionError: + caught_error = True + + if not caught_error: + raise AssertionError("Failed to catch a missing version") + + def ensure_version_on_first_line(self, cmd_output): + """Assert that the version is contained in the first line of output string""" + first_line = cmd_output.decode("utf-8").split("\n")[0] + + if re.match(self.version_expr, first_line) is None: + text = f"Could not find version { self.options.version } in { first_line }" + raise AssertionError(text) + +if __name__ == '__main__': + VersionTest().main() diff --git a/tests/integration_runner.py b/tests/integration_runner.py new file mode 100644 index 0000000..9506f5e --- /dev/null +++ b/tests/integration_runner.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Runs the integration tests +""" + +import subprocess +import sys + +from .integration.framework.test_runner import TestRunner + +def print_test_output(test_name, stdout, stderr=None): + """prints output from a test, including from stderr if provided""" + print("\n") + print(test_name) + print("----------------------") + + if stderr is not None: + print(stderr.decode("utf-8"), file=sys.stderr) + + print(stdout.decode("utf-8")) + +class IntegrationRunner(TestRunner): + """Runs the integration tests""" + + def __init__(self): + """Initializes the failure tracker and test result map""" + TestRunner.__init__(self) + self.found_failure = False + self.result_map = {} + + + def add_options(self, parser): + """Add test-specific --version option""" + parser.add_argument("--version", dest="version", required=True, + help="The version that is expected to be installed, eg: '1.14.5'") + + def run_test(self): + """Run all specified tests and inherit any failures""" + + #List of tests to run + tests = [ + [ "version", [ "--version", self.options.version ] ], + ] + + for test in tests: + self.result_map[test[0]] = self.run_individual_test(test) + + self.print_summary() + + if self.found_failure: + sys.exit(1) + + def run_individual_test(self, test): + """Run the actual test""" + command = [ + "/usr/bin/env", "python3", + "-m", f"tests.integration.{ test[0] }", + "--platform", self.options.platform, + "--image", self.options.image, + ] + + if len(test) > 1 and len(test[1]) > 0: + for arg in test[1]: + command.append(arg) + + if self.options.verbose: + command.append("--verbose") + + try: + output = subprocess.run(command, capture_output=True, check=True) + except subprocess.CalledProcessError as test_err: + self.found_failure = True + print_test_output(test[0], test_err.stdout, test_err.stderr) + return False + + if self.options.verbose: + print_test_output(test[0], output.stdout) + + return True + + def print_summary(self): + """Print a summary to stdout""" + print("\n") + print(f"RESULTS: for { self.options.image } on { self.options.platform }") + + successes = 0 + failures = 0 + for test, result in self.result_map.items(): + if result: + successes += 1 + result_str = "Success" + else: + failures += 1 + result_str = "Failure" + + print(f"{ test }: { result_str }") + + sum_str = f"{ successes } successful tests and { failures } failures" + print(f"\nFinished test suite with { sum_str }") + +if __name__ == '__main__': + IntegrationRunner().main() diff --git a/tools/genmatrix.js b/tools/genmatrix.js new file mode 100644 index 0000000..ca22d38 --- /dev/null +++ b/tools/genmatrix.js @@ -0,0 +1,163 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +// Files that are used for CI scripts +// we want to make decisions based on these changing +const CI_FILES = [ + '.github/workflows/build-ci.yml', + 'tools/genmatrix.js', + 'tests/integration', + 'tests/integration_runner.py', +]; + +// Regular expression for detecting version numbering of Dogecoin Core +// used to detect which versions are what +const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/; + +// Entry in the matrix, resistant to red and blue pills +class MatrixEntry { + constructor(version, variant, platform) { + this.version = version; + this.variant = variant; + this.platform = platform; + } + + // returns the relative path to the version/variant as string + path() { + return path.join(this.version, this.variant); + } + + // prints the entry to console + print() { + console.log(this.version, this.variant, this.platform); + } +} + +// Build a GH Actions matrix +class MatrixBuilder { + + constructor(baseDir) { + this.baseDir = baseDir; + this.matrix = []; + } + + // read a build matrix from the repository's directory hieararchy + readMatrix() { + // [1] read the versions from the repository root + const versions = this.readAllVersions(); + if (!Array.isArray(versions) || versions.length < 1) { + throw new Error('No versions to iterate'); + } + + // iterate all versions + versions.forEach(version => { + // [2] read the variants from the version directory + const variants = this.readVariantsForVersion(version); + if (!Array.isArray(variants) || variants.length < 1) { + throw new Error('No variants to iterate for ' + version); + } + + // iterate each variant for this version + variants.forEach(variant => { + // [3] Read the target platforms for this version/variant combination + const platforms = this.readPlatformsForVersionVariant(version, variant); + if (!Array.isArray(platforms) || platforms.length < 1) { + throw new Error('No platforms to iterate for ' + path.join(version, variant)); + } + + platforms.forEach(platform => { + // [4] Add an entry to the matrix for each version/variant/platform + // combination + this.matrix.push(new MatrixEntry(version, variant, platform)); + }); + }); + }); + } + + // read all subdirectories inside a given directory + // returns array of DirEnt objects + readDirs(fromDir) { + const files = fs.readdirSync(fromDir, { withFileTypes: true }); + return files.filter(file => file.isDirectory()); + } + + // read all the version directories in the builder's baseDir + // returns array of filename strings + readAllVersions() { + const dirs = this.readDirs(this.baseDir); + const versionDirs = dirs.filter(dir => dir.name.match(VERSION_DIR_RE)); + return versionDirs.map(dir => dir.name); + } + + // read all variants for a specific version + // returns array of filename strings + readVariantsForVersion(version) { + const fromDir = path.resolve(this.baseDir, version); + const dirs = this.readDirs(fromDir); + return dirs.map(dir => dir.name); + } + + // read all platforms for a specific version + // returns array of architecture strings, eg ["linux/amd64", "linux/arm64"] + readPlatformsForVersionVariant(version, variant) { + const pathToPlatformFile = path.resolve(this.baseDir, version, variant, 'PLATFORMS'); + + if (!fs.existsSync(pathToPlatformFile)) { + throw new Error('No PLATFORM file for ' + path.join(version, variant)); + } + + const contents = fs.readFileSync(pathToPlatformFile).toString('utf-8'); + const platforms = contents.split('\n'); + + return platforms + .map(platform => platform.trim()) // remove whitespace just in case + .filter(platform => platform !== ''); // remove empty string entries + } + + // filters the matrix against an array of files + filterIncludedVariants(fileList) { + this.matrix = this.matrix.filter(entry => { + const matchRegex = new RegExp(entry.path()); + return fileList.some(file => file.match(matchRegex)); + }); + } + + // output the expected GH Actions format + build() { + return this.matrix.length > 0 ? { include: this.matrix } : null; + } + +} + +// Checks if any of the CI files have been changed +const checkCIFilesChanged = (changedFiles) => { + return CI_FILES.some(file => changedFiles.some(changedFile => { + return changedFile.match(new RegExp(file)); + })); +} + +const generateMatrix = (baseDir, changedFiles) => { + + const builder = new MatrixBuilder(baseDir); + + // populate the matrix with all builds + builder.readMatrix(); + + // If the CI has changed, test all builds, otherwise, just build what's changed + if (checkCIFilesChanged(changedFiles)) { + console.log("CI files have changed, running all builds!"); + } else { + builder.filterIncludedVariants(changedFiles); + } + + // print the build list for auditability. + console.log('BUILD LIST:'); + builder.matrix.forEach(entry => entry.print()); + + // return the GH Actions expected matrix format as { "include": [Object] } + // or null if there are no entries + return builder.build(); +}; + +module.exports = generateMatrix;