#!/usr/bin/env python3 """ Build script for ooniprobe. You can get documentation regarding its usage by running `./make --help`. """ from __future__ import annotations import datetime import getopt import os import platform import shlex import shutil import subprocess import sys from typing import Any from typing import Dict from typing import List from typing import NoReturn from typing import Optional from typing import Protocol from typing import Tuple def android_cmdlinetools_os() -> str: """android_cmdlinetools_os maps the name of the current OS to the name used by the android command-line tools file.""" system = platform.system() if system == "Linux": return "linux" if system == "Darwin": return "mac" raise RuntimeError(system) def android_cmdlinetools_version() -> str: """android_cmdlinetools_version returns the version of the Android command-line tools that we'd like to download.""" return "6858069" def android_cmdlinetools_sha256sum() -> str: """android_cmdlinetools_sha256sum returns the SHA256 sum of the Android command-line tools zip file.""" return { "linux": "87f6dcf41d4e642e37ba03cb2e387a542aa0bd73cb689a9e7152aad40a6e7a08", "mac": "58a55d9c5bcacd7c42170d2cf2c9ae2889c6797a6128307aaf69100636f54a13", }[android_cmdlinetools_os()] def cachedir() -> str: """cachedir returns the directory where we cache the SDKs.""" return os.path.join(os.path.expandvars("${HOME}"), ".ooniprobe-build") def goversion() -> str: """goversion is the Go version we use.""" return "1.16.3" def gopath() -> str: """gopath is the GOPATH we use.""" return os.path.expandvars("${HOME}/go") def gosha256sum() -> str: """gosha256sum returns the SHA256 sum of the Go tarball.""" return { "linux": { "amd64": "951a3c7c6ce4e56ad883f97d9db74d3d6d80d5fec77455c6ada6c1f7ac4776d2", "arm64": "566b1d6f17d2bc4ad5f81486f0df44f3088c3ed47a3bec4099d8ed9939e90d5d", }, "darwin": { "amd64": "6bb1cf421f8abc2a9a4e39140b7397cdae6aca3e8d36dcff39a1a77f4f1170ac", "arm64": "f4e96bbcd5d2d1942f5b55d9e4ab19564da4fad192012f6d7b0b9b055ba4208f", }, }[goos()][goarch()] def goos() -> str: """goos returns the GOOS value for the current system.""" system = platform.system() if system == "Linux": return "linux" if system == "Darwin": return "darwin" raise RuntimeError(system) def goarch() -> str: """goarch returns the GOARCH value for the current system.""" machine = platform.machine() if machine in ("arm64", "arm", "386", "amd64"): return machine if machine in ("x86", "i386"): return "386" if machine == "x86_64": return "amd64" if machine == "aarch64": return "arm64" raise RuntimeError(machine) def android_ndk_version() -> str: """android_ndk_version returns the Android NDK version.""" return "22.1.7171670" def sdkmanager_install_cmd(binpath: str) -> List[str]: """sdkmanager_install_cmd returns the command line for installing all the required dependencies using the sdkmanager.""" return [ os.path.join(binpath, "sdkmanager"), "--install", "build-tools;29.0.3", "platforms;android-30", "ndk;{}".format(android_ndk_version()), ] def log(msg: str) -> None: """log prints a message on the standard output.""" print(msg, flush=True) class Options(Protocol): """Options contains the configured options.""" def debugging(self) -> bool: """debugging indicates whether to pass -x to `go build...`.""" def disable_embedding_psiphon_config(self) -> bool: """disable_embedding_psiphon_config indicates that the user does not want us to embed an encrypted psiphon config file into the binary.""" def dry_run(self) -> bool: """dry_run indicates whether to execute commands.""" def target(self) -> str: """target is the target to build.""" def verbose(self) -> bool: """verbose indicates whether to pass -v to `go build...`.""" class ConfigFromCLI: """ConfigFromCLI parses options from CLI flags.""" @classmethod def parse(cls, targets: List[str], top_targets: List[Target]) -> ConfigFromCLI: """parse parses command line options and returns a suitable configuration object.""" conf = cls() conf._parse(targets, top_targets) return conf def __init__(self) -> None: self._debugging = False self._disable_embedding_psiphon_config = False self._dry_run = False self._target = "" self._verbose = False def debugging(self) -> bool: return self._debugging def disable_embedding_psiphon_config(self) -> bool: return self._disable_embedding_psiphon_config def dry_run(self) -> bool: return self._dry_run def target(self) -> str: return self._target def verbose(self) -> bool: return self._verbose # The main reason why I am using getopt here is such that I am able # to print a very clear and detailed usage string. (But the same # could be obtained quite likely w/ argparse.) _usage_string = """\ usage: ./make [--disable-embedding-psiphon-config] [-nvx] -t target ./make -l ./make [--help|-h] The first form of the command builds the `target` specified using the `-t` command line flag. If the target has dependencies, this command will build the dependent targets first. The `-n` flag enables a dry run where the command only prints the commands it would run. The `-v` and `-x` flags are passed directly to `go build ...` and `gomobile bind ...`. The `--disable-embedding-psiphon-config` flag causes this command to disable embedding a psiphon config file into the generated binary; you should use this option when you cannot clone the private repository containing the psiphon configuration file. The second form of the command lists all the available targets, showing top-level targets and their recursive dependencies. The third form of the command prints this help screen. """ @classmethod def _usage(cls, err: str = "", exitcode: int = 0) -> NoReturn: if err: sys.stderr.write("error: {}\n".format(err)) sys.stderr.write(cls._usage_string) sys.exit(exitcode) def _parse(self, targets: List[str], top_targets: List[Target]): try: opts, args = getopt.getopt( sys.argv[1:], "hlnt:vx", ["disable-embedding-psiphon-config", "help"] ) except getopt.GetoptError as err: self._usage(err=err.msg, exitcode=1) if args: self._usage(err="unexpected number of positional arguments", exitcode=1) for key, value in opts: if key == "--disable-embedding-psiphon-config": self._disable_embedding_psiphon_config = True continue if key in ("-h", "--help"): self._usage() if key == "-l": self._list_targets(top_targets) if key == "-n": self._dry_run = True continue if key == "-t": self._target = value continue if key == "-v": self._verbose = True continue if key == "-x": self._debugging = True continue raise RuntimeError(key, value) if self._target == "": # no arguments is equivalent to --help self._usage() if self._target not in targets: sys.stderr.write("unknown target: {}\n".format(self._target)) sys.stderr.write("try `./make -l` to see the available targets.\n") sys.exit(1) def _list_targets(self, top_targets: List[Target]) -> NoReturn: for tgt in top_targets: self._print_target(tgt, 0) sys.exit(0) def _print_target(self, target: Target, indent: int) -> None: sys.stdout.write( "{}{}{}\n".format( " " * indent, target.name(), ":" if target.deps() else "" ) ) for dep in target.deps(): self._print_target(dep, indent + 1) if indent <= 0: sys.stdout.write("\n") class Engine(Protocol): """Engine is an engine for building targets.""" def backticks(self, cmdline: List[str]) -> str: """backticks executes the given command line and returns the output emitted by the command to the caller.""" def cat_sed_redirect( self, patterns: List[Tuple[str, str]], source: str, dest: str ) -> None: """cat_sed_redirect does `cat $source|sed -e "s/$patterns[0][0]/$patterns[0][1]/g" ... > $dest`.""" def echo_to_file(self, content: str, filepath: str) -> None: """echo_to_file writes the content string to the given file.""" def require(self, *executable: str) -> None: """require fails if executable is not found in path.""" def run( self, cmdline: List[str], cwd: Optional[str] = None, inputbytes: Optional[bytes] = None, ) -> None: """run runs the specified command line.""" def setenv(self, key: str, value: str) -> Optional[str]: """setenv sets an environment variable and returns the previous value of such variable (or None).""" def unsetenv(self, key: str) -> None: """unsetenv clears an environment variable.""" class CommandExecutor: """CommandExecutor executes commands.""" def __init__(self, dry_runner: Engine): self._dry_runner = dry_runner def backticks(self, cmdline: List[str]) -> str: """backticks implements Engine.backticks""" # Nothing else to do, because backticks is fully # implemented by CommandDryRunner. return self._dry_runner.backticks(cmdline) def cat_sed_redirect( self, patterns: List[Tuple[str, str]], source: str, dest: str ) -> None: """cat_sed_redirect implements Engine.cat_sed_redirect.""" self._dry_runner.cat_sed_redirect(patterns, source, dest) with open(source, "r") as sourcefp: data = sourcefp.read() for p, v in patterns: data = data.replace(p, v) with open(dest, "w") as destfp: destfp.write(data) def echo_to_file(self, content: str, filepath: str) -> None: """echo_to_file implements Engine.echo_to_file""" self._dry_runner.echo_to_file(content, filepath) with open(filepath, "w") as filep: filep.write(content) filep.write("\n") def require(self, *executable: str) -> None: """require implements Engine.require.""" for exc in executable: self._dry_runner.require(exc) fullpath = shutil.which(exc) if not fullpath: log("checking for {}... not found".format(exc)) sys.exit(1) log("checking for {}... {}".format(exc, fullpath)) def run( self, cmdline: List[str], cwd: Optional[str] = None, inputbytes: Optional[bytes] = None, ) -> None: """run implements Engine.run.""" self._dry_runner.run(cmdline, cwd, inputbytes) subprocess.run(cmdline, check=True, cwd=cwd, input=inputbytes) def setenv(self, key: str, value: str) -> Optional[str]: """setenv implements Engine.setenv.""" # Nothing else to do, because setenv is fully # implemented by CommandDryRunner. return self._dry_runner.setenv(key, value) def unsetenv(self, key: str) -> None: """unsetenv implements Engine.unsetenv.""" # Nothing else to do, because unsetenv is fully # implemented by CommandDryRunner. self._dry_runner.unsetenv(key) class CommandDryRunner: """CommandDryRunner is the dry runner.""" # Implementation note: here we try to log valid bash snippets # such that is really obvious what we are doing. def backticks(self, cmdline: List[str]) -> str: """backticks implements Engine.backticks""" # The backticks command is used to gather information used by # other commands. As such, it needs to always run. If it was not # running, we could not correctly implement the `-n` flag. It's # also a silent command, because it's not really part of the # sequence of bash commands that are executed. ¯\_(ツ)_/¯ popen = subprocess.Popen(cmdline, stdout=subprocess.PIPE) stdout = popen.communicate()[0] if popen.returncode != 0: raise RuntimeError(popen.returncode) return stdout.decode("utf-8").strip() def cat_sed_redirect( self, patterns: List[Tuple[str, str]], source: str, dest: str ) -> None: """cat_sed_redirect implements Engine.cat_sed_redirect.""" out = "./make: cat {}|sed".format(source) for p, v in patterns: out += " -e 's/{}/{}/g'".format(p, v) out += " > {}".format(dest) log(out) def echo_to_file(self, content: str, filepath: str) -> None: """echo_to_file implements Engine.echo_to_file""" log("./make: echo '{}' > {}".format(content, filepath)) def require(self, *executable: str) -> None: """require implements Engine.require.""" for exc in executable: log(f"./make: echo -n 'checking for {exc}... '") log("./make: command -v %s || { echo 'not found'; exit 1 }" % exc) def run( self, cmdline: List[str], cwd: Optional[str] = None, inputbytes: Optional[bytes] = None, ) -> None: """run implements Engine.run.""" cdpart = "" if cwd: cdpart = "cd {} && ".format(cwd) log("./make: {}{}".format(cdpart, shlex.join(cmdline))) def setenv(self, key: str, value: str) -> Optional[str]: """setenv implements Engine.setenv.""" log("./make: export {}={}".format(key, shlex.join([value]))) prev = os.environ.get(key) os.environ[key] = value return prev def unsetenv(self, key: str) -> None: """unsetenv implements Engine.unsetenv.""" log("./make: unset {}".format(key)) del os.environ[key] def new_engine(options: Options) -> Engine: """new_engine creates a new engine instance""" out: Engine = CommandDryRunner() if not options.dry_run(): out = CommandExecutor(out) return out class Environ: """Environ creates a context where specific environment variables are set. They will be restored to their previous value when we are leaving the context.""" def __init__(self, engine: Engine, key: str, value: str) -> None: self._engine = engine self._key = key self._value = value self._prev: Optional[str] = None def __enter__(self) -> None: self._prev = self._engine.setenv(self._key, self._value) def __exit__(self, type: Any, value: Any, traceback: Any) -> bool: if self._prev is None: self._engine.unsetenv(self._key) return False # propagate exc self._engine.setenv(self._key, self._prev) return False # propagate exc class AugmentedPath(Environ): """AugementedPath is an Environ that prepends the required directory to the currently existing search path.""" def __init__(self, engine: Engine, directory: str): value = os.pathsep.join([directory, os.environ["PATH"]]) super().__init__(engine, "PATH", value) class WorkingDir: """WorkingDir is a context manager that enters into a given working directory and returns to the previous directory when done.""" def __init__(self, dirpath: str) -> None: self._dirpath = dirpath self._prev: str = "" def __enter__(self) -> None: self._prev = os.getcwd() os.chdir(self._dirpath) def __exit__(self, type: Any, value: Any, traceback: Any) -> bool: os.chdir(self._prev) return False # propagate exc class Target(Protocol): """Target is a target to build.""" def name(self) -> str: """name returns the target name.""" def build(self, engine: Engine, options: Options) -> None: """build builds the specified target.""" def deps(self) -> List[Target]: """deps returns the target dependencies.""" class BaseTarget: """BaseTarget is the base class of all targets.""" def __init__(self, name: str, deps: List[Target]) -> None: # prevent child classes from easily using these variables self.__name = name self.__deps = deps def name(self) -> str: """name implements Target.name""" return self.__name def build_child_targets(self, engine: Engine, options: Options) -> None: """build_child_targets builds all the child targets""" for dep in self.__deps: dep.build(engine, options) def deps(self) -> List[Target]: """deps implements Target.deps.""" return self.__deps class SDKGolangGo(BaseTarget): """SDKGolangGo creates ${cachedir}/SDK/golang.""" # We download a golang SDK from upstream to make sure we # are always using a specific version of golang/go. def __init__(self) -> None: name = os.path.join(cachedir(), "SDK", "golang") super().__init__(name, []) def binpath(self) -> str: """binpath returns the path where the go binary is installed.""" return os.path.join(self.name(), "go", "bin") def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isdir(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) engine.require("mkdir", "curl", "shasum", "rm", "tar", "echo") filename = "go{}.{}-{}.tar.gz".format(goversion(), goos(), goarch()) url = "https://golang.org/dl/{}".format(filename) engine.run(["mkdir", "-p", self.name()]) filepath = os.path.join(self.name(), filename) engine.run(["curl", "-fsSLo", filepath, url]) sha256file = os.path.join(cachedir(), "SDK", "SHA256") engine.echo_to_file("{} {}".format(gosha256sum(), filepath), sha256file) engine.run(["shasum", "--check", sha256file]) engine.run(["rm", sha256file]) engine.run(["tar", "-xf", filename], cwd=self.name()) engine.run(["rm", filepath]) def goroot(self): """goroot returns the goroot.""" return os.path.join(self.name(), "go") class SDKOONIGo(BaseTarget): """SDKOONIGo creates ${cachedir}/SDK/oonigo.""" # We use a private fork of golang/go on Android as a # workaround for https://github.com/ooni/probe/issues/1444 def __init__(self) -> None: name = os.path.join(cachedir(), "SDK", "oonigo") self._gogo = SDKGolangGo() super().__init__(name, [self._gogo]) def binpath(self) -> str: """binpath returns the path where the go binary is installed.""" return os.path.join(self.name(), "bin") def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isdir(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) engine.require("git", "bash") engine.run( [ "git", "clone", "-b", "ooni", "--single-branch", "--depth", "8", "https://github.com/ooni/go", self.name(), ] ) with Environ(engine, "GOROOT_BOOTSTRAP", self._gogo.goroot()): engine.run( ["./make.bash"], cwd=os.path.join(self.name(), "src"), ) class SDKAndroid(BaseTarget): """SDKAndroid creates ${cachedir}/SDK/android.""" def __init__(self) -> None: name = os.path.join(cachedir(), "SDK", "android") super().__init__(name, []) def home(self) -> str: """home returns the ANDROID_HOME""" return self.name() def ndk_home(self) -> str: """ndk_home returns the ANDROID_NDK_HOME""" return os.path.join(self.home(), "ndk", android_ndk_version()) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isdir(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return log("\n./make: building {}...".format(self.name())) engine.require("mkdir", "curl", "echo", "shasum", "rm", "unzip", "mv", "java") filename = "commandlinetools-{}-{}_latest.zip".format( android_cmdlinetools_os(), android_cmdlinetools_version() ) url = "https://dl.google.com/android/repository/{}".format(filename) engine.run(["mkdir", "-p", self.name()]) filepath = os.path.join(self.name(), filename) engine.run(["curl", "-fsSLo", filepath, url]) sha256file = os.path.join(cachedir(), "SDK", "SHA256") engine.echo_to_file( "{} {}".format(android_cmdlinetools_sha256sum(), filepath), sha256file ) engine.run(["shasum", "--check", sha256file]) engine.run(["rm", sha256file]) engine.run(["unzip", filename], cwd=self.name()) engine.run(["rm", filepath]) # See https://stackoverflow.com/a/61176718 to understand why # we need to reorganize the directories like this: engine.run( ["mv", "cmdline-tools", android_cmdlinetools_version()], cwd=self.name() ) engine.run(["mkdir", "cmdline-tools"], cwd=self.name()) engine.run( ["mv", android_cmdlinetools_version(), "cmdline-tools"], cwd=self.name() ) engine.run( sdkmanager_install_cmd( os.path.join( self.name(), "cmdline-tools", android_cmdlinetools_version(), "bin", ), ), inputbytes=b"Y\n", # automatically accept license ) class OONIProbePrivate(BaseTarget): """OONIProbePrivate creates ${cachedir}/github.com/ooni/probe-private.""" # We use this private repository to copy the psiphon configuration # file to embed into the ooniprobe binaries def __init__(self) -> None: name = os.path.join(cachedir(), "github.com", "ooni", "probe-private") super().__init__(name, []) def copyfiles(self, engine: Engine, options: Options) -> None: """copyfiles copies psiphon config to the repository.""" if options.disable_embedding_psiphon_config(): log("./make: copy psiphon config: disabled by command line flags") return engine.run( [ "cp", os.path.join(self.name(), "psiphon-config.json.age"), os.path.join("internal", "engine"), ] ) engine.run( [ "cp", os.path.join(self.name(), "psiphon-config.key"), os.path.join("internal", "engine"), ] ) def build(self, engine: Engine, options: Options) -> None: if os.path.isdir(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return if options.disable_embedding_psiphon_config(): log("\n./make: {}: disabled by command line flags".format(self.name())) return log("\n./make: building {}...".format(self.name())) engine.require("git", "cp") engine.run( [ "git", "clone", "git@github.com:ooni/probe-private", self.name(), ] ) class OONIMKAllAAR(BaseTarget): """OONIMKAllAAR creates ./MOBILE/android/oonimkall.aar.""" def __init__(self) -> None: name = os.path.join(".", "MOBILE", "android", "oonimkall.aar") self._ooprivate = OONIProbePrivate() self._oonigo = SDKOONIGo() self._android = SDKAndroid() super().__init__(name, [self._ooprivate, self._oonigo, self._android]) def aarfile(self) -> str: """aarfile returns the aar file path""" return self.name() def srcfile(self) -> str: """srcfile returns the path to the jar file containing sources.""" return os.path.join(".", "MOBILE", "android", "oonimkall-sources.jar") def build(self, engine: Engine, options: Options) -> None: if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) engine.require("sh", "javac") self._go_get_gomobile(engine, options, self._oonigo) self._gomobile_init(engine, self._oonigo, self._android) self._gomobile_bind(engine, options, self._oonigo, self._android) # Implementation note: we use proxy scripts for go and gomobile # that explicitly print what they resolve go and gomobile to using # `command -v`. This gives us extra confidence that we are really # using the oonigo fork of golang/go. def _go_get_gomobile( self, engine: Engine, options: Options, oonigo: SDKOONIGo ) -> None: # TODO(bassosimone): find a way to run this command without # adding extra dependencies to go.mod and go.sum. cmdline: List[str] = [] cmdline.append("go") cmdline.append("get") cmdline.append("-u") if options.verbose(): cmdline.append("-v") if options.debugging(): cmdline.append("-x") cmdline.append("golang.org/x/mobile/cmd/gomobile@latest") with Environ(engine, "GOPATH", gopath()): with AugmentedPath(engine, oonigo.binpath()): engine.require("go") engine.run(cmdline) def _gomobile_init( self, engine: Engine, oonigo: SDKOONIGo, android: SDKAndroid, ) -> None: cmdline: List[str] = [] cmdline.append("gomobile") cmdline.append("init") with Environ(engine, "ANDROID_HOME", android.home()): with Environ(engine, "ANDROID_NDK_HOME", android.ndk_home()): with AugmentedPath(engine, oonigo.binpath()): with AugmentedPath(engine, os.path.join(gopath(), "bin")): engine.require("gomobile", "go") engine.run(cmdline) def _gomobile_bind( self, engine: Engine, options: Options, oonigo: SDKOONIGo, android: SDKAndroid, ) -> None: cmdline: List[str] = [] cmdline.append("gomobile") cmdline.append("bind") if options.verbose(): cmdline.append("-v") if options.debugging(): cmdline.append("-x") cmdline.append("-target") cmdline.append("android") cmdline.append("-o") cmdline.append(self.name()) if not options.disable_embedding_psiphon_config(): cmdline.append("-tags") cmdline.append("ooni_psiphon_config") cmdline.append("-ldflags") cmdline.append("-s -w") cmdline.append("./pkg/oonimkall") with Environ(engine, "ANDROID_HOME", android.home()): with Environ(engine, "ANDROID_NDK_HOME", android.ndk_home()): with AugmentedPath(engine, oonigo.binpath()): with AugmentedPath(engine, os.path.join(gopath(), "bin")): engine.require("gomobile", "go") engine.run(cmdline) def sign(engine: Engine, filepath: str) -> str: """sign signs the given filepath using pgp and returns the filepath of the signature file.""" engine.require("gpg") user = "simone@openobservatory.org" engine.run(["gpg", "-abu", user, filepath]) return filepath + ".asc" class BundleJAR(BaseTarget): """BundleJAR creates ./MOBILE/android/bundle.jar.""" # We upload the bundle.jar file to maven central to bless # a new release of the OONI libraries for Android. def __init__(self) -> None: name = os.path.join(".", "MOBILE", "android", "bundle.jar") self._oonimkall = OONIMKAllAAR() super().__init__(name, [self._oonimkall]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) engine.require("cp", "gpg", "jar") version = datetime.datetime.now().strftime("%Y.%m.%d-%H%M%S") engine.run( [ "cp", self._oonimkall.aarfile(), os.path.join("MOBILE", "android", "oonimkall-{}.aar".format(version)), ] ) engine.run( [ "cp", self._oonimkall.srcfile(), os.path.join( "MOBILE", "android", "oonimkall-{}-sources.jar".format(version) ), ] ) engine.cat_sed_redirect( [ ("@VERSION@", version), ], os.path.join("MOBILE", "template.pom"), os.path.join("MOBILE", "android", "oonimkall-{}.pom".format(version)), ) names = ( "oonimkall-{}.aar".format(version), "oonimkall-{}-sources.jar".format(version), "oonimkall-{}.pom".format(version), ) allnames: List[str] = [] with WorkingDir(os.path.join(".", "MOBILE", "android")): for name in names: allnames.append(name) allnames.append(sign(engine, name)) engine.run( [ "jar", "-cf", "bundle.jar", *allnames, ], cwd=os.path.join(".", "MOBILE", "android"), ) class Phony(BaseTarget): """Phony is a phony target that executes one or more other targets.""" def __init__(self, name: str, depends: List[Target]): super().__init__(name, depends) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" self.build_child_targets(engine, options) # Android is the top-level "android" target ANDROID = Phony("android", [BundleJAR()]) class OONIMKAllFramework(BaseTarget): """OONIMKAllFramework creates ./MOBILE/ios/oonimkall.framework.""" def __init__(self) -> None: name = os.path.join(".", "MOBILE", "ios", "oonimkall.framework") self._ooprivate = OONIProbePrivate() self._gogo = SDKGolangGo() super().__init__(name, [self._ooprivate, self._gogo]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build.""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) self._go_get_gomobile(engine, options, self._gogo) self._gomobile_init(engine, self._gogo) self._gomobile_bind(engine, options, self._gogo) def _go_get_gomobile( self, engine: Engine, options: Options, gogo: SDKGolangGo, ) -> None: # TODO(bassosimone): find a way to run this command without # adding extra dependencies to go.mod and go.sum. cmdline: List[str] = [] cmdline.append("go") cmdline.append("get") cmdline.append("-u") if options.verbose(): cmdline.append("-v") if options.debugging(): cmdline.append("-x") cmdline.append("golang.org/x/mobile/cmd/gomobile@latest") with AugmentedPath(engine, gogo.binpath()): with Environ(engine, "GOPATH", gopath()): engine.require("go") engine.run(cmdline) def _gomobile_init( self, engine: Engine, gogo: SDKGolangGo, ) -> None: cmdline: List[str] = [] cmdline.append("gomobile") cmdline.append("init") with AugmentedPath(engine, os.path.join(gopath(), "bin")): with AugmentedPath(engine, gogo.binpath()): engine.require("gomobile", "go") engine.run(cmdline) def _gomobile_bind( self, engine: Engine, options: Options, gogo: SDKGolangGo, ) -> None: cmdline: List[str] = [] cmdline.append("gomobile") cmdline.append("bind") if options.verbose(): cmdline.append("-v") if options.debugging(): cmdline.append("-x") cmdline.append("-target") cmdline.append("ios") cmdline.append("-o") cmdline.append(self.name()) if not options.disable_embedding_psiphon_config(): cmdline.append("-tags") cmdline.append("ooni_psiphon_config") cmdline.append("-ldflags") cmdline.append("-s -w") cmdline.append("./pkg/oonimkall") with AugmentedPath(engine, os.path.join(gopath(), "bin")): with AugmentedPath(engine, gogo.binpath()): engine.require("gomobile", "go") engine.run(cmdline) class OONIMKAllFrameworkZip(BaseTarget): """OONIMKAllFrameworkZip creates ./MOBILE/ios/oonimkall.framework.zip.""" def __init__(self) -> None: name = os.path.join(".", "MOBILE", "ios", "oonimkall.framework.zip") ooframework = OONIMKAllFramework() super().__init__(name, [ooframework]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return engine.require("zip", "rm") self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) engine.run( [ "rm", "-rf", "oonimkall.framework.zip", ], cwd=os.path.join(".", "MOBILE", "ios"), ) engine.run( [ "zip", "-yr", "oonimkall.framework.zip", "oonimkall.framework", ], cwd=os.path.join(".", "MOBILE", "ios"), ) class OONIMKAllPodspec(BaseTarget): """OONIMKAllPodspec creates ./MOBILE/ios/oonimkall.podspec.""" def __init__(self) -> None: name = os.path.join(".", "MOBILE", "ios", "oonimkall.podspec") super().__init__(name, []) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("./make: {}: already built".format(self.name())) return engine.require("cat", "sed") release = engine.backticks(["git", "describe", "--tags"]) version = datetime.datetime.now().strftime("%Y.%m.%d-%H%M%S") engine.cat_sed_redirect( [("@VERSION@", version), ("@RELEASE@", release)], os.path.join(".", "MOBILE", "template.podspec"), self.name(), ) # IOS is the top-level "ios" target. IOS = Phony("ios", [OONIMKAllFrameworkZip(), OONIMKAllPodspec()]) class MiniOONIDarwinOrWindows(BaseTarget): def __init__(self, goos: str, goarch: str): self._ext = ".exe" if goos == "windows" else "" self._os = goos self._arch = goarch name = os.path.join(".", "CLI", goos, goarch, "miniooni" + self._ext) self._ooprivate = OONIProbePrivate() self._gogo = SDKGolangGo() super().__init__(name, [self._ooprivate, self._gogo]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) cmdline = [ "go", "build", "-o", self.name(), "-ldflags=-s -w", ] if options.debugging(): cmdline.append("-x") if options.verbose(): cmdline.append("-v") if not options.disable_embedding_psiphon_config(): cmdline.append("-tags=ooni_psiphon_config") cmdline.append("./internal/cmd/miniooni") with Environ(engine, "GOOS", self._os): with Environ(engine, "GOARCH", self._arch): with Environ(engine, "CGO_ENABLED", "0"): with AugmentedPath(engine, self._gogo.binpath()): engine.require("go") engine.run(cmdline) class MiniOONILinux(BaseTarget): def __init__(self, goarch: str): self._arch = goarch name = os.path.join(".", "CLI", "linux", goarch, "miniooni") self._ooprivate = OONIProbePrivate() self._gogo = SDKGolangGo() super().__init__(name, [self._ooprivate, self._gogo]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) if self._arch == "arm": with Environ(engine, "GOARM", "7"): self._build(engine, options, self._gogo) else: self._build(engine, options, self._gogo) def _build(self, engine: Engine, options: Options, gogo: SDKGolangGo) -> None: cmdline = [ "go", "build", "-o", os.path.join("CLI", "linux", self._arch, "miniooni"), "-ldflags=-s -w -extldflags -static", ] if options.debugging(): cmdline.append("-x") if options.verbose(): cmdline.append("-v") tags = "-tags=netgo" if not options.disable_embedding_psiphon_config(): tags += ",ooni_psiphon_config" cmdline.append(tags) cmdline.append("./internal/cmd/miniooni") with Environ(engine, "GOOS", "linux"): with Environ(engine, "GOARCH", self._arch): with Environ(engine, "CGO_ENABLED", "0"): with AugmentedPath(engine, gogo.binpath()): engine.require("go") engine.run(cmdline) # MINIOONI is the top-level "miniooni" target. MINIOONI = Phony( "miniooni", [ MiniOONIDarwinOrWindows("darwin", "amd64"), MiniOONIDarwinOrWindows("darwin", "arm64"), MiniOONILinux("386"), MiniOONILinux("amd64"), MiniOONILinux("arm"), MiniOONILinux("arm64"), MiniOONIDarwinOrWindows("windows", "386"), MiniOONIDarwinOrWindows("windows", "amd64"), ], ) class OONIProbeLinux(BaseTarget): """OONIProbeLinux builds ooniprobe for Linux.""" # TODO(bassosimone): this works out of the box on macOS and # requires qemu-user-static on Fedora/Debian. I'm not sure what # is the right (set of) command(s) I should be checking for. def __init__(self, goarch: str): self._arch = goarch name = os.path.join(".", "CLI", "linux", goarch, "ooniprobe") self._ooprivate = OONIProbePrivate() super().__init__(name, [self._ooprivate]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build.""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) engine.require("docker") # make sure we have the latest version of the container image engine.run( [ "docker", "pull", "--platform", "linux/{}".format(self._arch), "golang:{}-alpine".format(goversion()), ] ) # then run the build inside the container cmdline = [ "docker", "run", "--platform", "linux/{}".format(self._arch), "-e", "GOARCH={}".format(self._arch), "-v", "{}:/ooni".format(os.getcwd()), "-w", "/ooni", "golang:{}-alpine".format(goversion()), os.path.join(".", "CLI", "linux", "build"), ] if options.debugging(): cmdline.append("-x") if options.verbose(): cmdline.append("-v") if not options.disable_embedding_psiphon_config(): cmdline.append("-tags=ooni_psiphon_config,netgo") else: cmdline.append("-tags=netgo") engine.run(cmdline) class OONIProbeWindows(BaseTarget): """OONIProbeWindows builds ooniprobe for Windows.""" def __init__(self, goarch: str): self._arch = goarch name = os.path.join(".", "CLI", "windows", goarch, "ooniprobe.exe") self._ooprivate = OONIProbePrivate() self._gogo = SDKGolangGo() super().__init__(name, [self._ooprivate, self._gogo]) def _gcc(self) -> str: if self._arch == "amd64": return "x86_64-w64-mingw32-gcc" if self._arch == "386": return "i686-w64-mingw32-gcc" raise NotImplementedError def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) cmdline = [ "go", "build", "-o", self.name(), "-ldflags=-s -w", ] if options.debugging(): cmdline.append("-x") if options.verbose(): cmdline.append("-v") if not options.disable_embedding_psiphon_config(): cmdline.append("-tags=ooni_psiphon_config") cmdline.append("./cmd/ooniprobe") with Environ(engine, "GOOS", "windows"): with Environ(engine, "GOARCH", self._arch): with Environ(engine, "CGO_ENABLED", "1"): with Environ(engine, "CC", self._gcc()): with AugmentedPath(engine, self._gogo.binpath()): engine.require(self._gcc(), "go") engine.run(cmdline) class OONIProbeDarwin(BaseTarget): """OONIProbeDarwin builds ooniprobe for macOS.""" def __init__(self, goarch: str): self._arch = goarch name = os.path.join(".", "CLI", "darwin", goarch, "ooniprobe") self._ooprivate = OONIProbePrivate() self._gogo = SDKGolangGo() super().__init__(name, [self._ooprivate, self._gogo]) def build(self, engine: Engine, options: Options) -> None: """build implements Target.build""" if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) self._ooprivate.copyfiles(engine, options) cmdline = [ "go", "build", "-o", self.name(), "-ldflags=-s -w", ] if options.debugging(): cmdline.append("-x") if options.verbose(): cmdline.append("-v") if not options.disable_embedding_psiphon_config(): cmdline.append("-tags=ooni_psiphon_config") cmdline.append("./cmd/ooniprobe") with Environ(engine, "GOOS", "darwin"): with Environ(engine, "GOARCH", self._arch): with Environ(engine, "CGO_ENABLED", "1"): with AugmentedPath(engine, self._gogo.binpath()): engine.require("gcc", "go") engine.run(cmdline) class Debian(BaseTarget): """Debian makes a debian package for a given architecture.""" def __init__(self, arch: str): name = "debian_{}".format(arch) self._target = OONIProbeLinux(arch) self._arch = arch super().__init__(name, [self._target]) def build(self, engine: Engine, options: Options) -> None: self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) engine.require("docker") # make sure we have the latest version of the container image engine.run( [ "docker", "pull", "--platform", "linux/{}".format(self._arch), "debian:stable", ] ) # then run the build inside the container cmdline: List[str] = [] cmdline.append("docker") cmdline.append("run") cmdline.append("--platform") cmdline.append("linux/{}".format(self._arch)) cmdline.append("-v") cmdline.append("{}:/ooni".format(os.getcwd())) cmdline.append("-w") cmdline.append("/ooni") cmdline.append("debian:stable") cmdline.append(os.path.join(".", "CLI", "linux", "debian")) if os.environ.get("GITHUB_ACTIONS", "") == "true": # When we're running inside a github action, figure out whether # we are building a tag or a commit. In the latter case, we will # append the run number to the version number. github_ref = os.environ.get("GITHUB_REF") if not github_ref: raise RuntimeError("missing GITHUB_REF") github_run_number = os.environ.get("GITHUB_RUN_NUMBER") if not github_run_number: raise RuntimeError("missing GITHUB_RUN_NUMBER") if not github_ref.startswith("/refs/tags/"): cmdline.append(github_run_number) engine.run(cmdline) class Sign(BaseTarget): """Sign signs a specific target artefact.""" def __init__(self, target: Target): self._target = target name = self._target.name() + ".asc" super().__init__(name, [self._target]) def build(self, engine: Engine, options: Options) -> None: if os.path.isfile(self.name()) and not options.dry_run(): log("\n./make: {}: already built".format(self.name())) return self.build_child_targets(engine, options) log("\n./make: building {}...".format(self.name())) sign(engine, self._target.name()) # OONIPROBE_RELEASE_DARWIN contains the release darwin targets OONIPROBE_RELEASE_DARWIN = Phony( "ooniprobe_release_darwin", [ Sign(OONIProbeDarwin("amd64")), Sign(OONIProbeDarwin("arm64")), ], ) # OONIPROBE_RELEASE_LINUX contains the release linux targets OONIPROBE_RELEASE_LINUX = Phony( "ooniprobe_release_linux", [ Sign(OONIProbeLinux("amd64")), Sign(OONIProbeLinux("arm64")), ], ) # OONIPROBE_RELEASE_WINDOWS contains the release windows targets OONIPROBE_RELEASE_WINDOWS = Phony( "ooniprobe_release_windows", [ Sign(OONIProbeWindows("amd64")), Sign(OONIProbeWindows("386")), ], ) # DEBIAN is the top-level "debian" target. DEBIAN = Phony( "debian", [ Debian("arm64"), Debian("amd64"), ], ) # TOP_TARGETS contains the top-level targets TOP_TARGETS: List[Target] = [ ANDROID, IOS, MINIOONI, OONIPROBE_RELEASE_DARWIN, OONIPROBE_RELEASE_LINUX, OONIPROBE_RELEASE_WINDOWS, DEBIAN, ] def expand_targets(targets: List[Target]) -> Dict[str, Target]: """expand_targets creates a dictionary mapping every existing target name to its implementation.""" out: Dict[str, Target] = {} for tgt in targets: out.update(expand_targets(tgt.deps())) out[tgt.name()] = tgt return out def main() -> None: """main function""" alltargets = expand_targets(TOP_TARGETS) options = ConfigFromCLI.parse(list(alltargets.keys()), TOP_TARGETS) engine = new_engine(options) # note that we check whether the target is known in parse() selected = alltargets[options.target()] selected.build(engine, options) if __name__ == "__main__": main()