Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
3
server/config/README.md
Обычный файл
3
server/config/README.md
Обычный файл
@@ -0,0 +1,3 @@
|
||||
# config.json
|
||||
|
||||
This is the system configuration file for your Mattermost server. Settings are specific to different editions of Mattermost. Please read the documentation before making changes: https://docs.mattermost.com/configure/configuration-settings.html/
|
||||
372
server/config/client.go
Обычный файл
372
server/config/client.go
Обычный файл
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// GenerateClientConfig renders the given configuration for a client.
|
||||
func GenerateClientConfig(c *model.Config, telemetryID string, license *model.License) map[string]string {
|
||||
props := GenerateLimitedClientConfig(c, telemetryID, license)
|
||||
|
||||
props["EnableCustomUserStatuses"] = strconv.FormatBool(*c.TeamSettings.EnableCustomUserStatuses)
|
||||
props["EnableLastActiveTime"] = strconv.FormatBool(*c.TeamSettings.EnableLastActiveTime)
|
||||
props["EnableUserDeactivation"] = strconv.FormatBool(*c.TeamSettings.EnableUserDeactivation)
|
||||
props["RestrictDirectMessage"] = *c.TeamSettings.RestrictDirectMessage
|
||||
props["TeammateNameDisplay"] = *c.TeamSettings.TeammateNameDisplay
|
||||
props["LockTeammateNameDisplay"] = strconv.FormatBool(*c.TeamSettings.LockTeammateNameDisplay)
|
||||
props["ExperimentalPrimaryTeam"] = *c.TeamSettings.ExperimentalPrimaryTeam
|
||||
props["ExperimentalViewArchivedChannels"] = strconv.FormatBool(*c.TeamSettings.ExperimentalViewArchivedChannels)
|
||||
|
||||
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
|
||||
props["EnableOAuthServiceProvider"] = strconv.FormatBool(*c.ServiceSettings.EnableOAuthServiceProvider)
|
||||
props["GoogleDeveloperKey"] = *c.ServiceSettings.GoogleDeveloperKey
|
||||
props["EnableIncomingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableIncomingWebhooks)
|
||||
props["EnableOutgoingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingWebhooks)
|
||||
props["EnableCommands"] = strconv.FormatBool(*c.ServiceSettings.EnableCommands)
|
||||
props["EnablePostUsernameOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostUsernameOverride)
|
||||
props["EnablePostIconOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostIconOverride)
|
||||
props["EnableUserAccessTokens"] = strconv.FormatBool(*c.ServiceSettings.EnableUserAccessTokens)
|
||||
props["EnableLinkPreviews"] = strconv.FormatBool(*c.ServiceSettings.EnableLinkPreviews)
|
||||
props["EnablePermalinkPreviews"] = strconv.FormatBool(*c.ServiceSettings.EnablePermalinkPreviews)
|
||||
props["EnableTesting"] = strconv.FormatBool(*c.ServiceSettings.EnableTesting)
|
||||
props["EnableDeveloper"] = strconv.FormatBool(*c.ServiceSettings.EnableDeveloper)
|
||||
props["EnableClientPerformanceDebugging"] = strconv.FormatBool(*c.ServiceSettings.EnableClientPerformanceDebugging)
|
||||
props["PostEditTimeLimit"] = fmt.Sprintf("%v", *c.ServiceSettings.PostEditTimeLimit)
|
||||
props["MinimumHashtagLength"] = fmt.Sprintf("%v", *c.ServiceSettings.MinimumHashtagLength)
|
||||
props["EnablePreviewFeatures"] = strconv.FormatBool(*c.ServiceSettings.EnablePreviewFeatures)
|
||||
props["EnableTutorial"] = strconv.FormatBool(*c.ServiceSettings.EnableTutorial)
|
||||
props["EnableOnboardingFlow"] = strconv.FormatBool(*c.ServiceSettings.EnableOnboardingFlow)
|
||||
props["ExperimentalEnableDefaultChannelLeaveJoinMessages"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableDefaultChannelLeaveJoinMessages)
|
||||
props["ExperimentalGroupUnreadChannels"] = *c.ServiceSettings.ExperimentalGroupUnreadChannels
|
||||
props["EnableSVGs"] = strconv.FormatBool(*c.ServiceSettings.EnableSVGs)
|
||||
props["EnableMarketplace"] = strconv.FormatBool(*c.PluginSettings.EnableMarketplace)
|
||||
props["EnableLatex"] = strconv.FormatBool(*c.ServiceSettings.EnableLatex)
|
||||
props["EnableInlineLatex"] = strconv.FormatBool(*c.ServiceSettings.EnableInlineLatex)
|
||||
props["ExtendSessionLengthWithActivity"] = strconv.FormatBool(*c.ServiceSettings.ExtendSessionLengthWithActivity)
|
||||
props["ManagedResourcePaths"] = *c.ServiceSettings.ManagedResourcePaths
|
||||
|
||||
// This setting is only temporary, so keep using the old setting name for the mobile and web apps
|
||||
props["ExperimentalEnablePostMetadata"] = "true"
|
||||
|
||||
props["EnableAppBar"] = strconv.FormatBool(*c.ExperimentalSettings.EnableAppBar)
|
||||
|
||||
props["ExperimentalEnableAutomaticReplies"] = strconv.FormatBool(*c.TeamSettings.ExperimentalEnableAutomaticReplies)
|
||||
props["ExperimentalTimezone"] = strconv.FormatBool(*c.DisplaySettings.ExperimentalTimezone)
|
||||
|
||||
props["SendEmailNotifications"] = strconv.FormatBool(*c.EmailSettings.SendEmailNotifications)
|
||||
props["SendPushNotifications"] = strconv.FormatBool(*c.EmailSettings.SendPushNotifications)
|
||||
props["RequireEmailVerification"] = strconv.FormatBool(*c.EmailSettings.RequireEmailVerification)
|
||||
props["EnableEmailBatching"] = strconv.FormatBool(*c.EmailSettings.EnableEmailBatching)
|
||||
props["EnablePreviewModeBanner"] = strconv.FormatBool(*c.EmailSettings.EnablePreviewModeBanner)
|
||||
props["EmailNotificationContentsType"] = *c.EmailSettings.EmailNotificationContentsType
|
||||
|
||||
props["ShowEmailAddress"] = strconv.FormatBool(*c.PrivacySettings.ShowEmailAddress)
|
||||
props["ShowFullName"] = strconv.FormatBool(*c.PrivacySettings.ShowFullName)
|
||||
|
||||
props["EnableFileAttachments"] = strconv.FormatBool(*c.FileSettings.EnableFileAttachments)
|
||||
props["EnablePublicLink"] = strconv.FormatBool(*c.FileSettings.EnablePublicLink)
|
||||
|
||||
props["AvailableLocales"] = *c.LocalizationSettings.AvailableLocales
|
||||
props["SQLDriverName"] = *c.SqlSettings.DriverName
|
||||
|
||||
props["EnableEmojiPicker"] = strconv.FormatBool(*c.ServiceSettings.EnableEmojiPicker)
|
||||
props["EnableGifPicker"] = strconv.FormatBool(*c.ServiceSettings.EnableGifPicker)
|
||||
props["GfycatApiKey"] = *c.ServiceSettings.GfycatAPIKey
|
||||
props["GfycatApiSecret"] = *c.ServiceSettings.GfycatAPISecret
|
||||
props["MaxFileSize"] = strconv.FormatInt(*c.FileSettings.MaxFileSize, 10)
|
||||
|
||||
props["MaxNotificationsPerChannel"] = strconv.FormatInt(*c.TeamSettings.MaxNotificationsPerChannel, 10)
|
||||
props["EnableConfirmNotificationsToChannel"] = strconv.FormatBool(*c.TeamSettings.EnableConfirmNotificationsToChannel)
|
||||
props["TimeBetweenUserTypingUpdatesMilliseconds"] = strconv.FormatInt(*c.ServiceSettings.TimeBetweenUserTypingUpdatesMilliseconds, 10)
|
||||
props["EnableUserTypingMessages"] = strconv.FormatBool(*c.ServiceSettings.EnableUserTypingMessages)
|
||||
props["EnableChannelViewedMessages"] = strconv.FormatBool(*c.ServiceSettings.EnableChannelViewedMessages)
|
||||
|
||||
props["RunJobs"] = strconv.FormatBool(*c.JobSettings.RunJobs)
|
||||
|
||||
props["EnableEmailInvitations"] = strconv.FormatBool(*c.ServiceSettings.EnableEmailInvitations)
|
||||
|
||||
props["CWSURL"] = *c.CloudSettings.CWSURL
|
||||
|
||||
// Set default values for all options that require a license.
|
||||
props["ExperimentalEnableAuthenticationTransfer"] = "true"
|
||||
props["LdapNicknameAttributeSet"] = "false"
|
||||
props["LdapFirstNameAttributeSet"] = "false"
|
||||
props["LdapLastNameAttributeSet"] = "false"
|
||||
props["LdapPictureAttributeSet"] = "false"
|
||||
props["LdapPositionAttributeSet"] = "false"
|
||||
props["EnableCompliance"] = "false"
|
||||
props["EnableMobileFileDownload"] = "true"
|
||||
props["EnableMobileFileUpload"] = "true"
|
||||
props["SamlFirstNameAttributeSet"] = "false"
|
||||
props["SamlLastNameAttributeSet"] = "false"
|
||||
props["SamlNicknameAttributeSet"] = "false"
|
||||
props["SamlPositionAttributeSet"] = "false"
|
||||
props["EnableCluster"] = "false"
|
||||
props["EnableMetrics"] = "false"
|
||||
props["EnableBanner"] = "false"
|
||||
props["BannerText"] = ""
|
||||
props["BannerColor"] = ""
|
||||
props["BannerTextColor"] = ""
|
||||
props["AllowBannerDismissal"] = "false"
|
||||
props["EnableThemeSelection"] = "true"
|
||||
props["DefaultTheme"] = ""
|
||||
props["AllowCustomThemes"] = "true"
|
||||
props["AllowedThemes"] = ""
|
||||
props["DataRetentionEnableMessageDeletion"] = "false"
|
||||
props["DataRetentionMessageRetentionDays"] = "0"
|
||||
props["DataRetentionEnableFileDeletion"] = "false"
|
||||
props["DataRetentionFileRetentionDays"] = "0"
|
||||
props["DataRetentionEnableBoardsDeletion"] = "false"
|
||||
props["DataRetentionBoardsRetentionDays"] = "0"
|
||||
|
||||
props["CustomUrlSchemes"] = strings.Join(c.DisplaySettings.CustomURLSchemes, ",")
|
||||
props["IsDefaultMarketplace"] = strconv.FormatBool(*c.PluginSettings.MarketplaceURL == model.PluginSettingsDefaultMarketplaceURL)
|
||||
props["ExperimentalSharedChannels"] = "false"
|
||||
props["CollapsedThreads"] = *c.ServiceSettings.CollapsedThreads
|
||||
props["EnableCustomGroups"] = "false"
|
||||
props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled)
|
||||
props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority)
|
||||
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
|
||||
|
||||
if license != nil {
|
||||
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
|
||||
|
||||
if *license.Features.LDAP {
|
||||
props["LdapNicknameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.NicknameAttribute != "")
|
||||
props["LdapFirstNameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.FirstNameAttribute != "")
|
||||
props["LdapLastNameAttributeSet"] = strconv.FormatBool(*c.LdapSettings.LastNameAttribute != "")
|
||||
props["LdapPictureAttributeSet"] = strconv.FormatBool(*c.LdapSettings.PictureAttribute != "")
|
||||
props["LdapPositionAttributeSet"] = strconv.FormatBool(*c.LdapSettings.PositionAttribute != "")
|
||||
}
|
||||
|
||||
if *license.Features.Compliance {
|
||||
props["EnableCompliance"] = strconv.FormatBool(*c.ComplianceSettings.Enable)
|
||||
props["EnableMobileFileDownload"] = strconv.FormatBool(*c.FileSettings.EnableMobileDownload)
|
||||
props["EnableMobileFileUpload"] = strconv.FormatBool(*c.FileSettings.EnableMobileUpload)
|
||||
}
|
||||
|
||||
if *license.Features.SAML {
|
||||
props["SamlFirstNameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.FirstNameAttribute != "")
|
||||
props["SamlLastNameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.LastNameAttribute != "")
|
||||
props["SamlNicknameAttributeSet"] = strconv.FormatBool(*c.SamlSettings.NicknameAttribute != "")
|
||||
props["SamlPositionAttributeSet"] = strconv.FormatBool(*c.SamlSettings.PositionAttribute != "")
|
||||
}
|
||||
|
||||
if *license.Features.FutureFeatures {
|
||||
props["ExperimentalClientSideCertEnable"] = strconv.FormatBool(*c.ExperimentalSettings.ClientSideCertEnable)
|
||||
props["ExperimentalClientSideCertCheck"] = *c.ExperimentalSettings.ClientSideCertCheck
|
||||
}
|
||||
|
||||
if *license.Features.Cluster {
|
||||
props["EnableCluster"] = strconv.FormatBool(*c.ClusterSettings.Enable)
|
||||
}
|
||||
|
||||
if *license.Features.Cluster {
|
||||
props["EnableMetrics"] = strconv.FormatBool(*c.MetricsSettings.Enable)
|
||||
}
|
||||
|
||||
if *license.Features.Announcement {
|
||||
props["EnableBanner"] = strconv.FormatBool(*c.AnnouncementSettings.EnableBanner)
|
||||
props["BannerText"] = *c.AnnouncementSettings.BannerText
|
||||
props["BannerColor"] = *c.AnnouncementSettings.BannerColor
|
||||
props["BannerTextColor"] = *c.AnnouncementSettings.BannerTextColor
|
||||
props["AllowBannerDismissal"] = strconv.FormatBool(*c.AnnouncementSettings.AllowBannerDismissal)
|
||||
}
|
||||
|
||||
if *license.Features.ThemeManagement {
|
||||
props["EnableThemeSelection"] = strconv.FormatBool(*c.ThemeSettings.EnableThemeSelection)
|
||||
props["DefaultTheme"] = *c.ThemeSettings.DefaultTheme
|
||||
props["AllowCustomThemes"] = strconv.FormatBool(*c.ThemeSettings.AllowCustomThemes)
|
||||
props["AllowedThemes"] = strings.Join(c.ThemeSettings.AllowedThemes, ",")
|
||||
}
|
||||
|
||||
if *license.Features.DataRetention {
|
||||
props["DataRetentionEnableMessageDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableMessageDeletion)
|
||||
props["DataRetentionMessageRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.MessageRetentionDays), 10)
|
||||
props["DataRetentionEnableFileDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableFileDeletion)
|
||||
props["DataRetentionFileRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.FileRetentionDays), 10)
|
||||
props["DataRetentionEnableBoardsDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableBoardsDeletion)
|
||||
props["DataRetentionBoardsRetentionDays"] = strconv.FormatInt(int64(*c.DataRetentionSettings.BoardsRetentionDays), 10)
|
||||
}
|
||||
|
||||
if license.HasSharedChannels() {
|
||||
props["ExperimentalSharedChannels"] = strconv.FormatBool(*c.ExperimentalSettings.EnableSharedChannels)
|
||||
props["ExperimentalRemoteClusterService"] = strconv.FormatBool(c.FeatureFlags.EnableRemoteClusterService && *c.ExperimentalSettings.EnableRemoteClusterService)
|
||||
}
|
||||
|
||||
if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise {
|
||||
props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups)
|
||||
}
|
||||
|
||||
if (license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise) && c.FeatureFlags.PostPriority {
|
||||
props["PostAcknowledgements"] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
// GenerateLimitedClientConfig renders the given configuration for an untrusted client.
|
||||
func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *model.License) map[string]string {
|
||||
props := make(map[string]string)
|
||||
|
||||
props["Version"] = model.CurrentVersion
|
||||
props["BuildNumber"] = model.BuildNumber
|
||||
props["BuildDate"] = model.BuildDate
|
||||
props["BuildHash"] = model.BuildHash
|
||||
props["BuildHashEnterprise"] = model.BuildHashEnterprise
|
||||
props["BuildEnterpriseReady"] = model.BuildEnterpriseReady
|
||||
props["BuildHashBoards"] = model.BuildHashBoards
|
||||
props["BuildBoards"] = model.BuildBoards
|
||||
props["BuildHashPlaybooks"] = model.BuildHashPlaybooks
|
||||
|
||||
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
|
||||
props["EnableFile"] = strconv.FormatBool(*c.LogSettings.EnableFile)
|
||||
props["FileLevel"] = *c.LogSettings.FileLevel
|
||||
|
||||
props["SiteURL"] = strings.TrimRight(*c.ServiceSettings.SiteURL, "/")
|
||||
props["SiteName"] = *c.TeamSettings.SiteName
|
||||
props["WebsocketURL"] = strings.TrimRight(*c.ServiceSettings.WebsocketURL, "/")
|
||||
props["WebsocketPort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketPort)
|
||||
props["WebsocketSecurePort"] = fmt.Sprintf("%v", *c.ServiceSettings.WebsocketSecurePort)
|
||||
props["EnableUserCreation"] = strconv.FormatBool(*c.TeamSettings.EnableUserCreation)
|
||||
props["EnableOpenServer"] = strconv.FormatBool(*c.TeamSettings.EnableOpenServer)
|
||||
|
||||
props["AndroidLatestVersion"] = c.ClientRequirements.AndroidLatestVersion
|
||||
props["AndroidMinVersion"] = c.ClientRequirements.AndroidMinVersion
|
||||
props["IosLatestVersion"] = c.ClientRequirements.IosLatestVersion
|
||||
props["IosMinVersion"] = c.ClientRequirements.IosMinVersion
|
||||
|
||||
props["EnableDiagnostics"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
|
||||
|
||||
props["EnableComplianceExport"] = strconv.FormatBool(*c.MessageExportSettings.EnableExport)
|
||||
|
||||
props["EnableSignUpWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignUpWithEmail)
|
||||
props["EnableSignInWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithEmail)
|
||||
props["EnableSignInWithUsername"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithUsername)
|
||||
|
||||
props["EmailLoginButtonColor"] = *c.EmailSettings.LoginButtonColor
|
||||
props["EmailLoginButtonBorderColor"] = *c.EmailSettings.LoginButtonBorderColor
|
||||
props["EmailLoginButtonTextColor"] = *c.EmailSettings.LoginButtonTextColor
|
||||
|
||||
props["EnableSignUpWithGitLab"] = strconv.FormatBool(*c.GitLabSettings.Enable)
|
||||
props["GitLabButtonColor"] = *c.GitLabSettings.ButtonColor
|
||||
props["GitLabButtonText"] = *c.GitLabSettings.ButtonText
|
||||
|
||||
props["TermsOfServiceLink"] = *c.SupportSettings.TermsOfServiceLink
|
||||
props["PrivacyPolicyLink"] = *c.SupportSettings.PrivacyPolicyLink
|
||||
props["AboutLink"] = *c.SupportSettings.AboutLink
|
||||
props["HelpLink"] = *c.SupportSettings.HelpLink
|
||||
props["ReportAProblemLink"] = *c.SupportSettings.ReportAProblemLink
|
||||
props["SupportEmail"] = *c.SupportSettings.SupportEmail
|
||||
props["EnableAskCommunityLink"] = strconv.FormatBool(*c.SupportSettings.EnableAskCommunityLink)
|
||||
|
||||
props["DefaultClientLocale"] = *c.LocalizationSettings.DefaultClientLocale
|
||||
|
||||
props["EnableCustomEmoji"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomEmoji)
|
||||
props["AppDownloadLink"] = *c.NativeAppSettings.AppDownloadLink
|
||||
props["AndroidAppDownloadLink"] = *c.NativeAppSettings.AndroidAppDownloadLink
|
||||
props["IosAppDownloadLink"] = *c.NativeAppSettings.IosAppDownloadLink
|
||||
|
||||
props["DiagnosticId"] = telemetryID
|
||||
props["TelemetryId"] = telemetryID
|
||||
props["DiagnosticsEnabled"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
|
||||
|
||||
props["HasImageProxy"] = strconv.FormatBool(*c.ImageProxySettings.Enable)
|
||||
|
||||
props["PluginsEnabled"] = strconv.FormatBool(*c.PluginSettings.Enable)
|
||||
|
||||
props["PasswordMinimumLength"] = fmt.Sprintf("%v", *c.PasswordSettings.MinimumLength)
|
||||
props["PasswordRequireLowercase"] = strconv.FormatBool(*c.PasswordSettings.Lowercase)
|
||||
props["PasswordRequireUppercase"] = strconv.FormatBool(*c.PasswordSettings.Uppercase)
|
||||
props["PasswordRequireNumber"] = strconv.FormatBool(*c.PasswordSettings.Number)
|
||||
props["PasswordRequireSymbol"] = strconv.FormatBool(*c.PasswordSettings.Symbol)
|
||||
|
||||
// Set default values for all options that require a license.
|
||||
props["EnableCustomBrand"] = "false"
|
||||
props["CustomBrandText"] = ""
|
||||
props["CustomDescriptionText"] = ""
|
||||
props["EnableLdap"] = "false"
|
||||
props["LdapLoginFieldName"] = ""
|
||||
props["LdapLoginButtonColor"] = ""
|
||||
props["LdapLoginButtonBorderColor"] = ""
|
||||
props["LdapLoginButtonTextColor"] = ""
|
||||
props["EnableSaml"] = "false"
|
||||
props["SamlLoginButtonText"] = ""
|
||||
props["SamlLoginButtonColor"] = ""
|
||||
props["SamlLoginButtonBorderColor"] = ""
|
||||
props["SamlLoginButtonTextColor"] = ""
|
||||
props["EnableSignUpWithGoogle"] = "false"
|
||||
props["EnableSignUpWithOffice365"] = "false"
|
||||
props["EnableSignUpWithOpenId"] = "false"
|
||||
props["OpenIdButtonText"] = ""
|
||||
props["OpenIdButtonColor"] = ""
|
||||
props["CWSURL"] = ""
|
||||
props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand)
|
||||
props["CustomBrandText"] = *c.TeamSettings.CustomBrandText
|
||||
props["CustomDescriptionText"] = *c.TeamSettings.CustomDescriptionText
|
||||
props["EnableMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnableMultifactorAuthentication)
|
||||
props["EnforceMultifactorAuthentication"] = "false"
|
||||
props["EnableGuestAccounts"] = strconv.FormatBool(*c.GuestAccountsSettings.Enable)
|
||||
props["GuestAccountsEnforceMultifactorAuthentication"] = strconv.FormatBool(*c.GuestAccountsSettings.EnforceMultifactorAuthentication)
|
||||
|
||||
if license != nil {
|
||||
if *license.Features.LDAP {
|
||||
props["EnableLdap"] = strconv.FormatBool(*c.LdapSettings.Enable)
|
||||
props["LdapLoginFieldName"] = *c.LdapSettings.LoginFieldName
|
||||
props["LdapLoginButtonColor"] = *c.LdapSettings.LoginButtonColor
|
||||
props["LdapLoginButtonBorderColor"] = *c.LdapSettings.LoginButtonBorderColor
|
||||
props["LdapLoginButtonTextColor"] = *c.LdapSettings.LoginButtonTextColor
|
||||
}
|
||||
|
||||
if *license.Features.SAML {
|
||||
props["EnableSaml"] = strconv.FormatBool(*c.SamlSettings.Enable)
|
||||
props["SamlLoginButtonText"] = *c.SamlSettings.LoginButtonText
|
||||
props["SamlLoginButtonColor"] = *c.SamlSettings.LoginButtonColor
|
||||
props["SamlLoginButtonBorderColor"] = *c.SamlSettings.LoginButtonBorderColor
|
||||
props["SamlLoginButtonTextColor"] = *c.SamlSettings.LoginButtonTextColor
|
||||
}
|
||||
|
||||
if *license.Features.CustomTermsOfService {
|
||||
props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled)
|
||||
props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10)
|
||||
}
|
||||
|
||||
if *license.Features.MFA {
|
||||
props["EnforceMultifactorAuthentication"] = strconv.FormatBool(*c.ServiceSettings.EnforceMultifactorAuthentication)
|
||||
}
|
||||
|
||||
if license.IsCloud() {
|
||||
// MM-48727: enable SSO options for free cloud - not in self hosted
|
||||
*license.Features.GoogleOAuth = true
|
||||
*license.Features.Office365OAuth = true
|
||||
}
|
||||
|
||||
if *license.Features.GoogleOAuth {
|
||||
props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable)
|
||||
}
|
||||
|
||||
if *license.Features.Office365OAuth {
|
||||
props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable)
|
||||
}
|
||||
|
||||
if *license.Features.OpenId {
|
||||
props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable)
|
||||
props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor
|
||||
props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range c.FeatureFlags.ToMap() {
|
||||
props["FeatureFlag"+key] = value
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
442
server/config/client_test.go
Обычный файл
442
server/config/client_test.go
Обычный файл
@@ -0,0 +1,442 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func TestGetClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
telemetryID string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: model.NewBool(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: model.NewString("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: model.NewInt(80),
|
||||
WebsocketSecurePort: model.NewInt(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed, but not for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: model.NewBool(false),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: model.NewBool(false),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag1",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for theme management",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
AllowCustomThemes: model.NewBool(false),
|
||||
},
|
||||
},
|
||||
"tag2",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
ThemeManagement: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"DiagnosticId": "tag2",
|
||||
"EmailNotificationContentsType": "full",
|
||||
"AllowCustomThemes": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for enforcement",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
EnforceMultifactorAuthentication: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
MFA: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
map[string]string{
|
||||
"EnforceMultifactorAuthentication": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"default marketplace",
|
||||
&model.Config{
|
||||
PluginSettings: model.PluginSettings{
|
||||
MarketplaceURL: model.NewString(model.PluginSettingsDefaultMarketplaceURL),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
nil,
|
||||
map[string]string{
|
||||
"IsDefaultMarketplace": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"non-default marketplace",
|
||||
&model.Config{
|
||||
PluginSettings: model.PluginSettings{
|
||||
MarketplaceURL: model.NewString("http://example.com"),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
nil,
|
||||
map[string]string{
|
||||
"IsDefaultMarketplace": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"enable ShowFullName prop",
|
||||
&model.Config{
|
||||
PrivacySettings: model.PrivacySettings{
|
||||
ShowFullName: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"tag1",
|
||||
nil,
|
||||
map[string]string{
|
||||
"ShowFullName": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Insights professional license",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
InsightsEnabled: true,
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: model.LicenseShortSkuProfessional,
|
||||
},
|
||||
map[string]string{
|
||||
"InsightsEnabled": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Insights enterprise license",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
InsightsEnabled: true,
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: model.LicenseShortSkuEnterprise,
|
||||
},
|
||||
map[string]string{
|
||||
"InsightsEnabled": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Insights other license",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
InsightsEnabled: true,
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: "other",
|
||||
},
|
||||
map[string]string{
|
||||
"InsightsEnabled": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Insights professional license, feature flag disabled",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
InsightsEnabled: false,
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: model.LicenseShortSkuProfessional,
|
||||
},
|
||||
map[string]string{
|
||||
"InsightsEnabled": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Custom groups professional license",
|
||||
&model.Config{},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: model.LicenseShortSkuProfessional,
|
||||
},
|
||||
map[string]string{
|
||||
"EnableCustomGroups": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Custom groups enterprise license",
|
||||
&model.Config{},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: model.LicenseShortSkuEnterprise,
|
||||
},
|
||||
map[string]string{
|
||||
"EnableCustomGroups": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Custom groups other license",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
InsightsEnabled: true,
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{},
|
||||
SkuShortName: "other",
|
||||
},
|
||||
map[string]string{
|
||||
"EnableCustomGroups": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Shared channels other license",
|
||||
&model.Config{
|
||||
ExperimentalSettings: model.ExperimentalSettings{
|
||||
EnableSharedChannels: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
SharedChannels: model.NewBool(false),
|
||||
},
|
||||
SkuShortName: "other",
|
||||
},
|
||||
map[string]string{
|
||||
"ExperimentalSharedChannels": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"licensed for shared channels",
|
||||
&model.Config{
|
||||
ExperimentalSettings: model.ExperimentalSettings{
|
||||
EnableSharedChannels: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
SharedChannels: model.NewBool(true),
|
||||
},
|
||||
SkuShortName: "other",
|
||||
},
|
||||
map[string]string{
|
||||
"ExperimentalSharedChannels": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Shared channels professional license",
|
||||
&model.Config{
|
||||
ExperimentalSettings: model.ExperimentalSettings{
|
||||
EnableSharedChannels: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
SharedChannels: model.NewBool(false),
|
||||
},
|
||||
SkuShortName: model.LicenseShortSkuProfessional,
|
||||
},
|
||||
map[string]string{
|
||||
"ExperimentalSharedChannels": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Shared channels enterprise license",
|
||||
&model.Config{
|
||||
ExperimentalSettings: model.ExperimentalSettings{
|
||||
EnableSharedChannels: model.NewBool(true),
|
||||
},
|
||||
},
|
||||
"",
|
||||
&model.License{
|
||||
Features: &model.Features{
|
||||
SharedChannels: model.NewBool(false),
|
||||
},
|
||||
SkuShortName: model.LicenseShortSkuEnterprise,
|
||||
},
|
||||
map[string]string{
|
||||
"ExperimentalSharedChannels": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
}
|
||||
|
||||
configMap := GenerateClientConfig(testCase.config, testCase.telemetryID, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLimitedClientConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
description string
|
||||
config *model.Config
|
||||
telemetryID string
|
||||
license *model.License
|
||||
expectedFields map[string]string
|
||||
}{
|
||||
{
|
||||
"unlicensed",
|
||||
&model.Config{
|
||||
EmailSettings: model.EmailSettings{
|
||||
EmailNotificationContentsType: model.NewString(model.EmailNotificationContentsFull),
|
||||
},
|
||||
ThemeSettings: model.ThemeSettings{
|
||||
// Ignored, since not licensed.
|
||||
AllowCustomThemes: model.NewBool(false),
|
||||
},
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
WebsocketURL: model.NewString("ws://mattermost.example.com:8065"),
|
||||
WebsocketPort: model.NewInt(80),
|
||||
WebsocketSecurePort: model.NewInt(443),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"DiagnosticId": "",
|
||||
"EnforceMultifactorAuthentication": "false",
|
||||
"WebsocketURL": "ws://mattermost.example.com:8065",
|
||||
"WebsocketPort": "80",
|
||||
"WebsocketSecurePort": "443",
|
||||
},
|
||||
},
|
||||
{
|
||||
"password settings",
|
||||
&model.Config{
|
||||
PasswordSettings: model.PasswordSettings{
|
||||
MinimumLength: model.NewInt(15),
|
||||
Lowercase: model.NewBool(true),
|
||||
Uppercase: model.NewBool(true),
|
||||
Number: model.NewBool(true),
|
||||
Symbol: model.NewBool(false),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"PasswordMinimumLength": "15",
|
||||
"PasswordRequireLowercase": "true",
|
||||
"PasswordRequireUppercase": "true",
|
||||
"PasswordRequireNumber": "true",
|
||||
"PasswordRequireSymbol": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Feature Flags",
|
||||
&model.Config{
|
||||
FeatureFlags: &model.FeatureFlags{
|
||||
TestFeature: "myvalue",
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"FeatureFlagTestFeature": "myvalue",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCase.config.SetDefaults()
|
||||
if testCase.license != nil {
|
||||
testCase.license.Features.SetDefaults()
|
||||
}
|
||||
|
||||
configMap := GenerateLimitedClientConfig(testCase.config, testCase.telemetryID, testCase.license)
|
||||
for expectedField, expectedValue := range testCase.expectedFields {
|
||||
actualValue, ok := configMap[expectedField]
|
||||
if assert.True(t, ok, fmt.Sprintf("config does not contain %v", expectedField)) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
182
server/config/common_test.go
Обычный файл
182
server/config/common_test.go
Обычный файл
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
var emptyConfig, readOnlyConfig, minimalConfig, minimalConfigNoFF, invalidConfig, fixesRequiredConfig, ldapConfig, testConfig, customConfigDefaults *model.Config
|
||||
|
||||
func init() {
|
||||
emptyConfig = &model.Config{}
|
||||
readOnlyConfig = &model.Config{
|
||||
ClusterSettings: model.ClusterSettings{
|
||||
Enable: model.NewBool(true),
|
||||
ReadOnlyConfig: model.NewBool(true),
|
||||
},
|
||||
}
|
||||
minimalConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("http://minimal"),
|
||||
},
|
||||
SqlSettings: model.SqlSettings{
|
||||
AtRestEncryptKey: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
PublicLinkSalt: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: model.NewString("en"),
|
||||
DefaultClientLocale: model.NewString("en"),
|
||||
},
|
||||
}
|
||||
|
||||
minimalConfig.SetDefaults()
|
||||
|
||||
minimalConfigNoFF = minimalConfig.Clone()
|
||||
minimalConfigNoFF.FeatureFlags = nil
|
||||
|
||||
invalidConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("invalid"),
|
||||
},
|
||||
}
|
||||
fixesRequiredConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("http://trailingslash/"),
|
||||
},
|
||||
SqlSettings: model.SqlSettings{
|
||||
AtRestEncryptKey: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
DriverName: model.NewString(model.ImageDriverLocal),
|
||||
Directory: model.NewString("/path/to/directory"),
|
||||
PublicLinkSalt: model.NewString("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: model.NewString("garbage"),
|
||||
DefaultClientLocale: model.NewString("garbage"),
|
||||
},
|
||||
}
|
||||
ldapConfig = &model.Config{
|
||||
LdapSettings: model.LdapSettings{
|
||||
BindPassword: model.NewString("password"),
|
||||
},
|
||||
}
|
||||
testConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("http://TestStoreNew"),
|
||||
},
|
||||
}
|
||||
customConfigDefaults = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("http://custom.com"),
|
||||
},
|
||||
DisplaySettings: model.DisplaySettings{
|
||||
ExperimentalTimezone: model.NewBool(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigs(t *testing.T) {
|
||||
t.Run("merge two default configs with different salts/keys", func(t *testing.T) {
|
||||
base := &model.Config{}
|
||||
base.SetDefaults()
|
||||
patch := &model.Config{}
|
||||
patch.SetDefaults()
|
||||
|
||||
merged, err := Merge(base, patch, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, patch, merged)
|
||||
})
|
||||
t.Run("merge identical configs", func(t *testing.T) {
|
||||
base := &model.Config{}
|
||||
base.SetDefaults()
|
||||
patch := base.Clone()
|
||||
|
||||
merged, err := Merge(base, patch, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, base, merged)
|
||||
assert.Equal(t, patch, merged)
|
||||
})
|
||||
t.Run("merge configs with a different setting", func(t *testing.T) {
|
||||
base := &model.Config{}
|
||||
base.SetDefaults()
|
||||
patch := base.Clone()
|
||||
patch.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
|
||||
|
||||
merged, err := Merge(base, patch, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, base, merged)
|
||||
assert.Equal(t, patch, merged)
|
||||
})
|
||||
t.Run("merge default config with changes from a mostly nil patch", func(t *testing.T) {
|
||||
base := &model.Config{}
|
||||
base.SetDefaults()
|
||||
patch := &model.Config{}
|
||||
patch.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
|
||||
patch.GoogleSettings.Enable = model.NewBool(true)
|
||||
|
||||
expected := base.Clone()
|
||||
expected.ServiceSettings.SiteURL = model.NewString("http://newhost.ca")
|
||||
expected.GoogleSettings.Enable = model.NewBool(true)
|
||||
|
||||
merged, err := Merge(base, patch, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, base, merged)
|
||||
assert.NotEqual(t, patch, merged)
|
||||
assert.Equal(t, expected, merged)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigEnvironmentOverrides(t *testing.T) {
|
||||
memstore, err := NewMemoryStore()
|
||||
require.NoError(t, err)
|
||||
base, err := NewStoreFromBacking(memstore, nil, false)
|
||||
require.NoError(t, err)
|
||||
originalConfig := &model.Config{}
|
||||
originalConfig.ServiceSettings.SiteURL = model.NewString("http://notoverridden.ca")
|
||||
|
||||
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://overridden.ca")
|
||||
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
|
||||
|
||||
t.Run("loading config should respect environment variable overrides", func(t *testing.T) {
|
||||
err := base.Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http://overridden.ca", *base.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
|
||||
t.Run("setting config should respect environment variable overrides", func(t *testing.T) {
|
||||
_, _, err := base.Set(originalConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "http://overridden.ca", *base.Get().ServiceSettings.SiteURL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveEnvironmentOverrides(t *testing.T) {
|
||||
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://overridden.ca")
|
||||
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
|
||||
|
||||
memstore, err := NewMemoryStore()
|
||||
require.NoError(t, err)
|
||||
base, err := NewStoreFromBacking(memstore, nil, false)
|
||||
require.NoError(t, err)
|
||||
oldCfg := base.Get()
|
||||
assert.Equal(t, "http://overridden.ca", *oldCfg.ServiceSettings.SiteURL)
|
||||
newCfg := base.RemoveEnvironmentOverrides(oldCfg)
|
||||
assert.Equal(t, "", *newCfg.ServiceSettings.SiteURL)
|
||||
}
|
||||
425
server/config/database.go
Обычный файл
425
server/config/database.go
Обычный файл
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
// Load the MySQL driver
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
// Load the Postgres driver
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/mattermost/morph"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/store/sqlstore"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
|
||||
"github.com/mattermost/morph/drivers"
|
||||
ms "github.com/mattermost/morph/drivers/mysql"
|
||||
ps "github.com/mattermost/morph/drivers/postgres"
|
||||
mbindata "github.com/mattermost/morph/sources/embedded"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var assets embed.FS
|
||||
|
||||
// MaxWriteLength defines the maximum length accepted for write to the Configurations or
|
||||
// ConfigurationFiles table.
|
||||
//
|
||||
// It is imposed by MySQL's default max_allowed_packet value of 4Mb.
|
||||
const MaxWriteLength = 4 * 1024 * 1024
|
||||
|
||||
// We use the something different from the default migration table name of morph
|
||||
const migrationsTableName = "db_config_migrations"
|
||||
|
||||
// The timeout value for each migration file to run.
|
||||
const migrationsTimeoutInSeconds = 100000
|
||||
|
||||
// DatabaseStore is a config store backed by a database.
|
||||
// Not to be used directly. Only to be used as a backing store for config.Store
|
||||
type DatabaseStore struct {
|
||||
originalDsn string
|
||||
driverName string
|
||||
dataSourceName string
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewDatabaseStore creates a new instance of a config store backed by the given database.
|
||||
func NewDatabaseStore(dsn string) (ds *DatabaseStore, err error) {
|
||||
driverName, dataSourceName, err := parseDSN(dsn)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid DSN")
|
||||
}
|
||||
|
||||
db, err := sqlx.Open(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to connect to %s database", driverName)
|
||||
}
|
||||
// Set conservative connection configuration for configuration database.
|
||||
db.SetMaxIdleConns(0)
|
||||
db.SetMaxOpenConns(2)
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
db.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
ds = &DatabaseStore{
|
||||
driverName: driverName,
|
||||
originalDsn: dsn,
|
||||
dataSourceName: dataSourceName,
|
||||
db: db,
|
||||
}
|
||||
if err = ds.initializeConfigurationsTable(); err != nil {
|
||||
err = errors.Wrap(err, "failed to initialize")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// initializeConfigurationsTable ensures the requisite tables in place to form the backing store.
|
||||
//
|
||||
// Uses MEDIUMTEXT on MySQL, and TEXT on sane databases.
|
||||
func (ds *DatabaseStore) initializeConfigurationsTable() error {
|
||||
assetsList, err := assets.ReadDir(filepath.Join("migrations", ds.driverName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
assetNamesForDriver := make([]string, len(assetsList))
|
||||
for i, entry := range assetsList {
|
||||
assetNamesForDriver[i] = entry.Name()
|
||||
}
|
||||
|
||||
src, err := mbindata.WithInstance(&mbindata.AssetSource{
|
||||
Names: assetNamesForDriver,
|
||||
AssetFunc: func(name string) ([]byte, error) {
|
||||
return assets.ReadFile(filepath.Join("migrations", ds.driverName, name))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var driver drivers.Driver
|
||||
switch ds.driverName {
|
||||
case model.DatabaseDriverMysql:
|
||||
dataSource, rErr := sqlstore.ResetReadTimeout(ds.dataSourceName)
|
||||
if rErr != nil {
|
||||
return fmt.Errorf("failed to reset read timeout from datasource: %w", rErr)
|
||||
}
|
||||
|
||||
dataSource, err = sqlstore.AppendMultipleStatementsFlag(dataSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var db *sqlx.DB
|
||||
db, err = sqlx.Open(ds.driverName, dataSource)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to connect to %s database", ds.driverName)
|
||||
}
|
||||
|
||||
driver, err = ms.WithInstance(db.DB)
|
||||
|
||||
defer db.Close()
|
||||
case model.DatabaseDriverPostgres:
|
||||
driver, err = ps.WithInstance(ds.db.DB)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported database type %s for migration", ds.driverName)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := []morph.EngineOption{
|
||||
morph.WithLock("mm-config-lock-key"),
|
||||
morph.SetMigrationTableName(migrationsTableName),
|
||||
morph.SetStatementTimeoutInSeconds(migrationsTimeoutInSeconds),
|
||||
}
|
||||
engine, err := morph.New(context.Background(), driver, src, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer engine.Close()
|
||||
|
||||
return engine.ApplyAll()
|
||||
}
|
||||
|
||||
// parseDSN splits up a connection string into a driver name and data source name.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// mysql://mmuser:mostest@localhost:5432/mattermost_test
|
||||
//
|
||||
// returns
|
||||
//
|
||||
// driverName = mysql
|
||||
// dataSourceName = mmuser:mostest@localhost:5432/mattermost_test
|
||||
//
|
||||
// By contrast, a Postgres DSN is returned unmodified.
|
||||
func parseDSN(dsn string) (string, string, error) {
|
||||
// Treat the DSN as the URL that it is.
|
||||
s := strings.SplitN(dsn, "://", 2)
|
||||
if len(s) != 2 {
|
||||
return "", "", errors.New("failed to parse DSN as URL")
|
||||
}
|
||||
|
||||
scheme := s[0]
|
||||
switch scheme {
|
||||
case "mysql":
|
||||
// Strip off the mysql:// for the dsn with which to connect.
|
||||
dsn = s[1]
|
||||
|
||||
case "postgres", "postgresql":
|
||||
// No changes required
|
||||
|
||||
default:
|
||||
return "", "", errors.Errorf("unsupported scheme %s", scheme)
|
||||
}
|
||||
|
||||
return scheme, dsn, nil
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety and updates the backing store.
|
||||
func (ds *DatabaseStore) Set(newCfg *model.Config) error {
|
||||
return ds.persist(newCfg)
|
||||
}
|
||||
|
||||
// maxLength identifies the maximum length of a configuration or configuration file
|
||||
func (ds *DatabaseStore) checkLength(length int) error {
|
||||
if ds.db.DriverName() == "mysql" && length > MaxWriteLength {
|
||||
return errors.Errorf("value is too long: %d > %d bytes", length, MaxWriteLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// persist writes the configuration to the configured database.
|
||||
func (ds *DatabaseStore) persist(cfg *model.Config) error {
|
||||
b, err := marshalConfig(cfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize")
|
||||
}
|
||||
|
||||
value := string(b)
|
||||
err = ds.checkLength(len(value))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "marshalled configuration failed length check")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(b)
|
||||
|
||||
// Skip the persist altogether if we're effectively writing the same configuration.
|
||||
var oldValue string
|
||||
var row *sql.Row
|
||||
if ds.driverName == model.DatabaseDriverMysql {
|
||||
// We use a sub-query to get the Id first because selecting the Id column using
|
||||
// active uses the index, but selecting SHA column using active does not use the index.
|
||||
// The sub-query uses the active index, and then the top-level query uses the primary key.
|
||||
// This takes 2 queries, but it is actually faster than one slow query for MySQL
|
||||
row = ds.db.QueryRow("SELECT SHA FROM Configurations WHERE Id = (select Id from Configurations Where Active)")
|
||||
} else {
|
||||
row = ds.db.QueryRow("SELECT SHA FROM Configurations WHERE Active")
|
||||
}
|
||||
if err = row.Scan(&oldValue); err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrap(err, "failed to query active configuration")
|
||||
}
|
||||
|
||||
// postgres retruns blank-padded therefore we trim the space
|
||||
oldSum, err := hex.DecodeString(strings.TrimSpace(oldValue))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not encode value")
|
||||
}
|
||||
|
||||
// compare checksums, it's more efficient rather than comparing entire config itself
|
||||
if bytes.Equal(oldSum, sum[0:]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := ds.db.Beginx()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to begin transaction")
|
||||
}
|
||||
defer func() {
|
||||
// Rollback after Commit just returns sql.ErrTxDone.
|
||||
if err = tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||
mlog.Error("Failed to rollback configuration transaction", mlog.Err(err))
|
||||
}
|
||||
}()
|
||||
|
||||
var oldId string
|
||||
if ds.driverName == model.DatabaseDriverMysql {
|
||||
// the query doesn't use active index if we query for value (mysql, no surprise)
|
||||
// we select Id column which triggers using index hence we do quicker reads
|
||||
// that's the reason we select id first then query against id to get the value.
|
||||
row = tx.QueryRow("SELECT Id FROM Configurations WHERE Active")
|
||||
if err = row.Scan(&oldId); err != nil && err != sql.ErrNoRows {
|
||||
return errors.Wrap(err, "failed to query active configuration")
|
||||
}
|
||||
if oldId != "" {
|
||||
if _, err := tx.NamedExec("UPDATE Configurations SET Active = NULL WHERE Id = :id", map[string]any{"id": oldId}); err != nil {
|
||||
return errors.Wrap(err, "failed to deactivate current configuration")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec("UPDATE Configurations SET Active = NULL WHERE Active"); err != nil {
|
||||
return errors.Wrap(err, "failed to deactivate current configuration")
|
||||
}
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"id": model.NewId(),
|
||||
"value": value,
|
||||
"create_at": model.GetMillis(),
|
||||
"key": "ConfigurationId",
|
||||
"sha": hex.EncodeToString(sum[0:]),
|
||||
}
|
||||
|
||||
if _, err := tx.NamedExec("INSERT INTO Configurations (Id, Value, CreateAt, Active, SHA) VALUES (:id, :value, :create_at, TRUE, :sha)", params); err != nil {
|
||||
return errors.Wrap(err, "failed to record new configuration")
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.Wrap(err, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load updates the current configuration from the backing store.
|
||||
func (ds *DatabaseStore) Load() ([]byte, error) {
|
||||
var configurationData []byte
|
||||
|
||||
row := ds.db.QueryRow("SELECT Value FROM Configurations WHERE Active")
|
||||
if err := row.Scan(&configurationData); err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrap(err, "failed to query active configuration")
|
||||
}
|
||||
|
||||
// Initialize from the default config if no active configuration could be found.
|
||||
if len(configurationData) == 0 {
|
||||
configWithDB := model.Config{}
|
||||
configWithDB.SqlSettings.DriverName = model.NewString(ds.driverName)
|
||||
configWithDB.SqlSettings.DataSource = model.NewString(ds.dataSourceName)
|
||||
return json.Marshal(configWithDB)
|
||||
}
|
||||
|
||||
return configurationData, nil
|
||||
}
|
||||
|
||||
// GetFile fetches the contents of a previously persisted configuration file.
|
||||
func (ds *DatabaseStore) GetFile(name string) ([]byte, error) {
|
||||
query, args, err := sqlx.Named("SELECT Data FROM ConfigurationFiles WHERE Name = :name", map[string]any{
|
||||
"name": name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data []byte
|
||||
row := ds.db.QueryRowx(ds.db.Rebind(query), args...)
|
||||
if err = row.Scan(&data); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to scan data from row for %s", name)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// SetFile sets or replaces the contents of a configuration file.
|
||||
func (ds *DatabaseStore) SetFile(name string, data []byte) error {
|
||||
err := ds.checkLength(len(data))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "file data failed length check")
|
||||
}
|
||||
params := map[string]any{
|
||||
"name": name,
|
||||
"data": data,
|
||||
"create_at": model.GetMillis(),
|
||||
"update_at": model.GetMillis(),
|
||||
}
|
||||
|
||||
result, err := ds.db.NamedExec("UPDATE ConfigurationFiles SET Data = :data, UpdateAt = :update_at WHERE Name = :name", params)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to update row for %s", name)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to count rows affected for %s", name)
|
||||
} else if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = ds.db.NamedExec("INSERT INTO ConfigurationFiles (Name, Data, CreateAt, UpdateAt) VALUES (:name, :data, :create_at, :update_at)", params)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to insert row for %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFile returns true if the given file was previously persisted.
|
||||
func (ds *DatabaseStore) HasFile(name string) (bool, error) {
|
||||
query, args, err := sqlx.Named("SELECT COUNT(*) FROM ConfigurationFiles WHERE Name = :name", map[string]any{
|
||||
"name": name,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var count int64
|
||||
row := ds.db.QueryRowx(ds.db.Rebind(query), args...)
|
||||
if err = row.Scan(&count); err != nil {
|
||||
return false, errors.Wrapf(err, "failed to scan count of rows for %s", name)
|
||||
}
|
||||
|
||||
return count != 0, nil
|
||||
}
|
||||
|
||||
// RemoveFile remoevs a previously persisted configuration file.
|
||||
func (ds *DatabaseStore) RemoveFile(name string) error {
|
||||
_, err := ds.db.NamedExec("DELETE FROM ConfigurationFiles WHERE Name = :name", map[string]any{
|
||||
"name": name,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to remove row for %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the path to the database backing the config, masking the password.
|
||||
func (ds *DatabaseStore) String() string {
|
||||
return stripPassword(ds.originalDsn, ds.driverName)
|
||||
}
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
func (ds *DatabaseStore) Close() error {
|
||||
return ds.db.Close()
|
||||
}
|
||||
|
||||
// removes configurations from database if they are older than threshold.
|
||||
func (ds *DatabaseStore) cleanUp(thresholdCreatAt int) error {
|
||||
if _, err := ds.db.NamedExec("DELETE FROM Configurations Where CreateAt < :timestamp", map[string]any{"timestamp": thresholdCreatAt}); err != nil {
|
||||
return errors.Wrap(err, "unable to clean Configurations table")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
1165
server/config/database_test.go
Обычный файл
1165
server/config/database_test.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
161
server/config/diff.go
Обычный файл
161
server/config/diff.go
Обычный файл
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type ConfigDiffs []ConfigDiff
|
||||
|
||||
type ConfigDiff struct {
|
||||
Path string `json:"path"`
|
||||
BaseVal any `json:"base_val"`
|
||||
ActualVal any `json:"actual_val"`
|
||||
}
|
||||
|
||||
func (c *ConfigDiff) Auditable() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": c.Path,
|
||||
"base_val": c.BaseVal,
|
||||
"actual_val": c.ActualVal,
|
||||
}
|
||||
}
|
||||
|
||||
func (cd *ConfigDiffs) Auditable() map[string]interface{} {
|
||||
var s []interface{}
|
||||
for _, d := range cd.Sanitize() {
|
||||
s = append(s, d.Auditable())
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"config_diffs": s,
|
||||
}
|
||||
}
|
||||
|
||||
var configSensitivePaths = map[string]bool{
|
||||
"LdapSettings.BindPassword": true,
|
||||
"FileSettings.PublicLinkSalt": true,
|
||||
"FileSettings.AmazonS3SecretAccessKey": true,
|
||||
"SqlSettings.DataSource": true,
|
||||
"SqlSettings.AtRestEncryptKey": true,
|
||||
"SqlSettings.DataSourceReplicas": true,
|
||||
"SqlSettings.DataSourceSearchReplicas": true,
|
||||
"EmailSettings.SMTPPassword": true,
|
||||
"GitLabSettings.Secret": true,
|
||||
"GoogleSettings.Secret": true,
|
||||
"Office365Settings.Secret": true,
|
||||
"OpenIdSettings.Secret": true,
|
||||
"ElasticsearchSettings.Password": true,
|
||||
"MessageExportSettings.GlobalRelaySettings.SMTPUsername": true,
|
||||
"MessageExportSettings.GlobalRelaySettings.SMTPPassword": true,
|
||||
"MessageExportSettings.GlobalRelaySettings.EmailAddress": true,
|
||||
"ServiceSettings.GfycatAPISecret": true,
|
||||
"ServiceSettings.SplitKey": true,
|
||||
"PluginSettings.Plugins": true,
|
||||
}
|
||||
|
||||
// Sanitize replaces sensitive config values in the diff with asterisks filled strings.
|
||||
func (cd ConfigDiffs) Sanitize() ConfigDiffs {
|
||||
if len(cd) == 1 {
|
||||
cfgPtr, ok := cd[0].BaseVal.(*model.Config)
|
||||
if ok {
|
||||
cfgPtr.Sanitize()
|
||||
}
|
||||
cfgPtr, ok = cd[0].ActualVal.(*model.Config)
|
||||
if ok {
|
||||
cfgPtr.Sanitize()
|
||||
}
|
||||
cfgVal, ok := cd[0].BaseVal.(model.Config)
|
||||
if ok {
|
||||
cfgVal.Sanitize()
|
||||
}
|
||||
cfgVal, ok = cd[0].ActualVal.(model.Config)
|
||||
if ok {
|
||||
cfgVal.Sanitize()
|
||||
}
|
||||
}
|
||||
|
||||
for i := range cd {
|
||||
if configSensitivePaths[cd[i].Path] {
|
||||
cd[i].BaseVal = model.FakeSetting
|
||||
cd[i].ActualVal = model.FakeSetting
|
||||
}
|
||||
}
|
||||
|
||||
return cd
|
||||
}
|
||||
|
||||
func diff(base, actual reflect.Value, label string) ([]ConfigDiff, error) {
|
||||
var diffs []ConfigDiff
|
||||
|
||||
if base.IsZero() && actual.IsZero() {
|
||||
return diffs, nil
|
||||
}
|
||||
|
||||
if base.IsZero() || actual.IsZero() {
|
||||
return append(diffs, ConfigDiff{
|
||||
Path: label,
|
||||
BaseVal: base.Interface(),
|
||||
ActualVal: actual.Interface(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
baseType := base.Type()
|
||||
actualType := actual.Type()
|
||||
|
||||
if baseType.Kind() == reflect.Ptr {
|
||||
base = reflect.Indirect(base)
|
||||
actual = reflect.Indirect(actual)
|
||||
baseType = base.Type()
|
||||
actualType = actual.Type()
|
||||
}
|
||||
|
||||
if baseType != actualType {
|
||||
return nil, fmt.Errorf("not same type %s %s", baseType, actualType)
|
||||
}
|
||||
|
||||
switch baseType.Kind() {
|
||||
case reflect.Struct:
|
||||
if base.NumField() != actual.NumField() {
|
||||
return nil, fmt.Errorf("not same number of fields in struct")
|
||||
}
|
||||
for i := 0; i < base.NumField(); i++ {
|
||||
fieldLabel := baseType.Field(i).Name
|
||||
if label != "" {
|
||||
fieldLabel = label + "." + fieldLabel
|
||||
}
|
||||
d, err := diff(base.Field(i), actual.Field(i), fieldLabel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diffs = append(diffs, d...)
|
||||
}
|
||||
default:
|
||||
if !reflect.DeepEqual(base.Interface(), actual.Interface()) {
|
||||
diffs = append(diffs, ConfigDiff{
|
||||
Path: label,
|
||||
BaseVal: base.Interface(),
|
||||
ActualVal: actual.Interface(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return diffs, nil
|
||||
}
|
||||
|
||||
func Diff(base, actual *model.Config) (ConfigDiffs, error) {
|
||||
if base == nil || actual == nil {
|
||||
return nil, fmt.Errorf("input configs should not be nil")
|
||||
}
|
||||
baseVal := reflect.Indirect(reflect.ValueOf(base))
|
||||
actualVal := reflect.Indirect(reflect.ValueOf(actual))
|
||||
return diff(baseVal, actualVal, "")
|
||||
}
|
||||
|
||||
func (cd ConfigDiffs) String() string {
|
||||
return fmt.Sprintf("%+v", []ConfigDiff(cd))
|
||||
}
|
||||
969
server/config/diff_test.go
Обычный файл
969
server/config/diff_test.go
Обычный файл
@@ -0,0 +1,969 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func defaultConfigGen() *model.Config {
|
||||
cfg := &model.Config{}
|
||||
cfg.SetDefaults()
|
||||
return cfg
|
||||
}
|
||||
|
||||
func BenchmarkDiff(b *testing.B) {
|
||||
b.Run("equal empty", func(b *testing.B) {
|
||||
baseCfg := &model.Config{}
|
||||
actualCfg := &model.Config{}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("equal with defaults", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := defaultConfigGen()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("actual empty", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := &model.Config{}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("base empty", func(b *testing.B) {
|
||||
baseCfg := &model.Config{}
|
||||
actualCfg := defaultConfigGen()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("some diffs", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := defaultConfigGen()
|
||||
baseCfg.ServiceSettings.SiteURL = model.NewString("http://localhost")
|
||||
baseCfg.ServiceSettings.ReadTimeout = model.NewInt(300)
|
||||
baseCfg.SqlSettings.QueryTimeout = model.NewInt(0)
|
||||
actualCfg.PluginSettings.EnableUploads = nil
|
||||
actualCfg.TeamSettings.MaxChannelsPerTeam = model.NewInt64(100000)
|
||||
actualCfg.FeatureFlags = nil
|
||||
actualCfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
"ds2",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffSanitized(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
base *model.Config
|
||||
actual *model.Config
|
||||
diffs ConfigDiffs
|
||||
err string
|
||||
}{
|
||||
{
|
||||
"nil",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
"input configs should not be nil",
|
||||
},
|
||||
{
|
||||
"empty",
|
||||
&model.Config{},
|
||||
&model.Config{},
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"defaults",
|
||||
defaultConfigGen(),
|
||||
defaultConfigGen(),
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"default base, actual empty",
|
||||
defaultConfigGen(),
|
||||
&model.Config{},
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "",
|
||||
BaseVal: func() model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Sanitize()
|
||||
return *cfg
|
||||
}(),
|
||||
ActualVal: model.Config{},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty base, actual default",
|
||||
&model.Config{},
|
||||
defaultConfigGen(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "",
|
||||
BaseVal: model.Config{},
|
||||
ActualVal: func() model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Sanitize()
|
||||
return *cfg
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive LdapSettings.BindPassword",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.LdapSettings.BindPassword = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.LdapSettings.BindPassword = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "LdapSettings.BindPassword",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive FileSettings.PublicLinkSalt",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.PublicLinkSalt = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.PublicLinkSalt = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "FileSettings.PublicLinkSalt",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive FileSettings.AmazonS3SecretAccessKey",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.AmazonS3SecretAccessKey = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.AmazonS3SecretAccessKey = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "FileSettings.AmazonS3SecretAccessKey",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive SqlSettings.DataSource",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSource = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSource = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSource",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive SqlSettings.AtRestEncryptKey",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.AtRestEncryptKey = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.AtRestEncryptKey = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.AtRestEncryptKey",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive SqlSettings.DataSourceReplicas",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
"ds2",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSourceReplicas",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive SqlSettings.DataSourceSearchReplicas",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceSearchReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceSearchReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
"ds2",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSourceSearchReplicas",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive EmailSettings.SMTPPassword",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.EmailSettings.SMTPPassword = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.EmailSettings.SMTPPassword = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "EmailSettings.SMTPPassword",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive GitLabSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GitLabSettings.Secret = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GitLabSettings.Secret = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "GitLabSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive GoogleSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GoogleSettings.Secret = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GoogleSettings.Secret = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "GoogleSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive Office365Settings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Office365Settings.Secret = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Office365Settings.Secret = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "Office365Settings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive OpenIdSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.OpenIdSettings.Secret = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.OpenIdSettings.Secret = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "OpenIdSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive ElasticsearchSettings.Password",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ElasticsearchSettings.Password = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ElasticsearchSettings.Password = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ElasticsearchSettings.Password",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive MessageExportSettings.GlobalRelaySettings",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{
|
||||
SMTPUsername: model.NewString("base"),
|
||||
SMTPPassword: model.NewString("base"),
|
||||
EmailAddress: model.NewString("base"),
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{
|
||||
SMTPUsername: model.NewString("actual"),
|
||||
SMTPPassword: model.NewString("actual"),
|
||||
EmailAddress: model.NewString("actual"),
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "MessageExportSettings.GlobalRelaySettings.SMTPUsername",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
{
|
||||
Path: "MessageExportSettings.GlobalRelaySettings.SMTPPassword",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
{
|
||||
Path: "MessageExportSettings.GlobalRelaySettings.EmailAddress",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive ServiceSettings.GfycatAPISecret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.GfycatAPISecret = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.GfycatAPISecret = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.GfycatAPISecret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive ServiceSettings.SplitKey",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SplitKey = model.NewString("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SplitKey = model.NewString("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.SplitKey",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"plugin config",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.Plugins = map[string]map[string]any{
|
||||
"com.mattermost.newplugin": {
|
||||
"key": true,
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.Plugins",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diffs, err := Diff(tc.base, tc.actual)
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
require.Nil(t, diffs)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tc.diffs, diffs.Sanitize())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
base *model.Config
|
||||
actual *model.Config
|
||||
diffs ConfigDiffs
|
||||
err string
|
||||
}{
|
||||
{
|
||||
"nil",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
"input configs should not be nil",
|
||||
},
|
||||
{
|
||||
"empty",
|
||||
&model.Config{},
|
||||
&model.Config{},
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"defaults",
|
||||
defaultConfigGen(),
|
||||
defaultConfigGen(),
|
||||
nil,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"default base, actual empty",
|
||||
defaultConfigGen(),
|
||||
&model.Config{},
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "",
|
||||
BaseVal: *defaultConfigGen(),
|
||||
ActualVal: model.Config{},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"empty base, actual default",
|
||||
&model.Config{},
|
||||
defaultConfigGen(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "",
|
||||
BaseVal: model.Config{},
|
||||
ActualVal: *defaultConfigGen(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"string change",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SiteURL = model.NewString("http://changed")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.SiteURL",
|
||||
BaseVal: *defaultConfigGen().ServiceSettings.SiteURL,
|
||||
ActualVal: "http://changed",
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"string nil",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SiteURL = nil
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.SiteURL",
|
||||
BaseVal: defaultConfigGen().ServiceSettings.SiteURL,
|
||||
ActualVal: func() *string {
|
||||
return nil
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bool change",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.Enable = model.NewBool(!*cfg.PluginSettings.Enable)
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.Enable",
|
||||
BaseVal: true,
|
||||
ActualVal: false,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bool nil",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.Enable = nil
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.Enable",
|
||||
BaseVal: defaultConfigGen().PluginSettings.Enable,
|
||||
ActualVal: func() *bool {
|
||||
return nil
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"int change",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.ReadTimeout = model.NewInt(0)
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.ReadTimeout",
|
||||
BaseVal: *defaultConfigGen().ServiceSettings.ReadTimeout,
|
||||
ActualVal: 0,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"int nil",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.ReadTimeout = nil
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "ServiceSettings.ReadTimeout",
|
||||
BaseVal: defaultConfigGen().ServiceSettings.ReadTimeout,
|
||||
ActualVal: func() *int {
|
||||
return nil
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"slice addition",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSourceReplicas",
|
||||
BaseVal: defaultConfigGen().SqlSettings.DataSourceReplicas,
|
||||
ActualVal: []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"slice deletion",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSourceReplicas",
|
||||
BaseVal: []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
},
|
||||
ActualVal: []string{
|
||||
"ds0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"slice nil",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSourceReplicas = nil
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSourceReplicas",
|
||||
BaseVal: []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
},
|
||||
ActualVal: func() []string {
|
||||
return nil
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"map change",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.PluginStates["com.mattermost.nps"] = &model.PluginState{
|
||||
Enable: !cfg.PluginSettings.PluginStates["com.mattermost.nps"].Enable,
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.PluginStates",
|
||||
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
|
||||
ActualVal: map[string]*model.PluginState{
|
||||
"com.mattermost.nps": {
|
||||
Enable: !defaultConfigGen().PluginSettings.PluginStates["com.mattermost.nps"].Enable,
|
||||
},
|
||||
"focalboard": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.apps": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"map addition",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.PluginStates["com.mattermost.newplugin"] = &model.PluginState{
|
||||
Enable: true,
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.PluginStates",
|
||||
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
|
||||
ActualVal: map[string]*model.PluginState{
|
||||
"com.mattermost.nps": {
|
||||
Enable: defaultConfigGen().PluginSettings.PluginStates["com.mattermost.nps"].Enable,
|
||||
},
|
||||
"com.mattermost.newplugin": {
|
||||
Enable: true,
|
||||
},
|
||||
"focalboard": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.apps": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"map deletion",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
delete(cfg.PluginSettings.PluginStates, "com.mattermost.nps")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.PluginStates",
|
||||
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
|
||||
ActualVal: map[string]*model.PluginState{
|
||||
"focalboard": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.apps": {
|
||||
Enable: true,
|
||||
},
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"map nil",
|
||||
defaultConfigGen(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.PluginStates = nil
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.PluginStates",
|
||||
BaseVal: defaultConfigGen().PluginSettings.PluginStates,
|
||||
ActualVal: func() map[string]*model.PluginState {
|
||||
return nil
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"map type change",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.Plugins = map[string]map[string]any{
|
||||
"com.mattermost.newplugin": {
|
||||
"key": true,
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.PluginSettings.Plugins = map[string]map[string]any{
|
||||
"com.mattermost.newplugin": {
|
||||
"key": "string",
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "PluginSettings.Plugins",
|
||||
BaseVal: func() any {
|
||||
return map[string]map[string]any{
|
||||
"com.mattermost.newplugin": {
|
||||
"key": true,
|
||||
},
|
||||
}
|
||||
}(),
|
||||
ActualVal: func() any {
|
||||
return map[string]map[string]any{
|
||||
"com.mattermost.newplugin": {
|
||||
"key": "string",
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
diffs, err := Diff(tc.base, tc.actual)
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
require.Nil(t, diffs)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tc.diffs, diffs)
|
||||
})
|
||||
}
|
||||
}
|
||||
66
server/config/emitter.go
Обычный файл
66
server/config/emitter.go
Обычный файл
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// Listener is a callback function invoked when the configuration changes.
|
||||
type Listener func(oldCfg, newCfg *model.Config)
|
||||
|
||||
// emitter enables threadsafe registration and broadcasting to configuration listeners
|
||||
type emitter struct {
|
||||
listeners sync.Map
|
||||
}
|
||||
|
||||
// AddListener adds a callback function to invoke when the configuration is modified.
|
||||
func (e *emitter) AddListener(listener Listener) string {
|
||||
id := model.NewId()
|
||||
e.listeners.Store(id, listener)
|
||||
return id
|
||||
}
|
||||
|
||||
// RemoveListener removes a callback function using an id returned from AddListener.
|
||||
func (e *emitter) RemoveListener(id string) {
|
||||
e.listeners.Delete(id)
|
||||
}
|
||||
|
||||
// invokeConfigListeners synchronously notifies all listeners about the configuration change.
|
||||
func (e *emitter) invokeConfigListeners(oldCfg, newCfg *model.Config) {
|
||||
e.listeners.Range(func(key, value any) bool {
|
||||
listener := value.(Listener)
|
||||
listener(oldCfg, newCfg)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// srcEmitter enables threadsafe registration and broadcasting to configuration listeners
|
||||
type logSrcEmitter struct {
|
||||
listeners sync.Map
|
||||
}
|
||||
|
||||
// AddListener adds a callback function to invoke when the configuration is modified.
|
||||
func (e *logSrcEmitter) AddListener(listener LogSrcListener) string {
|
||||
id := model.NewId()
|
||||
e.listeners.Store(id, listener)
|
||||
return id
|
||||
}
|
||||
|
||||
// RemoveListener removes a callback function using an id returned from AddListener.
|
||||
func (e *logSrcEmitter) RemoveListener(id string) {
|
||||
e.listeners.Delete(id)
|
||||
}
|
||||
|
||||
// invokeConfigListeners synchronously notifies all listeners about the configuration change.
|
||||
func (e *logSrcEmitter) invokeConfigListeners(oldCfg, newCfg mlog.LoggerConfiguration) {
|
||||
e.listeners.Range(func(key, value any) bool {
|
||||
listener := value.(LogSrcListener)
|
||||
listener(oldCfg, newCfg)
|
||||
return true
|
||||
})
|
||||
}
|
||||
95
server/config/emitter_test.go
Обычный файл
95
server/config/emitter_test.go
Обычный файл
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestEmitter(t *testing.T) {
|
||||
var e emitter
|
||||
|
||||
expectedOldCfg := &model.Config{}
|
||||
expectedNewCfg := &model.Config{}
|
||||
|
||||
listener1 := false
|
||||
id1 := e.AddListener(func(oldCfg, newCfg *model.Config) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener1 = true
|
||||
})
|
||||
|
||||
listener2 := false
|
||||
id2 := e.AddListener(func(oldCfg, newCfg *model.Config) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener2 = true
|
||||
})
|
||||
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.True(t, listener2, "listener 2 not called")
|
||||
|
||||
e.RemoveListener(id2)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
|
||||
e.RemoveListener(id1)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.False(t, listener1, "listener 1 should not have been called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
}
|
||||
|
||||
func TestLogSrcEmitter(t *testing.T) {
|
||||
var e logSrcEmitter
|
||||
|
||||
expectedOldCfg := make(mlog.LoggerConfiguration)
|
||||
expectedNewCfg := make(mlog.LoggerConfiguration)
|
||||
|
||||
listener1 := false
|
||||
id1 := e.AddListener(func(oldCfg, newCfg mlog.LoggerConfiguration) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener1 = true
|
||||
})
|
||||
|
||||
listener2 := false
|
||||
id2 := e.AddListener(func(oldCfg, newCfg mlog.LoggerConfiguration) {
|
||||
assert.Equal(t, expectedOldCfg, oldCfg)
|
||||
assert.Equal(t, expectedNewCfg, newCfg)
|
||||
listener2 = true
|
||||
})
|
||||
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.True(t, listener2, "listener 2 not called")
|
||||
|
||||
e.RemoveListener(id2)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.True(t, listener1, "listener 1 not called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
|
||||
e.RemoveListener(id1)
|
||||
|
||||
listener1 = false
|
||||
listener2 = false
|
||||
e.invokeConfigListeners(expectedOldCfg, expectedNewCfg)
|
||||
assert.False(t, listener1, "listener 1 should not have been called")
|
||||
assert.False(t, listener2, "listener 2 should not have been called")
|
||||
}
|
||||
194
server/config/environment.go
Обычный файл
194
server/config/environment.go
Обычный файл
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func GetEnvironment() map[string]string {
|
||||
mmenv := make(map[string]string)
|
||||
for _, env := range os.Environ() {
|
||||
kv := strings.SplitN(env, "=", 2)
|
||||
key := strings.ToUpper(kv[0])
|
||||
if strings.HasPrefix(key, "MM") {
|
||||
mmenv[key] = kv[1]
|
||||
}
|
||||
}
|
||||
|
||||
return mmenv
|
||||
}
|
||||
|
||||
func applyEnvKey(key, value string, rValueSubject reflect.Value) {
|
||||
keyParts := strings.SplitN(key, "_", 2)
|
||||
if len(keyParts) < 1 {
|
||||
return
|
||||
}
|
||||
rFieldValue := rValueSubject.FieldByNameFunc(func(candidate string) bool {
|
||||
candidateUpper := strings.ToUpper(candidate)
|
||||
return candidateUpper == keyParts[0]
|
||||
})
|
||||
|
||||
if !rFieldValue.IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
if rFieldValue.Kind() == reflect.Ptr {
|
||||
rFieldValue = rFieldValue.Elem()
|
||||
if !rFieldValue.IsValid() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch rFieldValue.Kind() {
|
||||
case reflect.Struct:
|
||||
// If we have only one part left, we can't deal with a struct
|
||||
// the env var is incomplete so give up.
|
||||
if len(keyParts) < 2 {
|
||||
return
|
||||
}
|
||||
applyEnvKey(keyParts[1], value, rFieldValue)
|
||||
case reflect.String:
|
||||
rFieldValue.Set(reflect.ValueOf(value))
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(value)
|
||||
if err == nil {
|
||||
rFieldValue.Set(reflect.ValueOf(boolVal))
|
||||
}
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(value, 10, 0)
|
||||
if err == nil {
|
||||
rFieldValue.Set(reflect.ValueOf(int(intVal)))
|
||||
}
|
||||
case reflect.Int64:
|
||||
intVal, err := strconv.ParseInt(value, 10, 0)
|
||||
if err == nil {
|
||||
rFieldValue.Set(reflect.ValueOf(intVal))
|
||||
}
|
||||
case reflect.SliceOf(reflect.TypeOf("")).Kind():
|
||||
rFieldValue.Set(reflect.ValueOf(strings.Split(value, " ")))
|
||||
case reflect.Map:
|
||||
target := reflect.New(rFieldValue.Type()).Interface()
|
||||
if err := json.Unmarshal([]byte(value), target); err == nil {
|
||||
rFieldValue.Set(reflect.ValueOf(target).Elem())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvironmentMap(inputConfig *model.Config, env map[string]string) *model.Config {
|
||||
appliedConfig := inputConfig.Clone()
|
||||
|
||||
rvalConfig := reflect.ValueOf(appliedConfig).Elem()
|
||||
for envKey, envValue := range env {
|
||||
applyEnvKey(strings.TrimPrefix(envKey, "MM_"), envValue, rvalConfig)
|
||||
}
|
||||
|
||||
return appliedConfig
|
||||
}
|
||||
|
||||
// generateEnvironmentMap creates a map[string]any containing true at the leaves mirroring the
|
||||
// configuration structure so the client can know which env variables are overridden
|
||||
func generateEnvironmentMap(env map[string]string, filter func(reflect.StructField) bool) map[string]any {
|
||||
rType := reflect.TypeOf(model.Config{})
|
||||
return generateEnvironmentMapWithBaseKey(env, rType, "MM", filter)
|
||||
}
|
||||
|
||||
func generateEnvironmentMapWithBaseKey(env map[string]string, rType reflect.Type, base string, filter func(reflect.StructField) bool) map[string]any {
|
||||
if rType.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
mapRepresentation := make(map[string]any)
|
||||
for i := 0; i < rType.NumField(); i++ {
|
||||
rField := rType.Field(i)
|
||||
if filter != nil && !filter(rField) {
|
||||
continue
|
||||
}
|
||||
if rField.Type.Kind() == reflect.Struct {
|
||||
if val := generateEnvironmentMapWithBaseKey(env, rField.Type, base+"_"+rField.Name, filter); val != nil {
|
||||
mapRepresentation[rField.Name] = val
|
||||
}
|
||||
} else {
|
||||
if _, ok := env[strings.ToUpper(base+"_"+rField.Name)]; ok {
|
||||
mapRepresentation[rField.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mapRepresentation) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return mapRepresentation
|
||||
}
|
||||
|
||||
// removeEnvOverrides returns a new config without the given environment overrides.
|
||||
// If a config variable has an environment override, that variable is set to the value that was
|
||||
// read from the store.
|
||||
func removeEnvOverrides(cfg, cfgWithoutEnv *model.Config, envOverrides map[string]any) *model.Config {
|
||||
paths := getPaths(envOverrides)
|
||||
newCfg := cfg.Clone()
|
||||
for _, path := range paths {
|
||||
originalVal := getVal(cfgWithoutEnv, path)
|
||||
newVal := getVal(newCfg, path)
|
||||
if newVal.CanSet() {
|
||||
newVal.Set(originalVal)
|
||||
}
|
||||
}
|
||||
return newCfg
|
||||
}
|
||||
|
||||
// getPaths turns a nested map into a slice of paths describing the keys of the map. Eg:
|
||||
// map[string]map[string]map[string]bool{"this":{"is first":{"path":true}, "is second":{"path":true}))) is turned into:
|
||||
// [][]string{{"this", "is first", "path"}, {"this", "is second", "path"}}
|
||||
func getPaths(m map[string]any) [][]string {
|
||||
return getPathsRec(m, nil)
|
||||
}
|
||||
|
||||
// getPathsRec assembles the paths (see `getPaths` above)
|
||||
func getPathsRec(src any, curPath []string) [][]string {
|
||||
if srcMap, ok := src.(map[string]any); ok {
|
||||
paths := [][]string{}
|
||||
for k, v := range srcMap {
|
||||
paths = append(paths, getPathsRec(v, append(curPath, k))...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
return [][]string{curPath}
|
||||
}
|
||||
|
||||
// getVal walks `src` (here it starts with a model.Config, then recurses into its leaves)
|
||||
// and returns the reflect.Value of the leaf at the end `path`
|
||||
func getVal(src any, path []string) reflect.Value {
|
||||
var val reflect.Value
|
||||
|
||||
// If we recursed on a Value, we already have it. If we're calling on an any, get the Value.
|
||||
switch v := src.(type) {
|
||||
case reflect.Value:
|
||||
val = v
|
||||
default:
|
||||
val = reflect.ValueOf(src)
|
||||
}
|
||||
|
||||
// Move into the struct
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem().FieldByName(path[0])
|
||||
} else {
|
||||
val = val.FieldByName(path[0])
|
||||
}
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() == reflect.Struct {
|
||||
return getVal(val, path[1:])
|
||||
}
|
||||
return val
|
||||
}
|
||||
202
server/config/environment_test.go
Обычный файл
202
server/config/environment_test.go
Обычный файл
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func modifiedDefault(modify func(*model.Config)) *model.Config {
|
||||
def := defaultConfig()
|
||||
modify(def)
|
||||
return def
|
||||
}
|
||||
|
||||
func defaultConfig() *model.Config {
|
||||
def := &model.Config{}
|
||||
def.SetDefaults()
|
||||
return def
|
||||
}
|
||||
|
||||
func TestRemoveEnvOverrides(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
inputConfig *model.Config
|
||||
env map[string]string
|
||||
expectedConfig *model.Config
|
||||
}{
|
||||
{
|
||||
name: "config override",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ServiceSettings.TLSMinVer = "1.4"
|
||||
in.PluginSettings.PluginStates = map[string]*model.PluginState{
|
||||
"plugin1": {
|
||||
Enable: false,
|
||||
},
|
||||
}
|
||||
in.PluginSettings.Plugins = map[string]map[string]interface{}{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key1": "value1",
|
||||
},
|
||||
"com_mattermost_plugin-2": {
|
||||
"key2": "value2",
|
||||
},
|
||||
}
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_SERVICESETTINGS_TLSMINVER": "1.5",
|
||||
"MM_PLUGINSETTINGS_PLUGINSTATES": `{
|
||||
"plugin1": {
|
||||
"Enable": true
|
||||
}
|
||||
}`,
|
||||
"MM_PLUGINSETTINGS_PLUGINS": `{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key1": "other-value"
|
||||
},
|
||||
"com_mattermost_plugin-2": {
|
||||
"key2": "other-value"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ServiceSettings.TLSMinVer = "1.5"
|
||||
in.PluginSettings.PluginStates = map[string]*model.PluginState{
|
||||
"plugin1": {
|
||||
Enable: true,
|
||||
},
|
||||
}
|
||||
in.PluginSettings.Plugins = map[string]map[string]interface{}{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key1": "other-value",
|
||||
},
|
||||
"com_mattermost_plugin-2": {
|
||||
"key2": "other-value",
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "feature flags",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
in.FeatureFlags.TestFeature = "somevalue"
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_FEATUREFLAGS_TESTFEATURE": "correctvalue",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
in.FeatureFlags.TestFeature = "correctvalue"
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "int setting",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ClusterSettings.GossipPort = 500
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_CLUSTERSETTINGS_GOSSIPPORT": "600",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ClusterSettings.GossipPort = 600
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "int64 setting",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ServiceSettings.TLSStrictTransportMaxAge = 500
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_SERVICESETTINGS_TLSSTRICTTRANSPORTMAXAGE": "4294967294",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ServiceSettings.TLSStrictTransportMaxAge = 4294967294
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "bool setting",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ClusterSettings.UseIPAddress = false
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_CLUSTERSETTINGS_USEIPADDRESS": "true",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
*in.ClusterSettings.UseIPAddress = true
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "[]string setting",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
in.SqlSettings.DataSourceReplicas = []string{"something"}
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_SQLSETTINGS_DATASOURCEREPLICAS": "otherthing alsothis",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
in.SqlSettings.DataSourceReplicas = []string{"otherthing", "alsothis"}
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "complex env settings",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_PLUGINSETTINGS_PLUGINSTATES": `{
|
||||
"com.mattermost.plugin-1": {
|
||||
"enable": true
|
||||
}
|
||||
}`,
|
||||
"MM_PLUGINSETTINGS_PLUGINS": `{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key": {
|
||||
"key": "(?P<key>KEY)-(?P<id>\\d{1,6})(?P<comma>[,;]*)",
|
||||
"value": "[$key-$id](https://example.com/?$project-$id)$comma"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
in.PluginSettings.PluginStates = map[string]*model.PluginState{
|
||||
"com.mattermost.plugin-1": {
|
||||
Enable: true,
|
||||
},
|
||||
}
|
||||
in.PluginSettings.Plugins = map[string]map[string]interface{}{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key": map[string]interface{}{
|
||||
"key": "(?P<key>KEY)-(?P<id>\\d{1,6})(?P<comma>[,;]*)",
|
||||
"value": "[$key-$id](https://example.com/?$project-$id)$comma",
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "bad env",
|
||||
inputConfig: modifiedDefault(func(in *model.Config) {
|
||||
}),
|
||||
env: map[string]string{
|
||||
"MM_SERVICESETTINGS": "huh?",
|
||||
"NOTMM": "huh?",
|
||||
"MM_NOTEXIST": "huh?",
|
||||
"MM_NOTEXIST_MORE_AND_MORE": "huh?",
|
||||
"MM_": "huh?",
|
||||
"MM": "huh?",
|
||||
"MM__": "huh?",
|
||||
"_": "huh?",
|
||||
},
|
||||
expectedConfig: modifiedDefault(func(in *model.Config) {
|
||||
}),
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
require.Equal(t, testCase.expectedConfig, applyEnvironmentMap(testCase.inputConfig, testCase.env))
|
||||
})
|
||||
}
|
||||
}
|
||||
223
server/config/file.go
Обычный файл
223
server/config/file.go
Обычный файл
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrReadOnlyConfiguration is returned when an attempt to modify a read-only configuration is made.
|
||||
ErrReadOnlyConfiguration = errors.New("configuration is read-only")
|
||||
)
|
||||
|
||||
// FileStore is a config store backed by a file such as config/config.json.
|
||||
//
|
||||
// It also uses the folder containing the configuration file for storing other configuration files.
|
||||
// Not to be used directly. Only to be used as a backing store for config.Store
|
||||
type FileStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// NewFileStore creates a new instance of a config store backed by the given file path.
|
||||
func NewFileStore(path string, createFileIfNotExists bool) (fs *FileStore, err error) {
|
||||
resolvedPath, err := resolveConfigFilePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(resolvedPath)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) && createFileIfNotExists {
|
||||
file, err2 := os.Create(resolvedPath)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("could not create config file: %w", err2)
|
||||
}
|
||||
defer file.Close()
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
return &FileStore{
|
||||
path: resolvedPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveConfigFilePath attempts to resolve the given configuration file path to an absolute path.
|
||||
//
|
||||
// Consideration is given to maintaining backwards compatibility when resolving the path to the
|
||||
// configuration file.
|
||||
func resolveConfigFilePath(path string) (string, error) {
|
||||
// Absolute paths are explicit and require no resolution.
|
||||
if filepath.IsAbs(path) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search for the relative path to the file in the channels/config folder, taking into account
|
||||
// various common starting points.
|
||||
if configFile := fileutils.FindFile(filepath.Join("channels/config", path)); configFile != "" {
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
// Search for the relative path to the file in the config folder, taking into account
|
||||
// various common starting points.
|
||||
if configFile := fileutils.FindFile(filepath.Join("config", path)); configFile != "" {
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
// Search for the relative path in the current working directory, also taking into account
|
||||
// various common starting points.
|
||||
if configFile := fileutils.FindPath(path, []string{"."}, nil); configFile != "" {
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
if configFolder, found := fileutils.FindDir("config"); found {
|
||||
return filepath.Join(configFolder, path), nil
|
||||
}
|
||||
|
||||
// Fail altogether if we can't even find the config/ folder. This should only happen if
|
||||
// the executable is relocated away from the supporting files.
|
||||
return "", fmt.Errorf("failed to find config file %s", path)
|
||||
}
|
||||
|
||||
// resolveFilePath uses the name if name is absolute path.
|
||||
// otherwise returns the combined path/name
|
||||
func (fs *FileStore) resolveFilePath(name string) string {
|
||||
// Absolute paths are explicit and require no resolution.
|
||||
if filepath.IsAbs(name) {
|
||||
return name
|
||||
}
|
||||
return filepath.Join(filepath.Dir(fs.path), name)
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety and updates the backing store.
|
||||
func (fs *FileStore) Set(newCfg *model.Config) error {
|
||||
if *newCfg.ClusterSettings.Enable && *newCfg.ClusterSettings.ReadOnlyConfig {
|
||||
return ErrReadOnlyConfiguration
|
||||
}
|
||||
|
||||
return fs.persist(newCfg)
|
||||
}
|
||||
|
||||
// persist writes the configuration to the configured file.
|
||||
func (fs *FileStore) persist(cfg *model.Config) error {
|
||||
b, err := marshalConfig(cfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to serialize")
|
||||
}
|
||||
|
||||
err = os.WriteFile(fs.path, b, 0600)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to write file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load updates the current configuration from the backing store.
|
||||
func (fs *FileStore) Load() ([]byte, error) {
|
||||
f, err := os.Open(fs.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
|
||||
} else if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to open %s for reading", fs.path)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileBytes, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileBytes, nil
|
||||
}
|
||||
|
||||
// GetFile fetches the contents of a previously persisted configuration file.
|
||||
func (fs *FileStore) GetFile(name string) ([]byte, error) {
|
||||
resolvedPath := fs.resolveFilePath(name)
|
||||
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to read file from %s", resolvedPath)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetFilePath returns the resolved path of a configuration file.
|
||||
// The file may not necessarily exist.
|
||||
func (fs *FileStore) GetFilePath(name string) string {
|
||||
return fs.resolveFilePath(name)
|
||||
}
|
||||
|
||||
// SetFile sets or replaces the contents of a configuration file.
|
||||
func (fs *FileStore) SetFile(name string, data []byte) error {
|
||||
resolvedPath := fs.resolveFilePath(name)
|
||||
|
||||
err := os.WriteFile(resolvedPath, data, 0600)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to write file to %s", resolvedPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFile returns true if the given file was previously persisted.
|
||||
func (fs *FileStore) HasFile(name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
resolvedPath := fs.resolveFilePath(name)
|
||||
|
||||
_, err := os.Stat(resolvedPath)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, errors.Wrap(err, "failed to check if file exists")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveFile removes a previously persisted configuration file.
|
||||
func (fs *FileStore) RemoveFile(name string) error {
|
||||
if filepath.IsAbs(name) {
|
||||
// Don't delete absolute filenames, as may be mounted drive, etc.
|
||||
mlog.Debug("Skipping removal of configuration file with absolute path", mlog.String("filename", name))
|
||||
return nil
|
||||
}
|
||||
resolvedPath := filepath.Join(filepath.Dir(fs.path), name)
|
||||
|
||||
err := os.Remove(resolvedPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to remove file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the path to the file backing the config.
|
||||
func (fs *FileStore) String() string {
|
||||
return "file://" + fs.path
|
||||
}
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
func (fs *FileStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
1353
server/config/file_test.go
Обычный файл
1353
server/config/file_test.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
165
server/config/logconfigsrc.go
Обычный файл
165
server/config/logconfigsrc.go
Обычный файл
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
type LogSrcListener func(old, new mlog.LoggerConfiguration)
|
||||
|
||||
// LogConfigSrc abstracts the Advanced Logging configuration so that implementations can
|
||||
// fetch from file, database, etc.
|
||||
type LogConfigSrc interface {
|
||||
// Get fetches the current, cached configuration.
|
||||
Get() mlog.LoggerConfiguration
|
||||
|
||||
// Set updates the dsn specifying the source and reloads
|
||||
Set(dsn string, configStore *Store) (err error)
|
||||
|
||||
// Close cleans up resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewLogConfigSrc creates an advanced logging configuration source, backed by a
|
||||
// file, JSON string, or database.
|
||||
func NewLogConfigSrc(dsn string, configStore *Store) (LogConfigSrc, error) {
|
||||
if dsn == "" {
|
||||
return nil, errors.New("dsn should not be empty")
|
||||
}
|
||||
|
||||
if configStore == nil {
|
||||
return nil, errors.New("configStore should not be nil")
|
||||
}
|
||||
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
|
||||
if isJSONMap(dsn) {
|
||||
return newJSONSrc(dsn)
|
||||
}
|
||||
|
||||
path := dsn
|
||||
// If this is a file based config we need the full path so it can be watched.
|
||||
if strings.HasPrefix(configStore.String(), "file://") && !filepath.IsAbs(dsn) {
|
||||
configPath := strings.TrimPrefix(configStore.String(), "file://")
|
||||
path = filepath.Join(filepath.Dir(configPath), dsn)
|
||||
}
|
||||
|
||||
return newFileSrc(path, configStore)
|
||||
}
|
||||
|
||||
// jsonSrc
|
||||
|
||||
type jsonSrc struct {
|
||||
logSrcEmitter
|
||||
mutex sync.RWMutex
|
||||
cfg mlog.LoggerConfiguration
|
||||
}
|
||||
|
||||
func newJSONSrc(data string) (*jsonSrc, error) {
|
||||
src := &jsonSrc{}
|
||||
return src, src.Set(data, nil)
|
||||
}
|
||||
|
||||
// Get fetches the current, cached configuration
|
||||
func (src *jsonSrc) Get() mlog.LoggerConfiguration {
|
||||
src.mutex.RLock()
|
||||
defer src.mutex.RUnlock()
|
||||
return src.cfg
|
||||
}
|
||||
|
||||
// Set updates the JSON specifying the source and reloads
|
||||
func (src *jsonSrc) Set(data string, _ *Store) error {
|
||||
cfg, err := logTargetCfgFromJSON([]byte(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.set(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (src *jsonSrc) set(cfg mlog.LoggerConfiguration) {
|
||||
src.mutex.Lock()
|
||||
defer src.mutex.Unlock()
|
||||
|
||||
old := src.cfg
|
||||
src.cfg = cfg
|
||||
src.invokeConfigListeners(old, cfg)
|
||||
}
|
||||
|
||||
// Close cleans up resources.
|
||||
func (src *jsonSrc) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSrc
|
||||
|
||||
type fileSrc struct {
|
||||
mutex sync.RWMutex
|
||||
cfg mlog.LoggerConfiguration
|
||||
path string
|
||||
}
|
||||
|
||||
func newFileSrc(path string, configStore *Store) (*fileSrc, error) {
|
||||
src := &fileSrc{
|
||||
path: path,
|
||||
}
|
||||
if err := src.Set(path, configStore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// Get fetches the current, cached configuration
|
||||
func (src *fileSrc) Get() mlog.LoggerConfiguration {
|
||||
src.mutex.RLock()
|
||||
defer src.mutex.RUnlock()
|
||||
return src.cfg
|
||||
}
|
||||
|
||||
// Set updates the dsn specifying the file source and reloads.
|
||||
// The file will be watched for changes and reloaded as needed,
|
||||
// and all listeners notified.
|
||||
func (src *fileSrc) Set(path string, configStore *Store) error {
|
||||
data, err := configStore.GetFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := logTargetCfgFromJSON(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.set(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (src *fileSrc) set(cfg mlog.LoggerConfiguration) {
|
||||
src.mutex.Lock()
|
||||
defer src.mutex.Unlock()
|
||||
|
||||
src.cfg = cfg
|
||||
}
|
||||
|
||||
// Close cleans up resources.
|
||||
func (src *fileSrc) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func logTargetCfgFromJSON(data []byte) (mlog.LoggerConfiguration, error) {
|
||||
cfg := make(mlog.LoggerConfiguration)
|
||||
err := json.Unmarshal(data, &cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
49
server/config/logconfigsrc_test.go
Обычный файл
49
server/config/logconfigsrc_test.go
Обычный файл
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
validJSON = `{"file":{ "Type":"file"}}`
|
||||
badJSON = `{"file":{ Type="file"}}`
|
||||
)
|
||||
|
||||
func TestNewLogConfigSrc(t *testing.T) {
|
||||
store := NewTestMemoryStore()
|
||||
require.NotNil(t, store)
|
||||
err := store.SetFile("advancedlogging.conf", []byte(validJSON))
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsn string
|
||||
configStore *Store
|
||||
wantErr bool
|
||||
wantType LogConfigSrc
|
||||
}{
|
||||
{name: "empty dsn", dsn: "", configStore: store, wantErr: true, wantType: nil},
|
||||
{name: "garbage dsn", dsn: "!@wfejwcevioj", configStore: store, wantErr: true, wantType: nil},
|
||||
{name: "valid json dsn", dsn: validJSON, configStore: store, wantErr: false, wantType: &jsonSrc{}},
|
||||
{name: "invalid json dsn", dsn: badJSON, configStore: store, wantErr: true, wantType: nil},
|
||||
{name: "valid filespec dsn", dsn: "advancedlogging.conf", configStore: store, wantErr: false, wantType: &fileSrc{}},
|
||||
{name: "invalid filespec dsn", dsn: "/nobody/here.conf", configStore: store, wantErr: true, wantType: nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewLogConfigSrc(tt.dsn, tt.configStore)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, tt.wantType, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
238
server/config/logger.go
Обычный файл
238
server/config/logger.go
Обычный файл
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils/fileutils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
LogRotateSizeMB = 100
|
||||
LogCompress = true
|
||||
LogRotateMaxAge = 0
|
||||
LogRotateMaxBackups = 0
|
||||
LogFilename = "mattermost.log"
|
||||
LogNotificationFilename = "notifications.log"
|
||||
LogMinLevelLen = 5
|
||||
LogMinMsgLen = 45
|
||||
LogDelim = " "
|
||||
LogEnableCaller = true
|
||||
)
|
||||
|
||||
type fileLocationFunc func(string) string
|
||||
|
||||
func MloggerConfigFromLoggerConfig(s *model.LogSettings, configSrc LogConfigSrc, getFileFunc fileLocationFunc) (mlog.LoggerConfiguration, error) {
|
||||
cfg := make(mlog.LoggerConfiguration)
|
||||
|
||||
var targetCfg mlog.TargetCfg
|
||||
var err error
|
||||
|
||||
// add the simple logging config
|
||||
if *s.EnableConsole {
|
||||
targetCfg, err = makeSimpleConsoleTarget(*s.ConsoleLevel, *s.ConsoleJson, *s.EnableColor)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg["_defConsole"] = targetCfg
|
||||
}
|
||||
|
||||
if *s.EnableFile {
|
||||
targetCfg, err = makeSimpleFileTarget(getFileFunc(*s.FileLocation), *s.FileLevel, *s.FileJson)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg["_defFile"] = targetCfg
|
||||
}
|
||||
|
||||
if configSrc == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// add advanced logging config
|
||||
cfgAdv := configSrc.Get()
|
||||
cfg.Append(cfgAdv)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func MloggerConfigFromAuditConfig(auditSettings model.ExperimentalAuditSettings, configSrc LogConfigSrc) (mlog.LoggerConfiguration, error) {
|
||||
cfg := make(mlog.LoggerConfiguration)
|
||||
|
||||
var targetCfg mlog.TargetCfg
|
||||
var err error
|
||||
|
||||
// add the simple audit config
|
||||
if *auditSettings.FileEnabled {
|
||||
targetCfg, err = makeSimpleFileTarget(*auditSettings.FileName, "error", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// apply audit specific levels
|
||||
targetCfg.Levels = []mlog.Level{mlog.LvlAuditAPI, mlog.LvlAuditContent, mlog.LvlAuditPerms, mlog.LvlAuditCLI}
|
||||
|
||||
// apply audit specific formatting
|
||||
targetCfg.FormatOptions = json.RawMessage(`{"disable_timestamp": false, "disable_msg": true, "disable_stacktrace": true, "disable_level": true}`)
|
||||
|
||||
cfg["_defAudit"] = targetCfg
|
||||
}
|
||||
|
||||
if configSrc == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// add advanced audit config
|
||||
cfgAdv := configSrc.Get()
|
||||
cfg.Append(cfgAdv)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func GetLogFileLocation(fileLocation string) string {
|
||||
if fileLocation == "" {
|
||||
fileLocation, _ = fileutils.FindDir("logs")
|
||||
}
|
||||
|
||||
return filepath.Join(fileLocation, LogFilename)
|
||||
}
|
||||
|
||||
func GetNotificationsLogFileLocation(fileLocation string) string {
|
||||
if fileLocation == "" {
|
||||
fileLocation, _ = fileutils.FindDir("logs")
|
||||
}
|
||||
|
||||
return filepath.Join(fileLocation, LogNotificationFilename)
|
||||
}
|
||||
|
||||
func GetLogSettingsFromNotificationsLogSettings(notificationLogSettings *model.NotificationLogSettings) *model.LogSettings {
|
||||
settings := &model.LogSettings{}
|
||||
settings.SetDefaults()
|
||||
settings.ConsoleJson = notificationLogSettings.ConsoleJson
|
||||
settings.ConsoleLevel = notificationLogSettings.ConsoleLevel
|
||||
settings.EnableConsole = notificationLogSettings.EnableConsole
|
||||
settings.EnableFile = notificationLogSettings.EnableFile
|
||||
settings.FileJson = notificationLogSettings.FileJson
|
||||
settings.FileLevel = notificationLogSettings.FileLevel
|
||||
settings.FileLocation = notificationLogSettings.FileLocation
|
||||
settings.AdvancedLoggingConfig = notificationLogSettings.AdvancedLoggingConfig
|
||||
settings.EnableColor = notificationLogSettings.EnableColor
|
||||
return settings
|
||||
}
|
||||
|
||||
func makeSimpleConsoleTarget(level string, outputJSON bool, color bool) (mlog.TargetCfg, error) {
|
||||
levels, err := stdLevels(level)
|
||||
if err != nil {
|
||||
return mlog.TargetCfg{}, err
|
||||
}
|
||||
|
||||
target := mlog.TargetCfg{
|
||||
Type: "console",
|
||||
Levels: levels,
|
||||
Options: json.RawMessage(`{"out": "stdout"}`),
|
||||
MaxQueueSize: 1000,
|
||||
}
|
||||
|
||||
if outputJSON {
|
||||
target.Format = "json"
|
||||
target.FormatOptions = makeJSONFormatOptions()
|
||||
} else {
|
||||
target.Format = "plain"
|
||||
target.FormatOptions = makePlainFormatOptions(color)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func makeSimpleFileTarget(filename string, level string, json bool) (mlog.TargetCfg, error) {
|
||||
levels, err := stdLevels(level)
|
||||
if err != nil {
|
||||
return mlog.TargetCfg{}, err
|
||||
}
|
||||
|
||||
fileOpts, err := makeFileOptions(filename)
|
||||
if err != nil {
|
||||
return mlog.TargetCfg{}, fmt.Errorf("cannot encode file options: %w", err)
|
||||
}
|
||||
|
||||
target := mlog.TargetCfg{
|
||||
Type: "file",
|
||||
Levels: levels,
|
||||
Options: fileOpts,
|
||||
MaxQueueSize: 1000,
|
||||
}
|
||||
|
||||
if json {
|
||||
target.Format = "json"
|
||||
target.FormatOptions = makeJSONFormatOptions()
|
||||
} else {
|
||||
target.Format = "plain"
|
||||
target.FormatOptions = makePlainFormatOptions(false)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func stdLevels(level string) ([]mlog.Level, error) {
|
||||
stdLevel, err := stringToStdLevel(level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var levels []mlog.Level
|
||||
for _, l := range mlog.StdAll {
|
||||
if l.ID <= stdLevel.ID {
|
||||
levels = append(levels, l)
|
||||
}
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
func stringToStdLevel(level string) (mlog.Level, error) {
|
||||
level = strings.ToLower(level)
|
||||
for _, l := range mlog.StdAll {
|
||||
if l.Name == level {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
return mlog.Level{}, fmt.Errorf("%s is not a standard level", level)
|
||||
}
|
||||
|
||||
func makeJSONFormatOptions() json.RawMessage {
|
||||
str := fmt.Sprintf(`{"enable_caller": %t}`, LogEnableCaller)
|
||||
return json.RawMessage(str)
|
||||
}
|
||||
|
||||
func makePlainFormatOptions(enableColor bool) json.RawMessage {
|
||||
str := fmt.Sprintf(`{"delim": "%s", "min_level_len": %d, "min_msg_len": %d, "enable_color": %t, "enable_caller": %t}`,
|
||||
LogDelim, LogMinLevelLen, LogMinMsgLen, enableColor, LogEnableCaller)
|
||||
return json.RawMessage(str)
|
||||
}
|
||||
|
||||
func makeFileOptions(filename string) (json.RawMessage, error) {
|
||||
opts := struct {
|
||||
Filename string `json:"filename"`
|
||||
Max_size int `json:"max_size"`
|
||||
Max_age int `json:"max_age"`
|
||||
Max_backups int `json:"max_backups"`
|
||||
Compress bool `json:"compress"`
|
||||
}{
|
||||
Filename: filename,
|
||||
Max_size: LogRotateSizeMB,
|
||||
Max_age: LogRotateMaxAge,
|
||||
Max_backups: LogRotateMaxBackups,
|
||||
Compress: LogCompress,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.RawMessage(b), nil
|
||||
}
|
||||
53
server/config/logger_test.go
Обычный файл
53
server/config/logger_test.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
func TestMloggerConfigFromAuditConfig(t *testing.T) {
|
||||
auditSettings := model.ExperimentalAuditSettings{
|
||||
FileEnabled: model.NewBool(true),
|
||||
FileName: model.NewString("audit.log"),
|
||||
FileMaxSizeMB: model.NewInt(20),
|
||||
FileMaxAgeDays: model.NewInt(1),
|
||||
FileMaxBackups: model.NewInt(5),
|
||||
FileCompress: model.NewBool(true),
|
||||
FileMaxQueueSize: model.NewInt(5000),
|
||||
}
|
||||
|
||||
t.Run("validate default audit settings", func(t *testing.T) {
|
||||
cfg, err := MloggerConfigFromAuditConfig(auditSettings, nil)
|
||||
require.NoError(t, err, "audit config should not error")
|
||||
require.Len(t, cfg, 1, "default audit config should have one target")
|
||||
|
||||
targetCfg := cfg["_defAudit"]
|
||||
|
||||
// check general
|
||||
assert.Equal(t, targetCfg.Type, "file")
|
||||
assert.Equal(t, targetCfg.Format, "json")
|
||||
assert.ElementsMatch(t, targetCfg.Levels, []mlog.Level{mlog.LvlAuditAPI, mlog.LvlAuditContent, mlog.LvlAuditPerms, mlog.LvlAuditCLI})
|
||||
|
||||
// check format options
|
||||
optionsExpected := map[string]any{
|
||||
"disable_timestamp": false,
|
||||
"disable_msg": true,
|
||||
"disable_stacktrace": true,
|
||||
"disable_level": true,
|
||||
}
|
||||
var optionsReceived map[string]any
|
||||
err = json.Unmarshal(targetCfg.FormatOptions, &optionsReceived)
|
||||
require.NoError(t, err, "unmarshal should not fail")
|
||||
assert.Equal(t, optionsExpected, optionsReceived)
|
||||
})
|
||||
|
||||
}
|
||||
74
server/config/main_test.go
Обычный файл
74
server/config/main_test.go
Обычный файл
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/testlib"
|
||||
)
|
||||
|
||||
var mainHelper *testlib.MainHelper
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
var options = testlib.HelperOptions{
|
||||
EnableStore: true,
|
||||
}
|
||||
|
||||
mainHelper = testlib.NewMainHelperWithOptions(&options)
|
||||
defer mainHelper.Close()
|
||||
|
||||
mainHelper.Main(m)
|
||||
}
|
||||
|
||||
// truncateTable clears the given table
|
||||
func truncateTable(t *testing.T, table string) {
|
||||
t.Helper()
|
||||
sqlSetting := mainHelper.GetSQLSettings()
|
||||
sqlStore := mainHelper.GetSQLStore()
|
||||
|
||||
switch *sqlSetting.DriverName {
|
||||
case model.DatabaseDriverMysql:
|
||||
_, err := sqlStore.GetMasterX().Exec(fmt.Sprintf("TRUNCATE TABLE %s", table))
|
||||
if err != nil {
|
||||
if driverErr, ok := err.(*mysql.MySQLError); ok {
|
||||
// Ignore if the Configurations table does not exist.
|
||||
if driverErr.Number == 1146 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
case model.DatabaseDriverPostgres:
|
||||
_, err := sqlStore.GetMasterX().Exec(fmt.Sprintf("TRUNCATE TABLE %s", table))
|
||||
if err != nil {
|
||||
if driverErr, ok := err.(*pq.Error); ok {
|
||||
// Ignore if the Configurations table does not exist.
|
||||
if driverErr.Code == "42P01" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
default:
|
||||
require.Failf(t, "failed", "unsupported driver name: %s", *sqlSetting.DriverName)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateTables clears tables used by the config package for reuse in other tests
|
||||
func truncateTables(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
truncateTable(t, "Configurations")
|
||||
truncateTable(t, "ConfigurationFiles")
|
||||
truncateTable(t, migrationsTableName)
|
||||
}
|
||||
120
server/config/memory.go
Обычный файл
120
server/config/memory.go
Обычный файл
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
// MemoryStore implements the Store interface. It is meant primarily for testing.
|
||||
// Not to be used directly. Only to be used as a backing store for config.Store
|
||||
type MemoryStore struct {
|
||||
allowEnvironmentOverrides bool
|
||||
validate bool
|
||||
files map[string][]byte
|
||||
savedConfig *model.Config
|
||||
}
|
||||
|
||||
// MemoryStoreOptions makes configuration of the memory store explicit.
|
||||
type MemoryStoreOptions struct {
|
||||
IgnoreEnvironmentOverrides bool
|
||||
SkipValidation bool
|
||||
InitialConfig *model.Config
|
||||
InitialFiles map[string][]byte
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new MemoryStore instance with default options.
|
||||
func NewMemoryStore() (*MemoryStore, error) {
|
||||
return NewMemoryStoreWithOptions(&MemoryStoreOptions{})
|
||||
}
|
||||
|
||||
// NewMemoryStoreWithOptions creates a new MemoryStore instance.
|
||||
func NewMemoryStoreWithOptions(options *MemoryStoreOptions) (*MemoryStore, error) {
|
||||
savedConfig := options.InitialConfig
|
||||
if savedConfig == nil {
|
||||
savedConfig = &model.Config{}
|
||||
savedConfig.SetDefaults()
|
||||
}
|
||||
|
||||
initialFiles := options.InitialFiles
|
||||
if initialFiles == nil {
|
||||
initialFiles = make(map[string][]byte)
|
||||
}
|
||||
|
||||
ms := &MemoryStore{
|
||||
allowEnvironmentOverrides: !options.IgnoreEnvironmentOverrides,
|
||||
validate: !options.SkipValidation,
|
||||
files: initialFiles,
|
||||
savedConfig: savedConfig,
|
||||
}
|
||||
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety.
|
||||
func (ms *MemoryStore) Set(newCfg *model.Config) error {
|
||||
return ms.persist(newCfg)
|
||||
}
|
||||
|
||||
// persist copies the active config to the saved config.
|
||||
func (ms *MemoryStore) persist(cfg *model.Config) error {
|
||||
ms.savedConfig = cfg.Clone()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load applies environment overrides to the default config as if a re-load had occurred.
|
||||
func (ms *MemoryStore) Load() ([]byte, error) {
|
||||
cfgBytes, err := marshalConfig(ms.savedConfig)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to serialize config")
|
||||
}
|
||||
|
||||
return cfgBytes, nil
|
||||
|
||||
}
|
||||
|
||||
// GetFile fetches the contents of a previously persisted configuration file.
|
||||
func (ms *MemoryStore) GetFile(name string) ([]byte, error) {
|
||||
data, ok := ms.files[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not stored", name)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// SetFile sets or replaces the contents of a configuration file.
|
||||
func (ms *MemoryStore) SetFile(name string, data []byte) error {
|
||||
ms.files[name] = data
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFile returns true if the given file was previously persisted.
|
||||
func (ms *MemoryStore) HasFile(name string) (bool, error) {
|
||||
_, ok := ms.files[name]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// RemoveFile removes a previously persisted configuration file.
|
||||
func (ms *MemoryStore) RemoveFile(name string) error {
|
||||
delete(ms.files, name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a hard-coded description, as there is no backing store.
|
||||
func (ms *MemoryStore) String() string {
|
||||
return "memory://"
|
||||
}
|
||||
|
||||
// Close does nothing for a memory store.
|
||||
func (ms *MemoryStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
205
server/config/memory_test.go
Обычный файл
205
server/config/memory_test.go
Обычный файл
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupConfigMemory(t *testing.T) {
|
||||
t.Helper()
|
||||
os.Clearenv()
|
||||
}
|
||||
|
||||
func TestMemoryGetFile(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
InitialFiles: map[string][]byte{
|
||||
"empty-file": {},
|
||||
"test-file": []byte("test"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
t.Run("get empty filename", func(t *testing.T) {
|
||||
_, err := ms.GetFile("")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("get non-existent file", func(t *testing.T) {
|
||||
_, err := ms.GetFile("unknown")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("get empty file", func(t *testing.T) {
|
||||
data, err := ms.GetFile("empty-file")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, data)
|
||||
})
|
||||
|
||||
t.Run("get non-empty file", func(t *testing.T) {
|
||||
data, err := ms.GetFile("test-file")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("test"), data)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemorySetFile(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
t.Run("set new file", func(t *testing.T) {
|
||||
err := ms.SetFile("new", []byte("new file"))
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := ms.GetFile("new")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("new file"), data)
|
||||
})
|
||||
|
||||
t.Run("overwrite existing file", func(t *testing.T) {
|
||||
err := ms.SetFile("existing", []byte("existing file"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ms.SetFile("existing", []byte("overwritten file"))
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := ms.GetFile("existing")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("overwritten file"), data)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemoryHasFile(t *testing.T) {
|
||||
t.Run("has non-existent", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
has, err := ms.HasFile("non-existent")
|
||||
require.NoError(t, err)
|
||||
require.False(t, has)
|
||||
})
|
||||
|
||||
t.Run("has existing", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
err = ms.SetFile("existing", []byte("existing file"))
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err := ms.HasFile("existing")
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
})
|
||||
|
||||
t.Run("has manually created file", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
InitialFiles: map[string][]byte{
|
||||
"manual": []byte("manual file"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
has, err := ms.HasFile("manual")
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemoryRemoveFile(t *testing.T) {
|
||||
t.Run("remove non-existent", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
err = ms.RemoveFile("non-existent")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("remove existing", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
err = ms.SetFile("existing", []byte("existing file"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ms.RemoveFile("existing")
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err := ms.HasFile("existing")
|
||||
require.NoError(t, err)
|
||||
require.False(t, has)
|
||||
|
||||
_, err = ms.GetFile("existing")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("remove manually created file", func(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{
|
||||
InitialConfig: minimalConfig,
|
||||
InitialFiles: map[string][]byte{
|
||||
"manual": []byte("manual file"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
err = ms.RemoveFile("manual")
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err := ms.HasFile("manual")
|
||||
require.NoError(t, err)
|
||||
require.False(t, has)
|
||||
|
||||
_, err = ms.GetFile("manual")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemoryStoreString(t *testing.T) {
|
||||
setupConfigMemory(t)
|
||||
|
||||
ms, err := NewMemoryStoreWithOptions(&MemoryStoreOptions{InitialConfig: emptyConfig})
|
||||
require.NoError(t, err)
|
||||
defer ms.Close()
|
||||
|
||||
assert.Equal(t, "memory://", ms.String())
|
||||
}
|
||||
69
server/config/migrate.go
Обычный файл
69
server/config/migrate.go
Обычный файл
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Migrate migrates SAML keys, certificates, and other config files from one store to another given their data source names.
|
||||
func Migrate(from, to string) error {
|
||||
source, err := NewStoreFromDSN(from, false, nil, false)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to access source config %s", from)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := NewStoreFromDSN(to, false, nil, true)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to access destination config %s", to)
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
sourceConfig := source.Get()
|
||||
if _, _, err = destination.Set(sourceConfig); err != nil {
|
||||
return errors.Wrapf(err, "failed to set config")
|
||||
}
|
||||
|
||||
files := []string{
|
||||
*sourceConfig.SamlSettings.IdpCertificateFile,
|
||||
*sourceConfig.SamlSettings.PublicCertificateFile,
|
||||
*sourceConfig.SamlSettings.PrivateKeyFile,
|
||||
}
|
||||
|
||||
// Only migrate advanced logging config if it is not embedded JSON.
|
||||
if !isJSONMap(*sourceConfig.LogSettings.AdvancedLoggingConfig) {
|
||||
files = append(files, *sourceConfig.LogSettings.AdvancedLoggingConfig)
|
||||
}
|
||||
|
||||
files = append(files, sourceConfig.PluginSettings.SignaturePublicKeyFiles...)
|
||||
|
||||
for _, file := range files {
|
||||
if err := migrateFile(file, source, destination); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateFile(name string, source *Store, destination *Store) error {
|
||||
fileExists, err := source.HasFile(name)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to check existence of %s", name)
|
||||
}
|
||||
|
||||
if fileExists {
|
||||
file, err := source.GetFile(name)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to migrate %s", name)
|
||||
}
|
||||
err = destination.SetFile(name, file)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to migrate %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
162
server/config/migrate_test.go
Обычный файл
162
server/config/migrate_test.go
Обычный файл
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
type cleanUpFn func(store *Store)
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping migration test in short mode")
|
||||
}
|
||||
files := []string{
|
||||
"IdpCertificateFile",
|
||||
"PublicCertificateFile",
|
||||
"PrivateKeyFile",
|
||||
"internal.crt",
|
||||
"internal2.crt",
|
||||
}
|
||||
filesData := make([]string, len(files))
|
||||
for i := range files {
|
||||
// Generate random data for each file, ensuring that stale data from a past test
|
||||
// won't generate a false positive.
|
||||
filesData[i] = model.NewId()
|
||||
}
|
||||
|
||||
setup := func(t *testing.T) {
|
||||
os.Clearenv()
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "TestMigrate")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
|
||||
err = os.Chdir(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
truncateTables(t)
|
||||
}
|
||||
|
||||
setupSource := func(t *testing.T, source *Store) cleanUpFn {
|
||||
t.Helper()
|
||||
|
||||
cfg := source.Get()
|
||||
originalCfg := cfg.Clone()
|
||||
cfg.ServiceSettings.SiteURL = model.NewString("http://example.com")
|
||||
cfg.SamlSettings.IdpCertificateFile = &files[0]
|
||||
cfg.SamlSettings.PublicCertificateFile = &files[1]
|
||||
cfg.SamlSettings.PrivateKeyFile = &files[2]
|
||||
cfg.PluginSettings.SignaturePublicKeyFiles = []string{
|
||||
files[3],
|
||||
files[4],
|
||||
}
|
||||
cfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"mysql://mmuser:password@tcp(replicahost:3306)/mattermost",
|
||||
}
|
||||
cfg.SqlSettings.DataSourceSearchReplicas = []string{
|
||||
"mysql://mmuser:password@tcp(searchreplicahost:3306)/mattermost",
|
||||
}
|
||||
|
||||
_, _, err := source.Set(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
for i, file := range files {
|
||||
err = source.SetFile(file, []byte(filesData[i]))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return func(store *Store) {
|
||||
_, _, err := store.Set(originalCfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
assertDestination := func(t *testing.T, destination *Store, source *Store) {
|
||||
t.Helper()
|
||||
|
||||
for i, file := range files {
|
||||
hasFile, err := destination.HasFile(file)
|
||||
require.NoError(t, err)
|
||||
require.Truef(t, hasFile, "destination missing file %s", file)
|
||||
|
||||
actualData, err := destination.GetFile(file)
|
||||
require.NoError(t, err)
|
||||
assert.Equalf(t, []byte(filesData[i]), actualData, "destination has wrong contents for file %s", file)
|
||||
}
|
||||
|
||||
assert.Equal(t, source.Get(), destination.Get())
|
||||
}
|
||||
|
||||
t.Run("database to file", func(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlSettings := mainHelper.GetSQLSettings()
|
||||
destinationDSN := path.Join(pwd, "config-custom.json")
|
||||
sourceDSN := getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource)
|
||||
|
||||
sourcedb, err := NewDatabaseStore(sourceDSN)
|
||||
require.NoError(t, err)
|
||||
source, err := NewStoreFromBacking(sourcedb, nil, false)
|
||||
require.NoError(t, err)
|
||||
defer source.Close()
|
||||
|
||||
cleanUp := setupSource(t, source)
|
||||
err = Migrate(sourceDSN, destinationDSN)
|
||||
require.NoError(t, err)
|
||||
|
||||
destinationfile, err := NewFileStore(destinationDSN, false)
|
||||
require.NoError(t, err)
|
||||
destination, err := NewStoreFromBacking(destinationfile, nil, false)
|
||||
require.NoError(t, err)
|
||||
defer destination.Close()
|
||||
defer cleanUp(destination)
|
||||
|
||||
assertDestination(t, destination, source)
|
||||
})
|
||||
|
||||
t.Run("file to database", func(t *testing.T) {
|
||||
setup(t)
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlSettings := mainHelper.GetSQLSettings()
|
||||
sourceDSN := path.Join(pwd, "config-custom.json")
|
||||
destinationDSN := getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource)
|
||||
|
||||
sourcefile, err := NewFileStore(sourceDSN, true)
|
||||
require.NoError(t, err)
|
||||
source, err := NewStoreFromBacking(sourcefile, nil, false)
|
||||
require.NoError(t, err)
|
||||
defer source.Close()
|
||||
|
||||
cleanUp := setupSource(t, source)
|
||||
err = Migrate(sourceDSN, destinationDSN)
|
||||
require.NoError(t, err)
|
||||
|
||||
destinationdb, err := NewDatabaseStore(destinationDSN)
|
||||
require.NoError(t, err)
|
||||
destination, err := NewStoreFromBacking(destinationdb, nil, false)
|
||||
require.NoError(t, err)
|
||||
defer destination.Close()
|
||||
defer cleanUp(destination)
|
||||
|
||||
assertDestination(t, destination, source)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-- It is intentionally to not have a rollback for adding configurations table.
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE IF NOT EXISTS Configurations (
|
||||
Id VARCHAR(26) PRIMARY KEY,
|
||||
Value TEXT NOT NULL,
|
||||
CreateAt BIGINT NOT NULL,
|
||||
Active BOOLEAN NULL UNIQUE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Configurations'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'Value'
|
||||
AND NOT data_type = 'MEDIUMTEXT'
|
||||
) > 0,
|
||||
'ALTER TABLE Configurations MODIFY Value MEDIUMTEXT;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE alterIfExists FROM @preparedStatement;
|
||||
EXECUTE alterIfExists;
|
||||
DEALLOCATE PREPARE alterIfExists;
|
||||
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES AS T
|
||||
JOIN INFORMATION_SCHEMA.COLUMNS AS C USING (TABLE_SCHEMA, TABLE_NAME)
|
||||
JOIN INFORMATION_SCHEMA.COLLATION_CHARACTER_SET_APPLICABILITY AS CCSA ON (T.TABLE_COLLATION = CCSA.COLLATION_NAME)
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND C.DATA_TYPE IN ('enum', 'varchar', 'char', 'text', 'mediumtext', 'longtext')
|
||||
AND TABLE_NAME = 'Configurations'
|
||||
AND C.CHARACTER_SET_NAME != 'utf8mb4'
|
||||
) > 0,
|
||||
'ALTER TABLE Configurations CONVERT TO CHARACTER SET utf8mb4;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE alterIfExists FROM @preparedStatement;
|
||||
EXECUTE alterIfExists;
|
||||
DEALLOCATE PREPARE alterIfExists;
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- It is intentionally to not have a rollback for adding configuration files table.
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE IF NOT EXISTS ConfigurationFiles (
|
||||
Name VARCHAR(64) PRIMARY KEY,
|
||||
Data TEXT NOT NULL,
|
||||
CreateAt BIGINT NOT NULL,
|
||||
UpdateAt BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'ConfigurationFiles'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'Data'
|
||||
AND NOT data_type = 'MEDIUMTEXT'
|
||||
) > 0,
|
||||
'ALTER TABLE ConfigurationFiles MODIFY Data MEDIUMTEXT;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE alterIfExists FROM @preparedStatement;
|
||||
EXECUTE alterIfExists;
|
||||
DEALLOCATE PREPARE alterIfExists;
|
||||
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES AS T
|
||||
JOIN INFORMATION_SCHEMA.COLUMNS AS C USING (TABLE_SCHEMA, TABLE_NAME)
|
||||
JOIN INFORMATION_SCHEMA.COLLATION_CHARACTER_SET_APPLICABILITY AS CCSA ON (T.TABLE_COLLATION = CCSA.COLLATION_NAME)
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND C.DATA_TYPE IN ('enum', 'varchar', 'char', 'text', 'mediumtext', 'longtext')
|
||||
AND TABLE_NAME = 'ConfigurationFiles'
|
||||
AND C.CHARACTER_SET_NAME != 'utf8mb4'
|
||||
) > 0,
|
||||
'ALTER TABLE ConfigurationFiles CONVERT TO CHARACTER SET utf8mb4;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE alterIfExists FROM @preparedStatement;
|
||||
EXECUTE alterIfExists;
|
||||
DEALLOCATE PREPARE alterIfExists;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Configurations'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'SHA'
|
||||
) > 0,
|
||||
'ALTER TABLE Configurations DROP COLUMN SHA;',
|
||||
'SELECT 1'
|
||||
));
|
||||
|
||||
PREPARE alterIfExists FROM @preparedStatement;
|
||||
EXECUTE alterIfExists;
|
||||
DEALLOCATE PREPARE alterIfExists;
|
||||
@@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Configurations'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'SHA'
|
||||
) > 0,
|
||||
'SELECT 1',
|
||||
'ALTER TABLE Configurations ADD COLUMN SHA char(64) DEFAULT "";'
|
||||
));
|
||||
|
||||
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||
EXECUTE alterIfNotExists;
|
||||
DEALLOCATE PREPARE alterIfNotExists;
|
||||
@@ -0,0 +1 @@
|
||||
-- It is intentionally to not have a rollback for adding configurations table.
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS configurations (
|
||||
id varchar(26),
|
||||
value text NOT NULL,
|
||||
createat bigint NOT NULL,
|
||||
active boolean DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (active)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
-- It is intentionally to not have a rollback for adding configuration files table.
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS configurationfiles (
|
||||
name VARCHAR(64),
|
||||
data text NOT NULL,
|
||||
createat bigint NOT NULL,
|
||||
updateat bigint NOT NULL,
|
||||
PRIMARY KEY (name)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE Configurations DROP COLUMN IF EXISTS SHA;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE Configurations ADD COLUMN IF NOT EXISTS SHA CHAR(64) DEFAULT '';
|
||||
413
server/config/store.go
Обычный файл
413
server/config/store.go
Обычный файл
@@ -0,0 +1,413 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils/jsonutils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrReadOnlyStore is returned when an attempt to modify a read-only
|
||||
// configuration store is made.
|
||||
ErrReadOnlyStore = errors.New("configuration store is read-only")
|
||||
)
|
||||
|
||||
// Store is the higher level object that handles storing and retrieval of config data.
|
||||
// To do so it relies on a variety of backing stores (e.g. file, database, memory).
|
||||
type Store struct {
|
||||
emitter
|
||||
backingStore BackingStore
|
||||
|
||||
configLock sync.RWMutex
|
||||
config *model.Config
|
||||
configNoEnv *model.Config
|
||||
configCustomDefaults *model.Config
|
||||
|
||||
readOnly bool
|
||||
readOnlyFF bool
|
||||
}
|
||||
|
||||
// BackingStore defines the behaviour exposed by the underlying store
|
||||
// implementation (e.g. file, database).
|
||||
type BackingStore interface {
|
||||
// Set replaces the current configuration in its entirety and updates the backing store.
|
||||
Set(*model.Config) error
|
||||
|
||||
// Load retrieves the configuration stored. If there is no configuration stored
|
||||
// the io.ReadCloser will be nil
|
||||
Load() ([]byte, error)
|
||||
|
||||
// GetFile fetches the contents of a previously persisted configuration file.
|
||||
// If no such file exists, an empty byte array will be returned without error.
|
||||
GetFile(name string) ([]byte, error)
|
||||
|
||||
// SetFile sets or replaces the contents of a configuration file.
|
||||
SetFile(name string, data []byte) error
|
||||
|
||||
// HasFile returns true if the given file was previously persisted.
|
||||
HasFile(name string) (bool, error)
|
||||
|
||||
// RemoveFile removes a previously persisted configuration file.
|
||||
RemoveFile(name string) error
|
||||
|
||||
// String describes the backing store for the config.
|
||||
String() string
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewStoreFromBacking creates and returns a new config store given a backing store.
|
||||
func NewStoreFromBacking(backingStore BackingStore, customDefaults *model.Config, readOnly bool) (*Store, error) {
|
||||
store := &Store{
|
||||
backingStore: backingStore,
|
||||
configCustomDefaults: customDefaults,
|
||||
readOnly: readOnly,
|
||||
readOnlyFF: true,
|
||||
}
|
||||
|
||||
if err := store.Load(); err != nil {
|
||||
return nil, errors.Wrap(err, "unable to load on store creation")
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// NewStoreFromDSN creates and returns a new config store backed by either a database or file store
|
||||
// depending on the value of the given data source name string.
|
||||
func NewStoreFromDSN(dsn string, readOnly bool, customDefaults *model.Config, createFileIfNotExist bool) (*Store, error) {
|
||||
var err error
|
||||
var backingStore BackingStore
|
||||
if IsDatabaseDSN(dsn) {
|
||||
backingStore, err = NewDatabaseStore(dsn)
|
||||
} else {
|
||||
backingStore, err = NewFileStore(dsn, createFileIfNotExist)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store, err := NewStoreFromBacking(backingStore, customDefaults, readOnly)
|
||||
if err != nil {
|
||||
backingStore.Close()
|
||||
return nil, errors.Wrap(err, "failed to create store")
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// NewTestMemoryStore returns a new config store backed by a memory store
|
||||
// to be used for testing purposes.
|
||||
func NewTestMemoryStore() *Store {
|
||||
memoryStore, err := NewMemoryStore()
|
||||
if err != nil {
|
||||
panic("failed to initialize memory store: " + err.Error())
|
||||
}
|
||||
|
||||
configStore, err := NewStoreFromBacking(memoryStore, nil, false)
|
||||
if err != nil {
|
||||
panic("failed to initialize config store: " + err.Error())
|
||||
}
|
||||
|
||||
return configStore
|
||||
}
|
||||
|
||||
// Get fetches the current, cached configuration.
|
||||
func (s *Store) Get() *model.Config {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return s.config
|
||||
}
|
||||
|
||||
// GetNoEnv fetches the current cached configuration without environment variable overrides.
|
||||
func (s *Store) GetNoEnv() *model.Config {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return s.configNoEnv
|
||||
}
|
||||
|
||||
// GetEnvironmentOverrides fetches the configuration fields overridden by environment variables.
|
||||
func (s *Store) GetEnvironmentOverrides() map[string]any {
|
||||
return generateEnvironmentMap(GetEnvironment(), nil)
|
||||
}
|
||||
|
||||
// GetEnvironmentOverridesWithFilter fetches the configuration fields overridden by environment variables.
|
||||
// If filter is not nil and returns false for a struct field, that field will be omitted.
|
||||
func (s *Store) GetEnvironmentOverridesWithFilter(filter func(reflect.StructField) bool) map[string]any {
|
||||
return generateEnvironmentMap(GetEnvironment(), filter)
|
||||
}
|
||||
|
||||
// RemoveEnvironmentOverrides returns a new config without the environment
|
||||
// overrides.
|
||||
func (s *Store) RemoveEnvironmentOverrides(cfg *model.Config) *model.Config {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return removeEnvOverrides(cfg, s.configNoEnv, s.GetEnvironmentOverrides())
|
||||
}
|
||||
|
||||
// SetReadOnlyFF sets whether feature flags should be written out to
|
||||
// config or treated as read-only.
|
||||
func (s *Store) SetReadOnlyFF(readOnly bool) {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
s.readOnlyFF = readOnly
|
||||
}
|
||||
|
||||
// Set replaces the current configuration in its entirety and updates the backing store.
|
||||
// It returns both old and new versions of the config.
|
||||
func (s *Store) Set(newCfg *model.Config) (*model.Config, *model.Config, error) {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
|
||||
if s.readOnly {
|
||||
return nil, nil, ErrReadOnlyStore
|
||||
}
|
||||
|
||||
newCfg = newCfg.Clone()
|
||||
oldCfg := s.config.Clone()
|
||||
oldCfgNoEnv := s.configNoEnv
|
||||
|
||||
// Setting defaults allows us to accept partial config objects.
|
||||
newCfg.SetDefaults()
|
||||
|
||||
// Sometimes the config is received with "fake" data in sensitive fields. Apply the real
|
||||
// data from the existing config as necessary.
|
||||
desanitize(oldCfg, newCfg)
|
||||
|
||||
// We apply back environment overrides since the input config may or
|
||||
// may not have them applied.
|
||||
newCfg = applyEnvironmentMap(newCfg, GetEnvironment())
|
||||
fixConfig(newCfg)
|
||||
|
||||
if err := newCfg.IsValid(); err != nil {
|
||||
return nil, nil, errors.Wrap(err, "new configuration is invalid")
|
||||
}
|
||||
|
||||
// We attempt to remove any environment override that may be present in the input config.
|
||||
newCfgNoEnv := removeEnvOverrides(newCfg, oldCfgNoEnv, s.GetEnvironmentOverrides())
|
||||
|
||||
// Don't store feature flags unless we are on MM cloud
|
||||
// MM cloud uses config in the DB as a cache of the feature flag
|
||||
// settings in case the management system is down when a pod starts.
|
||||
|
||||
// Backing up feature flags section in case we need to restore them later on.
|
||||
oldCfgFF := oldCfg.FeatureFlags
|
||||
oldCfgNoEnvFF := oldCfgNoEnv.FeatureFlags
|
||||
// Clearing FF sections to avoid both comparing and persisting them.
|
||||
if s.readOnlyFF {
|
||||
oldCfg.FeatureFlags = nil
|
||||
newCfg.FeatureFlags = nil
|
||||
newCfgNoEnv.FeatureFlags = nil
|
||||
}
|
||||
|
||||
if err := s.backingStore.Set(newCfgNoEnv); err != nil {
|
||||
return nil, nil, errors.Wrap(err, "failed to persist")
|
||||
}
|
||||
|
||||
hasChanged, err := equal(oldCfg, newCfg)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "failed to compare configs")
|
||||
}
|
||||
|
||||
// We restore the previously cleared feature flags sections back.
|
||||
if s.readOnlyFF {
|
||||
oldCfg.FeatureFlags = oldCfgFF
|
||||
newCfg.FeatureFlags = oldCfgFF
|
||||
newCfgNoEnv.FeatureFlags = oldCfgNoEnvFF
|
||||
}
|
||||
|
||||
s.configNoEnv = newCfgNoEnv
|
||||
s.config = newCfg
|
||||
|
||||
newCfgCopy := newCfg.Clone()
|
||||
|
||||
if hasChanged {
|
||||
s.configLock.Unlock()
|
||||
s.invokeConfigListeners(oldCfg, newCfgCopy.Clone())
|
||||
s.configLock.Lock()
|
||||
}
|
||||
|
||||
return oldCfg, newCfgCopy, nil
|
||||
}
|
||||
|
||||
// Load updates the current configuration from the backing store, possibly initializing.
|
||||
func (s *Store) Load() error {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
|
||||
oldCfg := &model.Config{}
|
||||
if s.config != nil {
|
||||
oldCfg = s.config.Clone()
|
||||
}
|
||||
|
||||
configBytes, err := s.backingStore.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadedCfg := &model.Config{}
|
||||
if len(configBytes) != 0 {
|
||||
if err = json.Unmarshal(configBytes, &loadedCfg); err != nil {
|
||||
return jsonutils.HumanizeJSONError(err, configBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have custom defaults set, the initial config is merged on
|
||||
// top of them and we delete them not to be used again in the
|
||||
// configuration reloads
|
||||
if s.configCustomDefaults != nil {
|
||||
var mErr error
|
||||
loadedCfg, mErr = Merge(s.configCustomDefaults, loadedCfg, nil)
|
||||
if mErr != nil {
|
||||
return errors.Wrap(mErr, "failed to merge custom config defaults")
|
||||
}
|
||||
s.configCustomDefaults = nil
|
||||
}
|
||||
|
||||
// We set the SiteURL to empty (if nil) so that the following call to
|
||||
// SetDefaults() will generate missing data. This avoids an additional write
|
||||
// to the backing store.
|
||||
if loadedCfg.ServiceSettings.SiteURL == nil {
|
||||
loadedCfg.ServiceSettings.SiteURL = model.NewString("")
|
||||
}
|
||||
|
||||
// Setting defaults allows us to accept partial config objects.
|
||||
loadedCfg.SetDefaults()
|
||||
|
||||
// No need to clone here since the below call to applyEnvironmentMap
|
||||
// already does that internally.
|
||||
loadedCfgNoEnv := loadedCfg
|
||||
fixConfig(loadedCfgNoEnv)
|
||||
|
||||
loadedCfg = applyEnvironmentMap(loadedCfg, GetEnvironment())
|
||||
fixConfig(loadedCfg)
|
||||
if appErr := loadedCfg.IsValid(); appErr != nil {
|
||||
// Translating the error before displaying it in the console.
|
||||
// Defaulting to english for server side language.
|
||||
appErr.Translate(i18n.GetUserTranslations("en"))
|
||||
return errors.Wrap(appErr, "invalid config")
|
||||
}
|
||||
|
||||
// Backing up feature flags section in case we need to restore them later on.
|
||||
oldCfgFF := oldCfg.FeatureFlags
|
||||
loadedCfgFF := loadedCfg.FeatureFlags
|
||||
loadedCfgNoEnvFF := loadedCfgNoEnv.FeatureFlags
|
||||
// Clearing FF sections to avoid both comparing and persisting them.
|
||||
if s.readOnlyFF {
|
||||
oldCfg.FeatureFlags = nil
|
||||
loadedCfg.FeatureFlags = nil
|
||||
loadedCfgNoEnv.FeatureFlags = nil
|
||||
}
|
||||
|
||||
// Check for changes that may have happened on load to the backing store.
|
||||
hasChanged, err := equal(oldCfg, loadedCfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to compare configs")
|
||||
}
|
||||
|
||||
// We write back to the backing store only if the store is not read-only
|
||||
// and the config has either changed or is missing.
|
||||
if !s.readOnly && (hasChanged || len(configBytes) == 0) {
|
||||
err := s.backingStore.Set(loadedCfgNoEnv)
|
||||
if err != nil && !errors.Is(err, ErrReadOnlyConfiguration) {
|
||||
return errors.Wrap(err, "failed to persist")
|
||||
}
|
||||
}
|
||||
|
||||
// We restore the previously cleared feature flags sections back.
|
||||
if s.readOnlyFF {
|
||||
oldCfg.FeatureFlags = oldCfgFF
|
||||
loadedCfg.FeatureFlags = loadedCfgFF
|
||||
loadedCfgNoEnv.FeatureFlags = loadedCfgNoEnvFF
|
||||
}
|
||||
|
||||
s.config = loadedCfg
|
||||
s.configNoEnv = loadedCfgNoEnv
|
||||
|
||||
loadedCfgCopy := loadedCfg.Clone()
|
||||
|
||||
if hasChanged {
|
||||
s.configLock.Unlock()
|
||||
s.invokeConfigListeners(oldCfg, loadedCfgCopy)
|
||||
s.configLock.Lock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFile fetches the contents of a previously persisted configuration file.
|
||||
// If no such file exists, an empty byte array will be returned without error.
|
||||
func (s *Store) GetFile(name string) ([]byte, error) {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return s.backingStore.GetFile(name)
|
||||
}
|
||||
|
||||
// SetFile sets or replaces the contents of a configuration file.
|
||||
func (s *Store) SetFile(name string, data []byte) error {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
if s.readOnly {
|
||||
return ErrReadOnlyStore
|
||||
}
|
||||
return s.backingStore.SetFile(name, data)
|
||||
}
|
||||
|
||||
// HasFile returns true if the given file was previously persisted.
|
||||
func (s *Store) HasFile(name string) (bool, error) {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return s.backingStore.HasFile(name)
|
||||
}
|
||||
|
||||
// RemoveFile removes a previously persisted configuration file.
|
||||
func (s *Store) RemoveFile(name string) error {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
if s.readOnly {
|
||||
return ErrReadOnlyStore
|
||||
}
|
||||
return s.backingStore.RemoveFile(name)
|
||||
}
|
||||
|
||||
// String describes the backing store for the config.
|
||||
func (s *Store) String() string {
|
||||
return s.backingStore.String()
|
||||
}
|
||||
|
||||
// Close cleans up resources associated with the store.
|
||||
func (s *Store) Close() error {
|
||||
s.configLock.Lock()
|
||||
defer s.configLock.Unlock()
|
||||
return s.backingStore.Close()
|
||||
}
|
||||
|
||||
// IsReadOnly returns whether or not the store is read-only.
|
||||
func (s *Store) IsReadOnly() bool {
|
||||
s.configLock.RLock()
|
||||
defer s.configLock.RUnlock()
|
||||
return s.readOnly
|
||||
}
|
||||
|
||||
// Cleanup removes outdated configurations from the database.
|
||||
// this is a no-op function for FileStore type backing store.
|
||||
func (s *Store) CleanUp() error {
|
||||
switch bs := s.backingStore.(type) {
|
||||
case *DatabaseStore:
|
||||
dur := time.Duration(*s.config.JobSettings.CleanupConfigThresholdDays) * time.Hour * 24
|
||||
expiry := model.GetMillisForTime(time.Now().Add(-dur))
|
||||
return bs.cleanUp(int(expiry))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
103
server/config/store_test.go
Обычный файл
103
server/config/store_test.go
Обычный файл
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewStoreFromDSN(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
sqlSettings := mainHelper.GetSQLSettings()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "TestNewStore")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.Chdir(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.Mkdir(filepath.Join(tempDir, "config"), 0700))
|
||||
|
||||
t.Run("database dsn", func(t *testing.T) {
|
||||
ds, err2 := NewStoreFromDSN(getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource), false, nil, false)
|
||||
require.NoError(t, err2)
|
||||
ds.Close()
|
||||
})
|
||||
|
||||
t.Run("file dsn", func(t *testing.T) {
|
||||
defer os.Remove("config_test.json")
|
||||
fs, err := NewStoreFromDSN("config_test.json", false, nil, true)
|
||||
require.NoError(t, err)
|
||||
fs.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStoreReadOnly(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
sqlSettings := mainHelper.GetSQLSettings()
|
||||
|
||||
tempDir, tErr := os.MkdirTemp("", "TestNewStore")
|
||||
require.NoError(t, tErr)
|
||||
|
||||
tErr = os.Chdir(tempDir)
|
||||
require.NoError(t, tErr)
|
||||
|
||||
require.NoError(t, os.Mkdir(filepath.Join(tempDir, "config"), 0700))
|
||||
|
||||
t.Run("database dsn", func(t *testing.T) {
|
||||
ds, err := NewStoreFromDSN(getDsn(*sqlSettings.DriverName, *sqlSettings.DataSource), true, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Set", func(t *testing.T) {
|
||||
oldCfg, newCfg, err2 := ds.Set(emptyConfig)
|
||||
require.Nil(t, oldCfg)
|
||||
require.Nil(t, newCfg)
|
||||
require.Equal(t, ErrReadOnlyStore, err2)
|
||||
})
|
||||
|
||||
t.Run("SetFile", func(t *testing.T) {
|
||||
err := ds.SetFile("config.json", []byte{})
|
||||
require.Equal(t, ErrReadOnlyStore, err)
|
||||
})
|
||||
|
||||
t.Run("RemoveFile", func(t *testing.T) {
|
||||
err := ds.RemoveFile("config.json")
|
||||
require.Equal(t, ErrReadOnlyStore, err)
|
||||
})
|
||||
|
||||
ds.Close()
|
||||
})
|
||||
|
||||
t.Run("file dsn", func(t *testing.T) {
|
||||
fs, err := NewStoreFromDSN("config_test.json", true, nil, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Set", func(t *testing.T) {
|
||||
oldCfg, newCfg, err := fs.Set(emptyConfig)
|
||||
require.Nil(t, oldCfg)
|
||||
require.Nil(t, newCfg)
|
||||
require.Equal(t, ErrReadOnlyStore, err)
|
||||
})
|
||||
|
||||
t.Run("SetFile", func(t *testing.T) {
|
||||
err := fs.SetFile("config_test.json", []byte{})
|
||||
require.Equal(t, ErrReadOnlyStore, err)
|
||||
})
|
||||
|
||||
t.Run("RemoveFile", func(t *testing.T) {
|
||||
err := fs.RemoveFile("config_test.json")
|
||||
require.Equal(t, ErrReadOnlyStore, err)
|
||||
})
|
||||
|
||||
fs.Close()
|
||||
})
|
||||
}
|
||||
263
server/config/utils.go
Обычный файл
263
server/config/utils.go
Обычный файл
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
// marshalConfig converts the given configuration into JSON bytes for persistence.
|
||||
func marshalConfig(cfg *model.Config) ([]byte, error) {
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
|
||||
// desanitize replaces fake settings with their actual values.
|
||||
func desanitize(actual, target *model.Config) {
|
||||
if target.LdapSettings.BindPassword != nil && *target.LdapSettings.BindPassword == model.FakeSetting {
|
||||
*target.LdapSettings.BindPassword = *actual.LdapSettings.BindPassword
|
||||
}
|
||||
|
||||
if *target.FileSettings.PublicLinkSalt == model.FakeSetting {
|
||||
*target.FileSettings.PublicLinkSalt = *actual.FileSettings.PublicLinkSalt
|
||||
}
|
||||
if *target.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting {
|
||||
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
|
||||
if *target.EmailSettings.SMTPPassword == model.FakeSetting {
|
||||
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
|
||||
}
|
||||
|
||||
if *target.GitLabSettings.Secret == model.FakeSetting {
|
||||
target.GitLabSettings.Secret = actual.GitLabSettings.Secret
|
||||
}
|
||||
|
||||
if target.GoogleSettings.Secret != nil && *target.GoogleSettings.Secret == model.FakeSetting {
|
||||
target.GoogleSettings.Secret = actual.GoogleSettings.Secret
|
||||
}
|
||||
|
||||
if target.Office365Settings.Secret != nil && *target.Office365Settings.Secret == model.FakeSetting {
|
||||
target.Office365Settings.Secret = actual.Office365Settings.Secret
|
||||
}
|
||||
|
||||
if target.OpenIdSettings.Secret != nil && *target.OpenIdSettings.Secret == model.FakeSetting {
|
||||
target.OpenIdSettings.Secret = actual.OpenIdSettings.Secret
|
||||
}
|
||||
|
||||
if *target.SqlSettings.DataSource == model.FakeSetting {
|
||||
*target.SqlSettings.DataSource = *actual.SqlSettings.DataSource
|
||||
}
|
||||
if *target.SqlSettings.AtRestEncryptKey == model.FakeSetting {
|
||||
target.SqlSettings.AtRestEncryptKey = actual.SqlSettings.AtRestEncryptKey
|
||||
}
|
||||
|
||||
if *target.ElasticsearchSettings.Password == model.FakeSetting {
|
||||
*target.ElasticsearchSettings.Password = *actual.ElasticsearchSettings.Password
|
||||
}
|
||||
|
||||
if len(target.SqlSettings.DataSourceReplicas) == len(actual.SqlSettings.DataSourceReplicas) {
|
||||
for i, value := range target.SqlSettings.DataSourceReplicas {
|
||||
if value == model.FakeSetting {
|
||||
target.SqlSettings.DataSourceReplicas[i] = actual.SqlSettings.DataSourceReplicas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(target.SqlSettings.DataSourceSearchReplicas) == len(actual.SqlSettings.DataSourceSearchReplicas) {
|
||||
for i, value := range target.SqlSettings.DataSourceSearchReplicas {
|
||||
if value == model.FakeSetting {
|
||||
target.SqlSettings.DataSourceSearchReplicas[i] = actual.SqlSettings.DataSourceSearchReplicas[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *target.MessageExportSettings.GlobalRelaySettings.SMTPPassword == model.FakeSetting {
|
||||
*target.MessageExportSettings.GlobalRelaySettings.SMTPPassword = *actual.MessageExportSettings.GlobalRelaySettings.SMTPPassword
|
||||
}
|
||||
|
||||
if target.ServiceSettings.GfycatAPISecret != nil && *target.ServiceSettings.GfycatAPISecret == model.FakeSetting {
|
||||
*target.ServiceSettings.GfycatAPISecret = *actual.ServiceSettings.GfycatAPISecret
|
||||
}
|
||||
|
||||
if *target.ServiceSettings.SplitKey == model.FakeSetting {
|
||||
*target.ServiceSettings.SplitKey = *actual.ServiceSettings.SplitKey
|
||||
}
|
||||
}
|
||||
|
||||
// fixConfig patches invalid or missing data in the configuration.
|
||||
func fixConfig(cfg *model.Config) {
|
||||
// Ensure SiteURL has no trailing slash.
|
||||
if strings.HasSuffix(*cfg.ServiceSettings.SiteURL, "/") {
|
||||
*cfg.ServiceSettings.SiteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/")
|
||||
}
|
||||
|
||||
// Ensure the directory for a local file store has a trailing slash.
|
||||
if *cfg.FileSettings.DriverName == model.ImageDriverLocal {
|
||||
if *cfg.FileSettings.Directory != "" && !strings.HasSuffix(*cfg.FileSettings.Directory, "/") {
|
||||
*cfg.FileSettings.Directory += "/"
|
||||
}
|
||||
}
|
||||
|
||||
FixInvalidLocales(cfg)
|
||||
}
|
||||
|
||||
// FixInvalidLocales checks and corrects the given config for invalid locale-related settings.
|
||||
//
|
||||
// Ideally, this function would be completely internal, but it's currently exposed to allow the cli
|
||||
// to test the config change before allowing the save.
|
||||
func FixInvalidLocales(cfg *model.Config) bool {
|
||||
var changed bool
|
||||
|
||||
locales := i18n.GetSupportedLocales()
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultServerLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = model.DefaultLocale
|
||||
mlog.Warn("DefaultServerLocale must be one of the supported locales. Setting DefaultServerLocale to en as default value.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultClientLocale]; !ok {
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = model.DefaultLocale
|
||||
mlog.Warn("DefaultClientLocale must be one of the supported locales. Setting DefaultClientLocale to en as default value.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
if *cfg.LocalizationSettings.AvailableLocales != "" {
|
||||
isDefaultClientLocaleInAvailableLocales := false
|
||||
for _, word := range strings.Split(*cfg.LocalizationSettings.AvailableLocales, ",") {
|
||||
if _, ok := locales[word]; !ok {
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
mlog.Warn("AvailableLocales must include DefaultClientLocale. Setting AvailableLocales to all locales as default value.")
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
|
||||
if word == *cfg.LocalizationSettings.DefaultClientLocale {
|
||||
isDefaultClientLocaleInAvailableLocales = true
|
||||
}
|
||||
}
|
||||
|
||||
availableLocales := *cfg.LocalizationSettings.AvailableLocales
|
||||
|
||||
if !isDefaultClientLocaleInAvailableLocales {
|
||||
availableLocales += "," + *cfg.LocalizationSettings.DefaultClientLocale
|
||||
mlog.Warn("Adding DefaultClientLocale to AvailableLocales.")
|
||||
changed = true
|
||||
}
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = strings.Join(utils.RemoveDuplicatesFromStringArray(strings.Split(availableLocales, ",")), ",")
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// Merge merges two configs together. The receiver's values are overwritten with the patch's
|
||||
// values except when the patch's values are nil.
|
||||
func Merge(cfg *model.Config, patch *model.Config, mergeConfig *utils.MergeConfig) (*model.Config, error) {
|
||||
ret, err := utils.Merge(cfg, patch, mergeConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retCfg := ret.(model.Config)
|
||||
return &retCfg, nil
|
||||
}
|
||||
|
||||
func IsDatabaseDSN(dsn string) bool {
|
||||
return strings.HasPrefix(dsn, "mysql://") ||
|
||||
strings.HasPrefix(dsn, "postgres://") ||
|
||||
strings.HasPrefix(dsn, "postgresql://")
|
||||
}
|
||||
|
||||
// stripPassword remove the password from a given DSN
|
||||
func stripPassword(dsn, schema string) string {
|
||||
prefix := schema + "://"
|
||||
dsn = strings.TrimPrefix(dsn, prefix)
|
||||
|
||||
i := strings.Index(dsn, ":")
|
||||
j := strings.LastIndex(dsn, "@")
|
||||
|
||||
// Return error if no @ sign is found
|
||||
if j < 0 {
|
||||
return "(omitted due to error parsing the DSN)"
|
||||
}
|
||||
|
||||
// Return back the input if no password is found
|
||||
if i < 0 || i > j {
|
||||
return prefix + dsn
|
||||
}
|
||||
|
||||
return prefix + dsn[:i+1] + dsn[j:]
|
||||
}
|
||||
|
||||
func isJSONMap(data string) bool {
|
||||
var m map[string]any
|
||||
return json.Unmarshal([]byte(data), &m) == nil
|
||||
}
|
||||
|
||||
func GetValueByPath(path []string, obj any) (any, bool) {
|
||||
r := reflect.ValueOf(obj)
|
||||
var val reflect.Value
|
||||
if r.Kind() == reflect.Map {
|
||||
val = r.MapIndex(reflect.ValueOf(path[0]))
|
||||
if val.IsValid() {
|
||||
val = val.Elem()
|
||||
}
|
||||
} else {
|
||||
val = r.FieldByName(path[0])
|
||||
}
|
||||
|
||||
if !val.IsValid() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(path) == 1:
|
||||
return val.Interface(), true
|
||||
case val.Kind() == reflect.Struct:
|
||||
return GetValueByPath(path[1:], val.Interface())
|
||||
case val.Kind() == reflect.Map:
|
||||
remainingPath := strings.Join(path[1:], ".")
|
||||
mapIter := val.MapRange()
|
||||
for mapIter.Next() {
|
||||
key := mapIter.Key().String()
|
||||
if strings.HasPrefix(remainingPath, key) {
|
||||
i := strings.Count(key, ".") + 2 // number of dots + a dot on each side
|
||||
mapVal := mapIter.Value()
|
||||
// if no sub field path specified, return the object
|
||||
if len(path[i:]) == 0 {
|
||||
return mapVal.Interface(), true
|
||||
}
|
||||
data := mapVal.Interface()
|
||||
if mapVal.Kind() == reflect.Ptr {
|
||||
data = mapVal.Elem().Interface() // if value is a pointer, dereference it
|
||||
}
|
||||
// pass subpath
|
||||
return GetValueByPath(path[i:], data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func equal(oldCfg, newCfg *model.Config) (bool, error) {
|
||||
oldCfgBytes, err := json.Marshal(oldCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to marshal old config: %w", err)
|
||||
}
|
||||
newCfgBytes, err := json.Marshal(newCfg)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to marshal new config: %w", err)
|
||||
}
|
||||
return !bytes.Equal(oldCfgBytes, newCfgBytes), nil
|
||||
}
|
||||
309
server/config/utils_test.go
Обычный файл
309
server/config/utils_test.go
Обычный файл
@@ -0,0 +1,309 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
|
||||
)
|
||||
|
||||
func TestDesanitize(t *testing.T) {
|
||||
actual := &model.Config{}
|
||||
actual.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
actual.LdapSettings.Enable = model.NewBool(false)
|
||||
actual.FileSettings.DriverName = model.NewString("s3")
|
||||
|
||||
// These settings should be desanitized into target.
|
||||
actual.LdapSettings.BindPassword = model.NewString("bind_password")
|
||||
actual.FileSettings.PublicLinkSalt = model.NewString("public_link_salt")
|
||||
actual.FileSettings.AmazonS3SecretAccessKey = model.NewString("amazon_s3_secret_access_key")
|
||||
actual.EmailSettings.SMTPPassword = model.NewString("smtp_password")
|
||||
actual.GitLabSettings.Secret = model.NewString("secret")
|
||||
actual.OpenIdSettings.Secret = model.NewString("secret")
|
||||
actual.SqlSettings.DataSource = model.NewString("data_source")
|
||||
actual.SqlSettings.AtRestEncryptKey = model.NewString("at_rest_encrypt_key")
|
||||
actual.ElasticsearchSettings.Password = model.NewString("password")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica0")
|
||||
actual.SqlSettings.DataSourceReplicas = append(actual.SqlSettings.DataSourceReplicas, "replica1")
|
||||
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica0")
|
||||
actual.SqlSettings.DataSourceSearchReplicas = append(actual.SqlSettings.DataSourceSearchReplicas, "search_replica1")
|
||||
|
||||
target := &model.Config{}
|
||||
target.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
target.LdapSettings.Enable = model.NewBool(true)
|
||||
target.FileSettings.DriverName = model.NewString("file")
|
||||
|
||||
// These settings should be updated from actual
|
||||
target.LdapSettings.BindPassword = model.NewString(model.FakeSetting)
|
||||
target.FileSettings.PublicLinkSalt = model.NewString(model.FakeSetting)
|
||||
target.FileSettings.AmazonS3SecretAccessKey = model.NewString(model.FakeSetting)
|
||||
target.EmailSettings.SMTPPassword = model.NewString(model.FakeSetting)
|
||||
target.GitLabSettings.Secret = model.NewString(model.FakeSetting)
|
||||
target.OpenIdSettings.Secret = model.NewString(model.FakeSetting)
|
||||
target.SqlSettings.DataSource = model.NewString(model.FakeSetting)
|
||||
target.SqlSettings.AtRestEncryptKey = model.NewString(model.FakeSetting)
|
||||
target.ElasticsearchSettings.Password = model.NewString(model.FakeSetting)
|
||||
target.SqlSettings.DataSourceReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
target.SqlSettings.DataSourceSearchReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
|
||||
actualClone := actual.Clone()
|
||||
desanitize(actual, target)
|
||||
assert.Equal(t, actualClone, actual, "actual should not have been changed")
|
||||
|
||||
// Verify the settings that should have been left untouched in target
|
||||
assert.True(t, *target.LdapSettings.Enable, "LdapSettings.Enable should not have changed")
|
||||
assert.Equal(t, "file", *target.FileSettings.DriverName, "FileSettings.DriverName should not have been changed")
|
||||
|
||||
// Verify the settings that should have been desanitized into target
|
||||
assert.Equal(t, *actual.LdapSettings.BindPassword, *target.LdapSettings.BindPassword)
|
||||
assert.Equal(t, *actual.FileSettings.PublicLinkSalt, *target.FileSettings.PublicLinkSalt)
|
||||
assert.Equal(t, *actual.FileSettings.AmazonS3SecretAccessKey, *target.FileSettings.AmazonS3SecretAccessKey)
|
||||
assert.Equal(t, *actual.EmailSettings.SMTPPassword, *target.EmailSettings.SMTPPassword)
|
||||
assert.Equal(t, *actual.GitLabSettings.Secret, *target.GitLabSettings.Secret)
|
||||
assert.Equal(t, *actual.OpenIdSettings.Secret, *target.OpenIdSettings.Secret)
|
||||
assert.Equal(t, *actual.SqlSettings.DataSource, *target.SqlSettings.DataSource)
|
||||
assert.Equal(t, *actual.SqlSettings.AtRestEncryptKey, *target.SqlSettings.AtRestEncryptKey)
|
||||
assert.Equal(t, *actual.ElasticsearchSettings.Password, *target.ElasticsearchSettings.Password)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceReplicas, target.SqlSettings.DataSourceReplicas)
|
||||
assert.Equal(t, actual.SqlSettings.DataSourceSearchReplicas, target.SqlSettings.DataSourceSearchReplicas)
|
||||
assert.Equal(t, actual.ServiceSettings.SplitKey, target.ServiceSettings.SplitKey)
|
||||
}
|
||||
|
||||
func TestFixInvalidLocales(t *testing.T) {
|
||||
utils.TranslationsPreInit()
|
||||
|
||||
cfg := &model.Config{}
|
||||
cfg.SetDefaults()
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
|
||||
changed := FixInvalidLocales(cfg)
|
||||
assert.False(t, changed)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultServerLocale)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = ""
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultServerLocale)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "de"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.False(t, changed)
|
||||
assert.NotContains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultServerLocale should not be added to AvailableLocales")
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = ""
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultClientLocale)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = ""
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "en", *cfg.LocalizationSettings.DefaultClientLocale)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Contains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultClientLocale should have been added to AvailableLocales")
|
||||
|
||||
// validate AvailableLocales
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "en"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "en"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "", *cfg.LocalizationSettings.AvailableLocales)
|
||||
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en,de,junk"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "", *cfg.LocalizationSettings.AvailableLocales)
|
||||
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = "fr"
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = "de"
|
||||
*cfg.LocalizationSettings.AvailableLocales = "en"
|
||||
changed = FixInvalidLocales(cfg)
|
||||
assert.True(t, changed)
|
||||
assert.NotContains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultServerLocale, "DefaultServerLocale should not be added to AvailableLocales")
|
||||
assert.Contains(t, *cfg.LocalizationSettings.AvailableLocales, *cfg.LocalizationSettings.DefaultClientLocale, "DefaultClientLocale should have been added to AvailableLocales")
|
||||
}
|
||||
|
||||
func TestIsDatabaseDSN(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
DSN string
|
||||
Expected bool
|
||||
}{
|
||||
{
|
||||
Name: "Mysql DSN",
|
||||
DSN: "mysql://localhost",
|
||||
Expected: true,
|
||||
},
|
||||
{
|
||||
Name: "Postgresql 'postgres' DSN",
|
||||
DSN: "postgres://localhost",
|
||||
Expected: true,
|
||||
},
|
||||
{
|
||||
Name: "Postgresql 'postgresql' DSN",
|
||||
DSN: "postgresql://localhost",
|
||||
Expected: true,
|
||||
},
|
||||
{
|
||||
Name: "Empty DSN",
|
||||
DSN: "",
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "Default file DSN",
|
||||
DSN: "config.json",
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "Relative path DSN",
|
||||
DSN: "configuration/config.json",
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "Absolute path DSN",
|
||||
DSN: "/opt/mattermost/configuration/config.json",
|
||||
Expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.Expected, IsDatabaseDSN(tc.DSN))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPassword(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
DSN string
|
||||
Schema string
|
||||
ExpectedOut string
|
||||
}{
|
||||
"mysql": {
|
||||
DSN: "mysql://mmuser:password@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
Schema: "mysql",
|
||||
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
},
|
||||
"mysql idempotent": {
|
||||
DSN: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
Schema: "mysql",
|
||||
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
},
|
||||
"mysql: password with : and @": {
|
||||
DSN: "mysql://mmuser:p:assw@ord@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
Schema: "mysql",
|
||||
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
},
|
||||
"mysql: password with @ and :": {
|
||||
DSN: "mysql://mmuser:pa@sswo:rd@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
Schema: "mysql",
|
||||
ExpectedOut: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s",
|
||||
},
|
||||
"postgres": {
|
||||
DSN: "postgres://mmuser:password@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
|
||||
Schema: "postgres",
|
||||
ExpectedOut: "postgres://mmuser:@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
|
||||
},
|
||||
"pipe": {
|
||||
DSN: "mysql://user@unix(/path/to/socket)/dbname",
|
||||
Schema: "mysql",
|
||||
ExpectedOut: "mysql://user@unix(/path/to/socket)/dbname",
|
||||
},
|
||||
"malformed without :": {
|
||||
DSN: "postgres://mmuserpassword@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
|
||||
Schema: "postgres",
|
||||
ExpectedOut: "postgres://mmuserpassword@localhost:5432/mattermost?sslmode=disable&connect_timeout=10",
|
||||
},
|
||||
"malformed without @": {
|
||||
DSN: "postgres://mmuser:passwordlocalhost:5432/mattermost?sslmode=disable&connect_timeout=10",
|
||||
Schema: "postgres",
|
||||
ExpectedOut: "(omitted due to error parsing the DSN)",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out := stripPassword(test.DSN, test.Schema)
|
||||
|
||||
assert.Equal(t, test.ExpectedOut, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsJSONMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want bool
|
||||
}{
|
||||
{name: "good json", data: `{"local_tcp": {
|
||||
"Type": "tcp","Format": "json","Levels": [
|
||||
{"ID": 5,"Name": "debug","Stacktrace": false}
|
||||
],
|
||||
"Options": {"ip": "localhost","port": 18065},
|
||||
"MaxQueueSize": 1000}}
|
||||
`, want: true,
|
||||
},
|
||||
{name: "empty json", data: "{}", want: true},
|
||||
{name: "string json", data: `"test"`, want: false},
|
||||
{name: "array json", data: `["test1", "test2"]`, want: false},
|
||||
{name: "bad json", data: `{huh?}`, want: false},
|
||||
{name: "filename", data: "/tmp/logger.conf", want: false},
|
||||
{name: "mysql dsn", data: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s", want: false},
|
||||
{name: "postgres dsn", data: "postgres://mmuser:passwordlocalhost:5432/mattermost?sslmode=disable&connect_timeout=10", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isJSONMap(tt.data); got != tt.want {
|
||||
t.Errorf("isJSONMap() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqual(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
diff, err := equal(nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.False(t, diff)
|
||||
})
|
||||
|
||||
t.Run("no diff", func(t *testing.T) {
|
||||
old := minimalConfig.Clone()
|
||||
new := minimalConfig.Clone()
|
||||
diff, err := equal(old, new)
|
||||
require.NoError(t, err)
|
||||
require.False(t, diff)
|
||||
})
|
||||
|
||||
t.Run("diff", func(t *testing.T) {
|
||||
old := minimalConfig.Clone()
|
||||
new := minimalConfig.Clone()
|
||||
new.SqlSettings = model.SqlSettings{}
|
||||
diff, err := equal(old, new)
|
||||
require.NoError(t, err)
|
||||
require.True(t, diff)
|
||||
})
|
||||
}
|
||||
Ссылка в новой задаче
Block a user