Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run transferbot in a Kubernetes Job #830

Merged
merged 15 commits into from
Jul 30, 2024
Merged
8 changes: 4 additions & 4 deletions app/Http/Controllers/WikiEntityImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Illuminate\Validation\Rule;
use App\Wiki;
use App\WikiEntityImport;
use App\Jobs\WikiEntityImportDummyJob;
use App\Jobs\WikiEntityImportJob;
use Carbon\Carbon;

class WikiEntityImportController extends Controller
Expand All @@ -30,7 +30,7 @@ public function create(Request $request): \Illuminate\Http\JsonResponse
{
$validatedInput = $request->validate([
'wiki' => ['required', 'integer'],
'source_wiki_url' => ['required', 'string'],
'source_wiki_url' => ['required', 'url'],
m90 marked this conversation as resolved.
Show resolved Hide resolved
'entity_ids' => ['required', 'string', function (string $attr, mixed $value, \Closure $fail) {
$chunks = explode(',', $value);
foreach ($chunks as $chunk) {
Expand Down Expand Up @@ -66,7 +66,7 @@ public function create(Request $request): \Illuminate\Http\JsonResponse
'payload' => $request->all(),
]);

dispatch(new WikiEntityImportDummyJob(
dispatch(new WikiEntityImportJob(
wikiId: $wiki->id,
sourceWikiUrl: $validatedInput['source_wiki_url'],
importId: $import->id,
Expand All @@ -83,7 +83,7 @@ public function update(Request $request): \Illuminate\Http\JsonResponse
// access right.
$validatedInput = $request->validate([
'wiki_entity_import' => ['required', 'integer'],
'status' => ['required', Rule::enum(WikiEntityImportStatus::class)],
'status' => ['required', Rule::in([WikiEntityImportStatus::Failed->value, WikiEntityImportStatus::Success->value])],
]);

$import = WikiEntityImport::find($validatedInput['wiki_entity_import']);
Expand Down
34 changes: 0 additions & 34 deletions app/Jobs/WikiEntityImportDummyJob.php

This file was deleted.

240 changes: 240 additions & 0 deletions app/Jobs/WikiEntityImportJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

namespace App\Jobs;

use App\WikiEntityImportStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Config;
use App\Wiki;
use App\WikiEntityImport;
use Carbon\Carbon;
use Maclof\Kubernetes\Client;
use Maclof\Kubernetes\Models\Job as KubernetesJob;

class WikiEntityImportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

/**
* Create a new job instance.
*/
public function __construct(
public int $wikiId,
public string $sourceWikiUrl,
public array $entityIds,
public int $importId,
)
{}

private string $targetWikiUrl;

/**
* Execute the job.
*/
public function handle(Client $kubernetesClient): void
{
$import = null;
try {
$wiki = Wiki::findOrFail($this->wikiId);
$import = WikiEntityImport::findOrFail($this->importId);
$creds = $this->acquireCredentials($wiki->domain);

$this->targetWikiUrl = str_contains($wiki->domain, "localhost")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ugly. I'd be happy to learn about a cleaner solution for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only suggestion I have is we revisit the behaviour of the TransferBot image again and do something like the sidecar proxy thing and always talk to that over http.

I agree this is a nasty leaking of knowing the details about the network environment of the job container into the api.

We could at least tighten it by parsing the url and checking it's the TLD of the FQDN. That would save us having some mystery errors in the future for localhostingofdinnerparties.wikibase.cloud which this would inadvertently catch

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think parsing the domain and then checking its TLD is a good compromise.

? "http://".$wiki->domain
: "https://".$wiki->domain;

$kubernetesJob = new TransferBotKubernetesJob(
kubernetesClient: $kubernetesClient,
wiki: $wiki,
creds: $creds,
entityIds: $this->entityIds,
sourceWikiUrl: $this->sourceWikiUrl,
targetWikiUrl: $this->targetWikiUrl,
importId: $this->importId,
);
$jobName = $kubernetesJob->spawn();
Log::info(
'transferbot job for wiki "'.$wiki->domain.'" was created with name "'.$jobName.'".'
);
} catch (\Exception $ex) {
Log::error('Entity import job failed with error: '.$ex->getMessage());
$import?->update([
'status' => WikiEntityImportStatus::Failed,
'finished_at' => Carbon::now(),
]);
$this->fail(
new \Exception('Error spawning transferbot for wiki '.$this->wikiId.': '.$ex->getMessage()),
);
}
}

private static function acquireCredentials(string $wikiDomain): OAuthCredentials
tarrow marked this conversation as resolved.
Show resolved Hide resolved
{
$response = Http::withHeaders(['host' => $wikiDomain])->asForm()->post(
getenv('PLATFORM_MW_BACKEND_HOST').'/w/api.php?action=wbstackPlatformOauthGet&format=json',
[
'consumerName' => 'WikiEntityImportJob',
'ownerOnly' => '1',
'consumerVersion' => '1',
'grants' => 'basic|highvolume|import|editpage|editprotected|createeditmovepage|uploadfile|uploadeditmovefile|rollback|delete|mergehistory',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure these could be reduced even further, but I am totally lost when debugging and was happy I found a working combo that is not "every permission ever".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised most of these are needed. I'm trying to think if there is a quick way for us to test these

'callbackUrlTail' => '/w/index.php',
],
);

if ($response->status() > 399) {
throw new \Exception('Unexpected status code '.$response->status().' from Mediawiki');
}

$body = $response->json();
if (!$body || $body['wbstackPlatformOauthGet']['success'] !== '1') {
throw new \ErrorException('Unexpected error acquiring oauth credentials for wiki '.$wikiDomain);
}

return OAuthCredentials::unmarshalMediaWikiResponse($body);
}
}

class TransferBotKubernetesJob
{
public function __construct(
public Client $kubernetesClient,
public Wiki $wiki,
public OAuthCredentials $creds,
public array $entityIds,
public string $sourceWikiUrl,
public string $targetWikiUrl,
public int $importId,
){
$this->kubernetesNamespace = Config::get('wbstack.api_job_namespace');
}

private string $kubernetesNamespace;

public function spawn(): string
{
$spec = $this->constructSpec();
$jobSpec = new KubernetesJob($spec);

$this->kubernetesClient->setNamespace($this->kubernetesNamespace);
$jobObject = $this->kubernetesClient->jobs()->apply($jobSpec);
$jobName = data_get($jobObject, 'metadata.name');
if (data_get($jobObject, 'status') === 'Failure' || !$jobName) {
// The k8s client does not fail reliably on 4xx responses, so checking the name
// currently serves as poor man's error handling.
throw new \RuntimeException(
'transferbot creation for wiki "'.$this->wiki->domain.'" failed with message: '.data_get($jobObject, 'message', 'n/a')
);
}
return $jobName;
}

private function constructSpec(): array
{
return [
'metadata' => [
'generateName' => 'run-transferbot-',
'namespace' => $this->kubernetesNamespace,
'labels' => [
'app.kubernetes.io/instance' => $this->wiki->domain,
'app.kubernetes.io/name' => 'run-transferbot',
]
],
'spec' => [
'ttlSecondsAfterFinished' => 0,
'backoffLimit' => 0,
tarrow marked this conversation as resolved.
Show resolved Hide resolved
'template' => [
'metadata' => [
'name' => 'run-transferbot'
],
'spec' => [
'containers' => [
0 => [
'hostNetwork' => true,
'name' => 'run-qs-updater',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably change this from the copy paste.

Indeed inf we are looking for a name I'd be keen on EntityImport and moving away from transferbot (some random chat thread we had one month ago: https://mattermost.wikimedia.de/swe/pl/4kao4tr49inobd4i45iuz8cqqo)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apart from the typo, my reasoning for using transferbot wording here was that this is the name of the image we're running. But maybe ditching this in favor of entity-import is better, yes.

'image' => 'ghcr.io/wbstack/transferbot:1.0.0',
m90 marked this conversation as resolved.
Show resolved Hide resolved
'env' => [
...$this->creds->marshalEnv(),
[
'name' => 'CALLBACK_ON_FAILURE',
'value' => 'curl -H "Accept: application/json" -H "Content-Type: application/json" --data \'{"wiki_entity_import":'.$this->importId.',"status":"failed"}\' -XPATCH http://api-app-backend.default.svc.cluster.local/backend/wiki/updateEntityImport'
],
[
'name' => 'CALLBACK_ON_SUCCESS',
'value' => 'curl -H "Accept: application/json" -H "Content-Type: application/json" --data \'{"wiki_entity_import":'.$this->importId.',"status":"success"}\' -XPATCH http://api-app-backend.default.svc.cluster.local/backend/wiki/updateEntityImport'
],
],
'command' => [
'transferbot',
$this->sourceWikiUrl,
$this->targetWikiUrl,
...$this->entityIds,
],
'resources' => [
'requests' => [
'cpu' => '0.25',
'memory' => '250Mi',
],
'limits' => [
'cpu' => '0.5',
'memory' => '500Mi',
],
],
tarrow marked this conversation as resolved.
Show resolved Hide resolved
]
],
'restartPolicy' => 'Never'
]
]
]
];
}
}

class OAuthCredentials
{
public function __construct(
public string $consumerToken,
public string $consumerSecret,
public string $accessToken,
public string $accessSecret,
)
{}

public static function unmarshalMediaWikiResponse(array $response): OAuthCredentials
{
$data = $response['wbstackPlatformOauthGet']['data'];
return new OAuthCredentials(
consumerToken: $data['consumerKey'],
consumerSecret: $data['consumerSecret'],
accessToken: $data['accessKey'],
accessSecret: $data['accessSecret'],
);
}

public function marshalEnv(string $prefix = 'TARGET_WIKI_OAUTH'): array
{
return [
[
'name' => $prefix.'_CONSUMER_TOKEN',
'value' => $this->consumerToken,
],
[
'name' => $prefix.'_CONSUMER_SECRET',
'value' => $this->consumerSecret,
],
[
'name' => $prefix.'_ACCESS_TOKEN',
'value' => $this->accessToken,
],
[
'name' => $prefix.'_ACCESS_SECRET',
'value' => $this->accessSecret,
],
];
}
}
7 changes: 0 additions & 7 deletions app/WikiEntityImport.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,3 @@ class WikiEntityImport extends Model
'status' => WikiEntityImportStatus::class,
];
}

enum WikiEntityImportStatus: string
{
case Pending = "pending";
case Success = "success";
case Failed = "failed";
}
10 changes: 10 additions & 0 deletions app/WikiEntityImportStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App;

enum WikiEntityImportStatus: string
tarrow marked this conversation as resolved.
Show resolved Hide resolved
{
case Pending = "pending";
case Success = "success";
case Failed = "failed";
}
Loading
Loading