From ecf70792411ce442c78d45d9ab1cb7d50d797bf9 Mon Sep 17 00:00:00 2001 From: Cyril Diagne Date: Sat, 11 Jan 2020 21:54:07 +0100 Subject: [PATCH] progress with publish --- README.md | 41 ++-- cli/cmd/deploy.go | 66 +++---- cli/cmd/dev.go | 22 +-- cli/cmd/publish.go | 28 ++- docs/cli.md | 38 ++-- examples/hello-gpu-flask/kuda.yaml | 16 +- go.mod | 3 + go.sum | 10 + images/deployer/main.go | 68 +++++++ pkg/config/knative.go | 2 +- pkg/config/skaffold.go | 7 + pkg/deployer/api.go | 80 ++++++++ pkg/deployer/auth.go | 69 +++++++ pkg/deployer/build.go | 63 ++++++ pkg/deployer/deploy.go | 78 ++++++++ pkg/deployer/deployer.go | 298 ----------------------------- pkg/deployer/handler.go | 72 +++++++ pkg/deployer/publish.go | 63 ++++++ pkg/deployer/skaffold.go | 8 +- pkg/gcloud/auth.go | 19 ++ pkg/gcloud/firebase.go | 30 +++ pkg/gcloud/gcr.go | 24 +++ pkg/gcloud/gke.go | 21 ++ pkg/manifest/latest/manifest.go | 2 + pkg/manifest/latest/utils.go | 8 +- pkg/utils/config_io.go | 20 +- 26 files changed, 735 insertions(+), 421 deletions(-) create mode 100644 images/deployer/main.go create mode 100644 pkg/deployer/api.go create mode 100644 pkg/deployer/auth.go create mode 100644 pkg/deployer/build.go create mode 100644 pkg/deployer/deploy.go delete mode 100644 pkg/deployer/deployer.go create mode 100644 pkg/deployer/handler.go create mode 100644 pkg/deployer/publish.go create mode 100644 pkg/gcloud/auth.go create mode 100644 pkg/gcloud/firebase.go create mode 100644 pkg/gcloud/gcr.go create mode 100644 pkg/gcloud/gke.go diff --git a/README.md b/README.md index 35dbfc2..7edd6ca 100644 --- a/README.md +++ b/README.md @@ -3,32 +3,36 @@ [![](https://circleci.com/gh/cyrildiagne/kuda/tree/master.svg?style=shield&circle-token=b14f5838ae2acabe21a8255070507f7e36ba510b)](https://circleci.com/gh/cyrildiagne/kuda) [![](https://img.shields.io/github/v/release/cyrildiagne/kuda?include_prereleases)](https://github.com/cyrildiagne/kuda/releases) -🧪 **Status:** experimental +**Status:** 🧪Experimental -## Develop and deploy APIs on remote GPUs +## Turn any model into a serverless API + +Easily turn any model into a serverless API that will consume cloud GPUs only +when it's being called. + +Kuda deploys your API as a docker container, so you can use any language, any +framework, and there is no library to import in your code. -Kuda deploys APIs as serverless containers on remote GPUs using [Knative](https://knative.dev). -So you can use any language, any framework, and there is no library to import in your code. All you need is a Dockerfile. ## Easy to use -- `kuda init` Initializes your local & remote configurations. -- `kuda dev` Deploy the API on remote GPUs in dev mode (with file sync & live reload). -- `kuda deploy` Deploy the API in production mode. - It will be automatically scaled down to zero when there is no traffic, - and back up when there are new requests. +- `kuda init` Initializes your local & remote configurations +- `kuda dev` Deploys the API in dev mode (with file sync & live reload) +- `kuda deploy` Deploys the API in production mode +- `kuda publish` Publishes the API template to the registry ## Features - Provision GPUs & scale based on traffic (from zero to N) -- Interactive development on remote GPUs from any workstation +- Interactive development on cloud GPUs from any workstation - Protect & control access to your APIs using API Keys - HTTPS with TLS termination & automatic certificate management ## Use the frameworks you know -Here's a simple example that prints the result of `nvidia-smi` using [Flask](http://flask.palletsprojects.com): +Here's a minimal example that just prints the result of `nvidia-smi` using +[Flask](http://flask.palletsprojects.com): - `main.py` @@ -40,7 +44,7 @@ app = flask.Flask(__name__) @app.route('/') def hello(): - return 'Hello GPU!\n\n' + os.popen('nvidia-smi').read() + return 'Hello GPU:\n' + os.popen('nvidia-smi').read() ``` - `Dockerfile` @@ -48,17 +52,10 @@ def hello(): ```Dockerfile FROM nvidia/cuda:10.1-base -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-pip \ - && \ - apt-get clean && \ - apt-get autoremove && \ - rm -rf /var/lib/apt/lists/* +RUN apt-get install -y python3 python3-pip RUN pip3 install setuptools Flask gunicorn -WORKDIR /app - COPY main.py ./main.py CMD exec gunicorn --bind :80 --workers 1 --threads 8 main:app @@ -68,7 +65,6 @@ CMD exec gunicorn --bind :80 --workers 1 --threads 8 main:app ```yaml name: hello-gpu - deploy: dockerfile: ./Dockerfile ``` @@ -100,7 +96,8 @@ Hello GPU! ``` -Checkout the full example with annotations in [examples/hello-gpu-flask](examples/hello-gpu-flask). +Checkout the full example with annotations in +[examples/hello-gpu-flask](examples/hello-gpu-flask). ## Get Started diff --git a/cli/cmd/deploy.go b/cli/cmd/deploy.go index b1707e5..70975ef 100644 --- a/cli/cmd/deploy.go +++ b/cli/cmd/deploy.go @@ -22,18 +22,11 @@ var deployCmd = &cobra.Command{ Use: "deploy", Short: "Deploy the API remotely in production mode.", Run: func(cmd *cobra.Command, args []string) { - - // Check if dry run - dryRun, err := cmd.Flags().GetBool("dry-run") - if err != nil { - panic(err) - } - published, _ := cmd.Flags().GetString("from") if published != "" { - deployFromPublished(published, dryRun) + deployFromPublished(published) } else { - deployFromLocal(dryRun) + deployFromLocal() } }, } @@ -41,10 +34,9 @@ var deployCmd = &cobra.Command{ func init() { RootCmd.AddCommand(deployCmd) deployCmd.Flags().StringP("from", "f", "", "Fully qualified name of a published API image.") - deployCmd.Flags().Bool("dry-run", false, "Generate the config files but skip execution.") } -func deployFromPublished(published string, dryRun bool) error { +func deployFromPublished(published string) error { fmt.Println("Deploy from published API image", published) fmt.Println("Sending to deployer:", cfg.Deployer.Remote.DeployerURL) @@ -58,7 +50,7 @@ func deployFromPublished(published string, dryRun bool) error { // Close writer writer.Close() - url := cfg.Deployer.Remote.DeployerURL + url := cfg.Deployer.Remote.DeployerURL + "/deploy" req, err := http.NewRequest("POST", url, body) if err != nil { return err @@ -71,7 +63,7 @@ func deployFromPublished(published string, dryRun bool) error { return nil } -func deployFromLocal(dryRun bool) { +func deployFromLocal() { // Load the manifest manifestFile := "./kuda.yaml" manifest, err := utils.LoadManifest(manifestFile) @@ -81,17 +73,17 @@ func deployFromLocal(dryRun bool) { } if cfg.Deployer.Remote != nil { - if err := deployWithRemote(manifest, dryRun); err != nil { + if err := deploy(manifest); err != nil { fmt.Println("ERROR:", err) } } else if cfg.Deployer.Skaffold != nil { - if err := deployWithSkaffold(manifest, dryRun); err != nil { + if err := deployWithSkaffold(manifest); err != nil { fmt.Println("ERROR:", err) } } } -func deployWithRemote(manifest *latest.Manifest, dryRun bool) error { +func addContextFilesToRequest(source string, writer *multipart.Writer) error { // Create destination tar file output, err := ioutil.TempFile("", "*.tar") fmt.Println("Building context tar:", output.Name()) @@ -107,22 +99,11 @@ func deployWithRemote(manifest *latest.Manifest, dryRun bool) error { defer dockerignore.Close() // Tar context folder. - source := "./" utils.Tar(source, output.Name(), output, dockerignore) - // Stop here if dry run. - if dryRun { - fmt.Println("Dry run: Skipping remote deployment.") - return nil - } - // Defer the deletion of the temp tar file. defer os.Remove(output.Name()) - // Create request - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - // Add tar file to request file, err := os.Open(output.Name()) defer file.Close() @@ -135,14 +116,28 @@ func deployWithRemote(manifest *latest.Manifest, dryRun bool) error { } io.Copy(part, file) + return nil +} + +func deploy(manifest *latest.Manifest) error { + // Create request body + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + // Add context + if err := addContextFilesToRequest("./", writer); err != nil { + return err + } // Add namespace writer.WriteField("namespace", cfg.Namespace) - // Close writer writer.Close() - url := cfg.Deployer.Remote.DeployerURL + // Create request. + url := cfg.Deployer.Remote.DeployerURL + "/deploy" req, err := http.NewRequest("POST", url, body) + if err != nil { + return err + } req.Header.Set("Content-Type", writer.FormDataContentType()) // Send to remote deployer. @@ -186,7 +181,7 @@ func sendToRemoteDeployer(req *http.Request) error { return nil } -func deployWithSkaffold(manifest *latest.Manifest, dryRun bool) error { +func deployWithSkaffold(manifest *latest.Manifest) error { folder := cfg.Deployer.Skaffold.ConfigFolder registry := cfg.Deployer.Skaffold.DockerRegistry @@ -197,20 +192,13 @@ func deployWithSkaffold(manifest *latest.Manifest, dryRun bool) error { DockerArtifact: registry + "/" + manifest.Name, } - skaffoldFile, err := utils.GenerateSkaffoldConfigFiles(service, manifest.Deploy, folder) - if err != nil { + if err := utils.GenerateSkaffoldConfigFiles(service, manifest.Deploy, folder); err != nil { return err } fmt.Println("Config files have been written in:", folder) - // Stop here if dry run. - if dryRun { - fmt.Println("Dry run: Skipping execution.") - return nil - } - // Run command. - args := []string{"run", "-f", skaffoldFile} + args := []string{"run", "-f", folder + "/skaffold.yaml"} cmd := exec.Command("skaffold", args...) cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin diff --git a/cli/cmd/dev.go b/cli/cmd/dev.go index 54ebf39..5f8c3e9 100644 --- a/cli/cmd/dev.go +++ b/cli/cmd/dev.go @@ -24,17 +24,11 @@ var devCmd = &cobra.Command{ panic(err) } - // Check if dry run - dryRun, err := cmd.Flags().GetBool("dry-run") - if err != nil { - panic(err) - } - if cfg.Deployer.Remote != nil { panic("dev is not yet supported on remote deployers") } else if cfg.Deployer.Skaffold != nil { // Start dev with Skaffold. - if err := devWithSkaffold(*manifest, dryRun); err != nil { + if err := devWithSkaffold(*manifest); err != nil { fmt.Println("ERROR:", err) } } @@ -43,10 +37,9 @@ var devCmd = &cobra.Command{ func init() { RootCmd.AddCommand(devCmd) - devCmd.Flags().Bool("dry-run", false, "Generate the config files but skip execution.") } -func devWithSkaffold(manifest latest.Manifest, dryRun bool) error { +func devWithSkaffold(manifest latest.Manifest) error { folder := cfg.Deployer.Skaffold.ConfigFolder registry := cfg.Deployer.Skaffold.DockerRegistry @@ -57,20 +50,13 @@ func devWithSkaffold(manifest latest.Manifest, dryRun bool) error { DockerArtifact: registry + "/" + manifest.Name, } - skaffoldFile, err := utils.GenerateSkaffoldConfigFiles(service, manifest.Dev, folder) - if err != nil { + if err := utils.GenerateSkaffoldConfigFiles(service, manifest.Dev, folder); err != nil { return err } fmt.Println("Config files have been written in:", folder) - // Stop here if dry run. - if dryRun { - fmt.Println("Dry run: Skipping execution.") - return nil - } - // Run command. - args := []string{"dev", "-f", skaffoldFile} + args := []string{"dev", "-f", folder + "/skaffold.yaml"} cmd := exec.Command("skaffold", args...) cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index 687c832..8be02ce 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -2,7 +2,6 @@ package cmd import ( "bytes" - "errors" "fmt" "mime/multipart" "net/http" @@ -10,7 +9,7 @@ import ( "github.com/cyrildiagne/kuda/pkg/manifest/latest" "github.com/cyrildiagne/kuda/pkg/utils" "github.com/spf13/cobra" - yaml "gopkg.in/yaml.v2" + // "github.com/go-openapi/loads/fmts" ) // publishCmd represents the `kuda publish` command. @@ -23,9 +22,13 @@ var publishCmd = &cobra.Command{ manifestFile := "./kuda.yaml" manifest, err := utils.LoadManifest(manifestFile) if err != nil { - fmt.Println("Could not load manifest", manifestFile) + fmt.Println("Could not load ./kuda.yaml", manifestFile) panic(err) } + + // TODO: Ensure there is an OpenAPI spec. + + // Publish if err := publish(manifest); err != nil { panic(err) } @@ -34,28 +37,22 @@ var publishCmd = &cobra.Command{ func init() { RootCmd.AddCommand(publishCmd) - publishCmd.Flags().Bool("dry-run", false, "Check the manifest for publication but skip execution.") } func publish(manifest *latest.Manifest) error { - // Make sure a version is set. - if manifest.Version == "" { - return errors.New("version missing in manifest file") - } - // Create request + // Create request body body := &bytes.Buffer{} writer := multipart.NewWriter(body) - // Add namespace - writer.WriteField("namespace", cfg.Namespace) - // Add manifest - manifestYAML, err := yaml.Marshal(manifest) - if err != nil { + // Add context + if err := addContextFilesToRequest("./", writer); err != nil { return err } - writer.WriteField("manifest", string(manifestYAML)) + // Add namespace + writer.WriteField("namespace", cfg.Namespace) // Close writer writer.Close() + // Create request. url := cfg.Deployer.Remote.DeployerURL + "/publish" req, err := http.NewRequest("POST", url, body) if err != nil { @@ -63,6 +60,7 @@ func publish(manifest *latest.Manifest) error { } req.Header.Set("Content-Type", writer.FormDataContentType()) + // Send to remote deployer. if err := sendToRemoteDeployer(req); err != nil { return err } diff --git a/docs/cli.md b/docs/cli.md index ecc4c3f..cbbd9f6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,18 +46,14 @@ kuda init \ ## → Dev ```bash -kuda dev [flags] +kuda dev ``` -Deploys an API in development mode (with live file sync & app reload). - -**Flags** - -- `[--dry-run]` Generate the config files and skip execution. +Deploys the API in development mode (with live file sync & app reload). **Examples** -Deploy an API from the local directory: +Deploy the API from the local directory: ```bash kuda dev @@ -66,27 +62,45 @@ kuda dev ## → Deploy ``` -kuda deploy [flags] +kuda deploy ``` -Deploys an API in production mode. +Deploys the API in production mode. **Flags** - `[-f, --from]` Qualitifed name of a published API from the registry: `/:` -- `[--dry-run]` Generate the config files and skip deployment. **Examples** -Deploy an API from the local directory: +Deploy the API from the local directory: ```bash kuda deploy ``` -Deploy an API from a published API in the repo: +Deploy the API from a published API in the repo: ```bash kuda deploy -f cyrildiagne/hello-gpu kuda deploy -f cyrildiagne/hello-gpu:1.3.0 ``` + +## → Publish + +``` +kuda publish +``` + +Publish the API template to the registry. +This command publishes the API template & docker image so that other users can +deploy it inside their own environment. +It doesn't affect access to your deployed APIs. + +**Examples** + +Publish the API template from the local directory: + +```bash +kuda publish +``` \ No newline at end of file diff --git a/examples/hello-gpu-flask/kuda.yaml b/examples/hello-gpu-flask/kuda.yaml index 7fb033b..12f2ccd 100644 --- a/examples/hello-gpu-flask/kuda.yaml +++ b/examples/hello-gpu-flask/kuda.yaml @@ -24,4 +24,18 @@ dev: # Set FLASK_ENV to "development" to enable Flask debugger & live reload. env: - name: FLASK_ENV - value: development \ No newline at end of file + value: development + +# 'paths' is optional. It lets you specify how to interact with the API. +# It follows the OpenAPI 3.0 specification. +# It is only required when publishing to a registry. +paths: + /: + get: + responses: + "200": + description: The output of nvidia-smi + content: + text/plain: + schema: + type: string \ No newline at end of file diff --git a/go.mod b/go.mod index 27a94ad..5840b14 100644 --- a/go.mod +++ b/go.mod @@ -14,11 +14,14 @@ require ( firebase.google.com/go v3.11.1+incompatible github.com/GoogleContainerTools/skaffold v1.1.0 github.com/docker/docker v1.14.0-0.20190319215453-e7b5f7dbe98c + github.com/ghodss/yaml v1.0.0 github.com/google/go-cmp v0.3.1 + github.com/google/go-containerregistry v0.0.0-20191211193041-0eaa33c3d13c github.com/gorilla/mux v1.7.3 github.com/mitchellh/go-homedir v1.1.0 github.com/openzipkin/zipkin-go v0.2.2 // indirect github.com/spf13/cobra v0.0.5 + google.golang.org/grpc v1.25.1 gopkg.in/yaml.v2 v2.2.7 gotest.tools v2.2.0+incompatible k8s.io/api v0.0.0-20190831074750-7364b6bdad65 diff --git a/go.sum b/go.sum index 80d9345..af9702e 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,10 @@ github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb0 github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/Netflix/go-expect v0.0.0-20190729225929-0e00d9168667/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= @@ -124,6 +126,7 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/docker/cli v0.0.0-20190321234815-f40f9c240ab0/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v0.0.0-20191212191748-ebca1413117a h1:rgpgmLocRiSIM3zdtVgJcyvH7S2cSiIPtL7LvFY8K/0= github.com/docker/cli v0.0.0-20191212191748-ebca1413117a/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.6.0-rc.1.0.20180327202408-83389a148052+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= @@ -132,6 +135,7 @@ github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc github.com/docker/docker v1.4.2-0.20191212201129-5f9f41018e9d h1:OVXiYAdNJc5sLW3mmCUG0lIBv6cXEwnMqdQ84cegCOY= github.com/docker/docker v1.4.2-0.20191212201129-5f9f41018e9d/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.0/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= @@ -183,16 +187,21 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+jeTWn7tUa8o= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3 h1:0XRyw8kguri6Yw4SxhsQA/atC88yqrk0+G4YhI2wabc= github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobuffalo/envy v1.6.5/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= @@ -328,6 +337,7 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/markbates/inflect v1.0.4/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzferVeWqbgNs= github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a h1:+J2gw7Bw77w/fbK7wnNJJDKmw1IbWft2Ul5BzrG1Qm8= diff --git a/images/deployer/main.go b/images/deployer/main.go new file mode 100644 index 0000000..e757ffc --- /dev/null +++ b/images/deployer/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/cyrildiagne/kuda/pkg/deployer" + "github.com/cyrildiagne/kuda/pkg/gcloud" + + "github.com/gorilla/mux" +) + +func handleRoot(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "hello!\n") +} + +func main() { + gcpProjectID := os.Getenv("KUDA_GCP_PROJECT") + if gcpProjectID == "" { + panic("cloud not load env var KUDA_GCP_PROJECT") + } + log.Println("Using project:", gcpProjectID) + + if err := gcloud.AuthServiceAccount(); err != nil { + log.Fatalf("error authenticating with credentials. %v\n", err) + } + + if err := gcloud.GetKubeConfig(gcpProjectID); err != nil { + log.Fatalf("could not retrieve kubectl credentials %v\n", err) + } + + auth, fs, err := gcloud.InitFirebase(gcpProjectID) + if err != nil { + log.Fatalf("error initializing firebase: %v\n", err) + } + + env := &deployer.Env{ + GCPProjectID: gcpProjectID, + DB: fs, + Auth: auth, + } + + // user := "cyrildiagne" + // api := "hello-gpu" + // image := env.GetDockerImagePath(user, api) + // if err := gcloud.ListImageTags(image); err != nil { + // panic(err) + // } + + port := "8080" + if value, ok := os.LookupEnv("port"); ok { + port = value + } + fmt.Println("Starting deployer on port", port) + + r := mux.NewRouter() + r.HandleFunc("/", handleRoot).Methods("GET") + + deployHandler := deployer.Handler{Env: env, H: deployer.HandleDeploy} + r.Handle("/deploy", deployHandler).Methods("POST") + + publishHandler := deployer.Handler{Env: env, H: deployer.HandlePublish} + r.Handle("/publish", publishHandler).Methods("POST") + + http.ListenAndServe(":"+port, r) +} diff --git a/pkg/config/knative.go b/pkg/config/knative.go index d58c373..2eeeae3 100644 --- a/pkg/config/knative.go +++ b/pkg/config/knative.go @@ -24,7 +24,7 @@ func MarshalKnativeConfig(s v1.Service) ([]byte, error) { func GenerateKnativeConfig(service ServiceSummary, cfg latest.Config) (v1.Service, error) { // Kuda supports only 1 GPU per service for now. - numGPUs, _ := resource.ParseQuantity("1") + numGPUs, _ := resource.ParseQuantity("0") container := corev1.Container{ Image: service.DockerArtifact, diff --git a/pkg/config/skaffold.go b/pkg/config/skaffold.go index ffb9c26..0b249a0 100644 --- a/pkg/config/skaffold.go +++ b/pkg/config/skaffold.go @@ -31,9 +31,16 @@ func GenerateSkaffoldConfig(service ServiceSummary, manifest latest.Config, knat Sync: sync, } + tagPolicy := v1.TagPolicy{ + EnvTemplateTagger: &v1.EnvTemplateTagger{ + Template: "{{.IMAGE_NAME}}:{{.API_VERSION}}", + }, + } + build := v1.BuildConfig{ Artifacts: []*v1.Artifact{&artifact}, BuildType: service.BuildType, + TagPolicy: tagPolicy, } deploy := v1.DeployConfig{ diff --git a/pkg/deployer/api.go b/pkg/deployer/api.go new file mode 100644 index 0000000..01f7cda --- /dev/null +++ b/pkg/deployer/api.go @@ -0,0 +1,80 @@ +package deployer + +import ( + "context" + "fmt" + + "cloud.google.com/go/firestore" + "github.com/cyrildiagne/kuda/pkg/manifest/latest" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// APIVersion stores an API version. +type APIVersion struct { + IsPublic bool `firestore:"isPublic"` + Version string `firestore:"version"` + Manifest *latest.Manifest `firestore:"manifest"` + // Paths openapi.Paths `firestore:"paths,omitempty"` + // Paths *openapi3.Swagger `firestore:"openapi,omitempty"` + // Paths openapi3.Paths `firestore:"openapi,omitempty"` + // Paths map[string]*openapi3.PathItem `firestore:"openapi,omitempty"` + // Paths *map[string]interface{} `firestore:"openapi,omitempty"` +} + +// API stores an API. +type API struct { + Author string + Name string + Image string + Versions []APIVersion +} + +func registerAPI(env *Env, author string, api APIVersion) error { + name := api.Manifest.Name + version := api.Version + + fullapiname := author + "__" + name + + ctx := context.Background() + + // Get API document. + apiDoc := env.DB.Collection("apis").Doc(fullapiname) + + // Update API metadata. + _, err := apiDoc.Set(ctx, map[string]interface{}{ + "author": author, + "name": name, + "image": env.GetDockerImagePath(author, name), + }, firestore.MergeAll) + if err != nil { + return err + } + + // Retrieve api version document + versDoc := apiDoc.Collection("versions").Doc(version) + vers, versDocErr := versDoc.Get(ctx) + if versDocErr != nil && status.Code(versDocErr) != codes.NotFound { + return versDocErr + } + + if versDocErr == nil { + // Don't update if that API version exists and is public. + apiVersion := APIVersion{} + if err := vers.DataTo(&apiVersion); err != nil { + return err + } + if apiVersion.IsPublic { + err := fmt.Errorf("version %s already exists and is public", version) + return StatusError{400, err} + } + } + + // Write version. + _, errS := versDoc.Set(ctx, api) + if errS != nil { + return errS + } + + return nil +} diff --git a/pkg/deployer/auth.go b/pkg/deployer/auth.go new file mode 100644 index 0000000..9eafc4b --- /dev/null +++ b/pkg/deployer/auth.go @@ -0,0 +1,69 @@ +package deployer + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" +) + +// GetAuthorizedNamespace returns a namespace only if user is admin. +func GetAuthorizedNamespace(env *Env, r *http.Request) (string, error) { + // Retrieve namespace. + namespace := r.FormValue("namespace") + namespace = strings.ToValidUTF8(namespace, "") + if namespace == "" { + err := errors.New("error retrieving namespace") + return "", StatusError{400, err} + } + if namespace == "kuda" { + err := errors.New("namespace cannot be kuda") + return "", StatusError{403, err} + } + + // Check authorizations. + accessToken := r.Header.Get("Authorization") + if err := CheckAuthorized(env, namespace, accessToken); err != nil { + return "", err + } + + return namespace, nil +} + +// CheckAuthorized checks if a user is authorized to update a namespace. +func CheckAuthorized(env *Env, namespace string, accessToken string) error { + // Get bearer token. + accessToken = strings.Split(accessToken, "Bearer ")[1] + // Verify Token + token, err := env.Auth.VerifyIDToken(context.Background(), accessToken) + if err != nil { + err = fmt.Errorf("error verifying token %v", err) + return StatusError{401, err} + } + + // Check if namespace has the user id as admin. + ctx := context.Background() + ns, err := env.DB.Collection("namespaces").Doc(namespace).Get(ctx) + if err != nil { + err = fmt.Errorf("error getting namespace info %v", err) + return StatusError{500, err} + } + if !ns.Exists() { + err := fmt.Errorf("namespace not found %v", namespace) + return StatusError{400, err} + } + nsData := ns.Data() + nsAdmins, hasAdmins := nsData["admins"] + if !hasAdmins { + err := fmt.Errorf("no admin found for namespace %v", namespace) + return StatusError{403, err} + } + _, isAdmin := nsAdmins.(map[string]interface{})[token.UID] + if !isAdmin { + err := fmt.Errorf("user %v must be admin of %v", token.UID, namespace) + return StatusError{403, err} + } + + return nil +} diff --git a/pkg/deployer/build.go b/pkg/deployer/build.go new file mode 100644 index 0000000..1daeee6 --- /dev/null +++ b/pkg/deployer/build.go @@ -0,0 +1,63 @@ +package deployer + +import ( + "io/ioutil" + "net/http" + "os" + "path/filepath" + + v1 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/v1" + "github.com/cyrildiagne/kuda/pkg/config" + "github.com/cyrildiagne/kuda/pkg/utils" +) + +func generate(namespace string, contextDir string, env *Env) error { + // Load the manifest. + manifestFile := filepath.FromSlash(contextDir + "/kuda.yaml") + manifest, err := utils.LoadManifest(manifestFile) + if err != nil { + return StatusError{400, err} + } + + // TODO: replace namespace by user ID. + dockerArtifact := env.GetDockerImagePath(namespace, manifest.Name) + + // Generate Skaffold & Knative config files. + service := config.ServiceSummary{ + Name: manifest.Name, + Namespace: namespace, + DockerArtifact: dockerArtifact, + BuildType: v1.BuildType{ + GoogleCloudBuild: &v1.GoogleCloudBuild{ + ProjectID: env.GCPProjectID, + }, + }, + } + // Export API version in an env var for Skaffold's tagger. + os.Setenv("API_VERSION", manifest.Version) + if err := utils.GenerateSkaffoldConfigFiles(service, manifest.Deploy, contextDir); err != nil { + return err + } + return nil +} + +func extractContext(prefix string, r *http.Request) (string, error) { + // Retrieve Filename, Header and Size of the file. + file, _, err := r.FormFile("context") + if err != nil { + return "", err + } + defer file.Close() + // Create new temp directory. + tempDir, err := ioutil.TempDir("", prefix) + if err != nil { + return "", err + } + // Extract file to temp directory. + err = utils.Untar(tempDir, file) + if err != nil { + return "", err + } + // Return tempDir path + return tempDir, nil +} diff --git a/pkg/deployer/deploy.go b/pkg/deployer/deploy.go new file mode 100644 index 0000000..895a551 --- /dev/null +++ b/pkg/deployer/deploy.go @@ -0,0 +1,78 @@ +package deployer + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/cyrildiagne/kuda/pkg/utils" +) + +func deployFromPublished(env *Env, w http.ResponseWriter, r *http.Request) error { + // Retrieve namespace. + namespace, err := GetAuthorizedNamespace(env, r) + if err != nil { + return err + } + fmt.Println(namespace) + + // TODO: Check if image@version exists and is public. + // TODO: Check if image@version is public. + // TODO: Generate Knative YAML with appropriate namespace. + // TODO: Run kubectl apply. + return nil +} + +// HandleDeploy handles deployments from tar archived in body & published images. +func HandleDeploy(env *Env, w http.ResponseWriter, r *http.Request) error { + // Set maximum upload size to 2GB. + r.ParseMultipartForm((2 * 1000) << 20) + + // Retrieve namespace. + namespace, err := GetAuthorizedNamespace(env, r) + if err != nil { + return err + } + + // Extract archive to temp folder. + contextDir, err := extractContext(namespace, r) + if err != nil { + return err + } + defer os.RemoveAll(contextDir) // Clean up. + + // Build and push image. + if err := generate(namespace, contextDir, env); err != nil { + return err + } + + // Setup client stream. + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/event-stream") + + // // Build with Skaffold. + if err := Skaffold("run", contextDir, contextDir+"/skaffold.yaml", w); err != nil { + return err + } + + // Load the manifest. + manifestFile := filepath.FromSlash(contextDir + "/kuda.yaml") + manifest, err := utils.LoadManifest(manifestFile) + if err != nil { + return StatusError{400, err} + } + + // Register API. + apiVersion := APIVersion{ + IsPublic: false, + Version: manifest.Version, + Manifest: manifest, + } + if err := registerAPI(env, namespace, apiVersion); err != nil { + return err + } + + fmt.Fprintf(w, "Deployment successful!\n") + return nil +} diff --git a/pkg/deployer/deployer.go b/pkg/deployer/deployer.go deleted file mode 100644 index 732c4f5..0000000 --- a/pkg/deployer/deployer.go +++ /dev/null @@ -1,298 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io/ioutil" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - - v1 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/v1" - "github.com/cyrildiagne/kuda/pkg/config" - "github.com/cyrildiagne/kuda/pkg/manifest/latest" - "github.com/cyrildiagne/kuda/pkg/utils" - - "cloud.google.com/go/firestore" - firebase "firebase.google.com/go" - firebaseAuth "firebase.google.com/go/auth" - "github.com/gorilla/mux" - - yaml "gopkg.in/yaml.v2" -) - -var gcpProjectID string -var dockerRegistry string -var fsDb *firestore.Client -var fbAuth *firebaseAuth.Client - -func checkAuthorized(namespace string, w http.ResponseWriter, r *http.Request) (int, error) { - // Get bearer token. - accessToken := r.Header.Get("Authorization") - accessToken = strings.Split(accessToken, "Bearer ")[1] - // Verify Token - token, err := fbAuth.VerifyIDToken(context.Background(), accessToken) - if err != nil { - return 401, fmt.Errorf("error verifying token %v", err) - } - - // Check if namespace has the user id as admin. - ctx := context.Background() - ns, err := fsDb.Collection("namespaces").Doc(namespace).Get(ctx) - if err != nil { - return 500, fmt.Errorf("error getting namespace info %v", err) - } - if !ns.Exists() { - return 400, fmt.Errorf("namespace not found %v", namespace) - } - nsData := ns.Data() - nsAdmins, hasAdmins := nsData["admins"] - if !hasAdmins { - return 403, fmt.Errorf("no admin found for namespace %v", namespace) - } - _, isAdmin := nsAdmins.(map[string]interface{})[token.UID] - if !isAdmin { - return 403, fmt.Errorf("user %v must be admin of %v", token.UID, namespace) - } - - return 200, nil -} - -func getNamespace(r *http.Request) (string, int, error) { - // Retrieve namespace. - namespace := r.FormValue("namespace") - namespace = strings.ToValidUTF8(namespace, "") - if namespace == "" { - err := "error retrieving namespace" - return "", 400, errors.New(err) - } - if namespace == "kuda" { - err := "namespace cannot be kuda" - return "", 403, errors.New(err) - } - return namespace, 200, nil -} - -func handlePublish(w http.ResponseWriter, r *http.Request) { - // Retrieve namespace. - namespace, code, err := getNamespace(r) - if err != nil { - http.Error(w, err.Error(), code) - return - } - - // Check authorizations. - if code, err := checkAuthorized(namespace, w, r); err != nil { - http.Error(w, err.Error(), code) - return - } - - // Load image manifest - manifestYAML := r.FormValue("manifest") - fmt.Println(manifestYAML) - - // Add manifest - manifest := latest.Manifest{} - if err := yaml.Unmarshal([]byte(manifestYAML), &manifest); err != nil { - http.Error(w, err.Error(), 500) - } - fmt.Println(manifest.Version) - - // TODO: Check if image@version exists. - // TODO: Mark image@version as public. -} - -func handleDeploymentFromPublished(w http.ResponseWriter, r *http.Request) { - // Retrieve namespace. - namespace, code, err := getNamespace(r) - if err != nil { - http.Error(w, err.Error(), code) - return - } - - // Check authorizations. - if code, err := checkAuthorized(namespace, w, r); err != nil { - http.Error(w, err.Error(), code) - return - } - - // TODO: Check if image@version exists. - // TODO: Check if image@version is public. - // TODO: Generate Knative YAML with appropriate namespace. - // TODO: Run kubectl apply. -} - -func handleDeployment(w http.ResponseWriter, r *http.Request) { - // Set maximum upload size to 2GB. - r.ParseMultipartForm((2 * 1000) << 20) - - // Retrieve requested namespace. - namespace, code, err := getNamespace(r) - if err != nil { - http.Error(w, err.Error(), code) - return - } - - // Check authorizations. - if code, err := checkAuthorized(namespace, w, r); err != nil { - http.Error(w, err.Error(), code) - return - } - - // Retrieve Filename, Header and Size of the file. - file, handler, err := r.FormFile("context") - if err != nil { - fmt.Println(err) - http.Error(w, "error retrieving file", 500) - return - } - defer file.Close() - log.Printf("Building: %+v, %+v Ko, for namespace %v\n", handler.Filename, handler.Size/1024, namespace) - - // Create new temp directory. - tempDir, err := ioutil.TempDir("", namespace) - fmt.Println(tempDir) - if err != nil { - fmt.Println(err) - http.Error(w, "error creating temp dir", 500) - return - } - defer os.RemoveAll(tempDir) // Clean up. - - // Extract file to temp directory. - err = utils.Untar(tempDir, file) - if err != nil { - fmt.Println(err) - http.Error(w, "error extracting content", 500) - return - } - - // Load the manifest. - manifestFile := filepath.FromSlash(tempDir + "/kuda.yaml") - manifest, err := utils.LoadManifest(manifestFile) - if err != nil { - fmt.Println(err) - http.Error(w, "could not load manifest", 400) - return - } - - // TODO: replace namespace by user ID. - dockerArtifact := dockerRegistry + "/" + namespace + "__" + manifest.Name - - // Generate Skaffold & Knative config files. - service := config.ServiceSummary{ - Name: manifest.Name, - Namespace: namespace, - DockerArtifact: dockerArtifact, - BuildType: v1.BuildType{ - GoogleCloudBuild: &v1.GoogleCloudBuild{ - ProjectID: gcpProjectID, - }, - }, - } - folder := filepath.FromSlash(tempDir + "/.kuda") - skaffoldFile, err := utils.GenerateSkaffoldConfigFiles(service, manifest.Deploy, folder) - if err != nil { - fmt.Println(err) - http.Error(w, "could not generate config files", 500) - return - } - - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "text/event-stream") - - if err := RunSkaffold(tempDir, skaffoldFile, w); err != nil { - http.Error(w, fmt.Sprintf("error running skaffold: %v", err), 500) - return - } - - // TODO: add API entry to the APIs base: - // { meta, user, image, versions[ {tag, public, openapi},...] } - - fmt.Fprintf(w, "Deployment successful!\n") -} - -func hello(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "hello!\n") -} - -func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - return fallback -} - -func initGCP() { - // Authenticate gcloud using application credentials. - cmd := exec.Command("gcloud", "auth", "activate-service-account", "--key-file", - os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - log.Fatalf("Error authenticating with credentials. %v\n", err) - } - - // Get kubeconfig. - args := []string{"container", "clusters", "get-credentials", - "--project", gcpProjectID, - "--region", "us-central1-a", "kuda"} - cmd = exec.Command("gcloud", args...) - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - log.Fatalf("could not retrieve kubectl credentials %v\n", err) - } -} - -func initFirebase() (*firebaseAuth.Client, *firestore.Client) { - config := &firebase.Config{ProjectID: gcpProjectID} - app, err := firebase.NewApp(context.Background(), config) - if err != nil { - log.Fatalf("error initializing app: %v\n", err) - } - - auth, err := app.Auth(context.Background()) - if err != nil { - log.Fatalf("error getting auth client: %v\n", err) - } - - fs, err := app.Firestore(context.Background()) - if err != nil { - log.Fatalf("error connecting to firestore: %v\n", err) - } - - return auth, fs -} - -func main() { - gcpProjectID = os.Getenv("KUDA_GCP_PROJECT") - if gcpProjectID == "" { - panic("cloud not load env var KUDA_GCP_PROJECT") - } - log.Println("Using project:", gcpProjectID) - - dockerRegistry = "gcr.io/" + gcpProjectID - log.Println("Using registry:", dockerRegistry) - - initGCP() - - auth, fs := initFirebase() - fbAuth = auth - fsDb = fs - - port := getEnv("PORT", "8080") - fmt.Println("Starting deployer on port", port) - - r := mux.NewRouter() - r.HandleFunc("/", hello).Methods("GET") - r.HandleFunc("/", handleDeployment).Methods("POST") - r.HandleFunc("/publish", handlePublish).Methods("POST") - http.ListenAndServe(":"+port, r) -} diff --git a/pkg/deployer/handler.go b/pkg/deployer/handler.go new file mode 100644 index 0000000..8c1a230 --- /dev/null +++ b/pkg/deployer/handler.go @@ -0,0 +1,72 @@ +package deployer + +import ( + "log" + "net/http" + + "cloud.google.com/go/firestore" + firebaseAuth "firebase.google.com/go/auth" +) + +// Error represents a handler error. It provides methods for a HTTP status +// code and embeds the built-in error interface. +type Error interface { + error + Status() int +} + +// StatusError represents an error with an associated HTTP status code. +type StatusError struct { + Code int + Err error +} + +// Allows StatusError to satisfy the error interface. +func (se StatusError) Error() string { + return se.Err.Error() +} + +// Status returns our HTTP status code. +func (se StatusError) Status() int { + return se.Code +} + +// Env stores our application-wide configuration. +type Env struct { + GCPProjectID string + DockerRegistry string + DB *firestore.Client + Auth *firebaseAuth.Client +} + +// GetDockerImagePath returns the fully qualified URL of a docker image on GCR +func (e *Env) GetDockerImagePath(user string, image string) string { + return "gcr.io/" + e.GCPProjectID + "/" + user + "__" + image +} + +// Handler takes a configured Env and a function matching our signature. +type Handler struct { + *Env + H func(e *Env, w http.ResponseWriter, r *http.Request) error +} + +// ServeHTTP allows our Handler type to satisfy http.Handler. +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + err := h.H(h.Env, w, r) + if err != nil { + switch e := err.(type) { + case Error: + // We can retrieve the status here and write out a specific + // HTTP status code. + log.Printf("HTTP %d - %s", e.Status(), e) + http.Error(w, e.Error(), e.Status()) + break + default: + log.Printf("Internal Error - %s", e) + // Any error types we don't specifically look out for default + // to serving a HTTP 500 + http.Error(w, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + } + } +} diff --git a/pkg/deployer/publish.go b/pkg/deployer/publish.go new file mode 100644 index 0000000..8f937cd --- /dev/null +++ b/pkg/deployer/publish.go @@ -0,0 +1,63 @@ +package deployer + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/cyrildiagne/kuda/pkg/utils" +) + +// HandlePublish publishes from tar file in body. +func HandlePublish(env *Env, w http.ResponseWriter, r *http.Request) error { + // Set maximum upload size to 2GB. + r.ParseMultipartForm((2 * 1000) << 20) + + // Retrieve namespace. + namespace, err := GetAuthorizedNamespace(env, r) + if err != nil { + return err + } + + // Extract archive to temp folder. + contextDir, err := extractContext(namespace, r) + if err != nil { + return err + } + defer os.RemoveAll(contextDir) // Clean up. + + // Build and push image. + if err := generate(namespace, contextDir, env); err != nil { + return err + } + + // Setup client stream. + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/event-stream") + + // Build with Skaffold. + if err := Skaffold("build", contextDir, contextDir+"/skaffold.yaml", w); err != nil { + return err + } + + // Load the manifest. + manifestFile := filepath.FromSlash(contextDir + "/kuda.yaml") + manifest, err := utils.LoadManifest(manifestFile) + if err != nil { + return StatusError{400, err} + } + + // Register API. + apiVersion := APIVersion{ + IsPublic: true, + Version: manifest.Version, + Manifest: manifest, + } + if err := registerAPI(env, namespace, apiVersion); err != nil { + return err + } + + fmt.Fprintf(w, "Publish successful!\n") + return nil +} diff --git a/pkg/deployer/skaffold.go b/pkg/deployer/skaffold.go index f9f6c06..4667b57 100644 --- a/pkg/deployer/skaffold.go +++ b/pkg/deployer/skaffold.go @@ -1,4 +1,4 @@ -package main +package deployer import ( "bufio" @@ -8,10 +8,10 @@ import ( "os/exec" ) -// RunSkaffold launches 'run' on skaffold and streams logs to w. -func RunSkaffold(tempDir string, skaffoldFile string, w io.Writer) error { +// Skaffold builds an image with skaffold and streams logs to w. +func Skaffold(command string, tempDir string, skaffoldFile string, w io.Writer) error { // Run Skaffold Deploy. - args := []string{"run", "-f", skaffoldFile} + args := []string{command, "-f", skaffoldFile} cmd := exec.Command("skaffold", args...) cmdout, _ := cmd.StdoutPipe() cmderr, _ := cmd.StderrPipe() diff --git a/pkg/gcloud/auth.go b/pkg/gcloud/auth.go new file mode 100644 index 0000000..a505913 --- /dev/null +++ b/pkg/gcloud/auth.go @@ -0,0 +1,19 @@ +package gcloud + +import ( + "os" + "os/exec" +) + +// AuthServiceAccount authenticates gcloud using application credentials. +func AuthServiceAccount() error { + cmd := exec.Command("gcloud", "auth", "activate-service-account", "--key-file", + os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return nil +} diff --git a/pkg/gcloud/firebase.go b/pkg/gcloud/firebase.go new file mode 100644 index 0000000..1e040e0 --- /dev/null +++ b/pkg/gcloud/firebase.go @@ -0,0 +1,30 @@ +package gcloud + +import ( + "context" + + "cloud.google.com/go/firestore" + firebase "firebase.google.com/go" + firebaseAuth "firebase.google.com/go/auth" +) + +// InitFirebase returns a firebase auth and firestore objects. +func InitFirebase(gcpProjectID string) (*firebaseAuth.Client, *firestore.Client, error) { + config := &firebase.Config{ProjectID: gcpProjectID} + app, err := firebase.NewApp(context.Background(), config) + if err != nil { + return nil, nil, err + } + + auth, err := app.Auth(context.Background()) + if err != nil { + return nil, nil, err + } + + fs, err := app.Firestore(context.Background()) + if err != nil { + return nil, nil, err + } + + return auth, fs, nil +} diff --git a/pkg/gcloud/gcr.go b/pkg/gcloud/gcr.go new file mode 100644 index 0000000..17f4b11 --- /dev/null +++ b/pkg/gcloud/gcr.go @@ -0,0 +1,24 @@ +package gcloud + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/google" +) + +// ListImageTags lists all tags of an image on gcr.io. +func ListImageTags(repoName string) error { + repo, err := name.NewRepository(repoName) + if err != nil { + return err + } + fmt.Println(repo.Name()) + tags, err := google.List(repo, google.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return err + } + fmt.Println(tags) + return nil +} diff --git a/pkg/gcloud/gke.go b/pkg/gcloud/gke.go new file mode 100644 index 0000000..39fb2be --- /dev/null +++ b/pkg/gcloud/gke.go @@ -0,0 +1,21 @@ +package gcloud + +import ( + "os" + "os/exec" +) + +// GetKubeConfig gets the kubeconfig. +func GetKubeConfig(gcpProjectID string) error { + args := []string{"container", "clusters", "get-credentials", + "--project", gcpProjectID, + "--region", "us-central1-a", "kuda"} + cmd := exec.Command("gcloud", args...) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return nil +} diff --git a/pkg/manifest/latest/manifest.go b/pkg/manifest/latest/manifest.go index 8a18ade..d3ce7c1 100644 --- a/pkg/manifest/latest/manifest.go +++ b/pkg/manifest/latest/manifest.go @@ -1,6 +1,7 @@ package latest import ( + // openapi "github.com/go-openapi/spec" corev1 "k8s.io/api/core/v1" ) @@ -12,6 +13,7 @@ type Manifest struct { Meta Meta `yaml:"meta,omitempty"` Deploy Config `yaml:"deploy"` Dev Config `yaml:"dev,omitempty"` + // Paths *openapi.Paths `yaml:"paths,omitempty"` } // Meta stores the metadata. diff --git a/pkg/manifest/latest/utils.go b/pkg/manifest/latest/utils.go index f684579..71281a5 100644 --- a/pkg/manifest/latest/utils.go +++ b/pkg/manifest/latest/utils.go @@ -6,7 +6,8 @@ import ( "io/ioutil" "reflect" - yaml "gopkg.in/yaml.v2" + // yaml "gopkg.in/yaml.v2" + "github.com/ghodss/yaml" ) // Load the content of a file into a manifest. @@ -20,6 +21,11 @@ func (manifest *Manifest) Load(reader io.Reader) error { if err = yaml.Unmarshal(data, manifest); err != nil { return err } + + // Default version. + if manifest.Version == "" { + manifest.Version = "0.1.0" + } // Dev extends values from Deploy. valuesDev := reflect.ValueOf(&manifest.Dev).Elem() valuesDeploy := reflect.ValueOf(&manifest.Deploy).Elem() diff --git a/pkg/utils/config_io.go b/pkg/utils/config_io.go index c49d0b7..8634ed6 100644 --- a/pkg/utils/config_io.go +++ b/pkg/utils/config_io.go @@ -29,7 +29,7 @@ func LoadManifest(manifestFile string) (*latest.Manifest, error) { } // GenerateSkaffoldConfigFiles generates the skaffold config files to disk. -func GenerateSkaffoldConfigFiles(service config.ServiceSummary, appCfg latest.Config, folder string) (string, error) { +func GenerateSkaffoldConfigFiles(service config.ServiceSummary, appCfg latest.Config, folder string) error { // Make sure output folder exists. if _, err := os.Stat(folder); os.IsNotExist(err) { os.Mkdir(folder, 0700) @@ -38,32 +38,32 @@ func GenerateSkaffoldConfigFiles(service config.ServiceSummary, appCfg latest.Co // Generate the knative yaml file. knativeCfg, err := config.GenerateKnativeConfig(service, appCfg) if err != nil { - return "", err + return err } knativeYAML, err := config.MarshalKnativeConfig(knativeCfg) if err != nil { - return "", err + return err } - knativeFile := filepath.FromSlash(folder + "/knative-" + service.Name + ".yaml") + knativeFile := filepath.FromSlash(folder + "/knative.yaml") if err := writeYAML(knativeYAML, knativeFile); err != nil { - return "", err + return err } // Generate the skaffold yaml file. skaffoldCfg, err := config.GenerateSkaffoldConfig(service, appCfg, knativeFile) if err != nil { - return "", err + return err } skaffoldYAML, err := yaml.Marshal(skaffoldCfg) if err != nil { - return "", err + return err } - skaffoldFile := filepath.FromSlash(folder + "/skaffold-" + service.Name + ".yaml") + skaffoldFile := filepath.FromSlash(folder + "/skaffold.yaml") if err := writeYAML(skaffoldYAML, skaffoldFile); err != nil { - return "", err + return err } - return skaffoldFile, nil + return nil } func writeYAML(content []byte, name string) error {