1/* 2Copyright 2015 The Perkeep Authors 3 4Licensed under the Apache License, Version 2.0 (the "License"); 5you may not use this file except in compliance with the License. 6You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10Unless required by applicable law or agreed to in writing, software 11distributed under the License is distributed on an "AS IS" BASIS, 12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13See the License for the specific language governing permissions and 14limitations under the License. 15*/ 16 17package gce 18 19import ( 20 "bytes" 21 "context" 22 "encoding/hex" 23 "encoding/json" 24 "errors" 25 "fmt" 26 "html/template" 27 "io" 28 "io/ioutil" 29 "log" 30 "math/rand" 31 "net/http" 32 "os" 33 "path" 34 "path/filepath" 35 "regexp" 36 "strconv" 37 "strings" 38 "sync" 39 "time" 40 41 "perkeep.org/internal/httputil" 42 "perkeep.org/pkg/auth" 43 "perkeep.org/pkg/blob" 44 "perkeep.org/pkg/blobserver" 45 "perkeep.org/pkg/blobserver/localdisk" 46 "perkeep.org/pkg/sorted" 47 "perkeep.org/pkg/sorted/leveldb" 48 49 "cloud.google.com/go/compute/metadata" 50 "golang.org/x/net/xsrftoken" 51 "golang.org/x/oauth2" 52 "golang.org/x/oauth2/google" 53 compute "google.golang.org/api/compute/v1" 54) 55 56const cookieExpiration = 24 * time.Hour 57 58var ( 59 helpMachineTypes = "https://cloud.google.com/compute/docs/machine-types" 60 helpZones = "https://cloud.google.com/compute/docs/zones#available" 61 62 machineValues = []string{ 63 "f1-micro", 64 "g1-small", 65 "n1-highcpu-2", 66 } 67 68 backupZones = map[string][]string{ 69 "us-central1": {"-a", "-b", "-f"}, 70 "europe-west1": {"-b", "-c", "-d"}, 71 "asia-east1": {"-a", "-b", "-c"}, 72 } 73) 74 75// DeployHandler serves a wizard that helps with the deployment of Perkeep on Google 76// Compute Engine. It must be initialized with NewDeployHandler. 77type DeployHandler struct { 78 scheme string // URL scheme for the URLs served by this handler. Defaults to "https". 79 host string // URL host for the URLs served by this handler. 80 prefix string // prefix is the pattern for which this handler is registered as an http.Handler. 81 help map[string]template.HTML // various help bits used in the served pages, keyed by relevant names. 82 xsrfKey string // for XSRF protection. 83 piggyGIF string // path to the piggy gif file, defaults to /static/piggy.gif 84 mux *http.ServeMux 85 86 tplMu sync.RWMutex 87 tpl *template.Template 88 89 // Our wizard's credentials, acting on behalf of the user. 90 // Obtained from the environment for now. 91 clientID string 92 clientSecret string 93 94 // stores the user submitted configuration as a JSON-encoded InstanceConf 95 instConf blobserver.Storage 96 // key is blobRef of the relevant InstanceConf, value is the current state of 97 // the instance creation process, as JSON-encoded creationState 98 instState sorted.KeyValue 99 100 recordStateErrMu sync.RWMutex 101 // recordStateErr maps the blobRef of the relevant InstanceConf to the error 102 // that occurred when recording the creation state. 103 recordStateErr map[string]error 104 105 zonesMu sync.RWMutex 106 // maps a region to all its zones suffixes (e.g. "asia-east1" -> "-a","-b"). updated in the 107 // background every 24 hours. defaults to backupZones. 108 zones map[string][]string 109 regions []string 110 111 camliVersionMu sync.RWMutex 112 camliVersion string // git revision found in https://storage.googleapis.com/camlistore-release/docker/VERSION 113 114 logger *log.Logger // should not be nil. 115} 116 117// Config is the set of parameters to initialize the DeployHandler. 118type Config struct { 119 ClientID string `json:"clientID"` // handler's credentials for OAuth. Required. 120 ClientSecret string `json:"clientSecret"` // handler's credentials for OAuth. Required. 121 Project string `json:"project"` // any Google Cloud project we can query to get the valid Google Cloud zones. Optional. Set from metadata on GCE. 122 ServiceAccount string `json:"serviceAccount"` // JSON file with credentials to Project. Optional. Unused on GCE. 123 DataDir string `json:"dataDir"` // where to store the instances configurations and states. Optional. 124} 125 126// NewDeployHandlerFromConfig initializes a DeployHandler from cfg. 127// Host and prefix have the same meaning as for NewDeployHandler. cfg should not be nil. 128func NewDeployHandlerFromConfig(host, prefix string, cfg *Config) (*DeployHandler, error) { 129 if cfg == nil { 130 panic("NewDeployHandlerFromConfig: nil config") 131 } 132 if cfg.ClientID == "" { 133 return nil, errors.New("oauth2 clientID required in config") 134 } 135 if cfg.ClientSecret == "" { 136 return nil, errors.New("oauth2 clientSecret required in config") 137 } 138 os.Setenv("CAMLI_GCE_CLIENTID", cfg.ClientID) 139 os.Setenv("CAMLI_GCE_CLIENTSECRET", cfg.ClientSecret) 140 os.Setenv("CAMLI_GCE_PROJECT", cfg.Project) 141 os.Setenv("CAMLI_GCE_SERVICE_ACCOUNT", cfg.ServiceAccount) 142 os.Setenv("CAMLI_GCE_DATA", cfg.DataDir) 143 return NewDeployHandler(host, prefix) 144} 145 146// NewDeployHandler initializes a DeployHandler that serves at https://host/prefix/ and returns it. 147// A Google account client ID should be set in CAMLI_GCE_CLIENTID with its corresponding client 148// secret in CAMLI_GCE_CLIENTSECRET. 149func NewDeployHandler(host, prefix string) (*DeployHandler, error) { 150 clientID := os.Getenv("CAMLI_GCE_CLIENTID") 151 if clientID == "" { 152 return nil, errors.New("Need an oauth2 client ID defined in CAMLI_GCE_CLIENTID") 153 } 154 clientSecret := os.Getenv("CAMLI_GCE_CLIENTSECRET") 155 if clientSecret == "" { 156 return nil, errors.New("Need an oauth2 client secret defined in CAMLI_GCE_CLIENTSECRET") 157 } 158 tpl, err := template.New("root").Parse(noTheme + tplHTML()) 159 if err != nil { 160 return nil, fmt.Errorf("could not parse template: %v", err) 161 } 162 host = strings.TrimSuffix(host, "/") 163 prefix = strings.TrimSuffix(prefix, "/") 164 scheme := "https" 165 xsrfKey := os.Getenv("CAMLI_GCE_XSRFKEY") 166 if xsrfKey == "" { 167 xsrfKey = auth.RandToken(20) 168 log.Printf("xsrf key not provided as env var CAMLI_GCE_XSRFKEY, so generating one instead: %v", xsrfKey) 169 } 170 instConf, instState, err := dataStores() 171 if err != nil { 172 return nil, fmt.Errorf("could not initialize conf or state storage: %v", err) 173 } 174 h := &DeployHandler{ 175 host: host, 176 xsrfKey: xsrfKey, 177 instConf: instConf, 178 instState: instState, 179 recordStateErr: make(map[string]error), 180 scheme: scheme, 181 prefix: prefix, 182 help: map[string]template.HTML{ 183 "machineTypes": template.HTML(helpMachineTypes), 184 "zones": template.HTML(helpZones), 185 }, 186 clientID: clientID, 187 clientSecret: clientSecret, 188 tpl: tpl, 189 piggyGIF: "/static/piggy.gif", 190 } 191 mux := http.NewServeMux() 192 mux.HandleFunc(prefix+"/callback", func(w http.ResponseWriter, r *http.Request) { 193 h.serveCallback(w, r) 194 }) 195 mux.HandleFunc(prefix+"/instance", func(w http.ResponseWriter, r *http.Request) { 196 h.serveInstanceState(w, r) 197 }) 198 mux.HandleFunc(prefix+"/", func(w http.ResponseWriter, r *http.Request) { 199 h.serveRoot(w, r) 200 }) 201 h.mux = mux 202 h.SetLogger(log.New(os.Stderr, "GCE DEPLOYER: ", log.LstdFlags)) 203 h.zones = backupZones 204 var refreshZonesFn func() 205 refreshZonesFn = func() { 206 if err := h.refreshZones(); err != nil { 207 h.logger.Printf("error while refreshing zones: %v", err) 208 } 209 time.AfterFunc(24*time.Hour, refreshZonesFn) 210 } 211 go refreshZonesFn() 212 var refreshCamliVersionFn func() 213 refreshCamliVersionFn = func() { 214 if err := h.refreshCamliVersion(); err != nil { 215 h.logger.Printf("error while refreshing Perkeep version: %v", err) 216 } 217 time.AfterFunc(time.Hour, refreshCamliVersionFn) 218 } 219 go refreshCamliVersionFn() 220 return h, nil 221} 222 223func (h *DeployHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 224 if h.mux == nil { 225 http.Error(w, "handler not properly initialized", http.StatusInternalServerError) 226 return 227 } 228 h.mux.ServeHTTP(w, r) 229} 230 231func (h *DeployHandler) SetScheme(scheme string) { h.scheme = scheme } 232 233// authenticatedClient returns the GCE project running the /launch/ 234// app (e.g. "camlistore-website" usually for the main instance) and 235// an authenticated OAuth2 client acting as that service account. 236// This is only used for refreshing the list of valid zones to give to 237// the user in a drop-down. 238 239// If we're not running on GCE (e.g. dev mode on localhost) and have 240// no other way to get the info, the error value is is errNoRefresh. 241func (h *DeployHandler) authenticatedClient() (project string, hc *http.Client, err error) { 242 var ctx = context.Background() 243 244 project = os.Getenv("CAMLI_GCE_PROJECT") 245 accountFile := os.Getenv("CAMLI_GCE_SERVICE_ACCOUNT") 246 if project != "" && accountFile != "" { 247 data, errr := ioutil.ReadFile(accountFile) 248 err = errr 249 if err != nil { 250 return 251 } 252 jwtConf, errr := google.JWTConfigFromJSON(data, "https://www.googleapis.com/auth/compute.readonly") 253 err = errr 254 if err != nil { 255 return 256 } 257 hc = jwtConf.Client(ctx) 258 return 259 } 260 if !metadata.OnGCE() { 261 err = errNoRefresh 262 return 263 } 264 project, _ = metadata.ProjectID() 265 hc, err = google.DefaultClient(ctx) 266 return project, hc, err 267} 268 269var gitRevRgx = regexp.MustCompile(`^[a-z0-9]{40}?$`) 270 271func (h *DeployHandler) refreshCamliVersion() error { 272 h.camliVersionMu.Lock() 273 defer h.camliVersionMu.Unlock() 274 resp, err := http.Get("https://storage.googleapis.com/camlistore-release/docker/VERSION") 275 if err != nil { 276 return err 277 } 278 defer resp.Body.Close() 279 data, err := ioutil.ReadAll(resp.Body) 280 if err != nil { 281 return err 282 } 283 version := strings.TrimSpace(string(data)) 284 if !gitRevRgx.MatchString(version) { 285 return fmt.Errorf("wrong revision format in VERSION file: %q", version) 286 } 287 h.camliVersion = version 288 return nil 289} 290 291func (h *DeployHandler) camliRev() string { 292 h.camliVersionMu.RLock() 293 defer h.camliVersionMu.RUnlock() 294 return h.camliVersion 295} 296 297var errNoRefresh error = errors.New("not on GCE, and at least one of CAMLI_GCE_PROJECT or CAMLI_GCE_SERVICE_ACCOUNT not defined") 298 299func (h *DeployHandler) refreshZones() error { 300 h.zonesMu.Lock() 301 defer h.zonesMu.Unlock() 302 defer func() { 303 h.regions = make([]string, 0, len(h.zones)) 304 for r := range h.zones { 305 h.regions = append(h.regions, r) 306 } 307 }() 308 project, hc, err := h.authenticatedClient() 309 if err != nil { 310 if err == errNoRefresh { 311 h.zones = backupZones 312 h.logger.Printf("Cannot refresh zones. Using hard-coded ones instead.") 313 return nil 314 } 315 return err 316 } 317 s, err := compute.New(hc) 318 if err != nil { 319 return err 320 } 321 rl, err := compute.NewRegionsService(s).List(project).Do() 322 if err != nil { 323 return fmt.Errorf("could not get a list of regions: %v", err) 324 } 325 h.zones = make(map[string][]string) 326 for _, r := range rl.Items { 327 zones := make([]string, 0, len(r.Zones)) 328 for _, z := range r.Zones { 329 zone := path.Base(z) 330 if zone == "europe-west1-a" { 331 // Because even though the docs mark it as deprecated, it still shows up here, go figure. 332 continue 333 } 334 zone = strings.Replace(zone, r.Name, "", 1) 335 zones = append(zones, zone) 336 } 337 h.zones[r.Name] = zones 338 } 339 return nil 340} 341 342func (h *DeployHandler) zoneValues() []string { 343 h.zonesMu.RLock() 344 defer h.zonesMu.RUnlock() 345 return h.regions 346} 347 348// if there's project as a query parameter, it means we've just created a 349// project for them and we're redirecting them to the form, but with the projectID 350// field pre-filled for them this time. 351func (h *DeployHandler) serveRoot(w http.ResponseWriter, r *http.Request) { 352 if r.Method == "POST" { 353 h.serveSetup(w, r) 354 return 355 } 356 _, err := r.Cookie("user") 357 if err != nil { 358 http.SetCookie(w, newCookie()) 359 } 360 camliRev := h.camliRev() 361 if r.FormValue("WIP") == "1" { 362 camliRev = "WORKINPROGRESS" 363 } 364 365 h.tplMu.RLock() 366 defer h.tplMu.RUnlock() 367 if err := h.tpl.ExecuteTemplate(w, "withform", &TemplateData{ 368 ProjectID: r.FormValue("project"), 369 Prefix: h.prefix, 370 Help: h.help, 371 ZoneValues: h.zoneValues(), 372 MachineValues: machineValues, 373 CamliVersion: camliRev, 374 }); err != nil { 375 h.logger.Print(err) 376 } 377} 378 379func (h *DeployHandler) serveSetup(w http.ResponseWriter, r *http.Request) { 380 if r.FormValue("mode") != "setupproject" { 381 h.serveError(w, r, errors.New("bad form")) 382 return 383 } 384 ck, err := r.Cookie("user") 385 if err != nil { 386 h.serveFormError(w, errors.New("cookie expired, or CSRF attempt. Please reload and retry")) 387 h.logger.Printf("Cookie expired, or CSRF attempt on form.") 388 return 389 } 390 391 ctx := r.Context() 392 instConf, err := h.confFromForm(r) 393 if err != nil { 394 h.serveFormError(w, err) 395 return 396 } 397 398 br, err := h.storeInstanceConf(ctx, instConf) 399 if err != nil { 400 h.serveError(w, r, fmt.Errorf("could not store instance configuration: %v", err)) 401 return 402 } 403 404 xsrfToken := xsrftoken.Generate(h.xsrfKey, ck.Value, br.String()) 405 state := fmt.Sprintf("%s:%x", br.String(), xsrfToken) 406 redirectURL := h.oAuthConfig().AuthCodeURL(state) 407 http.Redirect(w, r, redirectURL, http.StatusFound) 408 return 409} 410 411func (h *DeployHandler) serveCallback(w http.ResponseWriter, r *http.Request) { 412 ctx := r.Context() 413 414 ck, err := r.Cookie("user") 415 if err != nil { 416 http.Error(w, 417 fmt.Sprintf("Cookie expired, or CSRF attempt. Restart from %s://%s%s", h.scheme, h.host, h.prefix), 418 http.StatusBadRequest) 419 h.logger.Printf("Cookie expired, or CSRF attempt on callback.") 420 return 421 } 422 code := r.FormValue("code") 423 if code == "" { 424 h.serveError(w, r, errors.New("No oauth code parameter in callback URL")) 425 return 426 } 427 h.logger.Printf("successful authentication: %v", r.URL.RawQuery) 428 429 br, tk, err := fromState(r) 430 if err != nil { 431 h.serveError(w, r, err) 432 return 433 } 434 if !xsrftoken.Valid(tk, h.xsrfKey, ck.Value, br.String()) { 435 h.serveError(w, r, fmt.Errorf("Invalid xsrf token: %q", tk)) 436 return 437 } 438 439 oAuthConf := h.oAuthConfig() 440 tok, err := oAuthConf.Exchange(ctx, code) 441 if err != nil { 442 h.serveError(w, r, fmt.Errorf("could not obtain a token: %v", err)) 443 return 444 } 445 h.logger.Printf("successful authorization with token: %v", tok) 446 447 instConf, err := h.instanceConf(ctx, br) 448 if err != nil { 449 h.serveError(w, r, err) 450 return 451 } 452 453 depl := &Deployer{ 454 Client: oAuthConf.Client(ctx, tok), 455 Conf: instConf, 456 Logger: h.logger, 457 } 458 459 // They've requested that we create a project for them. 460 if instConf.CreateProject { 461 // So we try to do so. 462 projectID, err := depl.CreateProject(ctx) 463 if err != nil { 464 h.logger.Printf("error creating project: %v", err) 465 // TODO(mpl): we log the errors, but none of them are 466 // visible to the user (they just get a 500). I should 467 // probably at least detect and report them the project 468 // creation quota errors. 469 h.serveError(w, r, err) 470 return 471 } 472 // And serve the form again if we succeeded. 473 http.Redirect(w, r, fmt.Sprintf("%s/?project=%s", h.prefix, projectID), http.StatusFound) 474 return 475 } 476 477 if found := h.serveOldInstance(w, br, depl); found { 478 return 479 } 480 481 if err := h.recordState(br, &creationState{ 482 InstConf: br, 483 }); err != nil { 484 h.serveError(w, r, err) 485 return 486 } 487 488 go func() { 489 inst, err := depl.Create(context.Background()) 490 state := &creationState{ 491 InstConf: br, 492 } 493 if err != nil { 494 h.logger.Printf("could not create instance: %v", err) 495 switch e := err.(type) { 496 case instanceExistsError: 497 state.Err = fmt.Sprintf("%v %v", e, helpDeleteInstance) 498 case projectIDError: 499 state.Err = fmt.Sprintf("%v", e) 500 default: 501 state.Err = fmt.Sprintf("%v. %v", err, fileIssue(br.String())) 502 } 503 } else { 504 state.InstAddr = addr(inst) 505 state.Success = true 506 if instConf.Hostname != "" { 507 state.InstHostname = instConf.Hostname 508 } 509 } 510 if err := h.recordState(br, state); err != nil { 511 h.logger.Printf("Could not record creation state for %v: %v", br, err) 512 h.recordStateErrMu.Lock() 513 defer h.recordStateErrMu.Unlock() 514 h.recordStateErr[br.String()] = err 515 return 516 } 517 if state.Err != "" { 518 return 519 } 520 if instConf.Hostname != "" { 521 return 522 } 523 // We also try to get the "camlistore-hostname" from the 524 // instance, so we can tell the user what their hostname is. It can 525 // take a while as perkeepd itself sets it after it has 526 // registered with the camlistore.net DNS. 527 giveupTime := time.Now().Add(time.Hour) 528 pause := time.Second 529 for { 530 hostname, err := depl.getInstanceAttribute("camlistore-hostname") 531 if err != nil && err != errAttrNotFound { 532 h.logger.Printf("could not get camlistore-hostname of instance: %v", err) 533 state.Success = false 534 state.Err = fmt.Sprintf("could not get camlistore-hostname of instance: %v", err) 535 break 536 } 537 if err == nil { 538 state.InstHostname = hostname 539 break 540 } 541 if time.Now().After(giveupTime) { 542 h.logger.Printf("Giving up on getting camlistore-hostname of instance") 543 state.Success = false 544 state.Err = fmt.Sprintf("could not get camlistore-hostname of instance") 545 break 546 } 547 time.Sleep(pause) 548 pause *= 2 549 } 550 if err := h.recordState(br, state); err != nil { 551 h.logger.Printf("Could not record hostname for %v: %v", br, err) 552 h.recordStateErrMu.Lock() 553 defer h.recordStateErrMu.Unlock() 554 h.recordStateErr[br.String()] = err 555 return 556 } 557 }() 558 h.serveProgress(w, br) 559} 560 561// serveOldInstance looks on GCE for an instance such as defined in depl.Conf, and if 562// found, serves the appropriate page depending on whether the instance is usable. It does 563// not serve anything if the instance is not found. 564func (h *DeployHandler) serveOldInstance(w http.ResponseWriter, br blob.Ref, depl *Deployer) (found bool) { 565 inst, err := depl.Get() 566 if err != nil { 567 // TODO(mpl,bradfitz): log or do something more 568 // drastic if the error is something other than 569 // instance not found. 570 return false 571 } 572 h.logger.Printf("Reusing existing instance for (%v, %v, %v)", depl.Conf.Project, depl.Conf.Name, depl.Conf.Zone) 573 574 if err := h.recordState(br, &creationState{ 575 InstConf: br, 576 InstAddr: addr(inst), 577 Exists: true, 578 }); err != nil { 579 h.logger.Printf("Could not record creation state for %v: %v", br, err) 580 h.serveErrorPage(w, fmt.Errorf("An error occurred while recording the state of your instance. %v", fileIssue(br.String()))) 581 return true 582 } 583 h.serveProgress(w, br) 584 return true 585} 586 587func (h *DeployHandler) serveFormError(w http.ResponseWriter, err error, hints ...string) { 588 var topHints []string 589 topHints = append(topHints, hints...) 590 h.logger.Print(err) 591 h.tplMu.RLock() 592 defer h.tplMu.RUnlock() 593 if tplErr := h.tpl.ExecuteTemplate(w, "withform", &TemplateData{ 594 Prefix: h.prefix, 595 Help: h.help, 596 Err: err, 597 Hints: topHints, 598 ZoneValues: h.zoneValues(), 599 MachineValues: machineValues, 600 }); tplErr != nil { 601 h.logger.Printf("Could not serve form error %q because: %v", err, tplErr) 602 } 603} 604 605func fileIssue(br string) string { 606 return fmt.Sprintf("Please file an issue with your instance key (%v) at https://perkeep.org/issue", br) 607} 608 609// serveInstanceState serves the state of the requested Google Cloud Engine VM creation 610// process. If the operation was successful, it serves a success page. If it failed, it 611// serves an error page. If it isn't finished yet, it replies with "running". 612func (h *DeployHandler) serveInstanceState(w http.ResponseWriter, r *http.Request) { 613 ctx := r.Context() 614 if r.Method != "GET" { 615 h.serveError(w, r, fmt.Errorf("Wrong method: %v", r.Method)) 616 return 617 } 618 br := r.URL.Query().Get("instancekey") 619 stateValue, err := h.instState.Get(br) 620 if err != nil { 621 http.Error(w, "unknown instance", http.StatusNotFound) 622 return 623 } 624 var state creationState 625 if err := json.Unmarshal([]byte(stateValue), &state); err != nil { 626 h.serveError(w, r, fmt.Errorf("could not json decode instance state: %v", err)) 627 return 628 } 629 if state.Err != "" { 630 // No need to log that error here since we're already doing it in serveCallback 631 h.serveErrorPage(w, fmt.Errorf("an error occurred while creating your instance: %v", state.Err)) 632 return 633 } 634 if state.Success || state.Exists { 635 conf, err := h.instanceConf(ctx, state.InstConf) 636 if err != nil { 637 h.logger.Printf("Could not get parameters for success message: %v", err) 638 h.serveErrorPage(w, fmt.Errorf("your instance was created and should soon be up at https://%s but there might have been a problem in the creation process. %v", state.Err, fileIssue(br))) 639 return 640 } 641 h.serveSuccess(w, &TemplateData{ 642 Prefix: h.prefix, 643 Help: h.help, 644 InstanceIP: state.InstAddr, 645 InstanceHostname: state.InstHostname, 646 ProjectConsoleURL: fmt.Sprintf("%s/project/%s/compute", ConsoleURL, conf.Project), 647 Conf: conf, 648 ZoneValues: h.zoneValues(), 649 MachineValues: machineValues, 650 }) 651 return 652 } 653 h.recordStateErrMu.RLock() 654 defer h.recordStateErrMu.RUnlock() 655 if _, ok := h.recordStateErr[br]; ok { 656 // No need to log that error here since we're already doing it in serveCallback 657 h.serveErrorPage(w, fmt.Errorf("An error occurred while recording the state of your instance. %v", fileIssue(br))) 658 return 659 } 660 fmt.Fprintf(w, "running") 661} 662 663// serveProgress serves a page with some javascript code that regularly queries 664// the server about the progress of the requested Google Cloud Engine VM creation. 665// The server replies through serveInstanceState. 666func (h *DeployHandler) serveProgress(w http.ResponseWriter, instanceKey blob.Ref) { 667 h.tplMu.RLock() 668 defer h.tplMu.RUnlock() 669 if err := h.tpl.ExecuteTemplate(w, "withform", &TemplateData{ 670 Prefix: h.prefix, 671 InstanceKey: instanceKey.String(), 672 PiggyGIF: h.piggyGIF, 673 }); err != nil { 674 h.logger.Printf("Could not serve progress: %v", err) 675 } 676} 677 678func (h *DeployHandler) serveErrorPage(w http.ResponseWriter, err error, hints ...string) { 679 var topHints []string 680 topHints = append(topHints, hints...) 681 h.logger.Print(err) 682 h.tplMu.RLock() 683 defer h.tplMu.RUnlock() 684 if tplErr := h.tpl.ExecuteTemplate(w, "noform", &TemplateData{ 685 Prefix: h.prefix, 686 Err: err, 687 Hints: topHints, 688 }); tplErr != nil { 689 h.logger.Printf("Could not serve error %q because: %v", err, tplErr) 690 } 691} 692 693func (h *DeployHandler) serveSuccess(w http.ResponseWriter, data *TemplateData) { 694 h.tplMu.RLock() 695 defer h.tplMu.RUnlock() 696 if err := h.tpl.ExecuteTemplate(w, "noform", data); err != nil { 697 h.logger.Printf("Could not serve success: %v", err) 698 } 699} 700 701func newCookie() *http.Cookie { 702 expiration := cookieExpiration 703 return &http.Cookie{ 704 Name: "user", 705 Value: auth.RandToken(15), 706 Expires: time.Now().Add(expiration), 707 } 708} 709 710func formValueOrDefault(r *http.Request, formField, defValue string) string { 711 val := r.FormValue(formField) 712 if val == "" { 713 return defValue 714 } 715 return val 716} 717 718func (h *DeployHandler) confFromForm(r *http.Request) (*InstanceConf, error) { 719 newProject, err := strconv.ParseBool(r.FormValue("newproject")) 720 if err != nil { 721 return nil, fmt.Errorf("could not convert \"newproject\" value to bool: %v", err) 722 } 723 var projectID string 724 if newProject { 725 projectID = r.FormValue("newprojectid") 726 } else { 727 projectID = r.FormValue("projectid") 728 if projectID == "" { 729 return nil, errors.New("missing project ID parameter") 730 } 731 } 732 var zone string 733 zoneReg := formValueOrDefault(r, "zone", DefaultRegion) 734 if LooksLikeRegion(zoneReg) { 735 region := zoneReg 736 zone = h.randomZone(region) 737 } else if strings.Count(zoneReg, "-") == 2 { 738 zone = zoneReg 739 } else { 740 return nil, errors.New("invalid zone or region") 741 } 742 isFreeTier, err := strconv.ParseBool(formValueOrDefault(r, "freetier", "false")) 743 if err != nil { 744 return nil, fmt.Errorf("could not convert \"freetier\" value to bool: %v", err) 745 } 746 machine := formValueOrDefault(r, "machine", DefaultMachineType) 747 if isFreeTier { 748 if !strings.HasPrefix(zone, "us-") { 749 return nil, fmt.Errorf("The %v zone was selected, but the Google Cloud Free Tier is only available for US zones", zone) 750 } 751 if machine != "f1-micro" { 752 return nil, fmt.Errorf("The %v machine type was selected, but the Google Cloud Free Tier is only available for f1-micro", machine) 753 } 754 } 755 return &InstanceConf{ 756 CreateProject: newProject, 757 Name: formValueOrDefault(r, "name", DefaultInstanceName), 758 Project: projectID, 759 Machine: machine, 760 Zone: zone, 761 Hostname: formValueOrDefault(r, "hostname", ""), 762 Ctime: time.Now(), 763 WIP: r.FormValue("WIP") == "1", 764 }, nil 765} 766 767// randomZone picks one of the zone suffixes for region and returns it 768// appended to region, as a fully-qualified zone name. 769// If the given region is invalid, the default Zone is returned instead. 770func (h *DeployHandler) randomZone(region string) string { 771 h.zonesMu.RLock() 772 defer h.zonesMu.RUnlock() 773 zones, ok := h.zones[region] 774 if !ok { 775 return fallbackZone 776 } 777 return region + zones[rand.Intn(len(zones))] 778} 779 780func (h *DeployHandler) SetLogger(lg *log.Logger) { 781 h.logger = lg 782} 783 784func (h *DeployHandler) serveError(w http.ResponseWriter, r *http.Request, err error) { 785 if h.logger != nil { 786 h.logger.Printf("%v", err) 787 } 788 httputil.ServeError(w, r, err) 789} 790 791func (h *DeployHandler) oAuthConfig() *oauth2.Config { 792 oauthConfig := NewOAuthConfig(h.clientID, h.clientSecret) 793 oauthConfig.RedirectURL = fmt.Sprintf("%s://%s%s/callback", h.scheme, h.host, h.prefix) 794 return oauthConfig 795} 796 797// fromState parses the oauth state parameter from r to extract the blobRef of the 798// instance configuration and the xsrftoken that were stored during serveSetup. 799func fromState(r *http.Request) (br blob.Ref, xsrfToken string, err error) { 800 params := strings.Split(r.FormValue("state"), ":") 801 if len(params) != 2 { 802 return br, "", fmt.Errorf("Invalid format for state parameter: %q, wanted blobRef:xsrfToken", r.FormValue("state")) 803 } 804 br, ok := blob.Parse(params[0]) 805 if !ok { 806 return br, "", fmt.Errorf("Invalid blobRef in state parameter: %q", params[0]) 807 } 808 token, err := hex.DecodeString(params[1]) 809 if err != nil { 810 return br, "", fmt.Errorf("can't decode hex xsrftoken %q: %v", params[1], err) 811 } 812 return br, string(token), nil 813} 814 815func (h *DeployHandler) storeInstanceConf(ctx context.Context, conf *InstanceConf) (blob.Ref, error) { 816 contents, err := json.Marshal(conf) 817 if err != nil { 818 return blob.Ref{}, fmt.Errorf("could not json encode instance config: %v", err) 819 } 820 hash := blob.NewHash() 821 _, err = io.Copy(hash, bytes.NewReader(contents)) 822 if err != nil { 823 return blob.Ref{}, fmt.Errorf("could not hash blob contents: %v", err) 824 } 825 br := blob.RefFromHash(hash) 826 if _, err := blobserver.Receive(ctx, h.instConf, br, bytes.NewReader(contents)); err != nil { 827 return blob.Ref{}, fmt.Errorf("could not store instance config blob: %v", err) 828 } 829 return br, nil 830} 831 832func (h *DeployHandler) instanceConf(ctx context.Context, br blob.Ref) (*InstanceConf, error) { 833 rc, _, err := h.instConf.Fetch(ctx, br) 834 if err != nil { 835 return nil, fmt.Errorf("could not fetch conf at %v: %v", br, err) 836 } 837 defer rc.Close() 838 contents, err := ioutil.ReadAll(rc) 839 if err != nil { 840 return nil, fmt.Errorf("could not read conf in blob %v: %v", br, err) 841 } 842 var instConf InstanceConf 843 if err := json.Unmarshal(contents, &instConf); err != nil { 844 return nil, fmt.Errorf("could not json decode instance config: %v", err) 845 } 846 return &instConf, nil 847} 848 849func (h *DeployHandler) recordState(br blob.Ref, state *creationState) error { 850 val, err := json.Marshal(state) 851 if err != nil { 852 return fmt.Errorf("could not json encode instance state: %v", err) 853 } 854 if err := h.instState.Set(br.String(), string(val)); err != nil { 855 return fmt.Errorf("could not record instance state: %v", err) 856 } 857 return nil 858} 859 860func addr(inst *compute.Instance) string { 861 if inst == nil { 862 return "" 863 } 864 if len(inst.NetworkInterfaces) == 0 || inst.NetworkInterfaces[0] == nil { 865 return "" 866 } 867 if len(inst.NetworkInterfaces[0].AccessConfigs) == 0 || inst.NetworkInterfaces[0].AccessConfigs[0] == nil { 868 return "" 869 } 870 return inst.NetworkInterfaces[0].AccessConfigs[0].NatIP 871} 872 873// creationState keeps information all along the creation process of the instance. The 874// fields are only exported because we json encode them. 875type creationState struct { 876 Err string `json:",omitempty"` // if non blank, creation failed. 877 InstConf blob.Ref // key to the user provided instance configuration. 878 InstAddr string // ip address of the instance. 879 InstHostname string // hostame (in the camlistore.net domain) of the instance 880 Success bool // whether new instance creation was successful. 881 Exists bool // true if an instance with same zone, same project name, and same instance name already exists. 882} 883 884// dataStores returns the blobserver that stores the instances configurations, and the kv 885// store for the instances states. 886func dataStores() (blobserver.Storage, sorted.KeyValue, error) { 887 dataDir := os.Getenv("CAMLI_GCE_DATA") 888 if dataDir == "" { 889 var err error 890 dataDir, err = ioutil.TempDir("", "camli-gcedeployer-data") 891 if err != nil { 892 return nil, nil, err 893 } 894 log.Printf("data dir not provided as env var CAMLI_GCE_DATA, so defaulting to %v", dataDir) 895 } 896 blobsDir := filepath.Join(dataDir, "instance-conf") 897 if err := os.MkdirAll(blobsDir, 0700); err != nil { 898 return nil, nil, err 899 } 900 instConf, err := localdisk.New(blobsDir) 901 if err != nil { 902 return nil, nil, err 903 } 904 instState, err := leveldb.NewStorage(filepath.Join(dataDir, "instance-state")) 905 if err != nil { 906 return nil, nil, err 907 } 908 return instConf, instState, nil 909} 910 911// TODO(mpl): AddTemplateTheme is a mistake, since the text argument is user 912// input and hence can contain just any field, that is not a known field of 913// TemplateData. Which will make the execution of the template fail. We should 914// probably just somehow hardcode website/tmpl/page.html as the template. 915// Or better, probably hardcode our own version of website/tmpl/page.html, 916// because we don't want to be bound to whatever new template fields the 917// website may need that we don't (such as .Domain). 918// See issue #815, and issue #985. 919 920// AddTemplateTheme allows to enhance the aesthetics of the default template. To that 921// effect, text can provide the template definitions for "header", "banner", "toplinks", and 922// "footer". 923func (h *DeployHandler) AddTemplateTheme(text string) error { 924 tpl, err := template.New("root").Parse(text + tplHTML()) 925 if err != nil { 926 return err 927 } 928 h.tplMu.Lock() 929 defer h.tplMu.Unlock() 930 h.tpl = tpl 931 return nil 932} 933 934// TemplateData is the data passed for templates of tplHTML. 935type TemplateData struct { 936 Title string 937 Help map[string]template.HTML // help bits within the form. 938 Hints []string // helping hints printed in case of an error. 939 Err error 940 Prefix string // handler prefix. 941 InstanceKey string // instance creation identifier, for the JS code to regularly poll for progress. 942 PiggyGIF string // URI to the piggy gif for progress animation. 943 Conf *InstanceConf // Configuration requested by the user 944 InstanceIP string `json:",omitempty"` // instance IP address that we display after successful creation. 945 InstanceHostname string `json:",omitempty"` 946 ProjectConsoleURL string 947 ProjectID string // set by us when we've just created a project on the behalf of the user 948 ZoneValues []string 949 MachineValues []string 950 CamliVersion string // git revision found in https://storage.googleapis.com/camlistore-release/docker/VERSION 951 952 // Unused stuff, but needed by page.html. See TODO above, 953 // before AddTemplateTheme. 954 GoImportDomain string 955 GoImportUpstream string 956} 957 958const toHyperlink = `<a href="$1$3">$1$3</a>` 959 960var googURLPattern = regexp.MustCompile(`(https://([a-zA-Z0-9\-\.]+)?\.google.com)([a-zA-Z0-9\-\_/]+)?`) 961 962// empty definitions for "banner", "toplinks", and "footer" to avoid error on 963// ExecuteTemplate when the definitions have not been added with AddTemplateTheme. 964var noTheme = ` 965{{define "header"}} 966 <head> 967 <title>Perkeep on Google Cloud</title> 968 </head> 969{{end}} 970{{define "banner"}} 971{{end}} 972{{define "toplinks"}} 973{{end}} 974{{define "footer"}} 975{{end}} 976` 977 978func tplHTML() string { 979 return ` 980 {{define "progress"}} 981 {{if .InstanceKey}} 982 <script> 983 // start of progress animation/message 984 var availWidth = window.innerWidth; 985 var availHeight = window.innerHeight; 986 var w = availWidth * 0.8; 987 var h = availHeight * 0.8; 988 var piggyWidth = 84; 989 var piggyHeight = 56; 990 var borderWidth = 18; 991 var maskDiv = document.createElement('div'); 992 maskDiv.style.zIndex = 2; 993 994 var dialogDiv = document.createElement('div'); 995 dialogDiv.style.position = 'fixed'; 996 dialogDiv.style.width = w; 997 dialogDiv.style.height = h; 998 dialogDiv.style.left = (availWidth - w) / 2; 999 dialogDiv.style.top = (availHeight - h) / 2; 1000 dialogDiv.style.borderWidth = borderWidth; 1001 dialogDiv.style.textAlign = 'center'; 1002 1003 var imgDiv = document.createElement('div'); 1004 imgDiv.style.marginRight = 3; 1005 imgDiv.style.position = 'relative'; 1006 imgDiv.style.left = w / 2 - (piggyWidth / 2); 1007 imgDiv.style.top = h * 0.33; 1008 imgDiv.style.display = 'block'; 1009 imgDiv.style.height = piggyHeight; 1010 imgDiv.style.width = piggyWidth; 1011 imgDiv.style.overflow = 'hidden'; 1012 1013 var img = document.createElement('img'); 1014 img.src = {{.PiggyGIF}}; 1015 1016 var msg = document.createElement('span'); 1017 msg.innerHTML = 'Please wait (up to a couple of minutes) while we create your instance...'; 1018 msg.style.boxSizing = 'border-box'; 1019 msg.style.color = '#444'; 1020 msg.style.display = 'block'; 1021 msg.style.fontFamily = 'Open Sans, sans-serif'; 1022 msg.style.fontSize = '24px'; 1023 msg.style.fontStyle = 'normal'; 1024 msg.style.fontVariant = 'normal'; 1025 msg.style.fontWeight = 'normal'; 1026 msg.style.textAlign = 'center'; 1027 msg.style.position = 'relative'; 1028 msg.style.top = h * 0.33 + piggyHeight; 1029 msg.style.height = 'auto'; 1030 msg.style.width = 'auto'; 1031 1032 imgDiv.appendChild(img); 1033 dialogDiv.appendChild(imgDiv); 1034 dialogDiv.appendChild(msg); 1035 maskDiv.appendChild(dialogDiv); 1036 document.getElementsByTagName('body')[0].appendChild(maskDiv); 1037 // end of progress animation code 1038 1039 var progress = setInterval(function(){getInstanceState('{{.Prefix}}/instance?instancekey={{.InstanceKey}}')},2000); 1040 1041 function getInstanceState(progressURL) { 1042 var xmlhttp = new XMLHttpRequest(); 1043 xmlhttp.open("GET",progressURL,false); 1044 xmlhttp.send(); 1045 console.log(xmlhttp.responseText); 1046 if (xmlhttp.responseText != "running") { 1047 clearInterval(progress); 1048 window.document.open(); 1049 window.document.write(xmlhttp.responseText); 1050 window.document.close(); 1051 history.pushState(null, 'Perkeep on Google Cloud', progressURL); 1052 } 1053 } 1054 </script> 1055 {{end}} 1056 {{end}} 1057 1058 {{define "messages"}} 1059 <div class='content'> 1060 <h1><a href="{{.Prefix}}">Perkeep on Google Cloud</a></h1> 1061 1062 {{if .InstanceIP}} 1063 {{if .InstanceHostname}} 1064 <p>Success. Your Perkeep instance is running at <a href="https://{{.InstanceHostname}}">{{.InstanceHostname}}</a>.</p> 1065 {{else}} 1066 <!-- TODO(mpl): refresh automatically with some js when InstanceHostname is ready? --> 1067 <p>Success. Your Perkeep instance is deployed at {{.InstanceIP}}. Refresh this page in a couple of minutes to know your hostname. Or go to <a href="{{.ProjectConsoleURL}}/instancesDetail/zones/{{.Conf.Zone}}/instances/camlistore-server">camlistore-server instance</a>, and look for <b>camlistore-hostname</b> (which might take a while to appear too) in the custom metadata section.</p> 1068 {{end}} 1069 <p>Please save the information on this page.</p> 1070 1071 <h4>First connection</h4> 1072 <p> 1073 The password to access the web interface of your Perkeep instance was automatically generated. Go to the <a href="{{.ProjectConsoleURL}}/instancesDetail/zones/{{.Conf.Zone}}/instances/camlistore-server">camlistore-server instance</a> page to view it, and possibly change it. It is <b>camlistore-password</b> in the custom metadata section. Similarly, the username is camlistore-username. Then restart Perkeep from the /status page if you changed anything. 1074 </p> 1075 1076 <h4>Further configuration</h4> 1077 <p> 1078 Manage your instance at <a href="{{.ProjectConsoleURL}}">{{.ProjectConsoleURL}}</a>. 1079 </p> 1080 1081 <p> 1082 If you want to use your own HTTPS certificate and key, go to <a href="https://console.developers.google.com/project/{{.Conf.Project}}/storage/browser/{{.Conf.Project}}-camlistore/config/">the storage browser</a>. Delete "<b>` + certFilename() + `</b>", "<b>` + keyFilename() + `</b>", and replace them by uploading your own files (with the same names). Then restart Perkeep. 1083 </p> 1084 1085 <p> Perkeep should not require system 1086administration but to manage/add SSH keys, go to the <a 1087href="{{.ProjectConsoleURL}}/instancesDetail/zones/{{.Conf.Zone}}/instances/camlistore-server">camlistore-server 1088instance</a> page. Scroll down to the SSH Keys section. Note that the 1089machine can be deleted or wiped at any time without losing 1090information. All state is stored in Cloud Storage. The index, however, 1091is stored in MySQL on the instance. The index can be rebuilt if lost 1092or corrupted.</p> 1093 1094 </p> 1095 {{end}} 1096 {{if .Err}} 1097 <p style="color:red"><b>Error:</b> {{.Err}}</p> 1098 {{range $hint := .Hints}} 1099 <p style="color:red">{{$hint}}</p> 1100 {{end}} 1101 {{end}} 1102 {{end}} 1103 1104{{define "withform"}} 1105<html> 1106{{template "header" .}} 1107<body> 1108 <!-- TODO(mpl): bundle jquery --> 1109 <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js" ></script> 1110 <!-- Change the text of the submit button, the billing URL, and the "disabled" of the input fields, depending on whether we create a new project or use a selected one. --> 1111 <script type="text/javascript"> 1112 $(document).ready(function(){ 1113 var setBillingURL = function() { 1114 var projectID = $('#project_id').val(); 1115 if (projectID != "") { 1116 $('#billing_url').attr("href", "https://console.cloud.google.com/billing/?project="+projectID); 1117 } else { 1118 $('#billing_url').attr("href", "https://console.cloud.google.com/billing/"); 1119 } 1120 }; 1121 setBillingURL(); 1122 var allRegions = []; 1123 var usRegions = []; 1124 $('#regions').children().each(function(idx, value) { 1125 var region = $(this).val(); 1126 if (region.startsWith("us-")) { 1127 usRegions.push(region); 1128 }; 1129 allRegions.push(region); 1130 }); 1131 var setRegions = function(usOnly) { 1132 var regions = $('#regions'); 1133 regions.empty(); 1134 var currentRegions = allRegions; 1135 if (usOnly == "true") { 1136 currentRegions = usRegions; 1137 } 1138 currentRegions.forEach(function(region) { 1139 var opt = document.createElement("option"); 1140 opt.value = region; 1141 opt.text = region; 1142 regions.append(opt); 1143 }); 1144 if (usOnly == "true") { 1145 if (!$('#zone').val().startsWith("us-")) { 1146 $('#zone').val("us-central1"); 1147 } 1148 } 1149 }; 1150 var toggleFormAction = function(newProject) { 1151 if (newProject == "true") { 1152 $('#zone').prop("disabled", true); 1153 $('#machine').prop("disabled", true); 1154 $('#free_tier').prop("disabled", true); 1155 $('#submit_btn').val("Create project"); 1156 return 1157 } 1158 $('#zone').prop("disabled", false); 1159 if ($('#free_tier').prop("checked")) { 1160 $('#machine').prop("disabled", true); 1161 } else { 1162 $('#machine').prop("disabled", false); 1163 } 1164 $('#free_tier').prop("disabled", false); 1165 $('#submit_btn').val("Create instance"); 1166 }; 1167 var toggleFreeTier = function() { 1168 if ($('#free_tier').prop("checked")) { 1169 $('#machine').val("f1-micro"); 1170 setRegions("true"); 1171 return 1172 } 1173 $('#zone').prop("disabled", false); 1174 setRegions("false"); 1175 }; 1176 $("#new_project_id").focus(function(){ 1177 $('#newproject_yes').prop("checked", true); 1178 toggleFormAction("true"); 1179 }); 1180 $("#project_id").focus(function(){ 1181 $('#newproject_no').prop("checked", true); 1182 toggleFormAction("false"); 1183 }); 1184 $("#project_id").bind('input', function(e){ 1185 setBillingURL(); 1186 }); 1187 $("#newproject_yes").change(function(e){ 1188 toggleFormAction("true"); 1189 }); 1190 $("#newproject_no").change(function(e){ 1191 toggleFormAction("false"); 1192 }); 1193 $("#free_tier").change(function(e){ 1194 toggleFreeTier(); 1195 }); 1196 }) 1197 </script> 1198 {{if .InstanceKey}} 1199 <div style="z-index:0; -webkit-filter: blur(5px);"> 1200 {{end}} 1201 {{template "banner" .}} 1202 {{template "toplinks" .}} 1203 {{template "progress" .}} 1204 {{template "messages" .}} 1205 <form method="post" enctype="multipart/form-data"> 1206 <input type='hidden' name="mode" value="setupproject"> 1207 1208 <h3>Deploy Perkeep</h3> 1209 1210 <p> This tool creates your own private 1211Perkeep instance running on <a 1212href="https://cloud.google.com/">Google Cloud Platform</a>. Be sure to 1213understand <a 1214href="https://cloud.google.com/compute/pricing#machinetype">Compute Engine pricing</a> 1215and 1216<a href="https://cloud.google.com/storage/pricing">Cloud Storage pricing</a> 1217before proceeding. Note that Perkeep metadata adds overhead on top of the size 1218of any raw data added to your instance. To delete your 1219instance and stop paying Google for the virtual machine, visit the <a 1220href="https://console.developers.google.com/">Google Cloud console</a> 1221and visit both the "Compute Engine" and "Storage" sections for your project. 1222</p> 1223 {{if .CamliVersion}} 1224 <p> Perkeep version deployed by this tool: <a href="https://camlistore.googlesource.com/camlistore/+/{{.CamliVersion}}">{{.CamliVersion}}</a></p> 1225 {{end}} 1226 1227 <table border=0 cellpadding=3 style='margin-top: 2em'> 1228 <tr valign=top><td align=right><nobr>Google Project ID:</nobr></td><td margin=left style='width:1%;white-space:nowrap;'> 1229 {{if .ProjectID}} 1230 <input id='newproject_yes' type="radio" name="newproject" value="true"> <label for="newproject_yes">Create a new project: </label></td><td align=left><input id='new_project_id' name="newprojectid" size=30 placeholder="Leave blank for auto-generated"></td></tr><tr valign=top><td></td><td> 1231 <input id='newproject_no' type="radio" name="newproject" value="false" checked='checked'> <a href="https://console.cloud.google.com/iam-admin/projects">Existing project</a> ID: </td><td align=left><input id='project_id' name="projectid" size=30 value="{{.ProjectID}}"></td></tr><tr valign=top><td></td><td colspan="2"> 1232 {{else}} 1233 <input id='newproject_yes' type="radio" name="newproject" value="true" checked='checked'> <label for="newproject_yes">Create a new project: </label></td><td align=left><input id='new_project_id' name="newprojectid" size=30 placeholder="Leave blank for auto-generated"></td></tr><tr valign=top><td></td><td> 1234 <input id='newproject_no' type="radio" name="newproject" value="false"> <a href="https://console.cloud.google.com/iam-admin/projects">Existing project</a> ID: </td><td align=left><input id='project_id' name="projectid" size=30 value="{{.ProjectID}}"></td></tr><tr valign=top><td></td><td colspan="2"> 1235 {{end}} 1236 <span style="padding-left:0;margin-left:0">You need to <a id='billing_url' href="https://console.cloud.google.com/billing">enable billing</a> with Google for the selected project.</span> 1237 </td></tr> 1238 <tr valign=top><td align=right><nobr><a href="{{.Help.zones}}">Zone</a> or Region</nobr>:</td><td> 1239 {{if .ProjectID}} 1240 <input id='zone' name="zone" list="regions" value="` + DefaultRegion + `"> 1241 {{else}} 1242 <input id='zone' name="zone" list="regions" value="` + DefaultRegion + `" disabled='disabled'> 1243 {{end}} 1244 <datalist id="regions"> 1245 {{range $k, $v := .ZoneValues}} 1246 <option value={{$v}}>{{$v}}</option> 1247 {{end}} 1248 </datalist></td></tr> 1249 <tr valign=top><td></td><td colspan="2"><span style="font-size:75%">If a region is specified, a random zone (-a, -b, -c, etc) in that region will be selected.</span> 1250 </td></tr> 1251 <tr valign=top><td align=right><a href="{{.Help.machineTypes}}">Machine type</a>:</td><td> 1252 {{if .ProjectID}} 1253 <input id='machine' name="machine" list="machines" value="g1-small"> 1254 {{else}} 1255 <input id='machine' name="machine" list="machines" value="g1-small" disabled='disabled'> 1256 {{end}} 1257 <datalist id="machines"> 1258 {{range $k, $v := .MachineValues}} 1259 <option value={{$v}}>{{$v}}</option> 1260 {{end}} 1261 </datalist></td></tr> 1262 <tr valign=top><td></td><td colspan="2"><span style="font-size:75%">As of 2015-12-27, a g1-small is $13.88 (USD) per month, before storage usage charges. See <a href="https://cloud.google.com/compute/pricing#machinetype">current pricing</a>.</span> 1263 </td></tr> 1264 {{if .ProjectID}} 1265 <tr valign=top><td></td><td align=left colspan="2"><input id='free_tier' type="checkbox" name="freetier" value="true"> Use <a href="https://cloud.google.com/free/">Google Cloud Free Tier</a></td></tr> 1266 {{else}} 1267 <tr valign=top><td></td><td align=left colspan="2"><input id='free_tier' type="checkbox" name="freetier" value="true" disabled='disabled'> Use <a href="https://cloud.google.com/free/">Google Cloud Free Tier</a></td></tr> 1268 {{end}} 1269 <tr valign=top><td></td><td colspan="2"><span style="font-size:75%">The Free Tier implies using an f1-micro instance, which might not be powerful enough in the long run. Also, the free storage is limited to 5GB, and must be in a US region.</span> 1270 </td></tr> 1271 <tr><td></td><td> 1272 {{if .ProjectID}} 1273 <input id='submit_btn' type='submit' value="Create instance" style='background: #eee; padding: 0.8em; font-weight: bold'><br><span style="font-size:75%">(it will ask for permissions)</span> 1274 {{else}} 1275 <input id='submit_btn' type='submit' value="Create project" style='background: #eee; padding: 0.8em; font-weight: bold'><br><span style="font-size:75%">(it will ask for permissions)</span> 1276 {{end}} 1277 </td></tr> 1278 </table> 1279 </form> 1280 </div> 1281 {{template "footer" .}} 1282 {{if .InstanceKey}} 1283 </div> 1284 {{end}} 1285</body> 1286</html> 1287{{end}} 1288 1289{{define "noform"}} 1290<html> 1291{{template "header" .}} 1292<body> 1293 {{if .InstanceKey}} 1294 <div style="z-index:0; -webkit-filter: blur(5px);"> 1295 {{end}} 1296 {{template "banner" .}} 1297 {{template "toplinks" .}} 1298 {{template "progress" .}} 1299 {{template "messages" .}} 1300 {{template "footer" .}} 1301 {{if .InstanceKey}} 1302 </div> 1303 {{end}} 1304</body> 1305</html> 1306{{end}} 1307` 1308} 1309 1310// TODO(bradfitz,mpl): move this to go4.org/cloud/google/gceutil 1311func ZonesOfRegion(hc *http.Client, project, region string) (zones []string, err error) { 1312 s, err := compute.New(hc) 1313 if err != nil { 1314 return nil, err 1315 } 1316 zl, err := compute.NewZonesService(s).List(project).Do() 1317 if err != nil { 1318 return nil, fmt.Errorf("could not get a list of zones: %v", err) 1319 } 1320 if zl.NextPageToken != "" { 1321 return nil, errors.New("TODO: more than one page of zones found; use NextPageToken") 1322 } 1323 for _, z := range zl.Items { 1324 if path.Base(z.Region) != region { 1325 continue 1326 } 1327 zones = append(zones, z.Name) 1328 } 1329 return zones, nil 1330} 1331