🛠️ Cozy migration CLI tools and client (#876)

* feat: migration cli tools and client

* feat: creating folder tree / ref cli cmds / Dockerfile

* feat: refresh app auth token for requests

* feat: skip already migrated files

* ref: auth token refresh and download request

* ref: using cloudery to create user instace and generate token

* ref: using config for cloudery

* fix: docker for backend

* fix: docker for backend

* fix: docker for backend

* fix: docker for backend

* fix: lint/Dockerfile

* fix: docker for backend

* fix: docker for backend

* ref: delete client

* fix: user default company

* feat: stream file upload and progress

* feat: use default company

* ref: file upload

* ref: removed migration endpoints for external client
This commit is contained in:
Montassar Ghanmy
2025-05-16 13:41:33 +01:00
committed by GitHub
parent 70f3298f01
commit a272d66dcd
13 changed files with 5835 additions and 738 deletions
@@ -162,5 +162,12 @@
"timeout": "AV_TIMEOUT", "timeout": "AV_TIMEOUT",
"maxFileSize": "AV_MAX_FILE_SIZE", "maxFileSize": "AV_MAX_FILE_SIZE",
"deleteInfectedFiles": "AV_DELETE_INFECTED_FILES" "deleteInfectedFiles": "AV_DELETE_INFECTED_FILES"
},
"migration": {
"cozyDomain": "COZY_DOMAIN",
"cozyManagerUrl": "COZY_MANAGER_URL",
"cozyManagerToken": "COZY_MANAGER_TOKEN",
"pollInterval": "MIGRATUION_POLL_INTERVAL",
"maxRetries": "MIGRATION_MAX_RETRIES"
} }
} }
+7
View File
@@ -149,6 +149,13 @@
"maxFileSize": 4294967295, "maxFileSize": 4294967295,
"deleteInfectedFiles": false "deleteInfectedFiles": false
}, },
"migration": {
"cozyDomain": "stg.lin-saas.com",
"cozyManagerUrl": "https://manager-int.cozycloud.cc/api/public",
"cozyManagerToken": "COZY_TOKEN_HERE",
"pollInterval": 5000,
"maxRetries": 3
},
"applications": { "applications": {
"grid": [ "grid": [
{ {
+5353 -733
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -136,6 +136,11 @@
"class-transformer": "^0.3.1", "class-transformer": "^0.3.1",
"cli-table": "^0.3.6", "cli-table": "^0.3.6",
"config": "^3.3.2", "config": "^3.3.2",
"cozy-client": "^57.7.0",
"cozy-device-helper": "^3.8.0",
"cozy-flags": "^4.7.0",
"cozy-intent": "^2.30.0",
"cozy-logger": "^1.17.0",
"deep-object-diff": "^1.1.0", "deep-object-diff": "^1.1.0",
"emoji-name-map": "^1.2.9", "emoji-name-map": "^1.2.9",
"eta": "^2.2.0", "eta": "^2.2.0",
+7
View File
@@ -0,0 +1,7 @@
// src/types/react-override.d.ts
import "react";
declare module "react" {
export type ReactChild = React.ReactNode;
}
@@ -0,0 +1,14 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Migrate data from Twake Drive",
command: "migrate <command>",
builder: yargs =>
yargs.commandDir("migration_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -0,0 +1,173 @@
import runWithPlatform from "../../lib/run-with-platform";
import runWithLoggerLevel from "../../utils/run-with-logger-level";
import globalResolver from "../../../services/global-resolver";
import User from "../../../services/user/entities/user";
import yargs from "yargs";
import { DriveFile, TYPE } from "../../../services/documents/entities/drive-file";
import { getPath } from "../../../services/documents/utils";
import CozyClient from "cozy-client";
import {
uploadFile,
COZY_DOMAIN,
DEFAULT_COMPANY,
getDriveToken,
nodeReadableToWebReadable,
} from "./utils";
const purgeIndexesCommand: yargs.CommandModule<unknown, unknown> = {
command: "migrate-files",
describe: "Migrates files data from Twake Drive",
builder: {
dryRun: {
type: "boolean",
alias: "d",
description: "Simulate the migration and returns stats for the db.",
default: true,
},
emails: {
type: "string",
alias: "e",
description: "Comma-separated list of user emails to migrate files for specific users",
},
},
handler: async argv => {
const dryRun = argv.dryRun as boolean;
console.log("DRY RUN: ", dryRun);
const emailsArg = argv.emails as string | undefined;
const specifiedEmails = emailsArg
? emailsArg.split(",").map(email => email.trim().toLowerCase())
: null;
if (specifiedEmails) {
console.log("Migrating only specified emails:", specifiedEmails.join(", "));
}
await runWithPlatform("Migrate files", async () => {
return await runWithLoggerLevel("fatal", async () => {
const usersRepo = await globalResolver.database.getRepository<User>("user", User);
const documentsRepo = await globalResolver.database.getRepository<DriveFile>(
TYPE,
DriveFile,
);
const allUsers = await (await usersRepo.find({})).getEntities();
let usersToMigrate = allUsers;
if (specifiedEmails) {
usersToMigrate = allUsers.filter(user =>
specifiedEmails.includes(user.email_canonical.toLowerCase()),
);
}
for (const user of usersToMigrate) {
const userCompany = DEFAULT_COMPANY;
const userFiles = await documentsRepo.find({ creator: user.id, is_directory: false });
const userId = user.email_canonical.split("@")[0];
console.log(`User ${user.id} has ${userFiles.getEntities().length} files`);
const userFilesObjects = [];
for (const userFile of userFiles.getEntities()) {
if (userFile.migrated) {
continue;
}
const filePathItems = await getPath(userFile.id, documentsRepo, true, {
company: { id: userCompany },
} as any);
const filePath = filePathItems
.slice(1, -1)
.map(p => p.name)
.join("/");
const fileObject = {
owner: userId,
_id: userFile.id,
is_in_trash: userFile.is_in_trash,
is_directory: userFile.is_directory,
name: userFile.name,
added: userFile.added,
last_modified: userFile.last_modified,
size: userFile.size,
path: filePath !== "My Drive" ? filePath : "",
company_id: userCompany,
};
userFilesObjects.push(fileObject);
if (!dryRun) {
try {
const cozyUrl = `${userId}.${COZY_DOMAIN}`;
const userToken = await getDriveToken(cozyUrl);
const client = new CozyClient({
uri: `https://${cozyUrl}`,
token: userToken.token,
});
let fileDirPath = "io.cozy.files.root-dir";
if (fileObject.path !== "") {
const sanitizedPath = fileObject.path.replace(/^\//, "");
fileDirPath = (
await client.collection("io.cozy.files").createDirectoryByPath(sanitizedPath)
).data.id;
}
// 2. Download file from backend
const archiveOrFile = await globalResolver.services.documents.documents.download(
userFile.id,
userFile.last_version_cache.id,
null, // No archive callback needed
{
company: { id: userCompany },
user: { id: user.id },
} as any,
);
if (!archiveOrFile.file) {
console.error(`File ${userFile.id} was returned as archive. Skipping.`);
continue;
}
let uploadedBytes = 0;
const totalSize = fileObject.size || 0;
const { file: fileStream } = archiveOrFile.file;
const fileReadable = nodeReadableToWebReadable(fileStream, chunkSize => {
uploadedBytes += chunkSize;
const percentage =
totalSize > 0 ? ((uploadedBytes / totalSize) * 100).toFixed(2) : "0";
process.stdout.write(`\rUploading ${fileObject.name}... ${percentage}%`);
});
const resp = await uploadFile(
fileObject.name,
userId,
fileDirPath,
userToken.token,
fileReadable,
);
if (!resp.ok) {
console.error(`❌ ERROR UPLOADING THE FILE: ${fileObject.name}`);
console.error(`❌ ERROR: ${JSON.stringify(resp)} ${resp}`);
continue;
}
// 3. Migrate file
userFile.migrated = true;
userFile.migration_date = Date.now();
await documentsRepo.save(userFile);
console.log(`\n✅ File migrated successfully: ${fileObject.name}`);
} catch (error) {
console.error(`❌ ERROR CREATING THE FILE: ${fileObject.name}`);
console.error(`❌ ERROR: ${JSON.stringify(error)} ${error}`);
}
} else {
console.log(`[DRY-RUN] Would create Cozy instance for user ${user.email_canonical}`);
}
}
}
});
});
},
};
export default purgeIndexesCommand;
@@ -0,0 +1,85 @@
import runWithPlatform from "../../lib/run-with-platform";
import runWithLoggerLevel from "../../utils/run-with-logger-level";
import globalResolver from "../../../services/global-resolver";
import User from "../../../services/user/entities/user";
import yargs from "yargs";
import { createCozyInstance } from "./utils";
const migrateUsersCommand: yargs.CommandModule<unknown, unknown> = {
command: "migrate-users",
describe: "Migrates users data from Twake Drive",
builder: {
dryRun: {
type: "boolean",
alias: "d",
description: "Simulate the migration and return stats for the DB.",
default: true,
},
emails: {
type: "string",
alias: "e",
description: "Comma-separated list of user emails to migrate specific users",
},
},
handler: async argv => {
const dryRun = argv.dryRun as boolean;
const emailsArg = argv.emails as string | undefined;
const specifiedEmails = emailsArg
? emailsArg.split(",").map(email => email.trim().toLowerCase())
: null;
console.log("DRY RUN:", dryRun);
if (specifiedEmails) {
console.log("Migrating only specified emails:", specifiedEmails.join(", "));
}
await runWithPlatform("Migrate users", async () => {
await runWithLoggerLevel("info", async () => {
const usersRepo = await globalResolver.database.getRepository<User>("user", User);
const allUsers = await (await usersRepo.find({})).getEntities();
const migratedUsers = await (await usersRepo.find({ migrated: true })).getEntities();
const pendingUsers = allUsers.filter(
user => !migratedUsers.some(migratedUser => migratedUser.id === user.id),
);
let usersToMigrate = pendingUsers;
if (specifiedEmails) {
usersToMigrate = pendingUsers.filter(user =>
specifiedEmails.includes(user.email_canonical.toLowerCase()),
);
}
if (usersToMigrate.length === 0) {
console.log("✅ No users to migrate.");
return;
}
console.log(`🚀 Users to migrate: ${usersToMigrate.length}`);
for (const user of usersToMigrate) {
const userId = user.email_canonical.split("@")[0];
const userObject = {
_id: user.id,
id: userId,
email: user.email_canonical,
name: `${user.first_name} ${user.last_name}`,
};
if (!dryRun) {
try {
await createCozyInstance(userObject);
} catch (error) {
console.error(`❌ Failed to migrate user ${user.email_canonical}`, error);
// Even if error, continue to next user
}
} else {
console.log(`[DRY-RUN] Would create Cozy instance for user ${user.email_canonical}`);
}
}
});
});
},
};
export default migrateUsersCommand;
@@ -0,0 +1,167 @@
/* eslint-disable prettier/prettier */
import axios from "axios";
import config from "config";
export const DEFAULT_COMPANY = config.get<string>("drive.defaultCompany");
export const COZY_DOMAIN = config.get<string>("migration.cozyDomain");
const COZY_MANAGER_URL = config.get<string>("migration.cozyManagerUrl");
const COZY_MANAGER_TOKEN = config.get<string>("migration.cozyManagerToken");
const POLL_INTERVAL_MS = config.get<number>("migration.pollInterval");
const MAX_RETRIES = config.get<number>("migration.maxRetries");
function buildUploadUrl(baseUrl, params) {
const searchParams = new URLSearchParams(params);
return `${baseUrl}?${searchParams.toString()}`;
}
export function nodeReadableToWebReadable(readable, onProgress?: (chunkSize: number) => void) {
const reader = readable[Symbol.asyncIterator]();
return new ReadableStream({
async pull(controller) {
const { value, done } = await reader.next();
if (done) {
controller.close();
} else {
if (onProgress) onProgress(value.length); // report progress
controller.enqueue(value);
}
},
cancel() {
reader.return?.();
},
});
}
export async function createCozyInstance(user: {
id: string;
email: string;
name: string;
_id: string;
}) {
console.log(`🚀 Creating Cozy instance for ${user.email}...`);
try {
// Step 1: Create the instance
const { data: createInstanceData } = await axios.post(
`${COZY_MANAGER_URL}/instances`,
{
offer: "twake",
slug: user.id,
domain: COZY_DOMAIN,
email: user.email,
public_name: user.name,
locale: "fr",
oidc: user.id,
},
{
headers: {
Authorization: `Bearer ${COZY_MANAGER_TOKEN}`,
"Content-Type": "application/json",
},
},
);
const workflowId = createInstanceData.workflow;
console.log(`✅ Created instance for ${user.email}, workflow ID: ${workflowId}`);
// Step 2: Check workflow status
const checkWorkflowStatus = async () => {
const { data } = await axios.get(`${COZY_MANAGER_URL}/workflows/${workflowId}`, {
headers: {
Authorization: `Bearer ${COZY_MANAGER_TOKEN}`,
"Content-Type": "application/json",
},
});
return data.status;
};
let attempt = 0;
let finished = false;
while (attempt < MAX_RETRIES && !finished) {
attempt++;
console.log(
`🔄 Checking workflow status for ${user.email} (attempt ${attempt}/${MAX_RETRIES})...`,
);
const status = await checkWorkflowStatus();
if (status === "finished") {
console.log(`🎉 Workflow finished for ${user.email}`);
finished = true;
} else {
console.log(
`⏳ Workflow not finished yet for ${user.email}, waiting ${POLL_INTERVAL_MS / 1000}s...`,
);
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
if (!finished) {
console.warn(
`⚠️ Workflow for ${user.email} did not finish after ${MAX_RETRIES} tries. Moving on.`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`❌ Error migrating ${user.email}:`,
error.response.status,
error.response.data,
);
} else {
console.error(`❌ Error migrating ${user.email}:`, error.message);
}
throw error;
}
}
export async function getDriveToken(slugDomain: string): Promise<{ token: string }> {
const url = `${COZY_MANAGER_URL}/instances/${slugDomain}/drive_token`;
try {
const response = await axios.post(url, null, {
headers: {
Authorization: `Bearer ${COZY_MANAGER_TOKEN}`,
"Content-Type": "application/json",
},
});
return response.data; // Should be { token: "..." }
} catch (error: any) {
console.error(
`Failed to get drive token for ${slugDomain}`,
error.response?.data || error.message,
url,
);
throw new Error(`Could not retrieve drive token for ${slugDomain}`);
}
}
export async function uploadFile(
fileName: string,
userId: string,
fileDirPath: string,
userToken: string,
fileReadable: ReadableStream<any>,
) {
const baseUrl = `https://${userId}.${COZY_DOMAIN}/files/${fileDirPath}`;
const params = {
Name: fileName,
Type: "file",
Executable: false,
Encrypted: false,
Size: "",
};
const uploadUrl = buildUploadUrl(baseUrl, params);
const resp = await fetch(uploadUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${userToken}`,
"Content-Type": "application/octet-stream",
},
duplex: "half",
body: fileReadable,
} as RequestInit);
return resp;
}
@@ -109,6 +109,12 @@ export class DriveFile {
@Column("access_info", "encoded_json") @Column("access_info", "encoded_json")
access_info: AccessInformation; access_info: AccessInformation;
@Column("migrated", "tdrive_boolean")
migrated: boolean;
@Column("migration_date", "number")
migration_date: number;
/** /**
* If this field is non-null, then an editing session is in progress (probably in OnlyOffice). * If this field is non-null, then an editing session is in progress (probably in OnlyOffice).
* Use {@see EditingSessionKeyFormat} to generate and interpret it. * Use {@see EditingSessionKeyFormat} to generate and interpret it.
@@ -82,6 +82,12 @@ export default class User {
@Column("deleted", "tdrive_boolean") @Column("deleted", "tdrive_boolean")
deleted: boolean; deleted: boolean;
@Column("migrated", "tdrive_boolean")
migrated: boolean;
@Column("migration_date", "number")
migration_date: number;
/** /**
* If set, a restartable suppression process is currently incomplete. * If set, a restartable suppression process is currently incomplete.
*/ */
+3 -3
View File
@@ -16,7 +16,7 @@ COPY backend/node/package*.json ./
# Test Stage # Test Stage
FROM node-base AS test FROM node-base AS test
RUN npm install RUN npm install --legacy-peer-deps
COPY backend/node/ . COPY backend/node/ .
# Add frontend Stage # Add frontend Stage
@@ -25,7 +25,7 @@ FROM node-base AS installed-libs
COPY backend/node/ . COPY backend/node/ .
#Install dev dependancies for build #Install dev dependancies for build
ENV NODE_ENV=development ENV NODE_ENV=development
RUN npm ci RUN npm ci --legacy-peer-deps
#Build in production mode #Build in production mode
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -40,7 +40,7 @@ FROM installed-libs AS development
ENV NODE_ENV=development ENV NODE_ENV=development
RUN npm install -g pino-pretty && \ RUN npm install -g pino-pretty && \
npm install -g tsc-watch && \ npm install -g tsc-watch && \
npm ci npm ci --legacy-peer-deps
CMD ["npm", "run", "dev:debug"] CMD ["npm", "run", "dev:debug"]
# Production Stage # Production Stage