put-multiarch.pl 17 KB

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