put-multiarch.pl 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. #!/usr/bin/env perl
  2. use Mojo::Base -strict, -signatures;
  3. # this is a replacement for "bashbrew put-shared" (without "--single-arch") to combine many architecture-specific repositories into manifest lists in a separate repository
  4. # for example, combining amd64/bash:latest, arm32v5/bash:latest, ..., s390x/bash:latest into a single library/bash:latest manifest list
  5. # (in a more efficient way than manifest-tool can do generically such that we can reasonably do 3700+ no-op tag pushes individually in ~9 minutes)
  6. use Digest::SHA;
  7. use Mojo::Promise;
  8. use Mojo::UserAgent;
  9. use Mojo::Util;
  10. my $publicProxy = $ENV{DOCKERHUB_PUBLIC_PROXY} || die 'missing DOCKERHUB_PUBLIC_PROXY env (https://github.com/tianon/dockerhub-public-proxy)';
  11. my $dryRun = ($ARGV[0] || '') eq '--dry-run';
  12. shift @ARGV if $dryRun;
  13. my $ua = Mojo::UserAgent->new->max_redirects(10)->connect_timeout(120)->inactivity_timeout(120);
  14. $ua->transactor->name(join ' ',
  15. # https://github.com/docker/docker/blob/v1.11.2/dockerversion/useragent.go#L13-L34
  16. 'docker/1.11.2',
  17. 'go/1.6.2',
  18. 'git-commit/v1.11.2',
  19. 'kernel/4.4.11',
  20. 'os/linux',
  21. 'arch/amd64',
  22. # BOGUS USER AGENTS FOR THE BOGUS USER AGENT THRONE
  23. );
  24. sub ua_retry_simple_req_p ($method, $url, $tries = 10) {
  25. --$tries;
  26. my $lastTry = $tries < 1;
  27. my $methodP = lc($method) . '_p';
  28. my $prom = $ua->$methodP($url);
  29. if (!$lastTry) {
  30. $prom = $prom->then(sub ($tx) {
  31. return $tx if !$tx->error || $tx->res->code == 404 || $tx->res->code == 401;
  32. return ua_retry_simple_req_p($method, $url, $tries);
  33. }, sub {
  34. return ua_retry_simple_req_p($method, $url, $tries);
  35. });
  36. }
  37. return $prom;
  38. }
  39. sub split_image_name ($image) {
  40. if ($image =~ m{
  41. ^
  42. (?: ([^/:]+) / )? # optional namespace
  43. ([^/:]+) # image name
  44. (?: : ([^/:]+) )? # optional tag
  45. $
  46. }x) {
  47. my ($namespace, $name, $tag) = (
  48. $1 // 'library', # namespace
  49. $2, # image name
  50. $3 // 'latest', # tag
  51. );
  52. return ($namespace, $name, $tag);
  53. }
  54. die "unrecognized image name format in: $image";
  55. }
  56. sub arch_to_platform ($arch) {
  57. if ($arch =~ m{
  58. ^
  59. (?: ([^-]+) - )? # optional "os" prefix ("windows-", etc)
  60. ([^-]+?) # "architecture" bit ("arm64", "s390x", etc)
  61. (v[0-9]+)? # optional "variant" suffix ("v7", "v6", etc)
  62. $
  63. }x) {
  64. return (
  65. os => $1 // 'linux',
  66. architecture => (
  67. $2 eq 'i386'
  68. ? '386'
  69. : (
  70. $2 eq 'arm32'
  71. ? 'arm'
  72. : $2
  73. )
  74. ),
  75. ($3 ? (variant => $3) : ()),
  76. );
  77. }
  78. die "unrecognized architecture format in: $arch";
  79. }
  80. # TODO make this promise-based and non-blocking?
  81. # https://github.com/jberger/Mojolicious-Plugin-TailLog/blob/master/lib/Mojolicious/Plugin/TailLog.pm#L16-L22
  82. # https://metacpan.org/pod/Capture::Tiny
  83. # https://metacpan.org/pod/Mojo::IOLoop#subprocess
  84. # https://metacpan.org/pod/IO::Async::Process
  85. sub bashbrew (@) {
  86. open my $fh, '-|', 'bashbrew', @_ or die "failed to run 'bashbrew': $!";
  87. local $/;
  88. my $output = <$fh>;
  89. close $fh or die "failed to close 'bashbrew'";
  90. chomp $output;
  91. return $output;
  92. }
  93. sub get_manifest_p ($org, $repo, $ref, $tries = 3) {
  94. --$tries;
  95. my $lastTry = $tries < 1;
  96. state %cache;
  97. if ($ref =~ m!^sha256:! && $cache{$ref}) {
  98. return Mojo::Promise->resolve($cache{$ref});
  99. }
  100. return ua_retry_simple_req_p(GET => "$publicProxy/v2/$org/$repo/manifests/$ref")->then(sub ($tx) {
  101. return if $tx->res->code == 404 || $tx->res->code == 401;
  102. if (!$lastTry && $tx->res->code != 200) {
  103. return get_manifest_p($org, $repo, $ref, $tries);
  104. }
  105. die "unexpected exit code fetching '$org/$repo:$ref': " . $tx->res->code unless $tx->res->code == 200;
  106. my $digest = $tx->res->headers->header('docker-content-digest') or die "'$org/$repo:$ref' is missing 'docker-content-digest' header";
  107. die "malformed 'docker-content-digest' header in '$org/$repo:$ref': '$digest'" unless $digest =~ m!^sha256:!;
  108. my $manifest = $tx->res->json or die "'$org/$repo:$ref' has bad or missing JSON";
  109. my $size = int($tx->res->headers->content_length);
  110. my $verbatim = $tx->res->body;
  111. return $cache{$digest} = {
  112. digest => $digest,
  113. manifest => $manifest,
  114. size => $size,
  115. verbatim => $verbatim,
  116. mediaType => (
  117. $manifest->{schemaVersion} == 1
  118. ? 'application/vnd.docker.distribution.manifest.v1+json'
  119. : (
  120. $manifest->{schemaVersion} == 2
  121. ? $manifest->{mediaType}
  122. : die "unknown schemaVersion for '$org/$repo' at '$ref'"
  123. )
  124. ),
  125. };
  126. });
  127. }
  128. sub get_blob_p ($org, $repo, $ref, $tries = 3) {
  129. die "unexpected blob reference for '$org/$repo': '$ref'" unless $ref =~ m!^sha256:!;
  130. --$tries;
  131. my $lastTry = $tries < 1;
  132. state %cache;
  133. return Mojo::Promise->resolve($cache{$ref}) if $cache{$ref};
  134. return ua_retry_simple_req_p(GET => "$publicProxy/v2/$org/$repo/blobs/$ref")->then(sub ($tx) {
  135. return if $tx->res->code == 404;
  136. if (!$lastTry && $tx->res->code != 200) {
  137. return get_blob_p($org, $repo, $ref, $tries);
  138. }
  139. die "unexpected exit code fetching blob from '$org/$repo:$ref'': " . $tx->res->code unless $tx->res->code == 200;
  140. return $cache{$ref} = $tx->res->json;
  141. });
  142. }
  143. sub head_manifest_p ($org, $repo, $ref) {
  144. die "unexpected manifest reference for HEAD '$org/$repo': '$ref'" unless $ref =~ m!^sha256:!;
  145. my $cacheKey = "$org/$repo:$ref";
  146. state %cache;
  147. return Mojo::Promise->resolve($cache{$cacheKey}) if $cache{$cacheKey};
  148. return ua_retry_simple_req_p(HEAD => "$publicProxy/v2/$org/$repo/manifests/$ref")->then(sub ($tx) {
  149. return 0 if $tx->res->code == 404 || $tx->res->code == 401;
  150. die "unexpected exit code HEADing manifest '$cacheKey': " . $tx->res->code unless $tx->res->code == 200;
  151. return $cache{$cacheKey} = 1;
  152. });
  153. }
  154. sub head_blob_p ($org, $repo, $ref) {
  155. die "unexpected blob reference for HEAD '$org/$repo': '$ref'" unless $ref =~ m!^sha256:!;
  156. my $cacheKey = "$org/$repo:$ref";
  157. state %cache;
  158. return Mojo::Promise->resolve($cache{$cacheKey}) if $cache{$cacheKey};
  159. return ua_retry_simple_req_p(HEAD => "$publicProxy/v2/$org/$repo/blobs/$ref")->then(sub ($tx) {
  160. return 0 if $tx->res->code == 404 || $tx->res->code == 401;
  161. die "unexpected exit code HEADing blob '$cacheKey': " . $tx->res->code unless $tx->res->code == 200;
  162. return $cache{$cacheKey} = 1;
  163. });
  164. }
  165. # get list of manifest list items and necessary blobs for a particular architecture
  166. sub get_arch_p ($targetNamespace, $arch, $archNamespace, $repo, $tag) {
  167. return get_manifest_p($archNamespace, $repo, $tag)->then(sub ($manifestData = undef) {
  168. return unless $manifestData;
  169. my ($digest, $manifest, $size) = ($manifestData->{digest}, $manifestData->{manifest}, $manifestData->{size});
  170. my $mediaType = $manifestData->{mediaType};
  171. if ($mediaType eq 'application/vnd.docker.distribution.manifest.list.v2+json') {
  172. # jackpot -- if it's already a manifest list, the hard work is done!
  173. return ($archNamespace, $manifest->{manifests});
  174. }
  175. if ($mediaType eq 'application/vnd.docker.distribution.manifest.v1+json' || $mediaType eq 'application/vnd.docker.distribution.manifest.v2+json') {
  176. my $manifestListItem = {
  177. mediaType => $mediaType,
  178. size => $size,
  179. digest => $digest,
  180. platform => {
  181. arch_to_platform($arch),
  182. ($manifest->{'os.version'} ? ('os.version' => $manifest->{'os.version'}) : ()),
  183. },
  184. };
  185. if ($manifestListItem->{platform}{os} eq 'windows' && !$manifestListItem->{platform}{'os.version'} && $mediaType eq 'application/vnd.docker.distribution.manifest.v2+json') {
  186. # if we're on Windows, we need to make an effort to fetch the "os.version" value from the config for the platform object
  187. return get_blob_p($archNamespace, $repo, $manifest->{config}{digest})->then(sub ($config = undef) {
  188. if ($config && $config->{'os.version'}) {
  189. $manifestListItem->{platform}{'os.version'} = $config->{'os.version'};
  190. }
  191. return ($archNamespace, [ $manifestListItem ]);
  192. });
  193. }
  194. else {
  195. return ($archNamespace, [ $manifestListItem ]);
  196. }
  197. }
  198. die "unknown mediaType '$mediaType' for '$archNamespace/$repo:$tag'";
  199. });
  200. }
  201. sub needed_artifacts_p ($targetNamespace, $sourceNamespace, $repo, $manifestDigest) {
  202. return head_manifest_p($targetNamespace, $repo, $manifestDigest)->then(sub ($exists) {
  203. return if $exists;
  204. return get_manifest_p($sourceNamespace, $repo, $manifestDigest)->then(sub ($manifestData = undef) {
  205. return unless $manifestData;
  206. my $manifest = $manifestData->{manifest};
  207. my $schemaVersion = $manifest->{schemaVersion};
  208. my @blobs;
  209. if ($schemaVersion == 1) {
  210. push @blobs, map { $_->{blobSum} } @{ $manifest->{fsLayers} };
  211. }
  212. elsif ($schemaVersion == 2) {
  213. die "this should never happen: $manifest->{mediaType}" unless $manifest->{mediaType} eq 'application/vnd.docker.distribution.manifest.v2+json'; # sanity check
  214. push @blobs, $manifest->{config}{digest}, map { $_->{urls} ? () : $_->{digest} } @{ $manifest->{layers} };
  215. }
  216. else {
  217. die "this should never happen: $schemaVersion"; # sanity check
  218. }
  219. return Mojo::Promise->all(
  220. Mojo::Promise->resolve([ $sourceNamespace, $repo, 'manifest', $manifestDigest ]),
  221. Mojo::Promise->map({ concurrency => 3 }, sub ($blob) {
  222. return head_blob_p($targetNamespace, $repo, $blob)->then(sub ($exists) {
  223. return if $exists;
  224. return $sourceNamespace, $repo, 'blob', $blob;
  225. });
  226. }, @blobs),
  227. )->then(sub { map { @$_ } @_ });
  228. });
  229. });
  230. }
  231. sub get_dockerhub_creds {
  232. die 'missing DOCKER_CONFIG or HOME environment variable' unless $ENV{DOCKER_CONFIG} or $ENV{HOME};
  233. my $config = Mojo::File->new(($ENV{DOCKER_CONFIG} || ($ENV{HOME} . '/.docker')) . '/config.json')->slurp;
  234. die 'missing or empty ".docker/config.json" file' unless $config;
  235. my $json = Mojo::JSON::decode_json($config);
  236. die 'invalid ".docker/config.json" file' unless $json && $json->{auths};
  237. for my $registry (keys %{ $json->{auths} }) {
  238. my $auth = $json->{auths}{$registry}{auth};
  239. next unless $auth;
  240. if ($registry eq 'https://index.docker.io/v1/' || $registry eq 'index.docker.io') {
  241. $auth = Mojo::Util::b64_decode($auth);
  242. return $auth if $auth && $auth =~ m!:!;
  243. }
  244. }
  245. die 'failed to find credentials for Docker Hub in ".docker/config.json" file';
  246. }
  247. sub authenticated_registry_req_p ($method, $repos, $url, $contentType = undef, $payload = undef, $tries = 10) {
  248. --$tries;
  249. my $lastTry = $tries < 1;
  250. my %headers = ($contentType ? ('Content-Type' => $contentType) : ());
  251. state %tokens;
  252. if (my $token = $tokens{$repos}) {
  253. $headers{Authorization} = "Bearer $token";
  254. }
  255. my $methodP = lc($method) . '_p';
  256. my $fullUrl = "https://registry-1.docker.io/v2/$url";
  257. my $prom = $ua->$methodP($fullUrl, \%headers, ($payload ? $payload : ()));
  258. if (!$lastTry) {
  259. $prom = $prom->then(sub ($tx) {
  260. if (!$lastTry && $tx->res->code == 401) {
  261. # "Unauthorized" -- we must need to go fetch a token for this registry request (so let's go do that, then retry the original registry request)
  262. my $auth = $tx->res->headers->www_authenticate;
  263. die "unexpected WWW-Authenticate header ('$url'): $auth" unless $auth =~ m{ ^ Bearer \s+ (\S.*) $ }x;
  264. my $realm = $1;
  265. my $authUrl = Mojo::URL->new;
  266. while ($realm =~ m{
  267. # key="val",
  268. ([^=]+)
  269. =
  270. "([^"]+)"
  271. ,?
  272. }xg) {
  273. my ($key, $val) = ($1, $2);
  274. next if $key eq 'error' and $val eq 'invalid_token'; # just ignore the error if it's "invalid_token" because it likely means our token expired mid-push so we just need to renew
  275. die "WWW-Authenticate header error ('$url'): $val ($auth)" if $key eq 'error';
  276. if ($key eq 'realm') {
  277. $authUrl->base(Mojo::URL->new($val));
  278. }
  279. else {
  280. $authUrl->query->append($key => $_) for split / /, $val; # Docker's auth server expects "scope=xxx&scope=yyy" instead of "scope=xxx%20yyy"
  281. }
  282. }
  283. $authUrl = $authUrl->to_abs;
  284. say {*STDERR} "Note: grabbing auth token from $authUrl (for $fullUrl; $tries tries remain)";
  285. my $dockerhubCreds = get_dockerhub_creds();
  286. return ua_retry_simple_req_p(GET => $authUrl->userinfo($dockerhubCreds)->to_unsafe_string)->then(sub ($tx) {
  287. if (my $error = $tx->error) {
  288. die "registry authentication error ('$url'): " . ($error->{code} ? $error->{code} . ' -- ' : '') . $error->{message};
  289. }
  290. $tokens{$repos} = $tx->res->json->{token};
  291. return authenticated_registry_req_p($method, $repos, $url, $contentType, $payload, $tries);
  292. });
  293. }
  294. if (!$lastTry && $tx->res->code != 200) {
  295. return authenticated_registry_req_p($method, $repos, $url, $contentType, $payload, $tries);
  296. }
  297. if (my $error = $tx->error) {
  298. $tx->req->headers->authorization('REDATCTED') if $tx->req->headers->authorization;
  299. die "registry request error ('$url'): " . ($error->{code} ? $error->{code} . ' -- ' : '') . $error->{message} . "\n\nREQUEST:\n" . $tx->req->headers->to_string . "\n\n" . $tx->req->body . "\n\nRESPONSE:\n" . $tx->res->to_string . "\n";
  300. }
  301. return $tx;
  302. }, sub {
  303. return authenticated_registry_req_p($method, $repos, $url, $contentType, $payload, $tries);
  304. });
  305. }
  306. return $prom;
  307. }
  308. Mojo::Promise->map({ concurrency => 8 }, sub ($img) {
  309. my ($org, $repo, $tag) = split_image_name($img);
  310. die "image '$img' is missing explict namespace -- bailing to avoid accidental push to '$org'" unless $img =~ m!/!;
  311. my @tags = (
  312. $img =~ m/:/
  313. ? ( "$repo:$tag" )
  314. : ( List::Util::uniq sort split /\n/, bashbrew('list', $repo) )
  315. );
  316. return Mojo::Promise->map({ concurrency => 1 }, sub ($repoTag) {
  317. my (undef, $repo, $tag) = split_image_name($repoTag);
  318. my @arches = List::Util::uniq sort split /\n/, bashbrew('cat', '--format', '{{ range .Entries }}{{ range .Architectures }}{{ . }}={{ archNamespace . }}{{ "\n" }}{{ end }}{{ end }}', "$repo:$tag");
  319. return Mojo::Promise->map({ concurrency => 1 }, sub ($archData) {
  320. my ($arch, $archNamespace) = split /=/, $archData;
  321. return get_arch_p($org, $arch, $archNamespace, $repo, $tag);
  322. }, @arches)->then(sub (@archResponses) {
  323. my @manifestListItems;
  324. my @neededArtifactPromises;
  325. for my $archResponse (@archResponses) {
  326. next unless @$archResponse;
  327. my ($archNamespace, $manifestListItems) = @$archResponse;
  328. push @manifestListItems, @$manifestListItems;
  329. push @neededArtifactPromises, map { my $digest = $_->{digest}; sub { needed_artifacts_p($org, $archNamespace, $repo, $digest) } } @$manifestListItems;
  330. }
  331. my $manifestList = {
  332. schemaVersion => 2,
  333. mediaType => 'application/vnd.docker.distribution.manifest.list.v2+json',
  334. manifests => \@manifestListItems,
  335. };
  336. my $manifestListJson = Mojo::JSON::encode_json($manifestList);
  337. my $manifestListDigest = 'sha256:' . Digest::SHA::sha256_hex($manifestListJson);
  338. return head_manifest_p($org, $repo, $manifestListDigest)->then(sub ($exists) {
  339. # if we already have the manifest we're planning to push in the namespace where we plan to push it, we can skip all blob mounts! \m/
  340. return if $exists;
  341. # (we can also skip if we're in "dry run" mode since we only care about the final manifest matching in that case)
  342. return if $dryRun;
  343. return (
  344. @neededArtifactPromises
  345. ? Mojo::Promise->map({ concurrency => 1 }, sub { $_->() }, @neededArtifactPromises)
  346. : Mojo::Promise->resolve
  347. )->then(sub (@neededArtifacts) {
  348. @neededArtifacts = map { @$_ } @neededArtifacts;
  349. # now "@neededArtifacts" is a list of tuples of the format [ sourceNamespace, sourceRepo, type, digest ], ready for cross-repo mounting / PUTing (where type is "blob" or "manifest")
  350. my @mountBlobPromises;
  351. my @putManifestPromises;
  352. for my $neededArtifact (@neededArtifacts) {
  353. next unless @$neededArtifact;
  354. my ($sourceNamespace, $sourceRepo, $type, $digest) = @$neededArtifact;
  355. if ($type eq 'blob') {
  356. # https://docs.docker.com/registry/spec/api/#mount-blob
  357. push @mountBlobPromises, sub { authenticated_registry_req_p(POST => "$org/$repo:push,$sourceNamespace/$sourceRepo:pull" => "$org/$repo/blobs/uploads/?mount=$digest&from=$sourceNamespace/$sourceRepo") };
  358. }
  359. elsif ($type eq 'manifest') {
  360. push @putManifestPromises, sub { get_manifest_p($sourceNamespace, $sourceRepo, $digest)->then(sub ($manifestData = undef) {
  361. return unless $manifestData;
  362. return authenticated_registry_req_p(PUT => "$org/$repo:push" => "$org/$repo/manifests/$digest" => $manifestData->{mediaType} => $manifestData->{verbatim});
  363. }) };
  364. }
  365. else {
  366. die "this should never happen: $type"; # sanity check
  367. }
  368. }
  369. # mount any necessary blobs
  370. return (
  371. @mountBlobPromises
  372. ? Mojo::Promise->map({ concurrency => 1 }, sub { $_->() }, @mountBlobPromises)
  373. : Mojo::Promise->resolve
  374. )->then(sub {
  375. # ... *then* push any missing image manifests (because they'll fail to push if the blobs aren't pushed first)
  376. if (@putManifestPromises) {
  377. return Mojo::Promise->map({ concurrency => 1 }, sub { $_->() }, @putManifestPromises);
  378. }
  379. return;
  380. });
  381. });
  382. })->then(sub {
  383. # let's do one final check of the tag we're pushing to see if it's already the manifest we expect it to be (to avoid making literally every image constantly "Updated a few seconds ago" all the time)
  384. return get_manifest_p($org, $repo, $tag)->then(sub ($manifestData = undef) {
  385. if ($manifestData && $manifestData->{digest} eq $manifestListDigest) {
  386. say "Skipping $org/$repo:$tag ($manifestListDigest)" unless $dryRun; # if we're in "dry run" mode, we need clean output
  387. return;
  388. }
  389. if ($dryRun) {
  390. say "Would push $org/$repo:$tag ($manifestListDigest)";
  391. return;
  392. }
  393. # finally, all necessary blobs and manifests are pushed, we've verified that we do in fact need to push this manifest, so we should be golden to push it!
  394. return authenticated_registry_req_p(PUT => "$org/$repo:push" => "$org/$repo/manifests/$tag" => $manifestList->{mediaType} => $manifestListJson)->then(sub ($tx) {
  395. my $digest = $tx->res->headers->header('Docker-Content-Digest');
  396. say "Pushed $org/$repo:$tag ($digest)";
  397. say {*STDERR} "WARNING: expected '$manifestListDigest', got '$digest' (for '$org/$repo:$tag')" unless $manifestListDigest eq $digest;
  398. });
  399. });
  400. });
  401. });
  402. }, @tags);
  403. }, @ARGV)->catch(sub {
  404. say {*STDERR} "ERROR: $_" for @_;
  405. exit scalar @_;
  406. })->wait;