// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.api.integration.deployment; import com.yahoo.component.Version; import java.time.Instant; import java.util.Optional; import java.util.OptionalLong; import static ai.vespa.validation.Validation.requireAtLeast; import static java.util.Objects.requireNonNull; /** * An application package version, identified by a source revision and a build number. * * @author bratseth * @author mpolden * @author jonmv */ public class ApplicationVersion implements Comparable { // This never changes and is only used to create a valid semantic version number, as required by application bundles private static final String majorVersion = "1.0"; private final RevisionId id; private final Optional source; private final Optional authorEmail; private final Optional compileVersion; private final Optional allowedMajor; private final Optional buildTime; private final Optional sourceUrl; private final Optional commit; private final Optional bundleHash; private final Optional obsoleteAt; private final boolean hasPackage; private final boolean shouldSkip; private final Optional description; private final Optional submittedAt; private final int risk; public ApplicationVersion(RevisionId id, Optional source, Optional authorEmail, Optional compileVersion, Optional allowedMajor, Optional buildTime, Optional sourceUrl, Optional commit, Optional bundleHash, Optional obsoleteAt, boolean hasPackage, boolean shouldSkip, Optional description, Optional submittedAt, int risk) { if (commit.isPresent() && commit.get().length() > 128) throw new IllegalArgumentException("Commit may not be longer than 128 characters"); if (authorEmail.isPresent() && ! authorEmail.get().matches("[^@]+@[^@]+")) throw new IllegalArgumentException("Invalid author email '" + authorEmail.get() + "'."); if (compileVersion.isPresent() && compileVersion.get().equals(Version.emptyVersion)) throw new IllegalArgumentException("The empty version is not a legal compile version."); this.id = id; this.source = source; this.authorEmail = authorEmail; this.compileVersion = compileVersion; this.allowedMajor = requireNonNull(allowedMajor); this.buildTime = buildTime; this.sourceUrl = requireNonNull(sourceUrl, "sourceUrl cannot be null"); this.commit = requireNonNull(commit, "commit cannot be null"); this.bundleHash = bundleHash; this.obsoleteAt = obsoleteAt; this.hasPackage = hasPackage; this.shouldSkip = shouldSkip; this.description = description; this.submittedAt = requireNonNull(submittedAt); this.risk = requireAtLeast(risk, "application build risk", 0); } public RevisionId id() { return id; } /** Creates a minimal version for a development build. */ public static ApplicationVersion forDevelopment(RevisionId id, Optional compileVersion, Optional allowedMajor) { return new ApplicationVersion(id, Optional.empty(), Optional.empty(), compileVersion, allowedMajor, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), true, false, Optional.empty(), Optional.empty(), 0); } /** Creates a version from a completed build, an author email, and build metadata. */ public static ApplicationVersion forProduction(RevisionId id, Optional source, Optional authorEmail, Optional compileVersion, Optional allowedMajor, Optional buildTime, Optional sourceUrl, Optional commit, Optional bundleHash, Optional description, Instant submittedAt, int risk) { return new ApplicationVersion(id, source, authorEmail, compileVersion, allowedMajor, buildTime, sourceUrl, commit, bundleHash, Optional.empty(), true, false, description, Optional.of(submittedAt), risk); } /** Returns a unique identifier for this version or "unknown" if version is not known */ // TODO jonmv: kill public String stringId() { return source.map(SourceRevision::commit).map(ApplicationVersion::abbreviateCommit) .or(this::commit) .map(commit -> String.format("%s.%d-%s", majorVersion, buildNumber(), commit)) .orElseGet(() -> majorVersion + "." + buildNumber()); } /** * Returns information about the source of this revision, or empty if the source is not know/defined * (which is the case for command-line deployment from developers, but never for deployment jobs) */ public Optional source() { return source; } /** Returns the build number of this version */ public long buildNumber() { return id.number(); } /** Returns the email of the author of commit of this version, if known */ public Optional authorEmail() { return authorEmail; } /** Returns the Vespa version this package was compiled against, if known. */ public Optional compileVersion() { return compileVersion; } public Optional allowedMajor() { return allowedMajor; } /** Returns the time this package was built, if known. */ public Optional buildTime() { return buildTime; } /** Returns the hash of app package except deployment/build-meta data */ public Optional bundleHash() { return bundleHash; } /** Returns the source URL for this application version. */ public Optional sourceUrl() { return sourceUrl.or(() -> source.map(source -> { String repository = source.repository(); if (repository.startsWith("git@")) repository = "https://" + repository.substring(4).replace(':', '/'); if (repository.endsWith(".git")) repository = repository.substring(0, repository.length() - 4); return repository + "/tree/" + source.commit(); })); } /** Returns the commit name of this application version. */ public Optional commit() { return commit.or(() -> source.map(SourceRevision::commit)); } /** Returns whether the application package for this version was deployed directly to zone */ public boolean isDeployedDirectly() { return ! id.isProduction(); } /** Returns a copy of this without a package stored. */ public ApplicationVersion withoutPackage() { return new ApplicationVersion(id, source, authorEmail, compileVersion, allowedMajor, buildTime, sourceUrl, commit, bundleHash, obsoleteAt, false, shouldSkip, description, submittedAt, risk); } /** Returns a copy of this which is obsolete now. */ public ApplicationVersion obsoleteAt(Instant now) { return new ApplicationVersion(id, source, authorEmail, compileVersion, allowedMajor, buildTime, sourceUrl, commit, bundleHash, Optional.of(now), hasPackage, shouldSkip, description, submittedAt, risk); } /** Returns the instant at which this became obsolete, i.e., no longer relevant for automated deployments. */ public Optional obsoleteAt() { return obsoleteAt; } /** Whether we still have the package for this revision. */ public boolean hasPackage() { return hasPackage; } /** Returns a copy of this which will not be rolled out to production. */ public ApplicationVersion skipped() { return new ApplicationVersion(id, source, authorEmail, compileVersion, allowedMajor, buildTime, sourceUrl, commit, bundleHash, obsoleteAt, hasPackage, true, description, submittedAt, risk); } /** Whether we have chosen to skip this version. */ public boolean shouldSkip() { return shouldSkip; } /** Whether this revision can be deployed. */ public boolean isDeployable() { return hasPackage && ! shouldSkip; } /** An optional, free-text description on this build. */ public Optional description() { return description; } /** Instant at which this version was submitted to the build system. */ public Optional submittedAt() { return submittedAt; } /** The assumed risk of rolling out this revision, relative to the previous. */ public int risk() { return risk; } @Override public boolean equals(Object o) { if (this == o) return true; if ( ! (o instanceof ApplicationVersion)) return false; ApplicationVersion that = (ApplicationVersion) o; return id.equals(that.id); } @Override public int hashCode() { return id.hashCode(); } @Override public String toString() { return id + source.map(s -> ", " + s).orElse("") + authorEmail.map(e -> ", by " + e).orElse("") + compileVersion.map(v -> ", built against " + v).orElse("") + buildTime.map(t -> " at " + t).orElse("") ; } /** Abbreviate given commit hash to 9 characters */ private static String abbreviateCommit(String hash) { return hash.length() <= 9 ? hash : hash.substring(0, 9); } @Override public int compareTo(ApplicationVersion o) { return id.compareTo(o.id); } }