diff options
12 files changed, 512 insertions, 13 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequest.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequest.java index 31665c8ae0a..11adc1f7bb6 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequest.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequest.java @@ -16,7 +16,7 @@ public class ChangeRequest { private final Approval approval; private final Impact impact; - private ChangeRequest(String id, ChangeRequestSource changeRequestSource, List<String> impactedSwitches, List<String> impactedHosts, Approval approval, Impact impact) { + public ChangeRequest(String id, ChangeRequestSource changeRequestSource, List<String> impactedSwitches, List<String> impactedHosts, Approval approval, Impact impact) { this.id = Objects.requireNonNull(id); this.changeRequestSource = Objects.requireNonNull(changeRequestSource); this.impactedSwitches = Objects.requireNonNull(impactedSwitches); diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequestSource.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequestSource.java index 63f6c256766..632999ed1c3 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequestSource.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/ChangeRequestSource.java @@ -17,7 +17,7 @@ public class ChangeRequestSource { private final ZonedDateTime plannedEndTime; - private ChangeRequestSource(String system, String id, String url, Status status, ZonedDateTime plannedStartTime, ZonedDateTime plannedEndTime) { + public ChangeRequestSource(String system, String id, String url, Status status, ZonedDateTime plannedStartTime, ZonedDateTime plannedEndTime) { this.system = Objects.requireNonNull(system); this.id = Objects.requireNonNull(id); this.url = Objects.requireNonNull(url); diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/HostAction.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/HostAction.java new file mode 100644 index 00000000000..eeb7d89cbe7 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/HostAction.java @@ -0,0 +1,57 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.vcmr; + +import java.time.Instant; +import java.util.Objects; + +/** + * @author olaa + * + */ +public class HostAction { + + private final String hostname; + private final State state; + private final Instant lastUpdated; + + public HostAction(String hostname, State state, Instant lastUpdated) { + this.hostname = hostname; + this.state = state; + this.lastUpdated = lastUpdated; + } + + public String getHostname() { + return hostname; + } + + public State getState() { + return state; + } + + public Instant getLastUpdated() { + return lastUpdated; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HostAction that = (HostAction) o; + return Objects.equals(hostname, that.hostname) && + state == that.state && + Objects.equals(lastUpdated, that.lastUpdated); + } + + @Override + public int hashCode() { + return Objects.hash(hostname, state, lastUpdated); + } + + public enum State { + REQUIRES_OPERATOR_ACTION, + PENDING_RETIREMENT, + RETIRING, + RETIRED, + COMPLETE + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/VespaChangeRequest.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/VespaChangeRequest.java new file mode 100644 index 00000000000..1dda6bb099c --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/vcmr/VespaChangeRequest.java @@ -0,0 +1,74 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.vcmr; + +import com.yahoo.config.provision.zone.ZoneId; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * @author olaa + */ +public class VespaChangeRequest extends ChangeRequest { + + private final Status status; + private final ZoneId zoneId; + // TODO: Create applicationActionPlan + private final List<HostAction> hostActionPlan; + + public VespaChangeRequest(String id, ChangeRequestSource changeRequestSource, List<String> impactedSwitches, List<String> impactedHosts, Approval approval, Impact impact, Status status, List<HostAction> hostActionPlan, ZoneId zoneId) { + super(id, changeRequestSource, impactedSwitches, impactedHosts, approval, impact); + this.status = status; + this.hostActionPlan = hostActionPlan; + this.zoneId = zoneId; + } + public VespaChangeRequest(ChangeRequest changeRequest, ZoneId zoneId) { + this(changeRequest.getId(), changeRequest.getChangeRequestSource(), changeRequest.getImpactedSwitches(), + changeRequest.getImpactedHosts(), changeRequest.getApproval(), changeRequest.getImpact(), Status.PENDING_ASSESSMENT, List.of(), zoneId); + } + + public Status getStatus() { + return status; + } + + public List<HostAction> getHostActionPlan() { + return hostActionPlan; + } + + public ZoneId getZoneId() { + return zoneId; + } + + public VespaChangeRequest withSource(ChangeRequestSource source) { + return new VespaChangeRequest(getId(), source, getImpactedSwitches(), getImpactedHosts(), getApproval(), getImpact(), status, hostActionPlan, zoneId); + } + + public VespaChangeRequest withImpactedHosts(List<String> hosts) { + return new VespaChangeRequest(getId(), getChangeRequestSource(), getImpactedSwitches(), new ArrayList<>(hosts), getApproval(), getImpact(), status, hostActionPlan, zoneId); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + VespaChangeRequest that = (VespaChangeRequest) o; + return status == that.status && + Objects.equals(hostActionPlan, that.hostActionPlan) && + Objects.equals(zoneId, that.zoneId); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), status, hostActionPlan, zoneId); + } + + public enum Status { + COMPLETED, + IN_PROGRESS, + PENDING_ACTION, + PENDING_ASSESSMENT, + REQUIRES_OPERATOR_ACTION + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainer.java index ca9ebe132fd..c26e982d911 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainer.java @@ -1,13 +1,22 @@ // Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.maintenance; +import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.api.integration.configserver.NodeRepository; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestClient; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; +import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; import java.time.Duration; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -20,11 +29,15 @@ public class ChangeRequestMaintainer extends ControllerMaintainer { private final Logger logger = Logger.getLogger(ChangeRequestMaintainer.class.getName()); private final ChangeRequestClient changeRequestClient; private final SystemName system; + private final CuratorDb curator; + private final NodeRepository nodeRepository; public ChangeRequestMaintainer(Controller controller, Duration interval) { super(controller, interval, null, SystemName.allOf(Predicate.not(SystemName::isPublic))); this.changeRequestClient = controller.serviceRegistry().changeRequestClient(); this.system = controller.system(); + this.curator = controller.curator(); + this.nodeRepository = controller.serviceRegistry().configServer().nodeRepository(); } @@ -37,10 +50,11 @@ public class ChangeRequestMaintainer extends ControllerMaintainer { changeRequests.forEach(changeRequest -> logger.info(changeRequest::toString)); } - if (system.equals(SystemName.main)) + if (system.equals(SystemName.main)) { approveChanges(changeRequests); + storeChangeRequests(changeRequests); + } - // TODO: Store in curator? return true; } @@ -52,4 +66,47 @@ public class ChangeRequestMaintainer extends ControllerMaintainer { changeRequestClient.approveChangeRequests(unapprovedRequests); } + + private void storeChangeRequests(List<ChangeRequest> changeRequests) { + var existingChangeRequests = curator.readChangeRequests() + .stream() + .collect(Collectors.toMap(ChangeRequest::getId, Function.identity())); + + var hostsByZone = hostsByZone(); + // Create or update requests in curator + try (var lock = curator.lockChangeRequests()) { + changeRequests.forEach(changeRequest -> { + var optionalZone = inferZone(changeRequest, hostsByZone); + optionalZone.ifPresent(zone -> { + var vcmr = existingChangeRequests + .getOrDefault(changeRequest.getId(), new VespaChangeRequest(changeRequest, zone)) + .withSource(changeRequest.getChangeRequestSource()); + curator.writeChangeRequest(vcmr); + }); + }); + } + } + + private Map<ZoneId, List<String>> hostsByZone() { + return controller().zoneRegistry() + .zones() + .reachable() + .in(Environment.prod) + .ids() + .stream() + .collect(Collectors.toMap( + zone -> zone, + zone -> nodeRepository.list(zone, false) + .stream() + .map(node -> node.hostname().value()) + .collect(Collectors.toList()) + )); + } + + private Optional<ZoneId> inferZone(ChangeRequest changeRequest, Map<ZoneId, List<String>> hostsByZone) { + return hostsByZone.entrySet().stream() + .filter(entry -> !Collections.disjoint(entry.getValue(), changeRequest.getImpactedHosts())) + .map(Map.Entry::getKey) + .findFirst(); + } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializer.java new file mode 100644 index 00000000000..407eb5ad5ab --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializer.java @@ -0,0 +1,150 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.persistence; + +import com.yahoo.config.provision.zone.ZoneId; +import com.yahoo.slime.ArrayTraverser; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.Slime; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestSource; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.HostAction; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * @author olaa + */ +public class ChangeRequestSerializer { + + // WARNING: Since there are multiple servers in a ZooKeeper cluster and they upgrade one by one + // (and rewrite all nodes on startup), changes to the serialized format must be made + // such that what is serialized on version N+1 can be read by version N: + // - ADDING FIELDS: Always ok + // - REMOVING FIELDS: Stop reading the field first. Stop writing it on a later version. + // - CHANGING THE FORMAT OF A FIELD: Don't do it bro. + + private static final String ID_FIELD = "id"; + private static final String SOURCE_FIELD = "source"; + private static final String SOURCE_SYSTEM_FIELD = "system"; + private static final String STATUS_FIELD = "status"; + private static final String URL_FIELD = "url"; + private static final String ZONE_FIELD = "zoneId"; + private static final String START_TIME_FIELD = "plannedStartTime"; + private static final String END_TIME_FIELD = "plannedEndTime"; + private static final String APPROVAL_FIELD = "approval"; + private static final String IMPACT_FIELD = "impact"; + private static final String IMPACTED_HOSTS_FIELD = "impactedHosts"; + private static final String IMPACTED_SWITCHES_FIELD = "impactedSwitches"; + private static final String ACTION_PLAN_FIELD = "actionPlan"; + private static final String HOST_FIELD = "hostname"; + private static final String ACTION_STATE_FIELD = "state"; + private static final String LAST_UPDATED_FIELD = "lastUpdated"; + private static final String HOSTS_FIELD = "hosts"; + + + public static VespaChangeRequest fromSlime(Slime slime) { + var inspector = slime.get(); + var id = inspector.field(ID_FIELD).asString(); + var zoneId = ZoneId.from(inspector.field(ZONE_FIELD).asString()); + var changeRequestSource = readChangeRequestSource(inspector.field(SOURCE_FIELD)); + var actionPlan = readHostActionPlan(inspector.field(ACTION_PLAN_FIELD)); + var status = VespaChangeRequest.Status.valueOf(inspector.field(STATUS_FIELD).asString()); + var impact = ChangeRequest.Impact.valueOf(inspector.field(IMPACT_FIELD).asString()); + var approval = ChangeRequest.Approval.valueOf(inspector.field(APPROVAL_FIELD).asString()); + + var impactedHosts = new ArrayList<String>(); + inspector.field(IMPACTED_HOSTS_FIELD) + .traverse((ArrayTraverser) (i, hostname) -> impactedHosts.add(hostname.asString())); + var impactedSwitches = new ArrayList<String>(); + inspector.field(IMPACTED_SWITCHES_FIELD) + .traverse((ArrayTraverser) (i, switchName) -> impactedSwitches.add(switchName.asString())); + + return new VespaChangeRequest( + id, + changeRequestSource, + impactedSwitches, + impactedHosts, + approval, + impact, + status, + actionPlan, + zoneId); + } + + public static Slime toSlime(VespaChangeRequest changeRequest) { + var slime = new Slime(); + writeChangeRequest(slime.setObject(), changeRequest); + return slime; + } + + public static void writeChangeRequest(Cursor cursor, VespaChangeRequest changeRequest) { + cursor.setString(ID_FIELD, changeRequest.getId()); + cursor.setString(STATUS_FIELD, changeRequest.getStatus().name()); + cursor.setString(IMPACT_FIELD, changeRequest.getImpact().name()); + cursor.setString(APPROVAL_FIELD, changeRequest.getApproval().name()); + cursor.setString(ZONE_FIELD, changeRequest.getZoneId().value()); + writeChangeRequestSource(cursor.setObject(SOURCE_FIELD), changeRequest.getChangeRequestSource()); + writeActionPlan(cursor.setObject(ACTION_PLAN_FIELD), changeRequest); + + var impactedHosts = cursor.setArray(IMPACTED_HOSTS_FIELD); + changeRequest.getImpactedHosts().forEach(impactedHosts::addString); + var impactedSwitches = cursor.setArray(IMPACTED_SWITCHES_FIELD); + changeRequest.getImpactedSwitches().forEach(impactedSwitches::addString); + } + + private static void writeActionPlan(Cursor cursor, VespaChangeRequest changeRequest) { + var hostsCursor = cursor.setArray(HOSTS_FIELD); + + changeRequest.getHostActionPlan().forEach(action -> { + var actionCursor = hostsCursor.addObject(); + actionCursor.setString(HOST_FIELD, action.getHostname()); + actionCursor.setString(ACTION_STATE_FIELD, action.getState().name()); + actionCursor.setString(LAST_UPDATED_FIELD, action.getLastUpdated().toString()); + }); + + // TODO: Add action plan per application + } + + private static void writeChangeRequestSource(Cursor cursor, ChangeRequestSource source) { + cursor.setString(SOURCE_SYSTEM_FIELD, source.getSystem()); + cursor.setString(ID_FIELD, source.getId()); + cursor.setString(URL_FIELD, source.getUrl()); + cursor.setString(START_TIME_FIELD, source.getPlannedStartTime().toString()); + cursor.setString(END_TIME_FIELD, source.getPlannedEndTime().toString()); + cursor.setString(STATUS_FIELD, source.getStatus().name()); + } + + private static ChangeRequestSource readChangeRequestSource(Inspector inspector) { + return new ChangeRequestSource( + inspector.field(SOURCE_SYSTEM_FIELD).asString(), + inspector.field(ID_FIELD).asString(), + inspector.field(URL_FIELD).asString(), + ChangeRequestSource.Status.valueOf(inspector.field(STATUS_FIELD).asString()), + ZonedDateTime.parse(inspector.field(START_TIME_FIELD).asString()), + ZonedDateTime.parse(inspector.field(END_TIME_FIELD).asString()) + ); + } + + private static List<HostAction> readHostActionPlan(Inspector inspector) { + if (!inspector.valid()) + return List.of(); + + var actionPlan = new ArrayList<HostAction>(); + inspector.field(HOSTS_FIELD).traverse((ArrayTraverser) (index, hostObject) -> + actionPlan.add( + new HostAction( + hostObject.field(HOST_FIELD).asString(), + HostAction.State.valueOf(hostObject.field(ACTION_STATE_FIELD).asString()), + Instant.parse(hostObject.field(LAST_UPDATED_FIELD).asString()) + ) + ) + ); + return actionPlan; + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java index 34741bcaedf..aea0eebd374 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java @@ -24,6 +24,7 @@ import com.yahoo.vespa.hosted.controller.auditlog.AuditLog; import com.yahoo.vespa.hosted.controller.deployment.Run; import com.yahoo.vespa.hosted.controller.deployment.Step; import com.yahoo.vespa.hosted.controller.dns.NameServiceQueue; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; import com.yahoo.vespa.hosted.controller.routing.GlobalRouting; import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy; import com.yahoo.vespa.hosted.controller.routing.RoutingPolicyId; @@ -39,7 +40,6 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.time.Duration; -import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -54,7 +54,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeoutException; import java.util.function.Function; import java.util.function.Predicate; -import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -89,6 +88,7 @@ public class CuratorDb { private static final Path zoneRoutingPoliciesRoot = root.append("zoneRoutingPolicies"); private static final Path endpointCertificateRoot = root.append("applicationCertificates"); private static final Path archiveBucketsRoot = root.append("archiveBuckets"); + private static final Path changeRequestsRoot = root.append("changeRequests"); private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer(); private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer); @@ -204,6 +204,10 @@ public class CuratorDb { return curator.lock(lockRoot.append("archiveBuckets").append(zoneId.value()), defaultLockTimeout); } + public Lock lockChangeRequests() { + return curator.lock(lockRoot.append("changeRequests"), defaultLockTimeout); + } + // -------------- Helpers ------------------------------------------ /** Try locking with a low timeout, meaning it is OK to fail lock acquisition. @@ -563,6 +567,24 @@ public class CuratorDb { curator.set(archiveBucketsPath(zoneid), asJson(ArchiveBucketsSerializer.toSlime(archiveBuckets))); } + // -------------- VCMRs --------------------------------------------------- + + public Optional<VespaChangeRequest> readChangeRequest(String changeRequestId) { + return readSlime(changeRequestPath(changeRequestId)).map(ChangeRequestSerializer::fromSlime); + } + + public List<VespaChangeRequest> readChangeRequests() { + return curator.getChildren(changeRequestsRoot) + .stream() + .map(this::readChangeRequest) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + } + + public void writeChangeRequest(VespaChangeRequest changeRequest) { + curator.set(changeRequestPath(changeRequest.getId()), asJson(ChangeRequestSerializer.toSlime(changeRequest))); + } + // -------------- Paths --------------------------------------------------- private Path lockPath(TenantName tenant) { @@ -688,4 +710,8 @@ public class CuratorDb { return archiveBucketsRoot.append(zoneId.value()); } + private static Path changeRequestPath(String id) { + return changeRequestsRoot.append(id); + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandler.java index 2077278ee0c..5973cc3fcf3 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandler.java @@ -20,6 +20,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; import com.yahoo.vespa.hosted.controller.auditlog.AuditLoggingRequestHandler; import com.yahoo.vespa.hosted.controller.maintenance.ChangeManagementAssessor; +import com.yahoo.vespa.hosted.controller.persistence.ChangeRequestSerializer; import com.yahoo.yolean.Exceptions; import javax.ws.rs.BadRequestException; @@ -63,6 +64,7 @@ public class ChangeManagementApiHandler extends AuditLoggingRequestHandler { private HttpResponse get(HttpRequest request) { Path path = new Path(request.getUri()); if (path.matches("/changemanagement/v1/assessment/{changeRequestId}")) return changeRequestAssessment(path.get("changeRequestId")); + if (path.matches("/changemanagement/v1/vcmr")) return getVCMRs(); return ErrorResponse.notFoundError("Nothing at " + path); } @@ -87,8 +89,7 @@ public class ChangeManagementApiHandler extends AuditLoggingRequestHandler { } private HttpResponse changeRequestAssessment(String changeRequestId) { - var optionalChangeRequest = controller.serviceRegistry().changeRequestClient() - .getUpcomingChangeRequests() + var optionalChangeRequest = controller.curator().readChangeRequests() .stream() .filter(request -> changeRequestId.equals(request.getChangeRequestSource().getId())) .findFirst(); @@ -171,6 +172,17 @@ public class ChangeManagementApiHandler extends AuditLoggingRequestHandler { return new SlimeJsonResponse(slime); } + private HttpResponse getVCMRs() { + var changeRequests = controller.curator().readChangeRequests(); + var slime = new Slime(); + var cursor = slime.setObject().setArray("vcmrs"); + changeRequests.forEach(changeRequest -> { + var changeCursor = cursor.addObject(); + ChangeRequestSerializer.writeChangeRequest(changeCursor, changeRequest); + }); + return new SlimeJsonResponse(slime); + } + private Optional<ZoneId> affectedZone(List<String> hosts) { var affectedHosts = hosts.stream() .map(HostName::from) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainerTest.java index 1ce59587d6c..183130a8ec8 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ChangeRequestMaintainerTest.java @@ -1,10 +1,12 @@ // Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.maintenance; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestSource; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.MockChangeRequestClient; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; import org.junit.Test; import java.time.Duration; @@ -24,10 +26,11 @@ public class ChangeRequestMaintainerTest { @Test public void only_approve_requests_pending_approval() { - + var changeRequest1 = newChangeRequest("id1", ChangeRequest.Approval.APPROVED); + var changeRequest2 = newChangeRequest("id2", ChangeRequest.Approval.REQUESTED); var upcomingChangeRequests = List.of( - newChangeRequest("id1", ChangeRequest.Approval.APPROVED), - newChangeRequest("id2", ChangeRequest.Approval.REQUESTED) + changeRequest1, + changeRequest2 ); changeRequestClient.setUpcomingChangeRequests(upcomingChangeRequests); @@ -37,6 +40,11 @@ public class ChangeRequestMaintainerTest { assertEquals(1, approvedChangeRequests.size()); assertEquals("id2", approvedChangeRequests.get(0).getId()); + var writtenChangeRequests = tester.curator().readChangeRequests(); + assertEquals(2, writtenChangeRequests.size()); + + var expectedChangeRequest = new VespaChangeRequest(changeRequest1, ZoneId.from("prod.us-east-3")); + assertEquals(expectedChangeRequest, writtenChangeRequests.get(0)); } private ChangeRequest newChangeRequest(String id, ChangeRequest.Approval approval) { @@ -45,7 +53,7 @@ public class ChangeRequestMaintainerTest { .approval(approval) .impact(ChangeRequest.Impact.VERY_HIGH) .impactedSwitches(List.of()) - .impactedHosts(List.of()) + .impactedHosts(List.of("node-1-tenant-host-prod.us-east-3")) .changeRequestSource(new ChangeRequestSource.Builder() .plannedStartTime(ZonedDateTime.now()) .plannedEndTime(ZonedDateTime.now()) @@ -55,6 +63,5 @@ public class ChangeRequestMaintainerTest { .status(ChangeRequestSource.Status.CLOSED) .build()) .build(); - } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializerTest.java new file mode 100644 index 00000000000..40a045c44cf --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ChangeRequestSerializerTest.java @@ -0,0 +1,45 @@ +package com.yahoo.vespa.hosted.controller.persistence; + +import com.yahoo.config.provision.zone.ZoneId; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestSource; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.HostAction; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; +import org.junit.Test; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * @author olaa + */ +public class ChangeRequestSerializerTest { + + @Test + public void reserialization_equality() { + var source = new ChangeRequestSource("aws", "id321", "url", ChangeRequestSource.Status.STARTED, ZonedDateTime.now(), ZonedDateTime.now()); + var actionPlan = List.of( + new HostAction("host1", HostAction.State.RETIRING, Instant.now()), + new HostAction("host2", HostAction.State.RETIRED, Instant.now()) + ); + + var changeRequest = new VespaChangeRequest( + "id123", + source, + List.of("switch1"), + List.of("host1", "host2"), + ChangeRequest.Approval.APPROVED, + ChangeRequest.Impact.VERY_HIGH, + VespaChangeRequest.Status.IN_PROGRESS, + actionPlan, + ZoneId.defaultId() + ); + + var reserialized = ChangeRequestSerializer.fromSlime(ChangeRequestSerializer.toSlime(changeRequest)); + assertEquals(changeRequest, reserialized); + } + +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandlerTest.java index cd815a2064b..c4412531f80 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandlerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/ChangeManagementApiHandlerTest.java @@ -12,6 +12,10 @@ import com.yahoo.vespa.hosted.controller.api.integration.noderepository.NodeOwne import com.yahoo.vespa.hosted.controller.api.integration.noderepository.NodeRepositoryNode; import com.yahoo.vespa.hosted.controller.api.integration.noderepository.NodeState; import com.yahoo.vespa.hosted.controller.api.integration.noderepository.NodeType; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequest; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestSource; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.HostAction; +import com.yahoo.vespa.hosted.controller.api.integration.vcmr.VespaChangeRequest; import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; import org.intellij.lang.annotations.Language; @@ -19,6 +23,8 @@ import org.junit.Before; import org.junit.Test; import java.io.File; +import java.time.Instant; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -35,11 +41,14 @@ public class ChangeManagementApiHandlerTest extends ControllerContainerTest { addUserToHostedOperatorRole(operator); tester.serviceRegistry().configServer().nodeRepository().addNodes(ZoneId.from("prod.us-east-3"), createNodes()); tester.serviceRegistry().configServer().nodeRepository().putNodes(ZoneId.from("prod.us-east-3"), createNode()); + tester.controller().curator().writeChangeRequest(createChangeRequest()); + } @Test public void test_api() { assertFile(new Request("http://localhost:8080/changemanagement/v1/assessment", "{\"zone\":\"prod.us-east-3\", \"hosts\": [\"host1\"]}", Request.Method.POST), "initial.json"); + assertFile(new Request("http://localhost:8080/changemanagement/v1/vcmr"), "vcmrs.json"); } private void assertResponse(Request request, @Language("JSON") String body, int statusCode) { @@ -58,6 +67,28 @@ public class ChangeManagementApiHandlerTest extends ControllerContainerTest { .build(); } + private VespaChangeRequest createChangeRequest() { + var instant = Instant.ofEpochMilli(9001); + var date = ZonedDateTime.ofInstant(instant, java.time.ZoneId.of("UTC")); + var source = new ChangeRequestSource("aws", "id321", "url", ChangeRequestSource.Status.STARTED, date, date); + var actionPlan = List.of( + new HostAction("host1", HostAction.State.RETIRING, instant), + new HostAction("host2", HostAction.State.RETIRED, instant) + ); + + return new VespaChangeRequest( + "id123", + source, + List.of("switch1"), + List.of("host1", "host2"), + ChangeRequest.Approval.APPROVED, + ChangeRequest.Impact.VERY_HIGH, + VespaChangeRequest.Status.IN_PROGRESS, + actionPlan, + ZoneId.defaultId() + ); + } + private List<NodeRepositoryNode> createNodes() { List<NodeRepositoryNode> nodes = new ArrayList<>(); nodes.add(createNode("node1", "host1", "default", 0 )); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/responses/vcmrs.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/responses/vcmrs.json new file mode 100644 index 00000000000..54d4ea8bcbd --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/changemanagement/responses/vcmrs.json @@ -0,0 +1,40 @@ +{ + "vcmrs": [ + { + "id": "id123", + "status": "IN_PROGRESS", + "impact": "VERY_HIGH", + "approval": "APPROVED", + "zoneId": "prod.default", + "source": { + "system": "aws", + "id": "id321", + "url": "url", + "plannedStartTime": "1970-01-01T00:00:09.001Z[UTC]", + "plannedEndTime": "1970-01-01T00:00:09.001Z[UTC]", + "status": "STARTED" + }, + "actionPlan": { + "hosts": [ + { + "hostname": "host1", + "state": "RETIRING", + "lastUpdated": "1970-01-01T00:00:09.001Z" + }, + { + "hostname": "host2", + "state": "RETIRED", + "lastUpdated": "1970-01-01T00:00:09.001Z" + } + ] + }, + "impactedHosts": [ + "host1", + "host2" + ], + "impactedSwitches": [ + "switch1" + ] + } + ] +}
\ No newline at end of file |