Skip to content

Commit

Permalink
Add support for S3 as a remote filesystem (#2676)
Browse files Browse the repository at this point in the history
  • Loading branch information
zackgalbreath authored Jan 27, 2025
1 parent 5cb23a0 commit 88c87dc
Show file tree
Hide file tree
Showing 35 changed files with 442 additions and 236 deletions.
3 changes: 3 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ SLOW_PAGE_TIME=1000000
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025

AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
47 changes: 38 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ DB_PASSWORD=secret
#DB_PORT=
#DB_USERNAME=

# cdash.php

# How long since the last submission before considering a project inactive.
# Set to 0 to always show all projects on viewProjects.php.
#ACTIVE_PROJECT_DAYS=7
Expand Down Expand Up @@ -77,6 +79,12 @@ DB_PASSWORD=secret
# something other than an email address in LDAP.
#LOGIN_FIELD=Email

# The maximum visibility level for user-created projects on this instance.
# Instance admins are able to override this setting and set project visibility
# to anything. Thus, this setting is only meaningful if USER_CREATE_PROJECTS=true.
# Options: PUBLIC, PROTECTED, PRIVATE
# MAX_PROJECT_VISIBILITY=PUBLIC

# Maximum per-project upload quota, in GB
#MAX_UPLOAD_QUOTA=10

Expand Down Expand Up @@ -148,6 +156,9 @@ DB_PASSWORD=secret
# VCS (eg. GitHub) API endpoints.
#USE_VCS_API=true

# Should normal users be allowed to create projects
# USER_CREATE_PROJECTS = false

# logging.php
#LOG_CHANNEL=stack

Expand All @@ -172,6 +183,33 @@ QUEUE_CONNECTION=database
# Number of minutes before and idle session is allowed to expire.
#SESSION_LIFETIME=120

# filesystem.php

# Default filesystem driver for CDash to use.
# Supported options are 'local' and 's3'.
#FILESYSTEM_DRIVER=local

# The following env vars are only relevant for S3 support.
# The name of the bucket that CDash where will store files.
# AWS_BUCKET=cdash

# The AWS region where this S3 bucket is stored.
# Otherwise set this to 'local' if you're using MinIO.
# AWS_REGION=

# Credentials for access to this S3 bucket.
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=

# Set this to true if you're using MinIO
#AWS_USE_PATH_STYLE_ENDPOINT=false

# URL of your MinIO server (if you're using MinIO). Leave blank otherwise.
#AWS_ENDPOINT=

# URL of the bucket on your MinIO server (if you're using MinIO). Leave blank otherwise.
#AWS_URL=

# mail.php
#MAIL_MAILER=smtp
#MAIL_HOST=smtp.mailgun.org
Expand Down Expand Up @@ -258,12 +296,3 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

# Whether or not to automatically register new users upon first login
#SAML2_AUTO_REGISTER_NEW_USERS=false

# Should normal users be allowed to create projects
# USER_CREATE_PROJECTS = false

# The maximum visibility level for user-created projects on this instance.
# Instance admins are able to override this setting and set project visibility
# to anything. Thus, this setting is only meaningful if USER_CREATE_PROJECTS=true.
# Options: PUBLIC, PROTECTED, PRIVATE
# MAX_PROJECT_VISIBILITY=PUBLIC
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,32 @@ jobs:
env:
SITENAME: GitHub Actions
BASE_IMAGE: ${{matrix.base-image}}
STORAGE_TYPE: ${{matrix.storage}}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
database: ['mysql', 'postgres']
base-image: ['debian', 'ubi']
storage: ['local', 'minio']
exclude:
- storage: minio
base-image: ubi
- storage: minio
database: mysql
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build images
shell: bash
run: |
if [ "${{matrix.storage}}" == "minio" ]; then
extra_args="-f docker/docker-compose.minio.yml"
fi
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.dev.yml \
-f "docker/docker-compose.${{matrix.database}}.yml" \
-f "docker/docker-compose.${{matrix.database}}.yml" ${extra_args} \
--env-file .env.dev up -d \
--build \
--wait
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ctest_driver_script.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ctest_empty_binary_directory("${CTEST_BINARY_DIRECTORY}")
set(cfg_options
"-DCDASH_DIR_NAME="
"-DCDASH_SERVER=localhost:8080"
"-DCDASH_STORAGE_TYPE=${STORAGE_TYPE}"
)

# Backup .env file
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ submit_type="${submit_type:-Experimental}"

site="${SITENAME:-$(hostname)}"

storage_type="${STORAGE_TYPE:-local}"

echo "site=$site"
echo "database=$database"
echo "ctest_driver=$ctest_driver"
Expand All @@ -40,6 +42,7 @@ docker exec cdash bash -c "\
--schedule-random \
-DSITENAME=\"${site}\" \
-DDATABASE=\"${database}\" \
-DSTORAGE_TYPE=\"${storage_type}\" \
-DSUBMIT_TYPE=\"${submit_type}\" \
-S \"${ctest_driver}\" \
"
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ configure_file(
# to configure the testing install
set(CDASH_SERVER localhost CACHE STRING "CDash testing server")
set(CDASH_IMAGE "$ENV{BASE_IMAGE}" CACHE STRING "Docker image name")
if(NOT DEFINED CDASH_STORAGE_TYPE)
set(CDASH_STORAGE_TYPE "local")
endif()

get_filename_component(CDASH_DIR_NAME_DEFAULT ${CDash_SOURCE_DIR} NAME)
set(CDASH_DIR_NAME "${CDASH_DIR_NAME_DEFAULT}" CACHE STRING "URL suffix. Ie 'http://<CDASH_SERVER>/<CDASH_DIR_NAME>'")
Expand Down
5 changes: 5 additions & 0 deletions app/Console/Commands/ValidateXml.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use App\Utils\SubmissionUtils;
use BadMethodCallException;
use Illuminate\Console\Command;
use League\Flysystem\UnableToReadFile;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;

class ValidateXml extends Command
{
Expand Down Expand Up @@ -69,6 +71,9 @@ public function handle(): int
$this->warn("WARNING: Skipped input file '{$input_xml_file}' as validation"
. ' of this file format is currently not supported.');
$has_skipped = true;
} catch (FileNotFoundException|UnableToReadFile $e) {
$this->error($e->getMessage());
$has_skipped = true;
}
}

Expand Down
24 changes: 20 additions & 4 deletions app/Http/Controllers/BuildController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;

require_once 'include/api_common.php';

Expand Down Expand Up @@ -829,7 +829,7 @@ public function files(int $build_id): View
->with('urls', $urls);
}

public function build_file(int $build_id, int $file_id): BinaryFileResponse
public function build_file(int $build_id, int $file_id): StreamedResponse
{
$this->setBuildById($build_id);

Expand All @@ -841,10 +841,26 @@ public function build_file(int $build_id, int $file_id): BinaryFileResponse
$uploadFile = new UploadFile();
$uploadFile->Id = $file_id;
$uploadFile->Fill();
return response()->file(Storage::path("upload/{$uploadFile->Sha1Sum}"), [

// The code below satisfies the following requirements:
// 1) Render text and images in browser (as opposed to forcing a download).
// 2) Download other files to the proper filename (not a numeric identifier).
// 3) Support downloading files that are larger than the PHP memory_limit.
$fp = Storage::readStream("upload/{$uploadFile->Sha1Sum}");
if ($fp === null) {
abort(404, 'File not found');
}
$filename = $uploadFile->Filename;
$headers = [
'Content-Type' => 'text/plain',
'Content-Disposition' => "inline/attachment; filename={$uploadFile->Filename}",
]);
];
return response()->streamDownload(function () use ($fp) {
while (!feof($fp)) {
echo fread($fp, 1024);
}
fclose($fp);
}, $filename, $headers, 'inline');
}

public function ajaxBuildNote(): View
Expand Down
3 changes: 2 additions & 1 deletion app/Http/Controllers/RemoteProcessingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public function requeueSubmissionFile(): Response
$filename = request()->string('filename');
$buildid = request()->integer('buildid');
$projectid = request()->integer('projectid');
$md5 = request()->string('md5');
if (!Storage::exists("inprogress/{$filename}")) {
return response('File not found', Response::HTTP_NOT_FOUND);
}
Expand All @@ -112,7 +113,7 @@ public function requeueSubmissionFile(): Response
// Requeue the file with exponential backoff.
PendingSubmissions::IncrementForBuildId($buildid);
$delay = ((int) config('cdash.retry_base')) ** $retry_handler->Retries;
ProcessSubmission::dispatch($filename, $projectid, $buildid, md5_file(Storage::path("inbox/{$filename}")))->delay(now()->addSeconds($delay));
ProcessSubmission::dispatch($filename, $projectid, $buildid, $md5)->delay(now()->addSeconds($delay));
return response('OK', Response::HTTP_OK);
}
}
45 changes: 30 additions & 15 deletions app/Http/Controllers/SubmissionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Flysystem\UnableToReadFile;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;

final class SubmissionController extends AbstractProjectController
Expand Down Expand Up @@ -85,23 +87,22 @@ private function submitProcess(): Response
$authtoken = AuthTokenUtil::getBearerToken();
$authtoken_hash = $authtoken === null || $authtoken === '' ? '' : AuthTokenUtil::hashToken($authtoken);

// Save the incoming file in the inbox directory.
$filename = "{$projectname}_-_{$authtoken_hash}_-_" . Str::uuid()->toString() . "_-_{$expected_md5}.xml";
$fp = request()->getContent(true);
if (!Storage::put("inbox/{$filename}", $fp)) {
Log::error("Failed to save submission to inbox for $projectname (md5=$expected_md5)");
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to save submission file.');
}

// Check that the md5sum of the file matches what we were told to expect.
$fp = request()->getContent(true);
if (strlen($expected_md5) > 0) {
$md5sum = md5_file(Storage::path("inbox/{$filename}"));
$md5sum = SubmissionUtils::hashFileHandle($fp, 'md5');
if ($md5sum != $expected_md5) {
Storage::delete("inbox/{$filename}");
abort(Response::HTTP_BAD_REQUEST, "md5 mismatch. expected: {$expected_md5}, received: {$md5sum}");
}
}

// Save the incoming file in the inbox directory.
$filename = "{$projectname}_-_{$authtoken_hash}_-_" . Str::uuid()->toString() . "_-_{$expected_md5}.xml";
if (!Storage::put("inbox/{$filename}", $fp)) {
Log::error("Failed to save submission to inbox for $projectname (md5=$expected_md5)");
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to save submission file.');
}

// Check if we can connect to the database before proceeding any further.
try {
DB::connection()->getPdo();
Expand Down Expand Up @@ -138,7 +139,7 @@ private function submitProcess(): Response
$stored_filename = 'inbox/' . $filename;
$xml_info = [];
try {
$xml_info = SubmissionUtils::get_xml_type(fopen(Storage::path($stored_filename), 'r'), $stored_filename);
$xml_info = SubmissionUtils::get_xml_type(Storage::readStream($stored_filename), $stored_filename);
} catch (BadSubmissionException $e) {
$xml_info['xml_handler'] = '';
$message = "Could not determine submission file type for: '{$stored_filename}'";
Expand All @@ -149,7 +150,15 @@ private function submitProcess(): Response
}
if ($xml_info['xml_handler'] !== '') {
// If validation is enabled and if this file has a corresponding schema, validate it
$validation_errors = $xml_info['xml_handler']::validate(storage_path('app/' . $stored_filename));
$validation_errors = [];
try {
$validation_errors = $xml_info['xml_handler']::validate($stored_filename);
} catch (FileNotFoundException|UnableToReadFile $e) {
Log::warning($e->getMessage());
if ((bool) config('cdash.validate_xml_submissions') === true) {
abort(400, "XML validation failed for $filename:" . PHP_EOL . $e->getMessage());
}
}
if (count($validation_errors) > 0) {
$error_string = implode(PHP_EOL, $validation_errors);

Expand Down Expand Up @@ -233,18 +242,24 @@ public function storeUploadedFile(Request $request): Response
}

try {
$sha1sum = decrypt($request->input('sha1sum'));
$expected_sha1sum = decrypt($request->input('sha1sum'));
} catch (DecryptException $e) {
return response('This feature is disabled', Response::HTTP_CONFLICT);
}

$uploaded_file = array_values(request()->allFiles())[0];
$stored_path = $uploaded_file->storeAs('upload', $sha1sum);
$stored_path = $uploaded_file->storeAs('upload', $expected_sha1sum);
if ($stored_path === false) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to store uploaded file');
}

if (sha1_file(Storage::path($stored_path)) !== $sha1sum) {
$fp = Storage::readStream($stored_path);
if ($fp === null) {
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to store uploaded file');
}

$found_sha1sum = SubmissionUtils::hashFileHandle($fp, 'sha1');
if ($found_sha1sum !== $expected_sha1sum) {
Storage::delete($stored_path);
return response('Uploaded file does not match expected sha1sum', Response::HTTP_BAD_REQUEST);
}
Expand Down
18 changes: 12 additions & 6 deletions app/Jobs/ProcessSubmission.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ private function requeueSubmissionFile($buildid): bool
'filename' => $this->filename,
'buildid' => $buildid,
'projectid' => $this->projectid,
'md5' => $this->expected_md5,
]);
if ($this->localFilename !== '') {
unlink($this->localFilename);
Expand All @@ -107,7 +108,7 @@ private function requeueSubmissionFile($buildid): bool
return $response->ok();
} else {
// Increment retry count.
$retry_handler = new RetryHandler(Storage::path("inprogress/{$this->filename}"));
$retry_handler = new RetryHandler("inprogress/{$this->filename}");
$retry_handler->increment();

// Move file back to inbox.
Expand All @@ -119,9 +120,9 @@ private function requeueSubmissionFile($buildid): bool
if (config('queue.default') === 'sqs-fifo') {
// Special handling for sqs-fifo, which does not support per-message delays.
sleep(10);
self::dispatch($this->filename, $this->projectid, $buildid, md5_file(Storage::path("inbox/{$this->filename}")));
self::dispatch($this->filename, $this->projectid, $buildid, $this->expected_md5);
} else {
self::dispatch($this->filename, $this->projectid, $buildid, md5_file(Storage::path("inbox/{$this->filename}")))->delay(now()->addSeconds($delay));
self::dispatch($this->filename, $this->projectid, $buildid, $this->expected_md5)->delay(now()->addSeconds($delay));
}

return true;
Expand Down Expand Up @@ -215,8 +216,13 @@ private function doSubmit($filename, $projectid, $buildid = null, $expected_md5
return $handler;
}

// Parse the XML file
$handler = ctest_parse($filehandle, $filename, $projectid, $expected_md5, $buildid);
// Special handling for unparsed (non-XML) submissions.
$handler = parse_put_submission($filename, $projectid, $expected_md5, $buildid);
if ($handler === false) {
// Otherwise, parse this submission as CTest XML.
$handler = ctest_parse($filehandle, $filename, $projectid, $expected_md5, $buildid);
}

fclose($filehandle);
unset($filehandle);

Expand Down Expand Up @@ -293,7 +299,7 @@ private function getSubmissionFileHandle($filename)
if ((bool) config('cdash.remote_workers') && is_string($filename)) {
return $this->getRemoteSubmissionFileHandle($filename);
} elseif (Storage::exists($filename)) {
return fopen(Storage::path($filename), 'r');
return Storage::readStream($filename);
} else {
\Log::error('Failed to get a file handle for submission (was type ' . gettype($filename) . ')');
return false;
Expand Down
Loading

0 comments on commit 88c87dc

Please sign in to comment.