Skip to content

Commit e5335d1

Browse files
committed
add running OpenSearch version to cluster configuration entity table
1 parent 4320536 commit e5335d1

10 files changed

Lines changed: 217 additions & 14 deletions

File tree

data-node/src/test/java/org/graylog/datanode/opensearch/statemachine/tracer/InMemoryDataNodeMetadataService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
import org.graylog2.cluster.nodes.OpensearchVersionsOverview;
2323

2424
import java.util.ArrayList;
25+
import java.util.Collection;
2526
import java.util.HashMap;
2627
import java.util.Map;
2728
import java.util.Optional;
29+
import java.util.stream.Collectors;
2830

2931
class InMemoryDataNodeMetadataService implements DataNodeMetadataService {
3032

@@ -43,6 +45,13 @@ public Optional<DataNodeMetadata> findByNodeId(String nodeId) {
4345
return Optional.ofNullable(store.get(nodeId));
4446
}
4547

48+
@Override
49+
public Map<String, DataNodeMetadata> findByNodeIds(Collection<String> nodeIds) {
50+
return store.entrySet().stream()
51+
.filter(entry -> nodeIds.contains(entry.getKey()))
52+
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
53+
}
54+
4655
@Override
4756
public OpensearchVersionsOverview getVersionsOverview() {
4857
return OpensearchVersionsOverview.of(new ArrayList<>(store.values()));

graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeMetadataService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@
1818

1919
import jakarta.annotation.Nullable;
2020

21+
import java.util.Collection;
22+
import java.util.Map;
2123
import java.util.Optional;
2224

2325
public interface DataNodeMetadataService {
2426

2527
void setOpensearchVersions(String nodeId, String currentVersion, @Nullable String latestAvailableVersion);
2628

2729
Optional<DataNodeMetadata> findByNodeId(String nodeId);
30+
31+
Map<String, DataNodeMetadata> findByNodeIds(Collection<String> nodeIds);
2832

2933
OpensearchVersionsOverview getVersionsOverview();
3034
}

graylog2-server/src/main/java/org/graylog2/cluster/nodes/DataNodeMetadataServiceImpl.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@
2626
import org.graylog2.database.MongoCollections;
2727

2828
import java.util.ArrayList;
29+
import java.util.Collection;
2930
import java.util.List;
31+
import java.util.Map;
3032
import java.util.Optional;
33+
import java.util.function.Function;
34+
import java.util.stream.Collectors;
3135

3236
public class DataNodeMetadataServiceImpl implements DataNodeMetadataService {
3337

@@ -62,6 +66,17 @@ public Optional<DataNodeMetadata> findByNodeId(String nodeId) {
6266
);
6367
}
6468

69+
@Override
70+
public Map<String, DataNodeMetadata> findByNodeIds(Collection<String> nodeIds) {
71+
if (nodeIds.isEmpty()) {
72+
return Map.of();
73+
}
74+
return collection.find(Filters.in(DataNodeMetadata.FIELD_NODE_ID, nodeIds))
75+
.into(new ArrayList<>())
76+
.stream()
77+
.collect(Collectors.toMap(DataNodeMetadata::nodeId, Function.identity(), (first, ignored) -> first));
78+
}
79+
6580
@Override
6681
public OpensearchVersionsOverview getVersionsOverview() {
6782
final List<DataNodeMetadata> nodes = collection.find().into(new ArrayList<>());
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog2.rest.resources.datanodes;
18+
19+
import com.fasterxml.jackson.annotation.JsonProperty;
20+
import com.fasterxml.jackson.annotation.JsonUnwrapped;
21+
import jakarta.annotation.Nullable;
22+
import org.graylog2.cluster.nodes.DataNodeDto;
23+
24+
/**
25+
* A data node, enriched with the OpenSearch version it is currently running. That version is not part of the
26+
* {@code datanodes} collection, it lives in {@code datanode_metadata} and is written by the data node itself once
27+
* OpenSearch has come up.
28+
* <p>
29+
* The data node is unwrapped, so the JSON representation is flat and remains backwards compatible with the plain
30+
* {@link DataNodeDto} representation.
31+
*/
32+
public record DataNodeWithOpensearchVersion(
33+
@JsonUnwrapped DataNodeDto dataNode,
34+
@JsonProperty(FIELD_OPENSEARCH_VERSION) @Nullable String opensearchVersion
35+
) {
36+
public static final String FIELD_OPENSEARCH_VERSION = "opensearch_version";
37+
}

graylog2-server/src/main/java/org/graylog2/rest/resources/datanodes/DatanodeResource.java

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@
3131
import jakarta.ws.rs.core.MediaType;
3232
import org.apache.shiro.authz.annotation.RequiresAuthentication;
3333
import org.graylog2.cluster.nodes.DataNodeDto;
34+
import org.graylog2.cluster.nodes.DataNodeMetadata;
35+
import org.graylog2.cluster.nodes.DataNodeMetadataService;
3436
import org.graylog2.cluster.nodes.DataNodePaginatedService;
3537
import org.graylog2.cluster.nodes.DataNodeStatus;
38+
import org.graylog2.cluster.nodes.NodeDto;
3639
import org.graylog2.database.PaginatedList;
3740
import org.graylog2.rest.models.SortOrder;
3841
import org.graylog2.rest.models.tools.responses.PageListResponse;
@@ -48,6 +51,8 @@
4851
import java.util.Arrays;
4952
import java.util.List;
5053
import java.util.Locale;
54+
import java.util.Map;
55+
import java.util.Optional;
5156
import java.util.Set;
5257
import java.util.stream.Collectors;
5358

@@ -58,6 +63,7 @@
5863
public class DatanodeResource extends RestResource {
5964

6065
private final DataNodePaginatedService dataNodePaginatedService;
66+
private final DataNodeMetadataService dataNodeMetadataService;
6167
private final SearchQueryParser searchQueryParser;
6268

6369
private static final ImmutableMap<String, SearchQueryField> SEARCH_FIELD_MAPPING = ImmutableMap.<String, SearchQueryField>builder()
@@ -74,6 +80,8 @@ public class DatanodeResource extends RestResource {
7480
EntityAttribute.builder().id(DataNodeDto.FIELD_CLUSTER_ADDRESS).title("Transport address").type(SearchQueryField.Type.STRING).searchable(true).sortable(true).build(),
7581
EntityAttribute.builder().id(DataNodeDto.FIELD_CERT_VALID_UNTIL).title("Certificate valid until").type(SearchQueryField.Type.DATE).sortable(true).build(),
7682
EntityAttribute.builder().id(DataNodeDto.FIELD_DATANODE_VERSION).title("Version").type(SearchQueryField.Type.STRING).sortable(true).build(),
83+
// Not sortable: the OpenSearch version is stored in a separate collection and joined in after paging.
84+
EntityAttribute.builder().id(DataNodeWithOpensearchVersion.FIELD_OPENSEARCH_VERSION).title("OpenSearch version").type(SearchQueryField.Type.STRING).sortable(false).build(),
7785
EntityAttribute.builder().id(DataNodeDto.FIELD_OPENSEARCH_ROLES).title("Roles").type(SearchQueryField.Type.STRING).sortable(true).build()
7886
);
7987

@@ -86,32 +94,45 @@ private static Set<FilterOption> danodeStatusOptions() {
8694
.build();
8795

8896
@Inject
89-
public DatanodeResource(DataNodePaginatedService dataNodePaginatedService) {
97+
public DatanodeResource(DataNodePaginatedService dataNodePaginatedService,
98+
DataNodeMetadataService dataNodeMetadataService) {
9099
this.dataNodePaginatedService = dataNodePaginatedService;
100+
this.dataNodeMetadataService = dataNodeMetadataService;
91101
this.searchQueryParser = new SearchQueryParser("hostname", SEARCH_FIELD_MAPPING);
92102
}
93103

94104
@GET
95105
@Timed
96106
@Operation(summary = "Get a paginated list of all datanodes in this cluster")
97-
public PageListResponse<DataNodeDto> dataNodes(@Parameter(name = "page") @QueryParam("page") @DefaultValue("1") int page,
98-
@Parameter(name = "per_page") @QueryParam("per_page") @DefaultValue("50") int perPage,
99-
@Parameter(name = "query") @QueryParam("query") @DefaultValue("") String query,
100-
@Parameter(name = "sort",
101-
description = "The field to sort the result on",
102-
required = true,
103-
schema = @Schema(allowableValues = {"hostname", "data_node_status", "transport_address", "cert_valid_until", "datanode_version"}))
104-
@DefaultValue(DEFAULT_SORT_FIELD) @QueryParam("sort") String sort,
105-
@Parameter(name = "order", description = "The sort direction",
106-
schema = @Schema(allowableValues = {"asc", "desc"}))
107-
@DefaultValue(DEFAULT_SORT_DIRECTION) @QueryParam("order") SortOrder order
107+
public PageListResponse<DataNodeWithOpensearchVersion> dataNodes(@Parameter(name = "page") @QueryParam("page") @DefaultValue("1") int page,
108+
@Parameter(name = "per_page") @QueryParam("per_page") @DefaultValue("50") int perPage,
109+
@Parameter(name = "query") @QueryParam("query") @DefaultValue("") String query,
110+
@Parameter(name = "sort",
111+
description = "The field to sort the result on",
112+
required = true,
113+
schema = @Schema(allowableValues = {"hostname", "data_node_status", "transport_address", "cert_valid_until", "datanode_version"}))
114+
@DefaultValue(DEFAULT_SORT_FIELD) @QueryParam("sort") String sort,
115+
@Parameter(name = "order", description = "The sort direction",
116+
schema = @Schema(allowableValues = {"asc", "desc"}))
117+
@DefaultValue(DEFAULT_SORT_DIRECTION) @QueryParam("order") SortOrder order
108118

109119
) {
110120
final SearchQuery searchQuery = searchQueryParser.parse(query);
111121
final PaginatedList<DataNodeDto> result = dataNodePaginatedService.searchPaginated(searchQuery, order.toBsonSort(sort), page, perPage);
112122

113-
114123
return PageListResponse.create(query, result.pagination(),
115-
result.grandTotal().orElse(0L), sort, order, result.stream().toList(), attributes, settings);
124+
result.grandTotal().orElse(0L), sort, order, withOpensearchVersions(result), attributes, settings);
125+
}
126+
127+
private List<DataNodeWithOpensearchVersion> withOpensearchVersions(List<DataNodeDto> dataNodes) {
128+
final Map<String, DataNodeMetadata> metadata = dataNodeMetadataService.findByNodeIds(
129+
dataNodes.stream().map(NodeDto::getNodeId).toList());
130+
131+
return dataNodes.stream()
132+
.map(dataNode -> new DataNodeWithOpensearchVersion(dataNode,
133+
Optional.ofNullable(metadata.get(dataNode.getNodeId()))
134+
.map(DataNodeMetadata::currentOpensearchVersion)
135+
.orElse(null)))
136+
.toList();
116137
}
117138
}

graylog2-server/src/test/java/org/graylog2/cluster/nodes/DataNodeMetadataServiceImplTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import org.junit.jupiter.api.Test;
2323
import org.junit.jupiter.api.extension.ExtendWith;
2424

25+
import java.util.List;
26+
2527
import static org.assertj.core.api.Assertions.assertThat;
2628

2729
@ExtendWith(MongoDBExtension.class)
@@ -77,6 +79,27 @@ void findByNodeIdReturnsMetadataWithCorrectFields() {
7779
});
7880
}
7981

82+
@Test
83+
void findByNodeIdsReturnsEmptyMapForEmptyInput() {
84+
service.setOpensearchVersions(NODE_ID, "2.19.5", null);
85+
86+
assertThat(service.findByNodeIds(List.of())).isEmpty();
87+
}
88+
89+
@Test
90+
void findByNodeIdsOnlyReturnsRequestedNodesThatHaveMetadata() {
91+
final String otherNodeId = "other-node-0000-0000-0000-000000000000";
92+
final String unknownNodeId = "unknown-node-0000-0000-0000-000000000000";
93+
94+
service.setOpensearchVersions(NODE_ID, "2.19.5", null);
95+
service.setOpensearchVersions(otherNodeId, "2.18.0", null);
96+
97+
assertThat(service.findByNodeIds(List.of(NODE_ID, unknownNodeId)))
98+
.hasSize(1)
99+
.extractingByKey(NODE_ID)
100+
.satisfies(metadata -> assertThat(metadata.currentOpensearchVersion()).isEqualTo("2.19.5"));
101+
}
102+
80103
@Test
81104
void storesLatestAvailableVersion() {
82105
service.setOpensearchVersions(NODE_ID, "2.18.0", "2.19.5");
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog2.rest.resources.datanodes;
18+
19+
import com.fasterxml.jackson.databind.JsonNode;
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import org.graylog2.cluster.nodes.DataNodeDto;
22+
import org.graylog2.cluster.nodes.DataNodeStatus;
23+
import org.graylog2.shared.bindings.providers.ObjectMapperProvider;
24+
import org.junit.jupiter.api.Test;
25+
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
28+
class DataNodeWithOpensearchVersionTest {
29+
30+
private final ObjectMapper objectMapper = new ObjectMapperProvider().get();
31+
32+
private static DataNodeDto dataNode() {
33+
return DataNodeDto.builder()
34+
.setId("node-id")
35+
.setHostname("datanode1.example.com")
36+
.setDataNodeStatus(DataNodeStatus.AVAILABLE)
37+
.setDatanodeVersion("7.2.0")
38+
.build();
39+
}
40+
41+
@Test
42+
void serializesOpensearchVersionAlongsideUnwrappedDataNodeFields() {
43+
final JsonNode json = objectMapper.valueToTree(new DataNodeWithOpensearchVersion(dataNode(), "2.19.5"));
44+
45+
assertThat(json.path("opensearch_version").asText()).isEqualTo("2.19.5");
46+
// the data node itself must stay flat, no nesting introduced by the wrapper
47+
assertThat(json.path("hostname").asText()).isEqualTo("datanode1.example.com");
48+
assertThat(json.path("node_id").asText()).isEqualTo("node-id");
49+
assertThat(json.path("datanode_version").asText()).isEqualTo("7.2.0");
50+
assertThat(json.path("datanode_status").asText()).isEqualTo("AVAILABLE");
51+
assertThat(json.has("data_node")).isFalse();
52+
}
53+
54+
@Test
55+
void serializesNullOpensearchVersionForNodesWithoutMetadata() {
56+
final JsonNode json = objectMapper.valueToTree(new DataNodeWithOpensearchVersion(dataNode(), null));
57+
58+
assertThat(json.path("opensearch_version").isNull()).isTrue();
59+
assertThat(json.path("hostname").asText()).isEqualTo("datanode1.example.com");
60+
}
61+
}

graylog2-web-interface/src/components/cluster-configuration/data-nodes/DataNodesColumnConfiguration.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,27 @@ describe('DataNodesColumnConfiguration', () => {
5151
expect(screen.getByText('8.0.0')).toBeInTheDocument();
5252
expect(screen.queryByTitle(warningMessage)).not.toBeInTheDocument();
5353
});
54+
55+
const renderOpensearchVersionCell = (opensearchVersion: string | undefined) => {
56+
const { attributes } = createColumnRenderers(productName);
57+
const cell = attributes.opensearch_version.renderCell(
58+
undefined,
59+
{ opensearch_version: opensearchVersion } as ClusterDataNode,
60+
undefined,
61+
);
62+
63+
render(<>{cell}</>);
64+
};
65+
66+
it('shows the OpenSearch version the data node is running', () => {
67+
renderOpensearchVersionCell('2.19.5');
68+
69+
expect(screen.getByText('2.19.5')).toBeInTheDocument();
70+
});
71+
72+
it('shows a placeholder when the OpenSearch version is unknown', () => {
73+
renderOpensearchVersionCell(undefined);
74+
75+
expect(screen.getByText('N/A')).toBeInTheDocument();
76+
});
5477
});

graylog2-web-interface/src/components/cluster-configuration/data-nodes/DataNodesColumnConfiguration.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const DEFAULT_VISIBLE_COLUMNS = [
3535
'hostname',
3636
'opensearch_roles',
3737
'datanode_version',
38+
'opensearch_version',
3839
'datanode_status',
3940
'cpu',
4041
'memory',
@@ -167,6 +168,14 @@ export const createColumnRenderers = (productName: string): ColumnRenderers<Clus
167168
},
168169
minWidth: 200,
169170
},
171+
opensearch_version: {
172+
renderCell: (_value, entity) => (
173+
<SecondaryText>
174+
<span>{entity.opensearch_version ?? 'N/A'}</span>
175+
</SecondaryText>
176+
),
177+
minWidth: 180,
178+
},
170179
opensearch_roles: {
171180
renderCell: (_value, entity) => getRoleLabels(getDataNodeRoles(entity)),
172181
minWidth: 220,

graylog2-web-interface/src/components/datanode/Types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type DataNode = {
6161
cert_valid_until: string | null;
6262
error_msg?: string;
6363
datanode_version: string;
64+
opensearch_version?: string;
6465
version_compatible: boolean;
6566
object_id?: string;
6667
cluster_address: string;

0 commit comments

Comments
 (0)