├── .gitignore ├── .travis.yml ├── README.md ├── index.html └── src ├── acm.go ├── cloudfront.go ├── deploy.go ├── init.go ├── route53.go ├── s3.go ├── scarr.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | foobar 2 | scarr 3 | .DS_STore 4 | scarr.yml 5 | .vscode 6 | test 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.10" 4 | script: 5 | - go test -v ./... 6 | - export BUILD_DATE=$(date -Iseconds) 7 | - cp src/*.go . 8 | - GOOS=linux GOARCH=amd64 go build -ldflags "-X main.BuildDate=$BUILD_DATE" -o "dist/scarr-linux" 9 | - GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.BuildDate=$BUILD_DATE" -o "dist/scarr-macos" 10 | - GOOS=windows GOARCH=amd64 go build -ldflags "-X main.BuildDate=$BUILD_DATE" -o "dist/scarr-windows.exe" 11 | - echo $TRAVIS_BUILD_DIR 12 | - ./dist/scarr-linux --version 13 | deploy: 14 | provider: releases 15 | api_key: ${GITHUB_OATH_TOKEN} 16 | skip_cleanup: true 17 | file: 18 | - dist/scarr-linux 19 | - dist/scarr-macos 20 | - dist/scarr-windows.exe 21 | on: 22 | tags: true 23 | branch: kk_release_test 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/kkuchta/scarr.svg?branch=master)](https://travis-ci.org/kkuchta/scarr) 2 | 3 | # Scarr 4 | 5 | - **S** 3 6 | - **C** loudfront 7 | - **A** CM 8 | - **R** oute53 9 | - **R** edundant letter to prevent name collisions 10 | 11 | If you want to set up a production-grade flat file site, a reasonable way to accomplish this would be to load your files to S3, put cloudfront in front of that for caching, use route53 for domain registration + DNS, and ACM for your TLS certificate. This tool automates all of that from registering the domain to uploading files. 12 | 13 | It works like this: 14 | 15 | ``` 16 | $ scarr init -domain nogood.reisen -name nogoodreisen 17 | Initializing...done 18 | $ cd nogoodreisen/ 19 | $ mvim scarr.yml # Edit a few fields here 20 | $ echo "hello world" > index.html 21 | $ AWS_PROFILE=scarr scarr deploy 22 | ... a bunch of aws stuff happens automatically ... 23 | $ curl https://nogood.reisen 24 | hullo wold 25 | ``` 26 | The `deploy` command does the following: 27 | 28 | 1. Registers the given domain through route53 (you'll be prompted to confirm this) 29 | 2. Creates a TLS certificate through ACM 30 | 3. Uses route53 DNS to validate that certificate 31 | 4. Creates an S3 bucket 32 | 5. Creates a cloudfront distribution pointed to that S3 bucket using the ACM certificate 33 | 6. Creates an apex dns record pointing to that cloudfront 34 | 7. Syncs the current directory to that S3 bucket and invalidates the cloudfront cache. 35 | 36 | TLDR: Cheap, painless, fast, bulletproof flatfile sites with https and an apex domain. 37 | 38 | # Quickstart 39 | 1. Download the binary from [github.com/kkuchta/scarr/releases](https://github.com/kkuchta/scarr/releases) 40 | 2. Set up an aws user with the permissions listed under "Configure" below 41 | 3. Run `scarr init -domain domainyouwant.com -name mycoolproject` and cd into the generated directory 42 | 4. Create an index.html page in that directory 43 | 5. Run `AWS_ACCESS_KEY_ID=your_access_key_here AWS_SECRET_KEY_ID=your_secret_key_here scarr deploy` 44 | 45 | And once scarr finishes deploying, your site should be live at https://domainyouwant.com 46 | 47 | # Installation 48 | 49 | Scarr is distributed as a simple binary that you can download [here](github.com/kkuchta/scarr/releases). 50 | 51 | ``` 52 | curl -L https://github.com/kkuchta/scarr/releases/download/v0.4.3/scarr-macos > scarr 53 | chmod +x scarr 54 | ./scarr init ... 55 | ``` 56 | 57 | # Configure 58 | 59 | ### AWS Credentials 60 | You'll need an AWS IAM user with sufficient permissions. One way to do this is to go to to https://console.aws.amazon.com/iam/home and create a new policy with the following policy json: 61 | ``` 62 | { 63 | "Version": "2012-10-17", 64 | "Statement": [ 65 | { 66 | "Sid": "VisualEditor0", 67 | "Effect": "Allow", 68 | "Action": [ 69 | "route53:CreateHostedZone", 70 | "s3:GetBucketWebsite", 71 | "route53:ListHostedZones", 72 | "cloudfront:GetInvalidation", 73 | "route53:ChangeResourceRecordSets", 74 | "s3:CreateBucket", 75 | "s3:ListBucket", 76 | "cloudfront:CreateDistribution", 77 | "route53domains:GetDomainDetail", 78 | "s3:GetBucketAcl", 79 | "cloudfront:CreateInvalidation", 80 | "route53domains:GetOperationDetail", 81 | "s3:PutObject", 82 | "s3:GetObject", 83 | "route53domains:CheckDomainAvailability", 84 | "s3:PutBucketWebsite", 85 | "acm:DescribeCertificate", 86 | "acm:RequestCertificate", 87 | "route53domains:RegisterDomain", 88 | "cloudfront:ListDistributions", 89 | "route53:ListResourceRecordSets", 90 | "s3:PutBucketAcl", 91 | "acm:ListCertificates", 92 | "s3:PutObjectAcl" 93 | ], 94 | "Resource": "*" 95 | } 96 | ] 97 | } 98 | ``` 99 | Then create a new IAM user and attach that policy to it. Use that user's access key id and secret access key values to run scarr: 100 | 101 | `AWS_ACCESS_KEY_ID=your_access_key_here AWS_SECRET_KEY_ID=your_secret_key_here scarr deploy` 102 | 103 | Alternately, if you know how aws credentials files work, scarr supports supports the `AWS_PROFILE` environment variable as well. 104 | 105 | ### scarr.yml 106 | 107 | The scarr init command will generate a scarr.yml file with pretty much everything you need in it. You _will_ have to fill out the domainContact details if you want to use scarr for domain registration, though. The config options are as follows: 108 | 109 | - `domain: scarr.io` the domain of your site. 110 | - `name: scarr` used for a number of internal identifiers in the infrastructure, eg the bucket will be called `yourname-bucket`. 111 | - `region: us-west-1` the region to use for everything that's not either region independent (like route53) or that requires a specific region (ACM certs must be in us-east-1 to be used by cloudfront). 112 | - `exclude: ...` a list of regexes (_not_ glob patterns) to exclude from s3 upload. The regexes will get run against the full relative path of each file (eg if you've got file `/foo/bar/biz/baz` and you run scarr in `bar`, the regex gets run against `biz/baz`. Note that backslashes need to be escaped in yaml. 113 | ``` 114 | exclude: 115 | - "src" 116 | - "\\.gitignore" 117 | - "\\.dat$" 118 | ``` 119 | - `domainContact`: the contact info for domain registration. See the [aws docs](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-register-values-specify.html) for more info. Most fields are accepted by aws so long as you input _something_, but contactType, countryCode, email, phone, state, and zip all have format validations. 120 | ``` 121 | domainContact: 122 | address1: 'fillmein' 123 | address2: '' 124 | city: 'fillmein' 125 | contactType: 'PERSON' 126 | countryCode: 'US' 127 | email: 'kevin@kevinkuchta.com' 128 | firstName: 'fillmein' 129 | lastName: 'fillmein' 130 | phoneNumber: '+1.4157582482' 131 | state: 'CA' 132 | zipCode: '94117' 133 | ``` 134 | 135 | 136 | # Commands 137 | 138 | ### Init 139 | 140 | `scarr init -domain example.com -name mycoolproject` generates a directory with a scarr.yml config file in it. It doesn't touch anything on AWS. 141 | 142 | ### Deploy 143 | 144 | `scarr deploy` should be run in a directory with a scarr.yml file in it. It checks whether your infrastructure (s3 bucket, cloudfront, etc) is already set up and if not, sets it up. It then syncs the current directory to S3 and invalidates the cloudfront cache. TODO: Actually _sync_. Right now it just copies all files to S3. 145 | 146 | - `-skip-setup` skips all the infrastructure setup and just does the S3 sync + cache invalidation. Scarr won't re-create your infrastructure if it already exists _anyway_, but this option prevents it from even checking the infrastructure, leading to slightly faster file syncs. 147 | - `-auto-register` causes scarr to automatically register the domain (rather than prompting for confirmation from the user) if it's not already in our route53 account and is available to register. 148 | - `-silent` runs scarr without any output except errors and the registration prompt (if -auto-register is off). 149 | 150 | # On the code 151 | 152 | Let's face it: this codebase is pretty ugly. The organization is a procedural mess, everything's in the same package, global functions and variables everywhere. Part of that is because this is literally the first golang code I've ever written, and part of it's because I thought this was going to be a 50-line shell script - I just got carried away and now here we are! I'll reorganize and clean everything up at some point. 153 | 154 | ### TODO: 155 | - Handle bad input better (eg init with no input gives useless error) 156 | - Handle the case where a domain is registered, but there's no hosted zone yet (eg just transferred in the domain from another registrar). 157 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/acm.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/acm" 7 | "os" 8 | "time" 9 | ) 10 | 11 | func amcService() *acm.ACM { 12 | // ACM needs to do stuff in us-east-1 for cloudfront to work 13 | sess := session.Must(session.NewSession(&aws.Config{ 14 | Region: aws.String("us-east-1")})) 15 | return acm.New(sess) 16 | } 17 | 18 | func getAcmCertificateARN(domain string) *string { 19 | service := amcService() 20 | listResult, err := service.ListCertificates(&acm.ListCertificatesInput{}) 21 | dieOnError(err, "Failed to load acm certificates") 22 | 23 | for _, certSummary := range listResult.CertificateSummaryList { 24 | if *certSummary.DomainName == domain { 25 | return certSummary.CertificateArn 26 | } 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func createACMCertificate(domain string) *string { 33 | service := amcService() 34 | requestResult, err := service.RequestCertificate(&acm.RequestCertificateInput{ 35 | DomainName: &domain, 36 | SubjectAlternativeNames: aws.StringSlice([]string{"*." + domain}), 37 | ValidationMethod: aws.String("DNS"), 38 | }) 39 | dieOnError(err, "Failed to request ACM certificate") 40 | setACMDNS(*requestResult.CertificateArn, domain) 41 | 42 | // TODO: wait until the acm cert lists as validated (might need to re-trigger something?) 43 | return requestResult.CertificateArn 44 | } 45 | 46 | func getCertificateValidation(certificateARN string) *acm.DomainValidation { 47 | service := amcService() 48 | describeResult, err := service.DescribeCertificate(&acm.DescribeCertificateInput{ 49 | CertificateArn: &certificateARN, 50 | }) 51 | dieOnError(err, "Failed to describe ACM certificate") 52 | return describeResult.Certificate.DomainValidationOptions[0] 53 | } 54 | 55 | func setACMDNS(certificateARN string, domain string) { 56 | 57 | var domainValidation *acm.DomainValidation 58 | // Right after certificate creation, validation status seems to be nil. Wait a bit. 59 | for i := 0; i < 5; i++ { 60 | domainValidation := getCertificateValidation(certificateARN) 61 | if domainValidation.ValidationStatus != nil { 62 | break 63 | } else { 64 | time.Sleep(5 * time.Second) 65 | } 66 | } 67 | // TODO: read up on go memory management so I don't have to do this again here to avoid segfaults 68 | domainValidation = getCertificateValidation(certificateARN) 69 | 70 | if *(domainValidation.ValidationStatus) == "PENDING_VALIDATION" { 71 | log("not yet valid; creating validation dns records...") 72 | dns := domainValidation.ResourceRecord 73 | // If the dns record already exists, we're just waiting for validation so don't try to recreate it. 74 | if !dnsRecordExists(getHostedZone(domain), *dns.Name, *dns.Type) { 75 | createDNSRecord(domain, *dns.Name, *dns.Type, dns.Value, nil) 76 | } 77 | 78 | log("waiting for validation (takes up to a few hours - feel free to ctrl-c and restart scarr later)...") 79 | time.Sleep(5 * time.Second) 80 | 81 | maxTries := 60 * 3 82 | for i := 0; i < maxTries; i++ { 83 | if *getCertificateValidation(certificateARN).ValidationStatus != "PENDING_VALIDATION" { 84 | setACMDNS(certificateARN, domain) 85 | break 86 | } 87 | if i == (maxTries - 1) { 88 | logln("\nTimed out waiting for ACM certificate to validate.") 89 | os.Exit(1) 90 | } 91 | time.Sleep(60 * time.Second) 92 | } 93 | } else if *domainValidation.ValidationStatus == "FAILED" { 94 | logln("Err! Cert validation failed!") 95 | os.Exit(1) 96 | } else { 97 | log("Certificate validated") 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/cloudfront.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aws/aws-sdk-go/aws" 6 | "github.com/aws/aws-sdk-go/aws/awserr" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/cloudfront" 9 | "os" 10 | "time" 11 | ) 12 | 13 | func cloudFrontService() *cloudfront.CloudFront { 14 | sess := session.Must(session.NewSession(&aws.Config{})) 15 | return cloudfront.New(sess) 16 | } 17 | 18 | // Returns cloudfrontDomain, distId 19 | func getCloudfront(s3Domain string) (*string, *string) { 20 | service := cloudFrontService() 21 | result, err := service.ListDistributions(&cloudfront.ListDistributionsInput{}) 22 | dieOnError(err, "Failed getting distribution list") 23 | if *result.DistributionList.IsTruncated { 24 | // If you have over 1k distributions 25 | fmt.Println("TODO: handle paginated result lists for cloudfront") 26 | os.Exit(1) 27 | } 28 | 29 | for _, dist := range result.DistributionList.Items { 30 | for _, origin := range dist.Origins.Items { 31 | if *origin.DomainName == s3Domain { 32 | // s3Url looks like: 33 | // voyage-found.s3-website-us-west-1.amazonaws.com 34 | return dist.DomainName, dist.Id 35 | } 36 | } 37 | } 38 | return nil, nil 39 | } 40 | 41 | func createCloudFront(s3Domain string, bucketName string, certificateArn string, domain string) *string { 42 | 43 | // Taking a break from this function to go set up ACM, since we'll need that ID 44 | service := cloudFrontService() 45 | originID := "S3-" + bucketName 46 | // s3Domain := bucketName + ".s3.amazonaws.com" 47 | 48 | aliases := cloudfront.Aliases{ 49 | Items: aws.StringSlice([]string{domain}), 50 | Quantity: aws.Int64(1), 51 | } 52 | 53 | defaultCacheBehavior := cloudfront.DefaultCacheBehavior{ 54 | AllowedMethods: &cloudfront.AllowedMethods{ 55 | Items: aws.StringSlice([]string{"GET", "HEAD"}), 56 | Quantity: aws.Int64(2), 57 | CachedMethods: &cloudfront.CachedMethods{ 58 | Items: aws.StringSlice([]string{"GET", "HEAD"}), 59 | Quantity: aws.Int64(2), 60 | }, 61 | }, 62 | Compress: aws.Bool(true), 63 | ForwardedValues: &cloudfront.ForwardedValues{ 64 | Cookies: &cloudfront.CookiePreference{ 65 | Forward: aws.String("none"), 66 | }, 67 | QueryString: aws.Bool(false), 68 | }, 69 | MinTTL: aws.Int64(0), 70 | TargetOriginId: &originID, 71 | TrustedSigners: &cloudfront.TrustedSigners{ 72 | Enabled: aws.Bool(false), 73 | Quantity: aws.Int64(0), 74 | }, 75 | ViewerProtocolPolicy: aws.String("redirect-to-https"), 76 | } 77 | 78 | // Custom-style origin. 79 | origin := cloudfront.Origin{ 80 | CustomOriginConfig: &cloudfront.CustomOriginConfig{ 81 | HTTPPort: aws.Int64(80), 82 | HTTPSPort: aws.Int64(443), 83 | OriginProtocolPolicy: aws.String("http-only"), 84 | }, 85 | DomainName: &s3Domain, 86 | Id: &originID, 87 | } 88 | // S3-style origin 89 | // origin := cloudfront.Origin{ 90 | // S3OriginConfig: &cloudfront.S3OriginConfig{ 91 | // // Empty for now. Allows people to access s3 resources directly (which we don't care about) 92 | // OriginAccessIdentity: aws.String(""), 93 | // }, 94 | // DomainName: &s3Domain, 95 | // Id: &originID, 96 | // } 97 | 98 | origins := cloudfront.Origins{ 99 | Items: []*cloudfront.Origin{&origin}, 100 | Quantity: aws.Int64(1), 101 | } 102 | 103 | certificate := cloudfront.ViewerCertificate{ 104 | ACMCertificateArn: &certificateArn, 105 | SSLSupportMethod: aws.String("sni-only"), 106 | MinimumProtocolVersion: aws.String("TLSv1"), 107 | } 108 | 109 | callerReference := time.Now().Format(time.RFC850) 110 | 111 | config := cloudfront.DistributionConfig{ 112 | Aliases: &aliases, 113 | CallerReference: &callerReference, 114 | Comment: aws.String("Created by Scarr.io"), 115 | DefaultCacheBehavior: &defaultCacheBehavior, 116 | CacheBehaviors: &cloudfront.CacheBehaviors{Quantity: aws.Int64(0)}, 117 | Enabled: aws.Bool(true), 118 | CustomErrorResponses: &cloudfront.CustomErrorResponses{Quantity: aws.Int64(0)}, 119 | PriceClass: aws.String("PriceClass_All"), 120 | Restrictions: &cloudfront.Restrictions{ 121 | GeoRestriction: &cloudfront.GeoRestriction{ 122 | RestrictionType: aws.String("none"), 123 | Quantity: aws.Int64(0), 124 | }, 125 | }, 126 | Origins: &origins, 127 | ViewerCertificate: &certificate, 128 | } 129 | createResult, err := service.CreateDistribution(&cloudfront.CreateDistributionInput{DistributionConfig: &config}) 130 | 131 | if err != nil { 132 | awserror := err.(awserr.Error) 133 | fmt.Println("code=", awserror.Code()) 134 | fmt.Println(awserror.Message()) 135 | } 136 | 137 | dieOnError(err, "Failed to create cloudfront distribution") 138 | 139 | log("Waiting for distribution to finish (20-40 minutes)...") 140 | service.WaitUntilDistributionDeployed(&cloudfront.GetDistributionInput{ 141 | Id: createResult.Distribution.Id, 142 | }) 143 | logln(" done") 144 | return createResult.Distribution.DomainName 145 | } 146 | 147 | func createCloudfrontInvalidation(s3Url string, paths []string) { 148 | _, distributionID := getCloudfront(s3Url) 149 | service := cloudFrontService() 150 | callerReference := time.Now().Format(time.RFC850) 151 | log("Invalidating cache...") 152 | invalidationResult, err := service.CreateInvalidation(&cloudfront.CreateInvalidationInput{ 153 | DistributionId: distributionID, 154 | InvalidationBatch: &cloudfront.InvalidationBatch{ 155 | CallerReference: &callerReference, 156 | Paths: &cloudfront.Paths{ 157 | Items: aws.StringSlice(paths), 158 | Quantity: aws.Int64(int64(len(paths))), 159 | }, 160 | }, 161 | }) 162 | dieOnError(err, "Failed to create Invalidation") 163 | 164 | log("waiting (5-10 minutes)...") 165 | service.WaitUntilInvalidationCompleted(&cloudfront.GetInvalidationInput{ 166 | DistributionId: distributionID, 167 | Id: invalidationResult.Invalidation.Id, 168 | }) 169 | logln(" done") 170 | } 171 | -------------------------------------------------------------------------------- /src/deploy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | type contactDetailsType struct { 14 | Address1 string `yaml:"address1"` 15 | Address2 string `yaml:"address2"` 16 | City string `yaml:"city"` 17 | ContactType string `yaml:"contactType"` 18 | CountryCode string `yaml:"countryCode"` 19 | Email string `yaml:"email"` 20 | FirstName string `yaml:"firstName"` 21 | LastName string `yaml:"lastName"` 22 | PhoneNumber string `yaml:"phoneNumber"` 23 | State string `yaml:"state"` 24 | ZipCode string `yaml:"zipCode"` 25 | } 26 | 27 | type configType struct { 28 | Domain string `yaml:"domain"` 29 | Name string `yaml:"name"` 30 | Region string `yaml:"region"` 31 | DomainContact contactDetailsType `yaml:"domainContact"` 32 | Exclude []string `yaml:"exclude"` 33 | } 34 | 35 | func dieOnError(err error, message string) { 36 | if err != nil { 37 | fmt.Println(message, err) 38 | os.Exit(1) 39 | } 40 | } 41 | 42 | func confirm(message string) bool { 43 | fmt.Print(message + " [y/N]") 44 | 45 | reader := bufio.NewReader(os.Stdin) 46 | text, err := reader.ReadString('\n') 47 | fmt.Println("") 48 | dieOnError(err, "Failed reading y/n input") 49 | return "y" == strings.TrimSpace(strings.ToLower(text)) 50 | } 51 | 52 | func getConfig() configType { 53 | yamlFile, err := ioutil.ReadFile("scarr.yml") 54 | dieOnError(err, "Error reading scarr.yml") 55 | 56 | var config configType 57 | err = yaml.Unmarshal(yamlFile, &config) 58 | dieOnError(err, "Error parsing scarr.yml") 59 | 60 | return config 61 | } 62 | 63 | // Gets the root domain (eg foo.com from bar.foo.com). 64 | func getRootDomain(domain string) string { 65 | domainParts := strings.Split(domain, ".") 66 | return domainParts[len(domainParts)-2] + "." + domainParts[len(domainParts)-1] 67 | } 68 | 69 | func ensureDomainRegistered(config configType, autoRegister bool) { 70 | // We can't register a subdomain, so let's check registration on the main domain instead 71 | domain := getRootDomain(config.Domain) 72 | log("Checking domain " + domain + " registration...") 73 | 74 | domainDetail := getDomainDetails(domain) 75 | if domainDetail == nil { 76 | logln("\nNot registered in our Route53") 77 | 78 | // Not clear if there's a good way to detect this 79 | // if isRegistering(domain) { 80 | // fmt.Println("Your domain is still registering. Try again later.") 81 | // return 82 | // } 83 | 84 | domainAvailability := getDomainAvailability(domain) 85 | if domainAvailability { 86 | logln(` 87 | But it *is* available to register. For current prices, see the document linked at: 88 | https://aws.amazon.com/route53/pricing/ 89 | `) 90 | if strings.HasSuffix(domain, ".com") { 91 | logln("(As of April 2018, .com TLDs were $12/yr)") 92 | } 93 | if autoRegister || confirm("Register that domain?") { 94 | registerDomain(domain, config.DomainContact) 95 | } 96 | } else { 97 | fmt.Println(` 98 | Unfortunately that domain is not available to register. Maybe it's still 99 | registering from the last time you ran scarr? If so, try again in a few. 100 | If you own that domain through a different registrar, transfer it to 101 | route53. Alternately, use both --skip-dns and --skip-domain to bypass 102 | this (you'll have to manage your own domain + dns setup then) 103 | (//TODO: implement those flags)`) 104 | os.Exit(1) 105 | } 106 | } else { 107 | logln("Looks good!") 108 | } 109 | } 110 | func ensureS3BucketExists(s3BucketName string, region string) { 111 | logf("Checking bucket %v...", s3BucketName) 112 | if !bucketExists(s3BucketName, region) { 113 | log(" bucket doesn't exist; creating it now...") 114 | createBucket(s3BucketName, region) 115 | } else { 116 | log(" bucket already exists.") 117 | } 118 | 119 | // if !bucketIsWorldReadable(s3BucketName, region) { 120 | // // We could _make_ this bucket world-readable, but that'd be bad if it turns out to have sensitive info in it. 121 | // logln("\nBucket is not world-readable. You should fix this (or delete the bucket and let us re-create it).") 122 | // os.Exit(1) 123 | // } 124 | logln(" done") 125 | ensureBucketIsWebsite(s3BucketName, region) 126 | } 127 | 128 | func ensureACMCertificate(domain string) string { 129 | logf("Checking ACM cert for %v...", domain) 130 | certificateArn := getAcmCertificateARN(domain) 131 | if certificateArn == nil { 132 | log("doesn't exist; creating...") 133 | certificateArn = createACMCertificate(domain) 134 | } else { 135 | // Ensure it's DNS is set up 136 | log("already exists; ensuring it's validated...") 137 | setACMDNS(*certificateArn, domain) 138 | } 139 | logln(" done") 140 | return *certificateArn 141 | } 142 | func ensureCloudFrontExists(certificateArn string, s3Url string, s3Bucket string, domain string) string { 143 | cloudfrontDomain, _ := getCloudfront(s3Url) 144 | if cloudfrontDomain == nil { 145 | logln("CloudFront distribution does not exist; creating") 146 | cloudfrontDomain = createCloudFront(s3Url, s3Bucket, certificateArn, domain) 147 | } 148 | return *cloudfrontDomain 149 | } 150 | func ensureDomainPointingToCloudfront(cloudfrontDomain string, mainDomain string) { 151 | hostedZoneID := getHostedZone(mainDomain) 152 | if dnsRecordExists(hostedZoneID, mainDomain, "A") { 153 | logln("Domain has a (hopefully-correct) alias already configured") 154 | } else { 155 | logln("Creating A-record alias to domain") 156 | createAliasRecord(mainDomain, mainDomain, cloudfrontDomain) 157 | } 158 | 159 | // TODO: set up an alias or redirect from www to apex 160 | } 161 | 162 | func invalidateCloudfront(s3Domain string, pathsToInvalidate []string) { 163 | // TODO: actually invalidate what's passed in 164 | createCloudfrontInvalidation(s3Domain, []string{"/*"}) 165 | } 166 | 167 | func runDeploy(skipSetup bool, autoRegister bool) { 168 | logln("Deploying") 169 | config := getConfig() 170 | s3Bucket := config.Name + "-bucket" 171 | s3Url := s3Bucket + ".s3-website-" + config.Region + ".amazonaws.com" 172 | 173 | if !skipSetup { 174 | ensureDomainRegistered(config, autoRegister) 175 | certArn := ensureACMCertificate(config.Domain) 176 | ensureS3BucketExists(s3Bucket, config.Region) 177 | cloudfrontDomain := ensureCloudFrontExists(certArn, s3Url, s3Bucket, config.Domain) 178 | ensureDomainPointingToCloudfront(cloudfrontDomain, config.Domain) 179 | } 180 | 181 | changedFiles := s3Sync(config.Region, s3Bucket, &config.Exclude) 182 | invalidateCloudfront(s3Url, changedFiles) 183 | 184 | logf("Deployed to https://%v", config.Domain) 185 | } 186 | -------------------------------------------------------------------------------- /src/init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "text/template" 9 | ) 10 | 11 | const configTemplateString = `domain: "{{.domain}}" 12 | name: "{{.name}}" 13 | region: "{{.region}}" 14 | 15 | # This section's only used if you use scarr to register a domain. Which fields 16 | # are required depends on what TLD you register. See 17 | # https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-register-values-specify.html 18 | # for details. 19 | domainContact: 20 | address1: 'fillmein' 21 | address2: '' 22 | city: 'fillmein' 23 | contactType: 'PERSON' 24 | countryCode: 'fillmein' 25 | email: 'fillmein' 26 | firstName: 'fillmein' 27 | lastName: 'fillmein' 28 | phoneNumber: 'fillmein' 29 | state: 'fillmein' 30 | zipCode: 'fillmein' 31 | 32 | # A list of regexes to be run against paths in the current directory. Any file path matching any of these regexes will not be synced to s3 33 | exclude: 34 | - "scarr\\.yml" 35 | - "^\\.git" 36 | - "\\.DS_Store" 37 | ` 38 | 39 | func generateConfig(domain string, name string, region string) string { 40 | configTemplate := template.Must(template.New("config").Parse(configTemplateString)) 41 | buffer := &bytes.Buffer{} 42 | data := map[string]interface{}{ 43 | "name": name, 44 | "domain": domain, 45 | "region": region, 46 | } 47 | check(configTemplate.Execute(buffer, data)) 48 | 49 | return buffer.String() 50 | } 51 | 52 | func writeFile(path string, content string) { 53 | check(ioutil.WriteFile(path, []byte(content), 0644)) 54 | } 55 | 56 | func runInit(domain string, name string, region string) { 57 | log("Initializing...") 58 | err := os.Mkdir(name, 0755) 59 | if err != nil { 60 | fmt.Println(err) 61 | os.Exit(1) 62 | } 63 | config := generateConfig(domain, name, region) 64 | writeFile(name+"/scarr.yml", config) 65 | logln("done") 66 | logln("You'll need to edit scarr.yml to fill in contact details if you want to use scarr register domain names.") 67 | } 68 | -------------------------------------------------------------------------------- /src/route53.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aws/aws-sdk-go/aws" 6 | "github.com/aws/aws-sdk-go/aws/awserr" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/route53" 9 | "github.com/aws/aws-sdk-go/service/route53domains" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | func route53DomainsService() *route53domains.Route53Domains { 16 | // Route53 only has the one domain, so hardcode to us east 17 | sess := session.Must(session.NewSession(&aws.Config{ 18 | Region: aws.String("us-east-1")})) 19 | return route53domains.New(sess) 20 | } 21 | func route53Service() *route53.Route53 { 22 | // Route53 only has the one domain, so hardcode to us east 23 | sess := session.Must(session.NewSession(&aws.Config{ 24 | Region: aws.String("us-east-1")})) 25 | return route53.New(sess) 26 | } 27 | 28 | func registerDomain(domain string, contactDetails contactDetailsType) { 29 | logf("Registering %v...", domain) 30 | contact := route53domains.ContactDetail{ 31 | AddressLine1: &contactDetails.Address1, 32 | AddressLine2: &contactDetails.Address2, 33 | City: &contactDetails.City, 34 | ContactType: &contactDetails.ContactType, 35 | CountryCode: &contactDetails.CountryCode, 36 | Email: &contactDetails.Email, 37 | FirstName: &contactDetails.FirstName, 38 | LastName: &contactDetails.LastName, 39 | PhoneNumber: &contactDetails.PhoneNumber, 40 | State: &contactDetails.State, 41 | ZipCode: &contactDetails.ZipCode, 42 | } 43 | autoRenew := false 44 | duration := int64(1) 45 | input := route53domains.RegisterDomainInput{ 46 | AdminContact: &contact, 47 | RegistrantContact: &contact, 48 | TechContact: &contact, 49 | AutoRenew: &autoRenew, 50 | DomainName: &domain, 51 | DurationInYears: &duration, 52 | } 53 | route53DomainsService := route53DomainsService() 54 | result, err := route53DomainsService.RegisterDomain(&input) 55 | dieOnError(err, "Failed to register domain") 56 | 57 | operationInput := route53domains.GetOperationDetailInput{ 58 | OperationId: result.OperationId, 59 | } 60 | 61 | // Wait up to 60 minutes for registration 62 | for i := 0; i < 60; i++ { 63 | operationResult, err := route53DomainsService.GetOperationDetail(&operationInput) 64 | dieOnError(err, "Failed to get registration operation (but probably still registered the domain)") 65 | if *operationResult.Status == "SUCCESSFUL" { 66 | logln(" done") 67 | return 68 | } else if *operationResult.Status == "FAILED" { 69 | logln("Domain registration failed") 70 | os.Exit(1) 71 | } 72 | time.Sleep(60 * time.Second) 73 | } 74 | logln("\nTimed out waiting for registration to finish. It may yet succeed - check your aws console.") 75 | } 76 | 77 | func getDomainDetails(domain string) *route53domains.GetDomainDetailOutput { 78 | route53DomainsService := route53DomainsService() 79 | 80 | // So, apparently the only way to determine if a domain exists in our route53 81 | // is to try to fetch it and see if we get a specific error. Well, ok, we could 82 | // call ListDomains, but then we'd have to paginate through an arbitrarily 83 | // large list which is just silly. 84 | input := route53domains.GetDomainDetailInput{DomainName: &domain} 85 | result, err := route53DomainsService.GetDomainDetail(&input) 86 | if err != nil { 87 | if strings.Contains(err.Error(), "Domain "+domain+" not found in") { 88 | return nil 89 | } 90 | dieOnError(err, "error loading domain: ") 91 | } 92 | 93 | // TODO: handle domain-found result 94 | //fmt.Println("get domain details result = ", result) 95 | return result 96 | } 97 | 98 | func getDomainAvailability(domain string) bool { 99 | return getDomainAvailabilityWithRetries(domain, 3) 100 | } 101 | 102 | func getDomainAvailabilityWithRetries(domain string, retries int) bool { 103 | 104 | route53DomainsService := route53DomainsService() 105 | input := route53domains.CheckDomainAvailabilityInput{DomainName: &domain} 106 | availabilityResult, err := route53DomainsService.CheckDomainAvailability(&input) 107 | dieOnError(err, "error getting domain availability") 108 | if *availabilityResult.Availability == "AVAILABLE" { 109 | return true 110 | } 111 | if *availabilityResult.Availability == "PENDING" { 112 | if retries > 0 { 113 | time.Sleep(time.Second) 114 | return getDomainAvailabilityWithRetries(domain, retries-1) 115 | } 116 | } 117 | return false 118 | } 119 | 120 | func dnsRecordExists(hostedZoneID string, domain string, recordType string) bool { 121 | service := route53Service() 122 | result, err := service.ListResourceRecordSets(&route53.ListResourceRecordSetsInput{ 123 | HostedZoneId: &hostedZoneID, 124 | }) 125 | dieOnError(err, "Failed listing resource record sets") 126 | 127 | for _, recordSet := range result.ResourceRecordSets { 128 | if *recordSet.Name == domain+"." && *recordSet.Type == recordType { 129 | return true 130 | } 131 | } 132 | return false 133 | } 134 | 135 | func getHostedZone(domain string) string { 136 | rootDomain := getRootDomain(domain) 137 | service := route53Service() 138 | 139 | hostedZoneID := "" 140 | 141 | hostedZonesList, err := service.ListHostedZones(&route53.ListHostedZonesInput{}) 142 | dieOnError(err, "Failed to list hosted zones") 143 | 144 | for _, hostedZone := range hostedZonesList.HostedZones { 145 | if *hostedZone.Name == rootDomain+"." { 146 | hostedZoneID = *hostedZone.Id 147 | } 148 | } 149 | 150 | if hostedZoneID == "" { 151 | // TODO: we can probably just create the hosted zone in this case 152 | logln("Couldn't find hosted zone for domain") 153 | os.Exit(1) 154 | } 155 | return hostedZoneID 156 | } 157 | 158 | func createAliasRecord(hostedZoneDomain string, recordName string, cloudfrontDomain string) { 159 | createDNSRecord(hostedZoneDomain, recordName, "A", nil, &route53.AliasTarget{ 160 | DNSName: &cloudfrontDomain, 161 | EvaluateTargetHealth: aws.Bool(false), 162 | HostedZoneId: aws.String("Z2FDTNDATAQYW2"), 163 | }) 164 | // ^ Hardcoded zone ID as specified in aws docs 165 | } 166 | 167 | func createDNSRecord(domain string, recordName string, recordType string, recordValue *string, aliasTarget *route53.AliasTarget) { 168 | // fmt.Println("Creating record of type", recordType, recordName, recordValue) 169 | service := route53Service() 170 | 171 | hostedZoneID := getHostedZone(domain) 172 | 173 | input := route53.ChangeResourceRecordSetsInput{ 174 | HostedZoneId: &hostedZoneID, 175 | ChangeBatch: &route53.ChangeBatch{ 176 | Comment: aws.String("Created by scarr.io"), 177 | Changes: []*route53.Change{ 178 | { 179 | Action: aws.String("CREATE"), 180 | ResourceRecordSet: &route53.ResourceRecordSet{ 181 | Name: &recordName, 182 | Type: &recordType, 183 | }, 184 | }, 185 | }, 186 | }, 187 | } 188 | 189 | if recordValue != nil { 190 | input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords = []*route53.ResourceRecord{ 191 | { 192 | Value: recordValue, 193 | }, 194 | } 195 | } 196 | 197 | // If we're setting an alias record, we need this extra input 198 | if aliasTarget != nil { 199 | input.ChangeBatch.Changes[0].ResourceRecordSet.AliasTarget = aliasTarget 200 | } else { 201 | // Maybe necessary for cert validation dns? 202 | input.ChangeBatch.Changes[0].ResourceRecordSet.TTL = aws.Int64(300) 203 | } 204 | 205 | _, err := service.ChangeResourceRecordSets(&input) 206 | if err != nil { 207 | awserror := err.(awserr.Error) 208 | 209 | fmt.Println("code=", awserror.Code()) 210 | } 211 | 212 | dieOnError(err, "Failed to create dns record") 213 | } 214 | -------------------------------------------------------------------------------- /src/s3.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/awserr" 6 | "github.com/aws/aws-sdk-go/aws/session" 7 | "github.com/aws/aws-sdk-go/service/s3" 8 | "github.com/aws/aws-sdk-go/service/s3/s3manager" 9 | "mime" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | ) 15 | 16 | func s3Service(region string) *s3.S3 { 17 | sess := session.Must(session.NewSession(&aws.Config{ 18 | Region: aws.String(region)})) 19 | return s3.New(sess) 20 | } 21 | func s3ManagerService(region string) *s3manager.Uploader { 22 | sess := session.Must(session.NewSession(&aws.Config{ 23 | Region: aws.String(region)})) 24 | return s3manager.NewUploader(sess) 25 | } 26 | 27 | func bucketExists(bucketName string, region string) bool { 28 | service := s3Service(region) 29 | _, err := service.HeadBucket(&s3.HeadBucketInput{Bucket: &bucketName}) 30 | 31 | if err != nil { 32 | awsError := err.(awserr.Error) 33 | if awsError.Code() == "NotFound" { 34 | return false 35 | } 36 | dieOnError(err, "Error HEADing bucket") 37 | } 38 | return true 39 | } 40 | 41 | func bucketIsWorldReadable(bucketName string, region string) bool { 42 | service := s3Service(region) 43 | aclResult, err := service.GetBucketAcl(&s3.GetBucketAclInput{ 44 | Bucket: &bucketName, 45 | }) 46 | dieOnError(err, "Error getting bucket ACL") 47 | 48 | // Make sure this bucket is publicly readable 49 | for _, grant := range aclResult.Grants { 50 | if *grant.Grantee.Type == "Group" && 51 | *grant.Grantee.URI == "http://acs.amazonaws.com/groups/global/AllUsers" && 52 | *grant.Permission == "READ" { 53 | return true 54 | } 55 | } 56 | return false 57 | } 58 | 59 | func ensureBucketIsWebsite(bucketName string, region string) { 60 | service := s3Service(region) 61 | _, err := service.GetBucketWebsite(&s3.GetBucketWebsiteInput{Bucket: &bucketName}) 62 | if err != nil { 63 | awsError := err.(awserr.Error) 64 | if awsError.Code() == "NoSuchWebsiteConfiguration" { 65 | log("Making S3 bucket website...") 66 | indexFile := "index.html" 67 | _, err = service.PutBucketWebsite(&s3.PutBucketWebsiteInput{ 68 | Bucket: &bucketName, 69 | WebsiteConfiguration: &s3.WebsiteConfiguration{ 70 | IndexDocument: &s3.IndexDocument{Suffix: &indexFile}, 71 | }, 72 | }) 73 | dieOnError(err, "Failed to update s3 bucket website config") 74 | logln(" done") 75 | } else { 76 | dieOnError(err, "Failed to get bucket website config") 77 | } 78 | } else { 79 | logln("Bucket correctly configured for website") 80 | } 81 | } 82 | 83 | func createBucket(bucketName string, region string) { 84 | service := s3Service(region) 85 | 86 | input := s3.CreateBucketInput{ 87 | Bucket: &bucketName, 88 | } 89 | 90 | // Apparently the aws api treats us-east-1 as the default for s3 buckets *and 91 | // throws an error* if you try to specify us-east-1 as the region. Wtf. 92 | if region != "us-east-1" { 93 | input.CreateBucketConfiguration = &s3.CreateBucketConfiguration{ 94 | LocationConstraint: ®ion, 95 | } 96 | } 97 | 98 | _, err := service.CreateBucket(&input) 99 | dieOnError(err, "Failed to create bucket") 100 | } 101 | 102 | func s3Sync(region string, bucket string, configuredExclude *[]string) []string { 103 | service := s3ManagerService(region) 104 | 105 | fileList := []string{} 106 | err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error { 107 | if info.IsDir() { 108 | return nil 109 | } 110 | 111 | for _, exclude := range *configuredExclude { 112 | matched, err := regexp.MatchString(exclude, path) 113 | dieOnError(err, "Invalid exclude regex") 114 | if matched { 115 | return nil 116 | } 117 | } 118 | 119 | fileList = append(fileList, path) 120 | return nil 121 | }) 122 | if err != nil { 123 | logf("walk error [%v]\n", err) 124 | } 125 | 126 | // TODO: detect differences and actually sync, rather than just overwriting everything 127 | for _, filename := range fileList { 128 | file, fileErr := os.Open(filename) 129 | dieOnError(fileErr, "Failed to open file") 130 | 131 | ext := filepath.Ext(filename) 132 | 133 | contentType := "" 134 | 135 | // Detect content type from the extension 136 | switch ext { 137 | case ".htm", ".html": 138 | contentType = "text/html" 139 | case ".css": 140 | contentType = "text/css" 141 | case ".js": 142 | contentType = "application/javascript" 143 | default: 144 | contentType = mime.TypeByExtension(ext) 145 | } 146 | 147 | // If we can't figure out content type from the extension, try DetectContentType 148 | if contentType == "" { 149 | // Grab the first 512 bytes to detect the content type 150 | buffer := make([]byte, 512) 151 | _, err = file.Read(buffer) 152 | dieOnError(err, "Failed reading start of file to detect content type for "+filename) 153 | // Reset the read pointer if necessary. 154 | file.Seek(0, 0) 155 | contentType = http.DetectContentType(buffer) 156 | } 157 | 158 | logln("Uploading ", filename, " to ", bucket) 159 | _, uploadErr := service.Upload(&s3manager.UploadInput{ 160 | Bucket: aws.String(bucket), 161 | Key: aws.String(filename), 162 | Body: file, 163 | GrantRead: aws.String("uri=http://acs.amazonaws.com/groups/global/AllUsers"), 164 | ContentType: &contentType, 165 | }) 166 | dieOnError(uploadErr, "Failed to upload file") 167 | } 168 | return fileList 169 | } 170 | -------------------------------------------------------------------------------- /src/scarr.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | // "github.com/aws/aws-sdk-go/aws" 8 | // "github.com/aws/aws-sdk-go/aws/session" 9 | // "github.com/aws/aws-sdk-go/service/s3" 10 | ) 11 | 12 | func check(e error) { 13 | if e != nil { 14 | panic(e) 15 | } 16 | } 17 | 18 | func exitErrorf(msg string, args ...interface{}) { 19 | fmt.Fprintf(os.Stderr, msg+"\n", args...) 20 | os.Exit(1) 21 | } 22 | 23 | func getUsage() string { 24 | return ` 25 | A tool for generating and updating flat-file sites on AWS. Registers domain names, 26 | creates TLS certificates, makes S3 buckets, configures cloudfront distributions and 27 | syncs files. 28 | 29 | Available commands: 30 | init # Generates a new scarr.yml file 31 | deploy # Sets up infrastructure + syncs files to it 32 | version # Print version 33 | 34 | Use "scarr -h" for more information. 35 | 36 | Scarr requires an AWS IAM user with appropriate permissions. You can set this user 37 | using one of these two patterns: 38 | 39 | $ AWS_PROFILE=some_profile scarr deploy -args 40 | $ AWS_ACCESS_KEY_ID=some_id AWS_SECRET_ACCESS_KEY=some_key scarr deploy -args 41 | 42 | AWS_PROFILE names a profile set up in an ~/.aws/credentials file as described here: 43 | 44 | https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html 45 | 46 | Either way, the IAM user you connect should have the following permissions: 47 | 48 | { 49 | "Effect": "Allow", 50 | "Action": [ 51 | "route53:CreateHostedZone", 52 | "s3:GetBucketWebsite", 53 | "route53:ListHostedZones", 54 | "cloudfront:GetInvalidation", 55 | "route53:ChangeResourceRecordSets", 56 | "s3:CreateBucket", 57 | "s3:ListBucket", 58 | "cloudfront:CreateDistribution", 59 | "route53domains:GetDomainDetail", 60 | "s3:GetBucketAcl", 61 | "cloudfront:CreateInvalidation", 62 | "route53domains:GetOperationDetail", 63 | "s3:PutObject", 64 | "s3:GetObject", 65 | "route53domains:CheckDomainAvailability", 66 | "s3:PutBucketWebsite", 67 | "acm:DescribeCertificate", 68 | "acm:RequestCertificate", 69 | "route53domains:RegisterDomain", 70 | "cloudfront:ListDistributions", 71 | "route53:ListResourceRecordSets", 72 | "s3:PutBucketAcl", 73 | "acm:ListCertificates", 74 | "s3:PutObjectAcl" 75 | ], 76 | "Resource": "*" 77 | } 78 | ` 79 | } 80 | 81 | // 0 = silent 82 | // 1 = normal 83 | // todo: verbose/debug 84 | var logLevel int 85 | 86 | func logln(msgs ...interface{}) { 87 | if logLevel == 1 { 88 | fmt.Println(msgs...) 89 | } 90 | } 91 | func log(msgs ...interface{}) { 92 | if logLevel == 1 { 93 | fmt.Print(msgs...) 94 | } 95 | } 96 | func logf(msg string, rest ...interface{}) { 97 | if logLevel == 1 { 98 | fmt.Printf(msg, rest...) 99 | } 100 | } 101 | 102 | func printVersion() { 103 | fmt.Println("Scarr " + getVersion()) 104 | } 105 | 106 | func main() { 107 | initCommand := flag.NewFlagSet("init", flag.ExitOnError) 108 | deployCommand := flag.NewFlagSet("deploy", flag.ExitOnError) 109 | 110 | domainPtr := initCommand.String("domain", "", "The domain this site will live at") 111 | namePtr := initCommand.String("name", "", "The name of this project") 112 | regionPtr := initCommand.String("region", "us-west-1", "The aws region this project's resources will live in (eg us-west-1)") 113 | 114 | skipSetupPtr := deployCommand.Bool("skip-setup", false, "Assume the infrastructure is all set up and just do the file upload + cache invalidations.") 115 | autoRegisterPtr := deployCommand.Bool("auto-register", false, "Register the domain name without prompting if necessary and available") 116 | silentDeployPtr := deployCommand.Bool("silent", false, "Limits stdout to errors and user-input prompts. Run with -auto-register or use an existing domain name to avoid a registration prompt") 117 | 118 | if len(os.Args) < 2 { 119 | fmt.Println("Missing command") 120 | os.Exit(1) 121 | } 122 | 123 | command := os.Args[1] 124 | 125 | switch command { 126 | case "-h": 127 | fmt.Println(getUsage()) 128 | case "-help": 129 | fmt.Println(getUsage()) 130 | case "--help": 131 | fmt.Println(getUsage()) 132 | case "init": 133 | initCommand.Parse(os.Args[2:]) 134 | case "deploy": 135 | deployCommand.Parse(os.Args[2:]) 136 | case "version": 137 | printVersion() 138 | case "-version": 139 | printVersion() 140 | case "--version": 141 | printVersion() 142 | case "-v": 143 | printVersion() 144 | default: 145 | fmt.Println("Unknown command ", command) 146 | flag.PrintDefaults() 147 | os.Exit(1) 148 | } 149 | logLevel = 1 150 | 151 | if initCommand.Parsed() { 152 | runInit(*domainPtr, *namePtr, *regionPtr) 153 | // fmt.Println("init parsed", *domainPtr, *namePtr, *regionPtr) 154 | } else if deployCommand.Parsed() { 155 | if *silentDeployPtr { 156 | logLevel = 0 157 | } 158 | runDeploy(*skipSetupPtr, *autoRegisterPtr) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func getVersion() string { 4 | return "0.4.5" 5 | } 6 | --------------------------------------------------------------------------------