diff --git a/common/src/main/java/com/linecorp/centraldogma/common/RedundantChangeException.java b/common/src/main/java/com/linecorp/centraldogma/common/RedundantChangeException.java index 0c8d4ed0c..a71f91a17 100644 --- a/common/src/main/java/com/linecorp/centraldogma/common/RedundantChangeException.java +++ b/common/src/main/java/com/linecorp/centraldogma/common/RedundantChangeException.java @@ -16,37 +16,33 @@ package com.linecorp.centraldogma.common; +import static java.util.Objects.requireNonNull; + +import javax.annotation.Nullable; + /** * A {@link CentralDogmaException} that is raised when attempted to push a commit without effective changes. */ public class RedundantChangeException extends CentralDogmaException { private static final long serialVersionUID = 8739464985038079688L; - - /** - * Creates a new instance. - */ - public RedundantChangeException() {} + @Nullable + private final Revision headRevision; /** * Creates a new instance. */ public RedundantChangeException(String message) { super(message); + headRevision = null; } /** * Creates a new instance. */ - public RedundantChangeException(Throwable cause) { - super(cause); - } - - /** - * Creates a new instance. - */ - public RedundantChangeException(String message, Throwable cause) { - super(message, cause); + public RedundantChangeException(Revision headRevision, String message) { + super(message); + this.headRevision = requireNonNull(headRevision, "headRevision"); } /** @@ -57,13 +53,14 @@ public RedundantChangeException(String message, Throwable cause) { */ public RedundantChangeException(String message, boolean writableStackTrace) { super(message, writableStackTrace); + headRevision = null; } /** - * Creates a new instance. + * Returns the head revision of the repository when this exception was raised. */ - protected RedundantChangeException(String message, Throwable cause, boolean enableSuppression, - boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); + @Nullable + public Revision headRevision() { + return headRevision; } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/AbstractChangesApplier.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/AbstractChangesApplier.java index 3860315da..f404e9b72 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/AbstractChangesApplier.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/AbstractChangesApplier.java @@ -38,6 +38,7 @@ import com.linecorp.centraldogma.common.CentralDogmaException; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.internal.admin.service.TokenNotFoundException; import com.linecorp.centraldogma.server.storage.StorageException; abstract class AbstractChangesApplier { @@ -59,7 +60,7 @@ int apply(Repository jGitRepository, @Nullable Revision baseRevision, } return doApply(dirCache, reader, inserter); - } catch (CentralDogmaException | IllegalArgumentException e) { + } catch (CentralDogmaException | TokenNotFoundException | IllegalArgumentException e) { throw e; } catch (Exception e) { throw new StorageException("failed to apply changes on revision: " + diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/CommitExecutor.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/CommitExecutor.java index bfd2ae892..df73572cf 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/CommitExecutor.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/CommitExecutor.java @@ -156,10 +156,12 @@ RevisionAndEntries commit(@Nullable Revision prevRevision, Revision nextRevision } if (!allowEmptyCommit && isEmpty) { + // prevRevision is not null when allowEmptyCommit is false. + assert prevRevision != null; throw new RedundantChangeException( + prevRevision, "changes did not change anything in " + gitRepository.parent().name() + '/' + - gitRepository.name() + " at revision " + - (prevRevision != null ? prevRevision.major() : 0) + ": " + changes); + gitRepository.name() + " at revision " + prevRevision.major() + ": " + changes); } // flush the current index to repository and get the result tree object id. diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/TransformingChangesApplier.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/TransformingChangesApplier.java index 663f41c36..adbd4e8b0 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/TransformingChangesApplier.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/repository/git/TransformingChangesApplier.java @@ -30,10 +30,12 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.google.common.base.MoreObjects; +import com.linecorp.centraldogma.common.CentralDogmaException; import com.linecorp.centraldogma.common.ChangeConflictException; import com.linecorp.centraldogma.common.EntryType; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.server.command.ContentTransformer; +import com.linecorp.centraldogma.server.internal.admin.service.TokenNotFoundException; final class TransformingChangesApplier extends AbstractChangesApplier { @@ -61,6 +63,8 @@ int doApply(DirCache dirCache, ObjectReader reader, ObjectInserter inserter) thr applyPathEdit(dirCache, new InsertJson(changePath, inserter, newJsonNode)); return 1; } + } catch (CentralDogmaException | TokenNotFoundException | IllegalArgumentException e) { + throw e; } catch (Exception e) { throw new ChangeConflictException("failed to transform the content: " + oldJsonNode + " transformer: " + transformer, e); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java index b37fc4e21..c6827c1b1 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java @@ -17,6 +17,7 @@ package com.linecorp.centraldogma.server.metadata; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.linecorp.centraldogma.internal.jsonpatch.JsonPatchOperation.asJsonArray; import static com.linecorp.centraldogma.internal.jsonpatch.JsonPatchUtil.encodeSegment; import static com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager.listProjectsWithoutInternal; @@ -28,6 +29,7 @@ import java.util.Collection; import java.util.Map; +import java.util.Map.Entry; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -36,28 +38,31 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonPointer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.spotify.futures.CompletableFutures; import com.linecorp.armeria.common.util.Exceptions; import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.common.Change; import com.linecorp.centraldogma.common.ChangeConflictException; +import com.linecorp.centraldogma.common.EntryNotFoundException; +import com.linecorp.centraldogma.common.EntryType; import com.linecorp.centraldogma.common.ProjectRole; import com.linecorp.centraldogma.common.RepositoryExistsException; import com.linecorp.centraldogma.common.Revision; import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.internal.jsonpatch.AddOperation; -import com.linecorp.centraldogma.internal.jsonpatch.JsonPatchOperation; -import com.linecorp.centraldogma.internal.jsonpatch.RemoveIfExistsOperation; import com.linecorp.centraldogma.internal.jsonpatch.RemoveOperation; import com.linecorp.centraldogma.internal.jsonpatch.ReplaceOperation; import com.linecorp.centraldogma.internal.jsonpatch.TestAbsenceOperation; import com.linecorp.centraldogma.server.QuotaConfig; import com.linecorp.centraldogma.server.command.CommandExecutor; -import com.linecorp.centraldogma.server.internal.admin.service.TokenNotFoundException; +import com.linecorp.centraldogma.server.command.ContentTransformer; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.project.ProjectManager; @@ -238,34 +243,80 @@ public CompletableFuture addMember(Author author, String projectName, } /** - * Removes the specified {@code member} from the {@link ProjectMetadata} in the specified - * {@code projectName}. It also removes permission of the specified {@code member} from every + * Removes the specified {@code user} from the {@link ProjectMetadata} in the specified + * {@code projectName}. It also removes permission of the specified {@code user} from every * {@link RepositoryMetadata}. */ - public CompletableFuture removeMember(Author author, String projectName, User member) { + public CompletableFuture removeMember(Author author, String projectName, User user) { requireNonNull(author, "author"); requireNonNull(projectName, "projectName"); - requireNonNull(member, "member"); + requireNonNull(user, "user"); + final String memberId = user.id(); final String commitSummary = - "Remove the member '" + member.id() + "' from the project '" + projectName + '\''; - return metadataRepo.push( - projectName, Project.REPO_DOGMA, author, commitSummary, - () -> fetchMetadata(projectName).thenApply( - metadataWithRevision -> { - final ImmutableList.Builder patches = ImmutableList.builder(); - metadataWithRevision - .object().repos().values() - .stream().filter(r -> r.perUserPermissions().containsKey(member.id())) - .forEach(r -> patches.add(new RemoveOperation( - perUserPermissionPointer(r.name(), member.id())))); - patches.add(new RemoveOperation(JsonPointer.compile("/members" + - encodeSegment(member.id())))); - final Change change = - Change.ofJsonPatch(METADATA_JSON, Jackson.valueToTree(patches.build())); - return HolderWithRevision.of(change, metadataWithRevision.revision()); - }) - ); + "Remove the member '" + memberId + "' from the project '" + projectName + '\''; + + final ContentTransformer transformer = new ContentTransformer<>( + METADATA_JSON, EntryType.JSON, node -> { + final ProjectMetadata projectMetadata = projectMetadata(node); + projectMetadata.member(memberId); // Raises an exception if the member does not exist. + final Map members = projectMetadata.members(); + final ImmutableMap.Builder membersBuilder = + ImmutableMap.builderWithExpectedSize(members.size() - 1); + for (Entry entry : members.entrySet()) { + if (!entry.getKey().equals(memberId)) { + membersBuilder.put(entry); + } + } + final Map newMembers = membersBuilder.build(); + + final ImmutableMap newRepos = + removeMemberFromRepositories(projectMetadata, memberId); + return Jackson.valueToTree(new ProjectMetadata(projectMetadata.name(), + newRepos, + newMembers, + projectMetadata.tokens(), + projectMetadata.creation(), + projectMetadata.removal())); + }); + return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); + } + + private static ProjectMetadata projectMetadata(JsonNode node) { + try { + return Jackson.treeToValue(node, ProjectMetadata.class); + } catch (JsonParseException | JsonMappingException e) { + // Should never reach here. + throw new Error(); + } + } + + private static ImmutableMap removeMemberFromRepositories( + ProjectMetadata projectMetadata, String memberId) { + final ImmutableMap.Builder reposBuilder = + ImmutableMap.builderWithExpectedSize(projectMetadata.repos().size()); + for (Entry entry : projectMetadata.repos().entrySet()) { + final RepositoryMetadata repositoryMetadata = entry.getValue(); + final Map> perUserPermissions = + repositoryMetadata.perUserPermissions(); + if (perUserPermissions.get(memberId) != null) { + final Map> newPerUserPermission = + perUserPermissions.entrySet().stream() + .filter(e -> !e.getKey().equals(memberId)) + .collect(toImmutableMap(Entry::getKey, Entry::getValue)); + reposBuilder.put(entry.getKey(), + new RepositoryMetadata(repositoryMetadata.name(), + repositoryMetadata.perRolePermissions(), + newPerUserPermission, + repositoryMetadata.perTokenPermissions(), + repositoryMetadata.creation(), + repositoryMetadata.removal(), + repositoryMetadata.writeQuota())); + } else { + reposBuilder.put(entry); + } + } + return reposBuilder.build(); } /** @@ -490,27 +541,66 @@ public CompletableFuture removeToken(Author author, String projectName private CompletableFuture removeToken(String projectName, Author author, String appId, boolean quiet) { final String commitSummary = "Remove the token '" + appId + "' from the project '" + projectName + '\''; - return metadataRepo.push( - projectName, Project.REPO_DOGMA, author, commitSummary, - () -> fetchMetadata(projectName).thenApply(metadataWithRevision -> { - final ImmutableList.Builder patches = ImmutableList.builder(); - final ProjectMetadata metadata = metadataWithRevision.object(); - metadata.repos().values() - .stream().filter(repo -> repo.perTokenPermissions().containsKey(appId)) - .forEach(r -> patches.add( - new RemoveOperation(perTokenPermissionPointer(r.name(), appId)))); - if (quiet) { - patches.add(new RemoveIfExistsOperation(JsonPointer.compile("/tokens" + - encodeSegment(appId)))); - } else { - patches.add(new RemoveOperation(JsonPointer.compile("/tokens" + - encodeSegment(appId)))); + + final ContentTransformer transformer = new ContentTransformer<>( + METADATA_JSON, EntryType.JSON, node -> { + final ProjectMetadata projectMetadata = projectMetadata(node); + final Map tokens = projectMetadata.tokens(); + final Map newTokens; + if (tokens.get(appId) == null) { + if (!quiet) { + throw new EntryNotFoundException( + "failed to find the token " + appId + " in project " + projectName); + } + newTokens = tokens; + } else { + final ImmutableMap.Builder tokensBuilder = + ImmutableMap.builderWithExpectedSize(tokens.size() - 1); + for (Entry entry : tokens.entrySet()) { + if (!entry.getKey().equals(appId)) { + tokensBuilder.put(entry); } - final Change change = - Change.ofJsonPatch(METADATA_JSON, Jackson.valueToTree(patches.build())); - return HolderWithRevision.of(change, metadataWithRevision.revision()); - }) - ); + } + newTokens = tokensBuilder.build(); + } + + final ImmutableMap newRepos = + removeTokenFromRepositories(appId, projectMetadata); + return Jackson.valueToTree(new ProjectMetadata(projectMetadata.name(), + newRepos, + projectMetadata.members(), + newTokens, + projectMetadata.creation(), + projectMetadata.removal())); + }); + return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); + } + + private static ImmutableMap removeTokenFromRepositories( + String appId, ProjectMetadata projectMetadata) { + final ImmutableMap.Builder builder = + ImmutableMap.builderWithExpectedSize(projectMetadata.repos().size()); + for (Entry entry : projectMetadata.repos().entrySet()) { + final RepositoryMetadata repositoryMetadata = entry.getValue(); + final Map> perTokenPermissions = + repositoryMetadata.perTokenPermissions(); + if (perTokenPermissions.get(appId) != null) { + final Map> newPerTokenPermissions = + perTokenPermissions.entrySet().stream() + .filter(e -> !e.getKey().equals(appId)) + .collect(toImmutableMap(Entry::getKey, Entry::getValue)); + builder.put(entry.getKey(), new RepositoryMetadata(repositoryMetadata.name(), + repositoryMetadata.perRolePermissions(), + repositoryMetadata.perUserPermissions(), + newPerTokenPermissions, + repositoryMetadata.creation(), + repositoryMetadata.removal(), + repositoryMetadata.writeQuota())); + } else { + builder.put(entry); + } + } + return builder.build(); } /** @@ -878,23 +968,33 @@ public CompletableFuture destroyToken(Author author, String appId) { requireNonNull(author, "author"); requireNonNull(appId, "appId"); - return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, - "Delete the token: " + appId, - () -> tokenRepo - .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON) - .thenApply(tokens -> { - final JsonPointer deletionPath = - JsonPointer.compile("/appIds" + encodeSegment(appId) + - "/deletion"); - final Change change = Change.ofJsonPatch( - TOKEN_JSON, - asJsonArray(new TestAbsenceOperation(deletionPath), - new AddOperation(deletionPath, - Jackson.valueToTree( - UserAndTimestamp.of( - author))))); - return HolderWithRevision.of(change, tokens.revision()); - })); + final String commitSummary = "Delete the token: " + appId; + final UserAndTimestamp userAndTimestamp = UserAndTimestamp.of(author); + + final ContentTransformer transformer = new ContentTransformer<>( + TOKEN_JSON, EntryType.JSON, node -> { + final Tokens tokens = tokens(node); + final Token token = tokens.get(appId); // Raise an exception if not found. + if (token.deletion() != null) { + throw new IllegalArgumentException("The token is already deleted: " + appId); + } + + final ImmutableMap.Builder appIdsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.appIds().entrySet()) { + if (!entry.getKey().equals(appId)) { + appIdsBuilder.put(entry); + } else { + final String secret = token.secret(); + assert secret != null; + appIdsBuilder.put(appId, new Token(token.appId(), secret, token.isAdmin(), + token.isAdmin(), token.creation(), token.deactivation(), + userAndTimestamp)); + } + } + final Map newAppIds = appIdsBuilder.build(); + return Jackson.valueToTree(new Tokens(newAppIds, tokens.secrets())); + }); + return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer); } /** @@ -921,23 +1021,46 @@ public Revision purgeToken(Author author, String appId) { } } - return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, "Remove the token: " + appId, - () -> tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON) - .thenApply(tokens -> { - final Token token = tokens.object().get(appId); - final JsonPointer appIdPath = - JsonPointer.compile("/appIds" + encodeSegment(appId)); - final String secret = token.secret(); - assert secret != null; - final JsonPointer secretPath = - JsonPointer.compile( - "/secrets" + encodeSegment(secret)); - final Change change = Change.ofJsonPatch( - TOKEN_JSON, - asJsonArray(new RemoveOperation(appIdPath), - new RemoveIfExistsOperation(secretPath))); - return HolderWithRevision.of(change, tokens.revision()); - })).join(); + final String commitSummary = "Remove the token: " + appId; + + final ContentTransformer transformer = new ContentTransformer<>( + TOKEN_JSON, EntryType.JSON, node -> { + final Tokens tokens = tokens(node); + tokens.get(appId); // Raise an exception if not found. + final ImmutableMap.Builder appIdsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.appIds().entrySet()) { + if (!entry.getKey().equals(appId)) { + appIdsBuilder.put(entry); + } + } + final Map newAppIds = appIdsBuilder.build(); + final Map newSecrets = secretsWithout(appId, tokens); + + return Jackson.valueToTree(new Tokens(newAppIds, newSecrets)); + }); + return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer) + .join(); + } + + private static Tokens tokens(JsonNode node) { + final Tokens tokens; + try { + tokens = Jackson.treeToValue(node, Tokens.class); + } catch (JsonParseException | JsonMappingException e) { + // Should never reach here. + throw new Error(e); + } + return tokens; + } + + private static Map secretsWithout(String appId, Tokens tokens) { + final ImmutableMap.Builder secretsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.secrets().entrySet()) { + if (!entry.getValue().equals(appId)) { + secretsBuilder.put(entry); + } + } + return secretsBuilder.build(); } /** @@ -947,28 +1070,37 @@ public CompletableFuture activateToken(Author author, String appId) { requireNonNull(author, "author"); requireNonNull(appId, "appId"); - return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, - "Enable the token: " + appId, - () -> tokenRepo - .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON) - .thenApply(tokens -> { - final Token token = tokens.object().get(appId); - final JsonPointer removalPath = - JsonPointer.compile("/appIds" + encodeSegment(appId) + - "/deactivation"); - final String secret = token.secret(); - assert secret != null; - final JsonPointer secretPath = - JsonPointer.compile("/secrets" + - encodeSegment(secret)); - final Change change = Change.ofJsonPatch( - TOKEN_JSON, - asJsonArray(new RemoveOperation(removalPath), - new AddOperation(secretPath, - Jackson.valueToTree(appId)))); - return HolderWithRevision.of(change, tokens.revision()); - }) - ); + final String commitSummary = "Enable the token: " + appId; + + final ContentTransformer transformer = new ContentTransformer<>( + TOKEN_JSON, EntryType.JSON, node -> { + final Tokens tokens = tokens(node); + final Token token = tokens.get(appId); // Raise an exception if not found. + if (token.deactivation() == null) { + throw new IllegalArgumentException("The token is already activated: " + appId); + } + final String secret = token.secret(); + assert secret != null; + + final ImmutableMap.Builder appIdsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.appIds().entrySet()) { + if (!entry.getKey().equals(appId)) { + appIdsBuilder.put(entry); + } else { + appIdsBuilder.put(appId, + new Token(token.appId(), secret, token.isAdmin(), token.creation())); + } + } + final Map newAppIds = appIdsBuilder.build(); + + final ImmutableMap.Builder secretsBuilder = ImmutableMap.builder(); + secretsBuilder.putAll(tokens.secrets()); + secretsBuilder.put(secret, appId); + final Map newSecrets = secretsBuilder.build(); + + return Jackson.valueToTree(new Tokens(newAppIds, newSecrets)); + }); + return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer); } /** @@ -978,28 +1110,34 @@ public CompletableFuture deactivateToken(Author author, String appId) requireNonNull(author, "author"); requireNonNull(appId, "appId"); - return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, - "Disable the token: " + appId, - () -> tokenRepo - .fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON) - .thenApply(tokens -> { - final Token token = tokens.object().get(appId); - final JsonPointer removalPath = - JsonPointer.compile("/appIds" + encodeSegment(appId) + - "/deactivation"); - final String secret = token.secret(); - assert secret != null; - final JsonPointer secretPath = - JsonPointer.compile("/secrets" + - encodeSegment(secret)); - final Change change = Change.ofJsonPatch( - TOKEN_JSON, - asJsonArray(new TestAbsenceOperation(removalPath), - new AddOperation(removalPath, Jackson.valueToTree( - UserAndTimestamp.of(author))), - new RemoveOperation(secretPath))); - return HolderWithRevision.of(change, tokens.revision()); - })); + final String commitSummary = "Deactivate the token: " + appId; + final UserAndTimestamp userAndTimestamp = UserAndTimestamp.of(author); + + final ContentTransformer transformer = new ContentTransformer<>( + TOKEN_JSON, EntryType.JSON, node -> { + final Tokens tokens = tokens(node); + final Token token = tokens.get(appId); // Raise an exception if not found. + if (token.deactivation() != null) { + throw new IllegalArgumentException("The token is already deactivated: " + appId); + } + final String secret = token.secret(); + assert secret != null; + + final ImmutableMap.Builder appIdsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.appIds().entrySet()) { + if (!entry.getKey().equals(appId)) { + appIdsBuilder.put(entry); + } else { + appIdsBuilder.put(appId, new Token(token.appId(), secret, token.isAdmin(), token.isAdmin(), + token.creation(), userAndTimestamp, null)); + } + } + final Map newAppIds = appIdsBuilder.build(); + final Map newSecrets = secretsWithout(appId, tokens); + + return Jackson.valueToTree(new Tokens(newAppIds, newSecrets)); + }); + return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer); } /** @@ -1008,32 +1146,29 @@ public CompletableFuture deactivateToken(Author author, String appId) public CompletableFuture updateTokenLevel(Author author, String appId, boolean toBeAdmin) { requireNonNull(author, "author"); requireNonNull(appId, "appId"); + final String commitSummary = + "Update the token level: " + appId + " to " + (toBeAdmin ? "admin" : "user"); + + final ContentTransformer transformer = new ContentTransformer<>( + TOKEN_JSON, EntryType.JSON, node -> { + final Tokens tokens = tokens(node); + final Token token = tokens.get(appId); // Raise an exception if not found. + if (toBeAdmin == token.isAdmin()) { + throw new IllegalArgumentException("The token is already " + (toBeAdmin ? "admin" : "user")); + } - return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, - "Update the token level: " + appId + " to " + (toBeAdmin ? "admin" : "user"), - () -> tokenRepo.fetch(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, TOKEN_JSON) - .thenApply(tokens -> { - final Tokens tokens0 = tokens.object(); - final Token token = tokens0.get(appId); - if (token == null) { - throw new TokenNotFoundException("App ID: " + appId); - } - if (toBeAdmin == token.isAdmin()) { - throw new IllegalArgumentException( - "The token is already " + - (toBeAdmin ? "admin" : "user")); - } - final JsonPointer path = JsonPointer.compile( - "/appIds" + encodeSegment(appId)); - - final Change change = Change.ofJsonPatch( - TOKEN_JSON, - new ReplaceOperation( - path, - Jackson.valueToTree(token.withAdmin( - toBeAdmin))).toJsonNode()); - return HolderWithRevision.of(change, tokens.revision()); - })); + final ImmutableMap.Builder appIdsBuilder = ImmutableMap.builder(); + for (Entry entry : tokens.appIds().entrySet()) { + if (!entry.getKey().equals(appId)) { + appIdsBuilder.put(entry); + } else { + appIdsBuilder.put(appId, token.withAdmin(toBeAdmin)); + } + } + final Map newAppIds = appIdsBuilder.build(); + return Jackson.valueToTree(new Tokens(newAppIds, tokens.secrets())); + }); + return tokenRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author, commitSummary, transformer); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java index 36354b0e5..811d9f33f 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java @@ -163,7 +163,7 @@ public Member member(String memberId) { if (member != null) { return member; } - throw new EntryNotFoundException(memberId); + throw new EntryNotFoundException("failed to find member " + memberId + " in project " + name()); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositorySupport.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositorySupport.java index 1704b51f8..592850661 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositorySupport.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositorySupport.java @@ -16,13 +16,10 @@ package com.linecorp.centraldogma.server.metadata; -import static com.linecorp.armeria.common.util.Functions.voidFunction; import static java.util.Objects.requireNonNull; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; import java.util.function.Function; -import java.util.function.Supplier; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; @@ -30,7 +27,6 @@ import com.linecorp.armeria.common.util.Exceptions; import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.common.Change; -import com.linecorp.centraldogma.common.ChangeConflictException; import com.linecorp.centraldogma.common.Entry; import com.linecorp.centraldogma.common.Markup; import com.linecorp.centraldogma.common.RedundantChangeException; @@ -39,6 +35,7 @@ import com.linecorp.centraldogma.server.command.Command; import com.linecorp.centraldogma.server.command.CommandExecutor; import com.linecorp.centraldogma.server.command.CommitResult; +import com.linecorp.centraldogma.server.command.ContentTransformer; import com.linecorp.centraldogma.server.storage.project.ProjectManager; import com.linecorp.centraldogma.server.storage.repository.Repository; @@ -108,53 +105,27 @@ private CompletableFuture push(String projectName, String repoName, Au .thenApply(CommitResult::revision); } - CompletableFuture push(String projectName, String repoName, Author author, String commitSummary, - Supplier>>> changeSupplier) { + CompletableFuture push(String projectName, String repoName, + Author author, String commitSummary, + ContentTransformer transformer) { requireNonNull(projectName, "projectName"); requireNonNull(repoName, "repoName"); requireNonNull(author, "author"); requireNonNull(commitSummary, "commitSummary"); - requireNonNull(changeSupplier, "changeSupplier"); - - final CompletableFuture future = new CompletableFuture<>(); - push(projectName, repoName, author, commitSummary, changeSupplier, future); - return future; - } - - private void push(String projectName, String repoName, Author author, String commitSummary, - Supplier>>> changeSupplier, - CompletableFuture future) { - changeSupplier.get().thenAccept(changeWithRevision -> { - final Revision revision = changeWithRevision.revision(); - final Change change = changeWithRevision.object(); - - push(projectName, repoName, author, commitSummary, change, revision) - .thenAccept(future::complete) - .exceptionally(voidFunction(cause -> { - cause = Exceptions.peel(cause); - if (cause instanceof ChangeConflictException) { - final Revision latestRevision; - try { - latestRevision = projectManager().get(projectName).repos().get(repoName) - .normalizeNow(Revision.HEAD); - } catch (Throwable cause1) { - future.completeExceptionally(cause1); - return; - } - - if (revision.equals(latestRevision)) { - future.completeExceptionally(cause); - return; - } - // Try again. - push(projectName, repoName, author, commitSummary, changeSupplier, future); - } else if (cause instanceof RedundantChangeException) { - future.complete(revision); - } else { - future.completeExceptionally(cause); - } - })); - }).exceptionally(voidFunction(future::completeExceptionally)); + requireNonNull(transformer, "transformer"); + + return executor.execute(Command.transform(null, author, projectName, repoName, Revision.HEAD, + commitSummary, "", Markup.PLAINTEXT, transformer)) + .thenApply(CommitResult::revision) + .exceptionally(cause -> { + final Throwable peeled = Exceptions.peel(cause); + if (peeled instanceof RedundantChangeException) { + final Revision revision = ((RedundantChangeException) peeled).headRevision(); + assert revision != null; + return revision; + } + return Exceptions.throwUnsafely(peeled); + }); } Revision normalize(Repository repository) { diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java index 0c80a7434..f9d7346db 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java @@ -35,12 +35,14 @@ import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.common.ChangeConflictException; +import com.linecorp.centraldogma.common.EntryNotFoundException; import com.linecorp.centraldogma.common.ProjectExistsException; import com.linecorp.centraldogma.common.ProjectRole; import com.linecorp.centraldogma.common.RepositoryExistsException; import com.linecorp.centraldogma.common.RepositoryNotFoundException; import com.linecorp.centraldogma.server.QuotaConfig; import com.linecorp.centraldogma.server.command.Command; +import com.linecorp.centraldogma.server.internal.admin.service.TokenNotFoundException; import com.linecorp.centraldogma.testing.internal.ProjectManagerExtension; class MetadataServiceTest { @@ -314,6 +316,10 @@ void removeMember() { assertThat(mds.findPermissions(project1, repo1, user2).join()) .containsExactly(Permission.READ); + + // Remove 'user1' again. + assertThatThrownBy(() -> mds.removeMember(author, project1, user1).join()) + .hasCauseInstanceOf(EntryNotFoundException.class); } @Test @@ -340,6 +346,10 @@ void removeToken() { assertThat(mds.findPermissions(project1, repo1, app2).join()) .containsExactly(Permission.READ); + + // Remove 'app1' again. + assertThatThrownBy(() -> mds.removeToken(author, project1, app1).join()) + .hasCauseInstanceOf(EntryNotFoundException.class); } @Test @@ -369,6 +379,10 @@ void destroyToken() { assertThat(mds.findPermissions(project1, repo1, app2).join()) .containsExactly(Permission.READ); + + // Remove 'app1' again. + assertThatThrownBy(() -> mds.destroyToken(author, app1).join()) + .hasCauseInstanceOf(TokenNotFoundException.class); } @Test @@ -387,8 +401,14 @@ void tokenActivationAndDeactivation() { assertThat(token.deactivation()).isNotNull(); assertThat(token.deactivation().user()).isEqualTo(owner.id()); + assertThatThrownBy(() -> mds.deactivateToken(author, app1).join()) + .hasCauseInstanceOf(IllegalArgumentException.class); + mds.activateToken(author, app1).join(); assertThat(mds.getTokens().join().get(app1).isActive()).isTrue(); + + assertThatThrownBy(() -> mds.activateToken(author, app1).join()) + .hasCauseInstanceOf(IllegalArgumentException.class); } @Test @@ -422,10 +442,14 @@ void updateUser() { mds.updateTokenLevel(author, app1, true).join(); token = mds.getTokens().join().get(app1); assertThat(token.isAdmin()).isTrue(); + assertThatThrownBy(() -> mds.updateTokenLevel(author, app1, true).join()) + .hasCauseInstanceOf(IllegalArgumentException.class); mds.updateTokenLevel(author, app1, false).join(); token = mds.getTokens().join().get(app1); assertThat(token.isAdmin()).isFalse(); + assertThatThrownBy(() -> mds.updateTokenLevel(author, app1, false).join()) + .hasCauseInstanceOf(IllegalArgumentException.class); } private static RepositoryMetadata getRepo1(MetadataService mds) {