diff --git a/Makefile b/Makefile index 52122ade..a88efafa 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,7 @@ endif (cd test/e2e-tests/tests && S3_BUCKET=${S3_BUCKET} TARGET_IMAGE_TAGS_JSON=${TARGET_IMAGE_TAGS_JSON} NAMESPACE=${NAMESPACE} ${GO} test -timeout 30m) test: + ${MAKE} -C cli cli-test @(./test/shell-pipeline-tests/seldon-bike-rentals/pipelines-test-seldon-bike-rentals.sh) @(./test/shell-pipeline-tests/openvino-tensorflow-housing/pipelines-test-openvino-tensorflow-housing.sh) diff --git a/cli/examples/params.yaml.sample b/cli/examples/params.yaml.sample new file mode 100644 index 00000000..823923ad --- /dev/null +++ b/cli/examples/params.yaml.sample @@ -0,0 +1,47 @@ +params: + # The name of the S3 bucket where the model is stored + - name: s3-bucket-name + value: BUCKET_NAME + # The URL of the git repository where the containerfile is stored + - name: git-containerfile-repo + value: GIT_REPO_URL + # The branch of the git repository where the containerfile is stored + - name: git-containerfile-revision + value: GIT_BRANCH + # The relative path to the containerfile in the git repository + - name: containerfileRelativePath + value: RELATIVE_PATH + # The method used to fetch the model (s3 or git) + - name: fetch-model + value: FETCH_METHOD + # The URL of the git repository where the model is stored. This is only used if fetch-model is set to git. + - name: git-model-repo + value: GIT_REPO_URL + # The relative path to the model in the git repository. This is only used if fetch-model is set to git. + - name: modelRelativePath + value: RELATIVE_PATH + # The branch of the git repository where the model is stored. This is only used if fetch-model is set to git. + - name: git-model-revision + value: GIT_BRANCH + # The name of the model serving test endpoint (e.g. invocations) + - name: test-endpoint + value: ENDPOINT_NAME + # The candidate image tag reference. This is the intermediate image that is built during the pipeline. + # e.g. image-registry.openshift-image-registry.svc:5000/$(context.pipelineRun.namespace)/$(params.model-name):$(params.model-version)-candidate + - name: candidate-image-tag-reference + value: CANDIDATE_IMAGE_TAG + # The target image tag references. These are the final images that are pushed to the image registry. A typical value would be: + # - quay.io/rhoai-edge/$(params.model-name):$(params.model-version)-$(context.pipelineRun.uid) + # - quay.io/rhoai-edge/$(params.model-name):$(params.model-version) + # - quay.io/rhoai-edge/$(params.model-name):latest + - name: target-image-tag-references + value: + - TARGET_IMAGE_TAG_1 + - TARGET_IMAGE_TAG_2 + # The action to take upon the completion of the pipeline (e.g. delete) + - name: upon-end + value: ACTION + # The name of the secret that contains the S3 credentials + - name: s3SecretName + value: SECRET_NAME + # The name of the config map that contains the test data diff --git a/cli/go.mod b/cli/go.mod index 98b26ea2..e4e6de93 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -8,82 +8,28 @@ require ( github.com/charmbracelet/lipgloss v0.9.1 github.com/kubeflow/model-registry v0.0.0-20240312073310-67d9e4deff70 github.com/spf13/cobra v1.8.0 - github.com/spf13/viper v1.18.2 - gotest.tools/v3 v3.5.1 - k8s.io/api v0.29.2 - k8s.io/apimachinery v0.29.2 - k8s.io/client-go v0.29.2 + gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v4.12.0+incompatible // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.15.0 // indirect - github.com/onsi/gomega v1.31.1 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.6 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/oauth2 v0.15.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/term v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.18.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/klog/v2 v2.110.1 // indirect - k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect - k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 05dafaee..91166d23 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -10,59 +10,8 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= -github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -74,10 +23,6 @@ github.com/kubeflow/model-registry v0.0.0-20240312073310-67d9e4deff70 h1:pQ8qHl3 github.com/kubeflow/model-registry v0.0.0-20240312073310-67d9e4deff70/go.mod h1:3EgC6tpQm8sIgftEGujni7lZDx50/GjI1wLVpOOxw5I= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -85,13 +30,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -100,143 +38,32 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= -github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= -github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= -github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= -golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.8/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= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= -k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= -k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= -k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= -k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= -k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= -k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= -k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= -k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/cli/pkg/commands/common/cmd.go b/cli/pkg/commands/common/cmd.go new file mode 100644 index 00000000..900fee1d --- /dev/null +++ b/cli/pkg/commands/common/cmd.go @@ -0,0 +1,106 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package common + +import ( + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/flags" + "github.com/spf13/cobra" +) + +// SubCommand is a type to represent the subcommand +type SubCommand int + +const ( + // SubCommandList is a subcommand to list items + SubCommandList SubCommand = iota + // SubCommandAdd is a subcommand to add items + SubCommandAdd +) + +// NewCmd creates a new cobra command. +// +// The command will create a new tea program, passing the model created by the modelFactory, and run it. +// The modelFactory will be called with the args, flags and subCommand. +// +// Example: +// cmd := NewCmd( +// "images", +// "List images", +// `List images`, +// cobra.ExactArgs(3), +// []flags.Flag{flags.FlagModelRegistryUrl}, +// SubCommandList, +// func(args []string, flags map[string]string, subCommand SubCommand) tea.Model { +// return NewImagesModel(args, flags, subCommand) +// }, +// ) +func NewCmd( + use, short, long string, + args cobra.PositionalArgs, + flags []flags.Flag, + command SubCommand, + modelFactory func(args []string, flags map[string]string, subCommand SubCommand) tea.Model, +) *cobra.Command { + + cmd := cobra.Command{ + Use: use, + Short: short, + Long: long, + Args: args, + Run: func(cmd *cobra.Command, args []string) { + ff := make(map[string]string) + for _, f := range flags { + v := "" + err := error(nil) + if f.IsParentFlag() { + v, err = cmd.InheritedFlags().GetString(f.String()) + if err != nil { + cmd.PrintErrf("Error reading inherited flag %s: %v\n", f, err) + os.Exit(1) + } + } else { + v, err = cmd.Flags().GetString(f.String()) + if err != nil { + cmd.PrintErrf("Error reading flag %s: %v\n", f, err) + os.Exit(1) + } + } + ff[f.String()] = v + } + _, err := tea.NewProgram(modelFactory(args, ff, command)).Run() + if err != nil { + cmd.PrintErrf("Error: %v\n", err) + os.Exit(1) + } + }, + } + + for _, f := range flags { + if !f.IsParentFlag() { + if f.IsInherited() { + cmd.PersistentFlags().StringP(f.String(), f.Shorthand(), f.Value(), f.Usage()) + } else { + cmd.Flags().StringP(f.String(), f.Shorthand(), f.Value(), f.Usage()) + } + } + } + + return &cmd +} diff --git a/cli/pkg/commands/common/errors.go b/cli/pkg/commands/common/errors.go new file mode 100644 index 00000000..563268fd --- /dev/null +++ b/cli/pkg/commands/common/errors.go @@ -0,0 +1,25 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package common + +// ErrMsg is a wrapper for an error that implements the error interface. +// +// This is useful for returning an error from a model in the bubbletea program. +type ErrMsg struct{ Err error } + +// Error returns the error message. +func (e ErrMsg) Error() string { return e.Err.Error() } diff --git a/cli/pkg/commands/common/styles.go b/cli/pkg/commands/common/styles.go new file mode 100644 index 00000000..b8148297 --- /dev/null +++ b/cli/pkg/commands/common/styles.go @@ -0,0 +1,35 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package common + +import "github.com/charmbracelet/lipgloss" + +// TableBaseStyle is the base style for the table +var TableBaseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#04B575")) + +// MessageStyle is the style for regular messages +var MessageStyle = lipgloss.NewStyle(). + Bold(true) + +// ErrorStyle is the style for error messages +var ErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Bold(true). + Height(4). + Width(120) diff --git a/cli/pkg/commands/flags/flags.go b/cli/pkg/commands/flags/flags.go new file mode 100644 index 00000000..a9b5ac6d --- /dev/null +++ b/cli/pkg/commands/flags/flags.go @@ -0,0 +1,105 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package flags + +import ( + "fmt" + "os" +) + +// Flag represents a command line flag. +// +// Flags can be inherited by subcommands, in which case they will be passed to the subcommand. +type Flag struct { + name string + inherited bool // Flag is inherited by subcommands + parentFlag bool // Flag is defined in the parent command + shorthand string + value string + usage string +} + +var ( + // FlagModelRegistryURL is the URL of the model registry + FlagModelRegistryURL = Flag{ + name: "model-registry-url", inherited: true, shorthand: "m", value: "http://localhost:8080", + usage: "URL of the model registry", + } + + // FlagKubeconfig is the path to the kubeconfig file + FlagKubeconfig = Flag{ + name: "kubeconfig", inherited: true, shorthand: "k", + value: fmt.Sprintf("%s/.kube/config", os.Getenv("HOME")), + usage: "path to the kubeconfig file", + } + + // FlagNamespace is the namespace to use + FlagNamespace = Flag{name: "namespace"} + + // FlagParams is the path to the build parameters file + FlagParams = Flag{ + name: "params", + shorthand: "p", + value: "params.yaml", + usage: "path to the build parameters file", + } + + // Flags is a list of all flags + Flags = []Flag{FlagKubeconfig, FlagModelRegistryURL, FlagNamespace, FlagParams} +) + +// String returns the name of the flag. +func (f Flag) String() string { + return f.name +} + +// SetInherited sets the flag to be inherited by subcommands. +func (f Flag) SetInherited() Flag { + f.inherited = true + return f +} + +// IsInherited returns true if the flag is inherited by subcommands. +func (f Flag) IsInherited() bool { + return f.inherited +} + +// SetParentFlag sets the flag as one that's defined in the parent command. +func (f Flag) SetParentFlag() Flag { + f.parentFlag = true + return f +} + +// IsParentFlag returns true if the flag is defined in the parent command. +func (f Flag) IsParentFlag() bool { + return f.parentFlag +} + +// Shorthand returns the shorthand of the flag. +func (f Flag) Shorthand() string { + return f.shorthand +} + +// Value returns the value of the flag. +func (f Flag) Value() string { + return f.value +} + +// Usage returns the usage of the flag. +func (f Flag) Usage() string { + return f.usage +} diff --git a/cli/pkg/commands/models.go b/cli/pkg/commands/models.go deleted file mode 100644 index 1ec8cf7c..00000000 --- a/cli/pkg/commands/models.go +++ /dev/null @@ -1,163 +0,0 @@ -/* -Copyright 2023 KStreamer Authors - -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. -*/ - -package commands - -import ( - "context" - "fmt" - "os" - - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - openapiclient "github.com/kubeflow/model-registry/pkg/openapi" - "github.com/spf13/cobra" -) - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#04B575")) - -var errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")). - Bold(true). - Height(4). - Width(120) - -type registeredModelsMsg *openapiclient.RegisteredModelList - -type errMsg struct{ err error } - -func (e errMsg) Error() string { return e.err.Error() } - -func listRegisteredModels() tea.Msg { - configuration := openapiclient.NewConfiguration() - configuration.Servers = openapiclient.ServerConfigurations{ - { - URL: modelRegistryURL, - }, - } - - apiClient := openapiclient.NewAPIClient(configuration) - models, httpRes, err := apiClient.ModelRegistryServiceAPI.GetRegisteredModels( - context.Background(), - ).Execute() - if err != nil { - return errMsg{err} - } - if httpRes.StatusCode != 200 { - fmt.Printf("not 200: %d\n", httpRes.StatusCode) - return errMsg{ - fmt.Errorf( - "Failed to get models, calling the model registry API returned status code %d\n", httpRes.StatusCode, - ), - } - } - return registeredModelsMsg(models) -} - -type model struct { - registeredModelsList *openapiclient.RegisteredModelList - cursor int - choices int - err error -} - -func (m model) Init() tea.Cmd { - return listRegisteredModels -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case errMsg: - m.err = msg - return m, tea.Quit - - case registeredModelsMsg: - m.registeredModelsList = msg - m.choices = len(m.registeredModelsList.Items) - return m, tea.Quit - } - return m, nil -} - -func (m model) View() string { - if m.err != nil { - return errorStyle.Render(fmt.Sprintf("Error: %s", m.err)) - // return "" - } - columns := []table.Column{ - {Title: "Id", Width: 4}, - {Title: "Name", Width: 20}, - {Title: "Description", Width: 60}, - } - - rows := make([]table.Row, 0) - - if m.registeredModelsList != nil { - for _, model := range m.registeredModelsList.Items { - rows = append( - rows, table.Row{ - model.GetId(), - model.GetName(), - model.GetDescription(), - }, - ) - } - } - - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithHeight(len(rows)+1), - ) - - s := table.DefaultStyles() - s.Cell.Foreground(lipgloss.Color("#FFF")) - s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("#04B575")). - BorderBottom(true). - Bold(true) - t.SetStyles(s) - t.SetCursor(m.cursor) - return baseStyle.Render(t.View()) + "\n" -} - -// modelsCmd represents the models command -var modelsCmd = &cobra.Command{ - Use: "models", - Short: "Manage models", - Long: `Manage Open Data Hub models from the command line. - -This command will list all the registered models available in the Open Data Hub model registry.`, - Run: func(cmd *cobra.Command, args []string) { - m, err := tea.NewProgram(model{}).Run() - if err != nil { - cmd.PrintErrf("Error: %v\n", err) - os.Exit(1) - } - if m.(model).err != nil { - // cmd.PrintErrf("Error: %v\n", m.(model).err) - os.Exit(1) - } - }, -} - -func init() { - rootCmd.AddCommand(modelsCmd) -} diff --git a/cli/pkg/commands/models/add.go b/cli/pkg/commands/models/add.go new file mode 100644 index 00000000..4e526fd6 --- /dev/null +++ b/cli/pkg/commands/models/add.go @@ -0,0 +1,36 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package models + +import ( + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/common" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/flags" + "github.com/spf13/cobra" +) + +var addCmd = common.NewCmd( + "add ", + "Add a model and version to the model registry", + `Add a model to the model registry + +This command allows you to add a model and version to the model registry along with the build parameters from the +provided parameters file.`, + cobra.ExactArgs(3), + []flags.Flag{flags.FlagModelRegistryURL.SetParentFlag(), flags.FlagParams}, + common.SubCommandAdd, + NewTeaModel, +) diff --git a/cli/pkg/commands/models/models.go b/cli/pkg/commands/models/models.go new file mode 100644 index 00000000..45e768fa --- /dev/null +++ b/cli/pkg/commands/models/models.go @@ -0,0 +1,176 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package models + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/common" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/flags" + "github.com/opendatahub-io/ai-edge/cli/pkg/edgeclient" + "github.com/opendatahub-io/ai-edge/cli/pkg/pipelines" + "github.com/spf13/cobra" +) + +type registeredModelsMsg []edgeclient.Model +type newModelAddedMsg struct{} + +type teaModel struct { + args []string + flags map[string]string + edgeClient *edgeclient.Client + registeredModelsList []edgeclient.Model + err error + subCommand common.SubCommand +} + +// NewTeaModel creates a new bubbletea model for the models command +func NewTeaModel(args []string, flgs map[string]string, subCommand common.SubCommand) tea.Model { + return &teaModel{ + args: args, + flags: flgs, + edgeClient: edgeclient.NewClient(flgs[flags.FlagModelRegistryURL.String()]), + subCommand: subCommand, + } +} + +// Init initializes the model according to the subcommand +func (m teaModel) Init() tea.Cmd { + switch m.subCommand { + case common.SubCommandList: + return m.listRegisteredModels() + case common.SubCommandAdd: + return m.addModel() + } + return nil +} + +func (m teaModel) listRegisteredModels() func() tea.Msg { + c := m.edgeClient + return func() tea.Msg { + models, err := c.GetModels() + if err != nil { + return common.ErrMsg{err} + } + return registeredModelsMsg(models) + } +} + +func (m teaModel) addModel() func() tea.Msg { + c := m.edgeClient + return func() tea.Msg { + params, err := pipelines.ReadParams(m.flags[flags.FlagParams.String()]) + if err != nil { + return common.ErrMsg{err} + } + _, err = c.AddNewModelWithImage(m.args[0], m.args[1], m.args[2], "", params.ToSimpleMap()) + if err != nil { + return common.ErrMsg{err} + } + return newModelAddedMsg{} + + } +} + +// Update updates the model according to the message +func (m teaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case common.ErrMsg: + m.err = msg + return m, tea.Quit + + case registeredModelsMsg: + m.registeredModelsList = msg + return m, tea.Quit + case newModelAddedMsg: + return m, tea.Quit + } + return m, nil +} + +// View returns the view corresponding to the subcommand +func (m teaModel) View() string { + if m.err != nil { + return common.ErrorStyle.Render(fmt.Sprintf("Error: %s", m.err)) + } + switch m.subCommand { + case common.SubCommandList: + return m.viewListModels() + case common.SubCommandAdd: + return common.MessageStyle.Render("\nModel added successfully\n\n") + + } + return "" +} + +func (m teaModel) viewListModels() string { + columns := []table.Column{ + {Title: "Id", Width: 4}, + {Title: "Name", Width: 20}, + {Title: "Description", Width: 60}, + } + + rows := make([]table.Row, 0) + + if m.registeredModelsList != nil { + for _, model := range m.registeredModelsList { + rows = append( + rows, table.Row{ + model.ID, + model.Name, + model.Description, + }, + ) + } + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithHeight(len(rows)+1), + ) + + s := table.DefaultStyles() + s.Cell.Foreground(lipgloss.Color("#FFF")) + s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#04B575")). + BorderBottom(true). + Bold(true) + t.SetStyles(s) + return common.TableBaseStyle.Render(t.View()) + "\n" +} + +// Cmd represents the models command +var Cmd = common.NewCmd( + "models", + "Manage models", + `Manage Open Data Hub models from the command line. + +This command will list all the registered models available in the Open Data Hub model registry.`, + cobra.NoArgs, + []flags.Flag{flags.FlagModelRegistryURL.SetParentFlag()}, + common.SubCommandList, + NewTeaModel, +) + +func init() { + Cmd.AddCommand(addCmd) +} diff --git a/cli/pkg/commands/root.go b/cli/pkg/commands/root.go index f3709e50..dc81e03e 100644 --- a/cli/pkg/commands/root.go +++ b/cli/pkg/commands/root.go @@ -13,25 +13,17 @@ 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. */ + package commands import ( - "context" - "fmt" "os" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/flags" + "github.com/opendatahub-io/ai-edge/cli/pkg/commands/models" "github.com/spf13/cobra" - "github.com/spf13/viper" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/clientcmd" ) -// clientSet is a kubernetes clientset that can be used to interact with the kubernetes API -var clientSet kubernetes.Interface -var kubeconfig string -var modelRegistryURL string - // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "odh-cli", @@ -39,19 +31,11 @@ var rootCmd = &cobra.Command{ Long: `Manage Open Data Hub resources from the command line. This application is a tool to perform various operations on Open Data Hub.`, - Run: func(cmd *cobra.Command, args []string) { - - // Get the pods in the "default" namespace - namespaces, err := clientSet.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}) - if err != nil { - panic(err.Error()) - } +} - // Print the pod names - for _, ns := range namespaces.Items { - cmd.Printf("Namespace: %s\n", ns.Name) - } - }, +var rootFlags = []flags.Flag{ + flags.FlagModelRegistryURL, + flags.FlagKubeconfig, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -65,28 +49,13 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) + + for _, f := range rootFlags { + rootCmd.PersistentFlags().StringP(f.String(), f.Shorthand(), f.Value(), f.Usage()) + } + rootCmd.AddCommand(models.Cmd) } // initConfig reads in config file and ENV variables if set. func initConfig() { - viper.AutomaticEnv() // read in environment variables that match - - if kubeconfig = viper.GetString("KUBECONFIG"); kubeconfig == "" { - kubeconfig = fmt.Sprintf("%s/.kube/config", os.Getenv("HOME")) - } - - if modelRegistryURL = viper.GetString("MODEL_REGISTRY_URL"); modelRegistryURL == "" { - modelRegistryURL = "http://localhost:8080" - } - - config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) - if err != nil { - panic(err.Error()) - } - - // Create a new clientset which includes all the API schemas - clientSet, err = kubernetes.NewForConfig(config) - if err != nil { - panic(err.Error()) - } } diff --git a/cli/pkg/commands/root_test.go b/cli/pkg/commands/root_test.go deleted file mode 100644 index 8af333bc..00000000 --- a/cli/pkg/commands/root_test.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2023 KStreamer Authors - -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. -*/ - -package commands - -import ( - "bytes" - "context" - "testing" - - "github.com/spf13/cobra" - "gotest.tools/v3/assert" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/fake" -) - -// TestRootCmd is a basic test that will be updated later once we have some real functionality to test -func TestRootCmd(t *testing.T) { - // Create a buffer to hold the output - var buf bytes.Buffer - - cobra.OnInitialize( - func() { - // Create a fake clientset - clientSet = fake.NewSimpleClientset() - - // Add a couple of namespaces to simulate existing data - ns1 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace-1"}} - ns2 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace-2"}} - clientSet.CoreV1().Namespaces().Create(context.TODO(), ns1, metav1.CreateOptions{}) - clientSet.CoreV1().Namespaces().Create(context.TODO(), ns2, metav1.CreateOptions{}) - }, - ) - - rootCmd.SetOut(&buf) - rootCmd.Execute() - assert.Equal(t, "Namespace: test-namespace-1\nNamespace: test-namespace-2\n", buf.String()) -} diff --git a/cli/pkg/edgeclient/client.go b/cli/pkg/edgeclient/client.go new file mode 100644 index 00000000..b76ac2f9 --- /dev/null +++ b/cli/pkg/edgeclient/client.go @@ -0,0 +1,108 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package edgeclient + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/opendatahub-io/ai-edge/cli/pkg/modelregistry" +) + +// Client is a client representing the edge environment +// +// This client can be used to create and manage models and model container images suitable for deployment in edge environments. +type Client struct { + modelRegistryClient *modelregistry.Client +} + +// NewClient creates a new Client to interact with the edge environment. It requires the URL of the model registry service. +// +// This client can be used to create and manage models and model container images suitable for deployment in edge environments. +func NewClient(modelRegistryURL string) *Client { + return &Client{ + modelRegistryClient: modelregistry.NewClient(modelRegistryURL), + } +} + +// GetModels returns a list of models in the model registry. +func (c *Client) GetModels() ([]Model, error) { + models, err := c.modelRegistryClient.GetRegisteredModels() + if err != nil { + return nil, fmt.Errorf("failed to get models: %w", err) + } + ms := make([]Model, len(models)) + for i, m := range models { + ms[i] = Model{ + ID: m.GetId(), + Name: m.GetName(), + Description: m.GetDescription(), + } + } + return ms, nil +} + +// AddNewModelWithImage adds a model to the model registry along with the model version and the build parameters that +// will be used during the image build process. +func (c *Client) AddNewModelWithImage( + modelName string, + modelDescription string, + modelVersion string, + uri string, + parameters map[string]interface{}, +) (*ModelImage, error) { + + if modelName == "" || modelDescription == "" || modelVersion == "" { + return nil, fmt.Errorf("model name, description, version, and URI are required") + } + + modelFormatName := "ContainerImage" + // This will be used to flag the model as edge compatible (i.e. has the required metadata to be built by the edge pipeline) + parameters["edgeCompatible"] = "true" + externalID := getImageID(modelName, modelVersion, modelName) + md, err := modelregistry.ToMetadataValueMap(parameters) + if err != nil { + return nil, fmt.Errorf("failed to add model image: %w", err) + } + m, v, a, err := c.modelRegistryClient.AutoRegisterModelVersionArtifact( + modelName, modelDescription, modelVersion, modelName, externalID, uri, modelFormatName, "", md, + ) + if err != nil { + return nil, fmt.Errorf("failed to add model image: %w", err) + } + + return &ModelImage{ + ID: a.ModelArtifact.GetExternalID(), + ModelID: m.GetId(), + Name: m.GetName(), + Description: m.GetDescription(), + Version: v.GetName(), + URI: a.ModelArtifact.GetUri(), + }, nil +} + +func getImageID(registeredModelName, modelVersionName, artifactName string) string { + return shortHash(fmt.Sprintf("%s:%s:%s", registeredModelName, modelVersionName, artifactName)) +} + +func shortHash(s string) string { + hasher := sha256.New() + hasher.Write([]byte(s)) + hash := hex.EncodeToString(hasher.Sum(nil)) + return hash[:4] +} diff --git a/cli/pkg/edgeclient/types.go b/cli/pkg/edgeclient/types.go new file mode 100644 index 00000000..7c97b768 --- /dev/null +++ b/cli/pkg/edgeclient/types.go @@ -0,0 +1,58 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package edgeclient + +type modelImageStatus int + +const ( + // ModelImageStatusUnknown - The status of the model image is unknown. + ModelImageStatusUnknown modelImageStatus = iota + // ModelImageStatusNeedsSync - The model image needs to be synced to the edge device. + ModelImageStatusNeedsSync + // ModelImageStatusSynced - The model image has been synced to the edge device. + ModelImageStatusSynced + // ModelImageStatusBuilding - The model image is being built. + ModelImageStatusBuilding + // ModelImageStatusLive - The model image is live on the container registry. + ModelImageStatusLive + // ModelImageStatusFailed - The model image build has failed. + ModelImageStatusFailed +) + +func (s modelImageStatus) String() string { + return [...]string{"Unknown", "Needs Sync", "Synced", "Building", "Live", "Failed"}[s] +} + +// Model - A registered model in the model registry. +type Model struct { + ID string + Name string + Description string +} + +// ModelImage - A model to be registered in the model registry and is suitable for deployment in edge environments. +type ModelImage struct { + ID string + ModelID string + Name string + Description string + Version string + URI string + NeedsSync bool + BuildParams map[string]interface{} + Status modelImageStatus +} diff --git a/cli/pkg/httptest/server.go b/cli/pkg/httptest/server.go new file mode 100644 index 00000000..7aa7798f --- /dev/null +++ b/cli/pkg/httptest/server.go @@ -0,0 +1,110 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package httptest + +import ( + "encoding/json" + "log" + "net/http" + "net/http/httptest" +) + +// Response represents a response from the mock server that can be set for a given path and method +type Response struct { + StatusCode int + ContentType string + Body any +} + +// MockServer is a simple mock server that can be used to mock HTTP responses +type MockServer struct { + server *httptest.Server + routes map[string]map[string]Response +} + +// NewMockServer creates a new mock server +func NewMockServer() *MockServer { + + return &MockServer{ + routes: map[string]map[string]Response{ + "GET": {}, + "POST": {}, + "PUT": {}, + "DELETE": {}, + "PATCH": {}, + }, + } +} + +// WithGet sets a response for a GET request to the given path +func (m *MockServer) WithGet(path string, response Response) { + m.routes["GET"][path] = response +} + +// WithPost sets a response for a POST request to the given path +func (m *MockServer) WithPost(path string, response Response) { + m.routes["POST"][path] = response +} + +// Reset clears all the set responses +func (m *MockServer) Reset() { + m.routes = map[string]map[string]Response{ + "GET": {}, + "POST": {}, + "PUT": {}, + "DELETE": {}, + "PATCH": {}, + } +} + +// Start starts the mock server +func (m *MockServer) Start() { + // Create a new httptest server using the handler + m.server = httptest.NewServer(m.handler()) +} + +func (m *MockServer) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Get the response for the request + response, ok := m.routes[r.Method][r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + + // Set content type + w.Header().Set("Content-Type", response.ContentType) + + // Set the status code + w.WriteHeader(response.StatusCode) + + // Write the response + if err := json.NewEncoder(w).Encode(response.Body); err != nil { + log.Fatalf("Error encoding response: %v", err) + } + } +} + +// Close closes the mock server +func (m *MockServer) Close() { + m.server.Close() +} + +// GetURL returns the URL of the mock server +func (m *MockServer) GetURL() string { + return m.server.URL +} diff --git a/cli/pkg/modelregistry/client.go b/cli/pkg/modelregistry/client.go new file mode 100644 index 00000000..7af07b7c --- /dev/null +++ b/cli/pkg/modelregistry/client.go @@ -0,0 +1,368 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package modelregistry + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/kubeflow/model-registry/pkg/openapi" +) + +// Client is a client for the model registry service +type Client struct { + // modelRegistryURL is the URL of the model registry service + modelRegistryURL string + modelRegistryClient *openapi.APIClient +} + +// NewClient creates a new Client +func NewClient(modelRegistryURL string) *Client { + configuration := openapi.NewConfiguration() + configuration.Servers = openapi.ServerConfigurations{ + { + URL: modelRegistryURL, + }, + } + + return &Client{ + modelRegistryURL: modelRegistryURL, + modelRegistryClient: openapi.NewAPIClient(configuration), + } +} + +// AutoRegisterModelVersionArtifact is a convenience method to create a registered model, model version and model +// artifact in one call +// +// Errors: +// - ErrModelExists is returned when the model already exists +// - ErrVersionExists is returned when the version already exists +// - ErrArtifactExists is returned when the image already exists +func (c *Client) AutoRegisterModelVersionArtifact( + modelName, modelDescription, versionName, artifactName, artifactExternalID, uri, modelFormatName, modelFormatVersion string, + metaData map[string]openapi.MetadataValue, +) ( + *openapi.RegisteredModel, + *openapi.ModelVersion, + *openapi.Artifact, + error, +) { + if modelName == "" || modelDescription == "" || versionName == "" { + return nil, nil, nil, fmt.Errorf("name, description and version are required") + } + m, err := c.FindRegisteredModel(modelName) + if err != nil { + if !errors.Is(err, ErrModelNotFound) { + return nil, nil, nil, err + } + // If the model is not found, create a new model + m, err = c.CreateRegisteredModel(modelName, modelDescription, nil) + if err != nil { + return nil, nil, nil, err + } + } + + v, err := c.FindModelVersion(m.GetId(), versionName) + if err != nil { + if !errors.Is(err, ErrVersionNotFound) { + return nil, nil, nil, err + } + // If the version is not found, create a new version + v, err = c.CreateModelVersion(m.GetId(), versionName, metaData) + if err != nil { + return nil, nil, nil, err + } + } + + a, err := c.CreateModelArtifact( + v.GetId(), artifactName, modelDescription, uri, modelFormatName, modelFormatVersion, artifactExternalID, + ) + if err != nil { + return nil, nil, nil, err + } + + return m, v, a, nil +} + +// CreateRegisteredModel creates a registered model and stores the metadata for the model +func (c *Client) CreateRegisteredModel(name string, description string, metaData map[string]openapi.MetadataValue) ( + *openapi.RegisteredModel, + error, +) { + if name == "" || description == "" { + return nil, fmt.Errorf("name and description are required") + } + + m := openapi.NewRegisteredModelCreateWithDefaults() + m.SetName(name) + m.SetDescription(description) + + if metaData != nil { + m.SetCustomProperties(metaData) + } + + model, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.CreateRegisteredModel(context.Background()). + RegisteredModelCreate(*m).Execute() + if err != nil { + if resp == nil { + return nil, fmt.Errorf("error creating registered model: %w", err) + } + // Currently model registry returns 500 when the model exists. This is a workaround to handle the error + // until the model registry is fixed. The workaround is to check the error message and return ErrModelExists + // if the error message contains the expected error message. + // TODO: Remove this workaround when model registry returns 403 when the model exists + if resp.StatusCode != 201 && isOpenAPIErrorOfKind(err, ErrAlreadyExists) { + return nil, fmt.Errorf("%w. model name: %s", ErrModelExists, name) + } + // This is a weird case where we got a response and an error that we're unable to handle. + return nil, fmt.Errorf( + "error while creating a registered model: server responded with %s %w", resp.Status, err, + ) + } + if resp.StatusCode != 201 { + return nil, fmt.Errorf("failed to create a registered model: %s", resp.Status) + } + return model, nil +} + +// CreateModelVersion creates a model version for a registered model and stores the metadata for the version +func (c *Client) CreateModelVersion( + modelID string, + versionName string, + metaData map[string]openapi.MetadataValue, +) (*openapi.ModelVersion, error) { + + if modelID == "" || versionName == "" { + return nil, fmt.Errorf("model ID and version name are required") + } + + modelVersion := openapi.NewModelVersionWithDefaults() + modelVersion.SetName(versionName) + + if metaData != nil { + modelVersion.SetCustomProperties(metaData) + } + + v, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.CreateRegisteredModelVersion( + context.Background(), modelID, + ).ModelVersion(*modelVersion).Execute() + + if err != nil { + if resp == nil { + return nil, fmt.Errorf("error creating model version: %w", err) + } + if resp.StatusCode != 201 { + // TODO: Remove this workaround when model registry returns 404 when the model is not found + if isOpenAPIErrorOfKind(err, ErrModelNotFound) { + return nil, fmt.Errorf("%w. model id: %s", ErrModelNotFound, modelID) + } + if isOpenAPIErrorOfKind(err, ErrAlreadyExists) { + return nil, fmt.Errorf("%w. model id: %s version name: %s", ErrVersionExists, modelID, versionName) + } + } + // This is a weird case where we got a response and an error that we're unable to handle. + return nil, fmt.Errorf( + "error while creating a registered model version: server responded with %s %w", resp.Status, err, + ) + } + + if resp.StatusCode != 201 { + return nil, fmt.Errorf("failed to create a registered model version: %s", resp.Status) + } + return v, nil +} + +// CreateModelArtifact creates a model artifact for a model version +func (c *Client) CreateModelArtifact( + versionID string, + artifactName string, + description string, + uri string, + modelFormatName string, + modelFormatVersion string, + externalID string, +) (*openapi.Artifact, error) { + if versionID == "" || artifactName == "" { + return nil, fmt.Errorf("versionId and name are required") + } + artifact := openapi.NewModelArtifactWithDefaults() + artifact.SetName(artifactName) + artifact.SetUri(uri) + artifact.SetDescription(description) + artifact.SetModelFormatName(modelFormatName) + artifact.SetModelFormatVersion(modelFormatVersion) + artifact.SetExternalID(externalID) + + a, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.CreateModelVersionArtifact( + context.Background(), versionID, + ).Artifact(openapi.ModelArtifactAsArtifact(artifact)).Execute() + + if err != nil { + if resp == nil { + return nil, fmt.Errorf("error creating model version artifact: %w", err) + } + if resp.StatusCode != 201 { + // TODO: Remove this workaround when model registry returns 404 when the model version is not found + if isOpenAPIErrorOfKind(err, ErrVersionNotFound) { + return nil, fmt.Errorf("%w. version id: %s", ErrVersionNotFound, versionID) + } + if isOpenAPIErrorOfKind(err, ErrAlreadyExists) { + return nil, fmt.Errorf( + "%w. version id: %s artifact name: %s", ErrArtifactExists, versionID, artifactName, + ) + } + } + // This is a weird case where we got a response and an error that we're unable to handle. + return nil, fmt.Errorf( + "error while creating a model version artifact: server responded with %s %w", resp.Status, err, + ) + } + if resp.StatusCode != 201 { + return nil, fmt.Errorf("failed to create a model version artifact: %s", resp.Status) + } + return a, nil +} + +// GetRegisteredModels returns a list of registered models +func (c *Client) GetRegisteredModels() ([]openapi.RegisteredModel, error) { + models, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.GetRegisteredModels( + context.Background(), + ).Execute() + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to get registered models: %s", resp.Status) + } + return models.Items, nil +} + +// FindRegisteredModel finds a registered model by name +func (c *Client) FindRegisteredModel(name string) (*openapi.RegisteredModel, error) { + if name == "" { + return nil, fmt.Errorf("name is required") + } + model, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.FindRegisteredModel( + context.Background(), + ).Name(name).Execute() + if err != nil { + if resp == nil { + return nil, fmt.Errorf("error looking up model: %w", err) + } + if resp.StatusCode != 200 { + // TODO: Remove this workaround when model registry returns 404 when the model is not found + if isOpenAPIErrorOfKind(err, ErrModelNotFoundForName) { + return nil, fmt.Errorf("%w. model name: %s", ErrModelNotFound, name) + } + } + // This is a weird case where we got a response and an error that we're unable to handle. + return nil, fmt.Errorf( + "error while querying for a registered model: server responded with %s %w", resp.Status, err, + ) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to find a registered model: %s", resp.Status) + } + return model, nil +} + +// FindModelVersion finds a model version by name and model ID +func (c *Client) FindModelVersion(modelID, versionName string) (*openapi.ModelVersion, error) { + if modelID == "" || versionName == "" { + return nil, fmt.Errorf("model ID and version name are required") + } + version, resp, err := c.modelRegistryClient.ModelRegistryServiceAPI.FindModelVersion( + context.Background(), + ).ParentResourceID(modelID). + Name(versionName).Execute() + if err != nil { + if resp == nil { + return nil, fmt.Errorf("error looking up model version: %w", err) + } + if resp.StatusCode != 200 { + if isOpenAPIErrorOfKind(err, ErrVersionNotFoundForName) { + return nil, fmt.Errorf("%w. model ID: %s version name: %s", ErrVersionNotFound, modelID, versionName) + } + } + // This is a weird case where we got a response and an error that we're unable to handle. + return nil, fmt.Errorf( + "error while querying for a model version: server responded with %s %w", resp.Status, err, + ) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to find a model version: %s", resp.Status) + } + return version, nil +} + +type stringList struct { + Items []string `json:"items"` +} + +// ToMetadataValueMap converts a map of string to interface {} to a map of string to MetadataValue +func ToMetadataValueMap( + metaData map[string]interface{}, +) (map[string]openapi.MetadataValue, error) { + props := make(map[string]openapi.MetadataValue) + + for k, v := range metaData { + switch v := v.(type) { + case string: + mv := openapi.NewMetadataStringValueWithDefaults() + mv.SetStringValue(v) + props[k] = openapi.MetadataStringValueAsMetadataValue( + mv, + ) + case []interface{}: + var ss []string + for _, i := range v { + if _, ok := i.(string); !ok { + return nil, fmt.Errorf( + "unsupported metadata value type for %s: %T. Only string and []string are supported", k, i, + ) + } + ss = append(ss, i.(string)) + } + + sv, err := encodeToBase64(stringList{Items: ss}) + if err != nil { + return nil, fmt.Errorf("failed to encode metadata value for %s: %w", k, err) + } + mv := openapi.NewMetadataStructValueWithDefaults() + mv.SetStructValue(sv) + props[k] = openapi.MetadataStructValueAsMetadataValue(mv) + default: + continue + } + } + return props, nil +} + +func encodeToBase64(v interface{}) (string, error) { + var buf bytes.Buffer + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + err := json.NewEncoder(encoder).Encode(v) + if err != nil { + return "", err + } + encoder.Close() + return buf.String(), nil +} diff --git a/cli/pkg/modelregistry/client_test.go b/cli/pkg/modelregistry/client_test.go new file mode 100644 index 00000000..8dc12a83 --- /dev/null +++ b/cli/pkg/modelregistry/client_test.go @@ -0,0 +1,152 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package modelregistry + +import ( + "fmt" + "net/http" + "testing" + + "github.com/kubeflow/model-registry/pkg/openapi" + "github.com/opendatahub-io/ai-edge/cli/pkg/httptest" +) + +const ( + modelRegistryPath = "/api/model_registry" + getRegisteredModelsPath = "/v1alpha2/registered_models" + success = "\u2713" + failed = "\u2717" +) + +func TestClient_GetRegisteredModels(t *testing.T) { + + table := []struct { + name string + when string + responseBody map[string][]map[string]interface{} + expectedModels []openapi.RegisteredModel + }{ + { + name: "no models", + when: "the model registry returns no models", + responseBody: map[string][]map[string]interface{}{ + "items": {}, + }, + expectedModels: []openapi.RegisteredModel{}, + }, + { + name: "one model", + when: "the model registry returns one model", + responseBody: map[string][]map[string]interface{}{ + "items": { + convertModelToItem(newRegisteredModel("1", "model 1", "description 1")), + }, + }, + expectedModels: []openapi.RegisteredModel{ + newRegisteredModel("1", "model 1", "description 1"), + }, + }, + { + name: "many models", + when: "the model registry returns many models", + responseBody: map[string][]map[string]interface{}{ + "items": { + convertModelToItem(newRegisteredModel("1", "model 1", "description 1")), + convertModelToItem(newRegisteredModel("2", "model 2", "description 2")), + }, + }, + expectedModels: []openapi.RegisteredModel{ + newRegisteredModel("1", "model 1", "description 1"), + newRegisteredModel("2", "model 2", "description 2"), + }, + }, + } + server := httptest.NewMockServer() + server.Start() + defer server.Close() + t.Log("Given the need to test getting registered models from the model registry.") + { + for _, tt := range table { + t.Run( + tt.name, func(t *testing.T) { + t.Logf("\t%s:\tWhen %s", t.Name(), tt.when) + server.Reset() + server.WithGet( + fmt.Sprintf("%s%s", modelRegistryPath, getRegisteredModelsPath), httptest.Response{ + StatusCode: http.StatusOK, + ContentType: "application/json", + Body: tt.responseBody, + }, + ) + client := NewClient(server.GetURL()) + models, err := client.GetRegisteredModels() + if err != nil { + t.Fatalf("\t%s:\t%s\tShould not receive an error. Got: %v", t.Name(), failed, err) + } + t.Logf("\t%s:\t%s\tShould not receive an error", t.Name(), success) + + if len(models) != len(tt.expectedModels) { + t.Fatalf( + "\t%s:\t%s\tShould receive %d models, got %d", t.Name(), failed, len(tt.expectedModels), + len(models), + ) + t.Logf("\t%s:\t%s\tShould receive %d models", t.Name(), success, len(tt.expectedModels)) + } + + for i, model := range models { + compareRegisteredModels(t, model, tt.expectedModels[i]) + } + t.Logf("\t%s:\t%s\tShould receive the expected models", t.Name(), success) + }, + ) + } + } +} + +func compareRegisteredModels(t *testing.T, model openapi.RegisteredModel, expected openapi.RegisteredModel) { + t.Helper() + if model.GetId() != expected.GetId() { + t.Fatalf("\t%s:\t%s\tShould receive the expected ID, got %s", t.Name(), failed, model.GetId()) + } + if model.GetName() != expected.GetName() { + t.Fatalf("\t%s:\t%s\tShould receive the expected Name, got %s", t.Name(), failed, model.GetName()) + } + if model.GetDescription() != expected.GetDescription() { + t.Fatalf("\t%s:\t%s\tShould receive the expected Description, got %s", t.Name(), failed, model.GetDescription()) + } +} + +func convertModelToItem(model openapi.RegisteredModel) map[string]interface{} { + return map[string]interface{}{ + "id": model.GetId(), + "name": model.GetName(), + "description": model.GetDescription(), + "customProperties": model.GetCustomProperties(), + "state": model.GetState(), + "externalID": model.GetExternalID(), + "createTimeSinceEpoch": model.GetCreateTimeSinceEpoch(), + "lastUpdateTimeSinceEpoch": model.GetLastUpdateTimeSinceEpoch(), + } +} + +func newRegisteredModel(id, name, description string) openapi.RegisteredModel { + model := openapi.NewRegisteredModel() + model.SetId(id) + model.SetName(name) + model.SetDescription(description) + return *model +} diff --git a/cli/pkg/modelregistry/errors.go b/cli/pkg/modelregistry/errors.go new file mode 100644 index 00000000..8509b61f --- /dev/null +++ b/cli/pkg/modelregistry/errors.go @@ -0,0 +1,65 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package modelregistry + +import ( + "errors" + "strings" + + "github.com/kubeflow/model-registry/pkg/openapi" +) + +var ( + // ErrModelExists is returned when a model already exists + ErrModelExists = errors.New("model already exists") + // ErrModelNotFound is returned when a model is not found + ErrModelNotFound = errors.New("no registered model found") + + // ErrModelNotFoundForName is returned when a model is not found for a given name using FindRegisteredModel + ErrModelNotFoundForName = errors.New("no registered models found") + + // ErrVersionExists is returned when a version already exists for a given model + ErrVersionExists = errors.New("version already exists") + // ErrVersionNotFound is returned when a version is not found + ErrVersionNotFound = errors.New("no model version found") + // ErrVersionNotFoundForName is returned when a version is not found for a given name & model ID using FindModelVersion + ErrVersionNotFoundForName = errors.New("no model versions found") + + // ErrArtifactExists is returned when a model version artifact already exists for a given model version + ErrArtifactExists = errors.New("artifact already exists") + + // ErrAlreadyExists is a generic error to check the model registry returned errors when an entity (Model, Version, + // Artifact) already exists + ErrAlreadyExists = errors.New("already exists") +) + +// isOpenAPIErrorOfKind checks if the error is of the given kind (targetErr). It checks if sourceErr is an +// openapi.GenericOpenAPIError and if the error message contains the targetErr message. +// +// This is a workaround to handle the error until the model registry supports returning standard HTTP status codes for +// errors with known status codes. +func isOpenAPIErrorOfKind(sourceErr, targetErr error) bool { + var e *openapi.GenericOpenAPIError + if errors.As(sourceErr, &e) { + if me, ok := e.Model().(openapi.Error); ok { + if msg, ok := me.GetMessageOk(); ok { + return strings.Contains(*msg, targetErr.Error()) + } + } + } + return false +} diff --git a/cli/pkg/pipelines/params.go b/cli/pkg/pipelines/params.go new file mode 100644 index 00000000..9f8bccba --- /dev/null +++ b/cli/pkg/pipelines/params.go @@ -0,0 +1,74 @@ +/* +Copyright 2024. Open Data Hub Authors + +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. +*/ + +package pipelines + +import ( + "log" + "os" + + "gopkg.in/yaml.v2" +) + +// Param represents a parameter for a pipeline run +type Param struct { + Name string `yaml:"name"` + Value interface{} `yaml:"value"` +} + +// RunParams represents the parameters list for a pipeline run as defined in a YAML file +type RunParams struct { + Params []Param `yaml:"params"` +} + +// GetParamValue returns the value of a parameter by name +func (p *RunParams) GetParamValue(name string) interface{} { + for _, param := range p.Params { + if param.Name == name { + return param.Value + } + } + return nil +} + +// ToSimpleMap converts the RunParams struct to a simple map of string to interface{} +func (p *RunParams) ToSimpleMap() map[string]interface{} { + params := make(map[string]interface{}) + for _, param := range p.Params { + params[param.Name] = param.Value + } + return params +} + +// ReadParams reads a YAML file with pipeline run parameters and returns a RunParams struct +func ReadParams(paramsFile string) (*RunParams, error) { + // Read YAML file + data, err := os.ReadFile(paramsFile) + if err != nil { + log.Fatal("error reading file: ", err) + return nil, err + } + + // Unmarshal YAML to struct + var runParams RunParams + err = yaml.Unmarshal([]byte(data), &runParams) + if err != nil { + log.Fatal("error unmarshalling yaml: ", err) + return nil, err + } + + return &runParams, nil +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..df863d23 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,135 @@ +# ODH Edge CLI + +The ODH Edge CLI is a command line interface that allows you to interact with the ODH Edge platform. It is a tool that allows you to manage your ODH Edge models and images. + +## Overview + +The ODH Edge CLI uses the Model Registry as the source of truth for all models and images. The Model Registry is a central repository that stores all the metadata about the models and images that are available on the ODH Edge platform. The ODH Edge CLI allows you to interact with the Model Registry to perform various operations such as listing and adding models. + +Since building a model image is done via OpenShift Pipelines, all the pipeline parameters that are required to build the model image can be stored in the Model Registry. The ODH Edge CLI allows you to interact with the Model Registry to store and retrieve these pipeline parameters. + +> [!NOTE] +> The Model Registry stores the metadata about the models and images, but it does not store the actual model artifacts. +> The model artifacts are stored in a separate storage location that is accessible to the ODH Edge platform such as an +> S3 bucket or a Git repository. + + +## Building ODH Edge CLI + +To build the ODH Edge CLI, run the following commands from the root of the repository: + +```bash +cd cli +make cli-build +``` + +This will create a binary called `odh` in your current directory. You can move this binary to a location in your PATH to make it easier to run the ODH Edge CLI from anywhere. + +## Environment Setup + +### Prerequisites + +- Kubectl access to the OpenShift core cluster. +- Go (version 1.20 or 1.21, version 1.22 is not supported). +- [Model registry operator](https://github.com/opendatahub-io/model-registry-operator/releases/tag/v0.1.2) version 0.1.2 locally. + +### Installing Model Registry + +To install the Model Registry, follow the following steps: + +1. Change to the `model-registry-operator-0.1.2` directory: + + ```bash + cd model-registry-operator-0.1.2 + ``` +2. Install the CRDs into the cluster: + + ```bash + make install + ``` +3. Deploy the Model Registry operator: + + ```bash + make deploy + ``` +4. Create a Model Registry namespace: + + ```bash + kubectl create ns odh-model-registry + ``` +5. Edit the `config/samples/mysql/modelregistry_v1alpha1_modelregistry.yaml` and change the `spec.rest.serviceRoute` to `enabled`. + +6. Create a Model Registry instance: + + ```bash + kubectl -n odh-model-registry apply -k config/samples/mysql + ``` + +7. Wait for the Model Registry pods to be ready: + + ```bash + kubectl -n odh-model-registry get pods -w + ``` + +8. Create a model-registry route: + + ```bash + oc -n odh-model-registry expose service modelregistry-sample --port http-api + ``` +9. Get the route: + + ```bash + oc -n odh-model-registry get route + ``` + +## Usage + +The following flags are available globally for all commands: + +- `-m, --model-registry-url`: The URL of the Model Registry. This is the URL of the Model Registry service that is + running in the OpenShift cluster. +- `k, --kubeconfig` : The path to the kubeconfig file. This is the kubeconfig file that is used to access the OpenShift + cluster. + +### Listing Models + +To list all the models that are available in the Model Registry, run the following command: + +```bash +odh -m models +``` + +### Adding a Model + +Adding a model to the model registry means adding the metadata about the model to the Model Registry. This metadata includes the model name, a model version and the pipeline parameters that are required to build the model image. + +#### Preparing The Pipeline Parameters + +The parameters can be provided via a yaml file. The yaml file should have the following structure: + +```yaml +params: + - name: + value: + - name: + value: +``` + +This repository provides an example pipeline parameters file in the `cli/examples/params.yaml` file. You can use this file as a template to create your own pipeline parameters file. + +After you have created the pipeline parameters file, you can add the model to the Model Registry by running the following command: + +```bash +odh -m models add -p +``` + +This should print out the message `Model added successfully` if the model was added successfully. + +To verify that the model was added successfully, you can list all the models in the Model Registry by running the following command: + +```bash +odh -m models +``` + + +