Fix #4089: Test Connection - Ingestion Resource; Fix #4129: Status -Ingestion Resource (#4215)

* Fix #4089: Test Connection - Ingestion Resource; Fix #4129: Status - Ingestion Resource

* use TestServiceConnectionRequest

* Simplify test service connection

* Add exception info

* Update pipelineStatus

* Deprecate old class

* Use JSON Schema models

Co-authored-by: Pere Miquel Brull <peremiquelbrull@gmail.com>
This commit is contained in:
Sriharsha Chintalapani 2022-04-19 09:54:10 -07:00 committed by GitHub
parent 09f01fc7e3
commit 6b77ab29f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 229 additions and 67 deletions

View File

@ -21,12 +21,15 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.List;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.json.JSONObject; import org.json.JSONObject;
import org.openmetadata.catalog.airflow.models.AirflowAuthRequest; import org.openmetadata.catalog.airflow.models.AirflowAuthRequest;
import org.openmetadata.catalog.airflow.models.AirflowAuthResponse; import org.openmetadata.catalog.airflow.models.AirflowAuthResponse;
import org.openmetadata.catalog.api.services.ingestionPipelines.TestServiceConnection;
import org.openmetadata.catalog.entity.services.ingestionPipelines.IngestionPipeline; import org.openmetadata.catalog.entity.services.ingestionPipelines.IngestionPipeline;
import org.openmetadata.catalog.entity.services.ingestionPipelines.PipelineStatus;
import org.openmetadata.catalog.exception.AirflowException; import org.openmetadata.catalog.exception.AirflowException;
import org.openmetadata.catalog.exception.IngestionPipelineDeploymentException; import org.openmetadata.catalog.exception.IngestionPipelineDeploymentException;
import org.openmetadata.catalog.util.JsonUtils; import org.openmetadata.catalog.util.JsonUtils;
@ -150,4 +153,57 @@ public class AirflowRESTClient {
throw IngestionPipelineDeploymentException.byMessage(pipelineName, e.getMessage()); throw IngestionPipelineDeploymentException.byMessage(pipelineName, e.getMessage());
} }
} }
public IngestionPipeline getStatus(IngestionPipeline ingestionPipeline) {
try {
String token = authenticate();
String authToken = String.format(AUTH_TOKEN, token);
String statusEndPoint = "%s/rest_api/api?api=dag_status&dag_id=%s";
String statusUrl = String.format(statusEndPoint, airflowURL, ingestionPipeline.getName());
JSONObject requestPayload = new JSONObject();
HttpRequest request =
HttpRequest.newBuilder(URI.create(statusUrl))
.header(CONTENT_HEADER, CONTENT_TYPE)
.header(AUTH_HEADER, authToken)
.POST(HttpRequest.BodyPublishers.ofString(requestPayload.toString()))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
List<PipelineStatus> statuses = JsonUtils.readObjects(response.body(), PipelineStatus.class);
ingestionPipeline.setPipelineStatuses(statuses);
return ingestionPipeline;
}
throw AirflowException.byMessage(
ingestionPipeline.getName(),
"Failed to fetch ingestion pipeline runs",
Response.Status.fromStatusCode(response.statusCode()));
} catch (Exception e) {
throw AirflowException.byMessage(ingestionPipeline.getName(), e.getMessage());
}
}
public HttpResponse<String> testConnection(TestServiceConnection testServiceConnection) {
try {
String token = authenticate();
String authToken = String.format(AUTH_TOKEN, token);
String statusEndPoint = "%s/rest_api/api?api=test_connection";
String statusUrl = String.format(statusEndPoint, airflowURL);
String connectionPayload = JsonUtils.pojoToJson(testServiceConnection);
HttpRequest request =
HttpRequest.newBuilder(URI.create(statusUrl))
.header(CONTENT_HEADER, CONTENT_TYPE)
.header(AUTH_HEADER, authToken)
.POST(HttpRequest.BodyPublishers.ofString(connectionPayload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response;
}
throw AirflowException.byMessage(
"Failed to test connection.", String.valueOf(Response.Status.fromStatusCode(response.statusCode())));
} catch (Exception e) {
throw AirflowException.byMessage("Failed to test connection.", e.getMessage());
}
}
} }

View File

@ -17,6 +17,7 @@ import static org.openmetadata.catalog.Entity.FIELD_OWNER;
import static org.openmetadata.catalog.security.SecurityUtil.ADMIN; import static org.openmetadata.catalog.security.SecurityUtil.ADMIN;
import static org.openmetadata.catalog.security.SecurityUtil.BOT; import static org.openmetadata.catalog.security.SecurityUtil.BOT;
import static org.openmetadata.catalog.security.SecurityUtil.OWNER; import static org.openmetadata.catalog.security.SecurityUtil.OWNER;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.ExternalDocumentation;
@ -28,6 +29,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.IOException; import java.io.IOException;
import java.net.http.HttpResponse;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
@ -57,6 +59,7 @@ import org.openmetadata.catalog.Entity;
import org.openmetadata.catalog.airflow.AirflowConfiguration; import org.openmetadata.catalog.airflow.AirflowConfiguration;
import org.openmetadata.catalog.airflow.AirflowRESTClient; import org.openmetadata.catalog.airflow.AirflowRESTClient;
import org.openmetadata.catalog.api.services.ingestionPipelines.CreateIngestionPipeline; import org.openmetadata.catalog.api.services.ingestionPipelines.CreateIngestionPipeline;
import org.openmetadata.catalog.api.services.ingestionPipelines.TestServiceConnection;
import org.openmetadata.catalog.entity.services.DashboardService; import org.openmetadata.catalog.entity.services.DashboardService;
import org.openmetadata.catalog.entity.services.DatabaseService; import org.openmetadata.catalog.entity.services.DatabaseService;
import org.openmetadata.catalog.entity.services.MessagingService; import org.openmetadata.catalog.entity.services.MessagingService;
@ -164,7 +167,12 @@ public class IngestionPipelineResource extends EntityResource<IngestionPipeline,
Include include) Include include)
throws IOException { throws IOException {
ListFilter filter = new ListFilter(include).addQueryParam("service", serviceParam); ListFilter filter = new ListFilter(include).addQueryParam("service", serviceParam);
return super.listInternal(uriInfo, securityContext, fieldsParam, filter, limitParam, before, after); ResultList<IngestionPipeline> ingestionPipelines =
super.listInternal(uriInfo, securityContext, fieldsParam, filter, limitParam, before, after);
if (fieldsParam != null && fieldsParam.contains("pipelineStatuses")) {
addStatus(ingestionPipelines.getData());
}
return ingestionPipelines;
} }
@GET @GET
@ -217,7 +225,11 @@ public class IngestionPipelineResource extends EntityResource<IngestionPipeline,
@DefaultValue("non-deleted") @DefaultValue("non-deleted")
Include include) Include include)
throws IOException { throws IOException {
return getInternal(uriInfo, securityContext, id, fieldsParam, include); IngestionPipeline ingestionPipeline = getInternal(uriInfo, securityContext, id, fieldsParam, include);
if (fieldsParam != null && fieldsParam.contains("pipelineStatuses")) {
ingestionPipeline = addStatus(ingestionPipeline);
}
return ingestionPipeline;
} }
@GET @GET
@ -279,7 +291,11 @@ public class IngestionPipelineResource extends EntityResource<IngestionPipeline,
@DefaultValue("non-deleted") @DefaultValue("non-deleted")
Include include) Include include)
throws IOException { throws IOException {
return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); IngestionPipeline ingestionPipeline = getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include);
if (fieldsParam != null && fieldsParam.contains("pipelineStatuses")) {
ingestionPipeline = addStatus(ingestionPipeline);
}
return ingestionPipeline;
} }
@POST @POST
@ -376,6 +392,29 @@ public class IngestionPipelineResource extends EntityResource<IngestionPipeline,
return addHref(uriInfo, dao.get(uriInfo, id, fields)); return addHref(uriInfo, dao.get(uriInfo, id, fields));
} }
@POST
@Path("/testConnection")
@Operation(
summary = "Test Connection of a Service",
tags = "IngestionPipelines",
description = "Test Connection of a Service.",
responses = {
@ApiResponse(
responseCode = "200",
description = "The ingestion",
content =
@Content(mediaType = "application/json", schema = @Schema(implementation = IngestionPipeline.class))),
@ApiResponse(responseCode = "404", description = "Ingestion for instance {name} is not found")
})
public Response testIngestion(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid TestServiceConnection testServiceConnection)
throws IOException {
HttpResponse<String> response = airflowRESTClient.testConnection(testServiceConnection);
return Response.status(200, response.body()).build();
}
@DELETE @DELETE
@Path("/{id}") @Path("/{id}")
@Operation( @Operation(
@ -463,4 +502,17 @@ public class IngestionPipelineResource extends EntityResource<IngestionPipeline,
source.setSourceConfig(create.getSourceConfig()); source.setSourceConfig(create.getSourceConfig());
return source; return source;
} }
public void addStatus(List<IngestionPipeline> ingestionPipelines) {
listOrEmpty(ingestionPipelines).forEach(this::addStatus);
}
private IngestionPipeline addStatus(IngestionPipeline ingestionPipeline) {
try {
ingestionPipeline = airflowRESTClient.getStatus(ingestionPipeline);
} catch (Exception e) {
LOG.error("Failed to fetch status for {} due to {}", ingestionPipeline.getName(), e);
}
return ingestionPipeline;
}
} }

View File

@ -0,0 +1,40 @@
{
"$id": "https://open-metadata.org/schema/api/services/ingestionPipelines/testServiceConnection.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TestServiceConnectionRequest",
"description": "Test Service Connection to test user provided configuration is valid or not.",
"type": "object",
"properties": {
"connection": {
"description": "Database Connection.",
"oneOf": [
{
"$ref": "../../../entity/services/databaseService.json#/definitions/databaseConnection"
},
{
"$ref": "../../../entity/services/dashboardService.json#/definitions/dashboardConnection"
},
{
"$ref": "../../../entity/services/messagingService.json#/definitions/messagingConnection"
}
]
},
"connectionType": {
"description": "Type of database service such as MySQL, BigQuery, Snowflake, Redshift, Postgres...",
"type": "string",
"enum": ["Database", "Dashboard", "Messaging"],
"javaEnums": [
{
"name": "Database"
},
{
"name": "Dashboard"
},
{
"name": "Messaging"
}
]
}
},
"additionalProperties": false
}

View File

@ -11,6 +11,30 @@
"javaType": "org.openmetadata.catalog.entity.services.ingestionPipelines.PipelineType", "javaType": "org.openmetadata.catalog.entity.services.ingestionPipelines.PipelineType",
"enum": ["metadata", "usage"] "enum": ["metadata", "usage"]
}, },
"pipelineStatus": {
"type": "object",
"javaType": "org.openmetadata.catalog.entity.services.ingestionPipelines.PipelineStatus",
"description": "This defines runtime status of Pipeline.",
"properties": {
"runId": {
"description": "Pipeline unique run ID.",
"type": "string"
},
"state": {
"description": "Pipeline status denotes if its failed or succeeded.",
"type": "string"
},
"startDate": {
"description": "startDate of the pipeline run for this particular execution.",
"type": "string"
},
"endDate": {
"description": "endDate of the pipeline run for this particular execution.",
"type": "string"
}
},
"additionalProperties": false
},
"airflowConfig": { "airflowConfig": {
"description": "Properties to configure the Airflow pipeline that will run the workflow.", "description": "Properties to configure the Airflow pipeline that will run the workflow.",
"type": "object", "type": "object",
@ -151,6 +175,18 @@
"description": "Link to the database service where this database is hosted in.", "description": "Link to the database service where this database is hosted in.",
"$ref": "../../../type/entityReference.json" "$ref": "../../../type/entityReference.json"
}, },
"pipelineStatuses": {
"description": "List of executions and status for the Pipeline.",
"type": "array",
"items": {
"$ref": "#/definitions/pipelineStatus"
},
"default": null
},
"nextExecutionDate": {
"description": "Next execution date from the underlying pipeline platform once the pipeline scheduled.",
"$ref": "../../../type/basic.json#/definitions/date"
},
"href": { "href": {
"description": "Link to this ingestion pipeline resource.", "description": "Link to this ingestion pipeline resource.",
"$ref": "../../../type/basic.json#/definitions/href" "$ref": "../../../type/basic.json#/definitions/href"

View File

@ -48,7 +48,7 @@ APIS_METADATA = [
"post_arguments": [ "post_arguments": [
{ {
"name": "service_connection", "name": "service_connection",
"description": "ServiceConnectionModel config to test", "description": "TestServiceConnectionRequest config to test",
"required": True, "required": True,
}, },
], ],

View File

@ -10,11 +10,15 @@
# limitations under the License. # limitations under the License.
import json import json
from typing import Optional from typing import Optional, Union
from airflow.models import DagRun from airflow.models import DagRun
from flask import Response from flask import Response
from metadata.generated.schema.entity.services.ingestionPipelines.ingestionPipeline import (
PipelineStatus,
)
class ApiResponse: class ApiResponse:
""" """
@ -37,12 +41,8 @@ class ApiResponse:
return resp return resp
@staticmethod @staticmethod
def success(response_obj: Optional[dict] = None): def success(response_obj: Union[Optional[dict], Optional[list]] = None):
if not response_obj: return ApiResponse.standard_response(ApiResponse.STATUS_OK, response_obj or {})
response_obj = {}
response_obj["status"] = "success"
return ApiResponse.standard_response(ApiResponse.STATUS_OK, response_obj)
@staticmethod @staticmethod
def error(status, error): def error(status, error):
@ -70,47 +70,14 @@ class ResponseFormat:
pass pass
@staticmethod @staticmethod
def format_dag_run_state(dag_run: DagRun): def format_dag_run_state(dag_run: DagRun) -> PipelineStatus:
return { return PipelineStatus(
"state": dag_run.get_state(), state=dag_run.get_state(),
"run_id": dag_run.run_id, runId=dag_run.run_id,
"startDate": ( startDate=None
None if not dag_run.start_date
if not dag_run.start_date else dag_run.start_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z"),
else dag_run.start_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z") endDate=None
), if not dag_run.end_date
"endDate": ( else dag_run.end_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z"),
None )
if not dag_run.end_date
else dag_run.end_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
),
}
@staticmethod
def format_dag_task(task_instance):
return {
"taskId": task_instance.task_id,
"dagId": task_instance.dag_id,
"state": task_instance.state,
"tryNumber": (
None
if not task_instance._try_number
else str(task_instance._try_number)
),
"maxTries": (
None if not task_instance.max_tries else str(task_instance.max_tries)
),
"startDate": (
None
if not task_instance.start_date
else task_instance.start_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
),
"endDate": (
None
if not task_instance.end_date
else task_instance.end_date.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
),
"duration": (
None if not task_instance.duration else str(task_instance.duration)
),
}

View File

@ -39,8 +39,8 @@ from openmetadata.operations.test_connection import test_source_connection
from openmetadata.operations.trigger import trigger from openmetadata.operations.trigger import trigger
from pydantic.error_wrappers import ValidationError from pydantic.error_wrappers import ValidationError
from metadata.generated.schema.entity.services.connections.serviceConnection import ( from metadata.generated.schema.api.services.ingestionPipelines.testServiceConnection import (
ServiceConnectionModel, TestServiceConnectionRequest,
) )
from metadata.generated.schema.entity.services.ingestionPipelines.ingestionPipeline import ( from metadata.generated.schema.entity.services.ingestionPipelines.ingestionPipeline import (
IngestionPipeline, IngestionPipeline,
@ -172,10 +172,8 @@ class REST_API(AppBuilderBaseView):
json_request = request.get_json() json_request = request.get_json()
try: try:
service_connection_model = ServiceConnectionModel(**json_request) test_service_connection = TestServiceConnectionRequest(**json_request)
response = test_source_connection( response = test_source_connection(test_service_connection)
service_connection_model.serviceConnection.__root__.config
)
return response return response

View File

@ -11,6 +11,7 @@
""" """
Module containing the logic to check a DAG status Module containing the logic to check a DAG status
""" """
import json
from typing import Optional from typing import Optional
from airflow import settings from airflow import settings
@ -18,6 +19,10 @@ from airflow.models import DagRun
from flask import Response from flask import Response
from openmetadata.api.response import ApiResponse, ResponseFormat from openmetadata.api.response import ApiResponse, ResponseFormat
from metadata.generated.schema.entity.services.ingestionPipelines.ingestionPipeline import (
PipelineStatus,
)
def status(dag_id: str, run_id: Optional[str]) -> Response: def status(dag_id: str, run_id: Optional[str]) -> Response:
@ -34,9 +39,9 @@ def status(dag_id: str, run_id: Optional[str]) -> Response:
if dag_run is None: if dag_run is None:
return ApiResponse.not_found(f"DAG run {run_id} not found") return ApiResponse.not_found(f"DAG run {run_id} not found")
res_dag_run = ResponseFormat.format_dag_run_state(dag_run) res_dag_run: PipelineStatus = ResponseFormat.format_dag_run_state(dag_run)
return ApiResponse.success({"message": f"{res_dag_run}"}) return ApiResponse.success(json.loads(res_dag_run.json()))
runs = ( runs = (
query.filter( query.filter(
@ -47,6 +52,9 @@ def status(dag_id: str, run_id: Optional[str]) -> Response:
.all() .all()
) )
formatted = [ResponseFormat.format_dag_run_state(dag_run) for dag_run in runs] formatted = [
json.loads(ResponseFormat.format_dag_run_state(dag_run).json())
for dag_run in runs
]
return ApiResponse.success({"message": f"{formatted}"}) return ApiResponse.success(formatted)

View File

@ -15,6 +15,9 @@ from a WorkflowSource
from flask import Response from flask import Response
from openmetadata.api.response import ApiResponse from openmetadata.api.response import ApiResponse
from metadata.generated.schema.api.services.ingestionPipelines.testServiceConnection import (
TestServiceConnectionRequest,
)
from metadata.utils.engines import ( from metadata.utils.engines import (
SourceConnectionException, SourceConnectionException,
get_engine, get_engine,
@ -22,13 +25,15 @@ from metadata.utils.engines import (
) )
def test_source_connection(connection) -> Response: def test_source_connection(
test_service_connection: TestServiceConnectionRequest,
) -> Response:
""" """
Create the engine and test the connection Create the engine and test the connection
:param workflow_source: Source to test :param workflow_source: Source to test
:return: None or exception :return: None or exception
""" """
engine = get_engine(connection) engine = get_engine(test_service_connection.connection.config)
try: try:
test_connection(engine) test_connection(engine)