Skip to content

Commit

Permalink
fix(artifacts/github): Fix threading bug in github artifact resolver (
Browse files Browse the repository at this point in the history
…#3386)

* test(artifacts/github): Add tests to Github artifact resolving

* fix(artifacts/github): Fix threading bug in github artifact resolver

GithubArtifactCredentials is storing a Request.Builder in an instance
variable. This means that when multiple threads are concurrently
downloading Github artifacts, it's possible for one thread to download
an artifact from another thread's URL.
  • Loading branch information
ezimanyi authored Feb 15, 2019
1 parent d2a50b9 commit 97d0bc5
Showing 2 changed files with 174 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -22,13 +22,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.spinnaker.clouddriver.artifacts.config.ArtifactCredentials;
import com.netflix.spinnaker.kork.artifacts.model.Artifact;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.Request.Builder;
import com.squareup.okhttp.Response;
import java.util.Arrays;
import java.util.List;
import com.squareup.okhttp.*;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
@@ -38,15 +32,17 @@
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;

@Slf4j
@Data
public class GitHubArtifactCredentials implements ArtifactCredentials {
private final String name;
private final List<String> types = Arrays.asList("github/file");
private final List<String> types = Collections.singletonList("github/file");

@JsonIgnore
private final Builder requestBuilder;
private final Headers headers;

@JsonIgnore
OkHttpClient okHttpClient;
@@ -58,29 +54,43 @@ public GitHubArtifactCredentials(GitHubArtifactAccount account, OkHttpClient okH
this.name = account.getName();
this.okHttpClient = okHttpClient;
this.objectMapper = objectMapper;
Builder builder = new Request.Builder();
boolean useLogin = !StringUtils.isEmpty(account.getUsername()) && !StringUtils.isEmpty(account.getPassword());
boolean useUsernamePasswordFile = !StringUtils.isEmpty(account.getUsernamePasswordFile());
boolean useToken = !StringUtils.isEmpty(account.getToken());
boolean useTokenFile = !StringUtils.isEmpty(account.getTokenFile());
boolean useAuth = useLogin || useToken || useUsernamePasswordFile || useTokenFile;
if (useAuth) {
String authHeader = "";
if (useTokenFile) {
authHeader = "token " + credentialsFromFile(account.getTokenFile());
} else if (useUsernamePasswordFile) {
authHeader = "Basic " + Base64.encodeBase64String((credentialsFromFile(account.getUsernamePasswordFile())).getBytes());
} else if (useToken) {
authHeader = "token " + account.getToken();
} else if (useLogin) {
authHeader = "Basic " + Base64.encodeBase64String((account.getUsername() + ":" + account.getPassword()).getBytes());
}
builder.header("Authorization", authHeader);
this.headers = getHeaders(account);
}

private Request.Builder requestBuilder() {
return new Request.Builder().headers(headers);
}

private Headers getHeaders(GitHubArtifactAccount account) {
Headers.Builder headers = new Headers.Builder();
String authHeader = getAuthHeader(account);
if (authHeader != null) {
headers.set("Authorization", authHeader);
log.info("Loaded credentials for GitHub Artifact Account {}", account.getName());
} else {
log.info("No credentials included with GitHub Artifact Account {}", account.getName());
}
requestBuilder = builder;
return headers.build();
}

private String getAuthHeader(GitHubArtifactAccount account) {
if (StringUtils.isNotEmpty(account.getTokenFile())) {
return "token " + credentialsFromFile(account.getTokenFile());
}

if (StringUtils.isNotEmpty(account.getUsernamePasswordFile())) {
return "Basic " + Base64.encodeBase64String(credentialsFromFile(account.getUsernamePasswordFile()).getBytes());
}

if (StringUtils.isNotEmpty(account.getToken())) {
return "token " + account.getToken();
}

if (StringUtils.isNotEmpty(account.getUsername()) && StringUtils.isNotEmpty(account.getPassword())) {
return "Basic " + Base64.encodeBase64String((account.getUsername() + ":" + account.getPassword()).getBytes());
}

return null;
}

private String credentialsFromFile(String filename) {
@@ -107,7 +117,7 @@ public InputStream download(Artifact artifact) throws IOException {
}

metadataUrlBuilder.addQueryParameter("ref", version);
Request metadataRequest = requestBuilder
Request metadataRequest = requestBuilder()
.url(metadataUrlBuilder.build().toString())
.build();

@@ -124,7 +134,7 @@ public InputStream download(Artifact artifact) throws IOException {
throw new FailedDownloadException("Failed to retrieve your github artifact's download URL. This is likely due to incorrect auth setup. Artifact: " + artifact);
}

Request downloadRequest = requestBuilder
Request downloadRequest = requestBuilder()
.url(metadata.getDownloadUrl())
.build();

Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright 2019 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License")
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.artifacts.github;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.MappingBuilder;
import com.netflix.spinnaker.kork.artifacts.model.Artifact;
import com.squareup.okhttp.OkHttpClient;
import org.apache.commons.io.Charsets;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junitpioneer.jupiter.TempDirectory;
import ru.lanwen.wiremock.ext.WiremockResolver;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith({WiremockResolver.class, TempDirectory.class})
class GithubArtifactCredentialsTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final OkHttpClient okHttpClient = new OkHttpClient();

private final String METADATA_PATH = "/repos/spinnaker/testing/manifest.yml";
private final String FILE_CONTENTS = "file contents";

@Test
void downloadWithToken(@WiremockResolver.Wiremock WireMockServer server) throws IOException {
GitHubArtifactAccount account = new GitHubArtifactAccount();
account.setName("my-github-account");
account.setToken("abc");

runTestCase(server, account, m -> m.withHeader("Authorization", equalTo("token abc")));
}

@Test
void downloadWithTokenFromFile(@TempDirectory.TempDir Path tempDir, @WiremockResolver.Wiremock WireMockServer server) throws IOException {
Path authFile = tempDir.resolve("auth-file");
Files.write(authFile, "zzz".getBytes());

GitHubArtifactAccount account = new GitHubArtifactAccount();
account.setName("my-github-account");
account.setTokenFile(authFile.toAbsolutePath().toString());

runTestCase(server, account, m -> m.withHeader("Authorization",equalTo("token zzz")));
}

@Test
void downloadWithBasicAuth(@WiremockResolver.Wiremock WireMockServer server) throws IOException {
GitHubArtifactAccount account = new GitHubArtifactAccount();
account.setName("my-github-account");
account.setUsername("user");
account.setPassword("passw0rd");

runTestCase(server, account, m -> m.withBasicAuth("user", "passw0rd"));
}

@Test
void downloadWithBasicAuthFromFile(@TempDirectory.TempDir Path tempDir, @WiremockResolver.Wiremock WireMockServer server) throws IOException {
Path authFile = tempDir.resolve("auth-file");
Files.write(authFile, "someuser:somepassw0rd!".getBytes());

GitHubArtifactAccount account = new GitHubArtifactAccount();
account.setName("my-github-account");
account.setUsernamePasswordFile(authFile.toAbsolutePath().toString());

runTestCase(server, account, m -> m.withBasicAuth("someuser", "somepassw0rd!"));
}

@Test
void downloadWithNoAuth(@WiremockResolver.Wiremock WireMockServer server) throws IOException {
GitHubArtifactAccount account = new GitHubArtifactAccount();
account.setName("my-github-account");

runTestCase(server, account, m -> m.withHeader("Authorization",absent()));
}


private void runTestCase(WireMockServer server, GitHubArtifactAccount account, Function<MappingBuilder, MappingBuilder> expectedAuth) throws IOException {
GitHubArtifactCredentials credentials = new GitHubArtifactCredentials(account, okHttpClient, objectMapper);

Artifact artifact = Artifact.builder()
.reference(server.baseUrl() + METADATA_PATH)
.version("master")
.build();

prepareServer(server, expectedAuth);

assertThat(credentials.download(artifact))
.hasSameContentAs(new ByteArrayInputStream(FILE_CONTENTS.getBytes(Charsets.UTF_8)));
assertThat(server.findUnmatchedRequests().getRequests()).isEmpty();
}

private void prepareServer(WireMockServer server, Function<MappingBuilder, MappingBuilder> withAuth) throws IOException {
final String downloadPath = "/download/spinnaker/testing/master/manifest.yml";

GitHubArtifactCredentials.ContentMetadata contentMetadata = new GitHubArtifactCredentials.ContentMetadata().setDownloadUrl(server.baseUrl() + downloadPath);

server.stubFor(
withAuth.apply(
any(urlPathEqualTo(METADATA_PATH))
.withQueryParam("ref", equalTo("master"))
.willReturn(aResponse().withBody(objectMapper.writeValueAsString(contentMetadata)))
)
);

server.stubFor(
withAuth.apply(
any(urlPathEqualTo(downloadPath))
.willReturn(aResponse().withBody(FILE_CONTENTS))
)
);
}
}

0 comments on commit 97d0bc5

Please sign in to comment.