snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
3
server/config/README.md
Normal file
3
server/config/README.md
Normal file
@@ -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/
|
||||
472
server/config/client.go
Normal file
472
server/config/client.go
Normal file
@@ -0,0 +1,472 @@
|
||||
// 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/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/fips"
|
||||
)
|
||||
|
||||
// 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["LockProfileFieldsForEmailUsers"] = model.TeamSettingsLockProfileFieldsNone
|
||||
props["ExperimentalPrimaryTeam"] = *c.TeamSettings.ExperimentalPrimaryTeam
|
||||
props["EnableJoinLeaveMessageByDefault"] = strconv.FormatBool(*c.TeamSettings.EnableJoinLeaveMessageByDefault)
|
||||
props["EnableChannelCategorySorting"] = strconv.FormatBool(*c.TeamSettings.EnableChannelCategorySorting)
|
||||
|
||||
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
|
||||
props["EnableDesktopLandingPage"] = strconv.FormatBool(*c.ServiceSettings.EnableDesktopLandingPage)
|
||||
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["EnableOutgoingOAuthConnections"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingOAuthConnections)
|
||||
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["MaximumPersonalAccessTokenLifetimeDays"] = strconv.Itoa(*c.ServiceSettings.MaximumPersonalAccessTokenLifetimeDays)
|
||||
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"] = strconv.Itoa(*c.ServiceSettings.PostEditTimeLimit)
|
||||
props["MinimumHashtagLength"] = strconv.Itoa(*c.ServiceSettings.MinimumHashtagLength)
|
||||
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
|
||||
props["DeleteAccountLink"] = *c.ServiceSettings.DeleteAccountLink
|
||||
|
||||
// This setting is only temporary, so keep using the old setting name for the mobile and web apps
|
||||
props["ExperimentalEnablePostMetadata"] = "true"
|
||||
|
||||
props["DisableAppBar"] = strconv.FormatBool(*c.ExperimentalSettings.DisableAppBar)
|
||||
|
||||
props["ExperimentalEnableAutomaticReplies"] = strconv.FormatBool(*c.TeamSettings.ExperimentalEnableAutomaticReplies)
|
||||
props["ExperimentalTimezone"] = "true"
|
||||
|
||||
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["UseAnonymousURLs"] = strconv.FormatBool(*c.PrivacySettings.UseAnonymousURLs)
|
||||
|
||||
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["GiphySdkKey"] = getGiphySdkKey(c.ServiceSettings)
|
||||
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
|
||||
props["CWSMock"] = model.MockCWS
|
||||
|
||||
props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus)
|
||||
props["DisableWakeUpReconnectHandler"] = strconv.FormatBool(*c.ExperimentalSettings.DisableWakeUpReconnectHandler)
|
||||
props["UsersStatusAndProfileFetchingPollIntervalMilliseconds"] = strconv.FormatInt(*c.ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds, 10)
|
||||
// Here we set the new option, but we also send the old FeatureFlag property for backwards compatibility on mobile < 2.27
|
||||
props["EnableCrossTeamSearch"] = strconv.FormatBool(*c.ServiceSettings.EnableCrossTeamSearch)
|
||||
props["FeatureFlagExperimentalCrossTeamSearch"] = strconv.FormatBool(*c.ServiceSettings.EnableCrossTeamSearch)
|
||||
|
||||
// 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["DataRetentionMessageRetentionHours"] = "0"
|
||||
props["DataRetentionEnableFileDeletion"] = "false"
|
||||
props["DataRetentionFileRetentionHours"] = "0"
|
||||
|
||||
props["CustomUrlSchemes"] = strings.Join(c.DisplaySettings.CustomURLSchemes, ",")
|
||||
props["MaxMarkdownNodes"] = strconv.FormatInt(int64(*c.DisplaySettings.MaxMarkdownNodes), 10)
|
||||
props["IsDefaultMarketplace"] = strconv.FormatBool(*c.PluginSettings.MarketplaceURL == model.PluginSettingsDefaultMarketplaceURL)
|
||||
props["ExperimentalSharedChannels"] = "false"
|
||||
props["CollapsedThreads"] = *c.ServiceSettings.CollapsedThreads
|
||||
props["EnableCustomGroups"] = "false"
|
||||
props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority)
|
||||
props["AllowPersistentNotifications"] = strconv.FormatBool(*c.ServiceSettings.AllowPersistentNotifications)
|
||||
props["AllowPersistentNotificationsForGuests"] = strconv.FormatBool(*c.ServiceSettings.AllowPersistentNotificationsForGuests)
|
||||
props["PersistentNotificationMaxCount"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxCount), 10)
|
||||
props["PersistentNotificationIntervalMinutes"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationIntervalMinutes), 10)
|
||||
props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10)
|
||||
props["EnableBurnOnRead"] = strconv.FormatBool(*c.ServiceSettings.EnableBurnOnRead)
|
||||
props["BurnOnReadDurationSeconds"] = strconv.Itoa(*c.ServiceSettings.BurnOnReadDurationSeconds)
|
||||
props["BurnOnReadMaximumTimeToLiveSeconds"] = strconv.Itoa(*c.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds)
|
||||
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
|
||||
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
|
||||
props["YoutubeReferrerPolicy"] = strconv.FormatBool(*c.ExperimentalSettings.YoutubeReferrerPolicy)
|
||||
props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10)
|
||||
|
||||
props["EnableAttributeBasedAccessControl"] = strconv.FormatBool(*c.AccessControlSettings.EnableAttributeBasedAccessControl)
|
||||
props["EnableUserManagedAttributes"] = strconv.FormatBool(*c.AccessControlSettings.EnableUserManagedAttributes)
|
||||
props["EnableAccessControlAuditLogging"] = strconv.FormatBool(*c.AccessControlSettings.EnableAccessControlAuditLogging)
|
||||
props["AuditLoggingActive"] = strconv.FormatBool(IsAuditLoggingActive(c.ExperimentalAuditSettings, license != nil && license.Features != nil && *license.Features.AdvancedLogging))
|
||||
props["EnableChannelPolicyIndicators"] = strconv.FormatBool(*c.AccessControlSettings.EnableChannelPolicyIndicators)
|
||||
|
||||
props["WranglerPermittedWranglerRoles"] = strings.Join(c.WranglerSettings.PermittedWranglerRoles, ",")
|
||||
props["WranglerAllowedEmailDomain"] = strings.Join(c.WranglerSettings.AllowedEmailDomain, ",")
|
||||
props["WranglerMoveThreadMaxCount"] = strconv.FormatInt(*c.WranglerSettings.MoveThreadMaxCount, 10)
|
||||
props["WranglerMoveThreadToAnotherTeamEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadToAnotherTeamEnable)
|
||||
props["WranglerMoveThreadFromPrivateChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromPrivateChannelEnable)
|
||||
props["WranglerMoveThreadFromDirectMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromDirectMessageChannelEnable)
|
||||
props["WranglerMoveThreadFromGroupMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromGroupMessageChannelEnable)
|
||||
|
||||
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.Cluster {
|
||||
props["EnableCluster"] = strconv.FormatBool(*c.ClusterSettings.Enable)
|
||||
}
|
||||
|
||||
if *license.Features.Cluster {
|
||||
props["EnableMetrics"] = strconv.FormatBool(*c.MetricsSettings.Enable)
|
||||
props["EnableClientMetrics"] = strconv.FormatBool(*c.MetricsSettings.Enable && *c.MetricsSettings.EnableClientMetrics)
|
||||
props["EnableNotificationMetrics"] = strconv.FormatBool(*c.MetricsSettings.EnableNotificationMetrics)
|
||||
}
|
||||
|
||||
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["DataRetentionMessageRetentionHours"] = strconv.FormatInt(int64(c.DataRetentionSettings.GetMessageRetentionHours()), 10)
|
||||
props["DataRetentionEnableFileDeletion"] = strconv.FormatBool(*c.DataRetentionSettings.EnableFileDeletion)
|
||||
props["DataRetentionFileRetentionHours"] = strconv.FormatInt(int64(c.DataRetentionSettings.GetFileRetentionHours()), 10)
|
||||
}
|
||||
|
||||
if license.HasSharedChannels() {
|
||||
props["ExperimentalSharedChannels"] = strconv.FormatBool(*c.ConnectedWorkspacesSettings.EnableSharedChannels)
|
||||
props["ExperimentalRemoteClusterService"] = strconv.FormatBool(*c.ConnectedWorkspacesSettings.EnableRemoteClusterService)
|
||||
}
|
||||
|
||||
if model.MinimumProfessionalLicense(license) {
|
||||
props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups)
|
||||
props["PostAcknowledgements"] = "true"
|
||||
props["ScheduledPosts"] = strconv.FormatBool(*c.ServiceSettings.ScheduledPosts)
|
||||
}
|
||||
|
||||
if model.MinimumEnterpriseLicense(license) {
|
||||
props["MobileEnableBiometrics"] = strconv.FormatBool(*c.NativeAppSettings.MobileEnableBiometrics)
|
||||
props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture)
|
||||
props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection)
|
||||
props["ExperimentalEnableWatermark"] = strconv.FormatBool(*c.ExperimentalSettings.EnableWatermark)
|
||||
props["LockProfileFieldsForEmailUsers"] = *c.TeamSettings.LockProfileFieldsForEmailUsers
|
||||
}
|
||||
|
||||
if model.MinimumEnterpriseAdvancedLicense(license) {
|
||||
props["MobileEnableSecureFilePreview"] = strconv.FormatBool(*c.NativeAppSettings.MobileEnableSecureFilePreview)
|
||||
props["MobileAllowPdfLinkNavigation"] = strconv.FormatBool(*c.NativeAppSettings.MobileAllowPdfLinkNavigation)
|
||||
|
||||
props["ContentFlaggingEnabled"] = strconv.FormatBool(c.FeatureFlags.ContentFlagging && *c.ContentFlaggingSettings.EnableContentFlagging)
|
||||
props["EnableAutoTranslation"] = strconv.FormatBool(c.FeatureFlags.AutoTranslation && *c.AutoTranslationSettings.Enable)
|
||||
if c.FeatureFlags.AutoTranslation {
|
||||
props["AutoTranslationLanguages"] = strings.Join(*c.AutoTranslationSettings.TargetLanguages, ",")
|
||||
} else {
|
||||
props["AutoTranslationLanguages"] = ""
|
||||
}
|
||||
props["RestrictDMAndGMAutotranslation"] = strconv.FormatBool(*c.AutoTranslationSettings.RestrictDMAndGM)
|
||||
|
||||
if c.FeatureFlags.MobileEphemeralMode {
|
||||
ephemeralEnabled := c.MobileEphemeralModeSettings.Enable != nil && *c.MobileEphemeralModeSettings.Enable
|
||||
props["MobileEphemeralModeEnabled"] = strconv.FormatBool(ephemeralEnabled)
|
||||
if c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds != nil {
|
||||
props["MobileEphemeralModeDisconnectionTimeoutSeconds"] = strconv.Itoa(*c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds)
|
||||
}
|
||||
if c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours != nil {
|
||||
props["MobileEphemeralModeOfflinePersistenceTimerHours"] = strconv.Itoa(*c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours)
|
||||
}
|
||||
if c.MobileEphemeralModeSettings.AutoCacheCleanupDays != nil {
|
||||
props["MobileEphemeralModeAutoCacheCleanupDays"] = strconv.Itoa(*c.MobileEphemeralModeSettings.AutoCacheCleanupDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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["ServiceEnvironment"] = model.GetServiceEnvironment()
|
||||
props["IsFipsEnabled"] = strconv.FormatBool(fips.IsEnabled)
|
||||
|
||||
props["EnableBotAccountCreation"] = strconv.FormatBool(*c.ServiceSettings.EnableBotAccountCreation)
|
||||
props["EnableDesktopLandingPage"] = strconv.FormatBool(*c.ServiceSettings.EnableDesktopLandingPage)
|
||||
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"] = strconv.Itoa(*c.ServiceSettings.WebsocketPort)
|
||||
props["WebsocketSecurePort"] = strconv.Itoa(*c.ServiceSettings.WebsocketSecurePort)
|
||||
props["EnableUserCreation"] = strconv.FormatBool(*c.TeamSettings.EnableUserCreation)
|
||||
props["EnableOpenServer"] = strconv.FormatBool(*c.TeamSettings.EnableOpenServer)
|
||||
props["EnableJoinLeaveMessageByDefault"] = strconv.FormatBool(*c.TeamSettings.EnableJoinLeaveMessageByDefault)
|
||||
|
||||
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["EnableClientMetrics"] = strconv.FormatBool(*c.MetricsSettings.EnableClientMetrics)
|
||||
|
||||
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["TermsOfServiceLink"] = *c.SupportSettings.TermsOfServiceLink
|
||||
props["PrivacyPolicyLink"] = *c.SupportSettings.PrivacyPolicyLink
|
||||
props["AboutLink"] = *c.SupportSettings.AboutLink
|
||||
props["HelpLink"] = *c.SupportSettings.HelpLink
|
||||
props["ReportAProblemType"] = *c.SupportSettings.ReportAProblemType
|
||||
props["ReportAProblemLink"] = *c.SupportSettings.ReportAProblemLink
|
||||
props["ReportAProblemMail"] = *c.SupportSettings.ReportAProblemMail
|
||||
props["AllowDownloadLogs"] = strconv.FormatBool(*c.SupportSettings.AllowDownloadLogs)
|
||||
props["ForgotPasswordLink"] = *c.SupportSettings.ForgotPasswordLink
|
||||
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["EnableUserStatuses"] = strconv.FormatBool(*c.ServiceSettings.EnableUserStatuses)
|
||||
props["AppDownloadLink"] = *c.NativeAppSettings.AppDownloadLink
|
||||
props["AndroidAppDownloadLink"] = *c.NativeAppSettings.AndroidAppDownloadLink
|
||||
props["IosAppDownloadLink"] = *c.NativeAppSettings.IosAppDownloadLink
|
||||
props["MobileExternalBrowser"] = strconv.FormatBool(*c.NativeAppSettings.MobileExternalBrowser)
|
||||
|
||||
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["AppsPluginEnabled"] = strconv.FormatBool(*c.PluginSettings.Enable && c.PluginSettings.PluginStates[model.PluginIdApps] != nil && c.PluginSettings.PluginStates[model.PluginIdApps].Enable)
|
||||
|
||||
props["PasswordMinimumLength"] = strconv.Itoa(*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)
|
||||
props["PasswordEnableForgotLink"] = strconv.FormatBool(*c.PasswordSettings.EnableForgotLink)
|
||||
|
||||
// Set default values for all options that require a license.
|
||||
props["EnableCustomBrand"] = "false"
|
||||
props["CustomBrandText"] = ""
|
||||
props["CustomDescriptionText"] = ""
|
||||
props["EnableLdap"] = "false"
|
||||
props["LdapLoginFieldName"] = ""
|
||||
props["EnableSaml"] = "false"
|
||||
props["SamlLoginButtonText"] = ""
|
||||
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["HideGuestTags"] = strconv.FormatBool(*c.GuestAccountsSettings.HideTags)
|
||||
props["GuestAccountsEnforceMultifactorAuthentication"] = strconv.FormatBool(*c.GuestAccountsSettings.EnforceMultifactorAuthentication)
|
||||
props["EnableGuestMagicLink"] = strconv.FormatBool(*c.GuestAccountsSettings.EnableGuestMagicLink)
|
||||
|
||||
if license != nil {
|
||||
if *license.Features.LDAP {
|
||||
props["EnableLdap"] = strconv.FormatBool(*c.LdapSettings.Enable)
|
||||
props["LdapLoginFieldName"] = *c.LdapSettings.LoginFieldName
|
||||
}
|
||||
|
||||
if *license.Features.SAML {
|
||||
props["EnableSaml"] = strconv.FormatBool(*c.SamlSettings.Enable)
|
||||
props["SamlLoginButtonText"] = *c.SamlSettings.LoginButtonText
|
||||
}
|
||||
|
||||
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
|
||||
props["EnableSignUpWithGitLab"] = strconv.FormatBool(*c.GitLabSettings.Enable)
|
||||
props["GitLabButtonColor"] = *c.GitLabSettings.ButtonColor
|
||||
props["GitLabButtonText"] = *c.GitLabSettings.ButtonText
|
||||
}
|
||||
|
||||
if model.MinimumEnterpriseLicense(license) {
|
||||
props["MobileEnableBiometrics"] = strconv.FormatBool(*c.NativeAppSettings.MobileEnableBiometrics)
|
||||
props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture)
|
||||
props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection)
|
||||
props["ExperimentalEnableWatermark"] = strconv.FormatBool(*c.ExperimentalSettings.EnableWatermark)
|
||||
}
|
||||
|
||||
if model.MinimumEnterpriseAdvancedLicense(license) {
|
||||
// Check IntuneSettings configuration
|
||||
intuneEnabled := (c.IntuneSettings.Enable != nil && *c.IntuneSettings.Enable && c.IntuneSettings.IsValid() == nil)
|
||||
|
||||
props["IntuneMAMEnabled"] = strconv.FormatBool(intuneEnabled)
|
||||
|
||||
if intuneEnabled {
|
||||
// Use IntuneSettings for scope
|
||||
props["IntuneScope"] = fmt.Sprintf("api://%s/login.mattermost", *c.IntuneSettings.ClientId)
|
||||
|
||||
// Expose AuthService if set
|
||||
if c.IntuneSettings.AuthService != nil && *c.IntuneSettings.AuthService != "" {
|
||||
props["IntuneAuthService"] = *c.IntuneSettings.AuthService
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range c.FeatureFlags.ToMap() {
|
||||
props["FeatureFlag"+key] = value
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
func getGiphySdkKey(ss model.ServiceSettings) string {
|
||||
if model.GetServiceEnvironment() == model.ServiceEnvironmentProduction {
|
||||
if *ss.GiphySdkKey != "" {
|
||||
return *ss.GiphySdkKey
|
||||
}
|
||||
|
||||
return model.MattermostGiphySdkKey
|
||||
} else if model.GetServiceEnvironment() == model.ServiceEnvironmentDev || model.GetServiceEnvironment() == model.ServiceEnvironmentTest {
|
||||
return model.ServiceSettingsDefaultGiphySdkKeyTest
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
1005
server/config/client_test.go
Normal file
1005
server/config/client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
173
server/config/common_test.go
Normal file
173
server/config/common_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// 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/public/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: new(true),
|
||||
ReadOnlyConfig: new(true),
|
||||
},
|
||||
}
|
||||
minimalConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: new("http://minimal"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
PublicLinkSalt: new("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: new("en"),
|
||||
DefaultClientLocale: new("en"),
|
||||
},
|
||||
}
|
||||
|
||||
minimalConfig.SetDefaults()
|
||||
|
||||
minimalConfigNoFF = minimalConfig.Clone()
|
||||
minimalConfigNoFF.FeatureFlags = nil
|
||||
|
||||
invalidConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: new("invalid"),
|
||||
},
|
||||
}
|
||||
fixesRequiredConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: new("http://trailingslash/"),
|
||||
},
|
||||
FileSettings: model.FileSettings{
|
||||
DriverName: model.NewPointer(model.ImageDriverLocal),
|
||||
Directory: new("/path/to/directory"),
|
||||
PublicLinkSalt: new("abcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
},
|
||||
LocalizationSettings: model.LocalizationSettings{
|
||||
DefaultServerLocale: new("garbage"),
|
||||
DefaultClientLocale: new("garbage"),
|
||||
},
|
||||
}
|
||||
ldapConfig = &model.Config{
|
||||
LdapSettings: model.LdapSettings{
|
||||
BindPassword: new("password"),
|
||||
},
|
||||
}
|
||||
testConfig = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: new("http://TestStoreNew"),
|
||||
},
|
||||
}
|
||||
customConfigDefaults = &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: new("http://custom.com"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 = new("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 = new("http://newhost.ca")
|
||||
patch.GoogleSettings.Enable = new(true)
|
||||
|
||||
expected := base.Clone()
|
||||
expected.ServiceSettings.SiteURL = new("http://newhost.ca")
|
||||
expected.GoogleSettings.Enable = new(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 = new("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)
|
||||
}
|
||||
352
server/config/database.go
Normal file
352
server/config/database.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// 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 Postgres driver
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/mattermost/morph"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/morph/drivers"
|
||||
ps "github.com/mattermost/morph/drivers/postgres"
|
||||
mbindata "github.com/mattermost/morph/sources/embedded"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var assets embed.FS
|
||||
|
||||
// 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.
|
||||
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.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 parses a PostgreSQL connection string and validates the scheme.
|
||||
//
|
||||
// Accepts postgres:// or postgresql:// schemes and returns the DSN 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 "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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
sum := sha256.Sum256(b)
|
||||
|
||||
// Skip the persist altogether if we're effectively writing the same configuration.
|
||||
var oldValue string
|
||||
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))
|
||||
}
|
||||
}()
|
||||
|
||||
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 = new(ds.driverName)
|
||||
configWithDB.SqlSettings.DataSource = new(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 {
|
||||
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 {
|
||||
// This is called during the running of MM, so we expect the parsing of DSN
|
||||
// to be successful.
|
||||
sanitized, _ := model.SanitizeDataSource(ds.driverName, ds.originalDsn)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// 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,
|
||||
// keeping the active configuration and the last 5 most recent ones.
|
||||
func (ds *DatabaseStore) cleanUp(thresholdCreateAt int64) error {
|
||||
query := `
|
||||
DELETE FROM Configurations
|
||||
WHERE CreateAt < :timestamp
|
||||
AND (Active IS NULL OR Active = false)
|
||||
AND ID NOT IN (
|
||||
SELECT ID
|
||||
FROM Configurations
|
||||
ORDER BY CreateAt DESC
|
||||
LIMIT 5
|
||||
)
|
||||
`
|
||||
|
||||
if _, err := ds.db.NamedExec(query, map[string]any{"timestamp": thresholdCreateAt}); err != nil {
|
||||
return errors.Wrap(err, "unable to clean Configurations table")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
1147
server/config/database_test.go
Normal file
1147
server/config/database_test.go
Normal file
File diff suppressed because it is too large
Load Diff
164
server/config/diff.go
Normal file
164
server/config/diff.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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/public/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]any {
|
||||
return map[string]any{
|
||||
"path": c.Path,
|
||||
"base_val": c.BaseVal,
|
||||
"actual_val": c.ActualVal,
|
||||
}
|
||||
}
|
||||
|
||||
func (cd *ConfigDiffs) Auditable() map[string]any {
|
||||
var s []any
|
||||
for _, d := range cd.Sanitize() {
|
||||
s = append(s, d.Auditable())
|
||||
}
|
||||
return map[string]any{
|
||||
"config_diffs": s,
|
||||
}
|
||||
}
|
||||
|
||||
var configSensitivePaths = map[string]bool{
|
||||
"LdapSettings.BindPassword": true,
|
||||
"FileSettings.PublicLinkSalt": true,
|
||||
"FileSettings.AmazonS3SecretAccessKey": true,
|
||||
"FileSettings.AzureAccessKey": true,
|
||||
"FileSettings.ExportAzureAccessKey": true,
|
||||
"SqlSettings.DataSource": 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.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 {
|
||||
// PluginSettings.Plugins gets sanitized anyway, so there is no need to use the plugin manifests here.
|
||||
var pluginManifests []*model.Manifest
|
||||
|
||||
cfgPtr, ok := cd[0].BaseVal.(*model.Config)
|
||||
if ok {
|
||||
cfgPtr.Sanitize(pluginManifests, nil)
|
||||
}
|
||||
cfgPtr, ok = cd[0].ActualVal.(*model.Config)
|
||||
if ok {
|
||||
cfgPtr.Sanitize(pluginManifests, nil)
|
||||
}
|
||||
cfgVal, ok := cd[0].BaseVal.(model.Config)
|
||||
if ok {
|
||||
cfgVal.Sanitize(pluginManifests, nil)
|
||||
}
|
||||
cfgVal, ok = cd[0].ActualVal.(model.Config)
|
||||
if ok {
|
||||
cfgVal.Sanitize(pluginManifests, nil)
|
||||
}
|
||||
}
|
||||
|
||||
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.Pointer {
|
||||
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 := range base.NumField() {
|
||||
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))
|
||||
}
|
||||
913
server/config/diff_test.go
Normal file
913
server/config/diff_test.go
Normal file
@@ -0,0 +1,913 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/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{}
|
||||
for b.Loop() {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("equal with defaults", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := defaultConfigGen()
|
||||
for b.Loop() {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("actual empty", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := &model.Config{}
|
||||
for b.Loop() {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("base empty", func(b *testing.B) {
|
||||
baseCfg := &model.Config{}
|
||||
actualCfg := defaultConfigGen()
|
||||
for b.Loop() {
|
||||
_, _ = Diff(baseCfg, actualCfg)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("some diffs", func(b *testing.B) {
|
||||
baseCfg := defaultConfigGen()
|
||||
actualCfg := defaultConfigGen()
|
||||
baseCfg.ServiceSettings.SiteURL = new("http://localhost")
|
||||
baseCfg.ServiceSettings.ReadTimeout = new(300)
|
||||
baseCfg.SqlSettings.QueryTimeout = new(0)
|
||||
actualCfg.PluginSettings.EnableUploads = nil
|
||||
actualCfg.TeamSettings.MaxChannelsPerTeam = new(int64(100000))
|
||||
actualCfg.FeatureFlags = nil
|
||||
actualCfg.SqlSettings.DataSourceReplicas = []string{
|
||||
"ds0",
|
||||
"ds1",
|
||||
"ds2",
|
||||
}
|
||||
for b.Loop() {
|
||||
_, _ = 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(nil, nil)
|
||||
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(nil, nil)
|
||||
return *cfg
|
||||
}(),
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive LdapSettings.BindPassword",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.LdapSettings.BindPassword = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.LdapSettings.BindPassword = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "LdapSettings.BindPassword",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive FileSettings.PublicLinkSalt",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.PublicLinkSalt = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.PublicLinkSalt = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "FileSettings.PublicLinkSalt",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive FileSettings.AmazonS3SecretAccessKey",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.AmazonS3SecretAccessKey = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.FileSettings.AmazonS3SecretAccessKey = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "FileSettings.AmazonS3SecretAccessKey",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive SqlSettings.DataSource",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSource = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.SqlSettings.DataSource = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "SqlSettings.DataSource",
|
||||
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 = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.EmailSettings.SMTPPassword = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "EmailSettings.SMTPPassword",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive GitLabSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GitLabSettings.Secret = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GitLabSettings.Secret = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "GitLabSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive GoogleSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GoogleSettings.Secret = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.GoogleSettings.Secret = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "GoogleSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive Office365Settings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Office365Settings.Secret = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.Office365Settings.Secret = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "Office365Settings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive OpenIdSettings.Secret",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.OpenIdSettings.Secret = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.OpenIdSettings.Secret = new("actual")
|
||||
return cfg
|
||||
}(),
|
||||
ConfigDiffs{
|
||||
{
|
||||
Path: "OpenIdSettings.Secret",
|
||||
BaseVal: model.FakeSetting,
|
||||
ActualVal: model.FakeSetting,
|
||||
},
|
||||
},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"sensitive ElasticsearchSettings.Password",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ElasticsearchSettings.Password = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ElasticsearchSettings.Password = new("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: new("base"),
|
||||
SMTPPassword: new("base"),
|
||||
EmailAddress: new("base"),
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.MessageExportSettings.GlobalRelaySettings = &model.GlobalRelayMessageExportSettings{
|
||||
SMTPUsername: new("actual"),
|
||||
SMTPPassword: new("actual"),
|
||||
EmailAddress: new("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.SplitKey",
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SplitKey = new("base")
|
||||
return cfg
|
||||
}(),
|
||||
func() *model.Config {
|
||||
cfg := defaultConfigGen()
|
||||
cfg.ServiceSettings.SplitKey = new("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 = new("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 = new(!*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 = new(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,
|
||||
},
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
"mattermost-ai": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
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,
|
||||
},
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
"mattermost-ai": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
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{
|
||||
"com.mattermost.calls": {
|
||||
Enable: true,
|
||||
},
|
||||
"mattermost-ai": {
|
||||
Enable: true,
|
||||
},
|
||||
"playbooks": {
|
||||
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
Normal file
66
server/config/emitter.go
Normal file
@@ -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/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/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
Normal file
95
server/config/emitter_test.go
Normal file
@@ -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/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/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")
|
||||
}
|
||||
197
server/config/environment.go
Normal file
197
server/config/environment.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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/public/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.Pointer {
|
||||
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.Slice:
|
||||
if rFieldValue.Type() == reflect.TypeFor[json.RawMessage]() {
|
||||
rFieldValue.Set(reflect.ValueOf([]byte(value)))
|
||||
break
|
||||
}
|
||||
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.TypeFor[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 rField := range rType.Fields() {
|
||||
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.Pointer {
|
||||
val = val.Elem().FieldByName(path[0])
|
||||
} else {
|
||||
val = val.FieldByName(path[0])
|
||||
}
|
||||
if val.Kind() == reflect.Pointer {
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() == reflect.Struct {
|
||||
return getVal(val, path[1:])
|
||||
}
|
||||
return val
|
||||
}
|
||||
202
server/config/environment_test.go
Normal file
202
server/config/environment_test.go
Normal file
@@ -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/public/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]any{
|
||||
"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]any{
|
||||
"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]any{
|
||||
"com.mattermost.plugin-1": {
|
||||
"key": map[string]any{
|
||||
"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))
|
||||
})
|
||||
}
|
||||
}
|
||||
227
server/config/file.go
Normal file
227
server/config/file.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// 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/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(filepath.Dir(fs.path))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to open config directory")
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
err = root.Remove(name)
|
||||
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
|
||||
}
|
||||
1487
server/config/file_test.go
Normal file
1487
server/config/file_test.go
Normal file
File diff suppressed because it is too large
Load Diff
196
server/config/logconfigsrc.go
Normal file
196
server/config/logconfigsrc.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
const (
|
||||
LogConfigSrcTypeJSON LogConfigSrcType = "json"
|
||||
LogConfigSrcTypeFile LogConfigSrcType = "file"
|
||||
)
|
||||
|
||||
type LogSrcListener func(oldCfg, newCfg mlog.LoggerConfiguration)
|
||||
type LogConfigSrcType string
|
||||
|
||||
// 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 []byte, configStore *Store) (err error)
|
||||
|
||||
// GetType returns the type of config source (JSON, file, ...)
|
||||
GetType() LogConfigSrcType
|
||||
|
||||
// Close cleans up resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewLogConfigSrc creates an advanced logging configuration source, backed by a
|
||||
// file, JSON string, or database.
|
||||
func NewLogConfigSrc(dsn json.RawMessage, configStore *Store) (LogConfigSrc, error) {
|
||||
if len(dsn) == 0 {
|
||||
return nil, errors.New("dsn should not be empty")
|
||||
}
|
||||
|
||||
if configStore == nil {
|
||||
return nil, errors.New("configStore should not be nil")
|
||||
}
|
||||
|
||||
// check if embedded JSON
|
||||
if isJSONMap(dsn) {
|
||||
return newJSONSrc(dsn)
|
||||
}
|
||||
|
||||
// Now we're treating the DSN as a string which may contain escaped JSON or be a filespec.
|
||||
str := strings.TrimSpace(string(dsn))
|
||||
if s, err := strconv.Unquote(str); err == nil {
|
||||
str = s
|
||||
}
|
||||
|
||||
// check if escaped JSON
|
||||
strBytes := []byte(str)
|
||||
if isJSONMap(strBytes) {
|
||||
return newJSONSrc(strBytes)
|
||||
}
|
||||
|
||||
// If this is a file based config we need the full path so it can be watched.
|
||||
path := str
|
||||
if strings.HasPrefix(configStore.String(), "file://") && !filepath.IsAbs(path) {
|
||||
configPath := strings.TrimPrefix(configStore.String(), "file://")
|
||||
path = filepath.Join(filepath.Dir(configPath), path)
|
||||
}
|
||||
|
||||
return newFileSrc(path, configStore)
|
||||
}
|
||||
|
||||
// jsonSrc
|
||||
|
||||
type jsonSrc struct {
|
||||
logSrcEmitter
|
||||
mutex sync.RWMutex
|
||||
cfg mlog.LoggerConfiguration
|
||||
}
|
||||
|
||||
func newJSONSrc(data json.RawMessage) (*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 []byte, _ *Store) error {
|
||||
cfg, err := logTargetCfgFromJSON(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.set(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetType returns the config source type.
|
||||
func (src *jsonSrc) GetType() LogConfigSrcType {
|
||||
return LogConfigSrcTypeJSON
|
||||
}
|
||||
|
||||
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([]byte(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 []byte, configStore *Store) error {
|
||||
data, err := configStore.GetFile(string(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := logTargetCfgFromJSON(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.set(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetType returns the config source type.
|
||||
func (src *fileSrc) GetType() LogConfigSrcType {
|
||||
return LogConfigSrcTypeFile
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
54
server/config/logconfigsrc_test.go
Normal file
54
server/config/logconfigsrc_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var (
|
||||
validJSON = []byte(`{"file":{ "Type":"file"}}`)
|
||||
badJSON = []byte(`{"file":{ Type="file"}}`)
|
||||
validEscapedJSON = []byte(`"{\"file\":{ \"Type\":\"file\"}}"`)
|
||||
badEscapedJSON = []byte(`"{\"file\":{ Type:\"file\"}}"`)
|
||||
)
|
||||
|
||||
func TestNewLogConfigSrc(t *testing.T) {
|
||||
store := NewTestMemoryStore()
|
||||
require.NotNil(t, store)
|
||||
err := store.SetFile("advancedlogging.conf", validJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dsn json.RawMessage
|
||||
configStore *Store
|
||||
wantErr bool
|
||||
wantType LogConfigSrc
|
||||
}{
|
||||
{name: "empty dsn", dsn: []byte(""), configStore: store, wantErr: true, wantType: nil},
|
||||
{name: "garbage dsn", dsn: []byte("!@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 escaped json dsn", dsn: validEscapedJSON, configStore: store, wantErr: false, wantType: &jsonSrc{}},
|
||||
{name: "invalid escaped json dsn", dsn: badEscapedJSON, configStore: store, wantErr: true, wantType: nil},
|
||||
{name: "valid filespec dsn", dsn: []byte("advancedlogging.conf"), configStore: store, wantErr: false, wantType: &fileSrc{}},
|
||||
{name: "invalid filespec dsn", dsn: []byte("/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
417
server/config/logger.go
Normal file
417
server/config/logger.go
Normal file
@@ -0,0 +1,417 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/logr/v2"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
|
||||
)
|
||||
|
||||
const (
|
||||
LogRotateSizeMB = 100
|
||||
LogCompress = true
|
||||
LogRotateMaxAge = 0
|
||||
LogRotateMaxBackups = 0
|
||||
LogFilename = "mattermost.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. Cloned so the target can never alias, and mutate,
|
||||
// the package-level slice shared with auditLevelIDs and IsAuditLevelActive.
|
||||
targetCfg.Levels = slices.Clone(basicAuditFileLevels)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// basicAuditFileLevels is the fixed set of levels bound to the built-in `_defAudit` file
|
||||
// target created from ExperimentalAuditSettings.FileEnabled.
|
||||
//
|
||||
// audit-delivery is deliberately excluded: it produces one record per post per recipient,
|
||||
// which is far too high volume for the basic single-file audit sink. An admin opts in by
|
||||
// adding a target for it via ExperimentalAuditSettings.AdvancedLoggingJSON.
|
||||
var basicAuditFileLevels = []mlog.Level{mlog.LvlAuditAPI, mlog.LvlAuditContent, mlog.LvlAuditPerms, mlog.LvlAuditCLI}
|
||||
|
||||
// auditLevelIDs is the set of mlog level IDs the basic audit file target consumes
|
||||
// (audit-api, audit-content, audit-permissions, audit-cli).
|
||||
//
|
||||
// This is derived from basicAuditFileLevels rather than mlog.MLvlAuditAll on purpose: a
|
||||
// config whose only audit target is bound to audit-delivery is not emitting general audit
|
||||
// records, so IsAuditLoggingActive must still report false for it.
|
||||
var auditLevelIDs = func() map[logr.LevelID]struct{} {
|
||||
m := make(map[logr.LevelID]struct{}, len(basicAuditFileLevels))
|
||||
for _, l := range basicAuditFileLevels {
|
||||
m[l.ID] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// IsAuditLoggingActive reports whether the server is actually emitting audit logs to at
|
||||
// least one sink, given the audit settings and whether advanced logging is licensed.
|
||||
//
|
||||
// It returns true when basic file auditing is enabled, or when the advanced audit logging
|
||||
// config defines at least one target bound to an audit level. A valid advanced-logging
|
||||
// config that routes nothing to an audit level returns false.
|
||||
func IsAuditLoggingActive(auditSettings model.ExperimentalAuditSettings, allowAdvancedLogging bool) bool {
|
||||
if auditSettings.FileEnabled != nil && *auditSettings.FileEnabled {
|
||||
return true
|
||||
}
|
||||
|
||||
if !allowAdvancedLogging {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg := make(mlog.LoggerConfiguration)
|
||||
if err := json.Unmarshal(auditSettings.GetAdvancedLoggingConfig(), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, target := range cfg {
|
||||
for _, level := range target.Levels {
|
||||
if _, ok := auditLevelIDs[level.ID]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAuditLevelActive reports whether records logged at the given audit level would actually
|
||||
// reach a sink.
|
||||
//
|
||||
// Unlike IsAuditLoggingActive, basic file auditing only counts when the requested level is
|
||||
// one the built-in `_defAudit` target is bound to. Enabling
|
||||
// ExperimentalAuditSettings.FileEnabled does not make audit-delivery active, for example.
|
||||
func IsAuditLevelActive(auditSettings model.ExperimentalAuditSettings, allowAdvancedLogging bool, level mlog.Level) bool {
|
||||
if auditSettings.FileEnabled != nil && *auditSettings.FileEnabled &&
|
||||
slices.ContainsFunc(basicAuditFileLevels, func(l mlog.Level) bool { return l.ID == level.ID }) {
|
||||
return true
|
||||
}
|
||||
|
||||
if !allowAdvancedLogging {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg := make(mlog.LoggerConfiguration)
|
||||
if err := json.Unmarshal(auditSettings.GetAdvancedLoggingConfig(), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, target := range cfg {
|
||||
for _, targetLevel := range target.Levels {
|
||||
if targetLevel.ID == level.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func GetLogFileLocation(fileLocation string) string {
|
||||
if fileLocation == "" {
|
||||
fileLocation, _ = fileutils.FindDir("logs")
|
||||
}
|
||||
|
||||
return filepath.Join(fileLocation, LogFilename)
|
||||
}
|
||||
|
||||
// GetLogRootPath returns the root directory for all log files.
|
||||
// This is used for security validation to prevent arbitrary file reads via advanced logging.
|
||||
// The logging root is determined by:
|
||||
// 1. MM_LOG_PATH environment variable (if set and non-empty)
|
||||
// 2. The default "logs" directory (found relative to the binary)
|
||||
func GetLogRootPath() string {
|
||||
// Check environment variable
|
||||
if envPath := os.Getenv("MM_LOG_PATH"); envPath != "" {
|
||||
absPath, err := filepath.Abs(envPath)
|
||||
if err == nil {
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default logs directory
|
||||
logsDir, _ := fileutils.FindDir("logs")
|
||||
absPath, err := filepath.Abs(logsDir)
|
||||
if err != nil {
|
||||
return logsDir
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
||||
// ValidateLogFilePath validates that a log file path is within the logging root directory.
|
||||
// This prevents arbitrary file read/write vulnerabilities in logging configuration.
|
||||
// The logging root is determined by MM_LOG_PATH environment variable or the configured log directory.
|
||||
func ValidateLogFilePath(filePath string, loggingRoot string) error {
|
||||
// Resolve file path to absolute
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve path %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Resolve symlinks to prevent bypass via symlink attacks
|
||||
realPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
// If file doesn't exist, still validate the intended path
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("cannot resolve symlinks for %s: %w", absPath, err)
|
||||
}
|
||||
} else {
|
||||
absPath = realPath
|
||||
}
|
||||
|
||||
// Resolve logging root to absolute
|
||||
absRoot, err := filepath.Abs(loggingRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve logging root %s: %w", loggingRoot, err)
|
||||
}
|
||||
|
||||
// Ensure root has trailing separator for proper prefix matching
|
||||
// This prevents /tmp/log matching /tmp/logger
|
||||
rootWithSep := absRoot
|
||||
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
|
||||
rootWithSep += string(filepath.Separator)
|
||||
}
|
||||
|
||||
// Check if file is within the logging root
|
||||
// Allow exact match (absPath == absRoot) or proper prefix match
|
||||
if absPath != absRoot && !strings.HasPrefix(absPath, rootWithSep) {
|
||||
return fmt.Errorf("path %s is outside logging root %s", filePath, absRoot)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarnIfLogPathsOutsideRoot validates log file paths in the config and logs errors for paths outside the logging root.
|
||||
// This is called during config save to identify configurations that will cause server startup to fail in a future version.
|
||||
// Currently only logs errors; in a future version this will block server startup.
|
||||
func WarnIfLogPathsOutsideRoot(cfg *model.Config) {
|
||||
loggingRoot := GetLogRootPath()
|
||||
|
||||
// Check LogSettings.AdvancedLoggingJSON
|
||||
if !utils.IsEmptyJSON(cfg.LogSettings.AdvancedLoggingJSON) {
|
||||
validateAdvancedLoggingConfig(cfg.LogSettings.AdvancedLoggingJSON, "LogSettings.AdvancedLoggingJSON", loggingRoot)
|
||||
}
|
||||
|
||||
// Check ExperimentalAuditSettings.AdvancedLoggingJSON
|
||||
if !utils.IsEmptyJSON(cfg.ExperimentalAuditSettings.AdvancedLoggingJSON) {
|
||||
validateAdvancedLoggingConfig(cfg.ExperimentalAuditSettings.AdvancedLoggingJSON, "ExperimentalAuditSettings.AdvancedLoggingJSON", loggingRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func validateAdvancedLoggingConfig(loggingJSON json.RawMessage, configName string, loggingRoot string) {
|
||||
logCfg := make(mlog.LoggerConfiguration)
|
||||
if err := json.Unmarshal(loggingJSON, &logCfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for targetName, target := range logCfg {
|
||||
if target.Type != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
var fileOption struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(target.Options, &fileOption); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := ValidateLogFilePath(fileOption.Filename, loggingRoot); err != nil {
|
||||
mlog.Error("Log file path in logging config is outside logging root directory. This configuration will cause server startup to fail in a future version. To fix, set MM_LOG_PATH environment variable to a parent directory containing all log paths, or move log files to the configured logging root.",
|
||||
mlog.String("config_section", configName),
|
||||
mlog.String("target", targetName),
|
||||
mlog.String("path", fileOption.Filename),
|
||||
mlog.String("logging_root", loggingRoot),
|
||||
mlog.Err(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
387
server/config/logger_test.go
Normal file
387
server/config/logger_test.go
Normal file
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
)
|
||||
|
||||
func TestMloggerConfigFromAuditConfig(t *testing.T) {
|
||||
auditSettings := model.ExperimentalAuditSettings{
|
||||
FileEnabled: new(true),
|
||||
FileName: new("audit.log"),
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLogRootPath(t *testing.T) {
|
||||
t.Run("returns MM_LOG_PATH when set", func(t *testing.T) {
|
||||
// Create a temp directory to use as MM_LOG_PATH
|
||||
dir, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
t.Setenv("MM_LOG_PATH", dir)
|
||||
|
||||
result := GetLogRootPath()
|
||||
absDir, _ := filepath.Abs(dir)
|
||||
assert.Equal(t, absDir, result)
|
||||
})
|
||||
|
||||
t.Run("finds logs directory relative to binary when MM_LOG_PATH not set", func(t *testing.T) {
|
||||
// When MM_LOG_PATH is not set, GetLogRootPath falls back to FindDir("logs"),
|
||||
// which searches for a "logs" directory relative to the working directory
|
||||
// and the binary location. Create a logs directory relative to the test
|
||||
// binary to verify this behavior.
|
||||
t.Setenv("MM_LOG_PATH", "")
|
||||
|
||||
// Get the test binary location
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
require.NoError(t, err)
|
||||
binaryDir := filepath.Dir(exe)
|
||||
|
||||
// Create a "logs" directory next to the binary
|
||||
logsDir := filepath.Join(binaryDir, "logs")
|
||||
err = os.MkdirAll(logsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(logsDir)
|
||||
})
|
||||
|
||||
result := GetLogRootPath()
|
||||
|
||||
// Result should be an absolute path
|
||||
assert.True(t, filepath.IsAbs(result), "GetLogRootPath should return an absolute path, got: %s", result)
|
||||
|
||||
// FindDir searches working directory first, then binary directory.
|
||||
// The result should be either the logs directory we created or another
|
||||
// logs directory found earlier in the search path. Either way, it should
|
||||
// be a valid directory path ending in "logs".
|
||||
assert.True(t, filepath.Base(result) == "logs" || result == "./",
|
||||
"GetLogRootPath should return a logs directory path, got: %s", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLogFilePath(t *testing.T) {
|
||||
t.Run("valid path within root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
validFile := filepath.Join(root, "app.log")
|
||||
err = os.WriteFile(validFile, []byte("test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(validFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("valid path in subdirectory", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
subdir := filepath.Join(root, "subdir")
|
||||
err = os.MkdirAll(subdir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
validFile := filepath.Join(subdir, "app.log")
|
||||
err = os.WriteFile(validFile, []byte("test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(validFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects absolute path outside root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
outsideDir, err := os.MkdirTemp("", "outside")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(outsideDir)
|
||||
})
|
||||
|
||||
outsideFile := filepath.Join(outsideDir, "secret.txt")
|
||||
err = os.WriteFile(outsideFile, []byte("secret"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(outsideFile, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("rejects path traversal attack", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
traversalPath := filepath.Join(root, "..", "..", "etc", "passwd")
|
||||
|
||||
err = ValidateLogFilePath(traversalPath, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("rejects symlink pointing outside root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
outsideDir, err := os.MkdirTemp("", "outside")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(outsideDir)
|
||||
})
|
||||
|
||||
// Create a file outside the root
|
||||
outsideFile := filepath.Join(outsideDir, "secret.txt")
|
||||
err = os.WriteFile(outsideFile, []byte("secret"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a symlink inside root pointing to the outside file
|
||||
symlinkPath := filepath.Join(root, "sneaky.log")
|
||||
err = os.Symlink(outsideFile, symlinkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ValidateLogFilePath(symlinkPath, root)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "outside logging root")
|
||||
})
|
||||
|
||||
t.Run("allows non-existent file path within root", func(t *testing.T) {
|
||||
root, err := os.MkdirTemp("", "logroot")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(root)
|
||||
})
|
||||
|
||||
// File doesn't exist but path is within root
|
||||
nonExistentFile := filepath.Join(root, "future.log")
|
||||
|
||||
err = ValidateLogFilePath(nonExistentFile, root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// deliveryTargetJSON is an advanced logging config whose only target is bound to the
|
||||
// audit-delivery level.
|
||||
var deliveryTargetJSON = json.RawMessage(`{"my-delivery":{"type":"file","levels":[{"id":104,"name":"audit-delivery"}],"options":{"filename":"delivery.log"}}}`)
|
||||
|
||||
func TestIsAuditLoggingActive(t *testing.T) {
|
||||
auditTargetJSON := json.RawMessage(`{"my-audit":{"type":"file","levels":[{"id":100,"name":"audit-api"}],"options":{"filename":"audit.log"}}}`)
|
||||
stdTargetJSON := json.RawMessage(`{"my-log":{"type":"file","levels":[{"id":4,"name":"info"}],"options":{"filename":"info.log"}}}`)
|
||||
malformedJSON := json.RawMessage(`{not valid json`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileEnabled bool
|
||||
fileName string
|
||||
advancedLoggingJSON json.RawMessage
|
||||
allowAdvancedLogging bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "A - file audit enabled (license independent)",
|
||||
fileEnabled: true,
|
||||
fileName: "audit.log",
|
||||
allowAdvancedLogging: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "B - advanced audit target, licensed",
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: auditTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "B - advanced audit target, unlicensed",
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: auditTargetJSON,
|
||||
allowAdvancedLogging: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "C - valid advanced config, no audit level",
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: stdTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "D - nothing configured",
|
||||
fileEnabled: false,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "malformed advanced JSON",
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: malformedJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
// audit-delivery is in MLvlAuditAll but not in the basic audit file target, so
|
||||
// a delivery-only config is not general audit logging.
|
||||
name: "advanced target bound only to audit-delivery",
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: deliveryTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auditSettings := model.ExperimentalAuditSettings{}
|
||||
auditSettings.SetDefaults()
|
||||
*auditSettings.FileEnabled = tc.fileEnabled
|
||||
*auditSettings.FileName = tc.fileName
|
||||
auditSettings.AdvancedLoggingJSON = tc.advancedLoggingJSON
|
||||
|
||||
assert.Equal(t, tc.expected, IsAuditLoggingActive(auditSettings, tc.allowAdvancedLogging))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuditLevelActive(t *testing.T) {
|
||||
auditTargetJSON := json.RawMessage(`{"my-audit":{"type":"file","levels":[{"id":100,"name":"audit-api"}],"options":{"filename":"audit.log"}}}`)
|
||||
malformedJSON := json.RawMessage(`{not valid json`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
level mlog.Level
|
||||
fileEnabled bool
|
||||
advancedLoggingJSON json.RawMessage
|
||||
allowAdvancedLogging bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "file audit covers audit-api",
|
||||
level: mlog.LvlAuditAPI,
|
||||
fileEnabled: true,
|
||||
allowAdvancedLogging: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
// The whole point of the helper: FileEnabled alone must not make
|
||||
// audit-delivery active, because _defAudit is not bound to it.
|
||||
name: "file audit does not cover audit-delivery",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: true,
|
||||
allowAdvancedLogging: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "advanced delivery target, licensed",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: deliveryTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "advanced delivery target, unlicensed",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: deliveryTargetJSON,
|
||||
allowAdvancedLogging: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "advanced target for a different audit level",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: auditTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "file audit plus advanced delivery target",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: true,
|
||||
advancedLoggingJSON: deliveryTargetJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nothing configured",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: false,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "malformed advanced JSON",
|
||||
level: mlog.LvlAuditDelivery,
|
||||
fileEnabled: false,
|
||||
advancedLoggingJSON: malformedJSON,
|
||||
allowAdvancedLogging: true,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
auditSettings := model.ExperimentalAuditSettings{}
|
||||
auditSettings.SetDefaults()
|
||||
*auditSettings.FileEnabled = tc.fileEnabled
|
||||
*auditSettings.FileName = "audit.log"
|
||||
auditSettings.AdvancedLoggingJSON = tc.advancedLoggingJSON
|
||||
|
||||
assert.Equal(t, tc.expected, IsAuditLevelActive(auditSettings, tc.allowAdvancedLogging, tc.level))
|
||||
})
|
||||
}
|
||||
}
|
||||
61
server/config/main_test.go
Normal file
61
server/config/main_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/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.DatabaseDriverPostgres:
|
||||
_, err := sqlStore.GetMaster().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)
|
||||
}
|
||||
119
server/config/memory.go
Normal file
119
server/config/memory.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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/public/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
Normal file
205
server/config/memory_test.go
Normal file
@@ -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())
|
||||
}
|
||||
71
server/config/migrate.go
Normal file
71
server/config/migrate.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 a filespec.
|
||||
dsn := sourceConfig.LogSettings.GetAdvancedLoggingConfig()
|
||||
cfgSource, err := NewLogConfigSrc(dsn, source)
|
||||
if err == nil && cfgSource.GetType() == LogConfigSrcTypeFile {
|
||||
files = append(files, string(dsn))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
156
server/config/migrate_test.go
Normal file
156
server/config/migrate_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// 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/public/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 := t.TempDir()
|
||||
t.Chdir(tempDir)
|
||||
|
||||
truncateTables(t)
|
||||
}
|
||||
|
||||
setupSource := func(t *testing.T, source *Store) cleanUpFn {
|
||||
t.Helper()
|
||||
|
||||
cfg := source.Get()
|
||||
originalCfg := cfg.Clone()
|
||||
cfg.ServiceSettings.SiteURL = new("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{
|
||||
"postgres://mmuser:password@replicahost:5432/mattermost",
|
||||
}
|
||||
cfg.SqlSettings.DataSourceSearchReplicas = []string{
|
||||
"postgres://mmuser:password@searchreplicahost:5432/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 '';
|
||||
20
server/config/post-delivery-audit-config-sample.json
Normal file
20
server/config/post-delivery-audit-config-sample.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"post-delivery-audit": {
|
||||
"type": "file",
|
||||
"format": "json",
|
||||
"levels": [
|
||||
{
|
||||
"id": 104,
|
||||
"name": "audit-delivery"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"filename": "./logs/post-delivery-audit.log",
|
||||
"max_size": 100,
|
||||
"max_age": 0,
|
||||
"max_backups": 0,
|
||||
"compress": true
|
||||
},
|
||||
"maxqueuesize": 10000
|
||||
}
|
||||
}
|
||||
413
server/config/store.go
Normal file
413
server/config/store.go
Normal file
@@ -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/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/utils"
|
||||
)
|
||||
|
||||
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 utils.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 = new("")
|
||||
}
|
||||
|
||||
// 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(expiry)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
97
server/config/store_test.go
Normal file
97
server/config/store_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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 := t.TempDir()
|
||||
t.Chdir(tempDir)
|
||||
|
||||
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 := t.TempDir()
|
||||
t.Chdir(tempDir)
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
267
server/config/utils.go
Normal file
267
server/config/utils.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// 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/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
// 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. Safe to call on
|
||||
// partial configs: every pointer dereference is nil-guarded so callers do not
|
||||
// need to run SetDefaults() first.
|
||||
func Desanitize(actual, target *model.Config) {
|
||||
if target.LdapSettings.BindPassword != nil && *target.LdapSettings.BindPassword == model.FakeSetting && actual.LdapSettings.BindPassword != nil {
|
||||
*target.LdapSettings.BindPassword = *actual.LdapSettings.BindPassword
|
||||
}
|
||||
|
||||
if target.FileSettings.PublicLinkSalt != nil && *target.FileSettings.PublicLinkSalt == model.FakeSetting && actual.FileSettings.PublicLinkSalt != nil {
|
||||
*target.FileSettings.PublicLinkSalt = *actual.FileSettings.PublicLinkSalt
|
||||
}
|
||||
if target.FileSettings.AmazonS3SecretAccessKey != nil && *target.FileSettings.AmazonS3SecretAccessKey == model.FakeSetting && actual.FileSettings.AmazonS3SecretAccessKey != nil {
|
||||
target.FileSettings.AmazonS3SecretAccessKey = actual.FileSettings.AmazonS3SecretAccessKey
|
||||
}
|
||||
if target.FileSettings.ExportAmazonS3SecretAccessKey != nil && *target.FileSettings.ExportAmazonS3SecretAccessKey == model.FakeSetting && actual.FileSettings.ExportAmazonS3SecretAccessKey != nil {
|
||||
target.FileSettings.ExportAmazonS3SecretAccessKey = actual.FileSettings.ExportAmazonS3SecretAccessKey
|
||||
}
|
||||
if target.FileSettings.AzureAccessKey != nil && *target.FileSettings.AzureAccessKey == model.FakeSetting && actual.FileSettings.AzureAccessKey != nil {
|
||||
target.FileSettings.AzureAccessKey = actual.FileSettings.AzureAccessKey
|
||||
}
|
||||
if target.FileSettings.ExportAzureAccessKey != nil && *target.FileSettings.ExportAzureAccessKey == model.FakeSetting && actual.FileSettings.ExportAzureAccessKey != nil {
|
||||
target.FileSettings.ExportAzureAccessKey = actual.FileSettings.ExportAzureAccessKey
|
||||
}
|
||||
|
||||
if target.EmailSettings.SMTPPassword != nil && *target.EmailSettings.SMTPPassword == model.FakeSetting {
|
||||
target.EmailSettings.SMTPPassword = actual.EmailSettings.SMTPPassword
|
||||
}
|
||||
|
||||
if target.GitLabSettings.Secret != nil && *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 != nil && *target.SqlSettings.DataSource == model.FakeSetting && actual.SqlSettings.DataSource != nil {
|
||||
*target.SqlSettings.DataSource = *actual.SqlSettings.DataSource
|
||||
}
|
||||
|
||||
if target.ElasticsearchSettings.Password != nil && *target.ElasticsearchSettings.Password == model.FakeSetting && actual.ElasticsearchSettings.Password != nil {
|
||||
*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 != nil &&
|
||||
target.MessageExportSettings.GlobalRelaySettings.SMTPPassword != nil &&
|
||||
*target.MessageExportSettings.GlobalRelaySettings.SMTPPassword == model.FakeSetting &&
|
||||
actual.MessageExportSettings.GlobalRelaySettings != nil &&
|
||||
actual.MessageExportSettings.GlobalRelaySettings.SMTPPassword != nil {
|
||||
*target.MessageExportSettings.GlobalRelaySettings.SMTPPassword = *actual.MessageExportSettings.GlobalRelaySettings.SMTPPassword
|
||||
}
|
||||
|
||||
if target.ServiceSettings.SplitKey != nil && *target.ServiceSettings.SplitKey == model.FakeSetting && actual.ServiceSettings.SplitKey != nil {
|
||||
*target.ServiceSettings.SplitKey = *actual.ServiceSettings.SplitKey
|
||||
}
|
||||
|
||||
if target.ServiceSettings.GoogleDeveloperKey != nil && *target.ServiceSettings.GoogleDeveloperKey == model.FakeSetting {
|
||||
target.ServiceSettings.GoogleDeveloperKey = actual.ServiceSettings.GoogleDeveloperKey
|
||||
}
|
||||
|
||||
if target.ServiceSettings.GiphySdkKey != nil && *target.ServiceSettings.GiphySdkKey == model.FakeSetting {
|
||||
target.ServiceSettings.GiphySdkKey = actual.ServiceSettings.GiphySdkKey
|
||||
}
|
||||
|
||||
if target.CacheSettings.RedisPassword != nil && *target.CacheSettings.RedisPassword == model.FakeSetting {
|
||||
target.CacheSettings.RedisPassword = actual.CacheSettings.RedisPassword
|
||||
}
|
||||
|
||||
if target.AutoTranslationSettings.LibreTranslate != nil &&
|
||||
target.AutoTranslationSettings.LibreTranslate.APIKey != nil &&
|
||||
*target.AutoTranslationSettings.LibreTranslate.APIKey == model.FakeSetting {
|
||||
target.AutoTranslationSettings.LibreTranslate.APIKey = actual.AutoTranslationSettings.LibreTranslate.APIKey
|
||||
}
|
||||
|
||||
for id, settings := range target.PluginSettings.Plugins {
|
||||
for k, v := range settings {
|
||||
if v == model.FakeSetting {
|
||||
settings[k] = actual.PluginSettings.Plugins[id][k]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func fixInvalidLocales(cfg *model.Config) bool {
|
||||
var changed bool
|
||||
|
||||
locales := i18n.GetSupportedLocales()
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultServerLocale]; !ok {
|
||||
mlog.Warn("DefaultServerLocale must be one of the supported locales. Setting DefaultServerLocale to en as default value.", mlog.String("locale", *cfg.LocalizationSettings.DefaultServerLocale))
|
||||
*cfg.LocalizationSettings.DefaultServerLocale = model.DefaultLocale
|
||||
changed = true
|
||||
}
|
||||
|
||||
if _, ok := locales[*cfg.LocalizationSettings.DefaultClientLocale]; !ok {
|
||||
mlog.Warn("DefaultClientLocale must be one of the supported locales. Setting DefaultClientLocale to en as default value.", mlog.String("locale", *cfg.LocalizationSettings.DefaultClientLocale))
|
||||
*cfg.LocalizationSettings.DefaultClientLocale = model.DefaultLocale
|
||||
changed = true
|
||||
}
|
||||
|
||||
if *cfg.LocalizationSettings.AvailableLocales != "" {
|
||||
isDefaultClientLocaleInAvailableLocales := false
|
||||
for word := range strings.SplitSeq(*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) {
|
||||
return utils.Merge(cfg, patch, mergeConfig)
|
||||
}
|
||||
|
||||
func IsDatabaseDSN(dsn string) bool {
|
||||
return strings.HasPrefix(dsn, "postgres://") ||
|
||||
strings.HasPrefix(dsn, "postgresql://")
|
||||
}
|
||||
|
||||
func isJSONMap(data []byte) bool {
|
||||
var m map[string]any
|
||||
err := json.Unmarshal(data, &m)
|
||||
return err == 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.Pointer {
|
||||
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
|
||||
}
|
||||
360
server/config/utils_test.go
Normal file
360
server/config/utils_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
func TestDesanitize(t *testing.T) {
|
||||
actual := &model.Config{}
|
||||
actual.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
actual.LdapSettings.Enable = new(false)
|
||||
actual.FileSettings.DriverName = new("s3")
|
||||
|
||||
// These settings should be desanitized into target.
|
||||
actual.LdapSettings.BindPassword = new("bind_password")
|
||||
actual.FileSettings.PublicLinkSalt = new("public_link_salt")
|
||||
actual.FileSettings.AmazonS3SecretAccessKey = new("amazon_s3_secret_access_key")
|
||||
actual.FileSettings.ExportAmazonS3SecretAccessKey = new("export_amazon_s3_secret_access_key")
|
||||
actual.FileSettings.AzureAccessKey = new("azure_access_key")
|
||||
actual.FileSettings.ExportAzureAccessKey = new("export_azure_access_key")
|
||||
actual.EmailSettings.SMTPPassword = new("smtp_password")
|
||||
actual.GitLabSettings.Secret = new("secret")
|
||||
actual.OpenIdSettings.Secret = new("secret")
|
||||
actual.SqlSettings.DataSource = new("data_source")
|
||||
actual.ElasticsearchSettings.Password = new("password")
|
||||
actual.ServiceSettings.GoogleDeveloperKey = new("google_developer_key")
|
||||
actual.ServiceSettings.GiphySdkKey = new("giphy_sdk_key")
|
||||
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")
|
||||
actual.PluginSettings.Plugins = map[string]map[string]any{
|
||||
"plugin1": {
|
||||
"secret": "value1",
|
||||
"no_secret": "value2",
|
||||
},
|
||||
}
|
||||
|
||||
target := &model.Config{}
|
||||
target.SetDefaults()
|
||||
|
||||
// These setting should be ignored
|
||||
target.LdapSettings.Enable = new(true)
|
||||
target.FileSettings.DriverName = new("file")
|
||||
|
||||
// These settings should be updated from actual
|
||||
target.LdapSettings.BindPassword = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.PublicLinkSalt = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.AmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.ExportAmazonS3SecretAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.AzureAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.FileSettings.ExportAzureAccessKey = model.NewPointer(model.FakeSetting)
|
||||
target.EmailSettings.SMTPPassword = model.NewPointer(model.FakeSetting)
|
||||
target.GitLabSettings.Secret = model.NewPointer(model.FakeSetting)
|
||||
target.OpenIdSettings.Secret = model.NewPointer(model.FakeSetting)
|
||||
target.SqlSettings.DataSource = model.NewPointer(model.FakeSetting)
|
||||
target.ElasticsearchSettings.Password = model.NewPointer(model.FakeSetting)
|
||||
target.ServiceSettings.GoogleDeveloperKey = model.NewPointer(model.FakeSetting)
|
||||
target.ServiceSettings.GiphySdkKey = model.NewPointer(model.FakeSetting)
|
||||
target.SqlSettings.DataSourceReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
target.SqlSettings.DataSourceSearchReplicas = []string{model.FakeSetting, model.FakeSetting}
|
||||
target.PluginSettings.Plugins = map[string]map[string]any{
|
||||
"plugin1": {
|
||||
"secret": model.FakeSetting,
|
||||
"no_secret": "value2",
|
||||
},
|
||||
}
|
||||
|
||||
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.FileSettings.ExportAmazonS3SecretAccessKey, *target.FileSettings.ExportAmazonS3SecretAccessKey)
|
||||
assert.Equal(t, *actual.FileSettings.AzureAccessKey, *target.FileSettings.AzureAccessKey)
|
||||
assert.Equal(t, *actual.FileSettings.ExportAzureAccessKey, *target.FileSettings.ExportAzureAccessKey)
|
||||
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.ElasticsearchSettings.Password, *target.ElasticsearchSettings.Password)
|
||||
assert.Equal(t, *actual.ServiceSettings.GoogleDeveloperKey, *target.ServiceSettings.GoogleDeveloperKey)
|
||||
assert.Equal(t, *actual.ServiceSettings.GiphySdkKey, *target.ServiceSettings.GiphySdkKey)
|
||||
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)
|
||||
assert.Equal(t, actual.PluginSettings.Plugins, target.PluginSettings.Plugins)
|
||||
}
|
||||
|
||||
// TestDesanitizeRemovesAllFakeSettings verifies that every field masked by
|
||||
// Sanitize has a corresponding entry in desanitize, so FakeSetting is never
|
||||
// written back to stored config. No manual field listing is required: all
|
||||
// string fields are pre-populated via reflection so Sanitize will mask any
|
||||
// secret regardless of its default value.
|
||||
func TestDesanitizeRemovesAllFakeSettings(t *testing.T) {
|
||||
actual := &model.Config{}
|
||||
actual.SetDefaults()
|
||||
populateStrings(reflect.ValueOf(actual), "test-value")
|
||||
|
||||
sanitized := actual.Clone()
|
||||
sanitized.Sanitize(nil, nil)
|
||||
|
||||
Desanitize(actual, sanitized)
|
||||
|
||||
assertNoFakeSettings(t, reflect.ValueOf(*sanitized), "Config")
|
||||
}
|
||||
|
||||
// populateStrings sets every empty string reachable from v to value so that
|
||||
// Sanitize will replace it if it is a secret field.
|
||||
func populateStrings(v reflect.Value, value string) {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() && v.CanSet() {
|
||||
v.Set(reflect.New(v.Type().Elem()))
|
||||
}
|
||||
if !v.IsNil() {
|
||||
if v.Elem().Kind() == reflect.String {
|
||||
if v.Elem().String() == "" {
|
||||
v.Elem().SetString(value)
|
||||
}
|
||||
} else {
|
||||
populateStrings(v.Elem(), value)
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
for _, sf := range reflect.VisibleFields(v.Type()) {
|
||||
field := v.FieldByIndex(sf.Index)
|
||||
if field.CanSet() {
|
||||
populateStrings(field, value)
|
||||
}
|
||||
}
|
||||
case reflect.Slice:
|
||||
for i := range v.Len() {
|
||||
populateStrings(v.Index(i), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoFakeSettings walks v recursively and fails if any string field equals
|
||||
// model.FakeSetting, reporting the dotted path of the offending field.
|
||||
func assertNoFakeSettings(t *testing.T, v reflect.Value, path string) {
|
||||
t.Helper()
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if !v.IsNil() {
|
||||
assertNoFakeSettings(t, v.Elem(), path)
|
||||
}
|
||||
case reflect.Struct:
|
||||
for i := range v.NumField() {
|
||||
assertNoFakeSettings(t, v.Field(i), path+"."+v.Type().Field(i).Name)
|
||||
}
|
||||
case reflect.String:
|
||||
assert.NotEqual(t, model.FakeSetting, v.String(), "FakeSetting persisted at %s after desanitize", path)
|
||||
case reflect.Slice:
|
||||
for i := range v.Len() {
|
||||
assertNoFakeSettings(t, v.Index(i), fmt.Sprintf("%s[%d]", path, i))
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, key := range v.MapKeys() {
|
||||
elem := v.MapIndex(key)
|
||||
if elem.Kind() == reflect.Interface {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
assertNoFakeSettings(t, elem, fmt.Sprintf("%s[%v]", path, key))
|
||||
}
|
||||
case reflect.Interface:
|
||||
if !v.IsNil() {
|
||||
assertNoFakeSettings(t, v.Elem(), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixInvalidLocales(t *testing.T) {
|
||||
// utils.TranslationsPreInit errors when TestFixInvalidLocales is run as part of testing the package,
|
||||
// but doesn't error when the test is run individually.
|
||||
_ = 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: "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 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: "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([]byte(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()
|
||||
n := minimalConfig.Clone()
|
||||
diff, err := equal(old, n)
|
||||
require.NoError(t, err)
|
||||
require.False(t, diff)
|
||||
})
|
||||
|
||||
t.Run("diff", func(t *testing.T) {
|
||||
old := minimalConfig.Clone()
|
||||
n := minimalConfig.Clone()
|
||||
n.SqlSettings = model.SqlSettings{}
|
||||
diff, err := equal(old, n)
|
||||
require.NoError(t, err)
|
||||
require.True(t, diff)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user