You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-07 17:03:01 +03:00
Migration mode + docs
This commit is contained in:
committed by
Quentin Gliech
parent
630228d30c
commit
e6e98444f3
@@ -12,6 +12,7 @@
|
|||||||
- [Database setup](./setup/database.md)
|
- [Database setup](./setup/database.md)
|
||||||
- [Homeserver configuration](./setup/homeserver.md)
|
- [Homeserver configuration](./setup/homeserver.md)
|
||||||
- [Configuring a reverse proxy](./setup/reverse-proxy.md)
|
- [Configuring a reverse proxy](./setup/reverse-proxy.md)
|
||||||
|
- [Configuring .well-known](./setup/well-known.md)
|
||||||
- [Running the service](./setup/running.md)
|
- [Running the service](./setup/running.md)
|
||||||
- [Migrating an existing homeserver](./setup/migration.md)
|
- [Migrating an existing homeserver](./setup/migration.md)
|
||||||
# Usage
|
# Usage
|
||||||
|
@@ -12,4 +12,67 @@ Features that are provided to support this include:
|
|||||||
- Ability to import existing upstream IdP subject ID mappings
|
- Ability to import existing upstream IdP subject ID mappings
|
||||||
- Provides a compatibility layer for legacy Matrix authentication
|
- Provides a compatibility layer for legacy Matrix authentication
|
||||||
|
|
||||||
|
If
|
||||||
There will be tools to help with the migration process itself. But these aren't quite ready yet.
|
There will be tools to help with the migration process itself. But these aren't quite ready yet.
|
||||||
|
|
||||||
|
## Preparation for the migration
|
||||||
|
|
||||||
|
The deployment is non-trivial so it is important to read through and understand the steps involved and make a plan before starting.
|
||||||
|
|
||||||
|
### Run the migration advisor
|
||||||
|
|
||||||
|
You can use the advisor mode of the `syn2mas` tool to identify extra configuration steps or issues with the configuration of the homeserver.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
syn2mas --command=advisor --synapseConfigFile=homeserver.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
This will output `WARN` entries for any identified actions and `ERROR` entries in the case of any issues that will prevent the migration from working.
|
||||||
|
|
||||||
|
### Install and configure MAS alongside your existing homeserver
|
||||||
|
|
||||||
|
Follow the instructions in the [installation guide](installation.md) to install MAS alongside your existing homeserver.
|
||||||
|
|
||||||
|
### Map any upstream SSO providers
|
||||||
|
|
||||||
|
If you are using an upstream SSO provider then you will need to provision the upstream provide in MAS manually.
|
||||||
|
|
||||||
|
Each upstream provider will need to be given as an `--upstreamProviderMapping` command line option to the import tool.
|
||||||
|
|
||||||
|
### Do a dry-run of the import to test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
syn2mas --command migrate --synapseConfigFile homeserver.yaml --masConfigFile config.yaml --dryRun
|
||||||
|
```
|
||||||
|
|
||||||
|
If no errors are reported then you can proceed to the next step.
|
||||||
|
|
||||||
|
## Doing the migration
|
||||||
|
|
||||||
|
### Backup your data
|
||||||
|
|
||||||
|
As with any migration, it is important to backup your data before proceeding.
|
||||||
|
|
||||||
|
### Shutdown the homeserver
|
||||||
|
|
||||||
|
This is to ensure that no new sessions are created whilst the migration is in progress.
|
||||||
|
|
||||||
|
### Configure the homeserver
|
||||||
|
|
||||||
|
Follow the instructions in the [homeserver configuration guide](homeserver.md) to configure the homeserver to use MAS.
|
||||||
|
|
||||||
|
### Do the import
|
||||||
|
|
||||||
|
Run `syn2mas` in non-dry-run mode.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
syn2mas --command migrate --synapseConfigFile homeserver.yaml --masConfigFile config.yaml --dryRun false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start up the homeserver
|
||||||
|
|
||||||
|
Start up the homeserver again with the new configuration.
|
||||||
|
|
||||||
|
### Update or serve the .well-known
|
||||||
|
|
||||||
|
The `.well-known/matrix/client` needs to be served as described [here](./well-known.md).
|
||||||
|
23
docs/setup/well-known.md
Normal file
23
docs/setup/well-known.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# .well-known configuration
|
||||||
|
|
||||||
|
A `.well-known/matrix/client` file is required to be served to allow clients to discover the authentication service.
|
||||||
|
|
||||||
|
If no `.well-known/matrix/client` file is served currently then this will need to be enabled.
|
||||||
|
|
||||||
|
If the homeserver is Synapse and serving this file already then the correct values will already be included when the homeserver is [configured to use MAS](./homeserver.md).
|
||||||
|
|
||||||
|
If the .well-known is hosted elsewhere then `org.matrix.msc2965.authentication` entries need to be included similar to the following:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"m.homeserver": {
|
||||||
|
"base_url": "https://matrix.example.com"
|
||||||
|
},
|
||||||
|
"org.matrix.msc2965.authentication": {
|
||||||
|
"issuer": "https://example.com/",
|
||||||
|
"account": "https://auth.example.com/account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For more context on what the correct values are, see [here](./README.md).
|
@@ -119,7 +119,7 @@ export async function advisor(argv?: string[]): Promise<void> {
|
|||||||
if (synapseConfig.password_config?.enabled !== false && synapseConfig.password_config?.localdb_enabled === false) {
|
if (synapseConfig.password_config?.enabled !== false && synapseConfig.password_config?.localdb_enabled === false) {
|
||||||
warn("Synapse has a non-standard password auth enabled which won't work after migration and will need to be manually mapped to an upstream OpenID Provider during migration");
|
warn("Synapse has a non-standard password auth enabled which won't work after migration and will need to be manually mapped to an upstream OpenID Provider during migration");
|
||||||
} else if (synapseConfig.password_config?.enabled !== false) {
|
} else if (synapseConfig.password_config?.enabled !== false) {
|
||||||
warn("Migration of Synapse password auth is not yet supported");
|
warn("Synapse has password auth enabled, but support for password auth in MAS is not feature complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalIdAuthProviders = await synapse.select("auth_provider").count("* as Count").from("user_external_ids").groupBy("auth_provider") as { auth_provider: string; "Count": number }[];
|
const externalIdAuthProviders = await synapse.select("auth_provider").count("* as Count").from("user_external_ids").groupBy("auth_provider") as { auth_provider: string; "Count": number }[];
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { ArgumentConfig, parse } from "ts-command-line-args";
|
import { ArgumentConfig, parse } from "ts-command-line-args";
|
||||||
import log4js from "log4js";
|
import log4js from "log4js";
|
||||||
|
|
||||||
// import { migrate } from "./migrate.mjs";
|
import { migrate } from "./migrate.mjs";
|
||||||
import { advisor } from "./advisor.mjs";
|
import { advisor } from "./advisor.mjs";
|
||||||
|
|
||||||
log4js.configure({
|
log4js.configure({
|
||||||
@@ -28,10 +28,10 @@ const mainArgOptions: ArgumentConfig<MainOptions> = {
|
|||||||
export const mainArgs = parse<MainOptions>(mainArgOptions, { stopAtFirstUnknown: true });
|
export const mainArgs = parse<MainOptions>(mainArgOptions, { stopAtFirstUnknown: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// if (mainArgs.command === "migrate") {
|
if (mainArgs.command === "migrate") {
|
||||||
// await migrate();
|
await migrate();
|
||||||
// process.exit(0);
|
process.exit(0);
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (mainArgs.command === "advisor") {
|
if (mainArgs.command === "advisor") {
|
||||||
await advisor();
|
await advisor();
|
||||||
|
280
tools/syn2mas/src/migrate.mts
Normal file
280
tools/syn2mas/src/migrate.mts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { parse } from "ts-command-line-args";
|
||||||
|
import id128 from "id128";
|
||||||
|
import log4js from "log4js";
|
||||||
|
import yaml from "yaml";
|
||||||
|
|
||||||
|
import { SUser } from "./types/SUser";
|
||||||
|
import { SUserThreePid } from "./types/SUserThreePid";
|
||||||
|
import { MUserPassword } from "./types/MUserPassword";
|
||||||
|
import { MUserEmail } from "./types/MUserEmail";
|
||||||
|
import { SUserExternalId } from "./types/SUserExternalId";
|
||||||
|
import { SAccessToken } from "./types/SAccessToken";
|
||||||
|
import { SRefreshToken } from "./types/SRefreshToken";
|
||||||
|
import { MCompatAccessToken } from "./types/MCompatAccessToken";
|
||||||
|
import { MCompatRefreshToken } from "./types/MCompatRefreshToken";
|
||||||
|
import { MCompatSession } from "./types/MCompatSession";
|
||||||
|
import { MUpstreamOauthLink } from "./types/MUpstreamOauthLink";
|
||||||
|
import { MUpstreamOauthProvider } from "./types/MUpstreamOauthProvider";
|
||||||
|
import { UUID } from "./types";
|
||||||
|
import { SynapseConfig } from "./types/SynapseConfig";
|
||||||
|
import { MASConfig } from "./types/MASConfig";
|
||||||
|
import { connectToSynapseDatabase, connectToMASDatabase } from "./db.mjs";
|
||||||
|
|
||||||
|
const log = log4js.getLogger("migrate");
|
||||||
|
|
||||||
|
interface MigrationOptions {
|
||||||
|
command: string;
|
||||||
|
synapseConfigFile: string;
|
||||||
|
masConfigFile: string;
|
||||||
|
upstreamProviderMapping: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
help?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrate(argv?: string[]): Promise<void> {
|
||||||
|
const args = parse<MigrationOptions>({
|
||||||
|
command: { type: String, description: "Command to run", defaultOption: true, typeLabel: "migrate" },
|
||||||
|
synapseConfigFile: { type: String, description: "Path to synapse homeserver.yaml config file" },
|
||||||
|
masConfigFile: { type: String, description: "Path to MAS config.yaml" },
|
||||||
|
upstreamProviderMapping: { type: String, defaultValue: [], multiple: true, description: "Mapping of upstream provider IDs to MAS provider IDs. Format: <upstream_provider_id>:<mas_provider_id>" },
|
||||||
|
dryRun: { type: Boolean, optional: true, defaultValue: false },
|
||||||
|
help: { type: Boolean, optional: true, alias: "h", description: "Prints this usage guide" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
helpArg: "help",
|
||||||
|
argv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
function warn(message: string): void {
|
||||||
|
warnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fatals = 0;
|
||||||
|
function fatal(message: string): void {
|
||||||
|
log.fatal(message);
|
||||||
|
warnings.forEach((w) => log.warn(w));
|
||||||
|
if (!args.dryRun) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fatals += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUuid<T>(): UUID<T> {
|
||||||
|
return id128.Uuid4.fromRaw(id128.UlidMonotonic.generate().toRaw()).toCanonical();
|
||||||
|
}
|
||||||
|
|
||||||
|
// load synapse config
|
||||||
|
const synapseConfig: SynapseConfig = yaml.parse(await readFile(args.synapseConfigFile, "utf8"));
|
||||||
|
|
||||||
|
// connect to synapse databases
|
||||||
|
const synapse = connectToSynapseDatabase(synapseConfig);
|
||||||
|
|
||||||
|
// load MAS config
|
||||||
|
const masConfig: MASConfig = yaml.parse(await readFile(args.masConfigFile, "utf8"));
|
||||||
|
|
||||||
|
if (!masConfig.database?.uri) {
|
||||||
|
log.fatal("Missing database URI in MAS config");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const mas = connectToMASDatabase(masConfig);
|
||||||
|
|
||||||
|
const upstreamProviders = new Map<string, MUpstreamOauthProvider>();
|
||||||
|
|
||||||
|
for (const mapping of args.upstreamProviderMapping) {
|
||||||
|
const [providerId, masProviderId] = mapping.split(":");
|
||||||
|
if (!id128.Uuid.isRaw(masProviderId) && !id128.Uuid.isCanonical(masProviderId)) {
|
||||||
|
throw new Error(`Upstream provider mapping UUID is not in correct format. It should be a UUID: ${masProviderId}`);
|
||||||
|
}
|
||||||
|
log.info(`Loading existing upstream provider ${masProviderId} from MAS database as ${providerId}`);
|
||||||
|
const existingProvider = await mas("upstream_oauth_providers").select("*").where({ upstream_oauth_provider_id: masProviderId }).first();
|
||||||
|
if (!existingProvider) {
|
||||||
|
throw new Error(`Could not find upstream provider ${masProviderId} in MAS database`);
|
||||||
|
}
|
||||||
|
upstreamProviders.set(providerId, existingProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyAndRedact(input: any): string {
|
||||||
|
const x = JSON.stringify(input);
|
||||||
|
|
||||||
|
return x.replace(/("(password_hash|hashed_password|access_token|token)":")[^"]*"/, "$1redacted\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
type Execution = (() => Promise<void>);
|
||||||
|
|
||||||
|
const existingMasUsers = await mas.count({ count: "*" }).from("users").first();
|
||||||
|
|
||||||
|
if (parseInt(`${existingMasUsers?.count ?? 0}`) > 0) {
|
||||||
|
fatal(`Found ${existingMasUsers?.count} existing users in MAS. Refusing to continue. Please clean MAS and try again.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const synapseUsers = await synapse.select("*").from<SUser>("users");
|
||||||
|
log.info(`Found ${synapseUsers.length} users in Synapse`);
|
||||||
|
for (const user of synapseUsers) {
|
||||||
|
const localpart = user.name.split(":")[0].substring(1);
|
||||||
|
log.info(`Processing user ${user.name} as ${localpart}`);
|
||||||
|
|
||||||
|
let warningsForUser = 0;
|
||||||
|
const executions: Execution[] = [];
|
||||||
|
|
||||||
|
if (user.deactivated === 1) {
|
||||||
|
fatal(`Migration of deactivated users is not supported: ${user.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_guest === 1) {
|
||||||
|
fatal(`Migration of guest users is not supported: ${user.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// users => users
|
||||||
|
const masUser = {
|
||||||
|
user_id: makeUuid(),
|
||||||
|
username: localpart,
|
||||||
|
created_at: new Date(parseInt(`${user.creation_ts}`) * 1000),
|
||||||
|
};
|
||||||
|
executions.push(() => mas.insert(masUser!).into("users"));
|
||||||
|
log.debug(`${stringifyAndRedact(user)} => ${stringifyAndRedact(masUser)}`);
|
||||||
|
// users.password_hash => user_passwords
|
||||||
|
if (user.password_hash) {
|
||||||
|
const masUserPassword: MUserPassword = {
|
||||||
|
user_password_id: makeUuid(),
|
||||||
|
user_id: masUser.user_id,
|
||||||
|
hashed_password: user.password_hash,
|
||||||
|
created_at: masUser.created_at, // TODO: should we use now() instead of created_at?
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug(`Password ${user.password_hash.slice(-4)} => ${stringifyAndRedact(masUserPassword)}`);
|
||||||
|
executions.push(() => mas.insert(masUserPassword).into("user_passwords"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_threepids => user_emails
|
||||||
|
let primaryEmail: MUserEmail | undefined;
|
||||||
|
const synapseThreePids = await synapse.select("*").from<SUserThreePid>("user_threepids").where({ user_id: user.name });
|
||||||
|
for (const threePid of synapseThreePids) {
|
||||||
|
if (threePid.medium !== "email") {
|
||||||
|
warningsForUser += 1;
|
||||||
|
warn(`Skipping non-email 3pid ${threePid.medium} for user ${user.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const masUserEmail: MUserEmail = {
|
||||||
|
user_email_id: makeUuid(),
|
||||||
|
user_id: masUser.user_id,
|
||||||
|
email: threePid.address.toLowerCase(),
|
||||||
|
created_at: new Date(parseInt(`${threePid.added_at}`) * 1000),
|
||||||
|
confirmed_at: threePid.validated_at ? new Date(parseInt(`${threePid.validated_at}`) * 1000) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug(`${stringifyAndRedact(threePid)} => ${stringifyAndRedact(masUserEmail)}`);
|
||||||
|
if (!primaryEmail && threePid.validated_at) {
|
||||||
|
primaryEmail = masUserEmail;
|
||||||
|
}
|
||||||
|
executions.push(() => mas.insert(masUserEmail).into("user_emails"));
|
||||||
|
}
|
||||||
|
if (primaryEmail) {
|
||||||
|
log.debug(`Setting primary email for existing user ${masUser.username} to ${primaryEmail.email} as update`);
|
||||||
|
executions.push(() => mas("users").where({ user_id: masUser!.user_id }).update({ primary_user_email_id: primaryEmail!.user_email_id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_external_ids => upstream_oauth_links
|
||||||
|
const synapseExternalIds = await synapse.select("*").from<SUserExternalId>("user_external_ids").where({ user_id: user.name });
|
||||||
|
for (const externalId of synapseExternalIds) {
|
||||||
|
try {
|
||||||
|
if (!upstreamProviders.has(externalId.auth_provider)) {
|
||||||
|
throw new Error(`Unknown upstream provider ${externalId.auth_provider}`);
|
||||||
|
}
|
||||||
|
const provider = upstreamProviders.get(externalId.auth_provider)!;
|
||||||
|
const masUpstreamOauthLink: MUpstreamOauthLink = {
|
||||||
|
upstream_oauth_link_id: makeUuid(),
|
||||||
|
user_id: masUser.user_id,
|
||||||
|
upstream_oauth_provider_id: provider.upstream_oauth_provider_id,
|
||||||
|
subject: externalId.external_id,
|
||||||
|
created_at: masUser.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug(`${stringifyAndRedact(synapseExternalIds)} => ${stringifyAndRedact(masUpstreamOauthLink)}`);
|
||||||
|
|
||||||
|
executions.push(() => mas.insert(masUpstreamOauthLink).into("upstream_oauth_links"));
|
||||||
|
} catch (e) {
|
||||||
|
fatal(`Failed to import external id ${externalId.external_id} with ${externalId.auth_provider} for user ${user.name}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// access_tokens,refresh_tokens => compat_sessions,compat_access_tokens
|
||||||
|
const synapseAccessTokens = await synapse.select("*").from<SAccessToken>("access_tokens").where({ user_id: user.name });
|
||||||
|
for (const accessToken of synapseAccessTokens) {
|
||||||
|
if (!accessToken.device_id) {
|
||||||
|
warningsForUser += 1;
|
||||||
|
warn(`Skipping access token ${accessToken.token} for user ${user.name} with no device_id`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masCompatSession: MCompatSession = {
|
||||||
|
compat_session_id: makeUuid(),
|
||||||
|
user_id: masUser.user_id,
|
||||||
|
device_id: accessToken.device_id,
|
||||||
|
created_at: accessToken.last_validated ? new Date(parseInt(`${accessToken.last_validated}`)) : masUser.created_at,
|
||||||
|
is_synapse_admin: user.admin === 1,
|
||||||
|
};
|
||||||
|
log.debug(`${stringifyAndRedact(accessToken)} => ${stringifyAndRedact(masCompatSession)}`);
|
||||||
|
executions.push(() => mas.insert(masCompatSession).into("compat_sessions"));
|
||||||
|
|
||||||
|
const masCompatAccessToken: MCompatAccessToken = {
|
||||||
|
compat_access_token_id: makeUuid(),
|
||||||
|
compat_session_id: masCompatSession.compat_session_id,
|
||||||
|
access_token: accessToken.token,
|
||||||
|
created_at: masCompatSession.created_at,
|
||||||
|
};
|
||||||
|
log.debug(`Access token ${accessToken.id} => ${stringifyAndRedact(masCompatAccessToken)}`);
|
||||||
|
executions.push(() => mas.insert(masCompatAccessToken).into("compat_access_tokens"));
|
||||||
|
|
||||||
|
if (accessToken.refresh_token_id) {
|
||||||
|
const synapseRefreshToken = await synapse.select("*").from<SRefreshToken>("refresh_tokens").where({ id: accessToken.refresh_token_id }).first();
|
||||||
|
if (synapseRefreshToken) {
|
||||||
|
const masCompatRefreshToken: MCompatRefreshToken = {
|
||||||
|
compat_refresh_token_id: makeUuid(),
|
||||||
|
compat_session_id: masCompatSession.compat_session_id,
|
||||||
|
compat_access_token_id: masCompatAccessToken.compat_access_token_id,
|
||||||
|
refresh_token: synapseRefreshToken.token,
|
||||||
|
created_at: masCompatSession.created_at,
|
||||||
|
};
|
||||||
|
log.debug(`Refresh token ${synapseRefreshToken.id} => ${stringifyAndRedact(masCompatRefreshToken)}`);
|
||||||
|
executions.push(() => mas.insert(masCompatRefreshToken).into("compat_refresh_tokens"));
|
||||||
|
} else {
|
||||||
|
warningsForUser += 1;
|
||||||
|
warn(`Unable to locate refresh token ${accessToken.refresh_token_id} for user ${user.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warningsForUser > 0) {
|
||||||
|
if (!args.dryRun) {
|
||||||
|
fatal(`User ${user.name} had ${warningsForUser} warnings`);
|
||||||
|
} else {
|
||||||
|
log.warn(`User ${user.name} had ${warningsForUser} warnings`);
|
||||||
|
}
|
||||||
|
} else if (!args.dryRun) {
|
||||||
|
log.info(`Running ${executions.length} updates for user ${user.name}`);
|
||||||
|
const tx = await mas.transaction();
|
||||||
|
try {
|
||||||
|
for (const execution of executions) {
|
||||||
|
await execution();
|
||||||
|
}
|
||||||
|
await tx.commit();
|
||||||
|
log.info(`Migrated user ${user.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await tx.rollback();
|
||||||
|
} catch (e2) {
|
||||||
|
log.error(`Failed to rollback transaction: ${e2}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info(`Completed migration ${args.dryRun ? "dry-run " : ""}of ${synapseUsers.length} users with ${fatals} fatals and ${warnings.length} warnings:`);
|
||||||
|
warnings.forEach((w) => log.warn(w));
|
||||||
|
if (fatals > 0) {
|
||||||
|
throw new Error(`Migration failed with ${fatals} fatals`);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user