Browse Source

porter deploy commands impemented

Alexander Belanger 5 years ago
parent
commit
c6b71b867f

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

@@ -0,0 +1,74 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"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
+}

+ 1 - 1
cli/cmd/auth.go

@@ -220,7 +220,7 @@ func register() error {
 		return err
 	}
 
-	client := GetAPIClient()
+	client := GetAPIClient(config)
 
 	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
 		Email:    username,

+ 13 - 1
cli/cmd/config.go

@@ -46,6 +46,18 @@ type CLIConfig struct {
 //
 // 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
@@ -94,7 +106,7 @@ func InitAndLoadConfig() {
 	}
 
 	// unmarshal the config into the shared config struct
-	viper.Unmarshal(config)
+	viper.Unmarshal(_config)
 }
 
 // initFlagSet initializes the shared flags used by multiple commands

+ 71 - 231
cli/cmd/deploy.go

@@ -1,42 +1,22 @@
 package cmd
 
 import (
-	"context"
-	"errors"
 	"fmt"
-	"io/ioutil"
 	"os"
-	"path/filepath"
-	"strings"
 
 	"github.com/fatih/color"
 	"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/cli/cmd/deploy"
 	"github.com/spf13/cobra"
 )
 
-var app = ""
-
 // deployCmd represents the "porter deploy" base command when called
 // without any subcommands
 var deployCmd = &cobra.Command{
 	Use:   "deploy",
 	Short: "Builds and deploys a specified application given by the --app flag.",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deploy)
-
-		if err != nil {
-			os.Exit(1)
-		}
-	},
-}
-
-var deployInitCmd = &cobra.Command{
-	Use:   "init",
-	Short: "Initializes a deployment for a specified application given by the --app flag.",
-	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployInit)
+		err := checkLoginAndRun(args, deployFull)
 
 		if err != nil {
 			os.Exit(1)
@@ -44,22 +24,20 @@ var deployInitCmd = &cobra.Command{
 	},
 }
 
-var getEnvFileDest = ""
-
 var deployGetEnvCmd = &cobra.Command{
 	Use:   "get-env",
 	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
-	Long: fmt.Sprintf(`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, for example:
-	
+	Long: fmt.Sprintf(`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 dotenv file via the --file flag, which should specify the destination
 path for a .env file. For example:
 
   %s
 `,
-		color.New(color.FgGreen).Sprintf("porter deploy get-env --app <app> | xargs"),
+		color.New(color.FgGreen).Sprintf("porter deploy get-env --app <app>"),
 		color.New(color.FgGreen).Sprintf("porter deploy get-env --app <app> --file .env"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
@@ -73,7 +51,7 @@ path for a .env file. For example:
 
 var deployBuildCmd = &cobra.Command{
 	Use:   "build",
-	Short: "TBD",
+	Short: "Builds a new version of the application specified by the --app flag.",
 	Run: func(cmd *cobra.Command, args []string) {
 		err := checkLoginAndRun(args, deployBuild)
 
@@ -83,6 +61,9 @@ var deployBuildCmd = &cobra.Command{
 	},
 }
 
+var app string
+var getEnvFileDest string
+
 func init() {
 	rootCmd.AddCommand(deployCmd)
 
@@ -93,7 +74,13 @@ func init() {
 		"Application in the Porter dashboard",
 	)
 
-	deployCmd.AddCommand(deployInitCmd)
+	deployCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"Namespace of the application",
+	)
+
 	deployCmd.AddCommand(deployGetEnvCmd)
 
 	deployGetEnvCmd.PersistentFlags().StringVar(
@@ -106,261 +93,114 @@ func init() {
 	deployCmd.AddCommand(deployBuildCmd)
 }
 
-func deploy(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+func deployFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Deploying app:", app)
 
-	return deployInit(resp, client, args)
-}
-
-var release *api.GetReleaseResponse = nil
-
-// deployInit first reads the release given by the --app or the --job flag. It then
-// configures docker with the registries linked to the project.
-func deployInit(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	pID := config.Project
-	cID := config.Cluster
-
-	var err error
-
-	release, err = client.GetRelease(context.TODO(), pID, cID, namespace, app)
+	// initialize the deploy agent
+	deployAgent, err := deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
+		ProjectID: config.Project,
+		ClusterID: config.Cluster,
+		Namespace: namespace,
+	})
 
 	if err != nil {
 		return err
 	}
 
-	return dockerConfig(resp, client, args)
-}
-
-// deployGetEnv retrieves the env from a release and outputs it to either a file
-// or stdout depending on getEnvFileDest
-func deployGetEnv(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	if release == nil {
-		err := deployInit(resp, client, args)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	prefix, err := deploySetEnv(client)
+	buildEnv, err := deployAgent.GetBuildEnv()
 
 	if err != nil {
 		return err
 	}
 
-	// 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, prefix+"_") {
-			lines = append(lines, strings.Split(line, prefix+"_")[1])
-		}
-	}
-
-	output := strings.Join(lines, "\n")
-
-	// case on output type
-	if getEnvFileDest != "" {
-		ioutil.WriteFile(getEnvFileDest, []byte(output), 0700)
-	} else {
-		fmt.Println(output)
-	}
-
-	return nil
-}
-
-func deploySetEnv(client *api.Client) (prefix string, err error) {
-	prefix = fmt.Sprintf("PORTER_%s", strings.Replace(
-		strings.ToUpper(app), "-", "_", -1,
-	))
-
-	envVars, err := getEnvFromRelease()
-
-	if err != nil {
-		return prefix, err
-	}
-
-	// iterate through env and set the environment variables for the process
-	// these are prefixed with PORTER_<RELEASE> to avoid collisions
-	for key, val := range envVars {
-		prefixedKey := fmt.Sprintf("%s_%s", prefix, key)
-
-		err := os.Setenv(prefixedKey, val)
-
-		if err != nil {
-			return prefix, err
-		}
-	}
-
-	return prefix, nil
-}
-
-func getEnvFromRelease() (map[string]string, error) {
-	envConfig, err := getNestedMap(release.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")
-		}
-
-		mapEnvConfig[key] = valStr
-	}
-
-	return mapEnvConfig, 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
-}
-
-func deployBuild(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	if release == nil {
-		err := deployInit(resp, client, args)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	zipResp, err := client.GetRepoZIPDownloadURL(
-		context.Background(),
-		config.Project,
-		release.GitActionConfig,
-	)
+	// set the environment variables in the process
+	err = deployAgent.SetBuildEnv(buildEnv)
 
 	if err != nil {
 		return err
 	}
 
-	// download the repository from remote source into a temp directory
-	dst, err := downloadRepoToDir(zipResp.URLString)
+	// build the deployment
+	color.New(color.FgGreen).Println("Building docker image for", app)
 
-	if err != nil {
-		return err
-	}
-
-	agent, err := docker.NewAgentFromEnv()
+	err = deployAgent.Build()
 
 	if err != nil {
 		return err
 	}
 
-	err = pullCurrentReleaseImage(agent)
+	// push the deployment
+	color.New(color.FgGreen).Println("Deploying new application for", app)
+
+	err = deployAgent.Deploy()
 
 	if err != nil {
 		return err
 	}
 
-	// case on Dockerfile path
-	if release.GitActionConfig.DockerfilePath != "" {
-		return agent.BuildLocal(
-			release.GitActionConfig.DockerfilePath,
-			release.GitActionConfig.ImageRepoURI,
-			dst,
-		)
-	}
+	color.New(color.FgGreen).Println("Successfully deployed", app)
 
 	return nil
 }
 
-func pullCurrentReleaseImage(agent *docker.Agent) error {
-	// pull the currently deployed image to use cache, if possible
-	imageConfig, err := getNestedMap(release.Config, "image")
+func deployGetEnv(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	// initialize the deploy agent
+	deployAgent, err := deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
+		ProjectID: config.Project,
+		ClusterID: config.Cluster,
+		Namespace: namespace,
+	})
 
 	if err != nil {
-		return fmt.Errorf("could not get image config from release: %s", err.Error())
+		return err
 	}
 
-	tagInterface, ok := imageConfig["tag"]
+	buildEnv, err := deployAgent.GetBuildEnv()
 
-	if !ok {
-		return fmt.Errorf("tag field does not exist for image")
+	if err != nil {
+		return err
 	}
 
-	tagStr, ok := tagInterface.(string)
+	// set the environment variables in the process
+	err = deployAgent.SetBuildEnv(buildEnv)
 
-	if !ok {
-		return fmt.Errorf("could not cast image.tag field to string")
+	if err != nil {
+		return err
 	}
 
-	return agent.PullImage(fmt.Sprintf("%s:%s", release.GitActionConfig.ImageRepoURI, tagStr))
+	// write the environment variables to either a file or stdout (stdout by default)
+	return deployAgent.WriteBuildEnv(getEnvFileDest)
 }
 
-func downloadRepoToDir(downloadURL string) (string, error) {
-	dstDir := filepath.Join(home, ".porter")
-
-	downloader := &github.ZIPDownloader{
-		ZipFolderDest:       dstDir,
-		AssetFolderDest:     dstDir,
-		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(release.GitActionConfig.GitRepo, "/", "-", 1)),
-		RemoveAfterDownload: true,
-	}
+func deployBuild(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Println("Building app:", app)
 
-	err := downloader.DownloadToFile(downloadURL)
+	// initialize the deploy agent
+	deployAgent, err := deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
+		ProjectID: config.Project,
+		ClusterID: config.Cluster,
+		Namespace: namespace,
+	})
 
 	if err != nil {
-		return "", fmt.Errorf("Error downloading to file: %s", err.Error())
+		return err
 	}
 
-	err = downloader.UnzipToDir()
+	buildEnv, err := deployAgent.GetBuildEnv()
 
 	if err != nil {
-		return "", fmt.Errorf("Error unzipping to directory: %s", err.Error())
+		return err
 	}
 
-	var res string
-
-	dstFiles, err := ioutil.ReadDir(dstDir)
+	// set the environment variables in the process
+	err = deployAgent.SetBuildEnv(buildEnv)
 
-	for _, info := range dstFiles {
-		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(release.GitActionConfig.GitRepo, "/", "-", 1)) {
-			res = filepath.Join(dstDir, info.Name())
-		}
+	if err != nil {
+		return err
 	}
 
-	if res == "" {
-		return "", fmt.Errorf("unzipped file not found on host")
-	}
+	// build the deployment
+	color.New(color.FgGreen).Println("Building docker image for", app)
 
-	return res, nil
+	return deployAgent.Build()
 }

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

@@ -0,0 +1,325 @@
+package deploy
+
+import (
+	"context"
+	"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"
+	"k8s.io/client-go/util/homedir"
+)
+
+// 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
+}
+
+// DeployOpts are the options for creating a new DeployAgent
+type DeployOpts struct {
+	ProjectID uint
+	ClusterID uint
+	Namespace string
+}
+
+// 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,
+	}
+
+	// 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
+
+	return deployAgent, nil
+}
+
+func (d *DeployAgent) GetBuildEnv() (map[string]string, error) {
+	return d.getEnvFromRelease()
+}
+
+func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
+	// iterate through env and set the environment variables for the process
+	// these are prefixed with PORTER_<RELEASE> to avoid collisions
+	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
+}
+
+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
+}
+
+func (d *DeployAgent) Build() error {
+	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)
+
+	shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
+
+	if err != nil {
+		return err
+	}
+
+	agent, err := docker.NewAgentWithAuthGetter(d.client, d.opts.ProjectID)
+
+	if err != nil {
+		return err
+	}
+
+	err = d.pullCurrentReleaseImage()
+
+	// 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")
+	}
+
+	// case on Dockerfile path
+	if d.release.GitActionConfig.DockerfilePath != "" {
+		err = agent.BuildLocal(
+			d.release.GitActionConfig.DockerfilePath,
+			fmt.Sprintf("%s:%s", d.release.GitActionConfig.ImageRepoURI, shortRef),
+			dst,
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	d.tag = shortRef
+
+	return nil
+}
+
+func (d *DeployAgent) Deploy() error {
+	// push the created image
+	err := d.agent.PushImage(fmt.Sprintf("%s:%s", d.release.GitActionConfig.ImageRepoURI, d.tag))
+
+	if err != nil {
+		return err
+	}
+
+	releaseExt, err := d.client.GetReleaseWebhook(
+		context.Background(),
+		d.opts.ProjectID,
+		d.opts.ClusterID,
+		d.release.Name,
+		d.release.Namespace,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return d.client.DeployWithWebhook(
+		context.Background(),
+		releaseExt.WebhookToken,
+		d.tag,
+	)
+}
+
+// func deployWithNewTag(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+// 	if release == nil {
+// 		err := deployInit(resp, client, args)
+
+// 		if err != nil {
+// 			return err
+// 		}
+// 	}
+
+// }
+
+// HELPER METHODS
+func (d *DeployAgent) getEnvFromRelease() (map[string]string, error) {
+	envConfig, err := getNestedMap(d.release.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")
+		}
+
+		mapEnvConfig[key] = valStr
+	}
+
+	return mapEnvConfig, 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
+}
+
+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.release.GitActionConfig.ImageRepoURI, tagStr))
+
+	return d.agent.PullImage(fmt.Sprintf("%s:%s", d.release.GitActionConfig.ImageRepoURI, 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
+}

+ 166 - 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
@@ -145,32 +150,177 @@ type PullImageEvent struct {
 	} `json:"progressDetail"`
 }
 
+var PullImageErrNotFound = fmt.Errorf("Requested image not found")
+
 // 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 && strings.Contains(err.Error(), "Requested image not found") {
+		return PullImageErrNotFound
+	} else if err != nil {
+		return a.handleDockerClientErr(err, "Could not pull image "+image)
+	}
+
+	defer out.Close()
+
+	termFd, isTerm := term.GetFdInfo(os.Stderr)
+
+	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)
+
+	// decoder := json.NewDecoder(out)
+
+	// var event *PullImageEvent
+
+	// for {
+	// 	if err := decoder.Decode(&event); err != nil {
+	// 		if err == io.EOF {
+	// 			break
+	// 		}
+
+	// 		return err
+	// 	}
+
+	// 	fmt.Println(event.Status)
+	// }
+
+	return nil
+}
+
+// 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 a.handleDockerClientErr(err, "Could not pull image"+image)
+		return err
 	}
 
-	decoder := json.NewDecoder(out)
+	out, err := a.client.ImagePush(
+		context.Background(),
+		image,
+		opts,
+	)
+
+	if err != nil {
+		return err
+	}
 
-	var event *PullImageEvent
+	defer out.Close()
 
-	for {
-		if err := decoder.Decode(&event); err != nil {
-			if err == io.EOF {
-				break
-			}
+	termFd, isTerm := term.GetFdInfo(os.Stderr)
 
-			return err
-		}
+	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
 	}
 
-	return 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
+}

+ 7 - 41
cli/cmd/docker/builder.go

@@ -1,15 +1,13 @@
 package docker
 
 import (
-	"bufio"
 	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
+	"os"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
+	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/term"
 )
 
 // BuildLocal
@@ -20,7 +18,7 @@ func (a *Agent) BuildLocal(dockerfilePath, tag, buildContext string) error {
 		return err
 	}
 
-	res, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
+	out, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
 		Dockerfile: dockerfilePath,
 		Tags:       []string{tag},
 		Remove:     true,
@@ -30,41 +28,9 @@ func (a *Agent) BuildLocal(dockerfilePath, tag, buildContext string) error {
 		return err
 	}
 
-	return readBuildLogs(res.Body)
-}
-
-// TODO -- do something with these build logs (probably stream to Porter)
-type ErrorLine struct {
-	Error       string      `json:"error"`
-	ErrorDetail ErrorDetail `json:"errorDetail"`
-}
-
-type ErrorDetail struct {
-	Message string `json:"message"`
-}
-
-func readBuildLogs(rd io.ReadCloser) error {
-	var lastLine string
-
-	scanner := bufio.NewScanner(rd)
-
-	for scanner.Scan() {
-		lastLine = scanner.Text()
-		fmt.Println(scanner.Text())
-	}
-
-	errLine := &ErrorLine{}
-
-	json.Unmarshal([]byte(lastLine), errLine)
-
-	if errLine.Error != "" {
-		return errors.New(errLine.Error)
-	}
-
-	if err := scanner.Err(); err != nil {
-		return err
-	}
+	defer out.Body.Close()
 
-	return nil
+	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
+}

+ 1 - 1
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())
 

+ 1 - 1
cli/cmd/open.go

@@ -13,7 +13,7 @@ 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())
 

+ 2 - 3
cli/cmd/root.go

@@ -6,7 +6,6 @@ import (
 	"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"
 )
 
@@ -36,8 +35,8 @@ func Setup() {
 	InitAndLoadConfig()
 }
 
-func GetAPIClient() *api.Client {
-	if token := viper.GetString("token"); token != "" {
+func GetAPIClient(config *CLIConfig) *api.Client {
+	if token := config.Token; token != "" {
 		return api.NewClientWithToken(config.Host+"/api", token)
 	}
 

+ 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)
 }

+ 2 - 0
go.mod

@@ -53,6 +53,8 @@ require (
 	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/moby/moby v20.10.6+incompatible
+	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
 	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

+ 4 - 0
go.sum

@@ -854,10 +854,14 @@ github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/5
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/moby/moby v1.13.1 h1:mC5WwQwCXt/dYxZ1cIrRsnJAWw7VdtcTZUIGr4tXzOM=
 github.com/moby/moby v17.12.0-ce-rc1.0.20200618181300-9dc6525e6118+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
+github.com/moby/moby v20.10.6+incompatible h1:3wn5wW3KwjAv8Z36VHdbvaqvY273JiWUDFuudH0z5Vs=
+github.com/moby/moby v20.10.6+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
 github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
 github.com/moby/term v0.0.0-20200312100748-672ec06f55cd h1:aY7OQNf2XqY/JQ6qREWamhI/81os/agb2BAGpcx5yWI=
 github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
+github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 h1:rzf0wL0CHVc8CEsgyygG0Mn9CNCCPZqOPaz8RiiHYQk=
 github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=

+ 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 {