File Coverage

blib/lib/WebService/CEPH/NetAmazonS3.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             =encoding utf8
2              
3             =head1 WebService::CEPH::NetAmazonS3
4              
5             Драйвер для CEPH на базе Net::Amazon::S3.
6              
7             Сделан скорее не на базе Net::Amazon::S3, а на базе Net::Amazon::S3::Client
8             см. POD https://metacpan.org/pod/Net::Amazon::S3::Client, там отдельная документация
9             и сказано что это более новый интерфейс, при этом в докции Net::Amazon::S3 ссылки на это нет.
10              
11             Лезет в приватные методы и не документированные возможности Net::Amazon::S3,
12             в связи с тем что Net::Amazon::S3 сложно назвать документированным в принципе, а публичного
13             функционала не хватает.
14              
15             Стабильность такого решения обеспечивается интеграционным тестом netamazons3_integration,
16             который по идее потестирует всё-всё. Проблемы могут быть только если вы поставили этот
17             модуль, затем обновили Net::Amazon::S3 на новую, ещё не существующую версию, которая
18             сломала обратную совместимость приватных методов.
19              
20             Интерфейс данного модуля документирован. Придерживайтесь того что документировано, WebService::CEPH
21             на всё это рассчитывает. Можете написать свой драйвер с таким же интерфейсом, но с другой реализацией.
22              
23             =cut
24              
25             package WebService::CEPH::NetAmazonS3;
26              
27             our $VERSION = '0.015'; # VERSION
28              
29 3     3   139487 use strict;
  3         8  
  3         68  
30 3     3   13 use warnings;
  3         6  
  3         60  
31 3     3   11 use Carp;
  3         5  
  3         119  
32 3     3   497 use Time::Local;
  3         2264  
  3         146  
33 3     3   917 use Net::Amazon::S3;
  0            
  0            
34             use HTTP::Status;
35             use Digest::MD5 qw/md5_hex/;
36              
37              
38             sub _time { # for mocking in tests
39             time()
40             }
41              
42             =head2 new
43              
44             Конструктор
45              
46             protocol - 'http' или 'https'
47              
48             host - хост Amazon S3 или CEPH
49              
50             bucket - (обязателен для всех операций кроме запроса списка бакетов) имя бакета, этот бакет будет использоваться для всех операций объекта
51              
52             key - ключ доступа
53              
54             secret - секретный секрет
55              
56             =cut
57              
58             sub new {
59             my ($class, %args) = @_;
60              
61             my $self = bless +{}, $class;
62              
63             $self->{$_} = delete $args{$_} // confess "Missing $_" for (qw/protocol host key secret/);
64             $self->{bucket} = delete $args{bucket};
65              
66             confess "Unused arguments %args" if %args;
67             confess "protocol should be 'http' or 'https'" unless $self->{protocol} =~ /^https?$/;
68              
69             my $s3 = Net::Amazon::S3->new({
70             aws_access_key_id => $self->{key},
71             aws_secret_access_key => $self->{secret}, # TODO: фильтровать в логировании?
72             host => $self->{host},
73             secure => $self->{protocol} eq 'https',
74             retry => 1,
75             });
76              
77             $self->{client} = Net::Amazon::S3::Client->new( s3 => $s3 );
78             $self;
79             }
80              
81             =head2 _request_object
82              
83             Приватный метод. Возвращает объект Net::Amazon::S3::Client::Bucket, который затем может использоваться.
84             Используется в коде несколько раз
85              
86             =cut
87              
88             sub _request_object {
89             my ($self) = @_;
90              
91             confess "Missing bucket" unless $self->{bucket};
92              
93             $self->{client}->bucket(name => $self->{bucket});
94             }
95              
96             =head2 get_buckets_list
97              
98             Returns buckets list
99              
100             =cut
101              
102             sub get_buckets_list {
103             my ($self) = @_;
104              
105             return $self->{client}->buckets->{buckets};
106             }
107              
108             =head2 upload_single_request
109              
110             Закачивает данные.
111              
112             Параметры:
113              
114             1) $self
115              
116             2) $key - имя объекта
117              
118             3) сами данные (блоб)
119              
120             4) Content-Type, не обязателен
121              
122             Закачивает объект за один запрос (не-multipart upload), ставит приватный ACL,
123             добавляет кастомный заголовок x-amz-meta-md5, который равен md5 hex от файла
124              
125             =cut
126              
127             sub upload_single_request {
128             my ($self, $key) = (shift, shift); # after shifts: $_[0] - value, $_[1] - content-type
129              
130             my $md5 = md5_hex($_[0]);
131             my $object = $self->_request_object->object(
132             key => $key,
133             acl_short => 'private',
134             $_[1] ? ( content_type => $_[1] ) : ()
135             );
136             $object->user_metadata->{'md5'} = $md5;
137             $object->_put($_[0], length($_[0]), $md5); # private _put so we can re-use md5. only for that.
138             }
139              
140             =head2 list_multipart_uploads
141              
142             Возвращает список multipart_upload
143              
144             Параметры:
145              
146             нет
147              
148             Возвращает:
149              
150             [
151             {
152             key => 'Upload key',
153             upload_id => 'Upload ID',
154             initiated => 'Init date',
155             initiated_epoch => то же, что initiated но в формате epoch time
156             initiated_age_seconds => это просто time() - initiated_epoch Ñ‚.е. возраст upload
157             },
158             ...
159             ]
160              
161             =cut
162              
163             sub list_multipart_uploads {
164             my ($self) = @_;
165              
166             $self->{client}->bucket(name => $self->{bucket});
167              
168             my $http_request = Net::Amazon::S3::HTTPRequest->new(
169             s3 => $self->{client}->s3,
170             method => 'GET',
171             path => $self->{bucket} . '?uploads',
172             )->http_request;
173              
174             my $xpc = $self->{client}->_send_request_xpc($http_request);
175              
176             my @uploads;
177             my $t0 = _time();
178             foreach my $node ( $xpc->findnodes(".//s3:Upload") ) {
179              
180             my $initiated = $xpc->findvalue( ".//s3:Initiated", $node );
181              
182             my ($y, $m, $d, $hour, $min, $sec) = $initiated =~ /^(\d{4})\-(\d{2})\-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
183             or confess "Bad date $initiated";
184             my $initiated_epoch = timegm($sec, $min, $hour, $d, $m - 1, $y); # interpret time as GMT+00 time and convert to epoch
185              
186             push @uploads, {
187             key => $xpc->findvalue( ".//s3:Key", $node ),
188             upload_id => $xpc->findvalue( ".//s3:UploadId", $node ),
189             initiated => $initiated,
190             initiated_epoch => $initiated_epoch,
191             initiated_age_seconds => $t0 - $initiated_epoch,
192             };
193              
194             }
195              
196             return \@uploads;
197             }
198              
199             =head2 delete_multipart_upload
200              
201             Удаляет аплоад
202              
203             Параметры:
204              
205             $key, $upload_id
206              
207             =cut
208              
209             sub delete_multipart_upload {
210             my ($self, $key, $upload_id) = @_;
211              
212             $self->{client}->bucket(name => $self->{bucket});
213              
214             my $http_request = Net::Amazon::S3::Request::AbortMultipartUpload->new(
215             s3 => $self->{client}->s3,
216             bucket => $self->{bucket},
217             key => $key,
218             upload_id => $upload_id,
219             )->http_request;
220              
221             $self->{client}->_send_request_raw($http_request);
222             }
223              
224             =head2 initiate_multipart_upload
225              
226             Инициирует multipart upload
227              
228             Параметры:
229              
230             1) $self
231              
232             2) $key - имя объекта
233              
234             3) md5 от данных
235              
236             Инициирует multipart upload, устанавливает x-amz-meta-md5 в значение md5 файла (нужно посчитать
237             заранее и передать как параметр).
238             Возвращает ссылку на структуру, недокументированной природы, которая в дальнейшем должна
239             использоваться для работы с этим multipart upload
240              
241             =cut
242              
243             sub initiate_multipart_upload {
244             my ($self, $key, $md5, $content_type) = @_;
245              
246             confess "Missing bucket" unless $self->{bucket};
247              
248             my $object = $self->_request_object->object( key => $key, acl_short => 'private' );
249              
250             my $http_request = Net::Amazon::S3::Request::InitiateMultipartUpload->new(
251             s3 => $self->{client}->s3,
252             bucket => $self->{bucket},
253             key => $key,
254             headers => +{
255             'X-Amz-Meta-Md5' => $md5,
256             $content_type ? ( 'Content-type' => $content_type ) : ()
257             }
258             )->http_request;
259              
260             my $xpc = $self->{client}->_send_request_xpc($http_request);
261             my $upload_id = $xpc->findvalue('//s3:UploadId');
262             confess "Couldn't get upload id from initiate_multipart_upload response XML"
263             unless $upload_id;
264              
265             +{ key => $key, upload_id => $upload_id, object => $object, md5 => $md5};
266             }
267              
268             =head2 upload_part
269              
270             Закачивает часть данных при multipart upload'е
271              
272             Параметры:
273              
274             1) $self
275              
276             2) $multipart_upload - ссылка, полученная из initiate_multipart_upload
277              
278             3) $part_number - номер части, от 1 и выше.
279              
280             Работает только если части закачивались по очереди, с возрастающими номерами
281             (что естественно, если это последовательная закачка, и делает невозможным паралллельную
282             закачку из разных процессов)
283              
284             Ничего не возвращает
285              
286             =cut
287              
288             sub upload_part {
289             my ($self, $multipart_upload, $part_number) = (shift, shift, shift);
290              
291             $multipart_upload->{object}->put_part(
292             upload_id => $multipart_upload->{upload_id},
293             part_number => $part_number,
294             value => $_[0]
295             );
296              
297             # TODO:Part numbers should be in accessing order (in case someone uploads in parallel) !
298             push @{$multipart_upload->{parts} ||= [] }, $part_number;
299             push @{$multipart_upload->{etags} ||= [] }, md5_hex($_[0]);
300             }
301              
302             =head2 complete_multipart_upload
303              
304             Финализирует multipart upload
305              
306             Параметры:
307              
308             1) $self
309              
310             2) $multipart_upload - ссылка, полученная из initiate_multipart_upload
311              
312             ничего не возвращает. падает с исчлючением, если что-то не так.
313              
314             =cut
315              
316             sub complete_multipart_upload {
317             my ($self, $multipart_upload) = @_;
318              
319             $multipart_upload->{object}->complete_multipart_upload(
320             upload_id => $multipart_upload->{upload_id},
321             etags => $multipart_upload->{etags},
322             part_numbers => $multipart_upload->{parts}
323             );
324             }
325              
326             =head2 download_with_range
327              
328             Скачивает объект с заголовком HTTP Range (Ñ‚.е. часть данных).
329              
330             Параметры:
331              
332             1) $self
333              
334             2) $key - имя объекта
335              
336             3) $first - первый байт для Range
337              
338             4) $last - последний байт для Range
339              
340             Если $first, $last отсутствуют или undef, скачивается весь файл, без заголовка Range
341             Если $last отсутствует, скачивает данные с определённой позиции и до конца (так же как в спецификации Range)
342             Если объект отсутствует, возвращает пустой список. Если другая ошибка - исключение.
343              
344             Возвращает:
345              
346             1) Scalar Ref на скачанные данные
347              
348             2) Количество оставшихся байтов, которые ещё можно скачать (или undef, если параметра $first не было)
349              
350             3) ETag заголовок с удалёнными кавычками (или undef если его нет)
351              
352             4) X-Amz-Meta-Md5 заголовок (или undef, если его нет)
353              
354             =cut
355              
356             sub download_with_range {
357             my ($self, $key, $first, $last) = @_;
358              
359             confess "Missing bucket" unless $self->{bucket};
360              
361             # TODO: How and when to validate ETag here?
362             my $http_request = Net::Amazon::S3::Request::GetObject->new(
363             s3 => $self->{client}->s3,
364             bucket => $self->{bucket},
365             key => $key,
366             method => 'GET',
367             )->http_request;
368              
369             if (defined $first) {
370             $last //= '';
371             $http_request->headers->header("Range", "bytes=$first-$last");
372             }
373              
374             my $http_response = $self->{client}->_send_request_raw($http_request);
375             #print $http_request->as_string, $http_response->as_string ;
376             if ( $http_response->code == 404 && $http_response->decoded_content =~ m!<Code>NoSuchKey</Code>!) {
377             return;
378             }
379             elsif (is_error($http_response->code)) {
380             my ($err) = $http_response->decoded_content =~ m!<Code>(.*)</Code>!;
381             $err //= 'none';
382             confess "Unknown error ".$http_response->code." $err";
383             } else {
384             my $left = undef;
385             if (defined $first) {
386             my $range = $http_response->header('Content-Range') // confess;
387             my ($f, $l, $total) = $range =~ m!bytes (\d+)\-(\d+)/(\d+)! or confess;
388             $left = $total - ( $l + 1);
389             }
390              
391             my $etag = $http_response->header('ETag');
392             if ($etag) {
393             $etag =~ s/^"//;
394             $etag =~ s/"$//;
395             }
396              
397             my $custom_md5 = $http_response->header('X-Amz-Meta-Md5');
398              
399             return (\$http_response->decoded_content, $left, $etag, $custom_md5);
400             }
401             }
402              
403             =head2 size
404              
405             Получает размер объекта с помощью HTTP HEAD запроса.
406              
407             Параметры:
408              
409             1) $self
410              
411             2) $key - имя объекта
412              
413             Если объект отсутствует, возвращает undef. Если другая ошибка - исключение.
414             Возвращает размер, в байтах.
415              
416             =cut
417              
418             sub size {
419             my ($self, $key) = @_;
420              
421             confess "Missing bucket" unless $self->{bucket};
422              
423             my $http_request = Net::Amazon::S3::Request::GetObject->new(
424             s3 => $self->{client}->s3,
425             bucket => $self->{bucket},
426             key => $key,
427             method => 'HEAD',
428             )->http_request;
429              
430             my $http_response = $self->{client}->_send_request_raw($http_request);
431             if ( $http_response->code == 404) { # It's not possible to distinct between NoSuchkey and NoSuchBucket??
432             return undef;
433             }
434             elsif (is_error($http_response->code)) {
435             confess "Unknown error ".$http_response->code;
436             }
437             else {
438             return $http_response->header('Content-Length') // 0;
439             }
440              
441              
442              
443             }
444              
445             =head2 delete
446              
447             Удаляет объект
448              
449             Параметры:
450              
451             1) $self
452              
453             2) $key - имя объекта
454              
455             Ничего не возвращает. Если объект не сузществовал, никак об этом не сигнализирует.
456              
457             =cut
458              
459             sub delete {
460             my ($self, $key) = @_;
461              
462             $self->_request_object->object( key => $key )->delete;
463             }
464              
465             =head2 query_string_authentication_uri
466              
467             Возвращает Query String Authentication URL для ключа $key, с экспайром $expires
468              
469             =cut
470              
471             sub query_string_authentication_uri {
472             my ($self, $key, $expires) = @_;
473              
474             $self->_request_object->object( key => $key, expires => $expires )->query_string_authentication_uri;
475             }
476              
477              
478             1;