Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5-internal/WPB-22961
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Migration of service (old bots) from cassandra to postgres
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ data:
migrateConversationCodes: {{ .migrateConversationCodes }}
migrateTeamFeatures: {{ .migrateTeamFeatures }}
migrateDomainRegistration: {{ .migrateDomainRegistration }}
migrateService: {{ .migrateService }}
migrationOptions:
{{ toYaml .migrationOptions | indent 6 }}

Expand Down
5 changes: 5 additions & 0 deletions charts/wire-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ galley:
teamFeatures: cassandra
domainRegistration: cassandra
user: cassandra
service: cassandra
settings:
httpPoolSize: 128
maxTeamSize: 10000
Expand Down Expand Up @@ -1017,6 +1018,10 @@ background-worker:
# It's important to set `settings.postgresMigration.domainRegistration` to `migration-to-postgresql`
# before starting the migration.
migrateDomainRegistration: false
# This will start the migration of services (old bots).
# It's important to set `settings.postgresMigration.service` to `migration-to-postgresql`
# before starting the migration.
migrateService: false

backendNotificationPusher:
pushBackoffMinWait: 10000 # in microseconds, so 10ms
Expand Down
10 changes: 10 additions & 0 deletions docs/src/developer/reference/config-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -2106,12 +2106,14 @@ galley:
teamFeatures: postgresql
domainRegistration: postgresql
user: postgresql
service: postgresql
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateService: false
```

#### Migration for existing installations
Expand Down Expand Up @@ -2142,6 +2144,7 @@ The current settings and their background-worker flags are:
- `conversationCodes` -> `migrateConversationCodes`
- `teamFeatures` -> `migrateTeamFeatures`
- `domainRegistration` -> `migrateDomainRegistration`
- `service` -> `migrateService`

**Migration pattern per migration setting**

Expand All @@ -2161,12 +2164,14 @@ The current settings and their background-worker flags are:
conversationCodes: migration-to-postgresql
teamFeatures: migration-to-postgresql
domainRegistration: cassandra
service: cassandra
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateService: false
```

This change should restart the affected pods, and new writes will follow the
Expand All @@ -2181,6 +2186,7 @@ The current settings and their background-worker flags are:
migrateConversationCodes: true
migrateTeamFeatures: true
migrateDomainRegistration: true
migrateService: true
```

During migration, Cassandra rows are not deleted. Writes and migration share
Expand All @@ -2196,6 +2202,7 @@ The current settings and their background-worker flags are:
- `conversationCodes`: `wire_conv_codes_migration_finished`
- `teamFeatures`: `wire_team_features_migration_finished`
- `domainRegistration`: `wire_domain_registration_migration_finished`
- `service`: `wire_service_migration_finished`

3. Cut over reads and writes to PostgreSQL for the selected migration
setting(s). This configuration must be used from now on for every new
Expand All @@ -2209,12 +2216,14 @@ The current settings and their background-worker flags are:
conversationCodes: postgresql
teamFeatures: postgresql
domainRegistration: cassandra
service: postgresql
background-worker:
config:
migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateService: false
```

**How to run migrations independently or in batches**
Expand Down Expand Up @@ -2303,6 +2312,7 @@ migrateConversations: false
migrateConversationCodes: false
migrateTeamFeatures: false
migrateDomainRegistration: false
migrateService: false

# migration settings
migrationOptions:
Expand Down
1 change: 1 addition & 0 deletions hack/helm_vars/common.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ conversationCodesStore: {{ $preferredStore }}
teamFeaturesStore: {{ $preferredStore }}
domainRegistration: {{ $preferredStore }}
userStore: {{ $preferredStore }}
serviceStore: {{ $preferredStore }}

{{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }}
uploadXml: {}
Expand Down
1 change: 1 addition & 0 deletions hack/helm_vars/wire-server/values.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ galley:
teamFeatures: {{ .Values.teamFeaturesStore }}
domainRegistration: {{ .Values.domainRegistration }}
user: {{ .Values.userStore }}
service: {{ .Values.serviceStore }}
settings:
maxConvAndTeamSize: 16
maxTeamSize: 32
Expand Down
1 change: 1 addition & 0 deletions integration/integration.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ library
Test.Migration.Conversation
Test.Migration.ConversationCodes
Test.Migration.DomainRegistration
Test.Migration.Service
Test.Migration.TeamFeatures
Test.Migration.Util
Test.MLS
Expand Down
76 changes: 76 additions & 0 deletions integration/test/Test/Migration/Service.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Test.Migration.Service where

import API.Brig
import API.Galley
import Control.Monad.Codensity
import Control.Monad.Reader
import Data.String.Conversions (cs)
import SetupHelpers
import Test.Bot (mkBotService)
import Test.Migration.Util (waitForMigration)
import Testlib.MockIntegrationService (MockServerSettings, withMockServer)
import Testlib.Prelude
import Testlib.ResourcePool

-- | Migrate the 'ServiceStore' (old bot provider/service connection data) from
-- Cassandra to PostgreSQL. A service is created while galley reads from
-- Cassandra; after the background worker backfills it, the service must still be
-- usable once galley reads exclusively from PostgreSQL (verified by adding a bot
-- to a conversation, which forces galley to look up the service).
testServiceMigration :: (HasCallStack) => App ()
testServiceMigration = do
resourcePool <- asks (.resourcePool)
let settings = def :: MockServerSettings
runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do
let domain = backend.berDomain
-- The mock service must stay alive across all lifecycle stages, since adding
-- a bot in the final (PostgreSQL) stage requires the service endpoint to be
-- reachable.
withMockServer settings mkBotService $ \(host, port) _chan -> do
let serviceUrl = "https://" <> host <> ":" <> show port
(alice, providerId, serviceId) <-
runCodensity (startDynamicBackend backend (conf "cassandra" False)) $ \_ -> do
alice <- randomUser OwnDomain def
password <- randomString 20
provider <- setupProvider alice def {newProviderPassword = Just password}
pid <- provider %. "id" & asString
service <-
newService OwnDomain pid
$ def {newServiceUrl = serviceUrl, newServiceKey = cs settings.publicKey}
sid <- asString $ service %. "id"
assertStatus 200 =<< updateServiceConn OwnDomain pid sid (object ["enabled" .= True, "password" .= password])
pure (alice, pid, sid)
runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) $ \_ ->
waitForMigration domain counterName
runCodensity (startDynamicBackend backend (conf "postgresql" False)) $ \_ -> do
conv <- getJSON 201 =<< postConversation alice defProteus
cid <- conv %. "qualified_id" & objId
bindResponse (addBot alice providerId serviceId cid) $ \res ->
res.status `shouldMatchInt` 201
where
conf :: String -> Bool -> ServiceOverrides
conf db runMigration =
def
{ galleyCfg = setField "postgresMigration.service" db,
backgroundWorkerCfg = setField "migrateService" runMigration
}

counterName :: String
counterName = "^wire_service_migration_finished"
6 changes: 6 additions & 0 deletions libs/wire-api/src/Wire/API/PostgresMarshall.hs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,9 @@ instance PostgresMarshall ByteString HttpsUrl where

instance PostgresMarshall ByteString Token where
postgresMarshall = (.unToken)
instance PostgresMarshall ByteString (Fingerprint a) where
postgresMarshall = fingerprintBytes


instance PostgresMarshall Text DnsVerificationToken where
postgresMarshall = Ascii.toText . (.unDnsVerificationToken)
Expand Down Expand Up @@ -1032,6 +1035,9 @@ instance PostgresUnmarshall ByteString HttpsUrl where

instance PostgresUnmarshall ByteString Token where
postgresUnmarshall = Right . Token
instance PostgresUnmarshall ByteString (Fingerprint a) where
postgresUnmarshall = Right . Fingerprint


instance PostgresUnmarshall Text DnsVerificationToken where
postgresUnmarshall = first Text.pack . fmap DnsVerificationToken . Ascii.validate
Expand Down
8 changes: 8 additions & 0 deletions libs/wire-api/src/Wire/API/Provider/Service.hs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ where

import Cassandra.CQL hiding (Set)
import Control.Lens (makeLenses, (?~))
import Data.Bifunctor (first)
import Data.Aeson (FromJSON (..), ToJSON (..))
import Data.Aeson qualified as A
import Data.ByteString.Builder qualified as BB
Expand All @@ -74,6 +75,7 @@ import Imports
import Wire.API.Provider.Service.Tag (ServiceTag (..))
import Wire.API.Routes.MultiVerb
import Wire.API.User.Profile (Asset, Name)
import Wire.API.PostgresMarshall
import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..))

--------------------------------------------------------------------------------
Expand Down Expand Up @@ -284,6 +286,12 @@ instance S.ToSchema ServiceToken where

deriving instance Cql ServiceToken

instance PostgresMarshall ByteString ServiceToken where
postgresMarshall = toByteString'

instance PostgresUnmarshall ByteString ServiceToken where
postgresUnmarshall = first Text.pack . runParser parser

--------------------------------------------------------------------------------
-- ServiceProfile

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import Crypto.KDF.Argon2 qualified as Argon2
import Data.Aeson as A
import Data.ByteString.Char8 qualified as BS8
import Data.Code qualified as Code
import Data.Misc (PlainTextPassword8, fromPlainTextPassword)
import Data.Misc (Fingerprint, PlainTextPassword8, Rsa, fromPlainTextPassword)
import Data.Text.Encoding (encodeUtf8)
import Imports
import Test.Tasty qualified as T
Expand All @@ -33,6 +33,7 @@ import Wire.API.Password as Password
import Wire.API.Password.Argon2id (Argon2HashedPassword (..), encodeArgon2HashedPassword)
import Wire.API.Password.Scrypt (encodeScryptPassword)
import Wire.API.PostgresMarshall
import Wire.API.Provider.Service (ServiceToken)
import Wire.API.Team.Feature
import Wire.Arbitrary qualified as Arbitrary ()

Expand All @@ -44,7 +45,9 @@ tests =
testRoundTrip @ByteString @Password.Password,
testRoundTrip @Int32 @FeatureStatus,
testRoundTrip @Int32 @LockStatus,
testRoundTrip @A.Value @DbConfig
testRoundTrip @A.Value @DbConfig,
testRoundTrip @ByteString @(Fingerprint Rsa),
testRoundTrip @ByteString @ServiceToken
]

testRoundTrip ::
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS service (
provider uuid NOT NULL,
id uuid NOT NULL,
base_url bytea NOT NULL,
auth_token bytea NOT NULL,
fingerprints bytea[] NOT NULL DEFAULT '{}',
enabled boolean NOT NULL,
PRIMARY KEY (provider, id)
);
4 changes: 3 additions & 1 deletion libs/wire-subsystems/src/Wire/PostgresMigrationOpts.hs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ data PostgresMigrationOpts = PostgresMigrationOpts
conversationCodes :: StorageLocation,
teamFeatures :: StorageLocation,
domainRegistration :: StorageLocation,
user :: StorageLocation
user :: StorageLocation,
service :: StorageLocation
}
deriving (Show)

Expand All @@ -68,3 +69,4 @@ instance FromJSON PostgresMigrationOpts where
<*> o .: "teamFeatures"
<*> o .: "domainRegistration"
<*> o .: "user"
<*> o .: "service"
27 changes: 27 additions & 0 deletions libs/wire-subsystems/src/Wire/ServiceStore/Cassandra/Queries.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Wire.ServiceStore.Cassandra.Queries where

import Cassandra as C hiding (Value)
import Data.Id
import Data.Misc (Fingerprint, HttpsUrl, Rsa)
import Imports
import Wire.API.Provider.Service (ServiceToken)

selectAllServices :: PrepQuery R () (ProviderId, ServiceId, HttpsUrl, ServiceToken, C.Set (Fingerprint Rsa), Bool)
selectAllServices = "SELECT provider, id, base_url, auth_token, fingerprints, enabled FROM service"
49 changes: 49 additions & 0 deletions libs/wire-subsystems/src/Wire/ServiceStore/DualWrite.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Wire.ServiceStore.DualWrite
( interpretServiceStoreToCassandraAndPostgres,
)
where

import Cassandra (ClientState)
import Imports
import Polysemy
import Polysemy.TinyLog (TinyLog)
import Wire.Postgres (PGConstraints)
import Wire.ServiceStore (ServiceStore (..))
import Wire.ServiceStore qualified as ServiceStore
import Wire.ServiceStore.Cassandra qualified as Cassandra
import Wire.ServiceStore.Postgres qualified as Postgres

-- | Cassandra is the source of truth during migration; writes are mirrored to Postgres.
interpretServiceStoreToCassandraAndPostgres ::
( Member TinyLog r,
PGConstraints r
) =>
ClientState ->
Sem (ServiceStore ': r) a ->
Sem r a
interpretServiceStoreToCassandraAndPostgres cassClient = interpret $ \case
GetService sr ->
Cassandra.interpretServiceStoreToCassandra cassClient $ ServiceStore.getService sr
CreateService s -> do
Cassandra.interpretServiceStoreToCassandra cassClient $ ServiceStore.createService s
Postgres.interpretServiceStoreToPostgres $ ServiceStore.createService s
DeleteService sr -> do
Cassandra.interpretServiceStoreToCassandra cassClient $ ServiceStore.deleteService sr
Postgres.interpretServiceStoreToPostgres $ ServiceStore.deleteService sr
Loading
Loading