blob: 935e3ac90bad7629150df122d67917b05823cebf [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:meta/meta.dart';
// This library parses Engine build config data out of the "Engine v2" build
// config JSON files with the format described at:
// https://github.com/flutter/engine/blob/main/ci/builders/README.md
/// Base class for all nodes in the build config.
sealed class BuildConfigBase {
BuildConfigBase(this.errors);
/// Accumulated errors. Non-null and non-empty when a node is invalid.
final List<String>? errors;
/// Whether there were errors when loading the data for this node.
late final bool valid = errors == null;
/// Returns an empty list when the object is valid, and errors when it is not.
/// Subclasses with more data to check for validity should override this
/// method and add `super.check(path)` to the returned list.
@mustCallSuper
List<String> check(String path) {
if (valid) {
return <String>[];
}
return errors!.map((String s) => '$path: $s').toList();
}
}
/// The build configuration is a json file containing a list of builds, tests,
/// generators and archives.
///
/// Each build config file contains a top-level json map with the following
/// fields:
/// {
/// "builds": [],
/// "tests": [],
/// "generators": {
/// "tasks": []
/// },
/// "archives": []
/// }
final class BuildConfig extends BuildConfigBase {
/// Load build configuration data into an instance of this class.
///
/// [path] should be the file system path to the file that the JSON data comes
/// from. [map] must be the JSON data returned by e.g. `JsonDecoder.convert`.
factory BuildConfig.fromJson({
required String path,
required Map<String, Object?> map,
}) {
final List<String> errors = <String>[];
// Parse the "builds" field.
final List<GlobalBuild>? builds = objListOfJson<GlobalBuild>(
map, 'builds', errors, GlobalBuild.fromJson,
);
// Parse the "tests" field.
final List<GlobalTest>? tests = objListOfJson<GlobalTest>(
map, 'tests', errors, GlobalTest.fromJson,
);
// Parse the "generators" field.
final List<TestTask>? generators;
if (map['generators'] == null) {
generators = <TestTask>[];
} else if (map['generators'] is! Map<String, Object?>) {
appendTypeError(map, 'generators', 'map', errors);
generators = null;
} else {
generators = objListOfJson(
map['generators']! as Map<String, Object?>,
'tasks',
errors,
TestTask.fromJson,
);
}
// Parse the "archives" field.
final List<GlobalArchive>? archives = objListOfJson<GlobalArchive>(
map, 'archives', errors, GlobalArchive.fromJson,
);
if (builds == null ||
tests == null ||
generators == null ||
archives == null) {
return BuildConfig._invalid(path, errors);
}
return BuildConfig._(path, builds, tests, generators, archives);
}
BuildConfig._(
this.path,
this.builds,
this.tests,
this.generators,
this.archives,
) : super(null);
BuildConfig._invalid(this.path, super.errors) :
builds = <GlobalBuild>[],
tests = <GlobalTest>[],
generators = <TestTask>[],
archives = <GlobalArchive>[];
/// The path to the JSON file.
final String path;
/// A list of independent builds that have no dependencies among them. They
/// can run in parallel if need be.
final List<GlobalBuild> builds;
/// A list of tests. The tests may have dependencies on one or more of the
/// builds.
final List<GlobalTest> tests;
/// A list of generator tasks that produce additional artifacts, which may
/// depend on the output of one or more builds.
final List<TestTask> generators;
/// A description of the upload instructions for the artifacts produced by
/// the global generators.
final List<GlobalArchive> archives;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
for (int i = 0; i < builds.length; i++) {
final GlobalBuild build = builds[i];
errors.addAll(build.check('$path/builds[$i]'));
}
for (int i = 0; i < tests.length; i++) {
final GlobalTest test = tests[i];
errors.addAll(test.check('$path/tests[$i]'));
}
for (int i = 0; i < generators.length; i++) {
final TestTask task = generators[i];
errors.addAll(task.check('$path/generators/tasks[$i]'));
}
for (int i = 0; i < archives.length; i++) {
final GlobalArchive archive = archives[i];
errors.addAll(archive.check('$path/archives[$i]'));
}
return errors;
}
}
/// A "build" is a dictionary with a gn command, a ninja command, zero or more
/// generator commands, zero or more local tests, zero or more local generators
/// and zero or more output artifacts.
///
/// "builds" contains a list of maps with fields like:
/// {
/// "name": "",
/// "gn": [""],
/// "ninja": {},
/// "tests": [],
/// "generators": {
/// "tasks": []
/// }, (optional)
/// "archives": [],
/// "drone_dimensions": [""],
/// "gclient_variables": {}
/// }
final class GlobalBuild extends BuildConfigBase {
factory GlobalBuild.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final List<String>? gn = stringListOfJson(map, 'gn', errors);
final List<BuildTest>? tests = objListOfJson(
map, 'tests', errors, BuildTest.fromJson,
);
final List<BuildArchive>? archives = objListOfJson(
map, 'archives', errors, BuildArchive.fromJson,
);
final List<String>? droneDimensions = stringListOfJson(
map, 'drone_dimensions', errors,
);
final BuildNinja? ninja;
if (map['ninja'] == null) {
ninja = BuildNinja.nop();
} else if (map['ninja'] is! Map<String, Object?>) {
ninja = null;
} else {
ninja = BuildNinja.fromJson(map['ninja']! as Map<String, Object?>);
}
if (ninja == null) {
appendTypeError(map, 'ninja', 'map', errors);
}
final List<BuildTask>? generators;
if (map['generators'] == null) {
generators = <BuildTask>[];
} else if (map['generators'] is! Map<String, Object?>) {
appendTypeError(map, 'generators', 'map', errors);
generators = null;
} else {
generators = objListOfJson(
map['generators']! as Map<String, Object?>,
'tasks',
errors,
BuildTask.fromJson,
);
}
final Map<String, Object?>? gclientVariables;
if (map['gclient_variables'] == null) {
gclientVariables = <String, Object?>{};
} else if (map['gclient_variables'] is! Map<String, Object?>) {
gclientVariables = null;
} else {
gclientVariables = map['gclient_variables']! as Map<String, Object?>;
}
if (gclientVariables == null) {
appendTypeError(map, 'gclient_variables', 'map', errors);
}
if (name == null ||
gn == null ||
ninja == null ||
archives == null ||
tests == null ||
generators == null ||
droneDimensions == null ||
gclientVariables == null) {
return GlobalBuild._invalid(errors);
}
return GlobalBuild._(
name, gn, ninja, tests, generators, archives, droneDimensions,
gclientVariables,
);
}
GlobalBuild._(
this.name,
this.gn,
this.ninja,
this.tests,
this.generators,
this.archives,
this.droneDimensions,
this.gclientVariables,
) : super(null);
GlobalBuild._invalid(super.errors) :
name = '',
gn = <String>[],
ninja = BuildNinja.nop(),
tests = <BuildTest>[],
generators = <BuildTask>[],
archives = <BuildArchive>[],
droneDimensions = <String>[],
gclientVariables = <String, Object?>{};
/// The name of the build which may also be used to reference it as a
/// depdendency of a global test.
final String name;
/// The parameters to pass to `flutter/tools/gn` to configure the build.
final List<String> gn;
/// The data to form the ninja command to perform the build.
final BuildNinja ninja;
/// The list of tests that can be run after the ninja build is finished.
final List<BuildTest> tests;
/// A list of other tasks that may generate new artifacts after the ninja
/// build is finished.
final List<BuildTask> generators;
/// Upload instructions for the artifacts produced by the build.
final List<BuildArchive> archives;
/// A list 'key=value' strings that are used to select the bot where this
/// build will be running.
final List<String> droneDimensions;
/// A dictionary with variables included in the `custom_vars` section of the
/// .gclient file before `gclient sync` is run.
final Map<String, Object?> gclientVariables;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
errors.addAll(ninja.check('$path/ninja'));
for (int i = 0; i < tests.length; i++) {
final BuildTest test = tests[i];
errors.addAll(test.check('$path/tests[$i]'));
}
for (int i = 0; i < generators.length; i++) {
final BuildTask task = generators[i];
errors.addAll(task.check('$path/generators/tasks[$i]'));
}
for (int i = 0; i < archives.length; i++) {
final BuildArchive archive = archives[i];
errors.addAll(archive.check('$path/archives[$i]'));
}
return errors;
}
}
/// "builds" -> "ninja" contains a map with fields like:
/// {
/// "config": "",
/// "targets": [""]
/// },
final class BuildNinja extends BuildConfigBase {
factory BuildNinja.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? config = stringOfJson(map, 'config', errors);
final List<String>? targets = stringListOfJson(map, 'targets', errors);
if (config == null || targets == null) {
return BuildNinja._invalid(errors);
}
return BuildNinja._(config, targets);
}
BuildNinja._(this.config, this.targets) : super(null);
BuildNinja._invalid(super.errors) :
config = '',
targets = <String>[];
BuildNinja.nop() :
config = '',
targets = <String>[],
super(null);
/// The name of the configuration created by gn.
///
/// This is also the subdirectory of the `out/` directory where the build
/// output will go.
final String config;
/// The ninja targets to build.
final List<String> targets;
}
/// "builds" -> "tests" contains a list of maps with fields like:
/// {
/// "language": "",
/// "name": "",
/// "parameters": [""],
/// "script": "",
/// "contexts": [""]
/// }
final class BuildTest extends BuildConfigBase {
factory BuildTest.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final String? script = stringOfJson(map, 'script', errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
final List<String>? contexts = stringListOfJson(
map, 'contexts', errors,
);
if (name == null ||
language == null ||
script == null ||
parameters == null ||
contexts == null) {
return BuildTest._invalid(errors);
}
return BuildTest._(name, language, script, parameters, contexts);
}
BuildTest._(
this.name,
this.language,
this.script,
this.parameters,
this.contexts,
) : super(null);
BuildTest._invalid(super.errors) :
name = '',
language = '',
script = '',
parameters = <String>[],
contexts = <String>[];
/// The human readable description of the test.
final String name;
/// The executable used to run the script.
final String language;
/// The path to the script to execute relative to the checkout directory.
final String script;
/// Flags or parameters passed to the script.
///
/// Parameters accept magic environment variables (placeholders replaced
/// before executing the test). Magic environment variables have the following
/// limitations: only ${FLUTTER_LOGS_DIR} is currently supported and it needs
/// to be used alone within the parameter string(e.g. ["${FLUTTER_LOGS_DIR}"]
/// is OK but ["path=${FLUTTER_LOGS_DIR}"] is not).
final List<String> parameters;
/// A list of available contexts to add to the text execution step.
///
/// Two contexts are supported: "android_virtual_device" and
/// "metric_center_token".
final List<String> contexts;
}
/// "builds" -> "generators" is a map containing a single property "tasks",
/// which is a list of maps with fields like:
/// {
/// "name": "",
/// "parameters": [""],
/// "scripts": [""],
/// "language": ""
/// }
///
/// The semantics of this task are that each script in the list of scripts is
/// run in sequence by appending the same parameter list to each one.
final class BuildTask extends BuildConfigBase {
factory BuildTask.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final List<String>? scripts = stringListOfJson(map, 'scripts', errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
if (name == null ||
language == null ||
scripts == null ||
parameters == null) {
return BuildTask._invalid(errors);
}
return BuildTask._(name, language, scripts, parameters);
}
BuildTask._invalid(super.errors) :
name = '',
language = '',
scripts = <String>[],
parameters = <String>[];
BuildTask._(this.name, this.language, this.scripts, this.parameters) :
super(null);
/// The human readable name of the step running the script.
final String name;
/// The script language executable to run the script. If empty it is assumed
/// to be bash.
final String language;
/// A list of paths of scripts relative to the checkout directory. Each
/// script is run in turn by appending the list of parameters to it.
final List<String> scripts;
/// The flags passed to the script. Paths referenced in the list are relative
/// to the checkout directory.
final List<String> parameters;
}
/// "builds" -> "archives" contains a list of maps with fields like:
/// {
/// "name": "",
/// "base_path": "",
/// "type": "",
/// "include_paths": [""],
/// "realm": ""
/// }
final class BuildArchive extends BuildConfigBase {
factory BuildArchive.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? type = stringOfJson(map, 'type', errors);
final String? basePath = stringOfJson(map, 'base_path', errors);
final String? realm = stringOfJson(map, 'realm', errors);
final List<String>? includePaths = stringListOfJson(
map, 'include_paths', errors,
);
if (name == null ||
type == null ||
basePath == null ||
realm == null ||
includePaths == null) {
return BuildArchive._invalid(errors);
}
return BuildArchive._(name, type, basePath, realm, includePaths);
}
BuildArchive._invalid(super.error) :
name = '',
type = '',
basePath = '',
realm = '',
includePaths = <String>[];
BuildArchive._(
this.name,
this.type,
this.basePath,
this.realm,
this.includePaths,
) : super(null);
/// The name which may be referenced later as a dependency of global tests.
final String name;
/// The type of storage to use. Currently only “gcs” and “cas” are supported.
final String type;
/// The portion of the path to remove from the full path before uploading
final String basePath;
/// Either "production" or "experimental".
final String realm;
/// A list of strings with the paths to be uploaded to a given destination.
final List<String> includePaths;
}
/// Global "tests" is a list of maps containing fields like:
/// {
/// "name": "",
/// "recipe": "",
/// "drone_dimensions": [""],
/// "dependencies": [""],
/// "tasks": [] (same format as above)
/// }
final class GlobalTest extends BuildConfigBase {
factory GlobalTest.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? recipe = stringOfJson(map, 'recipe', errors);
final List<String>? droneDimensions = stringListOfJson(
map, 'drone_dimensions', errors,
);
final List<String>? dependencies = stringListOfJson(
map, 'dependencies', errors,
);
final List<TestDependency>? testDependencies = objListOfJson(
map, 'test_dependencies', errors, TestDependency.fromJson,
);
final List<TestTask>? tasks = objListOfJson(
map, 'tasks', errors, TestTask.fromJson,
);
if (name == null ||
recipe == null ||
droneDimensions == null ||
dependencies == null ||
testDependencies == null ||
tasks == null) {
return GlobalTest._invalid(errors);
}
return GlobalTest._(
name, recipe, droneDimensions, dependencies, testDependencies, tasks);
}
GlobalTest._invalid(super.errors) :
name = '',
recipe = '',
droneDimensions = <String>[],
dependencies = <String>[],
testDependencies = <TestDependency>[],
tasks = <TestTask>[];
GlobalTest._(
this.name,
this.recipe,
this.droneDimensions,
this.dependencies,
this.testDependencies,
this.tasks,
) : super(null);
/// The name that will be assigned to the sub-build.
final String name;
/// The recipe name to use if different than tester.
final String recipe;
/// A list of strings with key values to select the bot where the test will
/// run.
final List<String> droneDimensions;
/// A list of build outputs required by the test.
final List<String> dependencies;
/// A list of dependencies required for the test to run.
final List<TestDependency> testDependencies;
/// A list of dictionaries representing scripts and parameters to run them
final List<TestTask> tasks;
@override
List<String> check(String path) {
final List<String> errors = <String>[];
errors.addAll(super.check(path));
for (int i = 0; i < testDependencies.length; i++) {
final TestDependency testDependency = testDependencies[i];
errors.addAll(testDependency.check('$path/test_dependencies[$i]'));
}
for (int i = 0; i < tasks.length; i++) {
final TestTask task = tasks[i];
errors.addAll(task.check('$path/tasks[$i]'));
}
return errors;
}
}
/// A test dependency for a global test has fields like:
/// {
/// "dependency": "",
/// "version": ""
/// }
final class TestDependency extends BuildConfigBase {
factory TestDependency.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? dependency = stringOfJson(map, 'dependency', errors);
final String? version = stringOfJson(map, 'version', errors);
if (dependency == null || version == null) {
return TestDependency._invalid(errors);
}
return TestDependency._(dependency, version);
}
TestDependency._invalid(super.error) : dependency = '', version = '';
TestDependency._(this.dependency, this.version) : super(null);
/// A dependency from the list at:
/// https://flutter.googlesource.com/recipes/+/refs/heads/main/recipe_modules/flutter_deps/api.py#75
final String dependency;
/// The CIPD version string of the dependency.
final String version;
}
/// Task for a global generator and a global test.
/// {
/// "name": "",
/// "parameters": [""],
/// "script": "",
/// "language": ""
/// }
final class TestTask extends BuildConfigBase {
factory TestTask.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? name = stringOfJson(map, 'name', errors);
final String? language = stringOfJson(map, 'language', errors);
final String? script = stringOfJson(map, 'script', errors);
final int? maxAttempts = intOfJson(map, 'max_attempts', fallback: 1, errors);
final List<String>? parameters = stringListOfJson(
map, 'parameters', errors,
);
if (name == null ||
language == null ||
script == null ||
maxAttempts == null ||
parameters == null) {
return TestTask._invalid(errors);
}
return TestTask._(name, language, script, maxAttempts, parameters);
}
TestTask._invalid(super.error) :
name = '',
language = '',
script = '',
maxAttempts = 0,
parameters = <String>[];
TestTask._(
this.name,
this.language,
this.script,
this.maxAttempts,
this.parameters,
) : super(null);
/// The human readable name of the step running the script.
final String name;
/// The script language executable to run the script. If empty it is assumed
/// to be bash.
final String language;
/// The script path relative to the checkout repository.
final String script;
/// The maximum number of failures to tolerate. The default is 1.
final int maxAttempts;
/// The flags passed to the script. Paths referenced in the list are relative
/// to the checkout directory.
final List<String> parameters;
}
/// The objects that populate the list of global archives have fields like:
/// {
/// "source": "out/debug/artifacts.zip",
/// "destination": "ios/artifacts.zip",
/// "realm": "production"
/// },
final class GlobalArchive extends BuildConfigBase {
factory GlobalArchive.fromJson(Map<String, Object?> map) {
final List<String> errors = <String>[];
final String? source = stringOfJson(map, 'source', errors);
final String? destination = stringOfJson(map, 'destination', errors);
final String? realm = stringOfJson(map, 'realm', errors);
if (source == null ||
destination == null ||
realm == null) {
return GlobalArchive._invalid(errors);
}
return GlobalArchive._(source, destination, realm);
}
GlobalArchive._invalid(super.error) :
source = '',
destination = '',
realm = '';
GlobalArchive._(this.source, this.destination, this.realm) :
super(null);
/// The path of the artifact relative to the engine checkout.
final String source;
/// The destination folder in the storage bucket.
final String destination;
/// Which storage bucket the destination path is relative to.
/// Either "production" or "experimental".
final String realm;
}
void appendTypeError(
Map<String, Object?> map,
String field,
String expected,
List<String> errors, {
Object? element,
}) {
if (element == null) {
final Type actual = map[field]!.runtimeType;
errors.add(
'For field "$field", expected type: $expected, actual type: $actual.',
);
} else {
final Type actual = element.runtimeType;
errors.add(
'For element "$element" of "$field", '
'expected type: $expected, actual type: $actual',
);
}
}
List<T>? objListOfJson<T>(
Map<String, Object?> map,
String field,
List<String> errors,
T Function(Map<String, Object?>) fn,
) {
if (map[field] == null) {
return <T>[];
}
if (map[field]! is! List<Object?>) {
appendTypeError(map, field, 'list', errors);
return null;
}
for (final Object? obj in map[field]! as List<Object?>) {
if (obj is! Map<String, Object?>) {
appendTypeError(map, field, 'map', errors);
return null;
}
}
return (map[field]! as List<Object?>)
.cast<Map<String, Object?>>().map<T>(fn).toList();
}
List<String>? stringListOfJson(
Map<String, Object?> map,
String field,
List<String> errors,
) {
if (map[field] == null) {
return <String>[];
}
if (map[field]! is! List<Object?>) {
appendTypeError(map, field, 'list', errors);
return null;
}
for (final Object? obj in map[field]! as List<Object?>) {
if (obj is! String) {
appendTypeError(map, field, element: obj, 'string', errors);
return null;
}
}
return (map[field]! as List<Object?>).cast<String>();
}
String? stringOfJson(
Map<String, Object?> map,
String field,
List<String> errors,
) {
if (map[field] == null) {
return '<undef>';
}
if (map[field]! is! String) {
appendTypeError(map, field, 'string', errors);
return null;
}
return map[field]! as String;
}
int? intOfJson(
Map<String, Object?> map,
String field,
List<String> errors, {
int fallback = 0,
}) {
if (map[field] == null) {
return fallback;
}
if (map[field]! is! int) {
appendTypeError(map, field, 'int', errors);
return null;
}
return map[field]! as int;
}