Skip to content

Commit

Permalink
feat: Verify chart upload in repo when deploying
Browse files Browse the repository at this point in the history
  • Loading branch information
Cho-William committed Nov 21, 2023
1 parent 3e9e592 commit 35e43f4
Show file tree
Hide file tree
Showing 15 changed files with 214 additions and 12 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ and disables the auto-detection feature:

- `helm:init` initializes Helm by downloading a specific version
- `helm:dependency-build` resolves the chart dependencies
- `helm:dependency-update` verifies that the required chart dependencies are present
- `helm:package` packages the given charts (chart.tar.gz)
- `helm:lint` tests the given charts
- `helm:template` Locally render templates
Expand Down Expand Up @@ -298,6 +299,8 @@ Parameter | Type | User Property | Required | Description
`<skipUpload>` | boolean | helm.upload.skip | false | skip upload goal
`<skipCatalog>` | boolean | helm.upload.skip.catalog | true | Skips creation of a catalog file with a list of helm chart upload details
`<insecure>` | boolean | helm.upload.insecure | false | Skip tls certificate checks for the chart upload.
`<uploadVerification>` | boolean | helm.upload.verification | false | wait for the chart to be added to the repository index before continuing
`<uploadVerificationTimeout>` | Integer | helm.upload.timeout | false | set the timeout limit (in seconds) for verification to be attempted
`<skipInstall>` | boolean | helm.install.skip | false | skip install goal
`<skipUninstall>` | boolean | helm.uninstall.skip | false | skip uninstall goal
`<security>` | string | helm.security | false | path to your [settings-security.xml](https://maven.apache.org/guides/mini/guide-encryption.html) (default: `~/.m2/settings-security.xml`)
Expand Down
Binary file added src/it/helm-executables/aarch64-helm/helm
Binary file not shown.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/main/java/io/kokuwa/maven/helm/AbstractHelmMojo.java
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ Path getHelmExecutablePath() throws MojoExecutionException {
if (useLocalHelmBinary && autoDetectLocalHelmBinary) {
optional = Stream.of(System.getenv("PATH").split(Pattern.quote(File.pathSeparator))).map(Paths::get);
if (helmExecutableDirectory != null) {
// if defined, search also in helm executable directory (eg. used for fallback binary download)
// if defined, search also in helm executable directory (e.g. used for fallback binary download)
optional = Stream.concat(optional, Stream.of(helmExecutableDirectory.toPath()));
}
} else {
Expand Down
68 changes: 68 additions & 0 deletions src/main/java/io/kokuwa/maven/helm/UploadMojo.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DatabindException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;

import io.kokuwa.maven.helm.pojo.Catalog;
import io.kokuwa.maven.helm.pojo.HelmChart;
import io.kokuwa.maven.helm.pojo.HelmRepository;
import io.kokuwa.maven.helm.pojo.RepoType;
import lombok.Setter;
Expand Down Expand Up @@ -99,6 +102,22 @@ public class UploadMojo extends AbstractHelmMojo {
@Parameter(property = "helm.upload.skip.catalog", defaultValue = "true")
private boolean skipCatalog;

/**
* Verify charts are accessible in repository.
*
* @since 6.13.0
*/
@Parameter(property = "helm.upload.verification", defaultValue = "false")
private boolean uploadVerification;

/**
* Set timeout period to try verifying charts are accessible in repository.
*
* @since 6.13.0
*/
@Parameter(property = "helm.upload.timeout", defaultValue = "30")
private Integer uploadVerificationTimeout;

@Override
public void execute() throws MojoExecutionException {

Expand All @@ -107,6 +126,10 @@ public void execute() throws MojoExecutionException {
return;
}

if (uploadVerificationTimeout != null && uploadVerificationTimeout <= 0) {
throw new IllegalArgumentException("Timeout must be a positive value.");
}

getLog().info("Uploading to " + getHelmUploadUrl() + "\n");
for (Path chart : getChartArchives()) {
getLog().info("Uploading " + chart + "...");
Expand All @@ -123,6 +146,17 @@ public void execute() throws MojoExecutionException {
mavenProjectHelper.attachArtifact(mavenProject, CATALOG_ARTIFACT_TYPE, CATALOG_ARTIFACT_NAME,
catalogPath.toFile());
}

if (uploadVerification) {
for (Path chartDirectory : getChartDirectories()) {
Path chartPath = chartDirectory.resolve("Chart.yaml");
getLog().info("Verifying upload of " + chartPath);
if (!verifyUpload(chartPath)) {
getLog().info("Upload verification timed out.");
throw new MojoExecutionException("Chart verification failed");
}
}
}
}

/**
Expand Down Expand Up @@ -276,6 +310,40 @@ private void uploadSingle(Path chart) throws MojoExecutionException, IOException
connection.disconnect();
}

private boolean verifyUpload(Path chartPath) throws MojoExecutionException {
ObjectMapper mapper = new YAMLMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
String chartName;
try {
chartName = mapper.readValue(chartPath.toFile(), HelmChart.class).getName();
} catch (IOException e) {
throw new MojoExecutionException("Unable to read chart from " + chartPath, e);
}

long startTimeMillis = System.currentTimeMillis();
long timeoutMillis = uploadVerificationTimeout * 1000;
long cutoffMillis = startTimeMillis + timeoutMillis;
boolean verificationSuccess = false;

while (System.currentTimeMillis() < cutoffMillis && !verificationSuccess) {
try {
helm()
.arguments("show", "chart", chartName,
"--version", getChartVersion(), "--repo", getHelmUploadUrl())
.execute("show chart failed");
verificationSuccess = true;
} catch (Exception e) {
getLog().info("Upload verification failed, retrying...");
try {
Thread.sleep(1000);
} catch (InterruptedException ie) {
throw new MojoExecutionException("Upload verification interrupted", ie);
}
}
}
return verificationSuccess;
}

private HttpURLConnection getConnectionForUploadToChartMuseum() throws IOException, MojoExecutionException {
HttpURLConnection connection = (HttpURLConnection) new URL(getHelmUploadUrl()).openConnection();
connection.setDoOutput(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
import lombok.Data;

/**
* POJO for list of "Chart.yaml" dependencies.
* POJO for "Chart.yaml" file and its dependencies.
*
* @since 6.10.0
*/
@Data
public class Dependencies {
public class HelmChart {

private String apiVersion;
private String name;
private String version;
private List<Dependency> dependencies;

@Data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;

import io.kokuwa.maven.helm.pojo.Dependencies;
import io.kokuwa.maven.helm.pojo.Dependencies.Dependency;
import io.kokuwa.maven.helm.pojo.HelmChart;
import io.kokuwa.maven.helm.pojo.HelmChart.Dependency;

/**
* Utility class for overwriting a local path charts within a chart's dependencies.
Expand Down Expand Up @@ -72,7 +72,7 @@ public void execute(Path directory) throws MojoExecutionException {
}
List<Dependency> dependencies;
try {
dependencies = MAPPER.readValue(chartFile.toFile(), Dependencies.class).getDependencies();
dependencies = MAPPER.readValue(chartFile.toFile(), HelmChart.class).getDependencies();
} catch (IOException e) {
throw new MojoExecutionException("Unable to read chart dependencies from " + chartFile, e);
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/java/io/kokuwa/maven/helm/AbstractMojoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ static Server getServer(String id, String username, String password) {
}

static Path copyPackagedHelmChartToOutputdirectory(AbstractHelmMojo mojo) {
Path source = Paths.get("src/test/resources/app-0.1.0.tgz");
Path source = Paths.get("src/test/resources/__files/app-0.1.0.tgz");
Path target = mojo.getOutputDirectory().resolve("app-0.1.0.tgz");
assertDoesNotThrow(() -> Files.createDirectories(target.getParent()));
assertDoesNotThrow(() -> Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING));
Expand Down
6 changes: 4 additions & 2 deletions src/test/java/io/kokuwa/maven/helm/HelmMojoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.api.condition.OS;

import io.kokuwa.maven.helm.junit.MojoExtension;
import io.kokuwa.maven.helm.pojo.K8SCluster;
import io.kokuwa.maven.helm.pojo.ValueOverride;

Expand All @@ -37,8 +38,9 @@ class Executable {
void fixed(LintMojo mojo) {
mojo.setUseLocalHelmBinary(true);
mojo.setAutoDetectLocalHelmBinary(false);
mojo.setHelmExecutableDirectory(new File("src/it"));
Path expected = Paths.get("src/it").resolve(HELM).toAbsolutePath();
mojo.setHelmExecutableDirectory(MojoExtension.determineHelmExecutableDirectory());
Path expected = Paths.get(MojoExtension.determineHelmExecutableDirectory().toString())
.resolve(HELM).toAbsolutePath();
Path actual = assertDoesNotThrow(() -> mojo.getHelmExecutablePath());
assertEquals(expected, actual);
}
Expand Down
3 changes: 2 additions & 1 deletion src/test/java/io/kokuwa/maven/helm/InitMojoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder;
import com.github.tomakehurst.wiremock.verification.LoggedRequest;

import io.kokuwa.maven.helm.junit.MojoExtension;
import io.kokuwa.maven.helm.pojo.HelmRepository;
import io.kokuwa.maven.helm.pojo.RepoType;

Expand Down Expand Up @@ -103,7 +104,7 @@ void localHelm(InitMojo mojo) {
mojo.setUseLocalHelmBinary(true);
mojo.setFallbackBinaryDownload(false);
mojo.setHelmVersion(null);
mojo.setHelmExecutableDirectory(new File("src/it"));
mojo.setHelmExecutableDirectory(MojoExtension.determineHelmExecutableDirectory());
assertHelm(mojo, "version", "repo add stable " + InitMojo.STABLE_HELM_REPO);
}

Expand Down
108 changes: 108 additions & 0 deletions src/test/java/io/kokuwa/maven/helm/UploadMojoTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import static org.junit.jupiter.api.Assertions.fail;

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

Expand Down Expand Up @@ -342,6 +343,58 @@ void inputRepositoryTypeRequired(UploadMojo mojo) throws Exception {
assertThrows(IllegalArgumentException.class, mojo::execute, "Missing repo type must fail.");
}

@DisplayName("flag: with flag verify")
@Test
void verify(UploadMojo mojo) throws IOException {
mojo.setUploadVerification(true);
mojo.setChartVersion("0.1.0");
mojo.setUploadRepoStable(new HelmRepository()
.setType(RepoType.NEXUS)
.setName("my-nexus")
.setUrl("http://127.0.0.1:" + mock.getPort() + "/nexus"));
Path packaged = copyPackagedHelmChartToOutputdirectory(mojo);
assertUploadVerifySuccess(mojo, RequestMethod.PUT, "/nexus/" + packaged.getFileName());
}

@DisplayName("flag: with flags verify and timeout")
@Test
void verifyAndTimeout(UploadMojo mojo) {
mojo.setUploadVerification(true);
mojo.setUploadVerificationTimeout(10);
mojo.setChartVersion("0.1.0");
mojo.setUploadRepoStable(new HelmRepository()
.setType(RepoType.NEXUS)
.setName("my-nexus")
.setUrl("http://127.0.0.1:" + mock.getPort() + "/nexus"));
Path packaged = copyPackagedHelmChartToOutputdirectory(mojo);
assertUploadVerifySuccess(mojo, RequestMethod.PUT, "/nexus/" + packaged.getFileName());
}

@DisplayName("flag: with flags verify and timeout times out")
@Test
void verifyAndTimeoutFail(UploadMojo mojo) {
mojo.setUploadVerification(true);
mojo.setUploadVerificationTimeout(10);
mojo.setChartVersion("0.1.0");
mojo.setUploadRepoStable(new HelmRepository()
.setType(RepoType.NEXUS)
.setName("my-nexus")
.setUrl("http://127.0.0.1:" + mock.getPort() + "/nexus"));
mojo.setChartDirectory(new File("src/test/resources/simple-fail/"));
Path packaged = copyPackagedHelmChartToOutputdirectory(mojo);
assertUploadVerifyFail(mojo, RequestMethod.PUT, "/nexus/" + packaged.getFileName());
}

@DisplayName("input: timeout not postive")
@Test
void timeoutTimeNotPositive(UploadMojo mojo) throws Exception {
mojo.setUploadVerification(true);
mojo.setUploadVerificationTimeout(-1);
mojo.setChartVersion("0.1.0");
copyPackagedHelmChartToOutputdirectory(mojo);
assertThrows(IllegalArgumentException.class, mojo::execute, "Nonpositive timeout must fail.");
}

private void assertUpload(UploadMojo mojo, RequestMethod method, String path, String authorization) {

RequestPatternBuilder requestPattern;
Expand Down Expand Up @@ -387,4 +440,59 @@ private void assertCatalog(UploadMojo mojo, Path archive, String uploadUrl) {
}

}

private void assertUploadVerifySuccess(UploadMojo mojo, RequestMethod method, String path) {
mockHelmShowChart();
assertDoesNotThrow(() -> mojo.execute(), "upload failed");
verifyRequests(mojo, method, path);
}

private void assertUploadVerifyFail(UploadMojo mojo, RequestMethod method, String path) {
mockHelmShowChart();
assertThrows(MojoExecutionException.class, mojo::execute, "could not verify");
verifyRequests(mojo, method, path);
}

private void mockHelmShowChart() {
mock.stubFor(WireMock.put(WireMock.urlMatching(".*"))
.willReturn(WireMock.ok()
.withStatus(200)));
mock.stubFor(WireMock.get(WireMock.urlMatching(".*/index\\.yaml$"))
.willReturn(WireMock.ok()
.withStatus(200)
.withHeader("Content-Type", "application/yaml")
.withBody(getIndexYamlBody())));
mock.stubFor(WireMock.get(WireMock.urlMatching(".*\\.tgz$"))
.willReturn(WireMock.ok()
.withStatus(200)
.withHeader("Content-Type", "application/gzip")
.withBodyFile("app-0.1.0.tgz")));
}

private void verifyRequests(UploadMojo mojo, RequestMethod method, String path) {
List<LoggedRequest> requests = mock.findAll(RequestPatternBuilder.allRequests());
LoggedRequest request = requests.get(0);
assertEquals(method, request.getMethod(), "method");
assertEquals((mojo.getUploadRepoStable().getUrl().startsWith("https")
? "https://127.0.0.1:" + mock.getHttpsPort()
: "http://127.0.0.1:" + mock.getPort()) + path, request.getAbsoluteUrl(), "url");
assertEquals("application/gzip", request.getHeader(HttpHeaders.CONTENT_TYPE), "content-type");
}

private String getIndexYamlBody() {
String indexYamlBody = "apiVersion: v1\n" +
"entries:\n" +
" app:\n" +
" - created: 2023-11-05T12:15:23.451853285-06:00\n" +
" description: Dummy chart for testing\n" +
" digest: digesthash1\n" +
" home: https://helm.sh/helm\n" +
" name: app\n" +
" sources:\n" +
" - https://github.com/helm/helm\n" +
" urls:\n" +
" - " + "http://127.0.0.1:" + mock.getPort() + "/nexus/app-0.1.0.tgz" + "\n" +
" version: 0.1.0";
return indexYamlBody;
}
}
16 changes: 15 additions & 1 deletion src/test/java/io/kokuwa/maven/helm/junit/MojoExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte
field.set(mojo, new File(parameter.getDefaultValue()));
} else if (parameter.getType().equals(String.class.getName())) {
field.set(mojo, parameter.getDefaultValue());
} else if (parameter.getType().equals(Integer.class.getName())) {
field.set(mojo, Integer.parseInt(parameter.getDefaultValue()));
} else {
fail("unsupported type: " + parameter.getType());
}
Expand All @@ -112,12 +114,24 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte
// preconfigure

mojo.setChartDirectory(new File("src/test/resources/simple")); // set some sane defaults for tests
mojo.setHelmExecutableDirectory(new File("src/it")); // avoid download helm
mojo.setHelmExecutableDirectory(determineHelmExecutableDirectory());
mojo.setHelmVersion("3.12.0"); // avoid github api

return mojo;
} catch (ReflectiveOperationException e) {
throw new ParameterResolutionException("Failed to setup mockito.", e);
}
}

/**
* Determines which helm executable to use based on the machine's architecture.
* @return location of appropriate helm executable
*/
public static File determineHelmExecutableDirectory() {
if (System.getProperty("os.arch").equals("aarch64")) {
return new File("src/it/helm-executables/aarch64-helm/");
} else {
return new File("src/it/helm-executables/x86_64-helm/");
}
}
}
File renamed without changes.
3 changes: 3 additions & 0 deletions src/test/resources/simple-fail/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
apiVersion: v1
name: app-fail
version: 0.1.0
2 changes: 1 addition & 1 deletion src/test/resources/simple/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
apiVersion: v1
name: app
version: 0.0.1
version: 0.1.0

0 comments on commit 35e43f4

Please sign in to comment.