com.yahoo.vespa.hosted.provision.restapi.NodePatcher Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of node-repository Show documentation
Show all versions of node-repository Show documentation
Keeps track of node assignment in a multi-application setup.
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.provision.restapi;
import com.google.common.collect.Maps;
import com.yahoo.component.Version;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.ClusterSpec;
import com.yahoo.config.provision.DockerImage;
import com.yahoo.config.provision.NodeFlavors;
import com.yahoo.config.provision.NodeResources;
import com.yahoo.config.provision.TenantName;
import com.yahoo.slime.Inspector;
import com.yahoo.slime.ObjectTraverser;
import com.yahoo.slime.SlimeUtils;
import com.yahoo.slime.Type;
import com.yahoo.vespa.hosted.provision.LockedNodeList;
import com.yahoo.vespa.hosted.provision.Node;
import com.yahoo.vespa.hosted.provision.NodeList;
import com.yahoo.vespa.hosted.provision.NodeMutex;
import com.yahoo.vespa.hosted.provision.NodeRepository;
import com.yahoo.vespa.hosted.provision.node.Address;
import com.yahoo.vespa.hosted.provision.node.Agent;
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 com.yahoo.yolean.Exceptions;
import java.io.InputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static com.yahoo.config.provision.NodeResources.DiskSpeed.fast;
import static com.yahoo.config.provision.NodeResources.DiskSpeed.slow;
import static com.yahoo.config.provision.NodeResources.StorageType.local;
import static com.yahoo.config.provision.NodeResources.StorageType.remote;
/**
* A class which can take a partial JSON node/v2 node JSON structure and apply it to a node object.
* This is a one-time use object.
*
* @author bratseth
*/
public class NodePatcher {
private static final String WANT_TO_RETIRE = "wantToRetire";
private static final String WANT_TO_DEPROVISION = "wantToDeprovision";
private static final String WANT_TO_REBUILD = "wantToRebuild";
private static final Set RECURSIVE_FIELDS = Set.of(WANT_TO_RETIRE);
private static final Set IP_CONFIG_FIELDS = Set.of("ipAddresses",
"additionalIpAddresses",
"additionalHostnames");
private final NodeRepository nodeRepository;
private final NodeFlavors nodeFlavors;
private final Clock clock;
public NodePatcher(NodeFlavors nodeFlavors, NodeRepository nodeRepository) {
this.nodeRepository = nodeRepository;
this.nodeFlavors = nodeFlavors;
this.clock = nodeRepository.clock();
}
/**
* Apply given JSON to the node identified by hostname. Any patched node(s) are written to the node repository.
*
* Note: This may patch more than one node if the field being patched must be applied recursively to host and node.
*/
public void patch(String hostname, InputStream json) {
Inspector root = Exceptions.uncheck(() -> SlimeUtils.jsonToSlime(json.readAllBytes())).get();
Map fields = new HashMap<>();
root.traverse(fields::put);
// Create views grouping fields by their locking requirements
Map regularFields = Maps.filterKeys(fields, k -> !IP_CONFIG_FIELDS.contains(k));
Map ipConfigFields = Maps.filterKeys(fields, IP_CONFIG_FIELDS::contains);
Map recursiveFields = Maps.filterKeys(fields, RECURSIVE_FIELDS::contains);
// Patch
NodeMutex nodeMutex = nodeRepository.nodes().lockAndGetRequired(hostname);
patch(nodeMutex, regularFields, root, false);
patchIpConfig(hostname, ipConfigFields);
if (nodeMutex.node().type().isHost()) {
patchChildrenOf(hostname, recursiveFields, root);
}
}
private void patch(NodeMutex nodeMutex, Map fields, Inspector root, boolean applyingAsChild) {
try (var lock = nodeMutex) {
Node node = nodeMutex.node();
for (var kv : fields.entrySet()) {
String name = kv.getKey();
Inspector value = kv.getValue();
try {
node = applyField(node, name, value, root, applyingAsChild);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Could not set field '" + name + "'", e);
}
}
nodeRepository.nodes().write(node, lock);
}
}
private void patchIpConfig(String hostname, Map ipConfigFields) {
if (ipConfigFields.isEmpty()) return; // Nothing to patch
try (var allocationLock = nodeRepository.nodes().lockUnallocated()) {
LockedNodeList nodes = nodeRepository.nodes().list(allocationLock);
Node node = nodes.node(hostname).orElseThrow(() -> new NotFoundException("No node with hostname '" + hostname + "'"));
for (var kv : ipConfigFields.entrySet()) {
String name = kv.getKey();
Inspector value = kv.getValue();
try {
node = applyIpconfigField(node, name, value, nodes);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Could not set field '" + name + "'", e);
}
}
nodeRepository.nodes().write(node, allocationLock);
}
}
private void patchChildrenOf(String hostname, Map recursiveFields, Inspector root) {
if (recursiveFields.isEmpty()) return;
NodeList children = nodeRepository.nodes().list().childrenOf(hostname);
for (var child : children) {
Optional childNodeMutex = nodeRepository.nodes().lockAndGet(child.hostname());
if (childNodeMutex.isEmpty()) continue; // Node disappeared after locking
patch(childNodeMutex.get(), recursiveFields, root, true);
}
}
private Node applyField(Node node, String name, Inspector value, Inspector root, boolean applyingAsChild) {
switch (name) {
case "currentRebootGeneration" :
return node.withCurrentRebootGeneration(asLong(value), clock.instant());
case "currentRestartGeneration" :
return patchCurrentRestartGeneration(node, asLong(value));
case "currentDockerImage" :
if (node.type().isHost())
throw new IllegalArgumentException("Container image can only be set for child nodes");
return node.with(node.status().withContainerImage(DockerImage.fromString(asString(value))));
case "vespaVersion" :
case "currentVespaVersion" :
return node.with(node.status().withVespaVersion(Version.fromString(asString(value))));
case "currentOsVersion" :
return node.withCurrentOsVersion(Version.fromString(asString(value)), clock.instant());
case "currentFirmwareCheck":
return node.withFirmwareVerifiedAt(Instant.ofEpochMilli(asLong(value)));
case "failCount" :
return node.with(node.status().withFailCount(asLong(value).intValue()));
case "flavor" :
return node.with(nodeFlavors.getFlavorOrThrow(asString(value)), Agent.operator, clock.instant());
case "parentHostname" :
return node.withParentHostname(asString(value));
case WANT_TO_RETIRE:
case WANT_TO_DEPROVISION:
case WANT_TO_REBUILD:
boolean wantToRetire = asOptionalBoolean(root.field(WANT_TO_RETIRE)).orElse(node.status().wantToRetire());
boolean wantToDeprovision = asOptionalBoolean(root.field(WANT_TO_DEPROVISION)).orElse(node.status().wantToDeprovision());
boolean wantToRebuild = asOptionalBoolean(root.field(WANT_TO_REBUILD)).orElse(node.status().wantToRebuild());
return node.withWantToRetire(wantToRetire,
wantToDeprovision && !applyingAsChild,
wantToRebuild && !applyingAsChild,
Agent.operator,
clock.instant());
case "reports" :
return nodeWithPatchedReports(node, value);
case "id" :
return node.withId(asString(value));
case "diskGb":
case "minDiskAvailableGb":
return node.with(node.flavor().with(node.flavor().resources().withDiskGb(value.asDouble())), Agent.operator, clock.instant());
case "memoryGb":
case "minMainMemoryAvailableGb":
return node.with(node.flavor().with(node.flavor().resources().withMemoryGb(value.asDouble())), Agent.operator, clock.instant());
case "vcpu":
case "minCpuCores":
return node.with(node.flavor().with(node.flavor().resources().withVcpu(value.asDouble())), Agent.operator, clock.instant());
case "fastDisk":
return node.with(node.flavor().with(node.flavor().resources().with(value.asBool() ? fast : slow)), Agent.operator, clock.instant());
case "remoteStorage":
return node.with(node.flavor().with(node.flavor().resources().with(value.asBool() ? remote : local)), Agent.operator, clock.instant());
case "bandwidthGbps":
return node.with(node.flavor().with(node.flavor().resources().withBandwidthGbps(value.asDouble())), Agent.operator, clock.instant());
case "modelName":
return value.type() == Type.NIX ? node.withoutModelName() : node.withModelName(asString(value));
case "requiredDiskSpeed":
return patchRequiredDiskSpeed(node, asString(value));
case "reservedTo":
return value.type() == Type.NIX ? node.withoutReservedTo() : node.withReservedTo(TenantName.from(value.asString()));
case "exclusiveTo":
case "exclusiveToApplicationId":
return node.withExclusiveToApplicationId(SlimeUtils.optionalString(value).map(ApplicationId::fromSerializedForm).orElse(null));
case "exclusiveToClusterType":
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");
}
}
private Node applyIpconfigField(Node node, String name, Inspector value, LockedNodeList nodes) {
switch (name) {
case "ipAddresses":
return IP.Config.verify(node.with(node.ipConfig().withPrimary(asStringSet(value))), nodes);
case "additionalIpAddresses":
return IP.Config.verify(node.with(node.ipConfig().withPool(node.ipConfig().pool().withIpAddresses(asStringSet(value)))), nodes);
case "additionalHostnames":
return IP.Config.verify(node.with(node.ipConfig().withPool(node.ipConfig().pool().withAddresses(asAddressList(value)))), nodes);
}
throw new IllegalArgumentException("Could not apply field '" + name + "' on a node: No such modifiable field");
}
private Node nodeWithPatchedReports(Node node, Inspector reportsInspector) {
Node patchedNode;
// "reports": null clears the reports
if (reportsInspector.type() == Type.NIX) {
patchedNode = node.with(new Reports());
} else {
var reportsBuilder = new Reports.Builder(node.reports());
reportsInspector.traverse((ObjectTraverser) (reportId, reportInspector) -> {
if (reportInspector.type() == Type.NIX) {
// ... "reports": { "reportId": null } clears the report "reportId"
reportsBuilder.clearReport(reportId);
} else {
// ... "reports": { "reportId": {...} } overrides the whole report "reportId"
reportsBuilder.setReport(Report.fromSlime(reportId, reportInspector));
}
});
patchedNode = node.with(reportsBuilder.build());
}
boolean hadHardFailReports = node.reports().getReports().stream()
.anyMatch(r -> r.getType() == Report.Type.HARD_FAIL);
boolean hasHardFailReports = patchedNode.reports().getReports().stream()
.anyMatch(r -> r.getType() == Report.Type.HARD_FAIL);
// If this patch resulted in going from not having HARD_FAIL report to having one, or vice versa
if (hadHardFailReports != hasHardFailReports) {
// Do not automatically change wantToDeprovision when
// 1. Transitioning to having a HARD_FAIL report and being in state failed:
// To allow operators manually unset before the host is parked and deleted.
// 2. When in parked state: Deletion is imminent, possibly already underway
if ((hasHardFailReports && node.state() == Node.State.failed) || node.state() == Node.State.parked)
return patchedNode;
patchedNode = patchedNode.withWantToRetire(hasHardFailReports, hasHardFailReports, Agent.system, clock.instant());
}
return patchedNode;
}
private Node nodeWithTrustStore(Node node, Inspector inspector) {
List trustStoreItems =
SlimeUtils.entriesStream(inspector)
.map(TrustStoreItem::fromSlime)
.collect(Collectors.toList());
return node.with(trustStoreItems);
}
private Set asStringSet(Inspector field) {
if ( ! field.type().equals(Type.ARRAY))
throw new IllegalArgumentException("Expected an ARRAY value, got a " + field.type());
TreeSet strings = new TreeSet<>();
for (int i = 0; i < field.entries(); i++) {
Inspector entry = field.entry(i);
if ( ! entry.type().equals(Type.STRING))
throw new IllegalArgumentException("Expected a STRING value, got a " + entry.type());
strings.add(entry.asString());
}
return strings;
}
private List asAddressList(Inspector field) {
if ( ! field.type().equals(Type.ARRAY))
throw new IllegalArgumentException("Expected an ARRAY value, got a " + field.type());
List addresses = new ArrayList<>(field.entries());
for (int i = 0; i < field.entries(); i++) {
Inspector entry = field.entry(i);
if ( ! entry.type().equals(Type.STRING))
throw new IllegalArgumentException("Expected a STRING value, got a " + entry.type());
Address address = new Address(entry.asString());
addresses.add(address);
}
return addresses;
}
private Node patchRequiredDiskSpeed(Node node, String value) {
Optional allocation = node.allocation();
if (allocation.isPresent())
return node.with(allocation.get().withRequestedResources(
allocation.get().requestedResources().with(NodeResources.DiskSpeed.valueOf(value))));
else
throw new IllegalArgumentException("Node is not allocated");
}
private Node patchCurrentRestartGeneration(Node node, Long value) {
Optional allocation = node.allocation();
if (allocation.isPresent())
return node.with(allocation.get().withRestart(allocation.get().restartGeneration().withCurrent(value)));
else
throw new IllegalArgumentException("Node is not allocated");
}
private Long asLong(Inspector field) {
if ( ! field.type().equals(Type.LONG))
throw new IllegalArgumentException("Expected a LONG value, got a " + field.type());
return field.asLong();
}
private String asString(Inspector field) {
if ( ! field.type().equals(Type.STRING))
throw new IllegalArgumentException("Expected a STRING value, got a " + field.type());
return field.asString();
}
private boolean asBoolean(Inspector field) {
if ( ! field.type().equals(Type.BOOL))
throw new IllegalArgumentException("Expected a BOOL value, got a " + field.type());
return field.asBool();
}
private Optional asOptionalBoolean(Inspector field) {
return Optional.of(field).filter(Inspector::valid).map(this::asBoolean);
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy