From 17a81b1846a6c9b56d9dfb86487a375e8a8ee5ee Mon Sep 17 00:00:00 2001 From: minwoox Date: Fri, 13 Mar 2026 21:29:53 +0900 Subject: [PATCH 1/3] Add REST API to fall back an encrypted repository to a file-based repository Motivation: After migrating a repository to an encrypted repository, there was no way to revert it back to the original file-based git repository. Modifications: - Add `FallbackToFileRepositoryCommand` and `CommandType.FALLBACK_TO_FILE_REPOSITORY` - Add `POST /projects/{projectName}/repos/{repoName}/migrate/file` endpoint, which sets fall back an encrypted repository. Result: - System administrators can revert an encrypted repository back to the original file-based git state. --- .../centraldogma/server/command/Command.java | 10 + .../server/command/CommandType.java | 1 + .../FallbackToFileRepositoryCommand.java | 77 ++++++ .../command/StandaloneCommandExecutor.java | 17 ++ .../internal/api/RepositoryServiceV1.java | 73 +++++ .../repository/RepositoryManagerWrapper.java | 6 + .../repository/git/GitRepositoryManager.java | 56 ++++ .../storage/repository/RepositoryManager.java | 5 + .../git/FallbackToFileRepositoryTest.java | 250 ++++++++++++++++++ 9 files changed, 495 insertions(+) create mode 100644 server/src/main/java/com/linecorp/centraldogma/server/command/FallbackToFileRepositoryCommand.java create mode 100644 server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java b/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java index 905ff49e7b..8045c7b012 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java @@ -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"), @@ -294,6 +295,15 @@ static Command 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 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. */ diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/CommandType.java b/server/src/main/java/com/linecorp/centraldogma/server/command/CommandType.java index 9da9cb444c..fef1f5199b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/CommandType.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/CommandType.java @@ -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), diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/FallbackToFileRepositoryCommand.java b/server/src/main/java/com/linecorp/centraldogma/server/command/FallbackToFileRepositoryCommand.java new file mode 100644 index 0000000000..a25a638c4b --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/FallbackToFileRepositoryCommand.java @@ -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 { + + 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); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java b/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java index 68e0308e24..1a8fc9cbbc 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java @@ -189,6 +189,10 @@ private CompletableFuture doExecute0(ExecutionContext ctx, Command com (MigrateToEncryptedRepositoryCommand) command); } + if (command instanceof FallbackToFileRepositoryCommand) { + return (CompletableFuture) fallbackToFileRepository((FallbackToFileRepositoryCommand) command); + } + if (command instanceof NormalizingPushCommand) { return (CompletableFuture) push((NormalizingPushCommand) command, true); } @@ -365,6 +369,19 @@ private CompletableFuture migrateToEncryptedRepository(MigrateToEncryptedR }, repositoryWorker); } + private CompletableFuture 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 push(RepositoryCommand c, boolean normalizing) { if (c instanceof TransformCommand) { final TransformCommand transformCommand = (TransformCommand) c; diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java index 3891d0f121..5a61ae79ae 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java @@ -316,6 +316,79 @@ public CompletableFuture updateStatus(Project project, .thenApply(unused -> newRepositoryDto(repository, newStatus)); } + /** + * POST /projects/{projectName}/repos/{repoName}/migrate/file + * + *

Falls back the repository from an encrypted repository to a file-based repository. + */ + @Post("/projects/{projectName}/repos/{repoName}/migrate/file") + @RequiresSystemAdministrator + public CompletableFuture 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 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 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 * diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/RepositoryManagerWrapper.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/RepositoryManagerWrapper.java index 4a50009354..13528cd372 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/RepositoryManagerWrapper.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/RepositoryManagerWrapper.java @@ -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 failureCauseSupplier) { delegate.close(failureCauseSupplier); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java index 12b248e5d7..dafe3a7df7 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java @@ -176,6 +176,62 @@ 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(); + + // 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(Paths.get(repoDir.getPath(), ENCRYPTED_REPO_PLACEHOLDER_FILE)); + } 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( + "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.")); + encryptionStorageManager().deleteRepositoryData(parent.name(), repositoryName); + } + @Override protected Repository openChild(File childDir) throws Exception { requireNonNull(childDir, "childDir"); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/RepositoryManager.java b/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/RepositoryManager.java index 442267c71a..881782f6a4 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/RepositoryManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/storage/repository/RepositoryManager.java @@ -32,4 +32,9 @@ public interface RepositoryManager extends StorageManager { * 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); } diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java new file mode 100644 index 0000000000..7792195828 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java @@ -0,0 +1,250 @@ +/* + * 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.internal.storage.repository.git; + +import static java.util.concurrent.ForkJoinPool.commonPool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; + +import com.linecorp.centraldogma.common.Author; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.common.Commit; +import com.linecorp.centraldogma.common.Entry; +import com.linecorp.centraldogma.common.Query; +import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.internal.storage.repository.git.rocksdb.RocksDbRepository; +import com.linecorp.centraldogma.server.storage.encryption.EncryptionStorageManager; +import com.linecorp.centraldogma.server.storage.encryption.WrappedDekDetails; +import com.linecorp.centraldogma.server.storage.project.Project; +import com.linecorp.centraldogma.server.storage.repository.Repository; + +class FallbackToFileRepositoryTest { + + private static final String PROJECT_NAME = "testProject"; + private static final String REPO_NAME = "testRepo"; + + @TempDir + private File rootDir; + + private EncryptionStorageManager encryptionStorageManager; + private GitRepositoryManager gitRepositoryManager; + private Project project; + + @BeforeEach + void setUp() { + project = mock(Project.class); + lenient().when(project.name()).thenReturn(PROJECT_NAME); + + encryptionStorageManager = EncryptionStorageManager.of( + new File(rootDir, "rocksdb").toPath(), false, "kekId"); + gitRepositoryManager = new GitRepositoryManager( + project, new File(rootDir, PROJECT_NAME), commonPool(), + MoreExecutors.directExecutor(), null, encryptionStorageManager); + } + + @AfterEach + void tearDown() { + if (encryptionStorageManager != null) { + encryptionStorageManager.close(); + } + } + + @Test + void fallbackRestoredContent() throws Exception { + // Create a file-based repository and add some commits before migration. + final Repository fileRepo = gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + assertThat(fileRepo.isEncrypted()).isFalse(); + + fileRepo.commit(Revision.INIT, 0, Author.SYSTEM, "Add foo", + ImmutableList.of(Change.ofJsonUpsert("/foo.json", "{\"a\":\"b\"}"))).join(); + fileRepo.commit(new Revision(2), 0, Author.SYSTEM, "Add bar", + ImmutableList.of(Change.ofTextUpsert("/bar.txt", "hello"))).join(); + + final Revision headBeforeMigration = fileRepo.normalizeNow(Revision.HEAD); + assertThat(headBeforeMigration.major()).isEqualTo(3); + + // Migrate to encrypted. + storeWdekAndMigrate(REPO_NAME); + assertThat(gitRepositoryManager.get(REPO_NAME).isEncrypted()).isTrue(); + + // Fall back to file-based. The original git files are still in repoDir + // (migrateToEncryptedRepository preserves them), so fallback simply deletes + // the placeholder and reopens the pre-migration file-based repository. + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + + final Repository restoredRepo = gitRepositoryManager.get(REPO_NAME); + assertThat(restoredRepo.isEncrypted()).isFalse(); + assertThat(restoredRepo.jGitRepository()).isNotInstanceOf(RocksDbRepository.class); + + // The restored repo is at the pre-migration state. + assertThat(restoredRepo.normalizeNow(Revision.HEAD)).isEqualTo(headBeforeMigration); + + final Entry fooEntry = restoredRepo.get(Revision.HEAD, Query.ofJson("/foo.json")).join(); + final JsonNode fooJson = Jackson.readTree(fooEntry.contentAsText()); + assertThat(fooJson.get("a").asText()).isEqualTo("b"); + + final Entry barEntry = restoredRepo.get(Revision.HEAD, Query.ofText("/bar.txt")).join(); + assertThat(barEntry.contentAsText().trim()).isEqualTo("hello"); + } + + @Test + void fallbackEncryptionDataCleanedUp() { + gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + storeWdekAndMigrate(REPO_NAME); + + // Encryption data must exist before fallback. + final Map> dataBeforeFallback = encryptionStorageManager.getAllData(); + assertThat(dataBeforeFallback.get("wdek")).isNotEmpty(); + assertThat(dataBeforeFallback.get("encrypted_object")).isNotEmpty(); + + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + + // All encryption data for this repo is deleted after fallback. + final Map> dataAfterFallback = encryptionStorageManager.getAllData(); + assertThat(dataAfterFallback.get("wdek")).isEmpty(); + assertThat(dataAfterFallback.get("encryption_metadata")).isEmpty(); + assertThat(dataAfterFallback.get("encrypted_object_id")).isEmpty(); + assertThat(dataAfterFallback.get("encrypted_object")).isEmpty(); + } + + @Test + void fallbackPlaceholderFileIsRemoved() { + final Repository fileRepo = gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + final File repoDir = fileRepo.repoDir(); + + storeWdekAndMigrate(REPO_NAME); + assertThat(Files.exists(Paths.get(repoDir.getPath(), ".encryption-repo-placeholder"))).isTrue(); + + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + assertThat(Files.exists(Paths.get(repoDir.getPath(), ".encryption-repo-placeholder"))).isFalse(); + } + + @Test + void fallbackRepoIsUsableAfterFallback() { + // Migrate and fall back; then verify new commits can be pushed to the restored repo. + gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + storeWdekAndMigrate(REPO_NAME); + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + + final Repository repo = gitRepositoryManager.get(REPO_NAME); + assertThat(repo.isEncrypted()).isFalse(); + + final Revision headBefore = repo.normalizeNow(Revision.HEAD); + repo.commit(headBefore, 0, Author.SYSTEM, "Post-fallback commit", + ImmutableList.of(Change.ofTextUpsert("/new.txt", "world"))).join(); + + assertThat(repo.normalizeNow(Revision.HEAD).major()).isEqualTo(headBefore.major() + 1); + assertThat(repo.get(Revision.HEAD, Query.ofText("/new.txt")).join() + .contentAsText().trim()).isEqualTo("world"); + } + + @Test + void fallbackPreservesPreMigrationCommitHistory() { + // Commits added before migration are visible in the restored file-based repo. + final Repository fileRepo = gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + + fileRepo.commit(Revision.INIT, 0, Author.SYSTEM, "First commit", + ImmutableList.of(Change.ofTextUpsert("/first.txt", "first"))).join(); + fileRepo.commit(new Revision(2), 0, Author.SYSTEM, "Second commit", + ImmutableList.of(Change.ofTextUpsert("/second.txt", "second"))).join(); + fileRepo.commit(new Revision(3), 0, Author.SYSTEM, "Third commit", + ImmutableList.of(Change.ofTextUpsert("/third.txt", "third"))).join(); + + storeWdekAndMigrate(REPO_NAME); + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + + final Repository restoredRepo = gitRepositoryManager.get(REPO_NAME); + assertThat(restoredRepo.normalizeNow(Revision.HEAD).major()).isEqualTo(4); + + // history() returns commits in ascending order (oldest first). + final List history = restoredRepo.history(new Revision(2), Revision.HEAD, "/**").join(); + assertThat(history).hasSize(3); + assertThat(history.get(0).summary()).isEqualTo("First commit"); + assertThat(history.get(1).summary()).isEqualTo("Second commit"); + assertThat(history.get(2).summary()).isEqualTo("Third commit"); + } + + @Test + void fallbackRepoReloadsCorrectlyAfterManagerRestart() { + // After fallback, a manager restart should load the repo as file-based (no placeholder). + gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + storeWdekAndMigrate(REPO_NAME); + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + gitRepositoryManager.close(() -> null); + + final GitRepositoryManager reloadedManager = new GitRepositoryManager( + project, new File(rootDir, PROJECT_NAME), commonPool(), + MoreExecutors.directExecutor(), null, encryptionStorageManager); + + final Repository reloadedRepo = reloadedManager.get(REPO_NAME); + assertThat(reloadedRepo.isEncrypted()).isFalse(); + assertThat(reloadedRepo.jGitRepository()).isNotInstanceOf(RocksDbRepository.class); + } + + @Test + void fallbackPreservesOtherRepoEncryptionData() { + // Falling back one repo must not affect another encrypted repo. + final String otherRepo = "otherRepo"; + + gitRepositoryManager.create(REPO_NAME, 0, Author.SYSTEM, false); + storeWdekAndMigrate(REPO_NAME); + + final String wdek2 = encryptionStorageManager.generateWdek().join(); + encryptionStorageManager.storeWdek( + new WrappedDekDetails(wdek2, 1, encryptionStorageManager.kekId(), + PROJECT_NAME, otherRepo)); + gitRepositoryManager.create(otherRepo, 0, Author.SYSTEM, true); + + assertThat(gitRepositoryManager.get(REPO_NAME).isEncrypted()).isTrue(); + assertThat(gitRepositoryManager.get(otherRepo).isEncrypted()).isTrue(); + + gitRepositoryManager.fallbackToFileRepository(REPO_NAME); + + assertThat(gitRepositoryManager.get(REPO_NAME).isEncrypted()).isFalse(); + assertThat(gitRepositoryManager.get(otherRepo).isEncrypted()).isTrue(); + + // Encryption data for otherRepo must still be present. + final Map> allData = encryptionStorageManager.getAllData(); + assertThat(allData.get("wdek")).isNotEmpty(); + assertThat(allData.get("encrypted_object")).isNotEmpty(); + } + + private void storeWdekAndMigrate(String repoName) { + final String wdek = encryptionStorageManager.generateWdek().join(); + encryptionStorageManager.storeWdek( + new WrappedDekDetails(wdek, 1, encryptionStorageManager.kekId(), + PROJECT_NAME, repoName)); + gitRepositoryManager.migrateToEncryptedRepository(repoName); + } +} From 6522453ed0082fd2549323691ff43946b5c6cc85 Mon Sep 17 00:00:00 2001 From: minwoox Date: Mon, 16 Mar 2026 14:55:13 +0900 Subject: [PATCH 2/3] Address comments from AI --- .../storage/repository/git/GitRepositoryManager.java | 7 ++++++- .../repository/git/FallbackToFileRepositoryTest.java | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java index dafe3a7df7..496b738ec0 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java @@ -229,7 +229,12 @@ public void fallbackToFileRepository(String repositoryName) { ((GitRepository) encryptedRepository).close(() -> new CentralDogmaException( projectRepositoryName(repositoryName) + " is fallback to a file-based repository. Try again.")); - encryptionStorageManager().deleteRepositoryData(parent.name(), repositoryName); + 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 diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java index 7792195828..a0ee81973b 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/storage/repository/git/FallbackToFileRepositoryTest.java @@ -42,6 +42,7 @@ import com.linecorp.centraldogma.common.Entry; import com.linecorp.centraldogma.common.Query; import com.linecorp.centraldogma.common.Revision; +import com.linecorp.centraldogma.common.ShuttingDownException; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.server.internal.storage.repository.git.rocksdb.RocksDbRepository; import com.linecorp.centraldogma.server.storage.encryption.EncryptionStorageManager; @@ -75,6 +76,10 @@ project, new File(rootDir, PROJECT_NAME), commonPool(), @AfterEach void tearDown() { + if (gitRepositoryManager != null) { + gitRepositoryManager.close(ShuttingDownException::new); + } + if (encryptionStorageManager != null) { encryptionStorageManager.close(); } @@ -210,6 +215,7 @@ project, new File(rootDir, PROJECT_NAME), commonPool(), final Repository reloadedRepo = reloadedManager.get(REPO_NAME); assertThat(reloadedRepo.isEncrypted()).isFalse(); assertThat(reloadedRepo.jGitRepository()).isNotInstanceOf(RocksDbRepository.class); + reloadedManager.close(ShuttingDownException::new); } @Test From 8a70567d7febf495d81ccbfea21387cd0910b232 Mon Sep 17 00:00:00 2001 From: minwoox Date: Mon, 16 Mar 2026 15:47:28 +0900 Subject: [PATCH 3/3] AI comment --- .../storage/repository/git/GitRepositoryManager.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java index 496b738ec0..88c536a34f 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/GitRepositoryManager.java @@ -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; @@ -184,11 +185,17 @@ public void fallbackToFileRepository(String repositoryName) { 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(Paths.get(repoDir.getPath(), ENCRYPTED_REPO_PLACEHOLDER_FILE)); + Files.delete(placeholderPath); } catch (IOException e) { throw new StorageException("failed to delete the encrypted repository placeholder file at: " + repoDir, e);