Repository: kserve/kserve

Issue:
Support for general web URI address for hosted KFServing model location
/kind feature

**Describe the solution you'd like**

Currently there are 4 types of storage URIs that are accepted in KFServing yaml files:

https://github.com/kubeflow/kfserving/blob/e25758f6da020e3e8ad63f0880ab95de6af8395b/python/kfserving/kfserving/storage.py#L26-L30

(1) GCS
(2) AWS S3
(3) Azure (blob over https://)
(4) local (file://)

I think it would be useful to be able reference a model file hosted anywhere (via general web URI, aka "http://", such as a file on github:

https://github.com/jpatanooga/kubeflow_ops_book_dev/blob/master/kfserving/models/sklearn/iris_sklearn_svm_model.joblib

(in this example the raw file would be downloaded from the url: https://github.com/jpatanooga/kubeflow_ops_book_dev/blob/master/kfserving/models/sklearn/iris_sklearn_svm_model.joblib?raw=true )

This would allow for examples to be executed more efficiently; the KFServing project could even provide a repo of the pre-built models (perhaps), so that example model deployments could be easier to run for new users.





Discussion:
Issue-Label Bot is automatically applying the labels:

| Label  | Probability |
| ------------- | ------------- |
| area/inference | 0.57 |

Please mark this comment with :thumbsup: or :thumbsdown: to give our bot feedback! 
Links: [app homepage](https://github.com/marketplace/issue-label-bot), [dashboard](https://github.com/marketplace/issue-label-botdata/kubeflow/kfserving) and [code](https://github.com/hamelsmu/MLapp) for this bot.
/good-first-issue
@yuzisun: 
	This request has been marked as suitable for new contributors.

Please ensure the request meets the requirements listed [here](https://git.k8s.io/community/contributors/guide/help-wanted.md#good-first-issue).

If this request no longer meets these requirements, the label can be removed
by commenting with the `/remove-good-first-issue` command.


<details>

In response to [this](https://github.com/kubeflow/kfserving/issues/826):

>/good-first-issue


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

Resolving patch:
diff --git a/python/kfserving/kfserving/storage.py b/python/kfserving/kfserving/storage.py
--- a/python/kfserving/kfserving/storage.py
+++ b/python/kfserving/kfserving/storage.py
@@ -15,9 +15,15 @@
 import glob
 import logging
 import tempfile
+import mimetypes
 import os
 import re
+import shutil
+import tarfile
+import zipfile
+import gzip
 from urllib.parse import urlparse
+import requests 
 from azure.storage.blob import BlockBlobService
 from google.auth import exceptions
 from google.cloud import storage
@@ -27,7 +33,8 @@
 _S3_PREFIX = "s3://"
 _BLOB_RE = "https://(.+?).blob.core.windows.net/(.+)"
 _LOCAL_PREFIX = "file://"
-
+_URI_RE = "https?://(.+)/(.+)"
+_HTTP_PREFIX = "http(s)://"
 
 class Storage(object): # pylint: disable=too-few-public-methods
     @staticmethod
@@ -52,10 +59,12 @@ def download(uri: str, out_dir: str = None) -> str:
             Storage._download_blob(uri, out_dir)
         elif is_local:
             return Storage._download_local(uri, out_dir)
+        elif re.search(_URI_RE, uri):
+            return Storage._download_from_uri(uri, out_dir)
         else:
             raise Exception("Cannot recognize storage type for " + uri +
-                            "\n'%s', '%s', and '%s' are the current available storage type." %
-                            (_GCS_PREFIX, _S3_PREFIX, _LOCAL_PREFIX))
+                            "\n'%s', '%s', '%s', and '%s' are the current available storage type." %
+                            (_GCS_PREFIX, _S3_PREFIX, _LOCAL_PREFIX, _HTTP_PREFIX))
 
         logging.info("Successfully copied %s to %s", uri, out_dir)
         return out_dir
@@ -206,6 +215,43 @@ def _download_local(uri, out_dir=None):
             os.symlink(src, dest_path)
         return out_dir
 
+    @staticmethod
+    def _download_from_uri(uri, out_dir=None):
+        url = urlparse(uri)
+        filename = os.path.basename(url.path)
+        mimetype, encoding = mimetypes.guess_type(uri)
+        local_path = os.path.join(out_dir, filename)
+
+        if filename == '':
+            raise ValueError('No filename contained in URI: %s' % (uri))
+
+        with requests.get(uri, stream=True) as response:
+            if response.status_code != 200:
+                raise RuntimeError("URI: %s returned a %s response code." % (uri, response.status_code))
+            if mimetype == 'application/zip' and not response.headers.get('Content-Type', '').startswith('application/zip'):
+                raise RuntimeError("URI: %s did not respond with \'Content-Type\': \'application/zip\'" % (uri))
+            if mimetype != 'application/zip' and not response.headers.get('Content-Type', '').startswith('application/octet-stream'):
+                raise RuntimeError("URI: %s did not respond with \'Content-Type\': \'application/octet-stream\'" % (uri))
+
+            if encoding == 'gzip':
+                stream = gzip.GzipFile(fileobj=response.raw)
+                local_path = os.path.join(out_dir, f'{filename}.tar')
+            else:
+                stream = response.raw
+            with open(local_path, 'wb') as out:
+                shutil.copyfileobj(stream, out)
+        
+        if mimetype in ["application/x-tar", "application/zip"]:
+            if mimetype == "application/x-tar":
+                archive = tarfile.open(local_path, 'r', encoding='utf-8')
+            else:
+                archive = zipfile.ZipFile(local_path, 'r')
+            archive.extractall(out_dir)
+            archive.close()
+            os.remove(local_path)
+
+        return out_dir
+
     @staticmethod
     def _create_minio_client():
         # Adding prefixing "http" in urlparse is necessary for it to be the netloc


Test patch:
diff --git a/pkg/apis/serving/v1alpha2/inferenceservice_validation_test.go b/pkg/apis/serving/v1alpha2/inferenceservice_validation_test.go
--- a/pkg/apis/serving/v1alpha2/inferenceservice_validation_test.go
+++ b/pkg/apis/serving/v1alpha2/inferenceservice_validation_test.go
@@ -99,6 +99,15 @@ func TestAzureBlobNoContainerFails(t *testing.T) {
 	g.Expect(isvc.ValidateCreate(c)).ShouldNot(gomega.Succeed())
 }
 
+func TestHttpStorageURIPrefixOK(t *testing.T) {
+	g := gomega.NewGomegaWithT(t)
+	isvc := makeTestInferenceService()
+	isvc.Spec.Default.Predictor.Tensorflow.StorageURI = "https://raw.githubusercontent.com/someOrg/someRepo/model.tar.gz"
+	g.Expect(isvc.ValidateCreate(c)).Should(gomega.Succeed())
+	isvc.Spec.Default.Predictor.Tensorflow.StorageURI = "http://raw.githubusercontent.com/someOrg/someRepo/model.tar.gz"
+	g.Expect(isvc.ValidateCreate(c)).Should(gomega.Succeed())
+}
+
 func TestUnkownStorageURIPrefixFails(t *testing.T) {
 	g := gomega.NewGomegaWithT(t)
 	isvc := makeTestInferenceService()
diff --git a/python/kfserving/test/test_storage.py b/python/kfserving/test/test_storage.py
--- a/python/kfserving/test/test_storage.py
+++ b/python/kfserving/test/test_storage.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import io
+import os
 import pytest
 import kfserving
 from minio import Minio, error
@@ -40,6 +42,57 @@ def test_no_prefix_local_path():
     assert kfserving.Storage.download(abs_path) == abs_path
     assert kfserving.Storage.download(relative_path) == relative_path
 
+class MockHttpResponse(object):
+    def __init__(
+        self,
+        status_code=404,
+        raw=b'',
+        content_type=''
+    ):
+        self.status_code = status_code
+        self.raw = io.BytesIO(raw)
+        self.headers = {'Content-Type': content_type}
+
+    def __enter__(self):
+        return self
+    def __exit__(self, ex_type, ex_val, traceback):
+        pass                                                                                                                    
+
+@mock.patch('requests.get', return_value=MockHttpResponse(status_code=200, content_type='application/octet-stream'))
+def test_http_uri_path(_):
+    http_uri = 'http://foo.bar/model.joblib'
+    http_with_query_uri = 'http://foo.bar/model.joblib?foo=bar'
+    out_dir = '.'
+    assert kfserving.Storage.download(http_uri, out_dir=out_dir) == out_dir
+    assert kfserving.Storage.download(http_with_query_uri, out_dir=out_dir) == out_dir
+    os.remove('./model.joblib')
+
+@mock.patch('requests.get', return_value=MockHttpResponse(status_code=200, content_type='application/octet-stream'))
+def test_https_uri_path(_):
+    https_uri = 'https://foo.bar/model.joblib' 
+    https_with_query_uri = 'https://foo.bar/model.joblib?foo=bar'
+    out_dir = '.'
+    assert kfserving.Storage.download(https_uri, out_dir=out_dir) == out_dir
+    assert kfserving.Storage.download(https_with_query_uri, out_dir=out_dir) == out_dir
+    os.remove('./model.joblib')
+
+@mock.patch('requests.get', return_value=MockHttpResponse(status_code=404))
+def test_nonexistent_uri(_):
+    non_existent_uri = 'https://theabyss.net/model.joblib'
+    with pytest.raises(RuntimeError):
+        kfserving.Storage.download(non_existent_uri)
+
+@mock.patch('requests.get', return_value=MockHttpResponse(status_code=200))
+def test_uri_no_filename(_):
+    bad_uri = 'https://foo.bar/test/'
+    with pytest.raises(ValueError):
+        kfserving.Storage.download(bad_uri)
+
+@mock.patch('requests.get', return_value=MockHttpResponse(status_code=200, content_type='text/html'))
+def test_html_content_type(_):
+    bad_uri = 'https://some.site.com/test.model'
+    with pytest.raises(RuntimeError):
+        kfserving.Storage.download(bad_uri)
 
 @mock.patch(STORAGE_MODULE + '.storage')
 def test_mock_gcs(mock_storage):