Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.linecorp.centraldogma.it.xds.k8s.LabelBasedNodeIpExtractor.NODE_IP_LABEL_PROPERTY;
import static com.linecorp.centraldogma.server.internal.storage.InternalProjectConstants.INTERNAL_PROJECT_XDS;
import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
Expand Down Expand Up @@ -85,7 +86,6 @@
@EnableKubernetesMockClient(crud = true)
class XdsKubernetesNodeIpExtractorTest {

private static final String XDS_CENTRAL_DOGMA_PROJECT = "@xds";
private static final String K8S_ENDPOINT_AGGREGATORS_DIRECTORY = "/k8s/endpointAggregators/";
private static final String K8S_ENDPOINTS_DIRECTORY = "/k8s/endpoints/";

Expand Down Expand Up @@ -149,7 +149,7 @@ void extractsNodeIpFromLabel() throws Exception {
assertThat(response.status()).isSameAs(HttpStatus.OK);
assertThat(response.headers().get("grpc-status")).isEqualTo("0");

final Repository fooGroup = dogma.projectManager().get(XDS_CENTRAL_DOGMA_PROJECT)
final Repository fooGroup = dogma.projectManager().get(INTERNAL_PROJECT_XDS)
.repos().get("foo");
final Entry<JsonNode> aggregatorEntry =
fooGroup.get(Revision.HEAD, Query.ofYaml(
Expand Down Expand Up @@ -225,7 +225,7 @@ void fallsBackToInternalIpWhenLabelKeyIsAbsent() throws Exception {
assertThat(response.status()).isSameAs(HttpStatus.OK);
assertThat(response.headers().get("grpc-status")).isEqualTo("0");

final Repository fooGroup = dogma.projectManager().get(XDS_CENTRAL_DOGMA_PROJECT)
final Repository fooGroup = dogma.projectManager().get(INTERNAL_PROJECT_XDS)
.repos().get("foo");
final Entry<JsonNode> aggregatorEntry =
fooGroup.get(Revision.HEAD, Query.ofYaml(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.linecorp.centraldogma.server.test;

import static com.linecorp.centraldogma.internal.CredentialUtil.credentialName;
import static com.linecorp.centraldogma.server.internal.storage.InternalProjectConstants.INTERNAL_PROJECT_XDS;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD;
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME;
import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -91,12 +92,13 @@ void shouldAllowMembersToAccessInternalProjects() throws Exception {
.build()
.blocking();

assertThat(adminClient.listProjects().join()).containsOnly("dogma", "foo", "@xds");
assertThat(adminClient.listProjects().join()).containsOnly("dogma", "foo", INTERNAL_PROJECT_XDS);
// The xDS project is a self-service project, so it is visible to any authenticated user even before
// being granted a role. Other internal projects (e.g. dogma) remain hidden.
assertThat(nonAdminClient.listProjects().join()).containsOnly("foo", "@xds");
assertThat(nonAdminClient.listProjects().join()).containsOnly("foo", INTERNAL_PROJECT_XDS);

final CentralDogmaRepository adminRepo = adminClient.createRepository("@xds", "test").join();
final CentralDogmaRepository adminRepo = adminClient.createRepository(INTERNAL_PROJECT_XDS, "test")
.join();
adminRepo.commit("Add test.txt", Change.ofTextUpsert("/text.txt", "foo"))
.push()
.join();
Expand All @@ -105,18 +107,18 @@ void shouldAllowMembersToAccessInternalProjects() throws Exception {
adminWebClient.prepare()
.post("/api/v1/projects/@xds/credentials")
.contentJson(new CreateCredentialRequest(
"test", new NoneCredential(credentialName("@xds", "test"))))
"test", new NoneCredential(credentialName(INTERNAL_PROJECT_XDS, "test"))))
.execute();
assertThat(credentialResponse.status()).isEqualTo(HttpStatus.CREATED);

// All CRUD operations should be blocked.
assertThatThrownBy(() -> {
nonAdminClient.createRepository("@xds", "test2").join();
nonAdminClient.createRepository(INTERNAL_PROJECT_XDS, "test2").join();
}).isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(PermissionException.class)
.hasMessageContaining("You must have the MEMBER project role to access the project '@xds'.");

final CentralDogmaRepository userRepo = nonAdminClient.forRepo("@xds", "test");
final CentralDogmaRepository userRepo = nonAdminClient.forRepo(INTERNAL_PROJECT_XDS, "test");
assertThatThrownBy(() -> {
userRepo.commit("Update test.txt", Change.ofTextUpsert("/text.txt", "bar"))
.push()
Expand Down Expand Up @@ -148,7 +150,7 @@ void shouldAllowMembersToAccessInternalProjects() throws Exception {

// @xds project should be visible to member app identities.
await().untilAsserted(
() -> assertThat(nonAdminClient.listProjects().join()).containsOnly("foo", "@xds")
() -> assertThat(nonAdminClient.listProjects().join()).containsOnly("foo", INTERNAL_PROJECT_XDS)
);
// Read and write should be granted as well.
userRepo.commit("Update test.txt", Change.ofTextUpsert("/text.txt", "bar"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo
changes.put(path, Change.ofRemoval(path));
}
});
validateChanges(changes);

final String summary = "Mirror " + remoteHead + ", '" + remoteUri() +
"' to the repository '" + localRepo().name() + '\'';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ MirrorResult mirrorRemoteToLocal(
}
});

validateChanges(changes);
try {
final Revision revision = executor.execute(Command.push(
MIRROR_AUTHOR, localRepo().parent().name(), localRepo().name(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.linecorp.centraldogma.internal.CredentialUtil.credentialFile;
import static com.linecorp.centraldogma.internal.CredentialUtil.credentialName;
import static com.linecorp.centraldogma.server.internal.storage.InternalProjectConstants.INTERNAL_PROJECT_XDS;
import static com.linecorp.centraldogma.server.internal.storage.repository.MirrorConfig.DEFAULT_SCHEDULE;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.Comparator;
Expand Down Expand Up @@ -245,6 +247,27 @@ void testMirrorWithCredentialId() {
assertThat(((SshKeyCredential) m.credential()).username()).isEqualTo("alice");
}

@Test
void xdsMirrorWithNonRootLocalPath_isRejected() {
final MirrorRequest badMirror = new MirrorRequest(
"xds-mirror", true, INTERNAL_PROJECT_XDS, DEFAULT_SCHEDULE, "REMOTE_TO_LOCAL", "some-group",
"/clusters/", "git+ssh", "git.example.com/org/repo.git", "/", "main", null, "", null);
assertThatThrownBy(() ->
metaRepo.createMirrorPushCommand("some-group", badMirror, Author.SYSTEM, null, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("localPath");
}

@Test
void xdsMirrorWithRootLocalPath_isAccepted() {
final MirrorRequest validMirror = new MirrorRequest(
"xds-mirror", true, INTERNAL_PROJECT_XDS, DEFAULT_SCHEDULE, "REMOTE_TO_LOCAL", "some-group",
"/", "git+ssh", "git.example.com/org/repo.git", "/", "main", null, "", null);
assertThatCode(() ->
metaRepo.createMirrorPushCommand("some-group", validMirror, Author.SYSTEM, null, false))
.doesNotThrowAnyException();
}

private List<Mirror> findMirrors() {
// Get the mirror list and sort it by localRepo name alphabetically for easier testing.
return metaRepo.mirrors().join().stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,57 @@
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;

import org.eclipse.jgit.ignore.IgnoreNode;
import org.eclipse.jgit.ignore.IgnoreNode.MatchResult;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.Cron;
import com.cronutils.model.time.ExecutionTime;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.MoreObjects.ToStringHelper;
import com.google.common.collect.ImmutableList;
import com.google.common.hash.Hashing;

import com.linecorp.centraldogma.common.Author;
import com.linecorp.centraldogma.common.Change;
import com.linecorp.centraldogma.common.Entry;
import com.linecorp.centraldogma.common.EntryType;
import com.linecorp.centraldogma.common.MirrorException;
import com.linecorp.centraldogma.server.command.CommandExecutor;
import com.linecorp.centraldogma.server.credential.Credential;
import com.linecorp.centraldogma.server.mirror.Mirror;
import com.linecorp.centraldogma.server.mirror.MirrorDirection;
import com.linecorp.centraldogma.server.mirror.MirrorFileValidator;
import com.linecorp.centraldogma.server.mirror.MirrorResult;
import com.linecorp.centraldogma.server.mirror.MirrorStatus;
import com.linecorp.centraldogma.server.mirror.RepositoryUri;
import com.linecorp.centraldogma.server.storage.repository.Repository;

public abstract class AbstractMirror implements Mirror {

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

private static final CronDescriptor CRON_DESCRIPTOR = CronDescriptor.instance();

private static final List<MirrorFileValidator> FILE_VALIDATORS;

static {
FILE_VALIDATORS = ImmutableList.copyOf(ServiceLoader.load(MirrorFileValidator.class));
logger.debug("Available {}s: {}", MirrorFileValidator.class.getSimpleName(), FILE_VALIDATORS);
}

protected static final Author MIRROR_AUTHOR = new Author("Mirror", "mirror@localhost.localdomain");

protected static final String MIRROR_STATE_FILE_NAME = "mirror_state.json";
Expand Down Expand Up @@ -252,6 +269,39 @@ protected final IgnoreNode ignoreNode() {
return ignoreNode;
}

/**
* Validates the given changes using all registered {@link MirrorFileValidator}s before they are
* committed. Mirror state files are excluded from validation.
*
* @throws MirrorException if any change fails validation
*/
protected final void validateChanges(Map<String, Change<?>> changes) {
if (FILE_VALIDATORS.isEmpty()) {
return;
}
final String projectName = localRepo().parent().name();
final String repoName = localRepo().name();
final List<String> errors = new ArrayList<>();
for (Change<?> change : changes.values()) {
// Skip mirror state files — they are internal bookkeeping, not user content.
if (change.path().endsWith(MIRROR_STATE_FILE_NAME)) {
continue;
}
for (MirrorFileValidator validator : FILE_VALIDATORS) {
try {
validator.validate(projectName, repoName, change);
} catch (MirrorException e) {
errors.add(e.getMessage());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add the MirrorException as a suppressed exception so that we could see the original exception in the centraldogma.log?

}
}
}
if (!errors.isEmpty()) {
throw new MirrorException(
"Mirror validation failed for '" + projectName + '/' + repoName + "':\n" +
String.join("\n", errors));
}
}

/**
* Filters the entries using gitignore patterns. Returns the entries as-is if no gitignore is configured.
* The entries should be sorted by path so that directory entries come before their children.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2026 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.storage;

/**
* Constants for internal project names.
*/
public final class InternalProjectConstants {

/**
* The name of the internal project used by the xDS control plane.
*/
public static final String INTERNAL_PROJECT_XDS = "@xds";

private InternalProjectConstants() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.linecorp.centraldogma.server.internal.storage.project;

import static com.linecorp.centraldogma.internal.Util.INTERNAL_PROJECT_PREFIX;
import static com.linecorp.centraldogma.server.internal.storage.InternalProjectConstants.INTERNAL_PROJECT_XDS;
import static com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer.INTERNAL_PROJECT_DOGMA;

import java.time.Instant;
Expand Down Expand Up @@ -47,10 +48,6 @@
*/
public final class ProjectApiManager {

// The xDS project is a self-service internal project: any authenticated user may access it (e.g. to list
// and create groups via the xDS web UI). It is intentionally more permissive than other internal projects.
private static final String INTERNAL_PROJECT_XDS = "@xds";

private final ProjectManager projectManager;
private final CommandExecutor commandExecutor;
private final MetadataService metadataService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.linecorp.centraldogma.internal.CredentialUtil.credentialFile;
import static com.linecorp.centraldogma.server.internal.storage.InternalProjectConstants.INTERNAL_PROJECT_XDS;
import static com.linecorp.centraldogma.server.internal.storage.repository.MirrorConverter.converterToMirrorConfig;
import static java.util.Objects.requireNonNull;

Expand Down Expand Up @@ -416,5 +417,11 @@ private static void validateMirror(MirrorRequest mirror, @Nullable ZoneConfig zo
checkArgument(zoneConfig.allZones().contains(zone),
"The zone '%s' is not in the zone configuration: %s", zone, zoneConfig);
}

if (INTERNAL_PROJECT_XDS.equals(mirror.projectName())) {
final String localPath = mirror.localPath();
checkArgument("/".equals(localPath) || localPath.isEmpty(),
"xDS mirrors must use localPath '/', but got: %s", localPath);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2026 LY Corporation
*
* LY 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.mirror;

import com.linecorp.centraldogma.common.Change;

/**
* Validates file changes before they are committed to a repository during mirroring.
*
* <p>Implementations are loaded via {@link java.util.ServiceLoader} and invoked in
* {@link com.linecorp.centraldogma.server.internal.mirror.AbstractMirror} before each push.</p>
*/
@FunctionalInterface
public interface MirrorFileValidator {

/**
* Validates a file change before it is committed to a repository during mirroring.
*/
void validate(String projectName, String repoName, Change<?> change);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface CredentialFormProps {
defaultValue: CredentialDto;
onSubmit: (credential: CredentialDto, onSuccess: () => void) => Promise<void>;
isWaitingResponse: boolean;
hideScope?: boolean;
}

interface CredentialTypeWithDescription {
Expand All @@ -70,6 +71,7 @@ const CredentialForm = ({
defaultValue,
onSubmit,
isWaitingResponse,
hideScope = false,
}: CredentialFormProps) => {
const [credentialType, setCredentialType] = useState<string>(defaultValue.type);

Expand Down Expand Up @@ -112,17 +114,18 @@ const CredentialForm = ({
<Heading size="lg" mb={4}>
{isNew ? 'New Credential' : 'Edit Credential'}
</Heading>
{repoName ? (
<HStack paddingBottom={2}>
<LabelledIcon icon={GoRepo} text="Repository" />
<Tag fontWeight={'bold'}>{repoName}</Tag>
</HStack>
) : (
<HStack paddingBottom={2}>
<LabelledIcon icon={FiBox} text="Project" />
<Tag fontWeight={'bold'}>{projectName}</Tag>
</HStack>
)}
{!hideScope &&
(repoName ? (
<HStack paddingBottom={2}>
<LabelledIcon icon={GoRepo} text="Repository" />
<Tag fontWeight={'bold'}>{repoName}</Tag>
</HStack>
) : (
<HStack paddingBottom={2}>
<LabelledIcon icon={FiBox} text="Project" />
<Tag fontWeight={'bold'}>{projectName}</Tag>
</HStack>
))}
<Divider />
<FormControl isRequired isInvalid={errors.id != null}>
<FormLabel>
Expand Down
Loading
Loading