Skip to content

Commit

Permalink
Provide a way to trigger Git mirroring manually (line#1041)
Browse files Browse the repository at this point in the history
Motivation:

- There is no easy way to test Git mirroring configurations. 
- Some users may want to run Git mirroring manually instead of using the
periodic scheduler.

Modifications:

- Add `POST /projects/{projectName}/mirrors/{mirrorId}/run` REST API to
`MirroringServiceV1` so as to trigger a mirroring task.
- Add `verboseResponses` to `CentralDogmaConfig` so as to contain full
stack traces in error responses.
- The cause of mirroring failures will be displayed in the UI for
administrators or `verboseResponses` is enabled.
- Change `AbstractMirror` to return `MirrorResult` which is used to
notify the result in the UI.
- Make `MirrorDto.schedule` nullable.
- Exclude a mirror configuration from scheduling `schedule` it is null.
- Move `MirrorException` to `common` and expose it in the error
responses.
- Add `cronstrue` dependency to `package.json` to display a human
readable description on the mirror form UI.
- Increase `maxWidth` of `NotificationWrapper` to render stack traces
well.

Result:

- You can now trigger a mirroring task in the mirror UI.
- Fixes line#700
- Fixes line#236
  • Loading branch information
ikhoon authored Nov 1, 2024
1 parent 9a9e098 commit 349460c
Show file tree
Hide file tree
Showing 57 changed files with 1,333 additions and 220 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
import com.linecorp.centraldogma.common.Markup;
import com.linecorp.centraldogma.common.MergeQuery;
import com.linecorp.centraldogma.common.MergedEntry;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.common.PathPattern;
import com.linecorp.centraldogma.common.ProjectExistsException;
import com.linecorp.centraldogma.common.ProjectNotFoundException;
Expand Down Expand Up @@ -133,6 +134,7 @@ public final class ArmeriaCentralDogma extends AbstractCentralDogma {
.put(RepositoryExistsException.class.getName(), RepositoryExistsException::new)
.put(InvalidPushException.class.getName(), InvalidPushException::new)
.put(ReadOnlyException.class.getName(), ReadOnlyException::new)
.put(MirrorException.class.getName(), MirrorException::new)
.build();

private final WebClient client;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017 LINE Corporation
* 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
Expand All @@ -14,12 +14,12 @@
* under the License.
*/

package com.linecorp.centraldogma.server;
package com.linecorp.centraldogma.common;

/**
* A {@link RuntimeException} raised when {@link MirroringService} failed to mirror a repository.
* A {@link CentralDogmaException} raised when failed to mirror a repository.
*/
public class MirrorException extends RuntimeException {
public class MirrorException extends CentralDogmaException {

private static final long serialVersionUID = 5648624670936197720L;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public final class MirrorDto {
private final String id;
private final boolean enabled;
private final String projectName;
@Nullable
private final String schedule;
private final String direction;
private final String localRepo;
Expand All @@ -52,7 +53,7 @@ public final class MirrorDto {
public MirrorDto(@JsonProperty("id") String id,
@JsonProperty("enabled") @Nullable Boolean enabled,
@JsonProperty("projectName") String projectName,
@JsonProperty("schedule") String schedule,
@JsonProperty("schedule") @Nullable String schedule,
@JsonProperty("direction") String direction,
@JsonProperty("localRepo") String localRepo,
@JsonProperty("localPath") String localPath,
Expand All @@ -65,7 +66,7 @@ public MirrorDto(@JsonProperty("id") String id,
this.id = requireNonNull(id, "id");
this.enabled = firstNonNull(enabled, true);
this.projectName = requireNonNull(projectName, "projectName");
this.schedule = requireNonNull(schedule, "schedule");
this.schedule = schedule;
this.direction = requireNonNull(direction, "direction");
this.localRepo = requireNonNull(localRepo, "localRepo");
this.localPath = requireNonNull(localPath, "localPath");
Expand All @@ -92,6 +93,7 @@ public String projectName() {
return projectName;
}

@Nullable
@JsonProperty("schedule")
public String schedule() {
return schedule;
Expand Down Expand Up @@ -155,7 +157,7 @@ public boolean equals(Object o) {
return id.equals(mirrorDto.id) &&
enabled == mirrorDto.enabled &&
projectName.equals(mirrorDto.projectName) &&
schedule.equals(mirrorDto.schedule) &&
Objects.equals(schedule, mirrorDto.schedule) &&
direction.equals(mirrorDto.direction) &&
localRepo.equals(mirrorDto.localRepo) &&
localPath.equals(mirrorDto.localPath) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@

import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.centraldogma.common.Change;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.internal.Jackson;
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
import com.linecorp.centraldogma.server.MirrorException;
import com.linecorp.centraldogma.server.MirroringService;
import com.linecorp.centraldogma.server.internal.mirror.MirrorState;
import com.linecorp.centraldogma.server.mirror.MirrorDirection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.CompletionException;

import javax.annotation.Nullable;

Expand Down Expand Up @@ -63,10 +64,11 @@
import com.linecorp.centraldogma.common.Change;
import com.linecorp.centraldogma.common.Commit;
import com.linecorp.centraldogma.common.Entry;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.common.PathPattern;
import com.linecorp.centraldogma.common.RedundantChangeException;
import com.linecorp.centraldogma.common.Revision;
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
import com.linecorp.centraldogma.server.MirrorException;
import com.linecorp.centraldogma.server.MirroringService;
import com.linecorp.centraldogma.server.internal.JGitUtil;
import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig;
Expand Down Expand Up @@ -478,6 +480,23 @@ private void pushMirrorSettings(String localRepo, @Nullable String localPath, @N
@Nullable String gitignore) {
final String localPath0 = localPath == null ? "/" : localPath;
final String remoteUri = gitUri + firstNonNull(remotePath, "");
try {
client.forRepo(projName, Project.REPO_META)
.commit("Add /credentials/none",
Change.ofJsonUpsert("/credentials/none.json",
"{ " +
"\"type\": \"none\", " +
"\"id\": \"none\", " +
"\"enabled\": true " +
'}'))
.push().join();
} catch (CompletionException e) {
if (e.getCause() instanceof RedundantChangeException) {
// The same content can be pushed several times.
} else {
throw e;
}
}
client.forRepo(projName, Project.REPO_META)
.commit("Add /mirrors/foo.json",
Change.ofJsonUpsert("/mirrors/foo.json",
Expand All @@ -490,6 +509,7 @@ private void pushMirrorSettings(String localRepo, @Nullable String localPath, @N
" \"localPath\": \"" + localPath0 + "\"," +
" \"remoteUri\": \"" + remoteUri + "\"," +
" \"schedule\": \"0 0 0 1 1 ? 2099\"," +
" \"credentialId\": \"none\"," +
" \"gitignore\": " + firstNonNull(gitignore, "\"\"") +
'}'))
.push().join();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletionException;

import javax.annotation.Nullable;

Expand All @@ -53,11 +54,12 @@
import com.linecorp.centraldogma.common.CentralDogmaException;
import com.linecorp.centraldogma.common.Change;
import com.linecorp.centraldogma.common.Entry;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.common.PathPattern;
import com.linecorp.centraldogma.common.RedundantChangeException;
import com.linecorp.centraldogma.common.Revision;
import com.linecorp.centraldogma.internal.Jackson;
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
import com.linecorp.centraldogma.server.MirrorException;
import com.linecorp.centraldogma.server.MirroringService;
import com.linecorp.centraldogma.server.internal.mirror.MirrorState;
import com.linecorp.centraldogma.server.mirror.MirrorDirection;
Expand Down Expand Up @@ -315,7 +317,7 @@ void localToRemote_tooManyBytes() throws Exception {
long remainder = MAX_NUM_BYTES + 1;
final int defaultFileSize = (int) (MAX_NUM_BYTES / MAX_NUM_FILES * 2);
final ArrayList<Change<String>> changes = new ArrayList<>();
for (int i = 0;; i++) {
for (int i = 0; ; i++) {
final int fileSize;
if (remainder > defaultFileSize) {
remainder -= defaultFileSize;
Expand Down Expand Up @@ -357,6 +359,23 @@ private void pushMirrorSettings(String localRepo, @Nullable String localPath, @N
@Nullable String gitignore, MirrorDirection direction) {
final String localPath0 = localPath == null ? "/" : localPath;
final String remoteUri = gitUri + firstNonNull(remotePath, "");
try {
client.forRepo(projName, Project.REPO_META)
.commit("Add /credentials/none",
Change.ofJsonUpsert("/credentials/none.json",
"{ " +
"\"type\": \"none\", " +
"\"id\": \"none\", " +
"\"enabled\": true " +
'}'))
.push().join();
} catch (CompletionException e) {
if (e.getCause() instanceof RedundantChangeException) {
// The same content can be pushed several times.
} else {
throw e;
}
}
client.forRepo(projName, Project.REPO_META)
.commit("Add /mirrors/foo.json",
Change.ofJsonUpsert("/mirrors/foo.json",
Expand All @@ -369,7 +388,8 @@ private void pushMirrorSettings(String localRepo, @Nullable String localPath, @N
" \"localPath\": \"" + localPath0 + "\"," +
" \"remoteUri\": \"" + remoteUri + "\"," +
" \"schedule\": \"0 0 0 1 1 ? 2099\"," +
" \"gitignore\": " + firstNonNull(gitignore, "\"\"") +
" \"gitignore\": " + firstNonNull(gitignore, "\"\"") + ',' +
" \"credentialId\": \"none\"" +
'}'))
.push().join();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* 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.it.mirror.git;

import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken;
import static org.assertj.core.api.Assertions.assertThat;

import java.nio.charset.StandardCharsets;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import com.google.common.io.Resources;

import com.linecorp.armeria.client.BlockingWebClient;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.ResponseEntity;
import com.linecorp.armeria.common.auth.AuthToken;
import com.linecorp.centraldogma.client.CentralDogma;
import com.linecorp.centraldogma.internal.api.v1.MirrorDto;
import com.linecorp.centraldogma.internal.api.v1.PushResultDto;
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
import com.linecorp.centraldogma.server.internal.credential.PublicKeyCredential;
import com.linecorp.centraldogma.server.mirror.MirrorResult;
import com.linecorp.centraldogma.server.mirror.MirrorStatus;
import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory;
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;

class MirrorRunnerTest {

private static final String FOO_PROJ = "foo";
private static final String BAR_REPO = "bar";
private static final String PRIVATE_KEY_FILE = "ecdsa_256.openssh";
private static final String TEST_MIRROR_ID = "test-mirror";

@RegisterExtension
static final CentralDogmaExtension dogma = new CentralDogmaExtension() {

@Override
protected void configure(CentralDogmaBuilder builder) {
builder.authProviderFactory(new TestAuthProviderFactory());
builder.administrators(USERNAME);
}

@Override
protected void scaffold(CentralDogma client) {
client.createProject(FOO_PROJ).join();
client.createRepository(FOO_PROJ, BAR_REPO).join();
}
};

private BlockingWebClient adminClient;

@BeforeEach
void setUp() throws Exception {
final String adminToken = getAccessToken(dogma.httpClient(), USERNAME, PASSWORD);
adminClient = WebClient.builder(dogma.httpClient().uri())
.auth(AuthToken.ofOAuth2(adminToken))
.build()
.blocking();
}

@Test
void triggerMirroring() throws Exception {
final PublicKeyCredential credential = getCredential();
ResponseEntity<PushResultDto> response =
adminClient.prepare()
.post("/api/v1/projects/{proj}/credentials")
.pathParam("proj", FOO_PROJ)
.contentJson(credential)
.asJson(PushResultDto.class)
.execute();
assertThat(response.status()).isEqualTo(HttpStatus.CREATED);

final MirrorDto newMirror = newMirror();
response = adminClient.prepare()
.post("/api/v1/projects/{proj}/mirrors")
.pathParam("proj", FOO_PROJ)
.contentJson(newMirror)
.asJson(PushResultDto.class)
.execute();
assertThat(response.status()).isEqualTo(HttpStatus.CREATED);

for (int i = 0; i < 3; i++) {
final ResponseEntity<MirrorResult> mirrorResponse =
adminClient.prepare()
.post("/api/v1/projects/{proj}/mirrors/{mirrorId}/run")
.pathParam("proj", FOO_PROJ)
.pathParam("mirrorId", TEST_MIRROR_ID)
.asJson(MirrorResult.class)
.execute();

assertThat(mirrorResponse.status()).isEqualTo(HttpStatus.OK);
if (i == 0) {
assertThat(mirrorResponse.content().mirrorStatus()).isEqualTo(MirrorStatus.SUCCESS);
assertThat(mirrorResponse.content().description())
.contains("'git+ssh://github.com/line/centraldogma-authtest.git#main' to " +
"the repository 'bar', revision: 2");
} else {
assertThat(mirrorResponse.content().mirrorStatus()).isEqualTo(MirrorStatus.UP_TO_DATE);
assertThat(mirrorResponse.content().description())
.contains("Repository 'foo/bar' already at");
}
}
}

private static MirrorDto newMirror() {
return new MirrorDto(TEST_MIRROR_ID,
true,
FOO_PROJ,
null,
"REMOTE_TO_LOCAL",
BAR_REPO,
"/",
"git+ssh",
"github.com/line/centraldogma-authtest.git",
"/",
"main",
null,
PRIVATE_KEY_FILE);
}

private static PublicKeyCredential getCredential() throws Exception {
final String publicKeyFile = "ecdsa_256.openssh.pub";

final byte[] privateKeyBytes =
Resources.toByteArray(GitMirrorAuthTest.class.getResource(PRIVATE_KEY_FILE));
final byte[] publicKeyBytes =
Resources.toByteArray(GitMirrorAuthTest.class.getResource(publicKeyFile));
final String privateKey = new String(privateKeyBytes, StandardCharsets.UTF_8).trim();
final String publicKey = new String(publicKeyBytes, StandardCharsets.UTF_8).trim();

return new PublicKeyCredential(
PRIVATE_KEY_FILE,
true,
"git",
publicKey,
privateKey,
null);
}
}
Loading

0 comments on commit 349460c

Please sign in to comment.