cmd/cloudshell_open/appfile.go (209 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"github.com/fatih/color"
"github.com/AlecAivazis/survey/v2"
)
type env struct {
Description string `json:"description"`
Value string `json:"value"`
Required *bool `json:"required"`
Generator string `json:"generator"`
Order *int `json:"order"`
}
type options struct {
AllowUnauthenticated *bool `json:"allow-unauthenticated"`
Memory string `json:"memory"`
CPU string `json:"cpu"`
Port int `json:"port"`
HTTP2 *bool `json:"http2"`
Concurrency int `json:"concurrency"`
MaxInstances int `json:"max-instances"`
}
type hook struct {
Commands []string `json:"commands"`
}
type buildpacks struct {
Builder string `json:"builder"`
}
type build struct {
Skip *bool `json:"skip"`
Buildpacks buildpacks `json:"buildpacks"`
}
type hooks struct {
PreCreate hook `json:"precreate"`
PostCreate hook `json:"postcreate"`
PreBuild hook `json:"prebuild"`
PostBuild hook `json:"postbuild"`
}
type appFile struct {
Name string `json:"name"`
Env map[string]env `json:"env"`
Options options `json:"options"`
Build build `json:"build"`
Hooks hooks `json:"hooks"`
// The following are unused variables that are still silently accepted
// for compatibility with Heroku app.json files.
IgnoredDescription string `json:"description"`
IgnoredKeywords []string `json:"keywords"`
IgnoredLogo string `json:"logo"`
IgnoredRepository string `json:"repository"`
IgnoredWebsite string `json:"website"`
IgnoredStack string `json:"stack"`
IgnoredFormation interface{} `json:"formation"`
}
const appJSON = `app.json`
// hasAppFile checks if the directory has an app.json file.
func hasAppFile(dir string) (bool, error) {
path := filepath.Join(dir, appJSON)
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return fi.Mode().IsRegular(), nil
}
func parseAppFile(r io.Reader) (*appFile, error) {
var v appFile
d := json.NewDecoder(r)
d.DisallowUnknownFields()
if err := d.Decode(&v); err != nil {
return nil, fmt.Errorf("failed to parse app.json: %+v", err)
}
// make "required" true by default
for k, env := range v.Env {
if env.Required == nil {
v := true
env.Required = &v
}
v.Env[k] = env
}
for k, env := range v.Env {
if env.Generator == "secret" && env.Value != "" {
return nil, fmt.Errorf("env var %q can't have both a value and use the secret generator", k)
}
}
return &v, nil
}
// getAppFile returns the parsed app.json in the directory if it exists,
// otherwise returns a zero appFile.
func getAppFile(dir string) (appFile, error) {
var v appFile
ok, err := hasAppFile(dir)
if err != nil {
return v, err
}
if !ok {
return v, nil
}
f, err := os.Open(filepath.Join(dir, appJSON))
if err != nil {
return v, fmt.Errorf("error opening app.json file: %v", err)
}
defer f.Close()
af, err := parseAppFile(f)
if err != nil {
return v, fmt.Errorf("failed to parse app.json file: %v", err)
}
return *af, nil
}
func rand64String() (string, error) {
b := make([]byte, 64)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
// takes the envs defined in app.json, and the existing envs and returns the new envs that need to be prompted for
func needEnvs(list map[string]env, existing map[string]struct{}) map[string]env {
for k := range list {
_, isPresent := existing[k]
if isPresent {
delete(list, k)
}
}
return list
}
func promptOrGenerateEnvs(list map[string]env) ([]string, error) {
var toGenerate []string
var toPrompt = make(map[string]env)
for k, e := range list {
if e.Generator == "secret" {
toGenerate = append(toGenerate, k)
} else {
toPrompt[k] = e
}
}
generated, err := generateEnvs(toGenerate)
if err != nil {
return nil, err
}
prompted, err := promptEnv(toPrompt)
if err != nil {
return nil, err
}
return append(generated, prompted...), nil
}
func generateEnvs(keys []string) ([]string, error) {
for i, key := range keys {
resp, err := rand64String()
if err != nil {
return nil, fmt.Errorf("failed to generate secret for %s : %v", key, err)
}
keys[i] = key + "=" + resp
}
return keys, nil
}
type envKeyValuePair struct {
k string
v env
}
type envKeyValuePairs []envKeyValuePair
func (e envKeyValuePairs) Len() int { return len(e) }
func (e envKeyValuePairs) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
}
func (e envKeyValuePairs) Less(i, j int) bool {
// if env.Order is unspecified, it should appear less.
// otherwise, less values show earlier.
if e[i].v.Order == nil {
return false
}
if e[j].v.Order == nil {
return true
}
return *e[i].v.Order < *e[j].v.Order
}
func sortedEnvs(envs map[string]env) []string {
var v envKeyValuePairs
for key, value := range envs {
v = append(v, envKeyValuePair{key, value})
}
sort.Sort(v)
var keys []string
for _, vv := range v {
keys = append(keys, vv.k)
}
return keys
}
func promptEnv(list map[string]env) ([]string, error) {
var out []string
sortedKeys := sortedEnvs(list)
for _, k := range sortedKeys {
e := list[k]
var resp string
if err := survey.AskOne(&survey.Input{
Message: fmt.Sprintf("Value of %s environment variable (%s)",
color.CyanString(k),
color.HiBlackString(e.Description)),
Default: e.Value,
}, &resp,
survey.WithValidator(survey.Required),
surveyIconOpts,
); err != nil {
return nil, fmt.Errorf("failed to get a response for environment variable %s", k)
}
out = append(out, k+"="+resp)
}
return out, nil
}