Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Git clone over HTTP in Central Dogma. #954

Merged
merged 14 commits into from
May 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import com.linecorp.armeria.server.auth.Authorizer;
import com.linecorp.armeria.server.cors.CorsService;
import com.linecorp.armeria.server.docs.DocService;
import com.linecorp.armeria.server.encoding.DecodingService;
import com.linecorp.armeria.server.encoding.EncodingService;
import com.linecorp.armeria.server.file.FileService;
import com.linecorp.armeria.server.file.HttpFile;
Expand Down Expand Up @@ -130,6 +131,7 @@
import com.linecorp.centraldogma.server.internal.admin.util.RestfulJsonResponseConverter;
import com.linecorp.centraldogma.server.internal.api.AdministrativeService;
import com.linecorp.centraldogma.server.internal.api.ContentServiceV1;
import com.linecorp.centraldogma.server.internal.api.GitHttpService;
import com.linecorp.centraldogma.server.internal.api.MetadataApiService;
import com.linecorp.centraldogma.server.internal.api.ProjectServiceV1;
import com.linecorp.centraldogma.server.internal.api.RepositoryServiceV1;
Expand Down Expand Up @@ -781,6 +783,10 @@ public String serviceName(ServiceRequestContext ctx) {
.responseConverters(v1ResponseConverter)
.build(new ContentServiceV1(executor, watchService));

sb.annotatedService().decorator(decorator)
.decorator(DecodingService.newDecorator())
.build(new GitHttpService(projectApiManager));

if (authProvider != null) {
final AuthConfig authCfg = cfg.authConfig();
assert authCfg != null : "authCfg";
Expand Down Expand Up @@ -860,6 +866,8 @@ private static Function<? super HttpService, EncodingService> contentEncodingDec
case "json":
case "xml":
case "x-thrift":
case "x-git-upload-pack-advertisement":
case "x-git-upload-pack-result":
return true;
default:
return subtype.endsWith("+json") ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright 2024 LINE Corporation
*
* LINE Corporation licenses this file to you 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:
*
* https://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.linecorp.centraldogma.server.internal.api;

import static java.util.Objects.requireNonNull;
import static org.eclipse.jgit.transport.GitProtocolConstants.COMMAND_FETCH;
import static org.eclipse.jgit.transport.GitProtocolConstants.COMMAND_LS_REFS;
import static org.eclipse.jgit.transport.GitProtocolConstants.OPTION_SHALLOW;
import static org.eclipse.jgit.transport.GitProtocolConstants.OPTION_WAIT_FOR_DONE;
import static org.eclipse.jgit.transport.GitProtocolConstants.VERSION_2_REQUEST;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.UploadPack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.ImmutableList;

import com.linecorp.armeria.common.AggregatedHttpRequest;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpHeaderNames;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.MediaType;
import com.linecorp.armeria.common.ResponseHeaders;
import com.linecorp.armeria.common.ServerCacheControl;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.stream.ByteStreamMessage;
import com.linecorp.armeria.common.stream.StreamMessage;
import com.linecorp.armeria.server.annotation.Get;
import com.linecorp.armeria.server.annotation.Header;
import com.linecorp.armeria.server.annotation.Param;
import com.linecorp.armeria.server.annotation.Post;
import com.linecorp.centraldogma.server.internal.api.auth.RequiresReadPermission;
import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager;

/**
* A service that provides Git HTTP protocol.
*/
@RequiresReadPermission
public final class GitHttpService {

private static final Logger logger = LoggerFactory.getLogger(GitHttpService.class);

private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();

// TODO(minwoox): Add the headers in this class to Armeria.
private static final AggregatedHttpResponse CAPABILITY_ADVERTISEMENT_RESPONSE = AggregatedHttpResponse.of(
ResponseHeaders.builder(200)
.add(HttpHeaderNames.CONTENT_TYPE, "application/x-git-upload-pack-advertisement")
.add(HttpHeaderNames.CACHE_CONTROL, ServerCacheControl.REVALIDATED.asHeaderValue())
.build(),
HttpData.ofUtf8(capabilityAdvertisement()));

// https://git-scm.com/docs/protocol-capabilities/
private static String capabilityAdvertisement() {
final StringBuilder sb = new StringBuilder();
sb.append("001e# service=git-upload-pack\n");
pktFlush(sb);
pktLine(sb, "version 2");
minwoox marked this conversation as resolved.
Show resolved Hide resolved
pktLine(sb, COMMAND_LS_REFS);
// Support limited options for now due to the unique characteristics of Git repositories in
// Central Dogma, such as having only a master branch and no tags, among other specifics.
pktLine(sb, COMMAND_FETCH + '=' + OPTION_WAIT_FOR_DONE + ' ' + OPTION_SHALLOW);
// TODO(minwoox): Migrate hash function https://git-scm.com/docs/hash-function-transition
pktLine(sb, "object-format=sha1");
pktFlush(sb);
return sb.toString();
}

// https://git-scm.com/docs/protocol-common#_pkt_line_format
static void pktLine(StringBuilder sb, String line) {
lineLength(sb, line.getBytes(StandardCharsets.UTF_8).length + 5);
sb.append(line).append('\n');
}

static void pktFlush(StringBuilder sb) {
https://git-scm.com/docs/protocol-v2/2.31.0#_packet_line_framing
sb.append("0000");
}

private static void lineLength(StringBuilder sb, int length) {
for (int i = 3; i >= 0; i--) {
sb.append(HEX_DIGITS[(length >>> (4 * i)) & 0xf]);
}
}

private final ProjectApiManager projectApiManager;

public GitHttpService(ProjectApiManager projectApiManager) {
this.projectApiManager = requireNonNull(projectApiManager, "projectApiManager");
}

// https://www.git-scm.com/docs/gitprotocol-http#_smart_clients
@Get("/{projectName}/{repoName}/info/refs")
minwoox marked this conversation as resolved.
Show resolved Hide resolved
public HttpResponse advertiseCapability(@Header("git-protocol") @Nullable String gitProtocol,
@Param String service,
@Param String projectName, @Param String repoName) {
if (!"git-upload-pack".equals(service)) {
// Return 403 https://www.git-scm.com/docs/http-protocol#_smart_server_response
return HttpResponse.of(HttpStatus.FORBIDDEN, MediaType.PLAIN_TEXT_UTF_8,
"Unsupported service: " + service);
}
repoName = maybeRemoveGitSuffix(repoName);
if (gitProtocol == null || !gitProtocol.contains(VERSION_2_REQUEST)) {
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
"Unsupported git-protocol: " + gitProtocol);
}

if (!projectApiManager.exists(projectName)) {
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
"Project not found: " + projectName);
}
if (!projectApiManager.getProject(projectName).repos().exists(repoName)) {
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
"Repository not found: " + repoName);
}
return CAPABILITY_ADVERTISEMENT_RESPONSE.toHttpResponse();
}

private static String maybeRemoveGitSuffix(String repoName) {
if (repoName.length() > 5 && repoName.endsWith(".git")) {
minwoox marked this conversation as resolved.
Show resolved Hide resolved
minwoox marked this conversation as resolved.
Show resolved Hide resolved
repoName = repoName.substring(0, repoName.length() - 4);
}
return repoName;
}

// https://www.git-scm.com/docs/gitprotocol-http#_smart_service_git_upload_pack
@Post("/{projectName}/{repoName}/git-upload-pack")
public HttpResponse gitUploadPack(AggregatedHttpRequest req,
@Param String projectName, @Param String repoName) {
repoName = maybeRemoveGitSuffix(repoName);
final String gitProtocol = req.headers().get("git-protocol");
if (gitProtocol == null || !gitProtocol.contains(VERSION_2_REQUEST)) {
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
"Unsupported git-protocol: " + gitProtocol);
}

final MediaType contentType = req.headers().contentType();
if (contentType == null || !"application/x-git-upload-pack-request".equals(contentType.toString())) {
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
"Unsupported content-type: " + contentType);
}

if (!projectApiManager.exists(projectName)) {
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
"Project not found: " + projectName);
}
if (!projectApiManager.getProject(projectName).repos().exists(repoName)) {
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
"Repository not found: " + repoName);
}

final Repository jGitRepository =
projectApiManager.getProject(projectName).repos().get(repoName).jGitRepository();

final ByteStreamMessage body = StreamMessage.fromOutputStream(os -> {
ikhoon marked this conversation as resolved.
Show resolved Hide resolved
// Don't need to close the input stream.
final ByteArrayInputStream inputStream = new ByteArrayInputStream(req.content().byteBuf().array());
// Don't need to close because we don't use the timer inside it.
final UploadPack uploadPack = new UploadPack(jGitRepository);
uploadPack.setTimeout(0); // Disable timeout because Armeria server will handle it.
// HTTP does not use bidirectional pipe.
uploadPack.setBiDirectionalPipe(false);
uploadPack.setExtraParameters(ImmutableList.of(VERSION_2_REQUEST));
try {
uploadPack.upload(inputStream, os, null);
} catch (IOException e) {
// Log until https://github.com/line/centraldogma/pull/719 is implemented.
logger.debug("Failed to respond git-upload-pack-request: {}", req.contentUtf8(), e);
throw new RuntimeException("failed to respond git-upload-pack-request: " +
req.contentUtf8(), e);
}
try {
os.close();
} catch (IOException e) {
// Should never reach here because StreamWriterOutputStream.close() never throws an exception.
logger.warn("Failed to close the output stream. request: {}", req.contentUtf8(), e);
}
});
return HttpResponse.of(
ResponseHeaders.builder(200)
.add(HttpHeaderNames.CONTENT_TYPE, "application/x-git-upload-pack-result")
.add(HttpHeaderNames.CACHE_CONTROL,
ServerCacheControl.REVALIDATED.asHeaderValue())
.build(), body);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,11 @@ public Project getProject(String projectName) {
}
return projectManager.get(projectName);
}

public boolean exists(String projectName) {
if (INTERNAL_PROJECT_DOGMA.equals(projectName) && !isAdmin()) {
minwoox marked this conversation as resolved.
Show resolved Hide resolved
throw new IllegalArgumentException("Cannot access " + projectName);
}
return projectManager.exists(projectName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ public DefaultMetaRepository(Repository repo) {
super(repo);
}

@Override
public org.eclipse.jgit.lib.Repository jGitRepository() {
return unwrap().jGitRepository();
}

@Override
public Set<Mirror> mirrors() {
mirrorLock.lock();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public final <T extends Repository> T unwrap() {
return (T) repo;
}

@Override
public org.eclipse.jgit.lib.Repository jGitRepository() {
return unwrap().jGitRepository();
}

@Override
public Project parent() {
return unwrap().parent();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ final class CachingRepository implements Repository {
this.cache = requireNonNull(cache, "cache");
}

@Override
public org.eclipse.jgit.lib.Repository jGitRepository() {
return repo.jGitRepository();
}

@Override
public long creationTimeMillis() {
return repo.creationTimeMillis();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,11 @@ void internalClose() {
close(() -> new CentralDogmaException("should never reach here"));
}

@Override
public org.eclipse.jgit.lib.Repository jGitRepository() {
return jGitRepository;
}

@Override
public Project parent() {
return parent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public interface Repository {

String ALL_PATH = "/**";

/**
* Returns the jGit {@link org.eclipse.jgit.lib.Repository}.
*/
org.eclipse.jgit.lib.Repository jGitRepository();

/**
* Returns the parent {@link Project} of this {@link Repository}.
*/
Expand Down
Loading
Loading