2026-10-26 10:37:42 +01:00
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
|
|
|
|
|
|
/// The images CI's checks never build: the deploy does, on the server, and
|
|
|
|
|
/// a package missing from one failed it there (26 Oct 2026, `halkyon_core`
|
|
|
|
|
/// kept out by `.dockerignore`, `halkyon_llm` never copied).
|
|
|
|
|
const _dockerfiles = [
|
|
|
|
|
'apps/learn/halkyon_learn_server/Dockerfile',
|
|
|
|
|
'apps/learn/halkyon_learn_curator/Dockerfile',
|
2026-10-29 16:04:05 +01:00
|
|
|
'apps/train/halkyon_train_server/Dockerfile',
|
2026-10-26 10:37:42 +01:00
|
|
|
];
|
|
|
|
|
|
2026-10-29 16:04:05 +01:00
|
|
|
/// The sidecars' images, which copy single files of the repository's: kept
|
|
|
|
|
/// out of the build's context, they failed it (29 Oct 2026, Train's running
|
|
|
|
|
/// profile, `apps/train/` left out whole).
|
|
|
|
|
const _sidecars = ['deploy/brouter/Dockerfile', 'deploy/maps/Dockerfile'];
|
|
|
|
|
|
2026-10-26 10:37:42 +01:00
|
|
|
/// The packages [package] takes by path, and theirs, with itself: pub
|
|
|
|
|
/// resolves every workspace member's dev dependencies too, so an image
|
|
|
|
|
/// needs those as well.
|
|
|
|
|
Set<String> _needed(String package) {
|
|
|
|
|
final needed = <String>{};
|
|
|
|
|
final waiting = [package];
|
|
|
|
|
while (waiting.isNotEmpty) {
|
|
|
|
|
final each = waiting.removeLast();
|
|
|
|
|
if (!needed.add(each)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
final pubspec =
|
|
|
|
|
loadYaml(File('$each/pubspec.yaml').readAsStringSync()) as YamlMap;
|
|
|
|
|
for (final section in ['dependencies', 'dev_dependencies']) {
|
|
|
|
|
final dependencies = pubspec[section] as YamlMap? ?? YamlMap();
|
|
|
|
|
for (final dependency in dependencies.values) {
|
|
|
|
|
if (dependency case {'path': final String path}) {
|
|
|
|
|
waiting.add(p.normalize(p.join(each, path)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return needed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The folders the image's build stage copies in as they are named.
|
|
|
|
|
Set<String> _copied(String dockerfile) => {
|
|
|
|
|
for (final match in RegExp(
|
|
|
|
|
r'^COPY ((?:apps|packages)/\S+) \1$',
|
|
|
|
|
multiLine: true,
|
|
|
|
|
).allMatches(dockerfile))
|
|
|
|
|
match[1]!,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// The members `tool/workspace_only.sh` keeps in the image's workspace.
|
|
|
|
|
List<String> _members(String dockerfile) => RegExp(
|
|
|
|
|
r'^RUN sh tool/workspace_only\.sh (.+)$',
|
|
|
|
|
multiLine: true,
|
|
|
|
|
).firstMatch(dockerfile)![1]!.split(' ');
|
|
|
|
|
|
|
|
|
|
/// The lines of `.dockerignore` that name a folder, without the slash.
|
|
|
|
|
Set<String> _ignored() => {
|
|
|
|
|
for (final line in File('.dockerignore').readAsLinesSync())
|
|
|
|
|
if (line.trim() case final folder
|
|
|
|
|
when folder.isNotEmpty && !folder.startsWith('#'))
|
|
|
|
|
folder.replaceFirst(RegExp(r'/$'), ''),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
for (final path in _dockerfiles) {
|
|
|
|
|
group(path, () {
|
|
|
|
|
final dockerfile = File(path).readAsStringSync();
|
|
|
|
|
final package = _members(dockerfile).first;
|
|
|
|
|
|
|
|
|
|
test('builds the package it lies in', () {
|
|
|
|
|
expect(package, p.dirname(path));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('copies every package the build resolves, and keeps each in the '
|
|
|
|
|
'workspace', () {
|
|
|
|
|
final needed = _needed(package);
|
|
|
|
|
expect(_copied(dockerfile), needed);
|
|
|
|
|
expect(_members(dockerfile).toSet(), needed);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("keeps none of them out of the build's context", () {
|
|
|
|
|
final ignored = _ignored();
|
|
|
|
|
for (final each in _needed(package)) {
|
|
|
|
|
expect(
|
|
|
|
|
ignored.where(
|
|
|
|
|
(folder) => each == folder || p.isWithin(folder, each),
|
|
|
|
|
),
|
|
|
|
|
isEmpty,
|
|
|
|
|
reason: '.dockerignore leaves $each out',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-29 16:04:05 +01:00
|
|
|
|
|
|
|
|
for (final path in _sidecars) {
|
|
|
|
|
test("$path copies files that are in the build's context", () {
|
|
|
|
|
final ignored = _ignored();
|
|
|
|
|
final sources = [
|
|
|
|
|
for (final match in RegExp(
|
|
|
|
|
r'^COPY (?:--\S+ )*(\S+) \S+$',
|
|
|
|
|
multiLine: true,
|
|
|
|
|
).allMatches(File(path).readAsStringSync()))
|
|
|
|
|
match[1]!,
|
|
|
|
|
];
|
|
|
|
|
expect(sources, isNotEmpty);
|
|
|
|
|
for (final source in sources) {
|
|
|
|
|
expect(File(source).existsSync(), isTrue, reason: source);
|
|
|
|
|
expect(
|
|
|
|
|
ignored.where(
|
|
|
|
|
(folder) => source == folder || p.isWithin(folder, source),
|
|
|
|
|
),
|
|
|
|
|
isEmpty,
|
|
|
|
|
reason: '.dockerignore leaves $source out',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-26 10:37:42 +01:00
|
|
|
}
|