github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/release/update/s3.go (about)

     1  // Copyright 2015 Keybase, Inc. All rights reserved. Use of
     2  // this source code is governed by the included BSD license.
     3  
     4  package update
     5  
     6  import (
     7  	"bytes"
     8  	"fmt"
     9  	"io"
    10  	"log"
    11  	"os"
    12  	"path/filepath"
    13  	"sort"
    14  	"strings"
    15  	"text/tabwriter"
    16  	"time"
    17  
    18  	"github.com/alecthomas/template"
    19  	"github.com/blang/semver"
    20  	"github.com/keybase/client/go/release/version"
    21  
    22  	"github.com/aws/aws-sdk-go/aws"
    23  	"github.com/aws/aws-sdk-go/aws/session"
    24  	"github.com/aws/aws-sdk-go/service/s3"
    25  )
    26  
    27  const defaultCacheControl = "max-age=60"
    28  
    29  const defaultChannel = "v2"
    30  
    31  // Section defines a set of releases
    32  type Section struct {
    33  	Header   string
    34  	Releases []Release
    35  }
    36  
    37  // Release defines a release bundle
    38  type Release struct {
    39  	Name       string
    40  	Key        string
    41  	URL        string
    42  	Version    string
    43  	DateString string
    44  	Date       time.Time
    45  	Commit     string
    46  }
    47  
    48  // ByRelease defines how to sort releases
    49  type ByRelease []Release
    50  
    51  func (s ByRelease) Len() int {
    52  	return len(s)
    53  }
    54  
    55  func (s ByRelease) Swap(i, j int) {
    56  	s[i], s[j] = s[j], s[i]
    57  }
    58  
    59  func (s ByRelease) Less(i, j int) bool {
    60  	// Reverse date order
    61  	return s[j].Date.Before(s[i].Date)
    62  }
    63  
    64  // Client is an S3 client
    65  type Client struct {
    66  	svc *s3.S3
    67  }
    68  
    69  // NewClient constructs a Client
    70  func NewClient() (*Client, error) {
    71  	sess, err := session.NewSession(&aws.Config{Region: aws.String("us-east-1")})
    72  	if err != nil {
    73  		return nil, err
    74  	}
    75  	svc := s3.New(sess)
    76  	return &Client{svc: svc}, nil
    77  }
    78  
    79  func convertEastern(t time.Time) time.Time {
    80  	locationNewYork, err := time.LoadLocation("America/New_York")
    81  	if err != nil {
    82  		log.Printf("Couldn't load location: %s", err)
    83  	}
    84  	return t.In(locationNewYork)
    85  }
    86  
    87  func loadReleases(objects []*s3.Object, bucketName string, prefix string, suffix string, truncate int) []Release {
    88  	var releases []Release
    89  	for _, obj := range objects {
    90  		if strings.HasSuffix(*obj.Key, suffix) {
    91  			urlString, name := urlStringForKey(*obj.Key, bucketName, prefix)
    92  			if name == "index.html" {
    93  				continue
    94  			}
    95  			version, _, date, commit, err := version.Parse(name)
    96  			if err != nil {
    97  				log.Printf("Couldn't get version from name: %s\n", name)
    98  			}
    99  			date = convertEastern(date)
   100  			releases = append(releases,
   101  				Release{
   102  					Name:       name,
   103  					Key:        *obj.Key,
   104  					URL:        urlString,
   105  					Version:    version,
   106  					Date:       date,
   107  					DateString: date.Format("Mon Jan _2 15:04:05 MST 2006"),
   108  					Commit:     commit,
   109  				})
   110  		}
   111  	}
   112  	// TODO: Should also sanity check that version sort is same as time sort
   113  	// otherwise something got messed up
   114  	sort.Sort(ByRelease(releases))
   115  	if truncate > 0 && len(releases) > truncate {
   116  		releases = releases[0:truncate]
   117  	}
   118  	return releases
   119  }
   120  
   121  // WriteHTML creates an html file for releases
   122  func WriteHTML(bucketName string, prefixes string, suffix string, outPath string, uploadDest string) error {
   123  	var sections []Section
   124  	for _, prefix := range strings.Split(prefixes, ",") {
   125  
   126  		objs, listErr := listAllObjects(bucketName, prefix)
   127  		if listErr != nil {
   128  			return listErr
   129  		}
   130  
   131  		releases := loadReleases(objs, bucketName, prefix, suffix, 50)
   132  		if len(releases) > 0 {
   133  			log.Printf("Found %d release(s) at %s\n", len(releases), prefix)
   134  			// for _, release := range releases {
   135  			// 	log.Printf(" %s %s %s\n", release.Name, release.Version, release.DateString)
   136  			// }
   137  		}
   138  		sections = append(sections, Section{
   139  			Header:   prefix,
   140  			Releases: releases,
   141  		})
   142  	}
   143  
   144  	var buf bytes.Buffer
   145  	err := WriteHTMLForLinks(bucketName, sections, &buf)
   146  	if err != nil {
   147  		return err
   148  	}
   149  	if outPath != "" {
   150  		err = makeParentDirs(outPath)
   151  		if err != nil {
   152  			return err
   153  		}
   154  		err = os.WriteFile(outPath, buf.Bytes(), 0644)
   155  		if err != nil {
   156  			return err
   157  		}
   158  	}
   159  
   160  	if uploadDest != "" {
   161  		client, err := NewClient()
   162  		if err != nil {
   163  			return err
   164  		}
   165  
   166  		log.Printf("Uploading to %s", uploadDest)
   167  		_, err = client.svc.PutObject(&s3.PutObjectInput{
   168  			Bucket:        aws.String(bucketName),
   169  			Key:           aws.String(uploadDest),
   170  			CacheControl:  aws.String(defaultCacheControl),
   171  			ACL:           aws.String("public-read"),
   172  			Body:          bytes.NewReader(buf.Bytes()),
   173  			ContentLength: aws.Int64(int64(buf.Len())),
   174  			ContentType:   aws.String("text/html"),
   175  		})
   176  		if err != nil {
   177  			return err
   178  		}
   179  	}
   180  
   181  	return nil
   182  }
   183  
   184  var htmlTemplate = `
   185  <!doctype html>
   186  <html lang="en">
   187  <head>
   188    <title>{{ .Title }}</title>
   189  	<style>
   190    body { font-family: monospace; }
   191    </style>
   192  </head>
   193  <body>
   194  	{{ range $index, $sec := .Sections }}
   195  		<h3>{{ $sec.Header }}</h3>
   196  		<ul>
   197  		{{ range $index2, $rel := $sec.Releases }}
   198  		<li><a href="{{ $rel.URL }}">{{ $rel.Name }}</a> <strong>{{ $rel.Version }}</strong> <em>{{ $rel.Date }}</em> <a href="https://github.com/keybase/client/commit/{{ $rel.Commit }}"">{{ $rel.Commit }}</a></li>
   199  		{{ end }}
   200  		</ul>
   201  	{{ end }}
   202  </body>
   203  </html>
   204  `
   205  
   206  // WriteHTMLForLinks writes a summary document for a set of releases
   207  func WriteHTMLForLinks(title string, sections []Section, writer io.Writer) error {
   208  	vars := map[string]interface{}{
   209  		"Title":    title,
   210  		"Sections": sections,
   211  	}
   212  
   213  	t, err := template.New("t").Parse(htmlTemplate)
   214  	if err != nil {
   215  		return err
   216  	}
   217  
   218  	return t.Execute(writer, vars)
   219  }
   220  
   221  // Platform defines where platform specific files are (in darwin, linux, windows)
   222  type Platform struct {
   223  	Name          string
   224  	Prefix        string
   225  	PrefixSupport string
   226  	Suffix        string
   227  	LatestName    string
   228  }
   229  
   230  // CopyLatest copies latest release to a fixed path
   231  func CopyLatest(bucketName string, platform string, dryRun bool) error {
   232  	client, err := NewClient()
   233  	if err != nil {
   234  		return err
   235  	}
   236  	return client.CopyLatest(bucketName, platform, dryRun)
   237  }
   238  
   239  const (
   240  	// PlatformTypeDarwin is platform type for OS X
   241  	PlatformTypeDarwin      = "darwin"
   242  	PlatformTypeDarwinArm64 = "darwin-arm64"
   243  	// PlatformTypeLinux is platform type for Linux
   244  	PlatformTypeLinux = "linux"
   245  	// PlatformTypeWindows is platform type for windows
   246  	PlatformTypeWindows = "windows"
   247  )
   248  
   249  var platformDarwin = Platform{Name: PlatformTypeDarwin, Prefix: "darwin/", PrefixSupport: "darwin-support/", LatestName: "Keybase.dmg"}
   250  var platformDarwinArm64 = Platform{Name: PlatformTypeDarwinArm64, Prefix: "darwin-arm64/", PrefixSupport: "darwin-arm64-support/", LatestName: "Keybase-arm64.dmg"}
   251  var platformLinuxDeb = Platform{Name: "deb", Prefix: "linux_binaries/deb/", Suffix: "_amd64.deb", LatestName: "keybase_amd64.deb"}
   252  var platformLinuxRPM = Platform{Name: "rpm", Prefix: "linux_binaries/rpm/", Suffix: ".x86_64.rpm", LatestName: "keybase_amd64.rpm"}
   253  var platformWindows = Platform{Name: PlatformTypeWindows, Prefix: "windows/", PrefixSupport: "windows-support/", LatestName: "keybase_setup_amd64.msi"}
   254  
   255  var platformsAll = []Platform{
   256  	platformDarwin,
   257  	platformDarwinArm64,
   258  	platformLinuxDeb,
   259  	platformLinuxRPM,
   260  	platformWindows,
   261  }
   262  
   263  // Platforms returns platforms for a name (linux may have multiple platforms) or all platforms is "" is specified
   264  func Platforms(name string) ([]Platform, error) {
   265  	switch name {
   266  	case PlatformTypeDarwin:
   267  		return []Platform{platformDarwin}, nil
   268  	case PlatformTypeDarwinArm64:
   269  		return []Platform{platformDarwinArm64}, nil
   270  	case PlatformTypeLinux:
   271  		return []Platform{platformLinuxDeb, platformLinuxRPM}, nil
   272  	case PlatformTypeWindows:
   273  		return []Platform{platformWindows}, nil
   274  	case "":
   275  		return platformsAll, nil
   276  	default:
   277  		return nil, fmt.Errorf("Invalid platform %s", name)
   278  	}
   279  }
   280  
   281  func listAllObjects(bucketName string, prefix string) ([]*s3.Object, error) {
   282  	client, err := NewClient()
   283  	if err != nil {
   284  		return nil, err
   285  	}
   286  
   287  	marker := ""
   288  	objs := make([]*s3.Object, 0, 1000)
   289  	for {
   290  		resp, err := client.svc.ListObjects(&s3.ListObjectsInput{
   291  			Bucket:    aws.String(bucketName),
   292  			Delimiter: aws.String("/"),
   293  			Prefix:    aws.String(prefix),
   294  			Marker:    aws.String(marker),
   295  		})
   296  		if err != nil {
   297  			return nil, err
   298  		}
   299  		if resp == nil {
   300  			break
   301  		}
   302  
   303  		out := *resp
   304  		nextMarker := ""
   305  		truncated := false
   306  		if out.NextMarker != nil {
   307  			nextMarker = *out.NextMarker
   308  		}
   309  		if out.IsTruncated != nil {
   310  			truncated = *out.IsTruncated
   311  		}
   312  
   313  		objs = append(objs, out.Contents...)
   314  		if !truncated {
   315  			break
   316  		}
   317  
   318  		log.Printf("Response is truncated, next marker is %s\n", nextMarker)
   319  		marker = nextMarker
   320  	}
   321  
   322  	return objs, nil
   323  }
   324  
   325  // FindRelease searches for a release matching a predicate
   326  func (p *Platform) FindRelease(bucketName string, f func(r Release) bool) (*Release, error) {
   327  	contents, err := listAllObjects(bucketName, p.Prefix)
   328  	if err != nil {
   329  		return nil, err
   330  	}
   331  
   332  	releases := loadReleases(contents, bucketName, p.Prefix, p.Suffix, 0)
   333  	for _, release := range releases {
   334  		if !strings.HasSuffix(release.Key, p.Suffix) {
   335  			continue
   336  		}
   337  		if f(release) {
   338  			return &release, nil
   339  		}
   340  	}
   341  	return nil, nil
   342  }
   343  
   344  // Files returns all files associated with this platforms release
   345  func (p Platform) Files(releaseName string) ([]string, error) {
   346  	switch p.Name {
   347  	case PlatformTypeDarwin:
   348  		return []string{
   349  			fmt.Sprintf("darwin/Keybase-%s.dmg", releaseName),
   350  			fmt.Sprintf("darwin-updates/Keybase-%s.zip", releaseName),
   351  			fmt.Sprintf("darwin-support/update-darwin-prod-%s.json", releaseName),
   352  		}, nil
   353  	case PlatformTypeDarwinArm64:
   354  		return []string{
   355  			fmt.Sprintf("darwin-arm64/Keybase-%s.dmg", releaseName),
   356  			fmt.Sprintf("darwin-arm64-updates/Keybase-%s.zip", releaseName),
   357  			fmt.Sprintf("darwin-arm64-support/update-darwin-prod-%s.json", releaseName),
   358  		}, nil
   359  	default:
   360  		return nil, fmt.Errorf("Unsupported for this platform: %s", p.Name)
   361  	}
   362  }
   363  
   364  // WriteHTML will generate index.html for the platform
   365  func (p Platform) WriteHTML(bucketName string) error {
   366  	return WriteHTML(bucketName, p.Prefix, "", "", p.Prefix+"/index.html")
   367  }
   368  
   369  // CopyLatest copies latest release to a fixed path for the Client
   370  func (c *Client) CopyLatest(bucketName string, platform string, dryRun bool) error {
   371  	platforms, err := Platforms(platform)
   372  	if err != nil {
   373  		return err
   374  	}
   375  	for _, platform := range platforms {
   376  		var url string
   377  		// Use update json to look for current DMG (for darwin)
   378  		// TODO: Fix for linux
   379  		switch platform.Name {
   380  		case PlatformTypeDarwin, PlatformTypeDarwinArm64, PlatformTypeWindows:
   381  			url, err = c.copyFromUpdate(platform, bucketName)
   382  		default:
   383  			_, url, err = c.copyFromReleases(platform, bucketName)
   384  		}
   385  		if err != nil {
   386  			return err
   387  		}
   388  		if url == "" {
   389  			continue
   390  		}
   391  
   392  		if dryRun {
   393  			log.Printf("DRYRUN: Would copy latest %s to %s\n", url, platform.LatestName)
   394  			return nil
   395  		}
   396  
   397  		_, err := c.svc.CopyObject(&s3.CopyObjectInput{
   398  			Bucket:       aws.String(bucketName),
   399  			CopySource:   aws.String(url),
   400  			Key:          aws.String(platform.LatestName),
   401  			CacheControl: aws.String(defaultCacheControl),
   402  			ACL:          aws.String("public-read"),
   403  		})
   404  		if err != nil {
   405  			return err
   406  		}
   407  	}
   408  	return nil
   409  }
   410  
   411  func (c *Client) copyFromUpdate(platform Platform, bucketName string) (url string, err error) {
   412  	currentUpdate, path, err := c.CurrentUpdate(bucketName, defaultChannel, platform.Name, "prod")
   413  	if err != nil {
   414  		err = fmt.Errorf("Error getting current public update: %s", err)
   415  		return
   416  	}
   417  	if currentUpdate == nil {
   418  		err = fmt.Errorf("No latest for %s at %s", platform.Name, path)
   419  		return
   420  	}
   421  	switch platform.Name {
   422  	case PlatformTypeDarwin, PlatformTypeDarwinArm64:
   423  		url = urlString(bucketName, platform.Prefix, fmt.Sprintf("Keybase-%s.dmg", currentUpdate.Version))
   424  	case PlatformTypeWindows:
   425  		url = urlString(bucketName, platform.Prefix, fmt.Sprintf("Keybase_%s.amd64.msi", currentUpdate.Version))
   426  	default:
   427  		err = fmt.Errorf("Unsupported platform for copyFromUpdate")
   428  	}
   429  	return
   430  }
   431  
   432  func (c *Client) copyFromReleases(platform Platform, bucketName string) (release *Release, url string, err error) {
   433  	release, err = platform.FindRelease(bucketName, func(r Release) bool { return true })
   434  	if err != nil || release == nil {
   435  		return
   436  	}
   437  	url, _ = urlStringForKey(release.Key, bucketName, platform.Prefix)
   438  	return
   439  }
   440  
   441  // CurrentUpdate returns current update for a platform
   442  func (c *Client) CurrentUpdate(bucketName string, channel string, platformName string, env string) (currentUpdate *Update, path string, err error) {
   443  	path = updateJSONName(channel, platformName, env)
   444  	log.Printf("Fetching current update at %s", path)
   445  	resp, err := c.svc.GetObject(&s3.GetObjectInput{
   446  		Bucket: aws.String(bucketName),
   447  		Key:    aws.String(path),
   448  	})
   449  	if err != nil {
   450  		return
   451  	}
   452  	defer func() { _ = resp.Body.Close() }()
   453  	currentUpdate, err = DecodeJSON(resp.Body)
   454  	return
   455  }
   456  
   457  func promoteRelease(bucketName string, delay time.Duration, hourEastern int, toChannel string, platform Platform, env string, allowDowngrade bool, release string) (*Release, error) {
   458  	client, err := NewClient()
   459  	if err != nil {
   460  		return nil, err
   461  	}
   462  	return client.PromoteRelease(bucketName, delay, hourEastern, toChannel, platform, env, allowDowngrade, release)
   463  }
   464  
   465  func updateJSONName(channel string, platformName string, env string) string {
   466  	if channel == "" {
   467  		return fmt.Sprintf("update-%s-%s.json", platformName, env)
   468  	}
   469  	return fmt.Sprintf("update-%s-%s-%s.json", platformName, env, channel)
   470  }
   471  
   472  // PromoteARelease promotes a specific release to Prod.
   473  func PromoteARelease(releaseName string, bucketName string, platform string, dryRun bool) (release *Release, err error) {
   474  	switch platform {
   475  	case PlatformTypeDarwin, PlatformTypeDarwinArm64, PlatformTypeWindows:
   476  		// pass
   477  	default:
   478  		return nil, fmt.Errorf("Promoting releases is only supported for darwin or windows")
   479  
   480  	}
   481  
   482  	client, err := NewClient()
   483  	if err != nil {
   484  		return nil, err
   485  	}
   486  
   487  	platformRes, err := Platforms(platform)
   488  	if err != nil {
   489  		return nil, err
   490  	}
   491  	if len(platformRes) != 1 {
   492  		return nil, fmt.Errorf("Promoting on multiple platforms is not supported")
   493  	}
   494  
   495  	platformType := platformRes[0]
   496  	release, err = client.promoteAReleaseToProd(releaseName, bucketName, platformType, "prod", defaultChannel, dryRun)
   497  	if err != nil {
   498  		return nil, err
   499  	}
   500  	if dryRun {
   501  		return release, nil
   502  	}
   503  	log.Printf("Promoted %s release: %s\n", platform, releaseName)
   504  	return release, nil
   505  }
   506  
   507  func (c *Client) promoteAReleaseToProd(releaseName string, bucketName string, platform Platform, env string, toChannel string, dryRun bool) (release *Release, err error) {
   508  	var filePath string
   509  	switch platform.Name {
   510  	case PlatformTypeDarwin, PlatformTypeDarwinArm64:
   511  		filePath = fmt.Sprintf("Keybase-%s.dmg", releaseName)
   512  	case PlatformTypeWindows:
   513  		filePath = fmt.Sprintf("Keybase_%s.amd64.msi", releaseName)
   514  	default:
   515  		return nil, fmt.Errorf("Unsupported for this platform: %s", platform.Name)
   516  	}
   517  
   518  	release, err = platform.FindRelease(bucketName, func(r Release) bool {
   519  		return r.Name == filePath
   520  	})
   521  	if err != nil {
   522  		return nil, err
   523  	}
   524  	if release == nil {
   525  		return nil, fmt.Errorf("No matching release found")
   526  	}
   527  	log.Printf("Found %s release %s (%s), %s", platform.Name, release.Name, time.Since(release.Date), release.Version)
   528  	jsonName := updateJSONName(toChannel, platform.Name, env)
   529  	jsonURL := urlString(bucketName, platform.PrefixSupport, fmt.Sprintf("update-%s-%s-%s.json", platform.Name, env, release.Version))
   530  
   531  	if dryRun {
   532  		log.Printf("DRYRUN: Would PutCopy %s to %s\n", jsonURL, jsonName)
   533  		return release, nil
   534  	}
   535  	log.Printf("PutCopying %s to %s\n", jsonURL, jsonName)
   536  	_, err = c.svc.CopyObject(&s3.CopyObjectInput{
   537  		Bucket:       aws.String(bucketName),
   538  		CopySource:   aws.String(jsonURL),
   539  		Key:          aws.String(jsonName),
   540  		CacheControl: aws.String(defaultCacheControl),
   541  		ACL:          aws.String("public-read"),
   542  	})
   543  	return release, err
   544  }
   545  
   546  // PromoteRelease promotes a release to a channel
   547  func (c *Client) PromoteRelease(bucketName string, delay time.Duration, beforeHourEastern int, toChannel string, platform Platform, env string, allowDowngrade bool, releaseName string) (*Release, error) {
   548  	log.Printf("Finding release to promote to %q (%s delay) in env %s", toChannel, delay, env)
   549  	var release *Release
   550  	var err error
   551  
   552  	if releaseName != "" {
   553  		releaseName = fmt.Sprintf("Keybase-%s.dmg", releaseName)
   554  		release, err = platform.FindRelease(bucketName, func(r Release) bool {
   555  			return r.Name == releaseName
   556  		})
   557  	} else {
   558  		release, err = platform.FindRelease(bucketName, func(r Release) bool {
   559  			log.Printf("Checking release date %s", r.Date)
   560  			if delay != 0 && time.Since(r.Date) < delay {
   561  				return false
   562  			}
   563  			hour, _, _ := r.Date.Clock()
   564  			if beforeHourEastern != 0 && hour >= beforeHourEastern {
   565  				return false
   566  			}
   567  			return true
   568  		})
   569  	}
   570  
   571  	if err != nil {
   572  		return nil, err
   573  	}
   574  
   575  	if release == nil {
   576  		log.Printf("No matching release found")
   577  		return nil, nil
   578  	}
   579  	log.Printf("Found release %s (%s), %s", release.Name, time.Since(release.Date), release.Version)
   580  
   581  	currentUpdate, _, err := c.CurrentUpdate(bucketName, toChannel, platform.Name, env)
   582  	if err != nil {
   583  		log.Printf("Error looking for current update: %s (%s)", err, platform.Name)
   584  	}
   585  	if currentUpdate != nil {
   586  		log.Printf("Found current update: %s", currentUpdate.Version)
   587  		var currentVer semver.Version
   588  		currentVer, err = semver.Make(currentUpdate.Version)
   589  		if err != nil {
   590  			return nil, err
   591  		}
   592  		var releaseVer semver.Version
   593  		releaseVer, err = semver.Make(release.Version)
   594  		if err != nil {
   595  			return nil, err
   596  		}
   597  
   598  		if releaseVer.Equals(currentVer) {
   599  			log.Printf("Release unchanged")
   600  			return nil, nil
   601  		} else if releaseVer.LT(currentVer) {
   602  			if !allowDowngrade {
   603  				log.Printf("Release older than current update")
   604  				return nil, nil
   605  			}
   606  			log.Printf("Allowing downgrade")
   607  		}
   608  	}
   609  
   610  	jsonURL := urlString(bucketName, platform.PrefixSupport, fmt.Sprintf("update-%s-%s-%s.json", platform.Name, env, release.Version))
   611  	jsonName := updateJSONName(toChannel, platform.Name, env)
   612  	log.Printf("PutCopying %s to %s\n", jsonURL, jsonName)
   613  	_, err = c.svc.CopyObject(&s3.CopyObjectInput{
   614  		Bucket:       aws.String(bucketName),
   615  		CopySource:   aws.String(jsonURL),
   616  		Key:          aws.String(jsonName),
   617  		CacheControl: aws.String(defaultCacheControl),
   618  		ACL:          aws.String("public-read"),
   619  	})
   620  
   621  	if err != nil {
   622  		return nil, err
   623  	}
   624  	return release, nil
   625  }
   626  
   627  func copyUpdateJSON(bucketName string, fromChannel string, toChannel string, platformName string, env string) error {
   628  	client, err := NewClient()
   629  	if err != nil {
   630  		return err
   631  	}
   632  	jsonNameDest := updateJSONName(toChannel, platformName, env)
   633  	jsonURLSource := urlString(bucketName, "", updateJSONName(fromChannel, platformName, env))
   634  
   635  	log.Printf("PutCopying %s to %s\n", jsonURLSource, jsonNameDest)
   636  	_, err = client.svc.CopyObject(&s3.CopyObjectInput{
   637  		Bucket:       aws.String(bucketName),
   638  		CopySource:   aws.String(jsonURLSource),
   639  		Key:          aws.String(jsonNameDest),
   640  		CacheControl: aws.String(defaultCacheControl),
   641  		ACL:          aws.String("public-read"),
   642  	})
   643  	return err
   644  }
   645  
   646  func (c *Client) report(tw io.Writer, bucketName string, channel string, platformName string) {
   647  	update, jsonPath, err := c.CurrentUpdate(bucketName, channel, platformName, "prod")
   648  	fmt.Fprintf(tw, "%s\t%s\t", platformName, channel)
   649  	if err != nil {
   650  		fmt.Fprintln(tw, "Error")
   651  	} else if update != nil {
   652  		published := ""
   653  		if update.PublishedAt != nil {
   654  			published = convertEastern(FromTime(*update.PublishedAt)).Format(time.UnixDate)
   655  		}
   656  		fmt.Fprintf(tw, "%s\t%s\t%s\n", update.Version, published, jsonPath)
   657  	} else {
   658  		fmt.Fprintln(tw, "None")
   659  	}
   660  }
   661  
   662  // Report returns a summary of releases
   663  func Report(bucketName string, writer io.Writer) error {
   664  	client, err := NewClient()
   665  	if err != nil {
   666  		return err
   667  	}
   668  
   669  	tw := tabwriter.NewWriter(writer, 5, 0, 3, ' ', 0)
   670  	fmt.Fprintln(tw, "Platform\tChannel\tVersion\tCreated\tSource")
   671  	client.report(tw, bucketName, "test-v2", PlatformTypeDarwin)
   672  	client.report(tw, bucketName, "v2", PlatformTypeDarwin)
   673  	client.report(tw, bucketName, "test-v2", PlatformTypeDarwinArm64)
   674  	client.report(tw, bucketName, "v2", PlatformTypeDarwinArm64)
   675  	client.report(tw, bucketName, "test", PlatformTypeLinux)
   676  	client.report(tw, bucketName, "", PlatformTypeLinux)
   677  	return tw.Flush()
   678  }
   679  
   680  // promoteTestReleaseForDarwin creates a test release for darwin
   681  func promoteTestReleaseForDarwin(bucketName string, release string) (*Release, error) {
   682  	return promoteRelease(bucketName, time.Duration(0), 0, "test-v2", platformDarwin, "prod", true, release)
   683  }
   684  
   685  func promoteTestReleaseForDarwinArm64(bucketName string, release string) (*Release, error) {
   686  	return promoteRelease(bucketName, time.Duration(0), 0, "test-v2", platformDarwinArm64, "prod", true, release)
   687  }
   688  
   689  // promoteTestReleaseForLinux creates a test release for linux
   690  func promoteTestReleaseForLinux(bucketName string) error {
   691  	// This just copies public to test since we don't do promotion on this platform yet
   692  	return copyUpdateJSON(bucketName, "", "test", PlatformTypeLinux, "prod")
   693  }
   694  
   695  // promoteTestReleaseForWindows creates a test release for windows
   696  func promoteTestReleaseForWindows(bucketName string) error {
   697  	// This just copies public to test since we don't do promotion on this platform yet
   698  	return copyUpdateJSON(bucketName, "", "test", PlatformTypeWindows, "prod")
   699  }
   700  
   701  // PromoteTestReleases creates test releases for a platform
   702  func PromoteTestReleases(bucketName string, platformName string, release string) error {
   703  	switch platformName {
   704  	case PlatformTypeDarwin:
   705  		_, err := promoteTestReleaseForDarwin(bucketName, release)
   706  		return err
   707  	case PlatformTypeDarwinArm64:
   708  		_, err := promoteTestReleaseForDarwinArm64(bucketName, release)
   709  		return err
   710  	case PlatformTypeLinux:
   711  		return promoteTestReleaseForLinux(bucketName)
   712  	case PlatformTypeWindows:
   713  		return promoteTestReleaseForWindows(bucketName)
   714  	default:
   715  		return fmt.Errorf("Invalid platform %s", platformName)
   716  	}
   717  }
   718  
   719  // PromoteReleases creates releases for a platform
   720  func PromoteReleases(bucketName string, platformType string) (release *Release, err error) {
   721  	var platform Platform
   722  	switch platformType {
   723  	case PlatformTypeDarwin:
   724  		platform = platformDarwin
   725  	case PlatformTypeDarwinArm64:
   726  		platform = platformDarwinArm64
   727  	default:
   728  		log.Printf("Promoting releases is unsupported for %s", platformType)
   729  		return
   730  	}
   731  	release, err = promoteRelease(bucketName, time.Hour*27, 10, defaultChannel, platform, "prod", false, "")
   732  	if err != nil {
   733  		return nil, err
   734  	}
   735  	if release != nil {
   736  		log.Printf("Promoted (darwin) release: %s\n", release.Name)
   737  	}
   738  	return release, nil
   739  }
   740  
   741  // ReleaseBroken marks a release as broken. The releaseName is the version,
   742  // for example, 1.2.3+400-deadbeef.
   743  func ReleaseBroken(releaseName string, bucketName string, platformName string) ([]string, error) {
   744  	client, err := NewClient()
   745  	if err != nil {
   746  		return nil, err
   747  	}
   748  	platforms, err := Platforms(platformName)
   749  	if err != nil {
   750  		return nil, err
   751  	}
   752  	removed := []string{}
   753  	for _, platform := range platforms {
   754  		files, err := platform.Files(releaseName)
   755  		if err != nil {
   756  			return nil, err
   757  		}
   758  		for _, path := range files {
   759  			sourceURL := urlString(bucketName, "", path)
   760  			brokenPath := fmt.Sprintf("broken/%s", path)
   761  			log.Printf("Copying %s to %s", sourceURL, brokenPath)
   762  
   763  			_, err := client.svc.CopyObject(&s3.CopyObjectInput{
   764  				Bucket:       aws.String(bucketName),
   765  				CopySource:   aws.String(sourceURL),
   766  				Key:          aws.String(brokenPath),
   767  				CacheControl: aws.String(defaultCacheControl),
   768  				ACL:          aws.String("public-read"),
   769  			})
   770  			if err != nil {
   771  				log.Printf("There was an error trying to (put) copy %s: %s", sourceURL, err)
   772  				continue
   773  			}
   774  
   775  			log.Printf("Deleting: %s", path)
   776  			_, err = client.svc.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucketName), Key: aws.String(path)})
   777  			if err != nil {
   778  				return removed, err
   779  			}
   780  			removed = append(removed, path)
   781  		}
   782  
   783  		// Update html for platform
   784  		if err := platform.WriteHTML(bucketName); err != nil {
   785  			log.Printf("Error updating html: %s", err)
   786  		}
   787  
   788  		// Fix test releases if needed
   789  		if err := PromoteTestReleases(bucketName, platform.Name, ""); err != nil {
   790  			log.Printf("Error fixing test releases: %s", err)
   791  		}
   792  	}
   793  	log.Printf("Deleted %d files for %s", len(removed), releaseName)
   794  	if len(removed) == 0 {
   795  		return removed, fmt.Errorf("No files to remove for %s", releaseName)
   796  	}
   797  
   798  	return removed, nil
   799  }
   800  
   801  // SaveLog saves log to S3 bucket (last maxNumBytes) and returns the URL.
   802  // The log is publicly readable on S3 but the url is not discoverable.
   803  func SaveLog(bucketName string, localPath string, maxNumBytes int64) (string, error) {
   804  	client, err := NewClient()
   805  	if err != nil {
   806  		return "", err
   807  	}
   808  
   809  	file, err := os.Open(localPath)
   810  	if err != nil {
   811  		return "", fmt.Errorf("Error opening: %s", err)
   812  	}
   813  	defer func() { _ = file.Close() }()
   814  
   815  	stat, err := os.Stat(localPath)
   816  	if err != nil {
   817  		return "", fmt.Errorf("Error in stat: %s", err)
   818  	}
   819  	if maxNumBytes > stat.Size() {
   820  		maxNumBytes = stat.Size()
   821  	}
   822  
   823  	data := make([]byte, maxNumBytes)
   824  	start := stat.Size() - maxNumBytes
   825  	_, err = file.ReadAt(data, start)
   826  	if err != nil {
   827  		return "", fmt.Errorf("Error reading: %s", err)
   828  	}
   829  
   830  	filename := filepath.Base(localPath)
   831  	logID, err := RandomID()
   832  	if err != nil {
   833  		return "", err
   834  	}
   835  	uploadDest := filepath.ToSlash(filepath.Join("logs", fmt.Sprintf("%s-%s%s", filename, logID, ".txt")))
   836  
   837  	_, err = client.svc.PutObject(&s3.PutObjectInput{
   838  		Bucket:        aws.String(bucketName),
   839  		Key:           aws.String(uploadDest),
   840  		CacheControl:  aws.String(defaultCacheControl),
   841  		ACL:           aws.String("public-read"),
   842  		Body:          bytes.NewReader(data),
   843  		ContentLength: aws.Int64(int64(len(data))),
   844  		ContentType:   aws.String("text/plain"),
   845  	})
   846  	if err != nil {
   847  		return "", err
   848  	}
   849  
   850  	url := urlStringNoEscape(bucketName, uploadDest)
   851  	return url, nil
   852  }