blob: 551d619ac8c4b789d957b95d7a25178c1c12b9d4 [file] [log] [blame]
// 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]