Răsfoiți Sursa

Merge branch 'staging' of https://github.com/porter-dev/porter into staging

jusrhee 5 ani în urmă
părinte
comite
d5e6529e0f

+ 1 - 0
.dockerignore

@@ -0,0 +1 @@
+/dashboard/node_modules

+ 12 - 2
README.md

@@ -1,6 +1,6 @@
 # Porter
 
-For development:
+### Development
 
 ```sh
 docker-compose -f docker-compose.dev.yaml up --build
@@ -10,4 +10,14 @@ And then visit `localhost:8080` in the browser.
 
 ### Testing
 
-From the root folder, run `go test ./...` to run all tests and ensure the builds/tests pass. 
+From the root directory, run `go test ./...` to run all tests and ensure the builds/tests pass. 
+
+### Building
+
+From the root directory, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter`. Then you can run `docker run -p 8080:8080 porter`. 
+
+To build the test container, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter --target porter-test`. 
+
+### Running
+
+`docker run -p 8080:8080 porter1/porter:latest`

+ 36 - 0
cli/cmd/credstore/credstore.go

@@ -0,0 +1,36 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/credentials"
+
+const (
+	url   = "https://github.com/porter-dev/porter"
+	label = "Porter Credentials"
+)
+
+// Set stores a given username/pw with a given credentials label in the OS-specific
+// credentials store
+func Set(username, pw string) error {
+	cr := &credentials.Credentials{
+		ServerURL: url,
+		Username:  username,
+		Secret:    pw,
+	}
+
+	credentials.SetCredsLabel(label)
+
+	return ns.Add(cr)
+}
+
+// Get retrieves a given username/pw with a given credentials label in the OS-specific
+// credentials store
+func Get() (string, string, error) {
+	credentials.SetCredsLabel(label)
+	return ns.Get(url)
+}
+
+// Del removes a given credential that uses a label in the OS-specific
+// credentials store
+func Del() error {
+	credentials.SetCredsLabel(label)
+	return ns.Delete(url)
+}

+ 5 - 0
cli/cmd/credstore/credstore_darwin.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/osxkeychain"
+
+var ns = osxkeychain.Osxkeychain{}

+ 5 - 0
cli/cmd/credstore/credstore_linux.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/pass"
+
+var ns = pass.Pass{}

+ 34 - 0
cli/cmd/credstore/credstore_test.go

@@ -0,0 +1,34 @@
+package credstore_test
+
+import (
+	"log"
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/credstore"
+)
+
+func TestSetGet(t *testing.T) {
+	credstore.Set("user", "password")
+
+	user, secret, err := credstore.Get()
+	if err == nil {
+		if user != "user" {
+			t.Errorf("Expecting user, got %s", user)
+		}
+
+		if secret != "password" {
+			t.Errorf("Expecting password, got %s", secret)
+		}
+	} else {
+		log.Println("got error:", err)
+	}
+
+	credstore.Del()
+
+	_, _, err = credstore.Get()
+
+	if err == nil {
+		t.Fatalf("Expecting an error, got nil")
+	}
+
+}

+ 5 - 0
cli/cmd/credstore/credstore_windows.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/wincred"
+
+var ns = wincred.Wincred{}

+ 195 - 0
cli/cmd/docker/agent.go

@@ -0,0 +1,195 @@
+package docker
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/network"
+	"github.com/docker/docker/api/types/volume"
+	"github.com/docker/docker/client"
+)
+
+// Agent is a Docker client for performing operations that interact
+// with the Docker engine over REST
+type Agent struct {
+	client *client.Client
+	ctx    context.Context
+	label  string
+}
+
+// CreateLocalVolumeIfNotExist creates a volume using driver type "local" with the
+// given name if it does not exist. If the volume does exist but does not contain
+// the required label (a.label), an error is thrown.
+func (a *Agent) CreateLocalVolumeIfNotExist(name string) (*types.Volume, error) {
+	volListBody, err := a.client.VolumeList(a.ctx, filters.Args{})
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not list volumes")
+	}
+
+	for _, _vol := range volListBody.Volumes {
+		if contains, ok := _vol.Labels[a.label]; ok && contains == "true" && _vol.Name == name {
+			return _vol, nil
+		} else if !ok && _vol.Name == name {
+			return nil, fmt.Errorf("volume conflict for %s: please remove existing volume and try again", name)
+		}
+	}
+
+	return a.CreateLocalVolume(name)
+}
+
+// CreateLocalVolume creates a volume using driver type "local" with no
+// configured options. The equivalent of:
+//
+// docker volume create --driver local [name]
+func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	opts := volume.VolumeCreateBody{
+		Name:   name,
+		Driver: "local",
+		Labels: labels,
+	}
+
+	vol, err := a.client.VolumeCreate(a.ctx, opts)
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not create volume "+name)
+	}
+
+	return &vol, nil
+}
+
+// CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
+// given name if it does not exist. If the volume does exist but does not contain
+// the required label (a.label), an error is thrown.
+func (a *Agent) CreateBridgeNetworkIfNotExist(name string) (id string, err error) {
+	networks, err := a.client.NetworkList(a.ctx, types.NetworkListOptions{})
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not list volumes")
+	}
+
+	for _, net := range networks {
+		if contains, ok := net.Labels[a.label]; ok && contains == "true" && net.Name == name {
+			return net.ID, nil
+		} else if !ok && net.Name == name {
+			return "", fmt.Errorf("network conflict for %s: please remove existing network and try again", name)
+		}
+	}
+
+	return a.CreateBridgeNetwork(name)
+}
+
+// CreateBridgeNetwork creates a volume using the default driver type (bridge)
+// with the CLI label attached
+func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	opts := types.NetworkCreate{
+		Labels:     labels,
+		Attachable: true,
+	}
+
+	net, err := a.client.NetworkCreate(a.ctx, name, opts)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create network "+name)
+	}
+
+	return net.ID, nil
+}
+
+// ConnectContainerToNetwork attaches a container to a specified network
+func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName string) error {
+	// check if the container is connected already
+	net, err := a.client.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
+
+	if err != nil {
+		return a.handleDockerClientErr(err, "Could not inspect network"+networkID)
+	}
+
+	for _, cont := range net.Containers {
+		// if container is connected, just return
+		if cont.Name == containerName {
+			return nil
+		}
+	}
+
+	return a.client.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
+}
+
+// PullImageEvent represents a response from the Docker API with an image pull event
+type PullImageEvent struct {
+	Status         string `json:"status"`
+	Error          string `json:"error"`
+	Progress       string `json:"progress"`
+	ProgressDetail struct {
+		Current int `json:"current"`
+		Total   int `json:"total"`
+	} `json:"progressDetail"`
+}
+
+// PullImage pulls an image specified by the image string
+func (a *Agent) PullImage(image string) error {
+	fmt.Println("Pulling image:", image)
+
+	// pull the specified image
+	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
+
+	if err != nil {
+		return a.handleDockerClientErr(err, "Could not pull image"+image)
+	}
+
+	decoder := json.NewDecoder(out)
+
+	var event *PullImageEvent
+
+	for {
+		if err := decoder.Decode(&event); err != nil {
+			if err == io.EOF {
+				break
+			}
+
+			return err
+		}
+	}
+
+	fmt.Println("Finished pulling image:", image)
+
+	return nil
+}
+
+// WaitForContainerStop waits until a container has stopped to exit
+func (a *Agent) WaitForContainerStop(id string) error {
+	// wait for container to stop before exit
+	statusCh, errCh := a.client.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
+
+	select {
+	case err := <-errCh:
+		if err != nil {
+			return a.handleDockerClientErr(err, "Error waiting for stopped container")
+		}
+	case <-statusCh:
+	}
+
+	return nil
+}
+
+// ------------------------- AGENT HELPER FUNCTIONS ------------------------- //
+
+func (a *Agent) handleDockerClientErr(err error, errPrefix string) error {
+	if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
+		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.client.DaemonHost())
+	}
+
+	return fmt.Errorf("%s:%s", errPrefix, err.Error())
+}

+ 29 - 0
cli/cmd/docker/config.go

@@ -0,0 +1,29 @@
+package docker
+
+import (
+	"context"
+
+	"github.com/docker/docker/client"
+)
+
+const label = "CreatedByPorterCLI"
+
+// NewAgentFromEnv creates a new Docker agent using the environment variables set
+// on the host
+func NewAgentFromEnv() (*Agent, error) {
+	ctx := context.Background()
+	cli, err := client.NewClientWithOpts(
+		client.FromEnv,
+		client.WithAPIVersionNegotiation(),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &Agent{
+		client: cli,
+		ctx:    ctx,
+		label:  label,
+	}, nil
+}

+ 267 - 0
cli/cmd/docker/porter.go

@@ -0,0 +1,267 @@
+package docker
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/mount"
+	"github.com/docker/go-connections/nat"
+)
+
+// PorterStartOpts are the options for starting the Porter container
+type PorterStartOpts struct {
+	Name          string
+	Image         string
+	StartCmd      []string
+	HostPort      uint
+	ContainerPort uint
+	Mounts        []mount.Mount
+	VolumeMap     map[string]struct{}
+	Env           []string
+	NetworkID     string
+}
+
+// StartPorterContainer pulls a specific Porter image and starts a container
+// using the Docker engine. It returns the container ID
+func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
+	id, err := a.upsertPorterContainer(opts)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = a.startPorterContainer(id)
+
+	if err != nil {
+		return "", err
+	}
+
+	// attach container to network
+	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)
+
+	if err != nil {
+		return "", err
+	}
+
+	return id, nil
+}
+
+// detect if container exists and is running, and stop
+// if spec has changed, remove and recreate container
+// if container does not exist, create the container
+// otherwise, return stopped container
+func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err error) {
+	containers, err := a.getContainersCreatedByStart()
+
+	// remove the matching container
+	for _, container := range containers {
+		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+			}
+
+			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+			}
+		}
+	}
+
+	return a.pullAndCreatePorterContainer(opts)
+}
+
+// create the container and return its id
+func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, err error) {
+	a.PullImage(opts.Image)
+
+	// format the port array for binding to host machine
+	ports := []string{fmt.Sprintf("127.0.0.1:%d:%d/tcp", opts.HostPort, opts.ContainerPort)}
+
+	_, portBindings, err := nat.ParsePortSpecs(ports)
+
+	if err != nil {
+		return "", fmt.Errorf("Unable to parse port specification %s", ports)
+	}
+
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	// create the container with a label specifying this was created via the CLI
+	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+		Image:   opts.Image,
+		Cmd:     opts.StartCmd,
+		Tty:     false,
+		Labels:  labels,
+		Volumes: opts.VolumeMap,
+		Env:     opts.Env,
+	}, &container.HostConfig{
+		PortBindings: portBindings,
+		Mounts:       opts.Mounts,
+	}, nil, opts.Name)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create Porter container")
+	}
+
+	return resp.ID, nil
+}
+
+// start the container
+func (a *Agent) startPorterContainer(id string) error {
+	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+		return a.handleDockerClientErr(err, "Could not start Porter container")
+	}
+
+	return nil
+}
+
+// PostgresOpts are the options for starting the Postgres DB
+type PostgresOpts struct {
+	Name      string
+	Image     string
+	Env       []string
+	VolumeMap map[string]struct{}
+	Mounts    []mount.Mount
+	NetworkID string
+}
+
+// StartPostgresContainer pulls a specific Porter image and starts a container
+// using the Docker engine
+func (a *Agent) StartPostgresContainer(opts PostgresOpts) (string, error) {
+	id, err := a.upsertPostgresContainer(opts)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = a.startPostgresContainer(id)
+
+	if err != nil {
+		return "", err
+	}
+
+	// attach container to network
+	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)
+
+	if err != nil {
+		return "", err
+	}
+
+	return id, nil
+}
+
+// detect if container exists and is running, and stop
+// if it is running, stop it
+// if it is stopped, return id
+// if it does not exist, create it and return it
+func (a *Agent) upsertPostgresContainer(opts PostgresOpts) (id string, err error) {
+	containers, err := a.getContainersCreatedByStart()
+
+	// stop the matching container and return it
+	for _, container := range containers {
+		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not stop postgres container "+container.ID)
+			}
+
+			return container.ID, nil
+		}
+	}
+
+	return a.pullAndCreatePostgresContainer(opts)
+}
+
+// create the container and return it
+func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, err error) {
+	a.PullImage(opts.Image)
+
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	// create the container with a label specifying this was created via the CLI
+	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+		Image:   opts.Image,
+		Tty:     false,
+		Labels:  labels,
+		Volumes: opts.VolumeMap,
+		Env:     opts.Env,
+		ExposedPorts: nat.PortSet{
+			"5432": struct{}{},
+		},
+	}, &container.HostConfig{
+		Mounts: opts.Mounts,
+	}, nil, opts.Name)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create Porter container")
+	}
+
+	return resp.ID, nil
+}
+
+// start the container in the background
+func (a *Agent) startPostgresContainer(id string) error {
+	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+		return a.handleDockerClientErr(err, "Could not start Postgres container")
+	}
+
+	return nil
+}
+
+// StopPorterContainers finds all containers that were started via the CLI and stops them
+// without removal.
+func (a *Agent) StopPorterContainers() error {
+	fmt.Println("Stopping containers...")
+
+	containers, err := a.getContainersCreatedByStart()
+
+	if err != nil {
+		return err
+	}
+
+	// remove all Porter containers
+	for _, container := range containers {
+		timeout, _ := time.ParseDuration("15s")
+
+		err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+		if err != nil {
+			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+		}
+	}
+
+	return nil
+}
+
+// getContainersCreatedByStart gets all containers that were created by the "porter start"
+// command by looking for the label "CreatedByPorterCLI" (or .label of the agent)
+func (a *Agent) getContainersCreatedByStart() ([]types.Container, error) {
+	containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{
+		All: true,
+	})
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not list containers")
+	}
+
+	res := make([]types.Container, 0)
+
+	for _, container := range containers {
+		if contains, ok := container.Labels[a.label]; ok && contains == "true" {
+			res = append(res, container)
+		}
+	}
+
+	return res, nil
+}

+ 97 - 0
cli/cmd/generate.go

@@ -0,0 +1,97 @@
+package cmd
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/util/homedir"
+
+	"github.com/spf13/cobra"
+)
+
+var (
+	outputFile     string
+	kubeconfigPath string
+	print          *bool
+	contexts       *[]string
+)
+
+// generateCmd represents the generate command
+var generateCmd = &cobra.Command{
+	Use:   "generate",
+	Short: "Generates a kubeconfig with certificate data added",
+	Run: func(cmd *cobra.Command, args []string) {
+		generate(kubeconfigPath, outputFile, *print, *contexts)
+	},
+}
+
+func init() {
+	home := homedir.HomeDir()
+
+	rootCmd.AddCommand(generateCmd)
+
+	generateCmd.PersistentFlags().StringVarP(
+		&outputFile,
+		"output",
+		"o",
+		filepath.Join(home, ".porter", "porter.kubeconfig"),
+		"output file location",
+	)
+
+	generateCmd.PersistentFlags().StringVarP(
+		&kubeconfigPath,
+		"kubeconfig",
+		"k",
+		"",
+		"path to kubeconfig",
+	)
+
+	contexts = generateCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to use (defaults to the current context)",
+	)
+
+	print = generateCmd.PersistentFlags().BoolP(
+		"print",
+		"p",
+		false,
+		"print result to stdout, without writing to the fs",
+	)
+}
+
+func generate(kubeconfigPath string, output string, print bool, contexts []string) error {
+	conf, err := kubernetes.GetConfigFromHostWithCertData(kubeconfigPath, contexts)
+
+	if err != nil {
+		return err
+	}
+
+	rawConf, err := conf.RawConfig()
+
+	if err != nil {
+		return err
+	}
+
+	if print {
+		bytes, err := clientcmd.Write(rawConf)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Printf(string(bytes))
+
+		return nil
+	}
+
+	err = clientcmd.WriteToFile(rawConf, output)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 63 - 0
cli/cmd/helpers.go

@@ -0,0 +1,63 @@
+package cmd
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+func closeHandler(closer func() error) {
+	sig := make(chan os.Signal)
+	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sig
+		err := closer()
+
+		if err == nil {
+			fmt.Println("shutdown successful")
+			os.Exit(0)
+		}
+
+		fmt.Printf("shutdown unsuccessful: %s\n", err.Error())
+		os.Exit(1)
+	}()
+}
+
+func promptPlaintext(prompt string) (string, error) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print(prompt)
+	text, err := reader.ReadString('\n')
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(text), nil
+}
+
+func promptPasswordWithConfirmation() (string, error) {
+	fmt.Print("Password: ")
+	pw, err := terminal.ReadPassword(0)
+	fmt.Print("\r")
+
+	if err != nil {
+		return "", err
+	}
+
+	fmt.Print("Confirm password: ")
+	confirmPw, err := terminal.ReadPassword(0)
+	fmt.Print("\n")
+
+	if strings.TrimSpace(string(pw)) != strings.TrimSpace(string(confirmPw)) {
+		return "", errors.New("Passwords do not match")
+	}
+
+	return strings.TrimSpace(string(pw)), nil
+}

+ 24 - 0
cli/cmd/root.go

@@ -0,0 +1,24 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+	Use:   "porter",
+	Short: "Porter is a dashboard for managing Kubernetes clusters.",
+	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
+}
+
+// Execute adds all child commands to the root command and sets flags appropriately.
+// This is called by main.main(). It only needs to happen once to the rootCmd.
+func Execute() {
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}

+ 308 - 0
cli/cmd/start.go

@@ -0,0 +1,308 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"k8s.io/client-go/util/homedir"
+
+	"github.com/porter-dev/porter/cli/cmd/credstore"
+
+	"github.com/spf13/cobra"
+
+	"github.com/docker/docker/api/types/mount"
+)
+
+type startOps struct {
+	insecure       *bool
+	skipKubeconfig *bool
+	kubeconfigPath string
+	contexts       *[]string
+	imageTag       string `form:"required"`
+	db             string `form:"oneof=sqlite postgres"`
+}
+
+var opts = &startOps{}
+
+// startCmd represents the start command
+var startCmd = &cobra.Command{
+	Args: func(cmd *cobra.Command, args []string) error {
+		return nil
+	},
+	Use:   "start",
+	Short: "Starts a Porter instance using the Docker engine.",
+	Run: func(cmd *cobra.Command, args []string) {
+		closeHandler(stop)
+
+		err := start(
+			opts.imageTag,
+			opts.kubeconfigPath,
+			opts.db,
+			*opts.contexts,
+			*opts.insecure,
+			*opts.skipKubeconfig,
+		)
+
+		if err != nil {
+			fmt.Println("Error running start:", err.Error())
+			fmt.Println("Shutting down...")
+
+			err = stop()
+
+			if err != nil {
+				fmt.Println("Shutdown unsuccessful:", err.Error())
+			}
+
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(startCmd)
+
+	opts.insecure = startCmd.PersistentFlags().Bool(
+		"insecure",
+		false,
+		"skip admin setup and authorization",
+	)
+
+	opts.skipKubeconfig = startCmd.PersistentFlags().Bool(
+		"skip-kubeconfig",
+		false,
+		"skip initialization of the kubeconfig",
+	)
+
+	opts.contexts = startCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to use (defaults to the current context)",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.db,
+		"db",
+		"sqlite",
+		"the db to use, one of sqlite or postgres",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.kubeconfigPath,
+		"kubeconfig",
+		"",
+		"path to kubeconfig",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.imageTag,
+		"image-tag",
+		"latest",
+		"the Porter image tag to use",
+	)
+}
+
+func stop() error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainers()
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func start(
+	imageTag string,
+	kubeconfigPath string,
+	db string,
+	contexts []string,
+	insecure bool,
+	skipKubeconfig bool,
+) error {
+	var username, pw string
+	var err error
+	home := homedir.HomeDir()
+	outputConfPath := filepath.Join(home, ".porter", "porter.kubeconfig")
+	containerConfPath := "/porter/porter.kubeconfig"
+	port := 8080
+
+	// if not insecure, or username/pw set incorrectly, prompt for new username/pw
+	if username, pw, err = credstore.Get(); !insecure && err != nil {
+		username, err = promptPlaintext("Email: ")
+
+		if err != nil {
+			return err
+		}
+
+		pw, err = promptPasswordWithConfirmation()
+
+		if err != nil {
+			return err
+		}
+
+		credstore.Set(username, pw)
+	}
+
+	if !skipKubeconfig {
+		err = generate(
+			kubeconfigPath,
+			outputConfPath,
+			false,
+			contexts,
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	// the volume mounts to use
+	mounts := make([]mount.Mount, 0)
+
+	// the volumes passed to the Porter container
+	volumesMap := make(map[string]struct{})
+
+	if !skipKubeconfig {
+		// add a bind mount with the kubeconfig
+		mount := mount.Mount{
+			Type:        mount.TypeBind,
+			Source:      outputConfPath,
+			Target:      containerConfPath,
+			ReadOnly:    true,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+	}
+
+	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network")
+
+	if err != nil {
+		return err
+	}
+
+	env := make([]string, 0)
+
+	env = append(env, []string{
+		"ADMIN_INIT=true",
+		"ADMIN_EMAIL=" + username,
+		"ADMIN_PASSWORD=" + pw,
+	}...)
+
+	switch db {
+	case "sqlite":
+		// check if sqlite volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite")
+
+		if err != nil {
+			return err
+		}
+
+		// create mount
+		mount := mount.Mount{
+			Type:        mount.TypeVolume,
+			Source:      vol.Name,
+			Target:      "/sqlite",
+			ReadOnly:    false,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+		volumesMap[vol.Name] = struct{}{}
+
+		env = append(env, []string{
+			"SQL_LITE=true",
+			"SQL_LITE_PATH=/sqlite/porter.db",
+		}...)
+	case "postgres":
+		// check if postgres volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres")
+
+		if err != nil {
+			return err
+		}
+
+		// pgMount is mount for postgres container
+		pgMount := []mount.Mount{
+			mount.Mount{
+				Type:        mount.TypeVolume,
+				Source:      vol.Name,
+				Target:      "/var/lib/postgresql/data",
+				ReadOnly:    false,
+				Consistency: mount.ConsistencyFull,
+			},
+		}
+
+		// create postgres container with mount
+		startOpts := docker.PostgresOpts{
+			Name:   "porter_postgres",
+			Image:  "postgres:latest",
+			Mounts: pgMount,
+			VolumeMap: map[string]struct{}{
+				"porter_postgres": struct{}{},
+			},
+			NetworkID: netID,
+			Env: []string{
+				"POSTGRES_USER=porter",
+				"POSTGRES_PASSWORD=porter",
+				"POSTGRES_DB=porter",
+			},
+		}
+
+		pgID, err := agent.StartPostgresContainer(startOpts)
+
+		if err != nil {
+			return err
+		}
+
+		env = append(env, []string{
+			"SQL_LITE=false",
+			"DB_USER=porter",
+			"DB_PASS=porter",
+			"DB_NAME=porter",
+			"DB_HOST=porter_postgres",
+			"DB_PORT=5432",
+		}...)
+
+		defer agent.WaitForContainerStop(pgID)
+	}
+
+	// create Porter container
+	// TODO -- look for unused port
+	startOpts := docker.PorterStartOpts{
+		Name:          "porter_server",
+		Image:         "porter1/porter:" + imageTag,
+		HostPort:      uint(port),
+		ContainerPort: 8080,
+		Mounts:        mounts,
+		VolumeMap:     volumesMap,
+		NetworkID:     netID,
+		Env:           env,
+	}
+
+	id, err := agent.StartPorterContainer(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("Server ready: listening on localhost:%d\n", port)
+
+	agent.WaitForContainerStop(id)
+
+	return nil
+}

+ 11 - 0
cli/main.go

@@ -0,0 +1,11 @@
+// +build cli
+
+package main
+
+import (
+	"github.com/porter-dev/porter/cli/cmd"
+)
+
+func main() {
+	cmd.Execute()
+}

+ 78 - 1
cmd/app/main.go

@@ -2,10 +2,16 @@ package main
 
 import (
 	"fmt"
+	"io/ioutil"
 	"log"
 	"net/http"
+	"os"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
 
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
@@ -31,6 +37,15 @@ func main() {
 
 	repo := gorm.NewRepository(db)
 
+	// upsert admin if config requires
+	if appConf.Db.AdminInit {
+		err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
+
+		if err != nil {
+			fmt.Println("Error while upserting admin: " + err.Error())
+		}
+	}
+
 	// declare as Store interface (methods Get, New, Save)
 	var store sessions.Store
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
@@ -39,7 +54,7 @@ func main() {
 
 	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
 
-	appRouter := router.New(a, store, appConf.Server.CookieName)
+	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 
@@ -57,3 +72,65 @@ func main() {
 		log.Fatal("Server startup failed")
 	}
 }
+
+func upsertAdmin(repo repository.UserRepository, email, pw string) error {
+	admUser, err := repo.ReadUserByEmail(email)
+
+	// create the user in this case
+	if err != nil {
+		form := forms.CreateUserForm{
+			Email:    email,
+			Password: pw,
+		}
+
+		admUser, err = form.ToUser(repo)
+
+		if err != nil {
+			return err
+		}
+
+		admUser, err = repo.CreateUser(admUser)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	filename := "/porter/porter.kubeconfig"
+
+	// read if kubeconfig file exists, if it does update the user
+	if _, err := os.Stat(filename); !os.IsNotExist(err) {
+		fileBytes, err := ioutil.ReadFile(filename)
+
+		contexts := make([]string, 0)
+		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
+
+		if err != nil {
+			return err
+		}
+
+		for _, context := range allContexts {
+			contexts = append(contexts, context.Name)
+		}
+
+		form := forms.UpdateUserForm{
+			ID:              admUser.ID,
+			RawKubeConfig:   string(fileBytes),
+			AllowedContexts: contexts,
+		}
+
+		admUser, err = form.ToUser(repo)
+
+		if err != nil {
+			return err
+		}
+
+		admUser, err = repo.UpdateUser(admUser)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 7 - 3
dashboard/src/main/Main.tsx

@@ -31,8 +31,12 @@ export default class Main extends Component<PropsType, StateType> {
 
   componentDidMount() {
     let { setUserId } = this.context;
-    api.checkAuth('', {}, {}, (err: any, res: any) => {
-      if (res?.data) {
+    api.checkAuth('', {}, {}, (err: any, res: any) => {      
+      if (err && err.response.status == 403) {
+        this.setState({ isLoggedIn: false, loading: false })
+      }
+
+      if (res && res.data) {
         setUserId(res.data.id);
         this.setState({ isLoggedIn: true, initialized: true, loading: false });
       } else {
@@ -128,4 +132,4 @@ const StyledMain = styled.div`
   left: 0;
   background: #202227;
   color: white;
-`;
+`;

+ 0 - 18
docker-compose.dev.yaml

@@ -33,24 +33,6 @@ services:
       - 5400:5432
     volumes:
       - db:/var/lib/postgresql/data
-
-#   metabase:
-#     image: metabase/metabase
-#     restart: always
-#     ports: 
-#       - 3000:3000
-#     volumes: 
-#       - metabase:/metabase-data
-#     environment:
-#       MB_DB_TYPE: postgres
-#       MB_DB_DBNAME: porter
-#       MB_DB_PORT: 5432
-#       MB_DB_USER: porter
-#       MB_DB_PASS: porter
-#       MB_DB_HOST: postgres
-#     depends_on:
-#       - postgres
-
   nginx:
     image: nginx:mainline-alpine
     container_name: nginx

+ 0 - 45
docker-compose.yaml

@@ -1,45 +0,0 @@
-version: '3'
-services:
-  porter:
-    build:
-      context: .
-      dockerfile: ./docker/dev.Dockerfile
-    ports:
-      - 8080:8080
-    depends_on:
-      - postgres
-    env_file:
-      - ./docker/.env
-    restart: on-failure
-
-  postgres:
-    image: postgres:latest
-    container_name: postgres
-    environment:
-      - POSTGRES_USER=porter
-      - POSTGRES_PASSWORD=porter
-      - POSTGRES_DB=porter
-    ports:
-      - 5400:5432
-    volumes:
-      - db:/var/lib/postgresql/data
-
-  # metabase:
-  #   image: metabase/metabase
-  #   restart: always
-  #   ports: 
-  #     - 3000:3000
-  #   volumes: 
-  #     - metabase:/metabase-data
-  #   environment:
-  #     MB_DB_TYPE: postgres
-  #     MB_DB_DBNAME: porter
-  #     MB_DB_PORT: 5432
-  #     MB_DB_USER: porter
-  #     MB_DB_PASS: porter
-  #     MB_DB_HOST: postgres
-  #   depends_on:
-  #     - postgres
-  #   volumes:
-  #     db:
-  #     metabase:

+ 3 - 1
docker/.env

@@ -1,5 +1,7 @@
 DEBUG=true
 
+STATIC_FILE_PATH=/porter/static
+
 SERVER_PORT=8080
 SERVER_TIMEOUT_READ=5s
 SERVER_TIMEOUT_WRITE=10s
@@ -12,4 +14,4 @@ DB_PASS=porter
 DB_NAME=porter
 COOKIE_SECRETS=secret
 
-QUICK_START=false
+SQL_LITE=false

+ 62 - 7
docker/Dockerfile

@@ -1,15 +1,70 @@
-FROM golang:1.15-alpine
+# syntax=docker/dockerfile:1.1.7-experimental
+
+# Base Go environment
+# -------------------
+FROM golang:1.15-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git
 
 COPY go.mod go.sum ./
-RUN go mod download
+COPY /cmd ./cmd
+COPY /internal ./internal
+COPY /server ./server
+
+RUN --mount=type=cache,target=$GOPATH/pkg/mod \
+    go mod download
+
+# Go build environment
+# --------------------
+FROM base AS build-go
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
+    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate
+
+# Go test environment
+# -------------------
+FROM base AS porter-test
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go test ./...
+
+# Webpack build environment
+# -------------------------
+FROM node:latest as build-webpack
+WORKDIR /webpack
+
+COPY ./dashboard ./
+
+RUN npm i
+
+ENV NODE_ENV=production
+
+RUN npm run build
+
+# Deployment environment
+# ----------------------
+FROM alpine
+RUN apk update
+
+COPY --from=build-go /porter/bin/app /porter/
+COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-webpack /webpack/build /porter/static
+
+ENV DEBUG=false
+ENV STATIC_FILE_PATH=/porter/static
+ENV SERVER_PORT=8080
+ENV SERVER_TIMEOUT_READ=5s
+ENV SERVER_TIMEOUT_WRITE=10s
+ENV SERVER_TIMEOUT_IDLE=15s
 
-COPY . ./
+ENV COOKIE_SECRETS=secret
 
-RUN go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app \
-    && go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate \
-    && chmod +x /porter/docker/bin/*
+ENV SQL_LITE=true
+ENV ADMIN_INIT=false
 
-CMD ./docker/bin/start.sh
+EXPOSE 8080
+CMD /porter/migrate && /porter/app

+ 1 - 1
docker/dev.Dockerfile

@@ -16,4 +16,4 @@ RUN go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate \
 # for live reloading of go container
 RUN go get github.com/cosmtrek/air
 
-CMD /porter/bin/migrate; air -c .air.toml
+CMD air -c .air.toml

+ 10 - 3
go.mod

@@ -6,8 +6,13 @@ require (
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/containerd/containerd v1.4.1
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
+	github.com/danieljoos/wincred v1.1.0 // indirect
+	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
+	github.com/docker/docker-credential-helpers v0.6.3
+	github.com/docker/go-connections v0.4.0
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0 // indirect
@@ -30,13 +35,15 @@ require (
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	github.com/sirupsen/logrus v1.6.0
+	github.com/sirupsen/logrus v1.7.0
+	github.com/spf13/cobra v1.0.0
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
-	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
-	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
+	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
+	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 // indirect
+	google.golang.org/grpc v1.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0

+ 21 - 0
go.sum

@@ -74,6 +74,7 @@ github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZ
 github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
 github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
+github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -150,6 +151,8 @@ github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.
 github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.4 h1:3o0smo5SKY7H6AJCmJhsnCjR2/V2T8VmiHt7seN2/kI=
 github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.1 h1:pASeJT3R3YyVn+94qEPk0SnU1OQ20Jd/T+SPKy9xehY=
+github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY=
 github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
@@ -180,6 +183,8 @@ github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg=
 github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
+github.com/danieljoos/wincred v1.1.0 h1:3RNcEpBg4IhIChZdFRSdlQt1QjCp1sMAPIrOnm7Yf8g=
+github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -201,6 +206,7 @@ github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc
 github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce h1:KXS1Jg+ddGcWA8e1N7cupxaHHZhit5rB9tfDU+mfjyY=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo=
 github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ=
 github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
@@ -409,6 +415,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
@@ -565,6 +573,7 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -786,6 +795,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -972,9 +983,12 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb h1:mUVeFHoDKis5nxCAzoAi7E8Ghb86EXh/RK6wtvJIqRY=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1046,6 +1060,9 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc h1:HVFDs9bKvTxP6bh1Rj9MCSo+UmafQtI8ZWDPVwVk9g4=
+golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1179,6 +1196,8 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 h1:fG84H9C3EXfuDlzkG+VEPDYHHExklP6scH1QZ5gQTqU=
+google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1198,6 +1217,8 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.0 h1:IBKSUNL2uBS2DkJBncPP+TwT0sp9tgA8A75NjHt6umg=
+google.golang.org/grpc v1.33.0/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

+ 26 - 2
internal/adapter/gorm.go

@@ -2,6 +2,7 @@ package gorm
 
 import (
 	"fmt"
+	"time"
 
 	"github.com/porter-dev/porter/internal/config"
 	"gorm.io/driver/postgres"
@@ -12,7 +13,7 @@ import (
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	if conf.SQLLite {
-		return gorm.Open(sqlite.Open("./internal/porter.db"), &gorm.Config{})
+		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{})
 	}
 
 	dsn := fmt.Sprintf(
@@ -23,5 +24,28 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		conf.Host,
 	)
 
-	return gorm.Open(postgres.Open(dsn), &gorm.Config{})
+	res, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+
+	// retry the connection 3 times
+	retryCount := 0
+	timeout, _ := time.ParseDuration("5s")
+
+	if err != nil {
+		for {
+			time.Sleep(timeout)
+			res, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
+
+			if retryCount > 3 {
+				return nil, err
+			}
+
+			if err == nil {
+				return res, nil
+			}
+
+			retryCount++
+		}
+	}
+
+	return res, err
 }

+ 13 - 7
internal/config/config.go

@@ -17,12 +17,13 @@ type Conf struct {
 
 // ServerConf is the server configuration
 type ServerConf struct {
-	Port         int           `env:"SERVER_PORT,default=8080"`
-	CookieName   string        `env:"COOKIE_NAME,default=porter"`
-	CookieSecret []byte        `env:"COOKIE_SECRET,default=secret"`
-	TimeoutRead  time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
-	TimeoutWrite time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
-	TimeoutIdle  time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+	Port           int           `env:"SERVER_PORT,default=8080"`
+	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
+	CookieName     string        `env:"COOKIE_NAME,default=porter"`
+	CookieSecret   []byte        `env:"COOKIE_SECRET,default=secret"`
+	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
+	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
+	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,
@@ -34,7 +35,12 @@ type DBConf struct {
 	Password string `env:"DB_PASS,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
 
-	SQLLite bool `env:"QUICK_START,default=false"`
+	AdminInit     bool   `env:"ADMIN_INIT,default=true"`
+	AdminEmail    string `env:"ADMIN_EMAIL,default=admin@example.com"`
+	AdminPassword string `env:"ADMIN_PASSWORD,default=password"`
+
+	SQLLite     bool   `env:"SQL_LITE,default=false"`
+	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
 }
 
 // K8sConf is the global configuration for the k8s agents

+ 4 - 7
internal/forms/user.go

@@ -110,13 +110,10 @@ func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User,
 
 	contextsJoin := strings.Join(contexts, ",")
 
-	return &models.User{
-		Model: gorm.Model{
-			ID: uuf.ID,
-		},
-		Contexts:      contextsJoin,
-		RawKubeConfig: rawBytes,
-	}, nil
+	savedUser.Contexts = contextsJoin
+	savedUser.RawKubeConfig = rawBytes
+
+	return savedUser, nil
 }
 
 // DeleteUserForm represents the accepted values for deleting a user

+ 160 - 1
internal/kubernetes/kubeconfig.go

@@ -1,9 +1,16 @@
 package kubernetes
 
 import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
 	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
+	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
+	"k8s.io/client-go/util/homedir"
 )
 
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
@@ -90,7 +97,6 @@ func GetContextsFromBytes(bytes []byte, allowedContexts []string) ([]models.Cont
 	contexts := toContexts(&rawConf, allowedContexts)
 
 	return contexts, nil
-
 }
 
 func toContexts(rawConf *api.Config, allowedContexts []string) []models.Context {
@@ -137,3 +143,156 @@ func createAllowedContextMap(contexts []string) map[string]string {
 
 	return aContextMap
 }
+
+// GetConfigFromHostWithCertData gets the kubeconfig using default options set on the host:
+// the kubeconfig can either be retrieved from a specified path or an environment variable.
+// This function only outputs a clientcmd that uses the allowedContexts.
+//
+// This function also populates all of the certificate data that's specified as a filepath.
+func GetConfigFromHostWithCertData(kubeconfigPath string, allowedContexts []string) (clientcmd.ClientConfig, error) {
+	envVarName := clientcmd.RecommendedConfigPathEnvVar
+
+	if kubeconfigPath != "" {
+		if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
+			// the specified kubeconfig does not exist so fallback to other options
+			kubeconfigPath = ""
+		}
+	}
+
+	if kubeconfigPath == "" && os.Getenv(envVarName) == "" {
+		if home := homedir.HomeDir(); home != "" {
+			kubeconfigPath = filepath.Join(home, ".kube", "config")
+		}
+	}
+
+	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
+	loadingRules.ExplicitPath = kubeconfigPath
+
+	clientConf := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
+	rawConf, err := clientConf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	populateCertificateRefs(&rawConf)
+
+	if len(allowedContexts) == 0 {
+		allowedContexts = []string{rawConf.CurrentContext}
+
+		if allowedContexts[0] == "" {
+			return nil, fmt.Errorf("at least one context must be specified")
+		}
+	}
+
+	res, err := stripAndValidateClientContexts(&rawConf, allowedContexts[0], allowedContexts)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
+// a context name, and the set of allowed contexts.
+func stripAndValidateClientContexts(
+	rawConf *clientcmdapi.Config,
+	currentContext string,
+	allowedContexts []string,
+) (clientcmd.ClientConfig, error) {
+	// grab a copy to get the pointer and set clusters, authinfos, and contexts to empty
+	copyConf := rawConf.DeepCopy()
+
+	copyConf.Clusters = make(map[string]*api.Cluster)
+	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
+	copyConf.Contexts = make(map[string]*api.Context)
+	copyConf.CurrentContext = currentContext
+
+	// put allowed clusters in a map
+	aContextMap := createAllowedContextMap(allowedContexts)
+
+	for contextName, context := range rawConf.Contexts {
+		userName := context.AuthInfo
+		clusterName := context.Cluster
+		authInfo, userFound := rawConf.AuthInfos[userName]
+		cluster, clusterFound := rawConf.Clusters[clusterName]
+
+		// make sure the cluster is "allowed"
+		_, isAllowed := aContextMap[contextName]
+
+		if userFound && clusterFound && isAllowed {
+			copyConf.Clusters[clusterName] = cluster
+			copyConf.AuthInfos[userName] = authInfo
+			copyConf.Contexts[contextName] = context
+		}
+	}
+
+	// validate the copyConf and create a ClientConfig
+	err := clientcmd.Validate(*copyConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clientConf := clientcmd.NewDefaultClientConfig(*copyConf, &clientcmd.ConfigOverrides{})
+
+	return clientConf, nil
+}
+
+func populateCertificateRefs(config *clientcmdapi.Config) {
+	for _, cluster := range config.Clusters {
+		refs := clientcmd.GetClusterFileReferences(cluster)
+		for _, str := range refs {
+			// only write certificate if the file reference is CA
+			if *str != cluster.CertificateAuthority {
+				break
+			}
+
+			fileBytes, err := ioutil.ReadFile(*str)
+
+			if err != nil {
+				continue
+			}
+
+			cluster.CertificateAuthorityData = fileBytes
+			cluster.CertificateAuthority = ""
+		}
+	}
+
+	for _, authInfo := range config.AuthInfos {
+		refs := clientcmd.GetAuthInfoFileReferences(authInfo)
+		for _, str := range refs {
+			if *str == "" {
+				continue
+			}
+
+			var refType int
+
+			if authInfo.ClientCertificate == *str {
+				refType = 0
+			} else if authInfo.ClientKey == *str {
+				refType = 1
+			} else if authInfo.TokenFile == *str {
+				refType = 2
+			}
+
+			fileBytes, err := ioutil.ReadFile(*str)
+
+			if err != nil {
+				continue
+			}
+
+			if refType == 0 {
+				authInfo.ClientCertificateData = fileBytes
+				authInfo.ClientCertificate = ""
+			} else if refType == 1 {
+				authInfo.ClientKeyData = fileBytes
+				authInfo.ClientKey = ""
+			} else if refType == 2 {
+				authInfo.Token = string(fileBytes)
+				authInfo.TokenFile = ""
+			}
+		}
+	}
+}

+ 1 - 1
server/api/helpers_test.go

@@ -80,7 +80,7 @@ func newTester(canQuery bool) *tester {
 
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
 	app := api.New(logger, repo, validator, store, appConf.Server.CookieName, true)
-	r := router.New(app, store, appConf.Server.CookieName)
+	r := router.New(app, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
 
 	return &tester{
 		app:    app,

+ 19 - 1
server/router/router.go

@@ -1,6 +1,9 @@
 package router
 
 import (
+	"net/http"
+	"os"
+
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/server/api"
@@ -9,7 +12,12 @@ import (
 )
 
 // New creates a new Chi router instance
-func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
+func New(
+	a *api.App,
+	store sessions.Store,
+	cookieName string,
+	staticFilePath string,
+) *chi.Mux {
 	l := a.Logger()
 	r := chi.NewRouter()
 	auth := mw.NewAuth(store, cookieName)
@@ -39,5 +47,15 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})
 
+	fs := http.FileServer(http.Dir(staticFilePath))
+
+	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
+		if _, err := os.Stat(staticFilePath + r.RequestURI); os.IsNotExist(err) {
+			http.StripPrefix(r.RequestURI, fs).ServeHTTP(w, r)
+		} else {
+			fs.ServeHTTP(w, r)
+		}
+	})
+
 	return r
 }