| // 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. | 
 |  | 
 |  | 
 | /// This script removes published archives from the cloud storage and the | 
 | /// corresponding JSON metadata file that the website uses to determine what | 
 | /// releases are available. | 
 | /// | 
 | /// If asked to remove a release that is currently the release on that channel, | 
 | /// it will replace that release with the next most recent release on that | 
 | /// channel. | 
 |  | 
 | import 'dart:async'; | 
 | import 'dart:convert'; | 
 | import 'dart:io' hide Platform; | 
 | import 'dart:typed_data'; | 
 |  | 
 | import 'package:args/args.dart'; | 
 | import 'package:path/path.dart' as path; | 
 | import 'package:platform/platform.dart' show Platform, LocalPlatform; | 
 | import 'package:process/process.dart'; | 
 |  | 
 | const String gsBase = 'gs://flutter_infra'; | 
 | const String releaseFolder = '/releases'; | 
 | const String gsReleaseFolder = '$gsBase$releaseFolder'; | 
 | const String baseUrl = 'https://storage.googleapis.com/flutter_infra'; | 
 |  | 
 | /// Exception class for when a process fails to run, so we can catch | 
 | /// it and provide something more readable than a stack trace. | 
 | class UnpublishException implements Exception { | 
 |   UnpublishException(this.message, [this.result]); | 
 |  | 
 |   final String message; | 
 |   final ProcessResult result; | 
 |   int get exitCode => result?.exitCode ?? -1; | 
 |  | 
 |   @override | 
 |   String toString() { | 
 |     String output = runtimeType.toString(); | 
 |     if (message != null) { | 
 |       output += ': $message'; | 
 |     } | 
 |     final String stderr = result?.stderr as String ?? ''; | 
 |     if (stderr.isNotEmpty) { | 
 |       output += ':\n$stderr'; | 
 |     } | 
 |     return output; | 
 |   } | 
 | } | 
 |  | 
 | enum Channel { dev, beta, stable } | 
 |  | 
 | String getChannelName(Channel channel) { | 
 |   switch (channel) { | 
 |     case Channel.beta: | 
 |       return 'beta'; | 
 |     case Channel.dev: | 
 |       return 'dev'; | 
 |     case Channel.stable: | 
 |       return 'stable'; | 
 |   } | 
 |   return null; | 
 | } | 
 |  | 
 | Channel fromChannelName(String name) { | 
 |   switch (name) { | 
 |     case 'beta': | 
 |       return Channel.beta; | 
 |     case 'dev': | 
 |       return Channel.dev; | 
 |     case 'stable': | 
 |       return Channel.stable; | 
 |     default: | 
 |       throw ArgumentError('Invalid channel name.'); | 
 |   } | 
 | } | 
 |  | 
 | enum PublishedPlatform { linux, macos, windows } | 
 |  | 
 | String getPublishedPlatform(PublishedPlatform platform) { | 
 |   switch (platform) { | 
 |     case PublishedPlatform.linux: | 
 |       return 'linux'; | 
 |     case PublishedPlatform.macos: | 
 |       return 'macos'; | 
 |     case PublishedPlatform.windows: | 
 |       return 'windows'; | 
 |   } | 
 |   return null; | 
 | } | 
 |  | 
 | PublishedPlatform fromPublishedPlatform(String name) { | 
 |   switch (name) { | 
 |     case 'linux': | 
 |       return PublishedPlatform.linux; | 
 |     case 'macos': | 
 |       return PublishedPlatform.macos; | 
 |     case 'windows': | 
 |       return PublishedPlatform.windows; | 
 |     default: | 
 |       throw ArgumentError('Invalid published platform name.'); | 
 |   } | 
 | } | 
 |  | 
 | /// A helper class for classes that want to run a process, optionally have the | 
 | /// stderr and stdout reported as the process runs, and capture the stdout | 
 | /// properly without dropping any. | 
 | class ProcessRunner { | 
 |   /// Creates a [ProcessRunner]. | 
 |   /// | 
 |   /// The [processManager], [subprocessOutput], and [platform] arguments must | 
 |   /// not be null. | 
 |   ProcessRunner({ | 
 |     this.processManager = const LocalProcessManager(), | 
 |     this.subprocessOutput = true, | 
 |     this.defaultWorkingDirectory, | 
 |     this.platform = const LocalPlatform(), | 
 |   }) : assert(subprocessOutput != null), | 
 |        assert(processManager != null), | 
 |        assert(platform != null) { | 
 |     environment = Map<String, String>.from(platform.environment); | 
 |   } | 
 |  | 
 |   /// The platform to use for a starting environment. | 
 |   final Platform platform; | 
 |  | 
 |   /// Set [subprocessOutput] to show output as processes run. Stdout from the | 
 |   /// process will be printed to stdout, and stderr printed to stderr. | 
 |   final bool subprocessOutput; | 
 |  | 
 |   /// Set the [processManager] in order to inject a test instance to perform | 
 |   /// testing. | 
 |   final ProcessManager processManager; | 
 |  | 
 |   /// Sets the default directory used when `workingDirectory` is not specified | 
 |   /// to [runProcess]. | 
 |   final Directory defaultWorkingDirectory; | 
 |  | 
 |   /// The environment to run processes with. | 
 |   Map<String, String> environment; | 
 |  | 
 |   /// Run the command and arguments in `commandLine` as a sub-process from | 
 |   /// `workingDirectory` if set, or the [defaultWorkingDirectory] if not. Uses | 
 |   /// [Directory.current] if [defaultWorkingDirectory] is not set. | 
 |   /// | 
 |   /// Set `failOk` if [runProcess] should not throw an exception when the | 
 |   /// command completes with a a non-zero exit code. | 
 |   Future<String> runProcess( | 
 |     List<String> commandLine, { | 
 |     Directory workingDirectory, | 
 |     bool failOk = false, | 
 |   }) async { | 
 |     workingDirectory ??= defaultWorkingDirectory ?? Directory.current; | 
 |     if (subprocessOutput) { | 
 |       stderr.write('Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n'); | 
 |     } | 
 |     final List<int> output = <int>[]; | 
 |     final Completer<void> stdoutComplete = Completer<void>(); | 
 |     final Completer<void> stderrComplete = Completer<void>(); | 
 |     Process process; | 
 |     Future<int> allComplete() async { | 
 |       await stderrComplete.future; | 
 |       await stdoutComplete.future; | 
 |       return process.exitCode; | 
 |     } | 
 |  | 
 |     try { | 
 |       process = await processManager.start( | 
 |         commandLine, | 
 |         workingDirectory: workingDirectory.absolute.path, | 
 |         environment: environment, | 
 |       ); | 
 |       process.stdout.listen( | 
 |         (List<int> event) { | 
 |           output.addAll(event); | 
 |           if (subprocessOutput) { | 
 |             stdout.add(event); | 
 |           } | 
 |         }, | 
 |         onDone: () async => stdoutComplete.complete(), | 
 |       ); | 
 |       if (subprocessOutput) { | 
 |         process.stderr.listen( | 
 |           (List<int> event) { | 
 |             stderr.add(event); | 
 |           }, | 
 |           onDone: () async => stderrComplete.complete(), | 
 |         ); | 
 |       } else { | 
 |         stderrComplete.complete(); | 
 |       } | 
 |     } on ProcessException catch (e) { | 
 |       final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' | 
 |           'failed with:\n${e.toString()}'; | 
 |       throw UnpublishException(message); | 
 |     } on ArgumentError catch (e) { | 
 |       final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' | 
 |           'failed with:\n${e.toString()}'; | 
 |       throw UnpublishException(message); | 
 |     } | 
 |  | 
 |     final int exitCode = await allComplete(); | 
 |     if (exitCode != 0 && !failOk) { | 
 |       final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} failed'; | 
 |       throw UnpublishException( | 
 |         message, | 
 |         ProcessResult(0, exitCode, null, 'returned $exitCode'), | 
 |       ); | 
 |     } | 
 |     return utf8.decoder.convert(output).trim(); | 
 |   } | 
 | } | 
 |  | 
 | typedef HttpReader = Future<Uint8List> Function(Uri url, {Map<String, String> headers}); | 
 |  | 
 | class ArchiveUnpublisher { | 
 |   ArchiveUnpublisher( | 
 |     this.tempDir, | 
 |     this.revisionsBeingRemoved, | 
 |     this.channels, | 
 |     this.platform, { | 
 |     this.confirmed = false, | 
 |     ProcessManager processManager, | 
 |     bool subprocessOutput = true, | 
 |   })  : assert(revisionsBeingRemoved.length == 40), | 
 |         metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}', | 
 |         _processRunner = ProcessRunner( | 
 |           processManager: processManager, | 
 |           subprocessOutput: subprocessOutput, | 
 |         ); | 
 |  | 
 |   final PublishedPlatform platform; | 
 |   final String metadataGsPath; | 
 |   final Set<Channel> channels; | 
 |   final Set<String> revisionsBeingRemoved; | 
 |   final bool confirmed; | 
 |   final Directory tempDir; | 
 |   final ProcessRunner _processRunner; | 
 |   static String getMetadataFilename(PublishedPlatform platform) => 'releases_${getPublishedPlatform(platform)}.json'; | 
 |  | 
 |   /// Remove the archive from Google Storage. | 
 |   Future<void> unpublishArchive() async { | 
 |     final Map<String, dynamic> jsonData = await _loadMetadata(); | 
 |     final List<Map<String, String>> releases = (jsonData['releases'] as List<dynamic>).map<Map<String, String>>((dynamic entry) { | 
 |       final Map<String, dynamic> mapEntry = entry as Map<String, dynamic>; | 
 |       return mapEntry.cast<String, String>(); | 
 |     }).toList(); | 
 |     final Map<Channel, Map<String, String>> paths = await _getArchivePaths(releases); | 
 |     releases.removeWhere((Map<String, String> value) => revisionsBeingRemoved.contains(value['hash']) && channels.contains(fromChannelName(value['channel']))); | 
 |     releases.sort((Map<String, String> a, Map<String, String> b) { | 
 |       final DateTime aDate = DateTime.parse(a['release_date']); | 
 |       final DateTime bDate = DateTime.parse(b['release_date']); | 
 |       return bDate.compareTo(aDate); | 
 |     }); | 
 |     jsonData['releases'] = releases; | 
 |     for (final Channel channel in channels) { | 
 |       if (!revisionsBeingRemoved.contains(jsonData['current_release'][getChannelName(channel)])) { | 
 |         // Don't replace the current release if it's not one of the revisions we're removing. | 
 |         continue; | 
 |       } | 
 |       final Map<String, String> replacementRelease = releases.firstWhere((Map<String, String> value) => value['channel'] == getChannelName(channel)); | 
 |       if (replacementRelease == null) { | 
 |         throw UnpublishException('Unable to find previous release for channel ${getChannelName(channel)}.'); | 
 |       } | 
 |       jsonData['current_release'][getChannelName(channel)] = replacementRelease['hash']; | 
 |       print( | 
 |         '${confirmed ? 'Reverting' : 'Would revert'} current ${getChannelName(channel)} ' | 
 |         '${getPublishedPlatform(platform)} release to ${replacementRelease['hash']} (version ${replacementRelease['version']}).' | 
 |       ); | 
 |     } | 
 |     await _cloudRemoveArchive(paths); | 
 |     await _updateMetadata(jsonData); | 
 |   } | 
 |  | 
 |   Future<Map<Channel, Map<String, String>>> _getArchivePaths(List<Map<String, String>> releases) async { | 
 |     final Set<String> hashes = <String>{}; | 
 |     final Map<Channel, Map<String, String>> paths = <Channel, Map<String, String>>{}; | 
 |     for (final Map<String, String> revision in releases) { | 
 |       final String hash = revision['hash']; | 
 |       final Channel channel = fromChannelName(revision['channel']); | 
 |       hashes.add(hash); | 
 |       if (revisionsBeingRemoved.contains(hash) && channels.contains(channel)) { | 
 |         paths[channel] ??= <String, String>{}; | 
 |         paths[channel][hash] = revision['archive']; | 
 |       } | 
 |     } | 
 |     final Set<String> missingRevisions = revisionsBeingRemoved.difference(hashes.intersection(revisionsBeingRemoved)); | 
 |     if (missingRevisions.isNotEmpty) { | 
 |       final bool plural = missingRevisions.length > 1; | 
 |       throw UnpublishException('Revision${plural ? 's' : ''} $missingRevisions ${plural ? 'are' : 'is'} not present in the server metadata.'); | 
 |     } | 
 |     return paths; | 
 |   } | 
 |  | 
 |   Future<Map<String, dynamic>> _loadMetadata() async { | 
 |     final File metadataFile = File( | 
 |       path.join(tempDir.absolute.path, getMetadataFilename(platform)), | 
 |     ); | 
 |     // Always run this, even in dry runs. | 
 |     await _runGsUtil(<String>['cp', metadataGsPath, metadataFile.absolute.path], confirm: true); | 
 |     final String currentMetadata = metadataFile.readAsStringSync(); | 
 |     if (currentMetadata.isEmpty) { | 
 |       throw UnpublishException('Empty metadata received from server'); | 
 |     } | 
 |  | 
 |     Map<String, dynamic> jsonData; | 
 |     try { | 
 |       jsonData = json.decode(currentMetadata) as Map<String, dynamic>; | 
 |     } on FormatException catch (e) { | 
 |       throw UnpublishException('Unable to parse JSON metadata received from cloud: $e'); | 
 |     } | 
 |  | 
 |     return jsonData; | 
 |   } | 
 |  | 
 |   Future<void> _updateMetadata(Map<String, dynamic> jsonData) 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 = File( | 
 |       path.join(tempDir.absolute.path, getMetadataFilename(platform)), | 
 |     ); | 
 |     const JsonEncoder encoder = JsonEncoder.withIndent('  '); | 
 |     metadataFile.writeAsStringSync(encoder.convert(jsonData)); | 
 |     print('${confirmed ? 'Overwriting' : 'Would overwrite'} $metadataGsPath with contents of ${metadataFile.absolute.path}'); | 
 |     await _cloudReplaceDest(metadataFile.absolute.path, metadataGsPath); | 
 |   } | 
 |  | 
 |   Future<String> _runGsUtil( | 
 |     List<String> args, { | 
 |     Directory workingDirectory, | 
 |     bool failOk = false, | 
 |     bool confirm = false, | 
 |   }) async { | 
 |     final List<String> command = <String>['gsutil', '--', ...args]; | 
 |     if (confirm) { | 
 |       return _processRunner.runProcess( | 
 |         command, | 
 |         workingDirectory: workingDirectory, | 
 |         failOk: failOk, | 
 |       ); | 
 |     } else { | 
 |       print('Would run: ${command.join(' ')}'); | 
 |       return ''; | 
 |     } | 
 |   } | 
 |  | 
 |   Future<void> _cloudRemoveArchive(Map<Channel, Map<String, String>> paths) async { | 
 |     final List<String> files = <String>[]; | 
 |     print('${confirmed ? 'Removing' : 'Would remove'} the following release archives:'); | 
 |     for (final Channel channel in paths.keys) { | 
 |       final Map<String, String> hashes = paths[channel]; | 
 |       for (final String hash in hashes.keys) { | 
 |         final String file = '$gsReleaseFolder/${hashes[hash]}'; | 
 |         files.add(file); | 
 |         print('  $file'); | 
 |       } | 
 |     } | 
 |     await _runGsUtil(<String>['rm', ...files], failOk: true, confirm: confirmed); | 
 |   } | 
 |  | 
 |   Future<String> _cloudReplaceDest(String src, String dest) async { | 
 |     assert(dest.startsWith('gs:'), '_cloudReplaceDest must have a destination in cloud storage.'); | 
 |     assert(!src.startsWith('gs:'), '_cloudReplaceDest must have a local source file.'); | 
 |     // We often don't have permission to overwrite, but | 
 |     // we have permission to remove, so that's what we do first. | 
 |     await _runGsUtil(<String>['rm', dest], failOk: true, confirm: confirmed); | 
 |     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'; | 
 |     } | 
 |     final List<String> args = <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'], | 
 |       ...<String>['cp', src, dest], | 
 |     ]; | 
 |     return await _runGsUtil(args, confirm: confirmed); | 
 |   } | 
 | } | 
 |  | 
 | void _printBanner(String message) { | 
 |   final String banner = '*** $message ***'; | 
 |   print('\n'); | 
 |   print('*' * banner.length); | 
 |   print('$banner'); | 
 |   print('*' * banner.length); | 
 |   print('\n'); | 
 | } | 
 |  | 
 | /// Prepares a flutter git repo to be removed from the published cloud storage. | 
 | Future<void> main(List<String> rawArguments) async { | 
 |   final List<String> allowedChannelValues = Channel.values.map<String>((Channel channel) => getChannelName(channel)).toList(); | 
 |   final List<String> allowedPlatformNames = PublishedPlatform.values.map<String>((PublishedPlatform platform) => getPublishedPlatform(platform)).toList(); | 
 |   final ArgParser argParser = ArgParser(); | 
 |   argParser.addOption( | 
 |     'temp_dir', | 
 |     defaultsTo: null, | 
 |     help: 'A location where temporary files may be written. Defaults to a ' | 
 |         'directory in the system temp folder. If a temp_dir is not ' | 
 |         'specified, then by default a generated temporary directory will be ' | 
 |         'created, used, and removed automatically when the script exits.', | 
 |   ); | 
 |   argParser.addMultiOption('revision', | 
 |       help: 'The Flutter git repo revisions to remove from the published site. ' | 
 |           'Must be full 40-character hashes. More than one may be specified, ' | 
 |           'either by giving the option more than once, or by giving a comma ' | 
 |           'separated list. Required.'); | 
 |   argParser.addMultiOption( | 
 |     'channel', | 
 |     allowed: allowedChannelValues, | 
 |     help: 'The Flutter channels to remove the archives corresponding to the ' | 
 |         'revisions given with --revision. More than one may be specified, ' | 
 |         'either by giving the option more than once, or by giving a ' | 
 |         'comma separated list. If not specified, then the archives from all ' | 
 |         'channels that a revision appears in will be removed.', | 
 |   ); | 
 |   argParser.addMultiOption( | 
 |     'platform', | 
 |     allowed: allowedPlatformNames, | 
 |     help: 'The Flutter platforms to remove the archive from. May specify more ' | 
 |         'than one, either by giving the option more than once, or by giving a ' | 
 |         'comma separated list. If not specified, then the archives from all ' | 
 |         'platforms that a revision appears in will be removed.', | 
 |   ); | 
 |   argParser.addFlag( | 
 |     'confirm', | 
 |     defaultsTo: false, | 
 |     help: 'If set, will actually remove the archive from Google Cloud Storage ' | 
 |         'upon successful execution of this script. Published archives will be ' | 
 |         'removed from this directory: $baseUrl$releaseFolder.  This option ' | 
 |         'must be set to perform any action on the server, otherwise only a dry ' | 
 |         'run is performed.', | 
 |   ); | 
 |   argParser.addFlag( | 
 |     'help', | 
 |     defaultsTo: false, | 
 |     negatable: false, | 
 |     help: 'Print help for this command.', | 
 |   ); | 
 |  | 
 |   final ArgResults parsedArguments = argParser.parse(rawArguments); | 
 |  | 
 |   if (parsedArguments['help'] as bool) { | 
 |     print(argParser.usage); | 
 |     exit(0); | 
 |   } | 
 |  | 
 |   void errorExit(String message, {int exitCode = -1}) { | 
 |     stderr.write('Error: $message\n\n'); | 
 |     stderr.write('${argParser.usage}\n'); | 
 |     exit(exitCode); | 
 |   } | 
 |  | 
 |   final List<String> revisions = parsedArguments['revision'] as List<String>; | 
 |   if (revisions.isEmpty) { | 
 |     errorExit('Invalid argument: at least one --revision must be specified.'); | 
 |   } | 
 |   for (final String revision in revisions) { | 
 |     if (revision.length != 40) { | 
 |       errorExit('Invalid argument: --revision "$revision" must be the entire hash, not just a prefix.'); | 
 |     } | 
 |     if (revision.contains(RegExp(r'[^a-fA-F0-9]'))) { | 
 |       errorExit('Invalid argument: --revision "$revision" contains non-hex characters.'); | 
 |     } | 
 |   } | 
 |  | 
 |   final String tempDirArg = parsedArguments['temp_dir'] as String; | 
 |   Directory tempDir; | 
 |   bool removeTempDir = false; | 
 |   if (tempDirArg == null || tempDirArg.isEmpty) { | 
 |     tempDir = Directory.systemTemp.createTempSync('flutter_package.'); | 
 |     removeTempDir = true; | 
 |   } else { | 
 |     tempDir = Directory(tempDirArg); | 
 |     if (!tempDir.existsSync()) { | 
 |       errorExit("Temporary directory $tempDirArg doesn't exist."); | 
 |     } | 
 |   } | 
 |  | 
 |   if (!(parsedArguments['confirm'] as bool)) { | 
 |     _printBanner('This will be just a dry run.  To actually perform the changes below, re-run with --confirm argument.'); | 
 |   } | 
 |  | 
 |   final List<String> channelArg = parsedArguments['channel'] as List<String>; | 
 |   final List<String> channelOptions = channelArg.isNotEmpty ? channelArg : allowedChannelValues; | 
 |   final Set<Channel> channels = channelOptions.map<Channel>((String value) => fromChannelName(value)).toSet(); | 
 |   final List<String> platformArg = parsedArguments['platform'] as List<String>; | 
 |   final List<String> platformOptions = platformArg.isNotEmpty ? platformArg : allowedPlatformNames; | 
 |   final List<PublishedPlatform> platforms = platformOptions.map<PublishedPlatform>((String value) => fromPublishedPlatform(value)).toList(); | 
 |   int exitCode = 0; | 
 |   String message; | 
 |   String stack; | 
 |   try { | 
 |     for (final PublishedPlatform platform in platforms) { | 
 |       final ArchiveUnpublisher publisher = ArchiveUnpublisher( | 
 |         tempDir, | 
 |         revisions.toSet(), | 
 |         channels, | 
 |         platform, | 
 |         confirmed: parsedArguments['confirm'] as bool, | 
 |       ); | 
 |       await publisher.unpublishArchive(); | 
 |     } | 
 |   } on UnpublishException catch (e, s) { | 
 |     exitCode = e.exitCode; | 
 |     message = e.message; | 
 |     stack = s.toString(); | 
 |   } catch (e, s) { | 
 |     exitCode = -1; | 
 |     message = e.toString(); | 
 |     stack = s.toString(); | 
 |   } finally { | 
 |     if (removeTempDir) { | 
 |       tempDir.deleteSync(recursive: true); | 
 |     } | 
 |     if (exitCode != 0) { | 
 |       errorExit('$message\n$stack', exitCode: exitCode); | 
 |     } | 
 |     if (!(parsedArguments['confirm'] as bool)) { | 
 |       _printBanner('This was just a dry run.  To actually perform the above changes, re-run with --confirm argument.'); | 
 |     } | 
 |     exit(0); | 
 |   } | 
 | } |