From 07e8467fb2e00ecf185f063e270358b699fd439d Mon Sep 17 00:00:00 2001 From: Teddy Xinyuan Chen <45612704+tddschn@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:29:07 +0800 Subject: [PATCH 01/37] comment out code for server's --polling-interval flag (#20) Signed-off-by: Teddy Xinyuan Chen <45612704+tddschn@users.noreply.github.com> --- mdz/pkg/cmd/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mdz/pkg/cmd/server.go b/mdz/pkg/cmd/server.go index 1ef51e3..9578721 100644 --- a/mdz/pkg/cmd/server.go +++ b/mdz/pkg/cmd/server.go @@ -8,7 +8,7 @@ import ( var ( serverVerbose bool - serverPollingInterval time.Duration + serverPollingInterval time.Duration = 3 * time.Second ) // serverCmd represents the server command @@ -29,7 +29,6 @@ func init() { // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: serverCmd.PersistentFlags().BoolVarP(&serverVerbose, "verbose", "v", false, "Verbose output") - serverCmd.PersistentFlags().DurationVarP(&serverPollingInterval, "polling-interval", "p", 3*time.Second, "Polling interval") // Cobra supports local flags which will only run when this command // is called directly, e.g.: From 36092a3eae0891fd098439aa7cca0d01b5b8aa5d Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Mon, 31 Jul 2023 16:55:07 +0800 Subject: [PATCH 02/37] chore: Update readme (#72) Signed-off-by: Ce Gao --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f34ff43..b69f032 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,10 @@ The internal IP address will be used as the default endpoint of your deployments ```bash # Provide the public IP as an argument $ mdz server start 1.2.3.4 +... +🎉 You could set the environment variable to get started! + +export MDZ_AGENT=http://1.2.3.4.modelz.live ``` ### Create your first deployment From 300cfd57cee33e481940dac440db1aa1fe1d6ae7 Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Mon, 31 Jul 2023 17:27:00 +0800 Subject: [PATCH 03/37] chore: Bump helm chart version (#74) Signed-off-by: Ce Gao --- mdz/pkg/cmd/server_start.go | 6 ++++++ mdz/pkg/server/engine.go | 1 + mdz/pkg/server/openmodelz.yaml | 2 +- mdz/pkg/server/openmodelz_install.go | 8 +++++++- mdz/pkg/version/version.go | 2 ++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index 4332f65..02f025c 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -9,11 +9,13 @@ import ( "github.com/tensorchord/openmodelz/agent/pkg/consts" "github.com/tensorchord/openmodelz/mdz/pkg/server" + "github.com/tensorchord/openmodelz/mdz/pkg/version" ) var ( serverStartRuntime string serverStartDomain string = consts.Domain + serverStartVersion string ) // serverStartCmd represents the server start command @@ -40,6 +42,9 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: // serverStartCmd.Flags().StringVarP(&serverStartRuntime, "runtime", "r", "k3s", "Runtime to use (k3s, docker) in the started server") + serverStartCmd.Flags().StringVarP(&serverStartVersion, "version", "", + version.HelmChartVersion, "Version of the server to start") + serverStartCmd.Flags().MarkHidden("version") } func commandServerStart(cmd *cobra.Command, args []string) error { @@ -54,6 +59,7 @@ func commandServerStart(cmd *cobra.Command, args []string) error { OutputStream: cmd.ErrOrStderr(), RetryInternal: serverPollingInterval, Domain: domain, + Version: serverStartVersion, }) if err != nil { cmd.PrintErrf("Failed to start the server: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index ace9d7e..4c4f4f8 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -17,6 +17,7 @@ type Options struct { RetryInternal time.Duration ServerIP string Domain *string + Version string } type Runtime string diff --git a/mdz/pkg/server/openmodelz.yaml b/mdz/pkg/server/openmodelz.yaml index 32135f8..94e148c 100644 --- a/mdz/pkg/server/openmodelz.yaml +++ b/mdz/pkg/server/openmodelz.yaml @@ -14,7 +14,7 @@ spec: chart: openmodelz repo: https://tensorchord.github.io/openmodelz-charts targetNamespace: openmodelz - version: 0.0.8 + version: {{.Version}} set: valuesContent: |- fullnameOverride: openmodelz diff --git a/mdz/pkg/server/openmodelz_install.go b/mdz/pkg/server/openmodelz_install.go index 97030ca..2c20512 100644 --- a/mdz/pkg/server/openmodelz_install.go +++ b/mdz/pkg/server/openmodelz_install.go @@ -47,7 +47,10 @@ func (s *openModelZInstallStep) Run() error { variables := struct { Domain string IpToDomain bool - }{} + Version string + }{ + Version: s.options.Version, + } if s.options.Domain != nil { variables.Domain = *s.options.Domain variables.IpToDomain = false @@ -66,6 +69,9 @@ func (s *openModelZInstallStep) Run() error { panic(err) } + logrus.WithField("variables", variables). + Debugf("Deploying OpenModelZ with the following variables") + if _, err := io.WriteString(stdin, buf.String()); err != nil { return err } diff --git a/mdz/pkg/version/version.go b/mdz/pkg/version/version.go index 20deaef..2325ae3 100644 --- a/mdz/pkg/version/version.go +++ b/mdz/pkg/version/version.go @@ -30,6 +30,8 @@ var ( // Package is filled at linking time Package = "github.com/tensorchord/openmodelz/agent" + HelmChartVersion = "0.0.9" + // Revision is filled with the VCS (e.g. git) revision being used to build // the program at linking time. Revision = "" From eff8536fe53d5dfd5c50d20dacf46f90c8692a8b Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Mon, 31 Jul 2023 17:45:48 +0800 Subject: [PATCH 04/37] chore: Find the IP from the load balancer (#75) Signed-off-by: Ce Gao --- mdz/pkg/server/engine.go | 6 ++++++ mdz/pkg/server/gpu_install.go | 2 ++ mdz/pkg/server/k3s_install.go | 2 ++ mdz/pkg/server/k3s_join.go | 2 ++ mdz/pkg/server/nginx_install.go | 2 ++ mdz/pkg/server/openmodelz_install.go | 16 +++++++++++++++- 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index 4c4f4f8..7277089 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -119,6 +119,12 @@ func (e *Engine) Run() (*Result, error) { MDZURL: fmt.Sprintf("http://%s", *e.options.Domain), }, nil } + // Get the server IP. + if resultDomain != "" { + return &Result{ + MDZURL: fmt.Sprintf("http://%s", resultDomain), + }, nil + } return &Result{ MDZURL: fmt.Sprintf("http://0.0.0.0:%d", AgentPort), }, nil diff --git a/mdz/pkg/server/gpu_install.go b/mdz/pkg/server/gpu_install.go index 8580c79..9dab144 100644 --- a/mdz/pkg/server/gpu_install.go +++ b/mdz/pkg/server/gpu_install.go @@ -41,6 +41,8 @@ func (s *gpuInstallStep) Run() error { if _, err := io.WriteString(stdin, gpuYamlContent); err != nil { return err } + // Close the input stream to finish the pipe. Then the command will use the + // input from the pipe to start the next process. stdin.Close() if err := cmd.Wait(); err != nil { diff --git a/mdz/pkg/server/k3s_install.go b/mdz/pkg/server/k3s_install.go index 0144b2e..6a823ec 100644 --- a/mdz/pkg/server/k3s_install.go +++ b/mdz/pkg/server/k3s_install.go @@ -57,6 +57,8 @@ func (s *k3sInstallStep) Run() error { if _, err := io.WriteString(stdin, bashContent); err != nil { return err } + // Close the input stream to finish the pipe. Then the command will use the + // input from the pipe to start the next process. stdin.Close() fmt.Fprintf(s.options.OutputStream, "🚧 Waiting for the server to be created...\n") diff --git a/mdz/pkg/server/k3s_join.go b/mdz/pkg/server/k3s_join.go index a5c79ae..253365f 100644 --- a/mdz/pkg/server/k3s_join.go +++ b/mdz/pkg/server/k3s_join.go @@ -49,6 +49,8 @@ func (s *k3sJoinStep) Run() error { if _, err := io.WriteString(stdin, bashContent); err != nil { return err } + // Close the input stream to finish the pipe. Then the command will use the + // input from the pipe to start the next process. stdin.Close() fmt.Fprintf(s.options.OutputStream, "🚧 Waiting for the server to be ready...\n") diff --git a/mdz/pkg/server/nginx_install.go b/mdz/pkg/server/nginx_install.go index 5841749..651b4e4 100644 --- a/mdz/pkg/server/nginx_install.go +++ b/mdz/pkg/server/nginx_install.go @@ -42,6 +42,8 @@ func (s *nginxInstallStep) Run() error { if _, err := io.WriteString(stdin, nginxYamlContent); err != nil { return err } + // Close the input stream to finish the pipe. Then the command will use the + // input from the pipe to start the next process. stdin.Close() if err := cmd.Wait(); err != nil { diff --git a/mdz/pkg/server/openmodelz_install.go b/mdz/pkg/server/openmodelz_install.go index 2c20512..f5a4e0a 100644 --- a/mdz/pkg/server/openmodelz_install.go +++ b/mdz/pkg/server/openmodelz_install.go @@ -6,6 +6,7 @@ import ( "html/template" "io" "os/exec" + "regexp" "strings" "syscall" @@ -15,6 +16,8 @@ import ( //go:embed openmodelz.yaml var yamlContent string +var resultDomain string + // openModelZInstallStep installs the OpenModelZ deployments. type openModelZInstallStep struct { options Options @@ -75,6 +78,8 @@ func (s *openModelZInstallStep) Run() error { if _, err := io.WriteString(stdin, buf.String()); err != nil { return err } + // Close the input stream to finish the pipe. Then the command will use the + // input from the pipe to start the next process. stdin.Close() fmt.Fprintf(s.options.OutputStream, "🚧 Waiting for the server to be ready...\n") @@ -96,8 +101,17 @@ func (s *openModelZInstallStep) Verify() error { return err } logrus.Debugf("kubectl get cmd output: %s\n", output) - if len(output) == 0 { + if len(output) <= 4 { return fmt.Errorf("cannot get the ingress ip: output is empty") } + + // Get the IP from the output lie this: `[{"ip":"192.168.71.93"}]` + re := regexp.MustCompile(`(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}`) + found := re.MatchString(string(output)) + if !found { + return fmt.Errorf("cannot get the ingress ip") + } + + resultDomain = re.FindString(string(output)) return nil } From bcb5ba2431cf2b6e92ccc7d4b01f8ad46268b8d4 Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 31 Jul 2023 22:37:39 +0800 Subject: [PATCH 05/37] feat: skip gpu if nvidia-toolkit is not installed (#76) Signed-off-by: Keming --- mdz/pkg/server/gpu_install.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/mdz/pkg/server/gpu_install.go b/mdz/pkg/server/gpu_install.go index 9dab144..a115358 100644 --- a/mdz/pkg/server/gpu_install.go +++ b/mdz/pkg/server/gpu_install.go @@ -4,7 +4,9 @@ import ( _ "embed" "fmt" "io" + "os" "os/exec" + "path/filepath" "syscall" ) @@ -16,7 +18,32 @@ type gpuInstallStep struct { options Options } +// check if the Nvidia Toolkit is installed on the host +func (s *gpuInstallStep) hasNvidiaToolkit() bool { + locations := []string{ + "/usr/local/nvidia/toolkit", + "/usr/bin", + } + binaryNames := []string{ + "nvidia-container-runtime", + "nvidia-container-runtime-experimental", + } + for _, location := range locations { + for _, name := range binaryNames { + path := filepath.Join(location, name) + if _, err := os.Stat(path); err == nil { + return true + } + } + } + return false +} + func (s *gpuInstallStep) Run() error { + if !s.hasNvidiaToolkit() { + fmt.Fprintf(s.options.OutputStream, "🚧 Nvidia Toolkit is missing, skip the GPU initialization.\n") + return nil + } fmt.Fprintf(s.options.OutputStream, "🚧 Initializing the GPU resource...\n") cmd := exec.Command("/bin/sh", "-c", "sudo k3s kubectl apply -f -") From be9148825f10d73702c80aea5e7800b59aa8e60e Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 00:16:55 +0800 Subject: [PATCH 06/37] chore: Bump version (#77) Signed-off-by: Ce Gao --- mdz/pkg/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdz/pkg/version/version.go b/mdz/pkg/version/version.go index 2325ae3..39901df 100644 --- a/mdz/pkg/version/version.go +++ b/mdz/pkg/version/version.go @@ -30,7 +30,7 @@ var ( // Package is filled at linking time Package = "github.com/tensorchord/openmodelz/agent" - HelmChartVersion = "0.0.9" + HelmChartVersion = "0.0.10" // Revision is filled with the VCS (e.g. git) revision being used to build // the program at linking time. From 73f100709ea3c38d894c95c3cdc5dc0fe4dfb65d Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 11:59:25 +0800 Subject: [PATCH 07/37] feat: add force-gpu flag, detect gpu from lspci (#78) Signed-off-by: Keming --- mdz/pkg/cmd/server_start.go | 4 ++++ mdz/pkg/server/engine.go | 1 + mdz/pkg/server/gpu_install.go | 19 ++++++++++++++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index 02f025c..f976b3f 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -16,6 +16,7 @@ var ( serverStartRuntime string serverStartDomain string = consts.Domain serverStartVersion string + serverStartWithGPU bool ) // serverStartCmd represents the server start command @@ -45,6 +46,8 @@ func init() { serverStartCmd.Flags().StringVarP(&serverStartVersion, "version", "", version.HelmChartVersion, "Version of the server to start") serverStartCmd.Flags().MarkHidden("version") + serverStartCmd.Flags().BoolVarP(&serverStartWithGPU, "force-gpu", "g", + false, "Start the server with GPU support (ignore the GPU detection)") } func commandServerStart(cmd *cobra.Command, args []string) error { @@ -60,6 +63,7 @@ func commandServerStart(cmd *cobra.Command, args []string) error { RetryInternal: serverPollingInterval, Domain: domain, Version: serverStartVersion, + ForceGPU: serverStartWithGPU, }) if err != nil { cmd.PrintErrf("Failed to start the server: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index 7277089..2028e4f 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -18,6 +18,7 @@ type Options struct { ServerIP string Domain *string Version string + ForceGPU bool } type Runtime string diff --git a/mdz/pkg/server/gpu_install.go b/mdz/pkg/server/gpu_install.go index a115358..a3f2786 100644 --- a/mdz/pkg/server/gpu_install.go +++ b/mdz/pkg/server/gpu_install.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "syscall" ) @@ -39,10 +40,22 @@ func (s *gpuInstallStep) hasNvidiaToolkit() bool { return false } +func (s *gpuInstallStep) hasNvidiaDevice() bool { + output, err := exec.Command("/bin/sh", "-c", "lspci").Output() + if err != nil { + return false + } + regexNvidia := regexp.MustCompile("(?i)nvidia") + return regexNvidia.Match(output) +} + func (s *gpuInstallStep) Run() error { - if !s.hasNvidiaToolkit() { - fmt.Fprintf(s.options.OutputStream, "🚧 Nvidia Toolkit is missing, skip the GPU initialization.\n") - return nil + if !s.options.ForceGPU { + // detect GPU + if !(s.hasNvidiaDevice() || s.hasNvidiaToolkit()) { + fmt.Fprintf(s.options.OutputStream, "🚧 Nvidia Toolkit is missing, skip the GPU initialization.\n") + return nil + } } fmt.Fprintf(s.options.OutputStream, "🚧 Initializing the GPU resource...\n") From bae49eb468467e3b0d86be1019dc99789add85b1 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 16:39:37 +0800 Subject: [PATCH 08/37] fix: server list show gpu info (#81) Signed-off-by: Keming --- mdz/pkg/cmd/server_list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mdz/pkg/cmd/server_list.go b/mdz/pkg/cmd/server_list.go index 6bb0f1d..ec23aa5 100644 --- a/mdz/pkg/cmd/server_list.go +++ b/mdz/pkg/cmd/server_list.go @@ -105,8 +105,8 @@ func labelsString(labels map[string]string) string { func resourceListString(l types.ResourceList) string { res := fmt.Sprintf("cpu: %s\nmem: %s", l["cpu"], l["memory"]) - if l["nvidia.com/gpu"] != "" { - res += fmt.Sprintf("\ngpu: %s", l["nvidia.com/gpu"]) + if l[types.ResourceGPU] != "" { + res += fmt.Sprintf("\ngpu: %s", l[types.ResourceGPU]) } return res } From c2144b7c30662b443739381fa63cdbeed85d128f Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 16:53:23 +0800 Subject: [PATCH 09/37] chore: Rename env var to MDZ_URL (#82) Signed-off-by: Ce Gao --- README.md | 4 ++-- mdz/pkg/cmd/root.go | 4 ++-- mdz/pkg/cmd/server_start.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b69f032..cbb57ec 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Agent: 🐳 The server is running at http://192.168.71.93.modelz.live 🎉 You could set the environment variable to get started! -export MDZ_AGENT=http://192.168.71.93.modelz.live +export MDZ_URL=http://192.168.71.93.modelz.live ``` The internal IP address will be used as the default endpoint of your deployments. You could provide the public IP address of your server to the `mdz server start` command to make it accessible from the outside world. @@ -69,7 +69,7 @@ $ mdz server start 1.2.3.4 ... 🎉 You could set the environment variable to get started! -export MDZ_AGENT=http://1.2.3.4.modelz.live +export MDZ_URL=http://1.2.3.4.modelz.live ``` ### Create your first deployment diff --git a/mdz/pkg/cmd/root.go b/mdz/pkg/cmd/root.go index 6c4f46b..58cb4cb 100644 --- a/mdz/pkg/cmd/root.go +++ b/mdz/pkg/cmd/root.go @@ -76,8 +76,8 @@ func commandInit(cmd *cobra.Command, args []string) error { if agentClient == nil { if mdzURL == "" { - // Checkout environment variable MDZ_AGENT. - mdzURL = os.Getenv("MDZ_AGENT") + // Checkout environment variable MDZ_URL. + mdzURL = os.Getenv("MDZ_URL") } if mdzURL == "" { mdzURL = "http://localhost:80" diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index f976b3f..e897a50 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -93,6 +93,6 @@ func commandServerStart(cmd *cobra.Command, args []string) error { } cmd.Printf("🐳 The server is running at %s\n", mdzURL) cmd.Printf("🎉 You could set the environment variable to get started!\n\n") - cmd.Printf("export MDZ_AGENT=%s\n", mdzURL) + cmd.Printf("export MDZ_URL=%s\n", mdzURL) return nil } From 12b3ded93ddb64ae379955713487796bfe50223a Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 17:01:06 +0800 Subject: [PATCH 10/37] chore: Bump version (#83) Signed-off-by: Ce Gao --- mdz/pkg/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdz/pkg/version/version.go b/mdz/pkg/version/version.go index 39901df..5fc033e 100644 --- a/mdz/pkg/version/version.go +++ b/mdz/pkg/version/version.go @@ -30,7 +30,7 @@ var ( // Package is filled at linking time Package = "github.com/tensorchord/openmodelz/agent" - HelmChartVersion = "0.0.10" + HelmChartVersion = "0.0.11" // Revision is filled with the VCS (e.g. git) revision being used to build // the program at linking time. From dbdeae613b2f38a0c16fe6faabb55cc4f556719e Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 17:16:57 +0800 Subject: [PATCH 11/37] chore: Update readme (#84) Signed-off-by: Ce Gao --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cbb57ec..a734f8d 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ Simplify machine learning deployment for any environment.

discord invitation link trackgit-views +docs all-contributors -

OpenModelZ (MDZ) provides a simple CLI to deploy and manage your machine learning workloads on any cloud or home lab. -## Why use OpenModelZ +## Why use OpenModelZ 🙋 OpenModelZ is the ideal solution for practitioners who want to quickly deploy their machine learning models to a (public or private) endpoint without the hassle of spending excessive time, money, and effort to figure out the entire end-to-end process. @@ -29,6 +29,10 @@ With OpenModelZ, we take care of the underlying technical details for you, and p You could **start from a single machine and scale it up to a cluster of machines** without any hassle. OpenModelZ lies at the heart of our [ModelZ](https://modelz.ai), which is a serverless inference platform. It's used in production to deploy models for our customers. +## Documentation 📝 + +You can find the documentation at [docs.open.modelz.ai](https://docs.open.modelz.ai). + ## Quick Start 🚀 Once you've installed the `mdz` you can start deploying models and experimenting with them. @@ -152,10 +156,6 @@ $ mdz server label node3 gpu=true type=nvidia-a100 $ mdz deploy --image aikain/simplehttpserver:0.1 --name simple-server --port 80 --node-labels gpu=true,type=nvidia-a100 ``` -## More on documentation 📝 - -See [OpenModelZ documentation](https://docs.open.modelz.ai/). - ## Roadmap 🗂️ Please checkout [ROADMAP](https://docs.open.modelz.ai/community). From 60d2086bc023986c78eddcc5c1a0d2b596f1b821 Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 17:31:38 +0800 Subject: [PATCH 12/37] chore: Update license (#86) Signed-off-by: Ce Gao --- LICENSE | 202 +++++++++++++++++++++++++++++++++++++++ ingress-operator/LICENSE | 1 + mdz/LICENSE | 0 modelzetes/LICENSE | 1 + 4 files changed, 204 insertions(+) create mode 100644 LICENSE delete mode 100644 mdz/LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ingress-operator/LICENSE b/ingress-operator/LICENSE index 3804aa4..d53a9a9 100644 --- a/ingress-operator/LICENSE +++ b/ingress-operator/LICENSE @@ -1,5 +1,6 @@ MIT License +Copyright (c) 2023 TensorChord Inc. Copyright (c) 2017-2019 OpenFaaS Author(s) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/mdz/LICENSE b/mdz/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/modelzetes/LICENSE b/modelzetes/LICENSE index b2b0490..c820691 100644 --- a/modelzetes/LICENSE +++ b/modelzetes/LICENSE @@ -1,5 +1,6 @@ MIT License +Copyright (c) 2023 TensorChord Inc. Copyright (c) 2020 OpenFaaS Author(s) Copyright (c) 2017 Alex Ellis From 4c0bc390583074b34f0d15a94bf6d28f5f7c2692 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 18:34:32 +0800 Subject: [PATCH 13/37] feat: add pypi release (#87) Signed-off-by: Keming --- .github/workflows/publish.yaml | 38 +++++++ .gitignore | 185 +++++++++++++++++++++++++++++++++ MANIFEST.in | 15 +++ mdz/Makefile | 13 ++- pyproject.toml | 51 +++++++++ setup.py | 54 ++++++++++ 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 MANIFEST.in create mode 100644 pyproject.toml create mode 100644 setup.py diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c552033..9034ca1 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -36,3 +36,41 @@ jobs: args: release --rm-dist env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pypi_publish: + needs: goreleaser + # only trigger on main repo when tag starts with v + if: github.repository == 'tensorchord/openmodelz' && startsWith(github.ref, 'refs/tags/v') + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + matrix: + os: [ubuntu-20.04] + steps: + - uses: actions/checkout@v3 + - name: Get gobin + uses: actions/download-artifact@v3 + with: + name: gobin_${{ github.event.release.tag_name }} + path: dist/ + - name: Configure linux build environment + if: runner.os == 'Linux' + run: | + mkdir -p mdz/bin + mv dist/envd_linux_amd64_v1/mdz mdz/bin/mdz + chmod +x mdz/bin/mdz + - name: Build wheels + uses: pypa/cibuildwheel@v2.14.1 + - name: Build source distribution + if: runner.os == 'Linux' # Only release source under linux to avoid conflict + run: | + python -m pip install wheel + python setup.py sdist + mv dist/*.tar.gz wheelhouse/ + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python -m pip install --upgrade pip + python -m pip install twine + python -m twine upload wheelhouse/* diff --git a/.gitignore b/.gitignore index 849ddff..b40678e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,186 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +_version.txt +_version.py +wheelhouse/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +.ruff_cache/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a8abe23 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,15 @@ +prune autoscaler +prune ingress-operator +prune modelzetes +prune .github +include LICENSE +include README.md +include .goreleaser.yaml +include mdz/Makefile mdz/go.mod mdz/go.sum mdz/LICENSE +graft mdz/pkg +graft mdz/cmd +prune mdz/bin +prune mdz/docs +prune mdz/examples +graft agent/pkg +prune agent/bin diff --git a/mdz/Makefile b/mdz/Makefile index 92c273b..b30836b 100644 --- a/mdz/Makefile +++ b/mdz/Makefile @@ -138,11 +138,22 @@ swag-install: build-local: @for target in $(TARGETS); do \ - CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -tags $(DASHBOARD_BUILD) -trimpath -v -o $(OUTPUT_DIR)/$${target} \ + CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -v -o $(OUTPUT_DIR)/$${target} \ -ldflags "-s -w -X $(ROOT)/pkg/version.version=$(VERSION) -X $(ROOT)/pkg/version.buildDate=$(BUILD_DATE) -X $(ROOT)/pkg/version.gitCommit=$(GIT_COMMIT) -X $(ROOT)/pkg/version.gitTreeState=$(GIT_TREE_STATE)" \ $(CMD_DIR)/$${target}; \ done +build-release: + @for target in $(TARGETS); do \ + CGO_ENABLED=$(CGO_ENABLED) go build -trimpath -o $(OUTPUT_DIR)/$${target} \ + -ldflags "-s -w -X $(ROOT)/pkg/version.version=$(VERSION) \ + -X $(ROOT)/pkg/version.buildDate=$(BUILD_DATE) \ + -X $(ROOT)/pkg/version.gitCommit=$(GIT_COMMIT) \ + -X $(ROOT)/pkg/version.gitTreeState=$(GIT_TREE_STATE) \ + -X $(ROOT)/pkg/version.gitTag=$(GIT_TAG)" \ + $(CMD_DIR)/$${target}; \ + done + # It is used by vscode to attach into the process. debug-local: @for target in $(TARGETS); do \ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f333999 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "openmodelz" +description = "Simplify machine learning deployment for any environments." +readme = "README.md" +authors = [ + {name = "TensorChord", email = "modelz@tensorchord.ai"}, +] +license = {text = "Apache-2.0"} +keywords = ["machine learning", "deep learning", "model serving"] +dynamic = ["version"] +requires-python = ">=2.7" +classifiers = [ + "Environment :: GPU", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Build Tools", +] + +[project.urls] +homepage = "https://modelz.ai/" +documentation = "https://docs.open.modelz.ai/" +repository = "https://github.com/tensorchord/openmodelz" +changelog = "https://github.com/tensorchord/openmodelz/releases" + +[tool.cibuildwheel] +build-frontend = "build" +archs = ["auto64"] +skip = "pp*" # skip pypy +before-all = "" +environment = { PIP_NO_CLEAN="yes" } +before-build = "ls -la mdz/bin" # help to debug + +[project.optional-dependencies] + +[project.scripts] + +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "mdz/_version.py" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2793361 --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +import os +import subprocess +import shlex +from wheel.bdist_wheel import bdist_wheel +from setuptools import setup, find_packages, Extension +from setuptools.command.build_ext import build_ext +from setuptools_scm import get_version + + +class bdist_wheel_universal(bdist_wheel): + def get_tag(self): + *_, plat = super().get_tag() + return "py2.py3", "none", plat + + +def build_if_not_exist(): + if os.path.isfile("mdz/bin/mdz"): + return + version = get_version() + print(f"build mdz from source ({version})") + subprocess.call(["make", "mdz"]) + errno = subprocess.call(shlex.split( + f"make build-release GIT_TAG={version}" + ), cwd="mdz") + assert errno == 0, f"mdz build failed with code {errno}" + + +class ModelzExtension(Extension): + """A custom extension to define the OpenModelz extension.""" + + +class ModelzBuildExt(build_ext): + def build_extension(self, ext: Extension) -> None: + if not isinstance(ext, ModelzExtension): + return super().build_extension(ext) + + build_if_not_exist() + + +setup( + name="openmodelz", + use_scm_version=True, + packages=find_packages("mdz"), + include_package_data=True, + data_files=[("bin", ["mdz/bin/mdz"])], + zip_safe=False, + ext_modules=[ + ModelzExtension(name="mdz", sources=["mdz/*"]), + ], + cmdclass=dict( + build_ext=ModelzBuildExt, + bdist_wheel=bdist_wheel_universal, + ), +) From ed138dfaeba03a625acd1e5c8b7b0a2e0db00628 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 18:53:56 +0800 Subject: [PATCH 14/37] fix: upload go releaser artifacts (#88) Signed-off-by: Keming --- .github/workflows/publish.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 9034ca1..6dfefb9 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -36,6 +36,14 @@ jobs: args: release --rm-dist env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: upload gobin + uses: actions/upload-artifact@v3 + with: + name: gobin_${{ github.event.release.tag_name }} + retention-days: 1 + path: | + dist/mdz_linux_amd64_v1/mdz + if-no-files-found: error pypi_publish: needs: goreleaser # only trigger on main repo when tag starts with v @@ -56,7 +64,7 @@ jobs: if: runner.os == 'Linux' run: | mkdir -p mdz/bin - mv dist/envd_linux_amd64_v1/mdz mdz/bin/mdz + mv dist/mdz_linux_amd64_v1/mdz mdz/bin/mdz chmod +x mdz/bin/mdz - name: Build wheels uses: pypa/cibuildwheel@v2.14.1 From 4d7451e9dc471cb1e919e7bb40be8a47d408ee70 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 19:16:14 +0800 Subject: [PATCH 15/37] fix: use release event, fix artifact path (#89) Signed-off-by: Keming --- .github/workflows/publish.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6dfefb9..3c08ab5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,9 +1,8 @@ name: release on: - push: - tags: - - 'v*' + release: + types: [published] pull_request: paths: - '.github/workflows/release.yml' @@ -64,7 +63,7 @@ jobs: if: runner.os == 'Linux' run: | mkdir -p mdz/bin - mv dist/mdz_linux_amd64_v1/mdz mdz/bin/mdz + mv dist/mdz mdz/bin/mdz chmod +x mdz/bin/mdz - name: Build wheels uses: pypa/cibuildwheel@v2.14.1 From 6b3405969a27345214cdda3ce4663b9787f3ad07 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 19:37:13 +0800 Subject: [PATCH 16/37] fix: sdist build requirements (#90) Signed-off-by: Keming --- .github/workflows/publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 3c08ab5..cb2a31c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -70,7 +70,7 @@ jobs: - name: Build source distribution if: runner.os == 'Linux' # Only release source under linux to avoid conflict run: | - python -m pip install wheel + python -m pip install wheel setuptools_scm python setup.py sdist mv dist/*.tar.gz wheelhouse/ - name: Upload to PyPI From 2a0d9f77dc6b0b595301be6ef934d1ae3d336af0 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 1 Aug 2023 19:50:27 +0800 Subject: [PATCH 17/37] chore: add meta data to setup (#91) Signed-off-by: Keming --- setup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/setup.py b/setup.py index 2793361..eab70e2 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,9 @@ from setuptools_scm import get_version +with open("README.md", "r", encoding="utf-8") as f: + readme = f.read() + class bdist_wheel_universal(bdist_wheel): def get_tag(self): *_, plat = super().get_tag() @@ -40,6 +43,13 @@ def build_extension(self, ext: Extension) -> None: setup( name="openmodelz", use_scm_version=True, + description="Simplify machine learning deployment for any environments.", + long_description=readme, + long_description_content_type="text/markdown", + url="https://github.com/tensorchord/openmodelz", + license="Apache License 2.0", + author="TensorChord", + author_email="modelz@tensorchord.ai", packages=find_packages("mdz"), include_package_data=True, data_files=[("bin", ["mdz/bin/mdz"])], From 943761c29289f7355b07905d825a2b81cd6655eb Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Tue, 1 Aug 2023 19:59:20 +0800 Subject: [PATCH 18/37] feat: Add installation (#92) Signed-off-by: Ce Gao --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a734f8d..f789403 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,21 @@ You can find the documentation at [docs.open.modelz.ai](https://docs.open.modelz ## Quick Start 🚀 -Once you've installed the `mdz` you can start deploying models and experimenting with them. +### Install `mdz` -There are only two concepts in `mdz`: +You can install OpenModelZ using the following command: + +```text +pip install openmodelz +``` + +You could verify the installation by running the following command: + +```text +mdz +``` + +Once you've installed the `mdz` you can start deploying models and experimenting with them. There are only two concepts in `mdz`: - **Deployment**: A deployment is a running inference service. You could configure the number of replicas, the port, and the image, and some other parameters. - **Server**: A server is a machine that could run the deployments. It could be a cloud VM, a PC, or even a Raspberry Pi. You could start from a single server and scale it up to a cluster of machines without any hassle. From 0aeb6c0d341f79b007426c4d0f113d3808fc0628 Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Wed, 2 Aug 2023 11:07:10 +0800 Subject: [PATCH 19/37] feat(cli): Support command flag in deploy (#95) Signed-off-by: Ce Gao --- mdz/README.md | 4 +++- mdz/pkg/cmd/deploy.go | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mdz/README.md b/mdz/README.md index a220607..6996112 100644 --- a/mdz/README.md +++ b/mdz/README.md @@ -13,7 +13,9 @@ CLI for OpenModelZ. ## Installation -TODO +``` +pip install openmodelz +``` ## CLI Reference diff --git a/mdz/pkg/cmd/deploy.go b/mdz/pkg/cmd/deploy.go index e050e7d..6ce08bf 100644 --- a/mdz/pkg/cmd/deploy.go +++ b/mdz/pkg/cmd/deploy.go @@ -20,6 +20,7 @@ var ( deployName string deployGPU int deployNodeLabel []string + deployCommand string ) // deployCmd represents the deploy command @@ -53,6 +54,7 @@ func init() { deployCmd.Flags().IntVar(&deployGPU, "gpu", 0, "Number of GPUs") deployCmd.Flags().StringVar(&deployName, "name", "", "Name of inference") deployCmd.Flags().StringSliceVarP(&deployNodeLabel, "node-labels", "l", []string{}, "Node labels") + deployCmd.Flags().StringVar(&deployCommand, "command", "", "Command to run") } func commandDeploy(cmd *cobra.Command, args []string) error { @@ -87,6 +89,10 @@ func commandDeploy(cmd *cobra.Command, args []string) error { }, } + if deployCommand != "" { + inf.Spec.Command = &deployCommand + } + if len(deployNodeLabel) > 0 { inf.Spec.Constraints = []string{} for _, label := range deployNodeLabel { From f52fbb8c57d7a4abd06d9e468d11c66195f2921a Mon Sep 17 00:00:00 2001 From: Keming Date: Wed, 2 Aug 2023 14:22:17 +0800 Subject: [PATCH 20/37] feat: support registry mirror (#96) Signed-off-by: Keming --- mdz/pkg/cmd/server.go | 6 ++-- mdz/pkg/cmd/server_join.go | 8 +++++ mdz/pkg/cmd/server_start.go | 8 +++++ mdz/pkg/server/engine.go | 16 +++++++++ mdz/pkg/server/k3s_prepare.go | 66 ++++++++++++++++++++++++++++++++++ mdz/pkg/server/registries.yaml | 4 +++ 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 mdz/pkg/server/k3s_prepare.go create mode 100644 mdz/pkg/server/registries.yaml diff --git a/mdz/pkg/cmd/server.go b/mdz/pkg/cmd/server.go index 9578721..bf078c5 100644 --- a/mdz/pkg/cmd/server.go +++ b/mdz/pkg/cmd/server.go @@ -7,8 +7,10 @@ import ( ) var ( - serverVerbose bool - serverPollingInterval time.Duration = 3 * time.Second + serverVerbose bool + serverPollingInterval time.Duration = 3 * time.Second + serverRegistryMirrorName string + serverRegistryMirrorEndpoints []string ) // serverCmd represents the server command diff --git a/mdz/pkg/cmd/server_join.go b/mdz/pkg/cmd/server_join.go index f5c2316..3245350 100644 --- a/mdz/pkg/cmd/server_join.go +++ b/mdz/pkg/cmd/server_join.go @@ -28,6 +28,10 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: + serverJoinCmd.Flags().StringVarP(&serverRegistryMirrorName, "mirror-name", "", + "", "Mirror name of the registry") + serverJoinCmd.Flags().StringArrayVarP(&serverRegistryMirrorEndpoints, "mirror-endpoints", "", + []string{}, "Mirror endpoints of the registry") } func commandServerJoin(cmd *cobra.Command, args []string) error { @@ -36,6 +40,10 @@ func commandServerJoin(cmd *cobra.Command, args []string) error { OutputStream: cmd.ErrOrStderr(), RetryInternal: serverPollingInterval, ServerIP: args[0], + Mirror: server.Mirror{ + Name: serverRegistryMirrorName, + Endpoints: serverRegistryMirrorEndpoints, + }, }) if err != nil { cmd.PrintErrf("Failed to join the cluster: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index e897a50..6be11cc 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -48,6 +48,10 @@ func init() { serverStartCmd.Flags().MarkHidden("version") serverStartCmd.Flags().BoolVarP(&serverStartWithGPU, "force-gpu", "g", false, "Start the server with GPU support (ignore the GPU detection)") + serverStartCmd.Flags().StringVarP(&serverRegistryMirrorName, "mirror-name", "", + "", "Mirror name of the registry") + serverStartCmd.Flags().StringArrayVarP(&serverRegistryMirrorEndpoints, "mirror-endpoints", "", + []string{}, "Mirror endpoints of the registry") } func commandServerStart(cmd *cobra.Command, args []string) error { @@ -64,6 +68,10 @@ func commandServerStart(cmd *cobra.Command, args []string) error { Domain: domain, Version: serverStartVersion, ForceGPU: serverStartWithGPU, + Mirror: server.Mirror{ + Name: serverRegistryMirrorName, + Endpoints: serverRegistryMirrorEndpoints, + }, }) if err != nil { cmd.PrintErrf("Failed to start the server: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index 2028e4f..9759dea 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -14,6 +14,7 @@ type Options struct { Verbose bool OutputStream io.Writer Runtime Runtime + Mirror Mirror RetryInternal time.Duration ServerIP string Domain *string @@ -21,6 +22,15 @@ type Options struct { ForceGPU bool } +type Mirror struct { + Name string + Endpoints []string +} + +func (m *Mirror) Configured() bool { + return m.Name != "" && len(m.Endpoints) > 0 +} + type Runtime string var ( @@ -38,6 +48,9 @@ type Result struct { } func NewStart(o Options) (*Engine, error) { + if o.Verbose { + fmt.Fprintf(o.OutputStream, "Starting the server with config: %+v\n", o) + } var engine *Engine switch o.Runtime { case RuntimeDocker: @@ -54,6 +67,9 @@ func NewStart(o Options) (*Engine, error) { options: o, Steps: []Step{ // Install k3s and related tools. + &k3sPrepare{ + options: o, + }, &k3sInstallStep{ options: o, }, diff --git a/mdz/pkg/server/k3s_prepare.go b/mdz/pkg/server/k3s_prepare.go new file mode 100644 index 0000000..2e3d77d --- /dev/null +++ b/mdz/pkg/server/k3s_prepare.go @@ -0,0 +1,66 @@ +package server + +import ( + _ "embed" + "fmt" + "os/exec" + "path/filepath" + "strings" + "syscall" + "text/template" +) + +//go:embed registries.yaml +var registriesContent string + +const mirrorPath = "/etc/rancher/k3s" +const mirrorFile = "registries.yaml" + +// k3sPrepare install everything required by k3s. +type k3sPrepare struct { + options Options +} + +func (s *k3sPrepare) Run() error { + if !s.options.Mirror.Configured() { + return nil + } + fmt.Fprintf(s.options.OutputStream, "🚧 Configure the mirror...\n") + + tmpl, err := template.New("registries").Parse(registriesContent) + if err != nil { + panic(err) + } + buf := strings.Builder{} + err = tmpl.Execute(&buf, s.options.Mirror) + if err != nil { + panic(err) + } + + cmd := exec.Command("/bin/sh", "-c", fmt.Sprintf( + "sudo mkdir -p %s && sudo tee %s > /dev/null << EOF\n%s\nEOF", + mirrorPath, + filepath.Join(mirrorPath, mirrorFile), + buf.String(), + )) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + } + if s.options.Verbose { + cmd.Stderr = s.options.OutputStream + cmd.Stdout = s.options.OutputStream + } else { + cmd.Stdout = nil + cmd.Stderr = nil + } + err = cmd.Run() + if err != nil { + return err + } + + return nil +} + +func (s *k3sPrepare) Verify() error { + return nil +} diff --git a/mdz/pkg/server/registries.yaml b/mdz/pkg/server/registries.yaml new file mode 100644 index 0000000..e073e1d --- /dev/null +++ b/mdz/pkg/server/registries.yaml @@ -0,0 +1,4 @@ +mirrors: + {{ .Name }}: + endpoint: + {{ range $endpoint := .Endpoints }}- "{{ $endpoint }}"{{ end }} From 4b0ef3c45dd6b8f94bab71b2e1a654ceec208ea4 Mon Sep 17 00:00:00 2001 From: Keming Date: Wed, 2 Aug 2023 14:27:22 +0800 Subject: [PATCH 21/37] fix: list server label verbose panic (#97) Signed-off-by: Keming --- mdz/pkg/cmd/server_list.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mdz/pkg/cmd/server_list.go b/mdz/pkg/cmd/server_list.go index ec23aa5..f7a9eb4 100644 --- a/mdz/pkg/cmd/server_list.go +++ b/mdz/pkg/cmd/server_list.go @@ -100,6 +100,9 @@ func labelsString(labels map[string]string) string { for k, v := range labels { res += fmt.Sprintf("%s=%s\n", k, v) } + if len(res) == 0 { + return res + } return res[:len(res)-1] } From 23b18f26dc3ee00a319b6327a293eb58db3eb107 Mon Sep 17 00:00:00 2001 From: Keming Date: Wed, 2 Aug 2023 16:51:14 +0800 Subject: [PATCH 22/37] feat: support `mdz server destroy` cmd (#99) Signed-off-by: Keming --- mdz/pkg/cmd/server_destroy.go | 50 +++++++++++++++++++++++++++++++++++ mdz/pkg/server/engine.go | 12 +++++++++ mdz/pkg/server/k3s_destroy.go | 38 ++++++++++++++++++++++++++ mdz/pkg/server/k3s_killall.go | 2 +- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 mdz/pkg/cmd/server_destroy.go create mode 100644 mdz/pkg/server/k3s_destroy.go diff --git a/mdz/pkg/cmd/server_destroy.go b/mdz/pkg/cmd/server_destroy.go new file mode 100644 index 0000000..36792c9 --- /dev/null +++ b/mdz/pkg/cmd/server_destroy.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" + + "github.com/tensorchord/openmodelz/mdz/pkg/server" +) + +// serverDestroyCmd represents the server destroy command +var serverDestroyCmd = &cobra.Command{ + Use: "destroy", + Short: "Destroy the cluster", + Long: `Destroy the cluster`, + Example: ` mdz server destroy`, + PreRunE: commandInitLog, + RunE: commandServerDestroy, +} + +func init() { + serverCmd.AddCommand(serverDestroyCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: +} + +func commandServerDestroy(cmd *cobra.Command, args []string) error { + engine, err := server.NewDestroy(server.Options{ + Verbose: serverVerbose, + OutputStream: cmd.ErrOrStderr(), + RetryInternal: serverPollingInterval, + }) + if err != nil { + cmd.PrintErrf("Failed to destroy the server: %s\n", errors.Cause(err)) + return err + } + + _, err = engine.Run() + if err != nil { + cmd.PrintErrf("Failed to destroy the server: %s\n", errors.Cause(err)) + return err + } + cmd.Printf("✅ Server destroyed\n") + return nil +} diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index 9759dea..91573a3 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -100,6 +100,18 @@ func NewStop(o Options) (*Engine, error) { }, nil } +func NewDestroy(o Options) (*Engine, error) { + return &Engine{ + options: o, + Steps: []Step{ + // Destroy all k3s and related tools. + &k3sDestroyAllStep{ + options: o, + }, + }, + }, nil +} + func NewJoin(o Options) (*Engine, error) { return &Engine{ options: o, diff --git a/mdz/pkg/server/k3s_destroy.go b/mdz/pkg/server/k3s_destroy.go new file mode 100644 index 0000000..38158b3 --- /dev/null +++ b/mdz/pkg/server/k3s_destroy.go @@ -0,0 +1,38 @@ +package server + +import ( + "fmt" + "os/exec" + "syscall" +) + +// k3sDestroyAllStep installs k3s and related tools. +type k3sDestroyAllStep struct { + options Options +} + +func (s *k3sDestroyAllStep) Run() error { + fmt.Fprintf(s.options.OutputStream, "🚧 Destroy the OpenModelz Cluster...\n") + // TODO(gaocegege): Embed the script into the binary. + cmd := exec.Command("/bin/sh", "-c", "/usr/local/bin/k3s-uninstall.sh") + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + } + if s.options.Verbose { + cmd.Stderr = s.options.OutputStream + cmd.Stdout = s.options.OutputStream + } else { + cmd.Stdout = nil + cmd.Stderr = nil + } + err := cmd.Run() + if err != nil { + return err + } + + return nil +} + +func (s *k3sDestroyAllStep) Verify() error { + return nil +} diff --git a/mdz/pkg/server/k3s_killall.go b/mdz/pkg/server/k3s_killall.go index 43f2614..b797d31 100644 --- a/mdz/pkg/server/k3s_killall.go +++ b/mdz/pkg/server/k3s_killall.go @@ -12,7 +12,7 @@ type k3sKillAllStep struct { } func (s *k3sKillAllStep) Run() error { - fmt.Fprintf(s.options.OutputStream, "🚧 Stopping all the processes...\n") + fmt.Fprintf(s.options.OutputStream, "🚧 Stopping the OpenModelz Cluster...\n") // TODO(gaocegege): Embed the script into the binary. cmd := exec.Command("/bin/sh", "-c", "/usr/local/bin/k3s-killall.sh") cmd.SysProcAttr = &syscall.SysProcAttr{ From 700fb86e60c16b282833fd21c6ea3725d5dc5433 Mon Sep 17 00:00:00 2001 From: Keming Date: Thu, 3 Aug 2023 11:30:07 +0800 Subject: [PATCH 23/37] feat: add telemetry (#103) Signed-off-by: Keming --- mdz/Makefile | 4 +- mdz/go.mod | 2 + mdz/go.sum | 4 + mdz/pkg/cmd/deploy.go | 7 ++ mdz/pkg/cmd/list.go | 2 + mdz/pkg/cmd/root.go | 14 ++- mdz/pkg/cmd/scale.go | 3 + mdz/pkg/cmd/server_join.go | 5 +- mdz/pkg/cmd/server_list.go | 2 + mdz/pkg/cmd/server_start.go | 7 ++ mdz/pkg/telemetry/telemetry.go | 166 +++++++++++++++++++++++++++++++++ mdz/pkg/version/version.go | 10 +- 12 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 mdz/pkg/telemetry/telemetry.go diff --git a/mdz/Makefile b/mdz/Makefile index b30836b..2f73a9e 100644 --- a/mdz/Makefile +++ b/mdz/Makefile @@ -109,12 +109,12 @@ export GOFLAGS ?= -count=1 .DEFAULT_GOAL:=build -build: build-local ## Build the release version of envd +build: build-release ## Build the release version help: ## Display this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) -debug: debug-local ## Build the debug version of envd +debug: debug-local ## Build the debug version # more info about `GOGC` env: https://github.com/golangci/golangci-lint#memory-usage-of-golangci-lint lint: $(GOLANGCI_LINT) ## Lint GO code diff --git a/mdz/go.mod b/mdz/go.mod index c83dd38..3456790 100644 --- a/mdz/go.mod +++ b/mdz/go.mod @@ -70,6 +70,8 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/segmentio/analytics-go/v3 v3.2.1 // indirect + github.com/segmentio/backo-go v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/swaggo/swag v1.8.12 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/mdz/go.sum b/mdz/go.sum index 91cde3a..465f35a 100644 --- a/mdz/go.sum +++ b/mdz/go.sum @@ -155,6 +155,10 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/analytics-go/v3 v3.2.1 h1:G+f90zxtc1p9G+WigVyTR0xNfOghOGs/PYAlljLOyeg= +github.com/segmentio/analytics-go/v3 v3.2.1/go.mod h1:p8owAF8X+5o27jmvUognuXxdtqvSGtD0ZrfY2kcS9bE= +github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA= +github.com/segmentio/backo-go v1.0.0/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= diff --git a/mdz/pkg/cmd/deploy.go b/mdz/pkg/cmd/deploy.go index 6ce08bf..bc38237 100644 --- a/mdz/pkg/cmd/deploy.go +++ b/mdz/pkg/cmd/deploy.go @@ -9,6 +9,7 @@ import ( petname "github.com/dustinkirkland/golang-petname" "github.com/spf13/cobra" "github.com/tensorchord/openmodelz/agent/api/types" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) var ( @@ -110,6 +111,12 @@ func commandDeploy(cmd *cobra.Command, args []string) error { } } + telemetry.GetTelemetry().Record( + "deploy", + telemetry.AddField("GPU", deployGPU), + telemetry.AddField("FromZero", deployMinReplicas == 0), + ) + if _, err := agentClient.InferenceCreate( cmd.Context(), namespace, inf); err != nil { cmd.PrintErrf("Failed to create the inference: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/cmd/list.go b/mdz/pkg/cmd/list.go index 60573f1..cd2d2c8 100644 --- a/mdz/pkg/cmd/list.go +++ b/mdz/pkg/cmd/list.go @@ -8,6 +8,7 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" "github.com/tensorchord/openmodelz/agent/api/types" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) const ( @@ -48,6 +49,7 @@ func init() { } func commandList(cmd *cobra.Command, args []string) error { + telemetry.GetTelemetry().Record("list") infs, err := agentClient.InferenceList(cmd.Context(), namespace) if err != nil { cmd.PrintErrf("Failed to list inferences: %v\n", err) diff --git a/mdz/pkg/cmd/root.go b/mdz/pkg/cmd/root.go index 58cb4cb..82d02bb 100644 --- a/mdz/pkg/cmd/root.go +++ b/mdz/pkg/cmd/root.go @@ -9,13 +9,15 @@ import ( "github.com/spf13/cobra/doc" "github.com/tensorchord/openmodelz/agent/client" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) var ( // Used for flags. - mdzURL string - namespace string - debug bool + mdzURL string + namespace string + debug bool + disableTelemetry bool agentClient *client.Client ) @@ -62,6 +64,8 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "Enable debug logging") + rootCmd.PersistentFlags().BoolVarP(&disableTelemetry, "disable-telemetry", "", false, "Disable anonymous telemetry") + // Cobra also supports local flags, which will only run // when this action is called directly. rootCmd.AddGroup(&cobra.Group{ID: "basic", Title: "Basic Commands:"}) @@ -89,6 +93,10 @@ func commandInit(cmd *cobra.Command, args []string) error { return err } } + + if err := telemetry.Initialize(!disableTelemetry); err != nil { + logrus.WithError(err).Debug("Failed to initialize telemetry") + } return nil } diff --git a/mdz/pkg/cmd/scale.go b/mdz/pkg/cmd/scale.go index 5d97b73..eb63700 100644 --- a/mdz/pkg/cmd/scale.go +++ b/mdz/pkg/cmd/scale.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/spf13/cobra" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) var ( @@ -73,6 +74,8 @@ func commandScale(cmd *cobra.Command, args []string) error { deployment.Spec.Scaling.TargetLoad = int32Ptr(targetInflightRequests) } + telemetry.GetTelemetry().Record("scale") + if _, err := agentClient.DeploymentUpdate(cmd.Context(), namespace, deployment); err != nil { cmd.PrintErrf("Failed to update deployment: %s\n", err) return err diff --git a/mdz/pkg/cmd/server_join.go b/mdz/pkg/cmd/server_join.go index 3245350..cc4a8a8 100644 --- a/mdz/pkg/cmd/server_join.go +++ b/mdz/pkg/cmd/server_join.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/tensorchord/openmodelz/mdz/pkg/server" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) // serverJoinCmd represents the server join command @@ -46,10 +47,12 @@ func commandServerJoin(cmd *cobra.Command, args []string) error { }, }) if err != nil { - cmd.PrintErrf("Failed to join the cluster: %s\n", errors.Cause(err)) + cmd.PrintErrf("Failed to configure before join: %s\n", errors.Cause(err)) return err } + telemetry.GetTelemetry().Record("server join") + _, err = engine.Run() if err != nil { cmd.PrintErrf("Failed to join the cluster: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/cmd/server_list.go b/mdz/pkg/cmd/server_list.go index f7a9eb4..600af55 100644 --- a/mdz/pkg/cmd/server_list.go +++ b/mdz/pkg/cmd/server_list.go @@ -7,6 +7,7 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" "github.com/tensorchord/openmodelz/agent/api/types" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) var ( @@ -40,6 +41,7 @@ func init() { } func commandServerList(cmd *cobra.Command, args []string) error { + telemetry.GetTelemetry().Record("server list") servers, err := agentClient.ServerList(cmd.Context()) if err != nil { cmd.PrintErrf("Failed to list servers: %s\n", errors.Cause(err)) diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index 6be11cc..d267f6a 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -9,6 +9,7 @@ import ( "github.com/tensorchord/openmodelz/agent/pkg/consts" "github.com/tensorchord/openmodelz/mdz/pkg/server" + "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" "github.com/tensorchord/openmodelz/mdz/pkg/version" ) @@ -60,6 +61,12 @@ func commandServerStart(cmd *cobra.Command, args []string) error { domainWithSuffix := fmt.Sprintf("%s.%s", args[0], serverStartDomain) domain = &domainWithSuffix } + defer func(start time.Time) { + telemetry.GetTelemetry().Record( + "server start", + telemetry.AddField("duration", time.Since(start).Seconds()), + ) + }(time.Now()) engine, err := server.NewStart(server.Options{ Verbose: serverVerbose, Runtime: server.Runtime(serverStartRuntime), diff --git a/mdz/pkg/telemetry/telemetry.go b/mdz/pkg/telemetry/telemetry.go new file mode 100644 index 0000000..580a9bd --- /dev/null +++ b/mdz/pkg/telemetry/telemetry.go @@ -0,0 +1,166 @@ +package telemetry + +import ( + "io" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/cockroachdb/errors" + "github.com/google/uuid" + segmentio "github.com/segmentio/analytics-go/v3" + "github.com/sirupsen/logrus" + + "github.com/tensorchord/openmodelz/mdz/pkg/version" +) + +type TelemetryField func(*segmentio.Properties) + +type Telemetry interface { + Record(command string, args ...TelemetryField) +} + +type defaultTelemetry struct { + client segmentio.Client + uid string + enabled bool +} + +const telemetryToken = "65WHA9bxCNX74K3HjgplMOmsio9LkYSI" + +var ( + once sync.Once + telemetry *defaultTelemetry + telemetryConfigFile string +) + +func init() { + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + telemetryConfigFile = filepath.Join(home, ".config", "openmodelz", "telemetry") +} + +func GetTelemetry() Telemetry { + return telemetry +} + +func Initialize(enabled bool) error { + once.Do(func() { + client, err := segmentio.NewWithConfig(telemetryToken, segmentio.Config{ + BatchSize: 1, + }) + if err != nil { + panic(err) + } + telemetry = &defaultTelemetry{ + client: client, + enabled: enabled, + } + }) + return telemetry.init() +} + +func (t *defaultTelemetry) init() error { + if !t.enabled { + return nil + } + // detect if the config file already exists + _, err := os.Stat(telemetryConfigFile) + if err != nil { + if !os.IsNotExist(err) { + return errors.Wrap(err, "failed to stat telemetry config file") + } + t.uid = uuid.New().String() + return t.dumpConfig() + } + if err = t.loadConfig(); err != nil { + return errors.Wrap(err, "failed to load telemetry config") + } + t.Idnetify() + return nil +} + +func (t *defaultTelemetry) dumpConfig() error { + if err := os.MkdirAll(filepath.Dir(telemetryConfigFile), os.ModeDir|0700); err != nil { + return errors.Wrap(err, "failed to create telemetry config directory") + } + file, err := os.Create(telemetryConfigFile) + if err != nil { + return errors.Wrap(err, "failed to create telemetry config file") + } + defer file.Close() + _, err = file.WriteString(t.uid) + if err != nil { + return errors.Wrap(err, "failed to write telemetry config file") + } + return nil +} + +func (t *defaultTelemetry) loadConfig() error { + file, err := os.Open(telemetryConfigFile) + if err != nil { + return errors.Wrap(err, "failed to open telemetry config file") + } + defer file.Close() + uid, err := io.ReadAll(file) + if err != nil { + return errors.Wrap(err, "failed to read telemetry config file") + } + t.uid = string(uid) + return nil +} + +func (t *defaultTelemetry) Idnetify() { + if !t.enabled { + return + } + v := version.GetOpenModelzVersion() + if err := t.client.Enqueue(segmentio.Identify{ + UserId: t.uid, + Context: &segmentio.Context{ + OS: segmentio.OSInfo{ + Name: runtime.GOOS, + Version: runtime.GOARCH, + }, + App: segmentio.AppInfo{ + Name: "openmodelz", + Version: v, + }, + }, + Timestamp: time.Now(), + Traits: segmentio.NewTraits(), + }); err != nil { + logrus.WithError(err).Debug("failed to identify user") + return + } +} + +func AddField(name string, value interface{}) TelemetryField { + return func(p *segmentio.Properties) { + p.Set(name, value) + } +} + +func (t *defaultTelemetry) Record(command string, fields ...TelemetryField) { + if !t.enabled { + return + } + logrus.WithField("UID", t.uid).WithField("command", command).Debug("send telemetry") + track := segmentio.Track{ + UserId: t.uid, + Event: command, + Properties: segmentio.NewProperties(), + } + for _, field := range fields { + field(&track.Properties) + } + if err := t.client.Enqueue(track); err != nil { + logrus.WithError(err).Debug("failed to send telemetry") + } + // make sure the msg can be sent out + t.client.Close() +} diff --git a/mdz/pkg/version/version.go b/mdz/pkg/version/version.go index 5fc033e..59a6680 100644 --- a/mdz/pkg/version/version.go +++ b/mdz/pkg/version/version.go @@ -44,7 +44,7 @@ var ( developmentFlag = "false" ) -// Version contains envd version information +// Version contains OpenModelz version information type Version struct { Version string BuildDate string @@ -65,8 +65,8 @@ func SetGitTagForE2ETest(tag string) { gitTag = tag } -// GetEnvdVersion gets Envd version information -func GetEnvdVersion() string { +// GetOpenModelzVersion gets OpenModelz version information +func GetOpenModelzVersion() string { var versionStr string if gitCommit != "" && gitTag != "" && @@ -97,7 +97,7 @@ func GetEnvdVersion() string { // GetVersion returns the version information func GetVersion() Version { return Version{ - Version: GetEnvdVersion(), + Version: GetOpenModelzVersion(), BuildDate: buildDate, GitCommit: gitCommit, GitTag: gitTag, @@ -128,5 +128,5 @@ func UserAgent() string { version = matches[0][1] + "-dev" } - return "envd/" + version + return "modelz/" + version } From 5b8145b17b4a04d858613cd84493df7af1609bec Mon Sep 17 00:00:00 2001 From: Keming Date: Thu, 3 Aug 2023 15:59:32 +0800 Subject: [PATCH 24/37] docs: mirror config (#106) Signed-off-by: Keming --- mdz/pkg/cmd/server_join.go | 4 ++-- mdz/pkg/cmd/server_start.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mdz/pkg/cmd/server_join.go b/mdz/pkg/cmd/server_join.go index cc4a8a8..f996a24 100644 --- a/mdz/pkg/cmd/server_join.go +++ b/mdz/pkg/cmd/server_join.go @@ -30,9 +30,9 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: serverJoinCmd.Flags().StringVarP(&serverRegistryMirrorName, "mirror-name", "", - "", "Mirror name of the registry") + "docker.io", "Mirror domain name of the registry") serverJoinCmd.Flags().StringArrayVarP(&serverRegistryMirrorEndpoints, "mirror-endpoints", "", - []string{}, "Mirror endpoints of the registry") + []string{}, "Mirror URL endpoints of the registry like `https://quay.io`") } func commandServerJoin(cmd *cobra.Command, args []string) error { diff --git a/mdz/pkg/cmd/server_start.go b/mdz/pkg/cmd/server_start.go index d267f6a..468100f 100644 --- a/mdz/pkg/cmd/server_start.go +++ b/mdz/pkg/cmd/server_start.go @@ -50,9 +50,9 @@ func init() { serverStartCmd.Flags().BoolVarP(&serverStartWithGPU, "force-gpu", "g", false, "Start the server with GPU support (ignore the GPU detection)") serverStartCmd.Flags().StringVarP(&serverRegistryMirrorName, "mirror-name", "", - "", "Mirror name of the registry") + "docker.io", "Mirror domain name of the registry") serverStartCmd.Flags().StringArrayVarP(&serverRegistryMirrorEndpoints, "mirror-endpoints", "", - []string{}, "Mirror endpoints of the registry") + []string{}, "Mirror URL endpoints of the registry like `https://quay.io`") } func commandServerStart(cmd *cobra.Command, args []string) error { From 2fc7853067a2cb537d8b4749f73e2d4d0e5a40f3 Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Thu, 3 Aug 2023 16:01:40 +0800 Subject: [PATCH 25/37] chore: Remove unnecessary version info (#107) Signed-off-by: Ce Gao --- mdz/pkg/cmd/version.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/mdz/pkg/cmd/version.go b/mdz/pkg/cmd/version.go index 72df358..f6650a2 100644 --- a/mdz/pkg/cmd/version.go +++ b/mdz/pkg/cmd/version.go @@ -53,8 +53,6 @@ func printServerVersion(cmd *cobra.Command) error { } cmd.Println("Server:") - cmd.Printf(" Name: \t\t%s\n", info.Name) - cmd.Printf(" Orchestration: %s\n", info.Orchestration) cmd.Printf(" Version: \t%s\n", info.Version.Version) cmd.Printf(" Build Date: \t%s\n", info.Version.BuildDate) cmd.Printf(" Git Commit: \t%s\n", info.Version.GitCommit) From 61d182cb1abc23e3e6fc25d4a9d263ad38c32dda Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Thu, 3 Aug 2023 16:28:20 +0800 Subject: [PATCH 26/37] chore: Bump version (#108) Signed-off-by: Ce Gao --- README.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f789403..3d02d71 100644 --- a/README.md +++ b/README.md @@ -39,32 +39,32 @@ You can find the documentation at [docs.open.modelz.ai](https://docs.open.modelz You can install OpenModelZ using the following command: -```text +```text copy pip install openmodelz ``` You could verify the installation by running the following command: -```text +```text copy mdz ``` -Once you've installed the `mdz` you can start deploying models and experimenting with them. There are only two concepts in `mdz`: - -- **Deployment**: A deployment is a running inference service. You could configure the number of replicas, the port, and the image, and some other parameters. -- **Server**: A server is a machine that could run the deployments. It could be a cloud VM, a PC, or even a Raspberry Pi. You could start from a single server and scale it up to a cluster of machines without any hassle. +Once you've installed the `mdz` you can start deploying models and experimenting with them. ### Bootstrap `mdz` -It's super easy to bootstrap the `mdz` server. You just need to find a server (could be a cloud VM, a home lab, or even a single machine) and run the `mdz server start` command. The `mdz` server will be bootstrapped on the server and you could start deploying your models. +It's super easy to bootstrap the `mdz` server. You just need to find a server (could be a cloud VM, a home lab, or even a single machine) and run the `mdz server start` command. The `mdz` server will be bootstrapped on the server as a controller node and you could start deploying your models. ``` $ mdz server start +🚧 Creating the server... +🚧 Initializing the load balancer... +🚧 Initializing the GPU resource... 🚧 Initializing the server... 🚧 Waiting for the server to be ready... 🐋 Checking if the server is running... Agent: - Version: v0.0.5 + Version: v0.0.13 Build Date: 2023-07-19T09:12:55Z Git Commit: 84d0171640453e9272f78a63e621392e93ef6bbb Git State: clean @@ -82,10 +82,12 @@ The internal IP address will be used as the default endpoint of your deployments ```bash # Provide the public IP as an argument $ mdz server start 1.2.3.4 -... -🎉 You could set the environment variable to get started! +``` -export MDZ_URL=http://1.2.3.4.modelz.live +You could also specify the registry mirror to speed up the image pulling process. Here is an example: + +```bash +$ mdz server start --mirror-endpoints https://docker.mirrors.sjtug.sjtu.edu.cn ``` ### Create your first deployment From 1b0d1e02baae27d2c5f3fb53adeddf649c7050cf Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Thu, 3 Aug 2023 20:43:02 +0800 Subject: [PATCH 27/37] chore: Update README (#114) Signed-off-by: Ce Gao --- .all-contributorsrc | 11 +++++++ README.md | 79 +++++++++++++++++++++++++++++---------------- agent/README.md | 22 ++++++++++--- 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6c094b5..4653b7b 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -70,6 +70,17 @@ "contributions": [ "ideas" ] + }, + { + "login": "Xuanwo", + "name": "Xuanwo", + "avatar_url": "https://avatars.githubusercontent.com/u/5351546?v=4", + "profile": "https://xuanwo.io/", + "contributions": [ + "content", + "design", + "ideas" + ] } ], "contributorsPerLine": 7 diff --git a/README.md b/README.md index 3d02d71..ca34828 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ # OpenModelZ -Simplify machine learning deployment for any environment. - +One-click machine learning deployment at scale on any cluster (GCP, AWS, Lambda labs, your home lab, or even a single machine)

@@ -13,9 +12,7 @@ Simplify machine learning deployment for any environment. all-contributors

-OpenModelZ (MDZ) provides a simple CLI to deploy and manage your machine learning workloads on any cloud or home lab. - -## Why use OpenModelZ 🙋 +## Why use OpenModelZ OpenModelZ is the ideal solution for practitioners who want to quickly deploy their machine learning models to a (public or private) endpoint without the hassle of spending excessive time, money, and effort to figure out the entire end-to-end process. @@ -27,11 +24,9 @@ We created OpenModelZ in response to the difficulties of finding a simple, cost- With OpenModelZ, we take care of the underlying technical details for you, and provide a simple and easy-to-use CLI to deploy your models to **any cloud (GCP, AWS, or others), your home lab, or even a single machine**. -You could **start from a single machine and scale it up to a cluster of machines** without any hassle. OpenModelZ lies at the heart of our [ModelZ](https://modelz.ai), which is a serverless inference platform. It's used in production to deploy models for our customers. - -## Documentation 📝 +You could **start from a single machine and scale it up to a cluster of machines** without any hassle. Besides this, We **provision a separate subdomain for each deployment** without any extra cost and effort, making each deployment easily accessible from the outside. -You can find the documentation at [docs.open.modelz.ai](https://docs.open.modelz.ai). +OpenModelZ forms the core of our [ModelZ](https://modelz.ai) platform, which is a serverless machine learning inference service. It is utilized in a production environment to provision models for our clients. ## Quick Start 🚀 @@ -86,40 +81,63 @@ $ mdz server start 1.2.3.4 You could also specify the registry mirror to speed up the image pulling process. Here is an example: -```bash +```bash /--mirror-endpoints/ $ mdz server start --mirror-endpoints https://docker.mirrors.sjtug.sjtu.edu.cn ``` -### Create your first deployment +### Create your first UI-based deployment -Once you've bootstrapped the `mdz` server, you can start deploying your first applications. +Once you've bootstrapped the `mdz` server, you can start deploying your first applications. We will use jupyter notebook as an example in this tutorial. You could use any docker image as your deployment. +```text +$ mdz deploy --image jupyter/minimal-notebook:lab-4.0.3 --name jupyter --port 8888 --command "jupyter notebook --ip='*' --NotebookApp.token='' --NotebookApp.password=''" +Inference jupyter is created +$ mdz list + NAME ENDPOINT STATUS INVOCATIONS REPLICAS + jupyter http://jupyter-9pnxdkeb6jsfqkmq.192.168.71.93.modelz.live Ready 488 1/1 + http://192.168.71.93/inference/jupyter.default ``` -$ mdz deploy --image aikain/simplehttpserver:0.1 --name simple-server --port 80 + +You could access the deployment by visiting the endpoint URL. The endpoint will be automatically generated for each deployment with the following format: `-..modelz.live`. + +It is `http://jupyter-9pnxdkeb6jsfqkmq.192.168.71.93.modelz.live` in this case. The endpoint could be accessed from the outside world as well if you've provided the public IP address of your server to the `mdz server start` command. + +![jupyter notebook](./images/jupyter.png) + +### Create your first API-based deployment + +You could also create API-based deployments. We will use a simple python server as an example in this tutorial. You could use any docker image as your deployment. + +```text +$ mdz deploy --image python:3.9.6-slim-buster --name simple-server --port 8080 --command "python -m http.server 8080" Inference simple-server is created $ mdz list - NAME ENDPOINT STATUS INVOCATIONS REPLICAS - simple-server http://simple-server-4k2epq5lynxbaayn.192.168.71.93.modelz.live Ready 2 1/1 - http://192.168.71.93.modelz.live/inference/simple-server.default + NAME ENDPOINT STATUS INVOCATIONS REPLICAS + jupyter http://jupyter-9pnxdkeb6jsfqkmq.192.168.71.93.modelz.live Ready 488 1/1 + http://192.168.71.93/inference/jupyter.default + simple-server http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live Ready 0 1/1 + http://192.168.71.93/inference/simple-server.default +$ curl http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live +... ``` -You could access the deployment by visiting the endpoint URL. It will be `http://simple-server-4k2epq5lynxbaayn.192.168.71.93.modelz.live` in this case. The endpoint could be accessed from the outside world as well if you've provided the public IP address of your server to the `mdz server start` command. - ### Scale your deployment You could scale your deployment by using the `mdz scale` command. -```bash +```text /scale/ $ mdz scale simple-server --replicas 3 ``` -The requests will be load balanced between the replicas of your deployment. +The requests will be load balanced between the replicas of your deployment. + +You could also tell the `mdz` to **autoscale your deployment** based on the inflight requests. Please check out the [Autoscaling](https://docs.open.modelz.ai/deployment/autoscale) documentation for more details. ### Debug your deployment Sometimes you may want to debug your deployment. You could use the `mdz logs` command to get the logs of your deployment. -```bash +```text /logs/ $ mdz logs simple-server simple-server-6756dd67ff-4bf4g: 10.42.0.1 - - [27/Jul/2023 02:32:16] "GET / HTTP/1.1" 200 - simple-server-6756dd67ff-4bf4g: 10.42.0.1 - - [27/Jul/2023 02:32:16] "GET / HTTP/1.1" 200 - @@ -128,22 +146,23 @@ simple-server-6756dd67ff-4bf4g: 10.42.0.1 - - [27/Jul/2023 02:32:17] "GET / HTTP You could also use the `mdz exec` command to execute a command in the container of your deployment. You do not need to ssh into the server to do that. -``` +```text /exec/ $ mdz exec simple-server ps PID USER TIME COMMAND 1 root 0:00 /usr/bin/dumb-init /bin/sh -c python3 -m http.server 80 7 root 0:00 /bin/sh -c python3 -m http.server 80 8 root 0:00 python3 -m http.server 80 9 root 0:00 ps +``` + +```text /exec/ $ mdz exec simple-server -ti bash -bash-4.4# uname -r -5.19.0-46-generic bash-4.4# ``` Or you could port-forward the deployment to your local machine and debug it locally. -``` +```text /port-forward/ $ mdz port-forward simple-server 7860 Forwarding inference simple-server to local port 7860 ``` @@ -152,22 +171,25 @@ Forwarding inference simple-server to local port 7860 You could add more servers to your cluster by using the `mdz server join` command. The `mdz` server will be bootstrapped on the server and join the cluster automatically. -``` +```text /join/ +$ mdz server join $ mdz server list NAME PHASE ALLOCATABLE CAPACITY node1 Ready cpu: 16 cpu: 16 mem: 32784748Ki mem: 32784748Ki + gpu: 1 gpu: 1 node2 Ready cpu: 16 cpu: 16 mem: 32784748Ki mem: 32784748Ki + gpu: 1 gpu: 1 ``` ### Label your servers You could label your servers to deploy your models to specific servers. For example, you could label your servers with `gpu=true` and deploy your models to servers with GPUs. -``` +```text /--node-labels gpu=true,type=nvidia-a100/ $ mdz server label node3 gpu=true type=nvidia-a100 -$ mdz deploy --image aikain/simplehttpserver:0.1 --name simple-server --port 80 --node-labels gpu=true,type=nvidia-a100 +$ mdz deploy ... --node-labels gpu=true,type=nvidia-a100 ``` ## Roadmap 🗂️ @@ -192,6 +214,7 @@ We welcome all kinds of contributions from the open-source community, individual Jinjing Zhou
Jinjing Zhou

💬 🐛 🤔 Keming
Keming

💻 🎨 🚇 Teddy Xinyuan Chen
Teddy Xinyuan Chen

📖 + Xuanwo
Xuanwo

🖋 🎨 🤔 cutecutecat
cutecutecat

🤔 xieydd
xieydd

🤔 diff --git a/agent/README.md b/agent/README.md index 2f7d274..c3eeaf6 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,8 +1,20 @@ +
+ # OpenModelZ Agent -TODO: +
+ +

+discord invitation link +trackgit-views +

+ +## Installation + +``` +pip install openmodelz +``` + +## Architecture -- Make resolver optional -- proxy with use case -- mdz init -- ai lab +Please check out [Architecture](https://docs.open.modelz.ai/architecture) documentation. From 04d9068613d01aced956349b65abc893ebf7e12f Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Thu, 3 Aug 2023 21:25:02 +0800 Subject: [PATCH 28/37] feat: Add a simple landing page (#115) Signed-off-by: Ce Gao --- agent/pkg/server/handler_root.go | 16 ++ agent/pkg/server/server_init_route.go | 3 + agent/pkg/server/static/index.html | 339 ++++++++++++++++++++++++++ agent/pkg/server/static/landing.go | 34 +++ agent/pkg/version/version.go | 6 +- images/jupyter.png | Bin 0 -> 143357 bytes 6 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 agent/pkg/server/handler_root.go create mode 100644 agent/pkg/server/static/index.html create mode 100644 agent/pkg/server/static/landing.go create mode 100644 images/jupyter.png diff --git a/agent/pkg/server/handler_root.go b/agent/pkg/server/handler_root.go new file mode 100644 index 0000000..0fc6039 --- /dev/null +++ b/agent/pkg/server/handler_root.go @@ -0,0 +1,16 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "github.com/tensorchord/openmodelz/agent/pkg/server/static" +) + +func (s *Server) handleRoot(c *gin.Context) error { + lp, err := static.RenderLoadingPage() + if err != nil { + return err + } + + c.Data(200, "text/html; charset=utf-8", lp.Bytes()) + return nil +} diff --git a/agent/pkg/server/server_init_route.go b/agent/pkg/server/server_init_route.go index 4fbd0f9..9ba4ca1 100644 --- a/agent/pkg/server/server_init_route.go +++ b/agent/pkg/server/server_init_route.go @@ -38,6 +38,9 @@ func (s *Server) registerRoutes() { // healthz root.GET(endpointHealthz, WrapHandler(s.handleHealthz)) + // landing page + root.GET("/", WrapHandler(s.handleRoot)) + // control plane controlPlane := root.Group("/system") // inferences diff --git a/agent/pkg/server/static/index.html b/agent/pkg/server/static/index.html new file mode 100644 index 0000000..04d48d7 --- /dev/null +++ b/agent/pkg/server/static/index.html @@ -0,0 +1,339 @@ + + + + + + + + OpenModelZ Serving | Running + + + + +
+
+

+ OpenModelZ server is running + Version: {{.Version}} + +

+

+ Please check out the documentation + for the next steps. + + Please contact us + on discord if there is any issue. + +

+
+
+ + + diff --git a/agent/pkg/server/static/landing.go b/agent/pkg/server/static/landing.go new file mode 100644 index 0000000..5b49de3 --- /dev/null +++ b/agent/pkg/server/static/landing.go @@ -0,0 +1,34 @@ +package static + +import ( + "bytes" + _ "embed" + "html/template" + + "github.com/tensorchord/openmodelz/agent/pkg/version" +) + +//go:embed index.html +var htmlTemplate string + +type htmlStruct struct { + Version string +} + +func RenderLoadingPage() (*bytes.Buffer, error) { + tmpl, err := template.New("root").Parse(htmlTemplate) + if err != nil { + return nil, err + } + + data := htmlStruct{ + Version: version.GetAgentVersion(), + } + + var buffer bytes.Buffer + if err := tmpl.Execute(&buffer, data); err != nil { + return nil, err + } + + return &buffer, nil +} diff --git a/agent/pkg/version/version.go b/agent/pkg/version/version.go index 20deaef..ca631b7 100644 --- a/agent/pkg/version/version.go +++ b/agent/pkg/version/version.go @@ -63,8 +63,8 @@ func SetGitTagForE2ETest(tag string) { gitTag = tag } -// GetEnvdVersion gets Envd version information -func GetEnvdVersion() string { +// GetAgentVersion gets Envd version information +func GetAgentVersion() string { var versionStr string if gitCommit != "" && gitTag != "" && @@ -95,7 +95,7 @@ func GetEnvdVersion() string { // GetVersion returns the version information func GetVersion() Version { return Version{ - Version: GetEnvdVersion(), + Version: GetAgentVersion(), BuildDate: buildDate, GitCommit: gitCommit, GitTag: gitTag, diff --git a/images/jupyter.png b/images/jupyter.png new file mode 100644 index 0000000000000000000000000000000000000000..ea10f0f039104903d010c1fe22be303531c7b26b GIT binary patch literal 143357 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 Date: Fri, 4 Aug 2023 09:58:48 +0800 Subject: [PATCH 29/37] chore: Bump version (#116) Signed-off-by: Ce Gao --- mdz/pkg/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mdz/pkg/version/version.go b/mdz/pkg/version/version.go index 59a6680..5bd3562 100644 --- a/mdz/pkg/version/version.go +++ b/mdz/pkg/version/version.go @@ -30,7 +30,7 @@ var ( // Package is filled at linking time Package = "github.com/tensorchord/openmodelz/agent" - HelmChartVersion = "0.0.11" + HelmChartVersion = "0.0.13" // Revision is filled with the VCS (e.g. git) revision being used to build // the program at linking time. From 9e9e11451e513506301adadcd2f417273eb4851f Mon Sep 17 00:00:00 2001 From: Keming Date: Sat, 5 Aug 2023 09:49:19 +0800 Subject: [PATCH 30/37] feat: use pretty quantity for memory resource (#118) Signed-off-by: Keming --- mdz/go.mod | 1 + mdz/go.sum | 2 ++ mdz/pkg/cmd/server_list.go | 31 ++++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/mdz/go.mod b/mdz/go.mod index 3456790..b8fbbdb 100644 --- a/mdz/go.mod +++ b/mdz/go.mod @@ -84,5 +84,6 @@ require ( golang.org/x/text v0.9.0 // indirect golang.org/x/tools v0.7.0 // indirect google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/mdz/go.sum b/mdz/go.sum index 465f35a..63fefe9 100644 --- a/mdz/go.sum +++ b/mdz/go.sum @@ -275,6 +275,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/mdz/pkg/cmd/server_list.go b/mdz/pkg/cmd/server_list.go index 600af55..7f05c50 100644 --- a/mdz/pkg/cmd/server_list.go +++ b/mdz/pkg/cmd/server_list.go @@ -2,10 +2,14 @@ package cmd import ( "fmt" + "math" "github.com/cockroachdb/errors" "github.com/jedib0t/go-pretty/v6/table" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/resource" + "github.com/tensorchord/openmodelz/agent/api/types" "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) @@ -108,8 +112,33 @@ func labelsString(labels map[string]string) string { return res[:len(res)-1] } +func prettyByteSize(quantity string) (string, error) { + r, err := resource.ParseQuantity(quantity) + if err != nil { + return "", err + } + bf := float64(r.Value()) + for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti"} { + if math.Abs(bf) < 1024.0 { + return fmt.Sprintf("%3.1f%sB", bf, unit), nil + } + bf /= 1024.0 + } + return fmt.Sprintf("%.1fPiB", bf), nil +} + func resourceListString(l types.ResourceList) string { - res := fmt.Sprintf("cpu: %s\nmem: %s", l["cpu"], l["memory"]) + res := fmt.Sprintf("cpu: %s", l[types.ResourceCPU]) + memory, ok := l[types.ResourceMemory] + if ok { + prettyMem, err := prettyByteSize(string(memory)) + if err != nil { + logrus.Infof("failed to parse the memory quantity: %s", memory) + } else { + memory = types.Quantity(prettyMem) + } + } + res += fmt.Sprintf("\nmemory: %s", memory) if l[types.ResourceGPU] != "" { res += fmt.Sprintf("\ngpu: %s", l[types.ResourceGPU]) } From 5ed6ef6425273828886bf36ea91def9154b3c49a Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Sat, 5 Aug 2023 11:12:01 +0800 Subject: [PATCH 31/37] feat: Add readme (#120) Signed-off-by: Ce Gao --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ca34828..2c7d9e1 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,12 @@ It is `http://jupyter-9pnxdkeb6jsfqkmq.192.168.71.93.modelz.live` in this case. ![jupyter notebook](./images/jupyter.png) -### Create your first API-based deployment +### Create your first OpenAI compatible API server -You could also create API-based deployments. We will use a simple python server as an example in this tutorial. You could use any docker image as your deployment. +You could also create API-based deployments. We will use [OpenAI compatible API server with Bloomz 560M](https://github.com/tensorchord/modelz-llm#run-the-self-hosted-api-server) as an example in this tutorial. ```text -$ mdz deploy --image python:3.9.6-slim-buster --name simple-server --port 8080 --command "python -m http.server 8080" +$ mdz deploy --image modelzai/llm-bloomz-560m:23.07.4 --name simple-server Inference simple-server is created $ mdz list NAME ENDPOINT STATUS INVOCATIONS REPLICAS @@ -117,8 +117,21 @@ $ mdz list http://192.168.71.93/inference/jupyter.default simple-server http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live Ready 0 1/1 http://192.168.71.93/inference/simple-server.default -$ curl http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live -... +``` + +You could use OpenAI python package and the endpoint `http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live` in this case, to interact with the deployment. + +```python +import openai +openai.api_base="http://simple-server-lagn8m9m8648q6kx.192.168.71.93.modelz.live" +openai.api_key="any" + +# create a chat completion +chat_completion = openai.ChatCompletion.create(model="bloomz", messages=[ + {"role": "user", "content": "Who are you?"}, + {"role": "assistant", "content": "I am a student"}, + {"role": "user", "content": "What do you learn?"}, +], max_tokens=100) ``` ### Scale your deployment From 766ee3e464ed9f6c3f72e63e9531d44de6a5c89a Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Sat, 5 Aug 2023 11:23:10 +0800 Subject: [PATCH 32/37] chore: Add sudo doc (#121) Signed-off-by: Ce Gao --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c7d9e1..f0d076a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ Once you've installed the `mdz` you can start deploying models and experimenting ### Bootstrap `mdz` -It's super easy to bootstrap the `mdz` server. You just need to find a server (could be a cloud VM, a home lab, or even a single machine) and run the `mdz server start` command. The `mdz` server will be bootstrapped on the server as a controller node and you could start deploying your models. +It's super easy to bootstrap the `mdz` server. You just need to find a server (could be a cloud VM, a home lab, or even a single machine) and run the `mdz server start` command. + +> Notice: We may require the root permission to bootstrap the `mdz` server on port 80. ``` $ mdz server start From dd993eca35ad71200213bea26ba8ffff7a0d2bea Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 7 Aug 2023 15:45:20 +0800 Subject: [PATCH 33/37] feat: support HTTP probe configuration (#129) Signed-off-by: Keming --- mdz/pkg/cmd/deploy.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mdz/pkg/cmd/deploy.go b/mdz/pkg/cmd/deploy.go index bc38237..adb4fc5 100644 --- a/mdz/pkg/cmd/deploy.go +++ b/mdz/pkg/cmd/deploy.go @@ -22,6 +22,7 @@ var ( deployGPU int deployNodeLabel []string deployCommand string + deployProbePath string ) // deployCmd represents the deploy command @@ -56,6 +57,7 @@ func init() { deployCmd.Flags().StringVar(&deployName, "name", "", "Name of inference") deployCmd.Flags().StringSliceVarP(&deployNodeLabel, "node-labels", "l", []string{}, "Node labels") deployCmd.Flags().StringVar(&deployCommand, "command", "", "Command to run") + deployCmd.Flags().StringVar(&deployProbePath, "probe-path", "", "HTTP Health probe path") } func commandDeploy(cmd *cobra.Command, args []string) error { @@ -93,6 +95,9 @@ func commandDeploy(cmd *cobra.Command, args []string) error { if deployCommand != "" { inf.Spec.Command = &deployCommand } + if deployProbePath != "" { + inf.Spec.HTTPProbePath = &deployProbePath + } if len(deployNodeLabel) > 0 { inf.Spec.Constraints = []string{} From 49557f2502d2d1434ef05b7278e9049d47384f8a Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 7 Aug 2023 15:54:17 +0800 Subject: [PATCH 34/37] chore: fix typos (#128) Signed-off-by: Keming --- .github/workflows/CI.yaml | 11 +++++++++++ agent/pkg/app/config.go | 2 +- agent/pkg/app/root.go | 4 ++-- agent/pkg/prom/prometheus_query.go | 2 +- agent/pkg/runtime/util_domain.go | 2 +- autoscaler/pkg/autoscaler/scaler.go | 2 +- autoscaler/pkg/prom/prom.go | 2 +- ingress-operator/pkg/controller/v1/controller_test.go | 2 +- mdz/pkg/server/agentd_run.go | 2 +- mdz/pkg/server/engine.go | 1 - modelzetes/pkg/app/root.go | 4 ++-- modelzetes/pkg/k8s/proxy.go | 2 +- typos.toml | 5 +++++ 13 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 typos.toml diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 684f87e..ce8938b 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -21,6 +21,17 @@ concurrency: cancel-in-progress: true jobs: + typos-check: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v3 + - name: Check spelling with custom config file + uses: crate-ci/typos@v1.16.2 + with: + config: ./typos.toml + test: name: test strategy: diff --git a/agent/pkg/app/config.go b/agent/pkg/app/config.go index 19b41f8..73ab589 100644 --- a/agent/pkg/app/config.go +++ b/agent/pkg/app/config.go @@ -11,7 +11,7 @@ func configFromCLI(c *cli.Context) config.Config { // server cfg.Server.Dev = c.Bool(flagDev) - cfg.Server.ServerPort = c.Int(flageServerPort) + cfg.Server.ServerPort = c.Int(flagServerPort) cfg.Server.ReadTimeout = c.Duration(flagServerReadTimeout) cfg.Server.WriteTimeout = c.Duration(flagServerWriteTimeout) diff --git a/agent/pkg/app/root.go b/agent/pkg/app/root.go index e6bd2d8..871a326 100644 --- a/agent/pkg/app/root.go +++ b/agent/pkg/app/root.go @@ -21,7 +21,7 @@ const ( flagDev = "dev" // server - flageServerPort = "server-port" + flagServerPort = "server-port" flagServerReadTimeout = "server-read-timeout" flagServerWriteTimeout = "server-write-timeout" @@ -89,7 +89,7 @@ func New() App { Usage: "enable development mode", }, &cli.IntFlag{ - Name: flageServerPort, + Name: flagServerPort, Value: 8081, Usage: "port to listen on", EnvVars: []string{"MODELZ_AGENT_SERVER_PORT"}, diff --git a/agent/pkg/prom/prometheus_query.go b/agent/pkg/prom/prometheus_query.go index 0019f48..2b293bf 100644 --- a/agent/pkg/prom/prometheus_query.go +++ b/agent/pkg/prom/prometheus_query.go @@ -77,7 +77,7 @@ func (q PrometheusQuery) Fetch(query string) (*VectorQueryResponse, error) { unmarshalErr := json.Unmarshal(bytesOut, &values) if unmarshalErr != nil { - return nil, fmt.Errorf("error unmarshaling result: %s, '%s'", unmarshalErr, string(bytesOut)) + return nil, fmt.Errorf("error unmarshalling result: %s, '%s'", unmarshalErr, string(bytesOut)) } return &values, nil diff --git a/agent/pkg/runtime/util_domain.go b/agent/pkg/runtime/util_domain.go index 3fac331..bc0429a 100644 --- a/agent/pkg/runtime/util_domain.go +++ b/agent/pkg/runtime/util_domain.go @@ -11,7 +11,7 @@ const ( ) const ( - // stdLen is a standard length of uniuri string to achive ~95 bits of entropy. + // stdLen is a standard length of uniuri string to achieve ~95 bits of entropy. stdLen = 16 ) diff --git a/autoscaler/pkg/autoscaler/scaler.go b/autoscaler/pkg/autoscaler/scaler.go index cd05c00..51d3598 100644 --- a/autoscaler/pkg/autoscaler/scaler.go +++ b/autoscaler/pkg/autoscaler/scaler.go @@ -306,7 +306,7 @@ func (s *Scaler) AutoScale(interval time.Duration) { "service": service, "replicas": totalReplicas, "expectedReplicas": expectedReplicas, - "availabelReplicas": availableReplicas, + "availableReplicas": availableReplicas, "currentLoad": lc.CurrentLoad, "targetLoad": targetLoad, "zeroDuration": zeroDuration, diff --git a/autoscaler/pkg/prom/prom.go b/autoscaler/pkg/prom/prom.go index 4436544..794c590 100644 --- a/autoscaler/pkg/prom/prom.go +++ b/autoscaler/pkg/prom/prom.go @@ -64,7 +64,7 @@ func (q PrometheusQuery) Fetch(query string) (*VectorQueryResponse, error) { unmarshalErr := json.Unmarshal(bytesOut, &values) if unmarshalErr != nil { - return nil, fmt.Errorf("error unmarshaling result: %s, '%s'", unmarshalErr, string(bytesOut)) + return nil, fmt.Errorf("error unmarshalling result: %s, '%s'", unmarshalErr, string(bytesOut)) } return &values, nil diff --git a/ingress-operator/pkg/controller/v1/controller_test.go b/ingress-operator/pkg/controller/v1/controller_test.go index a38fd4b..b6ccee4 100644 --- a/ingress-operator/pkg/controller/v1/controller_test.go +++ b/ingress-operator/pkg/controller/v1/controller_test.go @@ -152,7 +152,7 @@ func Test_makeRules_Traefik_NestedPath_TrimsRegex_And_TrailingSlash(t *testing.T } } -func Test_makTLS(t *testing.T) { +func Test_makeTLS(t *testing.T) { cases := []struct { name string diff --git a/mdz/pkg/server/agentd_run.go b/mdz/pkg/server/agentd_run.go index 2bef043..9c16080 100644 --- a/mdz/pkg/server/agentd_run.go +++ b/mdz/pkg/server/agentd_run.go @@ -11,7 +11,7 @@ type agentDRunStep struct { } // TODO(gaocegege): There is still a bug, thus it cannot be used actually. -// The process wil exit after the command returns. We need to put it in systemd. +// The process will exit after the command returns. We need to put it in systemd. func (s *agentDRunStep) Run() error { fmt.Fprintf(s.options.OutputStream, "🚧 Running the agent for docker runtime...\n") cmd := exec.Command("/bin/sh", "-c", "mdz local-agent &") diff --git a/mdz/pkg/server/engine.go b/mdz/pkg/server/engine.go index 91573a3..f92ccec 100644 --- a/mdz/pkg/server/engine.go +++ b/mdz/pkg/server/engine.go @@ -116,7 +116,6 @@ func NewJoin(o Options) (*Engine, error) { return &Engine{ options: o, Steps: []Step{ - // Kill all k3s and related tools. &k3sJoinStep{ options: o, }, diff --git a/modelzetes/pkg/app/root.go b/modelzetes/pkg/app/root.go index bfef40d..e98bf5d 100644 --- a/modelzetes/pkg/app/root.go +++ b/modelzetes/pkg/app/root.go @@ -21,7 +21,7 @@ const ( flagDebug = "debug" // metrics - flageMetricsServerPort = "metrics-server-port" + flagMetricsServerPort = "metrics-server-port" // kubernetes flagMasterURL = "master-url" @@ -73,7 +73,7 @@ func New() App { EnvVars: []string{"DEBUG"}, }, &cli.IntFlag{ - Name: flageMetricsServerPort, + Name: flagMetricsServerPort, Value: 8081, Usage: "port to listen on", EnvVars: []string{"MODELZETES_SERVER_PORT"}, diff --git a/modelzetes/pkg/k8s/proxy.go b/modelzetes/pkg/k8s/proxy.go index c05b24d..e5662f8 100644 --- a/modelzetes/pkg/k8s/proxy.go +++ b/modelzetes/pkg/k8s/proxy.go @@ -109,6 +109,6 @@ func (l *FunctionLookup) verifyNamespace(name string) error { if name != "kube-system" { return nil } - // ToDo use global namepace parse and validation + // ToDo use global namespace parse and validation return fmt.Errorf("namespace not allowed") } diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..b4298e3 --- /dev/null +++ b/typos.toml @@ -0,0 +1,5 @@ +[files] +extend-exclude = ["CHANGELOG.md", "go.mod", "go.sum"] +[default.extend-words] +requestor = "requestor" +ba = "ba" From 5cadbb7b542327ce4ad563b5220a72e45cb90f5b Mon Sep 17 00:00:00 2001 From: Ce Gao Date: Mon, 7 Aug 2023 16:40:33 +0800 Subject: [PATCH 35/37] docs: add @Zheaoli as a contributor (#130) --- .all-contributorsrc | 11 +++++++++++ README.md | 3 +++ 2 files changed, 14 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 4653b7b..d653e6a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -81,6 +81,17 @@ "design", "ideas" ] + }, + { + "login": "Zheaoli", + "name": "Nadeshiko Manju", + "avatar_url": "https://avatars.githubusercontent.com/u/7054676?v=4", + "profile": "http://manjusaka.itscoder.com/", + "contributions": [ + "bug", + "design", + "ideas" + ] } ], "contributorsPerLine": 7 diff --git a/README.md b/README.md index f0d076a..9cdfb91 100644 --- a/README.md +++ b/README.md @@ -228,9 +228,12 @@ We welcome all kinds of contributions from the open-source community, individual Ce Gao
Ce Gao

💻 👀 Jinjing Zhou
Jinjing Zhou

💬 🐛 🤔 Keming
Keming

💻 🎨 🚇 + Nadeshiko Manju
Nadeshiko Manju

🐛 🎨 🤔 Teddy Xinyuan Chen
Teddy Xinyuan Chen

📖 Xuanwo
Xuanwo

🖋 🎨 🤔 cutecutecat
cutecutecat

🤔 + + xieydd
xieydd

🤔 From c23067f0818305c675be06950c883faa3299e386 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 8 Aug 2023 16:22:44 +0800 Subject: [PATCH 36/37] feat: support follow the k8s log (#131) Signed-off-by: Keming --- agent/client/log.go | 41 ++++++++++++++-------- agent/pkg/log/k8s.go | 23 ++++++------ agent/pkg/server/handler_inference_logs.go | 10 ++++-- mdz/pkg/cmd/logs.go | 6 ++-- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/agent/client/log.go b/agent/client/log.go index e35fc49..13c60fd 100644 --- a/agent/client/log.go +++ b/agent/client/log.go @@ -12,13 +12,16 @@ import ( "net/url" "strings" + "github.com/sirupsen/logrus" "github.com/tensorchord/openmodelz/agent/api/types" ) +const LogBufferSize = 128 + // DeploymentLogGet gets the deployment logs. func (cli *Client) DeploymentLogGet(ctx context.Context, namespace, name string, - since string, tail int, end string) ( - []types.Message, error) { + since string, tail int, end string, follow bool) ( + <-chan types.Message, error) { urlValues := url.Values{} urlValues.Add("namespace", namespace) urlValues.Add("name", name) @@ -35,26 +38,34 @@ func (cli *Client) DeploymentLogGet(ctx context.Context, namespace, name string, urlValues.Add("tail", fmt.Sprintf("%d", tail)) } - resp, err := cli.get(ctx, "/system/logs/inference", urlValues, nil) - defer ensureReaderClosed(resp) + if follow { + urlValues.Add("follow", "true") + } + resp, err := cli.get(ctx, "/system/logs/inference", urlValues, nil) + if err != nil { - return nil, - wrapResponseError(err, resp, "deployment logs", name) + return nil, wrapResponseError(err, resp, "deployment logs", name) } - + + stream := make(chan types.Message, LogBufferSize) var log types.Message - logs := []types.Message{} scanner := bufio.NewScanner(resp.body) - for scanner.Scan() { - err = json.NewDecoder(strings.NewReader(scanner.Text())).Decode(&log) - if err != nil { - return nil, wrapResponseError(err, resp, "deployment logs", name) + go func () { + defer ensureReaderClosed(resp) + defer close(stream) + for scanner.Scan() { + err = json.Unmarshal(scanner.Bytes(), &log) + if err != nil { + logrus.Warnf("failed to decode %s log: %v | %s | [%s]", name, err, scanner.Text(), scanner.Err()) + return + // continue + } + stream <- log } - logs = append(logs, log) - } + }() - return logs, err + return stream, err } func (cli *Client) BuildLogGet(ctx context.Context, namespace, name, since string, diff --git a/agent/pkg/log/k8s.go b/agent/pkg/log/k8s.go index 0914ef7..417fbb4 100644 --- a/agent/pkg/log/k8s.go +++ b/agent/pkg/log/k8s.go @@ -2,7 +2,6 @@ package log import ( "bufio" - "bytes" "context" "fmt" "io" @@ -63,6 +62,9 @@ func (k *K8sAPIRequestor) Query(ctx context.Context, if err != nil { return nil, errdefs.InvalidParameter(err) } + } else if r.Follow { + // avoid truncate + endTime = time.Now().Add(time.Hour) } else { endTime = time.Now() } @@ -152,7 +154,7 @@ func podLogs(ctx context.Context, i v1.PodInterface, pod, container, opts.SinceSeconds = parseSince(since) } - stream, err := i.GetLogs(pod, opts).Stream(context.TODO()) + stream, err := i.GetLogs(pod, opts).Stream(ctx) if err != nil { return err } @@ -160,14 +162,9 @@ func podLogs(ctx context.Context, i v1.PodInterface, pod, container, done := make(chan error) go func() { - reader := bufio.NewReader(stream) - for { - line, err := reader.ReadBytes('\n') - if err != nil { - done <- err - return - } - msg, ts := extractTimestampAndMsg(string(bytes.Trim(line, "\x00"))) + scanner := bufio.NewScanner(stream) + for scanner.Scan() { + msg, ts := extractTimestampAndMsg(scanner.Text()) dst <- types.Message{ Timestamp: ts, Text: msg, @@ -176,13 +173,19 @@ func podLogs(ctx context.Context, i v1.PodInterface, pod, container, Namespace: namespace, } } + if err := scanner.Err(); err != nil { + done <- err + return + } }() select { case <-ctx.Done(): + logrus.Debug("get-log context cancelled") return ctx.Err() case err := <-done: if err != io.EOF { + logrus.Debugf("failed to read from pod log: %v", err) return err } return nil diff --git a/agent/pkg/server/handler_inference_logs.go b/agent/pkg/server/handler_inference_logs.go index 5c19035..a0fc630 100644 --- a/agent/pkg/server/handler_inference_logs.go +++ b/agent/pkg/server/handler_inference_logs.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "time" "github.com/cockroachdb/errors" "github.com/gin-gonic/gin" @@ -46,8 +47,13 @@ func (s Server) getLogsFromRequester(c *gin.Context, requester log.Requester) er } _ = cn - ctx, cancelQuery := context.WithTimeout(c.Request.Context(), - s.config.Inference.LogTimeout) + timeout := s.config.Inference.LogTimeout + if req.Follow { + // use a much larger timeout for streaming log + timeout = time.Hour + } + + ctx, cancelQuery := context.WithTimeout(c.Request.Context(), timeout) defer cancelQuery() messages, err := requester.Query(ctx, req) diff --git a/mdz/pkg/cmd/logs.go b/mdz/pkg/cmd/logs.go index 53bcdd2..d0a3e30 100644 --- a/mdz/pkg/cmd/logs.go +++ b/mdz/pkg/cmd/logs.go @@ -9,6 +9,7 @@ var ( tail int since string end string + follow bool ) // logCmd represents the log command @@ -36,15 +37,16 @@ func init() { logsCmd.Flags().IntVarP(&tail, "tail", "t", 0, "Number of lines to show from the end of the logs") logsCmd.Flags().StringVarP(&since, "since", "s", "2006-01-02T15:04:05Z", "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)") logsCmd.Flags().StringVarP(&end, "end", "e", "", "Only return logs before this timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)") + logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") } func commandLogs(cmd *cobra.Command, args []string) error { - logs, err := agentClient.DeploymentLogGet(cmd.Context(), namespace, args[0], since, tail, end) + logStream, err := agentClient.DeploymentLogGet(cmd.Context(), namespace, args[0], since, tail, end, follow) if err != nil { cmd.PrintErrf("Failed to get logs: %s\n", err) return err } - for _, log := range logs { + for log := range logStream { cmd.Printf("%s: %s\n", log.Instance, log.Text) } return nil From df7893a8578d6ada42b25eaf388291b5ebc15ee9 Mon Sep 17 00:00:00 2001 From: Teddy Xinyuan Chen <45612704+tddschn@users.noreply.github.com> Date: Wed, 9 Aug 2023 20:45:42 +0800 Subject: [PATCH 37/37] attempt to fix Client type import --- mdz/pkg/cmd/deploy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mdz/pkg/cmd/deploy.go b/mdz/pkg/cmd/deploy.go index d612c9c..6b8cfb3 100644 --- a/mdz/pkg/cmd/deploy.go +++ b/mdz/pkg/cmd/deploy.go @@ -9,6 +9,7 @@ import ( petname "github.com/dustinkirkland/golang-petname" "github.com/spf13/cobra" "github.com/tensorchord/openmodelz/agent/api/types" + "github.com/tensorchord/openmodelz/agent/client" "github.com/tensorchord/openmodelz/mdz/pkg/telemetry" ) @@ -62,7 +63,7 @@ func init() { deployCmd.Flags().StringVar(&deployProbePath, "probe-path", "", "HTTP Health probe path") } -func waitForDeploymentReady(cmd *cobra.Command, client *Client, namespace, name string, interval time.Duration, timeoutSeconds int) error { +func waitForDeploymentReady(cmd *cobra.Command, client *client.Client, namespace, name string, interval time.Duration, timeoutSeconds int) error { timeout := time.After(time.Duration(timeoutSeconds) * time.Second) tick := time.Tick(interval)