Line data Source code
1 : import 'dart:convert';
2 : import 'dart:io';
3 :
4 : import 'package:testainers/src/testainers_config.dart';
5 : import 'package:testainers/src/testainers_exception.dart';
6 : import 'package:testainers/src/testainers_network.dart';
7 : import 'package:testainers/src/testainers_utils.dart';
8 :
9 : class Testainers {
10 : final String image;
11 : final String tag;
12 : final Map<int, int> ports;
13 : final Map<String, String> env;
14 : final bool detached;
15 : final bool remove;
16 : final List<String> links;
17 : final List<TestainersNetwork> networks;
18 : final String healthCmd;
19 : final int healthInterval;
20 : final int healthRetries;
21 : final int healthTimeout;
22 : final int healthStartPeriod;
23 : final bool noHealthcheck;
24 : final int stopTime;
25 : final List<String> command;
26 : final TestainersConfig config;
27 : final String name;
28 : String? id;
29 :
30 9 : Testainers({
31 : required this.image,
32 : required this.tag,
33 : required this.ports,
34 : required this.env,
35 : required this.detached,
36 : required this.remove,
37 : this.links = const <String>[],
38 : this.networks = const <TestainersNetwork>[],
39 : this.healthCmd = '',
40 : this.healthInterval = 0,
41 : this.healthRetries = 0,
42 : this.healthTimeout = 0,
43 : this.healthStartPeriod = 0,
44 : this.noHealthcheck = false,
45 : this.stopTime = 0,
46 : this.command = const <String>[],
47 : this.config = const TestainersConfig(),
48 : String? name,
49 9 : }) : name = name ?? TestainersUtils.generateName();
50 :
51 2 : Future<Map<int, int>> portsFilter(Map<int, int> ports) async => ports;
52 :
53 6 : Future<Map<String, String>> envFilter(Map<String, String> env) async => env;
54 :
55 9 : Future<List<String>> linksFilter(List<String> links) async => links;
56 :
57 9 : Future<List<TestainersNetwork>> networksFilter(
58 : List<TestainersNetwork> networks,
59 : ) async =>
60 : networks;
61 :
62 9 : Future<List<String>> commandFilter(List<String> command) async => command;
63 :
64 9 : Future<String> start({Duration? bootSleep}) async {
65 27 : if (config.runner.isEmpty) {
66 1 : throw TestainersException('Runner not defined.');
67 : }
68 :
69 27 : if (config.defaultUsername.isEmpty) {
70 1 : throw TestainersException('Default username not defined.');
71 : }
72 :
73 27 : if (config.defaultPassword.isEmpty) {
74 1 : throw TestainersException('Default password not defined.');
75 : }
76 :
77 18 : if (image.isEmpty) {
78 1 : throw TestainersException('Image not defined.');
79 : }
80 :
81 18 : if (tag.isEmpty) {
82 1 : throw TestainersException('Tag not defined.');
83 : }
84 :
85 18 : await config.runnerVersion();
86 :
87 9 : final List<String> arguments = <String>[
88 : 'run',
89 18 : if (detached) '-d',
90 18 : if (remove) '--rm',
91 9 : '--name',
92 9 : name,
93 : ];
94 :
95 18 : final Map<int, int> effectivePorts = await portsFilter(ports);
96 :
97 17 : for (final MapEntry<int, int> entry in effectivePorts.entries) {
98 : /// Zero is a docker random port.
99 16 : if (entry.key <= 0 ||
100 16 : entry.key > 65535 ||
101 16 : entry.value <= 0 ||
102 16 : entry.value > 65535) {
103 1 : throw TestainersException(
104 3 : 'Invalid port ${entry.key}:${entry.value}.',
105 : );
106 : }
107 :
108 : arguments
109 8 : ..add('-p')
110 32 : ..add('${entry.key}:${entry.value}');
111 : }
112 :
113 18 : final Map<String, String> effectiveEnv = await envFilter(env);
114 :
115 12 : for (final MapEntry<String, String> entry in effectiveEnv.entries) {
116 : arguments
117 3 : ..add('-e')
118 12 : ..add('${entry.key}=${entry.value}');
119 : }
120 :
121 27 : for (final String link in await linksFilter(links)) {
122 0 : if (link.isNotEmpty) {
123 : arguments
124 0 : ..add('--link')
125 0 : ..add(link);
126 : }
127 : }
128 :
129 27 : for (final TestainersNetwork network in await networksFilter(networks)) {
130 : arguments
131 0 : ..add('--network')
132 0 : ..add(network.name);
133 : }
134 :
135 22 : if (healthCmd.isNotEmpty && !noHealthcheck) {
136 9 : arguments.add('--health-cmd=$healthCmd');
137 :
138 6 : if (healthInterval > 0) {
139 9 : arguments.add('--health-interval=${healthInterval}s');
140 : }
141 :
142 6 : if (healthRetries > 0) {
143 9 : arguments.add('--health-retries=$healthRetries');
144 : }
145 :
146 6 : if (healthTimeout > 0) {
147 9 : arguments.add('--health-timeout=${healthTimeout}s');
148 : }
149 :
150 6 : if (healthStartPeriod > 0) {
151 9 : arguments.add('--health-start-period=${healthStartPeriod}s');
152 : }
153 : }
154 :
155 9 : if (noHealthcheck) {
156 1 : arguments.add('--no-healthcheck');
157 : }
158 :
159 36 : arguments.add('$image:$tag');
160 :
161 28 : for (final String command in await commandFilter(this.command)) {
162 1 : arguments.add(command);
163 : }
164 :
165 27 : id = await config.exec(
166 : arguments: arguments,
167 36 : exceptionExec: 'Container $name with image $image:$tag not started.',
168 : );
169 :
170 22 : if (healthCmd.isEmpty || noHealthcheck) {
171 16 : await Future<void>.delayed(bootSleep ?? config.bootSleep);
172 : } else {
173 : late Map<dynamic, dynamic> inspectMap;
174 :
175 : String healthStatus = 'starting';
176 :
177 3 : while (healthStatus == 'starting') {
178 3 : await Future<void>.delayed(const Duration(seconds: 1));
179 :
180 6 : final String inspectString = await config.exec(
181 3 : arguments: <String>[
182 : 'inspect',
183 : "--format='{{json .State.Health}}'",
184 3 : name,
185 : ],
186 12 : exceptionExec: 'Container $name with image $image:$tag '
187 : 'could not be inspected.',
188 : );
189 :
190 : inspectMap =
191 12 : jsonDecode(inspectString.substring(1, inspectString.length - 1));
192 :
193 6 : if (config.debug) {
194 : // ignore: avoid_print
195 0 : print('Inspect: $inspectMap');
196 : }
197 :
198 3 : healthStatus = inspectMap['Status'];
199 : }
200 :
201 3 : if (healthStatus != 'healthy') {
202 0 : throw TestainersException(
203 0 : 'Container $name with image $image:$tag is $healthStatus.',
204 : reason: inspectMap,
205 : );
206 : }
207 : }
208 :
209 9 : return id!;
210 : }
211 :
212 : /// Executes a command inside a running container.
213 : ///
214 : /// The container must be started (via [start]) before calling this method.
215 : /// Uses `docker exec` (or the configured runner) to run the given [command]
216 : /// inside the container identified by [name].
217 : ///
218 : /// Parameters:
219 : /// - [command] — the command and its arguments to execute inside the
220 : /// container (e.g., `['echo', 'hello']` or `['psql', '-c', 'SELECT 1']`).
221 : /// - [interactive] — if `true`, allocates a pseudo-TTY (`-i -t` flags).
222 : /// Defaults to `false`, which runs without TTY allocation
223 : /// (suitable for non-interactive / scripted usage).
224 : ///
225 : /// Returns the trimmed stdout output of the executed command.
226 : ///
227 : /// Throws [TestainersException] if the container has not been started
228 : /// (i.e., [id] is `null`) or if the command exits with a non-zero code.
229 : ///
230 : /// Example:
231 : /// ```dart
232 : /// final container = Testainers(
233 : /// image: 'alpine', tag: 'latest',
234 : /// detached: true, remove: true,
235 : /// env: {}, ports: {},
236 : /// );
237 : /// await container.start();
238 : ///
239 : /// final output = await container.exec(command: ['echo', 'hello']);
240 : /// print(output); // 'hello'
241 : ///
242 : /// await container.stop();
243 : /// ```
244 1 : Future<String> exec({
245 : required List<String> command,
246 : bool interactive = false,
247 : }) async {
248 1 : if (id == null) {
249 3 : throw TestainersException('Container $name not started.');
250 : }
251 :
252 1 : final List<String> arguments = <String>[
253 : 'exec',
254 0 : if (interactive) ...<String>['-i', '-t'],
255 1 : name,
256 1 : ...command,
257 : ];
258 :
259 2 : return config.exec(
260 : arguments: arguments,
261 2 : exceptionExec: 'Exec failed in container $name.',
262 : );
263 : }
264 :
265 : /// Stops the container.
266 : ///
267 : /// Sends a stop signal to the running container identified by [name].
268 : /// If [stopTime] is greater than zero, it is passed as the `--timeout`
269 : /// argument to the stop command.
270 : ///
271 : /// Throws [TestainersException] if the container could not be stopped
272 : /// or if the stop output does not match the expected container name.
273 9 : Future<void> stop() async {
274 9 : final List<String> arguments = <String>[
275 : 'stop',
276 21 : if (stopTime > 0) ...<String>['--timeout', '$stopTime'],
277 9 : name,
278 : ];
279 :
280 9 : final ProcessResult result = await Process.run(
281 18 : config.runner,
282 : arguments,
283 27 : ).timeout(config.timeout);
284 :
285 14 : if (result.exitCode != 0) {
286 0 : throw TestainersException(
287 0 : 'Container $name with image $image:$tag not stopped.',
288 0 : reason: result.stderr,
289 : );
290 : }
291 :
292 35 : if (result.stdout.toString().trim() != name) {
293 0 : throw TestainersException(
294 0 : 'Check if the container $name has stopped.',
295 0 : reason: result.stdout,
296 : );
297 : }
298 : }
299 : }
|