diff --git a/cmd/argocd/commands/app.go b/cmd/argocd/commands/app.go index 21b244088484c..4b70e019e7b1e 100644 --- a/cmd/argocd/commands/app.go +++ b/cmd/argocd/commands/app.go @@ -3,6 +3,8 @@ package commands import ( "context" "fmt" + "log" + "net/url" "os" "text/tabwriter" @@ -41,31 +43,57 @@ func NewApplicationCommand(clientOpts *argocdclient.ClientOptions) *cobra.Comman // NewApplicationAddCommand returns a new instance of an `argocd app add` command func NewApplicationAddCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command { var ( + fileURL string repoURL string appPath string + appName string env string destServer string destNamespace string ) var command = &cobra.Command{ Use: "add", - Short: fmt.Sprintf("%s app add APPNAME", cliName), + Short: fmt.Sprintf("%s app add", cliName), Run: func(c *cobra.Command, args []string) { - if len(args) != 1 { + if len(args) != 0 { c.HelpFunc()(c, args) os.Exit(1) } - app := argoappv1.Application{ - ObjectMeta: metav1.ObjectMeta{ - Name: args[0], - }, - Spec: argoappv1.ApplicationSpec{ - Source: argoappv1.ApplicationSource{ - RepoURL: repoURL, - Path: appPath, - Environment: env, + var app argoappv1.Application + if fileURL != "" { + var ( + fileContents []byte + err error + ) + _, err = url.ParseRequestURI(fileURL) + if err != nil { + fileContents, err = readLocalFile(fileURL) + } else { + fileContents, err = readRemoteFile(fileURL) + } + if err != nil { + log.Fatal(err) + } + unmarshalApplication(fileContents, &app) + + } else { + // all these params are required if we're here + if repoURL == "" || appPath == "" || appName == "" { + c.HelpFunc()(c, args) + os.Exit(1) + } + app = argoappv1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: appName, }, - }, + Spec: argoappv1.ApplicationSpec{ + Source: argoappv1.ApplicationSource{ + RepoURL: repoURL, + Path: appPath, + Environment: env, + }, + }, + } } if destServer != "" || destNamespace != "" { app.Spec.Destination = &argoappv1.ApplicationDestination{ @@ -79,10 +107,10 @@ func NewApplicationAddCommand(clientOpts *argocdclient.ClientOptions) *cobra.Com errors.CheckError(err) }, } - command.Flags().StringVar(&repoURL, "repo", "", "Repository URL") - errors.CheckError(command.MarkFlagRequired("repo")) - command.Flags().StringVar(&appPath, "path", "", "Path in repository to the ksonnet app directory") - errors.CheckError(command.MarkFlagRequired("path")) + command.Flags().StringVarP(&fileURL, "file", "f", "", "Filename or URL to Kubernetes manifests for the app") + command.Flags().StringVar(&appName, "name", "", "A name for the app, ignored if a file is set") + command.Flags().StringVar(&repoURL, "repo", "", "Repository URL, ignored if a file is set") + command.Flags().StringVar(&appPath, "path", "", "Path in repository to the ksonnet app directory, ignored if a file is set") command.Flags().StringVar(&env, "env", "", "Application environment to monitor") command.Flags().StringVar(&destServer, "dest-server", "", "K8s cluster URL (overrides the server URL specified in the ksonnet app.yaml)") command.Flags().StringVar(&destNamespace, "dest-namespace", "", "K8s target namespace (overrides the namespace specified in the ksonnet app.yaml)") diff --git a/cmd/argocd/commands/util.go b/cmd/argocd/commands/util.go new file mode 100644 index 0000000000000..5892364c760e9 --- /dev/null +++ b/cmd/argocd/commands/util.go @@ -0,0 +1,49 @@ +package commands + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + + argoappv1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1" + "github.com/ghodss/yaml" +) + +// unmarshalApplication tries to convert a YAML or JSON byte array into an Application struct. +func unmarshalApplication(data []byte, app *argoappv1.Application) { + // first, try unmarshaling as JSON + // Based on technique from Kubectl, which supports both YAML and JSON: + // https://mlafeldt.github.io/blog/teaching-go-programs-to-love-json-and-yaml/ + // http://ghodss.com/2014/the-right-way-to-handle-yaml-in-golang/ + // Short version: JSON unmarshaling won't zero out null fields; YAML unmarshaling will. + // This may have unintended effects or hard-to-catch issues when populating our application object. + data, err := yaml.YAMLToJSON(data) + if err != nil { + log.Fatal("Could not decode valid JSON or YAML Kubernetes manifest") + } + err = json.Unmarshal(data, &app) + if err != nil { + log.Fatalf("Could not unmarshal Kubernetes manifest: %s", string(data)) + } +} + +// readLocalFile reads a file from disk and returns its contents as a byte array. +// The caller is responsible for checking error return values. +func readLocalFile(path string) (data []byte, err error) { + data, err = ioutil.ReadFile(path) + return +} + +// readRemoteFile issues a GET request to retrieve the contents of the specified URL as a byte array. +// The caller is responsible for checking error return values. +func readRemoteFile(url string) (data []byte, err error) { + resp, err := http.Get(url) + if err == nil { + defer func() { + _ = resp.Body.Close() + }() + data, err = ioutil.ReadAll(resp.Body) + } + return +} diff --git a/cmd/argocd/commands/util_test.go b/cmd/argocd/commands/util_test.go new file mode 100644 index 0000000000000..cf76c6768784c --- /dev/null +++ b/cmd/argocd/commands/util_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "testing" +) + +func TestReadLocalFile(t *testing.T) { + sentinel := "Hello, world!" + + file, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + panic(err) + } + defer func() { + _ = os.Remove(file.Name()) + }() + + _, _ = file.WriteString(sentinel) + _ = file.Sync() + + data, err := readLocalFile(file.Name()) + if string(data) != sentinel { + t.Errorf("Test data did not match (err = %v)! Expected \"%s\" and received \"%s\"", err, sentinel, string(data)) + } +} + +func TestReadRemoteFile(t *testing.T) { + sentinel := "Hello, world!" + + serve := func(c chan<- string) { + // listen on first available dynamic (unprivileged) port + listener, err := net.Listen("tcp", ":0") + if err != nil { + panic(err) + } + + // send back the address so that it can be used + c <- listener.Addr().String() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // return the sentinel text at root URL + fmt.Fprint(w, sentinel) + }) + + panic(http.Serve(listener, nil)) + } + + c := make(chan string, 1) + + // run a local webserver to test data retrieval + go serve(c) + + address := <-c + data, err := readRemoteFile("http://" + address) + t.Logf("Listening at address: %s", address) + if string(data) != sentinel { + t.Errorf("Test data did not match (err = %v)! Expected \"%s\" and received \"%s\"", err, sentinel, string(data)) + } +}