Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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 @@ -21,6 +21,7 @@
import static com.linecorp.centraldogma.internal.CredentialUtil.credentialName;
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 +246,27 @@ void testMirrorWithCredentialId() {
assertThat(((SshKeyCredential) m.credential()).username()).isEqualTo("alice");
}

@Test
void xdsMirrorWithNonRootLocalPath_isRejected() {
final MirrorRequest badMirror = new MirrorRequest(
"xds-mirror", true, "@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, "@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
Expand Up @@ -400,6 +400,8 @@ private CompletableFuture<Void> validateCredentialType(MirrorRequest mirrorReque
});
}

private static final String XDS_PROJECT_NAME = "@xds";

private static void validateMirror(MirrorRequest mirror, @Nullable ZoneConfig zoneConfig) {
checkArgument(!Strings.isNullOrEmpty(mirror.id()), "Mirror ID is empty");
final String scheduleString = mirror.schedule();
Expand All @@ -416,5 +418,10 @@ 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 (XDS_PROJECT_NAME.equals(mirror.projectName())) {
checkArgument("/".equals(mirror.localPath()),
"xDS mirrors must use localPath '/', but got: %s", mirror.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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type CredentialListProps<Data extends object> = {
credentials: CredentialDto[];
deleteCredential: (projectName: string, id: string, repoName?: string) => Promise<void>;
isLoading: boolean;
buildDetailUrl?: (id: string) => string;
};

const CredentialList = <Data extends object>({
Expand All @@ -21,16 +22,19 @@ const CredentialList = <Data extends object>({
credentials,
deleteCredential,
isLoading,
buildDetailUrl,
}: CredentialListProps<Data>) => {
const columnHelper = createColumnHelper<CredentialDto>();
const columns = useMemo(
() => [
columnHelper.accessor((row: CredentialDto) => row.id, {
cell: (info) => {
const id = info.getValue() || 'undefined';
const credentialLink = repoName
? `/app/projects/${projectName}/repos/${repoName}/settings/credentials/${info.row.original.id}`
: `/app/projects/${projectName}/settings/credentials/${info.row.original.id}`;
const credentialLink = buildDetailUrl
? buildDetailUrl(info.row.original.id)
: repoName
? `/app/projects/${projectName}/repos/${repoName}/settings/credentials/${info.row.original.id}`
: `/app/projects/${projectName}/settings/credentials/${info.row.original.id}`;
return (
<ChakraLink href={credentialLink} fontWeight="semibold">
{id}
Expand Down Expand Up @@ -59,7 +63,7 @@ const CredentialList = <Data extends object>({
enableSorting: false,
}),
],
[columnHelper, deleteCredential, isLoading, projectName, repoName],
[buildDetailUrl, columnHelper, deleteCredential, isLoading, projectName, repoName],
);
return <DataTableClientPagination columns={columns as ColumnDef<CredentialDto>[]} data={credentials || []} />;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,19 @@ interface CredentialViewProps {
projectName: string;
repoName?: string;
credential: CredentialDto;
editUrl?: string;
hideScope?: boolean;
}

const AlignedIcon = ({ as }: { as: IconType }) => <Icon as={as} marginBottom="-4px" marginRight={2} />;

const CredentialView = ({ projectName, repoName, credential }: CredentialViewProps) => {
const CredentialView = ({
projectName,
repoName,
credential,
editUrl,
hideScope = false,
}: CredentialViewProps) => {
const dispatch = useAppDispatch();

return (
Expand All @@ -129,21 +137,22 @@ const CredentialView = ({ projectName, repoName, credential }: CredentialViewPro
<TableContainer>
<Table fontSize={'lg'} variant="unstyled">
<Tbody>
{repoName ? (
<Tr>
<HeadRow>
<AlignedIcon as={GoRepo} /> Repository
</HeadRow>
<Td fontWeight="semibold">{repoName}</Td>
</Tr>
) : (
<Tr>
<HeadRow>
<AlignedIcon as={FiBox} /> Project
</HeadRow>
<Td fontWeight="semibold">{projectName}</Td>
</Tr>
)}
{!hideScope &&
(repoName ? (
<Tr>
<HeadRow>
<AlignedIcon as={GoRepo} /> Repository
</HeadRow>
<Td fontWeight="semibold">{repoName}</Td>
</Tr>
) : (
<Tr>
<HeadRow>
<AlignedIcon as={FiBox} /> Project
</HeadRow>
<Td fontWeight="semibold">{projectName}</Td>
</Tr>
))}
<Tr>
<HeadRow>
<AlignedIcon as={HiOutlineIdentification} /> Credential ID
Expand Down Expand Up @@ -231,7 +240,10 @@ const CredentialView = ({ projectName, repoName, credential }: CredentialViewPro

<Center mt={10}>
<Link
href={`/app/projects/${projectName}${repoName ? `/repos/${repoName}` : ''}/settings/credentials/${credential.id}/edit`}
href={
editUrl ??
`/app/projects/${projectName}${repoName ? `/repos/${repoName}` : ''}/settings/credentials/${credential.id}/edit`
}
>
<Button colorScheme="teal">
<EditIcon mr={2} />
Expand Down
Loading
Loading