diff options
Diffstat (limited to 'controller-server/src/main/java/com/yahoo/vespa')
9 files changed, 403 insertions, 156 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java index 34a7ae89dd2..e09e1f04b8e 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java @@ -15,7 +15,6 @@ import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.Tags; import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.zone.ZoneId; -import com.yahoo.log.LogLevel; import com.yahoo.text.Text; import com.yahoo.transaction.Mutex; import com.yahoo.vespa.athenz.api.AthenzDomain; @@ -39,7 +38,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServ import com.yahoo.vespa.hosted.controller.api.integration.configserver.ContainerEndpoint; import com.yahoo.vespa.hosted.controller.api.integration.configserver.DeploymentResult; import com.yahoo.vespa.hosted.controller.api.integration.configserver.DeploymentResult.LogEntry; -import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node; import com.yahoo.vespa.hosted.controller.api.integration.configserver.NodeFilter; import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationStore; @@ -58,6 +56,7 @@ 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.application.pkg.ApplicationPackage; +import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackageStream; import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackageValidator; import com.yahoo.vespa.hosted.controller.athenz.impl.AthenzFacade; import com.yahoo.vespa.hosted.controller.certificate.EndpointCertificates; @@ -79,6 +78,7 @@ import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.vespa.hosted.controller.versions.VespaVersion.Confidence; import com.yahoo.yolean.Exceptions; +import java.io.ByteArrayInputStream; import java.security.Principal; import java.security.cert.X509Certificate; import java.time.Clock; @@ -87,7 +87,6 @@ import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -98,6 +97,7 @@ import java.util.TreeMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -489,9 +489,6 @@ public class ApplicationController { DeploymentId deployment = new DeploymentId(job.application(), zone); try (Mutex deploymentLock = lockForDeployment(job.application(), zone)) { - Set<ContainerEndpoint> containerEndpoints; - Optional<EndpointCertificateMetadata> endpointCertificateMetadata; - Run run = controller.jobController().last(job) .orElseThrow(() -> new IllegalStateException("No known run of '" + job + "'")); @@ -500,30 +497,32 @@ public class ApplicationController { Version platform = run.versions().sourcePlatform().filter(__ -> deploySourceVersions).orElse(run.versions().targetPlatform()); RevisionId revision = run.versions().sourceRevision().filter(__ -> deploySourceVersions).orElse(run.versions().targetRevision()); - ApplicationPackage applicationPackage = new ApplicationPackage(applicationStore.get(deployment, revision)); - + ApplicationPackageStream applicationPackage = new ApplicationPackageStream(() -> applicationStore.stream(deployment, revision), + ApplicationPackageStream.addingCertificate(run.testerCertificate())); AtomicReference<RevisionId> lastRevision = new AtomicReference<>(); Instance instance; + Set<ContainerEndpoint> containerEndpoints; try (Mutex lock = lock(applicationId)) { LockedApplication application = new LockedApplication(requireApplication(applicationId), lock); application.get().revisions().last().map(ApplicationVersion::id).ifPresent(lastRevision::set); instance = application.get().require(job.application().instance()); - if ( ! applicationPackage.trustedCertificates().isEmpty() - && run.testerCertificate().isPresent()) - applicationPackage = applicationPackage.withTrustedCertificate(run.testerCertificate().get()); - - endpointCertificateMetadata = endpointCertificates.getMetadata(instance, zone, applicationPackage.deploymentSpec()); - containerEndpoints = controller.routing().of(deployment).prepare(application); } // Release application lock while doing the deployment, which is a lengthy task. + Supplier<Optional<EndpointCertificateMetadata>> endpointCertificateMetadata = () -> { + try (Mutex lock = lock(applicationId)) { + Optional<EndpointCertificateMetadata> data = endpointCertificates.getMetadata(instance, zone, applicationPackage.truncatedPackage().deploymentSpec()); + data.ifPresent(e -> deployLogger.accept("Using CA signed certificate version %s".formatted(e.version()))); + return data; + } + }; + // Carry out deployment without holding the application lock. DeploymentResult result = deploy(job.application(), instance.tags(), applicationPackage, zone, platform, containerEndpoints, endpointCertificateMetadata, run.isDryRun()); - endpointCertificateMetadata.ifPresent(e -> deployLogger.accept("Using CA signed certificate version %s".formatted(e.version()))); // Record the quota usage for this application var quotaUsage = deploymentQuotaUsage(zone, job.application()); @@ -544,8 +543,10 @@ public class ApplicationController { .distinct() .collect(Collectors.toList())) .orElseGet(List::of); - if (warnings.isEmpty()) controller.notificationsDb().removeNotification(source, Notification.Type.applicationPackage); - else controller.notificationsDb().setNotification(source, Notification.Type.applicationPackage, Notification.Level.warning, warnings); + if (warnings.isEmpty()) + controller.notificationsDb().removeNotification(source, Notification.Type.applicationPackage); + else + controller.notificationsDb().setNotification(source, Notification.Type.applicationPackage, Notification.Level.warning, warnings); } lockApplicationOrThrow(applicationId, application -> @@ -606,23 +607,23 @@ public class ApplicationController { /** Deploy a system application to given zone */ public DeploymentResult deploySystemApplicationPackage(SystemApplication application, ZoneId zone, Version version) { if (application.hasApplicationPackage()) { - ApplicationPackage applicationPackage = new ApplicationPackage( - artifactRepository.getSystemApplicationPackage(application.id(), zone, version) + ApplicationPackageStream applicationPackage = new ApplicationPackageStream( + () -> new ByteArrayInputStream(artifactRepository.getSystemApplicationPackage(application.id(), zone, version)) ); - return deploy(application.id(), Tags.empty(), applicationPackage, zone, version, Set.of(), /* No application cert */ Optional.empty(), false); + return deploy(application.id(), Tags.empty(), applicationPackage, zone, version, Set.of(), Optional::empty, false); } else { throw new RuntimeException("This system application does not have an application package: " + application.id().toShortString()); } } /** Deploys the given tester application to the given zone. */ - public DeploymentResult deployTester(TesterId tester, ApplicationPackage applicationPackage, ZoneId zone, Version platform) { - return deploy(tester.id(), Tags.empty(), applicationPackage, zone, platform, Set.of(), /* No application cert for tester*/ Optional.empty(), false); + public DeploymentResult deployTester(TesterId tester, ApplicationPackageStream applicationPackage, ZoneId zone, Version platform) { + return deploy(tester.id(), Tags.empty(), applicationPackage, zone, platform, Set.of(), Optional::empty, false); } - private DeploymentResult deploy(ApplicationId application, Tags tags, ApplicationPackage applicationPackage, + private DeploymentResult deploy(ApplicationId application, Tags tags, ApplicationPackageStream applicationPackage, ZoneId zone, Version platform, Set<ContainerEndpoint> endpoints, - Optional<EndpointCertificateMetadata> endpointCertificateMetadata, + Supplier<Optional<EndpointCertificateMetadata>> endpointCertificateMetadata, boolean dryRun) { DeploymentId deployment = new DeploymentId(application, zone); try { @@ -638,13 +639,8 @@ public class ApplicationController { .filter(tenant-> tenant instanceof AthenzTenant) .map(tenant -> ((AthenzTenant)tenant).domain()); - if (zone.environment().isManuallyDeployed()) - controller.applications().applicationStore().putMeta(deployment, - clock.instant(), - applicationPackage.metaDataZip()); - - Quota deploymentQuota = DeploymentQuotaCalculator.calculate(billingController.getQuota(application.tenant()), - asList(application.tenant()), application, zone, applicationPackage.deploymentSpec()); + Supplier<Quota> deploymentQuota = () -> DeploymentQuotaCalculator.calculate(billingController.getQuota(application.tenant()), + asList(application.tenant()), application, zone, applicationPackage.truncatedPackage().deploymentSpec()); List<TenantSecretStore> tenantSecretStores = controller.tenants() .get(application.tenant()) @@ -654,9 +650,9 @@ public class ApplicationController { List<X509Certificate> operatorCertificates = controller.supportAccess().activeGrantsFor(deployment).stream() .map(SupportAccessGrant::certificate) .collect(toList()); - Optional<CloudAccount> cloudAccount = decideCloudAccountOf(deployment, applicationPackage.deploymentSpec()); + Supplier<Optional<CloudAccount>> cloudAccount = () -> decideCloudAccountOf(deployment, applicationPackage.truncatedPackage().deploymentSpec()); ConfigServer.PreparedApplication preparedApplication = - configServer.deploy(new DeploymentData(application, tags, zone, applicationPackage.zippedContent(), platform, + configServer.deploy(new DeploymentData(application, tags, zone, applicationPackage::zipStream, platform, endpoints, endpointCertificateMetadata, dockerImageRepo, domain, deploymentQuota, tenantSecretStores, operatorCertificates, cloudAccount, dryRun)); @@ -665,7 +661,12 @@ public class ApplicationController { } finally { // Even if prepare fails, routing configuration may need to be updated if ( ! application.instance().isTester()) { - controller.routing().of(deployment).configure(applicationPackage.deploymentSpec()); + controller.routing().of(deployment).configure(applicationPackage.truncatedPackage().deploymentSpec()); + if (zone.environment().isManuallyDeployed()) + controller.applications().applicationStore().putMeta(deployment, + clock.instant(), + applicationPackage.truncatedPackage().metaDataZip()); + } } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackage.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackage.java index b99d825a779..53c78d7c8ec 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackage.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackage.java @@ -59,19 +59,19 @@ import static java.util.stream.Collectors.toMap; * A representation of the content of an application package. * Only meta-data content can be accessed as anything other than compressed data. * A package is identified by a hash of the content. - * - * This is immutable. - * + * * @author bratseth * @author jonmv */ public class ApplicationPackage { - private static final String trustedCertificatesFile = "security/clients.pem"; - private static final String buildMetaFile = "build-meta.json"; + static final String trustedCertificatesFile = "security/clients.pem"; + static final String buildMetaFile = "build-meta.json"; static final String deploymentFile = "deployment.xml"; - private static final String validationOverridesFile = "validation-overrides.xml"; + static final String validationOverridesFile = "validation-overrides.xml"; static final String servicesFile = "services.xml"; + static final Set<String> prePopulated = Set.of(deploymentFile, validationOverridesFile, servicesFile, buildMetaFile, trustedCertificatesFile); + private static Hasher hasher() { return Hashing.murmur3_128().newHasher(); } private final String bundleHash; @@ -101,7 +101,7 @@ public class ApplicationPackage { */ public ApplicationPackage(byte[] zippedContent, boolean requireFiles) { this.zippedContent = Objects.requireNonNull(zippedContent, "The application package content cannot be null"); - this.files = new ZipArchiveCache(zippedContent, Set.of(deploymentFile, validationOverridesFile, servicesFile, buildMetaFile, trustedCertificatesFile)); + this.files = new ZipArchiveCache(zippedContent, prePopulated); Optional<DeploymentSpec> deploymentSpec = files.get(deploymentFile).map(bytes -> new String(bytes, UTF_8)).map(DeploymentSpec::fromXml); if (requireFiles && deploymentSpec.isEmpty()) @@ -122,17 +122,6 @@ public class ApplicationPackage { preProcessAndPopulateCache(); } - /** Returns a copy of this with the given certificate appended. */ - public ApplicationPackage withTrustedCertificate(X509Certificate certificate) { - List<X509Certificate> trustedCertificates = new ArrayList<>(this.trustedCertificates); - trustedCertificates.add(certificate); - byte[] certificatesBytes = X509CertificateUtils.toPem(trustedCertificates).getBytes(UTF_8); - - ByteArrayOutputStream modified = new ByteArrayOutputStream(zippedContent.length + certificatesBytes.length); - ZipEntries.transferAndWrite(modified, new ByteArrayInputStream(zippedContent), trustedCertificatesFile, certificatesBytes); - return new ApplicationPackage(modified.toByteArray()); - } - /** Hash of all files and settings that influence what is deployed to config servers. */ public String bundleHash() { return bundleHash; @@ -295,7 +284,7 @@ public class ApplicationPackage { private Map<Path, Optional<byte[]>> read(Collection<String> names) { var entries = ZipEntries.from(zip, - name -> names.contains(name), + names::contains, maxSize, true) .asList().stream() diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackageStream.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackageStream.java new file mode 100644 index 00000000000..021064417ac --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ApplicationPackageStream.java @@ -0,0 +1,265 @@ +package com.yahoo.vespa.hosted.controller.application.pkg; + +import com.yahoo.security.X509CertificateUtils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import static com.yahoo.security.X509CertificateUtils.certificateListFromPem; +import static java.io.OutputStream.nullOutputStream; +import static java.lang.Math.min; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Wraps a zipped application package stream. + * This allows replacing content as the input stream is read. + * This also retains a truncated {@link ApplicationPackage}, containing only the specified set of files, + * which can be accessed when this stream is fully exhausted. + * + * @author jonmv + */ +public class ApplicationPackageStream { + + private final Supplier<Replacer> replacer; + private final Supplier<Predicate<String>> filter; + private final Supplier<InputStream> in; + private final AtomicReference<ApplicationPackage> truncatedPackage = new AtomicReference<>(); + + public static Supplier<Replacer> addingCertificate(Optional<X509Certificate> certificate) { + return certificate.map(cert -> Replacer.of(Map.of(ApplicationPackage.trustedCertificatesFile, + trustBytes -> append(trustBytes, cert)))) + .orElse(Replacer.of(Map.of())); + } + + static InputStream append(InputStream trustIn, X509Certificate cert) { + try { + List<X509Certificate> trusted = trustIn == null ? new ArrayList<>() + : new ArrayList<>(certificateListFromPem(new String(trustIn.readAllBytes(), UTF_8))); + trusted.add(cert); + return new ByteArrayInputStream(X509CertificateUtils.toPem(trusted).getBytes(UTF_8)); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Stream that effectively copies the input stream to its {@link #truncatedPackage()} when exhausted. */ + public ApplicationPackageStream(Supplier<InputStream> in) { + this(in, () -> __ -> true, Map.of()); + } + + /** Stream that replaces the indicated entries, and copies all metadata files to its {@link #truncatedPackage()} when exhausted. */ + public ApplicationPackageStream(Supplier<InputStream> in, Supplier<Replacer> replacer) { + this(in, () -> name -> ApplicationPackage.prePopulated.contains(name) || name.endsWith(".xml"), replacer); + } + + /** Stream that replaces the indicated entries, and copies the filtered entries to its {@link #truncatedPackage()} when exhausted. */ + public ApplicationPackageStream(Supplier<InputStream> in, Supplier<Predicate<String>> truncation, Map<String, UnaryOperator<InputStream>> replacements) { + this(in, truncation, Replacer.of(replacements)); + } + + /** Stream that uses the given replacer to modify content, and copies the filtered entries to its {@link #truncatedPackage()} when exhausted. */ + public ApplicationPackageStream(Supplier<InputStream> in, Supplier<Predicate<String>> truncation, Supplier<Replacer> replacer) { + this.in = in; + this.filter = truncation; + this.replacer = replacer; + } + + /** + * Returns a new stream continaing the zipped application package this wraps. Separate streams may exist concurrently, + * and the first to be exhausted will populate the truncated application package. + */ + public InputStream zipStream() { + return new Stream(in.get(), replacer.get(), filter.get(), truncatedPackage); + } + + /** + * Returns the application package backed by only the files indicated by the truncation filter. + * Throws if no instances of {@link #zipStream()} have been exhausted yet. + */ + public ApplicationPackage truncatedPackage() { + ApplicationPackage truncated = truncatedPackage.get(); + if (truncated == null) throw new IllegalStateException("must completely exhaust input before reading package"); + return truncated; + } + + private static class Stream extends InputStream { + + private final byte[] inBuffer = new byte[1 << 16]; + private final ByteArrayOutputStream teeOut = new ByteArrayOutputStream(1 << 16); + private final ZipOutputStream teeZip = new ZipOutputStream(teeOut); + private final ByteArrayOutputStream out = new ByteArrayOutputStream(1 << 16); + private final ZipOutputStream outZip = new ZipOutputStream(out); + private final AtomicReference<ApplicationPackage> truncatedPackage; + private final InputStream in; + private final ZipInputStream inZip; + private final Replacer replacer; + private final Predicate<String> filter; + private byte[] currentOut = new byte[0]; + private InputStream currentIn = InputStream.nullInputStream(); + private boolean includeCurrent = false; + private int pos = 0; + private boolean closed = false; + private boolean done = false; + + private Stream(InputStream in, Replacer replacer, Predicate<String> filter, AtomicReference<ApplicationPackage> truncatedPackage) { + this.in = in; + this.inZip = new ZipInputStream(in); + this.replacer = replacer; + this.filter = filter; + this.truncatedPackage = truncatedPackage; + } + + private void fill() throws IOException { + if (done) return; + while (out.size() == 0) { + // Exhaust current entry first. + int i, n = out.size(); + while (out.size() == 0 && (i = currentIn.read(inBuffer)) != -1) { + if (includeCurrent) teeZip.write(inBuffer, 0, i); + outZip.write(inBuffer, 0, i); + n += i; + } + + // Current entry exhausted, look for next. + if (n == 0) { + next(); + if (done) break; + } + } + + currentOut = out.toByteArray(); + out.reset(); + pos = 0; + } + + private void next() throws IOException { + if (includeCurrent) teeZip.closeEntry(); + outZip.closeEntry(); + + ZipEntry next = inZip.getNextEntry(); + String name; + InputStream content = null; + if (next == null) { + // We may still have replacements to fill in, but if we don't, we're done filling, forever! + name = replacer.next(); + if (name == null) { + outZip.close(); // This typically makes new output available, so must check for that after this. + teeZip.close(); + currentIn = nullInputStream(); + truncatedPackage.compareAndSet(null, new ApplicationPackage(teeOut.toByteArray())); + done = true; + return; + } + } + else { + name = next.getName(); + content = new FilterInputStream(inZip) { @Override public void close() { } }; // Protect inZip from replacements closing it. + } + + includeCurrent = truncatedPackage.get() == null && filter.test(name); + currentIn = replacer.modify(name, content); + if (currentIn == null) { + currentIn = InputStream.nullInputStream(); + } + else { + if (includeCurrent) teeZip.putNextEntry(new ZipEntry(name)); + outZip.putNextEntry(new ZipEntry(name)); + } + } + + @Override + public int read() throws IOException { + if (closed) throw new IOException("stream closed"); + if (pos == currentOut.length) { + fill(); + if (pos == currentOut.length) return -1; + } + return 0xff & currentOut[pos++]; + } + + @Override + public int read(byte[] out, int off, int len) throws IOException { + if (closed) throw new IOException("stream closed"); + if ((off | len | (off + len) | (out.length - (off + len))) < 0) throw new IndexOutOfBoundsException(); + if (pos == currentOut.length) { + fill(); + if (pos == currentOut.length) return -1; + } + int n = min(currentOut.length - pos, len); + System.arraycopy(currentOut, pos, out, off, n); + pos += n; + return n; + } + + @Override + public int available() throws IOException { + return pos == currentOut.length && done ? 0 : 1; + } + + @Override + public void close() { + if ( ! closed) try { + transferTo(nullOutputStream()); // Finish reading the zip, to populate the truncated package in case of errors. + in.transferTo(nullOutputStream()); // For some inane reason, ZipInputStream doesn't exhaust its wrapped input. + inZip.close(); + closed = true; + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + } + + /** Replaces entries in a zip stream as they are encountered, then appends remaining entries at the end. */ + public interface Replacer { + + /** Called when the entries of the original zip stream are exhausted. Return remaining names, or {@code null} when none left. */ + String next(); + + /** Modify content for a given name; return {@code null} for removal; in is {@code null} for entries not present in the input. */ + InputStream modify(String name, InputStream in); + + /** + * Wraps a map of fixed replacements, and: + * <ul> + * <li>Removes entries whose value is {@code null}.</li> + * <li>Modifies entries present in both input and the map.</li> + * <li>Appends entries present exclusively in the map.</li> + * <li>Writes all other entries as they are.</li> + * </ul> + */ + static Supplier<Replacer> of(Map<String, UnaryOperator<InputStream>> replacements) { + return () -> new Replacer() { + final Map<String, UnaryOperator<InputStream>> remaining = new HashMap<>(replacements); + @Override public String next() { + return remaining.isEmpty() ? null : remaining.keySet().iterator().next(); + } + @Override public InputStream modify(String name, InputStream in) { + UnaryOperator<InputStream> mapper = remaining.remove(name); + return mapper == null ? in : mapper.apply(in); + } + }; + } + + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/TestPackage.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/TestPackage.java index 5b20c57fcca..17644d5e207 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/TestPackage.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/TestPackage.java @@ -18,15 +18,14 @@ import com.yahoo.text.Text; import com.yahoo.vespa.hosted.controller.api.integration.deployment.RunId; import com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterCloud.Suite; import com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterId; +import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackageStream.Replacer; import com.yahoo.vespa.hosted.controller.config.ControllerConfig; import com.yahoo.vespa.hosted.controller.config.ControllerConfig.Steprunner.Testerapp; import com.yahoo.yolean.Exceptions; import javax.security.auth.x500.X500Principal; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UncheckedIOException; +import java.io.InputStream; import java.math.BigInteger; import java.security.KeyPair; import java.security.cert.X509Certificate; @@ -43,6 +42,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; import java.util.jar.JarInputStream; import java.util.jar.Manifest; import java.util.regex.Pattern; @@ -53,8 +54,9 @@ import static com.yahoo.vespa.hosted.controller.api.integration.deployment.Teste import static com.yahoo.vespa.hosted.controller.api.integration.deployment.TesterCloud.Suite.system; import static com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackage.deploymentFile; import static com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackage.servicesFile; -import static com.yahoo.vespa.hosted.controller.application.pkg.ZipEntries.transferAndWrite; +import static java.io.InputStream.nullInputStream; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.function.UnaryOperator.identity; import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.toList; @@ -71,32 +73,14 @@ public class TestPackage { static final NodeResources DEFAULT_TESTER_RESOURCES_AWS = new NodeResources(2, 8, 50, 0.3, NodeResources.DiskSpeed.any); static final NodeResources DEFAULT_TESTER_RESOURCES = new NodeResources(1, 4, 50, 0.3, NodeResources.DiskSpeed.any); - private final ApplicationPackage applicationPackage; + private final ApplicationPackageStream applicationPackageStream; private final X509Certificate certificate; - public TestPackage(byte[] testPackage, boolean isPublicSystem, RunId id, Testerapp testerApp, + public TestPackage(Supplier<InputStream> inZip, boolean isPublicSystem, RunId id, Testerapp testerApp, DeploymentSpec spec, Instant certificateValidFrom, Duration certificateValidDuration) { - - // Copy contents of submitted application-test.zip, and ensure required directories exist within the zip. - Map<String, byte[]> entries = new HashMap<>(); - entries.put("artifacts/.ignore-" + UUID.randomUUID(), new byte[0]); - entries.put("tests/.ignore-" + UUID.randomUUID(), new byte[0]); - - entries.put(servicesFile, - servicesXml(! isPublicSystem, - certificateValidFrom != null, - hasLegacyTests(testPackage), - testerResourcesFor(id.type().zone(), spec.requireInstance(id.application().instance())), - testerApp)); - - entries.put(deploymentFile, - deploymentXml(id.tester(), - spec.athenzDomain(), - spec.requireInstance(id.application().instance()) - .athenzService(id.type().zone().environment(), id.type().zone().region()))); - + KeyPair keyPair; if (certificateValidFrom != null) { - KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA, 2048); + keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA, 2048); X500Principal subject = new X500Principal("CN=" + id.tester().id().toFullString() + "." + id.type() + "." + id.number()); this.certificate = X509CertificateBuilder.fromKeypair(keyPair, subject, @@ -105,26 +89,60 @@ public class TestPackage { SignatureAlgorithm.SHA512_WITH_RSA, BigInteger.valueOf(1)) .build(); - entries.put("artifacts/key", KeyUtils.toPem(keyPair.getPrivate()).getBytes(UTF_8)); - entries.put("artifacts/cert", X509CertificateUtils.toPem(certificate).getBytes(UTF_8)); } else { + keyPair = null; this.certificate = null; } + this.applicationPackageStream = new ApplicationPackageStream(inZip, () -> __ -> false, () -> new Replacer() { + + // Initially skips all declared entries, ensuring they're generated and appended after all input entries. + final Map<String, UnaryOperator<InputStream>> entries = new HashMap<>(); + final Map<String, UnaryOperator<InputStream>> replacements = new HashMap<>(); + boolean hasLegacyTests = false; + + @Override + public String next() { + if (entries.isEmpty()) return null; + String next = entries.keySet().iterator().next(); + replacements.put(next, entries.remove(next)); + return next; + } - ByteArrayOutputStream buffer = new ByteArrayOutputStream(testPackage.length + 10_000); - transferAndWrite(buffer, new ByteArrayInputStream(testPackage), entries); - this.applicationPackage = new ApplicationPackage(buffer.toByteArray()); - } - - static boolean hasLegacyTests(byte[] testPackage) { - return ZipEntries.from(testPackage, __ -> true, 0, false).asList().stream() - .anyMatch(file -> file.name().startsWith("artifacts/") && file.name().endsWith("-tests.jar")); + @Override + public InputStream modify(String name, InputStream in) { + hasLegacyTests |= name.startsWith("artifacts/") && name.endsWith("-tests.jar"); + return entries.containsKey(name) ? null : replacements.getOrDefault(name, identity()).apply(in); + } + { + // Copy contents of submitted application-test.zip, and ensure required directories exist within the zip. + entries.put("artifacts/.ignore-" + UUID.randomUUID(), __ -> nullInputStream()); + entries.put("tests/.ignore-" + UUID.randomUUID(), __ -> nullInputStream()); + + entries.put(servicesFile, + __ -> new ByteArrayInputStream(servicesXml( ! isPublicSystem, + certificateValidFrom != null, + hasLegacyTests, + testerResourcesFor(id.type().zone(), spec.requireInstance(id.application().instance())), + testerApp))); + + entries.put(deploymentFile, + __ -> new ByteArrayInputStream(deploymentXml(id.tester(), + spec.athenzDomain(), + spec.requireInstance(id.application().instance()) + .athenzService(id.type().zone().environment(), id.type().zone().region())))); + + if (certificate != null) { + entries.put("artifacts/key", __ -> new ByteArrayInputStream(KeyUtils.toPem(keyPair.getPrivate()).getBytes(UTF_8))); + entries.put("artifacts/cert", __ -> new ByteArrayInputStream(X509CertificateUtils.toPem(certificate).getBytes(UTF_8))); + } + } + }); } - public ApplicationPackage asApplicationPackage() { - return applicationPackage; + public ApplicationPackageStream asApplicationPackage() { + return applicationPackageStream; } public X509Certificate certificate() { @@ -207,7 +225,7 @@ public class TestPackage { return new TestSummary(problems, suites); } - public static NodeResources testerResourcesFor(ZoneId zone, DeploymentInstanceSpec spec) { + static NodeResources testerResourcesFor(ZoneId zone, DeploymentInstanceSpec spec) { NodeResources nodeResources = spec.steps().stream() .filter(step -> step.concerns(zone.environment())) .findFirst() @@ -219,7 +237,7 @@ public class TestPackage { } /** Returns the generated services.xml content for the tester application. */ - public static byte[] servicesXml(boolean systemUsesAthenz, boolean useTesterCertificate, boolean hasLegacyTests, + static byte[] servicesXml(boolean systemUsesAthenz, boolean useTesterCertificate, boolean hasLegacyTests, NodeResources resources, ControllerConfig.Steprunner.Testerapp config) { int jdiscMemoryGb = 2; // 2Gb memory for tester application which uses Maven. int jdiscMemoryPct = (int) Math.ceil(100 * jdiscMemoryGb / resources.memoryGb()); @@ -279,7 +297,7 @@ public class TestPackage { } /** Returns a dummy deployment xml which sets up the service identity for the tester, if present. */ - public static byte[] deploymentXml(TesterId id, Optional<AthenzDomain> athenzDomain, Optional<AthenzService> athenzService) { + static byte[] deploymentXml(TesterId id, Optional<AthenzDomain> athenzDomain, Optional<AthenzService> athenzService) { String deploymentSpec = "<?xml version='1.0' encoding='UTF-8'?>\n" + "<deployment version=\"1.0\" " + diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ZipEntries.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ZipEntries.java index 6bbcd551924..185c97f866e 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ZipEntries.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/pkg/ZipEntries.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; @@ -35,36 +36,6 @@ public class ZipEntries { this.entries = List.copyOf(Objects.requireNonNull(entries)); } - /** Copies the zipped content from in to out, adding/overwriting an entry with the given name and content. */ - public static void transferAndWrite(OutputStream out, InputStream in, String name, byte[] content) { - transferAndWrite(out, in, Map.of(name, content)); - } - - /** Copies the zipped content from in to out, adding/overwriting/removing (on {@code null}) entries as specified. */ - public static void transferAndWrite(OutputStream out, InputStream in, Map<String, byte[]> entries) { - try (ZipOutputStream zipOut = new ZipOutputStream(out); - ZipInputStream zipIn = new ZipInputStream(in)) { - for (ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn.getNextEntry()) { - if (entries.containsKey(entry.getName())) - continue; - - zipOut.putNextEntry(new ZipEntry(entry.getName())); - zipIn.transferTo(zipOut); - zipOut.closeEntry(); - } - for (Entry<String, byte[]> entry : entries.entrySet()) { - if (entry.getValue() != null) { - zipOut.putNextEntry(new ZipEntry(entry.getKey())); - zipOut.write(entry.getValue()); - zipOut.closeEntry(); - } - } - } - catch (IOException e) { - throw new UncheckedIOException(e); - } - } - /** Read ZIP entries from inputStream */ public static ZipEntries from(byte[] zip, Predicate<String> entryNameMatcher, int maxEntrySizeInBytes, boolean throwIfEntryExceedsMaxSize) { @@ -107,7 +78,7 @@ public class ZipEntries { } public String name() { return name; } - public byte[] contentOrThrow() { return content.orElseThrow(); } + public byte[] contentOrThrow() { return content.orElseThrow(() -> new NoSuchElementException("'" + name + "' has no content")); } public Optional<byte[]> content() { return content; } public long size() { return size; } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java index e8c92d3e3f6..efe072c2a6d 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/InternalStepRunner.java @@ -34,6 +34,7 @@ import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.Endpoint; import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId; import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackage; +import com.yahoo.vespa.hosted.controller.application.pkg.ApplicationPackageStream; import com.yahoo.vespa.hosted.controller.application.pkg.TestPackage; import com.yahoo.vespa.hosted.controller.maintenance.JobRunner; import com.yahoo.vespa.hosted.controller.notification.Notification; @@ -43,6 +44,7 @@ import com.yahoo.vespa.hosted.controller.routing.context.DeploymentRoutingContex import com.yahoo.yolean.Exceptions; import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.io.PrintStream; import java.io.UncheckedIOException; import java.security.cert.CertificateExpiredException; @@ -248,7 +250,9 @@ public class InternalStepRunner implements StepRunner { } case LOAD_BALANCER_NOT_READY, PARENT_HOST_NOT_READY -> { logger.log(e.message()); // Consider splitting these messages in summary and details, on config server. - controller.jobController().locked(id, run -> run.sleepingUntil(startTime.plusSeconds(300))); + Instant someTimeAfterStart = startTime.plusSeconds(450); + Instant inALittleWhile = controller.clock().instant().plusSeconds(90); + controller.jobController().locked(id, run -> run.sleepingUntil(someTimeAfterStart.isAfter(inALittleWhile) ? someTimeAfterStart : inALittleWhile)); return result; } case NODE_ALLOCATION_FAILURE -> { @@ -926,14 +930,13 @@ public class InternalStepRunner implements StepRunner { } /** Returns the application package for the tester application, assembled from a generated config, fat-jar and services.xml. */ - private ApplicationPackage testerPackage(RunId id) { + private ApplicationPackageStream testerPackage(RunId id) { RevisionId revision = controller.jobController().run(id).versions().targetRevision(); DeploymentSpec spec = controller.applications().requireApplication(TenantAndApplicationId.from(id.application())).deploymentSpec(); - byte[] testZip = controller.applications().applicationStore().getTester(id.application().tenant(), - id.application().application(), revision); boolean useTesterCertificate = useTesterCertificate(id); - TestPackage testPackage = new TestPackage(testZip, + TestPackage testPackage = new TestPackage(() -> controller.applications().applicationStore().streamTester(id.application().tenant(), + id.application().application(), revision), controller.system().isPublic(), id, controller.controllerConfig().steprunner().testerapp(), diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java index 08cf8d2e1c4..f94bd51fe4c 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/JobController.java @@ -207,32 +207,36 @@ public class JobController { return run; List<LogEntry> log; - Instant deployedAt; + Optional<Instant> deployedAt; Instant from; if ( ! run.id().type().isProduction()) { - deployedAt = run.stepInfo(installInitialReal).or(() -> run.stepInfo(installReal)).flatMap(StepInfo::startTime).orElseThrow(); - from = run.lastVespaLogTimestamp().isAfter(run.start()) ? run.lastVespaLogTimestamp() : deployedAt.minusSeconds(10); - log = LogEntry.parseVespaLog(controller.serviceRegistry().configServer() - .getLogs(new DeploymentId(id.application(), zone), - Map.of("from", Long.toString(from.toEpochMilli()))), - from); + deployedAt = run.stepInfo(installInitialReal).or(() -> run.stepInfo(installReal)).flatMap(StepInfo::startTime); + if (deployedAt.isPresent()) { + from = run.lastVespaLogTimestamp().isAfter(run.start()) ? run.lastVespaLogTimestamp() : deployedAt.get().minusSeconds(10); + log = LogEntry.parseVespaLog(controller.serviceRegistry().configServer() + .getLogs(new DeploymentId(id.application(), zone), + Map.of("from", Long.toString(from.toEpochMilli()))), + from); + } + else log = List.of(); } - else - log = List.of(); + else log = List.of(); if (id.type().isTest()) { - deployedAt = run.stepInfo(installTester).flatMap(StepInfo::startTime).orElseThrow(); - from = run.lastVespaLogTimestamp().isAfter(run.start()) ? run.lastVespaLogTimestamp() : deployedAt.minusSeconds(10); - List<LogEntry> testerLog = LogEntry.parseVespaLog(controller.serviceRegistry().configServer() - .getLogs(new DeploymentId(id.tester().id(), zone), - Map.of("from", Long.toString(from.toEpochMilli()))), - from); - - Instant justNow = controller.clock().instant().minusSeconds(2); - log = Stream.concat(log.stream(), testerLog.stream()) - .filter(entry -> entry.at().isBefore(justNow)) - .sorted(comparing(LogEntry::at)) - .collect(toUnmodifiableList()); + deployedAt = run.stepInfo(installTester).flatMap(StepInfo::startTime); + if (deployedAt.isPresent()) { + from = run.lastVespaLogTimestamp().isAfter(run.start()) ? run.lastVespaLogTimestamp() : deployedAt.get().minusSeconds(10); + List<LogEntry> testerLog = LogEntry.parseVespaLog(controller.serviceRegistry().configServer() + .getLogs(new DeploymentId(id.tester().id(), zone), + Map.of("from", Long.toString(from.toEpochMilli()))), + from); + + Instant justNow = controller.clock().instant().minusSeconds(2); + log = Stream.concat(log.stream(), testerLog.stream()) + .filter(entry -> entry.at().isBefore(justNow)) + .sorted(comparing(LogEntry::at)) + .toList(); + } } if (log.isEmpty()) return run; diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java index 9bea7fb829d..4f4e21d9f25 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java @@ -58,7 +58,7 @@ import static com.yahoo.yolean.Exceptions.uncheck; public class ConfigServerRestExecutorImpl extends AbstractComponent implements ConfigServerRestExecutor { private static final Logger LOG = Logger.getLogger(ConfigServerRestExecutorImpl.class.getName()); - private static final Duration PROXY_REQUEST_TIMEOUT = Duration.ofSeconds(10); + private static final Duration PROXY_REQUEST_TIMEOUT = Duration.ofSeconds(20); private static final Duration PING_REQUEST_TIMEOUT = Duration.ofMillis(500); private static final Duration SINGLE_TARGET_WAIT = Duration.ofSeconds(2); private static final int SINGLE_TARGET_RETRIES = 3; 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 0abf1470d29..d8acd2aa8b2 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 @@ -305,7 +305,6 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (path.matches("/application/v4/tenant/{tenant}/info/billing")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoBilling); if (path.matches("/application/v4/tenant/{tenant}/info/contacts")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoContacts); if (path.matches("/application/v4/tenant/{tenant}/info/resend-mail-verification")) return withCloudTenant(path.get("tenant"), request, this::resendEmailVerification); - if (path.matches("/application/v4/tenant/{tenant}/archive-access")) return allowAwsArchiveAccess(path.get("tenant"), request); // TODO(enygaard, 2022-05-25) Remove when no longer used by console if (path.matches("/application/v4/tenant/{tenant}/archive-access/aws")) return allowAwsArchiveAccess(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/archive-access/gcp")) return allowGcpArchiveAccess(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/secret-store/{name}")) return addSecretStore(path.get("tenant"), path.get("name"), request); @@ -355,7 +354,6 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (path.matches("/application/v4/tenant/{tenant}")) return deleteTenant(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/access/managed/operator")) return removeManagedAccess(path.get("tenant")); if (path.matches("/application/v4/tenant/{tenant}/key")) return removeDeveloperKey(path.get("tenant"), request); - if (path.matches("/application/v4/tenant/{tenant}/archive-access")) return removeAwsArchiveAccess(path.get("tenant")); // TODO(enygaard, 2022-05-25) Remove when no longer used by console if (path.matches("/application/v4/tenant/{tenant}/archive-access/aws")) return removeAwsArchiveAccess(path.get("tenant")); if (path.matches("/application/v4/tenant/{tenant}/archive-access/gcp")) return removeGcpArchiveAccess(path.get("tenant")); if (path.matches("/application/v4/tenant/{tenant}/secret-store/{name}")) return deleteSecretStore(path.get("tenant"), path.get("name"), request); @@ -2626,8 +2624,6 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { log.warning(String.format("Failed to get quota for tenant %s: %s", tenant.name(), Exceptions.toMessageString(e))); } - // TODO(enygaard, 2022-05-25) Remove when console is using new archive access structure - cloudTenant.archiveAccess().awsRole().ifPresent(role -> object.setString("archiveAccessRole", role)); toSlime(cloudTenant.archiveAccess(), object.setObject("archiveAccess")); break; |