|  | // 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_release'; | 
|  | const String releaseFolder = '/releases'; | 
|  | const String gsReleaseFolder = '$gsBase$releaseFolder'; | 
|  | const String baseUrl = 'https://storage.googleapis.com/flutter_infra_release'; | 
|  |  | 
|  | /// 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'; | 
|  | } | 
|  | } | 
|  |  | 
|  | 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'; | 
|  | } | 
|  | } | 
|  |  | 
|  | 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. | 
|  | late 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 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>(); | 
|  | late 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 ?? const LocalProcessManager(), | 
|  | 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'] as Map<String, dynamic>)[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'] as Map<String, dynamic>)[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 _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; | 
|  | late String message; | 
|  | late 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); | 
|  | } | 
|  | } |