All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.yahoo.vespa.model.container.xml.ContainerModelBuilder Maven / Gradle / Ivy

There is a newer version: 8.441.21
Show newest version
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.model.container.xml;

import com.yahoo.component.ComponentId;
import com.yahoo.component.ComponentSpecification;
import com.yahoo.component.Version;
import com.yahoo.component.chain.dependencies.Dependencies;
import com.yahoo.component.chain.model.ChainedComponentModel;
import com.yahoo.config.application.Xml;
import com.yahoo.config.application.api.ApplicationFile;
import com.yahoo.config.application.api.ApplicationPackage;
import com.yahoo.config.application.api.DeployLogger;
import com.yahoo.config.application.api.DeploymentSpec;
import com.yahoo.config.model.ConfigModelContext;
import com.yahoo.config.model.api.ApplicationClusterEndpoint;
import com.yahoo.config.model.api.ConfigServerSpec;
import com.yahoo.config.model.api.ContainerEndpoint;
import com.yahoo.config.model.api.TenantSecretStore;
import com.yahoo.config.model.application.provider.IncludeDirs;
import com.yahoo.config.model.builder.xml.ConfigModelBuilder;
import com.yahoo.config.model.builder.xml.ConfigModelId;
import com.yahoo.config.model.deploy.DeployState;
import com.yahoo.config.model.producer.AnyConfigProducer;
import com.yahoo.config.model.producer.TreeConfigProducer;
import com.yahoo.config.provision.AthenzDomain;
import com.yahoo.config.provision.AthenzService;
import com.yahoo.config.provision.Capacity;
import com.yahoo.config.provision.ClusterMembership;
import com.yahoo.config.provision.ClusterSpec;
import com.yahoo.config.provision.DataplaneToken;
import com.yahoo.config.provision.HostName;
import com.yahoo.config.provision.InstanceName;
import com.yahoo.config.provision.NodeType;
import com.yahoo.config.provision.Zone;
import com.yahoo.config.provision.ZoneEndpoint;
import com.yahoo.config.provision.zone.ZoneId;
import com.yahoo.container.bundle.BundleInstantiationSpecification;
import com.yahoo.container.jdisc.DataplaneProxyService;
import com.yahoo.container.logging.AccessLog;
import com.yahoo.container.logging.FileConnectionLog;
import com.yahoo.io.IOUtils;
import com.yahoo.jdisc.http.filter.security.cloud.config.CloudTokenDataPlaneFilterConfig;
import com.yahoo.jdisc.http.filter.security.cloud.config.CloudTokenDataPlaneFilterConfig.Builder;
import com.yahoo.jdisc.http.server.jetty.DataplaneProxyCredentials;
import com.yahoo.jdisc.http.server.jetty.VoidRequestLog;
import com.yahoo.osgi.provider.model.ComponentModel;
import com.yahoo.path.Path;
import com.yahoo.schema.OnnxModel;
import com.yahoo.schema.derived.FileDistributedOnnxModels;
import com.yahoo.schema.derived.RankProfileList;
import com.yahoo.search.rendering.RendererRegistry;
import com.yahoo.security.X509CertificateUtils;
import com.yahoo.text.XML;
import com.yahoo.vespa.defaults.Defaults;
import com.yahoo.vespa.model.AbstractService;
import com.yahoo.vespa.model.HostResource;
import com.yahoo.vespa.model.HostSystem;
import com.yahoo.vespa.model.builder.xml.dom.DomComponentBuilder;
import com.yahoo.vespa.model.builder.xml.dom.DomHandlerBuilder;
import com.yahoo.vespa.model.builder.xml.dom.ModelElement;
import com.yahoo.vespa.model.builder.xml.dom.NodesSpecification;
import com.yahoo.vespa.model.builder.xml.dom.VespaDomBuilder;
import com.yahoo.vespa.model.builder.xml.dom.chains.docproc.DomDocprocChainsBuilder;
import com.yahoo.vespa.model.builder.xml.dom.chains.processing.DomProcessingBuilder;
import com.yahoo.vespa.model.builder.xml.dom.chains.search.DomSearchChainsBuilder;
import com.yahoo.vespa.model.clients.ContainerDocumentApi;
import com.yahoo.vespa.model.container.ApplicationContainer;
import com.yahoo.vespa.model.container.ApplicationContainerCluster;
import com.yahoo.vespa.model.container.Container;
import com.yahoo.vespa.model.container.ContainerCluster;
import com.yahoo.vespa.model.container.ContainerModel;
import com.yahoo.vespa.model.container.ContainerModelEvaluation;
import com.yahoo.vespa.model.container.DataplaneProxy;
import com.yahoo.vespa.model.container.IdentityProvider;
import com.yahoo.vespa.model.container.PlatformBundles;
import com.yahoo.vespa.model.container.SecretStore;
import com.yahoo.vespa.model.container.component.AccessLogComponent;
import com.yahoo.vespa.model.container.component.BindingPattern;
import com.yahoo.vespa.model.container.component.Component;
import com.yahoo.vespa.model.container.component.ConnectionLogComponent;
import com.yahoo.vespa.model.container.component.FileStatusHandlerComponent;
import com.yahoo.vespa.model.container.component.Handler;
import com.yahoo.vespa.model.container.component.SignificanceModelRegistry;
import com.yahoo.vespa.model.container.component.SimpleComponent;
import com.yahoo.vespa.model.container.component.SystemBindingPattern;
import com.yahoo.vespa.model.container.component.UserBindingPattern;
import com.yahoo.vespa.model.container.docproc.ContainerDocproc;
import com.yahoo.vespa.model.container.docproc.DocprocChains;
import com.yahoo.vespa.model.container.http.AccessControl;
import com.yahoo.vespa.model.container.http.Client;
import com.yahoo.vespa.model.container.http.ConnectorFactory;
import com.yahoo.vespa.model.container.http.Filter;
import com.yahoo.vespa.model.container.http.FilterBinding;
import com.yahoo.vespa.model.container.http.FilterChains;
import com.yahoo.vespa.model.container.http.Http;
import com.yahoo.vespa.model.container.http.HttpFilterChain;
import com.yahoo.vespa.model.container.http.JettyHttpServer;
import com.yahoo.vespa.model.container.http.ssl.HostedSslConnectorFactory;
import com.yahoo.vespa.model.container.http.ssl.HostedSslConnectorFactory.SslClientAuth;
import com.yahoo.vespa.model.container.http.xml.HttpBuilder;
import com.yahoo.vespa.model.container.processing.ProcessingChains;
import com.yahoo.vespa.model.container.search.ContainerSearch;
import com.yahoo.vespa.model.container.search.PageTemplates;
import com.yahoo.vespa.model.container.search.searchchain.SearchChains;
import com.yahoo.vespa.model.container.xml.document.DocumentFactoryBuilder;
import com.yahoo.vespa.model.content.StorageGroup;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import java.io.IOException;
import java.io.Reader;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.yahoo.vespa.model.container.ContainerCluster.VIP_HANDLER_BINDING;
import static java.util.logging.Level.WARNING;

/**
 * @author Tony Vaagenes
 * @author gjoranv
 */
public class ContainerModelBuilder extends ConfigModelBuilder {

    // Default path to vip status file for container in Hosted Vespa.
    static final String HOSTED_VESPA_STATUS_FILE = Defaults.getDefaults().underVespaHome("var/vespa/load-balancer/status.html");

    static final String HOSTED_VESPA_TENANT_PARENT_DOMAIN = "vespa.tenant.";

    //Path to vip status file for container in Hosted Vespa. Only used if set, else use HOSTED_VESPA_STATUS_FILE
    private static final String HOSTED_VESPA_STATUS_FILE_SETTING = "VESPA_LB_STATUS_FILE";

    private static final String CONTAINER_TAG = "container";
    private static final String ENVIRONMENT_VARIABLES_ELEMENT = "environment-variables";

    // The node count to enforce in a cluster running ZooKeeper
    private static final int MIN_ZOOKEEPER_NODE_COUNT = 1;
    private static final int MAX_ZOOKEEPER_NODE_COUNT = 7;

    public enum Networking { disable, enable }

    private ApplicationPackage app;
    private final boolean standaloneBuilder;
    private final Networking networking;
    private final boolean rpcServerEnabled;
    private final boolean httpServerEnabled;
    protected DeployLogger log;

    public static final List configModelIds = List.of(ConfigModelId.fromName(CONTAINER_TAG));

    private static final String xmlRendererId = RendererRegistry.xmlRendererId.getName();
    private static final String jsonRendererId = RendererRegistry.jsonRendererId.getName();

    public ContainerModelBuilder(boolean standaloneBuilder, Networking networking) {
        super(ContainerModel.class);
        this.standaloneBuilder = standaloneBuilder;
        this.networking = networking;
        // Always disable rpc server for standalone container
        this.rpcServerEnabled = !standaloneBuilder;
        this.httpServerEnabled = networking == Networking.enable;
    }

    @Override
    public List handlesElements() {
        return configModelIds;
    }

    @Override
    public void doBuild(ContainerModel model, Element spec, ConfigModelContext modelContext) {
        log = modelContext.getDeployLogger();
        app = modelContext.getApplicationPackage();

        checkVersion(spec);

        ApplicationContainerCluster cluster = createContainerCluster(spec, modelContext);
        addClusterContent(cluster, spec, modelContext);
        cluster.setMessageBusEnabled(rpcServerEnabled);
        cluster.setRpcServerEnabled(rpcServerEnabled);
        cluster.setHttpServerEnabled(httpServerEnabled);
        model.setCluster(cluster);
    }

    private ApplicationContainerCluster createContainerCluster(Element spec, ConfigModelContext modelContext) {
        return new VespaDomBuilder.DomConfigProducerBuilderBase() {
            @Override
            protected ApplicationContainerCluster doBuild(DeployState deployState, TreeConfigProducer ancestor, Element producerSpec) {
                return new ApplicationContainerCluster(ancestor, modelContext.getProducerId(),
                                                       modelContext.getProducerId(), deployState);
            }
        }.build(modelContext.getDeployState(), modelContext.getParentProducer(), spec);
    }

    private void addClusterContent(ApplicationContainerCluster cluster, Element spec, ConfigModelContext context) {
        DeployState deployState = context.getDeployState();
        DocumentFactoryBuilder.buildDocumentFactories(cluster, spec);

        addConfiguredComponents(deployState, cluster, spec);
        addSecretStore(cluster, spec, deployState);
        addSecrets(cluster, spec, deployState);

        addProcessing(deployState, spec, cluster, context);
        addSearch(deployState, spec, cluster, context);
        addDocproc(deployState, spec, cluster);
        addDocumentApi(deployState, spec, cluster, context);  // NOTE: Must be done after addSearch

        cluster.addDefaultHandlersExceptStatus();
        addStatusHandlers(cluster, context.getDeployState().isHosted());
        addUserHandlers(deployState, cluster, spec, context);

        addClients(deployState, spec, cluster);
        addHttp(deployState, spec, cluster, context);

        addAccessLogs(deployState, cluster, spec);
        addNodes(cluster, spec, context);

        addModelEvaluationRuntime(cluster);
        addModelEvaluation(spec, cluster, context); // NOTE: Must be done after addNodes

        addServerProviders(deployState, spec, cluster);

        if (!standaloneBuilder) cluster.addAllPlatformBundles();

        // Must be added after nodes:
        addDeploymentSpecConfig(cluster, context, deployState.getDeployLogger());
        addZooKeeper(cluster, spec);
        addAthenzServiceIdentityProvider(cluster, context, deployState.getDeployLogger());

        addParameterStoreValidationHandler(cluster, deployState);
    }


    private void addParameterStoreValidationHandler(ApplicationContainerCluster cluster, DeployState deployState) {
        if ( ! deployState.isHosted()) return;
        // Always add platform bundle. Cannot be controlled by a feature flag as platform bundle cannot change.
        cluster.addPlatformBundle(PlatformBundles.absoluteBundlePath("jdisc-cloud-aws"));
        if (deployState.zone().system().isPublic()) {
            BindingPattern bindingPattern = SystemBindingPattern.fromHttpPath("/validate-secret-store");
            Handler handler = new Handler(
                    new ComponentModel("com.yahoo.jdisc.cloud.aws.AwsParameterStoreValidationHandler", null, "jdisc-cloud-aws", null));
            handler.addServerBindings(bindingPattern);
            cluster.addComponent(handler);
        }
    }

    private void addZooKeeper(ApplicationContainerCluster cluster, Element spec) {
        Element zooKeeper = getZooKeeper(spec);
        if (zooKeeper == null) return;

        Element nodesElement = XML.getChild(spec, "nodes");
        boolean isCombined = nodesElement != null && nodesElement.hasAttribute("of");
        if (isCombined) {
            throw new IllegalArgumentException("A combined cluster cannot run ZooKeeper");
        }
        long nonRetiredNodes = cluster.getContainers().stream().filter(c -> !c.isRetired()).count();
        if (nonRetiredNodes < MIN_ZOOKEEPER_NODE_COUNT || nonRetiredNodes > MAX_ZOOKEEPER_NODE_COUNT || nonRetiredNodes % 2 == 0) {
            throw new IllegalArgumentException("Cluster with ZooKeeper needs an odd number of nodes, between " +
                                               MIN_ZOOKEEPER_NODE_COUNT + " and " + MAX_ZOOKEEPER_NODE_COUNT +
                                               ", have " + nonRetiredNodes + " non-retired");
        }
        cluster.addSimpleComponent("com.yahoo.vespa.curator.Curator", null, "zkfacade");
        cluster.addSimpleComponent("com.yahoo.vespa.curator.CuratorWrapper", null, "zkfacade");
        String sessionTimeoutSeconds = zooKeeper.getAttribute("session-timeout-seconds");
        if ( ! sessionTimeoutSeconds.isBlank()) {
            try {
                int timeoutSeconds = Integer.parseInt(sessionTimeoutSeconds);
                if (timeoutSeconds <= 0) throw new IllegalArgumentException("must be a positive value");
                cluster.setZookeeperSessionTimeoutSeconds(timeoutSeconds);
            }
            catch (RuntimeException e) {
                throw new IllegalArgumentException("invalid zookeeper session-timeout-seconds '" + sessionTimeoutSeconds + "'", e);
            }
        }

        // These need to be setup so that they will use the container's config id, since each container
        // have different config (id of zookeeper server)
        cluster.getContainers().forEach(ContainerModelBuilder::addReconfigurableZooKeeperServerComponents);
    }

    public static void addReconfigurableZooKeeperServerComponents(Container container) {
        container.addComponent(zookeeperComponent("com.yahoo.vespa.zookeeper.ReconfigurableVespaZooKeeperServer", container));
        container.addComponent(zookeeperComponent("com.yahoo.vespa.zookeeper.Reconfigurer", container));
        container.addComponent(zookeeperComponent("com.yahoo.vespa.zookeeper.VespaZooKeeperAdminImpl", container));
    }

    private static SimpleComponent zookeeperComponent(String idSpec, Container container) {
        String configId = container.getConfigId();
        return new SimpleComponent(new ComponentModel(idSpec, null, "zookeeper-server", configId));
    }

    private void addSecrets(ApplicationContainerCluster cluster, Element spec, DeployState deployState) {
        if ( ! deployState.isHosted() || ! cluster.getZone().system().isPublic())
            return;
        Element secretsElement = XML.getChild(spec, "secrets");
        if (secretsElement != null) {
            CloudSecrets secretsConfig = new CloudSecrets();
            for (Element element : XML.getChildren(secretsElement)) {
                String key = element.getTagName();
                String name = element.getAttribute("name");
                String vault = element.getAttribute("vault");
                secretsConfig.addSecret(key, name, vault);
            }
            cluster.addComponent(secretsConfig);
            cluster.addComponent(new CloudAsmSecrets(deployState.getProperties().ztsUrl(),
                                                     deployState.getProperties().tenantSecretDomain(),
                                                     deployState.zone().system(),
                                                     deployState.getProperties().applicationId().tenant()));
        }
    }

    private void addSecretStore(ApplicationContainerCluster cluster, Element spec, DeployState deployState) {

        Element secretStoreElement = XML.getChild(spec, "secret-store");
        if (secretStoreElement != null) {
            String type = secretStoreElement.getAttribute("type");
            if ("cloud".equals(type)) {
                addCloudSecretStore(cluster, secretStoreElement, deployState);
            } else {
                SecretStore secretStore = new SecretStore();
                for (Element group : XML.getChildren(secretStoreElement, "group")) {
                    secretStore.addGroup(group.getAttribute("name"), group.getAttribute("environment"));
                }
                cluster.setSecretStore(secretStore);
            }
        }
    }

    private void addCloudSecretStore(ApplicationContainerCluster cluster, Element secretStoreElement, DeployState deployState) {
        if ( ! deployState.isHosted()) return;
        if ( ! cluster.getZone().system().isPublic())
            throw new IllegalArgumentException("Cloud secret store is not supported in non-public system, see the documentation");
        CloudSecretStore cloudSecretStore = new CloudSecretStore();
        Map secretStoresByName = deployState.getProperties().tenantSecretStores()
                .stream()
                .collect(Collectors.toMap(
                        TenantSecretStore::getName,
                        store -> store
                ));
        Element store = XML.getChild(secretStoreElement, "store");
        for (Element group : XML.getChildren(store, "aws-parameter-store")) {
            String account = group.getAttribute("account");
            String region = group.getAttribute("aws-region");
            TenantSecretStore secretStore = secretStoresByName.get(account);

            if (secretStore == null)
                throw new IllegalArgumentException("No configured secret store named " + account);

            if (secretStore.getExternalId().isEmpty())
                throw new IllegalArgumentException("No external ID has been set for secret store " + secretStore.getName());

            cloudSecretStore.addConfig(account, region, secretStore.getAwsId(), secretStore.getRole(), secretStore.getExternalId().get());
        }

        cluster.addComponent(cloudSecretStore);
    }

    private void addAthenzServiceIdentityProvider(ApplicationContainerCluster cluster, ConfigModelContext context, DeployLogger deployLogger) {
        if ( ! context.getDeployState().isHosted()) return;
        if ( ! context.getDeployState().zone().system().isPublic()) return; // Non-public is handled by deployment spec config.
        if ( ! context.properties().launchApplicationAthenzService()) return;
        var appContext = context.getDeployState().zone().environment().isManuallyDeployed() ? "sandbox" : "production";
        addIdentityProvider(cluster,
                            context.getDeployState().getProperties().configServerSpecs(),
                            context.getDeployState().getProperties().loadBalancerName(),
                            context.getDeployState().getProperties().ztsUrl(),
                            context.getDeployState().getProperties().athenzDnsSuffix(),
                            context.getDeployState().zone(),
                            AthenzDomain.from(HOSTED_VESPA_TENANT_PARENT_DOMAIN + context.properties().applicationId().tenant().value()),
                            AthenzService.from("%s-%s".formatted(context.properties().applicationId().application().value(), appContext)));
    }

    private void addDeploymentSpecConfig(ApplicationContainerCluster cluster, ConfigModelContext context, DeployLogger deployLogger) {
        if ( ! context.getDeployState().isHosted()) return;
        DeploymentSpec deploymentSpec = app.getDeploymentSpec();
        if (deploymentSpec.isEmpty()) return;

        for (var deprecatedElement : deploymentSpec.deprecatedElements()) {
            deployLogger.logApplicationPackage(WARNING, deprecatedElement.humanReadableString());
        }

        addIdentityProvider(cluster,
                            context.getDeployState().getProperties().configServerSpecs(),
                            context.getDeployState().getProperties().loadBalancerName(),
                            context.getDeployState().getProperties().ztsUrl(),
                            context.getDeployState().getProperties().athenzDnsSuffix(),
                            context.getDeployState().zone(),
                            deploymentSpec);
        addRotationProperties(cluster, context.getDeployState().getEndpoints());
    }

    private void addRotationProperties(ApplicationContainerCluster cluster, Set endpoints) {
        cluster.getContainers().forEach(container -> {
            setRotations(container, endpoints, cluster.getName());
            container.setProp("activeRotation", "true"); // TODO(mpolden): This is unused and should be removed
        });
    }

    private void setRotations(Container container, Set endpoints, String containerClusterName) {
        var rotationsProperty = endpoints.stream()
                .filter(endpoint -> endpoint.clusterId().equals(containerClusterName))
                // Only consider global endpoints.
                .filter(endpoint -> endpoint.scope() == ApplicationClusterEndpoint.Scope.global)
                .flatMap(endpoint -> endpoint.names().stream())
                .collect(Collectors.toCollection(LinkedHashSet::new));

        // Build the comma delimited list of endpoints this container should be known as.
        // Confusingly called 'rotations' for legacy reasons.
        container.setProp("rotations", String.join(",", rotationsProperty));
    }

    private void addConfiguredComponents(DeployState deployState, ApplicationContainerCluster cluster, Element parent) {
        for (Element components : XML.getChildren(parent, "components")) {
            addIncludes(components);
            addConfiguredComponents(deployState, cluster, components, "component");
        }
        addConfiguredComponents(deployState, cluster, parent, "component");
    }

    protected void addStatusHandlers(ApplicationContainerCluster cluster, boolean isHostedVespa) {
        if (isHostedVespa) {
            String name = "status.html";
            Optional statusFile = Optional.ofNullable(System.getenv(HOSTED_VESPA_STATUS_FILE_SETTING));
            cluster.addComponent(
                    new FileStatusHandlerComponent(
                            name + "-status-handler",
                            statusFile.orElse(HOSTED_VESPA_STATUS_FILE),
                            SystemBindingPattern.fromHttpPath("/" + name)));
        } else {
            cluster.addVipHandler();
        }
    }

    private void addServerProviders(DeployState deployState, Element spec, ApplicationContainerCluster cluster) {
        addConfiguredComponents(deployState, cluster, spec, "server");
    }

    protected void addAccessLogs(DeployState deployState, ApplicationContainerCluster cluster, Element spec) {
        List accessLogElements = getAccessLogElements(spec);

        if (accessLogElements.isEmpty() && deployState.getAccessLoggingEnabledByDefault()) {
            cluster.addAccessLog();
        } else {
            if (cluster.isHostedVespa()) {
                log.logApplicationPackage(WARNING, "Applications are not allowed to override the 'accesslog' element");
            } else {
                List components = new ArrayList<>();
                for (Element accessLog : accessLogElements) {
                    AccessLogBuilder.buildIfNotDisabled(deployState, cluster, accessLog).ifPresent(accessLogComponent -> {
                        components.add(accessLogComponent);
                        cluster.addComponent(accessLogComponent);
                    });
                }
                if ( ! components.isEmpty()) {
                    cluster.removeSimpleComponent(VoidRequestLog.class);
                    cluster.addSimpleComponent(AccessLog.class);
                }
            }
        }

        // Add connection log if access log is configured
        if (cluster.getAllComponents().stream().anyMatch(component -> component instanceof AccessLogComponent))
            cluster.addComponent(new ConnectionLogComponent(cluster, FileConnectionLog.class, "access"));
    }

    private List getAccessLogElements(Element spec) {
        return XML.getChildren(spec, "accesslog");
    }

    protected void addHttp(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
        Element httpElement = XML.getChild(spec, "http");
        if (httpElement != null) {
            cluster.setHttp(buildHttp(deployState, cluster, httpElement, context));
        }
        if (isHostedTenantApplication(context)) {
            addHostedImplicitHttpIfNotPresent(deployState, cluster);
            addHostedImplicitAccessControlIfNotPresent(deployState, cluster);
            addDefaultConnectorHostedFilterBinding(cluster);
            addCloudMtlsConnector(deployState, cluster);
            addCloudDataPlaneFilter(deployState, cluster);
            addCloudTokenSupport(deployState, cluster);
        }
    }

    private static void addCloudDataPlaneFilter(DeployState deployState, ApplicationContainerCluster cluster) {
        if (!deployState.isHosted() || !deployState.zone().system().isPublic()) return;

        var dataplanePort = getMtlsDataplanePort(deployState);
        // Setup secure filter chain
        var secureChain = new HttpFilterChain("cloud-data-plane-secure", HttpFilterChain.Type.SYSTEM);
        secureChain.addInnerComponent(new CloudDataPlaneFilter(cluster, deployState));
        cluster.getHttp().getFilterChains().add(secureChain);
        // Set cloud data plane filter as default request filter chain for data plane connector
        cluster.getHttp().getHttpServer().orElseThrow().getConnectorFactories().stream()
                .filter(c -> c.getListenPort() == dataplanePort).findAny().orElseThrow()
                .setDefaultRequestFilterChain(secureChain.getComponentId());

        // Setup insecure filter chain
        var insecureChain = new HttpFilterChain("cloud-data-plane-insecure", HttpFilterChain.Type.SYSTEM);
        insecureChain.addInnerComponent(new Filter(
                new ChainedComponentModel(
                        new BundleInstantiationSpecification(
                                new ComponentSpecification("com.yahoo.jdisc.http.filter.security.misc.NoopFilter"),
                                null, new ComponentSpecification("jdisc-security-filters")),
                        Dependencies.emptyDependencies())));
        cluster.getHttp().getFilterChains().add(insecureChain);
        var insecureChainComponentSpec = new ComponentSpecification(insecureChain.getComponentId().toString());
        FilterBinding insecureBinding =
                FilterBinding.create(FilterBinding.Type.REQUEST, insecureChainComponentSpec, VIP_HANDLER_BINDING);
        cluster.getHttp().getBindings().add(insecureBinding);
        // Set insecure filter as default request filter chain for default connector
        cluster.getHttp().getHttpServer().orElseThrow().getConnectorFactories().stream()
                .filter(c -> c.getListenPort() == Defaults.getDefaults().vespaWebServicePort()).findAny().orElseThrow()
                .setDefaultRequestFilterChain(insecureChain.getComponentId());

    }

    protected void addClients(DeployState deployState, Element spec, ApplicationContainerCluster cluster) {
        if (!deployState.isHosted() || !deployState.zone().system().isPublic()) return;

        List clients;
        Element clientsElement = XML.getChild(spec, "clients");
        boolean legacyMode = false;
        if (clientsElement == null) {
            clients = List.of(new Client(
                    "default", List.of(), getCertificates(app.getFile(Path.fromString("security/clients.pem"))), List.of()));
            legacyMode = true;
        } else {
            clients = XML.getChildren(clientsElement, "client").stream()
                    .flatMap(elem -> getClient(elem, deployState).stream())
                    .toList();
            boolean atLeastOneClientWithCertificate = clients.stream().anyMatch(client -> !client.certificates().isEmpty());
            if (!atLeastOneClientWithCertificate)
                throw new IllegalArgumentException("At least one client must require a certificate");

            List duplicates = clients.stream().collect(Collectors.groupingBy(Client::id))
                    .entrySet().stream().filter(entry -> entry.getValue().size() > 1)
                    .map(Map.Entry::getKey).sorted().toList();
            if (! duplicates.isEmpty()) {
                throw new IllegalArgumentException("Duplicate client ids: " + duplicates);
            }
        }

        List operatorAndTesterCertificates = deployState.getProperties().operatorCertificates();
        if(!operatorAndTesterCertificates.isEmpty())
            clients = Stream.concat(clients.stream(), Stream.of(Client.internalClient(operatorAndTesterCertificates))).toList();
        cluster.setClients(legacyMode, clients);
    }

    private Optional getClient(Element clientElement, DeployState state) {
        String clientId = XML.attribute("id", clientElement).orElseThrow();
        if (clientId.startsWith("_"))
            throw new IllegalArgumentException("Invalid client id '%s', id cannot start with '_'".formatted(clientId));
        var permissions = XML.attribute("permissions", clientElement)
                .map(Client.Permission::fromCommaSeparatedString).orElse(Set.of());

        var certificates = XML.getChildren(clientElement, "certificate").stream()
                .flatMap(certElem -> {
                    var file = app.getFile(Path.fromString(certElem.getAttribute("file")));
                    if (!file.exists()) {
                        throw new IllegalArgumentException("Certificate file '%s' for client '%s' does not exist"
                                                                   .formatted(file.getPath().getRelative(), clientId));
                    }
                    return getCertificates(file).stream();
                })
                .toList();
        // A client cannot use both tokens and certificates
        if (!certificates.isEmpty()) return Optional.of(new Client(clientId, permissions, certificates, List.of()));

        var knownTokens = state.getProperties().dataplaneTokens().stream()
                .collect(Collectors.toMap(DataplaneToken::tokenId, Function.identity()));

        var referencedTokens = XML.getChildren(clientElement, "token").stream()
                .map(elem -> {
                    var tokenId = elem.getAttribute("id");
                    var token = knownTokens.get(tokenId);
                    if (token == null)
                        log.logApplicationPackage(
                                WARNING, "Token '%s' for client '%s' does not exist".formatted(tokenId, clientId));
                    return token;
                })
                .filter(token -> {
                    if (token == null) return false;
                    boolean empty = token.versions().isEmpty();
                    if (empty)
                        log.logApplicationPackage(
                                WARNING, "Token '%s' for client '%s' has no active versions"
                                        .formatted(token.tokenId(), clientId));
                    return !empty;
                })
                .toList();

        // Don't include 'client' that refers to token without versions
        if (referencedTokens.isEmpty()) {
            log.log(Level.INFO, "Skipping client '%s' as it does not refer to any activate tokens".formatted(clientId));
            return Optional.empty();
        }

        return Optional.of(new Client(clientId, permissions, List.of(), referencedTokens));
    }

    private List getCertificates(ApplicationFile file) {
        if (!file.exists()) return List.of();
        try {
            Reader reader = file.createReader();
            String certPem = IOUtils.readAll(reader);
            reader.close();
            List x509Certificates;
            try {
                x509Certificates = X509CertificateUtils.certificateListFromPem(certPem);
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException("File %s contains an invalid certificate".formatted(file.getPath().getRelative()), e);
            }
            if (x509Certificates.isEmpty()) {
                throw new IllegalArgumentException("File %s does not contain any certificates.".formatted(file.getPath().getRelative()));
            }
            return x509Certificates;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void addDefaultConnectorHostedFilterBinding(ApplicationContainerCluster cluster) {
        cluster.getHttp().getAccessControl()
                .ifPresent(accessControl -> accessControl.configureDefaultHostedConnector(cluster.getHttp()));
    }

    private void addCloudMtlsConnector(DeployState state, ApplicationContainerCluster cluster) {
        JettyHttpServer server = cluster.getHttp().getHttpServer().get();
        String serverName = server.getComponentId().getName();

        // If the deployment contains certificate/private key reference, setup TLS port
        var builder = HostedSslConnectorFactory.builder(serverName, getMtlsDataplanePort(state))
                .proxyProtocol(state.zone().cloud().useProxyProtocol())
                .tlsCiphersOverride(state.getProperties().tlsCiphersOverride())
                .endpointConnectionTtl(state.getProperties().endpointConnectionTtl())
                .requestPrefixForLoggingContent(state.getProperties().requestPrefixForLoggingContent());
        var endpointCert = state.endpointCertificateSecrets().orElse(null);
        if (endpointCert != null) {
            builder.endpointCertificate(endpointCert);
            Set mtlsEndpointNames = state.getEndpoints().stream()
                    .filter(endpoint -> endpoint.authMethod() == ApplicationClusterEndpoint.AuthMethod.mtls)
                    .flatMap(endpoint -> endpoint.names().stream())
                    .collect(Collectors.toSet());
            builder.knownServerNames(mtlsEndpointNames);
            boolean isPublic = state.zone().system().isPublic();
            List clientCertificates = getClientCertificates(cluster);
            if (isPublic) {
                if (clientCertificates.isEmpty())
                    throw new IllegalArgumentException("Client certificate authority security/clients.pem is missing - " +
                                                               "see: https://cloud.vespa.ai/en/security/guide#data-plane");
                builder.tlsCaCertificatesPem(X509CertificateUtils.toPem(clientCertificates))
                        .clientAuth(SslClientAuth.WANT_WITH_ENFORCER);
            } else {
                builder.tlsCaCertificatesPath("/opt/yahoo/share/ssl/certs/athenz_certificate_bundle.pem");
                var needAuth = cluster.getHttp().getAccessControl()
                        .map(accessControl -> accessControl.clientAuthentication)
                        .map(clientAuth -> clientAuth == AccessControl.ClientAuthentication.need)
                        .orElse(false);
                builder.clientAuth(needAuth ? SslClientAuth.NEED : SslClientAuth.WANT);
            }
        } else {
            builder.clientAuth(SslClientAuth.WANT_WITH_ENFORCER);
        }
        var connectorFactory = builder.build();
        cluster.getHttp().getAccessControl().ifPresent(accessControl -> accessControl.configureHostedConnector(connectorFactory));
        server.addConnector(connectorFactory);
    }

    private void addCloudTokenSupport(DeployState state, ApplicationContainerCluster cluster) {
        var server = cluster.getHttp().getHttpServer().get();
        if (!enableTokenSupport(state)) return;
        Set tokenEndpoints = tokenEndpoints(state).stream()
                .map(ContainerEndpoint::names)
                .flatMap(Collection::stream)
                .collect(Collectors.toSet());
        var endpointCert = state.endpointCertificateSecrets().orElseThrow();
        int tokenPort = getTokenDataplanePort(state).orElseThrow();

        // Set up component to generate proxy cert if token support is enabled
        cluster.addSimpleComponent(DataplaneProxyCredentials.class);
        cluster.addSimpleComponent(DataplaneProxyService.class);
        var dataplaneProxy = new DataplaneProxy(
                getMtlsDataplanePort(state),
                tokenPort,
                endpointCert.certificate(),
                endpointCert.key(),
                tokenEndpoints);
        cluster.addComponent(dataplaneProxy);

        // Setup dedicated connector
        var connector = HostedSslConnectorFactory.builder(server.getComponentId().getName()+"-token", tokenPort)
                .tokenEndpoint(true)
                .proxyProtocol(false)
                .endpointCertificate(endpointCert)
                .remoteAddressHeader("X-Forwarded-For")
                .remotePortHeader("X-Forwarded-Port")
                .clientAuth(SslClientAuth.NEED)
                .knownServerNames(tokenEndpoints)
                .requestPrefixForLoggingContent(state.getProperties().requestPrefixForLoggingContent())
                .build();
        server.addConnector(connector);

        // Setup token filter chain
        var tokenChain = new HttpFilterChain("cloud-token-data-plane-secure", HttpFilterChain.Type.SYSTEM);
        var tokenFilter = new CloudTokenDataPlaneFilter(cluster, state);
        tokenChain.addInnerComponent(tokenFilter);
        cluster.getHttp().getFilterChains().add(tokenChain);

        // Set as default filter for token port
        cluster.getHttp().getHttpServer().orElseThrow().getConnectorFactories().stream()
                .filter(c -> c.getListenPort() == tokenPort).findAny().orElseThrow()
                .setDefaultRequestFilterChain(tokenChain.getComponentId());

        // Set up handler that tells what fingerprints are known to the container
        class CloudTokenDataPlaneHandler extends Handler implements CloudTokenDataPlaneFilterConfig.Producer {
            CloudTokenDataPlaneHandler() {
                super(new ComponentModel("com.yahoo.jdisc.http.filter.security.cloud.CloudTokenDataPlaneHandler", null, "jdisc-security-filters", null));
                addServerBindings(SystemBindingPattern.fromHttpPortAndPath(Defaults.getDefaults().vespaWebServicePort(), "/data-plane-tokens/v1"));
            }
            @Override public void getConfig(Builder builder) { tokenFilter.getConfig(builder); }
        }
        cluster.addComponent(new CloudTokenDataPlaneHandler());
    }

    // Returns the client certificates of the clients defined for an application cluster
    private List getClientCertificates(ApplicationContainerCluster cluster) {
        return cluster.getClients()
                .stream()
                .map(Client::certificates)
                .flatMap(Collection::stream)
                .toList();
    }

    private static boolean isHostedTenantApplication(ConfigModelContext context) {
        return context.getDeployState().isHostedTenantApplication(context.getApplicationType());
    }

    private static void addHostedImplicitHttpIfNotPresent(DeployState deployState, ApplicationContainerCluster cluster) {
        if (cluster.getHttp() == null) {
            cluster.setHttp(new Http(new FilterChains(cluster)));
        }
        JettyHttpServer httpServer = cluster.getHttp().getHttpServer().orElse(null);
        if (httpServer == null) {
            httpServer = new JettyHttpServer("DefaultHttpServer", cluster, deployState);
            cluster.getHttp().setHttpServer(httpServer);
        }
        int defaultPort = Defaults.getDefaults().vespaWebServicePort();
        boolean defaultConnectorPresent = httpServer.getConnectorFactories().stream().anyMatch(connector -> connector.getListenPort() == defaultPort);
        if (!defaultConnectorPresent) {
            httpServer.addConnector(new ConnectorFactory.Builder("SearchServer", defaultPort).build());
        }
    }

    private void addHostedImplicitAccessControlIfNotPresent(DeployState deployState, ApplicationContainerCluster cluster) {
        Http http = cluster.getHttp();
        if (http.getAccessControl().isPresent()) return; // access control added explicitly
        AthenzDomain tenantDomain = deployState.getProperties().athenzDomain().orElse(null);
        if (tenantDomain == null) return; // tenant domain not present, cannot add access control. this should eventually be a failure.
        new AccessControl.Builder(tenantDomain.value())
                .setHandlers(cluster)
                .clientAuthentication(AccessControl.ClientAuthentication.need)
                .build()
                .configureHttpFilterChains(http);
    }

    private Http buildHttp(DeployState deployState, ApplicationContainerCluster cluster, Element httpElement, ConfigModelContext context) {
        Http http = new HttpBuilder(portBindingOverride(deployState, context)).build(deployState, cluster, httpElement);

        if (networking == Networking.disable)
            http.removeAllServers();

        return http;
    }

    private void addDocumentApi(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
        ContainerDocumentApi containerDocumentApi = buildDocumentApi(deployState, cluster, spec, context);
        if (containerDocumentApi == null) return;

        cluster.setDocumentApi(containerDocumentApi);
    }

    private void addDocproc(DeployState deployState, Element spec, ApplicationContainerCluster cluster) {
        ContainerDocproc containerDocproc = buildDocproc(deployState, cluster, spec);
        if (containerDocproc == null) return;
        cluster.setDocproc(containerDocproc);

        ContainerDocproc.Options docprocOptions = containerDocproc.options;
        cluster.setMbusParams(new ApplicationContainerCluster.MbusParams(
                docprocOptions.maxConcurrentFactor, docprocOptions.documentExpansionFactor, docprocOptions.containerCoreMemory));
    }

    private void addSearch(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
        Element searchElement = XML.getChild(spec, "search");
        if (searchElement == null) return;

        addIncludes(searchElement);
        cluster.setSearch(buildSearch(deployState, cluster, searchElement));

        addSearchHandler(deployState, cluster, searchElement, context);

        validateAndAddConfiguredComponents(deployState, cluster, searchElement, "renderer", ContainerModelBuilder::validateRendererElement);

        addSignificance(deployState, searchElement, cluster);
    }

    private void addSignificance(DeployState deployState, Element spec, ApplicationContainerCluster cluster) {
        Element significanceElement = XML.getChild(spec, "significance");

        SignificanceModelRegistry significanceModelRegistry = new SignificanceModelRegistry(deployState, significanceElement);
        cluster.addComponent(significanceModelRegistry);

    }

    private void addModelEvaluation(Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
        Element modelEvaluationElement = XML.getChild(spec, "model-evaluation");
        if (modelEvaluationElement == null) return;

        RankProfileList profiles =
                context.vespaModel() != null ? context.vespaModel().rankProfileList() : RankProfileList.empty;

        // Create a copy of models so each cluster can have its own specific settings
        FileDistributedOnnxModels models = profiles.getOnnxModels().clone();

        Element onnxElement = XML.getChild(modelEvaluationElement, "onnx");
        Element modelsElement = XML.getChild(onnxElement, "models");
        for (Element modelElement : XML.getChildren(modelsElement, "model") ) {
            OnnxModel onnxModel = models.asMap().get(modelElement.getAttribute("name"));
            if (onnxModel == null) {
                String availableModels = String.join(", ", profiles.getOnnxModels().asMap().keySet());
                context.getDeployState().getDeployLogger().logApplicationPackage(WARNING,
                        "Model '" + modelElement.getAttribute("name") + "' not found. Available ONNX " +
                        "models are: " + availableModels + ". Skipping this configuration.");
                continue;
            }
            onnxModel.setStatelessExecutionMode(getStringValue(modelElement, "execution-mode", null));
            onnxModel.setStatelessInterOpThreads(getIntValue(modelElement, "interop-threads", -1));
            onnxModel.setStatelessIntraOpThreads(getIntValue(modelElement, "intraop-threads", -1));
            Element gpuDeviceElement = XML.getChild(modelElement, "gpu-device");
            if (gpuDeviceElement != null) {
                int gpuDevice = Integer.parseInt(gpuDeviceElement.getTextContent());
                boolean hasGpu = cluster.getContainers().stream().anyMatch(container -> container.getHostResource() != null &&
                                                                                        !container.getHostResource().realResources().gpuResources().isZero());
                onnxModel.setGpuDevice(gpuDevice, hasGpu);
            }
        }
        for (OnnxModel onnxModel : models.asMap().values())
            cluster.onnxModelCostCalculator().registerModel(context.getApplicationPackage().getFile(onnxModel.getFilePath()), onnxModel.onnxModelOptions());

        cluster.setModelEvaluation(new ContainerModelEvaluation(cluster, profiles, models));
    }

    private String getStringValue(Element element, String name, String defaultValue) {
        Element child = XML.getChild(element, name);
        return (child != null) ? child.getTextContent() : defaultValue;
    }

    private int getIntValue(Element element, String name, int defaultValue) {
        Element child = XML.getChild(element, name);
        return (child != null) ? Integer.parseInt(child.getTextContent()) : defaultValue;
    }

    protected void addModelEvaluationRuntime(ApplicationContainerCluster cluster) {
        /* These bundles are added to all application container clusters, even if they haven't
         * declared 'model-evaluation' in services.xml, because there are many public API packages
         * in the model-evaluation bundle that could be used by customer code. */
        cluster.addPlatformBundle(ContainerModelEvaluation.MODEL_EVALUATION_BUNDLE_FILE);
        cluster.addPlatformBundle(ContainerModelEvaluation.MODEL_INTEGRATION_BUNDLE_FILE);
        cluster.addPlatformBundle(ContainerModelEvaluation.ONNXRUNTIME_BUNDLE_FILE);
        /* The ONNX runtime is always available for injection to any component */
        cluster.addSimpleComponent(
                ContainerModelEvaluation.ONNX_RUNTIME_CLASS, null, ContainerModelEvaluation.INTEGRATION_BUNDLE_NAME);
        /* Add runtime providing utilities such as metrics to embedder implementations */
        cluster.addSimpleComponent(
                "ai.vespa.embedding.EmbedderRuntime", null, ContainerModelEvaluation.INTEGRATION_BUNDLE_NAME);
    }

    private void addProcessing(DeployState deployState, Element spec, ApplicationContainerCluster cluster, ConfigModelContext context) {
        Element processingElement = XML.getChild(spec, "processing");
        if (processingElement == null) return;

        cluster.addSearchAndDocprocBundles();
        addIncludes(processingElement);
        cluster.setProcessingChains(new DomProcessingBuilder(null).build(deployState, cluster, processingElement),
                                    serverBindings(deployState, context, processingElement, ProcessingChains.defaultBindings).toArray(BindingPattern[]::new));
        validateAndAddConfiguredComponents(deployState, cluster, processingElement, "renderer", ContainerModelBuilder::validateRendererElement);
    }

    private ContainerSearch buildSearch(DeployState deployState, ApplicationContainerCluster containerCluster, Element producerSpec) {
        SearchChains searchChains = new DomSearchChainsBuilder()
                                            .build(deployState, containerCluster, producerSpec);

        ContainerSearch containerSearch = new ContainerSearch(deployState, containerCluster, searchChains);

        applyApplicationPackageDirectoryConfigs(deployState.getApplicationPackage(), containerSearch);
        containerSearch.setQueryProfiles(deployState.getQueryProfiles());
        containerSearch.setSemanticRules(deployState.getSemanticRules());

        return containerSearch;
    }

    private void applyApplicationPackageDirectoryConfigs(ApplicationPackage applicationPackage,ContainerSearch containerSearch) {
        PageTemplates.validate(applicationPackage);
        containerSearch.setPageTemplates(PageTemplates.create(applicationPackage));
    }

    private void addUserHandlers(DeployState deployState, ApplicationContainerCluster cluster, Element spec, ConfigModelContext context) {
        for (Element component: XML.getChildren(spec, "handler")) {
            cluster.addComponent(
                    new DomHandlerBuilder(cluster, portBindingOverride(deployState, context)).build(deployState, cluster, component));
        }
    }

    private void checkVersion(Element spec) {
        String version = spec.getAttribute("version");

        if ( ! Version.fromString(version).equals(new Version(1)))
            throw new IllegalArgumentException("Expected container version to be 1.0, but got " + version);
    }

    private void addNodes(ApplicationContainerCluster cluster, Element spec, ConfigModelContext context) {
        if (standaloneBuilder)
            addStandaloneNode(cluster, context.getDeployState());
        else
            addNodesFromXml(cluster, spec, context);
    }

    private void addStandaloneNode(ApplicationContainerCluster cluster, DeployState deployState) {
        ApplicationContainer container = new ApplicationContainer(cluster, "standalone", cluster.getContainers().size(), deployState);
        cluster.addContainers(List.of(container));
    }

    private static String buildJvmGCOptions(ConfigModelContext context, String jvmGCOptions) {
        return new JvmGcOptions(context.getDeployState(), jvmGCOptions).build();
    }

    private static String getJvmOptions(Element nodesElement,
                                        DeployState deployState,
                                        boolean legacyOptions) {
        return new JvmOptions(nodesElement, deployState, legacyOptions).build();
    }

    private static String extractAttribute(Element element, String attrName) {
        return element.hasAttribute(attrName) ? element.getAttribute(attrName) : null;
    }

    private void extractJvmOptions(List nodes,
                                   ApplicationContainerCluster cluster,
                                   Element nodesElement,
                                   ConfigModelContext context) {
        Element jvmElement = XML.getChild(nodesElement, "jvm");
        if (jvmElement == null) {
            extractJvmFromLegacyNodesTag(nodes, cluster, nodesElement, context);
        } else {
            extractJvmTag(nodes, cluster, nodesElement, jvmElement, context);
        }
    }

    private void extractJvmFromLegacyNodesTag(List nodes, ApplicationContainerCluster cluster,
                                              Element nodesElement, ConfigModelContext context) {
        applyNodesTagJvmArgs(nodes, getJvmOptions(nodesElement, context.getDeployState(), true));

        if (cluster.getJvmGCOptions().isEmpty()) {
            String jvmGCOptions = extractAttribute(nodesElement, VespaDomBuilder.JVM_GC_OPTIONS);

            if (jvmGCOptions != null && !jvmGCOptions.isEmpty()) {
                DeployLogger logger = context.getDeployState().getDeployLogger();
                logger.logApplicationPackage(WARNING, "'jvm-gc-options' is deprecated and will be removed in Vespa 9." +
                        " Please merge into 'gc-options' in 'jvm' element." +
                        " See https://docs.vespa.ai/en/reference/services-container.html#jvm");
            }

            cluster.setJvmGCOptions(buildJvmGCOptions(context, jvmGCOptions));
        }

        if (applyMemoryPercentage(cluster, nodesElement.getAttribute(VespaDomBuilder.Allocated_MEMORY_ATTRIB_NAME)))
            context.getDeployState().getDeployLogger()
                   .logApplicationPackage(WARNING, "'allocated-memory' is deprecated and will be removed in Vespa 9." +
                           " Please merge into 'allocated-memory' in 'jvm' element." +
                           " See https://docs.vespa.ai/en/reference/services-container.html#jvm");
    }

    private void extractJvmTag(List nodes, ApplicationContainerCluster cluster,
                               Element nodesElement, Element jvmElement, ConfigModelContext context) {
        applyNodesTagJvmArgs(nodes, getJvmOptions(nodesElement, context.getDeployState(), false));
        applyMemoryPercentage(cluster, jvmElement.getAttribute(VespaDomBuilder.Allocated_MEMORY_ATTRIB_NAME));
        String jvmGCOptions = extractAttribute(jvmElement, VespaDomBuilder.GC_OPTIONS);
        cluster.setJvmGCOptions(buildJvmGCOptions(context, jvmGCOptions));
    }

    /**
     * Add nodes to cluster according to the given containerElement.
     *
     * Note: DO NOT change allocation behaviour to allow version X and Y of the config-model to allocate a different set
     * of nodes. Such changes must be guarded by a common condition (e.g. feature flag) so the behaviour can be changed
     * simultaneously for all active config models.
     */
    private void addNodesFromXml(ApplicationContainerCluster cluster, Element containerElement, ConfigModelContext context) {
        Element nodesElement = XML.getChild(containerElement, "nodes");
        if (nodesElement == null) {
            cluster.addContainers(allocateWithoutNodesTag(cluster, context));
            cluster.setJvmGCOptions(buildJvmGCOptions(context, null));
        } else {
            List nodes = createNodes(cluster, containerElement, nodesElement, context);

            extractJvmOptions(nodes, cluster, nodesElement, context);
            applyDefaultPreload(nodes, nodesElement);
            var envVars = getEnvironmentVariables(XML.getChild(nodesElement, ENVIRONMENT_VARIABLES_ELEMENT)).entrySet();
            for (var container : nodes) {
                for (var entry : envVars) {
                    container.addEnvironmentVariable(entry.getKey(), entry.getValue());
                }
            }
            if (useCpuSocketAffinity(nodesElement))
                AbstractService.distributeCpuSocketAffinity(nodes);
            cluster.addContainers(nodes);
        }
    }

    private ZoneEndpoint zoneEndpoint(ConfigModelContext context, ClusterSpec.Id cluster) {
        InstanceName instance = context.properties().applicationId().instance();
        ZoneId zone = ZoneId.from(context.properties().zone().environment(),
                                  context.properties().zone().region());
        return context.getApplicationPackage().getDeploymentSpec().zoneEndpoint(instance, zone, cluster);
    }

    private static Map getEnvironmentVariables(Element environmentVariables) {
        var map = new LinkedHashMap();
        if (environmentVariables != null) {
            for (Element var: XML.getChildren(environmentVariables)) {
                var name = new com.yahoo.text.Identifier(var.getNodeName());
                map.put(name.toString(), var.getTextContent());
            }
        }
        return map;
    }
    
    private List createNodes(ApplicationContainerCluster cluster, Element containerElement,
                                                   Element nodesElement, ConfigModelContext context) {
        if (nodesElement.hasAttribute("type")) // internal use for hosted system infrastructure nodes
            return createNodesFromNodeType(cluster, nodesElement, context);
        else if (nodesElement.hasAttribute("of")) {// hosted node spec referencing a content cluster
            // TODO: Remove support for combined clusters in Vespa 9
            List containers = createNodesFromContentServiceReference(cluster, nodesElement, context);
            log.logApplicationPackage(WARNING, "Declaring combined cluster with  is deprecated without " +
                                               "replacement, and the feature will be removed in Vespa 9. Use separate container and " +
                                               "content clusters instead");
            return containers;
        } else if (nodesElement.hasAttribute("count")) // regular, hosted node spec
            return createNodesFromNodeCount(cluster, containerElement, nodesElement, context);
        else if (cluster.isHostedVespa()) // default to 1 if node count is not specified
            return createNodesFromNodeCount(cluster, containerElement, nodesElement, context);
        else // the non-hosted option
            return createNodesFromNodeList(context.getDeployState(), cluster, nodesElement);
    }
    
    private static boolean applyMemoryPercentage(ApplicationContainerCluster cluster, String memoryPercentage) {
        try {
            if (memoryPercentage == null || memoryPercentage.isEmpty()) return false;
            memoryPercentage = memoryPercentage.trim();
            if ( ! memoryPercentage.endsWith("%"))
                throw new IllegalArgumentException("Missing % sign");
            memoryPercentage = memoryPercentage.substring(0, memoryPercentage.length()-1).trim();
            cluster.setMemoryPercentage(Integer.parseInt(memoryPercentage));
            return true;
        }
        catch (NumberFormatException e) {
            throw new IllegalArgumentException("The memory percentage given for nodes in " + cluster +
                                               " must be given as an integer followe by '%'", e);
        }
    }

    /** Allocate a container cluster without a nodes tag */
    private List allocateWithoutNodesTag(ApplicationContainerCluster cluster, ConfigModelContext context) {
        DeployState deployState = context.getDeployState();
        HostSystem hostSystem = cluster.hostSystem();
        if (deployState.isHosted()) {
            // request just enough nodes to satisfy environment capacity requirement
            int nodeCount = deployState.zone().environment().isProduction() ? 2 : 1;
            deployState.getDeployLogger().logApplicationPackage(Level.INFO, "Using " + nodeCount + " nodes in " + cluster);
            var nodesSpec = NodesSpecification.dedicated(nodeCount, context);
            ClusterSpec.Id clusterId = ClusterSpec.Id.from(cluster.getName());
            var hosts = nodesSpec.provision(hostSystem,
                                            ClusterSpec.Type.container,
                                            clusterId,
                                            zoneEndpoint(context, clusterId),
                                            deployState.getDeployLogger(),
                                            false,
                                            context.clusterInfo().build());
            return createNodesFromHosts(hosts, cluster, context.getDeployState());
        }
        else {
            return singleHostContainerCluster(cluster, hostSystem.getHost(Container.SINGLENODE_CONTAINER_SERVICESPEC), context);
        }
    }

    private List singleHostContainerCluster(ApplicationContainerCluster cluster, HostResource host, ConfigModelContext context) {
        ApplicationContainer node = new ApplicationContainer(cluster, "container.0", 0, context.getDeployState());
        node.setHostResource(host);
        node.initService(context.getDeployState());
        return List.of(node);
    }

    private static void requireFixedSizeSingularNodeIfTester(ConfigModelContext context, NodesSpecification nodes) {
        if ( ! context.properties().hostedVespa() || ! context.properties().applicationId().instance().isTester())
            return;

        if ( ! nodes.maxResources().equals(nodes.minResources()))
            throw new IllegalArgumentException("tester resources must be absolute, but min and max resources differ: " + nodes);

        if (nodes.maxResources().nodes() > 1)
            throw new IllegalArgumentException("tester cannot run on more than 1 node, but " + nodes.maxResources().nodes() + " nodes were specified");
    }

    private List createNodesFromNodeCount(ApplicationContainerCluster cluster, Element containerElement, Element nodesElement, ConfigModelContext context) {
        try {
            var nodesSpecification = NodesSpecification.from(new ModelElement(nodesElement), context);
            requireFixedSizeSingularNodeIfTester(context, nodesSpecification);
            var clusterId = ClusterSpec.Id.from(cluster.name());
            Map hosts = nodesSpecification.provision(cluster.getRoot().hostSystem(),
                                                                                      ClusterSpec.Type.container,
                                                                                      clusterId,
                                                                                      zoneEndpoint(context, clusterId),
                                                                                      log,
                                                                                      getZooKeeper(containerElement) != null,
                                                                                      context.clusterInfo().build());
            return createNodesFromHosts(hosts, cluster, context.getDeployState());
        }
        catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("In " + cluster, e);
        }
    }

    private List createNodesFromNodeType(ApplicationContainerCluster cluster, Element nodesElement, ConfigModelContext context) {
        NodeType type = NodeType.valueOf(nodesElement.getAttribute("type"));
        ClusterSpec clusterSpec = ClusterSpec.request(ClusterSpec.Type.container, ClusterSpec.Id.from(cluster.getName()))
                .vespaVersion(context.getDeployState().getWantedNodeVespaVersion())
                .dockerImageRepository(context.getDeployState().getWantedDockerImageRepo())
                .build();
        Map hosts = 
                cluster.getRoot().hostSystem().allocateHosts(clusterSpec,
                                                             Capacity.fromRequiredNodeType(type), log);
        return createNodesFromHosts(hosts, cluster, context.getDeployState());
    }
    
    private List createNodesFromContentServiceReference(ApplicationContainerCluster cluster, Element nodesElement, ConfigModelContext context) {
        NodesSpecification nodeSpecification;
        try {
            nodeSpecification = NodesSpecification.from(new ModelElement(nodesElement), context);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException(cluster + " contains an invalid reference", e);
        }
        String referenceId = nodesElement.getAttribute("of");
        cluster.setHostClusterId(referenceId);

        Map hosts = 
                StorageGroup.provisionHosts(nodeSpecification,
                                            referenceId, 
                                            cluster.getRoot().hostSystem(),
                                            context);
        return createNodesFromHosts(hosts, cluster, context.getDeployState());
    }

    private List createNodesFromHosts(Map hosts,
                                                            ApplicationContainerCluster cluster,
                                                            DeployState deployState) {
        List nodes = new ArrayList<>();
        for (Map.Entry entry : hosts.entrySet()) {
            String id = "container." + entry.getValue().index();
            ApplicationContainer container = new ApplicationContainer(cluster, id, entry.getValue().retired(), entry.getValue().index(), deployState);
            container.setHostResource(entry.getKey());
            container.initService(deployState);
            nodes.add(container);
        }
        return nodes;
    }

    private List createNodesFromNodeList(DeployState deployState, ApplicationContainerCluster cluster, Element nodesElement) {
        List nodes = new ArrayList<>();
        int nodeIndex = 0;
        for (Element nodeElem: XML.getChildren(nodesElement, "node")) {
            nodes.add(new ContainerServiceBuilder("container." + nodeIndex, nodeIndex).build(deployState, cluster, nodeElem));
            nodeIndex++;
        }
        return nodes;
    }

    private static boolean useCpuSocketAffinity(Element nodesElement) {
        if (nodesElement.hasAttribute(VespaDomBuilder.CPU_SOCKET_AFFINITY_ATTRIB_NAME))
            return Boolean.parseBoolean(nodesElement.getAttribute(VespaDomBuilder.CPU_SOCKET_AFFINITY_ATTRIB_NAME));
        else
            return false;
    }

    private static void applyNodesTagJvmArgs(List containers, String jvmArgs) {
        for (Container container: containers) {
            if (container.getAssignedJvmOptions().isEmpty())
                container.prependJvmOptions(jvmArgs);
        }
    }

    private static void applyDefaultPreload(List containers, Element nodesElement) {
        if (! nodesElement.hasAttribute(VespaDomBuilder.PRELOAD_ATTRIB_NAME)) return;
        for (Container container: containers)
            container.setPreLoad(nodesElement.getAttribute(VespaDomBuilder.PRELOAD_ATTRIB_NAME));
    }

    private void addSearchHandler(DeployState deployState, ApplicationContainerCluster cluster, Element searchElement, ConfigModelContext context) {
        var bindingPatterns = List.of(SearchHandler.DEFAULT_BINDING);
        if (isHostedTenantApplication(context)) {
            bindingPatterns = SearchHandler.bindingPattern(getDataplanePorts(deployState));
        }
        SearchHandler searchHandler = new SearchHandler(deployState, cluster,
                                                        serverBindings(deployState, context, searchElement, bindingPatterns),
                                                        searchElement);
        cluster.addComponent(searchHandler);

        // Add as child to SearchHandler to get the correct chains config.
        searchHandler.addComponent(Component.fromClassAndBundle(SearchHandler.EXECUTION_FACTORY, PlatformBundles.SEARCH_AND_DOCPROC_BUNDLE));
    }

    private List serverBindings(DeployState deployState, ConfigModelContext context, Element searchElement, Collection defaultBindings) {
        List bindings = XML.getChildren(searchElement, "binding");
        if (bindings.isEmpty())
            return List.copyOf(defaultBindings);

        return toBindingList(deployState, context, bindings);
    }

    private List toBindingList(DeployState deployState, ConfigModelContext context, List bindingElements) {
        List result = new ArrayList<>();
        var portOverride = isHostedTenantApplication(context) ? getDataplanePorts(deployState) : Set.of();
        for (Element element: bindingElements) {
            String text = element.getTextContent().trim();
            if (!text.isEmpty())
                result.addAll(userBindingPattern(text, portOverride));
        }

        return result;
    }
    private static Collection userBindingPattern(String path, Set portBindingOverride) {
        UserBindingPattern bindingPattern = UserBindingPattern.fromPattern(path);
        if (portBindingOverride.isEmpty()) return Set.of(bindingPattern);
        return portBindingOverride.stream()
                .map(bindingPattern::withOverriddenPort)
                .toList();
    }


    private ContainerDocumentApi buildDocumentApi(DeployState deployState, ApplicationContainerCluster cluster, Element spec, ConfigModelContext context) {
        Element documentApiElement = XML.getChild(spec, "document-api");
        if (documentApiElement == null) return null;

        ContainerDocumentApi.HandlerOptions documentApiOptions = DocumentApiOptionsBuilder.build(documentApiElement);
        Element ignoreUndefinedFields = XML.getChild(documentApiElement, "ignore-undefined-fields");
        return new ContainerDocumentApi(deployState, cluster, documentApiOptions,
                                        "true".equals(XML.getValue(ignoreUndefinedFields)), portBindingOverride(deployState, context));
    }

    private Set portBindingOverride(DeployState deployState, ConfigModelContext context) {
        return isHostedTenantApplication(context)
                ? getDataplanePorts(deployState)
                : Set.of();
    }

    private ContainerDocproc buildDocproc(DeployState deployState, ApplicationContainerCluster cluster, Element spec) {
        Element docprocElement = XML.getChild(spec, "document-processing");
        if (docprocElement == null)
            return null;

        addIncludes(docprocElement);
        DocprocChains chains = new DomDocprocChainsBuilder(null, false).build(deployState, cluster, docprocElement);

        ContainerDocproc.Options docprocOptions = DocprocOptionsBuilder.build(docprocElement, deployState.getDeployLogger());
        return new ContainerDocproc(cluster, chains, docprocOptions, !standaloneBuilder);
     }

    private void addIncludes(Element parentElement) {
        List includes = XML.getChildren(parentElement, IncludeDirs.INCLUDE);
        if (includes.isEmpty()) {
            return;
        }
        if (app == null) {
            throw new IllegalArgumentException("Element  given in XML config, but no application package given.");
        }
        for (Element include : includes) {
            addInclude(parentElement, include);
        }
    }

    private void addInclude(Element parentElement, Element include) {
        String dirName = include.getAttribute(IncludeDirs.DIR);
        app.validateIncludeDir(dirName);

        List includedFiles = Xml.allElemsFromPath(app, dirName);
        for (Element includedFile : includedFiles) {
            List includedSubElements = XML.getChildren(includedFile);
            for (Element includedSubElement : includedSubElements) {
                Node copiedNode = parentElement.getOwnerDocument().importNode(includedSubElement, true);
                parentElement.appendChild(copiedNode);
            }
        }
    }

    private static void addConfiguredComponents(DeployState deployState, ContainerCluster cluster,
                                                Element parent, String componentName) {
        for (Element component : XML.getChildren(parent, componentName)) {
            ModelIdResolver.resolveModelIds(component, deployState.isHosted());
            cluster.addComponent(new DomComponentBuilder().build(deployState, cluster, component));
        }
    }

    private static void validateAndAddConfiguredComponents(DeployState deployState,
                                                           ContainerCluster cluster,
                                                           Element spec,
                                                           String componentName,
                                                           Consumer elementValidator) {
        for (Element node : XML.getChildren(spec, componentName)) {
            elementValidator.accept(node); // throws exception here if something is wrong
            cluster.addComponent(new DomComponentBuilder().build(deployState, cluster, node));
        }
    }

    private void addIdentityProvider(ApplicationContainerCluster cluster, List configServerSpecs, HostName loadBalancerName,
                                     URI ztsUrl, String athenzDnsSuffix, Zone zone, DeploymentSpec spec) {
        spec.athenzDomain().ifPresent(domain -> {
            AthenzService service = spec.athenzService(app.getApplicationId().instance(), zone.environment(), zone.region())
                                        .orElseThrow(() -> new IllegalArgumentException("Missing Athenz service configuration in instance '" +
                                                                                        app.getApplicationId().instance() + "'"));
            addIdentityProvider(cluster, configServerSpecs, loadBalancerName, ztsUrl, athenzDnsSuffix, zone, domain, service);
        });
    }

    private void addIdentityProvider(ApplicationContainerCluster cluster, List configServerSpecs, HostName loadBalancerName,
                                     URI ztsUrl, String athenzDnsSuffix, Zone zone, AthenzDomain domain, AthenzService service) {
        String zoneDnsSuffix = zone.environment().value() + "-" + zone.region().value() + "." + athenzDnsSuffix;
        IdentityProvider identityProvider = new IdentityProvider(domain,
                                                                 service,
                                                                 getLoadBalancerName(loadBalancerName, configServerSpecs),
                                                                 ztsUrl,
                                                                 zoneDnsSuffix,
                                                                 zone);

        // Replace AthenzIdentityProviderProvider
        cluster.removeComponent(ComponentId.fromString("com.yahoo.container.jdisc.AthenzIdentityProviderProvider"));
        cluster.addComponent(identityProvider);

        var serviceIdentityProviderProvider = "com.yahoo.vespa.athenz.identityprovider.client.ServiceIdentityProviderProvider";
        cluster.addComponent(new SimpleComponent(new ComponentModel(serviceIdentityProviderProvider, serviceIdentityProviderProvider, "vespa-athenz")));

        cluster.getContainers().forEach(container -> {
            container.setProp("identity.domain", domain.value());
            container.setProp("identity.service", service.value());
        });
    }

    private HostName getLoadBalancerName(HostName loadbalancerName, List configServerSpecs) {
        // Set lbaddress, or use first hostname if not specified.
        // TODO: Remove this method and use the loadbalancerName directly
        return Optional.ofNullable(loadbalancerName)
                .orElseGet(
                        () -> HostName.of(configServerSpecs.stream()
                                                           .findFirst()
                                                           .map(ConfigServerSpec::getHostName)
                                                           .orElse("unknown") // Currently unable to test this, hence the unknown
                        ));
    }

    private static Element getZooKeeper(Element spec) {
        return XML.getChild(spec, "zookeeper");
    }

    /** Disallow renderers named "XmlRenderer" or "JsonRenderer" */
    private static void validateRendererElement(Element element) {
        String idAttr = element.getAttribute("id");

        if (idAttr.equals(xmlRendererId) || idAttr.equals(jsonRendererId)) {
            throw new IllegalArgumentException(String.format("Renderer id %s is reserved for internal use", idAttr));
        }
    }

    public static boolean isContainerTag(Element element) {
        return CONTAINER_TAG.equals(element.getTagName());
    }

    /**
     * Validates JVM options and logs a warning or fails deployment (depending on feature flag)
     * if anyone of them has invalid syntax or is an option that is unsupported for the running system.
     */
     private static class JvmOptions {

        private static final Pattern validPattern = Pattern.compile("-[a-zA-z0-9=:./,+*-]+");
        // debug port will not be available in hosted, don't allow
        private static final Pattern invalidInHostedPattern = Pattern.compile("-Xrunjdwp:transport=.*");

        private final Element nodesElement;
        private final DeployLogger logger;
        private final boolean legacyOptions;
        private final boolean isHosted;

        public JvmOptions(Element nodesElement, DeployState deployState, boolean legacyOptions) {
            this.nodesElement = nodesElement;
            this.logger = deployState.getDeployLogger();
            this.legacyOptions = legacyOptions;
            this.isHosted = deployState.isHosted();
        }

        String build() {
            if (legacyOptions)
                return buildLegacyOptions();

            Element jvmElement = XML.getChild(nodesElement, "jvm");
            if (jvmElement == null) return "";
            String jvmOptions = jvmElement.getAttribute(VespaDomBuilder.OPTIONS);
            if (jvmOptions.isEmpty()) return "";
            validateJvmOptions(jvmOptions);
            return jvmOptions;
        }

        String buildLegacyOptions() {
            String jvmOptions = null;
            if (nodesElement.hasAttribute(VespaDomBuilder.JVM_OPTIONS)) {
                jvmOptions = nodesElement.getAttribute(VespaDomBuilder.JVM_OPTIONS);
                if (! jvmOptions.isEmpty())
                    logger.logApplicationPackage(WARNING, "'jvm-options' is deprecated and will be removed in Vespa 9." +
                            " Please merge 'jvm-options' into 'options' or 'gc-options' in 'jvm' element." +
                            " See https://docs.vespa.ai/en/reference/services-container.html#jvm");
            }

            validateJvmOptions(jvmOptions);

            return jvmOptions;
        }

        private void validateJvmOptions(String jvmOptions) {
            if (jvmOptions == null || jvmOptions.isEmpty()) return;

            String[] optionList = jvmOptions.split(" ");
            List invalidOptions = Arrays.stream(optionList)
                                                .filter(option -> !option.isEmpty())
                                                .filter(option -> !Pattern.matches(validPattern.pattern(), option))
                                                .sorted()
                                                .collect(Collectors.toCollection(ArrayList::new));
            if (isHosted)
                invalidOptions.addAll(Arrays.stream(optionList)
                        .filter(option -> !option.isEmpty())
                        .filter(option -> Pattern.matches(invalidInHostedPattern.pattern(), option))
                        .sorted().toList());

            if (invalidOptions.isEmpty()) return;

            String message = "Invalid or misplaced JVM options in services.xml: " +
                    String.join(",", invalidOptions) + "." +
                    " See https://docs.vespa.ai/en/reference/services-container.html#jvm";
            if (isHosted)
                throw new IllegalArgumentException(message);
            else
                logger.logApplicationPackage(WARNING, message);
        }
    }

    /**
     * Validates JVM GC options and logs a warning or fails deployment (depending on feature flag)
     * if anyone of them has invalid syntax or is an option that is unsupported for the running system
     * (e.g. uses CMS options for hosted Vespa, which uses JDK 17).
     */
    private static class JvmGcOptions {

        private static final Pattern validPattern = Pattern.compile("-XX:[+-]*[a-zA-z0-9=]+");
        private static final Pattern invalidCMSPattern = Pattern.compile("-XX:[+-]\\w*CMS[a-zA-z0-9=]+");

        private final DeployState deployState;
        private final String jvmGcOptions;
        private final DeployLogger logger;
        private final boolean isHosted;

        public JvmGcOptions(DeployState deployState, String jvmGcOptions) {
            this.deployState = deployState;
            this.jvmGcOptions = jvmGcOptions;
            this.logger = deployState.getDeployLogger();
            this.isHosted = deployState.isHosted();
        }

        private String build() {
            String options = deployState.getProperties().jvmGCOptions();
            if (jvmGcOptions != null) {
                options = jvmGcOptions;
                String[] optionList = options.split(" ");
                List invalidOptions = Arrays.stream(optionList)
                        .filter(option -> !option.isEmpty())
                        .filter(option -> !Pattern.matches(validPattern.pattern(), option)
                                || Pattern.matches(invalidCMSPattern.pattern(), option)
                                || option.equals("-XX:+UseConcMarkSweepGC"))
                        .sorted()
                        .toList();

                logOrFailInvalidOptions(invalidOptions);
            }

            if (options == null || options.isEmpty())
                options = deployState.isHosted() ? ContainerCluster.PARALLEL_GC : ContainerCluster.G1GC;

            return options;
        }

        private void logOrFailInvalidOptions(List options) {
            if (options.isEmpty()) return;

            String message = "Invalid or misplaced JVM GC options in services.xml: " +
                    String.join(",", options) + "." +
                    " See https://docs.vespa.ai/en/reference/services-container.html#jvm";
            if (isHosted)
                throw new IllegalArgumentException(message);
            else
                logger.logApplicationPackage(WARNING, message);
        }

    }

    private static Set getDataplanePorts(DeployState ds) {
        var tokenPort = getTokenDataplanePort(ds);
        var mtlsPort = getMtlsDataplanePort(ds);
        return tokenPort.isPresent() ? Set.of(mtlsPort, tokenPort.getAsInt()) : Set.of(mtlsPort);
    }

    private static int getMtlsDataplanePort(DeployState ds) {
        return enableTokenSupport(ds) ? 8443 : 4443;
    }

    private static OptionalInt getTokenDataplanePort(DeployState ds) {
        return enableTokenSupport(ds) ? OptionalInt.of(8444) : OptionalInt.empty();
    }

    private static Set tokenEndpoints(DeployState deployState) {
        return deployState.getEndpoints().stream()
                .filter(endpoint -> endpoint.authMethod() == ApplicationClusterEndpoint.AuthMethod.token)
                .collect(Collectors.toSet());
    }

    private static boolean enableTokenSupport(DeployState state) {
        Set tokenEndpoints = tokenEndpoints(state);
        return state.isHosted() && state.zone().system().isPublic() && ! tokenEndpoints.isEmpty();
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy