• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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