diff --git a/README.md b/README.md index f555cce6..c7d15f88 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ $ curl https://api.uc.gateway.dev/home * cloudrun jobs * bq remote functions * cloudtasktarget +* uptime checks ## Data Typing Frameworks Supported @@ -336,7 +337,8 @@ Please file any issues, bugs or feature requests as an issue on our [GitHub](htt ☑ Deploy arbitrary Dockerfile to Cloudrun \ ☑ [Multi Container Deployments](https://cloud.google.com/blog/products/serverless/cloud-run-now-supports-multi-container-deployments) \ ☑ Create Deployment Service Accounts \ - ☑ Automatically add IAM invoker bindings on the backend based on deployed handlers + ☑ Automatically add IAM invoker bindings on the backend based on deployed handlers \ + ☑ [Uptime Checks](https://cloud.google.com/monitoring/uptime-checks) ## Want to Contribute diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index a3b52231..89f0a660 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -480,3 +480,21 @@ Another example using ``app.cloudtaskqueue`` to queue and handle tasks in the sa payload = {"message": {"title": "enqueue"}} client.enqueue(target="target", payload=payload) return {} + + +Uptime Check +^^^^^^^^^^^^ + +You can create uptime checks for backends that are public using the `@app.uptime` decorator. +You can customize the check using all available fields found in the `uptime documentation `_ + +.. code:: python + + from goblet import Goblet, goblet_entrypoint + + app = Goblet(function_name="uptime_example") + goblet_entrypoint(app) + + @app.uptime(name="target") + def example_uptime(): + return "success" diff --git a/examples/main.py b/examples/main.py index eff3f958..442e28ae 100644 --- a/examples/main.py +++ b/examples/main.py @@ -402,4 +402,9 @@ def handle_missing_route(error): # Example of handling ValueError. @app.errorhandler("ValueError") def return_error_string(error): - return Response(str(error), status_code=200) \ No newline at end of file + return Response(str(error), status_code=200) + +# Example uptime check +@app.uptime(timeout="30s") +def uptime_check(): + return "success" \ No newline at end of file diff --git a/goblet/__version__.py b/goblet/__version__.py index d3fb80fb..25567025 100644 --- a/goblet/__version__.py +++ b/goblet/__version__.py @@ -1,4 +1,4 @@ -VERSION = (0, 12, 4) +VERSION = (0, 12, 5) __version__ = ".".join(map(str, VERSION)) diff --git a/goblet/cli.py b/goblet/cli.py index 13567ca9..1d39d13a 100644 --- a/goblet/cli.py +++ b/goblet/cli.py @@ -26,6 +26,7 @@ "pubsub", "storage", "schedule", + "uptime", ] SUPPORTED_INFRASTRUCTURES = [ "alerts", diff --git a/goblet/client.py b/goblet/client.py index ceeb9584..e0810889 100644 --- a/goblet/client.py +++ b/goblet/client.py @@ -24,6 +24,7 @@ "iam": "v1", "cloudresourcemanager": "v3", "artifactregistry": "v1", + "monitoring": "v3", "storage": "v1", } @@ -223,6 +224,15 @@ def monitoring_alert(self): parent_schema="projects/{project_id}", ) + @property + def monitoring_uptime(self): + return Client( + "monitoring", + self.client_versions.get("monitoring", "v3"), + calls="projects.uptimeCheckConfigs", + parent_schema="projects/{project_id}", + ) + @property def logging_metric(self): return Client( diff --git a/goblet/decorators.py b/goblet/decorators.py index 3ab58c0b..0954ecbf 100644 --- a/goblet/decorators.py +++ b/goblet/decorators.py @@ -252,6 +252,15 @@ def eventarc(self, topic=None, event_filters=[], **kwargs): }, ) + def uptime(self, **kwargs): + """Uptime trigger""" + return self._create_registration_function( + handler_type="uptime", + registration_kwargs={ + "kwargs": kwargs, + }, + ) + def http(self, headers={}): """Base http trigger""" return self._create_registration_function( diff --git a/goblet/handlers/uptime.py b/goblet/handlers/uptime.py new file mode 100644 index 00000000..1cc45c20 --- /dev/null +++ b/goblet/handlers/uptime.py @@ -0,0 +1,145 @@ +import logging +import os + +from goblet.handlers.handler import Handler +from goblet.permissions import gcp_generic_resource_permissions +from goblet.utils import nested_update +from goblet_gcp_client.client import get_default_project, get_default_location +from googleapiclient.errors import HttpError + +log = logging.getLogger("goblet.deployer") +log.setLevel(logging.getLevelName(os.getenv("GOBLET_LOG_LEVEL", "INFO"))) + + +class Uptime(Handler): + """Uptime Trigger + Uptime checks only support public https urls and cloud run revisions currently https://cloud.google.com/monitoring/uptime-checks + """ + + resource_type = "uptime" + valid_backends = ["cloudfunction", "cloudfunctionv2", "cloudrun"] + required_apis = ["monitoring"] + permissions = [ + *gcp_generic_resource_permissions("monitoring", "uptimeCheckConfigs") + ] + + def __init__( + self, name, backend, versioned_clients=None, resources=None, routes_type=None + ): + super(Uptime, self).__init__( + name=name, + versioned_clients=versioned_clients, + resources=resources, + backend=backend, + ) + self.resources = resources or {} + self.routes_type = routes_type + + def register(self, name, func, kwargs): + self.resources[name] = {"func": func, "name": name, "kwargs": kwargs["kwargs"]} + + def __call__(self, request, context=None): + headers = request.headers or {} + uptime = self.resources[headers["X-Goblet-Uptime-Name"]] + return uptime["func"]() + + def _deploy(self, source=None, entrypoint=None): + # TODO: support api gateway + + if self.resources: + checks = self.list_uptime_checks() + for name, uptime in self.resources.items(): + uptime_config = {"displayName": f"{self.name}-{name}"} + if ( + self.backend.resource_type == "cloudrun" + and not self.routes_type == "apigateway" + ): + uptime_config["monitoredResource"] = { + "type": "cloud_run_revision", + "labels": { + "location": "us-central1", + "service_name": self.name, + "revision_name": "", + "configuration_name": "", + "project_id": get_default_project(), + }, + } + uptime_config["httpCheck"] = { + "useSsl": True, + "headers": {"X-Goblet-Uptime-Name": uptime["name"]}, + } + if self.backend.resource_type.startswith("cloudfunction"): + uptime_config["monitoredResource"] = { + "type": "uptime_url", + "labels": { + "host": f"{get_default_location()}-{get_default_project()}.cloudfunctions.net", + "project_id": get_default_project(), + }, + } + uptime_config["httpCheck"] = { + "useSsl": True, + "path": f"/{self.name}", + "headers": {"X-Goblet-Uptime-Name": uptime["name"]}, + } + + # update config based on user arguments + uptime_config = nested_update(uptime_config, uptime["kwargs"]) + + # Uptime exists + check = [ + check + for check in checks + if check["displayName"] == uptime_config["displayName"] + ] + if len(check) == 1: + # Setup update mask + keys = list(uptime_config.keys()) + + # Remove keys that cannot be updated + keys.remove("monitoredResource") + updateMask = ",".join(keys) + + self.versioned_clients.monitoring_uptime.execute( + "patch", + parent_key="name", + parent_schema=check[0]["name"], + params={"body": uptime_config, "updateMask": updateMask}, + ) + log.info(f"updated uptime check: {name} for {self.name}") + + else: + self.versioned_clients.monitoring_uptime.execute( + "create", params={"body": uptime_config} + ) + log.info(f"created uptime check: {name} for {self.name}") + + return + + def destroy(self): + if not self.resources: + return + for check in self.list_uptime_checks(): + self._destroy_uptime_check(check) + + def _destroy_uptime_check(self, check): + try: + self.versioned_clients.monitoring_uptime.execute( + "delete", parent_key="name", parent_schema=check["name"] + ) + log.info(f"Destroying uptime check {check['displayName']}......") + except HttpError as e: + if e.resp.status == 404: + log.info("Uptime check already destroyed") + else: + raise e + + def list_uptime_checks(self): + resp = self.versioned_clients.monitoring_uptime.execute( + "list", + parent_key="parent", + params={"filter": f"displayName=starts_with('{self.name}-')"}, + ) + return resp.get("uptimeCheckConfigs", []) + + def set_invoker_permissions(self): + return diff --git a/goblet/infrastructures/alerts.py b/goblet/infrastructures/alerts.py index c4a4bdaa..51793c08 100644 --- a/goblet/infrastructures/alerts.py +++ b/goblet/infrastructures/alerts.py @@ -369,3 +369,27 @@ def __init__(self, name, subscription_id, value=0, **kwargs) -> None: ), **kwargs, ) + + +class UptimeCondition(MetricCondition): + """ + Creates and deploys an alert for failed uptime checks. + Supports `uptime_url` or `cloud_run_revision` + """ + + def __init__(self, name, check_id, resource_type, value=1, **kwargs) -> None: + super().__init__( + name=name, + metric="monitoring.googleapis.com/uptime_check/check_passed", + value=value, + filter='metric.labels.check_id = "{check_id}" AND resource.type = "{resource_type}" AND metric.type = "monitoring.googleapis.com/uptime_check/check_passed"'.format( + check_id=check_id, resource_type=resource_type + ), + aggregations=[ + { + "alignmentPeriod": "1200s", + "crossSeriesReducer": "REDUCE_COUNT_FALSE", + } + ], + **kwargs, + ) diff --git a/goblet/resource_manager.py b/goblet/resource_manager.py index a1b03f65..38ebe4d8 100644 --- a/goblet/resource_manager.py +++ b/goblet/resource_manager.py @@ -18,6 +18,7 @@ from goblet.handlers.storage import Storage from goblet.handlers.http import HTTP from goblet.handlers.jobs import Jobs +from goblet.handlers.uptime import Uptime from goblet.infrastructures.redis import Redis from goblet.infrastructures.vpcconnector import VPCConnector @@ -51,6 +52,7 @@ "job", "bqremotefunction", "cloudtasktarget", + "uptime", ] SUPPORTED_BACKENDS = { @@ -95,6 +97,7 @@ def __init__( "jobs": Jobs(function_name, backend=backend), "schedule": Scheduler(function_name, backend=backend), "bqremotefunction": BigQueryRemoteFunction(function_name, backend=backend), + "uptime": Uptime(function_name, backend=backend, routes_type=routes_type), } self.infrastructure = { @@ -170,6 +173,8 @@ def __call__(self, request, context=None): response = self.handlers["bqremotefunction"](request) if event_type == "cloudtasktarget": response = self.handlers["cloudtasktarget"](request) + if event_type == "uptime": + response = self.handlers["uptime"](request) # call after request middleware response = self._call_middleware( @@ -200,6 +205,8 @@ def get_event_type(self, request, context=None): return context.event_type.split(".")[1].split("/")[0] if request.headers.get("X-Goblet-Type") == "schedule": return "schedule" + if request.headers.get("X-Goblet-Uptime-Name"): + return "uptime" if request.headers.get("User-Agent") == "Google-Cloud-Tasks": return "cloudtasktarget" if request.headers.get("Ce-Type") and request.headers.get("Ce-Source"): @@ -356,6 +363,7 @@ def is_http(self): or self.handlers["pubsub"].is_http() or len(self.handlers["bqremotefunction"].resources) > 0 or len(self.handlers["cloudtasktarget"].resources) > 0 + or len(self.handlers["uptime"].resources) > 0 ): return True return False diff --git a/goblet/tests/data/http/uptime-deploy-cloudrun/get-v3-projects-goblet-uptimeCheckConfigs_1.json b/goblet/tests/data/http/uptime-deploy-cloudrun/get-v3-projects-goblet-uptimeCheckConfigs_1.json new file mode 100644 index 00000000..fdfa1c6b --- /dev/null +++ b/goblet/tests/data/http/uptime-deploy-cloudrun/get-v3-projects-goblet-uptimeCheckConfigs_1.json @@ -0,0 +1,6 @@ +{ + "headers": {}, + "body": { + "totalSize": 3 + } +} \ No newline at end of file diff --git a/goblet/tests/data/http/uptime-deploy-cloudrun/post-v3-projects-goblet-uptimeCheckConfigs_1.json b/goblet/tests/data/http/uptime-deploy-cloudrun/post-v3-projects-goblet-uptimeCheckConfigs_1.json new file mode 100644 index 00000000..2ed3d1f4 --- /dev/null +++ b/goblet/tests/data/http/uptime-deploy-cloudrun/post-v3-projects-goblet-uptimeCheckConfigs_1.json @@ -0,0 +1,29 @@ +{ + "headers": {}, + "body": { + "name": "projects/goblet/uptimeCheckConfigs/test-uptime-cloudrun-eR4tvksJ3ss", + "displayName": "test-uptime-cloudrun", + "monitoredResource": { + "type": "cloud_run_revision", + "labels": { + "project_id": "goblet", + "revision_name": "", + "location": "us-central1", + "service_name": "test-uptime", + "configuration_name": "" + } + }, + "httpCheck": { + "useSsl": true, + "path": "/", + "port": 443, + "headers": { + "X-Goblet-Uptime-Name": "cloudrun" + }, + "requestMethod": "GET" + }, + "period": "60s", + "timeout": "10s", + "checkerType": "STATIC_IP_CHECKERS" + } +} \ No newline at end of file diff --git a/goblet/tests/data/http/uptime-deploy/get-v3-projects-goblet-uptimeCheckConfigs_1.json b/goblet/tests/data/http/uptime-deploy/get-v3-projects-goblet-uptimeCheckConfigs_1.json new file mode 100644 index 00000000..fdfa1c6b --- /dev/null +++ b/goblet/tests/data/http/uptime-deploy/get-v3-projects-goblet-uptimeCheckConfigs_1.json @@ -0,0 +1,6 @@ +{ + "headers": {}, + "body": { + "totalSize": 3 + } +} \ No newline at end of file diff --git a/goblet/tests/data/http/uptime-deploy/post-v3-projects-goblet-uptimeCheckConfigs_1.json b/goblet/tests/data/http/uptime-deploy/post-v3-projects-goblet-uptimeCheckConfigs_1.json new file mode 100644 index 00000000..731b5c82 --- /dev/null +++ b/goblet/tests/data/http/uptime-deploy/post-v3-projects-goblet-uptimeCheckConfigs_1.json @@ -0,0 +1,26 @@ +{ + "headers": {}, + "body": { + "name": "projects/goblet/uptimeCheckConfigs/uptime-test-test--jdBv-tjE1E", + "displayName": "uptime-test-test", + "monitoredResource": { + "type": "uptime_url", + "labels": { + "host": "us-central1-goblet.cloudfunctions.net", + "project_id": "goblet" + } + }, + "httpCheck": { + "useSsl": true, + "path": "/uptime-test", + "port": 443, + "headers": { + "X-Goblet-Uptime-Name": "test" + }, + "requestMethod": "GET" + }, + "period": "60s", + "timeout": "10s", + "checkerType": "STATIC_IP_CHECKERS" + } +} \ No newline at end of file diff --git a/goblet/tests/data/http/uptime-destroy/delete-v3-projects-goblet-uptimeCheckConfigs-uptime-test-test--jdBv-tjE1E_1.json b/goblet/tests/data/http/uptime-destroy/delete-v3-projects-goblet-uptimeCheckConfigs-uptime-test-test--jdBv-tjE1E_1.json new file mode 100644 index 00000000..57238766 --- /dev/null +++ b/goblet/tests/data/http/uptime-destroy/delete-v3-projects-goblet-uptimeCheckConfigs-uptime-test-test--jdBv-tjE1E_1.json @@ -0,0 +1,4 @@ +{ + "headers": {}, + "body": {} +} \ No newline at end of file diff --git a/goblet/tests/data/http/uptime-destroy/get-v3-projects-goblet-uptimeCheckConfigs_1.json b/goblet/tests/data/http/uptime-destroy/get-v3-projects-goblet-uptimeCheckConfigs_1.json new file mode 100644 index 00000000..1ee9a535 --- /dev/null +++ b/goblet/tests/data/http/uptime-destroy/get-v3-projects-goblet-uptimeCheckConfigs_1.json @@ -0,0 +1,31 @@ +{ + "headers": {}, + "body": { + "uptimeCheckConfigs": [ + { + "name": "projects/goblet/uptimeCheckConfigs/uptime-test-test--jdBv-tjE1E", + "displayName": "uptime-test-test", + "monitoredResource": { + "type": "uptime_url", + "labels": { + "project_id": "goblet", + "host": "us-central1-goblet.cloudfunctions.net" + } + }, + "httpCheck": { + "useSsl": true, + "path": "/uptime-test", + "port": 443, + "headers": { + "X-Goblet-Uptime-Name": "test" + }, + "requestMethod": "GET" + }, + "period": "60s", + "timeout": "10s", + "checkerType": "STATIC_IP_CHECKERS" + } + ], + "totalSize": 4 + } +} \ No newline at end of file diff --git a/goblet/tests/test_uptime.py b/goblet/tests/test_uptime.py new file mode 100644 index 00000000..a2197821 --- /dev/null +++ b/goblet/tests/test_uptime.py @@ -0,0 +1,142 @@ +from unittest.mock import Mock +from goblet import Goblet +from goblet.handlers.uptime import Uptime +from goblet.test_utils import ( + mock_dummy_function, + dummy_function, +) +from goblet.backends import CloudRun, CloudFunctionV1 +from goblet_gcp_client import ( + get_responses, + get_replay_count, + reset_replay_count, +) + + +class TestUptime: + def test_add_schedule(self, monkeypatch): + app = Goblet(function_name="test-uptime") + monkeypatch.setenv("GOOGLE_PROJECT", "TEST_PROJECT") + monkeypatch.setenv("GOOGLE_LOCATION", "us-central1") + + app.uptime(timeout="30s")(dummy_function) + + uptime = app.handlers["uptime"] + assert len(uptime.resources) == 1 + assert uptime.resources["dummy_function"]["func"] == dummy_function + assert uptime.resources["dummy_function"]["kwargs"] == {"timeout": "30s"} + + def test_call_uptime(self, monkeypatch): + app = Goblet(function_name="test-uptime") + monkeypatch.setenv("GOOGLE_PROJECT", "TEST_PROJECT") + monkeypatch.setenv("GOOGLE_LOCATION", "us-central1") + + mock = Mock() + + app.uptime()(mock_dummy_function(mock)) + + headers = { + "X-Goblet-Uptime-Name": "dummy_function", + } + + mock_event = Mock() + mock_event.headers = headers + + app(mock_event, None) + + assert mock.call_count == 1 + + def test_deploy_uptime_cloudfunction(self, monkeypatch): + monkeypatch.setenv("GOOGLE_PROJECT", "goblet") + monkeypatch.setenv("GOOGLE_LOCATION", "us-central1") + monkeypatch.setenv("G_TEST_NAME", "uptime-deploy") + monkeypatch.setenv("G_HTTP_TEST", "REPLAY") + + reset_replay_count() + + goblet_name = "uptime-test" + uptime = Uptime(goblet_name, backend=CloudFunctionV1(Goblet())) + uptime.register( + "test", + None, + kwargs={"kwargs": {}}, + ) + + uptime.deploy() + + responses = get_responses("uptime-deploy") + + assert get_replay_count() == 2 + + assert responses[1]["body"]["monitoredResource"] == { + "type": "uptime_url", + "labels": { + "host": "us-central1-goblet.cloudfunctions.net", + "project_id": "goblet", + }, + } + assert responses[1]["body"]["httpCheck"] == { + "useSsl": True, + "path": "/uptime-test", + "port": 443, + "headers": {"X-Goblet-Uptime-Name": "test"}, + "requestMethod": "GET", + } + + def test_deploy_uptime_cloudrun(self, monkeypatch): + monkeypatch.setenv("GOOGLE_PROJECT", "goblet") + monkeypatch.setenv("GOOGLE_LOCATION", "us-central1") + monkeypatch.setenv("G_TEST_NAME", "uptime-deploy-cloudrun") + monkeypatch.setenv("G_HTTP_TEST", "REPLAY") + + reset_replay_count() + + goblet_name = "test-uptime" + uptime = Uptime(goblet_name, backend=CloudRun(Goblet())) + uptime.register( + "cloudrun", + None, + kwargs={"kwargs": {}}, + ) + uptime.deploy() + + responses = get_responses("uptime-deploy-cloudrun") + + assert get_replay_count() == 2 + + assert responses[1]["body"]["monitoredResource"] == { + "type": "cloud_run_revision", + "labels": { + "project_id": "goblet", + "revision_name": "", + "location": "us-central1", + "service_name": "test-uptime", + "configuration_name": "", + }, + } + assert responses[1]["body"]["httpCheck"] == { + "useSsl": True, + "path": "/", + "port": 443, + "headers": {"X-Goblet-Uptime-Name": "cloudrun"}, + "requestMethod": "GET", + } + + def test_destroy_uptime(self, monkeypatch): + monkeypatch.setenv("GOOGLE_PROJECT", "goblet") + monkeypatch.setenv("GOOGLE_LOCATION", "us-central1") + monkeypatch.setenv("G_TEST_NAME", "uptime-destroy") + monkeypatch.setenv("G_HTTP_TEST", "REPLAY") + + reset_replay_count() + + goblet_name = "uptime-test" + uptime = Uptime(goblet_name, backend=CloudFunctionV1(Goblet())) + uptime.register( + "test", + None, + kwargs={"kwargs": {}}, + ) + uptime.destroy() + + assert get_replay_count() == 2