push.pl 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. #!/usr/bin/env perl
  2. use strict;
  3. use warnings;
  4. use 5.010;
  5. use open ':encoding(utf8)';
  6. use File::Basename qw(basename fileparse);
  7. use File::Temp;
  8. use Getopt::Long;
  9. use Mojo::File;
  10. use Mojo::JSON qw(decode_json);
  11. use Mojo::UserAgent;
  12. use Mojo::Util qw(decode encode trim url_escape);
  13. use Term::UI;
  14. use Term::ReadLine;
  15. my $hubLengthLimit = 25_000;
  16. my $githubBase = 'https://github.com/docker-library/docs/tree/master'; # TODO point this at the correct "dist-xxx" branch based on "namespace"
  17. my $username;
  18. my $password;
  19. my $batchmode;
  20. my $namespace;
  21. my $logos;
  22. GetOptions(
  23. 'u|username=s' => \$username,
  24. 'p|password=s' => \$password,
  25. 'batchmode!' => \$batchmode,
  26. 'namespace=s' => \$namespace,
  27. 'logos!' => \$logos,
  28. ) or die 'bad args';
  29. die 'no repos specified' unless @ARGV;
  30. my $ua = Mojo::UserAgent->new->max_redirects(10);
  31. $ua->transactor->name('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36');
  32. my $term = Term::ReadLine->new('docker-library-docs-push');
  33. unless (defined $username) {
  34. $username = $term->get_reply(prompt => 'Hub Username');
  35. }
  36. unless (defined $password) {
  37. $password = $term->get_reply(prompt => 'Hub Password'); # TODO hide the input? O:)
  38. }
  39. my $dockerHub = 'https://hub.docker.com';
  40. my $login = $ua->post($dockerHub . '/v2/users/login/' => {} => json => { username => $username, password => $password });
  41. die 'login failed: ' . $login->res->error->{message} unless $login->res->is_success;
  42. my $token = $login->res->json->{token};
  43. my $authorizationHeader = {
  44. Authorization => "JWT $token",
  45. };
  46. my $supportedTagsRegex = qr%^(# Supported tags and respective `Dockerfile` links\n\n)(.*?\n)(?=# |\[)%ms;
  47. sub prompt_for_edit {
  48. my $currentText = shift;
  49. my $proposedFile = shift;
  50. my $lengthLimit = shift // 0;
  51. my $proposedText = Mojo::File->new($proposedFile)->slurp // '** FILE MISSING! **';
  52. $proposedText = trim(decode('UTF-8', $proposedText));
  53. # remove our warning about generated files (Hub doesn't support HTML comments in Markdown)
  54. $proposedText =~ s% ^ <!-- .*? --> \s* %%sx;
  55. # extract/re-inject sponsored links
  56. my $sponsoredLinks = '';
  57. if ($currentText =~ m{ ( ^ [#] \Q Sponsored Resources\E \n .*? \n --- \n ) }smx) {
  58. $sponsoredLinks = $1 . "\n";
  59. $proposedText =~ s%$supportedTagsRegex%$sponsoredLinks$1$2%;
  60. }
  61. if ($lengthLimit > 0 && length($proposedText) > $lengthLimit) {
  62. # TODO https://github.com/docker/hub-beta-feedback/issues/238
  63. my $fullUrl = "$githubBase/$proposedFile";
  64. my $shortTags = "-\tSee [\"Supported tags and respective \`Dockerfile\` links\" at $fullUrl]($fullUrl#supported-tags-and-respective-dockerfile-links)\n\n";
  65. my $seeAlso = 'See also [docker/hub-feedback#238](https://github.com/docker/hub-feedback/issues/238) and [docker/roadmap#475](https://github.com/docker/roadmap/issues/475).';
  66. my $tagsNote = "**Note:** the description for this image is longer than the Hub length limit of $lengthLimit, so the \"Supported tags\" list has been trimmed to compensate. $seeAlso\n\n$shortTags";
  67. my $genericNote = "**Note:** the description for this image is longer than the Hub length limit of $lengthLimit, so has been trimmed. The full description can be found at [$fullUrl]($fullUrl). $seeAlso";
  68. my $startingNote = $genericNote . "\n\n";
  69. my $endingNote = "\n\n...\n\n" . $genericNote;
  70. my $trimmedText = $proposedText;
  71. # if our text is too long for the Hub length limit, let's first try removing the "Supported tags" list and add $tagsNote and see if that's enough to let us put the full image documentation
  72. $trimmedText =~ s%$supportedTagsRegex%$sponsoredLinks$1$tagsNote%ms;
  73. # (we scrape until the next "h1" or a line starting with a link which is likely a build status badge for an architecture-namespace)
  74. if (length($trimmedText) > $lengthLimit) {
  75. # ... if that doesn't do the trick, then do our older naïve description trimming
  76. $trimmedText = $startingNote . substr $proposedText, 0, ($lengthLimit - length($startingNote . $endingNote));
  77. # adding the "ending note" (https://github.com/docker/hub-feedback/issues/2220) is a bit more complicated as we have to deal with cutting off markdown ~cleanly so it renders correctly
  78. # TODO deal with "```foo" appropriately (so we don't drop our note in the middle of a code block) - the Hub's current markdown rendering (2022-04-07) does not auto-close a dangling block like this, so this isn't urgent
  79. if ($trimmedText =~ m/\n$/) {
  80. # if we already end with a newline, we should be fine to just trim newlines and add our ending note
  81. $trimmedText =~ s/\n+$//;
  82. }
  83. else {
  84. # otherwise, we need to get a little bit more creative and trim back to the last fully blank line (which we can reasonably assume is safe thanks to our markdownfmt)
  85. $trimmedText =~ s/\n\n(.\n?)*$//;
  86. }
  87. $trimmedText .= $endingNote;
  88. }
  89. $proposedText = $trimmedText;
  90. }
  91. return $currentText if $currentText eq $proposedText;
  92. my @proposedFileBits = fileparse($proposedFile, qr!\.[^.]*!);
  93. my $file = File::Temp->new(SUFFIX => '-' . basename($proposedFileBits[1]) . '-current' . $proposedFileBits[2]);
  94. my $filename = $file->filename;
  95. Mojo::File->new($filename)->spurt(encode('UTF-8', $currentText . "\n"));
  96. my $tempProposedFile = File::Temp->new(SUFFIX => '-' . basename($proposedFileBits[1]) . '-proposed' . $proposedFileBits[2]);
  97. my $tempProposedFilename = $tempProposedFile->filename;
  98. Mojo::File->new($tempProposedFilename)->spurt(encode('UTF-8', $proposedText . "\n"));
  99. system(qw(git --no-pager diff --no-index), $filename, $tempProposedFilename);
  100. my $reply;
  101. if ($batchmode) {
  102. $reply = 'yes';
  103. }
  104. else {
  105. $reply = $term->get_reply(
  106. prompt => 'Apply changes?',
  107. choices => [ qw( yes vimdiff no quit ) ],
  108. default => 'yes',
  109. );
  110. }
  111. if ($reply eq 'quit') {
  112. say 'quitting, as requested';
  113. exit;
  114. }
  115. if ($reply eq 'yes') {
  116. return $proposedText;
  117. }
  118. if ($reply eq 'vimdiff') {
  119. system('vimdiff', $tempProposedFilename, $filename) == 0 or die "vimdiff on $filename and $proposedFile failed";
  120. return trim(decode('UTF-8', Mojo::File->new($filename)->slurp));
  121. }
  122. return $currentText;
  123. }
  124. while (my $repo = shift) { # 'library/hylang', 'tianon/perl', etc
  125. $repo =~ s!^/+|/+$!!; # trim extra slashes (from "*/" globbing, for example)
  126. $repo = $namespace . '/' . $repo if $namespace; # ./push.pl --namespace xxx ...
  127. $repo = 'library/' . $repo unless $repo =~ m!/!; # "hylang" -> "library/hylang"
  128. my $repoName = $repo;
  129. $repoName =~ s!^.*/!!; # 'hylang', 'perl', etc
  130. my $repoUrl = $dockerHub . '/v2/repositories/' . $repo . '/';
  131. if ($logos && $repo =~ m{ ^ library/ }x) {
  132. # only DOI ("library"), DSOS, or DVP orgs can include a logo which is displayed in the Hub UI
  133. # if we have a logo file, let's update that metadata first
  134. my $repoLogo120 = $repoName . '/logo-120.png';
  135. if (!-f $repoLogo120) {
  136. my $repoLogoPng = $repoName . '/logo.png';
  137. my $repoLogoSvg = $repoName . '/logo.svg';
  138. my $logoToConvert = (
  139. -f $repoLogoPng
  140. ? $repoLogoPng
  141. : $repoLogoSvg
  142. );
  143. if (-f $logoToConvert) {
  144. say 'converting ' . $logoToConvert . ' to ' . $repoLogo120;
  145. system(
  146. qw( convert -background none -density 1200 -strip -resize 120x120> -gravity center -extent 120x120 ),
  147. $logoToConvert,
  148. $repoLogo120,
  149. ) == 0 or die "failed to convert $logoToConvert into $repoLogo120";
  150. }
  151. }
  152. my $logoUrlBase = $dockerHub . '/api/media/repos_logo/v1/' . url_escape($repo);
  153. if (-f $repoLogo120) {
  154. my $proposedLogo = Mojo::File->new($repoLogo120)->slurp;
  155. my $currentLogo = $ua->get($logoUrlBase, { 'Cache-Control' => 'no-cache' });
  156. $currentLogo = ($currentLogo->res->is_success ? $currentLogo->res->body : undef);
  157. if ($currentLogo && $currentLogo eq $proposedLogo) {
  158. say 'no change to ' . $repoName . ' logo; skipping';
  159. }
  160. else {
  161. say 'putting logo ' . $repoLogo120;
  162. my $logoUpload = $ua->post($logoUrlBase . '/upload' => { %$authorizationHeader, 'Content-Type' => 'image/png' } => $proposedLogo);
  163. die 'POST to ' . $logoUrlBase . '/upload failed: ' . $logoUpload->res->text unless $logoUpload->res->is_success;
  164. }
  165. } else {
  166. # if we had no logo file, we should send a DELETE request to the API just to be sure we're synchronizing the repo state appropriately even on complete logo removal
  167. say 'no ' . $repoLogo120 . '; deleting logo';
  168. my $logoDelete = $ua->delete($logoUrlBase => $authorizationHeader);
  169. die 'DELETE to ' . $logoUrlBase . ' failed: ' . $logoDelete->res->text unless $logoDelete->res->is_success or $logoDelete->res->code == 404;
  170. }
  171. }
  172. my $repoTx = $ua->get($repoUrl => $authorizationHeader);
  173. warn 'warning: failed to get: ' . $repoUrl . ' (skipping)' and next unless $repoTx->res->is_success;
  174. my $repoDetails = $repoTx->res->json;
  175. $repoDetails->{description} //= '';
  176. $repoDetails->{full_description} //= '';
  177. $repoDetails->{categories} //= [];
  178. my @repoCategories = sort map { $_->{slug} } @{ $repoDetails->{categories} };
  179. # read local categories from metadata.json
  180. my $repoMetadataBytes = Mojo::File->new($repoName . '/metadata.json')->slurp;
  181. my $repoMetadataJson = decode_json $repoMetadataBytes;
  182. my @localRepoCategories = sort @{ $repoMetadataJson->{hub}{categories} };
  183. # check if the local categories differ in length or items from the remote
  184. my $needCat = @localRepoCategories != @repoCategories;
  185. if (! $needCat) {
  186. foreach my $i (0 .. @localRepoCategories) {
  187. last if ! defined $repoCategories[$i]; # length difference already covered, so we can bail
  188. if ($localRepoCategories[$i] ne $repoCategories[$i]) {
  189. $needCat = 1;
  190. last;
  191. }
  192. }
  193. }
  194. if ($needCat) {
  195. say 'updating ' . $repoName . ' categories';
  196. my $catsPatch = $ua->patch($repoUrl . 'categories/' => { %$authorizationHeader, Accept => 'application/json' } => json => [
  197. map { {
  198. slug => $_,
  199. name => 'All those moments will be lost in time, like tears in rain... Time to die.',
  200. } } @{ $repoMetadataJson->{hub}{categories} }
  201. ]);
  202. die 'patch to categories failed: ' . $catsPatch->res->text unless $catsPatch->res->is_success;
  203. }
  204. my $hubShort = prompt_for_edit($repoDetails->{description}, $repoName . '/README-short.txt');
  205. my $hubLong = prompt_for_edit($repoDetails->{full_description}, $repoName . '/README.md', $hubLengthLimit);
  206. say 'no change to ' . $repoName . '; skipping' and next if $repoDetails->{description} eq $hubShort and $repoDetails->{full_description} eq $hubLong;
  207. say 'updating ' . $repoName;
  208. my $repoPatch = $ua->patch($repoUrl => $authorizationHeader => json => {
  209. description => $hubShort,
  210. full_description => $hubLong,
  211. });
  212. die 'patch to ' . $repoUrl . ' failed: ' . $repoPatch->res->text unless $repoPatch->res->is_success;
  213. }