1// Copyright 2019 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5 6/// This script removes published archives from the cloud storage and the 7/// corresponding JSON metadata file that the website uses to determine what 8/// releases are available. 9/// 10/// If asked to remove a release that is currently the release on that channel, 11/// it will replace that release with the next most recent release on that 12/// channel. 13 14import 'dart:async'; 15import 'dart:convert'; 16import 'dart:io' hide Platform; 17import 'dart:typed_data'; 18 19import 'package:args/args.dart'; 20import 'package:path/path.dart' as path; 21import 'package:platform/platform.dart' show Platform, LocalPlatform; 22import 'package:process/process.dart'; 23 24const String gsBase = 'gs://flutter_infra'; 25const String releaseFolder = '/releases'; 26const String gsReleaseFolder = '$gsBase$releaseFolder'; 27const String baseUrl = 'https://storage.googleapis.com/flutter_infra'; 28 29/// Exception class for when a process fails to run, so we can catch 30/// it and provide something more readable than a stack trace. 31class UnpublishException implements Exception { 32 UnpublishException(this.message, [this.result]); 33 34 final String message; 35 final ProcessResult result; 36 int get exitCode => result?.exitCode ?? -1; 37 38 @override 39 String toString() { 40 String output = runtimeType.toString(); 41 if (message != null) { 42 output += ': $message'; 43 } 44 final String stderr = result?.stderr ?? ''; 45 if (stderr.isNotEmpty) { 46 output += ':\n$stderr'; 47 } 48 return output; 49 } 50} 51 52enum Channel { dev, beta, stable } 53 54String getChannelName(Channel channel) { 55 switch (channel) { 56 case Channel.beta: 57 return 'beta'; 58 case Channel.dev: 59 return 'dev'; 60 case Channel.stable: 61 return 'stable'; 62 } 63 return null; 64} 65 66Channel fromChannelName(String name) { 67 switch (name) { 68 case 'beta': 69 return Channel.beta; 70 case 'dev': 71 return Channel.dev; 72 case 'stable': 73 return Channel.stable; 74 default: 75 throw ArgumentError('Invalid channel name.'); 76 } 77} 78 79enum PublishedPlatform { linux, macos, windows } 80 81String getPublishedPlatform(PublishedPlatform platform) { 82 switch (platform) { 83 case PublishedPlatform.linux: 84 return 'linux'; 85 case PublishedPlatform.macos: 86 return 'macos'; 87 case PublishedPlatform.windows: 88 return 'windows'; 89 } 90 return null; 91} 92 93PublishedPlatform fromPublishedPlatform(String name) { 94 switch (name) { 95 case 'linux': 96 return PublishedPlatform.linux; 97 case 'macos': 98 return PublishedPlatform.macos; 99 case 'windows': 100 return PublishedPlatform.windows; 101 default: 102 throw ArgumentError('Invalid published platform name.'); 103 } 104} 105 106/// A helper class for classes that want to run a process, optionally have the 107/// stderr and stdout reported as the process runs, and capture the stdout 108/// properly without dropping any. 109class ProcessRunner { 110 /// Creates a [ProcessRunner]. 111 /// 112 /// The [processManager], [subprocessOutput], and [platform] arguments must 113 /// not be null. 114 ProcessRunner({ 115 this.processManager = const LocalProcessManager(), 116 this.subprocessOutput = true, 117 this.defaultWorkingDirectory, 118 this.platform = const LocalPlatform(), 119 }) : assert(subprocessOutput != null), 120 assert(processManager != null), 121 assert(platform != null) { 122 environment = Map<String, String>.from(platform.environment); 123 } 124 125 /// The platform to use for a starting environment. 126 final Platform platform; 127 128 /// Set [subprocessOutput] to show output as processes run. Stdout from the 129 /// process will be printed to stdout, and stderr printed to stderr. 130 final bool subprocessOutput; 131 132 /// Set the [processManager] in order to inject a test instance to perform 133 /// testing. 134 final ProcessManager processManager; 135 136 /// Sets the default directory used when `workingDirectory` is not specified 137 /// to [runProcess]. 138 final Directory defaultWorkingDirectory; 139 140 /// The environment to run processes with. 141 Map<String, String> environment; 142 143 /// Run the command and arguments in `commandLine` as a sub-process from 144 /// `workingDirectory` if set, or the [defaultWorkingDirectory] if not. Uses 145 /// [Directory.current] if [defaultWorkingDirectory] is not set. 146 /// 147 /// Set `failOk` if [runProcess] should not throw an exception when the 148 /// command completes with a a non-zero exit code. 149 Future<String> runProcess( 150 List<String> commandLine, { 151 Directory workingDirectory, 152 bool failOk = false, 153 }) async { 154 workingDirectory ??= defaultWorkingDirectory ?? Directory.current; 155 if (subprocessOutput) { 156 stderr.write('Running "${commandLine.join(' ')}" in ${workingDirectory.path}.\n'); 157 } 158 final List<int> output = <int>[]; 159 final Completer<void> stdoutComplete = Completer<void>(); 160 final Completer<void> stderrComplete = Completer<void>(); 161 Process process; 162 Future<int> allComplete() async { 163 await stderrComplete.future; 164 await stdoutComplete.future; 165 return process.exitCode; 166 } 167 168 try { 169 process = await processManager.start( 170 commandLine, 171 workingDirectory: workingDirectory.absolute.path, 172 environment: environment, 173 ); 174 process.stdout.listen( 175 (List<int> event) { 176 output.addAll(event); 177 if (subprocessOutput) { 178 stdout.add(event); 179 } 180 }, 181 onDone: () async => stdoutComplete.complete(), 182 ); 183 if (subprocessOutput) { 184 process.stderr.listen( 185 (List<int> event) { 186 stderr.add(event); 187 }, 188 onDone: () async => stderrComplete.complete(), 189 ); 190 } else { 191 stderrComplete.complete(); 192 } 193 } on ProcessException catch (e) { 194 final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' 195 'failed with:\n${e.toString()}'; 196 throw UnpublishException(message); 197 } on ArgumentError catch (e) { 198 final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} ' 199 'failed with:\n${e.toString()}'; 200 throw UnpublishException(message); 201 } 202 203 final int exitCode = await allComplete(); 204 if (exitCode != 0 && !failOk) { 205 final String message = 'Running "${commandLine.join(' ')}" in ${workingDirectory.path} failed'; 206 throw UnpublishException( 207 message, 208 ProcessResult(0, exitCode, null, 'returned $exitCode'), 209 ); 210 } 211 return utf8.decoder.convert(output).trim(); 212 } 213} 214 215typedef HttpReader = Future<Uint8List> Function(Uri url, {Map<String, String> headers}); 216 217class ArchiveUnpublisher { 218 ArchiveUnpublisher( 219 this.tempDir, 220 this.revisionsBeingRemoved, 221 this.channels, 222 this.platform, { 223 this.confirmed = false, 224 ProcessManager processManager, 225 bool subprocessOutput = true, 226 }) : assert(revisionsBeingRemoved.length == 40), 227 metadataGsPath = '$gsReleaseFolder/${getMetadataFilename(platform)}', 228 _processRunner = ProcessRunner( 229 processManager: processManager, 230 subprocessOutput: subprocessOutput, 231 ); 232 233 final PublishedPlatform platform; 234 final String metadataGsPath; 235 final Set<Channel> channels; 236 final Set<String> revisionsBeingRemoved; 237 final bool confirmed; 238 final Directory tempDir; 239 final ProcessRunner _processRunner; 240 static String getMetadataFilename(PublishedPlatform platform) => 'releases_${getPublishedPlatform(platform)}.json'; 241 242 /// Remove the archive from Google Storage. 243 Future<void> unpublishArchive() async { 244 final Map<String, dynamic> jsonData = await _loadMetadata(); 245 final List<Map<String, String>> releases = jsonData['releases'].map<Map<String, String>>((dynamic entry) { 246 final Map<String, dynamic> mapEntry = entry; 247 return mapEntry.cast<String, String>(); 248 }).toList(); 249 final Map<Channel, Map<String, String>> paths = await _getArchivePaths(releases); 250 releases.removeWhere((Map<String, String> value) => revisionsBeingRemoved.contains(value['hash']) && channels.contains(fromChannelName(value['channel']))); 251 releases.sort((Map<String, String> a, Map<String, String> b) { 252 final DateTime aDate = DateTime.parse(a['release_date']); 253 final DateTime bDate = DateTime.parse(b['release_date']); 254 return bDate.compareTo(aDate); 255 }); 256 jsonData['releases'] = releases; 257 for (Channel channel in channels) { 258 if (!revisionsBeingRemoved.contains(jsonData['current_release'][getChannelName(channel)])) { 259 // Don't replace the current release if it's not one of the revisions we're removing. 260 continue; 261 } 262 final Map<String, String> replacementRelease = releases.firstWhere((Map<String, String> value) => value['channel'] == getChannelName(channel)); 263 if (replacementRelease == null) { 264 throw UnpublishException('Unable to find previous release for channel ${getChannelName(channel)}.'); 265 } 266 jsonData['current_release'][getChannelName(channel)] = replacementRelease['hash']; 267 print( 268 '${confirmed ? 'Reverting' : 'Would revert'} current ${getChannelName(channel)} ' 269 '${getPublishedPlatform(platform)} release to ${replacementRelease['hash']} (version ${replacementRelease['version']}).' 270 ); 271 } 272 await _cloudRemoveArchive(paths); 273 await _updateMetadata(jsonData); 274 } 275 276 Future<Map<Channel, Map<String, String>>> _getArchivePaths(List<Map<String, String>> releases) async { 277 final Set<String> hashes = <String>{}; 278 final Map<Channel, Map<String, String>> paths = <Channel, Map<String, String>>{}; 279 for (Map<String, String> revision in releases) { 280 final String hash = revision['hash']; 281 final Channel channel = fromChannelName(revision['channel']); 282 hashes.add(hash); 283 if (revisionsBeingRemoved.contains(hash) && channels.contains(channel)) { 284 paths[channel] ??= <String, String>{}; 285 paths[channel][hash] = revision['archive']; 286 } 287 } 288 final Set<String> missingRevisions = revisionsBeingRemoved.difference(hashes.intersection(revisionsBeingRemoved)); 289 if (missingRevisions.isNotEmpty) { 290 final bool plural = missingRevisions.length > 1; 291 throw UnpublishException('Revision${plural ? 's' : ''} $missingRevisions ${plural ? 'are' : 'is'} not present in the server metadata.'); 292 } 293 return paths; 294 } 295 296 Future<Map<String, dynamic>> _loadMetadata() async { 297 final File metadataFile = File( 298 path.join(tempDir.absolute.path, getMetadataFilename(platform)), 299 ); 300 // Always run this, even in dry runs. 301 await _runGsUtil(<String>['cp', metadataGsPath, metadataFile.absolute.path], confirm: true); 302 final String currentMetadata = metadataFile.readAsStringSync(); 303 if (currentMetadata.isEmpty) { 304 throw UnpublishException('Empty metadata received from server'); 305 } 306 307 Map<String, dynamic> jsonData; 308 try { 309 jsonData = json.decode(currentMetadata); 310 } on FormatException catch (e) { 311 throw UnpublishException('Unable to parse JSON metadata received from cloud: $e'); 312 } 313 314 return jsonData; 315 } 316 317 Future<void> _updateMetadata(Map<String, dynamic> jsonData) async { 318 // We can't just cat the metadata from the server with 'gsutil cat', because 319 // Windows wants to echo the commands that execute in gsutil.bat to the 320 // stdout when we do that. So, we copy the file locally and then read it 321 // back in. 322 final File metadataFile = File( 323 path.join(tempDir.absolute.path, getMetadataFilename(platform)), 324 ); 325 const JsonEncoder encoder = JsonEncoder.withIndent(' '); 326 metadataFile.writeAsStringSync(encoder.convert(jsonData)); 327 print('${confirmed ? 'Overwriting' : 'Would overwrite'} $metadataGsPath with contents of ${metadataFile.absolute.path}'); 328 await _cloudReplaceDest(metadataFile.absolute.path, metadataGsPath); 329 } 330 331 Future<String> _runGsUtil( 332 List<String> args, { 333 Directory workingDirectory, 334 bool failOk = false, 335 bool confirm = false, 336 }) async { 337 final List<String> command = <String>['gsutil', '--', ...args]; 338 if (confirm) { 339 return _processRunner.runProcess( 340 command, 341 workingDirectory: workingDirectory, 342 failOk: failOk, 343 ); 344 } else { 345 print('Would run: ${command.join(' ')}'); 346 return ''; 347 } 348 } 349 350 Future<void> _cloudRemoveArchive(Map<Channel, Map<String, String>> paths) async { 351 final List<String> files = <String>[]; 352 print('${confirmed ? 'Removing' : 'Would remove'} the following release archives:'); 353 for (Channel channel in paths.keys) { 354 final Map<String, String> hashes = paths[channel]; 355 for (String hash in hashes.keys) { 356 final String file = '$gsReleaseFolder/${hashes[hash]}'; 357 files.add(file); 358 print(' $file'); 359 } 360 } 361 await _runGsUtil(<String>['rm', ...files], failOk: true, confirm: confirmed); 362 } 363 364 Future<String> _cloudReplaceDest(String src, String dest) async { 365 assert(dest.startsWith('gs:'), '_cloudReplaceDest must have a destination in cloud storage.'); 366 assert(!src.startsWith('gs:'), '_cloudReplaceDest must have a local source file.'); 367 // We often don't have permission to overwrite, but 368 // we have permission to remove, so that's what we do first. 369 await _runGsUtil(<String>['rm', dest], failOk: true, confirm: confirmed); 370 String mimeType; 371 if (dest.endsWith('.tar.xz')) { 372 mimeType = 'application/x-gtar'; 373 } 374 if (dest.endsWith('.zip')) { 375 mimeType = 'application/zip'; 376 } 377 if (dest.endsWith('.json')) { 378 mimeType = 'application/json'; 379 } 380 final List<String> args = <String>[ 381 // Use our preferred MIME type for the files we care about 382 // and let gsutil figure it out for anything else. 383 if (mimeType != null) ...<String>['-h', 'Content-Type:$mimeType'], 384 ...<String>['cp', src, dest], 385 ]; 386 return await _runGsUtil(args, confirm: confirmed); 387 } 388} 389 390void _printBanner(String message) { 391 final String banner = '*** $message ***'; 392 print('\n'); 393 print('*' * banner.length); 394 print('$banner'); 395 print('*' * banner.length); 396 print('\n'); 397} 398 399/// Prepares a flutter git repo to be removed from the published cloud storage. 400Future<void> main(List<String> rawArguments) async { 401 final List<String> allowedChannelValues = Channel.values.map<String>((Channel channel) => getChannelName(channel)).toList(); 402 final List<String> allowedPlatformNames = PublishedPlatform.values.map<String>((PublishedPlatform platform) => getPublishedPlatform(platform)).toList(); 403 final ArgParser argParser = ArgParser(); 404 argParser.addOption( 405 'temp_dir', 406 defaultsTo: null, 407 help: 'A location where temporary files may be written. Defaults to a ' 408 'directory in the system temp folder. If a temp_dir is not ' 409 'specified, then by default a generated temporary directory will be ' 410 'created, used, and removed automatically when the script exits.', 411 ); 412 argParser.addMultiOption('revision', 413 help: 'The Flutter git repo revisions to remove from the published site. ' 414 'Must be full 40-character hashes. More than one may be specified, ' 415 'either by giving the option more than once, or by giving a comma ' 416 'separated list. Required.'); 417 argParser.addMultiOption( 418 'channel', 419 allowed: allowedChannelValues, 420 help: 'The Flutter channels to remove the archives corresponding to the ' 421 'revisions given with --revision. More than one may be specified, ' 422 'either by giving the option more than once, or by giving a ' 423 'comma separated list. If not specified, then the archives from all ' 424 'channels that a revision appears in will be removed.', 425 ); 426 argParser.addMultiOption( 427 'platform', 428 allowed: allowedPlatformNames, 429 help: 'The Flutter platforms to remove the archive from. May specify more ' 430 'than one, either by giving the option more than once, or by giving a ' 431 'comma separated list. If not specified, then the archives from all ' 432 'platforms that a revision appears in will be removed.', 433 ); 434 argParser.addFlag( 435 'confirm', 436 defaultsTo: false, 437 help: 'If set, will actually remove the archive from Google Cloud Storage ' 438 'upon successful execution of this script. Published archives will be ' 439 'removed from this directory: $baseUrl$releaseFolder. This option ' 440 'must be set to perform any action on the server, otherwise only a dry ' 441 'run is performed.', 442 ); 443 argParser.addFlag( 444 'help', 445 defaultsTo: false, 446 negatable: false, 447 help: 'Print help for this command.', 448 ); 449 450 final ArgResults parsedArguments = argParser.parse(rawArguments); 451 452 if (parsedArguments['help']) { 453 print(argParser.usage); 454 exit(0); 455 } 456 457 void errorExit(String message, {int exitCode = -1}) { 458 stderr.write('Error: $message\n\n'); 459 stderr.write('${argParser.usage}\n'); 460 exit(exitCode); 461 } 462 463 final List<String> revisions = parsedArguments['revision']; 464 if (revisions.isEmpty) { 465 errorExit('Invalid argument: at least one --revision must be specified.'); 466 } 467 for (String revision in revisions) { 468 if (revision.length != 40) { 469 errorExit('Invalid argument: --revision "$revision" must be the entire hash, not just a prefix.'); 470 } 471 if (revision.contains(RegExp(r'[^a-fA-F0-9]'))) { 472 errorExit('Invalid argument: --revision "$revision" contains non-hex characters.'); 473 } 474 } 475 476 Directory tempDir; 477 bool removeTempDir = false; 478 if (parsedArguments['temp_dir'] == null || parsedArguments['temp_dir'].isEmpty) { 479 tempDir = Directory.systemTemp.createTempSync('flutter_package.'); 480 removeTempDir = true; 481 } else { 482 tempDir = Directory(parsedArguments['temp_dir']); 483 if (!tempDir.existsSync()) { 484 errorExit("Temporary directory ${parsedArguments['temp_dir']} doesn't exist."); 485 } 486 } 487 488 if (!parsedArguments['confirm']) { 489 _printBanner('This will be just a dry run. To actually perform the changes below, re-run with --confirm argument.'); 490 } 491 492 final List<String> channelOptions = parsedArguments['channel'].isNotEmpty ? parsedArguments['channel'] : allowedChannelValues; 493 final Set<Channel> channels = channelOptions.map<Channel>((String value) => fromChannelName(value)).toSet(); 494 final List<String> platformOptions = parsedArguments['platform'].isNotEmpty ? parsedArguments['platform'] : allowedPlatformNames; 495 final List<PublishedPlatform> platforms = platformOptions.map<PublishedPlatform>((String value) => fromPublishedPlatform(value)).toList(); 496 int exitCode = 0; 497 String message; 498 String stack; 499 try { 500 for (PublishedPlatform platform in platforms) { 501 final ArchiveUnpublisher publisher = ArchiveUnpublisher( 502 tempDir, 503 revisions.toSet(), 504 channels, 505 platform, 506 confirmed: parsedArguments['confirm'], 507 ); 508 await publisher.unpublishArchive(); 509 } 510 } on UnpublishException catch (e, s) { 511 exitCode = e.exitCode; 512 message = e.message; 513 stack = s.toString(); 514 } catch (e, s) { 515 exitCode = -1; 516 message = e.toString(); 517 stack = s.toString(); 518 } finally { 519 if (removeTempDir) { 520 tempDir.deleteSync(recursive: true); 521 } 522 if (exitCode != 0) { 523 errorExit('$message\n$stack', exitCode: exitCode); 524 } 525 if (!parsedArguments['confirm']) { 526 _printBanner('This was just a dry run. To actually perform the above changes, re-run with --confirm argument.'); 527 } 528 exit(0); 529 } 530} 531