Skip to content

Commit

Permalink
Support Git clone over HTTP in Central Dogma. (line#954)
Browse files Browse the repository at this point in the history
Motivation:
Adding support for Git clone over HTTP in Central Dogma server would enhance its capabilities and make it more user-friendly. This PR partially addressed line#543.
Please note that I didn't impelent Git over HTTP fully because:
- We have APIs for commit separately.
- The underlying Git repository could be changed to another format instead of Git.

Modifications:
- Added support for Git clone over HTTP to Central Dogma.

Result:
- You can now clone your repositories via Git clone over HTTP.
  ```
  git clone -c http.extraHeader="Authorization: Bearer your-token" https://your-dogma.com/foo/bar.git
  ```

References:
- https://git-scm.com/docs/protocol-v2/
- https://git-scm.com/docs/protocol-capabilities/
- https://www.git-scm.com/docs/http-protocol
- https://www.git-scm.com/docs/git-upload-pack
- https://git-scm.com/docs/protocol-common
- https://git-scm.com/docs/pack-format/

The CLI command that allows you to view the packets being sent and received:
```
GIT_TRACE=2 GIT_CURL_VERBOSE=2 GIT_TRACE_PERFORMANCE=2 GIT_TRACE_PACK_ACCESS=2 GIT_TRACE_PACKET=2 GIT_TRACE_PACKFILE=2 GIT_TRACE_SETUP=2 GIT_TRACE_SHALLOW=2  git clone --depth=1 https://github.com/line/armeria.git
```
  • Loading branch information
minwoox authored May 22, 2024
1 parent 4f86c3d commit 5463d22
Show file tree
Hide file tree
Showing 10 changed files with 603 additions and 1 deletion.
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 @@ -782,6 +784,10 @@ public String serviceName(ServiceRequestContext ctx) {
.responseConverters(v1ResponseConverter)
.build(new ContentServiceV1(executor, watchService, meterRegistry));

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 @@ -861,6 +867,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,224 @@
/*
* 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 PacketLineFraming packetLineFraming = new PacketLineFraming();
packetLineFraming.putWithoutPktLine("001e# service=git-upload-pack\n");
packetLineFraming.flush();
packetLineFraming.put("version 2");
packetLineFraming.put(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.
packetLineFraming.put(COMMAND_FETCH + '=' + OPTION_WAIT_FOR_DONE + ' ' + OPTION_SHALLOW);
// TODO(minwoox): Migrate hash function https://git-scm.com/docs/hash-function-transition
packetLineFraming.put("object-format=sha1");
packetLineFraming.flush();
return packetLineFraming.toString();
}

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")
public HttpResponse advertiseCapability(@Header("git-protocol") @Nullable String gitProtocol,
@Param String service,
@Param String projectName, @Param String repoName) {
repoName = maybeRemoveGitSuffix(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);
}

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")) {
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 -> {
// 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);
}

static class PacketLineFraming {
private final StringBuilder sb = new StringBuilder();

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

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

void putWithoutPktLine(String line) {
sb.append(line);
}

void delim() {
sb.append("0001");
}

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

@Override
public String toString() {
return sb.toString();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.linecorp.armeria.server.annotation.Decorator;
import com.linecorp.armeria.server.annotation.DecoratorFactoryFunction;
import com.linecorp.centraldogma.server.internal.admin.auth.AuthUtil;
import com.linecorp.centraldogma.server.internal.api.GitHttpService;
import com.linecorp.centraldogma.server.internal.api.HttpApiUtil;
import com.linecorp.centraldogma.server.metadata.MetadataService;
import com.linecorp.centraldogma.server.metadata.MetadataServiceInjector;
Expand Down Expand Up @@ -70,7 +71,7 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exc
}
return unwrap().serve(ctx, req);
}
return serveUserRepo(ctx, req, mds, user, projectName, repoName);
return serveUserRepo(ctx, req, mds, user, projectName, maybeRemoveGitSuffix(repoName));
}

private static HttpResponse throwForbiddenResponse(ServiceRequestContext ctx, String projectName,
Expand All @@ -80,6 +81,17 @@ private static HttpResponse throwForbiddenResponse(ServiceRequestContext ctx, St
projectName, repoName, adminOrOwner);
}

/**
* Removes the trailing ".git" suffix from the repository name if it exists. This is added for
* GitHttpService. See {@link GitHttpService}.
*/
private static String maybeRemoveGitSuffix(String repoName) {
if (repoName.length() >= 5 && repoName.endsWith(".git")) {
repoName = repoName.substring(0, repoName.length() - 4);
}
return repoName;
}

private HttpResponse serveUserRepo(ServiceRequestContext ctx, HttpRequest req,
MetadataService mds, User user,
String projectName, String repoName) throws Exception {
Expand Down
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()) {
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

0 comments on commit 5463d22

Please sign in to comment.