Skip to content

Commit 37f458a

Browse files
ikhoonclaude
andcommitted
Add a Repository Status page to view and manage read-only repositories
Motivation: Central Dogma can put an individual repository or a whole project into read-only mode (scoped replication-failure read-only, #1305), but there was no way to see which repositories or projects are currently read-only, nor to set or clear that status, from the web UI. Modifications: - Add `GET /api/v1/status/repos/read-only` (system administrator only) that returns the projects and repositories that are currently read-only. A project-scoped entry uses `dogma` as its repository name. - Serialize `RepositoryState.updatedAt` as an ISO-8601 string via `@JsonFormat` so clients render the real timestamp instead of an epoch number. - Expose read-only scope metrics: a `repository.read.only.count` gauge and a `repository.read.only` multi-gauge tagged by project, repo and scope. - Add a "Repository Status" settings tab (system administrator only) that - lists the read-only projects and repositories with their scope, status and last-updated time; - offers a form to make a repository read-only, with project and repository auto-complete; and - offers a per-row action to revert a repository or project to writable. Both changes require re-typing the full `project/repository` name to confirm, to guard against accidental clicks. - Add the `updateRepositoryStatus` mutation and a `RepoStatus` cache tag so the list refreshes after a change. - Add unit tests, a Playwright e2e test and a server test for the new endpoint. Result: - A system administrator can now view all read-only repositories and projects and toggle read-only status from the web UI, with a type-to-confirm safeguard against mistakes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6206bd9 commit 37f458a

19 files changed

Lines changed: 1171 additions & 9 deletions

File tree

server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ private CommandExecutor startCommandExecutor(
623623
}
624624

625625
statusManager = new ServerStatusManager(cfg.dataDir());
626-
repoStatusManager = new RepoStatusManager(statusManager, pm);
626+
repoStatusManager = new RepoStatusManager(statusManager, pm, meterRegistry);
627627
logger.info("Startup mode: {}", statusManager.serverStatus());
628628
final CommandExecutor executor;
629629
final ReplicationMethod replicationMethod = cfg.replicationConfig().method();
@@ -1020,7 +1020,7 @@ private void configureHttpApi(ServerBuilder sb,
10201020
assert statusManager != null && repoStatusManager != null;
10211021
final ContextPathServicesBuilder apiV1ServiceBuilder = sb.contextPath(API_V1_PATH_PREFIX);
10221022
apiV1ServiceBuilder
1023-
.annotatedService(new ServerStatusService(executor, statusManager))
1023+
.annotatedService(new ServerStatusService(executor, statusManager, repoStatusManager))
10241024
.annotatedService(new ProjectServiceV1(projectApiManager, executor))
10251025
.annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager,
10261026
repoStatusManager))

server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/ServerStatusService.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.linecorp.centraldogma.server.internal.api.sysadmin;
1818

19+
import java.util.List;
1920
import java.util.concurrent.CompletableFuture;
2021

2122
import com.linecorp.armeria.common.HttpStatus;
@@ -29,6 +30,8 @@
2930
import com.linecorp.centraldogma.server.internal.api.AbstractService;
3031
import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator;
3132
import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest.Scope;
33+
import com.linecorp.centraldogma.server.internal.management.RepoStatusManager;
34+
import com.linecorp.centraldogma.server.internal.management.RepositoryState;
3235
import com.linecorp.centraldogma.server.internal.management.ServerStatusManager;
3336
import com.linecorp.centraldogma.server.internal.replication.ZooKeeperCommandExecutor;
3437
import com.linecorp.centraldogma.server.management.ServerStatus;
@@ -38,10 +41,13 @@
3841
public final class ServerStatusService extends AbstractService {
3942

4043
private final ServerStatusManager serverStatusManager;
44+
private final RepoStatusManager repoStatusManager;
4145

42-
public ServerStatusService(CommandExecutor executor, ServerStatusManager serverStatusManager) {
46+
public ServerStatusService(CommandExecutor executor, ServerStatusManager serverStatusManager,
47+
RepoStatusManager repoStatusManager) {
4348
super(executor);
4449
this.serverStatusManager = serverStatusManager;
50+
this.repoStatusManager = repoStatusManager;
4551
}
4652

4753
/**
@@ -54,6 +60,17 @@ public ServerStatus status() {
5460
return ServerStatus.of(executor().isWritable(), executor().isStarted());
5561
}
5662

63+
/**
64+
* GET /status/repos/read-only
65+
*
66+
* <p>Returns the projects and repositories that are currently read-only. A project-scoped entry uses
67+
* {@code dogma} as its repository name.
68+
*/
69+
@Get("/status/repos/read-only")
70+
public List<RepositoryState> readOnlyRepositories() {
71+
return repoStatusManager.readOnlyStatuses();
72+
}
73+
5774
/**
5875
* PUT /status
5976
*

server/src/main/java/com/linecorp/centraldogma/server/internal/management/RepoStatusManager.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
package com.linecorp.centraldogma.server.internal.management;
1818

19+
import static com.google.common.collect.ImmutableList.toImmutableList;
20+
1921
import java.time.Instant;
22+
import java.util.List;
2023
import java.util.Map;
2124
import java.util.concurrent.CompletableFuture;
2225
import java.util.concurrent.ConcurrentHashMap;
@@ -27,6 +30,7 @@
2730

2831
import com.fasterxml.jackson.core.JsonParseException;
2932
import com.fasterxml.jackson.databind.JsonMappingException;
33+
import com.google.common.collect.ImmutableList;
3034

3135
import com.linecorp.armeria.common.util.Exceptions;
3236
import com.linecorp.centraldogma.common.Author;
@@ -44,6 +48,11 @@
4448
import com.linecorp.centraldogma.server.storage.repository.Repository;
4549
import com.linecorp.centraldogma.server.storage.repository.RepositoryListener;
4650

51+
import io.micrometer.core.instrument.Gauge;
52+
import io.micrometer.core.instrument.MeterRegistry;
53+
import io.micrometer.core.instrument.MultiGauge;
54+
import io.micrometer.core.instrument.Tags;
55+
4756
/**
4857
* Manages the replication status of repositories and projects. The replication status is stored in a special
4958
* repository named "dogma" in dogma project
@@ -65,11 +74,17 @@ public final class RepoStatusManager {
6574
@Nullable
6675
private final StandaloneCrudOperation<RepositoryState> crudRepository;
6776
private final ServerStatusManager statusManager;
77+
private final MultiGauge readOnlyScopeGauge;
6878

69-
public RepoStatusManager(ServerStatusManager statusManager, ProjectManager pm) {
79+
public RepoStatusManager(ServerStatusManager statusManager, ProjectManager pm,
80+
MeterRegistry meterRegistry) {
7081
this.pm = pm;
7182
this.statusManager = statusManager;
7283
crudRepository = new StandaloneCrudOperation<>(RepositoryState.class, pm);
84+
85+
// read-only scope metrics
86+
Gauge.builder("repository.read.only.count", statusMap, Map::size).register(meterRegistry);
87+
readOnlyScopeGauge = MultiGauge.builder("repository.read.only").register(meterRegistry);
7388
}
7489

7590
public void initialize() {
@@ -92,6 +107,7 @@ public void initialize() {
92107
statusMap.put(getKey(repoState.projectName(), repoState.repoName()), repoState);
93108
}
94109
}
110+
updateReadOnlyScopeMetrics();
95111
}));
96112
}
97113

@@ -133,6 +149,15 @@ public RepositoryState getRepoStatus(String projectName, String repoName) {
133149
return new RepositoryState(projectName, repoName, ReplicationStatus.WRITABLE, null);
134150
}
135151

152+
/**
153+
* Returns the {@link RepositoryState}s of all projects and repositories that are currently not
154+
* {@link ReplicationStatus#WRITABLE}. A project-scoped read-only entry uses {@link Project#REPO_DOGMA}
155+
* as its repository name.
156+
*/
157+
public List<RepositoryState> readOnlyStatuses() {
158+
return ImmutableList.copyOf(statusMap.values());
159+
}
160+
136161
@Nullable
137162
private RepositoryState getRepoStatus0(String projectName, String repoName) {
138163
if (!statusManager.serverStatus().writable()) {
@@ -193,4 +218,17 @@ private static CrudContext crudContext(String projectName) {
193218
return new CrudContext(InternalProjectInitializer.INTERNAL_PROJECT_DOGMA,
194219
Project.REPO_DOGMA, targetPath);
195220
}
221+
222+
private void updateReadOnlyScopeMetrics() {
223+
readOnlyScopeGauge.register(
224+
statusMap.values().stream()
225+
.<MultiGauge.Row<?>>map(state -> MultiGauge.Row.of(
226+
Tags.of("project", state.projectName(),
227+
"repo", state.repoName(),
228+
"scope", state.repoName().equals(Project.REPO_DOGMA)
229+
? "project" : "repository"),
230+
1))
231+
.collect(toImmutableList()),
232+
true);
233+
}
196234
}

server/src/main/java/com/linecorp/centraldogma/server/internal/management/RepositoryState.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.jspecify.annotations.Nullable;
2525

2626
import com.fasterxml.jackson.annotation.JsonCreator;
27+
import com.fasterxml.jackson.annotation.JsonFormat;
2728
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
2829
import com.fasterxml.jackson.annotation.JsonInclude;
2930
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -39,6 +40,8 @@ public final class RepositoryState {
3940
private final String projectName;
4041
private final String repoName;
4142
private final ReplicationStatus status;
43+
// Serialize as an ISO-8601 string to match the rest of the API (e.g. RepositoryDto.createdAt).
44+
@JsonFormat(shape = JsonFormat.Shape.STRING)
4245
@Nullable
4346
private final Instant updatedAt;
4447

server/src/test/java/com/linecorp/centraldogma/server/internal/api/ServerStatusServiceTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson;
2121
import static org.assertj.core.api.Assertions.assertThat;
2222
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23+
import static org.awaitility.Awaitility.await;
2324

2425
import org.junit.jupiter.api.Test;
2526
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -32,6 +33,7 @@
3233
import com.linecorp.armeria.common.AggregatedHttpResponse;
3334
import com.linecorp.armeria.common.HttpHeaderNames;
3435
import com.linecorp.armeria.common.HttpStatus;
36+
import com.linecorp.centraldogma.common.ReplicationStatus;
3537
import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest;
3638
import com.linecorp.centraldogma.server.internal.api.sysadmin.UpdateServerStatusRequest.Scope;
3739
import com.linecorp.centraldogma.server.management.ServerStatus;
@@ -62,6 +64,37 @@ void status() {
6264
assertThat(res.contentUtf8()).isEqualTo("\"WRITABLE\"");
6365
}
6466

67+
@Test
68+
void readOnlyRepositories() {
69+
final BlockingWebClient client = dogma.httpClient().blocking();
70+
71+
// No read-only repository initially (an empty result is returned as 204 No Content).
72+
assertThat(client.get(API_V1_PATH_PREFIX + "status/repos/read-only").status())
73+
.isEqualTo(HttpStatus.NO_CONTENT);
74+
75+
// Set a repository read-only.
76+
dogma.client().createProject("foo").join();
77+
dogma.client().createRepository("foo", "bar").join();
78+
final AggregatedHttpResponse updateRes =
79+
client.prepare()
80+
.put(API_V1_PATH_PREFIX + "projects/foo/repos/bar/status")
81+
.contentJson(new UpdateRepositoryStatusRequest(ReplicationStatus.READ_ONLY))
82+
.execute();
83+
assertThat(updateRes.status()).isEqualTo(HttpStatus.OK);
84+
85+
// The read-only repository should be listed.
86+
await().untilAsserted(() -> {
87+
final AggregatedHttpResponse res = client.get(API_V1_PATH_PREFIX + "status/repos/read-only");
88+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
89+
assertThatJson(res.contentUtf8()).isArray().ofLength(1);
90+
assertThatJson(res.contentUtf8()).node("[0].projectName").isEqualTo("foo");
91+
assertThatJson(res.contentUtf8()).node("[0].repoName").isEqualTo("bar");
92+
assertThatJson(res.contentUtf8()).node("[0].status").isEqualTo("READ_ONLY");
93+
// updatedAt must serialize as an ISO-8601 string, not an epoch number (regression guard).
94+
assertThatJson(res.contentUtf8()).node("[0].updatedAt").isString();
95+
});
96+
}
97+
6598
@Test
6699
void updateStatus_setUnwritable() {
67100
final AggregatedHttpResponse res = updateStatus(ServerStatus.REPLICATION_ONLY);

server/src/test/java/com/linecorp/centraldogma/server/internal/api/auth/RequiresRoleTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ protected void configure(ServerBuilder sb) throws Exception {
9292
NoopEncryptionStorageManager.INSTANCE,
9393
com.google.common.collect.ImmutableMap.of());
9494
final ServerStatusManager statusManager = new ServerStatusManager(dataDir);
95-
final RepoStatusManager repoStatusManager = new RepoStatusManager(statusManager, pm);
95+
final RepoStatusManager repoStatusManager =
96+
new RepoStatusManager(statusManager, pm, NoopMeterRegistry.get());
9697
final CommandExecutor executor = new StandaloneCommandExecutor(
9798
pm, ForkJoinPool.commonPool(), statusManager, repoStatusManager, null,
9899
NoopEncryptionStorageManager.INSTANCE,

server/src/test/java/com/linecorp/centraldogma/server/internal/management/RepoStatusManagerTest.java

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.linecorp.centraldogma.server.internal.management;
1818

1919
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.tuple;
2021

2122
import org.junit.jupiter.api.BeforeEach;
2223
import org.junit.jupiter.api.Test;
@@ -26,16 +27,22 @@
2627
import com.linecorp.centraldogma.common.ReplicationStatus;
2728
import com.linecorp.centraldogma.testing.internal.ProjectManagerExtension;
2829

30+
import io.micrometer.core.instrument.MeterRegistry;
31+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
32+
2933
class RepoStatusManagerTest {
3034

3135
@RegisterExtension
3236
static ProjectManagerExtension pmExtension = new ProjectManagerExtension();
3337

3438
private RepoStatusManager statusManager;
39+
private MeterRegistry meterRegistry;
3540

3641
@BeforeEach
3742
void setUp() {
38-
statusManager = new RepoStatusManager(pmExtension.serverStatusManager(), pmExtension.projectManager());
43+
meterRegistry = new SimpleMeterRegistry();
44+
statusManager = new RepoStatusManager(pmExtension.serverStatusManager(), pmExtension.projectManager(),
45+
meterRegistry);
3946
statusManager.initialize();
4047
}
4148

@@ -75,4 +82,41 @@ void projectStatus_cruTest() {
7582
assertThat(entity).isEqualTo(
7683
new RepositoryState("test_prj", "dogma", ReplicationStatus.WRITABLE, null));
7784
}
85+
86+
@Test
87+
void readOnlyStatuses_and_metrics() {
88+
assertThat(statusManager.readOnlyStatuses()).isEmpty();
89+
assertThat(readOnlyCount()).isZero();
90+
91+
statusManager.updateRepoStatus("test_prj", "test_repo", Author.DEFAULT, ReplicationStatus.READ_ONLY)
92+
.join();
93+
statusManager.updateProjectStatus("test_prj2", Author.DEFAULT, ReplicationStatus.READ_ONLY).join();
94+
95+
assertThat(statusManager.readOnlyStatuses())
96+
.extracting(RepositoryState::projectName, RepositoryState::repoName, RepositoryState::status)
97+
.containsExactlyInAnyOrder(
98+
tuple("test_prj", "test_repo", ReplicationStatus.READ_ONLY),
99+
tuple("test_prj2", "dogma", ReplicationStatus.READ_ONLY));
100+
101+
assertThat(readOnlyCount()).isEqualTo(2);
102+
assertThat(scopeGauge("test_prj", "test_repo", "repository")).isOne();
103+
assertThat(scopeGauge("test_prj2", "dogma", "project")).isOne();
104+
105+
// Reverting to WRITABLE removes the scope from the list and the metrics.
106+
statusManager.updateRepoStatus("test_prj", "test_repo", Author.DEFAULT, ReplicationStatus.WRITABLE)
107+
.join();
108+
statusManager.updateProjectStatus("test_prj2", Author.DEFAULT, ReplicationStatus.WRITABLE).join();
109+
assertThat(statusManager.readOnlyStatuses()).isEmpty();
110+
assertThat(readOnlyCount()).isZero();
111+
}
112+
113+
private double readOnlyCount() {
114+
return meterRegistry.get("repository.read.only.count").gauge().value();
115+
}
116+
117+
private double scopeGauge(String projectName, String repoName, String scope) {
118+
return meterRegistry.get("repository.read.only")
119+
.tags("project", projectName, "repo", repoName, "scope", scope)
120+
.gauge().value();
121+
}
78122
}

testing-internal/src/main/java/com/linecorp/centraldogma/testing/internal/ProjectManagerExtension.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,10 @@ public void recreateProjectManager() {
172172
* Override this method to customize a {@link CommandExecutor}.
173173
*/
174174
protected CommandExecutor newCommandExecutor(ProjectManager projectManager, Executor worker, File dataDir) {
175+
final RepoStatusManager repoStatusManager =
176+
new RepoStatusManager(serverStatusManager, projectManager, NoopMeterRegistry.get());
175177
return new StandaloneCommandExecutor(projectManager, worker, serverStatusManager,
176-
new RepoStatusManager(serverStatusManager, projectManager), null,
178+
repoStatusManager, null,
177179
NoopEncryptionStorageManager.INSTANCE, null, null, null,
178180
null);
179181
}

0 commit comments

Comments
 (0)