summaryrefslogtreecommitdiffstats
path: root/node-repository
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2021-09-23 13:38:31 +0200
committerGitHub <noreply@github.com>2021-09-23 13:38:31 +0200
commit8246ef8688fa17a7e5783d1c81695f7431218263 (patch)
tree8646bd4224f50947c68117a7ba678e7bdc07cdda /node-repository
parentc7be1a493a89a65e40194266fd8a24bf3505b6f9 (diff)
parent7f69cf4acc3a6c785dff0ab956f90e87dbbbaa87 (diff)
Merge pull request #19246 from vespa-engine/mortent/persist-host-truststore
persist host truststore information in node repository
Diffstat (limited to 'node-repository')
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/Node.java69
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/node/TrustStoreItem.java68
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/CuratorDatabaseClient.java2
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializer.java25
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodePatcher.java11
-rw-r--r--node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java8
-rw-r--r--node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializerTest.java11
-rw-r--r--node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java20
8 files changed, 189 insertions, 25 deletions
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/Node.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/Node.java
index 240e041a504..df3ac00ce7f 100644
--- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/Node.java
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/Node.java
@@ -18,13 +18,16 @@ import com.yahoo.vespa.hosted.provision.node.IP;
import com.yahoo.vespa.hosted.provision.node.NodeAcl;
import com.yahoo.vespa.hosted.provision.node.Reports;
import com.yahoo.vespa.hosted.provision.node.Status;
+import com.yahoo.vespa.hosted.provision.node.TrustStoreItem;
import java.time.Instant;
import java.util.Arrays;
import java.util.EnumSet;
+import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
/**
* A node in the node repository. The identity of a node is given by its id.
@@ -50,6 +53,7 @@ public final class Node implements Nodelike {
private final Optional<ApplicationId> exclusiveToApplicationId;
private final Optional<ClusterSpec.Type> exclusiveToClusterType;
private final Optional<String> switchHostname;
+ private final List<TrustStoreItem> trustStoreItems;
/** Record of the last event of each type happening to this node */
private final History history;
@@ -79,7 +83,7 @@ public final class Node implements Nodelike {
Flavor flavor, Status status, State state, Optional<Allocation> allocation, History history, NodeType type,
Reports reports, Optional<String> modelName, Optional<TenantName> reservedTo,
Optional<ApplicationId> exclusiveToApplicationId, Optional<ClusterSpec.Type> exclusiveToClusterType,
- Optional<String> switchHostname) {
+ Optional<String> switchHostname, List<TrustStoreItem> trustStoreItems) {
this.id = Objects.requireNonNull(id, "A node must have an ID");
this.hostname = requireNonEmptyString(hostname, "A node must have a hostname");
this.ipConfig = Objects.requireNonNull(ipConfig, "A node must a have an IP config");
@@ -96,6 +100,7 @@ public final class Node implements Nodelike {
this.exclusiveToApplicationId = Objects.requireNonNull(exclusiveToApplicationId, "exclusiveToApplicationId cannot be null");
this.exclusiveToClusterType = Objects.requireNonNull(exclusiveToClusterType, "exclusiveToClusterType cannot be null");
this.switchHostname = requireNonEmptyString(switchHostname, "switchHostname cannot be null");
+ this.trustStoreItems = trustStoreItems.stream().distinct().collect(Collectors.toUnmodifiableList());
if (state == State.active)
requireNonEmpty(ipConfig.primary(), "Active node " + hostname + " must have at least one valid IP address");
@@ -207,6 +212,11 @@ public final class Node implements Nodelike {
return switchHostname;
}
+ /** Returns the trusted certificates for this host if any. */
+ public List<TrustStoreItem> trustedCertificates() {
+ return trustStoreItems;
+ }
+
/**
* Returns a copy of this where wantToFail is set to true and history is updated to reflect this.
*/
@@ -295,13 +305,13 @@ public final class Node implements Nodelike {
/** Returns a node with the status assigned to the given value */
public Node with(Status status) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state, allocation, history, type,
- reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a node with the type assigned to the given value */
public Node with(NodeType type) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state, allocation, history, type,
- reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a node with the flavor assigned to the given value */
@@ -309,31 +319,31 @@ public final class Node implements Nodelike {
if (flavor.equals(this.flavor)) return this;
History updateHistory = history.with(new History.Event(History.Event.Type.resized, agent, instant));
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state, allocation, updateHistory, type,
- reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this with the reboot generation set to generation */
public Node withReboot(Generation generation) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status.withReboot(generation), state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this with the openStackId set */
public Node withOpenStackId(String openStackId) {
return new Node(openStackId, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this with model name set to given value */
public Node withModelName(String modelName) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, Optional.of(modelName), reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, Optional.of(modelName), reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this with model name cleared */
public Node withoutModelName() {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, Optional.empty(), reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, Optional.empty(), reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this with a history record saying it was detected to be down at this instant */
@@ -364,55 +374,55 @@ public final class Node implements Nodelike {
*/
public Node with(Allocation allocation) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- Optional.of(allocation), history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ Optional.of(allocation), history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a new Node without an allocation. */
public Node withoutAllocation() {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- Optional.empty(), history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ Optional.empty(), history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this node with IP config set to the given value. */
public Node with(IP.Config ipConfig) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this node with the parent hostname assigned to the given value. */
public Node withParentHostname(String parentHostname) {
return new Node(id, ipConfig, hostname, Optional.of(parentHostname), flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
public Node withReservedTo(TenantName tenant) {
if (type != NodeType.host)
throw new IllegalArgumentException("Only host nodes can be reserved, " + hostname + " has type " + type);
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, Optional.of(tenant), exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, Optional.of(tenant), exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
/** Returns a copy of this node which is not reserved to a tenant */
public Node withoutReservedTo() {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, Optional.empty(), exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, Optional.empty(), exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
public Node withExclusiveToApplicationId(ApplicationId exclusiveTo) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, Optional.ofNullable(exclusiveTo), exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, Optional.ofNullable(exclusiveTo), exclusiveToClusterType, switchHostname, trustStoreItems);
}
public Node withExclusiveToClusterType(ClusterSpec.Type exclusiveTo) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, Optional.ofNullable(exclusiveTo), switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, Optional.ofNullable(exclusiveTo), switchHostname, trustStoreItems);
}
/** Returns a copy of this node with switch hostname set to given value */
public Node withSwitchHostname(String switchHostname) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, Optional.ofNullable(switchHostname));
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, Optional.ofNullable(switchHostname), trustStoreItems);
}
/** Returns a copy of this node with switch hostname unset */
@@ -450,12 +460,18 @@ public final class Node implements Nodelike {
/** Returns a copy of this node with the given history. */
public Node with(History history) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
}
public Node with(Reports reports) {
return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
- allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname);
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname, trustStoreItems);
+ }
+
+ public Node with(List<TrustStoreItem> trustStoreItems) {
+ return new Node(id, ipConfig, hostname, parentHostname, flavor, status, state,
+ allocation, history, type, reports, modelName, reservedTo, exclusiveToApplicationId, exclusiveToClusterType, switchHostname,
+ trustStoreItems);
}
private static Optional<String> requireNonEmptyString(Optional<String> value, String message) {
@@ -594,6 +610,7 @@ public final class Node implements Nodelike {
private Status status;
private Reports reports;
private History history;
+ private List<TrustStoreItem> trustStoreItems;
private Builder(String id, String hostname, Flavor flavor, State state, NodeType type) {
this.id = id;
@@ -663,12 +680,18 @@ public final class Node implements Nodelike {
return this;
}
+ public Builder trustedCertificates(List<TrustStoreItem> trustStoreItems) {
+ this.trustStoreItems = trustStoreItems;
+ return this;
+ }
+
public Node build() {
return new Node(id, Optional.ofNullable(ipConfig).orElse(IP.Config.EMPTY), hostname, Optional.ofNullable(parentHostname),
- flavor, Optional.ofNullable(status).orElseGet(Status::initial), state, Optional.ofNullable(allocation),
- Optional.ofNullable(history).orElseGet(History::empty), type, Optional.ofNullable(reports).orElseGet(Reports::new),
- Optional.ofNullable(modelName), Optional.ofNullable(reservedTo), Optional.ofNullable(exclusiveToApplicationId),
- Optional.ofNullable(exclusiveToClusterType), Optional.ofNullable(switchHostname));
+ flavor, Optional.ofNullable(status).orElseGet(Status::initial), state, Optional.ofNullable(allocation),
+ Optional.ofNullable(history).orElseGet(History::empty), type, Optional.ofNullable(reports).orElseGet(Reports::new),
+ Optional.ofNullable(modelName), Optional.ofNullable(reservedTo), Optional.ofNullable(exclusiveToApplicationId),
+ Optional.ofNullable(exclusiveToClusterType), Optional.ofNullable(switchHostname),
+ Optional.ofNullable(trustStoreItems).orElseGet(List::of));
}
}
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/node/TrustStoreItem.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/node/TrustStoreItem.java
new file mode 100644
index 00000000000..6fb94d0bc62
--- /dev/null
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/node/TrustStoreItem.java
@@ -0,0 +1,68 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.vespa.hosted.provision.node;
+
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Contains the fingerprint and expiry of certificates in a hosts truststore.
+ *
+ * @author mortent
+ */
+public class TrustStoreItem {
+ private static final String FINGERPRINT_FIELD = "fingerprint";
+ private static final String EXPIRY_FIELD = "expiry";
+
+ private final String fingerprint;
+ private final Instant expiry;
+
+ public TrustStoreItem(String fingerprint, Instant expiry) {
+ this.fingerprint = fingerprint;
+ this.expiry = expiry;
+ }
+
+ public String fingerprint() {
+ return fingerprint;
+ }
+
+ public Instant expiry() {
+ return expiry;
+ }
+
+ public void toSlime(Cursor trustedCertificatesRoot) {
+ Cursor object = trustedCertificatesRoot.addObject();
+ object.setString(FINGERPRINT_FIELD, fingerprint);
+ object.setLong(EXPIRY_FIELD, expiry.toEpochMilli());
+ }
+
+ public static TrustStoreItem fromSlime(Inspector inspector) {
+ String fingerprint = inspector.field(FINGERPRINT_FIELD).asString();
+ Instant expiry = Instant.ofEpochMilli(inspector.field(EXPIRY_FIELD).asLong());
+ return new TrustStoreItem(fingerprint, expiry);
+ }
+
+ @Override
+ public String toString() {
+ return "TrustedCertificate{" +
+ "fingerprint='" + fingerprint + '\'' +
+ ", expiry=" + expiry +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TrustStoreItem that = (TrustStoreItem) o;
+ return Objects.equals(fingerprint, that.fingerprint) && Objects.equals(expiry, that.expiry);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(fingerprint, expiry);
+ }
+} \ No newline at end of file
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/CuratorDatabaseClient.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/CuratorDatabaseClient.java
index d5a4c459ef3..7e252391cb3 100644
--- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/CuratorDatabaseClient.java
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/CuratorDatabaseClient.java
@@ -210,7 +210,7 @@ public class CuratorDatabaseClient {
toState.isAllocated() ? node.allocation() : Optional.empty(),
node.history().recordStateTransition(node.state(), toState, agent, clock.instant()),
node.type(), node.reports(), node.modelName(), node.reservedTo(),
- node.exclusiveToApplicationId(), node.exclusiveToClusterType(), node.switchHostname());
+ node.exclusiveToApplicationId(), node.exclusiveToClusterType(), node.switchHostname(), node.trustedCertificates());
writeNode(toState, curatorTransaction, node, newNode);
writtenNodes.add(newNode);
}
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializer.java
index 19bbe92eff6..868837daeeb 100644
--- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializer.java
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializer.java
@@ -36,6 +36,7 @@ import com.yahoo.vespa.hosted.provision.node.IP;
import com.yahoo.vespa.hosted.provision.node.OsVersion;
import com.yahoo.vespa.hosted.provision.node.Reports;
import com.yahoo.vespa.hosted.provision.node.Status;
+import com.yahoo.vespa.hosted.provision.node.TrustStoreItem;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -95,6 +96,7 @@ public class NodeSerializer {
private static final String exclusiveToApplicationIdKey = "exclusiveTo";
private static final String exclusiveToClusterTypeKey = "exclusiveToClusterType";
private static final String switchHostnameKey = "switchHostname";
+ private static final String trustedCertificatesKey = "trustedCertificates";
// Node resource fields
private static final String flavorKey = "flavor";
@@ -122,6 +124,10 @@ public class NodeSerializer {
// Network port fields
private static final String networkPortsKey = "networkPorts";
+ // Trusted certificates fields
+ private static final String fingerprintKey = "fingerprint";
+ private static final String expiresKey = "expires";
+
// A cache of deserialized Node objects. The cache is keyed on the hash of serialized node data.
//
// Deserializing a Node from slime is expensive, and happens frequently. Node instances that have already been
@@ -182,6 +188,7 @@ public class NodeSerializer {
node.reservedTo().ifPresent(tenant -> object.setString(reservedToKey, tenant.value()));
node.exclusiveToApplicationId().ifPresent(applicationId -> object.setString(exclusiveToApplicationIdKey, applicationId.serializedForm()));
node.exclusiveToClusterType().ifPresent(clusterType -> object.setString(exclusiveToClusterTypeKey, clusterType.name()));
+ trustedCertificatesToSlime(node.trustedCertificates(), object.setArray(trustedCertificatesKey));
}
private void toSlime(Flavor flavor, Cursor object) {
@@ -236,6 +243,14 @@ public class NodeSerializer {
});
}
+ private void trustedCertificatesToSlime(List<TrustStoreItem> trustStoreItems, Cursor array) {
+ trustStoreItems.forEach(cert -> {
+ Cursor object = array.addObject();
+ object.setString(fingerprintKey, cert.fingerprint());
+ object.setLong(expiresKey, cert.expiry().toEpochMilli());
+ });
+ }
+
// ---------------- Deserialization --------------------------------------------------
public Node fromJson(Node.State state, byte[] data) {
@@ -269,7 +284,8 @@ public class NodeSerializer {
reservedToFromSlime(object.field(reservedToKey)),
exclusiveToApplicationIdFromSlime(object.field(exclusiveToApplicationIdKey)),
exclusiveToClusterTypeFromSlime(object.field(exclusiveToClusterTypeKey)),
- switchHostnameFromSlime(object.field(switchHostnameKey)));
+ switchHostnameFromSlime(object.field(switchHostnameKey)),
+ trustedCertificatesFromSlime(object));
}
private Status statusFromSlime(Inspector object) {
@@ -419,6 +435,13 @@ public class NodeSerializer {
return Optional.of(ClusterSpec.Type.from(object.asString()));
}
+ private List<TrustStoreItem> trustedCertificatesFromSlime(Inspector object) {
+ return SlimeUtils.entriesStream(object.field(trustedCertificatesKey))
+ .map(elem -> new TrustStoreItem(elem.field(fingerprintKey).asString(),
+ Instant.ofEpochMilli(elem.field(expiresKey).asLong())))
+ .collect(Collectors.toList());
+ }
+
// ----------------- Enum <-> string mappings ----------------------------------------
/** Returns the event type, or null if this event type should be ignored */
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodePatcher.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodePatcher.java
index 8d37c13d2bc..5dfacc2c3d2 100644
--- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodePatcher.java
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodePatcher.java
@@ -25,6 +25,7 @@ import com.yahoo.vespa.hosted.provision.node.Allocation;
import com.yahoo.vespa.hosted.provision.node.IP;
import com.yahoo.vespa.hosted.provision.node.Report;
import com.yahoo.vespa.hosted.provision.node.Reports;
+import com.yahoo.vespa.hosted.provision.node.TrustStoreItem;
import java.io.IOException;
import java.io.InputStream;
@@ -186,6 +187,8 @@ public class NodePatcher implements AutoCloseable {
return node.withExclusiveToClusterType(SlimeUtils.optionalString(value).map(ClusterSpec.Type::valueOf).orElse(null));
case "switchHostname":
return value.type() == Type.NIX ? node.withoutSwitchHostname() : node.withSwitchHostname(value.asString());
+ case "trustStore":
+ return nodeWithTrustStore(node, value);
default :
throw new IllegalArgumentException("Could not apply field '" + name + "' on a node: No such modifiable field");
}
@@ -230,6 +233,14 @@ public class NodePatcher implements AutoCloseable {
return patchedNode;
}
+ private Node nodeWithTrustStore(Node node, Inspector inspector) {
+ List<TrustStoreItem> trustStoreItems =
+ SlimeUtils.entriesStream(inspector)
+ .map(TrustStoreItem::fromSlime)
+ .collect(Collectors.toList());
+ return node.with(trustStoreItems);
+ }
+
private Set<String> asStringSet(Inspector field) {
if ( ! field.type().equals(Type.ARRAY))
throw new IllegalArgumentException("Expected an ARRAY value, got a " + field.type());
diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java
index 2cf671514c4..80100128379 100644
--- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java
+++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java
@@ -16,6 +16,7 @@ import com.yahoo.vespa.hosted.provision.Node;
import com.yahoo.vespa.hosted.provision.NodeRepository;
import com.yahoo.vespa.hosted.provision.node.Address;
import com.yahoo.vespa.hosted.provision.node.History;
+import com.yahoo.vespa.hosted.provision.node.TrustStoreItem;
import com.yahoo.vespa.orchestrator.Orchestrator;
import com.yahoo.vespa.orchestrator.status.HostInfo;
import com.yahoo.vespa.orchestrator.status.HostStatus;
@@ -182,6 +183,7 @@ class NodesResponse extends SlimeJsonResponse {
node.modelName().ifPresent(modelName -> object.setString("modelName", modelName));
node.switchHostname().ifPresent(switchHostname -> object.setString("switchHostname", switchHostname));
nodeRepository.archiveUris().archiveUriFor(node).ifPresent(uri -> object.setString("archiveUri", uri));
+ trustedCertsToSlime(node.trustedCertificates(), object);
}
private void toSlime(ApplicationId id, Cursor object) {
@@ -228,6 +230,12 @@ class NodesResponse extends SlimeJsonResponse {
addresses.forEach(address -> addressesArray.addString(address.hostname()));
}
+ private void trustedCertsToSlime(List<TrustStoreItem> trustStoreItems, Cursor object) {
+ if (trustStoreItems.isEmpty()) return;
+ Cursor array = object.setArray("trustStore");
+ trustStoreItems.forEach(cert -> cert.toSlime(array));
+ }
+
private String lastElement(String path) {
if (path.endsWith("/"))
path = path.substring(0, path.length()-1);
diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializerTest.java
index 158a1d6e5ac..921b78252d3 100644
--- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializerTest.java
+++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/NodeSerializerTest.java
@@ -29,6 +29,7 @@ import com.yahoo.vespa.hosted.provision.node.History;
import com.yahoo.vespa.hosted.provision.node.IP;
import com.yahoo.vespa.hosted.provision.node.Report;
import com.yahoo.vespa.hosted.provision.node.Reports;
+import com.yahoo.vespa.hosted.provision.node.TrustStoreItem;
import com.yahoo.vespa.hosted.provision.provisioning.FlavorConfigBuilder;
import org.junit.Test;
@@ -462,6 +463,16 @@ public class NodeSerializerTest {
assertEquals(exclusiveToCluster, node.exclusiveToClusterType().get());
}
+ @Test
+ public void truststore_serialization() {
+ Node node = nodeSerializer.fromJson(State.active, nodeSerializer.toJson(createNode()));
+ assertEquals(List.of(), node.trustedCertificates());
+ List<TrustStoreItem> trustStoreItems = List.of(new TrustStoreItem("foo", Instant.parse("2023-09-01T23:59:59Z")), new TrustStoreItem("bar", Instant.parse("2025-05-20T23:59:59Z")));
+ node = node.with(trustStoreItems);
+ node = nodeSerializer.fromJson(State.active, nodeSerializer.toJson(node));
+ assertEquals(trustStoreItems, node.trustedCertificates());
+ }
+
private byte[] createNodeJson(String hostname, String... ipAddress) {
String ipAddressJsonPart = "";
if (ipAddress.length > 0) {
diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java
index 6c052a6c364..88a8a22913e 100644
--- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java
+++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java
@@ -1023,6 +1023,26 @@ public class NodesV2ApiTest {
tester.assertPartialResponse(new Request("http://localhost:8080/nodes/v2/node/host4.yahoo.com"), "archiveUri", false);
}
+ @Test
+ public void trusted_certificates_patch() throws IOException {
+ String url = "http://localhost:8080/nodes/v2/node/dockerhost1.yahoo.com";
+ tester.assertPartialResponse(new Request(url), "\"trustStore\":[]", false); // initially empty list
+
+ String trustStore = "\"trustStore\":[" +
+ "{" +
+ "\"fingerprint\":\"foo\"," +
+ "\"expiry\":1632302251000" +
+ "}," +
+ "{" +
+ "\"fingerprint\":\"bar\"," +
+ "\"expiry\":1758532706000" +
+ "}" +
+ "]";
+ assertResponse(new Request(url, Utf8.toBytes("{"+trustStore+"}"), Request.Method.PATCH),
+ "{\"message\":\"Updated dockerhost1.yahoo.com\"}");
+ tester.assertPartialResponse(new Request(url), trustStore, true);
+ }
+
private static String asDockerNodeJson(String hostname, String parentHostname, String... ipAddress) {
return asDockerNodeJson(hostname, NodeType.tenant, parentHostname, ipAddress);
}