Skip to content

Commit b5a3cc0

Browse files
committed
Add internal api to sync machines before purging cdn cache
1 parent ec88304 commit b5a3cc0

File tree

6 files changed

+139
-5
lines changed

6 files changed

+139
-5
lines changed

config/services.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ parameters:
1313
trusted_ip_header: ''
1414
github.webhook_secret: '%env(APP_GITHUB_WEBHOOK_SECRET)%'
1515
fallback_gh_tokens: []
16+
replica_ips: []
1617

1718
services:
1819
# default configuration for services in *this* file
@@ -44,6 +45,7 @@ services:
4445
$mailFromName: "%env(APP_MAILER_FROM_NAME)%"
4546
$fallbackGhTokens: "%fallback_gh_tokens%"
4647
$configDir: '%kernel.project_dir%/config/'
48+
$internalSecret: '%env(APP_SECRET)%'
4749

4850
# makes classes in src/ available to be used as services
4951
# this creates a service per class whose id is the fully-qualified class name
@@ -130,3 +132,7 @@ services:
130132
$metadataPublicEndpoint: '%env(default::CDN_METADATA_PUBLIC_ENDPOINT)%'
131133
$metadataApiKey: '%env(default::CDN_METADATA_API_KEY)%'
132134
$cdnApiKey: '%env(default::CDN_API_KEY)%'
135+
136+
App\Service\ReplicaClient:
137+
arguments:
138+
$replicaIps: '%replica_ips%'

src/Controller/InternalController.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types=1);
2+
3+
/*
4+
* This file is part of Packagist.
5+
*
6+
* (c) Jordi Boggiano <[email protected]>
7+
* Nils Adermann <[email protected]>
8+
*
9+
* For the full copyright and license information, please view the LICENSE
10+
* file that was distributed with this source code.
11+
*/
12+
13+
namespace App\Controller;
14+
15+
use Psr\Log\LoggerInterface;
16+
use Symfony\Component\HttpFoundation\IpUtils;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\Response;
19+
use Symfony\Component\Routing\Attribute\Route;
20+
21+
class InternalController extends Controller
22+
{
23+
public function __construct(
24+
private string $internalSecret,
25+
private string $metadataDir,
26+
private LoggerInterface $logger,
27+
) {
28+
}
29+
30+
#[Route(path: '/internal/update-metadata', name: 'internal_update_metadata', defaults: ['_format' => 'json'], methods: ['POST'])]
31+
public function updateMetadataAction(Request $req): Response
32+
{
33+
if ($req->getClientIp() === null || !IpUtils::isPrivateIp($req->getClientIp())) {
34+
$this->logger->error('Non-internal IP on internal IP');
35+
throw $this->createAccessDeniedException();
36+
}
37+
38+
$path = $req->request->getString('path');
39+
$contents = $req->request->getString('contents');
40+
$filemtime = $req->request->getInt('filemtime');
41+
$sig = (string) $req->headers->get('Internal-Signature');
42+
43+
$expectedSig = hash_hmac('sha256', $path.$contents.$filemtime, $this->internalSecret);
44+
if (!hash_equals($expectedSig, $sig)) {
45+
$this->logger->error('Invalid signature', ['contents' => $contents, 'path' => $path, 'filemtime' => $filemtime, 'sig' => $sig]);
46+
throw $this->createAccessDeniedException();
47+
}
48+
49+
$gzipped = gzencode($contents, 7);
50+
if (false === $gzipped) {
51+
throw new \RuntimeException('Failed gzencoding '.$contents);
52+
}
53+
54+
$path = $this->metadataDir . '/' . $path;
55+
file_put_contents($path.'.tmp', $gzipped);
56+
touch($path.'.tmp', $filemtime);
57+
rename($path.'.tmp', $path);
58+
59+
return new Response('OK', Response::HTTP_ACCEPTED);
60+
}
61+
}

src/Package/V2Dumper.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use App\Entity\PackageFreezeReason;
1818
use App\Entity\SecurityAdvisory;
1919
use App\Service\CdnClient;
20+
use App\Service\ReplicaClient;
2021
use Composer\Pcre\Preg;
2122
use Doctrine\DBAL\ArrayParameterType;
2223
use Symfony\Component\Filesystem\Filesystem;
@@ -54,6 +55,7 @@ public function __construct(
5455
private ProviderManager $providerManager,
5556
private Logger $logger,
5657
private readonly CdnClient $cdnClient,
58+
private readonly ReplicaClient $replicaClient,
5759
private readonly UrlGeneratorInterface $router,
5860
) {
5961
$webDir = realpath($webDir);
@@ -384,7 +386,24 @@ private function writeV2File(Package $package, string $name, string $path, strin
384386

385387
assert(isset($filemtime));
386388

387-
$this->writeFileAtomic($path, $contents, intval(ceil($filemtime/10000)));
389+
$timeUnix = intval(ceil($filemtime/10000));
390+
$this->writeFileAtomic($path, $contents, $timeUnix);
391+
392+
$retries = 3;
393+
do {
394+
try {
395+
$this->replicaClient->uploadMetadata($relativePath, $contents, $timeUnix);
396+
break;
397+
} catch (TransportExceptionInterface $e) {
398+
if ($retries === 0) {
399+
throw $e;
400+
}
401+
$this->logger->debug('Retrying due to failure', ['exception' => $e]);
402+
sleep(1);
403+
}
404+
} while ($retries-- > 0);
405+
406+
$this->cdnClient->purgeMetadataCache($relativePath);
388407

389408
$this->redis->zadd('metadata-dumps', [$pkgWithDevFlag => $filemtime]);
390409
$this->statsd->increment('packagist.metadata_dump_v2');

src/Service/CdnClient.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,20 @@ public function uploadMetadata(string $path, string $contents): int
4242
]);
4343
$time = strtotime($resp->getHeaders()['last-modified'][0]) * 10000;
4444

45+
return $time;
46+
}
47+
48+
public function purgeMetadataCache(string $path): void
49+
{
4550
$resp = $this->httpClient->request('POST', 'https://api.bunny.net/purge?'.http_build_query(['url' => $this->metadataPublicEndpoint.$path, 'async' => 'true']), [
4651
'headers' => [
4752
'AccessKey' => $this->cdnApiKey,
4853
],
4954
]);
5055
// wait for status code at least
5156
if ($resp->getStatusCode() !== 200) {
52-
$this->logger->error('Failed purging '.$path.' from CDN while writing an update to it', ['filemtime' => $time/10000]);
57+
$this->logger->error('Failed purging '.$path.' from CDN');
5358
}
54-
55-
return $time;
5659
}
5760

5861
public function sendUploadMetadataRequest(string $path, string $contents): ResponseInterface

src/Service/ReplicaClient.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Service;
4+
5+
use Symfony\Component\HttpClient\Exception\TransportException;
6+
use Symfony\Component\HttpFoundation\Response;
7+
use Symfony\Contracts\HttpClient\HttpClientInterface;
8+
9+
class ReplicaClient
10+
{
11+
public function __construct(
12+
private HttpClientInterface $httpClient,
13+
/**
14+
* @var list<string>
15+
*/
16+
private array $replicaIps,
17+
private string $internalSecret,
18+
) {}
19+
20+
public function uploadMetadata(string $path, string $contents, int $filemtime): void
21+
{
22+
$sig = hash_hmac('sha256', $path.$contents.$filemtime, $this->internalSecret);
23+
24+
$resps = [];
25+
foreach ($this->replicaIps as $ip) {
26+
$resps[] = $this->httpClient->request('POST', 'http://'.$ip.'/internal/update-metadata', [
27+
'headers' => [
28+
'Internal-Signature' => $sig,
29+
'Host' => 'packagist.org',
30+
],
31+
'body' => [
32+
'path' => $path,
33+
'contents' => $contents,
34+
'filemtime' => $filemtime,
35+
],
36+
]);
37+
}
38+
39+
foreach ($resps as $resp) {
40+
if ($resp->getStatusCode() !== Response::HTTP_ACCEPTED) {
41+
throw new TransportException('Invalid response code', $resp->getStatusCode());
42+
}
43+
}
44+
}
45+
}

tests/Controller/UserControllerTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
class UserControllerTest extends ControllerTestCase
1919
{
20-
public function testEnableTwoFactoCode(): void
20+
public function testEnableTwoFactorCode(): void
2121
{
2222
$user = self::createUser();
2323
$this->store($user);

0 commit comments

Comments
 (0)