StatusService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed 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
 *
 *     http://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 org.entrystore.rest.springboot.service;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.entrystore.PrincipalManager;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.backup.BackupScheduler;
import org.entrystore.repository.config.Settings;
import org.entrystore.repository.security.Password;
import org.entrystore.repository.util.SolrSearchIndex;
import org.entrystore.rest.springboot.configuration.AppStartedListener;
import org.entrystore.rest.springboot.configuration.AuthFeatureProperties;
import org.entrystore.rest.springboot.configuration.CorsProperties;
import org.entrystore.rest.springboot.configuration.EchoProperties;
import org.entrystore.rest.springboot.configuration.IndexedListSettings;
import org.entrystore.rest.springboot.configuration.InfoAppPropertiesConfiguration;
import org.entrystore.rest.springboot.configuration.StatusReportProperties;
import org.entrystore.rest.springboot.model.api.StatusExtendedIncludeEnum;
import org.entrystore.rest.springboot.model.api.StatusExtendedResponse;
import org.entrystore.rest.springboot.model.api.StatusResponse;
import org.entrystore.rest.springboot.util.CaseFolding;
import org.entrystore.rest.springboot.util.PrincipalManagerUtil;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryManagerMXBean;
import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import static org.entrystore.rest.springboot.configuration.StatusReportProperties.UNCONFIGURED;

@Service
@RequiredArgsConstructor
public class StatusService {

	private final InfoAppPropertiesConfiguration appConfig;
	private final StatusReportProperties statusProperties;
	private final AuthFeatureProperties authFeatures;
	private final Environment environment;
	private final CorsProperties corsProperties;
	private final EchoProperties echoProperties;

	private final RepositoryManagerImpl repositoryManager;
	private final AppStartedListener appStartedListener;
	private final RelationService relationService;
	private final Optional<BackupScheduler> backupScheduler;

	private List<String> signupWhitelist;

	/** Read once rather than per request: the reader scans every property source. */
	@PostConstruct
	void readSignupWhitelist() {
		signupWhitelist = IndexedListSettings.read(environment, Settings.SIGNUP_WHITELIST).stream()
				.map(CaseFolding::toLowerCase)
				.toList();
	}

	public boolean isUp() {
		return repositoryManager != null &&
			repositoryManager.getIndex() != null &&
			repositoryManager.getIndex().isUp();
	}

	public StatusResponse getStatus() {
		return new StatusResponse(
			appConfig.app().version(),
			repositoryManager != null ? "online" : "offline");
	}

	public StatusExtendedResponse getStatusExtended(List<StatusExtendedIncludeEnum> includeFields) {

		StatusExtendedResponse.StatusExtendedResponseBuilder builder = StatusExtendedResponse
			.fromStatusResponse(getStatus())
			.baseURI(repositoryManager.getRepositoryURL().toString())
			.rowstoreURL(statusProperties.rowstoreUrl())
			.startupTime(appStartedListener.getStartupTime().atZone(ZoneId.systemDefault()).toString())
			.repositoryType(statusProperties.repositoryType())
			.repositoryIndices(statusProperties.repositoryIndices())
			.quota(statusProperties.quota())
			.quotaDefault(statusProperties.quotaDefault())
			// Reported from the same bean EchoService enforces, so the report cannot disagree with the
			// cap actually applied.
			.echoMaxEntitySize(echoProperties.maxFileSize().toBytes())
			.cors(buildCorsInfo())
			.oaiHarvester(statusProperties.oaiHarvester())
			.oaiHarvesterMultiThreaded(statusProperties.oaiHarvesterMultiThreaded())
			.provenance(statusProperties.provenance())
			.auth(buildAuthenticationInfo())
			.solr(buildSolrInfo())
			.jvm(buildJvmInfo())
			.backup(buildBackupInfo());

		if (!CollectionUtils.isEmpty(includeFields)) {
			builder
				.countStats(
					includeFields.contains(StatusExtendedIncludeEnum.COUNT_STATS) ? buildCountStats() : null)
				.relationStats(
					!Collections.disjoint(
						includeFields,
						List.of(StatusExtendedIncludeEnum.RELATION_STATS, StatusExtendedIncludeEnum.RELATION_VERBOSE_STATS)
					) ? buildRelationStats(includeFields.contains(StatusExtendedIncludeEnum.RELATION_VERBOSE_STATS)) : null);
		}

		return builder.build();
	}

	// Every value comes from CorsProperties, the same bean EntryStoreCorsConfigurationSource builds its
	// policies from, so the report cannot disagree with the installed policy. Note that
	// entrystore.cors.origins defaults to "*": an unset key reports that effective wildcard rather
	// than claiming nothing is configured.
	private Map<String, Object> buildCorsInfo() {
		return Map.of(
			"enabled", corsProperties.enabled(),
			"headers", valueOrUnconfigured(corsProperties.headers()),
			"maxAge", corsProperties.maxAge() > -1
				? String.valueOf(corsProperties.maxAge())
				: UNCONFIGURED,
			"origins", valueOrUnconfigured(corsProperties.origins()),
			"originsAllowCredentials", valueOrUnconfigured(corsProperties.originsAllowCredentials())
		);
	}

	private static String valueOrUnconfigured(String value) {
		return value.isBlank() ? UNCONFIGURED : value;
	}

	private Map<String, Object> buildAuthenticationInfo() {
		return Map.of(
			"signup", authFeatures.signup(),
			"signupWhitelist", signupWhitelist,
			"passwordReset", authFeatures.passwordReset(),
			"passwordMaxLength", Password.PASSWORD_MAX_LENGTH
			//"authTokenCount", loginTokenCache.size() // not sure how to get this info in Spring-boot default in-memory session storage
		);
	}

	private Map<String, Object> buildSolrInfo() {
		SolrSearchIndex searchIndex = (SolrSearchIndex) repositoryManager.getIndex();
		return Map.of(
			"enabled", statusProperties.solrEnabled(),
			"reindexOnStartup", statusProperties.solrReindexOnStartup(),
			"status", searchIndex.isUp() ? "online" : "offline",
			"postQueueSize", searchIndex.getPostQueueSize(),
			"deleteQueueSize", searchIndex.getDeleteQueueSize(),
			"indexingContexts", searchIndex.getIndexingContexts(),
			"rejectedDocuments", searchIndex.getRejectedDocumentCount()
		);
	}

	private Map<String, Object> buildBackupInfo() {
		return Map.of(
			"active", backupScheduler.isPresent(),
			"format", statusProperties.backupFormat(),
			"formatResolved", backupScheduler.map(scheduler -> scheduler.getFormat().getName()).orElse(""),
			"maintenance", statusProperties.backupMaintenance(),
			"cronExpression", statusProperties.backupCronExpression(),
			"cronExpressionResolved", backupScheduler.map(BackupScheduler::getCronExpression).orElse(""),
			"maintenanceExpiresAfterDays", statusProperties.backupMaintenanceExpiresAfterDays(),
			"maintenanceLowerLimit", statusProperties.backupMaintenanceLowerLimit(),
			"maintenanceUpperLimit", statusProperties.backupMaintenanceUpperLimit()
		);
	}

	private Map<String, Object> buildJvmInfo() {
		return Map.of(
			"totalMemory", Runtime.getRuntime().totalMemory(),
			"freeMemory", Runtime.getRuntime().freeMemory(),
			"maxMemory", Runtime.getRuntime().maxMemory(),
			"availableProcessors", Runtime.getRuntime().availableProcessors(),
			"totalCommittedMemory", ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getCommitted() +
				ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getCommitted(),
			"committedHeap", ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getCommitted(),
			"totalUsedMemory", ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed() +
				ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getUsed(),
			"usedHeap", ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(),
			"gc", ManagementFactory.getGarbageCollectorMXBeans().stream()
				.map(MemoryManagerMXBean::getName)
				.collect(Collectors.toList())
		);
	}

	private Map<String, Object> buildCountStats() {
		PrincipalManager pm = repositoryManager.getPrincipalManager();
		return PrincipalManagerUtil.runAsAdmin(pm, () -> Map.of(
			"contextCount", repositoryManager.getContextManager().getEntries().size(),
			"groupCount", pm.getGroupUris().size(),
			"userCount", pm.getUsersAsUris().size(),
			"namedGraphCount", repositoryManager.getNamedGraphCount(),
			"tripleCount", repositoryManager.getTripleCount()
		));
	}

	private Map<String, Object> buildRelationStats(boolean verbose) {
		return relationService.getRelationStats(verbose);
	}
}