halkyon/tool/src/images.dart

124 lines
4.7 KiB
Dart
Raw Normal View History

// The images built from the workspace, as tool/check.dart replays their
// resolution: pub refuses in an image what it accepts on a laptop with Flutter
// (the deploys of 4 Nov 2026: overrides of Flutter plugins, exit 69), and only
// the deploy builds the images, on the server, after every check has passed.
import 'package:path/path.dart' as p;
/// The Dockerfiles whose build stage resolves the workspace with Dart's own
/// image (`FROM dart:<version> AS build`), built by the deploy.
const dartImages = [
'apps/learn/halkyon_learn_server/Dockerfile',
'apps/learn/halkyon_learn_curator/Dockerfile',
'apps/train/halkyon_train_server/Dockerfile',
'apps/todo/halkyon_todo_server/Dockerfile',
];
/// One instruction of a Dockerfile's build stage that the replay follows.
sealed class const BuildStep();
/// `COPY <sources…> <destination>`, from the build's context.
final class const CopyStep(final List<String> sources, final String destination)
extends BuildStep;
/// `RUN <command>`, through `sh -c`.
final class const RunStep(final String command) extends BuildStep;
/// `WORKDIR <path>`.
final class const WorkdirStep(final String path) extends BuildStep;
/// The resolution of an image: the Dart its build stage runs on, and the
/// stage's steps up to and including `RUN dart pub get`.
typedef ImageResolution = ({String dartVersion, List<BuildStep> steps});
final _from = RegExp(r'^FROM dart:(\S+) AS build$');
/// The build stage of [dockerfile] (its text) up to and including its
/// `RUN dart pub get`. Throws a [FormatException] where it has no such stage,
/// or an instruction the replay doesn't know comes before `pub get`, so that
/// a changed Dockerfile fails the check rather than being replayed wrongly.
ImageResolution imageResolution(String dockerfile) {
String? version;
final steps = <BuildStep>[];
for (final line in _instructions(dockerfile)) {
if (version == null) {
version = _from.firstMatch(line)?[1];
continue;
}
final [instruction, ...words] = line.split(RegExp(r'\s+'));
final argument = line.substring(instruction.length).trim();
switch (instruction) {
case 'COPY' when words.length >= 2 && !argument.startsWith('--'):
steps.add(CopyStep(words.sublist(0, words.length - 1), words.last));
case 'RUN':
steps.add(RunStep(argument));
if (argument == 'dart pub get') {
return (dartVersion: version, steps: steps);
}
case 'WORKDIR':
steps.add(WorkdirStep(argument));
default:
throw FormatException(
"The replay of an image's resolution doesn't know: $line",
);
}
}
throw FormatException(
version == null
? 'No build stage FROM dart:<version> AS build'
: 'No RUN dart pub get in the build stage',
);
}
/// The Dockerfile's instructions, comments and blank lines left out, lines
/// continued with a backslash joined.
Iterable<String> _instructions(String dockerfile) sync* {
var pending = '';
for (final line in dockerfile.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty || trimmed.startsWith('#')) {
continue;
}
if (trimmed.endsWith(r'\')) {
pending += '${trimmed.substring(0, trimmed.length - 1).trim()} ';
continue;
}
yield pending + trimmed;
pending = '';
}
}
/// Where [step] puts each of [files] (the repository's files, paths relative
/// to the build's context), relative to [workdir], as Docker's `COPY` does:
/// a folder's content into the destination, a file into it where it ends in
/// a slash or several sources are named, else as the destination itself.
Map<String, String> copiedFiles(
CopyStep step,
Iterable<String> files, {
String workdir = '/app',
}) {
final destination = p.posix.normalize(
p.posix.join(workdir, step.destination),
);
final intoFolder =
step.destination.endsWith('/') ||
step.destination == '.' ||
step.sources.length > 1;
return {
for (final source in step.sources.map(p.posix.normalize))
for (final file in files)
if (file == source)
file: intoFolder
? p.posix.join(destination, p.posix.basename(file))
: destination
else if (p.posix.isWithin(source, file))
file: p.posix.join(destination, p.posix.relative(file, from: source)),
};
}
/// The name of the Dart SDK's archive for this machine, as Google's archive
/// names it (`dartsdk-linux-x64-release.zip`); [version] is
/// `Platform.version`, and [os] `Platform.operatingSystem`.
String dartSdkArchive(String os, String version) {
final arch = RegExp(r'"\w+_(\w+)"').firstMatch(version)?[1] ?? 'x64';
return 'dartsdk-${os == 'macos' ? 'macos' : 'linux'}-$arch-release.zip';
}