1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.internal.api;
18  
19  import static com.google.common.base.MoreObjects.firstNonNull;
20  import static com.google.common.collect.ImmutableList.toImmutableList;
21  import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.checkUnremoveArgument;
22  import static com.linecorp.centraldogma.server.internal.api.HttpApiUtil.returnOrThrow;
23  import static java.util.Objects.requireNonNull;
24  
25  import java.util.List;
26  import java.util.Map;
27  import java.util.concurrent.CompletableFuture;
28  
29  import javax.annotation.Nullable;
30  
31  import com.fasterxml.jackson.databind.JsonNode;
32  import com.google.common.collect.ImmutableList;
33  import com.google.common.collect.ImmutableMap;
34  
35  import com.linecorp.armeria.common.HttpStatus;
36  import com.linecorp.armeria.common.logging.RequestOnlyLog;
37  import com.linecorp.armeria.server.ServiceRequestContext;
38  import com.linecorp.armeria.server.annotation.Consumes;
39  import com.linecorp.armeria.server.annotation.Delete;
40  import com.linecorp.armeria.server.annotation.Get;
41  import com.linecorp.armeria.server.annotation.Param;
42  import com.linecorp.armeria.server.annotation.Patch;
43  import com.linecorp.armeria.server.annotation.Post;
44  import com.linecorp.armeria.server.annotation.ProducesJson;
45  import com.linecorp.armeria.server.annotation.Put;
46  import com.linecorp.armeria.server.annotation.ResponseConverter;
47  import com.linecorp.armeria.server.annotation.StatusCode;
48  import com.linecorp.centraldogma.common.Author;
49  import com.linecorp.centraldogma.common.ProjectRole;
50  import com.linecorp.centraldogma.common.RepositoryRole;
51  import com.linecorp.centraldogma.common.RepositoryStatus;
52  import com.linecorp.centraldogma.common.Revision;
53  import com.linecorp.centraldogma.internal.api.v1.CreateRepositoryRequest;
54  import com.linecorp.centraldogma.internal.api.v1.RepositoryDto;
55  import com.linecorp.centraldogma.server.command.Command;
56  import com.linecorp.centraldogma.server.command.CommandExecutor;
57  import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole;
58  import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole;
59  import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter;
60  import com.linecorp.centraldogma.server.metadata.MetadataService;
61  import com.linecorp.centraldogma.server.metadata.ProjectMetadata;
62  import com.linecorp.centraldogma.server.metadata.RepositoryMetadata;
63  import com.linecorp.centraldogma.server.metadata.User;
64  import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer;
65  import com.linecorp.centraldogma.server.storage.project.Project;
66  import com.linecorp.centraldogma.server.storage.repository.Repository;
67  
68  import io.micrometer.core.instrument.Tag;
69  
70  /**
71   * Annotated service object for managing repositories.
72   */
73  @ProducesJson
74  public class RepositoryServiceV1 extends AbstractService {
75  
76      private final MetadataService mds;
77  
78      public RepositoryServiceV1(CommandExecutor executor, MetadataService mds) {
79          super(executor);
80          this.mds = requireNonNull(mds, "mds");
81      }
82  
83      /**
84       * GET /projects/{projectName}/repos?status={status}
85       *
86       * <p>Returns the list of the repositories or removed repositories.
87       */
88      @Get("/projects/{projectName}/repos")
89      public CompletableFuture<List<RepositoryDto>> listRepositories(ServiceRequestContext ctx, Project project,
90                                                                     @Param @Nullable String status, User user) {
91          if (status != null) {
92              HttpApiUtil.checkStatusArgument(status);
93          }
94  
95          if (InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(project.name())) {
96              if (user.isSystemAdmin()) {
97                  if (status != null) {
98                      return CompletableFuture.completedFuture(removedRepositories(project));
99                  }
100                 return CompletableFuture.completedFuture(
101                         project.repos().list().values().stream()
102                                .map(repository -> DtoConverter.convert(repository, RepositoryStatus.ACTIVE))
103                                .collect(toImmutableList()));
104             }
105             return HttpApiUtil.throwResponse(
106                     ctx, HttpStatus.FORBIDDEN,
107                     "You must be a system administrator to retrieve repositories of the dogma project.");
108         }
109 
110         if (status == null) {
111             final Map<String, RepositoryMetadata> repos = projectMetadata(project).repos();
112             return CompletableFuture.completedFuture(
113                     project.repos().list().values().stream()
114                            .filter(r -> user.isSystemAdmin() || !Project.isInternalRepo(r.name()))
115                            .map(repository -> DtoConverter.convert(repository, repos))
116                            .collect(toImmutableList()));
117         }
118 
119         return mds.findProjectRole(project.name(), user).handle((role, throwable) -> {
120             final boolean hasOwnerRole = role == ProjectRole.OWNER;
121             if (hasOwnerRole) {
122                 return removedRepositories(project);
123             }
124             return HttpApiUtil.throwResponse(
125                     ctx, HttpStatus.FORBIDDEN,
126                     "You must be an owner of project '%s' to retrieve removed repositories.",
127                     project.name());
128         });
129     }
130 
131     private static ProjectMetadata projectMetadata(Project project) {
132         assert !InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(project.name());
133         final ProjectMetadata metadata = project.metadata();
134         assert metadata != null; // not null because the project is not dogma project.
135         return metadata;
136     }
137 
138     private static RepositoryStatus repositoryStatus(Repository repository) {
139         final Map<String, RepositoryMetadata> repos = projectMetadata(repository.parent()).repos();
140         final String repoName = normalizeRepositoryName(repository);
141 
142         final RepositoryMetadata metadata = repos.get(repoName);
143         if (metadata == null) {
144             return RepositoryStatus.ACTIVE;
145         } else {
146             return metadata.status();
147         }
148     }
149 
150     private static String normalizeRepositoryName(Repository repository) {
151         final String repoName = repository.name();
152         if (!Project.REPO_META.equals(repoName)) {
153             return repoName;
154         }
155         // Use dogma repository for the meta repository because the meta repository will be removed.
156         return Project.REPO_DOGMA;
157     }
158 
159     private static ImmutableList<RepositoryDto> removedRepositories(Project project) {
160         return project.repos().listRemoved().keySet().stream().map(RepositoryDto::removed)
161                       .collect(toImmutableList());
162     }
163 
164     /**
165      * POST /projects/{projectName}/repos
166      *
167      * <p>Creates a new repository.
168      */
169     @Post("/projects/{projectName}/repos")
170     @StatusCode(201)
171     @ResponseConverter(CreateApiResponseConverter.class)
172     @RequiresProjectRole(ProjectRole.MEMBER)
173     public CompletableFuture<RepositoryDto> createRepository(ServiceRequestContext ctx, Project project,
174                                                              CreateRepositoryRequest request,
175                                                              Author author) {
176         final String repoName = request.name();
177         if (Project.isInternalRepo(repoName)) {
178             return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN,
179                                              "An internal repository cannot be created.");
180         }
181         final CommandExecutor commandExecutor = executor();
182         final CompletableFuture<Revision> future =
183                 RepositoryServiceUtil.createRepository(commandExecutor, mds, author, project.name(), repoName);
184         return future.handle(returnOrThrow(() -> {
185             final Repository repository = project.repos().get(repoName);
186             return DtoConverter.convert(repository, repositoryStatus(repository));
187         }));
188     }
189 
190     /**
191      * DELETE /projects/{projectName}/repos/{repoName}
192      *
193      * <p>Removes a repository.
194      */
195     @Delete("/projects/{projectName}/repos/{repoName}")
196     @RequiresRepositoryRole(RepositoryRole.ADMIN)
197     public CompletableFuture<Void> removeRepository(ServiceRequestContext ctx,
198                                                     @Param String repoName,
199                                                     Repository repository,
200                                                     Author author) {
201         if (Project.isInternalRepo(repoName)) {
202             return HttpApiUtil.throwResponse(ctx, HttpStatus.FORBIDDEN,
203                                              "An internal repository cannot be removed.");
204         }
205         return RepositoryServiceUtil.removeRepository(executor(), mds, author,
206                                                       repository.parent().name(), repoName)
207                                     .handle(HttpApiUtil::throwUnsafelyIfNonNull);
208     }
209 
210     /**
211      * DELETE /projects/{projectName}/repos/{repoName}/removed
212      *
213      * <p>Purges a repository that was removed before.
214      */
215     @Delete("/projects/{projectName}/repos/{repoName}/removed")
216     @RequiresProjectRole(ProjectRole.OWNER)
217     public CompletableFuture<Void> purgeRepository(@Param String repoName,
218                                                    Project project, Author author) {
219         return execute(Command.purgeRepository(author, project.name(), repoName))
220                 .thenCompose(unused -> mds.purgeRepo(author, project.name(), repoName)
221                                           .handle(HttpApiUtil::throwUnsafelyIfNonNull));
222     }
223 
224     // TODO(minwoox): Migrate to /projects/{projectName}/repos/{repoName}:unremove when it's supported.
225 
226     /**
227      * PATCH /projects/{projectName}/repos/{repoName}
228      *
229      * <p>Patches a repository with the JSON_PATCH. Currently, only unremove repository operation is supported.
230      */
231     @Consumes("application/json-patch+json")
232     @Patch("/projects/{projectName}/repos/{repoName}")
233     @RequiresProjectRole(ProjectRole.OWNER)
234     public CompletableFuture<RepositoryDto> patchRepository(@Param String repoName,
235                                                             Project project,
236                                                             JsonNode node,
237                                                             Author author) {
238         checkUnremoveArgument(node);
239         return execute(Command.unremoveRepository(author, project.name(), repoName))
240                 .thenCompose(unused -> mds.restoreRepo(author, project.name(), repoName))
241                 .handle(returnOrThrow(() -> {
242                     final Repository repository = project.repos().get(repoName);
243                     return DtoConverter.convert(repository, repositoryStatus(repository));
244                 }));
245     }
246 
247     /**
248      * GET /projects/{projectName}/repos/{repoName}/revision/{revision}
249      *
250      * <p>Normalizes the revision into an absolute revision.
251      */
252     @Get("/projects/{projectName}/repos/{repoName}/revision/{revision}")
253     @RequiresRepositoryRole(RepositoryRole.READ)
254     public Map<String, Integer> normalizeRevision(ServiceRequestContext ctx,
255                                                   Repository repository, @Param String revision) {
256         final Revision normalizedRevision = repository.normalizeNow(new Revision(revision));
257         final Revision head = repository.normalizeNow(Revision.HEAD);
258         increaseCounterIfOldRevisionUsed(ctx, repository, normalizedRevision, head);
259         return ImmutableMap.of("revision", normalizedRevision.major());
260     }
261 
262     /**
263      * GET /projects/{projectName}/repos/{repoName}/status
264      *
265      * <p>Returns the repository status.
266      */
267     @Get("/projects/{projectName}/repos/{repoName}")
268     @RequiresRepositoryRole(RepositoryRole.ADMIN)
269     public RepositoryDto status(Project project, Repository repository) {
270         validateDogmaProject(project);
271         return DtoConverter.convert(repository, repositoryStatus(repository));
272     }
273 
274     /**
275      * PUT /projects/{projectName}/repos/{repoName}/status
276      *
277      * <p>Changes the repository status.
278      */
279     @Put("/projects/{projectName}/repos/{repoName}/status")
280     @Consumes("application/json")
281     @RequiresRepositoryRole(RepositoryRole.ADMIN)
282     public CompletableFuture<RepositoryDto> updateStatus(Project project,
283                                                          Repository repository,
284                                                          Author author,
285                                                          UpdateRepositoryStatusRequest statusRequest)
286             throws Exception {
287         validateDogmaProject(project);
288         final RepositoryStatus oldStatus = repositoryStatus(repository);
289         final RepositoryStatus newStatus = statusRequest.status();
290         if (oldStatus == newStatus) {
291             // No need to update the status, just return the current status.
292             return CompletableFuture.completedFuture(DtoConverter.convert(repository, oldStatus));
293         }
294 
295         return mds.updateRepositoryStatus(author, project.name(),
296                                           normalizeRepositoryName(repository), newStatus)
297                   .thenApply(unused -> DtoConverter.convert(repository, newStatus));
298     }
299 
300     static void increaseCounterIfOldRevisionUsed(ServiceRequestContext ctx, Repository repository,
301                                                  Revision revision) {
302         final Revision normalized = repository.normalizeNow(revision);
303         final Revision head = repository.normalizeNow(Revision.HEAD);
304         increaseCounterIfOldRevisionUsed(ctx, repository, normalized, head);
305     }
306 
307     public static void increaseCounterIfOldRevisionUsed(
308             ServiceRequestContext ctx, Repository repository, Revision normalized, Revision head) {
309         final String projectName = repository.parent().name();
310         final String repoName = repository.name();
311         if (normalized.major() == 1) {
312             ctx.log().whenRequestComplete().thenAccept(
313                     log -> ctx.meterRegistry()
314                               .counter("revisions.init", generateTags(projectName, repoName, log).build())
315                               .increment());
316         }
317         if (head.major() - normalized.major() >= 5000) {
318             ctx.log().whenRequestComplete().thenAccept(
319                     log -> ctx.meterRegistry()
320                               .summary("revisions.old",
321                                        generateTags(projectName, repoName, log)
322                                                .add(Tag.of("init", Boolean.toString(normalized.major() == 1)))
323                                                .build())
324                               .record(head.major() - normalized.major()));
325         }
326     }
327 
328     private static ImmutableList.Builder<Tag> generateTags(
329             String projectName, String repoName, RequestOnlyLog log) {
330         final ImmutableList.Builder<Tag> builder = ImmutableList.builder();
331         return builder.add(Tag.of("project", projectName),
332                            Tag.of("repo", repoName),
333                            Tag.of("service", firstNonNull(log.serviceName(), "none")),
334                            Tag.of("method", log.name()));
335     }
336 
337     private static void validateDogmaProject(Project project) {
338         if (InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(project.name())) {
339             throw new IllegalArgumentException(
340                     "Cannot update the status of the internal project: " + project.name());
341         }
342     }
343 }