| // Copyright (C) 2024 The Android Open Source Project |
| // |
| // 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. |
| |
| import com.google.common.cache.* |
| import com.google.gerrit.common.data.* |
| import com.google.common.flogger.* |
| import com.google.common.util.concurrent.* |
| import com.google.gerrit.common.* |
| import com.google.gerrit.entities.* |
| import com.google.gerrit.extensions.annotations.* |
| import com.google.gerrit.extensions.events.* |
| import com.google.gerrit.extensions.registration.* |
| import com.google.gerrit.server.query.group.* |
| import com.google.gerrit.metrics.* |
| import com.google.gerrit.lifecycle.* |
| import com.google.gerrit.server.* |
| import com.google.gerrit.server.account.* |
| import com.google.gerrit.server.cache.* |
| import com.google.gerrit.server.config.* |
| import com.google.gerrit.server.project.* |
| import com.google.gerrit.sshd.* |
| import com.google.inject.* |
| import com.google.inject.name.* |
| |
| import java.time.* |
| import java.util.function.* |
| import java.util.stream.Collectors |
| |
| import org.kohsuke.args4j.* |
| |
| import static java.util.concurrent.TimeUnit.* |
| |
| @Singleton |
| class ActiveUsersMetric { |
| |
| static final NAME = "active_users" |
| static final DESCRIPTION = "Number of active users" |
| |
| @Inject |
| public ActiveUsersMetric(MetricMaker metrics, @Named(TrackActiveUsersCache.NAME) Cache<Integer, Long> trackActiveUsersCache){ |
| metrics.newCallbackMetric( |
| NAME, |
| Long.class, |
| new Description(DESCRIPTION).setGauge().setUnit("users"), |
| { -> trackActiveUsersCache.size() } |
| ); |
| } |
| } |
| |
| class TrackActiveUsersCache extends CacheModule { |
| static final NAME = "users_cache" |
| static final DEFAULT_CACHE_TTL = Duration.ofDays(90) |
| static final MAX_SIZE = 300_000 |
| |
| @Override |
| protected void configure() { |
| persist(NAME, Integer, Long) |
| .diskLimit(MAX_SIZE) |
| .maximumWeight(MAX_SIZE) |
| .expireAfterWrite(DEFAULT_CACHE_TTL) |
| } |
| } |
| |
| @Singleton |
| class TrackingGroupBackend implements GroupBackend { |
| @Inject |
| @Named(TrackActiveUsersCache.NAME) |
| Cache<Integer, Long> trackActiveUsersCache |
| |
| @Inject |
| Provider<CurrentUser> currentUserProvider |
| |
| @Override |
| boolean handles(AccountGroup.UUID uuid) { |
| return true |
| } |
| |
| @Override |
| GroupDescription.Basic get(AccountGroup.UUID uuid) { |
| return null |
| } |
| |
| @Override |
| Collection<GroupReference> suggest(String name, @Nullable ProjectState project) { |
| return Collections.emptyList() |
| } |
| |
| @Override |
| GroupMembership membershipsOf(CurrentUser user) { |
| def currentUser = currentUserProvider.get() |
| |
| if (currentUser.identifiedUser) { |
| def accountId = currentUser.accountId.get() |
| def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis()) |
| if (trackActiveUsersCache.getIfPresent(accountId) != currentMinutes) { |
| trackActiveUsersCache.put(accountId, currentMinutes) |
| } |
| } |
| return GroupMembership.EMPTY |
| } |
| |
| @Override |
| boolean isVisibleToAll(AccountGroup.UUID uuid) { |
| return false |
| } |
| } |
| |
| @Singleton |
| class AutoDisableInactiveUsersConfig { |
| static final FluentLogger logger = FluentLogger.forEnclosingClass() |
| |
| final Set<Account.Id> ignoreAccountIds |
| final Set<AccountGroup.UUID> ignoreGroupIds |
| final boolean preloadAccounts |
| final boolean disableAccounts |
| |
| private final PluginConfig config |
| |
| @Inject |
| AutoDisableInactiveUsersConfig( |
| PluginConfigFactory configFactory, |
| GroupCache groupCache, |
| InternalGroupBackend internalGroupBackend, |
| IdentifiedUser.GenericFactory userFactory, |
| Accounts accounts, |
| @PluginName String pluginName |
| ) { |
| config = configFactory.getFromGerritConfig(pluginName) |
| |
| ignoreAccountIds = ignoreAccountIdsFromConfig("ignoreAccountId") |
| ignoreGroupIds = ignoreGroupIdsFromConfig("ignoreGroup", groupCache) |
| preloadAccounts = config.getBoolean("preloadAccounts", true) |
| disableAccounts = config.getBoolean("disableAccounts", true) |
| |
| logger.atInfo().log("Accounts ids ignored for inactivity: %s", ignoreAccountIds) |
| logger.atInfo().log("Group ids ignored for inactivity: %s", ignoreGroupIds) |
| |
| def impliedAccountIds = accounts.all().findAll { |
| internalGroupBackend.membershipsOf(userFactory.create(it.account().id())).containsAnyOf(ignoreGroupIds) |
| }.collect { it.account.id() } |
| |
| logger.atInfo().log("Implied accounts ids ignored for inactivity: %s", impliedAccountIds) |
| ignoreAccountIds += impliedAccountIds |
| |
| logger.atInfo().log("Full list of accounts ids ignored for inactivity: %s", ignoreAccountIds) |
| } |
| |
| private Set<Account.Id> ignoreAccountIdsFromConfig(String name) { |
| def strings = config.getStringList(name) as Set |
| strings.stream() |
| .map(Account.Id.&tryParse) |
| .filter { it.isPresent() } |
| .map { it.get() } |
| .collect(Collectors.toSet()) |
| } |
| |
| private Set<AccountGroup.UUID> ignoreGroupIdsFromConfig(String name, GroupCache groupCache) { |
| def groups = config.getStringList(name) |
| def groupNames = groups.collect { AccountGroup.nameKey(it)} |
| groupNames |
| .collect { groupName -> |
| def group = groupCache.get(groupName) |
| if (group.empty) { |
| logger.atWarning().log("Group %s not found", groupName) |
| } |
| group |
| } |
| .findAll { it.present } |
| .collect { it.get().groupUUID } |
| .toSet() |
| } |
| } |
| |
| class AutoDisableInactiveUsersEvictionListener implements CacheRemovalListener<Integer, Long> { |
| static final FluentLogger logger = FluentLogger.forEnclosingClass() |
| |
| private final String pluginName |
| private final String fullCacheName |
| private final Cache<Integer, Long> trackActiveUsersCache |
| private final Provider<AccountsUpdate> accountsUpdate |
| private final AutoDisableInactiveUsersConfig autoDisableConfig |
| |
| @Inject |
| AutoDisableInactiveUsersEvictionListener( |
| @PluginName String pluginName, |
| @ServerInitiated Provider<AccountsUpdate> accountsUpdate, |
| @Named(TrackActiveUsersCache.NAME) Cache<Integer, Long> trackActiveUsersCache, |
| AutoDisableInactiveUsersConfig autoDisableConfig |
| ) { |
| this.pluginName = pluginName |
| this.accountsUpdate = accountsUpdate |
| this.autoDisableConfig = autoDisableConfig |
| this.trackActiveUsersCache = trackActiveUsersCache |
| fullCacheName = "${pluginName}.${TrackActiveUsersCache.NAME}" |
| } |
| |
| @Override |
| void onRemoval(String pluginName, String cacheName, RemovalNotification<Integer, Long> notification) { |
| if (fullCacheName != cacheName) { |
| return |
| } |
| |
| def accountId = Account.id(notification.key) |
| |
| if (notification.cause == RemovalCause.EXPIRED) { |
| if (autoDisableConfig.ignoreAccountIds.contains(accountId)) { |
| logger.atWarning().log("Account %s ignored from the auto-disable mechanism", accountId) |
| trackActiveUsersCache.put(notification.key, notification.value) |
| } else { |
| disableAccount(accountId) |
| } |
| } else if (notification.cause == RemovalCause.EXPLICIT) { |
| logger.atWarning().log( |
| "cache %s do not support eviction, entry for user %d will be added back", fullCacheName, notification.key) |
| trackActiveUsersCache.put(notification.key, notification.value) |
| } |
| } |
| |
| private void disableAccount(Account.Id accountId) { |
| if (!autoDisableConfig.disableAccounts || autoDisableConfig.ignoreAccountIds.contains(accountId)) { |
| return |
| } |
| |
| logger.atInfo().log("Automatically disabling user id: %d", accountId.get()) |
| accountsUpdate.get().update( |
| """Automatically disabling after inactivity |
| |
| Disabled by ${pluginName}""", |
| accountId, |
| new Consumer<AccountDelta.Builder>() { |
| @Override |
| void accept(AccountDelta.Builder builder) { |
| builder.setActive(false) |
| } |
| }) |
| } |
| } |
| |
| @Singleton |
| class AutoDisableInactiveUsersListener implements LifecycleListener { |
| static final FluentLogger logger = FluentLogger.forEnclosingClass() |
| |
| @Inject |
| Accounts accounts |
| |
| @Inject |
| ServiceUserClassifier serviceUserClassifier |
| |
| @Inject |
| @Named(TrackActiveUsersCache.NAME) |
| Cache<Integer, Long> trackActiveUsersCache |
| |
| @Inject |
| AutoDisableInactiveUsersConfig autoDisableConfig |
| |
| @Override |
| void start() { |
| if (autoDisableConfig.preloadAccounts) { |
| def accountsToPreload = accounts.all() |
| .collect { it.account() } |
| .findAll { it.isActive() } |
| .findAll { !serviceUserClassifier.isServiceUser(it.id()) } |
| .findAll { !trackActiveUsersCache.getIfPresent(it.id().get()) } |
| |
| def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis()) |
| def numAccountsToPreload = accountsToPreload.size() |
| logger.atInfo().log("Preloading $numAccountsToPreload accounts into ${TrackActiveUsersCache.NAME} with TS=$currentMinutes") |
| accountsToPreload.each { trackActiveUsersCache.put(it.id().get(), currentMinutes) } |
| } |
| } |
| |
| @Override |
| void stop() { |
| // no-op |
| } |
| } |
| |
| abstract class BaseSshCommand extends SshCommand { |
| void println(String msg, boolean verbose = false) { |
| if (verbose) { |
| stdout.println msg |
| } |
| stdout.flush() |
| } |
| void error(String msg) { println("[ERROR] $msg", true) } |
| void warning(String msg) { println("[WARNING] $msg", true) } |
| } |
| |
| @CommandMetaData(name = "list", description = "List all the active accounts and their latest activity timestamp") |
| @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER) |
| class ListActiveAccountsCommand extends BaseSshCommand { |
| |
| @Option(name = "--verbose", usage = "Display verbose output") |
| boolean verbose = false |
| |
| @Inject |
| @Named(TrackActiveUsersCache.NAME) |
| private final Cache<Integer, Long> trackActiveUsersCache |
| |
| @Inject |
| Accounts accounts |
| |
| @Override |
| public void run() { |
| println("Fetching all active account ids ... ", verbose) |
| def allActiveAccountsIds = accounts.all() |
| .collect { it.account() } |
| .findAll { it.isActive() } |
| .collect { it.id().get() } |
| println("Number of active accounts fetched: ${allActiveAccountsIds.size()}", verbose) |
| |
| println("AccountId | Last access [UTC] ", true) |
| println("==========|=============================", true) |
| def numTrackedActiveAccounts = 0 |
| allActiveAccountsIds.each { accountId -> |
| def ts = trackActiveUsersCache.getIfPresent(accountId) |
| if (ts != null) { |
| def epochTs = MINUTES.toMillis(ts) |
| def dateTime = new Date(epochTs) |
| def dateTimeString = dateTime.format("yyyy-MM-dd HH:mm:ss.SSSZ", TimeZone.getTimeZone('UTC')) |
| println(" $accountId | $dateTimeString", true) |
| numTrackedActiveAccounts += 1 |
| } else { |
| warning("Active account id $accountId is not tracked", verbose) |
| } |
| } |
| |
| println("Number of tracked active accounts: $numTrackedActiveAccounts", verbose) |
| } |
| } |
| |
| class TrackAndDisableInactiveUsersModule extends LifecycleModule { |
| @Override |
| void configure() { |
| install(new TrackActiveUsersCache()) |
| bind(ActiveUsersMetric.class).asEagerSingleton() |
| listener().to(AutoDisableInactiveUsersListener) |
| DynamicSet.bind(binder(), CacheRemovalListener).to(AutoDisableInactiveUsersEvictionListener) |
| |
| DynamicSet.bind(binder(), GroupBackend).to(TrackingGroupBackend) |
| } |
| } |
| |
| class TrackAndDisableInactiveUsersSshModule extends PluginCommandModule { |
| |
| @Inject |
| TrackAndDisableInactiveUsersSshModule(@PluginName String pluginName) { |
| super(pluginName) |
| } |
| |
| @Override |
| protected void configureCommands() { |
| command(ListActiveAccountsCommand.class); |
| } |
| } |
| |
| |
| modules = [TrackAndDisableInactiveUsersModule, TrackAndDisableInactiveUsersSshModule] |