From a529ace8399c1dbd4e2394b4dc2a5af21a6aeaa8 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Thu, 20 Feb 2025 18:25:05 -0600 Subject: [PATCH 1/4] fix(npe): fix and test for npe (#12697) --- .../metadata/entity/EntityServiceImpl.java | 9 +++- .../EntityServiceImplApplyUpsertTest.java | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index 7a2cb7bc9c16c..63fdd6672888b 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -250,8 +250,13 @@ static SystemAspect applyUpsert(ChangeMCP changeMCP, SystemAspect latestAspect) latestAspect.setAuditStamp(changeMCP.getAuditStamp()); } else { // Do not increment version with the incoming change (match existing version) - changeMCP.setNextAspectVersion(Long.valueOf(latestSystemMetadata.getVersion())); - changeSystemMetadata.setVersion(latestSystemMetadata.getVersion()); + long matchVersion = + Optional.ofNullable(latestSystemMetadata.getVersion()) + .map(Long::valueOf) + .orElse(rowNextVersion); + changeMCP.setNextAspectVersion(matchVersion); + changeSystemMetadata.setVersion(String.valueOf(matchVersion)); + latestSystemMetadata.setVersion(String.valueOf(matchVersion)); } // update previous - based on database aspect, populates MCL diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceImplApplyUpsertTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceImplApplyUpsertTest.java index 8b7bc4ab03d1d..3300416e21593 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceImplApplyUpsertTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceImplApplyUpsertTest.java @@ -249,4 +249,48 @@ public void testApplyUpsertMultiInsert() throws Exception { assertNotNull(result2.getCreatedOn()); assertEquals(result2.getCreatedBy(), TEST_AUDIT_STAMP.getActor().toString()); } + + @Test + public void testApplyUpsertNullVersionException() { + // Set up initial system metadata with null version + SystemMetadata initialMetadata = new SystemMetadata(); + initialMetadata.setRunId("run-1"); + + // Create initial aspect that will be stored in database + CorpUserInfo originalInfo = AspectGenerationUtils.createCorpUserInfo("test@test.com"); + EbeanAspectV2 databaseAspectV2 = + new EbeanAspectV2( + "urn:li:corpuser:test", + "corpUserInfo", + 0L, + RecordUtils.toJsonString(originalInfo), + new Timestamp(TEST_AUDIT_STAMP.getTime()), + TEST_AUDIT_STAMP.getActor().toString(), + null, + RecordUtils.toJsonString(initialMetadata)); + + // Create the latest aspect that includes database reference + SystemAspect latestAspect = + EbeanSystemAspect.builder().forUpdate(databaseAspectV2, testEntityRegistry); + + // Create change with different content to trigger update path + SystemMetadata newMetadata = new SystemMetadata(); + newMetadata.setRunId("run-2"); + + ChangeMCP changeMCP = + ChangeItemImpl.builder() + .urn(UrnUtils.getUrn("urn:li:corpuser:test")) + .aspectName("corpUserInfo") + .recordTemplate(originalInfo) + .systemMetadata(newMetadata) + .auditStamp(TEST_AUDIT_STAMP) + .nextAspectVersion(1L) + .build(opContext.getAspectRetriever()); + + SystemAspect upsert = EntityServiceImpl.applyUpsert(changeMCP, latestAspect); + assertEquals(upsert.getSystemMetadataVersion(), Optional.of(1L)); + assertEquals(upsert.getVersion(), 0); + assertEquals(changeMCP.getNextAspectVersion(), 1); + assertEquals(changeMCP.getSystemMetadata().getVersion(), "1"); + } } From 0e9ac144c3cde886ab185bc40f25b4b8fa6d5f57 Mon Sep 17 00:00:00 2001 From: Hyejin Yoon <0327jane@gmail.com> Date: Fri, 21 Feb 2025 10:13:05 +0900 Subject: [PATCH 2/4] fix: skip browsePathv2 generation for entities that do not support it (#12687) --- .../src/datahub/ingestion/api/source_helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/metadata-ingestion/src/datahub/ingestion/api/source_helpers.py b/metadata-ingestion/src/datahub/ingestion/api/source_helpers.py index 08af39cd24982..cfe9df5284e97 100644 --- a/metadata-ingestion/src/datahub/ingestion/api/source_helpers.py +++ b/metadata-ingestion/src/datahub/ingestion/api/source_helpers.py @@ -250,6 +250,10 @@ def auto_browse_path_v2( emitted_urns: Set[str] = set() containers_used_as_parent: Set[str] = set() for urn, batch in _batch_workunits_by_urn(stream): + # Do not generate browse path v2 for entities that do not support it + if not entity_supports_aspect(guess_entity_type(urn), BrowsePathsV2Class): + yield from batch + continue container_path: Optional[List[BrowsePathEntryClass]] = None legacy_path: Optional[List[BrowsePathEntryClass]] = None browse_path_v2: Optional[List[BrowsePathEntryClass]] = None From 0480df887c70d45865d5b95b6df5d1002d5ed4cd Mon Sep 17 00:00:00 2001 From: John Joyce Date: Thu, 20 Feb 2025 17:28:47 -0800 Subject: [PATCH 3/4] chore(): Bring latest incidents data model + GraphQL to OSS (#12671) Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: jayacryl <159848059+jayacryl@users.noreply.github.com> Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce Co-authored-by: John Joyce --- .../datahub/graphql/GmsGraphQLEngine.java | 4 + .../incident/EntityIncidentsResolver.java | 46 ++- .../resolvers/incident/IncidentUtils.java | 85 ++++++ .../incident/RaiseIncidentResolver.java | 63 ++-- .../incident/UpdateIncidentResolver.java | 143 +++++++++ .../UpdateIncidentStatusResolver.java | 4 + .../types/incident/IncidentMapper.java | 63 +++- .../src/main/resources/incident.graphql | 241 ++++++++++++++- .../incident/EntityIncidentsResolverTest.java | 180 +++++++++++- .../incident/IncidentInfoMatcher.java | 112 +++++++ .../resolvers/incident/IncidentUtilsTest.java | 106 +++++++ .../incident/RaiseIncidentResolverTest.java | 225 ++++++++++++++ .../incident/UpdateIncidentResolverTest.java | 278 ++++++++++++++++++ .../types/incident/IncidentMapperTest.java | 224 +++++++++++++- .../src/graphql/incident.graphql | 1 + .../src/graphql/mutations.graphql | 2 +- docs/how/updating-datahub.md | 3 + .../hook/incident/IncidentsSummaryHook.java | 34 ++- .../incident/IncidentsSummaryHookTest.java | 87 +++++- .../linkedin/incident/IncidentAssignee.pdl | 26 ++ .../com/linkedin/incident/IncidentInfo.pdl | 19 ++ .../com/linkedin/incident/IncidentStatus.pdl | 34 ++- .../metadata/search/utils/QueryUtils.java | 41 +++ .../metadata/search/utils/QueryUtilsTest.java | 181 ++++++++++++ smoke-test/tests/incidents/incidents_test.py | 6 +- 25 files changed, 2139 insertions(+), 69 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentInfoMatcher.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtilsTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolverTest.java create mode 100644 metadata-models/src/main/pegasus/com/linkedin/incident/IncidentAssignee.pdl create mode 100644 metadata-service/services/src/test/java/com/linkedin/metadata/search/utils/QueryUtilsTest.java diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index c142dbf7eeb22..3927ca900e62a 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -204,6 +204,7 @@ import com.linkedin.datahub.graphql.resolvers.health.EntityHealthResolver; import com.linkedin.datahub.graphql.resolvers.incident.EntityIncidentsResolver; import com.linkedin.datahub.graphql.resolvers.incident.RaiseIncidentResolver; +import com.linkedin.datahub.graphql.resolvers.incident.UpdateIncidentResolver; import com.linkedin.datahub.graphql.resolvers.incident.UpdateIncidentStatusResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CancelIngestionExecutionRequestResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CreateIngestionExecutionRequestResolver; @@ -1397,6 +1398,9 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "updateIncidentStatus", new UpdateIncidentStatusResolver(this.entityClient, this.entityService)) + .dataFetcher( + "updateIncident", + new UpdateIncidentResolver(this.entityClient, this.entityService)) .dataFetcher( "createForm", new CreateFormResolver(this.entityClient, this.formService)) .dataFetcher("deleteForm", new DeleteFormResolver(this.entityClient)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java index d79634c27d881..0e0c0457273e2 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java @@ -6,6 +6,7 @@ import com.linkedin.datahub.graphql.generated.Entity; import com.linkedin.datahub.graphql.generated.EntityIncidentsResult; import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.datahub.graphql.generated.IncidentPriority; import com.linkedin.datahub.graphql.types.incident.IncidentMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; @@ -38,6 +39,9 @@ public class EntityIncidentsResolver static final String INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME = "entities.keyword"; static final String INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME = "state"; static final String CREATED_TIME_SEARCH_INDEX_FIELD_NAME = "created"; + static final String INCIDENT_STAGE_SEARCH_INDEX_FIELD_NAME = "stage"; + static final String INCIDENT_PRIORITY_SEARCH_INDEX_FIELD_NAME = "priority"; + static final String INCIDENT_ASSIGNEES_SEARCH_INDEX_FIELD_NAME = "assignees"; private final EntityClient _entityClient; @@ -55,12 +59,18 @@ public CompletableFuture get(DataFetchingEnvironment envi final Integer start = environment.getArgumentOrDefault("start", 0); final Integer count = environment.getArgumentOrDefault("count", 20); final Optional maybeState = Optional.ofNullable(environment.getArgument("state")); - + final Optional maybeStage = Optional.ofNullable(environment.getArgument("stage")); + final Optional maybePriority = + Optional.ofNullable(environment.getArgument("priority")); + final Optional> maybeAssigneeUrns = + Optional.ofNullable(environment.getArgument("assigneeUrns")); try { // Step 1: Fetch set of incidents associated with the target entity from the Search // Index! // We use the search index so that we can easily sort by the last updated time. - final Filter filter = buildIncidentsEntityFilter(entityUrn, maybeState); + final Filter filter = + buildIncidentsFilter( + entityUrn, maybeState, maybeStage, maybePriority, maybeAssigneeUrns); final List sortCriteria = buildIncidentsSortCriteria(); final SearchResult searchResult = _entityClient.filter( @@ -110,13 +120,33 @@ public CompletableFuture get(DataFetchingEnvironment envi "get"); } - private Filter buildIncidentsEntityFilter( - final String entityUrn, final Optional maybeState) { - final Map criterionMap = new HashMap<>(); - criterionMap.put(INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, entityUrn); + private Filter buildIncidentsFilter( + final String entityUrn, + final Optional maybeState, + final Optional maybeStage, + final Optional maybePriority, + final Optional> maybeAssigneeUrns) { + final Map> criterionMap = new HashMap<>(); + criterionMap.put( + INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, Collections.singletonList(entityUrn)); maybeState.ifPresent( - incidentState -> criterionMap.put(INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME, incidentState)); - return QueryUtils.newFilter(criterionMap); + incidentState -> + criterionMap.put( + INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME, Collections.singletonList(incidentState))); + maybeStage.ifPresent( + incidentStage -> + criterionMap.put( + INCIDENT_STAGE_SEARCH_INDEX_FIELD_NAME, Collections.singletonList(incidentStage))); + maybePriority.ifPresent( + incidentPriority -> + criterionMap.put( + INCIDENT_PRIORITY_SEARCH_INDEX_FIELD_NAME, + Collections.singletonList( + IncidentUtils.mapIncidentPriority(IncidentPriority.valueOf(incidentPriority)) + .toString()))); + maybeAssigneeUrns.ifPresent( + assigneeUrns -> criterionMap.put(INCIDENT_ASSIGNEES_SEARCH_INDEX_FIELD_NAME, assigneeUrns)); + return QueryUtils.newListsFilter(criterionMap); } private List buildIncidentsSortCriteria() { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtils.java index 500813e01ad6b..4e41f41054aa2 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtils.java @@ -3,13 +3,41 @@ import com.datahub.authorization.ConjunctivePrivilegeGroup; import com.datahub.authorization.DisjunctivePrivilegeGroup; import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.generated.IncidentPriority; +import com.linkedin.incident.IncidentAssignee; +import com.linkedin.incident.IncidentAssigneeArray; +import com.linkedin.incident.IncidentStage; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; import com.linkedin.metadata.authorization.PoliciesConfig; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; public class IncidentUtils { + public static List stringsToUrns(List urns) { + return urns.stream() + .map( + rawUrn -> { + try { + return Urn.createFromString(rawUrn); + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + public static boolean isAuthorizedToEditIncidentForResource( final Urn resourceUrn, final QueryContext context) { final DisjunctivePrivilegeGroup orPrivilegeGroups = @@ -22,4 +50,61 @@ public static boolean isAuthorizedToEditIncidentForResource( return AuthorizationUtils.isAuthorized( context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups); } + + @Nullable + public static Integer mapIncidentPriority(@Nullable final IncidentPriority priority) { + if (priority == null) { + return null; + } + switch (priority) { + case LOW: + return 3; + case MEDIUM: + return 2; + case HIGH: + return 1; + case CRITICAL: + return 0; + default: + throw new IllegalArgumentException("Invalid incident priority: " + priority); + } + } + + @Nullable + public static IncidentAssigneeArray mapIncidentAssignees( + @Nullable final List assignees, @Nonnull final AuditStamp auditStamp) { + if (assignees == null) { + return null; + } + return new IncidentAssigneeArray( + assignees.stream() + .map(assignee -> createAssignee(assignee, auditStamp)) + .collect(Collectors.toList())); + } + + @Nonnull + public static IncidentStatus mapIncidentStatus( + @Nullable final com.linkedin.datahub.graphql.generated.IncidentStatusInput input, + @Nonnull final AuditStamp auditStamp) { + if (input == null) { + return new IncidentStatus().setState(IncidentState.ACTIVE).setLastUpdated(auditStamp); + } + + IncidentStatus status = new IncidentStatus(); + status.setState(IncidentState.valueOf(input.getState().toString())); + if (input.getStage() != null) { + status.setStage(IncidentStage.valueOf(input.getStage().toString())); + } + if (input.getMessage() != null) { + status.setMessage(input.getMessage()); + } + return status; + } + + private static IncidentAssignee createAssignee( + @Nonnull final String assigneeUrn, @Nonnull final AuditStamp auditStamp) { + return new IncidentAssignee().setActor(UrnUtils.getUrn(assigneeUrn)).setAssignedAt(auditStamp); + } + + private IncidentUtils() {} } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java index 68aef26bf4aa1..eadf0d781b6ab 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java @@ -2,6 +2,7 @@ import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.ALL_PRIVILEGES_GROUP; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; +import static com.linkedin.datahub.graphql.resolvers.incident.IncidentUtils.*; import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; import static com.linkedin.metadata.Constants.*; @@ -21,8 +22,6 @@ import com.linkedin.incident.IncidentInfo; import com.linkedin.incident.IncidentSource; import com.linkedin.incident.IncidentSourceType; -import com.linkedin.incident.IncidentState; -import com.linkedin.incident.IncidentStatus; import com.linkedin.incident.IncidentType; import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.key.IncidentKey; @@ -30,12 +29,16 @@ import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** Resolver used for creating (raising) a new asset incident. */ +// TODO: Add an incident impact summary that is computed here (or in a hook) @Slf4j @RequiredArgsConstructor public class RaiseIncidentResolver implements DataFetcher> { @@ -48,13 +51,27 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws final QueryContext context = environment.getContext(); final RaiseIncidentInput input = bindArgument(environment.getArgument("input"), RaiseIncidentInput.class); - final Urn resourceUrn = Urn.createFromString(input.getResourceUrn()); + final Urn resourceUrn = + input.getResourceUrn() != null ? Urn.createFromString(input.getResourceUrn()) : null; + final List resourceUrns = + new ArrayList<>( + input.getResourceUrns() != null + ? stringsToUrns(input.getResourceUrns()) + : Collections.emptyList()); + if (resourceUrn != null && !resourceUrns.contains(resourceUrn)) { + resourceUrns.add(resourceUrn); + } + if (resourceUrns.isEmpty()) { + throw new RuntimeException("At least 1 resource urn must be defined to raise an incident."); + } return GraphQLConcurrencyUtils.supplyAsync( () -> { - if (!isAuthorizedToCreateIncidentForResource(resourceUrn, context)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); + for (Urn urn : resourceUrns) { + if (!isAuthorizedToCreateIncidentForResource(urn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } } try { @@ -71,19 +88,24 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws key, INCIDENT_ENTITY_NAME, INCIDENT_INFO_ASPECT_NAME, - mapIncidentInfo(input, context)); + mapIncidentInfo(input, resourceUrns, context)); return _entityClient.ingestProposal(context.getOperationContext(), proposal, false); } catch (Exception e) { log.error("Failed to create incident. {}", e.getMessage()); - throw new RuntimeException("Failed to incident", e); + throw new RuntimeException(e.getMessage()); } }, this.getClass().getSimpleName(), "get"); } - private IncidentInfo mapIncidentInfo(final RaiseIncidentInput input, final QueryContext context) + private IncidentInfo mapIncidentInfo( + final RaiseIncidentInput input, List resourceUrns, final QueryContext context) throws URISyntaxException { + final AuditStamp actorStamp = + new AuditStamp() + .setActor(Urn.createFromString(context.getActorUrn())) + .setTime(System.currentTimeMillis()); final IncidentInfo result = new IncidentInfo(); result.setType( IncidentType.valueOf( @@ -91,25 +113,28 @@ private IncidentInfo mapIncidentInfo(final RaiseIncidentInput input, final Query .getType() .name())); // Assumption Alert: This assumes that GMS incident type === GraphQL // incident type. + if (IncidentType.CUSTOM.name().equals(input.getType().name()) + && input.getCustomType() == null) { + throw new URISyntaxException("Failed to create incident.", "customType is required"); + } result.setCustomType(input.getCustomType(), SetMode.IGNORE_NULL); result.setTitle(input.getTitle(), SetMode.IGNORE_NULL); result.setDescription(input.getDescription(), SetMode.IGNORE_NULL); - result.setEntities( - new UrnArray(ImmutableList.of(Urn.createFromString(input.getResourceUrn())))); + result.setEntities(new UrnArray(resourceUrns)); result.setCreated( new AuditStamp() .setActor(Urn.createFromString(context.getActorUrn())) .setTime(System.currentTimeMillis())); + if (input.getStartedAt() != null) { + result.setStartedAt(input.getStartedAt()); + } // Create the incident in the 'active' state by default. - result.setStatus( - new IncidentStatus() - .setState(IncidentState.ACTIVE) - .setLastUpdated( - new AuditStamp() - .setActor(Urn.createFromString(context.getActorUrn())) - .setTime(System.currentTimeMillis()))); result.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL), SetMode.IGNORE_NULL); - result.setPriority(input.getPriority(), SetMode.IGNORE_NULL); + result.setPriority(IncidentUtils.mapIncidentPriority(input.getPriority()), SetMode.IGNORE_NULL); + result.setAssignees( + IncidentUtils.mapIncidentAssignees(input.getAssigneeUrns(), actorStamp), + SetMode.IGNORE_NULL); + result.setStatus(IncidentUtils.mapIncidentStatus(input.getStatus(), actorStamp)); return result; } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java new file mode 100644 index 0000000000000..1f6e734a0f760 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java @@ -0,0 +1,143 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.ALL_PRIVILEGES_GROUP; +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; +import static com.linkedin.metadata.Constants.*; + +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; +import com.linkedin.datahub.graphql.generated.UpdateIncidentInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.EntityUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; + +/** GraphQL Resolver that updates an incident's status */ +@RequiredArgsConstructor +public class UpdateIncidentResolver implements DataFetcher> { + + private final EntityClient _entityClient; + private final EntityService _entityService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + final Urn incidentUrn = Urn.createFromString(environment.getArgument("urn")); + final UpdateIncidentInput input = + bindArgument(environment.getArgument("input"), UpdateIncidentInput.class); + return CompletableFuture.supplyAsync( + () -> { + + // Check whether the incident exists. + final IncidentInfo info = + (IncidentInfo) + EntityUtils.getAspectFromEntity( + context.getOperationContext(), + incidentUrn.toString(), + INCIDENT_INFO_ASPECT_NAME, + _entityService, + null); + + if (info != null) { + // Check whether the actor has permission to edit the incident. + verifyAuthorizationOrThrow(context, info, input); + + final AuditStamp actorStamp = + new AuditStamp() + .setActor(UrnUtils.getUrn(context.getActorUrn())) + .setTime(System.currentTimeMillis()); + updateIncidentInfo(info, input, actorStamp); + try { + // Finally, create the MetadataChangeProposal. + final MetadataChangeProposal proposal = + buildMetadataChangeProposalWithUrn(incidentUrn, INCIDENT_INFO_ASPECT_NAME, info); + _entityClient.ingestProposal(context.getOperationContext(), proposal, false); + return true; + } catch (Exception e) { + throw new RuntimeException("Failed to update incident status!", e); + } + } + throw new DataHubGraphQLException( + "Failed to update incident. Incident does not exist.", + DataHubGraphQLErrorCode.NOT_FOUND); + }); + } + + private void verifyAuthorizationOrThrow( + QueryContext context, IncidentInfo info, UpdateIncidentInput input) + throws AuthorizationException, IllegalArgumentException { + final List existingResourceUrns = info.getEntities(); + for (Urn resourceUrn : existingResourceUrns) { + if (!isAuthorizedToUpdateIncident(resourceUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + } + if (input.getResourceUrns() != null) { + if (input.getResourceUrns().isEmpty()) { + throw new IllegalArgumentException("resourceUrns cannot be empty if provided"); + } + final List newResourceUrns = IncidentUtils.stringsToUrns(input.getResourceUrns()); + for (Urn resourceUrn : newResourceUrns) { + if (!existingResourceUrns.contains(resourceUrn)) { + if (!isAuthorizedToUpdateIncident(resourceUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + } + } + } + } + + private void updateIncidentInfo( + final IncidentInfo info, final UpdateIncidentInput input, final AuditStamp actorStamp) { + if (input.getTitle() != null) { + info.setTitle(input.getTitle()); + } + if (input.getDescription() != null) { + info.setDescription(input.getDescription()); + } + if (input.getPriority() != null) { + info.setPriority(IncidentUtils.mapIncidentPriority(input.getPriority())); + } + if (input.getAssigneeUrns() != null) { + info.setAssignees(IncidentUtils.mapIncidentAssignees(input.getAssigneeUrns(), actorStamp)); + } + if (input.getStatus() != null) { + info.setStatus(IncidentUtils.mapIncidentStatus(input.getStatus(), actorStamp)); + } + if (input.getResourceUrns() != null && !input.getResourceUrns().isEmpty()) { + info.setEntities(new UrnArray(IncidentUtils.stringsToUrns(input.getResourceUrns()))); + } + } + + private boolean isAuthorizedToUpdateIncident(final Urn resourceUrn, final QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType())))); + return AuthorizationUtils.isAuthorized( + context, resourceUrn.getEntityType(), resourceUrn.toString(), orPrivilegeGroups); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java index dee92247ba311..aa1bd851ba3cc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java @@ -20,6 +20,7 @@ import com.linkedin.datahub.graphql.generated.UpdateIncidentStatusInput; import com.linkedin.entity.client.EntityClient; import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentStage; import com.linkedin.incident.IncidentState; import com.linkedin.incident.IncidentStatus; import com.linkedin.metadata.authorization.PoliciesConfig; @@ -73,6 +74,9 @@ public CompletableFuture get(final DataFetchingEnvironment environment) if (input.getMessage() != null) { info.getStatus().setMessage(input.getMessage()); } + if (input.getStage() != null) { + info.getStatus().setStage(IncidentStage.valueOf(input.getStage().name())); + } try { // Finally, create the MetadataChangeProposal. final MetadataChangeProposal proposal = diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java index f39549fdc6eed..68c30c5e64cb8 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java @@ -6,24 +6,34 @@ import com.linkedin.common.urn.Urn; import com.linkedin.data.template.GetMode; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpUser; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.datahub.graphql.generated.IncidentPriority; import com.linkedin.datahub.graphql.generated.IncidentSource; import com.linkedin.datahub.graphql.generated.IncidentSourceType; +import com.linkedin.datahub.graphql.generated.IncidentStage; import com.linkedin.datahub.graphql.generated.IncidentState; import com.linkedin.datahub.graphql.generated.IncidentStatus; import com.linkedin.datahub.graphql.generated.IncidentType; +import com.linkedin.datahub.graphql.generated.OwnerType; import com.linkedin.datahub.graphql.types.common.mappers.AuditStampMapper; import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.incident.IncidentAssignee; import com.linkedin.incident.IncidentInfo; import com.linkedin.metadata.Constants; +import java.util.List; +import java.util.stream.Collectors; import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; /** Maps a GMS {@link EntityResponse} to a GraphQL incident. */ +@Slf4j public class IncidentMapper { public static Incident map(@Nullable QueryContext context, final EntityResponse entityResponse) { @@ -41,9 +51,13 @@ public static Incident map(@Nullable QueryContext context, final EntityResponse result.setCustomType(info.getCustomType(GetMode.NULL)); result.setTitle(info.getTitle(GetMode.NULL)); result.setDescription(info.getDescription(GetMode.NULL)); - result.setPriority(info.getPriority(GetMode.NULL)); + result.setPriority(mapPriority(info.getPriority(GetMode.NULL))); + result.setAssignees(mapAssignees(info.getAssignees(GetMode.NULL))); // TODO: Support multiple entities per incident. result.setEntity(UrnToEntityMapper.map(context, info.getEntities().get(0))); + if (info.hasStartedAt()) { + result.setStartedAt(info.getStartedAt()); + } if (info.hasSource()) { result.setSource(mapIncidentSource(context, info.getSource())); } @@ -71,9 +85,56 @@ private static IncidentStatus mapStatus( result.setState(IncidentState.valueOf(incidentStatus.getState().name())); result.setMessage(incidentStatus.getMessage(GetMode.NULL)); result.setLastUpdated(AuditStampMapper.map(context, incidentStatus.getLastUpdated())); + if (incidentStatus.hasStage()) { + result.setStage(IncidentStage.valueOf(incidentStatus.getStage().toString())); + } return result; } + @Nullable + private static IncidentPriority mapPriority(@Nullable final Integer priority) { + if (priority == null) { + return null; + } + switch (priority) { + case 3: + return IncidentPriority.LOW; + case 2: + return IncidentPriority.MEDIUM; + case 1: + return IncidentPriority.HIGH; + case 0: + return IncidentPriority.CRITICAL; + default: + log.error(String.format("Invalid priority value: %s", priority)); + return null; + } + } + + @Nullable + private static List mapAssignees(@Nullable final List assignees) { + if (assignees == null) { + return null; + } + return assignees.stream().map(IncidentMapper::mapAssignee).collect(Collectors.toList()); + } + + private static OwnerType mapAssignee(final IncidentAssignee assignee) { + Urn actor = assignee.getActor(); + if (actor.getEntityType().equals(Constants.CORP_USER_ENTITY_NAME)) { + final CorpUser user = new CorpUser(); + user.setUrn(actor.toString()); + user.setType(EntityType.CORP_USER); + return user; + } else if (actor.getEntityType().equals(Constants.CORP_GROUP_ENTITY_NAME)) { + final CorpGroup group = new CorpGroup(); + group.setUrn(actor.toString()); + group.setType(EntityType.CORP_GROUP); + return group; + } + throw new IllegalArgumentException(String.format("Invalid assignee urn: %s", actor)); + } + private static IncidentSource mapIncidentSource( @Nullable QueryContext context, final com.linkedin.incident.IncidentSource incidentSource) { final IncidentSource result = new IncidentSource(); diff --git a/datahub-graphql-core/src/main/resources/incident.graphql b/datahub-graphql-core/src/main/resources/incident.graphql index c2938543ed949..2204cfaae2855 100644 --- a/datahub-graphql-core/src/main/resources/incident.graphql +++ b/datahub-graphql-core/src/main/resources/incident.graphql @@ -9,7 +9,7 @@ extend type Mutation { input: RaiseIncidentInput!): String """ - Update an existing incident for a resource (asset) + Update the status of an existing incident for a resource (asset) """ updateIncidentStatus( """ @@ -20,7 +20,21 @@ extend type Mutation { """ Input required to update the state of an existing incident """ - input: UpdateIncidentStatusInput!): Boolean + input: IncidentStatusInput!): Boolean + + """ + Update an existing data incident. Any fields that are omitted will simply not be updated. + """ + updateIncident( + """ + The urn for an existing incident + """ + urn: String! + + """ + Input required to update an existing incident + """ + input: UpdateIncidentInput!): Boolean } """ @@ -88,9 +102,14 @@ type Incident implements Entity { status: IncidentStatus! """ - Optional priority of the incident. Lower value indicates higher priority. + Optional priority of the incident. + """ + priority: IncidentPriority + + """ + The users or groups are assigned to resolve the incident """ - priority: Int + assignees: [OwnerType!] """ The entity that the incident is associated with. @@ -102,6 +121,11 @@ type Incident implements Entity { """ source: IncidentSource + """ + An optional time at which the incident actually started (may be before the date it was raised). + """ + startedAt: Long + """ The time at which the incident was initially created """ @@ -132,6 +156,62 @@ enum IncidentState { RESOLVED } +""" +The lifecycle stage of the incident. +""" +enum IncidentStage { + """ + The impact and priority of the incident is being actively assessed. + """ + TRIAGE + + """ + The incident root cause is being investigated. + """ + INVESTIGATION + + """ + The incident is in the remediation stage. + """ + WORK_IN_PROGRESS + + """ + The incident is in the resolved as completed stage. + """ + FIXED + + """ + The incident is in the resolved with no action required state, e.g., the + incident was a false positive, or was expected. + """ + NO_ACTION_REQUIRED +} + +""" +The priority of the incident +""" +enum IncidentPriority { + """ + A low priority incident (P3) + """ + LOW + + """ + A medium priority incident (P2) + """ + MEDIUM + + """ + A high priority incident (P1) + """ + HIGH + + """ + A critical priority incident (P0) + """ + CRITICAL +} + """ A specific type of incident """ @@ -187,6 +267,10 @@ type IncidentStatus { """ state: IncidentState! """ + The lifecycle stage of the incident. Null means that no stage has been assigned. + """ + stage: IncidentStage + """ An optional message associated with the status """ message: String @@ -248,16 +332,75 @@ input RaiseIncidentInput { description: String """ The resource (dataset, dashboard, chart, dataFlow, etc) that the incident is associated with. + This must be present if resourceUrns are not defined. """ - resourceUrn: String! + resourceUrn: String + """ + The resources (dataset, dashboard, chart, dataFlow, etc) that the incident is associated with. + This must be present and not empty if resourceUrn is not defined. + """ + resourceUrns: [String!] + """ + The time at which the incident actually started (may be before the date it was raised). + """ + startedAt: Long """ The source of the incident, i.e. how it was generated """ source: IncidentSourceInput """ - An optional priority for the incident. Lower value indicates a higher priority. + The status of the incident + """ + status: IncidentStatusInput """ - priority: Int + An optional priority for the incident. + """ + priority: IncidentPriority + """ + An optional set of user or group assignee urns + """ + assigneeUrns: [String!] +} + +""" +Input required to update an existing incident. +""" +input UpdateIncidentInput { + """ + An optional title associated with the incident + """ + title: String + + """ + An optional description associated with the incident + """ + description: String + + """ + An optional time at which the incident actually started (may be before the date it was raised). + """ + startedAt: Long + + """ + The status of the incident + """ + status: IncidentStatusInput + + """ + An optional priority for the incident. + """ + priority: IncidentPriority + + """ + An optional set of resources that the incident is assigned to. + If defined, there must be at least one in the list. + """ + resourceUrns: [String!] + + """ + An optional set of user or group assignee urns + """ + assigneeUrns: [String!] } """ @@ -270,6 +413,26 @@ input IncidentSourceInput { type: IncidentSourceType! } +""" +Input required to create an incident status +""" +input IncidentStatusInput { + """ + The state of the incident + """ + state: IncidentState! + + """ + The lifecycle stage ofthe incident + """ + stage: IncidentStage + + """ + An optional message associated with the status + """ + message: String +} + """ Input required to update status of an existing incident """ @@ -279,6 +442,10 @@ input UpdateIncidentStatusInput { """ state: IncidentState! """ + Optional - The new lifecycle stage of the incident + """ + stage: IncidentStage + """ An optional message associated with the new state """ message: String @@ -294,6 +461,18 @@ extend type Dataset { """ state: IncidentState, """ + Optional incident stage to filter by, defaults to any state. + """ + stage: IncidentStage, + """ + Optional incident priority to filter by, defaults to any state. + """ + priority: IncidentPriority, + """ + Optional assignee urns for an incident. + """ + assigneeUrns: [String!], + """ Optional start offset, defaults to 0. """ start: Int, @@ -313,6 +492,18 @@ extend type DataJob { """ state: IncidentState, """ + Optional incident stage to filter by, defaults to any state. + """ + stage: IncidentStage, + """ + Optional incident priority to filter by, defaults to any state. + """ + priority: IncidentPriority, + """ + Optional assignee urns for an incident. + """ + assigneeUrns: [String!], + """ Optional start offset, defaults to 0. """ start: Int, @@ -332,6 +523,18 @@ extend type DataFlow { """ state: IncidentState, """ + Optional incident stage to filter by, defaults to any state. + """ + stage: IncidentStage, + """ + Optional incident priority to filter by, defaults to any state. + """ + priority: IncidentPriority, + """ + Optional assignee urns for an incident. + """ + assigneeUrns: [String!], + """ Optional start offset, defaults to 0. """ start: Int, @@ -351,6 +554,18 @@ extend type Dashboard { """ state: IncidentState, """ + Optional incident stage to filter by, defaults to any state. + """ + stage: IncidentStage, + """ + Optional incident priority to filter by, defaults to any state. + """ + priority: IncidentPriority, + """ + Optional assignee urns for an incident. + """ + assigneeUrns: [String!], + """ Optional start offset, defaults to 0. """ start: Int, @@ -370,6 +585,18 @@ extend type Chart { """ state: IncidentState, """ + Optional incident stage to filter by, defaults to any state. + """ + stage: IncidentStage, + """ + Optional incident priority to filter by, defaults to any state. + """ + priority: IncidentPriority, + """ + Optional assignee urns for an incident. + """ + assigneeUrns: [String!], + """ Optional start offset, defaults to 0. """ start: Int, diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java index 4750143b8add8..9f7389bce25ee 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java @@ -1,8 +1,8 @@ package com.linkedin.datahub.graphql.resolvers.incident; import static com.linkedin.datahub.graphql.resolvers.incident.EntityIncidentsResolver.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; import static org.testng.Assert.*; import com.datahub.authentication.Authentication; @@ -13,16 +13,20 @@ import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CorpUser; import com.linkedin.datahub.graphql.generated.Dataset; import com.linkedin.datahub.graphql.generated.EntityIncidentsResult; import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.IncidentPriority; import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspectMap; import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentAssigneeArray; import com.linkedin.incident.IncidentInfo; import com.linkedin.incident.IncidentSource; import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentStage; import com.linkedin.incident.IncidentState; import com.linkedin.incident.IncidentStatus; import com.linkedin.incident.IncidentType; @@ -39,6 +43,7 @@ import io.datahubproject.metadata.context.OperationContext; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.mockito.Mockito; import org.testng.annotations.Test; @@ -61,14 +66,16 @@ public void testGetSuccess() throws Exception { IncidentInfo expectedInfo = new IncidentInfo() - .setType(IncidentType.OPERATIONAL) + .setType(IncidentType.FIELD) .setCustomType("Custom Type") .setDescription("Description") .setPriority(5) .setTitle("Title") .setEntities(new UrnArray(ImmutableList.of(datasetUrn))) .setSource( - new IncidentSource().setType(IncidentSourceType.MANUAL).setSourceUrn(assertionUrn)) + new IncidentSource() + .setType(IncidentSourceType.ASSERTION_FAILURE) + .setSourceUrn(assertionUrn)) .setStatus( new IncidentStatus() .setState(IncidentState.ACTIVE) @@ -80,9 +87,10 @@ public void testGetSuccess() throws Exception { Constants.INCIDENT_INFO_ASPECT_NAME, new com.linkedin.entity.EnvelopedAspect().setValue(new Aspect(expectedInfo.data()))); - final Map criterionMap = new HashMap<>(); - criterionMap.put(INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, datasetUrn.toString()); - Filter expectedFilter = QueryUtils.newFilter(criterionMap); + final Map> criterionMap = new HashMap<>(); + criterionMap.put( + INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, ImmutableList.of(datasetUrn.toString())); + Filter expectedFilter = QueryUtils.newListsFilter(criterionMap); SortCriterion expectedSort = new SortCriterion(); expectedSort.setField(CREATED_TIME_SEARCH_INDEX_FIELD_NAME); @@ -107,7 +115,7 @@ public void testGetSuccess() throws Exception { Mockito.when( mockClient.batchGetV2( - any(), + any(OperationContext.class), Mockito.eq(Constants.INCIDENT_ENTITY_NAME), Mockito.eq(ImmutableSet.of(incidentUrn)), Mockito.eq(null))) @@ -167,4 +175,160 @@ public void testGetSuccess() throws Exception { assertEquals(incident.getCreated().getActor(), expectedInfo.getCreated().getActor().toString()); assertEquals(incident.getCreated().getTime(), expectedInfo.getCreated().getTime()); } + + @Test + public void testGetSuccessAllFilters() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + + Urn assertionUrn = Urn.createFromString("urn:li:assertion:test"); + Urn userUrn = Urn.createFromString("urn:li:corpuser:test"); + Urn datasetUrn = Urn.createFromString("urn:li:dataset:(test,test,test)"); + Urn incidentUrn = Urn.createFromString("urn:li:incident:test-guid"); + + Map incidentAspects = new HashMap<>(); + incidentAspects.put( + Constants.INCIDENT_KEY_ASPECT_NAME, + new com.linkedin.entity.EnvelopedAspect() + .setValue(new Aspect(new IncidentKey().setId("test-guid").data()))); + + IncidentInfo expectedInfo = + new IncidentInfo() + .setType(IncidentType.FIELD) + .setCustomType("Custom Type") + .setDescription("Description") + .setPriority(5) + .setTitle("Title") + .setEntities(new UrnArray(ImmutableList.of(datasetUrn))) + .setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new com.linkedin.incident.IncidentAssignee().setActor(userUrn)))) + .setPriority(0) + .setSource( + new IncidentSource() + .setType(IncidentSourceType.ASSERTION_FAILURE) + .setSourceUrn(assertionUrn)) + .setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.TRIAGE) + .setMessage("Message") + .setLastUpdated(new AuditStamp().setTime(1L).setActor(userUrn))) + .setCreated(new AuditStamp().setTime(0L).setActor(userUrn)); + + incidentAspects.put( + Constants.INCIDENT_INFO_ASPECT_NAME, + new com.linkedin.entity.EnvelopedAspect().setValue(new Aspect(expectedInfo.data()))); + + final Map> criterionMap = new HashMap<>(); + criterionMap.put( + INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, ImmutableList.of(datasetUrn.toString())); + criterionMap.put( + INCIDENT_ASSIGNEES_SEARCH_INDEX_FIELD_NAME, ImmutableList.of(userUrn.toString())); + criterionMap.put(INCIDENT_PRIORITY_SEARCH_INDEX_FIELD_NAME, ImmutableList.of("0")); + criterionMap.put( + INCIDENT_STAGE_SEARCH_INDEX_FIELD_NAME, ImmutableList.of(IncidentStage.TRIAGE.toString())); + criterionMap.put( + INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME, ImmutableList.of(IncidentState.ACTIVE.toString())); + + Filter expectedFilter = QueryUtils.newListsFilter(criterionMap); + + SortCriterion expectedSort = new SortCriterion(); + expectedSort.setField(CREATED_TIME_SEARCH_INDEX_FIELD_NAME); + expectedSort.setOrder(SortOrder.DESCENDING); + + Mockito.when( + mockClient.filter( + Mockito.any(), + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(expectedFilter), + Mockito.eq(Collections.singletonList(expectedSort)), + Mockito.eq(0), + Mockito.eq(10))) + .thenReturn( + new SearchResult() + .setFrom(0) + .setPageSize(1) + .setNumEntities(1) + .setEntities( + new SearchEntityArray( + ImmutableSet.of(new SearchEntity().setEntity(incidentUrn))))); + + Mockito.when( + mockClient.batchGetV2( + any(OperationContext.class), + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(ImmutableSet.of(incidentUrn)), + Mockito.eq(null))) + .thenReturn( + ImmutableMap.of( + incidentUrn, + new EntityResponse() + .setEntityName(Constants.INCIDENT_ENTITY_NAME) + .setUrn(incidentUrn) + .setAspects(new EnvelopedAspectMap(incidentAspects)))); + + EntityIncidentsResolver resolver = new EntityIncidentsResolver(mockClient); + + // Execute resolver + QueryContext mockContext = Mockito.mock(QueryContext.class); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + Mockito.when(mockContext.getOperationContext()).thenReturn(mock(OperationContext.class)); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + Mockito.when(mockEnv.getArgumentOrDefault(Mockito.eq("start"), Mockito.eq(0))).thenReturn(0); + Mockito.when(mockEnv.getArgumentOrDefault(Mockito.eq("count"), Mockito.eq(20))).thenReturn(10); + Mockito.when(mockEnv.getArgument(Mockito.eq("state"))) + .thenReturn(IncidentState.ACTIVE.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("stage"))) + .thenReturn(IncidentStage.TRIAGE.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("assigneeUrns"))) + .thenReturn(ImmutableList.of(userUrn.toString())); + Mockito.when(mockEnv.getArgument(Mockito.eq("priority"))) + .thenReturn(IncidentPriority.CRITICAL.toString()); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Dataset parentEntity = new Dataset(); + parentEntity.setUrn(datasetUrn.toString()); + Mockito.when(mockEnv.getSource()).thenReturn(parentEntity); + + EntityIncidentsResult result = resolver.get(mockEnv).get(); + + // Assert that GraphQL Incident run event matches expectations + assertEquals(result.getStart(), 0); + assertEquals(result.getCount(), 1); + assertEquals(result.getTotal(), 1); + + com.linkedin.datahub.graphql.generated.Incident incident = + resolver.get(mockEnv).get().getIncidents().get(0); + assertEquals(incident.getUrn(), incidentUrn.toString()); + assertEquals(incident.getType(), EntityType.INCIDENT); + assertEquals(incident.getIncidentType().toString(), expectedInfo.getType().toString()); + assertEquals(incident.getTitle(), expectedInfo.getTitle()); + assertEquals(incident.getDescription(), expectedInfo.getDescription()); + assertEquals(incident.getCustomType(), expectedInfo.getCustomType()); + assertEquals( + incident.getStatus().getState().toString(), expectedInfo.getStatus().getState().toString()); + assertEquals( + incident.getStatus().getStage().toString(), expectedInfo.getStatus().getStage().toString()); + assertEquals(incident.getPriority(), IncidentPriority.CRITICAL); + assertEquals(incident.getAssignees().size(), 1); + assertEquals( + ((CorpUser) incident.getAssignees().get(0)).getUrn(), + expectedInfo.getAssignees().get(0).getActor().toString()); + assertEquals(incident.getStatus().getMessage(), expectedInfo.getStatus().getMessage()); + assertEquals( + incident.getStatus().getLastUpdated().getTime(), + expectedInfo.getStatus().getLastUpdated().getTime()); + assertEquals( + incident.getStatus().getLastUpdated().getActor(), + expectedInfo.getStatus().getLastUpdated().getActor().toString()); + assertEquals( + incident.getSource().getType().toString(), expectedInfo.getSource().getType().toString()); + assertEquals( + incident.getSource().getSource().getUrn(), + expectedInfo.getSource().getSourceUrn().toString()); + assertEquals(incident.getCreated().getActor(), expectedInfo.getCreated().getActor().toString()); + assertEquals(incident.getCreated().getTime(), expectedInfo.getCreated().getTime()); + } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentInfoMatcher.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentInfoMatcher.java new file mode 100644 index 0000000000000..2a8ff52365cc6 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentInfoMatcher.java @@ -0,0 +1,112 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import com.linkedin.incident.IncidentInfo; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.GenericAspect; +import com.linkedin.mxe.MetadataChangeProposal; +import org.mockito.ArgumentMatcher; + +public class IncidentInfoMatcher implements ArgumentMatcher { + + private MetadataChangeProposal left; + + public IncidentInfoMatcher(MetadataChangeProposal left) { + this.left = left; + } + + @Override + public boolean matches(MetadataChangeProposal right) { + return left.getEntityType().equals(right.getEntityType()) + && left.getAspectName().equals(right.getAspectName()) + && left.getChangeType().equals(right.getChangeType()) + && incidentInfoMatches(left.getAspect(), right.getAspect()); + } + + private boolean incidentInfoMatches(GenericAspect left, GenericAspect right) { + IncidentInfo leftProps = + GenericRecordUtils.deserializeAspect( + left.getValue(), "application/json", IncidentInfo.class); + + IncidentInfo rightProps = + GenericRecordUtils.deserializeAspect( + right.getValue(), "application/json", IncidentInfo.class); + + // Verify optional fields. + if (leftProps.hasDescription()) { + if (!leftProps.getDescription().equals(rightProps.getDescription())) { + return false; + } + } + + if (leftProps.hasTitle()) { + if (!leftProps.getTitle().equals(rightProps.getTitle())) { + return false; + } + } + + if (leftProps.hasType()) { + if (!leftProps.getType().equals(rightProps.getType())) { + return false; + } + } + + if (leftProps.hasAssignees() && leftProps.getAssignees().size() > 0) { + if (!((Integer) leftProps.getAssignees().size()).equals(rightProps.getAssignees().size())) { + return false; + } + // Just verify the first mapping + if (!leftProps + .getAssignees() + .get(0) + .getActor() + .equals(rightProps.getAssignees().get(0).getActor())) { + return false; + } + } + + if (leftProps.hasPriority()) { + if (!leftProps.getPriority().equals(rightProps.getPriority())) { + return false; + } + } + + if (leftProps.hasEntities()) { + if (!leftProps.getEntities().equals(rightProps.getEntities())) { + return false; + } + } + + if (leftProps.getStatus().hasStage()) { + if (!leftProps.getStatus().getStage().equals(rightProps.getStatus().getStage())) { + return false; + } + } + + if (leftProps.getStatus().hasState()) { + if (!leftProps.getStatus().getState().equals(rightProps.getStatus().getState())) { + return false; + } + } + + if (leftProps.getStatus().hasMessage()) { + if (!leftProps.getStatus().getMessage().equals(rightProps.getStatus().getMessage())) { + return false; + } + } + + if (leftProps.getSource().hasType()) { + if (!leftProps.getSource().getType().equals(rightProps.getSource().getType())) { + return false; + } + } + + if (leftProps.getSource().hasSourceUrn()) { + if (!leftProps.getSource().getSourceUrn().equals(rightProps.getSource().getSourceUrn())) { + return false; + } + } + + // All fields match! + return true; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtilsTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtilsTest.java new file mode 100644 index 0000000000000..0caf0fdcb5428 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/IncidentUtilsTest.java @@ -0,0 +1,106 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static org.testng.Assert.*; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.generated.IncidentPriority; +import com.linkedin.datahub.graphql.generated.IncidentStatusInput; +import com.linkedin.incident.IncidentAssigneeArray; +import com.linkedin.incident.IncidentStage; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.testng.annotations.Test; + +public class IncidentUtilsTest { + + private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:test"); + + @Test + public void testMapIncidentPriorityWithNull() { + assertNull(IncidentUtils.mapIncidentPriority(null)); + } + + @Test + public void testMapIncidentPriorityWithLow() { + assertEquals(IncidentUtils.mapIncidentPriority(IncidentPriority.LOW), Integer.valueOf(3)); + } + + @Test + public void testMapIncidentPriorityWithMedium() { + assertEquals(IncidentUtils.mapIncidentPriority(IncidentPriority.MEDIUM), Integer.valueOf(2)); + } + + @Test + public void testMapIncidentPriorityWithHigh() { + assertEquals(IncidentUtils.mapIncidentPriority(IncidentPriority.HIGH), Integer.valueOf(1)); + } + + @Test + public void testMapIncidentPriorityWithCritical() { + assertEquals(IncidentUtils.mapIncidentPriority(IncidentPriority.CRITICAL), Integer.valueOf(0)); + } + + @Test + public void testMapIncidentAssigneesWithNullAssignees() { + AuditStamp stamp = new AuditStamp(); + stamp.setActor(TEST_USER_URN); + stamp.setTime(System.currentTimeMillis()); + assertNull(IncidentUtils.mapIncidentAssignees(null, stamp)); + } + + @Test + public void testMapIncidentAssigneesWithEmptyAssignees() { + AuditStamp stamp = new AuditStamp(); + stamp.setActor(TEST_USER_URN); + stamp.setTime(System.currentTimeMillis()); + IncidentAssigneeArray result = + IncidentUtils.mapIncidentAssignees(Collections.emptyList(), stamp); + assertTrue(result.isEmpty()); + } + + @Test + public void testMapIncidentAssigneesWithValidAssignees() { + AuditStamp stamp = new AuditStamp(); + stamp.setActor(TEST_USER_URN); + stamp.setTime(System.currentTimeMillis()); + List assignees = Arrays.asList("urn:li:corpuser:1", "urn:li:corpuser:2"); + IncidentAssigneeArray result = IncidentUtils.mapIncidentAssignees(assignees, stamp); + assertNotNull(result); + assertEquals(result.size(), 2); + assertEquals(result.get(0).getActor().toString(), "urn:li:corpuser:1"); + assertEquals(result.get(1).getActor().toString(), "urn:li:corpuser:2"); + } + + @Test + public void testMapIncidentStatusWithNullInput() { + AuditStamp stamp = new AuditStamp(); + stamp.setActor(TEST_USER_URN); + stamp.setTime(System.currentTimeMillis()); + IncidentStatus status = IncidentUtils.mapIncidentStatus(null, stamp); + assertNotNull(status); + assertEquals(status.getState(), IncidentState.ACTIVE); + assertEquals(status.getLastUpdated(), stamp); + } + + @Test + public void testMapIncidentStatusWithValidInput() { + IncidentStatusInput input = new IncidentStatusInput(); + input.setState(com.linkedin.datahub.graphql.generated.IncidentState.RESOLVED); + input.setStage(com.linkedin.datahub.graphql.generated.IncidentStage.INVESTIGATION); + input.setMessage("Issue resolved"); + + AuditStamp stamp = new AuditStamp(); + stamp.setActor(TEST_USER_URN); + stamp.setTime(System.currentTimeMillis()); + IncidentStatus status = IncidentUtils.mapIncidentStatus(input, stamp); + + assertEquals(status.getState(), IncidentState.RESOLVED); + assertEquals(status.getStage(), IncidentStage.INVESTIGATION); + assertEquals(status.getMessage(), "Issue resolved"); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolverTest.java new file mode 100644 index 0000000000000..b8ddf30a259ad --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolverTest.java @@ -0,0 +1,225 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.metadata.Constants.INCIDENT_INFO_ASPECT_NAME; +import static org.mockito.ArgumentMatchers.any; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.IncidentPriority; +import com.linkedin.datahub.graphql.generated.RaiseIncidentInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentAssignee; +import com.linkedin.incident.IncidentAssigneeArray; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentStage; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.entity.AspectUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class RaiseIncidentResolverTest { + + private static final Urn TEST_INCIDENT_URN = UrnUtils.getUrn("urn:li:incident:TEST"); + + @Test + public void testGetSuccessAllFields() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RaiseIncidentInput testInput = new RaiseIncidentInput(); + testInput.setTitle("Title"); + testInput.setDescription("Description"); + testInput.setType(com.linkedin.datahub.graphql.generated.IncidentType.SQL); + testInput.setResourceUrn("urn:li:dataset:(test,test,test)"); + Long incidentStartedAtMillis = System.currentTimeMillis(); + testInput.setStartedAt(incidentStartedAtMillis); + testInput.setStatus( + new com.linkedin.datahub.graphql.generated.IncidentStatusInput( + com.linkedin.datahub.graphql.generated.IncidentState.ACTIVE, + com.linkedin.datahub.graphql.generated.IncidentStage.INVESTIGATION, + "Message")); + testInput.setAssigneeUrns(ImmutableList.of("urn:li:corpuser:test")); + testInput.setSource( + new com.linkedin.datahub.graphql.generated.IncidentSourceInput( + com.linkedin.datahub.graphql.generated.IncidentSourceType.MANUAL)); + testInput.setPriority(IncidentPriority.CRITICAL); + + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + RaiseIncidentResolver resolver = new RaiseIncidentResolver(mockClient); + String result = resolver.get(mockEnv).get(); + + Assert.assertEquals(result, TEST_INCIDENT_URN.toString()); + + IncidentInfo expectedInfo = new IncidentInfo(); + expectedInfo.setTitle("Title"); + expectedInfo.setDescription("Description"); + expectedInfo.setType(IncidentType.SQL); + expectedInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test)")))); + expectedInfo.setStartedAt(incidentStartedAtMillis); + expectedInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.INVESTIGATION) + .setMessage("Message")); + expectedInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp())))); + expectedInfo.setPriority(0); + expectedInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + // Verify entity client + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(OperationContext.class), + Mockito.argThat( + new IncidentInfoMatcher( + AspectUtils.buildMetadataChangeProposal( + TEST_INCIDENT_URN, INCIDENT_INFO_ASPECT_NAME, expectedInfo))), + Mockito.anyBoolean()); + } + + @Test + public void testCustomTypeRequired() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RaiseIncidentInput testInput = new RaiseIncidentInput(); + testInput.setType(com.linkedin.datahub.graphql.generated.IncidentType.CUSTOM); + testInput.setResourceUrn("urn:li:dataset:(test,test,test)"); + testInput.setTitle("Title"); + testInput.setDescription("Description"); + + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + RaiseIncidentResolver resolver = new RaiseIncidentResolver(mockClient); + + try { + resolver.get(mockEnv).get(); + Assert.fail("Expected exception was not thrown"); + } catch (ExecutionException e) { + Assert.assertEquals( + "customType is required: Failed to create incident.", e.getCause().getMessage()); + } + } + + @Test + public void testGetFailRequiredFields() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RaiseIncidentInput testInput = new RaiseIncidentInput(); + testInput.setType(com.linkedin.datahub.graphql.generated.IncidentType.SQL); + testInput.setResourceUrns(Collections.emptyList()); + + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + RaiseIncidentResolver resolver = new RaiseIncidentResolver(mockClient); + Exception exception = + Assert.expectThrows( + RuntimeException.class, + () -> { + resolver.get(mockEnv).get(); + }); + + Assert.assertEquals( + exception.getMessage(), "At least 1 resource urn must be defined to raise an incident."); + } + + @Test + public void testGetSuccessRequiredFields() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + RaiseIncidentInput testInput = new RaiseIncidentInput(); + testInput.setType(com.linkedin.datahub.graphql.generated.IncidentType.SQL); + testInput.setResourceUrn("urn:li:dataset:(test,test,test)"); + testInput.setResourceUrns( + List.of( + "urn:li:dataset:(test,test,test)", + "urn:li:dataset:(test,test,test2)", + "urn:li:dataset:(test,test,test3)")); + + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + RaiseIncidentResolver resolver = new RaiseIncidentResolver(mockClient); + String result = resolver.get(mockEnv).get(); + + Assert.assertEquals(result, TEST_INCIDENT_URN.toString()); + + IncidentInfo expectedInfo = new IncidentInfo(); + expectedInfo.setType(IncidentType.SQL); + expectedInfo.setEntities( + new UrnArray( + IncidentUtils.stringsToUrns( + ImmutableList.of( + "urn:li:dataset:(test,test,test)", + "urn:li:dataset:(test,test,test2)", + "urn:li:dataset:(test,test,test3)")))); + expectedInfo.setStatus(new IncidentStatus().setState(IncidentState.ACTIVE)); + expectedInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + // Verify entity client + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(OperationContext.class), + Mockito.argThat( + new IncidentInfoMatcher( + AspectUtils.buildMetadataChangeProposal( + TEST_INCIDENT_URN, INCIDENT_INFO_ASPECT_NAME, expectedInfo))), + Mockito.anyBoolean()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolverTest.java new file mode 100644 index 0000000000000..cf31e877a7e0f --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolverTest.java @@ -0,0 +1,278 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.metadata.Constants.INCIDENT_INFO_ASPECT_NAME; +import static org.mockito.ArgumentMatchers.any; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.IncidentPriority; +import com.linkedin.datahub.graphql.generated.UpdateIncidentInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentAssignee; +import com.linkedin.incident.IncidentAssigneeArray; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentStage; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.entity.AspectUtils; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.List; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class UpdateIncidentResolverTest { + + private static final Urn TEST_INCIDENT_URN = UrnUtils.getUrn("urn:li:incident:TEST"); + + @Test + public void testGetSuccessAllFields() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + IncidentInfo existingInfo = new IncidentInfo(); + existingInfo.setTitle("Title"); + existingInfo.setDescription("Description"); + existingInfo.setType(IncidentType.SQL); + existingInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test)")))); + existingInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.INVESTIGATION) + .setMessage("Message")); + existingInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp())))); + existingInfo.setPriority(0); + existingInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + EntityService mockEntityService = Mockito.mock(EntityService.class); + Mockito.when( + mockEntityService.getAspect( + any(OperationContext.class), + Mockito.eq(TEST_INCIDENT_URN), + Mockito.eq(INCIDENT_INFO_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(existingInfo); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + UpdateIncidentInput testInput = new UpdateIncidentInput(); + testInput.setTitle("New Title"); + testInput.setDescription("New Description"); + final Long incidentStartedAtNew = 10L; + testInput.setStartedAt(incidentStartedAtNew); + testInput.setStatus( + new com.linkedin.datahub.graphql.generated.IncidentStatusInput( + com.linkedin.datahub.graphql.generated.IncidentState.RESOLVED, + com.linkedin.datahub.graphql.generated.IncidentStage.FIXED, + "Message 2")); + testInput.setAssigneeUrns(ImmutableList.of("urn:li:corpuser:test", "urn:li:corpuser:test2")); + testInput.setPriority(IncidentPriority.LOW); + testInput.setResourceUrns(List.of("urn:li:dataset:(test,test,test2)")); + + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_INCIDENT_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + UpdateIncidentResolver resolver = new UpdateIncidentResolver(mockClient, mockEntityService); + Boolean result = resolver.get(mockEnv).get(); + + Assert.assertTrue(result); + + IncidentInfo expectedInfo = new IncidentInfo(); + expectedInfo.setTitle("New Title"); + expectedInfo.setDescription("New Description"); + expectedInfo.setType(IncidentType.SQL); + expectedInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test2)")))); + expectedInfo.setStartedAt(incidentStartedAtNew); + expectedInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.RESOLVED) + .setStage(IncidentStage.FIXED) + .setMessage("Message 2")); + expectedInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp()), + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test2")) + .setAssignedAt(new AuditStamp())))); + expectedInfo.setPriority(3); + expectedInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + // Verify entity client + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(OperationContext.class), + Mockito.argThat( + new IncidentInfoMatcher( + AspectUtils.buildMetadataChangeProposal( + TEST_INCIDENT_URN, INCIDENT_INFO_ASPECT_NAME, expectedInfo))), + Mockito.anyBoolean()); + } + + @Test + public void testGetSuccessRequiredFields() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + IncidentInfo existingInfo = new IncidentInfo(); + existingInfo.setTitle("Title"); + existingInfo.setDescription("Description"); + existingInfo.setType(IncidentType.SQL); + existingInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test)")))); + existingInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.INVESTIGATION) + .setMessage("Message")); + existingInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp())))); + existingInfo.setPriority(0); + existingInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + EntityService mockEntityService = Mockito.mock(EntityService.class); + Mockito.when( + mockEntityService.getAspect( + any(OperationContext.class), + Mockito.eq(TEST_INCIDENT_URN), + Mockito.eq(INCIDENT_INFO_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(existingInfo); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + UpdateIncidentInput testInput = new UpdateIncidentInput(); + + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_INCIDENT_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + UpdateIncidentResolver resolver = new UpdateIncidentResolver(mockClient, mockEntityService); + Boolean result = resolver.get(mockEnv).get(); + + Assert.assertTrue(result); + + IncidentInfo expectedInfo = new IncidentInfo(); + expectedInfo.setTitle("Title"); + expectedInfo.setDescription("Description"); + expectedInfo.setType(IncidentType.SQL); + expectedInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test)")))); + expectedInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.INVESTIGATION) + .setMessage("Message")); + expectedInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp())))); + expectedInfo.setPriority(0); + expectedInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + // Verify entity client + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(OperationContext.class), + Mockito.argThat( + new IncidentInfoMatcher( + AspectUtils.buildMetadataChangeProposal( + TEST_INCIDENT_URN, INCIDENT_INFO_ASPECT_NAME, expectedInfo))), + Mockito.anyBoolean()); + } + + @Test + public void testGetFailureIncidentDoesNotExist() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.when( + mockClient.ingestProposal( + any(OperationContext.class), + Mockito.any(MetadataChangeProposal.class), + Mockito.anyBoolean())) + .thenReturn(TEST_INCIDENT_URN.toString()); + + IncidentInfo existingInfo = new IncidentInfo(); + existingInfo.setTitle("Title"); + existingInfo.setDescription("Description"); + existingInfo.setType(IncidentType.SQL); + existingInfo.setEntities( + new UrnArray(ImmutableList.of(UrnUtils.getUrn("urn:li:dataset:(test,test,test)")))); + existingInfo.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setStage(IncidentStage.INVESTIGATION) + .setMessage("Message")); + existingInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(new AuditStamp())))); + existingInfo.setPriority(0); + existingInfo.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL)); + + EntityService mockEntityService = Mockito.mock(EntityService.class); + Mockito.when( + mockEntityService.getAspect( + any(OperationContext.class), + Mockito.eq(TEST_INCIDENT_URN), + Mockito.eq(INCIDENT_INFO_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + UpdateIncidentInput testInput = new UpdateIncidentInput(); + + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_INCIDENT_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(testInput); + + UpdateIncidentResolver resolver = new UpdateIncidentResolver(mockClient, mockEntityService); + + Assert.assertThrows(() -> resolver.get(mockEnv).get()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java index 0e2b78a9368ee..1522ab067fa95 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java @@ -2,24 +2,33 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.linkedin.common.AuditStamp; import com.linkedin.common.GlobalTags; import com.linkedin.common.TagAssociationArray; -import com.linkedin.common.UrnArray; import com.linkedin.common.urn.TagUrn; import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.SetMode; +import com.linkedin.datahub.graphql.generated.CorpGroup; +import com.linkedin.datahub.graphql.generated.CorpUser; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.datahub.graphql.generated.IncidentPriority; import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.incident.IncidentAssignee; +import com.linkedin.incident.IncidentAssigneeArray; import com.linkedin.incident.IncidentInfo; import com.linkedin.incident.IncidentSource; import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentStage; import com.linkedin.incident.IncidentState; import com.linkedin.incident.IncidentStatus; import com.linkedin.incident.IncidentType; @@ -31,6 +40,7 @@ public class IncidentMapperTest { @Test public void testMap() throws Exception { + // This is your existing full test that sets up many fields. EntityResponse entityResponse = new EntityResponse(); Urn urn = Urn.createFromString("urn:li:incident:1"); Urn userUrn = Urn.createFromString("urn:li:corpuser:test"); @@ -39,25 +49,40 @@ public void testMap() throws Exception { EnvelopedAspect envelopedIncidentInfo = new EnvelopedAspect(); IncidentInfo incidentInfo = new IncidentInfo(); - incidentInfo.setType(IncidentType.OPERATIONAL); + + AuditStamp lastStatus = new AuditStamp(); + lastStatus.setTime(1000L); + lastStatus.setActor(userUrn); + incidentInfo.setCreated(lastStatus); + + incidentInfo.setType(IncidentType.FIELD); incidentInfo.setCustomType("Custom Type"); incidentInfo.setTitle("Test Incident", SetMode.IGNORE_NULL); incidentInfo.setDescription("This is a test incident", SetMode.IGNORE_NULL); + // Set priority HIGH (1 -> HIGH) incidentInfo.setPriority(1, SetMode.IGNORE_NULL); - incidentInfo.setEntities(new UrnArray(Collections.singletonList(urn))); + incidentInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(lastStatus), + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpGroup:test2")) + .setAssignedAt(lastStatus)))); + // TODO: Support multiple entities per incident. + incidentInfo.setEntities(new com.linkedin.common.UrnArray(Collections.singletonList(urn))); + Long incidentStartedAt = 10L; + incidentInfo.setStartedAt(incidentStartedAt); IncidentSource source = new IncidentSource(); - source.setType(IncidentSourceType.MANUAL); + source.setType(IncidentSourceType.ASSERTION_FAILURE); source.setSourceUrn(assertionUrn); incidentInfo.setSource(source); - AuditStamp lastStatus = new AuditStamp(); - lastStatus.setTime(1000L); - lastStatus.setActor(userUrn); - incidentInfo.setCreated(lastStatus); - IncidentStatus status = new IncidentStatus(); status.setState(IncidentState.ACTIVE); + status.setStage(IncidentStage.INVESTIGATION); status.setLastUpdated(lastStatus); status.setMessage("This incident is open.", SetMode.IGNORE_NULL); incidentInfo.setStatus(status); @@ -73,10 +98,9 @@ public void testMap() throws Exception { GlobalTags tags = new GlobalTags(); tags.setTags( new TagAssociationArray( - new TagAssociationArray( - Collections.singletonList( - new com.linkedin.common.TagAssociation() - .setTag(TagUrn.createFromString("urn:li:tag:test")))))); + ImmutableList.of( + new com.linkedin.common.TagAssociation() + .setTag(TagUrn.createFromString("urn:li:tag:test"))))); envelopedTagsAspect.setValue(new Aspect(tags.data())); entityResponse.setAspects( @@ -93,25 +117,193 @@ public void testMap() throws Exception { assertEquals(incident.getCustomType(), "Custom Type"); assertEquals( incident.getIncidentType().toString(), - com.linkedin.datahub.graphql.generated.IncidentType.OPERATIONAL.toString()); + com.linkedin.datahub.graphql.generated.IncidentType.FIELD.toString()); assertEquals(incident.getTitle(), "Test Incident"); assertEquals(incident.getDescription(), "This is a test incident"); - assertEquals(incident.getPriority().intValue(), 1); + assertEquals(incident.getPriority(), IncidentPriority.HIGH); + assertEquals(incident.getStartedAt(), incidentStartedAt); assertEquals( incident.getSource().getType().toString(), - com.linkedin.datahub.graphql.generated.IncidentSourceType.MANUAL.toString()); + com.linkedin.datahub.graphql.generated.IncidentSourceType.ASSERTION_FAILURE.toString()); assertEquals(incident.getSource().getSource().getUrn(), assertionUrn.toString()); assertEquals( incident.getStatus().getState().toString(), com.linkedin.datahub.graphql.generated.IncidentState.ACTIVE.toString()); + assertEquals( + incident.getStatus().getStage().toString(), + com.linkedin.datahub.graphql.generated.IncidentStage.INVESTIGATION.toString()); assertEquals(incident.getStatus().getMessage(), "This incident is open."); assertEquals(incident.getStatus().getLastUpdated().getTime().longValue(), 1000L); assertEquals(incident.getStatus().getLastUpdated().getActor(), userUrn.toString()); assertEquals(incident.getCreated().getTime().longValue(), 1000L); assertEquals(incident.getCreated().getActor(), userUrn.toString()); + assertEquals(incident.getAssignees().size(), 2); + assertEquals(((CorpUser) incident.getAssignees().get(0)).getUrn(), "urn:li:corpuser:test"); + assertEquals(((CorpGroup) incident.getAssignees().get(1)).getUrn(), "urn:li:corpGroup:test2"); assertEquals(incident.getTags().getTags().size(), 1); assertEquals( incident.getTags().getTags().get(0).getTag().getUrn().toString(), "urn:li:tag:test"); } + + // --- Additional tests for priority mapping --- + + @Test + public void testMappingPriorityLow() throws Exception { + // Priority 3 should map to LOW + EntityResponse entityResponse = createBaseEntityResponse(); + setIncidentPriority(entityResponse, 3); + Incident incident = IncidentMapper.map(null, entityResponse); + assertEquals( + incident.getPriority(), IncidentPriority.LOW, "Priority 3 should be mapped to LOW"); + } + + @Test + public void testMappingPriorityMedium() throws Exception { + // Priority 2 should map to MEDIUM + EntityResponse entityResponse = createBaseEntityResponse(); + setIncidentPriority(entityResponse, 2); + Incident incident = IncidentMapper.map(null, entityResponse); + assertEquals( + incident.getPriority(), IncidentPriority.MEDIUM, "Priority 2 should be mapped to MEDIUM"); + } + + @Test + public void testMappingPriorityHigh() throws Exception { + // Priority 1 should map to HIGH + EntityResponse entityResponse = createBaseEntityResponse(); + setIncidentPriority(entityResponse, 1); + Incident incident = IncidentMapper.map(null, entityResponse); + assertEquals( + incident.getPriority(), IncidentPriority.HIGH, "Priority 1 should be mapped to HIGH"); + } + + @Test + public void testMappingPriorityCritical() throws Exception { + // Priority 0 should map to CRITICAL + EntityResponse entityResponse = createBaseEntityResponse(); + setIncidentPriority(entityResponse, 0); + Incident incident = IncidentMapper.map(null, entityResponse); + assertEquals( + incident.getPriority(), + IncidentPriority.CRITICAL, + "Priority 0 should be mapped to CRITICAL"); + } + + @Test + public void testMappingInvalidPriority() throws Exception { + // An invalid priority (e.g., 5) should result in a null mapping. + EntityResponse entityResponse = createBaseEntityResponse(); + setIncidentPriority(entityResponse, 5); + Incident incident = IncidentMapper.map(null, entityResponse); + assertNull( + incident.getPriority(), "Invalid priority value should result in a null priority mapping"); + } + + // --- Additional tests for assignee mapping (CorpUser vs CorpGroup) --- + + @Test + public void testMappingAssigneesMapping() throws Exception { + // Create an incident with one corp user and one corp group + EntityResponse entityResponse = createBaseEntityResponse(); + EnvelopedAspect incidentAspect = + entityResponse.getAspects().get(Constants.INCIDENT_INFO_ASPECT_NAME); + IncidentInfo incidentInfo = new IncidentInfo(incidentAspect.getValue().data()); + + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(2000L); + // Set explicit assignees + IncidentAssignee corpUserAssignee = + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:testUser")) + .setAssignedAt(auditStamp); + IncidentAssignee corpGroupAssignee = + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpGroup:testGroup")) + .setAssignedAt(auditStamp); + incidentInfo.setAssignees( + new IncidentAssigneeArray(ImmutableList.of(corpUserAssignee, corpGroupAssignee))); + incidentAspect.setValue(new Aspect(incidentInfo.data())); + + Incident incident = IncidentMapper.map(null, entityResponse); + assertNotNull(incident.getAssignees()); + assertEquals(incident.getAssignees().size(), 2); + assertTrue( + incident.getAssignees().get(0) instanceof CorpUser, + "Expected first assignee to be a CorpUser"); + assertTrue( + incident.getAssignees().get(1) instanceof CorpGroup, + "Expected second assignee to be a CorpGroup"); + assertEquals(((CorpUser) incident.getAssignees().get(0)).getUrn(), "urn:li:corpuser:testUser"); + assertEquals( + ((CorpGroup) incident.getAssignees().get(1)).getUrn(), "urn:li:corpGroup:testGroup"); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testMappingInvalidAssignee() throws Exception { + // Create an incident with an invalid assignee type. + EntityResponse entityResponse = createBaseEntityResponse(); + EnvelopedAspect incidentAspect = + entityResponse.getAspects().get(Constants.INCIDENT_INFO_ASPECT_NAME); + IncidentInfo incidentInfo = new IncidentInfo(incidentAspect.getValue().data()); + + // Use an actor URN that does not correspond to a corp user or corp group. + IncidentAssignee invalidAssignee = + new IncidentAssignee().setActor(UrnUtils.getUrn("urn:li:invalid:entity")); + incidentInfo.setAssignees(new IncidentAssigneeArray(ImmutableList.of(invalidAssignee))); + incidentAspect.setValue(new Aspect(incidentInfo.data())); + + // This call should throw IllegalArgumentException. + IncidentMapper.map(null, entityResponse); + } + + // --- Helper methods for creating a minimal base EntityResponse --- + + /** Creates a minimal EntityResponse with a basic IncidentInfo aspect. */ + private EntityResponse createBaseEntityResponse() throws Exception { + EntityResponse entityResponse = new EntityResponse(); + Urn urn = Urn.createFromString("urn:li:incident:1"); + Urn userUrn = Urn.createFromString("urn:li:corpuser:test"); + entityResponse.setUrn(urn); + + EnvelopedAspect envelopedIncidentInfo = new EnvelopedAspect(); + IncidentInfo incidentInfo = new IncidentInfo(); + + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(1000L); + auditStamp.setActor(userUrn); + incidentInfo.setCreated(auditStamp); + incidentInfo.setType(IncidentType.FIELD); + incidentInfo.setTitle("Base Incident", SetMode.IGNORE_NULL); + incidentInfo.setDescription("Base incident description", SetMode.IGNORE_NULL); + // Default priority; tests will override this. + incidentInfo.setPriority(1, SetMode.IGNORE_NULL); + // Provide a default assignee (corp user) so mapping works. + incidentInfo.setAssignees( + new IncidentAssigneeArray( + ImmutableList.of( + new IncidentAssignee() + .setActor(UrnUtils.getUrn("urn:li:corpuser:test")) + .setAssignedAt(auditStamp)))); + incidentInfo.setEntities(new com.linkedin.common.UrnArray(Collections.singletonList(urn))); + + envelopedIncidentInfo.setValue(new Aspect(incidentInfo.data())); + + EnvelopedAspectMap aspects = + new EnvelopedAspectMap( + ImmutableMap.of(Constants.INCIDENT_INFO_ASPECT_NAME, envelopedIncidentInfo)); + entityResponse.setAspects(aspects); + return entityResponse; + } + + /** + * Updates the priority value on the IncidentInfo aspect contained in the given EntityResponse. + */ + private void setIncidentPriority(EntityResponse entityResponse, int priority) throws Exception { + EnvelopedAspect incidentAspect = + entityResponse.getAspects().get(Constants.INCIDENT_INFO_ASPECT_NAME); + IncidentInfo incidentInfo = new IncidentInfo(incidentAspect.getValue().data()); + incidentInfo.setPriority(priority, SetMode.IGNORE_NULL); + incidentAspect.setValue(new Aspect(incidentInfo.data())); + } } diff --git a/datahub-web-react/src/graphql/incident.graphql b/datahub-web-react/src/graphql/incident.graphql index d8a6d4fd4305f..1ea8c09a5006b 100644 --- a/datahub-web-react/src/graphql/incident.graphql +++ b/datahub-web-react/src/graphql/incident.graphql @@ -9,6 +9,7 @@ fragment incidentsFields on EntityIncidentsResult { customType title description + startedAt status { state message diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index 339e9ce5a644f..4d5afc95f6229 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -106,7 +106,7 @@ mutation raiseIncident($input: RaiseIncidentInput!) { raiseIncident(input: $input) } -mutation updateIncidentStatus($urn: String!, $input: UpdateIncidentStatusInput!) { +mutation updateIncidentStatus($urn: String!, $input: IncidentStatusInput!) { updateIncidentStatus(urn: $urn, input: $input) } diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index 1debd07f28b2f..c8fcb0021414f 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -24,6 +24,9 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - #12408: The `platform` field in the DataPlatformInstance GraphQL type is removed. Clients need to retrieve the platform via the optional `dataPlatformInstance` field. +- #12671: The `priority` field of the Incident entity is changed from an integer to an enum. This field was previously completely unused in UI and API, so this change should not affect existing deployments. + + ### Known Issues - #12601: Jetty 12 introduces a stricter handling of url encoding. We are currently applying a workaround to prevent a regression, while technically breaking the official specifications. diff --git a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHook.java b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHook.java index 5483fed9116e1..49a41a5d41f4d 100644 --- a/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHook.java +++ b/metadata-jobs/mae-consumer/src/main/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHook.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -106,7 +107,7 @@ public void invoke(@Nonnull final MetadataChangeLog event) { if (isIncidentSoftDeleted(event)) { handleIncidentSoftDeleted(urn); } else if (isIncidentUpdate(event)) { - handleIncidentUpdated(urn); + handleIncidentUpdated(event, urn); } } } @@ -137,15 +138,42 @@ private void handleIncidentSoftDeleted(@Nonnull final Urn incidentUrn) { } /** Handle an incident update by adding to either resolved or active incidents for an entity. */ - private void handleIncidentUpdated(@Nonnull final Urn incidentUrn) { + private void handleIncidentUpdated( + @Nonnull final MetadataChangeLog event, @Nonnull final Urn incidentUrn) { // 1. Fetch incident info + status - IncidentInfo incidentInfo = + final IncidentInfo incidentInfo = incidentService.getIncidentInfo(systemOperationContext, incidentUrn); + final IncidentInfo previousInfo; + if (event.getAspectName().equals(INCIDENT_INFO_ASPECT_NAME) + && event.getPreviousAspectValue() != null) { + previousInfo = + GenericRecordUtils.deserializeAspect( + event.getPreviousAspectValue().getValue(), + event.getPreviousAspectValue().getContentType(), + IncidentInfo.class); + } else { + previousInfo = null; + } + // 2. Retrieve associated urns. if (incidentInfo != null) { final List incidentEntities = incidentInfo.getEntities(); + // 3. If we have removed incidents from any entities, remove them from their respective + // summary aspects + if (previousInfo != null) { + final Set removedFromEntities = + previousInfo.getEntities().stream() + .filter(urn -> !incidentEntities.contains(urn)) + .collect(Collectors.toSet()); + if (!removedFromEntities.isEmpty()) { + for (Urn entityUrn : removedFromEntities) { + removeIncidentFromSummary(incidentUrn, entityUrn); + } + } + } + // 3. For each urn, resolve the entity incidents aspect and add to active or resolved // incidents. for (Urn entityUrn : incidentEntities) { diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHookTest.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHookTest.java index 8c2c7c4d4ce31..07caba449509d 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHookTest.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/incident/IncidentsSummaryHookTest.java @@ -53,10 +53,11 @@ public void setup() { @Test public void testInvokeNotEnabled() throws Exception { IncidentInfo incidentInfo = - mockIncidentInfo( - ImmutableList.of(TEST_DATASET_URN, TEST_DATASET_2_URN), IncidentState.ACTIVE); + mockIncidentInfo(ImmutableList.of(TEST_DATASET_URN), IncidentState.ACTIVE); + IncidentService service = mockIncidentService(new IncidentsSummary(), incidentInfo); IncidentsSummaryHook hook = new IncidentsSummaryHook(service, false, 100).init(opContext); + final MetadataChangeLog event = buildMetadataChangeLog( TEST_INCIDENT_URN, INCIDENT_INFO_ASPECT_NAME, ChangeType.UPSERT, incidentInfo); @@ -228,6 +229,75 @@ public void testInvokeIncidentRunEventResolved(IncidentsSummary summary) throws any(OperationContext.class), eq(TEST_DATASET_2_URN), eq(expectedSummary)); } + @Test(dataProvider = "incidentsSummaryBaseProvider") + public void testInvokeIncidentResourcesChanged(IncidentsSummary summary) throws Exception { + final IncidentInfo info = + mockIncidentInfo(ImmutableList.of(TEST_DATASET_URN), IncidentState.RESOLVED); + final IncidentInfo prevIncidentInfo = + mockIncidentInfo( + ImmutableList.of(TEST_DATASET_URN, TEST_DATASET_2_URN), IncidentState.ACTIVE); + + IncidentService service = mockIncidentService(summary, info); + IncidentsSummaryHook hook = new IncidentsSummaryHook(service, true, 100).init(opContext); + final MetadataChangeLog event = + buildMetadataChangeLog( + TEST_INCIDENT_URN, + INCIDENT_INFO_ASPECT_NAME, + ChangeType.UPSERT, + info, + prevIncidentInfo); + hook.invoke(event); + if (summary == null) { + summary = new IncidentsSummary(); + } + + // first we update dataset 2 + Mockito.verify(service, Mockito.times(1)) + .getIncidentsSummary(any(OperationContext.class), eq(TEST_DATASET_2_URN)); + // incident completely gone + IncidentsSummary expectedSummaryDataset2 = new IncidentsSummary(summary.data()); + expectedSummaryDataset2.setActiveIncidentDetails( + new IncidentSummaryDetailsArray( + expectedSummaryDataset2.getActiveIncidentDetails().stream() + .filter(details -> !details.getUrn().equals(TEST_INCIDENT_URN)) + .collect(Collectors.toList()))); + expectedSummaryDataset2.setResolvedIncidentDetails( + new IncidentSummaryDetailsArray( + expectedSummaryDataset2.getResolvedIncidentDetails().stream() + .filter(details -> !details.getUrn().equals(TEST_INCIDENT_URN)) + .collect(Collectors.toList()))); + + Mockito.verify(service, Mockito.times(1)) + .getIncidentInfo(any(OperationContext.class), eq(TEST_INCIDENT_URN)); + Mockito.verify(service, Mockito.times(1)) + .updateIncidentsSummary( + any(OperationContext.class), eq(TEST_DATASET_2_URN), eq(expectedSummaryDataset2)); + + // then we update dataset 1 + Mockito.verify(service, Mockito.times(1)) + .getIncidentsSummary(any(OperationContext.class), eq(TEST_DATASET_URN)); + + // incident in resolved list + IncidentsSummary expectedSummary = new IncidentsSummary(summary.data()); + expectedSummary.setActiveIncidentDetails( + new IncidentSummaryDetailsArray( + expectedSummary.getActiveIncidentDetails().stream() + .filter(details -> !details.getUrn().equals(TEST_INCIDENT_URN)) + .collect(Collectors.toList()))); + expectedSummary.setResolvedIncidentDetails( + new IncidentSummaryDetailsArray( + expectedSummary.getResolvedIncidentDetails().stream() + .filter(details -> !details.getUrn().equals(TEST_INCIDENT_URN)) + .collect(Collectors.toList()))); + expectedSummary + .getResolvedIncidentDetails() + .add(buildIncidentSummaryDetails(TEST_INCIDENT_URN, info)); + + Mockito.verify(service, Mockito.times(1)) + .updateIncidentsSummary( + any(OperationContext.class), eq(TEST_DATASET_URN), eq(expectedSummary)); + } + @Test(dataProvider = "incidentsSummaryBaseProvider") public void testInvokeIncidentSoftDeleted(IncidentsSummary summary) throws Exception { IncidentInfo info = @@ -328,12 +398,25 @@ private IncidentSummaryDetails buildIncidentSummaryDetails( private MetadataChangeLog buildMetadataChangeLog( Urn urn, String aspectName, ChangeType changeType, RecordTemplate aspect) throws Exception { + return buildMetadataChangeLog(urn, aspectName, changeType, aspect, null); + } + + private MetadataChangeLog buildMetadataChangeLog( + Urn urn, + String aspectName, + ChangeType changeType, + RecordTemplate aspect, + RecordTemplate prevAspect) + throws Exception { MetadataChangeLog event = new MetadataChangeLog(); event.setEntityUrn(urn); event.setEntityType(INCIDENT_ENTITY_NAME); event.setAspectName(aspectName); event.setChangeType(changeType); event.setAspect(GenericRecordUtils.serializeAspect(aspect)); + if (prevAspect != null) { + event.setPreviousAspectValue(GenericRecordUtils.serializeAspect(prevAspect)); + } return event; } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentAssignee.pdl b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentAssignee.pdl new file mode 100644 index 0000000000000..23cbbb1fca8ad --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentAssignee.pdl @@ -0,0 +1,26 @@ +namespace com.linkedin.incident + +import com.linkedin.common.Urn +import com.linkedin.common.AuditStamp + +/** + * The incident assignee type. + * This is in a record so that we can add additional fields if we need to later (e.g. + * the type of the assignee. + */ +record IncidentAssignee { + /** + * The user or group assigned to the incident. + */ + @Searchable = { + "addToFilters": true, + "filterNameOverride": "Assignee", + "fieldName": "assignees" + } + actor: Urn + + /** + * The time & actor responsible for assiging the assignee. + */ + assignedAt: AuditStamp +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentInfo.pdl index 44baff5270bed..073c6bf5afeae 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentInfo.pdl @@ -2,6 +2,7 @@ namespace com.linkedin.incident import com.linkedin.common.AuditStamp import com.linkedin.common.EntityReference +import com.linkedin.common.Time import com.linkedin.common.Urn /** @@ -57,6 +58,8 @@ record IncidentInfo { /** * A numeric severity or priority for the incident. On the UI we will translate this into something easy to understand. + * Currently supported: 0 - CRITICAL, 1 - HIGH, 2 - MED, 3 - LOW + * (We probably should have modeled as an enum) */ @Searchable = { "addToFilters": true, @@ -64,6 +67,11 @@ record IncidentInfo { } priority: optional int = 0 + /** + * The parties assigned with resolving the incident + */ + assignees: optional array[IncidentAssignee] + /** * The current status of an incident, i.e. active or inactive. */ @@ -74,6 +82,17 @@ record IncidentInfo { */ source: optional IncidentSource + /** + * The time at which the incident actually started (may be before the date it was raised). + */ + @Searchable = { + "/time": { + "fieldName": "startedAt", + "fieldType": "COUNT" + } + } + startedAt: optional Time + /** * The time at which the request was initially created */ diff --git a/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentStatus.pdl b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentStatus.pdl index a3548b3cda520..d2924b542e5a4 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentStatus.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/incident/IncidentStatus.pdl @@ -7,7 +7,7 @@ import com.linkedin.common.AuditStamp */ record IncidentStatus { /** - * The state of the incident + * The top-level state of the incident, whether it's active or resolved. */ @Searchable = { "addToFilters": true, @@ -24,6 +24,38 @@ record IncidentStatus { RESOLVED } + /** + * The lifecycle stage for the incident - Null means no stage was assigned yet. + * In the future, we may add CUSTOM here with a customStage string field for user-defined stages. + */ + @Searchable = { + "addToFilters": true, + "filterNameOverride": "Stage" + } + stage: optional enum IncidentStage { + /** + * The impact and priority of the incident is being actively assessed. + */ + TRIAGE + /** + * The incident root cause is being investigated. + */ + INVESTIGATION + /** + * The incident is in the remediation stage. + */ + WORK_IN_PROGRESS + /** + * The incident is in the resolved as completed stage. + */ + FIXED + /** + * The incident is in the resolved with no action required state, e.g. the + * incident was a false positive, or was expected. + */ + NO_ACTION_REQUIRED + } + /** * Optional message associated with the incident */ diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java b/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java index 16faced03bc0c..b06e5744f2aa8 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/search/utils/QueryUtils.java @@ -7,6 +7,7 @@ import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; +import com.linkedin.data.template.StringArray; import com.linkedin.metadata.aspect.AspectVersion; import com.linkedin.metadata.config.DataHubAppConfiguration; import com.linkedin.metadata.models.EntitySpec; @@ -32,6 +33,7 @@ import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.validation.constraints.Null; import org.apache.commons.collections.CollectionUtils; public class QueryUtils { @@ -40,6 +42,26 @@ public class QueryUtils { private QueryUtils() {} + // Creates new Criterion with field and value, using EQUAL condition. + @Nullable + public static Criterion newCriterion(@Nonnull String field, @Nonnull List values) { + return newCriterion(field, values, Condition.EQUAL); + } + + // Creates new Criterion with field, value and condition. + @Null + public static Criterion newCriterion( + @Nonnull String field, @Nonnull List values, @Nonnull Condition condition) { + if (values.isEmpty()) { + return null; + } + return new Criterion() + .setField(field) + .setValue(values.get(0)) // Hack! This is due to bad modeling. + .setValues(new StringArray(values)) + .setCondition(condition); + } + // Creates new Filter from a map of Criteria by removing null-valued Criteria and using EQUAL // condition (default). @Nonnull @@ -58,6 +80,25 @@ public static Filter newFilter(@Nullable Map params) { ImmutableList.of(new ConjunctiveCriterion().setAnd(criteria)))); } + // Creates new Filter from a map of Criteria by removing null-valued Criteria and using EQUAL + // condition (default). + @Nonnull + public static Filter newListsFilter(@Nullable Map> params) { + if (params == null) { + return EMPTY_FILTER; + } + CriterionArray criteria = + params.entrySet().stream() + .filter(e -> Objects.nonNull(e.getValue())) + .map(e -> newCriterion(e.getKey(), e.getValue())) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(CriterionArray::new)); + return new Filter() + .setOr( + new ConjunctiveCriterionArray( + ImmutableList.of(new ConjunctiveCriterion().setAnd(criteria)))); + } + // Creates new Filter from a single Criterion with EQUAL condition (default). @Nonnull public static Filter newFilter(@Nonnull String field, @Nonnull String value) { diff --git a/metadata-service/services/src/test/java/com/linkedin/metadata/search/utils/QueryUtilsTest.java b/metadata-service/services/src/test/java/com/linkedin/metadata/search/utils/QueryUtilsTest.java new file mode 100644 index 0000000000000..36b87b90548e3 --- /dev/null +++ b/metadata-service/services/src/test/java/com/linkedin/metadata/search/utils/QueryUtilsTest.java @@ -0,0 +1,181 @@ +package com.linkedin.metadata.search.utils; + +import com.linkedin.data.template.StringArray; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.Filter; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class QueryUtilsTest { + + @Test + public void testNewCriterionEmptyValues() { + List emptyList = Collections.emptyList(); + // Should return null when the values list is empty. + Criterion crit = QueryUtils.newCriterion("testField", emptyList); + Assert.assertNull(crit, "Expected null when values list is empty"); + } + + @Test + public void testNewCriterionWithValues() { + List values = Arrays.asList("val1", "val2"); + Criterion crit = QueryUtils.newCriterion("testField", values); + Assert.assertNotNull(crit, "Criterion should not be null"); + Assert.assertEquals(crit.getField(), "testField", "Field name should match"); + Assert.assertEquals(crit.getValue(), "val1", "Value should be set to the first element"); + // Assuming StringArray has a method getElements() returning a List + Assert.assertEquals( + crit.getValues(), new StringArray(values), "Values should match the input list"); + Assert.assertEquals( + crit.getCondition(), Condition.EQUAL, "Condition should be EQUAL by default"); + } + + @Test + public void testNewCriterionWithProvidedCondition() { + List values = Arrays.asList("val1"); + // Here we explicitly pass the condition (using EQUAL in this case) + Criterion crit = QueryUtils.newCriterion("field", values, Condition.EQUAL); + Assert.assertNotNull(crit, "Criterion should not be null"); + Assert.assertEquals(crit.getField(), "field", "Field name should match"); + Assert.assertEquals(crit.getValue(), "val1", "Value should be the first element"); + Assert.assertEquals( + crit.getValues(), new StringArray(values), "Values should match the input list"); + Assert.assertEquals( + crit.getCondition(), Condition.EQUAL, "Condition should match the provided condition"); + } + + @Test + public void testNewFilterWithNullMap() { + // When params is null, the returned filter should be the EMPTY_FILTER + Filter filter = QueryUtils.newFilter((Map) null); + Assert.assertSame(filter, QueryUtils.EMPTY_FILTER, "Expected EMPTY_FILTER when params is null"); + } + + @Test + public void testNewFilterWithEmptyMap() { + // When an empty map is provided, we expect a filter whose inner criteria list is empty. + Filter filter = QueryUtils.newFilter(new HashMap<>()); + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertTrue(cc.getAnd().isEmpty(), "Expected empty criteria list for empty params map"); + } + + @Test + public void testNewFilterWithValidEntry() { + Map params = new HashMap<>(); + params.put("field1", "value1"); + Filter filter = QueryUtils.newFilter(params); + + // Verify the internal structure: one ConjunctiveCriterion containing one Criterion. + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertEquals(cc.getAnd().size(), 1, "Expected one criterion in the filter"); + Criterion crit = cc.getAnd().get(0); + Assert.assertEquals(crit.getField(), "field1", "Field name should match"); + Assert.assertEquals(crit.getValue(), "", "Value should be empty string"); + Assert.assertEquals( + crit.getValues(), + new StringArray(Collections.singletonList("value1")), + "Values should contain the provided value"); + Assert.assertEquals(crit.getCondition(), Condition.EQUAL, "Condition should be EQUAL"); + } + + @Test + public void testNewListsFilterWithNullMap() { + // When params is null, the returned filter should be the EMPTY_FILTER. + Filter filter = QueryUtils.newListsFilter(null); + Assert.assertSame(filter, QueryUtils.EMPTY_FILTER, "Expected EMPTY_FILTER when params is null"); + } + + @Test + public void testNewListsFilterWithEmptyMap() { + Filter filter = QueryUtils.newListsFilter(new HashMap<>()); + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertTrue(cc.getAnd().isEmpty(), "Expected empty criteria list for empty params map"); + } + + @Test + public void testNewListsFilterWithValidEntry() { + Map> params = new HashMap<>(); + params.put("field2", Arrays.asList("a", "b")); + Filter filter = QueryUtils.newListsFilter(params); + + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertEquals(cc.getAnd().size(), 1, "Expected one criterion in the filter"); + Criterion crit = cc.getAnd().get(0); + Assert.assertEquals(crit.getField(), "field2", "Field name should match"); + Assert.assertEquals( + crit.getValue(), "a", "Value should be set to the first element of the list"); + Assert.assertEquals( + crit.getValues(), + new StringArray(Arrays.asList("a", "b")), + "Values should match the input list"); + Assert.assertEquals(crit.getCondition(), Condition.EQUAL, "Condition should be EQUAL"); + } + + @Test + public void testNewFilterWithFieldAndValue() { + // This method is just a convenience overload that should be equivalent + // to newFilter(Collections.singletonMap(field, value)) + Filter filter = QueryUtils.newFilter("field3", "value3"); + + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertEquals(cc.getAnd().size(), 1, "Expected one criterion in the filter"); + Criterion crit = cc.getAnd().get(0); + Assert.assertEquals(crit.getField(), "field3", "Field name should match"); + Assert.assertEquals( + crit.getValues(), + new StringArray(Collections.singletonList("value3")), + "Values should contain the provided value"); + Assert.assertEquals(crit.getValue(), "", "Value should be empty string"); + Assert.assertEquals(crit.getCondition(), Condition.EQUAL, "Condition should be EQUAL"); + } + + @Test + public void testNewFilterWithCriterion() { + // Create a Criterion manually and then build a filter from it. + List values = Arrays.asList("x", "y"); + Criterion crit = + new Criterion() + .setField("field4") + .setValue("x") + .setValues(new StringArray(values)) + .setCondition(Condition.EQUAL); + Filter filter = QueryUtils.newFilter(crit); + + Assert.assertNotNull(filter.getOr(), "Filter 'or' clause should not be null"); + Assert.assertEquals(filter.getOr().size(), 1, "Expected one conjunctive criterion"); + ConjunctiveCriterion cc = filter.getOr().get(0); + Assert.assertNotNull(cc.getAnd(), "ConjunctiveCriterion 'and' clause should not be null"); + Assert.assertEquals(cc.getAnd().size(), 1, "Expected one criterion in the filter"); + Criterion critFromFilter = cc.getAnd().get(0); + Assert.assertEquals(critFromFilter.getField(), "field4", "Field name should match"); + Assert.assertEquals(critFromFilter.getValue(), "x", "Value should match"); + Assert.assertEquals( + critFromFilter.getValues(), + new StringArray(values), + "Values should match the original list"); + Assert.assertEquals( + critFromFilter.getCondition(), Condition.EQUAL, "Condition should be EQUAL"); + } +} diff --git a/smoke-test/tests/incidents/incidents_test.py b/smoke-test/tests/incidents/incidents_test.py index 864593c2e505f..29f73908166d5 100644 --- a/smoke-test/tests/incidents/incidents_test.py +++ b/smoke-test/tests/incidents/incidents_test.py @@ -121,7 +121,7 @@ def test_raise_resolve_incident(auth_session): "title": "test title 2", "description": "test description 2", "resourceUrn": TEST_DATASET_URN, - "priority": 0, + "priority": "CRITICAL", } }, } @@ -141,7 +141,7 @@ def test_raise_resolve_incident(auth_session): # Resolve the incident. update_incident_status = { - "query": """mutation updateIncidentStatus($urn: String!, $input: UpdateIncidentStatusInput!) {\n + "query": """mutation updateIncidentStatus($urn: String!, $input: IncidentStatusInput!) {\n updateIncidentStatus(urn: $urn, input: $input) }""", "variables": { @@ -226,7 +226,7 @@ def test_raise_resolve_incident(auth_session): assert new_incident["title"] == "test title 2" assert new_incident["description"] == "test description 2" assert new_incident["status"]["state"] == "RESOLVED" - assert new_incident["priority"] == 0 + assert new_incident["priority"] == "CRITICAL" delete_json = {"urn": new_incident_urn} From 564ba9e47677a9718d47cd76a30392a1ee517e34 Mon Sep 17 00:00:00 2001 From: Hyejin Yoon <0327jane@gmail.com> Date: Fri, 21 Feb 2025 11:40:01 +0900 Subject: [PATCH 4/4] docs: add mlflow to integration page (#12698) --- docs-website/filterTagIndexes.json | 13 ++++++++++++- .../FilterCard/quicklinkcard.module.scss | 4 ++-- .../static/img/logos/platforms/azure-ad.svg | 1 + docs-website/static/img/logos/platforms/mlflow.svg | 11 +++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs-website/static/img/logos/platforms/azure-ad.svg create mode 100644 docs-website/static/img/logos/platforms/mlflow.svg diff --git a/docs-website/filterTagIndexes.json b/docs-website/filterTagIndexes.json index b269f23cccd66..6333c5e5df31b 100644 --- a/docs-website/filterTagIndexes.json +++ b/docs-website/filterTagIndexes.json @@ -24,7 +24,7 @@ }, { "Path": "docs/generated/ingestion/sources/azure-ad", - "imgPath": "img/logos/platforms/azure-ad.png", + "imgPath": "img/logos/platforms/azure-ad.svg", "Title": "Azure AD", "Description": "Azure AD is a cloud-based identity and access management tool that provides secure authentication and authorization for users and applications.", "tags": { @@ -330,6 +330,17 @@ "Features": "" } }, + { + "Path": "docs/generated/ingestion/sources/mlflow", + "imgPath": "img/logos/platforms/mlflow.svg", + "Title": "MLflow", + "Description": "MLflow is an open-source platform for managing the end-to-end machine learning lifecycle.", + "tags": { + "Platform Type": "AI+ML", + "Connection Type": "Pull", + "Features": "" + } + }, { "Path": "docs/generated/ingestion/sources/mssql", "imgPath": "img/logos/platforms/mssql.svg", diff --git a/docs-website/src/pages/docs/_components/FilterCard/quicklinkcard.module.scss b/docs-website/src/pages/docs/_components/FilterCard/quicklinkcard.module.scss index ded87dc64eca3..66c6c43e225ae 100644 --- a/docs-website/src/pages/docs/_components/FilterCard/quicklinkcard.module.scss +++ b/docs-website/src/pages/docs/_components/FilterCard/quicklinkcard.module.scss @@ -35,7 +35,7 @@ img { display: block; - width: 100%; - height: auto; + width: auto; + height: 100%; } } diff --git a/docs-website/static/img/logos/platforms/azure-ad.svg b/docs-website/static/img/logos/platforms/azure-ad.svg new file mode 100644 index 0000000000000..82ac48348c643 --- /dev/null +++ b/docs-website/static/img/logos/platforms/azure-ad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs-website/static/img/logos/platforms/mlflow.svg b/docs-website/static/img/logos/platforms/mlflow.svg new file mode 100644 index 0000000000000..6dd3cde27236c --- /dev/null +++ b/docs-website/static/img/logos/platforms/mlflow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +