1   /*
2    * Copyright 2023 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.Preconditions.checkArgument;
20  import static com.google.common.collect.ImmutableList.toImmutableList;
21  import static com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin.mirrorConfig;
22  import static com.linecorp.centraldogma.server.internal.storage.repository.DefaultMetaRepository.mirrorFile;
23  import static com.linecorp.centraldogma.server.mirror.MirrorUtil.validateMirrorId;
24  
25  import java.net.URI;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.concurrent.CompletableFuture;
29  import java.util.concurrent.TimeUnit;
30  
31  import org.jspecify.annotations.Nullable;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  
35  import com.cronutils.model.Cron;
36  import com.google.common.collect.ImmutableList;
37  import com.google.common.collect.ImmutableMap;
38  
39  import com.linecorp.armeria.server.annotation.ConsumesJson;
40  import com.linecorp.armeria.server.annotation.Delete;
41  import com.linecorp.armeria.server.annotation.Get;
42  import com.linecorp.armeria.server.annotation.Param;
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.StatusCode;
47  import com.linecorp.armeria.server.annotation.decorator.RequestTimeout;
48  import com.linecorp.centraldogma.common.Author;
49  import com.linecorp.centraldogma.common.Change;
50  import com.linecorp.centraldogma.common.Markup;
51  import com.linecorp.centraldogma.common.ProjectRole;
52  import com.linecorp.centraldogma.common.RepositoryRole;
53  import com.linecorp.centraldogma.common.Revision;
54  import com.linecorp.centraldogma.internal.api.v1.MirrorDto;
55  import com.linecorp.centraldogma.internal.api.v1.MirrorRequest;
56  import com.linecorp.centraldogma.internal.api.v1.PushResultDto;
57  import com.linecorp.centraldogma.server.CentralDogmaConfig;
58  import com.linecorp.centraldogma.server.ZoneConfig;
59  import com.linecorp.centraldogma.server.command.Command;
60  import com.linecorp.centraldogma.server.command.CommandExecutor;
61  import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole;
62  import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole;
63  import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner;
64  import com.linecorp.centraldogma.server.internal.mirror.MirrorSchedulingService;
65  import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager;
66  import com.linecorp.centraldogma.server.metadata.User;
67  import com.linecorp.centraldogma.server.mirror.Mirror;
68  import com.linecorp.centraldogma.server.mirror.MirrorAccessController;
69  import com.linecorp.centraldogma.server.mirror.MirrorListener;
70  import com.linecorp.centraldogma.server.mirror.MirrorResult;
71  import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig;
72  import com.linecorp.centraldogma.server.storage.repository.MetaRepository;
73  import com.linecorp.centraldogma.server.storage.repository.Repository;
74  
75  /**
76   * Annotated service object for managing mirroring service.
77   */
78  @ProducesJson
79  public class MirroringServiceV1 extends AbstractService {
80  
81      private static final Logger logger = LoggerFactory.getLogger(MirroringServiceV1.class);
82  
83      // TODO(ikhoon):
84      //  - Write documentation for the REST API specification
85      //  - Add Java APIs to the CentralDogma client
86  
87      private final ProjectApiManager projectApiManager;
88      private final MirrorRunner mirrorRunner;
89      private final Map<String, Object> mirrorZoneConfig;
90      @Nullable
91      private final ZoneConfig zoneConfig;
92      private final MirrorAccessController accessController;
93  
94      public MirroringServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor,
95                                MirrorRunner mirrorRunner, CentralDogmaConfig config,
96                                MirrorAccessController accessController) {
97          super(executor);
98          this.projectApiManager = projectApiManager;
99          this.mirrorRunner = mirrorRunner;
100         zoneConfig = config.zone();
101         mirrorZoneConfig = mirrorZoneConfig(config);
102         this.accessController = accessController;
103     }
104 
105     private static Map<String, Object> mirrorZoneConfig(CentralDogmaConfig config) {
106         final MirroringServicePluginConfig mirrorConfig = mirrorConfig(config);
107         final ImmutableMap.Builder<String, Object> builder = ImmutableMap.builderWithExpectedSize(2);
108         final boolean zonePinned = mirrorConfig != null && mirrorConfig.zonePinned();
109         builder.put("zonePinned", zonePinned);
110         final ZoneConfig zone = config.zone();
111         if (zone != null) {
112             builder.put("zone", zone);
113         }
114         return builder.build();
115     }
116 
117     /**
118      * GET /projects/{projectName}/mirrors
119      *
120      * <p>Returns the list of the mirrors in the project.
121      */
122     @RequiresProjectRole(ProjectRole.OWNER)
123     @Get("/projects/{projectName}/mirrors")
124     public CompletableFuture<List<MirrorDto>> listProjectMirrors(@Param String projectName) {
125         final CompletableFuture<List<Mirror>> future = metaRepo(projectName).mirrors(true);
126         return convertToMirrorDtos(projectName, future);
127     }
128 
129     /**
130      * GET /projects/{projectName}/repos/{repoName}/mirrors
131      *
132      * <p>Returns the list of the mirrors in the repository.
133      */
134     @RequiresRepositoryRole(RepositoryRole.ADMIN)
135     @Get("/projects/{projectName}/repos/{repoName}/mirrors")
136     public CompletableFuture<List<MirrorDto>> listRepoMirrors(@Param String projectName,
137                                                               Repository repository) {
138         final CompletableFuture<List<Mirror>> future = metaRepo(projectName).mirrors(repository.name(), true);
139         return convertToMirrorDtos(projectName, future);
140     }
141 
142     /**
143      * GET /projects/{projectName}/repos/{repoName}/mirrors/{id}
144      *
145      * <p>Returns the mirror of the ID in the project mirror list.
146      */
147     @RequiresRepositoryRole(RepositoryRole.ADMIN)
148     @Get("/projects/{projectName}/repos/{repoName}/mirrors/{id}")
149     public CompletableFuture<MirrorDto> getMirror(@Param String projectName,
150                                                   Repository repository,
151                                                   @Param String id) {
152         return metaRepo(projectName).mirror(repository.name(), id).thenCompose(mirror -> {
153             return accessController.isAllowed(mirror.remoteRepoUri()).thenApply(allowed -> {
154                 return convertToMirrorDto(projectName, mirror, allowed);
155             });
156         });
157     }
158 
159     /**
160      * POST /projects/{projectName}/repos/{repoName}/mirrors
161      *
162      * <p>Creates a new mirror.
163      */
164     @Post("/projects/{projectName}/repos/{repoName}/mirrors")
165     @ConsumesJson
166     @StatusCode(201)
167     @RequiresRepositoryRole(RepositoryRole.ADMIN)
168     public CompletableFuture<PushResultDto> createMirror(@Param String projectName,
169                                                          Repository repository,
170                                                          MirrorRequest newMirror,
171                                                          Author author, User user) {
172         validateMirrorId(newMirror.id());
173         return createOrUpdate(projectName, repository.name(), newMirror, author, user, false);
174     }
175 
176     /**
177      * PUT /projects/{projectName}/repos/{repoName}/mirrors
178      *
179      * <p>Update the exising mirror.
180      */
181     @ConsumesJson
182     @Put("/projects/{projectName}/repos/{repoName}/mirrors/{id}")
183     @RequiresRepositoryRole(RepositoryRole.ADMIN)
184     public CompletableFuture<PushResultDto> updateMirror(@Param String projectName,
185                                                          Repository repository,
186                                                          MirrorRequest mirror,
187                                                          @Param String id, Author author, User user) {
188         checkArgument(id.equals(mirror.id()), "The mirror ID (%s) can't be updated", id);
189         return createOrUpdate(projectName, repository.name(), mirror, author, user, true);
190     }
191 
192     /**
193      * DELETE /projects/{projectName}/repos/{repoName}/mirrors/{id}
194      *
195      * <p>Delete the existing mirror.
196      */
197     @Delete("/projects/{projectName}/repos/{repoName}/mirrors/{id}")
198     @RequiresRepositoryRole(RepositoryRole.ADMIN)
199     public CompletableFuture<Void> deleteMirror(@Param String projectName,
200                                                 Repository repository,
201                                                 @Param String id, Author author) {
202         final MetaRepository metaRepository = metaRepo(projectName);
203         final String repoName = repository.name();
204         return metaRepository.mirror(repoName, id).thenCompose(mirror -> {
205             // mirror exists.
206             final Command<Revision> command =
207                     Command.push(author, projectName, metaRepository.name(),
208                                  Revision.HEAD, "Delete mirror: " + id + " in " + repoName, "",
209                                  Markup.PLAINTEXT, Change.ofRemoval(mirrorFile(repoName, id)));
210             return executor().execute(command).thenApply(result -> null);
211         });
212     }
213 
214     private CompletableFuture<PushResultDto> createOrUpdate(
215             String projectName, String repoName, MirrorRequest newMirror,
216             Author author, User user, boolean update) {
217         final MetaRepository metaRepo = metaRepo(projectName);
218 
219         return metaRepo.createMirrorPushCommand(repoName, newMirror, author, zoneConfig, update).thenCompose(
220                 command -> {
221                     return executor().execute(command).thenApply(revision -> {
222                         metaRepo.mirror(repoName, newMirror.id(), revision)
223                                 .handle((mirror, cause) -> {
224                                     if (cause != null) {
225                                         // This should not happen in normal cases.
226                                         logger.warn("Failed to get the mirror: {}", newMirror.id(), cause);
227                                         return null;
228                                     }
229                                     return notifyMirrorEvent(mirror, user, update);
230                                 });
231                         return new PushResultDto(revision, command.timestamp());
232                     });
233                 });
234     }
235 
236     private Void notifyMirrorEvent(Mirror mirror, User user, boolean update) {
237         try {
238             final MirrorListener listener = MirrorSchedulingService.mirrorListener();
239             if (update) {
240                 listener.onUpdate(mirror, user, accessController);
241             } else {
242                 listener.onCreate(mirror, user, accessController);
243             }
244         } catch (Throwable ex) {
245             logger.warn("Failed to notify the mirror listener. (mirror: {})", mirror, ex);
246         }
247         return null;
248     }
249 
250     /**
251      * POST /projects/{projectName}/repos/{repoName}/mirrors/{mirrorId}/run
252      *
253      * <p>Runs the mirroring task immediately.
254      */
255     // Mirroring may be a long-running task, so we need to increase the timeout.
256     @RequestTimeout(value = 5, unit = TimeUnit.MINUTES)
257     @Post("/projects/{projectName}/repos/{repoName}/mirrors/{mirrorId}/run")
258     @RequiresRepositoryRole(RepositoryRole.ADMIN)
259     public CompletableFuture<MirrorResult> runMirror(@Param String projectName,
260                                                      Repository repository,
261                                                      @Param String mirrorId,
262                                                      User user) throws Exception {
263         return mirrorRunner.run(projectName, repository.name(), mirrorId, user);
264     }
265 
266     /**
267      * GET /mirror/config
268      *
269      * <p>Returns the configuration of the mirroring service.
270      */
271     @Get("/mirror/config")
272     public Map<String, Object> config() {
273         // TODO(ikhoon): Add more configurations if necessary.
274         return mirrorZoneConfig;
275     }
276 
277     private CompletableFuture<List<MirrorDto>> convertToMirrorDtos(
278             String projectName, CompletableFuture<List<Mirror>> future) {
279         return future.thenCompose(mirrors -> {
280             final ImmutableList<String> remoteUris = mirrors.stream().map(
281                     mirror -> mirror.remoteRepoUri().toString()).collect(
282                     toImmutableList());
283             return accessController.isAllowed(remoteUris).thenApply(acl -> {
284                 return mirrors.stream()
285                               .map(mirror -> convertToMirrorDto(projectName, mirror, acl))
286                               .collect(toImmutableList());
287             });
288         });
289     }
290 
291     private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror, Map<String, Boolean> acl) {
292         final boolean allowed = acl.get(mirror.remoteRepoUri().toString());
293         return convertToMirrorDto(projectName, mirror, allowed);
294     }
295 
296     private static MirrorDto convertToMirrorDto(String projectName, Mirror mirror, boolean allowed) {
297         final URI remoteRepoUri = mirror.remoteRepoUri();
298         final Cron schedule = mirror.schedule();
299         final String scheduleStr = schedule != null ? schedule.asString() : null;
300         return new MirrorDto(mirror.id(),
301                              mirror.enabled(), projectName,
302                              scheduleStr,
303                              mirror.direction().name(),
304                              mirror.localRepo().name(),
305                              mirror.localPath(),
306                              remoteRepoUri.getScheme(),
307                              remoteRepoUri.getAuthority() + remoteRepoUri.getPath(),
308                              mirror.remotePath(),
309                              mirror.remoteBranch(),
310                              mirror.gitignore(),
311                              mirror.credential().name(), mirror.zone(), allowed);
312     }
313 
314     private MetaRepository metaRepo(String projectName) {
315         return projectApiManager.getProject(projectName).metaRepo();
316     }
317 }