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