File Coverage

blib/lib/Testcontainers/Container.pm
Criterion Covered Total %
statement 15 90 16.6
branch 0 34 0.0
condition 0 20 0.0
subroutine 5 20 25.0
pod 0 15 0.0
total 20 179 11.1


line stmt bran cond sub pod time code
1             package Testcontainers::Container;
2             # ABSTRACT: A running Docker container managed by Testcontainers
3              
4 3     3   20 use strict;
  3         5  
  3         238  
5 3     3   21 use warnings;
  3         7  
  3         163  
6 3     3   14 use Moo;
  3         5  
  3         18  
7 3     3   1100 use Carp qw( croak );
  3         7  
  3         290  
8 3     3   27 use Log::Any qw( $log );
  3         6  
  3         31  
9              
10             our $VERSION = '0.001';
11              
12             =head1 SYNOPSIS
13              
14             # Containers are created via Testcontainers::run()
15             my $container = Testcontainers::run('nginx:alpine',
16             exposed_ports => ['80/tcp'],
17             );
18              
19             # Get connection details
20             my $host = $container->host;
21             my $port = $container->mapped_port('80/tcp');
22             my $id = $container->id;
23              
24             # Execute commands
25             my $result = $container->exec(['echo', 'hello']);
26             say $result->{output};
27              
28             # Get logs
29             my $logs = $container->logs;
30              
31             # Terminate
32             $container->terminate;
33              
34             =head1 DESCRIPTION
35              
36             Represents a running Docker container created by Testcontainers. Provides
37             methods to interact with the container, get connection details, execute
38             commands, and manage its lifecycle.
39              
40             =cut
41              
42             has id => (
43             is => 'ro',
44             required => 1,
45             );
46              
47             =attr id
48              
49             The Docker container ID.
50              
51             =cut
52              
53             has image => (
54             is => 'ro',
55             required => 1,
56             );
57              
58             =attr image
59              
60             The Docker image name used to create this container.
61              
62             =cut
63              
64             has docker => (
65             is => 'ro',
66             required => 1,
67             );
68              
69             has request => (
70             is => 'ro',
71             required => 1,
72             );
73              
74             has _info => (
75             is => 'rw',
76             default => sub { {} },
77             );
78              
79             has _port_map => (
80             is => 'rw',
81             default => sub { {} },
82             );
83              
84             has _terminated => (
85             is => 'rw',
86             default => 0,
87             );
88              
89             sub refresh {
90 0     0 0   my ($self) = @_;
91              
92 0           my $info = $self->docker->inspect_container($self->id);
93 0           $self->_info($info);
94              
95             # Build port mapping from NetworkSettings
96 0           my $ports = {};
97 0           my $network_settings = $info->NetworkSettings;
98 0 0 0       if ($network_settings && ref $network_settings eq 'HASH') {
99 0   0       my $port_bindings = $network_settings->{Ports} // {};
100 0           for my $container_port (keys %$port_bindings) {
101 0           my $bindings = $port_bindings->{$container_port};
102 0 0 0       if ($bindings && ref $bindings eq 'ARRAY' && @$bindings) {
      0        
103             $ports->{$container_port} = {
104             host_ip => $bindings->[0]{HostIp} // '0.0.0.0',
105             host_port => $bindings->[0]{HostPort},
106 0   0       };
107             }
108             }
109             }
110 0           $self->_port_map($ports);
111              
112 0           return $self;
113             }
114              
115             =method refresh
116              
117             Refresh container information from Docker. Called automatically after start.
118              
119             =cut
120              
121             sub host {
122 0     0 0   my ($self) = @_;
123              
124             # In most cases, localhost is the right answer for testcontainers
125             # For remote Docker, we'd need to parse the docker host
126 0           my $docker_host = $self->docker->docker_host;
127              
128 0 0         if ($docker_host =~ m{^tcp://([^:]+)}) {
129 0           return $1;
130             }
131              
132 0           return 'localhost';
133             }
134              
135             =method host
136              
137             Returns the host address where the container is accessible. For local Docker,
138             returns C. For remote Docker (tcp://), returns the remote host.
139              
140             =cut
141              
142             sub mapped_port {
143 0     0 0   my ($self, $port) = @_;
144 0 0         croak "Port required" unless $port;
145              
146             # Normalize port format: "80" -> "80/tcp"
147 0 0         $port = "$port/tcp" unless $port =~ m{/};
148              
149 0           my $mapping = $self->_port_map->{$port};
150 0 0         croak "No mapping found for port $port" unless $mapping;
151              
152 0           return $mapping->{host_port};
153             }
154              
155             =method mapped_port($port)
156              
157             Returns the host port mapped to the given container port.
158              
159             my $port = $container->mapped_port('80/tcp');
160             # or
161             my $port = $container->mapped_port('80'); # assumes /tcp
162              
163             =cut
164              
165             sub mapped_port_info {
166 0     0 0   my ($self, $port) = @_;
167 0 0         croak "Port required" unless $port;
168              
169 0 0         $port = "$port/tcp" unless $port =~ m{/};
170              
171 0           my $mapping = $self->_port_map->{$port};
172 0 0         croak "No mapping found for port $port" unless $mapping;
173              
174 0           return $mapping;
175             }
176              
177             =method mapped_port_info($port)
178              
179             Returns a hashref with C and C for the given container port.
180              
181             =cut
182              
183             sub endpoint {
184 0     0 0   my ($self, $port) = @_;
185 0 0         croak "Port required" unless $port;
186              
187 0           my $host = $self->host;
188 0           my $mapped = $self->mapped_port($port);
189              
190 0           return "$host:$mapped";
191             }
192              
193             =method endpoint($port)
194              
195             Returns "host:port" string for the given container port.
196              
197             my $addr = $container->endpoint('80/tcp');
198             # e.g., "localhost:32789"
199              
200             =cut
201              
202             sub container_id {
203 0     0 0   my ($self) = @_;
204 0           return $self->id;
205             }
206              
207             =method container_id
208              
209             Alias for C. Returns the Docker container ID.
210              
211             =cut
212              
213             sub name {
214 0     0 0   my ($self) = @_;
215 0   0       my $name = $self->_info->Name // '';
216 0           $name =~ s{^/}{}; # Docker prefixes names with /
217 0           return $name;
218             }
219              
220             =method name
221              
222             Returns the container name (without leading /).
223              
224             =cut
225              
226             sub state {
227 0     0 0   my ($self) = @_;
228 0           $self->refresh;
229 0           my $state = $self->_info->State;
230 0 0         return $state if ref $state eq 'HASH';
231 0           return { Status => $state };
232             }
233              
234             =method state
235              
236             Returns the container state hashref. Refresh container info first.
237              
238             =cut
239              
240             sub is_running {
241 0     0 0   my ($self) = @_;
242 0           my $state = $self->state;
243 0 0 0       return $state->{Running} ? 1 : 0 if ref $state eq 'HASH' && exists $state->{Running};
    0          
244 0 0 0       return lc($state->{Status} // '') eq 'running' ? 1 : 0;
245             }
246              
247             =method is_running
248              
249             Returns true if the container is currently running.
250              
251             =cut
252              
253             sub logs {
254 0     0 0   my ($self, %opts) = @_;
255 0           return $self->docker->container_logs($self->id, %opts);
256             }
257              
258             =method logs(%opts)
259              
260             Get container logs. Options: C, C, C, C.
261              
262             =cut
263              
264             sub exec {
265 0     0 0   my ($self, $cmd, %opts) = @_;
266 0           return $self->docker->exec_in_container($self->id, $cmd, %opts);
267             }
268              
269             =method exec($cmd, %opts)
270              
271             Execute a command in the container. C<$cmd> is an ArrayRef or string.
272             Returns hashref with C and C.
273              
274             my $result = $container->exec(['echo', 'hello']);
275             say $result->{output}; # "hello\n"
276             say $result->{exit_code}; # 0
277              
278             =cut
279              
280             sub stop {
281 0     0 0   my ($self, %opts) = @_;
282 0           $self->docker->stop_container($self->id, %opts);
283 0           return;
284             }
285              
286             =method stop(%opts)
287              
288             Stop the container. Options: C.
289              
290             =cut
291              
292             sub start {
293 0     0 0   my ($self) = @_;
294 0           $self->docker->start_container($self->id);
295 0           $self->refresh;
296 0           return;
297             }
298              
299             =method start
300              
301             Start the container if it was stopped.
302              
303             =cut
304              
305             sub terminate {
306 0     0 0   my ($self) = @_;
307 0 0         return if $self->_terminated;
308              
309 0           $log->debugf("Terminating container: %s", $self->id);
310 0           eval { $self->docker->stop_container($self->id, timeout => 5) };
  0            
311 0           eval { $self->docker->remove_container($self->id, force => 1, volumes => 1) };
  0            
312 0           $self->_terminated(1);
313              
314 0           $log->debugf("Container terminated: %s", $self->id);
315 0           return 1;
316             }
317              
318             =method terminate
319              
320             Stop, remove the container and its volumes. Safe to call multiple times.
321              
322             =cut
323              
324             sub DEMOLISH {
325 0     0 0   my ($self, $in_global_destruction) = @_;
326 0 0         return if $in_global_destruction;
327             # Auto-cleanup on object destruction if not already terminated
328 0 0         $self->terminate unless $self->_terminated;
329 0           return;
330             }
331              
332             =head1 SEE ALSO
333              
334             =over
335              
336             =item * L - Main module
337              
338             =item * L - Wait strategies
339              
340             =back
341              
342             =cut
343              
344             1;