/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends; import static org.forgerock.opendj.ldap.schema.CoreSchema.*; import static org.opends.messages.BackendMessages.*; import static org.opends.messages.ReplicationMessages.*; import static org.opends.server.config.ConfigConstants.*; import static org.opends.server.replication.plugin.MultimasterReplication.*; import static org.opends.server.replication.server.changelog.api.DBCursor.KeyMatchingStrategy.*; import static org.opends.server.replication.server.changelog.api.DBCursor.PositionStrategy.*; import static org.opends.server.util.LDIFWriter.*; import static org.opends.server.util.ServerConstants.*; import static org.opends.server.util.StaticUtils.*; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TimeZone; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.atomic.AtomicReference; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.ldap.AttributeDescription; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.ConditionResult; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ModificationType; import org.forgerock.opendj.ldap.RDN; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.schema.AttributeType; import org.forgerock.opendj.ldap.schema.CoreSchema; import org.forgerock.opendj.ldap.schema.ObjectClass; import org.forgerock.opendj.server.config.server.LocalBackendCfg; import org.opends.server.api.LocalBackend; import org.opends.server.controls.EntryChangelogNotificationControl; import org.opends.server.controls.ExternalChangelogRequestControl; import org.opends.server.core.AddOperation; import org.opends.server.core.DeleteOperation; import org.opends.server.core.DirectoryServer; import org.opends.server.core.ModifyDNOperation; import org.opends.server.core.ModifyOperation; import org.opends.server.core.PersistentSearch; import org.opends.server.core.SearchOperation; import org.opends.server.core.ServerContext; import org.opends.server.replication.common.CSN; import org.opends.server.replication.common.MultiDomainServerState; import org.opends.server.replication.common.ServerState; import org.opends.server.replication.protocol.AddMsg; import org.opends.server.replication.protocol.DeleteMsg; import org.opends.server.replication.protocol.LDAPUpdateMsg; import org.opends.server.replication.protocol.ModifyCommonMsg; import org.opends.server.replication.protocol.ModifyDNMsg; import org.opends.server.replication.protocol.UpdateMsg; import org.opends.server.replication.server.ReplicationServer; import org.opends.server.replication.server.ReplicationServerDomain; import org.opends.server.replication.server.changelog.api.ChangeNumberIndexDB; import org.opends.server.replication.server.changelog.api.ChangeNumberIndexRecord; import org.opends.server.replication.server.changelog.api.ChangelogDB; import org.opends.server.replication.server.changelog.api.ChangelogException; import org.opends.server.replication.server.changelog.api.DBCursor; import org.opends.server.replication.server.changelog.api.DBCursor.CursorOptions; import org.opends.server.replication.server.changelog.api.ReplicaId; import org.opends.server.replication.server.changelog.api.ReplicationDomainDB; import org.opends.server.replication.server.changelog.file.ECLEnabledDomainPredicate; import org.opends.server.replication.server.changelog.file.ECLMultiDomainDBCursor; import org.opends.server.replication.server.changelog.file.MultiDomainDBCursor; import org.opends.server.types.Attribute; import org.opends.server.types.Attributes; import org.opends.server.types.BackupConfig; import org.opends.server.types.BackupDirectory; import org.opends.server.types.CanceledOperationException; import org.opends.server.types.Control; import org.opends.server.types.DirectoryException; import org.opends.server.types.Entry; import org.opends.server.types.FilterType; import org.opends.server.types.IndexType; import org.opends.server.types.InitializationException; import org.opends.server.types.LDIFExportConfig; import org.opends.server.types.LDIFImportConfig; import org.opends.server.types.LDIFImportResult; import org.opends.server.types.Modification; import org.opends.server.types.Privilege; import org.opends.server.types.RawAttribute; import org.opends.server.types.RestoreConfig; import org.opends.server.types.SearchFilter; import org.opends.server.types.WritabilityMode; import org.opends.server.util.StaticUtils; /** * A backend that provides access to the changelog, i.e. the "cn=changelog" * suffix. It is a read-only backend that is created by a * {@link ReplicationServer} and is not configurable. *
* There are two modes to search the changelog: *
changeNumber attribute is not returned with
* the entries.changeNumber attribute value is set from the content of
* ChangeNumberIndexDB.* Here is the flow of searches within the changelog backend APIs: *
changesOnly=false go through:
* changesOnly=true go through:
* * Only the base changelog entry can be retrieved by DN. Change records are streamed by * {@link #search(SearchOperation)}, which needs the search filter and the change number range to * position its cursors, so they cannot be looked up individually and {@code null} is returned for * them. None of them is ever an alias, so callers dereferencing aliases get the answer they need. */ @Override public Entry getEntry(final DN entryDN) throws DirectoryException { if (entryDN == null) { throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(), ERR_BACKEND_GET_ENTRY_NULL.get(getBackendID())); } if (CHANGELOG_BASE_DN.equals(entryDN)) { return buildBaseChangelogEntry(); } return null; } @Override public ConditionResult hasSubordinates(final DN entryDN) throws DirectoryException { if (CHANGELOG_BASE_DN.equals(entryDN)) { final Boolean hasSubs = baseChangelogHasSubordinates(); if (hasSubs == null) { return ConditionResult.UNDEFINED; } return ConditionResult.valueOf(hasSubs); } return ConditionResult.FALSE; } private Boolean baseChangelogHasSubordinates() throws DirectoryException { if (baseEntryHasSubordinates == null) { // compute its value try { final ReplicationDomainDB replicationDomainDB = getChangelogDB().getReplicationDomainDB(); CursorOptions options = new CursorOptions(GREATER_THAN_OR_EQUAL_TO_KEY, ON_MATCHING_KEY); try (final MultiDomainDBCursor cursor = replicationDomainDB.getCursorFrom(new MultiDomainServerState(), options, getExcludedBaseDNs())) { baseEntryHasSubordinates = cursor.next(); } } catch (ChangelogException e) { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_CHANGELOG_BACKEND_ATTRIBUTE.get( "hasSubordinates", DN_EXTERNAL_CHANGELOG_ROOT, stackTraceToSingleLineString(e))); } } return baseEntryHasSubordinates; } @Override public long getNumberOfEntriesInBaseDN(final DN baseDN) throws DirectoryException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_NUM_SUBORDINATES_NOT_SUPPORTED.get()); } @Override public long getNumberOfChildren(final DN parentDN) throws DirectoryException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_NUM_SUBORDINATES_NOT_SUPPORTED.get()); } /** * Notifies persistent searches of this backend that a new cookie entry was added to it. *
* Note: This method correspond to the "persistent search" phase. * It is executed multiple times per persistent search, multi-threaded, until the persistent search is cancelled. *
* This method must only be called after the provided data have been persisted to disk. * * @param baseDN * the baseDN of the newly added entry. * @param updateMsg * the update message of the newly added entry * @throws ChangelogException * If a problem occurs while notifying of the newly added entry. */ public void notifyCookieEntryAdded(DN baseDN, UpdateMsg updateMsg) throws ChangelogException { if (!(updateMsg instanceof LDAPUpdateMsg)) { return; } try { for (PersistentSearch pSearch : cookieBasedPersistentSearches) { final SearchOperation searchOp = pSearch.getSearchOperation(); final CookieEntrySender entrySender = searchOp.getAttachment(ENTRY_SENDER_ATTACHMENT); entrySender.persistentSearchSendEntry(baseDN, updateMsg); } } catch (DirectoryException e) { throw new ChangelogException(e.getMessageObject(), e); } } /** * Notifies persistent searches of this backend that a new change number entry was added to it. *
* Note: This method correspond to the "persistent search" phase. * It is executed multiple times per persistent search, multi-threaded, until the persistent search is cancelled. *
* This method must only be called after the provided data have been persisted to disk. * * @param baseDN * the baseDN of the newly added entry. * @param changeNumber * the change number of the newly added entry. It will be greater * than zero for entries added to the change number index and less * than or equal to zero for entries added to any replica DB * @param cookieString * a string representing the cookie of the newly added entry. * This is only meaningful for entries added to the change number index * @param updateMsg * the update message of the newly added entry * @throws ChangelogException * If a problem occurs while notifying of the newly added entry. */ public void notifyChangeNumberEntryAdded(DN baseDN, long changeNumber, String cookieString, UpdateMsg updateMsg) throws ChangelogException { if (!(updateMsg instanceof LDAPUpdateMsg) || changeNumberBasedPersistentSearches.isEmpty()) { return; } try { // changeNumber entry can be shared with multiple persistent searches final Entry changeNumberEntry = createEntryFromMsg(baseDN, changeNumber, cookieString, updateMsg); for (PersistentSearch pSearch : changeNumberBasedPersistentSearches) { final SearchOperation searchOp = pSearch.getSearchOperation(); final ChangeNumberEntrySender entrySender = searchOp.getAttachment(ENTRY_SENDER_ATTACHMENT); entrySender.persistentSearchSendEntry(changeNumber, changeNumberEntry); } } catch (DirectoryException e) { throw new ChangelogException(e.getMessageObject(), e); } } private boolean isCookieBased(final SearchOperation searchOp) { for (Control c : searchOp.getRequestControls()) { if (OID_ECL_COOKIE_EXCHANGE_CONTROL.equals(c.getOID())) { return true; } } return false; } @Override public void addEntry(Entry entry, AddOperation addOperation) throws DirectoryException, CanceledOperationException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_BACKEND_ADD_NOT_SUPPORTED.get(String.valueOf(entry.getName()), getBackendID())); } @Override public void deleteEntry(DN entryDN, DeleteOperation deleteOperation) throws DirectoryException, CanceledOperationException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_BACKEND_DELETE_NOT_SUPPORTED.get(String.valueOf(entryDN), getBackendID())); } @Override public void replaceEntry(Entry oldEntry, Entry newEntry, ModifyOperation modifyOperation) throws DirectoryException, CanceledOperationException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_BACKEND_MODIFY_NOT_SUPPORTED.get(String.valueOf(newEntry.getName()), getBackendID())); } @Override public void renameEntry(DN currentDN, Entry entry, ModifyDNOperation modifyDNOperation) throws DirectoryException, CanceledOperationException { throw new DirectoryException(ResultCode.UNWILLING_TO_PERFORM, ERR_BACKEND_MODIFY_DN_NOT_SUPPORTED.get(String.valueOf(currentDN), getBackendID())); } /** * {@inheritDoc} *
* Runs the "initial search" phase (as opposed to a "persistent search"
* phase). The "initial search" phase is the only search run by normal
* searches, but it is also run by persistent searches with
* changesOnly=false. Persistent searches with
* changesOnly=true never execute this code.
*
* Note: this method is executed only once per persistent search, single
* threaded.
*/
@Override
public void search(final SearchOperation searchOperation) throws DirectoryException
{
checkChangelogReadPrivilege(searchOperation);
final Set
* This class should be visible for tests.
*/
static final class ChangeNumberRange
{
private long lowerBound = -1;
private long upperBound = -1;
/**
* Returns the lowest change number to retrieve (inclusive).
*
* @return the lowest change number
*/
long getLowerBound()
{
return lowerBound;
}
/**
* Returns the highest change number to retrieve (inclusive).
*
* @return the highest change number
*/
long getUpperBound()
{
return upperBound;
}
}
/**
* Returns the set of DNs to exclude from the search.
*
* @return the DNs corresponding to domains to exclude from the search.
* @throws DirectoryException
* If a DN can't be decoded.
*/
private static Set
* It updates the provided cookie with the changes from the provided ECL cursor,
* up to (and including) the provided change number index record.
*
* Therefore, after calling this method, the cursor is positioned
* to the change immediately following the provided change number index record.
*
* @param cookie the cookie to update
* @param cursor the cursor where to read changes from
* @param cnIndexRecord the change number index record to go right after
* @throws ChangelogException if any problem occurs
*/
public static void updateCookieToMediumConsistencyPoint(
MultiDomainServerState cookie, ECLMultiDomainDBCursor cursor, ChangeNumberIndexRecord cnIndexRecord)
throws ChangelogException
{
if (cnIndexRecord == null)
{
return;
}
while (cursor.next())
{
UpdateMsg updateMsg = cursor.getRecord();
if (updateMsg.getCSN().compareTo(cnIndexRecord.getCSN()) > 0)
{
break;
}
cookie.update(cursor.getData(), updateMsg.getCSN());
}
}
private MultiDomainDBCursor initializeReplicaUpdatesCursor(
final ChangeNumberIndexRecord cnIndexRecord) throws ChangelogException
{
final MultiDomainServerState state = new MultiDomainServerState();
state.update(cnIndexRecord.getBaseDN(), cnIndexRecord.getCSN());
// No need for ECLMultiDomainDBCursor in this case
// as updateMsg will be matched with cnIndexRecord
CursorOptions options = new CursorOptions(GREATER_THAN_OR_EQUAL_TO_KEY, ON_MATCHING_KEY);
final MultiDomainDBCursor replicaUpdatesCursor =
getChangelogDB().getReplicationDomainDB().getCursorFrom(state, options);
replicaUpdatesCursor.next();
return replicaUpdatesCursor;
}
/**
* Returns the replica update message corresponding to the provided
* cnIndexRecord.
*
* @return the update message, which may be {@code null} if the update message
* could not be found because it was purged or because corresponding
* baseDN was removed from the changelog
* @throws DirectoryException
* If inconsistency is detected between the available update
* messages and the provided cnIndexRecord
*/
private UpdateMsg findReplicaUpdateMessage(final MultiDomainDBCursor replicaUpdatesCursor, CSN csn)
throws ChangelogException, DirectoryException
{
while (true)
{
final UpdateMsg updateMsg = replicaUpdatesCursor.getRecord();
final int compareIndexWithUpdateMsg = csn.compareTo(updateMsg.getCSN());
if (compareIndexWithUpdateMsg < 0) {
// Either update message has been purged or baseDN has been removed from changelogDB,
// ignore current index record and go to the next one
return null;
}
else if (compareIndexWithUpdateMsg == 0)
{
// Found the matching update message
return updateMsg;
}
// Case compareIndexWithUpdateMsg > 0 : the update message has not bean reached yet
if (!replicaUpdatesCursor.next())
{
// Should never happen, as it means some messages have disappeared
// TODO : put the correct I18N message
throw new DirectoryException(ResultCode.OPERATIONS_ERROR,
LocalizableMessage.raw("Could not find replica update message matching index record. " +
"No more replica update messages with a csn newer than " + updateMsg.getCSN() + " exist."));
}
}
}
/** Returns a cursor on CNIndexDB for the provided first change number. */
private DBCursor
* Map addMsg to an LDIF string for the 'changes' attribute, and pull out
* change initiators name if available which is contained in the creatorsName
* attribute.
*/
private static Entry createAddMsg(final DN baseDN, final long changeNumber, final String cookie, final UpdateMsg msg)
throws DirectoryException
{
final AddMsg addMsg = (AddMsg) msg;
String changeInitiatorsName = null;
String ldifChanges = null;
try
{
final StringBuilder builder = new StringBuilder(256);
for (Attribute attr : addMsg.getAttributes())
{
if (!attr.isEmpty()
&& attr.getAttributeDescription().getAttributeType().equals(getCreatorsNameAttributeType()))
{
// This attribute is not multi-valued.
changeInitiatorsName = attr.iterator().next().toString();
}
for (ByteString value : attr)
{
builder.append(attr.getAttributeDescription());
appendLDIFSeparatorAndValue(builder, value);
builder.append('\n');
}
}
ldifChanges = builder.toString();
}
catch (Exception e)
{
logEncodingMessageError("add", addMsg.getDN(), e);
}
return createChangelogEntry(baseDN, changeNumber, cookie, addMsg, ldifChanges, "add", changeInitiatorsName);
}
/**
* Creates an entry from a modify message.
*
* Map the modifyMsg to an LDIF string for the 'changes' attribute, and pull
* out change initiators name if available which is contained in the
* modifiersName attribute.
*/
private static Entry createModifyMsg(final DN baseDN, final long changeNumber, final String cookie,
final UpdateMsg msg) throws DirectoryException
{
final ModifyCommonMsg modifyMsg = (ModifyCommonMsg) msg;
String changeInitiatorsName = null;
String ldifChanges = null;
try
{
final StringBuilder builder = new StringBuilder(128);
for (Modification mod : modifyMsg.getMods())
{
final Attribute attr = mod.getAttribute();
if (mod.getModificationType() == ModificationType.REPLACE
&& !attr.isEmpty()
&& attr.getAttributeDescription().getAttributeType().equals(getModifiersNameAttributeType()))
{
// This attribute is not multi-valued.
changeInitiatorsName = attr.iterator().next().toString();
}
final AttributeDescription attrDesc = attr.getAttributeDescription();
builder.append(mod.getModificationType());
builder.append(": ");
builder.append(attrDesc);
builder.append('\n');
for (ByteString value : attr)
{
builder.append(attrDesc);
appendLDIFSeparatorAndValue(builder, value);
builder.append('\n');
}
builder.append("-\n");
}
ldifChanges = builder.toString();
}
catch (Exception e)
{
logEncodingMessageError("modify", modifyMsg.getDN(), e);
}
final boolean isModifyDNMsg = modifyMsg instanceof ModifyDNMsg;
final Entry entry = createChangelogEntry(baseDN, changeNumber, cookie, modifyMsg, ldifChanges,
isModifyDNMsg ? "modrdn" : "modify", changeInitiatorsName);
if (isModifyDNMsg)
{
final ModifyDNMsg modDNMsg = (ModifyDNMsg) modifyMsg;
addAttribute(entry, "newrdn", modDNMsg.getNewRDN());
if (modDNMsg.getNewSuperior() != null)
{
addAttribute(entry, "newsuperior", modDNMsg.getNewSuperior());
}
addAttribute(entry, "deleteoldrdn", String.valueOf(modDNMsg.deleteOldRdn()));
}
return entry;
}
/**
* Log an encoding message error.
*
* @param messageType
* String identifying type of message. Should be "add" or "modify".
* @param entryDN
* DN of original entry
*/
private static void logEncodingMessageError(String messageType, DN entryDN, Exception exception)
{
logger.traceException(exception);
logger.error(LocalizableMessage.raw(
"An exception was encountered while trying to encode a replication " + messageType + " message for entry \""
+ entryDN + "\" into an External Change Log entry: " + exception.getMessage()));
}
private void checkChangelogReadPrivilege(SearchOperation searchOp) throws DirectoryException
{
if (!searchOp.getClientConnection().hasPrivilege(Privilege.CHANGELOG_READ, searchOp))
{
throw new DirectoryException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS,
NOTE_SEARCH_CHANGELOG_INSUFFICIENT_PRIVILEGES.get());
}
}
/**
* Create a changelog entry from a set of provided information. This is the part of
* entry creation common to all types of msgs (ADD, DEL, MOD, MODDN).
*/
private static Entry createChangelogEntry(final DN baseDN, final long changeNumber, final String cookie,
final LDAPUpdateMsg msg, final String ldifChanges, final String changeType,
final String changeInitiatorsName) throws DirectoryException
{
final CSN csn = msg.getCSN();
String dnString;
if (changeNumber > 0)
{
// change number mode
dnString = "changeNumber=" + changeNumber + "," + DN_EXTERNAL_CHANGELOG_ROOT;
}
else
{
// Cookie mode
dnString = "replicationCSN=" + csn + "," + baseDN + "," + DN_EXTERNAL_CHANGELOG_ROOT;
}
final Map
* "initial search" phase must return the base entry immediately.
*
* @return {@code true} if search should continue, {@code false} otherwise
*/
private boolean sendBaseChangelogEntry(SearchOperation searchOp) throws DirectoryException
{
final DN baseDN = searchOp.getBaseDN();
final SearchFilter filter = searchOp.getFilter();
final SearchScope scope = searchOp.getScope();
if (ChangelogBackend.CHANGELOG_BASE_DN.isInScopeOf(baseDN, scope))
{
final Entry entry = buildBaseChangelogEntry();
if (filter.matchesEntry(entry) && !searchOp.returnEntry(entry, null))
{
// Abandon, size limit reached.
return false;
}
}
return !baseDN.equals(ChangelogBackend.CHANGELOG_BASE_DN)
|| !scope.equals(SearchScope.BASE_OBJECT);
}
private Entry buildBaseChangelogEntry() throws DirectoryException
{
final String hasSubordinatesStr = Boolean.toString(baseChangelogHasSubordinates());
final Map