-
Notifications
You must be signed in to change notification settings - Fork 1.7k
uploader: add ServerInfo protos and logic
#2878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| syntax = "proto3"; | ||
|
|
||
| package tensorboard.service; | ||
|
|
||
| // Request sent by uploader clients at the start of an upload session. Used to | ||
| // determine whether the client is recent enough to communicate with the | ||
| // server, and to receive any metadata needed for the upload session. | ||
| message ServerInfoRequest { | ||
| // Client-side TensorBoard version, per `tensorboard.version.VERSION`. | ||
| string version = 1; | ||
| } | ||
|
|
||
| message ServerInfoResponse { | ||
| // Primary bottom-line: is the server compatible with the client, and is | ||
| // there anything that the end user should be aware of? | ||
| Compatibility compatibility = 1; | ||
| // Identifier for a gRPC server providing the `TensorBoardExporterService` and | ||
| // `TensorBoardWriterService` services (under the `tensorboard.service` proto | ||
| // package). | ||
| ApiServer api_server = 2; | ||
| // How to generate URLs to experiment pages. | ||
| ExperimentUrlFormat url_format = 3; | ||
| } | ||
|
|
||
| enum CompatibilityVerdict { | ||
| VERDICT_UNKNOWN = 0; | ||
| // All is well. The client may proceed. | ||
| VERDICT_OK = 1; | ||
| // The client may proceed, but should heed the accompanying message. This | ||
| // may be the case if the user is on a version of TensorBoard that will | ||
| // soon be unsupported, or if the server is experiencing transient issues. | ||
| VERDICT_WARN = 2; | ||
| // The client should cease further communication with the server and abort | ||
| // operation after printing the accompanying `details` message. | ||
| VERDICT_ERROR = 3; | ||
| } | ||
|
|
||
| message Compatibility { | ||
| CompatibilityVerdict verdict = 1; | ||
| // Human-readable message to display. When non-empty, will be displayed in | ||
| // all cases, even when the client may proceed. | ||
| string details = 2; | ||
| } | ||
|
|
||
| message ApiServer { | ||
| // gRPC server URI: <https://github.com/grpc/grpc/blob/master/doc/naming.md>. | ||
| // For example: "api.tensorboard.dev:443". | ||
| string endpoint = 1; | ||
| } | ||
|
|
||
| message ExperimentUrlFormat { | ||
| // Template string for experiment URLs. All occurrences of the value of the | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want these to be absolute or relative? Relative has the advantage that it allows a single frontend to be exposed across multiple domain names and clients would generate the appropriate URLs. Of course, we could I suppose extract this from the incoming Another possibility would be adding a template placeholder for the base URL, which if included is basically the same as a relative URL, but could be omitted in the actual template to allow the frontend to return an absolute URL if it wanted to avoid ambiguity (e.g. if wanted to do that on prod).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I was doing the expansion server side based on the requested host.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SGTM, thanks for the explanation. |
||
| // `id_placeholder` field in this template string should be replaced with an | ||
| // experiment ID. For example, if `id_placeholder` is "{{EID}}", then | ||
| // `template` might be "https://tensorboard.dev/experiment/{{EID}}/". | ||
| // Should be absolute. | ||
| string template = 1; | ||
| // Placeholder string that should be replaced with an actual experiment ID. | ||
| // (See docs for `template` field.) | ||
| string id_placeholder = 2; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # Copyright 2019 The TensorFlow Authors. All Rights Reserved. | ||
| # | ||
| # 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. | ||
| # ============================================================================== | ||
| """Initial server communication to determine session parameters.""" | ||
|
|
||
| from __future__ import absolute_import | ||
| from __future__ import division | ||
| from __future__ import print_function | ||
|
|
||
| from google.protobuf import message | ||
| import requests | ||
|
|
||
| from tensorboard import version | ||
| from tensorboard.uploader.proto import server_info_pb2 | ||
|
|
||
|
|
||
| # Request timeout for communicating with remote server. | ||
| _REQUEST_TIMEOUT_SECONDS = 10 | ||
|
|
||
|
|
||
| def _server_info_request(): | ||
| request = server_info_pb2.ServerInfoRequest() | ||
| request.version = version.VERSION | ||
| return request | ||
|
|
||
|
|
||
| def fetch_server_info(origin): | ||
| """Fetches server info from a remote server. | ||
|
|
||
| Args: | ||
| origin: The server with which to communicate. Should be a string | ||
| like "https://tensorboard.dev", including protocol, host, and (if | ||
| needed) port. | ||
|
|
||
| Returns: | ||
| A `server_info_pb2.ServerInfoResponse` message. | ||
|
|
||
| Raises: | ||
| CommunicationError: Upon failure to connect to or successfully | ||
| communicate with the remote server. | ||
| """ | ||
| endpoint = "%s/api/uploader" % origin | ||
| post_body = _server_info_request().SerializeToString() | ||
| try: | ||
| response = requests.post( | ||
| endpoint, data=post_body, timeout=_REQUEST_TIMEOUT_SECONDS | ||
| ) | ||
| except requests.RequestException as e: | ||
| raise CommunicationError("Failed to connect to backend: %s" % e) | ||
| if not response.ok: | ||
| raise CommunicationError( | ||
| "Non-OK status from backend (%d %s): %r" | ||
| % (response.status_code, response.reason, response.content) | ||
| ) | ||
| try: | ||
| return server_info_pb2.ServerInfoResponse.FromString(response.content) | ||
| except message.DecodeError as e: | ||
| raise CommunicationError( | ||
| "Corrupt response from backend (%s): %r" % (e, response.content) | ||
| ) | ||
|
|
||
|
|
||
| def create_server_info(frontend_origin, api_endpoint): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is only used in tests, I think it's preferable to keep it in test code or utilities.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It’s used outside of tests (as of #2879), when you directly specify an
Historically, it’s been possible to test the uploader by spinning up a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, I didn't check follow-on changes for usage. If the goal is to allow locally overriding the API endpoint though, it seems better to me to just have Or concretely, if I were running local storzy but against the hosted-tensorboard-dev Spanner, I might want to be able to run If it would seem weird if specifying a local
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed; I also wasn’t fully content with the flags formulation in #2879. |
||
| """Manually creates server info given a frontend and backend. | ||
|
|
||
| Args: | ||
| frontend_origin: The origin of the TensorBoard.dev frontend, like | ||
| "https://tensorboard.dev" or "http://localhost:8000". | ||
| api_endpoint: As to `server_info_pb2.ApiServer.endpoint`. | ||
|
|
||
| Returns: | ||
| A `server_info_pb2.ServerInfoResponse` message. | ||
| """ | ||
| result = server_info_pb2.ServerInfoResponse() | ||
| result.compatibility.verdict = server_info_pb2.VERDICT_OK | ||
| result.api_server.endpoint = api_endpoint | ||
| url_format = result.url_format | ||
| placeholder = "{{EID}}" | ||
| while placeholder in frontend_origin: | ||
| placeholder = "{%s}" % placeholder | ||
| url_format.template = "%s/experiment/%s/" % (frontend_origin, placeholder) | ||
| url_format.id_placeholder = placeholder | ||
| return result | ||
|
|
||
|
|
||
| class CommunicationError(RuntimeError): | ||
| """Raised upon failure to communicate with the server.""" | ||
|
|
||
| pass | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| # Copyright 2019 The TensorFlow Authors. All Rights Reserved. | ||
| # | ||
| # 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. | ||
| # ============================================================================== | ||
| """Tests for tensorboard.uploader.server_info.""" | ||
|
|
||
| from __future__ import absolute_import | ||
| from __future__ import division | ||
| from __future__ import print_function | ||
|
|
||
| import errno | ||
| import os | ||
| import socket | ||
| from wsgiref import simple_server | ||
|
|
||
| from concurrent import futures | ||
| from werkzeug import wrappers | ||
|
|
||
| from tensorboard import test as tb_test | ||
| from tensorboard import version | ||
| from tensorboard.uploader import server_info | ||
| from tensorboard.uploader.proto import server_info_pb2 | ||
|
|
||
|
|
||
| class FetchServerInfoTest(tb_test.TestCase): | ||
| """Tests for `fetch_server_info`.""" | ||
|
|
||
| def _start_server(self, app): | ||
| """Starts a server and returns its origin ("http://localhost:PORT").""" | ||
| (_, localhost) = _localhost() | ||
| server_class = _make_ipv6_compatible_wsgi_server() | ||
| server = simple_server.make_server(localhost, 0, app, server_class) | ||
| executor = futures.ThreadPoolExecutor() | ||
| future = executor.submit(server.serve_forever, poll_interval=0.01) | ||
|
|
||
| def cleanup(): | ||
| server.shutdown() # stop handling requests | ||
| server.server_close() # release port | ||
| future.result(timeout=3) # wait for server termination | ||
|
|
||
| self.addCleanup(cleanup) | ||
| return "http://localhost:%d" % server.server_port | ||
|
|
||
| def test_fetches_response(self): | ||
| expected_result = server_info_pb2.ServerInfoResponse() | ||
| expected_result.compatibility.verdict = server_info_pb2.VERDICT_OK | ||
| expected_result.compatibility.details = "all clear" | ||
| expected_result.api_server.endpoint = "api.example.com:443" | ||
| expected_result.url_format.template = "http://localhost:8080/{{eid}}" | ||
| expected_result.url_format.id_placeholder = "{{eid}}" | ||
|
|
||
| @wrappers.BaseRequest.application | ||
| def app(request): | ||
| self.assertEqual(request.method, "POST") | ||
| self.assertEqual(request.path, "/api/uploader") | ||
| body = request.get_data() | ||
| request_pb = server_info_pb2.ServerInfoRequest.FromString(body) | ||
| self.assertEqual(request_pb.version, version.VERSION) | ||
| return wrappers.BaseResponse(expected_result.SerializeToString()) | ||
|
|
||
| origin = self._start_server(app) | ||
| result = server_info.fetch_server_info(origin) | ||
| self.assertEqual(result, expected_result) | ||
|
|
||
| def test_econnrefused(self): | ||
| (family, localhost) = _localhost() | ||
| s = socket.socket(family) | ||
| s.bind((localhost, 0)) | ||
| self.addCleanup(s.close) | ||
| port = s.getsockname()[1] | ||
| with self.assertRaises(server_info.CommunicationError) as cm: | ||
| server_info.fetch_server_info("http://localhost:%d" % port) | ||
| msg = str(cm.exception) | ||
| self.assertIn("Failed to connect to backend", msg) | ||
| if os.name != "nt": | ||
| self.assertIn(os.strerror(errno.ECONNREFUSED), msg) | ||
|
|
||
| def test_non_ok_response(self): | ||
| @wrappers.BaseRequest.application | ||
| def app(request): | ||
| del request # unused | ||
| return wrappers.BaseResponse(b"very sad", status="502 Bad Gateway") | ||
|
|
||
| origin = self._start_server(app) | ||
| with self.assertRaises(server_info.CommunicationError) as cm: | ||
| server_info.fetch_server_info(origin) | ||
| msg = str(cm.exception) | ||
| self.assertIn("Non-OK status from backend (502 Bad Gateway)", msg) | ||
| self.assertIn("very sad", msg) | ||
|
|
||
| def test_corrupt_response(self): | ||
| @wrappers.BaseRequest.application | ||
| def app(request): | ||
| del request # unused | ||
| return wrappers.BaseResponse(b"an unlikely proto") | ||
|
|
||
| origin = self._start_server(app) | ||
| with self.assertRaises(server_info.CommunicationError) as cm: | ||
| server_info.fetch_server_info(origin) | ||
| msg = str(cm.exception) | ||
| self.assertIn("Corrupt response from backend", msg) | ||
| self.assertIn("an unlikely proto", msg) | ||
|
|
||
|
|
||
| class CreateServerInfoTest(tb_test.TestCase): | ||
| """Tests for `create_server_info`.""" | ||
|
|
||
| def test(self): | ||
| frontend = "http://localhost:8080" | ||
| backend = "localhost:10000" | ||
| result = server_info.create_server_info(frontend, backend) | ||
|
|
||
| expected_compatibility = server_info_pb2.Compatibility() | ||
| expected_compatibility.verdict = server_info_pb2.VERDICT_OK | ||
| expected_compatibility.details = "" | ||
| self.assertEqual(result.compatibility, expected_compatibility) | ||
|
|
||
| expected_api_server = server_info_pb2.ApiServer() | ||
| expected_api_server.endpoint = backend | ||
| self.assertEqual(result.api_server, expected_api_server) | ||
|
|
||
| url_format = result.url_format | ||
| actual_url = url_format.template.replace(url_format.id_placeholder, "123") | ||
| expected_url = "http://localhost:8080/experiment/123/" | ||
| self.assertEqual(actual_url, expected_url) | ||
|
|
||
|
|
||
| def _localhost(): | ||
| """Gets family and nodename for a loopback address.""" | ||
| s = socket | ||
| infos = s.getaddrinfo(None, 0, s.AF_UNSPEC, s.SOCK_STREAM, 0, s.AI_ADDRCONFIG) | ||
| (family, _, _, _, address) = infos[0] | ||
| nodename = address[0] | ||
| return (family, nodename) | ||
|
|
||
|
|
||
| def _make_ipv6_compatible_wsgi_server(): | ||
| """Creates a `WSGIServer` subclass that works on IPv6-only machines.""" | ||
| address_family = _localhost()[0] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a wee bit ugly IMO to have this resolved at class definition time. Optional, but we could instead pass the address_family into https://github.com/python/cpython/blob/2.7/Lib/wsgiref/simple_server.py#L147
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK: overriding For some reason,
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm about 95% sure that the directive Evidence for this would be that otherwise "extended" makes no sense, and also that plenty of subclasses of Manually constructing the type on the fly is... new to me :P But if you'd rather not touch
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah—not “This class may be extended; do not override its initializer” but Either approach is fine with me, then, so I’ll keep it as is. |
||
| attrs = {"address_family": address_family} | ||
| bases = (simple_server.WSGIServer, object) # `object` needed for py2 | ||
| return type("_Ipv6CompatibleWsgiServer", bases, attrs) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| tb_test.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should take an explicit dep on this in our
setup.pynow that it's a direct dependency, right?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes—thanks!