Просмотр исходного кода

Merge branch 'master' into 0.4.0-launchflow-improvements

jusrhee 4 лет назад
Родитель
Сommit
dfb002fa87
73 измененных файлов с 4765 добавлено и 1183 удалено
  1. 224 0
      cli/cmd/api/deploy.go
  2. 60 0
      cli/cmd/api/domain.go
  3. 73 0
      cli/cmd/api/git_repo.go
  4. 9 4
      cli/cmd/api/github_action.go
  5. 40 0
      cli/cmd/api/k8s.go
  6. 39 0
      cli/cmd/api/registry.go
  7. 66 0
      cli/cmd/api/template.go
  8. 13 28
      cli/cmd/auth.go
  9. 5 12
      cli/cmd/cluster.go
  10. 263 124
      cli/cmd/config.go
  11. 16 30
      cli/cmd/connect.go
  12. 287 0
      cli/cmd/create.go
  13. 438 0
      cli/cmd/deploy.go
  14. 74 0
      cli/cmd/deploy/build.go
  15. 497 0
      cli/cmd/deploy/create.go
  16. 444 0
      cli/cmd/deploy/deploy.go
  17. 12 0
      cli/cmd/deploy/shared.go
  18. 15 10
      cli/cmd/docker.go
  19. 158 16
      cli/cmd/docker/agent.go
  20. 17 0
      cli/cmd/docker/agent_test.go
  21. 358 0
      cli/cmd/docker/auth.go
  22. 54 0
      cli/cmd/docker/builder.go
  23. 21 0
      cli/cmd/docker/config.go
  24. 4 2
      cli/cmd/docker/porter.go
  25. 3 3
      cli/cmd/errors.go
  26. 17 40
      cli/cmd/github/release.go
  27. 102 0
      cli/cmd/gitutils/git.go
  28. 5 10
      cli/cmd/helm_repo.go
  29. 89 0
      cli/cmd/job.go
  30. 3 10
      cli/cmd/open.go
  31. 32 0
      cli/cmd/pack/pack.go
  32. 2 11
      cli/cmd/project.go
  33. 8 13
      cli/cmd/registry.go
  34. 7 44
      cli/cmd/root.go
  35. 4 11
      cli/cmd/run.go
  36. 18 13
      cli/cmd/server.go
  37. 1 1
      cli/cmd/version.go
  38. 0 196
      cmd/docker-credential-porter/helper/cache.go
  39. 24 231
      cmd/docker-credential-porter/helper/helper.go
  40. 4 4
      cmd/docker-credential-porter/main.go
  41. 1 0
      dashboard/package.json
  42. 2 1
      dashboard/src/components/values-form/FormWrapper.tsx
  43. 95 66
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  44. 38 106
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  45. 18 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  46. 7 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  47. 6 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  48. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  49. 134 0
      dashboard/src/shared/hooks/useWebsockets.ts
  50. 225 0
      docs/deploy/applications/deploying-from-the-cli.md
  51. 5 1
      docs/guides/preserving-client-ip-addresses.md
  52. 23 48
      go.mod
  53. 209 118
      go.sum
  54. 6 0
      internal/forms/registry.go
  55. 7 0
      internal/forms/release.go
  56. 1 1
      internal/helm/agent_test.go
  57. 2 1
      internal/helm/config.go
  58. 4 0
      internal/models/gitrepo.go
  59. 10 5
      internal/models/release.go
  60. 20 2
      internal/registry/registry.go
  61. 15 0
      internal/repository/gorm/release.go
  62. 58 0
      internal/repository/gorm/release_test.go
  63. 1 0
      internal/repository/release.go
  64. 5 0
      internal/templater/dynamic/reader.go
  65. 13 2
      server/api/deploy_handler.go
  66. 21 0
      server/api/git_action_handler.go
  67. 55 0
      server/api/git_repo_handler.go
  68. 9 3
      server/api/helpers_test.go
  69. 5 1
      server/api/project_handler_test.go
  70. 56 0
      server/api/registry_handler.go
  71. 164 4
      server/api/release_handler.go
  72. 1 1
      server/middleware/auth.go
  73. 42 0
      server/router/router.go

+ 224 - 0
cli/cmd/api/deploy.go

@@ -0,0 +1,224 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GetReleaseLatestRevision gets the latest revision of a Helm release
+type GetReleaseWebhookResponse models.ReleaseExternal
+
+func (c *Client) GetReleaseWebhook(
+	ctx context.Context,
+	projID, clusterID uint,
+	name, namespace string,
+) (*GetReleaseWebhookResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/releases/%s/webhook_token?"+url.Values{
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID, name),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetReleaseWebhookResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// DeployWithWebhook deploys an application with an image tag using a unique webhook URI
+func (c *Client) DeployWithWebhook(
+	ctx context.Context,
+	webhook, tag string,
+) error {
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/webhooks/deploy/%s?commit=%s", c.BaseURL, webhook, tag),
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+type UpdateBatchImageRequest struct {
+	ImageRepoURI string `json:"image_repo_uri"`
+	Tag          string `json:"tag"`
+}
+
+// UpdateBatchImage updates all releases that use a certain image with a new tag
+func (c *Client) UpdateBatchImage(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace string,
+	updateImageReq *UpdateBatchImageRequest,
+) error {
+	data, err := json.Marshal(updateImageReq)
+
+	if err != nil {
+		return nil
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/image/update/batch?"+url.Values{
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+type DeployTemplateGitAction struct {
+	GitRepo        string            `json:"git_repo"`
+	GitBranch      string            `json:"git_branch"`
+	ImageRepoURI   string            `json:"image_repo_uri"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
+}
+
+type DeployTemplateRequest struct {
+	TemplateName string                   `json:"templateName"`
+	ImageURL     string                   `json:"imageURL"`
+	FormValues   map[string]interface{}   `json:"formValues"`
+	Namespace    string                   `json:"namespace"`
+	Name         string                   `json:"name"`
+	GitAction    *DeployTemplateGitAction `json:"github_action"`
+}
+
+func (c *Client) DeployTemplate(
+	ctx context.Context,
+	projID, clusterID uint,
+	templateName string,
+	templateVersion string,
+	deployReq *DeployTemplateRequest,
+) error {
+	data, err := json.Marshal(deployReq)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/deploy/%s/%s?"+url.Values{
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID, templateName, templateVersion),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+type UpgradeReleaseRequest struct {
+	Values    string `json:"values"`
+	Namespace string `json:"namespace"`
+}
+
+func (c *Client) UpgradeRelease(
+	ctx context.Context,
+	projID, clusterID uint,
+	name string,
+	upgradeReq *UpgradeReleaseRequest,
+) error {
+	data, err := json.Marshal(upgradeReq)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/%s/upgrade?"+url.Values{
+			"namespace":  []string{upgradeReq.Namespace},
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID, name),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 60 - 0
cli/cmd/api/domain.go

@@ -0,0 +1,60 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateDNSRecordRequest struct {
+	ReleaseName string `json:"release_name"`
+}
+
+// CreateDNSRecordResponse is the DNS record that was created
+type CreateDNSRecordResponse models.DNSRecordExternal
+
+// CreateGithubAction creates a Github action with basic authentication
+func (c *Client) CreateDNSRecord(
+	ctx context.Context,
+	projectID, clusterID uint,
+	createDNS *CreateDNSRecordRequest,
+) (*CreateDNSRecordResponse, error) {
+	data, err := json.Marshal(createDNS)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf(
+			"%s/projects/%d/k8s/subdomain?cluster_id=%d",
+			c.BaseURL,
+			projectID,
+			clusterID,
+		),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	res := &CreateDNSRecordResponse{}
+
+	if httpErr, err := c.sendRequest(req, res, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return res, nil
+}

+ 73 - 0
cli/cmd/api/git_repo.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/server/api"
 )
 
 // ListGitRepoResponse is the list of Git repo integrations for a project
@@ -39,3 +40,75 @@ func (c *Client) ListGitRepos(
 
 	return *bodyResp, nil
 }
+
+type GetRepoTarballDownloadURLResp api.HandleGetRepoZIPDownloadURLResp
+
+// ListGitRepos returns a list of Git repos for a project
+func (c *Client) GetRepoZIPDownloadURL(
+	ctx context.Context,
+	projectID uint,
+	gitActionConfig *models.GitActionConfigExternal,
+) (*GetRepoTarballDownloadURLResp, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf(
+			"%s/projects/%d/gitrepos/%d/repos/%s/%s/%s/tarball_url",
+			c.BaseURL,
+			projectID,
+			gitActionConfig.GitRepoID,
+			"github",
+			gitActionConfig.GitRepo,
+			gitActionConfig.GitBranch,
+		),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetRepoTarballDownloadURLResp{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListGitRepoResponse is the list of Git repo integrations for a project
+type ListGithubReposResponse []*api.Repo
+
+// ListGitRepos returns a list of Git repos for a project
+func (c *Client) ListGithubRepos(
+	ctx context.Context,
+	projectID, gitRepoID uint,
+) (ListGithubReposResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/gitrepos/%d/repos", c.BaseURL, projectID, gitRepoID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListGithubReposResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}

+ 9 - 4
cli/cmd/api/github_action.go

@@ -11,10 +11,15 @@ import (
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 type CreateGithubActionRequest struct {
-	GitRepo        string `json:"git_repo"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
+	ReleaseID      uint              `json:"release_id" form:"required"`
+	GitRepo        string            `json:"git_repo" form:"required"`
+	GitBranch      string            `json:"git_branch"`
+	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id" form:"required"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }
 
 // CreateGithubAction creates a Github action with basic authentication

+ 40 - 0
cli/cmd/api/k8s.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/url"
 
+	"github.com/porter-dev/porter/server/api"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -87,6 +88,45 @@ func (c *Client) GetKubeconfig(
 	return bodyResp, nil
 }
 
+// GetReleaseLatestRevision gets the latest revision of a Helm release
+type GetReleaseResponse api.PorterRelease
+
+// GetK8sAllPods gets all pods for a given release
+func (c *Client) GetRelease(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, name string,
+) (*GetReleaseResponse, error) {
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/releases/%s/0?"+url.Values{
+			"cluster_id": []string{cl},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projectID, name),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetReleaseResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // GetReleaseAllPodsResponse is the list of all pods for a given Helm release
 type GetReleaseAllPodsResponse []v1.Pod
 

+ 39 - 0
cli/cmd/api/registry.go

@@ -472,3 +472,42 @@ func (c *Client) ListImages(
 
 	return *bodyResp, nil
 }
+
+type CreateRepositoryRequest struct {
+	ImageRepoURI string `json:"image_repo_uri"`
+}
+
+// CreateECR creates an Elastic Container Registry integration
+func (c *Client) CreateRepository(
+	ctx context.Context,
+	projectID, regID uint,
+	createRepo *CreateRepositoryRequest,
+) error {
+	data, err := json.Marshal(createRepo)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries/%d/repository", c.BaseURL, projectID, regID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 66 - 0
cli/cmd/api/template.go

@@ -0,0 +1,66 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func (c *Client) ListTemplates(
+	ctx context.Context,
+) ([]*models.PorterChartList, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/templates", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := make([]*models.PorterChartList, 0)
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+func (c *Client) GetTemplate(
+	ctx context.Context,
+	name, version string,
+) (*models.PorterChartRead, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/templates/%s/%s", c.BaseURL, name, version),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &models.PorterChartRead{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 13 - 28
cli/cmd/auth.go

@@ -66,20 +66,6 @@ func init() {
 	authCmd.AddCommand(registerCmd)
 	authCmd.AddCommand(logoutCmd)
 
-	authCmd.PersistentFlags().StringVar(
-		&host,
-		"host",
-		getHost(),
-		"host url of Porter instance",
-	)
-
-	loginCmd.PersistentFlags().StringVar(
-		&token,
-		"token",
-		"",
-		"bearer token for authentication",
-	)
-
 	loginCmd.PersistentFlags().BoolVar(
 		&manual,
 		"manual",
@@ -89,12 +75,11 @@ func init() {
 }
 
 func login() error {
-	client := api.NewClientWithToken(getHost()+"/api", getToken())
+	client := api.NewClientWithToken(config.Host+"/api", config.Token)
 
 	user, _ := client.AuthCheck(context.Background())
 
 	if user != nil {
-		color.Yellow(getToken())
 		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
 		return nil
 	}
@@ -108,20 +93,20 @@ func login() error {
 	var err error
 
 	if token == "" {
-		token, err = loginBrowser.Login(getHost())
+		token, err = loginBrowser.Login(config.Host)
 
 		if err != nil {
 			return err
 		}
 
 		// set the token in config
-		err = setToken(token)
+		err = config.SetToken(token)
 
 		if err != nil {
 			return err
 		}
 
-		client := api.NewClientWithToken(getHost()+"/api", token)
+		client := api.NewClientWithToken(config.Host+"/api", token)
 
 		user, err := client.AuthCheck(context.Background())
 
@@ -140,17 +125,17 @@ func login() error {
 		}
 
 		if len(projects) > 0 {
-			setProject(projects[0].ID)
+			config.SetProject(projects[0].ID)
 		}
 	} else {
 		// set the token in config
-		err = setToken(token)
+		err = config.SetToken(token)
 
 		if err != nil {
 			return err
 		}
 
-		client := api.NewClientWithToken(getHost()+"/api", token)
+		client := api.NewClientWithToken(config.Host+"/api", token)
 
 		user, err := client.AuthCheck(context.Background())
 
@@ -167,14 +152,14 @@ func login() error {
 			return err
 		}
 
-		setProject(projID)
+		config.SetProject(projID)
 	}
 
 	return nil
 }
 
 func loginManual() error {
-	client := api.NewClient(getHost()+"/api", "cookie.json")
+	client := api.NewClient(config.Host+"/api", "cookie.json")
 
 	var username, pw string
 
@@ -202,7 +187,7 @@ func loginManual() error {
 	}
 
 	// set the token to empty since this is manual (cookie-based) login
-	setToken("")
+	config.SetToken("")
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
@@ -214,7 +199,7 @@ func loginManual() error {
 	}
 
 	if len(projects) > 0 {
-		setProject(projects[0].ID)
+		config.SetProject(projects[0].ID)
 	}
 
 	return nil
@@ -235,7 +220,7 @@ func register() error {
 		return err
 	}
 
-	client := GetAPIClient()
+	client := GetAPIClient(config)
 
 	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
 		Email:    username,
@@ -258,7 +243,7 @@ func logout(user *api.AuthCheckResponse, client *api.Client, args []string) erro
 		return err
 	}
 
-	setToken("")
+	config.SetToken("")
 
 	color.Green("Successfully logged out")
 

+ 5 - 12
cli/cmd/cluster.go

@@ -68,13 +68,6 @@ var clusterNamespaceListCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(clusterCmd)
 
-	clusterCmd.PersistentFlags().UintVar(
-		&clusterID,
-		"cluster-id",
-		getClusterID(),
-		"id of the cluster",
-	)
-
 	clusterCmd.AddCommand(clusterNamespaceCmd)
 	clusterCmd.AddCommand(clusterListCmd)
 	clusterCmd.AddCommand(clusterDeleteCmd)
@@ -83,7 +76,7 @@ func init() {
 }
 
 func listClusters(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	clusters, err := client.ListProjectClusters(context.Background(), getProjectID())
+	clusters, err := client.ListProjectClusters(context.Background(), config.Project)
 
 	if err != nil {
 		return err
@@ -94,7 +87,7 @@ func listClusters(user *api.AuthCheckResponse, client *api.Client, args []string
 
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVER")
 
-	currClusterID := getClusterID()
+	currClusterID := config.Cluster
 
 	for _, cluster := range clusters {
 		if currClusterID == cluster.ID {
@@ -129,7 +122,7 @@ func deleteCluster(user *api.AuthCheckResponse, client *api.Client, args []strin
 			return err
 		}
 
-		err = client.DeleteProjectCluster(context.Background(), getProjectID(), uint(id))
+		err = client.DeleteProjectCluster(context.Background(), config.Project, uint(id))
 
 		if err != nil {
 			return err
@@ -142,10 +135,10 @@ func deleteCluster(user *api.AuthCheckResponse, client *api.Client, args []strin
 }
 
 func listNamespaces(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
+	pID := config.Project
 
 	// get the service account based on the cluster id
-	cID := getClusterID()
+	cID := config.Cluster
 
 	// get the list of namespaces
 	namespaces, err := client.GetK8sNamespaces(

+ 263 - 124
cli/cmd/config.go

@@ -10,18 +10,257 @@ import (
 	"github.com/fatih/color"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
-)
 
-// a set of shared flags
-var (
-	driver     string
-	host       string
-	projectID  uint
-	registryID uint
-	clusterID  uint
-	helmRepoID uint
+	flag "github.com/spf13/pflag"
 )
 
+// shared sets of flags used by multiple commands
+var driverFlagSet = flag.NewFlagSet("driver", flag.ExitOnError)
+var defaultFlagSet = flag.NewFlagSet("shared", flag.ExitOnError) // used by all commands
+var registryFlagSet = flag.NewFlagSet("registry", flag.ExitOnError)
+var helmRepoFlagSet = flag.NewFlagSet("helmrepo", flag.ExitOnError)
+
+// config is a shared object used by all commands
+var config = &CLIConfig{}
+
+// CLIConfig is the set of shared configuration options for the CLI commands.
+// This config is used by viper: calling Set() function for any parameter will
+// update the corresponding field in the viper config file.
+type CLIConfig struct {
+	// Driver can be either "docker" or "local", and represents which driver is
+	// used to run an instance of the server.
+	Driver string `yaml:"driver"`
+
+	Host    string `yaml:"host"`
+	Project uint   `yaml:"project"`
+	Cluster uint   `yaml:"cluster"`
+
+	Token string `yaml:"token"`
+
+	Registry uint `yaml:"registry"`
+	HelmRepo uint `yaml:"helm_repo"`
+}
+
+// InitAndLoadConfig populates the config object with the following precedence rules:
+// 1. flag
+// 2. env
+// 3. config
+// 4. default
+//
+// It populates the shared config object above
+func InitAndLoadConfig() {
+	initAndLoadConfig(config)
+}
+
+func InitAndLoadNewConfig() *CLIConfig {
+	newConfig := &CLIConfig{}
+
+	initAndLoadConfig(newConfig)
+
+	return newConfig
+}
+
+func initAndLoadConfig(_config *CLIConfig) {
+	initFlagSet()
+
+	// check that the .porter folder exists; create if not
+	porterDir := filepath.Join(home, ".porter")
+
+	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
+		os.Mkdir(porterDir, 0700)
+	} else if err != nil {
+		color.New(color.FgRed).Printf("%v\n", err)
+		os.Exit(1)
+	}
+
+	viper.SetConfigName("porter")
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(porterDir)
+
+	// Bind the flagset initialized above
+	viper.BindPFlags(driverFlagSet)
+	viper.BindPFlags(defaultFlagSet)
+	viper.BindPFlags(registryFlagSet)
+	viper.BindPFlags(helmRepoFlagSet)
+
+	// Bind the environment variables with prefix "PORTER_"
+	viper.SetEnvPrefix("PORTER")
+	viper.BindEnv("host")
+	viper.BindEnv("project")
+	viper.BindEnv("cluster")
+	viper.BindEnv("token")
+
+	err := viper.ReadInConfig()
+
+	if err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+			// create blank config file
+			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("%v\n", err)
+				os.Exit(1)
+			}
+		} else {
+			// Config file was found but another error was produced
+			color.New(color.FgRed).Printf("%v\n", err)
+			os.Exit(1)
+		}
+	}
+
+	// unmarshal the config into the shared config struct
+	viper.Unmarshal(_config)
+}
+
+// initFlagSet initializes the shared flags used by multiple commands
+func initFlagSet() {
+	driverFlagSet.StringVar(
+		&config.Driver,
+		"driver",
+		"local",
+		"driver to use (local or docker)",
+	)
+
+	defaultFlagSet.StringVar(
+		&config.Host,
+		"host",
+		"https://dashboard.getporter.dev",
+		"host URL of Porter instance",
+	)
+
+	defaultFlagSet.UintVar(
+		&config.Project,
+		"project",
+		0,
+		"project ID of Porter project",
+	)
+
+	defaultFlagSet.UintVar(
+		&config.Cluster,
+		"cluster",
+		0,
+		"cluster ID of Porter cluster",
+	)
+
+	defaultFlagSet.StringVar(
+		&config.Token,
+		"token",
+		"",
+		"token for Porter authentication",
+	)
+
+	registryFlagSet.UintVar(
+		&config.Registry,
+		"registry",
+		0,
+		"registry ID of connected Porter registry",
+	)
+
+	helmRepoFlagSet.UintVar(
+		&config.HelmRepo,
+		"helmrepo",
+		0,
+		"helm repo ID of connected Porter Helm repository",
+	)
+}
+
+func (c *CLIConfig) SetDriver(driver string) error {
+	viper.Set("driver", driver)
+	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Driver = driver
+
+	return nil
+}
+
+func (c *CLIConfig) SetHost(host string) error {
+	viper.Set("host", host)
+	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Host = host
+
+	return nil
+}
+
+func (c *CLIConfig) SetProject(projectID uint) error {
+	viper.Set("project", projectID)
+	color.New(color.FgGreen).Printf("Set the current project as %d\n", projectID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Project = projectID
+
+	return nil
+}
+
+func (c *CLIConfig) SetCluster(clusterID uint) error {
+	viper.Set("cluster", clusterID)
+	color.New(color.FgGreen).Printf("Set the current cluster as %d\n", clusterID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Cluster = clusterID
+
+	return nil
+}
+
+func (c *CLIConfig) SetToken(token string) error {
+	viper.Set("token", token)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Token = token
+
+	return nil
+}
+
+func (c *CLIConfig) SetRegistry(registryID uint) error {
+	viper.Set("registry", registryID)
+	color.New(color.FgGreen).Printf("Set the current registry as %d\n", registryID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Registry = registryID
+
+	return nil
+}
+
+func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
+	viper.Set("helm_repo", helmRepoID)
+	color.New(color.FgGreen).Printf("Set the current Helm repo as %d\n", helmRepoID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.HelmRepo = helmRepoID
+
+	return nil
+}
+
 var configCmd = &cobra.Command{
 	Use:   "config",
 	Short: "Commands that control local configuration settings",
@@ -33,7 +272,7 @@ var configCmd = &cobra.Command{
 	},
 }
 
-var setProjectCmd = &cobra.Command{
+var configSetProjectCmd = &cobra.Command{
 	Use:   "set-project [id]",
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the project id in the default configuration",
@@ -45,7 +284,7 @@ var setProjectCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		err = setProject(uint(projID))
+		err = config.SetProject(uint(projID))
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -54,7 +293,7 @@ var setProjectCmd = &cobra.Command{
 	},
 }
 
-var setClusterCmd = &cobra.Command{
+var configSetClusterCmd = &cobra.Command{
 	Use:   "set-cluster [id]",
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the cluster id in the default configuration",
@@ -66,7 +305,7 @@ var setClusterCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		err = setCluster(uint(clusterID))
+		err = config.SetCluster(uint(clusterID))
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -75,7 +314,7 @@ var setClusterCmd = &cobra.Command{
 	},
 }
 
-var setRegistryCmd = &cobra.Command{
+var configSetRegistryCmd = &cobra.Command{
 	Use:   "set-registry [id]",
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the registry id in the default configuration",
@@ -87,7 +326,7 @@ var setRegistryCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		err = setRegistry(uint(registryID))
+		err = config.SetRegistry(uint(registryID))
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -96,7 +335,7 @@ var setRegistryCmd = &cobra.Command{
 	},
 }
 
-var setHelmRepoCmd = &cobra.Command{
+var configSetHelmRepoCmd = &cobra.Command{
 	Use:   "set-helmrepo [id]",
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the helm repo id in the default configuration",
@@ -108,7 +347,7 @@ var setHelmRepoCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		err = setHelmRepo(uint(hrID))
+		err = config.SetHelmRepo(uint(hrID))
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -117,12 +356,12 @@ var setHelmRepoCmd = &cobra.Command{
 	},
 }
 
-var setHostCmd = &cobra.Command{
+var configSetHostCmd = &cobra.Command{
 	Use:   "set-host [host]",
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the host in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := setHost(args[0])
+		err := config.SetHost(args[0])
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -134,30 +373,11 @@ var setHostCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(configCmd)
 
-	configCmd.AddCommand(setProjectCmd)
-	configCmd.AddCommand(setClusterCmd)
-	configCmd.AddCommand(setHostCmd)
-	configCmd.AddCommand(setRegistryCmd)
-	configCmd.AddCommand(setHelmRepoCmd)
-}
-
-func setDriver(driver string) error {
-	viper.Set("driver", driver)
-	err := viper.WriteConfig()
-	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
-	return err
-}
-
-func getDriver() string {
-	if driver != "" {
-		return driver
-	}
-
-	if opts.driver != "" {
-		return opts.driver
-	}
-
-	return viper.GetString("driver")
+	configCmd.AddCommand(configSetProjectCmd)
+	configCmd.AddCommand(configSetClusterCmd)
+	configCmd.AddCommand(configSetHostCmd)
+	configCmd.AddCommand(configSetRegistryCmd)
+	configCmd.AddCommand(configSetHelmRepoCmd)
 }
 
 func printConfig() error {
@@ -171,84 +391,3 @@ func printConfig() error {
 
 	return nil
 }
-
-func setProject(id uint) error {
-	viper.Set("project", id)
-	color.New(color.FgGreen).Printf("Set the current project id as %d\n", id)
-	return viper.WriteConfig()
-}
-
-func setCluster(id uint) error {
-	viper.Set("cluster", id)
-	color.New(color.FgGreen).Printf("Set the current cluster id as %d\n", id)
-	return viper.WriteConfig()
-}
-
-func setRegistry(id uint) error {
-	viper.Set("registry", id)
-	color.New(color.FgGreen).Printf("Set the current registry id as %d\n", id)
-	return viper.WriteConfig()
-}
-
-func setHelmRepo(id uint) error {
-	viper.Set("helm_repo", id)
-	color.New(color.FgGreen).Printf("Set the current helm repo id as %d\n", id)
-	return viper.WriteConfig()
-}
-
-func setHost(host string) error {
-	viper.Set("host", host)
-	err := viper.WriteConfig()
-	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
-	return err
-}
-
-func setToken(token string) error {
-	viper.Set("token", token)
-	err := viper.WriteConfig()
-	return err
-}
-
-func getHost() string {
-	if host != "" {
-		return host
-	}
-
-	return viper.GetString("host")
-}
-
-func getToken() string {
-	return viper.GetString("token")
-}
-
-func getClusterID() uint {
-	if clusterID != 0 {
-		return clusterID
-	}
-
-	return viper.GetUint("cluster")
-}
-
-func getRegistryID() uint {
-	if registryID != 0 {
-		return registryID
-	}
-
-	return viper.GetUint("registry")
-}
-
-func getHelmRepoID() uint {
-	if helmRepoID != 0 {
-		return helmRepoID
-	}
-
-	return viper.GetUint("helm_repo")
-}
-
-func getProjectID() uint {
-	if projectID != 0 {
-		return projectID
-	}
-
-	return viper.GetUint("project")
-}

+ 16 - 30
cli/cmd/connect.go

@@ -121,20 +121,6 @@ func init() {
 
 	connectCmd.AddCommand(connectKubeconfigCmd)
 
-	connectCmd.PersistentFlags().StringVar(
-		&host,
-		"host",
-		getHost(),
-		"host url of Porter instance",
-	)
-
-	projectID = *connectCmd.PersistentFlags().UintP(
-		"project-id",
-		"p",
-		getProjectID(),
-		"project id to use",
-	)
-
 	connectKubeconfigCmd.PersistentFlags().StringVarP(
 		&kubeconfigPath,
 		"kubeconfig",
@@ -161,7 +147,7 @@ func init() {
 func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	isLocal := false
 
-	if getDriver() == "local" {
+	if config.Driver == "local" {
 		isLocal = true
 	}
 
@@ -169,7 +155,7 @@ func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []stri
 		client,
 		kubeconfigPath,
 		*contexts,
-		getProjectID(),
+		config.Project,
 		isLocal,
 	)
 
@@ -177,90 +163,90 @@ func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []stri
 		return err
 	}
 
-	return setCluster(id)
+	return config.SetCluster(id)
 }
 
 func runConnectECR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	regID, err := connect.ECR(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setRegistry(regID)
+	return config.SetRegistry(regID)
 }
 
 func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	regID, err := connect.GCR(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setRegistry(regID)
+	return config.SetRegistry(regID)
 }
 
 func runConnectDOCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	regID, err := connect.DOCR(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setRegistry(regID)
+	return config.SetRegistry(regID)
 }
 
 func runConnectDockerhub(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Dockerhub(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setRegistry(regID)
+	return config.SetRegistry(regID)
 }
 
 func runConnectRegistry(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Registry(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setRegistry(regID)
+	return config.SetRegistry(regID)
 }
 
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.Helm(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return setHelmRepo(hrID)
+	return config.SetHelmRepo(hrID)
 }
 
 func runConnectActions(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	return connect.Actions(
 		client,
-		getProjectID(),
+		config.Project,
 	)
 }

+ 287 - 0
cli/cmd/create.go

@@ -0,0 +1,287 @@
+package cmd
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/gitutils"
+	"github.com/spf13/cobra"
+	"sigs.k8s.io/yaml"
+)
+
+// createCmd represents the "porter create" base command when called
+// without any subcommands
+var createCmd = &cobra.Command{
+	Use:   "create [kind]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Creates a new application with name given by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Creates a new application with name given by the --app flag and a "kind", which can be one of 
+web, worker, or job. For example:
+
+  %s
+
+To modify the default configuration of the application, you can pass a values.yaml file in via the 
+--values flag. 
+
+  %s
+
+To read more about the configuration options, go here: 
+
+https://docs.getporter.dev/docs/deploying-from-the-cli#common-configuration-options
+
+This command will automatically build from a local path, and will create a new Docker image in your 
+default Docker registry. The path can be configured via the --path flag. For example:
+  
+  %s
+
+To connect the application to Github, so that the application rebuilds and redeploys on each push 
+to a Github branch, you can specify "--source github". If your local branch is set to track changes 
+from an upstream remote branch, Porter will try to use the connected remote and remote branch as the 
+Github repository to link to. Otherwise, Porter will use the remote given by origin. For example:
+
+  %s
+
+To deploy an application from a Docker registry, use "--source registry" and pass the image in via the
+--image flag. The image flag must be of the form repository:tag. For example:
+
+  %s 
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter create\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --values values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --path ./path/to/app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --source github"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --source registry --image gcr.io/snowflake-12345/example-app:latest"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, createFull)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var name string
+var values string
+var source string
+var image string
+
+func init() {
+	rootCmd.AddCommand(createCmd)
+
+	createCmd.PersistentFlags().StringVar(
+		&name,
+		"app",
+		"",
+		"name of the new application/job/worker.",
+	)
+
+	createCmd.MarkPersistentFlagRequired("app")
+
+	createCmd.PersistentFlags().StringVarP(
+		&localPath,
+		"path",
+		"p",
+		".",
+		"if local build, the path to the build directory",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of the application",
+	)
+
+	createCmd.PersistentFlags().StringVarP(
+		&values,
+		"values",
+		"v",
+		"",
+		"filepath to a values.yaml file",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&dockerfile,
+		"dockerfile",
+		"",
+		"the path to the dockerfile",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&method,
+		"method",
+		"",
+		"the build method to use (\"docker\" or \"pack\")",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&source,
+		"source",
+		"local",
+		"the type of source (\"local\", \"github\", or \"registry\")",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&image,
+		"image",
+		"",
+		"if the source is \"registry\", the image to use, in repository:tag format",
+	)
+}
+
+var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
+
+func createFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	// check the kind
+	if _, exists := supportedKinds[args[0]]; !exists {
+		return fmt.Errorf("%s is not a supported type: specify web, job, or worker", args[0])
+	}
+
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Creating %s release: %s\n", args[0], name)
+
+	fullPath, err := filepath.Abs(localPath)
+
+	if err != nil {
+		return err
+	}
+
+	var buildMethod deploy.DeployBuildType
+
+	if method != "" {
+		buildMethod = deploy.DeployBuildType(method)
+	} else if dockerfile != "" {
+		buildMethod = deploy.DeployBuildTypeDocker
+	}
+
+	createAgent := &deploy.CreateAgent{
+		Client: client,
+		CreateOpts: &deploy.CreateOpts{
+			SharedOpts: &deploy.SharedOpts{
+				ProjectID:       config.Project,
+				ClusterID:       config.Cluster,
+				Namespace:       namespace,
+				LocalPath:       fullPath,
+				LocalDockerfile: dockerfile,
+				Method:          buildMethod,
+			},
+			Kind:        args[0],
+			ReleaseName: name,
+		},
+	}
+
+	if source == "local" {
+		subdomain, err := createAgent.CreateFromDocker(valuesObj)
+
+		return handleSubdomainCreate(subdomain, err)
+	} else if source == "github" {
+		return createFromGithub(createAgent, valuesObj)
+	}
+
+	subdomain, err := createAgent.CreateFromRegistry(image, valuesObj)
+
+	return handleSubdomainCreate(subdomain, err)
+}
+
+func handleSubdomainCreate(subdomain string, err error) error {
+	if err != nil {
+		return err
+	}
+
+	if subdomain != "" {
+		color.New(color.FgGreen).Printf("Your web application is ready at: %s\n", subdomain)
+	} else {
+		color.New(color.FgGreen).Printf("Application created successfully\n")
+	}
+
+	return nil
+}
+
+func createFromGithub(createAgent *deploy.CreateAgent, overrideValues map[string]interface{}) error {
+	fullPath, err := filepath.Abs(localPath)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = gitutils.GitDirectory(fullPath)
+
+	if err != nil {
+		return err
+	}
+
+	remote, gitBranch, err := gitutils.GetRemoteBranch(fullPath)
+
+	if err != nil {
+		return err
+	} else if gitBranch == "" {
+		return fmt.Errorf("git branch not automatically detectable")
+	}
+
+	ok, remoteRepo := gitutils.ParseGithubRemote(remote)
+
+	if !ok {
+		return fmt.Errorf("remote is not a Github repository")
+	}
+
+	subdomain, err := createAgent.CreateFromGithub(&deploy.GithubOpts{
+		Branch: gitBranch,
+		Repo:   remoteRepo,
+	}, overrideValues)
+
+	return handleSubdomainCreate(subdomain, err)
+}
+
+func readValuesFile() (map[string]interface{}, error) {
+	res := make(map[string]interface{})
+
+	if values == "" {
+		return res, nil
+	}
+
+	valuesFilePath, err := filepath.Abs(values)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if info, err := os.Stat(valuesFilePath); os.IsNotExist(err) || info.IsDir() {
+		return nil, fmt.Errorf("values file does not exist or is a directory")
+	}
+
+	reader, err := os.Open(valuesFilePath)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bytes, err := ioutil.ReadAll(reader)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = yaml.Unmarshal(bytes, &res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}

+ 438 - 0
cli/cmd/deploy.go

@@ -0,0 +1,438 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/spf13/cobra"
+)
+
+// updateCmd represents the "porter update" base command when called
+// without any subcommands
+var updateCmd = &cobra.Command{
+	Use:   "update",
+	Short: "Builds and updates a specified application given by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Builds and updates a specified application given by the --app flag. For example:
+
+  %s
+
+This command will automatically build from a local path. The path can be configured via the 
+--path flag. You can also overwrite the tag using the --tag flag. For example, to build from the 
+local directory ~/path-to-dir with the tag "testing":
+
+  %s
+
+If the application has a remote Git repository source configured, you can specify that the remote
+Git repository should be used to build the new image by specifying "--source github". Porter will use 
+the latest commit from the remote repo and branch to update an application, and will use the latest 
+commit as the image tag.
+
+  %s
+
+To add new configuration or update existing configuration, you can pass a values.yaml file in via the 
+--values flag. For example;
+
+  %s
+
+If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag 
+"--method pack". Conversely, if your application is set up to use a buildpack by default, you can 
+use a Dockerfile by passing the flag "--method docker". You can specify the relative path to a Dockerfile 
+in your remote Git repository. For example, if a Dockerfile is found at ./docker/prod.Dockerfile, you can 
+specify it as follows:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --path ~/path-to-dir --tag testing"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app remote-git-app --source github"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --values my-values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --method docker --dockerfile ./docker/prod.Dockerfile"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, updateFull)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var updateGetEnvCmd = &cobra.Command{
+	Use:   "get-env",
+	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Gets environment variables for a deployment for a specified application given by the --app 
+flag. By default, env variables are printed via stdout for use in downstream commands:
+
+  %s
+
+Output can also be written to a file via the --file flag, which should specify the 
+destination path for a .env file. For example:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update get-env\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app | xargs"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app --file .env"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, updateGetEnv)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var updateBuildCmd = &cobra.Command{
+	Use:   "build",
+	Short: "Builds a new version of the application specified by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Builds a new version of the application specified by the --app flag. Depending on the 
+configured settings, this command may work automatically or will require a specified 
+--method flag. 
+
+If you have configured the Dockerfile path and/or a build context for this application, 
+this command will by default use those settings, so you just need to specify the --app 
+flag:
+
+  %s
+
+If you have not linked the build-time requirements for this application, the command will
+use a local build. By default, the cloud-native buildpacks builder will automatically be run 
+from the current directory. If you would like to change the build method, you can do so by 
+using the --method flag, for example:
+
+  %s
+
+When using "--method docker", you can specify the path to the Dockerfile using the 
+--dockerfile flag. This will also override the Dockerfile path that you may have linked
+for the application:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update build\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app --method docker"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app --method docker --dockerfile ./prod.Dockerfile"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, updateBuild)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var updatePushCmd = &cobra.Command{
+	Use:   "push",
+	Short: "Pushes a new image for an application specified by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Pushes a new image for an application specified by the --app flag. This command uses
+the image repository saved in the application config by default. For example, if an 
+application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx", 
+the following command would push the image "gcr.io/snowflake-123456/nginx:new-tag":
+
+  %s
+
+This command will not use your pre-saved authentication set up via "docker login," so if you
+are using an image registry that was created outside of Porter, make sure that you have 
+linked it via "porter connect".
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, updatePush)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var updateConfigCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Updates the configuration for an application specified by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Updates the configuration for an application specified by the --app flag, using the configuration
+given by the --values flag. This will trigger a new deployment for the application with 
+new configuration set. Note that this will merge your existing configuration with configuration
+specified in the --values file. For example:
+
+  %s
+
+You can update the configuration with only a new tag with the --tag flag, which will only update
+the image that the application uses if no --values file is specified:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update config\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --values my-values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --tag custom-tag"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, updateUpgrade)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var app string
+var getEnvFileDest string
+var localPath string
+var tag string
+var dockerfile string
+var method string
+
+func init() {
+	rootCmd.AddCommand(updateCmd)
+
+	updateCmd.PersistentFlags().StringVar(
+		&app,
+		"app",
+		"",
+		"Application in the Porter dashboard",
+	)
+
+	updateCmd.MarkPersistentFlagRequired("app")
+
+	updateCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"Namespace of the application",
+	)
+
+	updateCmd.PersistentFlags().StringVar(
+		&source,
+		"source",
+		"local",
+		"the type of source (\"local\" or \"github\")",
+	)
+
+	updateCmd.PersistentFlags().StringVarP(
+		&localPath,
+		"path",
+		"p",
+		".",
+		"If local build, the path to the build directory",
+	)
+
+	updateCmd.PersistentFlags().StringVarP(
+		&tag,
+		"tag",
+		"t",
+		"",
+		"the specified tag to use, if not \"latest\"",
+	)
+
+	updateCmd.PersistentFlags().StringVarP(
+		&values,
+		"values",
+		"v",
+		"",
+		"Filepath to a values.yaml file",
+	)
+
+	updateCmd.PersistentFlags().StringVar(
+		&dockerfile,
+		"dockerfile",
+		"",
+		"the path to the dockerfile",
+	)
+
+	updateCmd.PersistentFlags().StringVar(
+		&method,
+		"method",
+		"",
+		"the build method to use (\"docker\" or \"pack\")",
+	)
+
+	updateCmd.AddCommand(updateGetEnvCmd)
+
+	updateGetEnvCmd.PersistentFlags().StringVar(
+		&getEnvFileDest,
+		"file",
+		"",
+		"file destination for .env files",
+	)
+
+	updateCmd.AddCommand(updateBuildCmd)
+	updateCmd.AddCommand(updatePushCmd)
+	updateCmd.AddCommand(updateConfigCmd)
+}
+
+func updateFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Println("Deploying app:", app)
+
+	updateAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	err = updateBuildWithAgent(updateAgent)
+
+	if err != nil {
+		return err
+	}
+
+	err = updatePushWithAgent(updateAgent)
+
+	if err != nil {
+		return err
+	}
+
+	err = updateUpgradeWithAgent(updateAgent)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func updateGetEnv(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	buildEnv, err := updateAgent.GetBuildEnv()
+
+	if err != nil {
+		return err
+	}
+
+	// set the environment variables in the process
+	err = updateAgent.SetBuildEnv(buildEnv)
+
+	if err != nil {
+		return err
+	}
+
+	// write the environment variables to either a file or stdout (stdout by default)
+	return updateAgent.WriteBuildEnv(getEnvFileDest)
+}
+
+func updateBuild(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	return updateBuildWithAgent(updateAgent)
+}
+
+func updatePush(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	return updatePushWithAgent(updateAgent)
+}
+
+func updateUpgrade(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
+
+	if err != nil {
+		return err
+	}
+
+	return updateUpgradeWithAgent(updateAgent)
+}
+
+// HELPER METHODS
+func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
+	var buildMethod deploy.DeployBuildType
+
+	if method != "" {
+		buildMethod = deploy.DeployBuildType(method)
+	}
+
+	// initialize the update agent
+	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
+		SharedOpts: &deploy.SharedOpts{
+			ProjectID:       config.Project,
+			ClusterID:       config.Cluster,
+			Namespace:       namespace,
+			LocalPath:       localPath,
+			LocalDockerfile: dockerfile,
+			OverrideTag:     tag,
+			Method:          buildMethod,
+		},
+		Local: source != "github",
+	})
+}
+
+func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
+	// build the deployment
+	color.New(color.FgGreen).Println("Building docker image for", app)
+
+	buildEnv, err := updateAgent.GetBuildEnv()
+
+	if err != nil {
+		return err
+	}
+
+	// set the environment variables in the process
+	err = updateAgent.SetBuildEnv(buildEnv)
+
+	if err != nil {
+		return err
+	}
+
+	return updateAgent.Build()
+}
+
+func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
+	// push the deployment
+	color.New(color.FgGreen).Println("Pushing new image for", app)
+
+	return updateAgent.Push()
+}
+
+func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
+	// push the deployment
+	color.New(color.FgGreen).Println("Calling webhook for", app)
+
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
+
+	if err != nil {
+		return err
+	}
+
+	err = updateAgent.UpdateImageAndValues(valuesObj)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Println("Successfully updated", app)
+
+	return nil
+}

+ 74 - 0
cli/cmd/deploy/build.go

@@ -0,0 +1,74 @@
+package deploy
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/pack"
+)
+
+// BuildAgent builds a new Docker container image for a new version of an application
+type BuildAgent struct {
+	*SharedOpts
+
+	client      *api.Client
+	imageRepo   string
+	env         map[string]string
+	imageExists bool
+}
+
+// BuildDocker uses the local Docker daemon to build the image
+func (b *BuildAgent) BuildDocker(dockerAgent *docker.Agent, dst, tag string) error {
+	opts := &docker.BuildOpts{
+		ImageRepo:    b.imageRepo,
+		Tag:          tag,
+		BuildContext: dst,
+		Env:          b.env,
+	}
+
+	return dockerAgent.BuildLocal(
+		opts,
+		b.LocalDockerfile,
+	)
+}
+
+// BuildPack uses the cloud-native buildpack client to build a container image
+func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error {
+	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
+	if b.imageExists {
+		err := dockerAgent.TagImage(
+			fmt.Sprintf("%s:%s", b.imageRepo, tag),
+			fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// create pack agent and build opts
+	packAgent := &pack.Agent{}
+
+	opts := &docker.BuildOpts{
+		ImageRepo: b.imageRepo,
+		// We tag the image with a stable param "pack-cache" so that pack can use the
+		// local image without attempting to re-pull from registry. We handle getting
+		// registry credentials and pushing/pulling the image.
+		Tag:          "pack-cache",
+		BuildContext: dst,
+		Env:          b.env,
+	}
+
+	// call builder
+	err := packAgent.Build(opts)
+
+	if err != nil {
+		return err
+	}
+
+	return dockerAgent.TagImage(
+		fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
+		fmt.Sprintf("%s:%s", b.imageRepo, tag),
+	)
+}

+ 497 - 0
cli/cmd/deploy/create.go

@@ -0,0 +1,497 @@
+package deploy
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/templater/utils"
+)
+
+// CreateAgent handles the creation of a new application on Porter
+type CreateAgent struct {
+	Client     *api.Client
+	CreateOpts *CreateOpts
+}
+
+// CreateOpts are required options for creating a new application on Porter: the
+// "kind" (web, worker, job) and the name of the application.
+type CreateOpts struct {
+	*SharedOpts
+
+	Kind        string
+	ReleaseName string
+}
+
+// GithubOpts are the options for linking a Github source to the app
+type GithubOpts struct {
+	Branch string
+	Repo   string
+}
+
+// CreateFromGithub uses the branch/repo to link the Github source for an application.
+// This function attempts to find a matching repository in the list of linked repositories
+// on Porter. If one is found, it will use that repository as the app source.
+func (c *CreateAgent) CreateFromGithub(
+	ghOpts *GithubOpts,
+	overrideValues map[string]interface{},
+) (string, error) {
+	opts := c.CreateOpts
+
+	// get all linked github repos and find matching repo
+	gitRepos, err := c.Client.ListGitRepos(
+		context.Background(),
+		c.CreateOpts.ProjectID,
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	var gitRepoMatch uint
+
+	for _, gitRepo := range gitRepos {
+		// for each git repo, search for a matching username/owner
+		githubRepos, err := c.Client.ListGithubRepos(
+			context.Background(),
+			c.CreateOpts.ProjectID,
+			gitRepo.ID,
+		)
+
+		if err != nil {
+			return "", err
+		}
+
+		for _, githubRepo := range githubRepos {
+			if githubRepo.FullName == ghOpts.Repo {
+				gitRepoMatch = gitRepo.ID
+				break
+			}
+		}
+
+		if gitRepoMatch != 0 {
+			break
+		}
+	}
+
+	if gitRepoMatch == 0 {
+		return "", fmt.Errorf("could not find a linked github repo for %s. Make sure you have linked your Github account on the Porter dashboard.", ghOpts.Repo)
+	}
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	if opts.Kind == "web" || opts.Kind == "worker" {
+		mergedValues["image"] = map[string]interface{}{
+			"repository": "public.ecr.aws/o1j4x7p4/hello-porter",
+			"tag":        "latest",
+		}
+	} else if opts.Kind == "job" {
+		mergedValues["image"] = map[string]interface{}{
+			"repository": "public.ecr.aws/o1j4x7p4/hello-porter-job",
+			"tag":        "latest",
+		}
+	}
+
+	regID, imageURL, err := c.GetImageRepoURL(opts.ReleaseName, opts.Namespace)
+
+	if err != nil {
+		return "", err
+	}
+
+	env, err := GetEnvFromConfig(mergedValues)
+
+	if err != nil {
+		env = map[string]string{}
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageURL,
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+			GitAction: &api.DeployTemplateGitAction{
+				GitRepo:        ghOpts.Repo,
+				GitBranch:      ghOpts.Branch,
+				ImageRepoURI:   imageURL,
+				DockerfilePath: opts.LocalDockerfile,
+				FolderPath:     ".",
+				GitRepoID:      gitRepoMatch,
+				BuildEnv:       env,
+				RegistryID:     regID,
+			},
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// CreateFromRegistry deploys a new application from an existing Docker repository + tag.
+func (c *CreateAgent) CreateFromRegistry(
+	image string,
+	overrideValues map[string]interface{},
+) (string, error) {
+	if image == "" {
+		return "", fmt.Errorf("image cannot be empty")
+	}
+
+	// split image into image-path:tag format
+	imageSpl := strings.Split(image, ":")
+
+	if len(imageSpl) != 2 {
+		return "", fmt.Errorf("invalid image format: must be image-path:tag format")
+	}
+
+	opts := c.CreateOpts
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	mergedValues["image"] = map[string]interface{}{
+		"repository": imageSpl[0],
+		"tag":        imageSpl[1],
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageSpl[0],
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// CreateFromDocker uses a local build context and a local Docker daemon to build a new
+// container image, and then deploys it onto Porter.
+func (c *CreateAgent) CreateFromDocker(
+	overrideValues map[string]interface{},
+) (string, error) {
+	opts := c.CreateOpts
+
+	// detect the build config
+	if opts.Method != "" {
+		if opts.Method == DeployBuildTypeDocker {
+			if opts.LocalDockerfile == "" {
+				hasDockerfile := c.HasDefaultDockerfile(opts.LocalPath)
+
+				if !hasDockerfile {
+					return "", fmt.Errorf("Dockerfile not found")
+				}
+
+				opts.LocalDockerfile = "Dockerfile"
+			}
+		}
+	} else {
+		// try to detect dockerfile, otherwise fall back to `pack`
+		hasDockerfile := c.HasDefaultDockerfile(opts.LocalPath)
+
+		if !hasDockerfile {
+			opts.Method = DeployBuildTypePack
+		} else {
+			opts.Method = DeployBuildTypeDocker
+			opts.LocalDockerfile = "Dockerfile"
+		}
+	}
+
+	// overwrite with docker image repository and tag
+	regID, imageURL, err := c.GetImageRepoURL(opts.ReleaseName, opts.Namespace)
+
+	if err != nil {
+		return "", err
+	}
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	mergedValues["image"] = map[string]interface{}{
+		"repository": imageURL,
+		"tag":        "latest",
+	}
+
+	// create docker agen
+	agent, err := docker.NewAgentWithAuthGetter(c.Client, opts.ProjectID)
+
+	if err != nil {
+		return "", err
+	}
+
+	env, err := GetEnvFromConfig(mergedValues)
+
+	if err != nil {
+		env = map[string]string{}
+	}
+
+	buildAgent := &BuildAgent{
+		SharedOpts:  opts.SharedOpts,
+		client:      c.Client,
+		imageRepo:   imageURL,
+		env:         env,
+		imageExists: false,
+	}
+
+	if opts.Method == DeployBuildTypeDocker {
+		err = buildAgent.BuildDocker(agent, opts.LocalPath, "latest")
+	} else {
+		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest")
+	}
+
+	if err != nil {
+		return "", err
+	}
+
+	// create repository
+	err = c.Client.CreateRepository(
+		context.Background(),
+		opts.ProjectID,
+		regID,
+		&api.CreateRepositoryRequest{
+			ImageRepoURI: imageURL,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, "latest"))
+
+	if err != nil {
+		return "", err
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageURL,
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// HasDefaultDockerfile detects if there is a dockerfile at the path `./Dockerfile`
+func (c *CreateAgent) HasDefaultDockerfile(buildPath string) bool {
+	dockerFilePath := filepath.Join(buildPath, "./Dockerfile")
+
+	info, err := os.Stat(dockerFilePath)
+
+	return err == nil && !os.IsNotExist(err) && !info.IsDir()
+}
+
+// GetImageRepoURL creates the image repository url by finding the first valid image
+// registry linked to Porter, and then generates a new name of the form:
+// `{registry}/{name}-{namespace}`
+func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, error) {
+	// get all image registries linked to the project
+	// get the list of namespaces
+	registries, err := c.Client.ListRegistries(
+		context.Background(),
+		c.CreateOpts.ProjectID,
+	)
+
+	if err != nil {
+		return 0, "", err
+	} else if len(registries) == 0 {
+		return 0, "", fmt.Errorf("must have created or linked an image registry")
+	}
+
+	// get the first non-empty registry
+	var imageURI string
+	var regID uint
+
+	for _, reg := range registries {
+		if reg.URL != "" {
+			regID = reg.ID
+			imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+			break
+		}
+	}
+
+	return regID, imageURI, nil
+}
+
+// GetLatestTemplateVersion retrieves the latest template version for a specific
+// Porter template from the chart repository.
+func (c *CreateAgent) GetLatestTemplateVersion(templateName string) (string, error) {
+	templates, err := c.Client.ListTemplates(
+		context.Background(),
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	var version string
+	// find the matching template name
+	for _, template := range templates {
+		if templateName == template.Name {
+			version = template.Versions[0]
+			break
+		}
+	}
+
+	if version == "" {
+		return "", fmt.Errorf("matching template version not found")
+	}
+
+	return version, nil
+}
+
+// GetLatestTemplateDefaultValues gets the default config (`values.yaml`) set for a specific
+// template.
+func (c *CreateAgent) GetLatestTemplateDefaultValues(templateName, templateVersion string) (map[string]interface{}, error) {
+	chart, err := c.Client.GetTemplate(
+		context.Background(),
+		templateName,
+		templateVersion,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return chart.Values, nil
+}
+
+func (c *CreateAgent) getMergedValues(overrideValues map[string]interface{}) (string, map[string]interface{}, error) {
+	// deploy the template
+	latestVersion, err := c.GetLatestTemplateVersion(c.CreateOpts.Kind)
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	// get the values of the template
+	values, err := c.GetLatestTemplateDefaultValues(c.CreateOpts.Kind, latestVersion)
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	// merge existing values with overriding values
+	mergedValues := utils.CoalesceValues(values, overrideValues)
+
+	return latestVersion, mergedValues, err
+}
+
+func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interface{}) (string, error) {
+	subdomain := ""
+
+	// check for automatic subdomain creation if web kind
+	if c.CreateOpts.Kind == "web" {
+		// look for ingress.enabled and no custom domains set
+		ingressMap, err := getNestedMap(mergedValues, "ingress")
+
+		if err == nil {
+			enabledVal, enabledExists := ingressMap["enabled"]
+
+			customDomVal, customDomExists := ingressMap["custom_domain"]
+
+			if enabledExists && customDomExists {
+				enabled, eOK := enabledVal.(bool)
+				customDomain, cOK := customDomVal.(bool)
+
+				// in the case of ingress enabled but no custom domain, create subdomain
+				if eOK && cOK && enabled && !customDomain {
+					dnsRecord, err := c.Client.CreateDNSRecord(
+						context.Background(),
+						c.CreateOpts.ProjectID,
+						c.CreateOpts.ClusterID,
+						&api.CreateDNSRecordRequest{
+							ReleaseName: c.CreateOpts.ReleaseName,
+						},
+					)
+
+					if err != nil {
+						return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
+					}
+
+					subdomain = dnsRecord.ExternalURL
+
+					if ingressVal, ok := mergedValues["ingress"]; !ok {
+						mergedValues["ingress"] = map[string]interface{}{
+							"porter_hosts": []string{
+								subdomain,
+							},
+						}
+					} else {
+						ingressValMap := ingressVal.(map[string]interface{})
+
+						ingressValMap["porter_hosts"] = []string{
+							subdomain,
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return subdomain, nil
+}

+ 444 - 0
cli/cmd/deploy/deploy.go

@@ -0,0 +1,444 @@
+package deploy
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/github"
+	"github.com/porter-dev/porter/internal/templater/utils"
+	"k8s.io/client-go/util/homedir"
+)
+
+// DeployBuildType is the option to use as a builder
+type DeployBuildType string
+
+const (
+	// uses local Docker daemon to build and push images
+	DeployBuildTypeDocker DeployBuildType = "docker"
+
+	// uses cloud-native build pack to build and push images
+	DeployBuildTypePack DeployBuildType = "pack"
+)
+
+// DeployAgent handles the deployment and redeployment of an application on Porter
+type DeployAgent struct {
+	App string
+
+	client         *api.Client
+	release        *api.GetReleaseResponse
+	agent          *docker.Agent
+	opts           *DeployOpts
+	tag            string
+	envPrefix      string
+	env            map[string]string
+	imageExists    bool
+	imageRepo      string
+	dockerfilePath string
+}
+
+// DeployOpts are the options for creating a new DeployAgent
+type DeployOpts struct {
+	*SharedOpts
+
+	Local bool
+}
+
+// NewDeployAgent creates a new DeployAgent given a Porter API client, application
+// name, and DeployOpts.
+func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
+	deployAgent := &DeployAgent{
+		App:    app,
+		opts:   opts,
+		client: client,
+		env:    make(map[string]string),
+	}
+
+	// get release from Porter API
+	release, err := client.GetRelease(context.TODO(), opts.ProjectID, opts.ClusterID, opts.Namespace, app)
+
+	if err != nil {
+		return nil, err
+	}
+
+	deployAgent.release = release
+
+	// set an environment prefix to avoid collisions
+	deployAgent.envPrefix = fmt.Sprintf("PORTER_%s", strings.Replace(
+		strings.ToUpper(app), "-", "_", -1,
+	))
+
+	// get docker agent
+	agent, err := docker.NewAgentWithAuthGetter(client, opts.ProjectID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	deployAgent.agent = agent
+
+	// if build method is not set, determine based on release config
+	if opts.Method == "" {
+		if release.GitActionConfig != nil {
+			// if the git action config exists, and dockerfile path is not empty, build type
+			// is docker
+			if release.GitActionConfig.DockerfilePath != "" {
+				deployAgent.opts.Method = DeployBuildTypeDocker
+			}
+
+			// otherwise build type is pack
+			deployAgent.opts.Method = DeployBuildTypePack
+		} else {
+			// if the git action config does not exist, we use docker by default
+			deployAgent.opts.Method = DeployBuildTypeDocker
+		}
+	}
+
+	if deployAgent.opts.Method == DeployBuildTypeDocker {
+		if release.GitActionConfig != nil {
+			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
+		}
+
+		if deployAgent.opts.LocalDockerfile != "" {
+			deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		}
+
+		if deployAgent.opts.LocalDockerfile == "" {
+			deployAgent.dockerfilePath = "./Dockerfile"
+		}
+	}
+
+	// if the git action config is not set, we use local builds since pulling remote source
+	// will fail. we set the image based on the git action config or the image written in the
+	// helm values
+	if release.GitActionConfig == nil {
+		deployAgent.opts.Local = true
+
+		imageRepo, err := deployAgent.getReleaseImage()
+
+		if err != nil {
+			return nil, err
+		}
+
+		deployAgent.imageRepo = imageRepo
+
+		deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+	} else {
+		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
+	}
+
+	deployAgent.tag = opts.OverrideTag
+
+	return deployAgent, nil
+}
+
+// GetBuildEnv retrieves the build env from the release config and returns it
+func (d *DeployAgent) GetBuildEnv() (map[string]string, error) {
+	return GetEnvFromConfig(d.release.Config)
+}
+
+// SetBuildEnv sets the build env vars in the process so that other commands can
+// use them
+func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
+	d.env = envVars
+
+	// iterate through env and set the environment variables for the process
+	// these are prefixed with PORTER_<RELEASE> to avoid collisions. We use
+	// these prefixed env when calling a custom build command as a child process.
+	for key, val := range envVars {
+		prefixedKey := fmt.Sprintf("%s_%s", d.envPrefix, key)
+
+		err := os.Setenv(prefixedKey, val)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// WriteBuildEnv writes the build env to either a file or stdout
+func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
+	// join lines together
+	lines := make([]string, 0)
+
+	// use os.Environ to get output already formatted as KEY=value
+	for _, line := range os.Environ() {
+		// filter for PORTER_<RELEASE> and strip prefix
+		if strings.Contains(line, d.envPrefix+"_") {
+			lines = append(lines, strings.Split(line, d.envPrefix+"_")[1])
+		}
+	}
+
+	output := strings.Join(lines, "\n")
+
+	if fileDest != "" {
+		ioutil.WriteFile(fileDest, []byte(output), 0700)
+	} else {
+		fmt.Println(output)
+	}
+
+	return nil
+}
+
+// Build uses the deploy agent options to build a new container image from either
+// buildpack or docker.
+func (d *DeployAgent) Build() error {
+	// if build is not local, fetch remote source
+	var dst string
+	var err error
+
+	if !d.opts.Local {
+		zipResp, err := d.client.GetRepoZIPDownloadURL(
+			context.Background(),
+			d.opts.ProjectID,
+			d.release.GitActionConfig,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		// download the repository from remote source into a temp directory
+		dst, err = d.downloadRepoToDir(zipResp.URLString)
+
+		if d.tag == "" {
+			shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
+			d.tag = shortRef
+		}
+
+		if err != nil {
+			return err
+		}
+	} else {
+		dst = filepath.Dir(d.opts.LocalPath)
+	}
+
+	if d.tag == "" {
+		currImageSection := d.release.Config["image"].(map[string]interface{})
+
+		d.tag = currImageSection["tag"].(string)
+	}
+
+	err = d.pullCurrentReleaseImage()
+
+	buildAgent := &BuildAgent{
+		SharedOpts:  d.opts.SharedOpts,
+		client:      d.client,
+		imageRepo:   d.imageRepo,
+		env:         d.env,
+		imageExists: d.imageExists,
+	}
+
+	// if image is not found, don't return an error
+	if err != nil && err != docker.PullImageErrNotFound {
+		return err
+	} else if err != nil && err == docker.PullImageErrNotFound {
+		fmt.Println("could not find image, moving to build step")
+		d.imageExists = false
+	}
+
+	if d.opts.Method == DeployBuildTypeDocker {
+		return buildAgent.BuildDocker(d.agent, dst, d.tag)
+	}
+
+	return buildAgent.BuildPack(d.agent, dst, d.tag)
+}
+
+// Push pushes a local image to the remote repository linked in the release
+func (d *DeployAgent) Push() error {
+	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
+}
+
+// UpdateImageAndValues updates the current image for a release, along with new
+// configuration passed in via overrrideValues. If overrideValues is nil, it just
+// reuses the configuration set for the application. If overrideValues is not nil,
+// it will merge the overriding values with the existing configuration.
+func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
+	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
+
+	// overwrite the tag based on a new image
+	currImageSection := mergedValues["image"].(map[string]interface{})
+
+	if d.tag != "" && currImageSection["tag"] != d.tag {
+		currImageSection["tag"] = d.tag
+	}
+
+	bytes, err := json.Marshal(mergedValues)
+
+	if err != nil {
+		return err
+	}
+
+	return d.client.UpgradeRelease(
+		context.Background(),
+		d.opts.ProjectID,
+		d.opts.ClusterID,
+		d.release.Name,
+		&api.UpgradeReleaseRequest{
+			Values:    string(bytes),
+			Namespace: d.release.Namespace,
+		},
+	)
+}
+
+// GetEnvFromConfig gets the env vars for a standard Porter template config. These env
+// vars are found at `container.env.normal`.
+func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error) {
+	envConfig, err := getNestedMap(config, "container", "env", "normal")
+
+	// if the field is not found, set envConfig to an empty map; this release has no env set
+	if e := (&NestedMapFieldNotFoundError{}); errors.As(err, &e) {
+		envConfig = make(map[string]interface{})
+	} else if err != nil {
+		return nil, fmt.Errorf("could not get environment variables from release: %s", err.Error())
+	}
+
+	mapEnvConfig := make(map[string]string)
+
+	for key, val := range envConfig {
+		valStr, ok := val.(string)
+
+		if !ok {
+			return nil, fmt.Errorf("could not cast environment variables to object")
+		}
+
+		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
+		// run-time, so we ignore it
+		if !strings.Contains(valStr, "PORTERSECRET") {
+			mapEnvConfig[key] = valStr
+		}
+	}
+
+	return mapEnvConfig, nil
+}
+
+func (d *DeployAgent) getReleaseImage() (string, error) {
+	if d.release.ImageRepoURI != "" {
+		return d.release.ImageRepoURI, nil
+	}
+
+	// get the image from the conig
+	imageConfig, err := getNestedMap(d.release.Config, "image")
+
+	if err != nil {
+		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
+	}
+
+	repoInterface, ok := imageConfig["repository"]
+
+	if !ok {
+		return "", fmt.Errorf("repository field does not exist for image")
+	}
+
+	repoStr, ok := repoInterface.(string)
+
+	if !ok {
+		return "", fmt.Errorf("could not cast image.image field to string")
+	}
+
+	return repoStr, nil
+}
+
+func (d *DeployAgent) pullCurrentReleaseImage() error {
+	// pull the currently deployed image to use cache, if possible
+	imageConfig, err := getNestedMap(d.release.Config, "image")
+
+	if err != nil {
+		return fmt.Errorf("could not get image config from release: %s", err.Error())
+	}
+
+	tagInterface, ok := imageConfig["tag"]
+
+	if !ok {
+		return fmt.Errorf("tag field does not exist for image")
+	}
+
+	tagStr, ok := tagInterface.(string)
+
+	if !ok {
+		return fmt.Errorf("could not cast image.tag field to string")
+	}
+
+	fmt.Printf("attempting to pull image: %s\n", fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
+
+	return d.agent.PullImage(fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
+}
+
+func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
+	dstDir := filepath.Join(homedir.HomeDir(), ".porter")
+
+	downloader := &github.ZIPDownloader{
+		ZipFolderDest:       dstDir,
+		AssetFolderDest:     dstDir,
+		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)),
+		RemoveAfterDownload: true,
+	}
+
+	err := downloader.DownloadToFile(downloadURL)
+
+	if err != nil {
+		return "", fmt.Errorf("Error downloading to file: %s", err.Error())
+	}
+
+	err = downloader.UnzipToDir()
+
+	if err != nil {
+		return "", fmt.Errorf("Error unzipping to directory: %s", err.Error())
+	}
+
+	var res string
+
+	dstFiles, err := ioutil.ReadDir(dstDir)
+
+	for _, info := range dstFiles {
+		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)) {
+			res = filepath.Join(dstDir, info.Name())
+		}
+	}
+
+	if res == "" {
+		return "", fmt.Errorf("unzipped file not found on host")
+	}
+
+	return res, nil
+}
+
+type NestedMapFieldNotFoundError struct {
+	Field string
+}
+
+func (e *NestedMapFieldNotFoundError) Error() string {
+	return fmt.Sprintf("could not find field %s in configuration", e.Field)
+}
+
+func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+	var res map[string]interface{}
+	curr := obj
+
+	for _, field := range fields {
+		objField, ok := curr[field]
+
+		if !ok {
+			return nil, &NestedMapFieldNotFoundError{field}
+		}
+
+		res, ok = objField.(map[string]interface{})
+
+		if !ok {
+			return nil, fmt.Errorf("%s is not a nested object", field)
+		}
+
+		curr = res
+	}
+
+	return res, nil
+}

+ 12 - 0
cli/cmd/deploy/shared.go

@@ -0,0 +1,12 @@
+package deploy
+
+// SharedOpts are common options for build, create, and deploy agents
+type SharedOpts struct {
+	ProjectID       uint
+	ClusterID       uint
+	Namespace       string
+	LocalPath       string
+	LocalDockerfile string
+	OverrideTag     string
+	Method          DeployBuildType
+}

+ 15 - 10
cli/cmd/docker.go

@@ -45,7 +45,7 @@ func init() {
 }
 
 func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
+	pID := config.Project
 
 	// get all registries that should be added
 	regToAdd := make([]string, 0)
@@ -124,7 +124,7 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		}
 	}
 
-	config := &configfile.ConfigFile{
+	configFile := &configfile.ConfigFile{
 		Filename: dockerConfigFile,
 	}
 
@@ -134,8 +134,8 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		return err
 	}
 
-	if config.CredentialHelpers == nil {
-		config.CredentialHelpers = make(map[string]string)
+	if configFile.CredentialHelpers == nil {
+		configFile.CredentialHelpers = make(map[string]string)
 	}
 
 	for _, regURL := range regToAdd {
@@ -144,7 +144,7 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		if strings.Contains(regURL, "index.docker.io") {
 			isAuthenticated := false
 
-			for key, _ := range config.AuthConfigs {
+			for key, _ := range configFile.AuthConfigs {
 				if key == "https://index.docker.io/v1/" {
 					isAuthenticated = true
 				}
@@ -152,7 +152,7 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 
 			if !isAuthenticated {
 				// get a dockerhub token from the Porter API
-				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), getProjectID())
+				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), config.Project)
 
 				if err != nil {
 					return err
@@ -170,21 +170,21 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 					return fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
 				}
 
-				config.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
+				configFile.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
 					Auth:     tokenResp.Token,
 					Username: parts[0],
 					Password: parts[1],
 				}
 
 				// since we're using token-based auth, unset the credstore
-				config.CredentialsStore = ""
+				configFile.CredentialsStore = ""
 			}
 		} else {
-			config.CredentialHelpers[regURL] = "porter"
+			configFile.CredentialHelpers[regURL] = "porter"
 		}
 	}
 
-	return config.Save()
+	return configFile.Save()
 }
 
 func downloadCredMatchingRelease() error {
@@ -197,6 +197,11 @@ func downloadCredMatchingRelease() error {
 		EntityID:            "porter-dev",
 		RepoName:            "porter",
 		IsPlatformDependent: true,
+		Downloader: &github.ZIPDownloader{
+			ZipFolderDest:   filepath.Join(home, ".porter"),
+			AssetFolderDest: "/usr/local/bin",
+			ZipName:         "docker-credential-porter_latest.zip",
+		},
 	}
 
 	return z.GetRelease(Version)

+ 158 - 16
cli/cmd/docker/agent.go

@@ -2,27 +2,32 @@ package docker
 
 import (
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
+	"os"
 	"strings"
 	"time"
 
+	"github.com/docker/distribution/reference"
 	"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"
+	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/term"
 )
 
 // 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
+	authGetter *AuthGetter
+	client     *client.Client
+	ctx        context.Context
+	label      string
 }
 
 // CreateLocalVolumeIfNotExist creates a volume using driver type "local" with the
@@ -134,6 +139,10 @@ func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName
 	return a.client.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
 }
 
+func (a *Agent) TagImage(old, new string) error {
+	return a.client.ImageTag(a.ctx, old, new)
+}
+
 // PullImageEvent represents a response from the Docker API with an image pull event
 type PullImageEvent struct {
 	Status         string `json:"status"`
@@ -145,32 +154,165 @@ type PullImageEvent struct {
 	} `json:"progressDetail"`
 }
 
+var PullImageErrNotFound = fmt.Errorf("Requested image not found")
+
+var PullImageErrUnauthorized = fmt.Errorf("Could not pull image: unauthorized")
+
 // PullImage pulls an image specified by the image string
 func (a *Agent) PullImage(image string) error {
+	opts, err := a.getPullOptions(image)
+
+	if err != nil {
+		return err
+	}
+
 	// pull the specified image
-	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
+	out, err := a.client.ImagePull(a.ctx, image, opts)
 
 	if err != nil {
-		return a.handleDockerClientErr(err, "Could not pull image"+image)
+		if client.IsErrNotFound(err) {
+			return PullImageErrNotFound
+		} else if client.IsErrUnauthorized(err) {
+			return PullImageErrUnauthorized
+		} else {
+			return a.handleDockerClientErr(err, "Could not pull image "+image)
+		}
 	}
 
-	decoder := json.NewDecoder(out)
+	defer out.Close()
 
-	var event *PullImageEvent
+	termFd, isTerm := term.GetFdInfo(os.Stderr)
 
-	for {
-		if err := decoder.Decode(&event); err != nil {
-			if err == io.EOF {
-				break
-			}
+	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)
+}
 
-			return err
-		}
+// PushImage pushes an image specified by the image string
+func (a *Agent) PushImage(image string) error {
+	opts, err := a.getPushOptions(image)
+
+	if err != nil {
+		return err
 	}
 
-	return nil
+	out, err := a.client.ImagePush(
+		context.Background(),
+		image,
+		opts,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defer out.Close()
+
+	termFd, isTerm := term.GetFdInfo(os.Stderr)
+
+	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)
 }
 
+func (a *Agent) getPullOptions(image string) (types.ImagePullOptions, error) {
+	// check if agent has an auth getter; otherwise, assume public usage
+	if a.authGetter == nil {
+		return types.ImagePullOptions{}, nil
+	}
+
+	// get using server url
+	serverURL, err := GetServerURLFromTag(image)
+
+	if err != nil {
+		return types.ImagePullOptions{}, err
+	}
+
+	user, secret, err := a.authGetter.GetCredentials(serverURL)
+
+	if err != nil {
+		return types.ImagePullOptions{}, err
+	}
+
+	var authConfig = types.AuthConfig{
+		Username:      user,
+		Password:      secret,
+		ServerAddress: "https://" + serverURL,
+	}
+
+	authConfigBytes, _ := json.Marshal(authConfig)
+	authConfigEncoded := base64.URLEncoding.EncodeToString(authConfigBytes)
+
+	return types.ImagePullOptions{
+		RegistryAuth: authConfigEncoded,
+	}, nil
+}
+
+func (a *Agent) getPushOptions(image string) (types.ImagePushOptions, error) {
+	pullOpts, err := a.getPullOptions(image)
+
+	return types.ImagePushOptions(pullOpts), err
+}
+
+func GetServerURLFromTag(image string) (string, error) {
+	named, err := reference.ParseNamed(image)
+
+	if err != nil {
+		return "", err
+	}
+
+	domain := reference.Domain(named)
+
+	// if domain name is empty, use index.docker.io/v1
+	if domain == "" {
+		return "index.docker.io/v1", nil
+	}
+
+	return domain, nil
+
+	// else if matches := ecrPattern.FindStringSubmatch(image); matches >= 3 {
+	// 	// if this matches ECR, just use the domain name
+	// 	return domain, nil
+	// } else if strings.Contains(image, "gcr.io") || strings.Contains(image, "registry.digitalocean.com") {
+	// 	// if this matches GCR or DOCR, use the first path component
+	// 	return fmt.Sprintf("%s/%s", domain, strings.Split(path, "/")[0]), nil
+	// }
+
+	// // otherwise, best-guess is to get components of path that aren't the image name
+	// pathParts := strings.Split(path, "/")
+	// nonImagePath := ""
+
+	// if len(pathParts) > 1 {
+	// 	nonImagePath = strings.Join(pathParts[0:len(pathParts)-1], "/")
+	// }
+
+	// if err != nil {
+	// 	return "", err
+	// }
+
+	// return fmt.Sprintf("%s/%s", domain, nonImagePath), nil
+}
+
+// func imagePush(dockerClient *client.Client) error {
+// 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*120)
+// 	defer cancel()
+
+// 	authConfigBytes, _ := json.Marshal(authConfig)
+// 	authConfigEncoded := base64.URLEncoding.EncodeToString(authConfigBytes)
+
+// 	tag := dockerRegistryUserID + "/node-hello"
+// 	opts := types.ImagePushOptions{RegistryAuth: authConfigEncoded}
+// 	rd, err := dockerClient.ImagePush(ctx, tag, opts)
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	defer rd.Close()
+
+// 	err = print(rd)
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	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

+ 17 - 0
cli/cmd/docker/agent_test.go

@@ -0,0 +1,17 @@
+package docker_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+)
+
+func TestGetServerURL(t *testing.T) {
+	res, err := docker.GetServerURLFromTag("docker.io/testing/test")
+
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	t.Errorf("%s", res)
+}

+ 358 - 0
cli/cmd/docker/auth.go

@@ -0,0 +1,358 @@
+package docker
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"k8s.io/client-go/util/homedir"
+)
+
+// AuthEntry is a stored token for registry access with an expiration time.
+type AuthEntry struct {
+	AuthorizationToken string
+	RequestedAt        time.Time
+	ExpiresAt          time.Time
+	ProxyEndpoint      string
+}
+
+// IsValid checks if AuthEntry is still valid at runtime. AuthEntries expire at 1/2 of their original
+// requested window.
+func (authEntry *AuthEntry) IsValid(testTime time.Time) bool {
+	validWindow := authEntry.ExpiresAt.Sub(authEntry.RequestedAt)
+	refreshTime := authEntry.ExpiresAt.Add(-1 * validWindow / time.Duration(2))
+	return testTime.Before(refreshTime)
+}
+
+// CredentialsCache is a simple interface for getting/setting auth credentials
+// so that we don't request new tokens when previous ones haven't expired
+type CredentialsCache interface {
+	Get(registry string) *AuthEntry
+	Set(registry string, entry *AuthEntry)
+	List() []*AuthEntry
+}
+
+// AuthGetter retrieves
+type AuthGetter struct {
+	Client    *api.Client
+	Cache     CredentialsCache
+	ProjectID uint
+}
+
+func (a *AuthGetter) GetCredentials(serverURL string) (user string, secret string, err error) {
+	if strings.Contains(serverURL, "gcr.io") {
+		return a.GetGCRCredentials(serverURL, a.ProjectID)
+	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
+		return a.GetDOCRCredentials(serverURL, a.ProjectID)
+	} else if strings.Contains(serverURL, "index.docker.io") {
+		return a.GetDockerHubCredentials(serverURL, a.ProjectID)
+	}
+
+	return a.GetECRCredentials(serverURL, a.ProjectID)
+}
+
+func (a *AuthGetter) GetGCRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	if err != nil {
+		return "", "", err
+	}
+
+	cachedEntry := a.Cache.Get(serverURL)
+
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetGCRAuthorizationToken(context.Background(), projID, &api.GetGCRTokenRequest{
+			ServerURL: serverURL,
+		})
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return "oauth2accesstoken", token, nil
+}
+
+func (a *AuthGetter) GetDOCRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	cachedEntry := a.Cache.Get(serverURL)
+
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+
+		// get a token from the server
+		tokenResp, err := a.Client.GetDOCRAuthorizationToken(context.Background(), projID, &api.GetDOCRTokenRequest{
+			ServerURL: serverURL,
+		})
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		if t := *tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
+			// set the token in cache
+			a.Cache.Set(serverURL, &AuthEntry{
+				AuthorizationToken: token,
+				RequestedAt:        time.Now(),
+				ExpiresAt:          t,
+				ProxyEndpoint:      serverURL,
+			})
+		}
+
+	}
+
+	return token, token, nil
+}
+
+var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
+
+func (a *AuthGetter) GetECRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	// parse the server url for region
+	matches := ecrPattern.FindStringSubmatch(serverURL)
+
+	if len(matches) == 0 {
+		err := fmt.Errorf("only ECR registry URLs are supported")
+
+		return "", "", err
+	} else if len(matches) < 3 {
+		err := fmt.Errorf("%s is not a valid ECR repository URI", serverURL)
+
+		return "", "", err
+	}
+
+	cachedEntry := a.Cache.Get(serverURL)
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetECRAuthorizationToken(context.Background(), projID, matches[3])
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return decodeDockerToken(token)
+}
+
+func (a *AuthGetter) GetDockerHubCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	cachedEntry := a.Cache.Get(serverURL)
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetDockerhubAuthorizationToken(context.Background(), projID)
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return decodeDockerToken(token)
+}
+
+func decodeDockerToken(token string) (string, string, error) {
+	decodedToken, err := base64.StdEncoding.DecodeString(token)
+
+	if err != nil {
+		return "", "", fmt.Errorf("Invalid token: %v", err)
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+
+	if len(parts) < 2 {
+		return "", "", fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
+	}
+
+	return parts[0], parts[1], nil
+}
+
+type FileCredentialCache struct {
+	path           string
+	filename       string
+	cachePrefixKey string
+}
+
+const registryCacheVersion = "1.0"
+
+type RegistryCache struct {
+	Registries map[string]*AuthEntry
+	Version    string
+}
+
+type fileCredentialCache struct {
+	path           string
+	filename       string
+	cachePrefixKey string
+}
+
+func newRegistryCache() *RegistryCache {
+	return &RegistryCache{
+		Registries: make(map[string]*AuthEntry),
+		Version:    registryCacheVersion,
+	}
+}
+
+// NewFileCredentialsCache returns a new file credentials cache.
+//
+// path is used for temporary files during save, and filename should be a relative filename
+// in the same directory where the cache is serialized and deserialized.
+//
+// cachePrefixKey is used for scoping credentials for a given credential cache (i.e. region and
+// accessKey).
+func NewFileCredentialsCache() CredentialsCache {
+	home := homedir.HomeDir()
+	path := filepath.Join(home, ".porter")
+
+	if _, err := os.Stat(path); err != nil {
+		os.MkdirAll(path, 0700)
+	}
+
+	return &FileCredentialCache{path: path, filename: "cache.json"}
+}
+
+func (f *FileCredentialCache) Get(registry string) *AuthEntry {
+	registryCache := f.init()
+
+	return registryCache.Registries[f.cachePrefixKey+registry]
+}
+
+func (f *FileCredentialCache) Set(registry string, entry *AuthEntry) {
+	registryCache := f.init()
+
+	registryCache.Registries[f.cachePrefixKey+registry] = entry
+
+	f.save(registryCache)
+}
+
+func (f *FileCredentialCache) Clear() {
+	os.Remove(f.fullFilePath())
+}
+
+// List returns all of the available AuthEntries (regardless of prefix)
+func (f *FileCredentialCache) List() []*AuthEntry {
+	registryCache := f.init()
+
+	// optimize allocation for copy
+	entries := make([]*AuthEntry, 0, len(registryCache.Registries))
+
+	for _, entry := range registryCache.Registries {
+		entries = append(entries, entry)
+	}
+
+	return entries
+}
+
+func (f *FileCredentialCache) fullFilePath() string {
+	return filepath.Join(f.path, f.filename)
+}
+
+// Saves credential cache to disk. This writes to a temporary file first, then moves the file to the config location.
+// This eliminates from reading partially written credential files, and reduces (but does not eliminate) concurrent
+// file access. There is not guarantee here for handling multiple writes at once since there is no out of process locking.
+func (f *FileCredentialCache) save(registryCache *RegistryCache) error {
+	file, err := ioutil.TempFile(f.path, ".config.json.tmp")
+	if err != nil {
+		return err
+	}
+
+	buff, err := json.MarshalIndent(registryCache, "", "  ")
+	if err != nil {
+		file.Close()
+		os.Remove(file.Name())
+		return err
+	}
+
+	_, err = file.Write(buff)
+
+	if err != nil {
+		file.Close()
+		os.Remove(file.Name())
+		return err
+	}
+
+	file.Close()
+	// note this is only atomic when relying on linux syscalls
+	os.Rename(file.Name(), f.fullFilePath())
+	return err
+}
+
+func (f *FileCredentialCache) init() *RegistryCache {
+	registryCache, err := f.load()
+	if err != nil {
+		f.Clear()
+		registryCache = newRegistryCache()
+	}
+	return registryCache
+}
+
+// Loading a cache from disk will return errors for malformed or incompatible cache files.
+func (f *FileCredentialCache) load() (*RegistryCache, error) {
+	registryCache := newRegistryCache()
+
+	file, err := os.Open(f.fullFilePath())
+	if os.IsNotExist(err) {
+		return registryCache, nil
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer file.Close()
+
+	if err = json.NewDecoder(file).Decode(&registryCache); err != nil {
+		return nil, err
+	}
+
+	return registryCache, nil
+}

+ 54 - 0
cli/cmd/docker/builder.go

@@ -0,0 +1,54 @@
+package docker
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/pkg/archive"
+	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/term"
+)
+
+type BuildOpts struct {
+	ImageRepo    string
+	Tag          string
+	BuildContext string
+	Env          map[string]string
+}
+
+// BuildLocal
+func (a *Agent) BuildLocal(opts *BuildOpts, dockerfilePath string) error {
+	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{})
+
+	if err != nil {
+		return err
+	}
+
+	buildArgs := make(map[string]*string)
+
+	for key, val := range opts.Env {
+		valCopy := val
+		buildArgs[key] = &valCopy
+	}
+
+	out, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
+		Dockerfile: dockerfilePath,
+		BuildArgs:  buildArgs,
+		Tags: []string{
+			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
+		},
+		Remove: true,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	defer out.Body.Close()
+
+	termFd, isTerm := term.GetFdInfo(os.Stderr)
+
+	return jsonmessage.DisplayJSONMessagesStream(out.Body, os.Stderr, termFd, isTerm, nil)
+}

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

@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"github.com/docker/docker/client"
+	"github.com/porter-dev/porter/cli/cmd/api"
 )
 
 const label = "CreatedByPorterCLI"
@@ -27,3 +28,23 @@ func NewAgentFromEnv() (*Agent, error) {
 		label:  label,
 	}, nil
 }
+
+func NewAgentWithAuthGetter(client *api.Client, projID uint) (*Agent, error) {
+	agent, err := NewAgentFromEnv()
+
+	if err != nil {
+		return nil, err
+	}
+
+	cache := NewFileCredentialsCache()
+
+	authGetter := &AuthGetter{
+		Client:    client,
+		Cache:     cache,
+		ProjectID: projID,
+	}
+
+	agent.authGetter = authGetter
+
+	return agent, nil
+}

+ 4 - 2
cli/cmd/docker/porter.go

@@ -9,6 +9,8 @@ import (
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/mount"
 	"github.com/docker/go-connections/nat"
+
+	specs "github.com/opencontainers/image-spec/specs-go/v1"
 )
 
 // PorterDB is used for enumerating DB types
@@ -261,7 +263,7 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id str
 	}, &container.HostConfig{
 		PortBindings: portBindings,
 		Mounts:       opts.Mounts,
-	}, nil, opts.Name)
+	}, nil, &specs.Platform{}, opts.Name)
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not create Porter container")
@@ -364,7 +366,7 @@ func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, er
 		},
 	}, &container.HostConfig{
 		Mounts: opts.Mounts,
-	}, nil, opts.Name)
+	}, nil, &specs.Platform{}, opts.Name)
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not create Porter container")

+ 3 - 3
cli/cmd/errors.go

@@ -9,7 +9,7 @@ import (
 )
 
 func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
-	client := GetAPIClient()
+	client := GetAPIClient(config)
 
 	user, err := client.AuthCheck(context.Background())
 
@@ -20,7 +20,7 @@ func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, cl
 			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
 			return nil
 		} else if strings.Contains(err.Error(), "connection refused") {
-			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
 			return nil
@@ -39,7 +39,7 @@ func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, cl
 			red.Print("You do not have the necessary permissions to view this resource")
 			return nil
 		} else if strings.Contains(err.Error(), "connection refused") {
-			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"")
 			red.Print("To start a local server, run \"porter server start\"")
 			return nil

+ 17 - 40
cli/cmd/github/release.go

@@ -38,6 +38,9 @@ type ZIPReleaseGetter struct {
 
 	// If the asset is platform dependent
 	IsPlatformDependent bool
+
+	// The downloader/unzipper
+	Downloader *ZIPDownloader
 }
 
 // GetLatestRelease downloads the latest .zip release from a given Github repository
@@ -67,7 +70,7 @@ func (z *ZIPReleaseGetter) GetRelease(releaseTag string) error {
 func (z *ZIPReleaseGetter) getReleaseFromURL(releaseURL string) error {
 	fmt.Printf("getting release %s\n", releaseURL)
 
-	err := z.downloadToFile(releaseURL)
+	err := z.Downloader.DownloadToFile(releaseURL)
 
 	fmt.Printf("downloaded release %s to file %s\n", z.AssetName, filepath.Join(z.ZipFolderDest, z.ZipName))
 
@@ -77,7 +80,7 @@ func (z *ZIPReleaseGetter) getReleaseFromURL(releaseURL string) error {
 
 	fmt.Printf("unzipping %s to %s\n", z.AssetName, z.AssetFolderDest)
 
-	err = z.unzipToDir()
+	err = z.Downloader.UnzipToDir()
 
 	return err
 }
@@ -152,45 +155,15 @@ func (z *ZIPReleaseGetter) getDownloadRegexp() (*regexp.Regexp, error) {
 	return regexp.MustCompile(fmt.Sprintf(`(?i)%s_.*\.zip`, z.AssetName)), nil
 }
 
-// // DownloadLatestServerRelease retrieves the latest Porter server release from Github, unzips
-// // it, and adds the binary to the porter directory
-// func DownloadLatestServerRelease(porterDir string) error {
-// 	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	zipFile := filepath.Join(porterDir, "portersrv_latest.zip")
-
-// 	err = downloadToFile(releaseURL, zipFile)
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	err = unzipToDir(zipFile, porterDir)
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	staticZipFile := filepath.Join(porterDir, "static_latest.zip")
-
-// 	err = downloadToFile(staticReleaseURL, staticZipFile)
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	staticDir := filepath.Join(porterDir, "static")
-
-// 	err = unzipToDir(staticZipFile, staticDir)
+type ZIPDownloader struct {
+	ZipFolderDest   string
+	ZipName         string
+	AssetFolderDest string
 
-// 	return err
-// }
+	RemoveAfterDownload bool
+}
 
-func (z *ZIPReleaseGetter) downloadToFile(url string) error {
+func (z *ZIPDownloader) DownloadToFile(url string) error {
 	// Get the data
 	resp, err := http.Get(url)
 
@@ -215,7 +188,7 @@ func (z *ZIPReleaseGetter) downloadToFile(url string) error {
 	return err
 }
 
-func (z *ZIPReleaseGetter) unzipToDir() error {
+func (z *ZIPDownloader) UnzipToDir() error {
 	r, err := zip.OpenReader(filepath.Join(z.ZipFolderDest, z.ZipName))
 
 	if err != nil {
@@ -268,5 +241,9 @@ func (z *ZIPReleaseGetter) unzipToDir() error {
 		}
 	}
 
+	if z.RemoveAfterDownload {
+		os.Remove(filepath.Join(z.ZipFolderDest, z.ZipName))
+	}
+
 	return nil
 }

+ 102 - 0
cli/cmd/gitutils/git.go

@@ -0,0 +1,102 @@
+package gitutils
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/cli/cli/git"
+)
+
+func GitDirectory(fullpath string) (string, error) {
+	currDir, err := os.Getwd()
+
+	if err != nil {
+		return "", fmt.Errorf("could not read current directory: %s", err.Error())
+	}
+
+	err = os.Chdir(fullpath)
+
+	if err != nil {
+		return "", nil
+	}
+
+	res, gitErr := git.ToplevelDir()
+
+	err = os.Chdir(currDir)
+
+	if err != nil {
+		return "", err
+	}
+
+	return res, gitErr
+}
+
+func GetRemoteBranch(fullpath string) (*git.Remote, string, error) {
+	var remote *git.Remote
+
+	currDir, err := os.Getwd()
+
+	if err != nil {
+		return nil, "", fmt.Errorf("could not read current directory: %s", err.Error())
+	}
+
+	err = os.Chdir(fullpath)
+
+	if err != nil {
+		return nil, "", nil
+	}
+
+	// read the current branch
+	branch, gitErr := git.CurrentBranch()
+
+	if gitErr == nil {
+		branchConf := git.ReadBranchConfig(branch)
+		remoteName := "origin"
+
+		if branchConf.RemoteName != "" {
+			remoteName = branchConf.RemoteName
+		}
+
+		remotes, err := git.Remotes()
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		for _, _remote := range remotes {
+			if _remote.Name == remoteName {
+				remote = _remote
+				break
+			}
+		}
+
+		if remote == nil {
+			return nil, "", fmt.Errorf("remote repository not found")
+		}
+	}
+
+	err = os.Chdir(currDir)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return remote, branch, gitErr
+}
+
+func ParseGithubRemote(remote *git.Remote) (bool, string) {
+	if remote == nil || remote.FetchURL == nil {
+		return false, ""
+	}
+
+	if remote.FetchURL.Host != "github.com" {
+		return false, ""
+	}
+
+	if !strings.Contains(remote.FetchURL.Path, ".git") {
+		return false, ""
+	}
+
+	return true, strings.Trim(strings.TrimSuffix(remote.FetchURL.Path, ".git"), "/")
+}

+ 5 - 10
cli/cmd/helm_repo.go

@@ -53,12 +53,7 @@ var helmRepoChartListCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(helmRepoCmd)
 
-	helmRepoCmd.PersistentFlags().UintVar(
-		&helmRepoID,
-		"helmrepo-id",
-		0,
-		"id of the helm repo",
-	)
+	helmRepoCmd.PersistentFlags().AddFlagSet(helmRepoFlagSet)
 
 	helmRepoCmd.AddCommand(helmRepoListCmd)
 	helmRepoCmd.AddCommand(helmRepoChartCmd)
@@ -67,7 +62,7 @@ func init() {
 }
 
 func listHelmRepos(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
+	pID := config.Project
 
 	hrs, err := client.ListHelmRepos(
 		context.Background(),
@@ -83,7 +78,7 @@ func listHelmRepos(user *api.AuthCheckResponse, client *api.Client, args []strin
 
 	fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "ID", "NAME", "URL", "SERVICE")
 
-	currHelmID := getHelmRepoID()
+	currHelmID := config.HelmRepo
 
 	for _, hr := range hrs {
 		if currHelmID == hr.ID {
@@ -99,8 +94,8 @@ func listHelmRepos(user *api.AuthCheckResponse, client *api.Client, args []strin
 }
 
 func listHelmRepoCharts(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
-	hrID := getHelmRepoID()
+	pID := config.Project
+	hrID := config.HelmRepo
 
 	charts, err := client.ListCharts(
 		context.Background(),

+ 89 - 0
cli/cmd/job.go

@@ -0,0 +1,89 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+var batchImageUpdateCmd = &cobra.Command{
+	Use:   "job update-images",
+	Short: "Updates the image tag of all jobs in a namespace which use a specific image.",
+	Long: fmt.Sprintf(`
+%s 
+
+Updates the image tag of all jobs in a namespace which use a specific image. Note that for all
+jobs with version <= v0.4.0, this will trigger a new run of a manual job. However, for versions
+>= v0.5.0, this will not create a new run of the job. 
+
+Example commands:
+
+  %s
+
+This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+use the --namespace flag:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter job update-images\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job update-images --image-repo-uri my-image.registry.io --tag newtag"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job update-images --namespace custom-namespace --image-repo-uri my-image.registry.io --tag newtag"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, batchImageUpdate)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var imageRepoURI string
+
+func init() {
+	rootCmd.AddCommand(batchImageUpdateCmd)
+
+	batchImageUpdateCmd.PersistentFlags().StringVar(
+		&tag,
+		"tag",
+		"",
+		"The new image tag to use.",
+	)
+
+	batchImageUpdateCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"",
+		"The namespace of the jobs.",
+	)
+
+	batchImageUpdateCmd.PersistentFlags().StringVarP(
+		&imageRepoURI,
+		"image-repo-uri",
+		"i",
+		"",
+		"Image repo uri",
+	)
+
+	batchImageUpdateCmd.MarkPersistentFlagRequired("image-repo-uri")
+	batchImageUpdateCmd.MarkPersistentFlagRequired("tag")
+}
+
+func batchImageUpdate(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Println("Updating all jobs which use the image:", imageRepoURI)
+
+	return client.UpdateBatchImage(
+		context.TODO(),
+		config.Project,
+		config.Cluster,
+		namespace,
+		&api.UpdateBatchImageRequest{
+			ImageRepoURI: imageRepoURI,
+			Tag:          tag,
+		},
+	)
+}

+ 3 - 10
cli/cmd/open.go

@@ -13,25 +13,18 @@ var openCmd = &cobra.Command{
 	Use:   "open",
 	Short: "Opens the browser at the currently set Porter instance",
 	Run: func(cmd *cobra.Command, args []string) {
-		client := GetAPIClient()
+		client := GetAPIClient(config)
 
 		user, err := client.AuthCheck(context.Background())
 
 		if err == nil {
-			utils.OpenBrowser(fmt.Sprintf("%s/login?email=%s", getHost(), user.Email))
+			utils.OpenBrowser(fmt.Sprintf("%s/login?email=%s", config.Host, user.Email))
 		} else {
-			utils.OpenBrowser(fmt.Sprintf("%s/register", getHost()))
+			utils.OpenBrowser(fmt.Sprintf("%s/register", config.Host))
 		}
 	},
 }
 
 func init() {
 	rootCmd.AddCommand(openCmd)
-
-	rootCmd.PersistentFlags().StringVar(
-		&host,
-		"host",
-		getHost(),
-		"host url of Porter instance",
-	)
 }

+ 32 - 0
cli/cmd/pack/pack.go

@@ -0,0 +1,32 @@
+package pack
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/buildpacks/pack"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+)
+
+type Agent struct{}
+
+func (a *Agent) Build(opts *docker.BuildOpts) error {
+	//create a context object
+	context := context.Background()
+
+	//initialize a pack client
+	client, err := pack.NewClient()
+	if err != nil {
+		panic(err)
+	}
+
+	buildOpts := pack.BuildOptions{
+		Image:        fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
+		Builder:      "heroku/buildpacks:18",
+		AppPath:      opts.BuildContext,
+		TrustBuilder: true,
+		Env:          opts.Env,
+	}
+
+	return client.Build(context, buildOpts)
+}

+ 2 - 11
cli/cmd/project.go

@@ -63,17 +63,8 @@ var listProjectCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(projectCmd)
 
-	projectCmd.PersistentFlags().StringVar(
-		&host,
-		"host",
-		getHost(),
-		"host url of Porter instance",
-	)
-
 	projectCmd.AddCommand(createProjectCmd)
-
 	projectCmd.AddCommand(deleteProjectCmd)
-
 	projectCmd.AddCommand(listProjectCmd)
 }
 
@@ -88,7 +79,7 @@ func createProject(_ *api.AuthCheckResponse, client *api.Client, args []string)
 
 	color.New(color.FgGreen).Printf("Created project with name %s and id %d\n", args[0], resp.ID)
 
-	return setProject(resp.ID)
+	return config.SetProject(resp.ID)
 }
 
 func listProjects(user *api.AuthCheckResponse, client *api.Client, args []string) error {
@@ -103,7 +94,7 @@ func listProjects(user *api.AuthCheckResponse, client *api.Client, args []string
 
 	fmt.Fprintf(w, "%s\t%s\n", "ID", "NAME")
 
-	currProjectID := getProjectID()
+	currProjectID := config.Project
 
 	for _, project := range projects {
 		if currProjectID == project.ID {

+ 8 - 13
cli/cmd/registry.go

@@ -87,12 +87,7 @@ var registryImageListCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(registryCmd)
 
-	registryCmd.PersistentFlags().UintVar(
-		&registryID,
-		"registry-id",
-		0,
-		"id of the registry",
-	)
+	registryCmd.PersistentFlags().AddFlagSet(registryFlagSet)
 
 	registryCmd.AddCommand(registryReposCmd)
 	registryCmd.AddCommand(registryListCmd)
@@ -105,7 +100,7 @@ func init() {
 }
 
 func listRegistries(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
+	pID := config.Project
 
 	// get the list of namespaces
 	registries, err := client.ListRegistries(
@@ -122,7 +117,7 @@ func listRegistries(user *api.AuthCheckResponse, client *api.Client, args []stri
 
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "URL", "SERVICE")
 
-	currRegistryID := getRegistryID()
+	currRegistryID := config.Registry
 
 	for _, registry := range registries {
 		if currRegistryID == registry.ID {
@@ -157,7 +152,7 @@ func deleteRegistry(user *api.AuthCheckResponse, client *api.Client, args []stri
 			return err
 		}
 
-		err = client.DeleteProjectRegistry(context.Background(), getProjectID(), uint(id))
+		err = client.DeleteProjectRegistry(context.Background(), config.Project, uint(id))
 
 		if err != nil {
 			return err
@@ -170,8 +165,8 @@ func deleteRegistry(user *api.AuthCheckResponse, client *api.Client, args []stri
 }
 
 func listRepos(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
-	rID := getRegistryID()
+	pID := config.Project
+	rID := config.Registry
 
 	// get the list of namespaces
 	repos, err := client.ListRegistryRepositories(
@@ -199,8 +194,8 @@ func listRepos(user *api.AuthCheckResponse, client *api.Client, args []string) e
 }
 
 func listImages(user *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := getProjectID()
-	rID := getRegistryID()
+	pID := config.Project
+	rID := config.Registry
 	repoName := args[0]
 
 	// get the list of namespaces

+ 7 - 44
cli/cmd/root.go

@@ -1,14 +1,11 @@
 package cmd
 
 import (
-	"io/ioutil"
 	"os"
-	"path/filepath"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
 	"k8s.io/client-go/util/homedir"
 )
 
@@ -26,6 +23,8 @@ var home = homedir.HomeDir()
 func Execute() {
 	Setup()
 
+	rootCmd.PersistentFlags().AddFlagSet(defaultFlagSet)
+
 	if err := rootCmd.Execute(); err != nil {
 		color.New(color.FgRed).Println(err)
 		os.Exit(1)
@@ -33,49 +32,13 @@ func Execute() {
 }
 
 func Setup() {
-	// check that the .porter folder exists; create if not
-	porterDir := filepath.Join(home, ".porter")
-
-	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
-		os.Mkdir(porterDir, 0700)
-	} else if err != nil {
-		color.New(color.FgRed).Printf("%v\n", err)
-		os.Exit(1)
-	}
-
-	viper.SetConfigName("porter")
-	viper.SetConfigType("yaml")
-	viper.AddConfigPath(porterDir)
-
-	err := viper.ReadInConfig()
-
-	if err != nil {
-		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
-			// create blank config file
-			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
-
-			if err != nil {
-				color.New(color.FgRed).Printf("%v\n", err)
-				os.Exit(1)
-			}
-		} else {
-			// Config file was found but another error was produced
-			color.New(color.FgRed).Printf("%v\n", err)
-			os.Exit(1)
-		}
-	}
-
-	// create defaults if configs are not set
-	if viper.GetString("host") == "" {
-		viper.Set("host", "https://dashboard.getporter.dev")
-		viper.WriteConfig()
-	}
+	InitAndLoadConfig()
 }
 
-func GetAPIClient() *api.Client {
-	if token := viper.GetString("token"); token != "" {
-		return api.NewClientWithToken(getHost()+"/api", token)
+func GetAPIClient(config *CLIConfig) *api.Client {
+	if token := config.Token; token != "" {
+		return api.NewClientWithToken(config.Host+"/api", token)
 	}
 
-	return api.NewClient(getHost()+"/api", "cookie.json")
+	return api.NewClient(config.Host+"/api", "cookie.json")
 }

+ 4 - 11
cli/cmd/run.go

@@ -38,13 +38,6 @@ var runCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(runCmd)
 
-	runCmd.PersistentFlags().StringVar(
-		&host,
-		"host",
-		getHost(),
-		"host url of Porter instance",
-	)
-
 	runCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
@@ -117,8 +110,8 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 }
 
 func getRESTConfig(client *api.Client) (*rest.Config, error) {
-	pID := getProjectID()
-	cID := getClusterID()
+	pID := config.Project
+	cID := config.Cluster
 
 	kubeResp, err := client.GetKubeconfig(context.TODO(), pID, cID)
 
@@ -156,8 +149,8 @@ type podSimple struct {
 }
 
 func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
-	pID := getProjectID()
-	cID := getClusterID()
+	pID := config.Project
+	cID := config.Cluster
 
 	resp, err := client.GetK8sAllPods(context.TODO(), pID, cID, namespace, releaseName)
 

+ 18 - 13
cli/cmd/server.go

@@ -34,8 +34,8 @@ var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter server instance on the host",
 	Run: func(cmd *cobra.Command, args []string) {
-		if getDriver() == "docker" {
-			setDriver("docker")
+		if config.Driver == "docker" {
+			config.SetDriver("docker")
 
 			err := startDocker(
 				opts.imageTag,
@@ -57,7 +57,7 @@ var startCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		} else {
-			setDriver("local")
+			config.SetDriver("local")
 			err := startLocal(
 				opts.db,
 				*opts.port,
@@ -76,7 +76,7 @@ var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		if getDriver() == "docker" {
+		if config.Driver == "docker" {
 			if err := stopDocker(); err != nil {
 				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
 				os.Exit(1)
@@ -91,6 +91,8 @@ func init() {
 	serverCmd.AddCommand(startCmd)
 	serverCmd.AddCommand(stopCmd)
 
+	serverCmd.PersistentFlags().AddFlagSet(driverFlagSet)
+
 	startCmd.PersistentFlags().StringVar(
 		&opts.db,
 		"db",
@@ -98,13 +100,6 @@ func init() {
 		"the db to use, one of sqlite or postgres",
 	)
 
-	startCmd.PersistentFlags().StringVar(
-		&opts.driver,
-		"driver",
-		"local",
-		"the driver to use, one of \"local\" or \"docker\"",
-	)
-
 	startCmd.PersistentFlags().StringVar(
 		&opts.imageTag,
 		"image-tag",
@@ -157,7 +152,7 @@ func startDocker(
 
 	green.Printf("Server ready: listening on localhost:%d\n", port)
 
-	return setHost(fmt.Sprintf("http://localhost:%d", port))
+	return config.SetHost(fmt.Sprintf("http://localhost:%d", port))
 }
 
 func startLocal(
@@ -168,7 +163,7 @@ func startLocal(
 		return fmt.Errorf("postgres not available for local driver, run \"porter server start --db postgres --driver docker\"")
 	}
 
-	setHost(fmt.Sprintf("http://localhost:%d", port))
+	config.SetHost(fmt.Sprintf("http://localhost:%d", port))
 
 	porterDir := filepath.Join(home, ".porter")
 	cmdPath := filepath.Join(home, ".porter", "portersvr")
@@ -260,6 +255,11 @@ func downloadMatchingRelease(porterDir string) error {
 		EntityID:            "porter-dev",
 		RepoName:            "porter",
 		IsPlatformDependent: true,
+		Downloader: &github.ZIPDownloader{
+			ZipFolderDest:   porterDir,
+			AssetFolderDest: porterDir,
+			ZipName:         "portersvr_latest.zip",
+		},
 	}
 
 	err := z.GetRelease(Version)
@@ -276,6 +276,11 @@ func downloadMatchingRelease(porterDir string) error {
 		EntityID:            "porter-dev",
 		RepoName:            "porter",
 		IsPlatformDependent: false,
+		Downloader: &github.ZIPDownloader{
+			ZipFolderDest:   porterDir,
+			AssetFolderDest: filepath.Join(porterDir, "static"),
+			ZipName:         "static_latest.zip",
+		},
 	}
 
 	return zStatic.GetRelease(Version)

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "0.2.0"
+var Version string = "v0.2.0"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 0 - 196
cmd/docker-credential-porter/helper/cache.go

@@ -1,196 +0,0 @@
-package helper
-
-import (
-	"crypto/md5"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"time"
-
-	"github.com/aws/aws-sdk-go/aws/credentials"
-	"k8s.io/client-go/util/homedir"
-)
-
-type CredentialsCache interface {
-	Get(registry string) *AuthEntry
-	Set(registry string, entry *AuthEntry)
-	List() []*AuthEntry
-	Clear()
-}
-
-type AuthEntry struct {
-	AuthorizationToken string
-	RequestedAt        time.Time
-	ExpiresAt          time.Time
-	ProxyEndpoint      string
-}
-
-// IsValid checks if AuthEntry is still valid at testTime. AuthEntries expire at 1/2 of their original
-// requested window.
-func (authEntry *AuthEntry) IsValid(testTime time.Time) bool {
-	validWindow := authEntry.ExpiresAt.Sub(authEntry.RequestedAt)
-	refreshTime := authEntry.ExpiresAt.Add(-1 * validWindow / time.Duration(2))
-	return testTime.Before(refreshTime)
-}
-
-func BuildCredentialsCache(region string) CredentialsCache {
-	home := homedir.HomeDir()
-	cacheDir := filepath.Join(home, ".porter")
-	cacheFilename := "cache.json"
-
-	return NewFileCredentialsCache(cacheDir, cacheFilename, region)
-}
-
-// Determine a key prefix for a credentials cache. Because auth tokens are scoped to an account and region, rely on provided
-// region, as well as hash of the access key.
-func credentialsCachePrefix(region string, credentials *credentials.Value) string {
-	return fmt.Sprintf("%s-%s-", region, checksum(credentials.AccessKeyID))
-}
-
-// Base64 encodes an MD5 checksum. Relied on for uniqueness, and not for cryptographic security.
-func checksum(text string) string {
-	hasher := md5.New()
-	data := hasher.Sum([]byte(text))
-	return base64.StdEncoding.EncodeToString(data)
-}
-
-const registryCacheVersion = "1.0"
-
-type RegistryCache struct {
-	Registries map[string]*AuthEntry
-	Version    string
-}
-
-type fileCredentialCache struct {
-	path           string
-	filename       string
-	cachePrefixKey string
-}
-
-func newRegistryCache() *RegistryCache {
-	return &RegistryCache{
-		Registries: make(map[string]*AuthEntry),
-		Version:    registryCacheVersion,
-	}
-}
-
-// NewFileCredentialsCache returns a new file credentials cache.
-//
-// path is used for temporary files during save, and filename should be a relative filename
-// in the same directory where the cache is serialized and deserialized.
-//
-// cachePrefixKey is used for scoping credentials for a given credential cache (i.e. region and
-// accessKey).
-func NewFileCredentialsCache(path string, filename string, cachePrefixKey string) CredentialsCache {
-	if _, err := os.Stat(path); err != nil {
-		os.MkdirAll(path, 0700)
-	}
-
-	return &fileCredentialCache{path: path, filename: filename, cachePrefixKey: cachePrefixKey}
-}
-
-func (f *fileCredentialCache) Get(registry string) *AuthEntry {
-	registryCache := f.init()
-
-	return registryCache.Registries[f.cachePrefixKey+registry]
-}
-
-func (f *fileCredentialCache) Set(registry string, entry *AuthEntry) {
-	registryCache := f.init()
-
-	registryCache.Registries[f.cachePrefixKey+registry] = entry
-
-	f.save(registryCache)
-}
-
-// List returns all of the available AuthEntries (regardless of prefix)
-func (f *fileCredentialCache) List() []*AuthEntry {
-	registryCache := f.init()
-
-	// optimize allocation for copy
-	entries := make([]*AuthEntry, 0, len(registryCache.Registries))
-
-	for _, entry := range registryCache.Registries {
-		entries = append(entries, entry)
-	}
-
-	return entries
-}
-
-func (f *fileCredentialCache) Clear() {
-	os.Remove(f.fullFilePath())
-}
-
-func (f *fileCredentialCache) fullFilePath() string {
-	return filepath.Join(f.path, f.filename)
-}
-
-// Saves credential cache to disk. This writes to a temporary file first, then moves the file to the config location.
-// This eliminates from reading partially written credential files, and reduces (but does not eliminate) concurrent
-// file access. There is not guarantee here for handling multiple writes at once since there is no out of process locking.
-func (f *fileCredentialCache) save(registryCache *RegistryCache) error {
-	file, err := ioutil.TempFile(f.path, ".config.json.tmp")
-	if err != nil {
-		return err
-	}
-
-	buff, err := json.MarshalIndent(registryCache, "", "  ")
-	if err != nil {
-		file.Close()
-		os.Remove(file.Name())
-		return err
-	}
-
-	_, err = file.Write(buff)
-
-	if err != nil {
-		file.Close()
-		os.Remove(file.Name())
-		return err
-	}
-
-	file.Close()
-	// note this is only atomic when relying on linux syscalls
-	os.Rename(file.Name(), f.fullFilePath())
-	return err
-}
-
-func (f *fileCredentialCache) init() *RegistryCache {
-	registryCache, err := f.load()
-	if err != nil {
-		f.Clear()
-		registryCache = newRegistryCache()
-	}
-	return registryCache
-}
-
-// Loading a cache from disk will return errors for malformed or incompatible cache files.
-func (f *fileCredentialCache) load() (*RegistryCache, error) {
-	registryCache := newRegistryCache()
-
-	file, err := os.Open(f.fullFilePath())
-	if os.IsNotExist(err) {
-		return registryCache, nil
-	}
-
-	if err != nil {
-		return nil, err
-	}
-
-	defer file.Close()
-
-	if err = json.NewDecoder(file).Decode(&registryCache); err != nil {
-		return nil, err
-	}
-
-	if registryCache.Version != registryCacheVersion {
-		return nil, fmt.Errorf("ecr: Registry cache version %#v is not compatible with %#v, ignoring existing cache",
-			registryCache.Version,
-			registryCacheVersion)
-	}
-
-	return registryCache, nil
-}

+ 24 - 231
cmd/docker-credential-porter/helper/helper.go

@@ -1,22 +1,9 @@
 package helper
 
 import (
-	"context"
-	"encoding/base64"
-	"fmt"
-	"log"
-	"net/url"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-
 	"github.com/docker/docker-credential-helpers/credentials"
 	"github.com/porter-dev/porter/cli/cmd"
-	"github.com/porter-dev/porter/cli/cmd/api"
-	"github.com/spf13/viper"
-	"k8s.io/client-go/util/homedir"
+	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 
 // PorterHelper implements credentials.Helper: it acts as a credentials
@@ -24,7 +11,26 @@ import (
 type PorterHelper struct {
 	Debug bool
 
-	credCache CredentialsCache
+	ProjectID  uint
+	AuthGetter *docker.AuthGetter
+	Cache      docker.CredentialsCache
+}
+
+func NewPorterHelper(debug bool) *PorterHelper {
+	// get the current project ID
+	config := cmd.InitAndLoadNewConfig()
+	cache := docker.NewFileCredentialsCache()
+
+	return &PorterHelper{
+		Debug:     debug,
+		ProjectID: config.Project,
+		AuthGetter: &docker.AuthGetter{
+			Client:    cmd.GetAPIClient(config),
+			Cache:     cache,
+			ProjectID: config.Project,
+		},
+		Cache: cache,
+	}
 }
 
 // Add appends credentials to the store.
@@ -39,234 +45,21 @@ func (p *PorterHelper) Delete(serverURL string) error {
 	return nil
 }
 
-var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
-
 // Get retrieves credentials from the store.
 // It returns username and secret as strings.
 func (p *PorterHelper) Get(serverURL string) (user string, secret string, err error) {
-	p.init()
-
-	if strings.Contains(serverURL, "gcr.io") {
-		return p.getGCR(serverURL)
-	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
-		return p.getDOCR(serverURL)
-	}
-
-	return p.getECR(serverURL)
-}
-
-func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err error) {
-	urlP, err := url.Parse("https://" + serverURL)
-
-	if err != nil {
-		return "", "", err
-	}
-
-	credCache := BuildCredentialsCache(urlP.Host)
-	cachedEntry := credCache.Get(serverURL)
-
-	var token string
-
-	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
-		token = cachedEntry.AuthorizationToken
-	} else {
-		projID := viper.GetUint("project")
-
-		client := cmd.GetAPIClient()
-
-		// get a token from the server
-		tokenResp, err := client.GetGCRAuthorizationToken(context.Background(), projID, &api.GetGCRTokenRequest{
-			ServerURL: serverURL,
-		})
-
-		if err != nil {
-			return "", "", err
-		}
-
-		token = tokenResp.Token
-
-		// set the token in cache
-		credCache.Set(serverURL, &AuthEntry{
-			AuthorizationToken: token,
-			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
-			ProxyEndpoint:      serverURL,
-		})
-	}
-
-	return "oauth2accesstoken", token, nil
-}
-
-func (p *PorterHelper) getDOCR(serverURL string) (user string, secret string, err error) {
-	urlP, err := url.Parse("https://" + serverURL)
-
-	if err != nil {
-		if p.Debug {
-			log.Printf("Error: %s\n", err.Error())
-		}
-
-		return "", "", err
-	}
-
-	credCache := BuildCredentialsCache(urlP.Host)
-	cachedEntry := credCache.Get(serverURL)
-
-	var token string
-
-	if p.Debug {
-		log.Printf("GETTING FROM DOCR", urlP)
-	}
-
-	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
-		token = cachedEntry.AuthorizationToken
-
-		if p.Debug {
-			log.Printf("USING CACHED TOKEN", token)
-		}
-	} else {
-		host := viper.GetString("host")
-		projID := viper.GetUint("project")
-
-		client := cmd.GetAPIClient()
-
-		if p.Debug {
-			log.Printf("MAKING REQUEST", host, projID)
-		}
-
-		// get a token from the server
-		tokenResp, err := client.GetDOCRAuthorizationToken(context.Background(), projID, &api.GetDOCRTokenRequest{
-			ServerURL: serverURL,
-		})
-
-		if err != nil {
-			if p.Debug {
-				log.Printf("Error: %s\n", err.Error())
-			}
-
-			return "", "", err
-		}
-
-		token = tokenResp.Token
-
-		if t := *tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
-			// set the token in cache
-			credCache.Set(serverURL, &AuthEntry{
-				AuthorizationToken: token,
-				RequestedAt:        time.Now(),
-				ExpiresAt:          t,
-				ProxyEndpoint:      serverURL,
-			})
-		}
-
-	}
-
-	return token, token, nil
-}
-
-func (p *PorterHelper) getECR(serverURL string) (user string, secret string, err error) {
-	// parse the server url for region
-	matches := ecrPattern.FindStringSubmatch(serverURL)
-
-	if len(matches) == 0 {
-		err := fmt.Errorf("only ECR registry URLs are supported")
-
-		if p.Debug {
-			log.Printf("Error: %s\n", err.Error())
-		}
-
-		return "", "", err
-	} else if len(matches) < 3 {
-		err := fmt.Errorf("%s is not a valid ECR repository URI", serverURL)
-
-		if p.Debug {
-			log.Printf("Error: %s\n", err.Error())
-		}
-
-		return "", "", err
-	}
-
-	region := matches[3]
-
-	credCache := BuildCredentialsCache(region)
-	cachedEntry := credCache.Get(serverURL)
-
-	var token string
-
-	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
-		token = cachedEntry.AuthorizationToken
-	} else {
-		projID := viper.GetUint("project")
-
-		client := cmd.GetAPIClient()
-
-		// get a token from the server
-		tokenResp, err := client.GetECRAuthorizationToken(context.Background(), projID, matches[3])
-
-		if err != nil {
-			return "", "", err
-		}
-
-		token = tokenResp.Token
-
-		// set the token in cache
-		credCache.Set(serverURL, &AuthEntry{
-			AuthorizationToken: token,
-			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
-			ProxyEndpoint:      serverURL,
-		})
-	}
-
-	return p.getAuth(token)
+	return p.AuthGetter.GetCredentials(serverURL)
 }
 
 // List returns the stored serverURLs and their associated usernames.
 func (p *PorterHelper) List() (map[string]string, error) {
-	p.init()
-
-	credCache := BuildCredentialsCache("")
-	entries := credCache.List()
+	entries := p.Cache.List()
 
 	res := make(map[string]string)
 
 	for _, entry := range entries {
-		user, _, err := p.getAuth(entry.AuthorizationToken)
-
-		if err != nil {
-			continue
-		}
-
-		res[entry.ProxyEndpoint] = user
+		res[entry.ProxyEndpoint] = entry.AuthorizationToken
 	}
 
 	return res, nil
 }
-
-func (p *PorterHelper) getAuth(token string) (string, string, error) {
-	decodedToken, err := base64.StdEncoding.DecodeString(token)
-
-	if err != nil {
-		return "", "", fmt.Errorf("Invalid token: %v", err)
-	}
-
-	parts := strings.SplitN(string(decodedToken), ":", 2)
-
-	if len(parts) < 2 {
-		return "", "", fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
-	}
-
-	return parts[0], parts[1], nil
-}
-
-func (p *PorterHelper) init() {
-	cmd.Setup()
-
-	if p.Debug {
-		var home = homedir.HomeDir()
-		file, err := os.OpenFile(filepath.Join(home, ".porter", "logs.txt"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
-
-		if err == nil {
-			log.SetOutput(file)
-		}
-	}
-}

+ 4 - 4
cmd/docker-credential-porter/main.go

@@ -10,7 +10,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "v0.1.0-beta.3.4"
+var Version string = "v0.4.0"
 
 func main() {
 	var versionFlag bool
@@ -23,7 +23,7 @@ func main() {
 		os.Exit(0)
 	}
 
-	credentials.Serve(&helper.PorterHelper{
-		Debug: Version == "dev",
-	})
+	helper := helper.NewPorterHelper(Version == "dev")
+
+	credentials.Serve(helper)
 }

+ 1 - 0
dashboard/package.json

@@ -6,6 +6,7 @@
     "@material-ui/core": "^4.11.3",
     "@types/d3-array": "^2.9.0",
     "@types/d3-time-format": "^3.0.0",
+    "@types/js-yaml": "^4.0.1",
     "@types/lodash": "^4.14.165",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/material-ui": "^0.21.8",

+ 2 - 1
dashboard/src/components/values-form/FormWrapper.tsx

@@ -12,6 +12,7 @@ type PropsType = {
   formData: any;
   onSubmit?: (formValues: any) => void;
   saveValuesStatus?: string | null;
+  saveButtonText?: string | null;
 
   // Handle additional non-form tabs
   // TODO: find cleaner way to share submitValues w/ rerun jobs button
@@ -385,7 +386,7 @@ export default class FormWrapper extends Component<PropsType, StateType> {
         {showSave && (
           <SaveButton
             disabled={this.isDisabled()}
-            text="Deploy"
+            text={this.props.saveButtonText || "Deploy"}
             onClick={this.handleSubmit}
             status={
               this.isDisabled() && this.props.saveValuesStatus != "loading"

+ 95 - 66
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,31 +1,27 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 
-import { ChartType } from "shared/types";
+import { ChartType, StorageType } from "shared/types";
 import { Context } from "shared/Context";
 import StatusIndicator from "components/StatusIndicator";
-import { pushFiltered, pushQueryParams } from "shared/routing";
-import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
+import api from "shared/api";
 
-type PropsType = RouteComponentProps & {
+type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
 };
 
-type StateType = {
-  expand: boolean;
-  update: any[];
-};
-
-class Chart extends Component<PropsType, StateType> {
-  state = {
-    expand: false,
-    update: [] as any[],
-  };
-
-  renderIcon = () => {
-    let { chart } = this.props;
+const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+  const [expand, setExpand] = useState<boolean>(false);
+  const [chartControllers, setChartControllers] = useState<any>([]);
+  const context = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+  const match = useRouteMatch();
 
+  const renderIcon = () => {
     if (chart.chart.metadata.icon && chart.chart.metadata.icon !== "") {
       return <Icon src={chart.chart.metadata.icon} />;
     } else {
@@ -33,65 +29,98 @@ class Chart extends Component<PropsType, StateType> {
     }
   };
 
-  readableDate = (s: string) => {
-    let ts = new Date(s);
-    let date = ts.toLocaleDateString();
-    let time = ts.toLocaleTimeString([], {
+  const getControllerForChart = async (chart: ChartType) => {
+    try {
+      const { currentCluster, currentProject } = context;
+      const res = await api.getChartControllers(
+        "<token>",
+        {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version,
+        }
+      );
+
+      const controllersUid = res.data.map((c: any) => {
+        return c.metadata.uid;
+      });
+      setChartControllers(controllersUid);
+    } catch (error) {
+      context.setCurrentError(JSON.stringify(error));
+    }
+  };
+
+  useEffect(() => {
+    getControllerForChart(chart);
+  }, [chart]);
+
+  const readableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
       hour: "numeric",
       minute: "2-digit",
     });
     return `${time} on ${date}`;
   };
 
-  render() {
-    let { chart } = this.props;
-
-    return (
-      <StyledChart
-        onMouseEnter={() => this.setState({ expand: true })}
-        onMouseLeave={() => this.setState({ expand: false })}
-        expand={this.state.expand}
-        onClick={() => {
-          let { location, match } = this.props;
-          let urlParams = new URLSearchParams(location.search);
-          let cluster = urlParams.get("cluster");
-          let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
-          pushFiltered(this.props, route, ["project_id"]);
-        }}
-      >
-        <Title>
-          <IconWrapper>{this.renderIcon()}</IconWrapper>
-          {chart.name}
-        </Title>
+  const filteredControllers = useMemo(() => {
+    let tmpControllers: any = {};
+    chartControllers.forEach((uid: any) => {
+      if (!controllers[uid]) {
+        return;
+      }
+      tmpControllers[uid] = controllers[uid];
+    });
+    return tmpControllers;
+  }, [chartControllers, controllers]);
 
-        <BottomWrapper>
-          <InfoWrapper>
-            <StatusIndicator
-              controllers={this.props.controllers}
-              status={chart.info.status}
-              margin_left={"17px"}
-            />
-            <LastDeployed>
-              <Dot>•</Dot> Last deployed{" "}
-              {this.readableDate(chart.info.last_deployed)}
-            </LastDeployed>
-          </InfoWrapper>
+  return (
+    <StyledChart
+      onMouseEnter={() => setExpand(true)}
+      onMouseLeave={() => setExpand(false)}
+      expand={expand}
+      onClick={() => {
+        let urlParams = new URLSearchParams(location.search);
+        let cluster = urlParams.get("cluster");
+        let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
+        pushFiltered({ location, history }, route, ["project_id"]);
+      }}
+    >
+      <Title>
+        <IconWrapper>{renderIcon()}</IconWrapper>
+        {chart.name}
+      </Title>
 
-          <TagWrapper>
-            Namespace
-            <NamespaceTag>{chart.namespace}</NamespaceTag>
-          </TagWrapper>
-        </BottomWrapper>
+      <BottomWrapper>
+        <InfoWrapper>
+          <StatusIndicator
+            controllers={filteredControllers}
+            status={chart.info.status}
+            margin_left={"17px"}
+          />
+          <LastDeployed>
+            <Dot>•</Dot> Last deployed {readableDate(chart.info.last_deployed)}
+          </LastDeployed>
+        </InfoWrapper>
 
-        <Version>v{chart.version}</Version>
-      </StyledChart>
-    );
-  }
-}
+        <TagWrapper>
+          Namespace
+          <NamespaceTag>{chart.namespace}</NamespaceTag>
+        </TagWrapper>
+      </BottomWrapper>
 
-Chart.contextType = Context;
+      <Version>v{chart.version}</Version>
+    </StyledChart>
+  );
+};
 
-export default withRouter(Chart);
+export default Chart;
 
 const BottomWrapper = styled.div`
   display: flex;

+ 38 - 106
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -8,8 +8,9 @@ import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
 import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
 
-type PropsType = {
+type Props = {
   currentCluster: ClusterType;
   namespace: string;
   // TODO Convert to enum
@@ -17,19 +18,21 @@ type PropsType = {
   currentView: PorterUrl;
 };
 
-const ChartList: React.FunctionComponent<PropsType> = ({
+const ChartList: React.FunctionComponent<Props> = ({
   namespace,
   sortType,
   currentView,
 }) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
   const [charts, setCharts] = useState<ChartType[]>([]);
-  const [chartLookupTable, setChartLookupTable] = useState<
-    Record<string, string>
-  >({});
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
-  const [websockets, setWebsockets] = useState<WebSocket[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
 
@@ -97,106 +100,45 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       console.log(error);
       context.setCurrentError(JSON.stringify(error));
       setIsError(true);
-    } finally {
-      setIsLoading(false);
     }
   };
 
   const setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-
-    let ws = new WebSocket(
-      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
-    );
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
-      let chartKey = chartLookupTable[object.metadata.uid];
-
-      // ignore if updated object does not belong to any chart in the list.
-      if (!chartKey) {
-        return;
-      }
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
 
-      let chartControllers = controllers[chartKey];
-      chartControllers[object.metadata.uid] = object;
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
 
-      setControllers((oldControllers) => ({
-        ...oldControllers,
-        [chartKey]: chartControllers,
-      }));
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
     };
 
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
-    };
+    newWebsocket(kind, apiPath, wsConfig);
 
-    return ws;
+    openWebsocket(kind);
   };
 
   const setControllerWebsockets = (controllers: any[]) => {
-    let websockets = controllers.map((kind: string) => {
+    controllers.map((kind: string) => {
       return setupWebsocket(kind);
     });
-    setWebsockets(websockets);
-  };
-
-  const getControllerForChart = async (chart: ChartType) => {
-    try {
-      const { currentCluster, currentProject } = context;
-      const res = await api.getChartControllers(
-        "<token>",
-        {
-          namespace: chart.namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
-          name: chart.name,
-          revision: chart.version,
-        }
-      );
-
-      let chartControllers = {} as Record<string, Record<string, any>>;
-
-      res.data.forEach((c: any) => {
-        c.metadata.kind = c.kind;
-        chartControllers[c.metadata.uid] = c;
-      });
-
-      res.data.forEach(async (c: any) => {
-        setChartLookupTable((oldChartLookupTable) => ({
-          ...oldChartLookupTable,
-          [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
-        }));
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [`${chart.namespace}-${chart.name}`]: chartControllers,
-        }));
-      });
-    } catch (error) {
-      context.setCurrentError(JSON.stringify(error));
-    }
-  };
-
-  const getControllers = (charts: any[]) => {
-    charts.forEach(async (chart: any) => {
-      // don't retrieve controllers for chart that failed to even deploy.
-      if (chart.info.status == "failed") return;
-      await getControllerForChart(chart);
-    });
   };
 
   // Setup basic websockets on start
@@ -207,18 +149,11 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       "daemonset",
       "replicaset",
     ]);
-  }, []);
 
-  // Close Websockets on unmount
-  useEffect(() => {
     return () => {
-      if (websockets.length) {
-        websockets.forEach((ws) => {
-          ws.close();
-        });
-      }
+      closeAllWebsockets();
     };
-  }, [websockets]);
+  }, []);
 
   useEffect(() => {
     let isSubscribed = true;
@@ -227,12 +162,12 @@ const ChartList: React.FunctionComponent<PropsType> = ({
       updateCharts().then((charts) => {
         if (isSubscribed) {
           setCharts(charts);
-          getControllers(charts);
+          setIsLoading(false);
         }
       });
     }
     return () => (isSubscribed = false);
-  }, [namespace]);
+  }, [namespace, currentView]);
 
   const renderChartList = () => {
     if (isLoading || (!namespace && namespace !== "")) {
@@ -262,10 +197,7 @@ const ChartList: React.FunctionComponent<PropsType> = ({
         <Chart
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
-          controllers={
-            controllers[`${chart.namespace}-${chart.name}`] ||
-            ({} as Record<string, any>)
-          }
+          controllers={controllers || {}}
         />
       );
     });

+ 18 - 8
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -273,7 +273,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     return ws;
   };
 
-  handleSaveValues = (config?: any) => {
+  handleSaveValues = (config?: any, runJob?: boolean) => {
     let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: "loading" });
 
@@ -325,6 +325,12 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         _.set(values, "image.tag", `${tag}`);
       }
 
+      if (runJob) {
+        _.set(values, "paused", false);
+      } else {
+        _.set(values, "paused", true);
+      }
+
       // Weave in preexisting values and convert to yaml
       conf = yaml.dump(
         {
@@ -414,6 +420,13 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   renderTabContents = (currentTab: string, submitValues?: any) => {
+    let saveButton = <SaveButton
+        text="Rerun Job"
+        onClick={() => this.handleSaveValues(submitValues, true)}
+        status={this.state.saveValuesStatus}
+        makeFlush={true}
+      />
+
     switch (currentTab) {
       case "jobs":
         if (this.state.imageIsPlaceholder) {
@@ -437,12 +450,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
                 this.setState({ jobs });
               }}
             />
-            <SaveButton
-              text="Rerun Job"
-              onClick={() => this.handleSaveValues(submitValues)}
-              status={this.state.saveValuesStatus}
-              makeFlush={true}
-            />
+            {saveButton}
           </TabWrapper>
         );
       case "settings":
@@ -454,6 +462,7 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
             setShowDeleteOverlay={(x: boolean) =>
               this.setState({ showDeleteOverlay: x })
             }
+            saveButtonText="Save Config"
           />
         );
       default:
@@ -611,8 +620,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
               isInModal={true}
               renderTabContents={this.renderTabContents}
               tabOptionsOnly={true}
-              onSubmit={this.handleSaveValues}
+              onSubmit={(formValues) => this.handleSaveValues(formValues, false)}
               saveValuesStatus={this.state.saveValuesStatus}
+              saveButtonText="Save Config"
             />
           </BodyWrapper>
         </StyledExpandedChart>

+ 7 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -24,6 +24,7 @@ type PropsType = {
   refreshChart: () => void;
   setShowDeleteOverlay: (x: boolean) => void;
   showSource?: boolean;
+  saveButtonText?: string | null;
 };
 
 type StateType = {
@@ -146,6 +147,11 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       _.set(values, "image.tag", this.state.selectedTag);
     }
 
+    // if this is a job, set it to paused
+    if (this.props.currentChart.chart.metadata.name == "job") {
+      _.set(values, "paused", true);
+    }
+
     // Weave in preexisting values and convert to yaml
     let conf = yaml.dump(
       {
@@ -204,7 +210,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         </StyledSettingsSection>
         {this.props.showSource && (
           <SaveButton
-            text="Deploy"
+            text={this.props.saveButtonText || "Save Config"}
             status={this.state.saveValuesStatus}
             onClick={this.handleSubmit}
             makeFlush={true}

+ 6 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -257,6 +257,12 @@ class LaunchFlow extends Component<PropsType, StateType> {
     }
 
     _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (this.props.currentTemplate?.name == "job") {
+      _.set(values, "paused", true)
+    }
+
     var url: string;
     // check if template is docker and create external domain if necessary
     if (this.props.currentTemplate.name == "web") {

+ 1 - 1
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -35,7 +35,7 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
       return;
     }
 
-    if (!this.hasInvalidCharacters(this.state.namespaceName)) {
+    if (this.hasInvalidCharacters(this.state.namespaceName)) {
       this.setState({
         status: "Only lowercase, numbers or dash (-) are allowed",
       });

+ 134 - 0
dashboard/src/shared/hooks/useWebsockets.ts

@@ -0,0 +1,134 @@
+import { useRef } from "react"
+
+interface NewWebsocketOptions {
+  onopen?: () => void;
+  onmessage?: (evt: MessageEvent) => void;
+  onerror?: (err: ErrorEvent) => void;
+  onclose?: (ev: CloseEvent) => void;
+}
+
+interface WebsocketConfig extends NewWebsocketOptions {
+  url: string;
+}
+
+type WebsocketConfigMap = {
+  [id: string]: WebsocketConfig
+}
+
+type WebsocketMap = {
+  [id: string]: WebSocket
+}
+
+export const useWebsockets = () => {
+  const websocketMap = useRef<WebsocketMap>({});
+  const websocketConfigMap = useRef<WebsocketConfigMap>({})
+  
+  /**
+   * Setup for a new websocket, after calling new websocket you can open the connection with openWebsocket
+   * @param id Id to access later the websocket config/connection
+   * @param apiEndpoint Endpoint to connect the websocket e.g: /api/websocket
+   * @param options Websocket listeners
+   * @returns An object with the config setted for that websocket. This config will be used to open the ws on openWebsocket
+   */
+  const newWebsocket = (id: string, apiEndpoint: string, options: NewWebsocketOptions): WebsocketConfig => {
+    
+    if (!id) {
+      console.log("Id cannot be empty");
+      return;
+    }
+
+    if (!apiEndpoint) {
+      console.log("Api endpoint string cannot be empty")
+      return;
+    }
+
+
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+
+    const url = `${protocol}://${window.location.host}${apiEndpoint}`
+
+    const mockFunction = () => {}
+    
+    const wsConfig: WebsocketConfig = {
+      url,
+      onopen: options?.onopen || mockFunction,
+      onmessage: options?.onmessage || mockFunction,
+      onerror: options?.onerror || mockFunction,
+      onclose: options?.onclose || mockFunction,
+    }
+    
+    websocketConfigMap.current = {
+      ...websocketConfigMap.current,
+      [id]: wsConfig,
+    }
+    return wsConfig;
+  }
+
+  /**
+   * Opens the websocket connection based on a config previously setted by
+   * newWebsocket 
+   */
+  const openWebsocket = (id: string) => {
+    const wsConfig = websocketConfigMap.current[id];
+
+    // Prevent calling openWebsocket before newWebsocket
+    if (!wsConfig) {
+      console.log("Couldn't find ws config")
+      return;
+    }
+    // In case of having a previous websocket opened with the same ID, close the previous one
+    const prevWs = getWebsocket(id);
+
+    if (prevWs) {
+      prevWs.close();
+    }
+    const { url, ...listeners } = wsConfig;
+
+    const ws = new WebSocket(wsConfig.url);
+    
+    Object.assign(ws, listeners);
+
+    websocketMap.current = {
+      ...websocketMap.current,
+      [id]: ws,
+    }
+  }
+
+  /**
+   * Close specific websocket
+   */
+  const closeWebsocket = (id: string, code?: number, reason?: string) => {
+    const ws = websocketMap.current[id];
+
+    if (!ws) {
+      console.log(`Couldn't find websocket to close for id: ${id}`);
+      return;
+    }
+
+    ws.close(code, reason);
+  }
+
+  /** 
+   * Closes all websockets opened by the useWebsocket hook
+   */ 
+  const closeAllWebsockets = () => {
+    Object.keys(websocketMap.current).forEach(key => {
+      closeWebsocket(key);
+    })
+  }
+
+  /**
+   * Get websocket by id
+   */
+  const getWebsocket = (id: string) => {
+    return websocketMap.current[id];
+  }
+
+  return {
+    newWebsocket,
+    openWebsocket,
+    getWebsocket,
+    closeWebsocket,
+    closeAllWebsockets
+  }
+}

+ 225 - 0
docs/deploy/applications/deploying-from-the-cli.md

@@ -0,0 +1,225 @@
+> 🚧
+> 
+> Deploying applications from the CLI is a `beta` feature at the moment. It may not be entirely stable or work for all possible combinations of builds/deployments. Please bring any issues to the Github or Discord so we can fix them as quickly as possible. 
+
+# Creating a New Application
+
+## Overview
+
+To create a new application via the Porter CLI, you can run:
+
+```sh
+porter create [kind] --app [app-name]
+```
+
+Required args/flags:
+- `kind` can be one of `web`, `worker`, or `job`
+- `app-name` must be a set of lowercase letters or digits separated by `-` 
+
+Each `kind` of application has a set of default values which can be overwritten. For example, `web` applications have the port set to `80`. To overwrite this, for example to port `3000`, create the following file `values.yaml`:
+
+```yaml
+container:
+  port: 3000
+```
+
+And then run the command:
+
+```sh
+porter create web --app web-test --values ./values.yaml
+```
+
+Go to the [common configuration options](#common-configuration-options) section to view `values.yaml` files for common use-cases. You can also view all possible configuration options in the `values.yaml` files of the respective applications: [`web`](https://github.com/porter-dev/porter-charts/blob/master/applications/web/values.yaml), [`worker`](https://github.com/porter-dev/porter-charts/blob/master/applications/worker/values.yaml), and [`job`](https://github.com/porter-dev/porter-charts/blob/master/applications/job/values.yaml).
+
+## Deploying from Local Files 
+
+The default behavior of `porter create` is to use the local filesystem to build, push, and deploy a Docker image. For example, to create a new web application from the current directory, you can simply run:
+
+```sh
+porter create web --app web-test
+```
+
+Porter will look for a `Dockerfile` located at the root of the current directory. If a `Dockerfile` is found, Porter will use the default Docker container registry linked to the Porter project to deploy the application. If a `Dockerfile` is not found, Porter will use a [Cloud-Native Buildpack](https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks) to build your application. 
+
+To point to a Dockerfile, you should pass the **relative path** to the Dockerfile from the root directory of the source code:
+
+```sh
+porter create web --name web-test --dockerfile /my/nested/Dockerfile
+```
+
+To use a cloud-native buildpack instead of a Dockerfile, you can specify the method directly:
+
+```sh
+porter create web --app web-test --method pack
+```
+
+## Deploying from Github
+
+By default, Porter will use the local filesystem to build, push, and deploy your application. Alternatively, if you have a local Git repository whose origin is set to a Github repository that matches one linked on Porter, you can pass in the `--source` flag to deploy your app:      
+
+```sh
+porter create web --app web-test --source github
+```
+
+If your local branch is set to track changes from an upstream remote branch, Porter will try to use the connected `remote` and remote branch as the Github repository to link to. Otherwise, Porter will use the remote given by `origin`, and the same branch name as your local branch. 
+
+## Deploying from a Docker Registry 
+
+The CLI also supports deploying directly from a Docker image which is hosted on a [connected Docker registry](https://docs.getporter.dev/docs/linking-an-existing-docker-container-registry). Simply specify `--source registry` and the application image via the `--image` tag:
+
+```sh
+porter create web --app web-test --source registry --image gcr.io/snowflake-12345/web-test:latest
+```
+
+# Updating an Existing Application
+
+## Overview
+
+You can update an existing application that was deployed from either the dashboard or the CLI. The root command for updating an application is:
+
+```sh
+porter update --app [app-name]
+```
+
+Where `app-name` is the name of a web, worker, or job application on the Porter dashboard. The default behavior of this command is to build a new image using the local filesystem, push this image to the connect image repository, and re-deploy the application on the Porter dashboard. However, each of these steps can be configured. 
+
+As with the `porter create` command, you can update the configuration that an application uses by passing in the `--values` flag, which should pass the filepath to a `values.yaml` file. **Note that this command merges the `values.yaml` file with your existing configuration, so you should only specify options that you would like to modify**. For example, the following `values.yaml` file:
+
+```yaml
+container:
+  port: 8080
+```
+
+Would only update the container port to `8080`, while keeping your existing configuration, after running the command: 
+
+```sh
+porter update --app --values ./values.yaml
+```
+
+Go to the [common configuration options](#common-configuration-options) section to view `values.yaml` files for common use-cases. You can also view all possible configuration options in the `values.yaml` files of the respective applications: [`web`](https://github.com/porter-dev/porter-charts/blob/master/applications/web/values.yaml), [`worker`](https://github.com/porter-dev/porter-charts/blob/master/applications/worker/values.yaml), and [`job`](https://github.com/porter-dev/porter-charts/blob/master/applications/job/values.yaml).
+
+## Building from Local Files 
+
+The default behavior of this command will vary depending on if the application already has a Github repository source specified:
+- If this application has a linked Github repository source, it will use the build settings from the linked source. That is, if the Github build settings specify a Dockerfile, this command will use the path to that Dockerfile. 
+- If the application does not have a linked source, this command will default to using a Dockerfile located at the root of the directory, at the path `./Dockerfile`. 
+
+ These default behaviors can be overwritten through a combination of the `--method` flag, the `--dockerfile` flag, and the `--path` flag:
+
+## Building from Github 
+
+If you specify `--source github`, this command will look for a remote Github repository that has been linked to this application. If one is found, the command will download an archive of the Github repository from the latest commit of the linked branch, and will use that as the filesystem to build from. 
+
+## Updating Configuration without Building
+
+If you would only like to update the configuration for your application via a `values.yaml` file (without building a new image), you can do so with the following command:
+
+```sh
+porter update config --app [app-name] --values [values-file]
+```
+
+# Common Configuration Options
+
+## Container Port
+
+```yaml
+container:
+  port: 3000
+```
+
+## Container Start Command
+
+```yaml
+container:
+  command: npm start
+```
+
+## [`web`] Un-exposing a Web Application
+
+This configuration only applies to `web` applications. 
+
+```yaml
+ingress:
+  enabled: false
+```
+
+## [`web`] Exposing a Web Application on a Custom Domain
+
+This configuration only applies to `web` applications. 
+
+```yaml
+ingress:
+  custom_domain: true
+  custom_paths:
+  - my-app.example.com
+```
+
+# Writing Custom Deployment Pipelines
+
+While this will be a subject of a separate guide soon, this section provides an overview of how you might use certain subcommands to build your own deployment pipeline. By default, the command `porter update` performs four steps: gets the environment variables for the application, builds a new Docker container from the source files, pushes a new Docker image to the remote registry, and calls a Porter endpoint to re-deploy the application. However, we designed this command to be modular: if you would like to add intermediate steps in your own build process, you can call different `porter update` sub-commands separately:
+
+- [`porter update get-env`](#porter-update-get-env) - prints the build environment variables to the terminal or a file.  
+- [`porter update build`](#porter-update-build) - builds the Docker container used for deployment.
+- [`porter update push`](#porter-update-push) - pushes the Docker container used for deployment to a remote registry.
+- [`porter update config`](#porter-update-config) - calls a Porter endpoint to re-deploy the application with new configuration. 
+
+### `porter update get-env`
+
+Gets environment variables for a deployment for a specified application given by the `--app` flag. By default, env variables are printed via stdout for use in downstream commands:
+
+```sh
+porter update get-env --app example-app | xargs
+```
+
+Output can also be written to a dotenv file via the `--file` flag, which should specify the destination path for a `.env` file. For example:
+
+```sh
+porter update get-env --app example-app --file .env
+```
+
+### `porter update build`
+
+Builds a new version of the application specified by the `--app` flag. Depending on the configured settings, this command may work automatically or will require a specified `--method` flag. 
+
+If you have configured the Dockerfile path and/or a build context for this application, this command will by default use those settings, so you just need to specify the `--app` flag:
+
+```sh
+porter update build --app example-app
+```
+
+If you have not linked the build-time requirements for this application, the command will use a local build. By default, the cloud-native buildpacks builder will automatically be run from the current directory. If you would like to change the build method, you can do so by using the `--method` flag, for example:
+
+```sh
+porter update build --app example-app --method docker
+```
+
+When using `--method docker`, you can specify the path to the Dockerfile using the `--dockerfile` flag. This will also override the Dockerfile path that you may have linked for the application:
+
+```sh
+porter update build --app example-app --method docker --dockerfile ./prod.Dockerfile
+```
+
+### `porter update push`
+
+Pushes a new image for an application specified by the --app flag. This command uses the image repository saved in the application config by default. For example, if an application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx", the following command would push the image "gcr.io/snowflake-123456/nginx:new-tag":
+
+```sh
+porter update push --app nginx --tag new-tag
+```
+
+This command will not use your pre-saved authentication set up via `docker login`, so if you are using an image registry that was created outside of Porter, make sure that you have linked it via `porter connect`.
+
+### `porter update config`
+
+Updates the configuration for an application specified by the --app flag, using the configuration given by the --values flag. This will trigger a new deployment for the application with new configuration set. Note that this will merge your existing configuration with configuration specified in the --values file. For example:
+
+```sh
+porter update config --app example-app --values my-values.yaml
+```
+
+You can update the configuration with only a new tag with the --tag flag, which will only update
+the image that the application uses if no --values file is specified:
+
+```sh
+porter update config --app example-app --tag custom-tag
+```

+ 5 - 1
docs/guides/preserving-client-ip-addresses.md

@@ -1,5 +1,9 @@
 # AWS
 
+> 🚧
+> 
+> Changing this configuration may result in a few minutes of downtime. It is recommended to set up client IP addresses before the application is live, or update it during a maintenance window. For more information, see [this Github issue](https://github.com/porter-dev/porter/issues/632#issuecomment-832939982).
+ 
 You will need to update your NGINX config to support proxying external IP addresses to Porter.
 
 In the `ingress-nginx` application, you'll be modifying the following Helm values:
@@ -64,4 +68,4 @@ It should look something like this:
 
 ![Healthz config](https://files.readme.io/9b5432a-Screen_Shot_2021-05-10_at_4.24.13_PM.png "Screen Shot 2021-05-10 at 4.24.13 PM.png")
 
-5. Click "Deploy". It will take 10-15 minutes for the load balancer to be created and the certificates to be issued.
+5. Click "Deploy". It will take 10-15 minutes for the load balancer to be created and the certificates to be issued.

+ 23 - 48
go.mod

@@ -5,93 +5,68 @@ go 1.15
 require (
 	cloud.google.com/go v0.65.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
-	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
-	github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
-	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/aws/aws-sdk-go v1.35.4
-	github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20201113001948-d77edb6d2e47
-	github.com/containerd/containerd v1.4.1 // indirect
-	github.com/coreos/rkt v1.30.0
-	github.com/creack/pty v1.1.11 // indirect
+	github.com/buildpacks/pack v0.19.0
+	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.56.0
-	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
+	github.com/docker/cli v20.10.5+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
-	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
+	github.com/docker/docker v20.10.0-beta1.0.20201110211921-af34b94a78a1+incompatible
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
-	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
-	github.com/go-redis/redis v6.15.9+incompatible
-	github.com/go-redis/redis/v7 v7.4.0
 	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-test/deep v1.0.7
-	github.com/google/go-cmp v0.5.2
 	github.com/google/go-github v17.0.0+incompatible
-	github.com/google/go-github/v32 v32.1.0
 	github.com/google/go-github/v33 v33.0.0
-	github.com/google/go-querystring v1.0.0 // indirect
-	github.com/googleapis/gnostic v0.2.2 // indirect
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2
 	github.com/hashicorp/golang-lru v0.5.3 // indirect
-	github.com/imdario/mergo v0.3.11 // indirect
-	github.com/itchyny/gojq v0.11.1
+	github.com/hokaccha/go-prettyjson v0.0.0-20190818114111-108c894c2c0e // indirect
+	github.com/itchyny/gojq v0.12.1
 	github.com/itchyny/timefmt-go v0.1.1 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
-	github.com/json-iterator/go v1.1.10 // indirect
-	github.com/kr/pretty v0.2.0 // indirect
-	github.com/kr/text v0.2.0 // indirect
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
-	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
-	github.com/onsi/ginkgo v1.14.2 // indirect
-	github.com/opentracing/opentracing-go v1.2.0 // indirect
-	github.com/pelletier/go-toml v1.8.1 // indirect
+	github.com/moby/moby v20.10.6+incompatible
+	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
+	github.com/opencontainers/image-spec v1.0.1
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
-	github.com/sirupsen/logrus v1.7.0
-	github.com/spf13/cobra v1.0.0
-	github.com/spf13/viper v1.4.0
-	github.com/stretchr/testify v1.6.1
+	github.com/spf13/cobra v1.1.3
+	github.com/spf13/pflag v1.0.5
+	github.com/spf13/viper v1.7.0
+	github.com/stretchr/testify v1.7.0
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
-	go.opentelemetry.io/otel v0.13.0 // indirect
-	golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
-	golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 // indirect
+	golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	golang.org/x/sys v0.0.0-20210108172913-0df2131ae363 // indirect
-	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	google.golang.org/api v0.30.0
-	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
-	google.golang.org/grpc v1.33.0 // indirect
-	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
+	google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
-	gopkg.in/yaml.v2 v2.3.0
+	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.20.2
-	gotest.tools/v3 v3.0.3 // indirect
-	helm.sh/helm/v3 v3.3.4
-	k8s.io/api v0.18.8
-	k8s.io/apimachinery v0.18.8
-	k8s.io/cli-runtime v0.18.8
-	k8s.io/client-go v0.18.8
+	helm.sh/helm/v3 v3.6.0
+	k8s.io/api v0.21.0
+	k8s.io/apimachinery v0.21.0
+	k8s.io/cli-runtime v0.21.0
+	k8s.io/client-go v0.21.0
 	k8s.io/helm v2.16.12+incompatible
-	k8s.io/klog/v2 v2.2.0 // indirect
-	k8s.io/kubectl v0.18.8
-	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
+	k8s.io/kubectl v0.21.0
 	rsc.io/letsencrypt v0.0.3 // indirect
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
 	sigs.k8s.io/yaml v1.2.0
-)
+)

Разница между файлами не показана из-за своего большого размера
+ 209 - 118
go.sum


+ 6 - 0
internal/forms/registry.go

@@ -77,3 +77,9 @@ func (urf *UpdateRegistryForm) ToRegistry(repo repository.RegistryRepository) (*
 
 	return registry, nil
 }
+
+// CreateRepository represents the accepted values for creating an image repository
+// within a registry
+type CreateRepository struct {
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
+}

+ 7 - 0
internal/forms/release.go

@@ -130,3 +130,10 @@ type InstallChartTemplateForm struct {
 	// optional git action config
 	GithubActionConfig *CreateGitActionOptional `json:"github_action,omitempty"`
 }
+
+// UpdateImageForm represents the accepted values for updating a Helm release's image
+type UpdateImageForm struct {
+	*ReleaseForm
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
+	Tag          string `json:"tag" form:"required"`
+}

+ 1 - 1
internal/helm/agent_test.go

@@ -21,7 +21,7 @@ func newAgentFixture(t *testing.T, namespace string) *helm.Agent {
 		Namespace: namespace,
 	}
 
-	return helm.GetAgentTesting(form, nil, l)
+	return helm.GetAgentTesting(form, nil, l, nil)
 }
 
 type releaseStub struct {

+ 2 - 1
internal/helm/config.go

@@ -104,7 +104,7 @@ func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 }
 
 // GetAgentTesting creates a new Agent using an optional existing storage class
-func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Agent {
+func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger, k8sAgent *kubernetes.Agent) *Agent {
 	testStorage := storage
 
 	if testStorage == nil {
@@ -122,5 +122,6 @@ func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Ag
 			Capabilities: chartutil.DefaultCapabilities,
 			Log:          l.Printf,
 		},
+		K8sAgent: k8sAgent,
 	}
 }

+ 4 - 0
internal/models/gitrepo.go

@@ -76,6 +76,9 @@ type GitActionConfigExternal struct {
 	// The git repo in ${owner}/${repo} form
 	GitRepo string `json:"git_repo"`
 
+	// The git branch to use
+	GitBranch string `json:"git_branch"`
+
 	// The complete image repository uri to pull from
 	ImageRepoURI string `json:"image_repo_uri"`
 
@@ -93,6 +96,7 @@ type GitActionConfigExternal struct {
 func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 	return &GitActionConfigExternal{
 		GitRepo:        r.GitRepo,
+		GitBranch:      r.GitBranch,
 		ImageRepoURI:   r.ImageRepoURI,
 		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,

+ 10 - 5
internal/models/release.go

@@ -10,11 +10,16 @@ import (
 type Release struct {
 	gorm.Model
 
-	WebhookToken    string          `json:"webhook_token" gorm:"unique"`
-	ClusterID       uint            `json:"cluster_id"`
-	ProjectID       uint            `json:"project_id"`
-	Name            string          `json:"name"`
-	Namespace       string          `json:"namespace"`
+	WebhookToken string `json:"webhook_token" gorm:"unique"`
+	ClusterID    uint   `json:"cluster_id"`
+	ProjectID    uint   `json:"project_id"`
+	Name         string `json:"name"`
+	Namespace    string `json:"namespace"`
+
+	// The complete image repository uri to pull from. This is also stored in GitActionConfig,
+	// but this should be used for the source of truth going forward.
+	ImageRepoURI string `json:"image_repo_uri,omitempty"`
+
 	GitActionConfig GitActionConfig `json:"git_action_config"`
 }
 

+ 20 - 2
internal/registry/registry.go

@@ -491,9 +491,27 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
+	imageDetails := describeResp.ImageDetails
+
+	nextToken := describeResp.NextToken
+
+	for nextToken != nil {
+		describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+			RepositoryName: &repoName,
+			ImageIds:       resp.ImageIds,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		nextToken = describeResp.NextToken
+		imageDetails = append(imageDetails, describeResp.ImageDetails...)
+	}
+
 	res := make([]*Image, 0)
 
-	for _, img := range describeResp.ImageDetails {
+	for _, img := range imageDetails {
 		for _, tag := range img.ImageTags {
 			res = append(res, &Image{
 				Digest:         *img.ImageDigest,
@@ -920,4 +938,4 @@ func (r *Registry) getPrivateRegistryDockerConfigFile(
 
 func generateAuthToken(username, password string) string {
 	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
-}
+}

+ 15 - 0
internal/repository/gorm/release.go

@@ -34,6 +34,21 @@ func (repo *ReleaseRepository) ReadRelease(clusterID uint, name, namespace strin
 	return release, nil
 }
 
+// ReadRelease finds a single release based on their unique name and namespace pair.
+func (repo *ReleaseRepository) ListReleasesByImageRepoURI(clusterID uint, imageRepoURI string) ([]*models.Release, error) {
+	releases := make([]*models.Release, 0)
+
+	if imageRepoURI == "" {
+		return releases, nil
+	}
+
+	if err := repo.db.Preload("GitActionConfig").Where("cluster_id = ?", clusterID).Where("image_repo_uri = ?", imageRepoURI).Find(&releases).Error; err != nil {
+		return nil, err
+	}
+
+	return releases, nil
+}
+
 // ReadReleaseByWebhookToken finds a single release based on their unique webhook token.
 func (repo *ReleaseRepository) ReadReleaseByWebhookToken(token string) (*models.Release, error) {
 	release := &models.Release{}

+ 58 - 0
internal/repository/gorm/release_test.go

@@ -1,8 +1,10 @@
 package gorm_test
 
 import (
+	"fmt"
 	"testing"
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/models"
 	orm "gorm.io/gorm"
 )
@@ -55,6 +57,62 @@ func TestCreateRelease(t *testing.T) {
 	}
 }
 
+func TestListReleasesByImageRepoURI(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_releases.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+
+	imageRepoURIs := []string{
+		"uri1",
+		"uri2",
+		"uri3",
+		"uri1",
+		"uri1",
+	}
+
+	releases := make([]*models.Release, 0)
+
+	for i, uri := range imageRepoURIs {
+		release := &models.Release{
+			Name:         fmt.Sprintf("denver-meister-dakota-%d", i),
+			Namespace:    "default",
+			ProjectID:    1,
+			ClusterID:    1,
+			WebhookToken: fmt.Sprintf("abcdefgh-%d", i),
+			ImageRepoURI: uri,
+		}
+
+		release, err := tester.repo.Release.CreateRelease(release)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		if uri == "uri1" {
+			releases = append(releases, release)
+		}
+	}
+
+	resReleases, err := tester.repo.Release.ListReleasesByImageRepoURI(1, "uri1")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure resulting arrays match
+	if len(resReleases) != 3 {
+		t.Fatalf("length of resulting release list not 3")
+	}
+
+	if diff := deep.Equal(releases, resReleases); diff != nil {
+		t.Errorf("release entry not equal:")
+		t.Error(diff)
+	}
+}
+
 func TestDeleteRelease(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_delete_release.db",

+ 1 - 0
internal/repository/release.go

@@ -12,6 +12,7 @@ type ReleaseRepository interface {
 	CreateRelease(release *models.Release) (*models.Release, error)
 	ReadRelease(clusterID uint, name, namespace string) (*models.Release, error)
 	ReadReleaseByWebhookToken(token string) (*models.Release, error)
+	ListReleasesByImageRepoURI(clusterID uint, imageRepoURI string) ([]*models.Release, error)
 	UpdateRelease(release *models.Release) (*models.Release, error)
 	DeleteRelease(release *models.Release) (*models.Release, error)
 }

+ 5 - 0
internal/templater/dynamic/reader.go

@@ -58,6 +58,11 @@ func NewDynamicTemplateReader(client dynamic.Interface, obj *Object) templater.T
 		Resource: r.Object.Resource,
 	}
 
+	// just case on the "core" group and unset it
+	if r.Object.Group == "core" {
+		objRes.Group = ""
+	}
+
 	r.gvr = objRes
 
 	r.resource = r.Client.Resource(objRes).Namespace(r.Object.Namespace)

+ 13 - 2
server/api/deploy_handler.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -105,7 +106,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 	}
 
-	_, err = agent.InstallChart(conf, app.DOConf)
+	rel, err := agent.InstallChart(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -124,12 +125,21 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// create release with webhook token in db
+	repository := rel.Config["image"].(map[string]interface{})["repository"]
+	repoStr, ok := repository.(string)
+
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field repository in config"), w)
+		return
+	}
+
 	release := &models.Release{
 		ClusterID:    form.ReleaseForm.Form.Cluster.ID,
 		ProjectID:    form.ReleaseForm.Form.Cluster.ProjectID,
 		Namespace:    form.ReleaseForm.Form.Namespace,
 		Name:         form.ChartTemplateForm.Name,
 		WebhookToken: token,
+		ImageRepoURI: repoStr,
 	}
 
 	_, err = app.Repo.Release.CreateRelease(release)
@@ -146,6 +156,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		gaForm := &forms.CreateGitAction{
 			ReleaseID:      release.ID,
 			GitRepo:        form.GithubActionConfig.GitRepo,
+			GitBranch:      form.GithubActionConfig.GitBranch,
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
@@ -159,7 +170,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		app.createGitActionFromForm(projID, release, name, gaForm, w, r)
+		app.createGitActionFromForm(projID, release, form.ChartTemplateForm.Name, gaForm, w, r)
 	}
 
 	w.WriteHeader(http.StatusOK)

+ 21 - 0
server/api/git_action_handler.go

@@ -139,6 +139,17 @@ func (app *App) createGitActionFromForm(
 
 	userID, _ := session.Values["user_id"].(uint)
 
+	if userID == 0 {
+		tok := app.getTokenFromRequest(r)
+
+		if tok != nil && tok.IBy != 0 {
+			userID = tok.IBy
+		} else if tok == nil || tok.IBy == 0 {
+			http.Error(w, "no user id found in request", http.StatusInternalServerError)
+			return nil
+		}
+	}
+
 	// generate porter jwt token
 	jwt, _ := token.GetTokenForAPI(userID, uint(projID))
 
@@ -187,5 +198,15 @@ func (app *App) createGitActionFromForm(
 
 	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
 
+	// update the release in the db with the image repo uri
+	release.ImageRepoURI = gitAction.ImageRepoURI
+
+	_, err = app.Repo.Release.UpdateRelease(release)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return nil
+	}
+
 	return ga.Externalize()
 }

+ 55 - 0
server/api/git_repo_handler.go

@@ -377,6 +377,61 @@ func (app *App) HandleGetProcfileContents(w http.ResponseWriter, r *http.Request
 	json.NewEncoder(w).Encode(parsedContents)
 }
 
+type HandleGetRepoZIPDownloadURLResp struct {
+	URLString       string `json:"url"`
+	LatestCommitSHA string `json:"latest_commit_sha"`
+}
+
+// HandleGetRepoZIPDownloadURL gets the URL for downloading a zip file from a Github
+// repository
+func (app *App) HandleGetRepoZIPDownloadURL(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.githubTokenFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+	owner := chi.URLParam(r, "owner")
+	name := chi.URLParam(r, "name")
+	branch := chi.URLParam(r, "branch")
+
+	branchResp, _, err := client.Repositories.GetBranch(
+		context.TODO(),
+		owner,
+		name,
+		branch,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	ghURL, _, err := client.Repositories.GetArchiveLink(
+		context.TODO(),
+		owner,
+		name,
+		github.Zipball,
+		&github.RepositoryContentGetOptions{
+			Ref: *branchResp.Commit.SHA,
+		},
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	apiResp := HandleGetRepoZIPDownloadURLResp{
+		URLString:       ghURL.String(),
+		LatestCommitSHA: *branchResp.Commit.SHA,
+	}
+
+	json.NewEncoder(w).Encode(apiResp)
+}
+
 // finds the github token given the git repo id and the project id
 func (app *App) githubTokenFromRequest(
 	r *http.Request,

+ 9 - 3
server/api/helpers_test.go

@@ -39,12 +39,16 @@ func (t *tester) reset() {
 }
 
 func (t *tester) createUserSession(email string, pw string) {
-	req, _ := http.NewRequest(
+	req, err := http.NewRequest(
 		"POST",
 		"/api/users",
 		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
 	)
 
+	if err != nil {
+		panic(err)
+	}
+
 	t.req = req
 	t.execute()
 
@@ -67,6 +71,7 @@ func newTester(canQuery bool) *tester {
 			TimeoutIdle:          time.Second * 15,
 			IsTesting:            true,
 			TokenGeneratorSecret: "secret",
+			BasicLoginEnabled:    true,
 		},
 		// unimportant here
 		Db: config.DBConf{},
@@ -79,15 +84,16 @@ func newTester(canQuery bool) *tester {
 	logger := lr.NewConsole(appConf.Debug)
 	repo := memory.NewRepository(canQuery)
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
+	k8sAgent := kubernetes.GetAgentTesting()
 
 	app, _ := api.New(&api.AppConfig{
 		Logger:     logger,
 		Repository: repo,
 		ServerConf: appConf.Server,
 		TestAgents: &api.TestAgents{
-			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
+			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger, k8sAgent),
 			HelmTestStorageDriver: helm.StorageMap["memory"](nil, nil, ""),
-			K8sAgent:              kubernetes.GetAgentTesting(),
+			K8sAgent:              k8sAgent,
 		},
 	})
 

+ 5 - 1
server/api/project_handler_test.go

@@ -141,7 +141,11 @@ func TestHandleDeleteProject(t *testing.T) {
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func initProject(tester *tester) {
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+	user, err := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+
+	if err != nil {
+		panic(err)
+	}
 
 	// handle write to the database
 	projModel, _ := tester.repo.Project.CreateProject(&models.Project{

+ 56 - 0
server/api/registry_handler.go

@@ -72,6 +72,62 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateRepository creates a new image repository in a registry, if the registry
+// does not allow for create-on-push behavior
+func (app *App) HandleCreateRepository(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	regID, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateRepository{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// read the registry
+	reg, err := app.Repo.Registry.ReadRegistry(uint(regID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	// parse the name from the registry
+	nameSpl := strings.Split(form.ImageRepoURI, "/")
+	repoName := nameSpl[len(nameSpl)-1]
+
+	err = regAPI.CreateRepository(*app.Repo, repoName)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+}
+
 // HandleListProjectRegistries returns a list of registries for a project
 func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)

+ 164 - 4
server/api/release_handler.go

@@ -7,6 +7,7 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
+	"sync"
 
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
@@ -77,9 +78,11 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 // PorterRelease is a helm release with a form attached
 type PorterRelease struct {
 	*release.Release
-	Form          *models.FormYAML `json:"form"`
-	HasMetrics    bool             `json:"has_metrics"`
-	LatestVersion string           `json:"latest_version"`
+	Form            *models.FormYAML                `json:"form"`
+	HasMetrics      bool                            `json:"has_metrics"`
+	LatestVersion   string                          `json:"latest_version"`
+	GitActionConfig *models.GitActionConfigExternal `json:"git_action_config"`
+	ImageRepoURI    string                          `json:"image_repo_uri"`
 }
 
 var porterApplications = map[string]string{"web": "", "job": "", "worker": ""}
@@ -161,7 +164,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		HelmRelease:   release,
 	}
 
-	res := &PorterRelease{release, nil, false, ""}
+	res := &PorterRelease{release, nil, false, "", nil, ""}
 
 	for _, file := range release.Chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
@@ -212,6 +215,19 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// if the release was created from this server,
+	modelRelease, err := app.Repo.Release.ReadRelease(form.Cluster.ID, release.Name, release.Namespace)
+
+	if modelRelease != nil {
+		res.ImageRepoURI = modelRelease.ImageRepoURI
+
+		gitAction := modelRelease.GitActionConfig
+
+		if gitAction.ID != 0 {
+			res.GitActionConfig = gitAction.Externalize()
+		}
+	}
+
 	if err := json.NewEncoder(w).Encode(res); err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
@@ -771,6 +787,24 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
 
 		if release != nil {
+			// update image repo uri if changed
+			repository := rel.Config["image"].(map[string]interface{})["repository"]
+			repoStr, ok := repository.(string)
+
+			if !ok {
+				app.handleErrorInternal(fmt.Errorf("Could not find field repository in config"), w)
+				return
+			}
+
+			if repoStr != release.ImageRepoURI {
+				release, err = app.Repo.Release.UpdateRelease(release)
+
+				if err != nil {
+					app.handleErrorInternal(err, w)
+					return
+				}
+			}
+
 			gitAction := release.GitActionConfig
 
 			if gitAction.ID != 0 {
@@ -943,6 +977,114 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleReleaseJobUpdateImage
+func (app *App) HandleReleaseUpdateJobImages(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	form := &forms.UpdateImageForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+	}
+
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.Repo.Cluster,
+	)
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	releases, err := app.Repo.Release.ListReleasesByImageRepoURI(form.Cluster.ID, form.ImageRepoURI)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"releases not found with given image repo uri"},
+		}, w)
+
+		return
+	}
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	// errors are handled in app.getAgentFromBodyParams
+	if err != nil {
+		return
+	}
+
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// asynchronously update releases with that image repo uri
+	var wg sync.WaitGroup
+	mu := &sync.Mutex{}
+	errors := make([]string, 0)
+
+	for i := range releases {
+		index := i
+		wg.Add(1)
+
+		go func() {
+			defer wg.Done()
+			// read release via agent
+			rel, err := agent.GetRelease(releases[index].Name, 0)
+
+			if err != nil {
+				mu.Lock()
+				errors = append(errors, err.Error())
+				mu.Unlock()
+			}
+
+			if rel.Chart.Name() == "job" {
+				image := map[string]interface{}{}
+				image["repository"] = releases[index].ImageRepoURI
+				image["tag"] = form.Tag
+				rel.Config["image"] = image
+				rel.Config["paused"] = true
+
+				conf := &helm.UpgradeReleaseConfig{
+					Name:       releases[index].Name,
+					Cluster:    form.ReleaseForm.Cluster,
+					Repo:       *app.Repo,
+					Registries: registries,
+					Values:     rel.Config,
+				}
+
+				_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
+
+				if err != nil {
+					mu.Lock()
+					errors = append(errors, err.Error())
+					mu.Unlock()
+				}
+			}
+		}()
+	}
+
+	wg.Wait()
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleRollbackRelease rolls a release back to a specified revision
 func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
@@ -1022,6 +1164,24 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 		release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, rel.Namespace)
 
 		if release != nil {
+			// update image repo uri if changed
+			repository := rel.Config["image"].(map[string]interface{})["repository"]
+			repoStr, ok := repository.(string)
+
+			if !ok {
+				app.handleErrorInternal(fmt.Errorf("Could not find field repository in config"), w)
+				return
+			}
+
+			if repoStr != release.ImageRepoURI {
+				release, err = app.Repo.Release.UpdateRelease(release)
+
+				if err != nil {
+					app.handleErrorInternal(err, w)
+					return
+				}
+			}
+
 			gitAction := release.GitActionConfig
 
 			if gitAction.ID != 0 {

+ 1 - 1
server/middleware/auth.go

@@ -188,7 +188,7 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 
 		var userID uint
 
-		if tok != nil && tok.ProjectID == uint(projID) {
+		if tok != nil && tok.ProjectID != 0 && tok.ProjectID == uint(projID) {
 			next.ServeHTTP(w, r)
 			return
 		} else if tok != nil {

+ 42 - 0
server/router/router.go

@@ -823,6 +823,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/registries/{registry_id}/repository",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveRegistryAccess(
+						requestlog.NewHandler(a.HandleCreateRepository, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/registries/ecr/{region}/token",
@@ -1103,6 +1117,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/tarball_url",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveGitRepoAccess(
+						requestlog.NewHandler(a.HandleGetRepoZIPDownloadURL, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/k8s routes
 			r.Method(
 				"GET",
@@ -1501,6 +1529,20 @@ func New(a *api.App) *chi.Mux {
 					mw.ReadAccess,
 				),
 			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/image/update/batch",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleReleaseUpdateJobImages, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
 		})
 	})
 

Некоторые файлы не были показаны из-за большого количества измененных файлов