diff options
Diffstat (limited to 'controller-server')
27 files changed, 908 insertions, 168 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java index 52ad813d7cc..433b2b340d5 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/RoutingController.java @@ -96,7 +96,7 @@ public class RoutingController { for (var routingMethod : controller.zoneRegistry().routingMethods(policy.id().zone())) { if (routingMethod.isDirect() && !isSystemApplication && !canRouteDirectlyTo(deployment, application.get())) continue; endpoints.addAll(policy.endpointsIn(controller.system(), routingMethod, controller.zoneRegistry())); - endpoints.add(policy.regionEndpointIn(controller.system(), routingMethod)); + endpoints.addAll(policy.regionEndpointsIn(controller.system(), routingMethod)); } } return EndpointList.copyOf(endpoints); @@ -315,6 +315,14 @@ public class RoutingController { .on(Port.fromRoutingMethod(method)) .routingMethod(method) .in(controller.system())); + if (controller.system().isPublic()) { + endpoints.add(Endpoint.of(routingId.application()) + .target(routingId.endpointId(), cluster, zones) + .on(Port.fromRoutingMethod(method)) + .routingMethod(method) + .legacy() + .in(controller.system())); + } // Add legacy endpoints if (legacyNamesAvailable && method == RoutingMethod.shared) { endpoints.add(Endpoint.of(routingId.application()) diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Endpoint.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Endpoint.java index cc1a0a455c4..3f079a5fb9b 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Endpoint.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Endpoint.java @@ -28,6 +28,10 @@ public class Endpoint { private static final String OATH_DNS_SUFFIX = ".vespa.oath.cloud"; private static final String PUBLIC_DNS_SUFFIX = ".public.vespa.oath.cloud"; private static final String PUBLIC_CD_DNS_SUFFIX = ".public-cd.vespa.oath.cloud"; + // TODO(mpolden): New domain is considered "legacy" for the time being, until it's ready for use. Once it's ready + // we'll make the vespa.oath.cloud variant legacy and this non-legacy. + private static final String PUBLIC_DNS_LEGACY_SUFFIX = ".vespa-app.cloud"; + private static final String PUBLIC_CD_LEGACY_DNS_SUFFIX = ".cd.vespa-app.cloud"; private final EndpointId id; private final ClusterSpec.Id cluster; @@ -173,13 +177,13 @@ public class Endpoint { String portPart = port.isDefault() ? "" : ":" + port.port; return URI.create(scheme + "://" + sanitize(namePart(name, separator)) + - systemPart(system, separator) + + systemPart(system, separator, legacy) + sanitize(instancePart(application, separator)) + sanitize(application.application().value()) + separator + sanitize(application.tenant().value()) + "." + - scopePart(scope, zones, legacy) + + scopePart(scope, zones, legacy, system) + dnsSuffix(system, legacy) + portPart + "/"); @@ -201,7 +205,15 @@ public class Endpoint { return name + separator; } - private static String scopePart(Scope scope, List<ZoneId> zones, boolean legacy) { + private static String scopePart(Scope scope, List<ZoneId> zones, boolean legacy, SystemName system) { + if (system.isPublic() && legacy) { + if (scope == Scope.global) return "g"; + var zone = zones.get(0); + var region = zone.region().value(); + char scopeSymbol = scope == Scope.region ? 'r' : 'z'; + String environment = zone.environment().isProduction() ? "" : "." + zone.environment().value(); + return region + environment + "." + scopeSymbol; + } if (scope == Scope.global) return "global"; var zone = zones.get(0); var region = zone.region().value(); @@ -215,8 +227,9 @@ public class Endpoint { return application.instance().value() + separator; } - private static String systemPart(SystemName system, String separator) { + private static String systemPart(SystemName system, String separator, boolean legacy) { if (!system.isCd()) return ""; + if (system.isPublic() && legacy) return ""; return system.value() + separator; } @@ -227,8 +240,10 @@ public class Endpoint { if (legacy) return YAHOO_DNS_SUFFIX; return OATH_DNS_SUFFIX; case Public: + if (legacy) return PUBLIC_DNS_LEGACY_SUFFIX; return PUBLIC_DNS_SUFFIX; case PublicCd: + if (legacy) return PUBLIC_CD_LEGACY_DNS_SUFFIX; return PUBLIC_CD_DNS_SUFFIX; default: throw new IllegalArgumentException("No DNS suffix declared for system " + system); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java index 015da1faae8..0491cf61ef3 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ControllerMaintenance.java @@ -61,7 +61,7 @@ public class ControllerMaintenance extends AbstractComponent { maintainers.add(new SystemRoutingPolicyMaintainer(controller, intervals.systemRoutingPolicyMaintainer)); maintainers.add(new ApplicationMetaDataGarbageCollector(controller, intervals.applicationMetaDataGarbageCollector)); maintainers.add(new ContainerImageExpirer(controller, intervals.containerImageExpirer)); - maintainers.add(new HostInfoUpdater(controller, intervals.hostSwitchUpdater)); + maintainers.add(new HostInfoUpdater(controller, intervals.hostInfoUpdater)); maintainers.add(new ReindexingTriggerer(controller, intervals.reindexingTriggerer)); maintainers.add(new EndpointCertificateMaintainer(controller, intervals.endpointCertificateMaintainer)); maintainers.add(new TrafficShareUpdater(controller, intervals.trafficFractionUpdater)); @@ -116,7 +116,7 @@ public class ControllerMaintenance extends AbstractComponent { private final Duration systemRoutingPolicyMaintainer; private final Duration applicationMetaDataGarbageCollector; private final Duration containerImageExpirer; - private final Duration hostSwitchUpdater; + private final Duration hostInfoUpdater; private final Duration reindexingTriggerer; private final Duration endpointCertificateMaintainer; private final Duration trafficFractionUpdater; @@ -148,7 +148,7 @@ public class ControllerMaintenance extends AbstractComponent { this.systemRoutingPolicyMaintainer = duration(10, MINUTES); this.applicationMetaDataGarbageCollector = duration(12, HOURS); this.containerImageExpirer = duration(12, HOURS); - this.hostSwitchUpdater = duration(12, HOURS); + this.hostInfoUpdater = duration(12, HOURS); this.reindexingTriggerer = duration(1, HOURS); this.endpointCertificateMaintainer = duration(12, HOURS); this.trafficFractionUpdater = duration(5, MINUTES); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index 2a7132c08d6..ae4d891069c 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -1308,6 +1308,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { object.setString("url", endpoint.url().toString()); object.setString("scope", endpointScopeString(endpoint.scope())); object.setString("routingMethod", routingMethodString(endpoint.routingMethod())); + object.setBool("legacy", endpoint.legacy()); } private void toSlime(Cursor response, DeploymentId deploymentId, Deployment deployment, HttpRequest request) { @@ -1319,17 +1320,22 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { var application = controller.applications().requireApplication(TenantAndApplicationId.from(deploymentId.applicationId())); // Add zone endpoints + boolean legacyEndpoints = request.getBooleanProperty("includeLegacyEndpoints"); var endpointArray = response.setArray("endpoints"); EndpointList zoneEndpoints = controller.routing().endpointsOf(deploymentId) - .scope(Endpoint.Scope.zone) - .not().legacy(); + .scope(Endpoint.Scope.zone); + if (!legacyEndpoints) { + zoneEndpoints = zoneEndpoints.not().legacy(); + } for (var endpoint : controller.routing().directEndpoints(zoneEndpoints, deploymentId.applicationId())) { toSlime(endpoint, endpointArray.addObject()); } // Add global endpoints EndpointList globalEndpoints = controller.routing().endpointsOf(application, deploymentId.applicationId().instance()) - .not().legacy() .targets(deploymentId.zoneId()); + if (!legacyEndpoints) { + globalEndpoints = globalEndpoints.not().legacy(); + } for (var endpoint : controller.routing().directEndpoints(globalEndpoints, deploymentId.applicationId())) { toSlime(endpoint, endpointArray.addObject()); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java index c56c2e93f65..daa84f4700c 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java @@ -26,14 +26,11 @@ import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingControll import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.yolean.Exceptions; -import org.apache.commons.csv.CSVFormat; import javax.ws.rs.BadRequestException; import javax.ws.rs.ForbiddenException; import javax.ws.rs.NotFoundException; import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; import java.math.BigDecimal; import java.security.Principal; import java.time.LocalDate; @@ -482,27 +479,4 @@ public class BillingApiHandler extends LoggingRequestHandler { .count() > 0; } - private static class CsvResponse extends HttpResponse { - private final String[] header; - private final List<Object[]> rows; - - CsvResponse(String[] header, List<Object[]> rows) { - super(200); - this.header = header; - this.rows = rows; - } - - @Override - public void render(OutputStream outputStream) throws IOException { - var writer = new OutputStreamWriter(outputStream); - var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer); - for (var row : this.rows) printer.printRecord(row); - printer.flush(); - } - - @Override - public String getContentType() { - return "text/csv; encoding=utf-8"; - } - } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java new file mode 100644 index 00000000000..bfcefecba0c --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java @@ -0,0 +1,352 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.LoggingRequestHandler; +import com.yahoo.restapi.MessageResponse; +import com.yahoo.restapi.RestApi; +import com.yahoo.restapi.RestApiException; +import com.yahoo.restapi.RestApiRequestHandler; +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.slime.Type; +import com.yahoo.vespa.hosted.controller.ApplicationController; +import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.TenantController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.CollectionMethod; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice; +import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; +import com.yahoo.vespa.hosted.controller.api.role.Role; +import com.yahoo.vespa.hosted.controller.api.role.SecurityContext; +import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Tenant; + +import javax.ws.rs.BadRequestException; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.Optional; +import java.util.List; + +/** + * @author ogronnesby + */ +public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandlerV2> { + private static final String[] CSV_INVOICE_HEADER = new String[]{ "ID", "Tenant", "From", "To", "CpuHours", "MemoryHours", "DiskHours", "Cpu", "Memory", "Disk", "Additional" }; + + private final ApplicationController applications; + private final TenantController tenants; + private final BillingController billing; + private final Clock clock; + + public BillingApiHandlerV2(LoggingRequestHandler.Context context, Controller controller) { + super(context, BillingApiHandlerV2::createRestApi); + this.applications = controller.applications(); + this.tenants = controller.tenants(); + this.billing = controller.serviceRegistry().billingController(); + this.clock = controller.serviceRegistry().clock(); + } + + private static RestApi createRestApi(BillingApiHandlerV2 self) { + return RestApi.builder() + /* + * This is the API that is available to tenants to view their status + */ + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}") + .get(self::tenant) + .patch(Slime.class, self::patchTenant)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/usage") + .get(self::tenantUsage)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill") + .get(self::tenantInvoiceList)) + .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill/{invoice}") + .get(self::tenantInvoice)) + /* + * This is the API that is created for accountant role in Vespa Cloud + */ + .addRoute(RestApi.route("/billing/v2/accountant") + .get(self::accountant)) + .addRoute(RestApi.route("/billing/v2/accountant/preview/tenant/{tenant}") + .get(self::previewBill) + .post(Slime.class, self::createBill)) + /* + * Utility - map Slime.class => SlimeJsonResponse + */ + .addRequestMapper(Slime.class, BillingApiHandlerV2::slimeRequestMapper) + .addResponseMapper(Slime.class, BillingApiHandlerV2::slimeResponseMapper) + .build(); + } + + // ---------- TENANT API ---------- + + private Slime tenant(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var plan = billing.getPlan(tenant.name()); + var collectionMethod = billing.getCollectionMethod(tenant.name()); + + var response = new Slime(); + var cursor = response.setObject(); + cursor.setString("tenant", tenant.name().value()); + cursor.setString("plan", plan.value()); + cursor.setString("collection", collectionMethod.name()); + return response; + } + + private Slime patchTenant(RestApi.RequestContext requestContext, Slime body) { + var security = requestContext.attributes().get(SecurityContext.ATTRIBUTE_NAME) + .map(SecurityContext.class::cast) + .orElseThrow(() -> new RestApiException.Forbidden("Must be logged in")); + + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var newPlan = body.get().field("plan"); + var newCollection = body.get().field("collection"); + + if (newPlan.valid() && newPlan.type() == Type.STRING) { + var planId = PlanId.from(newPlan.asString()); + var hasDeployments = tenantHasDeployments(tenant.name()); + var result = billing.setPlan(tenant.name(), planId, hasDeployments); + if (! result.isSuccess()) { + throw new RestApiException.Forbidden(result.getErrorMessage().get()); + } + } + + if (newCollection.valid() && newCollection.type() == Type.STRING) { + if (security.roles().contains(Role.hostedAccountant())) { + var collection = CollectionMethod.valueOf(newCollection.asString()); + billing.setCollectionMethod(tenant.name(), collection); + } else { + throw new RestApiException.Forbidden("Only accountant can change billing method"); + } + } + + var response = new Slime(); + var cursor = response.setObject(); + cursor.setString("tenant", tenant.name().value()); + cursor.setString("plan", billing.getPlan(tenant.name()).value()); + cursor.setString("collection", billing.getCollectionMethod(tenant.name()).name()); + return response; + } + + private Slime tenantInvoiceList(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var slime = new Slime(); + invoicesSummaryToSlime(slime.setObject().setArray("invoices"), billing.getInvoicesForTenant(tenant.name())); + return slime; + } + + private HttpResponse tenantInvoice(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + var invoiceId = requestContext.pathParameters().getStringOrThrow("invoice"); + var format = requestContext.queryParameters().getString("format").orElse("json"); + + var invoice = billing.getInvoicesForTenant(tenant.name()).stream() + .filter(inv -> inv.id().value().equals(invoiceId)) + .findAny() + .orElseThrow(RestApiException.NotFound::new); + + if (format.equals("json")) { + var slime = new Slime(); + toSlime(slime.setObject(), invoice); + return new SlimeJsonResponse(slime); + } + + if (format.equals("csv")) { + var csv = toCsv(invoice); + return new CsvResponse(CSV_INVOICE_HEADER, csv); + } + + throw new RestApiException.BadRequest("Unknown format: " + format); + } + + private boolean tenantHasDeployments(TenantName tenant) { + return applications.asList(tenant).stream() + .flatMap(app -> app.instances().values().stream()) + .mapToLong(instance -> instance.deployments().size()) + .sum() > 0; + } + + private Slime tenantUsage(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + var untilAt = untilParameter(requestContext); + var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate()); + + var slime = new Slime(); + usageToSlime(slime.setObject(), usage); + return slime; + } + + // --------- ACCOUNTANT API ---------- + + private Slime accountant(RestApi.RequestContext requestContext) { + var untilAt = untilParameter(requestContext); + var usagePerTenant = billing.createUncommittedInvoices(untilAt.atZone(ZoneOffset.UTC).toLocalDate()); + + var response = new Slime(); + var tenantsResponse = response.setObject().setArray("tenants"); + tenants.asList().stream().sorted(Comparator.comparing(Tenant::name)).forEach(tenant -> { + var usage = Optional.ofNullable(usagePerTenant.get(tenant.name())); + var tenantResponse = tenantsResponse.addObject(); + tenantResponse.setString("tenant", tenant.name().value()); + tenantResponse.setString("plan", billing.getPlan(tenant.name()).value()); + tenantResponse.setString("collection", billing.getCollectionMethod(tenant.name()).name()); + tenantResponse.setString("lastBill", usage.map(Invoice::getStartTime).map(DateTimeFormatter.ISO_DATE::format).orElse(null)); + tenantResponse.setString("unbilled", usage.map(Invoice::sum).map(BigDecimal::toPlainString).orElse("0.00")); + }); + + return response; + } + + private Slime previewBill(RestApi.RequestContext requestContext) { + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + var untilAt = untilParameter(requestContext); + + var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate()); + + var slime = new Slime(); + toSlime(slime.setObject(), usage); + return slime; + } + + private HttpResponse createBill(RestApi.RequestContext requestContext, Slime slime) { + var body = slime.get(); + var security = requestContext.attributes().get(SecurityContext.ATTRIBUTE_NAME) + .map(SecurityContext.class::cast) + .orElseThrow(() -> new RestApiException.Forbidden("Must be logged in")); + + var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant")); + var tenant = tenants.require(tenantName, CloudTenant.class); + + var startAt = LocalDate.parse(getInspectorFieldOrThrow(body, "from")).atStartOfDay(ZoneOffset.UTC); + var endAt = LocalDate.parse(getInspectorFieldOrThrow(body, "to")).atStartOfDay(ZoneOffset.UTC); + + var invoiceId = billing.createInvoiceForPeriod(tenant.name(), startAt, endAt, security.principal().getName()); + + // TODO: Make a redirect to the bill itself + return new MessageResponse("Created bill " + invoiceId.value()); + } + + + // --------- INVOICE RENDERING ---------- + + private void invoicesSummaryToSlime(Cursor slime, List<Invoice> invoices) { + invoices.forEach(invoice -> invoiceSummaryToSlime(slime.addObject(), invoice)); + } + + private void invoiceSummaryToSlime(Cursor slime, Invoice invoice) { + slime.setString("id", invoice.id().value()); + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + slime.setString("status", invoice.status()); + } + + private void usageToSlime(Cursor slime, Invoice invoice) { + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + toSlime(slime.setArray("items"), invoice.lineItems()); + } + + private void toSlime(Cursor slime, Invoice invoice) { + slime.setString("id", invoice.id().value()); + slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE)); + slime.setString("total", invoice.sum().toString()); + slime.setString("status", invoice.status()); + toSlime(slime.setArray("statusHistory"), invoice.statusHistory()); + toSlime(slime.setArray("items"), invoice.lineItems()); + } + + private void toSlime(Cursor slime, Invoice.StatusHistory history) { + history.getHistory().forEach((key, value) -> { + var c = slime.addObject(); + c.setString("at", key.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + c.setString("status", value); + }); + } + + private void toSlime(Cursor slime, List<Invoice.LineItem> items) { + items.forEach(item -> toSlime(slime.addObject(), item)); + } + + private void toSlime(Cursor slime, Invoice.LineItem item) { + slime.setString("id", item.id()); + slime.setString("description", item.description()); + slime.setString("amount",item.amount().toString()); + slime.setString("plan", item.plan()); + slime.setString("planName", billing.getPlanDisplayName(PlanId.from(item.plan()))); + + item.applicationId().ifPresent(appId -> { + slime.setString("application", appId.application().value()); + slime.setString("instance", appId.instance().value()); + }); + + item.zoneId().ifPresent(z -> slime.setString("zone", z.value())); + + toSlime(slime.setObject("cpu"), item.getCpuHours(), item.getCpuCost()); + toSlime(slime.setObject("memory"), item.getMemoryHours(), item.getMemoryCost()); + toSlime(slime.setObject("disk"), item.getDiskHours(), item.getDiskCost()); + } + + private void toSlime(Cursor slime, Optional<BigDecimal> hours, Optional<BigDecimal> cost) { + hours.ifPresent(h -> slime.setString("hours", h.toString())); + cost.ifPresent(c -> slime.setString("cost", c.toString())); + } + + private List<Object[]> toCsv(Invoice invoice) { + return List.<Object[]>of(new Object[]{ + invoice.id().value(), invoice.tenant().value(), + invoice.getStartTime().format(DateTimeFormatter.ISO_DATE), + invoice.getEndTime().format(DateTimeFormatter.ISO_DATE), + invoice.sumCpuHours(), invoice.sumMemoryHours(), invoice.sumDiskHours(), + invoice.sumCpuCost(), invoice.sumMemoryCost(), invoice.sumDiskCost(), + invoice.sumAdditionalCost() + }); + } + + // ---------- END INVOICE RENDERING ---------- + + private Instant untilParameter(RestApi.RequestContext ctx) { + return ctx.queryParameters().getString("until") + .map(LocalDate::parse) + .map(date -> date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()) + .orElseGet(clock::instant); + } + + private static String getInspectorFieldOrThrow(Inspector inspector, String field) { + if (!inspector.field(field).valid()) + throw new BadRequestException("Field " + field + " cannot be null"); + return inspector.field(field).asString(); + } + + private static Optional<Slime> slimeRequestMapper(RestApi.RequestContext requestContext) { + try { + return Optional.of(SlimeUtils.jsonToSlime(requestContext.requestContentOrThrow().content().readAllBytes())); + } catch (IOException e) { + throw new IllegalArgumentException("Could not parse JSON input"); + } + } + + private static HttpResponse slimeResponseMapper(RestApi.RequestContext ctx, Slime slime) { + return new SlimeJsonResponse(slime); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java new file mode 100644 index 00000000000..5aa993f2727 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java @@ -0,0 +1,33 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.container.jdisc.HttpResponse; +import org.apache.commons.csv.CSVFormat; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.List; + +class CsvResponse extends HttpResponse { + private final String[] header; + private final List<Object[]> rows; + + CsvResponse(String[] header, List<Object[]> rows) { + super(200); + this.header = header; + this.rows = rows; + } + + @Override + public void render(OutputStream outputStream) throws IOException { + var writer = new OutputStreamWriter(outputStream); + var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer); + for (var row : this.rows) printer.printRecord(row); + printer.flush(); + } + + @Override + public String getContentType() { + return "text/csv; encoding=utf-8"; + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java index 0d1469a61fc..e2a8be15361 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java @@ -119,54 +119,63 @@ public class RoutingPolicies { } } - /** Update global DNS record for given policies */ + /** Update global DNS records for given policies */ private void updateGlobalDnsOf(Collection<RoutingPolicy> routingPolicies, Set<ZoneId> inactiveZones, @SuppressWarnings("unused") Lock lock) { Map<RoutingId, List<RoutingPolicy>> routingTable = routingTableFrom(routingPolicies); for (Map.Entry<RoutingId, List<RoutingPolicy>> routeEntry : routingTable.entrySet()) { - Collection<RegionEndpoint> regionEndpoints = computeRegionEndpoints(routeEntry.getValue(), inactiveZones); - // Create a weighted ALIAS per region, pointing to all zones within the same region - regionEndpoints.forEach(regionEndpoint -> { - controller.nameServiceForwarder().createAlias(RecordName.from(regionEndpoint.target().name().value()), - Collections.unmodifiableSet(regionEndpoint.zoneTargets()), - Priority.normal); - }); - - // Create global latency-based ALIAS pointing to each per-region weighted ALIAS - Set<AliasTarget> latencyTargets = new LinkedHashSet<>(); - Set<AliasTarget> inactiveLatencyTargets = new LinkedHashSet<>(); - for (var regionEndpoint : regionEndpoints) { - if (regionEndpoint.active()) { - latencyTargets.add(regionEndpoint.target()); - } else { - inactiveLatencyTargets.add(regionEndpoint.target()); - } - } - // If all targets are configured out, all targets are set in. We do this because otherwise removing 100% of - // the ALIAS records would cause the global endpoint to stop resolving entirely (NXDOMAIN). - if (latencyTargets.isEmpty() && !inactiveLatencyTargets.isEmpty()) { - latencyTargets.addAll(inactiveLatencyTargets); - inactiveLatencyTargets.clear(); + RoutingId routingId = routeEntry.getKey(); + controller.routing().endpointsOf(routingId.application()) + .named(routingId.endpointId()) + .not().requiresRotation() + .forEach(endpoint -> updateGlobalDnsOf(endpoint, inactiveZones, routeEntry.getValue())); + } + } + + /** Update global DNS records for given global endpoint */ + private void updateGlobalDnsOf(Endpoint endpoint, Set<ZoneId> inactiveZones, List<RoutingPolicy> policies) { + if (endpoint.scope() != Endpoint.Scope.global) throw new IllegalArgumentException("Endpoint " + endpoint + " is not global"); + // Create a weighted ALIAS per region, pointing to all zones within the same region + Collection<RegionEndpoint> regionEndpoints = computeRegionEndpoints(policies, inactiveZones, endpoint.legacy()); + regionEndpoints.forEach(regionEndpoint -> { + controller.nameServiceForwarder().createAlias(RecordName.from(regionEndpoint.target().name().value()), + Collections.unmodifiableSet(regionEndpoint.zoneTargets()), + Priority.normal); + }); + + // Create global latency-based ALIAS pointing to each per-region weighted ALIAS + Set<AliasTarget> latencyTargets = new LinkedHashSet<>(); + Set<AliasTarget> inactiveLatencyTargets = new LinkedHashSet<>(); + for (var regionEndpoint : regionEndpoints) { + if (regionEndpoint.active()) { + latencyTargets.add(regionEndpoint.target()); + } else { + inactiveLatencyTargets.add(regionEndpoint.target()); } - var endpoints = controller.routing().endpointsOf(routeEntry.getKey().application()) - .named(routeEntry.getKey().endpointId()) - .not().requiresRotation(); - endpoints.forEach(endpoint -> controller.nameServiceForwarder().createAlias(RecordName.from(endpoint.dnsName()), - latencyTargets, Priority.normal)); - inactiveLatencyTargets.forEach(t -> controller.nameServiceForwarder() - .removeRecords(Record.Type.ALIAS, - RecordData.fqdn(t.name().value()), - Priority.normal)); } + + // If all targets are configured OUT, all targets are kept IN. We do this because otherwise removing 100% of + // the ALIAS records would cause the global endpoint to stop resolving entirely (NXDOMAIN). + if (latencyTargets.isEmpty() && !inactiveLatencyTargets.isEmpty()) { + latencyTargets.addAll(inactiveLatencyTargets); + inactiveLatencyTargets.clear(); + } + + controller.nameServiceForwarder().createAlias(RecordName.from(endpoint.dnsName()), latencyTargets, Priority.normal); + inactiveLatencyTargets.forEach(t -> controller.nameServiceForwarder() + .removeRecords(Record.Type.ALIAS, + RecordData.fqdn(t.name().value()), + Priority.normal)); } + /** Compute region endpoints and their targets from given policies */ - private Collection<RegionEndpoint> computeRegionEndpoints(List<RoutingPolicy> policies, Set<ZoneId> inactiveZones) { + private Collection<RegionEndpoint> computeRegionEndpoints(List<RoutingPolicy> policies, Set<ZoneId> inactiveZones, boolean legacy) { Map<Endpoint, RegionEndpoint> endpoints = new LinkedHashMap<>(); RoutingMethod routingMethod = RoutingMethod.exclusive; for (var policy : policies) { if (policy.dnsZone().isEmpty()) continue; if (!controller.zoneRegistry().routingMethods(policy.id().zone()).contains(routingMethod)) continue; - Endpoint regionEndpoint = policy.regionEndpointIn(controller.system(), routingMethod); + Endpoint regionEndpoint = policy.regionEndpointIn(controller.system(), routingMethod, legacy); var zonePolicy = db.readZoneRoutingPolicy(policy.id().zone()); long weight = 1; if (isConfiguredOut(policy, zonePolicy, inactiveZones)) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicy.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicy.java index 3ece10337f1..ae33d214ecc 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicy.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicy.java @@ -12,6 +12,7 @@ import com.yahoo.vespa.hosted.controller.application.EndpointId; import com.yahoo.vespa.hosted.controller.application.SystemApplication; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -75,29 +76,45 @@ public class RoutingPolicy { public List<Endpoint> endpointsIn(SystemName system, RoutingMethod routingMethod, ZoneRegistry zoneRegistry) { Optional<Endpoint> infraEndpoint = SystemApplication.matching(id.owner()) .flatMap(app -> app.endpointIn(id.zone(), zoneRegistry)); - List<Endpoint> endpoints = new ArrayList<>(3); if (infraEndpoint.isPresent()) { - endpoints.add(infraEndpoint.get()); - } else { - endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()).in(system)); - // Add legacy endpoints - if (routingMethod == RoutingMethod.shared) { - endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()) - .on(Port.plain(4080)) - .legacy() - .in(system)); - endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()) - .on(Port.tls(4443)) - .legacy() - .in(system)); - } + return List.of(infraEndpoint.get()); + } + List<Endpoint> endpoints = new ArrayList<>(3); + endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()).in(system)); + if (system.isPublic()) { + endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()).legacy().in(system)); + } + // Add legacy endpoints + if (routingMethod == RoutingMethod.shared) { + endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()) + .on(Port.plain(4080)) + .legacy() + .in(system)); + endpoints.add(endpoint(routingMethod).target(id.cluster(), id.zone()) + .on(Port.tls(4443)) + .legacy() + .in(system)); } return endpoints; } + /** Returns all region endpoints of this */ + public List<Endpoint> regionEndpointsIn(SystemName system, RoutingMethod routingMethod) { + List<Endpoint> endpoints = new ArrayList<>(2); + endpoints.add(regionEndpointIn(system, routingMethod, false)); + if (system.isPublic()) { + endpoints.add(regionEndpointIn(system, routingMethod, true)); + } + return Collections.unmodifiableList(endpoints); + } + /** Returns the region endpoint of this */ - public Endpoint regionEndpointIn(SystemName system, RoutingMethod routingMethod) { - return endpoint(routingMethod).targetRegion(id.cluster(), id.zone()).in(system); + public Endpoint regionEndpointIn(SystemName system, RoutingMethod routingMethod, boolean legacy) { + Endpoint.EndpointBuilder endpoint = endpoint(routingMethod).targetRegion(id.cluster(), id.zone()); + if (legacy) { + endpoint = endpoint.legacy(); + } + return endpoint.in(system); } @Override diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java index 422c856ca01..e52d1900a9d 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java @@ -599,7 +599,7 @@ public class ControllerTest { var context = tester.newDeploymentContext(); ZoneId zone = ZoneId.from("dev", "us-east-1"); tester.controllerTester().zoneRegistry() - .setRoutingMethod(ZoneApiMock.from(zone), RoutingMethod.shared, RoutingMethod.sharedLayer4); + .setRoutingMethod(ZoneApiMock.from(zone), RoutingMethod.shared, RoutingMethod.sharedLayer4); // Deploy context.runJob(zone, applicationPackage); @@ -834,7 +834,7 @@ public class ControllerTest { @Test public void testDeploymentDirectRouting() { // Rotation-less system - DeploymentTester tester = new DeploymentTester(new ControllerTester(new RotationsConfig.Builder().build())); + DeploymentTester tester = new DeploymentTester(new ControllerTester(new RotationsConfig.Builder().build(), main)); var context = tester.newDeploymentContext(); var zone1 = ZoneId.from("prod", "us-west-1"); var zone2 = ZoneId.from("prod", "us-east-3"); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java index 3e25a09b7d7..006e4e63136 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTester.java @@ -8,6 +8,7 @@ import com.yahoo.config.provision.HostName; import com.yahoo.config.provision.RegionName; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.TenantName; +import com.yahoo.config.provision.zone.RoutingMethod; import com.yahoo.config.provision.zone.ZoneApi; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.test.ManualClock; @@ -38,6 +39,7 @@ import com.yahoo.vespa.hosted.controller.integration.ConfigServerMock; import com.yahoo.vespa.hosted.controller.integration.MetricsMock; import com.yahoo.vespa.hosted.controller.integration.SecretStoreMock; import com.yahoo.vespa.hosted.controller.integration.ServiceRegistryMock; +import com.yahoo.vespa.hosted.controller.integration.ZoneApiMock; import com.yahoo.vespa.hosted.controller.integration.ZoneRegistryMock; import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb; @@ -68,6 +70,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.logging.Handler; import java.util.logging.Logger; +import java.util.stream.Collectors; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -106,8 +109,8 @@ public final class ControllerTester { this(new AthenzDbMock(), new MockCuratorDb(), defaultRotationsConfig(), serviceRegistryMock); } - public ControllerTester(RotationsConfig rotationsConfig) { - this(rotationsConfig, new MockCuratorDb()); + public ControllerTester(RotationsConfig rotationsConfig, SystemName system) { + this(new AthenzDbMock(), new MockCuratorDb(), rotationsConfig, new ServiceRegistryMock(system)); } public ControllerTester(MockCuratorDb curatorDb) { @@ -197,6 +200,21 @@ public final class ControllerTester { return new Version(current.getMajor(), nextMinorVersion.getAndIncrement(), current.getMicro()); } + /** Set the zones and system for this and bootstrap infrastructure nodes */ + public ControllerTester setZones(List<ZoneId> zones, SystemName system) { + zoneRegistry().setZones(zones.stream().map(ZoneApiMock::from).collect(Collectors.toList())) + .setSystemName(system); + configServer().bootstrap(zones, SystemApplication.notController()); + return this; + } + + /** Set the routing method for given zones */ + public ControllerTester setRoutingMethod(List<ZoneId> zones, RoutingMethod routingMethod) { + zoneRegistry().setRoutingMethod(zones.stream().map(ZoneApiMock::from).collect(Collectors.toList()), + routingMethod); + return this; + } + /** Create a new controller instance. Useful to verify that controller state is rebuilt from persistence */ public final void createNewController() { if (inContainer) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/application/EndpointTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/application/EndpointTest.java index 2d81d7304a1..468c92d3539 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/application/EndpointTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/application/EndpointTest.java @@ -69,6 +69,21 @@ public class EndpointTest { Endpoint.of(app1).target(endpointId).on(Port.tls()).routingMethod(RoutingMethod.exclusive).in(SystemName.Public) ); tests.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); + + Map<String, Endpoint> tests2 = Map.of( + // Default endpoint in public system using new domain + "https://a1.t1.g.vespa-app.cloud/", + Endpoint.of(app1).target(endpointId).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.Public), + + // Default endpoint in public CD system using new domain + "https://a1.t1.g.cd.vespa-app.cloud/", + Endpoint.of(app1).target(endpointId).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.PublicCd), + + // Custom instance in public system, using new domain + "https://i2.a2.t2.g.vespa-app.cloud/", + Endpoint.of(app2).target(endpointId).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.Public) + ); + tests2.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); } @Test @@ -117,6 +132,13 @@ public class EndpointTest { Endpoint.of(app1).target(endpointId).on(Port.tls()).routingMethod(RoutingMethod.exclusive).in(SystemName.Public) ); tests.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); + + Map<String, Endpoint> tests2 = Map.of( + // Custom endpoint and instance in public system, using new domain + "https://foo.i2.a2.t2.g.vespa-app.cloud/", + Endpoint.of(app2).target(EndpointId.of("foo")).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.Public) + ); + tests2.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); } @Test @@ -167,6 +189,21 @@ public class EndpointTest { Endpoint.of(app1).target(cluster, prodZone).on(Port.tls()).routingMethod(RoutingMethod.sharedLayer4).in(SystemName.main) ); tests.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); + + Map<String, Endpoint> tests2 = Map.of( + // Custom cluster name in public, using new domain + "https://c1.a1.t1.us-north-1.z.vespa-app.cloud/", + Endpoint.of(app1).target(ClusterSpec.Id.from("c1"), prodZone).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.Public), + + // Default cluster name in non-production zone in public, using new domain + "https://a1.t1.us-north-2.test.z.vespa-app.cloud/", + Endpoint.of(app1).target(ClusterSpec.Id.from("default"), testZone).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.Public), + + // Default cluster name in public CD, using new domain + "https://a1.t1.us-north-1.z.cd.vespa-app.cloud/", + Endpoint.of(app1).target(ClusterSpec.Id.from("default"), prodZone).on(Port.tls()).routingMethod(RoutingMethod.exclusive).legacy().in(SystemName.PublicCd) + ); + tests2.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); } @Test @@ -229,8 +266,9 @@ public class EndpointTest { } @Test - public void weighted_endpoints() { + public void region_endpoints() { var cluster = ClusterSpec.Id.from("default"); + var prodZone = ZoneId.from("prod", "us-north-2"); Map<String, Endpoint> tests = Map.of( "https://a1.t1.us-north-1-w.public.vespa.oath.cloud/", Endpoint.of(app1) @@ -240,7 +278,7 @@ public class EndpointTest { .in(SystemName.Public), "https://a1.t1.us-north-2-w.public.vespa.oath.cloud/", Endpoint.of(app1) - .targetRegion(cluster, ZoneId.from("prod", "us-north-2")) + .targetRegion(cluster, prodZone) .routingMethod(RoutingMethod.exclusive) .on(Port.tls()) .in(SystemName.Public), @@ -249,6 +287,13 @@ public class EndpointTest { .targetRegion(cluster, ZoneId.from("test", "us-north-2")) .routingMethod(RoutingMethod.exclusive) .on(Port.tls()) + .in(SystemName.Public), + "https://c1.a1.t1.us-north-2.r.vespa-app.cloud/", + Endpoint.of(app1) + .targetRegion(ClusterSpec.Id.from("c1"), prodZone) + .routingMethod(RoutingMethod.exclusive) + .on(Port.tls()) + .legacy() .in(SystemName.Public) ); tests.forEach((expected, endpoint) -> assertEquals(expected, endpoint.url().toString())); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java index 9eaa15cdbe3..fc7a99eb2f0 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java @@ -186,27 +186,28 @@ public class ApplicationPackageBuilder { return this; } + /** Add a trusted certificate to security/clients.pem */ public ApplicationPackageBuilder trust(X509Certificate certificate) { this.trustedCertificates.add(certificate); return this; } + /** Add a default trusted certificate to security/clients.pem */ public ApplicationPackageBuilder trustDefaultCertificate() { try { var generator = KeyPairGenerator.getInstance("RSA"); - var builder = X509CertificateBuilder.fromKeypair( + var certificate = X509CertificateBuilder.fromKeypair( generator.generateKeyPair(), new X500Principal("CN=name"), Instant.now(), Instant.now().plusMillis(300_000), SignatureAlgorithm.SHA256_WITH_RSA, X509CertificateBuilder.generateRandomSerialNumber() - ); - this.trustedCertificates.add(builder.build()); + ).build(); + return trust(certificate); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } - return this; } private byte[] deploymentSpec() { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTriggerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTriggerTest.java index 81c9f51278e..c8b4eaa5236 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTriggerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTriggerTest.java @@ -5,15 +5,11 @@ import com.yahoo.component.Version; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.zone.RoutingMethod; import com.yahoo.config.provision.zone.ZoneId; -import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationVersion; import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType; import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId; import com.yahoo.vespa.hosted.controller.application.ApplicationPackage; import com.yahoo.vespa.hosted.controller.application.Change; -import com.yahoo.vespa.hosted.controller.application.SystemApplication; -import com.yahoo.vespa.hosted.controller.integration.ServiceRegistryMock; -import com.yahoo.vespa.hosted.controller.integration.ZoneApiMock; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import org.junit.Test; @@ -1061,17 +1057,13 @@ public class DeploymentTriggerTest { ApplicationPackage cdPackage = new ApplicationPackageBuilder().region("cd-us-central-1") .region("cd-aws-us-east-1a") .build(); - ServiceRegistryMock services = new ServiceRegistryMock(); - var zones = List.of(ZoneApiMock.fromId("test.cd-us-central-1"), - ZoneApiMock.fromId("staging.cd-us-central-1"), - ZoneApiMock.fromId("prod.cd-us-central-1"), - ZoneApiMock.fromId("prod.cd-aws-us-east-1a")); - services.zoneRegistry() - .setSystemName(SystemName.cd) - .setZones(zones) - .setRoutingMethod(zones, RoutingMethod.shared); - tester = new DeploymentTester(new ControllerTester(services)); - tester.configServer().bootstrap(services.zoneRegistry().zones().all().ids(), SystemApplication.values()); + var zones = List.of(ZoneId.from("test.cd-us-central-1"), + ZoneId.from("staging.cd-us-central-1"), + ZoneId.from("prod.cd-us-central-1"), + ZoneId.from("prod.cd-aws-us-east-1a")); + tester.controllerTester() + .setZones(zones, SystemName.cd) + .setRoutingMethod(zones, RoutingMethod.shared); tester.controllerTester().upgradeSystem(Version.fromString("6.1")); tester.controllerTester().computeVersionStatus(); var app = tester.newDeploymentContext(); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java index c11c6ba155a..702ce83d116 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java @@ -68,7 +68,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg private final MockRunDataStore mockRunDataStore = new MockRunDataStore(); private final MockResourceTagger mockResourceTagger = new MockResourceTagger(); private final RoleService roleService = new MockRoleService(); - private final BillingController billingController = new MockBillingController(); + private final BillingController billingController = new MockBillingController(clock); private final ContainerRegistryMock containerRegistry = new ContainerRegistryMock(); private final NoopTenantSecretService tenantSecretService = new NoopTenantSecretService(); private final ArchiveService archiveService = new MockArchiveService(); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java index 31a3b5ff1cf..10e398ad133 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java @@ -1472,6 +1472,12 @@ public class ApplicationApiTest extends ControllerContainerTest { .userIdentity(USER_ID), new File("deployment-with-routing-policy.json")); + // GET deployment including legacy endpoints + tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/environment/prod/region/us-west-1/instance/instance1", GET) + .userIdentity(USER_ID) + .properties(Map.of("includeLegacyEndpoints", "true")), + new File("deployment-with-routing-policy-legacy.json")); + // Hide shared endpoints ((InMemoryFlagSource) tester.controller().flagSource()).withBooleanFlag(Flags.HIDE_SHARED_ROUTING_ENDPOINT.id(), true); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-cloud.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-cloud.json index 59022f124c0..3353d80204e 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-cloud.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-cloud.json @@ -10,7 +10,8 @@ "tls": true, "url": "https://albums.scoober.aws-us-east-1c.public.vespa.oath.cloud/", "scope": "zone", - "routingMethod": "exclusive" + "routingMethod": "exclusive", + "legacy": false } ], "clusters": "http://localhost:8080/application/v4/tenant/scoober/application/albums/instance/default/environment/prod/region/aws-us-east-1c/clusters", @@ -44,4 +45,4 @@ "queryLatencyMillis": 0.0, "writeLatencyMillis": 0.0 } -}
\ No newline at end of file +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy-legacy.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy-legacy.json new file mode 100644 index 00000000000..6efcc822264 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy-legacy.json @@ -0,0 +1,71 @@ +{ + "tenant": "tenant1", + "application": "application1", + "instance": "instance1", + "environment": "prod", + "region": "us-west-1", + "endpoints": [ + { + "cluster": "default", + "tls": true, + "url": "https://instance1.application1.tenant1.us-west-1.vespa.oath.cloud/", + "scope": "zone", + "routingMethod": "exclusive", + "legacy": false + }, + { + "cluster": "default", + "tls": true, + "url": "https://instance1--application1--tenant1.us-west-1.vespa.oath.cloud:4443/", + "scope": "zone", + "routingMethod": "shared", + "legacy": false + }, + { + "cluster": "default", + "tls": false, + "url": "http://instance1.application1.tenant1.us-west-1.prod.vespa.yahooapis.com:4080/", + "scope": "zone", + "routingMethod": "shared", + "legacy": true + }, + { + "cluster": "default", + "tls": true, + "url": "https://instance1--application1--tenant1.us-west-1.prod.vespa.yahooapis.com:4443/", + "scope": "zone", + "routingMethod": "shared", + "legacy": true + } + ], + "clusters": "http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/clusters", + "nodes": "http://localhost:8080/zone/v2/prod/us-west-1/nodes/v2/node/?recursive=true&application=tenant1.application1.instance1", + "yamasUrl": "http://monitoring-system.test/?environment=prod®ion=us-west-1&application=tenant1.application1.instance1", + "version": "6.1.0", + "revision": "1.0.1-commit1", + "deployTimeEpochMs": "(ignore)", + "screwdriverId": "1000", + "gitRepository": "repository1", + "gitBranch": "master", + "gitCommit": "commit1", + "applicationVersion": { + "hash": "1.0.1-commit1", + "build": 1, + "source": { + "gitRepository": "repository1", + "gitBranch": "master", + "gitCommit": "commit1" + }, + "sourceUrl": "repository1/tree/commit1", + "commit": "commit1" + }, + "status": "complete", + "activity": {}, + "metrics": { + "queriesPerSecond": 0.0, + "writesPerSecond": 0.0, + "documentCount": 0.0, + "queryLatencyMillis": 0.0, + "writeLatencyMillis": 0.0 + } +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy.json index 6b3c316a485..8767c369bc3 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-with-routing-policy.json @@ -10,14 +10,16 @@ "tls": true, "url": "https://instance1.application1.tenant1.us-west-1.vespa.oath.cloud/", "scope": "zone", - "routingMethod": "exclusive" + "routingMethod": "exclusive", + "legacy": false }, { "cluster": "default", "tls": true, "url": "https://instance1--application1--tenant1.us-west-1.vespa.oath.cloud:4443/", "scope": "zone", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false } ], "clusters":"http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/clusters", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-without-shared-endpoints.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-without-shared-endpoints.json index 66fe28a95ad..b59c1d6cf73 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-without-shared-endpoints.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment-without-shared-endpoints.json @@ -10,7 +10,8 @@ "tls": true, "url": "https://instance1.application1.tenant1.us-west-1.vespa.oath.cloud/", "scope": "zone", - "routingMethod": "exclusive" + "routingMethod": "exclusive", + "legacy": false } ], "clusters":"http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-west-1/clusters", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment.json index 946593fca00..6c00d654008 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/deployment.json @@ -10,14 +10,16 @@ "tls": true, "url": "https://instance1--application1--tenant1.us-central-1.vespa.oath.cloud:4443/", "scope": "zone", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false }, { "cluster": "foo", "tls": true, "url": "https://instance1--application1--tenant1.global.vespa.oath.cloud:4443/", "scope": "global", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false } ], "clusters": "http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-central-1/clusters", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/dev-us-east-1.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/dev-us-east-1.json index 7ba63e1664d..1084afc9388 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/dev-us-east-1.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/dev-us-east-1.json @@ -10,7 +10,8 @@ "tls": true, "url": "https://instance1--application1--tenant1.us-east-1.dev.vespa.oath.cloud:4443/", "scope": "zone", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false } ], "clusters":"http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/dev/region/us-east-1/clusters", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/prod-us-central-1.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/prod-us-central-1.json index 4251ba1ad95..9059ea338b1 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/prod-us-central-1.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/prod-us-central-1.json @@ -13,14 +13,16 @@ "tls": true, "url": "https://instance1--application1--tenant1.us-central-1.vespa.oath.cloud:4443/", "scope": "zone", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false }, { "cluster": "foo", "tls": true, "url": "https://instance1--application1--tenant1.global.vespa.oath.cloud:4443/", "scope": "global", - "routingMethod": "shared" + "routingMethod": "shared", + "legacy": false } ], "clusters":"http://localhost:8080/application/v4/tenant/tenant1/application/application1/instance/instance1/environment/prod/region/us-central-1/clusters", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java index b88715efcc4..88b2b939c48 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java @@ -20,6 +20,7 @@ import java.io.File; import java.math.BigDecimal; import java.time.LocalDate; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -221,8 +222,8 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest { assertEquals(CollectionMethod.INVOICE, billingController.getCollectionMethod(tenant)); } - private Invoice createInvoice() { - var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneId.systemDefault()); + static Invoice createInvoice() { + var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneOffset.UTC); var end = start.plusDays(5); var statusHistory = new Invoice.StatusHistory(new TreeMap<>(Map.of(start, "OPEN"))); return new Invoice( @@ -235,7 +236,7 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest { ); } - private Invoice.LineItem createLineItem(ZonedDateTime addedAt) { + static Invoice.LineItem createLineItem(ZonedDateTime addedAt) { return new Invoice.LineItem( "some-id", "description", diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java new file mode 100644 index 00000000000..e733f8e27d6 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java @@ -0,0 +1,138 @@ +package com.yahoo.vespa.hosted.controller.restapi.billing; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.config.provision.TenantName; +import com.yahoo.test.ManualClock; +import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController; +import com.yahoo.vespa.hosted.controller.api.role.Role; +import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; +import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest; +import com.yahoo.vespa.hosted.controller.security.Auth0Credentials; +import com.yahoo.vespa.hosted.controller.security.CloudTenantSpec; +import org.junit.Before; +import org.junit.Test; + +import java.time.Instant; +import java.util.Set; + +/** + * @author ogronnesby + */ +public class BillingApiHandlerV2Test extends ControllerContainerCloudTest { + + private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/"; + + private static final TenantName tenant = TenantName.from("tenant1"); + private static final TenantName tenant2 = TenantName.from("tenant2"); + private static final Set<Role> tenantReader = Set.of(Role.reader(tenant)); + private static final Set<Role> tenantAdmin = Set.of(Role.administrator(tenant)); + private static final Set<Role> financeAdmin = Set.of(Role.hostedAccountant()); + + private static final String ACCESS_DENIED = "{\n" + + " \"code\" : 403,\n" + + " \"message\" : \"Access denied\"\n" + + "}"; + + private MockBillingController billingController; + private ContainerTester tester; + + @Before + public void before() { + tester = new ContainerTester(container, responseFiles); + tester.controller().tenants().create(new CloudTenantSpec(tenant, ""), new Auth0Credentials(() -> "foo", Set.of(Role.hostedOperator()))); + var clock = (ManualClock) tester.controller().serviceRegistry().clock(); + clock.setInstant(Instant.parse("2021-04-13T00:00:00Z")); + billingController = (MockBillingController) tester.serviceRegistry().billingController(); + billingController.addInvoice(tenant, BillingApiHandlerTest.createInvoice(), true); + } + + @Override + protected String variablePartXml() { + return " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControlRequests'/>\n" + + " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControl'/>\n" + + + " <handler id='com.yahoo.vespa.hosted.controller.restapi.billing.BillingApiHandlerV2'>\n" + + " <binding>http://*/billing/v2/*</binding>\n" + + " </handler>\n" + + + " <http>\n" + + " <server id='default' port='8080' />\n" + + " <filtering>\n" + + " <request-chain id='default'>\n" + + " <filter id='com.yahoo.vespa.hosted.controller.restapi.filter.ControllerAuthorizationFilter'/>\n" + + " <binding>http://*/*</binding>\n" + + " </request-chain>\n" + + " </filtering>\n" + + " </http>\n"; + } + + @Test + public void require_tenant_info() { + var request = request("/billing/v2/tenant/" + tenant.value()).roles(tenantReader); + tester.assertResponse(request, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\"}"); + } + + @Test + public void require_admin_for_update_plan() { + var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH) + .data("{\"plan\": \"pay-as-you-go\"}"); + + var forbidden = request.roles(tenantReader); + tester.assertResponse(forbidden, ACCESS_DENIED, 403); + var success = request.roles(tenantAdmin); + tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"pay-as-you-go\",\"collection\":\"AUTO\"}"); + } + + @Test + public void require_accountant_for_update_collection() { + var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH) + .data("{\"collection\": \"INVOICE\"}"); + + var forbidden = request.roles(tenantAdmin); + tester.assertResponse(forbidden, "{\"error-code\":\"FORBIDDEN\",\"message\":\"Only accountant can change billing method\"}", 403); + + var success = request.roles(financeAdmin); + tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"INVOICE\"}"); + } + + @Test + public void require_tenant_usage() { + var request = request("/billing/v2/tenant/" + tenant + "/usage").roles(tenantReader); + tester.assertResponse(request, "{\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"items\":[]}"); + } + + @Test + public void require_tenant_invoice() { + var listRequest = request("/billing/v2/tenant/" + tenant + "/bill").roles(tenantReader); + tester.assertResponse(listRequest, "{\"invoices\":[{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\"}]}"); + + var singleRequest = request("/billing/v2/tenant/" + tenant + "/bill/id-1").roles(tenantReader); + tester.assertResponse(singleRequest, "{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2020-05-23T00:00:00Z\",\"status\":\"OPEN\"}],\"items\":[{\"id\":\"some-id\",\"description\":\"description\",\"amount\":\"123.00\",\"plan\":\"some-plan\",\"planName\":\"Plan with id: some-plan\",\"cpu\":{},\"memory\":{},\"disk\":{}}]}"); + } + + @Test + public void require_accountant_summary() { + var tenantRequest = request("/billing/v2/accountant").roles(tenantReader); + tester.assertResponse(tenantRequest, "{\n" + + " \"code\" : 403,\n" + + " \"message\" : \"Access denied\"\n" + + "}", 403); + + var accountantRequest = request("/billing/v2/accountant").roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, "{\"tenants\":[{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\",\"lastBill\":null,\"unbilled\":\"0.00\"}]}"); + } + + @Test + public void require_accountant_tenant_preview() { + var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1").roles(Role.hostedAccountant()); + tester.assertResponse(accountantRequest, "{\"id\":\"empty\",\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2021-04-13T00:00:00Z\",\"status\":\"OPEN\"}],\"items\":[]}"); + } + + @Test + public void require_accountant_tenant_bill() { + var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1", Request.Method.POST) + .roles(Role.hostedAccountant()) + .data("{\"from\": \"2020-05-01\",\"to\": \"2020-06-01\"}"); + tester.assertResponse(accountantRequest, "{\"message\":\"Created bill id-123\"}"); + } +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepositoryTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepositoryTest.java index 136ed508a33..aa9775f1d43 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepositoryTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepositoryTest.java @@ -51,7 +51,7 @@ public class RotationRepositoryTest { .region("us-west-1") .build(); - private final DeploymentTester tester = new DeploymentTester(new ControllerTester(rotationsConfig)); + private final DeploymentTester tester = new DeploymentTester(new ControllerTester(rotationsConfig, SystemName.main)); private final RotationRepository repository = tester.controller().routing().rotations(); private final DeploymentContext application = tester.newDeploymentContext("tenant1", "app1", "default"); @@ -92,7 +92,7 @@ public class RotationRepositoryTest { @Test public void strips_whitespace_in_rotation_fqdn() { - var tester = new DeploymentTester(new ControllerTester(rotationsConfigWhitespaces)); + var tester = new DeploymentTester(new ControllerTester(rotationsConfigWhitespaces, SystemName.main)); RotationRepository repository = tester.controller().routing().rotations(); var application2 = tester.newDeploymentContext("tenant1", "app2", "default"); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/routing/RoutingPoliciesTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/routing/RoutingPoliciesTest.java index bfb749eb681..047a4461f7c 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/routing/RoutingPoliciesTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/routing/RoutingPoliciesTest.java @@ -14,7 +14,6 @@ import com.yahoo.config.provision.HostName; import com.yahoo.config.provision.RegionName; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.zone.RoutingMethod; -import com.yahoo.config.provision.zone.ZoneApi; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.Instance; @@ -28,6 +27,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.dns.RecordName; import com.yahoo.vespa.hosted.controller.application.ApplicationPackage; import com.yahoo.vespa.hosted.controller.application.Endpoint; import com.yahoo.vespa.hosted.controller.application.EndpointId; +import com.yahoo.vespa.hosted.controller.application.EndpointList; import com.yahoo.vespa.hosted.controller.application.SystemApplication; import com.yahoo.vespa.hosted.controller.deployment.ApplicationPackageBuilder; import com.yahoo.vespa.hosted.controller.deployment.DeploymentContext; @@ -312,7 +312,7 @@ public class RoutingPoliciesTest { @Test public void zone_routing_policies_without_dns_update() { - var tester = new RoutingPoliciesTester(new DeploymentTester(), false); + var tester = new RoutingPoliciesTester(new DeploymentTester(), SystemName.main, false); var context = tester.newDeploymentContext("tenant1", "app1", "default"); tester.provisionLoadBalancers(1, context.instanceId(), true, zone1, zone2); context.submit(applicationPackage).deferLoadBalancerProvisioningIn(Environment.prod).deploy(); @@ -323,13 +323,17 @@ public class RoutingPoliciesTest { @Test public void global_routing_policies_in_rotationless_system() { - var tester = new RoutingPoliciesTester(new DeploymentTester(new ControllerTester(new RotationsConfig.Builder().build())), true); + var tester = new RoutingPoliciesTester(SystemName.Public); var context = tester.newDeploymentContext("tenant1", "app1", "default"); + List<ZoneId> prodZones = tester.controllerTester().controller().zoneRegistry().zones().all().in(Environment.prod).ids(); + ZoneId zone1 = prodZones.get(0); + ZoneId zone2 = prodZones.get(1); tester.provisionLoadBalancers(1, context.instanceId(), zone1, zone2); var applicationPackage = applicationPackageBuilder() .region(zone1.region().value()) .endpoint("r0", "c0") + .trustDefaultCertificate() .build(); context.submit(applicationPackage).deferLoadBalancerProvisioningIn(Environment.prod).deploy(); @@ -340,6 +344,39 @@ public class RoutingPoliciesTest { } @Test + public void global_routing_policies_in_public() { + var tester = new RoutingPoliciesTester(SystemName.Public); + var context = tester.newDeploymentContext("tenant1", "app1", "default"); + List<ZoneId> prodZones = tester.controllerTester().controller().zoneRegistry().zones().all().in(Environment.prod).ids(); + ZoneId zone1 = prodZones.get(0); + ZoneId zone2 = prodZones.get(1); + + var applicationPackage = applicationPackageBuilder() + .region(zone1.region().value()) + .region(zone2.region().value()) + .endpoint("default", "default") + .trustDefaultCertificate() + .build(); + context.submit(applicationPackage).deploy(); + + tester.assertTargets(context.instanceId(), EndpointId.defaultId(), + ClusterSpec.Id.from("default"), 0, + Map.of(zone1, 1L, zone2, 1L), true); + assertEquals("Registers expected DNS names", + Set.of("app1.tenant1.aws-eu-west-1-w.public.vespa.oath.cloud", + "app1.tenant1.aws-eu-west-1.r.vespa-app.cloud", + "app1.tenant1.aws-eu-west-1a.public.vespa.oath.cloud", + "app1.tenant1.aws-eu-west-1a.z.vespa-app.cloud", + "app1.tenant1.aws-us-east-1-w.public.vespa.oath.cloud", + "app1.tenant1.aws-us-east-1.r.vespa-app.cloud", + "app1.tenant1.aws-us-east-1c.public.vespa.oath.cloud", + "app1.tenant1.aws-us-east-1c.z.vespa-app.cloud", + "app1.tenant1.g.vespa-app.cloud", + "app1.tenant1.global.public.vespa.oath.cloud"), + tester.recordNames()); + } + + @Test public void manual_deployment_creates_routing_policy() { // Empty application package is valid in manually deployed environments var tester = new RoutingPoliciesTester(); @@ -572,24 +609,11 @@ public class RoutingPoliciesTest { var tester = new RoutingPoliciesTester(SystemName.Public); // Configure the system to use the same region for test, staging and prod - var sharedRegion = RegionName.from("aws-us-east-1c"); - var prodZone = ZoneId.from(Environment.prod, sharedRegion); - var stagingZone = ZoneId.from(Environment.staging, sharedRegion); - var testZone = ZoneId.from(Environment.test, sharedRegion); - var zones = List.of(ZoneApiMock.from(prodZone), - ZoneApiMock.from(stagingZone), - ZoneApiMock.from(testZone)); - tester.controllerTester().zoneRegistry() - .setZones(zones) - .setRoutingMethod(zones, RoutingMethod.exclusive); - tester.controllerTester().configServer().bootstrap(List.of(prodZone, stagingZone, testZone), - SystemApplication.notController()); - var context = tester.tester.newDeploymentContext(); var endpointId = EndpointId.of("r0"); var applicationPackage = applicationPackageBuilder() .trustDefaultCertificate() - .region(sharedRegion) + .region("aws-us-east-1c") .endpoint(endpointId.id(), "default") .build(); @@ -600,14 +624,14 @@ public class RoutingPoliciesTest { // Since runJob implicitly tears down the deployment and immediately deletes DNS records associated with the // deployment, we consume only one DNS update at a time here do { - context = context.flushDnsUpdates(1); + context.flushDnsUpdates(1); tester.assertTargets(context.instanceId(), endpointId, 0); } while (!tester.recordNames().isEmpty()); } // Deployment completes context.completeRollout(); - tester.assertTargets(context.instanceId(), endpointId, ClusterSpec.Id.from("default"), 0, Map.of(prodZone, 1L)); + tester.assertTargets(context.instanceId(), endpointId, ClusterSpec.Id.from("default"), 0, Map.of(ZoneId.from("prod", "aws-us-east-1c"), 1L)); } @Test @@ -711,6 +735,14 @@ public class RoutingPoliciesTest { return loadBalancers; } + private static List<ZoneId> publicZones() { + var sharedRegion = RegionName.from("aws-us-east-1c"); + return List.of(ZoneId.from(Environment.prod, sharedRegion), + ZoneId.from(Environment.prod, RegionName.from("aws-eu-west-1a")), + ZoneId.from(Environment.staging, sharedRegion), + ZoneId.from(Environment.test, sharedRegion)); + } + private static class RoutingPoliciesTester { private final DeploymentTester tester; @@ -720,7 +752,26 @@ public class RoutingPoliciesTest { } public RoutingPoliciesTester(SystemName system) { - this(new DeploymentTester(new ControllerTester(system)), true); + this(new DeploymentTester(system.isPublic() + ? new ControllerTester(new RotationsConfig.Builder().build(), system) + : new ControllerTester()), + system, + true); + } + + public RoutingPoliciesTester(DeploymentTester tester, SystemName system, boolean exclusiveRouting) { + this.tester = tester; + List<ZoneId> zones; + if (system.isPublic()) { + zones = publicZones(); + } else { + zones = new ArrayList<>(tester.controllerTester().zoneRegistry().zones().all().ids()); // Default zones + zones.add(zone4); // Missing from default ZoneRegistryMock zones + } + tester.controllerTester().setZones(zones, system); + if (exclusiveRouting) { + tester.controllerTester().setRoutingMethod(zones, RoutingMethod.exclusive); + } } public RoutingPolicies routingPolicies() { @@ -739,19 +790,6 @@ public class RoutingPoliciesTest { return tester.controllerTester(); } - public RoutingPoliciesTester(DeploymentTester tester, boolean exclusiveRouting) { - this.tester = tester; - List<ZoneApi> zones = new ArrayList<>(tester.controllerTester().zoneRegistry().zones().all().zones()); - zones.add(ZoneApiMock.from(zone3)); - zones.add(ZoneApiMock.from(zone4)); - tester.controllerTester().zoneRegistry().setZones(zones); - if (exclusiveRouting) { - tester.controllerTester().zoneRegistry().exclusiveRoutingIn(zones); - } - tester.controllerTester().configServer().bootstrap(tester.controllerTester().zoneRegistry().zones().all().ids(), - SystemApplication.notController()); - } - private void provisionLoadBalancers(int clustersPerZone, ApplicationId application, boolean shared, ZoneId... zones) { for (ZoneId zone : zones) { tester.configServer().removeLoadBalancers(application, zone); @@ -789,14 +827,21 @@ public class RoutingPoliciesTest { } private void assertTargets(ApplicationId application, EndpointId endpointId, ClusterSpec.Id cluster, int loadBalancerId, Map<ZoneId, Long> zoneWeights) { + assertTargets(application, endpointId, cluster, loadBalancerId, zoneWeights, false); + } + + private void assertTargets(ApplicationId application, EndpointId endpointId, ClusterSpec.Id cluster, int loadBalancerId, Map<ZoneId, Long> zoneWeights, boolean legacy) { Set<String> latencyTargets = new HashSet<>(); Map<String, List<ZoneId>> zonesByRegionEndpoint = new HashMap<>(); for (var zone : zoneWeights.keySet()) { - Endpoint regionEndpoint = tester.controller().routing().endpointsOf(new DeploymentId(application, zone)) - .scope(Endpoint.Scope.region) - .cluster(cluster) - .asList() - .get(0); + DeploymentId deployment = new DeploymentId(application, zone); + EndpointList regionEndpoints = tester.controller().routing().endpointsOf(deployment) + .cluster(cluster) + .scope(Endpoint.Scope.region); + if (!legacy) { + regionEndpoints = regionEndpoints.not().legacy(); + } + Endpoint regionEndpoint = regionEndpoints.first().orElseThrow(() -> new IllegalArgumentException("No region endpoint found for " + cluster + " in " + deployment)); zonesByRegionEndpoint.computeIfAbsent(regionEndpoint.dnsName(), (k) -> new ArrayList<>()) .add(zone); } |