Skip to content
Open
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 @@ -54,6 +54,7 @@
@Type(value = PurgeRepositoryCommand.class, name = "PURGE_REPOSITORY"),
@Type(value = UnremoveRepositoryCommand.class, name = "UNREMOVE_REPOSITORY"),
@Type(value = MigrateToEncryptedRepositoryCommand.class, name = "MIGRATE_TO_ENCRYPTED_REPOSITORY"),
@Type(value = FallbackToFileRepositoryCommand.class, name = "FALLBACK_TO_FILE_REPOSITORY"),
@Type(value = NormalizingPushCommand.class, name = "NORMALIZING_PUSH"),
@Type(value = PushAsIsCommand.class, name = "PUSH"),
@Type(value = RewrapAllKeysCommand.class, name = "REWRAP_ALL_KEYS"),
Expand Down Expand Up @@ -294,6 +295,15 @@ static Command<Void> purgeRepository(@Nullable Long timestamp, Author author,
return new PurgeRepositoryCommand(timestamp, author, projectName, repositoryName);
}

/**
* Returns a new {@link Command} which is used to fall back a repository to a file-based repository.
*/
static Command<Void> fallbackToFileRepository(@Nullable Long timestamp, Author author,
String projectName, String repositoryName) {
requireNonNull(author, "author");
return new FallbackToFileRepositoryCommand(timestamp, author, projectName, repositoryName);
}

/**
* Returns a new {@link Command} which is used to migrate a repository to an encrypted repository.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public enum CommandType {
REMOVE_REPOSITORY(Void.class),
UNREMOVE_REPOSITORY(Void.class),
MIGRATE_TO_ENCRYPTED_REPOSITORY(Void.class),
FALLBACK_TO_FILE_REPOSITORY(Void.class),
NORMALIZING_PUSH(CommitResult.class),
TRANSFORM(CommitResult.class),
PUSH(Revision.class),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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.command;

import static java.util.Objects.requireNonNull;

import org.jspecify.annotations.Nullable;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.MoreObjects.ToStringHelper;

import com.linecorp.centraldogma.common.Author;

/**
* A {@link Command} which is used for falling back a repository from an encrypted repository
* to a file-based repository.
*/
public final class FallbackToFileRepositoryCommand extends ProjectCommand<Void> {

private final String repositoryName;

@JsonCreator
FallbackToFileRepositoryCommand(@JsonProperty("timestamp") @Nullable Long timestamp,
@JsonProperty("author") @Nullable Author author,
@JsonProperty("projectName") String projectName,
@JsonProperty("repositoryName") String repositoryName) {
super(CommandType.FALLBACK_TO_FILE_REPOSITORY, timestamp, author, projectName);
this.repositoryName = requireNonNull(repositoryName, "repositoryName");
}

/**
* Returns the repository name.
*/
@JsonProperty
public String repositoryName() {
return repositoryName;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}

if (!(obj instanceof FallbackToFileRepositoryCommand)) {
return false;
}

final FallbackToFileRepositoryCommand that = (FallbackToFileRepositoryCommand) obj;
return super.equals(obj) && repositoryName.equals(that.repositoryName);
}

@Override
public int hashCode() {
return repositoryName.hashCode() * 31 + super.hashCode();
}

@Override
ToStringHelper toStringHelper() {
return super.toStringHelper().add("repositoryName", repositoryName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ private <T> CompletableFuture<T> doExecute0(ExecutionContext ctx, Command<T> com
(MigrateToEncryptedRepositoryCommand) command);
}

if (command instanceof FallbackToFileRepositoryCommand) {
return (CompletableFuture<T>) fallbackToFileRepository((FallbackToFileRepositoryCommand) command);
}

if (command instanceof NormalizingPushCommand) {
return (CompletableFuture<T>) push((NormalizingPushCommand) command, true);
}
Expand Down Expand Up @@ -365,6 +369,19 @@ private CompletableFuture<Void> migrateToEncryptedRepository(MigrateToEncryptedR
}, repositoryWorker);
}

private CompletableFuture<Void> fallbackToFileRepository(FallbackToFileRepositoryCommand c) {
final RepositoryManager repositoryManager = projectManager.get(c.projectName()).repos();
final Repository repository = repositoryManager.get(c.repositoryName());
if (!repository.isEncrypted()) {
throw new IllegalStateException(
"The repository is not encrypted: " + c.projectName() + '/' + c.repositoryName());
}
return CompletableFuture.supplyAsync(() -> {
repositoryManager.fallbackToFileRepository(c.repositoryName());
return null;
}, repositoryWorker);
}

private CompletableFuture<CommitResult> push(RepositoryCommand<?> c, boolean normalizing) {
if (c instanceof TransformCommand) {
final TransformCommand transformCommand = (TransformCommand) c;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,79 @@ public CompletableFuture<RepositoryDto> updateStatus(Project project,
.thenApply(unused -> newRepositoryDto(repository, newStatus));
}

/**
* POST /projects/{projectName}/repos/{repoName}/migrate/file
*
* <p>Falls back the repository from an encrypted repository to a file-based repository.
*/
@Post("/projects/{projectName}/repos/{repoName}/migrate/file")
@RequiresSystemAdministrator
public CompletableFuture<RepositoryDto> fallbackToFileRepository(ServiceRequestContext ctx,
Project project,
Repository repository,
Author author) {
validateFallbackPrerequisites(ctx, project, repository);
ctx.setRequestTimeoutMillis(Long.MAX_VALUE); // Disable the request timeout for migration.

return setRepositoryStatus(author, project, repository, RepositoryStatus.READ_ONLY)
.thenCompose(unused -> fallback(author, project, repository));
}

private static void validateFallbackPrerequisites(ServiceRequestContext ctx, Project project,
Repository repository) {
if (InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(project.name()) ||
project.name().startsWith("@") || Project.REPO_DOGMA.equals(repository.name())) {
throw new IllegalArgumentException(
"Cannot fallback the internal project or repository to a file-based repository. project: " +
project.name() + ", repository: " + repository.name());
}

if (!repository.isEncrypted()) {
throw new IllegalArgumentException(
"The repository is not encrypted. Cannot fallback to a file-based repository." +
" project: " + project.name() + ", repository: " + repository.name());
}

final RepositoryStatus currentStatus = repositoryStatus(repository);
if (currentStatus == RepositoryStatus.READ_ONLY) {
HttpApiUtil.throwResponse(
ctx, HttpStatus.CONFLICT,
"Cannot fallback a read-only repository to a file-based repository. " +
"Please change the status to ACTIVE first.");
}
}

private CompletionStage<RepositoryDto> fallback(Author author, Project project,
Repository repository) {
final String projectName = project.name();
final String repoName = repository.name();
logger.info("Starting repository fallback to file-based: project={}, repository={}",
projectName, repoName);

final Command<Void> command = Command.fallbackToFileRepository(
null, author, projectName, repoName);

return executor().execute(command)
.handle((unused, cause) -> {
if (cause != null) {
logger.warn("failed to fallback repository to a file-based repository: " +
"project={}, repository={}", projectName, repoName, cause);
return setRepositoryStatus(author, project, repository,
RepositoryStatus.ACTIVE)
.thenApply(unused1 -> (RepositoryDto) Exceptions.throwUnsafely(cause));
}
logger.info("Successfully fallback repository to a file-based repository: " +
"project={}, repository={}", projectName, repoName);
return setRepositoryStatus(author, project, repository, RepositoryStatus.ACTIVE)
.thenApply(unused1 -> {
final Repository updatedRepository =
project.repos().get(repository.name());
return newRepositoryDto(updatedRepository,
RepositoryStatus.ACTIVE);
});
}).thenCompose(Function.identity());
}

/**
* POST /projects/{projectName}/repos/{repoName}/migrate/encrypted
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ public void migrateToEncryptedRepository(String repositoryName) {
repos.replace(repositoryName, repoWrapper.apply(delegate.get(repositoryName)));
}

@Override
public void fallbackToFileRepository(String repositoryName) {
delegate.fallbackToFileRepository(repositoryName);
repos.replace(repositoryName, repoWrapper.apply(delegate.get(repositoryName)));
}

@Override
public void close(Supplier<CentralDogmaException> failureCauseSupplier) {
delegate.close(failureCauseSupplier);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -176,6 +177,73 @@ public void migrateToEncryptedRepository(String repositoryName) {
projectRepositoryName(repositoryName) + " is migrated to an encrypted repository. Try again."));
}

@Override
public void fallbackToFileRepository(String repositoryName) {
logger.info("Starting to fallback the repository '{}' to a file-based repository.",
projectRepositoryName(repositoryName));
final long startTime = System.nanoTime();
final Repository encryptedRepository = get(repositoryName);
final File repoDir = encryptedRepository.repoDir();

final Path placeholderPath = Paths.get(repoDir.getPath(), ENCRYPTED_REPO_PLACEHOLDER_FILE);
if (!encryptedRepository.isEncrypted() || !Files.exists(placeholderPath) || !exist(repoDir)) {
throw new StorageException("repository has no preserved file-based repository to fall back to: " +
projectRepositoryName(repositoryName));
}

// Delete the placeholder file so that the original file-based git data is recognized again.
// migrateToEncryptedRepository() preserves the original git files in repoDir,
// so we can simply reopen the existing file-based repository.
try {
Files.delete(placeholderPath);
} catch (IOException e) {
throw new StorageException("failed to delete the encrypted repository placeholder file at: " +
repoDir, e);
}

// Reopen the existing file-based git repository from repoDir.
final GitRepository fileRepository;
try {
fileRepository = openFileRepository(parent, repoDir, repositoryWorker, cache);
} catch (Throwable t) {
// Restore the placeholder file so the manager stays in a consistent state.
try {
Files.createFile(Paths.get(repoDir.getPath(), ENCRYPTED_REPO_PLACEHOLDER_FILE));
} catch (IOException ex) {
logger.warn("Failed to restore the encrypted repository placeholder file at: {}",
repoDir, ex);
}
throw new StorageException("failed to reopen the file-based repository after fallback. " +
"repositoryName: " + projectRepositoryName(repositoryName), t);
}

if (!replaceChild(repositoryName, encryptedRepository, fileRepository)) {
fileRepository.internalClose();
try {
Files.createFile(Paths.get(repoDir.getPath(), ENCRYPTED_REPO_PLACEHOLDER_FILE));
} catch (IOException ex) {
logger.warn("Failed to restore the encrypted repository placeholder file at: {}",
repoDir, ex);
}
throw new StorageException(
Copy link
Contributor

Choose a reason for hiding this comment

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

Note) I wasn't able to find where these exceptions would be logged - no problem as long as they are logged somewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"failed to replace the encrypted repository with the file-based repository. " +
"repositoryName: " + projectRepositoryName(repositoryName));
}

logger.info("Fallback the repository '{}' to a file-based repository in {} seconds.",
projectRepositoryName(repositoryName),
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime));
((GitRepository) encryptedRepository).close(() -> new CentralDogmaException(
projectRepositoryName(repositoryName) +
" is fallback to a file-based repository. Try again."));
try {
encryptionStorageManager().deleteRepositoryData(parent.name(), repositoryName);
} catch (Throwable t) {
logger.warn("Failed to delete the encrypted repository data for the repository '{}' " +
"after fallback. ", projectRepositoryName(repositoryName), t);
}
}

@Override
protected Repository openChild(File childDir) throws Exception {
requireNonNull(childDir, "childDir");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,9 @@ public interface RepositoryManager extends StorageManager<Repository> {
* Migrates the specified repository to an encrypted repository.
*/
void migrateToEncryptedRepository(String repositoryName);

/**
* Falls back the specified encrypted repository to a file-based repository.
*/
void fallbackToFileRepository(String repositoryName);
}
Loading
Loading