File Coverage

blib/lib/Teamcity/Executor.pm
Criterion Covered Total %
statement 32 172 18.6
branch 0 14 0.0
condition 0 8 0.0
subroutine 11 24 45.8
pod 0 10 0.0
total 43 228 18.8


line stmt bran cond sub pod time code
1             package Teamcity::Executor;
2 3     3   133553 use 5.020;
  3         33  
3 3     3   18 use strict;
  3         6  
  3         73  
4 3     3   23 use warnings;
  3         6  
  3         139  
5              
6             our $VERSION = "1.3.1";
7              
8 3     3   1687 use Moose;
  3         1402937  
  3         25  
9 3     3   25568 use HTTP::Tiny;
  3         151777  
  3         122  
10 3     3   1805 use Cpanel::JSON::XS;
  3         6843  
  3         185  
11 3     3   1329 use IO::Async::Timer::Periodic;
  3         52121  
  3         110  
12 3     3   1559 use Log::Any qw($log);
  3         24265  
  3         17  
13 3     3   7548 use Try::Tiny::Retry ':all';
  3         4056  
  3         462  
14              
15 3     3   23 use feature 'signatures';
  3         6  
  3         378  
16 3     3   21 no warnings 'experimental::signatures';
  3         7  
  3         6643  
17              
18             has credentials => (is => 'ro', isa => 'HashRef');
19              
20             has build_id_mapping => (is => 'ro', isa => 'HashRef');
21              
22             has http => (
23             is => 'ro',
24             isa => 'HTTP::Tiny',
25             default => sub { HTTP::Tiny->new(timeout => 10) }
26             );
27              
28             has loop => (
29             is => 'ro',
30             isa => 'IO::Async::Loop',
31             );
32              
33             has teamcity_builds => (
34             is => 'ro',
35             isa => 'HashRef',
36             default => sub { {} },
37             );
38              
39             has poll_interval => (
40             is => 'ro',
41             isa => 'Int',
42             default => 10,
43             );
44              
45             has teamcity_auth_url => (
46             is => 'ro',
47             isa => 'Str',
48             lazy => 1,
49             default => sub ($self) {
50             my $url = $self->credentials->{url};
51             my $user = $self->credentials->{user};
52             my $pass = $self->credentials->{pass};
53              
54             my ($protocol, $address) = $url =~ m{(http[s]://)(.*)};
55              
56             return $protocol . $user . ':' . $pass . '@' . $address;
57             }
58             );
59              
60 0     0 0   sub http_request ($self, $method, $url, $headers = {}, $content = '') {
  0            
  0            
  0            
  0            
  0            
  0            
61 0           my $response;
62              
63             # this code handles the teamcity authentification issues (sometimes authentification fails
64             # without a reason)
65             retry {
66 0     0     $response = $self->http->request(
67             $method, $url,
68             {
69             headers => $headers,
70             content => $content,
71             }
72             );
73              
74 0 0 0       if ($response->{status} == 599 || ($response->{status} == 401 && !$response->{reason})) {
      0        
75 0           $log->info("Authentification to teamcity failed, retrying.");
76 0           die 'Authentification to teamcity failed';
77             }
78             }
79 0     0     delay_exp { 10, 1e6 };
  0            
80              
81 0 0         if (!$response->{success}) {
82 0           die "HTTP $method request to $url failed: " . "$response->{status}: $response->{reason}";
83             }
84              
85 0           return $response;
86             }
87              
88 0     0 0   sub start_teamcity_build ($self, $build_type_id, $properties, $build_name) {
  0            
  0            
  0            
  0            
  0            
89 0   0       $build_name //= 'unnamed-build';
90              
91 0           my $build_queue_url = $self->teamcity_auth_url . '/httpAuth/app/rest/buildQueue';
92              
93 0           my $xml_properties = '';
94              
95 0           for my $key (keys %{$properties}) {
  0            
96 0           my $value = $properties->{$key};
97 0           $xml_properties .= qq{<property name="$key" value="$value" />\n};
98             }
99              
100 0           my $request_body = qq{<build>
101             <buildType id="$build_type_id"/>
102             <properties>
103             $xml_properties
104             </properties>
105             </build>};
106              
107 0           my $response = $self->http_request(
108             'POST',
109             $build_queue_url,
110             {
111             'Content-Type' => 'application/xml',
112             'Accept' => 'application/json',
113             },
114             $request_body,
115             );
116              
117 0           return decode_json $response->{content};
118             }
119              
120 0     0 0   sub run_teamcity_build ($self, $build_type_id, $properties, $build_name, $wait = 1) {
  0            
  0            
  0            
  0            
  0            
  0            
121              
122 0           my $json = $self->start_teamcity_build($build_type_id, $properties, $build_name);
123              
124 0           my $build_id = $json->{id};
125 0           my $build_detail_href = $json->{webUrl};
126              
127 0           my $f = $self->loop->new_future();
128              
129 0 0         if ($wait) {
130             $self->teamcity_builds->{$build_id} = {
131             id => $build_id,
132             status_href => $json->{href},
133 0           href => $build_detail_href,
134             name => $build_name,
135             params => $properties,
136             future => $f,
137             };
138             }
139             else {
140 0           $f->done({ id => $build_id, href => $build_detail_href, status => '', params => $properties, output => '' });
141             }
142              
143 0           return $f, $build_id, $json->{webUrl};
144             }
145              
146 0     0 0   sub get_artifact_list ($self, $build_result) {
  0            
  0            
  0            
147             # get artifacts summary
148 0           my $artifacts_href = $build_result->{output}{artifacts}{href};
149 0           my $artifacts_url = $self->teamcity_auth_url . $artifacts_href;
150 0           my $response = $self->http_request('GET', $artifacts_url, { 'Accept' => 'application/json' },);
151              
152 0           my $json = decode_json $response->{content};
153              
154 0           my %artifacts;
155              
156             # get individual artifacts URLs
157 0           for my $node (@{$json->{file}}) {
  0            
158 0           my $content_href = $node->{content}{href};
159 0           my $metadata_href = $node->{content}{href};
160 0           my $name = $node->{name};
161 0           $artifacts{$name} = {
162             name => $name,
163             content_href => $content_href,
164             metadata_href => $metadata_href,
165             };
166             }
167              
168 0           return \%artifacts;
169             }
170              
171 0     0 0   sub get_artifact_content ($self, $build_result, $artifact_name) {
  0            
  0            
  0            
  0            
172 0           my $artifact_list = $self->get_artifact_list($build_result);
173              
174 0 0         die "The artifact $artifact_name could not be found!" unless %$artifact_list{$artifact_name};
175              
176 0           my $content_url = $self->teamcity_auth_url . $artifact_list->{$artifact_name}{content_href};
177              
178 0           my $response = $self->http_request('GET', $content_url);
179              
180 0           return $response->{content};
181             }
182              
183 0     0 0   sub run ($self, $build_name, $properties = {}) {
  0            
  0            
  0            
  0            
184              
185 0           my $teamcity_job_parameters = join(', ', map { "$_: '$properties->{$_}'" } keys %{$properties});
  0            
  0            
186 0           $log->info("RUN\t$build_name($teamcity_job_parameters)");
187              
188 0           my ($f, $id, $url) = $self->run_teamcity_build($self->build_id_mapping->{$build_name}, $properties, $build_name,);
189              
190 0           $log->info("\t[$id]\t$url");
191              
192 0           return $f;
193             }
194              
195 0     0 0   sub touch ($self, $build_name, $properties = {}) {
  0            
  0            
  0            
  0            
196 0           my $teamcity_job_parameters = join(', ', map { "$_: '$properties->{$_}'" } keys %{$properties});
  0            
  0            
197 0           $log->info("TOUCH\t$build_name($teamcity_job_parameters)");
198              
199 0           my ($f, $id, $url) = $self->run_teamcity_build($self->build_id_mapping->{$build_name}, $properties, $build_name, 0);
200              
201 0           $log->info("\t[$id]\t$url");
202              
203 0           return $f;
204             }
205              
206 0     0 0   sub touch_without_future ($self, $build_name, $properties = {}) {
  0            
  0            
  0            
  0            
207 0           my $teamcity_job_parameters = join(', ', map { "$_: '$properties->{$_}'" } keys %{$properties});
  0            
  0            
208 0           $log->info("TOUCH WITHOUT FUTURE\t$build_name($teamcity_job_parameters)");
209              
210 0           my $result_json = $self->start_teamcity_build($self->build_id_mapping->{$build_name}, $properties, $build_name);
211            
212 0           $log->info("\t[$result_json->{id}]\t$result_json->{webUrl}");
213 0           return { id => $result_json->{id}, href => $result_json->{webUrl}, status => '', params => $properties, output => $result_json };
214             }
215              
216              
217 0     0 0   sub poll_teamcity_results($self) {
  0            
  0            
218 0           $log->info('.');
219              
220 0           for my $build (values %{$self->teamcity_builds}) {
  0            
221 0           my $url = $self->teamcity_auth_url . $build->{status_href};
222              
223 0           my $response = $self->http_request('GET', $url, { 'Accept' => 'application/json' },);
224              
225 0           my $json = decode_json $response->{content};
226              
227 0           my $state = $json->{state};
228 0           my $status = $json->{status};
229              
230 0 0         $log->info("$build->{name} [$build->{id}]: QUEUED") if $state eq 'queued';
231              
232 0 0         next if $state ne 'finished';
233              
234             my $job_result = {
235             id => $build->{id},
236             href => $build->{href},
237             status => $status,
238             params => $build->{params},
239 0           output => $json
240             };
241              
242 0           my $teamcity_job_parameters = join(', ', map { "$_: '$build->{params}->{$_}'" } keys %{$build->{params}});
  0            
  0            
243 0           $log->info("$status\t".$build->{name}."($teamcity_job_parameters)");
244 0           $log->info("\t[".$build->{id}."]\t".$build->{href});
245            
246 0 0         if ($status eq 'SUCCESS') {
247 0           $build->{future}->done($job_result);
248             }
249             else {
250 0           $build->{future}->fail($job_result);
251             }
252              
253 0           delete $self->teamcity_builds->{ $build->{id} };
254             }
255             }
256              
257 0     0 0   sub register_polling_timer($self) {
  0            
  0            
258             my $timer = IO::Async::Timer::Periodic->new(
259             interval => $self->poll_interval,
260             on_tick => sub {
261 0     0     $self->poll_teamcity_results();
262             },
263 0           );
264              
265 0           $self->loop->add($timer);
266 0           $timer->start();
267             }
268              
269             1;
270             __END__
271              
272             =encoding utf-8
273              
274             =head1 NAME
275              
276             Teamcity::Executor - Executor of TeamCity build configurations
277              
278             =head1 SYNOPSIS 1 - asynchronous usage
279              
280             use Teamcity::Executor;
281             use IO::Async::Loop;
282             use Log::Any::Adapter;
283              
284             Log::Any::Adapter->set(
285             'Dispatch',
286             outputs => [
287             [
288             'Screen',
289             min_level => 'debug',
290             stderr => 1,
291             newline => 1
292             ]
293             ]
294             );
295              
296             my $loop = IO::Async::Loop->new;
297             my $tc = Teamcity::Executor->new(
298             credentials => {
299             url => 'https://teamcity.example.com',
300             user => 'user',
301             pass => 'password',
302             },
303             build_id_mapping => {
304             hello_world => 'playground_HelloWorld',
305             hello_name => 'playground_HelloName',
306             }
307             poll_interval => 10,
308             loop => $loop,
309             );
310              
311             $tc->register_polling_timer();
312              
313             my $future = $tc->run('hello_name', { name => 'TeamCity' })->then(
314             sub {
315             my ($build) = @_;
316             print "Build succeeded\n";
317             my $greeting = $tc->get_artifact_content($build, 'greeting.txt');
318             print "Content of greeting.txt artifact: $greeting\n";
319             },
320             sub {
321             print "Build failed\n";
322             exit 1
323             }
324             );
325              
326             my $touch_future = $tc->touch('hello_name', { name => 'TeamCity' })->then(
327             sub {
328             my ($build) = @_;
329             print "Touch build started\n";
330             $loop->stop();
331             },
332             sub {
333             print "Touch build failed to start\n";
334             exit 1
335             }
336             );
337              
338             $loop->run();
339              
340             =head1 SYNOPSIS 2 - synchronous usage
341              
342             use Teamcity::Executor;
343             use Log::Any::Adapter;
344              
345             Log::Any::Adapter->set(
346             'Dispatch',
347             outputs => [
348             [
349             'Screen',
350             min_level => 'debug',
351             stderr => 1,
352             newline => 1
353             ]
354             ]
355             );
356              
357             my $tc = Teamcity::Executor->new(
358             credentials => {
359             url => 'https://teamcity.example.com',
360             user => 'user',
361             pass => 'password',
362             },
363             build_id_mapping => {
364             hello_world => 'playground_HelloWorld',
365             hello_name => 'playground_HelloName',
366             }
367             );
368              
369             my $resp = $tc->touch_without_future('hello_name', {});
370              
371             print "id: $resp->{id}\n";
372             print "webUrl: $resp->{webUrl}\n";
373              
374             =head1 DESCRIPTION
375              
376             Teamcity::Executor is a module for executing Teamcity build configurations.
377             When you execute one, you'll receive a future of the build. Teamcity::Executor
378             polls TeamCity and when it finds the build has ended, it resolves the future.
379              
380             =head1 LICENSE
381              
382             Copyright (C) Avast Software
383              
384             This library is free software; you can redistribute it and/or modify
385             it under the same terms as Perl itself.
386              
387             =head1 AUTHOR
388              
389             Miroslav Tynovsky E<lt>tynovsky@avast.comE<gt>
390              
391             =cut