generate-tag-details.pl 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. #!/usr/bin/env perl
  2. use strict;
  3. use warnings;
  4. use 5.010;
  5. use open ':encoding(utf8)';
  6. use Mojo::UserAgent;
  7. die 'no images specified' unless @ARGV;
  8. my $ua = Mojo::UserAgent->new->max_redirects(10);
  9. $ua->transactor->name('Docker');
  10. my $maxRetries = 3;
  11. sub ua_req {
  12. my $method = shift;
  13. my $tries = $maxRetries;
  14. my $tx;
  15. do {
  16. --$tries;
  17. $tx = $ua->$method(@_);
  18. return $tx if $tx->success;
  19. } while ($tries > 0);
  20. return $tx;
  21. }
  22. sub split_image_name {
  23. my $image = shift;
  24. if ($image =~ m{
  25. ^
  26. (?: ([^/:]+) / )? # optional namespace
  27. ([^/:]+) # image name
  28. (?: : ([^/:]+) )? # optional tag
  29. $
  30. }x) {
  31. my ($namespace, $name, $tag) = (
  32. $1 // 'library', # namespace
  33. $2, # image name
  34. $3 // 'latest', # tag
  35. );
  36. return ("$namespace/$name", $tag);
  37. }
  38. die "unrecognized image name format in: $image";
  39. }
  40. sub get_token {
  41. my $repo = shift;
  42. state %tokens;
  43. return $tokens{$repo} if $tokens{$repo};
  44. my $realmTx = $ua->get("https://registry-1.docker.io/v2/$repo/tags/list");
  45. my $auth = $realmTx->res->headers->www_authenticate;
  46. die "unexpected WWW-Authenticate header: $auth" unless $auth =~ m{ ^ Bearer \s+ (\S.*) $ }x;
  47. my $realm = $1;
  48. my $url = Mojo::URL->new;
  49. while ($realm =~ m{
  50. # key="val",
  51. ([^=]+)
  52. =
  53. "([^"]+)"
  54. ,?
  55. }xg) {
  56. my ($key, $val) = ($1, $2);
  57. if ($key eq 'realm') {
  58. $url->base(Mojo::URL->new($val));
  59. } else {
  60. $url->query->append($key => $val);
  61. }
  62. }
  63. $url = $url->to_abs;
  64. my $tokenTx = ua_req(get => $url);
  65. die "failed to fetch token for $repo" unless $tokenTx->success;
  66. return $tokens{$repo} = $tokenTx->res->json->{token};
  67. }
  68. sub get_manifest {
  69. my ($repo, $tag) = @_;
  70. my $image = "$repo:$tag";
  71. state %manifests;
  72. return $manifests{$image} if $manifests{$image};
  73. my $token = get_token($repo);
  74. my $authorizationHeader = { Authorization => "Bearer $token" };
  75. my $manifestTx = ua_req(get => "https://registry-1.docker.io/v2/$repo/manifests/$tag" => $authorizationHeader);
  76. return () if $manifestTx->res->code == 404; # tag doesn't exist
  77. die "failed to get manifest for $image" unless $manifestTx->success;
  78. return (
  79. $manifestTx->res->headers->header('Docker-Content-Digest'),
  80. $manifests{$image} = $manifestTx->res->json,
  81. );
  82. }
  83. sub get_blob_headers {
  84. my ($repo, $blob) = @_;
  85. my $key = $repo . '@' . $blob;
  86. state %headers;
  87. return $headers{$key} if $headers{$key};
  88. my $token = get_token($repo);
  89. my $authorizationHeader = { Authorization => "Bearer $token" };
  90. my $headersTx = ua_req(head => "https://registry-1.docker.io/v2/$repo/blobs/$blob" => $authorizationHeader);
  91. die "failed to get headers for $key" unless $headersTx->success;
  92. return $headers{$key} = $headersTx->res->headers;
  93. }
  94. sub get_layer_data {
  95. my ($repo, $id, $blob, $v1) = @_;
  96. $id //= $v1->{id};
  97. state %layers;
  98. return $layers{$id} if $layers{$id};
  99. die "missing v1/blob data for layer $id" unless $blob and $v1;
  100. my $data = {
  101. map({ $_ => $v1->{$_} } qw(id created parent docker_version)),
  102. container_command => $v1->{container_config}{Cmd},
  103. virtual_size => $v1->{Size} // 0,
  104. blob => $blob,
  105. };
  106. my $blobHeaders = get_blob_headers($repo, $blob);
  107. $data->{blob_content_length} = $blobHeaders->content_length;
  108. $data->{blob_last_modified} = $blobHeaders->last_modified;
  109. return $layers{$id} = $data;
  110. }
  111. sub cmd_to_dockerfile {
  112. my ($cmd) = @_;
  113. if (@$cmd == 1) {
  114. # likely 1.10+ squashed string :(
  115. # https://github.com/docker/docker/issues/22436
  116. # let's strip and "parse" to get as close to readable as we can
  117. my $shC = '/bin/sh -c ';
  118. my $nop = '#(nop) ';
  119. my $str = $cmd->[0];
  120. my @prefix = ();
  121. if ($str =~ s!^[|]\d+ (.*?) (\Q$shC\E)!$2!) {
  122. push @prefix, '# ARGS: ' . $1;
  123. }
  124. if (substr($str, 0, 1) eq '|' && !@prefix) {
  125. # must be something like:
  126. # |6 a=1 b=2 c=3 d=4 e=a b c f=a b " c echo $a
  127. # (and thus impossible to parse as-is)
  128. return '# unable to parse image command string further:' . "\n" . $str;
  129. }
  130. $str =~ s!^\Q$shC\E!!;
  131. unless ($str =~ s!^\Q$nop\E!!) {
  132. # if we don't have "#(nop)", RUN is implied
  133. $str = 'RUN ' . $str;
  134. }
  135. return join "\n", @prefix, $str;
  136. }
  137. my @buildArgs;
  138. if (substr($cmd->[0], 0, 1) eq '|') {
  139. # must have some build args for this RUN line
  140. # https://github.com/docker/docker/blob/a7742e437943bb0c59cc9e01fd9f5e68259ad3ec/builder/dockerfile/dispatchers.go#L353-L365
  141. my $n = int(substr(shift(@$cmd), 1)); # number of build args
  142. while ($n > 0) {
  143. my $arg = shift @$cmd;
  144. $arg =~ s/(["\\])/\\$1/g;
  145. my ($var, $val) = split /=/, $arg, 2;
  146. push @buildArgs, '"' . $var . '" => "' . $val .'"';
  147. --$n;
  148. }
  149. }
  150. my $args = join('', map { "# ARG: $_\n" } @buildArgs);
  151. if (scalar(@$cmd) == 3 && $cmd->[0] eq '/bin/sh' && $cmd->[1] eq '-c') {
  152. $cmd = $cmd->[2];
  153. if ($cmd =~ s{^(#[(]nop[)] )}{}) {
  154. return $args . $cmd;
  155. }
  156. # prefix tabs and 4-space-indents with \ and a newline (for readability), but only if we don't already have any newlines
  157. $cmd =~ s/ ( (?:\t|[ ]{4})+ ) /\\\n$1/xg unless $cmd =~ m!\n!;
  158. return $args . 'RUN ' . $cmd;
  159. }
  160. return $args . 'RUN ' . Mojo::JSON::encode_json($cmd);
  161. }
  162. my @humanSizeUnits = qw( B KB MB GB TB );
  163. my $humanSizeScale = 1000;
  164. sub human_size {
  165. my ($bytes) = @_;
  166. my $unit = 0;
  167. my $unitBytes = $bytes;
  168. while (($unitBytes = int($bytes / ($humanSizeScale ** $unit))) > $humanSizeScale) {
  169. last unless $humanSizeUnits[$unit + 1];
  170. ++$unit;
  171. }
  172. return sprintf '%.1f %s', $bytes / ($humanSizeScale ** $unit), $humanSizeUnits[$unit];
  173. }
  174. sub size {
  175. my $text = human_size(@_);
  176. $text .= " ($_[0] bytes)" unless $text =~ m! \s+ B $ !x;
  177. return $text;
  178. }
  179. sub date {
  180. my $date = Mojo::Date->new(@_);
  181. return $date->to_string;
  182. }
  183. while (my $image = shift) {
  184. print "\n";
  185. say '## `' . $image . '`';
  186. my ($repo, $tag) = split_image_name($image);
  187. my ($digest, $manifest) = get_manifest($repo, $tag);
  188. unless (defined $digest && defined $manifest) {
  189. # tag must not exist yet!
  190. say "\n", '**does not exist** (yet?)';
  191. next;
  192. }
  193. print "\n";
  194. say '```console';
  195. say '$ docker pull ' . $repo . '@' . $digest;
  196. say '```';
  197. my %parentChild;
  198. my %totals = (
  199. virtual_size => 0,
  200. blob_content_length => 0,
  201. );
  202. for my $i (0 .. $#{ $manifest->{fsLayers} }) {
  203. my $v1 = Mojo::Util::encode 'UTF-8', $manifest->{history}[$i]{v1Compatibility};
  204. my $data = get_layer_data(
  205. $repo, undef,
  206. $manifest->{fsLayers}[$i]{blobSum},
  207. Mojo::JSON::decode_json($v1),
  208. );
  209. $parentChild{$data->{parent} // ''} = $data->{id};
  210. $totals{$_} += $data->{$_} for keys %totals;
  211. }
  212. print "\n";
  213. say "-\t" . 'Total Virtual Size: ' . size($totals{virtual_size}) if $totals{virtual_size};
  214. say "-\t" . 'Total v2 Content-Length: ' . size($totals{blob_content_length});
  215. print "\n";
  216. say '### Layers (' . scalar(keys %parentChild) . ')';
  217. my $cur = $parentChild{''};
  218. while ($cur) {
  219. print "\n";
  220. say '#### `' . $cur . '`';
  221. my $data = get_layer_data($repo, $cur);
  222. if ($data->{container_command}) {
  223. print "\n";
  224. say '```dockerfile';
  225. say cmd_to_dockerfile($data->{container_command});
  226. say '```';
  227. }
  228. print "\n";
  229. say "-\t" . 'Created: ' . date($data->{created}) if $data->{created};
  230. say "-\t" . 'Parent Layer: `' . $data->{parent} . '`' if $data->{parent};
  231. say "-\t" . 'Docker Version: ' . $data->{docker_version} if $data->{docker_version};
  232. say "-\t" . 'Virtual Size: ' . size($data->{virtual_size}) if $totals{virtual_size};
  233. say "-\t" . 'v2 Blob: `' . $data->{blob} . '`';
  234. say "-\t" . 'v2 Content-Length: ' . size($data->{blob_content_length});
  235. say "-\t" . 'v2 Last-Modified: ' . date($data->{blob_last_modified}) if $data->{blob_last_modified};
  236. $cur = $parentChild{$cur};
  237. }
  238. }