| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:convert'; |
| |
| import 'package:convert/convert.dart'; |
| import 'package:crypto/crypto.dart'; |
| import 'package:file/file.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:platform/platform.dart' show LocalPlatform, Platform; |
| import 'package:process/process.dart'; |
| |
| import 'common.dart'; |
| import 'process_runner.dart'; |
| |
| class ArchivePublisher { |
| ArchivePublisher( |
| this.tempDir, |
| this.revision, |
| this.branch, |
| this.version, |
| this.outputFile, |
| this.dryRun, { |
| ProcessManager? processManager, |
| bool subprocessOutput = true, |
| required this.fs, |
| this.platform = const LocalPlatform(), |
| }) : assert(revision.length == 40), |
| platformName = platform.operatingSystem.toLowerCase(), |
| metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}', |
| _processRunner = ProcessRunner( |
| processManager: processManager, |
| subprocessOutput: subprocessOutput, |
| ); |
| |
| final Platform platform; |
| final FileSystem fs; |
| final String platformName; |
| final String metadataGsPath; |
| final Branch branch; |
| final String revision; |
| final Map<String, String> version; |
| final Directory tempDir; |
| final File outputFile; |
| final ProcessRunner _processRunner; |
| final bool dryRun; |
| String get destinationArchivePath => |
| '${branch.name}/$platformName/${path.basename(outputFile.path)}'; |
| static String getMetadataFilename(Platform platform) => |
| 'releases_${platform.operatingSystem.toLowerCase()}.json'; |
| |
| Future<String> _getChecksum(File archiveFile) async { |
| final AccumulatorSink<Digest> digestSink = AccumulatorSink<Digest>(); |
| final ByteConversionSink sink = sha256.startChunkedConversion(digestSink); |
| |
| final Stream<List<int>> stream = archiveFile.openRead(); |
| await stream.forEach((List<int> chunk) { |
| sink.add(chunk); |
| }); |
| sink.close(); |
| return digestSink.events.single.toString(); |
| } |
| |
| /// Publish the archive to Google Storage. |
| /// |
| /// This method will throw if the target archive already exists on cloud |
| /// storage. |
| Future<void> publishArchive([bool forceUpload = false]) async { |
| final String destGsPath = '$gsReleaseFolder/$destinationArchivePath'; |
| if (!forceUpload) { |
| if (await _cloudPathExists(destGsPath) && !dryRun) { |
| throw PreparePackageException('File $destGsPath already exists on cloud storage!'); |
| } |
| } |
| await _cloudCopy(src: outputFile.absolute.path, dest: destGsPath); |
| assert(tempDir.existsSync()); |
| final String gcsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}'; |
| await _publishMetadata(gcsPath); |
| } |
| |
| /// Downloads and updates the metadata file without publishing it. |
| Future<void> generateLocalMetadata() async { |
| await _updateMetadata('$gsReleaseFolder/${getMetadataFilename(platform)}'); |
| } |
| |
| Future<Map<String, dynamic>> _addRelease(Map<String, dynamic> jsonData) async { |
| jsonData['base_url'] = '$baseUrl$releaseFolder'; |
| if (!jsonData.containsKey('current_release')) { |
| jsonData['current_release'] = <String, String>{}; |
| } |
| (jsonData['current_release'] as Map<String, dynamic>)[branch.name] = revision; |
| if (!jsonData.containsKey('releases')) { |
| jsonData['releases'] = <Map<String, dynamic>>[]; |
| } |
| |
| final Map<String, dynamic> newEntry = <String, dynamic>{}; |
| newEntry['hash'] = revision; |
| newEntry['channel'] = branch.name; |
| newEntry['version'] = version[frameworkVersionTag]; |
| newEntry['dart_sdk_version'] = version[dartVersionTag]; |
| newEntry['dart_sdk_arch'] = version[dartTargetArchTag]; |
| newEntry['release_date'] = DateTime.now().toUtc().toIso8601String(); |
| newEntry['archive'] = destinationArchivePath; |
| newEntry['sha256'] = await _getChecksum(outputFile); |
| |
| // Search for any entries with the same hash and channel and remove them. |
| final List<dynamic> releases = jsonData['releases'] as List<dynamic>; |
| jsonData['releases'] = <Map<String, dynamic>>[ |
| for (final Map<String, dynamic> entry in releases.cast<Map<String, dynamic>>()) |
| if (entry['hash'] != newEntry['hash'] || |
| entry['channel'] != newEntry['channel'] || |
| entry['dart_sdk_arch'] != newEntry['dart_sdk_arch']) |
| entry, |
| newEntry, |
| ]..sort((Map<String, dynamic> a, Map<String, dynamic> b) { |
| final DateTime aDate = DateTime.parse(a['release_date'] as String); |
| final DateTime bDate = DateTime.parse(b['release_date'] as String); |
| return bDate.compareTo(aDate); |
| }); |
| return jsonData; |
| } |
| |
| Future<void> _updateMetadata(String gsPath) async { |
| // We can't just cat the metadata from the server with 'gsutil cat', because |
| // Windows wants to echo the commands that execute in gsutil.bat to the |
| // stdout when we do that. So, we copy the file locally and then read it |
| // back in. |
| final File metadataFile = fs.file( |
| path.join(tempDir.absolute.path, getMetadataFilename(platform)), |
| ); |
| await _runGsUtil(<String>['cp', gsPath, metadataFile.absolute.path]); |
| Map<String, dynamic> jsonData = <String, dynamic>{}; |
| if (!dryRun) { |
| final String currentMetadata = metadataFile.readAsStringSync(); |
| if (currentMetadata.isEmpty) { |
| throw PreparePackageException('Empty metadata received from server'); |
| } |
| try { |
| jsonData = json.decode(currentMetadata) as Map<String, dynamic>; |
| } on FormatException catch (e) { |
| throw PreparePackageException('Unable to parse JSON metadata received from cloud: $e'); |
| } |
| } |
| // Run _addRelease, even on a dry run, so we can inspect the metadata on a |
| // dry run. On a dry run, the only thing in the metadata file be the new |
| // release. |
| jsonData = await _addRelease(jsonData); |
| |
| const JsonEncoder encoder = JsonEncoder.withIndent(' '); |
| metadataFile.writeAsStringSync(encoder.convert(jsonData)); |
| } |
| |
| /// Publishes the metadata file to GCS. |
| Future<void> _publishMetadata(String gsPath) async { |
| final File metadataFile = fs.file( |
| path.join(tempDir.absolute.path, getMetadataFilename(platform)), |
| ); |
| await _cloudCopy( |
| src: metadataFile.absolute.path, |
| dest: gsPath, |
| // This metadata file is used by the website, so we don't want a long |
| // latency between publishing a release and it being available on the |
| // site. |
| cacheSeconds: shortCacheSeconds, |
| ); |
| } |
| |
| Future<String> _runGsUtil( |
| List<String> args, { |
| Directory? workingDirectory, |
| bool failOk = false, |
| }) async { |
| if (dryRun) { |
| print('gsutil.py -- $args'); |
| return ''; |
| } |
| return _processRunner.runProcess( |
| <String>[ |
| 'python3', |
| path.join(platform.environment['DEPOT_TOOLS']!, 'gsutil.py'), |
| '--', |
| ...args, |
| ], |
| workingDirectory: workingDirectory, |
| failOk: failOk, |
| ); |
| } |
| |
| /// Determine if a file exists at a given [cloudPath]. |
| Future<bool> _cloudPathExists(String cloudPath) async { |
| try { |
| await _runGsUtil(<String>['stat', cloudPath]); |
| } on PreparePackageException { |
| // `gsutil stat gs://path/to/file` will exit with 1 if file does not exist |
| return false; |
| } |
| return true; |
| } |
| |
| Future<String> _cloudCopy({required String src, required String dest, int? cacheSeconds}) async { |
| // We often don't have permission to overwrite, but |
| // we have permission to remove, so that's what we do. |
| await _runGsUtil(<String>['rm', dest], failOk: true); |
| String? mimeType; |
| if (dest.endsWith('.tar.xz')) { |
| mimeType = 'application/x-gtar'; |
| } |
| if (dest.endsWith('.zip')) { |
| mimeType = 'application/zip'; |
| } |
| if (dest.endsWith('.json')) { |
| mimeType = 'application/json'; |
| } |
| return _runGsUtil(<String>[ |
| // Use our preferred MIME type for the files we care about |
| // and let gsutil figure it out for anything else. |
| if (mimeType != null) ...<String>['-h', 'Content-Type:$mimeType'], |
| if (cacheSeconds != null) ...<String>['-h', 'Cache-Control:max-age=$cacheSeconds'], |
| 'cp', |
| src, |
| dest, |
| ]); |
| } |
| } |