diff --git a/README.md b/README.md index 9a9fa05..fa92830 100644 --- a/README.md +++ b/README.md @@ -8,101 +8,90 @@ Automate the beta testing distribution of your Android and iOS application files Gota is a [Golang](http://golang.org/) powered Over the Air Installation site creation tool. -![](./docs/gota_html.png) +![](./docs/gota_workflow.png) ## Feature Checklist * [x] Upload and generate site to a Nexus 3 Site Repository -* [ ] Upload and generate site to a Nexus 2 Site Repository (untested) -* [ ] Upload and generate site to an Amazon S3 bucket +* [x] Upload and generate site to an Amazon S3 bucket +* [ ] Upload and generate site to a Nexus 2 Site Repository ## Installation -Get the executable binary for your platform from the [Release Page](https://github.com/bzon/gota/releases/). +Get the executable binary for your platform from the [Release Page](https://github.com/bzon/gota/releases/) -## Commands Guide +If you have Go installed, just run `go get githug.com/bzon/gota`. -Gota command help `gota --help` +## User Guide + +To see the required flags, use the --help flag. ```bash -./gota --help   -Go Over the Air installation for Android APK and iOS Ipa files! +gota --help +gota nexus --help +gota s3 --help +``` -Usage: - gota [command] +Gota creates a `gotalink.txt` and `ipalink.txt` (if uploading an ipa) that contains the url or direct download link. -Available Commands: - help Help about any command - nexus Upload your apk or ipa file and create an over-the-air static site in a Nexus Site repository +If you are using a CI server, you can have it read these files for quickly getting the url that you can send to your team. -Flags: - --destDir string root directory of the site to create. - -h, --help help for gota - --srcFile string the apk or ipa file. -``` +### Upload to S3 Bucket -Nexus command help `gota nexus --help` ```bash -./gota nexus --help   -Upload your apk or ipa file and create an over-the-air static site in a Nexus Site repository - -Usage: - gota nexus [flags] - -Flags: - -h, --help help for nexus - --nexusHost string nexus host url (including http protocol) - --nexusPassword string nexus password (can be passed as env variable $NEXUS_PASSWORD) - --nexusRepo string nexus site repository id (nexus v3 raw repository not maven!) - --nexusUser string nexus username (can be passed as env variable $NEXUS_USER) - -Global Flags: - --destDir string root directory of the site to create. - --srcFile string the apk or ipa file. +# set the aws credentials +export AWS_ACCESS_KEY=xxxxx +export AWS_SECRET_ACCESS_KEY=xxxxx + +./gota s3 --bucket example-s3-bucket --srcFile sample.ipa --destDir ios_bucket + +2018/04/30 01:12:37 file uploaded: https://example-s3-bucket.s3.amazonaws.com/ios_bucket/1.0.0/4/appicon.png +2018/04/30 01:12:37 file uploaded: https://example-s3-bucket.s3.amazonaws.com/ios_bucket/1.0.0/version.json +2018/04/30 01:12:37 file uploaded: https://example-s3-bucket.s3.amazonaws.com/ios_bucket/1.0.0/4/index.html +2018/04/30 01:12:37 file uploaded: https://example-s3-bucket.s3.amazonaws.com/ios_bucket/1.0.0/4/sample.ipa +2018/04/30 01:12:37 file uploaded: https://example-s3-bucket.s3.amazonaws.com/ios_bucket/1.0.0/4/app.plist ``` -### Nexus APK Upload +__NOTE__: Currently, gota assigns a AES256 encrpytion and a public-read ACL to all files that are uploaded. +This may change to be configurable in the future. -Upload an APK file to a Nexus Site Repository +### Upload to Nexus + +The repository must be a [Raw Site Repository](https://help.sonatype.com/repomanager3/raw-repositories-and-maven-sites). ```bash +# set the nexus credentials +# this can also be set via command flags +export NEXUS_USER=admin +export NEXUS_PASSWORD=admin123 + ./gota nexus --nexusHost http://localhost:8081 \ --nexusRepo site \ - --nexusUser admin \ - --nexusPassword admin123 \ --destDir nexus_android_repo \ - --srcFile pkg/resources/DarkSouls.apk \ + --srcFile build/outpus/apk/sample.apk \ file uploaded: http://localhost:8081/repository/site/nexus_android_repo/1.0.0/10222333/appicon.png -file uploaded: http://localhost:8081/repository/site/nexus_android_repo/version.json +file uploaded: http://localhost:8081/repository/site/nexus_android_repo/1.0.0/version.json file uploaded: http://localhost:8081/repository/site/nexus_android_repo/1.0.0/10222333/index.html -file uploaded: http://localhost:8081/repository/site/nexus_android_repo/1.0.0/10222333/DarkSouls.apk +file uploaded: http://localhost:8081/repository/site/nexus_android_repo/1.0.0/10222333/sample.apk ``` -Access the index.html file url from your Android device! +__NOTE__: Currently supports only Nexus 3. -### Nexus IPA Upload - -Upload an IPA file to a Nexus Site Repository +### Site Directory Layout ```bash -./gota nexus --nexusHost http://localhost:8081 \ - --nexusRepo site \ - --nexusUser admin \ - --nexusPassword admin123 \ - --destDir nexus_ios_repo \ - --srcFile pkg/resources/DarkSouls.ipa \ - -file uploaded: http://localhost:8081/repository/site/nexus_ios_repo/1.0.0/4/appicon.png -file uploaded: http://localhost:8081/repository/site/nexus_ios_repo/version.json -file uploaded: http://localhost:8081/repository/site/nexus_ios_repo/1.0.0/4/index.html -file uploaded: http://localhost:8081/repository/site/nexus_ios_repo/1.0.0/4/DarkSouls.ipa -file uploaded: http://localhost:8081/repository/site/nexus_ios_repo/1.0.0/4/app.plist +destDir +\__(ipa CFBundleShortVersion or apk versionName) + \__version.json + \__(ipa CFBundleVersion or apk versionCode) + \__appicon.png + \__(ipa or apk file) + \__app.plist (if ipa file) + \__index.html ``` -Access the index.html file url from your iPhone device! - ## Development Setup ### Build and Test @@ -120,6 +109,16 @@ go get -v ./... go test -v ./... ``` +### S3 Feature Test + +Set these environment variables before running `go test` in s3 package. + +```bash +AWS_ACCESS_KEY=xxxxx +AWS_SECRET_ACCESS_KEY=xxxxx +GOTEST_AWS_BUCKET=example-bucket +``` + ### Nexus Feature Test You must have a Nexus 3 server running in your machine. diff --git a/cmd/root.go b/cmd/root.go index 73befba..7933c54 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,15 +33,6 @@ var srcFile, destDir string var rootCmd = &cobra.Command{ Use: "gota", Short: "Go Over the Air installation for Android APK and iOS Ipa files!", - // Run: func(cmd *cobra.Command, args []string) { - // appInfo, err := ipapk.NewAppParser(srcFile) - // if err != nil { - // log.Fatal(err) - // } - // app.UploadDate = time.Now().Format(time.RFC1123) - // app.AppInfo = appInfo - // app.File = srcFile - // }, } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/cmd/s3.go b/cmd/s3.go new file mode 100644 index 0000000..facf694 --- /dev/null +++ b/cmd/s3.go @@ -0,0 +1,72 @@ +// Copyright © 2018 NAME HERE +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "io/ioutil" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/bzon/gota/s3" +) + +var upload s3.Upload + +// s3Cmd represents the s3 command +var s3Cmd = &cobra.Command{ + Use: "s3", + Short: "Upload your apk or ipa file and create an over-the-air static site in an S3 Bucket directory", + Long: `Ensure that you have AWS_ACCESS_KEY and AWS_SECRET_ACCESS_KEY set in your environment variable.`, + Run: func(cmd *cobra.Command, args []string) { + if os.Getenv("AWS_ACCESS_KEY") == "" { + log.Fatal("AWS_ACCESS_KEY env variable is not set") + } + if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" { + log.Fatal("AWS_SECRET_ACCESS_KEY env variable is not set") + } + app := NewMobileAppParser() + if err := app.GenerateAssets(); err != nil { + log.Fatal(err) + } + assets, err := s3.UploadAssets(app, upload.Bucket, destDir) + if err != nil { + log.Fatal(err) + } + for _, v := range assets { + log.Println("file uploaded:", v) + // write the index.html file (the ota link) to a file to gotalink.txt + if strings.Contains(v, "index.html") { + if err := ioutil.WriteFile("gotalink.txt", []byte(v), 0644); err != nil { + log.Fatal(err) + } + } + // write the ipa download link to a file ipalink.txt + if strings.Contains(v, ".ipa") { + if err := ioutil.WriteFile("ipalink.txt", []byte(v), 0644); err != nil { + log.Fatal(err) + } + } + } + }, +} + +func init() { + rootCmd.AddCommand(s3Cmd) + s3Cmd.Flags().StringVar(&upload.Bucket, "bucket", "", "the amazon s3 bucket name") + s3Cmd.MarkFlagRequired("bucket") +} diff --git a/docs/gota_html.png b/docs/gota_html.png index f381a29..4068333 100644 Binary files a/docs/gota_html.png and b/docs/gota_html.png differ diff --git a/docs/gota_workflow.png b/docs/gota_workflow.png new file mode 100644 index 0000000..67c5443 Binary files /dev/null and b/docs/gota_workflow.png differ diff --git a/nexus/nexus_upload.go b/nexus/upload.go similarity index 74% rename from nexus/nexus_upload.go rename to nexus/upload.go index e312eb6..a290889 100644 --- a/nexus/nexus_upload.go +++ b/nexus/upload.go @@ -18,17 +18,17 @@ type Nexus struct { // NexusComponent contains the fields that will be passed as a parameter for NexusUpload type NexusComponent struct { - File, Filename, Directory string + SrcFile, DestFilePath string } // NexusUpload uploads a file to Nexus returns the uploaded file url func (n *Nexus) NexusUpload(c NexusComponent) (string, error) { - file, err := os.Open(c.File) + file, err := os.Open(c.SrcFile) if err != nil { return "", err } defer file.Close() - uri := n.getRepoURL() + "/" + c.Directory + "/" + c.Filename + uri := n.getRepoURL() + "/" + c.DestFilePath req, err := http.NewRequest("PUT", uri, file) if err != nil { return "", err @@ -54,13 +54,13 @@ func (n *Nexus) getRepoURL() string { } // NexusUploadAssets uploads the generated files by the parser package along with the ipa or apk file -func (n *Nexus) NexusUploadAssets(app *parser.MobileApp, dir string) ([]string, error) { +func (n *Nexus) NexusUploadAssets(app *parser.MobileApp, destBaseDir string) ([]string, error) { // create the site path names and assume the url before uploaded for templating - buildDir := app.Version + "/" + app.Build + buildDir := destBaseDir + "/" + app.Version + "/" + app.Build appIconPath := buildDir + "/" + parser.AppIconFile appSitePath := buildDir + "/" + filepath.Base(app.File) appIndexHTMLSitePath := buildDir + "/" + parser.IndexHTMLFile - app.DownloadURL = n.getRepoURL() + "/" + dir + "/" + appSitePath + app.DownloadURL = n.getRepoURL() + "/" + appSitePath // default directory of assets assetsDir := parser.AndroidAssetsDir @@ -70,7 +70,7 @@ func (n *Nexus) NexusUploadAssets(app *parser.MobileApp, dir string) ([]string, if app.IsIOS() { assetsDir = parser.IOSAssetsDir appPlistSitePath = buildDir + "/" + parser.IOSPlistFile - app.PlistURL = htmltemp.URL(n.getRepoURL() + "/" + dir + "/" + appPlistSitePath) + app.PlistURL = htmltemp.URL(n.getRepoURL() + "/" + appPlistSitePath) } // create the assets @@ -80,13 +80,13 @@ func (n *Nexus) NexusUploadAssets(app *parser.MobileApp, dir string) ([]string, } components := []NexusComponent{ - {assetsDir + "/" + parser.AppIconFile, appIconPath, dir}, - {assetsDir + "/" + parser.VersionJsonFile, app.Version + "/" + parser.VersionJsonFile, dir}, - {assetsDir + "/" + parser.IndexHTMLFile, appIndexHTMLSitePath, dir}, - {app.File, appSitePath, dir}, + {assetsDir + "/" + parser.AppIconFile, appIconPath}, + {assetsDir + "/" + parser.VersionJsonFile, destBaseDir + "/" + app.Version + "/" + parser.VersionJsonFile}, + {assetsDir + "/" + parser.IndexHTMLFile, appIndexHTMLSitePath}, + {app.File, appSitePath}, } if app.IsIOS() { - components = append(components, NexusComponent{assetsDir + "/" + parser.IOSPlistFile, appPlistSitePath, dir}) + components = append(components, NexusComponent{assetsDir + "/" + parser.IOSPlistFile, appPlistSitePath}) } for _, component := range components { diff --git a/nexus/nexus_upload_test.go b/nexus/upload_test.go similarity index 81% rename from nexus/nexus_upload_test.go rename to nexus/upload_test.go index 8bc8148..922ee25 100644 --- a/nexus/nexus_upload_test.go +++ b/nexus/upload_test.go @@ -18,9 +18,8 @@ var nexus = Nexus{ func TestNexusUpload(t *testing.T) { var testComponent = NexusComponent{ - File: "../resources/index.html", - Filename: "index.html", - Directory: "go_upload_test", + SrcFile: "../resources/index.html", + DestFilePath: "go_upload_test/index.html", } uri, err := nexus.NexusUpload(testComponent) if err != nil { @@ -36,8 +35,8 @@ func TestNexusUploadAssets(t *testing.T) { file string } tt := []tc{ - {"upload ios assets", "nexus_ios_repo", "../parser/testdata/sample.ipa"}, - {"upload android assets", "nexus_android_repo", "../parser/testdata/sample.apk"}, + {"upload ios assets", "xx_ios", "../parser/testdata/sample.ipa"}, + {"upload android assets", "xx_android", "../parser/testdata/sample.apk"}, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { diff --git a/s3/upload.go b/s3/upload.go new file mode 100644 index 0000000..dd359b8 --- /dev/null +++ b/s3/upload.go @@ -0,0 +1,122 @@ +package s3 + +import ( + "bytes" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + + htmltemp "html/template" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/bzon/gota/parser" +) + +// Upload is used by UploadFile +type Upload struct { + Bucket, SrcFile, DestFilePath string +} + +// UploadFile accepts a struct of type Upload. +// It is assumed that the caller has AWS_ACCESS_KEY and AWS_ACCESS_SECRET_KEY is defined as env variable. +// +// API doc: https://github.com/aws/aws-sdk-go/blob/master/service/s3/s3manager/upload.go#L218-L252 +func UploadFile(upload Upload) (string, error) { + f, err := os.Open(upload.SrcFile) + if err != nil { + return "", err + } + defer f.Close() + + // Set the content-type so that the file like index.html can be viewed directly from the browser + fileInfo, _ := f.Stat() + var size int64 = fileInfo.Size() + buffer := make([]byte, size) + _, err = f.Read(buffer) + if err != nil { + return "", err + } + contentType := http.DetectContentType(buffer) + + // Create a new session + sess := session.Must(session.NewSession()) + + // Create an uploader with session and default options + uploader := s3manager.NewUploader(sess) + + // Upload to S3 bucket + result, err := uploader.Upload(&s3manager.UploadInput{ + ACL: aws.String("public-read"), + Bucket: aws.String(upload.Bucket), + Key: aws.String(upload.DestFilePath), + // At this stage, the file that will be uploaded becomes an empty file because f.Read(buffer) already moved f to the last byte. + // So we use bytes.NewReader to read buffer from the first byte instead of passing in f + // If we do not do this, the uploaded file will have 0 byte which is equal to an empty file. + Body: bytes.NewReader(buffer), + ContentType: aws.String(contentType), + ServerSideEncryption: aws.String("AES256"), + }) + if err != nil { + return "", fmt.Errorf("failed to upload file, %v", err) + } + return aws.StringValue(&result.Location), nil +} + +// UploadAssets uploads the generated files by the parser package along with the ipa or apk file. +// It accepts a struct of type *parser.MobileApp and the destination base directory of the s3 bucket. +// It returns a slice of aws s3 files url. +func UploadAssets(app *parser.MobileApp, bucket, destBaseDir string) ([]string, error) { + // create the site path names and assume the url before uploaded for templating + buildDir := destBaseDir + "/" + app.Version + "/" + app.Build + appIconPath := buildDir + "/" + parser.AppIconFile + appSitePath := buildDir + "/" + filepath.Base(app.File) + appIndexHTMLSitePath := buildDir + "/" + parser.IndexHTMLFile + bucketHTTPSurl := "https://" + bucket + ".s3.amazonaws.com" + app.DownloadURL = bucketHTTPSurl + "/" + appSitePath + + // default directory of assets + assetsDir := parser.AndroidAssetsDir + // specific for ios + var appPlistSitePath string + if app.IsIOS() { + assetsDir = parser.IOSAssetsDir + appPlistSitePath = buildDir + "/" + parser.IOSPlistFile + app.PlistURL = htmltemp.URL(bucketHTTPSurl + "/" + appPlistSitePath) + } + + // create the assets + assets := []string{} + if err := app.GenerateAssets(); err != nil { + return assets, err + } + + uploads := []Upload{ + {bucket, assetsDir + "/" + parser.AppIconFile, appIconPath}, + {bucket, assetsDir + "/" + parser.VersionJsonFile, destBaseDir + "/" + app.Version + "/" + parser.VersionJsonFile}, + {bucket, assetsDir + "/" + parser.IndexHTMLFile, appIndexHTMLSitePath}, + {bucket, app.File, appSitePath}, + } + + if app.IsIOS() { + uploads = append(uploads, Upload{bucket, assetsDir + "/" + parser.IOSPlistFile, appPlistSitePath}) + } + + for _, upload := range uploads { + fileURL, err := UploadFile(upload) + if err != nil { + return assets, err + } + // Ensure the returned string is a decoded url + decodedURL, err := url.QueryUnescape(fileURL) + if err != nil { + return assets, err + } + assets = append(assets, decodedURL) + } + + return assets, nil +} diff --git a/s3/upload_test.go b/s3/upload_test.go new file mode 100644 index 0000000..0c9da44 --- /dev/null +++ b/s3/upload_test.go @@ -0,0 +1,73 @@ +package s3 + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/bzon/gota/parser" + "github.com/bzon/ipapk" +) + +func TestUploadFile(t *testing.T) { + if os.Getenv("GOTEST_AWS_BUCKET") == "" { + t.Fatal("GOTEST_AWS_BUCKET env variable is not set.") + } + if os.Getenv("AWS_ACCESS_KEY") == "" { + t.Fatal("AWS_ACCESS_KEY env variable is not set.") + } + if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" { + t.Fatal("AWS_SECRET_ACCESS_KEY env variable is not set.") + } + file, err := os.Create("samplefile.txt") + if err != nil { + t.Fatal(err) + } + defer file.Close() + defer os.Remove("samplefile.txt") + upload := Upload{ + Bucket: os.Getenv("GOTEST_AWS_BUCKET"), + SrcFile: "samplefile.txt", + DestFilePath: "dir1/dir2/samplefile.txt", + } + fileUrl, err := UploadFile(upload) + if err != nil { + t.Fatal(err) + } + fmt.Println(fileUrl) +} + +func TestUploadAssets(t *testing.T) { + type tc struct { + name string + destDir string + file string + } + tt := []tc{ + {"upload ios assets", "ios_test", "../parser/testdata/sample.ipa"}, + {"upload android assets", "android_test", "../parser/testdata/sample.apk"}, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + appInfo, err := ipapk.NewAppParser(tc.file) + if err != nil { + t.Fatal(err) + } + var app parser.MobileApp + app.UploadDate = time.Now().Format(time.RFC1123) + app.AppInfo = appInfo + app.File = tc.file + if err := app.GenerateAssets(); err != nil { + t.Fatal(err) + } + assets, err := UploadAssets(&app, os.Getenv("GOTEST_AWS_BUCKET"), tc.destDir) + if err != nil { + t.Fatal(err) + } + for _, v := range assets { + fmt.Println("s3 url:", v) + } + }) + } +}