diff options
8 files changed, 375 insertions, 476 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java index 419344eae95..d0d05befb36 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java @@ -51,12 +51,13 @@ enum PathGroup { /** Paths used for user management on the tenant level. */ tenantUsers(Matcher.tenant, PathPrefix.api, - "/user/v1/tenant/{tenant}", "/user/v1/tenant/{tenant}/info/"), + "/user/v1/tenant/{tenant}"), /** Paths used by tenant administrators. */ tenantInfo(Matcher.tenant, PathPrefix.api, "/application/v4/tenant/{tenant}/application/", + "/application/v4/tenant/{tenant}/info/", "/routing/v1/status/tenant/{tenant}/{*}"), tenantKeys(Matcher.tenant, diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java index 9e54d887952..b998ed29b71 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java @@ -134,6 +134,9 @@ public abstract class LockedTenant { return new Cloud(name, creator, keys, info); } + public Cloud withInfo(TenantInfo newInfo) { + return new Cloud(name, creator, developerKeys, newInfo); + } } } 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 9ba4b816138..0541cc91159 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 @@ -26,6 +26,7 @@ import com.yahoo.io.IOUtils; import com.yahoo.restapi.ErrorResponse; import com.yahoo.restapi.MessageResponse; import com.yahoo.restapi.Path; +import com.yahoo.restapi.ResourceResponse; import com.yahoo.restapi.SlimeJsonResponse; import com.yahoo.security.KeyUtils; import com.yahoo.slime.Cursor; @@ -45,6 +46,8 @@ import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbi import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.RestartAction; import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.ServiceInfo; import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; +import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Quota; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Application; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Cluster; import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException; @@ -69,6 +72,7 @@ import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.DeploymentMetrics; import com.yahoo.vespa.hosted.controller.application.Endpoint; import com.yahoo.vespa.hosted.controller.application.EndpointList; +import com.yahoo.vespa.hosted.controller.application.QuotaUsage; import com.yahoo.vespa.hosted.controller.application.SystemApplication; import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId; import com.yahoo.vespa.hosted.controller.deployment.DeploymentStatus; @@ -84,8 +88,12 @@ import com.yahoo.vespa.hosted.controller.rotation.RotationStatus; import com.yahoo.vespa.hosted.controller.routing.GlobalRouting; import com.yahoo.vespa.hosted.controller.security.AccessControlRequests; import com.yahoo.vespa.hosted.controller.security.Credentials; +import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.Tenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; +import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; +import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; import com.yahoo.vespa.hosted.controller.versions.VersionStatus; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.vespa.serviceview.bindings.ApplicationView; @@ -143,7 +151,6 @@ public class ApplicationApiHandler extends LoggingRequestHandler { private final Controller controller; private final AccessControlRequests accessControlRequests; private final TestConfigSerializer testConfigSerializer; - private final TenantResponses tenantResponses; @Inject public ApplicationApiHandler(LoggingRequestHandler.Context parentCtx, @@ -153,7 +160,6 @@ public class ApplicationApiHandler extends LoggingRequestHandler { this.controller = controller; this.accessControlRequests = accessControlRequests; this.testConfigSerializer = new TestConfigSerializer(controller.system()); - this.tenantResponses = new TenantResponses(controller, accessControlRequests); } @Override @@ -206,10 +212,10 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } private HttpResponse handleGET(Path path, HttpRequest request) { - if (path.matches("/application/v4/")) return tenantResponses.root(request); - if (path.matches("/application/v4/tenant")) return tenantResponses.tenants(request); - if (path.matches("/application/v4/tenant/{tenant}")) return tenantResponses.tenant(path.get("tenant"), request); - if (path.matches("/application/v4/tenant/{tenant}/info")) return tenantResponses.tenantInfo(path.get("tenant")); + if (path.matches("/application/v4/")) return root(request); + if (path.matches("/application/v4/tenant")) return tenants(request); + if (path.matches("/application/v4/tenant/{tenant}")) return tenant(path.get("tenant"), request); + if (path.matches("/application/v4/tenant/{tenant}/info")) return tenantInfo(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/application")) return applications(path.get("tenant"), Optional.empty(), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return application(path.get("tenant"), path.get("application"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}/compile-version")) return compileVersion(path.get("tenant"), path.get("application")); @@ -252,14 +258,14 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } private HttpResponse handlePUT(Path path, HttpRequest request) { - if (path.matches("/application/v4/tenant/{tenant}")) return tenantResponses.updateTenant(path.get("tenant"), request); + if (path.matches("/application/v4/tenant/{tenant}")) return updateTenant(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}/global-rotation/override")) return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), false, request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override")) return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), false, request); return ErrorResponse.notFoundError("Nothing at " + path); } private HttpResponse handlePOST(Path path, HttpRequest request) { - if (path.matches("/application/v4/tenant/{tenant}")) return tenantResponses.createTenant(path.get("tenant"), request); + if (path.matches("/application/v4/tenant/{tenant}")) return createTenant(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/key")) return addDeveloperKey(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return createApplication(path.get("tenant"), path.get("application"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying/platform")) return deployPlatform(path.get("tenant"), path.get("application"), "default", false, request); @@ -291,7 +297,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } private HttpResponse handleDELETE(Path path, HttpRequest request) { - if (path.matches("/application/v4/tenant/{tenant}")) return tenantResponses.deleteTenant(path.get("tenant"), request); + if (path.matches("/application/v4/tenant/{tenant}")) return deleteTenant(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/key")) return removeDeveloperKey(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return deleteApplication(path.get("tenant"), path.get("application"), request); if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deployment")) return removeAllProdDeployments(path.get("tenant"), path.get("application")); @@ -318,6 +324,85 @@ public class ApplicationApiHandler extends LoggingRequestHandler { return response; } + private HttpResponse recursiveRoot(HttpRequest request) { + Slime slime = new Slime(); + Cursor tenantArray = slime.setArray(); + for (Tenant tenant : controller.tenants().asList()) + toSlime(tenantArray.addObject(), tenant, request); + return new SlimeJsonResponse(slime); + } + + private HttpResponse root(HttpRequest request) { + return recurseOverTenants(request) + ? recursiveRoot(request) + : new ResourceResponse(request, "tenant"); + } + + private HttpResponse tenants(HttpRequest request) { + Slime slime = new Slime(); + Cursor response = slime.setArray(); + for (Tenant tenant : controller.tenants().asList()) + tenantInTenantsListToSlime(tenant, request.getUri(), response.addObject()); + return new SlimeJsonResponse(slime); + } + + private HttpResponse tenant(String tenantName, HttpRequest request) { + return controller.tenants().get(TenantName.from(tenantName)) + .map(tenant -> tenant(tenant, request)) + .orElseGet(() -> ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist")); + } + + private HttpResponse tenant(Tenant tenant, HttpRequest request) { + Slime slime = new Slime(); + toSlime(slime.setObject(), tenant, request); + return new SlimeJsonResponse(slime); + } + + private HttpResponse tenantInfo(String tenantName, HttpRequest request) { + return controller.tenants().get(TenantName.from(tenantName)) + .filter(tenant -> tenant.type() == Tenant.Type.cloud) + .map(tenant -> tenantInfo(((CloudTenant)tenant).info(), request)) + .orElseGet(() -> ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist or does not support this")); + } + + private SlimeJsonResponse tenantInfo(TenantInfo info, HttpRequest request) { + Slime slime = new Slime(); + Cursor infoCursor = slime.setObject(); + if (!info.isEmpty()) { + infoCursor.setString("name", info.name()); + infoCursor.setString("email", info.email()); + infoCursor.setString("website", info.website()); + infoCursor.setString("invoiceEmail", info.invoiceEmail()); + infoCursor.setString("contactName", info.contactName()); + infoCursor.setString("contactEmail", info.contactEmail()); + toSlime(info.address(), infoCursor); + toSlime(info.billingContact(), infoCursor); + } + + return new SlimeJsonResponse(slime); + } + + private void toSlime(TenantInfoAddress address, Cursor parentCursor) { + if (address.isEmpty()) return; + + Cursor addressCursor = parentCursor.setObject("address"); + addressCursor.setString("addressLines", address.addressLines()); + addressCursor.setString("postalCodeOrZip", address.postalCodeOrZip()); + addressCursor.setString("city", address.city()); + addressCursor.setString("stateRegionProvince", address.stateRegionProvince()); + addressCursor.setString("country", address.country()); + } + + private void toSlime(TenantInfoBillingContact billingContact, Cursor parentCursor) { + if (billingContact.isEmpty()) return; + + Cursor addressCursor = parentCursor.setObject("billingContact"); + addressCursor.setString("name", billingContact.name()); + addressCursor.setString("email", billingContact.email()); + addressCursor.setString("phone", billingContact.phone()); + toSlime(billingContact.address(), addressCursor); + } + private HttpResponse applications(String tenantName, Optional<String> applicationName, HttpRequest request) { TenantName tenant = TenantName.from(tenantName); if (controller.tenants().get(tenantName).isEmpty()) @@ -407,7 +492,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { private HttpResponse instance(String tenantName, String applicationName, String instanceName, HttpRequest request) { Slime slime = new Slime(); toSlime(slime.setObject(), getInstance(tenantName, applicationName, instanceName), - controller.jobController().deploymentStatus(getApplication(tenantName, applicationName)), request, controller); + controller.jobController().deploymentStatus(getApplication(tenantName, applicationName)), request); return new SlimeJsonResponse(slime); } @@ -754,7 +839,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } // Global endpoints - globalEndpointsToSlime(object, instance, controller); + globalEndpointsToSlime(object, instance); // Deployments sorted according to deployment spec List<Deployment> deployments = deploymentSpec.instance(instance.name()) @@ -771,7 +856,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { toSlime(instance.rotations(), instance.rotationStatus(), deployment, deploymentObject); if (recurseOverDeployments(request)) // List full deployment information when recursive. - toSlime(deploymentObject, new DeploymentId(instance.id(), deployment.zone()), deployment, request, controller); + toSlime(deploymentObject, new DeploymentId(instance.id(), deployment.zone()), deployment, request); else { deploymentObject.setString("environment", deployment.zone().environment().value()); deploymentObject.setString("region", deployment.zone().region().value()); @@ -785,7 +870,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } // TODO(mpolden): Remove once legacy dashboard and integration tests stop expecting these fields - private static void globalEndpointsToSlime(Cursor object, Instance instance, Controller controller) { + private void globalEndpointsToSlime(Cursor object, Instance instance) { var globalEndpointUrls = new LinkedHashSet<String>(); // Add global endpoints backed by rotations @@ -797,6 +882,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { .map(URI::toString) .forEach(globalEndpointUrls::add); + var globalRotationsArray = object.setArray("globalRotations"); globalEndpointUrls.forEach(globalRotationsArray::addString); @@ -807,7 +893,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { .ifPresent(rotation -> object.setString("rotationId", rotation.asString())); } - static void toSlime(Cursor object, Instance instance, DeploymentStatus status, HttpRequest request, Controller controller) { + private void toSlime(Cursor object, Instance instance, DeploymentStatus status, HttpRequest request) { com.yahoo.vespa.hosted.controller.Application application = status.application(); object.setString("tenant", instance.id().tenant().value()); object.setString("application", instance.id().application().value()); @@ -856,7 +942,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { application.majorVersion().ifPresent(majorVersion -> object.setLong("majorVersion", majorVersion)); // Global endpoint - globalEndpointsToSlime(object, instance, controller); + globalEndpointsToSlime(object, instance); // Deployments sorted according to deployment spec List<Deployment> deployments = @@ -885,7 +971,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } if (recurseOverDeployments(request)) // List full deployment information when recursive. - toSlime(deploymentObject, new DeploymentId(instance.id(), deployment.zone()), deployment, request, controller); + toSlime(deploymentObject, new DeploymentId(instance.id(), deployment.zone()), deployment, request); else { deploymentObject.setString("environment", deployment.zone().environment().value()); deploymentObject.setString("region", deployment.zone().region().value()); @@ -943,18 +1029,18 @@ public class ApplicationApiHandler extends LoggingRequestHandler { throw new NotExistsException(instance + " is not deployed in " + deploymentId.zoneId()); Slime slime = new Slime(); - toSlime(slime.setObject(), deploymentId, deployment, request, controller); + toSlime(slime.setObject(), deploymentId, deployment, request); return new SlimeJsonResponse(slime); } - private static void toSlime(Cursor object, Change change) { + private void toSlime(Cursor object, Change change) { change.platform().ifPresent(version -> object.setString("version", version.toString())); change.application() .filter(version -> !version.isUnknown()) .ifPresent(version -> toSlime(version, object.setObject("revision"))); } - private static void toSlime(Endpoint endpoint, Cursor object) { + private void toSlime(Endpoint endpoint, Cursor object) { object.setString("cluster", endpoint.cluster().value()); object.setBool("tls", endpoint.tls()); object.setString("url", endpoint.url().toString()); @@ -962,7 +1048,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { object.setString("routingMethod", routingMethodString(endpoint.routingMethod())); } - private static void toSlime(Cursor response, DeploymentId deploymentId, Deployment deployment, HttpRequest request, Controller controller) { + private void toSlime(Cursor response, DeploymentId deploymentId, Deployment deployment, HttpRequest request) { response.setString("tenant", deploymentId.applicationId().tenant().value()); response.setString("application", deploymentId.applicationId().application().value()); response.setString("instance", deploymentId.applicationId().instance().value()); // pointless @@ -987,7 +1073,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } response.setString("nodes", withPathAndQuery("/zone/v2/" + deploymentId.zoneId().environment() + "/" + deploymentId.zoneId().region() + "/nodes/v2/node/", "recursive=true&application=" + deploymentId.applicationId().tenant() + "." + deploymentId.applicationId().application() + "." + deploymentId.applicationId().instance(), request.getUri()).toString()); - response.setString("yamasUrl", monitoringSystemUri(deploymentId, controller).toString()); + response.setString("yamasUrl", monitoringSystemUri(deploymentId).toString()); response.setString("version", deployment.version().toFullString()); response.setString("revision", deployment.applicationVersion().id()); response.setLong("deployTimeEpochMs", deployment.at().toEpochMilli()); @@ -1036,7 +1122,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { metrics.instant().ifPresent(instant -> metricsObject.setLong("lastUpdated", instant.toEpochMilli())); } - private static void toSlime(ApplicationVersion applicationVersion, Cursor object) { + private void toSlime(ApplicationVersion applicationVersion, Cursor object) { if ( ! applicationVersion.isUnknown()) { object.setLong("buildNumber", applicationVersion.buildNumber().getAsLong()); object.setString("hash", applicationVersion.id()); @@ -1046,19 +1132,19 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } } - static void sourceRevisionToSlime(Optional<SourceRevision> revision, Cursor object) { + private void sourceRevisionToSlime(Optional<SourceRevision> revision, Cursor object) { if (revision.isEmpty()) return; object.setString("gitRepository", revision.get().repository()); object.setString("gitBranch", revision.get().branch()); object.setString("gitCommit", revision.get().commit()); } - private static void toSlime(RotationState state, Cursor object) { + private void toSlime(RotationState state, Cursor object) { Cursor bcpStatus = object.setObject("bcpStatus"); bcpStatus.setString("rotationStatus", rotationStateString(state)); } - private static void toSlime(List<AssignedRotation> rotations, RotationStatus status, Deployment deployment, Cursor object) { + private void toSlime(List<AssignedRotation> rotations, RotationStatus status, Deployment deployment, Cursor object) { var array = object.setArray("endpointStatus"); for (var rotation : rotations) { var statusObject = array.addObject(); @@ -1071,7 +1157,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } } - private static URI monitoringSystemUri(DeploymentId deploymentId, Controller controller) { + private URI monitoringSystemUri(DeploymentId deploymentId) { return controller.zoneRegistry().getMonitoringSystemUri(deploymentId); } @@ -1204,28 +1290,33 @@ public class ApplicationApiHandler extends LoggingRequestHandler { Cursor detailsMem = details.setObject("mem"); Cursor detailsDisk = details.setObject("disk"); - history.forEach((key, value) -> { - String instanceName = key.instance().value(); - Cursor detailsCpuApp = detailsCpu.setObject(instanceName); - Cursor detailsMemApp = detailsMem.setObject(instanceName); - Cursor detailsDiskApp = detailsDisk.setObject(instanceName); - Cursor detailsCpuData = detailsCpuApp.setArray("data"); - Cursor detailsMemData = detailsMemApp.setArray("data"); - Cursor detailsDiskData = detailsDiskApp.setArray("data"); - value.forEach(resourceSnapshot -> { - Cursor cpu = detailsCpuData.addObject(); - cpu.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); - cpu.setDouble("value", resourceSnapshot.getCpuCores()); - - Cursor mem = detailsMemData.addObject(); - mem.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); - mem.setDouble("value", resourceSnapshot.getMemoryGb()); - - Cursor disk = detailsDiskData.addObject(); - disk.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); - disk.setDouble("value", resourceSnapshot.getDiskGb()); - }); - }); + history.entrySet().stream() + .forEach(entry -> { + String instanceName = entry.getKey().instance().value(); + Cursor detailsCpuApp = detailsCpu.setObject(instanceName); + Cursor detailsMemApp = detailsMem.setObject(instanceName); + Cursor detailsDiskApp = detailsDisk.setObject(instanceName); + Cursor detailsCpuData = detailsCpuApp.setArray("data"); + Cursor detailsMemData = detailsMemApp.setArray("data"); + Cursor detailsDiskData = detailsDiskApp.setArray("data"); + entry.getValue().stream() + .forEach(resourceSnapshot -> { + + Cursor cpu = detailsCpuData.addObject(); + cpu.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); + cpu.setDouble("value", resourceSnapshot.getCpuCores()); + + Cursor mem = detailsMemData.addObject(); + mem.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); + mem.setDouble("value", resourceSnapshot.getMemoryGb()); + + Cursor disk = detailsDiskData.addObject(); + disk.setLong("unixms", resourceSnapshot.getTimestamp().toEpochMilli()); + disk.setDouble("value", resourceSnapshot.getDiskGb()); + + }); + + }); return new SlimeJsonResponse(slime); } @@ -1284,6 +1375,23 @@ public class ApplicationApiHandler extends LoggingRequestHandler { return controller.serviceRegistry().configServer().getApplicationPackageContent(deploymentId, "/" + restPath, request.getUri()); } + private HttpResponse updateTenant(String tenantName, HttpRequest request) { + getTenantOrThrow(tenantName); + TenantName tenant = TenantName.from(tenantName); + Inspector requestObject = toSlime(request.getData()).get(); + controller.tenants().update(accessControlRequests.specification(tenant, requestObject), + accessControlRequests.credentials(tenant, requestObject, request.getJDiscRequest())); + return tenant(controller.tenants().require(TenantName.from(tenantName)), request); + } + + private HttpResponse createTenant(String tenantName, HttpRequest request) { + TenantName tenant = TenantName.from(tenantName); + Inspector requestObject = toSlime(request.getData()).get(); + controller.tenants().create(accessControlRequests.specification(tenant, requestObject), + accessControlRequests.credentials(tenant, requestObject, request.getJDiscRequest())); + return tenant(controller.tenants().require(TenantName.from(tenantName)), request); + } + private HttpResponse createApplication(String tenantName, String applicationName, HttpRequest request) { Inspector requestObject = toSlime(request.getData()).get(); TenantAndApplicationId id = TenantAndApplicationId.from(tenantName, applicationName); @@ -1517,6 +1625,20 @@ public class ApplicationApiHandler extends LoggingRequestHandler { return new SlimeJsonResponse(toSlime(result)); } + private HttpResponse deleteTenant(String tenantName, HttpRequest request) { + Optional<Tenant> tenant = controller.tenants().get(tenantName); + if (tenant.isEmpty()) + return ErrorResponse.notFoundError("Could not delete tenant '" + tenantName + "': Tenant not found"); + + controller.tenants().delete(tenant.get().name(), + accessControlRequests.credentials(tenant.get().name(), + toSlime(request.getData()).get(), + request.getJDiscRequest())); + + // TODO: Change to a message response saying the tenant was deleted + return tenant(tenant.get(), request); + } + private HttpResponse deleteApplication(String tenantName, String applicationName, HttpRequest request) { TenantAndApplicationId id = TenantAndApplicationId.from(tenantName, applicationName); Credentials credentials = accessControlRequests.credentials(id.tenant(), toSlime(request.getData()).get(), request.getJDiscRequest()); @@ -1576,6 +1698,78 @@ public class ApplicationApiHandler extends LoggingRequestHandler { object.field("commit").asString()); } + private Tenant getTenantOrThrow(String tenantName) { + return controller.tenants().get(tenantName) + .orElseThrow(() -> new NotExistsException(new TenantId(tenantName))); + } + + private void toSlime(Cursor object, Tenant tenant, HttpRequest request) { + object.setString("tenant", tenant.name().value()); + object.setString("type", tenantType(tenant)); + List<com.yahoo.vespa.hosted.controller.Application> applications = controller.applications().asList(tenant.name()); + switch (tenant.type()) { + case athenz: + AthenzTenant athenzTenant = (AthenzTenant) tenant; + object.setString("athensDomain", athenzTenant.domain().getName()); + object.setString("property", athenzTenant.property().id()); + athenzTenant.propertyId().ifPresent(id -> object.setString("propertyId", id.toString())); + athenzTenant.contact().ifPresent(c -> { + object.setString("propertyUrl", c.propertyUrl().toString()); + object.setString("contactsUrl", c.url().toString()); + object.setString("issueCreationUrl", c.issueTrackerUrl().toString()); + Cursor contactsArray = object.setArray("contacts"); + c.persons().forEach(persons -> { + Cursor personArray = contactsArray.addArray(); + persons.forEach(personArray::addString); + }); + }); + break; + case cloud: { + CloudTenant cloudTenant = (CloudTenant) tenant; + + cloudTenant.creator().ifPresent(creator -> object.setString("creator", creator.getName())); + Cursor pemDeveloperKeysArray = object.setArray("pemDeveloperKeys"); + cloudTenant.developerKeys().forEach((key, user) -> { + Cursor keyObject = pemDeveloperKeysArray.addObject(); + keyObject.setString("key", KeyUtils.toPem(key)); + keyObject.setString("user", user.getName()); + }); + + var tenantQuota = controller.serviceRegistry().billingController().getQuota(tenant.name()); + var usedQuota = applications.stream() + .map(com.yahoo.vespa.hosted.controller.Application::quotaUsage) + .reduce(QuotaUsage.none, QuotaUsage::add); + + toSlime(tenantQuota, usedQuota, object.setObject("quota")); + + break; + } + default: throw new IllegalArgumentException("Unexpected tenant type '" + tenant.type() + "'."); + } + // TODO jonmv: This should list applications, not instances. + Cursor applicationArray = object.setArray("applications"); + for (com.yahoo.vespa.hosted.controller.Application application : applications) { + DeploymentStatus status = controller.jobController().deploymentStatus(application); + for (Instance instance : showOnlyProductionInstances(request) ? application.productionInstances().values() + : application.instances().values()) + if (recurseOverApplications(request)) + toSlime(applicationArray.addObject(), instance, status, request); + else + toSlime(instance.id(), applicationArray.addObject(), request); + } + } + + private void toSlime(Quota quota, QuotaUsage usage, Cursor object) { + quota.budget().ifPresentOrElse( + budget -> object.setDouble("budget", budget.doubleValue()), + () -> object.setNix("budget") + ); + object.setDouble("budgetUsed", usage.rate()); + + // TODO: Retire when we no longer use maxClusterSize as a meaningful limit + quota.maxClusterSize().ifPresent(maxClusterSize -> object.setLong("clusterSize", maxClusterSize)); + } + private void toSlime(ClusterResources resources, Cursor object) { object.setLong("nodes", resources.nodes()); object.setLong("groups", resources.groups()); @@ -1593,8 +1787,25 @@ public class ApplicationApiHandler extends LoggingRequestHandler { object.setString("storageType", valueOf(resources.storageType())); } + // A tenant has different content when in a list ... antipattern, but not solvable before application/v5 + private void tenantInTenantsListToSlime(Tenant tenant, URI requestURI, Cursor object) { + object.setString("tenant", tenant.name().value()); + Cursor metaData = object.setObject("metaData"); + metaData.setString("type", tenantType(tenant)); + switch (tenant.type()) { + case athenz: + AthenzTenant athenzTenant = (AthenzTenant) tenant; + metaData.setString("athensDomain", athenzTenant.domain().getName()); + metaData.setString("property", athenzTenant.property().id()); + break; + case cloud: break; + default: throw new IllegalArgumentException("Unexpected tenant type '" + tenant.type() + "'."); + } + object.setString("url", withPath("/application/v4/tenant/" + tenant.name().value(), requestURI).toString()); + } + /** Returns a copy of the given URI with the host and port from the given URI, the path set to the given path and the query set to given query*/ - static URI withPathAndQuery(String newPath, String newQuery, URI uri) { + private URI withPathAndQuery(String newPath, String newQuery, URI uri) { try { return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), newPath, newQuery, null); } @@ -1604,7 +1815,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { } /** Returns a copy of the given URI with the host and port from the given URI and the path set to the given path */ - static URI withPath(String newPath, URI uri) { + private URI withPath(String newPath, URI uri) { return withPathAndQuery(newPath, null, uri); } @@ -1627,7 +1838,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { object.setLong("at", run.end().orElse(run.start()).toEpochMilli()); } - static Slime toSlime(InputStream jsonStream) { + private Slime toSlime(InputStream jsonStream) { try { byte[] jsonBytes = IOUtils.readBytes(jsonStream, 1000 * 1000); return SlimeUtils.jsonToSlime(jsonBytes); @@ -1665,7 +1876,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { request.getUri()).toString()); } - static void toSlime(ApplicationId id, Cursor object, HttpRequest request) { + private void toSlime(ApplicationId id, Cursor object, HttpRequest request) { object.setString("tenant", id.tenant().value()); object.setString("application", id.application().value()); object.setString("instance", id.instance().value()); @@ -1737,14 +1948,30 @@ public class ApplicationApiHandler extends LoggingRequestHandler { return scanner.next(); } + private static boolean recurseOverTenants(HttpRequest request) { + return recurseOverApplications(request) || "tenant".equals(request.getProperty("recursive")); + } + + private static boolean recurseOverApplications(HttpRequest request) { + return recurseOverDeployments(request) || "application".equals(request.getProperty("recursive")); + } + private static boolean recurseOverDeployments(HttpRequest request) { return ImmutableSet.of("all", "true", "deployment").contains(request.getProperty("recursive")); } - static boolean showOnlyProductionInstances(HttpRequest request) { + private static boolean showOnlyProductionInstances(HttpRequest request) { return "true".equals(request.getProperty("production")); } + private static String tenantType(Tenant tenant) { + switch (tenant.type()) { + case athenz: return "ATHENS"; + case cloud: return "CLOUD"; + default: throw new IllegalArgumentException("Unknown tenant type: " + tenant.getClass().getSimpleName()); + } + } + private static ApplicationId appIdFromPath(Path path) { return ApplicationId.from(path.get("tenant"), path.get("application"), path.get("instance")); } @@ -1872,4 +2099,6 @@ public class ApplicationApiHandler extends LoggingRequestHandler { .map(Role::definition) .anyMatch(definition -> definition == RoleDefinition.hostedOperator); } + } + diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponses.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponses.java deleted file mode 100644 index 3ae50e17ef7..00000000000 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponses.java +++ /dev/null @@ -1,263 +0,0 @@ -package com.yahoo.vespa.hosted.controller.restapi.application; - -import com.google.common.collect.ImmutableSet; -import com.yahoo.config.provision.TenantName; -import com.yahoo.container.jdisc.HttpRequest; -import com.yahoo.container.jdisc.HttpResponse; -import com.yahoo.restapi.ErrorResponse; -import com.yahoo.restapi.ResourceResponse; -import com.yahoo.restapi.SlimeJsonResponse; -import com.yahoo.security.KeyUtils; -import com.yahoo.slime.Cursor; -import com.yahoo.slime.Inspector; -import com.yahoo.slime.Slime; -import com.yahoo.vespa.hosted.controller.Application; -import com.yahoo.vespa.hosted.controller.Controller; -import com.yahoo.vespa.hosted.controller.Instance; -import com.yahoo.vespa.hosted.controller.NotExistsException; -import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId; -import com.yahoo.vespa.hosted.controller.api.integration.billing.Quota; -import com.yahoo.vespa.hosted.controller.application.QuotaUsage; -import com.yahoo.vespa.hosted.controller.deployment.DeploymentStatus; -import com.yahoo.vespa.hosted.controller.security.AccessControlRequests; -import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; -import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; -import com.yahoo.vespa.hosted.controller.tenant.Tenant; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; - -import java.net.URI; -import java.util.List; -import java.util.Optional; - -public class TenantResponses { - - private final Controller controller; - private final AccessControlRequests accessControlRequests; - - TenantResponses(Controller controller, AccessControlRequests accessControlRequests) { - this.controller = controller; - this.accessControlRequests = accessControlRequests; - } - - private static boolean recurseOverDeployments(HttpRequest request) { - return ImmutableSet.of("all", "true", "deployment").contains(request.getProperty("recursive")); - } - - private static boolean recurseOverApplications(HttpRequest request) { - return recurseOverDeployments(request) || "application".equals(request.getProperty("recursive")); - } - - private static boolean recurseOverTenants(HttpRequest request) { - return recurseOverApplications(request) || "tenant".equals(request.getProperty("recursive")); - } - - HttpResponse root(HttpRequest request) { - return recurseOverTenants(request) - ? recursiveRoot(request) - : new ResourceResponse(request, "tenant"); - } - - HttpResponse recursiveRoot(HttpRequest request) { - Slime slime = new Slime(); - Cursor tenantArray = slime.setArray(); - for (Tenant tenant : controller.tenants().asList()) - toSlime(tenantArray.addObject(), tenant, request); - return new SlimeJsonResponse(slime); - } - - HttpResponse tenants(HttpRequest request) { - Slime slime = new Slime(); - Cursor response = slime.setArray(); - for (Tenant tenant : controller.tenants().asList()) - tenantInTenantsListToSlime(tenant, request.getUri(), response.addObject()); - return new SlimeJsonResponse(slime); - } - - HttpResponse tenant(String tenantName, HttpRequest request) { - return controller.tenants().get(TenantName.from(tenantName)) - .map(tenant -> tenant(tenant, request)) - .orElseGet(() -> ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist")); - } - - private HttpResponse tenant(Tenant tenant, HttpRequest request) { - Slime slime = new Slime(); - toSlime(slime.setObject(), tenant, request); - return new SlimeJsonResponse(slime); - } - - HttpResponse tenantInfo(String tenantName) { - return controller.tenants().get(TenantName.from(tenantName)) - .filter(tenant -> tenant instanceof CloudTenant) - .map(tenant -> (CloudTenant)tenant) - .map(tenant -> tenantInfo(tenant.info())) - .orElseGet(() -> ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist")); - } - - private HttpResponse tenantInfo(TenantInfo info) { - Slime slime = new Slime(); - Cursor infoCursor = slime.setObject(); - if (!info.isEmpty()) { - infoCursor.setString("name", info.name()); - infoCursor.setString("email", info.email()); - infoCursor.setString("website", info.website()); - infoCursor.setString("invoiceEmail", info.invoiceEmail()); - infoCursor.setString("contactName", info.contactName()); - infoCursor.setString("contactEmail", info.contactEmail()); - toSlime(info.address(), infoCursor); - toSlime(info.billingContact(), infoCursor); - } - - return new SlimeJsonResponse(slime); - } - - private void toSlime(TenantInfoAddress address, Cursor parentCursor) { - if (address.isEmpty()) return; - - Cursor addressCursor = parentCursor.setObject("address"); - addressCursor.setString("addressLines", address.addressLines()); - addressCursor.setString("postalCodeOrZip", address.postalCodeOrZip()); - addressCursor.setString("city", address.city()); - addressCursor.setString("stateRegionProvince", address.stateRegionProvince()); - addressCursor.setString("country", address.country()); - } - - private void toSlime(TenantInfoBillingContact billingContact, Cursor parentCursor) { - if (billingContact.isEmpty()) return; - - Cursor addressCursor = parentCursor.setObject("billingContact"); - addressCursor.setString("name", billingContact.name()); - addressCursor.setString("email", billingContact.email()); - addressCursor.setString("phone", billingContact.phone()); - toSlime(billingContact.address(), addressCursor); - } - - // A tenant has different content when in a list ... antipattern, but not solvable before application/v5 - private void tenantInTenantsListToSlime(Tenant tenant, URI requestURI, Cursor object) { - object.setString("tenant", tenant.name().value()); - Cursor metaData = object.setObject("metaData"); - metaData.setString("type", tenantType(tenant)); - switch (tenant.type()) { - case athenz: - AthenzTenant athenzTenant = (AthenzTenant) tenant; - metaData.setString("athensDomain", athenzTenant.domain().getName()); - metaData.setString("property", athenzTenant.property().id()); - break; - case cloud: break; - default: throw new IllegalArgumentException("Unexpected tenant type '" + tenant.type() + "'."); - } - object.setString("url", ApplicationApiHandler.withPath("/application/v4/tenant/" + tenant.name().value(), requestURI).toString()); - } - - private Tenant getTenantOrThrow(String tenantName) { - return controller.tenants().get(tenantName) - .orElseThrow(() -> new NotExistsException(new TenantId(tenantName))); - } - - private void toSlime(Cursor object, Tenant tenant, HttpRequest request) { - object.setString("tenant", tenant.name().value()); - object.setString("type", tenantType(tenant)); - List<Application> applications = controller.applications().asList(tenant.name()); - switch (tenant.type()) { - case athenz: - AthenzTenant athenzTenant = (AthenzTenant) tenant; - object.setString("athensDomain", athenzTenant.domain().getName()); - object.setString("property", athenzTenant.property().id()); - athenzTenant.propertyId().ifPresent(id -> object.setString("propertyId", id.toString())); - athenzTenant.contact().ifPresent(c -> { - object.setString("propertyUrl", c.propertyUrl().toString()); - object.setString("contactsUrl", c.url().toString()); - object.setString("issueCreationUrl", c.issueTrackerUrl().toString()); - Cursor contactsArray = object.setArray("contacts"); - c.persons().forEach(persons -> { - Cursor personArray = contactsArray.addArray(); - persons.forEach(personArray::addString); - }); - }); - break; - case cloud: { - CloudTenant cloudTenant = (CloudTenant) tenant; - - cloudTenant.creator().ifPresent(creator -> object.setString("creator", creator.getName())); - Cursor pemDeveloperKeysArray = object.setArray("pemDeveloperKeys"); - cloudTenant.developerKeys().forEach((key, user) -> { - Cursor keyObject = pemDeveloperKeysArray.addObject(); - keyObject.setString("key", KeyUtils.toPem(key)); - keyObject.setString("user", user.getName()); - }); - - var tenantQuota = controller.serviceRegistry().billingController().getQuota(tenant.name()); - var usedQuota = applications.stream() - .map(com.yahoo.vespa.hosted.controller.Application::quotaUsage) - .reduce(QuotaUsage.none, QuotaUsage::add); - - toSlime(tenantQuota, usedQuota, object.setObject("quota")); - - break; - } - default: throw new IllegalArgumentException("Unexpected tenant type '" + tenant.type() + "'."); - } - // TODO jonmv: This should list applications, not instances. - Cursor applicationArray = object.setArray("applications"); - for (com.yahoo.vespa.hosted.controller.Application application : applications) { - DeploymentStatus status = controller.jobController().deploymentStatus(application); - for (Instance instance : ApplicationApiHandler.showOnlyProductionInstances(request) ? application.productionInstances().values() - : application.instances().values()) - if (recurseOverApplications(request)) - ApplicationApiHandler.toSlime(applicationArray.addObject(), instance, status, request, controller); - else - ApplicationApiHandler.toSlime(instance.id(), applicationArray.addObject(), request); - } - } - - HttpResponse updateTenant(String tenantName, HttpRequest request) { - getTenantOrThrow(tenantName); - TenantName tenant = TenantName.from(tenantName); - Inspector requestObject = ApplicationApiHandler.toSlime(request.getData()).get(); - controller.tenants().update(accessControlRequests.specification(tenant, requestObject), - accessControlRequests.credentials(tenant, requestObject, request.getJDiscRequest())); - return tenant(controller.tenants().require(TenantName.from(tenantName)), request); - } - - HttpResponse createTenant(String tenantName, HttpRequest request) { - TenantName tenant = TenantName.from(tenantName); - Inspector requestObject = ApplicationApiHandler.toSlime(request.getData()).get(); - controller.tenants().create(accessControlRequests.specification(tenant, requestObject), - accessControlRequests.credentials(tenant, requestObject, request.getJDiscRequest())); - return tenant(controller.tenants().require(TenantName.from(tenantName)), request); - } - - HttpResponse deleteTenant(String tenantName, HttpRequest request) { - Optional<Tenant> tenant = controller.tenants().get(tenantName); - if (tenant.isEmpty()) - return ErrorResponse.notFoundError("Could not delete tenant '" + tenantName + "': Tenant not found"); - - controller.tenants().delete(tenant.get().name(), - accessControlRequests.credentials(tenant.get().name(), - ApplicationApiHandler.toSlime(request.getData()).get(), - request.getJDiscRequest())); - - // TODO: Change to a message response saying the tenant was deleted - return tenant(tenant.get(), request); - } - - private static String tenantType(Tenant tenant) { - switch (tenant.type()) { - case athenz: return "ATHENS"; - case cloud: return "CLOUD"; - default: throw new IllegalArgumentException("Unknown tenant type: " + tenant.getClass().getSimpleName()); - } - } - - private void toSlime(Quota quota, QuotaUsage usage, Cursor object) { - quota.budget().ifPresentOrElse( - budget -> object.setDouble("budget", budget.doubleValue()), - () -> object.setNix("budget") - ); - object.setDouble("budgetUsed", usage.rate()); - - // TODO: Retire when we no longer use maxClusterSize as a meaningful limit - quota.maxClusterSize().ifPresent(maxClusterSize -> object.setLong("clusterSize", maxClusterSize)); - } -} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java index a1b06262241..fe3a579ccbf 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import java.util.Collections; import java.util.Set; +import static com.yahoo.application.container.handler.Request.Method.GET; import static com.yahoo.application.container.handler.Request.Method.POST; import static com.yahoo.vespa.hosted.controller.restapi.application.ApplicationApiTest.createApplicationSubmissionData; @@ -44,12 +45,11 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { .withBooleanFlag(Flags.ENABLE_PUBLIC_SIGNUP_FLOW.id(), true); deploymentTester = new DeploymentTester(new ControllerTester(tester)); deploymentTester.controllerTester().computeVersionStatus(); + setupTenantAndApplication(); } @Test public void test_missing_security_clients_pem() { - setupTenantAndApplication(); - var application = prodBuilder().build(); var deployRequest = request("/application/v4/tenant/scoober/application/albums/submit", POST) @@ -62,6 +62,13 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { 400); } + @Test + public void get_empty_tenant_info() { + var infoRequest = + request("/application/v4/tenant/scoober/info", GET) + .roles(Set.of(Role.developer(tenantName))); + tester.assertResponse(infoRequest, "{}", 200); + } private ApplicationPackageBuilder prodBuilder() { return new ApplicationPackageBuilder() 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 3b08d622300..df3733abe21 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 @@ -15,6 +15,7 @@ import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.zone.RoutingMethod; import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.vespa.athenz.api.AthenzDomain; +import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzPrincipal; import com.yahoo.vespa.athenz.api.AthenzUser; import com.yahoo.vespa.athenz.api.OktaAccessToken; @@ -66,7 +67,9 @@ import com.yahoo.vespa.hosted.controller.routing.GlobalRouting; import com.yahoo.vespa.hosted.controller.security.AthenzCredentials; import com.yahoo.vespa.hosted.controller.security.AthenzTenantSpec; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; +import com.yahoo.yolean.Exceptions; import org.junit.Before; import org.junit.Test; @@ -75,19 +78,24 @@ import java.net.URI; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Supplier; import static com.yahoo.application.container.handler.Request.Method.DELETE; import static com.yahoo.application.container.handler.Request.Method.GET; import static com.yahoo.application.container.handler.Request.Method.PATCH; import static com.yahoo.application.container.handler.Request.Method.POST; import static com.yahoo.application.container.handler.Request.Method.PUT; -import static com.yahoo.vespa.hosted.controller.restapi.application.RequestBuilder.request; +import static java.net.URLEncoder.encode; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.joining; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -189,6 +197,10 @@ public class ApplicationApiTest extends ControllerContainerTest { tester.assertResponse(request("/application/v4/tenant/tenant2", GET).userIdentity(USER_ID), new File("tenant-with-contact-info.json")); + // GET tenant info + tester.assertResponse(request("/application/v4/tenant/tenant2", GET).userIdentity(USER_ID), + new File("tenant-with-contact-info.json")); + // POST (create) an application tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1", POST) .userIdentity(USER_ID) @@ -1518,20 +1530,21 @@ public class ApplicationApiTest extends ControllerContainerTest { "}"; } + /** Make a request with (athens) user domain1.mytenant */ + private RequestBuilder request(String path, Request.Method method) { + return new RequestBuilder(path, method); + } + /** * In production this happens outside hosted Vespa, so there is no API for it and we need to reach down into the * mock setup to replicate the action. */ - static void createAthenzDomainWithAdmin(AthenzDomain domain, UserId userId, ContainerTester tester) { + private void createAthenzDomainWithAdmin(AthenzDomain domain, UserId userId) { AthenzDbMock.Domain domainMock = tester.athenzClientFactory().getSetup().getOrCreateDomain(domain); domainMock.markAsVespaTenant(); domainMock.admin(AthenzUser.fromUserId(userId.id())); } - private void createAthenzDomainWithAdmin(AthenzDomain domain, UserId userId) { - createAthenzDomainWithAdmin(domain, userId, tester); - } - /** * Mock athenz service identity configuration. Simulates that configserver is allowed to launch a service */ @@ -1643,4 +1656,64 @@ public class ApplicationApiTest extends ControllerContainerTest { assertEquals(agent, westPolicy.status().globalRouting().agent()); assertEquals(changedAt.truncatedTo(ChronoUnit.MILLIS), westPolicy.status().globalRouting().changedAt()); } + + private static class RequestBuilder implements Supplier<Request> { + + private final String path; + private final Request.Method method; + private byte[] data = new byte[0]; + private AthenzIdentity identity; + private OktaIdentityToken oktaIdentityToken; + private OktaAccessToken oktaAccessToken; + private String contentType = "application/json"; + private final Map<String, List<String>> headers = new HashMap<>(); + private final Map<String, String> properties = new HashMap<>(); + + private RequestBuilder(String path, Request.Method method) { + this.path = path; + this.method = method; + } + + private RequestBuilder data(byte[] data) { this.data = data; return this; } + private RequestBuilder data(String data) { return data(data.getBytes(UTF_8)); } + private RequestBuilder data(MultiPartStreamer streamer) { + return Exceptions.uncheck(() -> data(streamer.data().readAllBytes()).contentType(streamer.contentType())); + } + + private RequestBuilder userIdentity(UserId userId) { this.identity = HostedAthenzIdentities.from(userId); return this; } + private RequestBuilder screwdriverIdentity(ScrewdriverId screwdriverId) { this.identity = HostedAthenzIdentities.from(screwdriverId); return this; } + private RequestBuilder oktaIdentityToken(OktaIdentityToken oktaIdentityToken) { this.oktaIdentityToken = oktaIdentityToken; return this; } + private RequestBuilder oktaAccessToken(OktaAccessToken oktaAccessToken) { this.oktaAccessToken = oktaAccessToken; return this; } + private RequestBuilder contentType(String contentType) { this.contentType = contentType; return this; } + private RequestBuilder recursive(String recursive) {return properties(Map.of("recursive", recursive)); } + private RequestBuilder properties(Map<String, String> properties) { this.properties.putAll(properties); return this; } + private RequestBuilder header(String name, String value) { + this.headers.putIfAbsent(name, new ArrayList<>()); + this.headers.get(name).add(value); + return this; + } + + @Override + public Request get() { + Request request = new Request("http://localhost:8080" + path + + properties.entrySet().stream() + .map(entry -> encode(entry.getKey(), UTF_8) + "=" + encode(entry.getValue(), UTF_8)) + .collect(joining("&", "?", "")), + data, method); + request.getHeaders().addAll(headers); + request.getHeaders().put("Content-Type", contentType); + // user and domain parameters are translated to a Principal by MockAuthorizer as we do not run HTTP filters + if (identity != null) { + addIdentityToRequest(request, identity); + } + if (oktaIdentityToken != null) { + addOktaIdentityToken(request, oktaIdentityToken); + } + if (oktaAccessToken != null) { + addOktaAccessToken(request, oktaAccessToken); + } + return request; + } + } + } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/RequestBuilder.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/RequestBuilder.java deleted file mode 100644 index 53ace941690..00000000000 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/RequestBuilder.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.yahoo.vespa.hosted.controller.restapi.application; - -import ai.vespa.hosted.api.MultiPartStreamer; -import com.yahoo.application.container.handler.Request; -import com.yahoo.vespa.athenz.api.AthenzIdentity; -import com.yahoo.vespa.athenz.api.OktaAccessToken; -import com.yahoo.vespa.athenz.api.OktaIdentityToken; -import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId; -import com.yahoo.vespa.hosted.controller.api.identifiers.UserId; -import com.yahoo.vespa.hosted.controller.athenz.HostedAthenzIdentities; -import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; -import com.yahoo.yolean.Exceptions; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; - -import static com.yahoo.vespa.hosted.controller.integration.AthenzFilterMock.IDENTITY_HEADER_NAME; -import static com.yahoo.vespa.hosted.controller.integration.AthenzFilterMock.OKTA_ACCESS_TOKEN_HEADER_NAME; -import static com.yahoo.vespa.hosted.controller.integration.AthenzFilterMock.OKTA_IDENTITY_TOKEN_HEADER_NAME; -import static java.net.URLEncoder.encode; -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.stream.Collectors.joining; - -class RequestBuilder implements Supplier<Request> { - - private final String path; - private final Request.Method method; - private byte[] data = new byte[0]; - private AthenzIdentity identity; - private OktaIdentityToken oktaIdentityToken; - private OktaAccessToken oktaAccessToken; - private String contentType = "application/json"; - private final Map<String, List<String>> headers = new HashMap<>(); - private final Map<String, String> properties = new HashMap<>(); - - RequestBuilder(String path, Request.Method method) { - this.path = path; - this.method = method; - } - - /** Make a request with (athens) user domain1.mytenant */ - static RequestBuilder request(String path, Request.Method method) { - return new RequestBuilder(path, method); - } - - RequestBuilder data(byte[] data) { this.data = data; return this; } - RequestBuilder data(String data) { return data(data.getBytes(UTF_8)); } - RequestBuilder data(MultiPartStreamer streamer) { - return Exceptions.uncheck(() -> data(streamer.data().readAllBytes()).contentType(streamer.contentType())); - } - - RequestBuilder userIdentity(UserId userId) { this.identity = HostedAthenzIdentities.from(userId); return this; } - RequestBuilder screwdriverIdentity(ScrewdriverId screwdriverId) { this.identity = HostedAthenzIdentities.from(screwdriverId); return this; } - RequestBuilder oktaIdentityToken(OktaIdentityToken oktaIdentityToken) { this.oktaIdentityToken = oktaIdentityToken; return this; } - RequestBuilder oktaAccessToken(OktaAccessToken oktaAccessToken) { this.oktaAccessToken = oktaAccessToken; return this; } - RequestBuilder contentType(String contentType) { this.contentType = contentType; return this; } - RequestBuilder recursive(String recursive) {return properties(Map.of("recursive", recursive)); } - RequestBuilder properties(Map<String, String> properties) { this.properties.putAll(properties); return this; } - RequestBuilder header(String name, String value) { - this.headers.putIfAbsent(name, new ArrayList<>()); - this.headers.get(name).add(value); - return this; - } - - static Request addIdentityToRequest(Request request, AthenzIdentity identity) { - request.getHeaders().put(IDENTITY_HEADER_NAME, identity.getFullName()); - return request; - } - - protected static Request addOktaIdentityToken(Request request, OktaIdentityToken token) { - request.getHeaders().put(OKTA_IDENTITY_TOKEN_HEADER_NAME, token.token()); - return request; - } - - protected static Request addOktaAccessToken(Request request, OktaAccessToken token) { - request.getHeaders().put(OKTA_ACCESS_TOKEN_HEADER_NAME, token.token()); - return request; - } - - @Override - public Request get() { - Request request = new Request("http://localhost:8080" + path + - properties.entrySet().stream() - .map(entry -> encode(entry.getKey(), UTF_8) + "=" + encode(entry.getValue(), UTF_8)) - .collect(joining("&", "?", "")), - data, method); - request.getHeaders().addAll(headers); - request.getHeaders().put("Content-Type", contentType); - // user and domain parameters are translated to a Principal by MockAuthorizer as we do not run HTTP filters - if (identity != null) { - addIdentityToRequest(request, identity); - } - if (oktaIdentityToken != null) { - addOktaIdentityToken(request, oktaIdentityToken); - } - if (oktaAccessToken != null) { - addOktaAccessToken(request, oktaAccessToken); - } - return request; - } -}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponsesTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponsesTest.java deleted file mode 100644 index 5f15eebd004..00000000000 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/TenantResponsesTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yahoo.vespa.hosted.controller.restapi.application; - -import com.yahoo.vespa.athenz.api.AthenzDomain; -import com.yahoo.vespa.athenz.api.OktaAccessToken; -import com.yahoo.vespa.athenz.api.OktaIdentityToken; -import com.yahoo.vespa.hosted.controller.api.identifiers.UserId; -import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; -import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; -import org.junit.Before; -import org.junit.Test; - -import java.io.File; - -import static com.yahoo.application.container.handler.Request.Method.GET; -import static com.yahoo.application.container.handler.Request.Method.POST; -import static com.yahoo.vespa.hosted.controller.restapi.application.ApplicationApiTest.createAthenzDomainWithAdmin; -import static com.yahoo.vespa.hosted.controller.restapi.application.RequestBuilder.request; - -public class TenantResponsesTest extends ControllerContainerTest { - - private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/"; - private static final UserId USER_ID = new UserId("myuser"); - private static final AthenzDomain ATHENZ_TENANT_DOMAIN = new AthenzDomain("domain1"); - private static final OktaIdentityToken OKTA_IT = new OktaIdentityToken("okta-it"); - private static final OktaAccessToken OKTA_AT = new OktaAccessToken("okta-at"); - private ContainerTester tester; - - @Before - public void before() { - tester = new ContainerTester(container, responseFiles); - } - - @Test - public void getTenantInfo() { - // Setup a tenant first - createAthenzDomainWithAdmin(ATHENZ_TENANT_DOMAIN, USER_ID, tester); - tester.assertResponse(request("/application/v4/tenant/tenant1", POST) - .userIdentity(USER_ID) - .data("{\"athensDomain\":\"domain1\", \"property\":\"property1\"}") - .oktaAccessToken(OKTA_AT).oktaIdentityToken(OKTA_IT), - new File("tenant-without-applications.json")); - - // Assert that initially the tenant has no info - tester.assertResponse(request("/application/v4/tenant/tenant1/info", GET).userIdentity(USER_ID), - new File("root.json")); - } -} |