Replace admin app rollout buttons with upgrade-application CLI command (#23212)
## What Removes the two rollout buttons from the admin application detail page and replaces the upgrade flow with a CLI command that can be run directly from a server or worker pod. Also restructures application stop into its own module with a kill switch CLI command, and surfaces stopped apps in workspace settings. ### Removed - "Install on all workspaces" button (General tab, `SettingsAdminApplicationRegistrationGeneralToggles`), its confirmation modal and tooltip - "Upgrade existing installations" button (`SettingsApplicationRegistrationGeneralStats`), its confirmation modal and batch size input - `backfillApplicationInstallation` and `upgradeRegistrationApplications` admin GraphQL mutations and their frontend documents / generated types - `BackfillApplicationInstallationJob` (its only trigger was the removed mutation); `UpgradeApplicationsJob` is kept since the auto-upgrade flow still enqueues it Per review, the "install on all workspaces" flow is dropped without a CLI replacement for now; a dedicated command will be added when needed. ### application:upgrade command Located in `application-upgrade/commands`, registered in `ApplicationUpgradeModule`: ``` yarn command:prod application:upgrade \ --application-registration-universal-identifier <universalIdentifier> \ [--batch-size 5] \ [--workspace-id <id> --workspace-id <id2>] \ [--workspace-count-limit 10] \ [--dry-run] [--yes] ``` - `--workspace-id` (repeatable) restricts the upgrade to specific workspaces; `--workspace-count-limit` caps how many installations are upgraded (max 50, for canary rollouts) - `--batch-size` and `--workspace-count-limit` are validated as positive integers, max 50 - `--dry-run` reports how many (and which) workspaces would be upgraded, without upgrading - Without `--dry-run`, a confirmation prompt shows the app, target version and impacted workspaces; the run then executes exactly the confirmed set; `--yes` skips the prompt for non-interactive usage The upgrade plan is computed by a new `ApplicationUpgradeService.findApplicationsToUpgrade`, and batches run through a new `upgradeApplications` method — both reused by `upgradeAllApplications`, so the auto-upgrade job path is unchanged. ### Application kill switch (per review) Global mechanism only — a per-workspace stop had no demonstrated operational need and added a Redis key format, execution branching, CLI options and tests; an isolated workspace issue can be handled directly in the DB or Redis with the same effort. - `ApplicationStopService` moved to a dedicated `application-stop/` folder with its own `ApplicationStopModule` (imported and re-exported by `ApplicationModule`) - `stop` / `remove` methods that enable or clear the Redis-backed global kill switch; the logic function executor checks it before executing - `application:kill-switch` command with a positional action, confirmation prompt (shows the installation count) and `--yes` bypass: ``` # Enable the kill switch (stop is the default action) yarn command:prod application:kill-switch stop -u <universalIdentifier> [-y] yarn command:prod application:kill-switch -u <universalIdentifier> # Remove the kill switch yarn command:prod application:kill-switch remove -u <universalIdentifier> [-y] ``` ### Stopped apps surfaced in workspace settings (per review) - Dedicated `isApplicationStopped(applicationUniversalIdentifier)` query backed by the kill switch, fetched with `network-only` policy solely by the application detail page — listing applications triggers no extra Redis reads - Application detail page shows a danger banner when the app is stopped: "We are currently encountering issues with this app, its behavior may be degraded while we work on a fix." ## Test - `npx nx typecheck twenty-server` / `npx nx typecheck twenty-front` pass - `npx nx lint:diff-with-main` passes for both packages - `application-stop.service.spec.ts` covers stop, remove, caching and fail-open behavior - Verified end to end locally: ran the kill switch command on a seeded workspace and confirmed the banner renders on the app detail page (screenshot shared separately)
This commit is contained in:
@@ -59,14 +59,6 @@ import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/applica
|
||||
import { FindApplicationRegistrationInstalledWorkspacesInput } from 'src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input';
|
||||
import { PaginatedApplicationRegistrationsDTO } from 'src/engine/core-modules/application/application-registration/dtos/paginated-application-registrations.dto';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import {
|
||||
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
|
||||
type BackfillApplicationInstallationJobData,
|
||||
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
|
||||
import {
|
||||
UPGRADE_APPLICATIONS_JOB_NAME,
|
||||
type UpgradeApplicationsJobData,
|
||||
} from 'src/engine/core-modules/application/jobs/upgrade-applications.job-constants';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AdminAiModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
@@ -153,8 +145,6 @@ export class AdminPanelResolver {
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly cronQueueService: MessageQueueService,
|
||||
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||
private readonly workspaceQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
@UseGuards(AdminPanelOrImpersonateGuard)
|
||||
@@ -535,59 +525,6 @@ export class AdminPanelResolver {
|
||||
return this.applicationRegistrationService.updateGlobal(input);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async backfillApplicationInstallation(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
): Promise<boolean> {
|
||||
const registration =
|
||||
await this.applicationRegistrationService.findOneByIdGlobal(
|
||||
applicationRegistrationId,
|
||||
);
|
||||
|
||||
if (!registration.isPreInstalled) {
|
||||
throw new UserInputError(
|
||||
'Only pre-installed apps can be backfilled. Enable pre-install first.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceQueueService.add<BackfillApplicationInstallationJobData>(
|
||||
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
|
||||
{ applicationRegistrationId },
|
||||
{
|
||||
id: `${BACKFILL_APPLICATION_INSTALLATION_JOB_NAME}-${applicationRegistrationId}`,
|
||||
}, // Avoids triggering multiple pending jobs for the same app
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async upgradeRegistrationApplications(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
@Args('batchSize', { type: () => Int, nullable: true })
|
||||
batchSize?: number,
|
||||
): Promise<boolean> {
|
||||
await this.applicationRegistrationService.findOneByIdGlobal(
|
||||
applicationRegistrationId,
|
||||
);
|
||||
|
||||
await this.workspaceQueueService.add<UpgradeApplicationsJobData>(
|
||||
UPGRADE_APPLICATIONS_JOB_NAME,
|
||||
{
|
||||
applicationRegistrationId,
|
||||
onlyAutoUpgrade: false,
|
||||
...(isDefined(batchSize) ? { batchSize } : {}),
|
||||
},
|
||||
{
|
||||
id: `${UPGRADE_APPLICATIONS_JOB_NAME}-${applicationRegistrationId}`,
|
||||
}, // Avoids triggering multiple pending jobs for the same app
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => GraphQLJSON)
|
||||
async getAiProviders(): Promise<Record<string, unknown>> {
|
||||
|
||||
+18
-1
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
APPLICATION_KILL_SWITCH_LOCAL_CACHE_TTL_MS,
|
||||
ApplicationStopService,
|
||||
} from 'src/engine/core-modules/application/application-stop.service';
|
||||
} from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
|
||||
const APPLICATION_UNIVERSAL_IDENTIFIER = 'application-universal-identifier-1';
|
||||
@@ -14,6 +14,8 @@ describe('ApplicationStopService', () => {
|
||||
|
||||
const cacheStorageService = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -36,6 +38,21 @@ describe('ApplicationStopService', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sets the kill switch when stopping an application', async () => {
|
||||
await applicationStopService.stop(APPLICATION_UNIVERSAL_IDENTIFIER);
|
||||
|
||||
expect(cacheStorageService.set).toHaveBeenCalledWith(
|
||||
KILL_SWITCH_KEY,
|
||||
'stopped',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the kill switch when removing it', async () => {
|
||||
await applicationStopService.remove(APPLICATION_UNIVERSAL_IDENTIFIER);
|
||||
|
||||
expect(cacheStorageService.del).toHaveBeenCalledWith(KILL_SWITCH_KEY);
|
||||
});
|
||||
|
||||
it('returns true when the kill switch exists', async () => {
|
||||
cacheStorageService.get.mockResolvedValue('stopped');
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { ApplicationKillSwitchCommand } from 'src/engine/core-modules/application/application-stop/commands/application-kill-switch.command';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationEntity,
|
||||
]),
|
||||
],
|
||||
providers: [ApplicationStopService, ApplicationKillSwitchCommand],
|
||||
exports: [ApplicationStopService],
|
||||
})
|
||||
export class ApplicationStopModule {}
|
||||
+13
@@ -23,6 +23,19 @@ export class ApplicationStopService {
|
||||
private readonly cacheStorageService: CacheStorageService,
|
||||
) {}
|
||||
|
||||
async stop(applicationUniversalIdentifier: string): Promise<void> {
|
||||
await this.cacheStorageService.set(
|
||||
this.getKillSwitchKey(applicationUniversalIdentifier),
|
||||
'stopped',
|
||||
);
|
||||
}
|
||||
|
||||
async remove(applicationUniversalIdentifier: string): Promise<void> {
|
||||
await this.cacheStorageService.del(
|
||||
this.getKillSwitchKey(applicationUniversalIdentifier),
|
||||
);
|
||||
}
|
||||
|
||||
async isApplicationStopped(
|
||||
applicationUniversalIdentifier: string,
|
||||
): Promise<boolean> {
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CommandLogger } from 'src/database/commands/logger';
|
||||
import { askCommandConfirmation } from 'src/database/commands/utils/ask-command-confirmation.util';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
const KILL_SWITCH_ACTIONS = ['stop', 'remove'] as const;
|
||||
|
||||
type KillSwitchAction = (typeof KILL_SWITCH_ACTIONS)[number];
|
||||
|
||||
type ApplicationKillSwitchCommandOptions = {
|
||||
applicationUniversalIdentifier: string;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'application:kill-switch',
|
||||
arguments: '[action]',
|
||||
description:
|
||||
'Toggle an application kill switch on every workspace: "stop" (default) halts its logic function executions until "remove" clears the switch.',
|
||||
})
|
||||
export class ApplicationKillSwitchCommand extends CommandRunner {
|
||||
protected logger: CommandLogger;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly applicationStopService: ApplicationStopService,
|
||||
) {
|
||||
super();
|
||||
this.logger = new CommandLogger({
|
||||
verbose: false,
|
||||
constructorName: this.constructor.name,
|
||||
});
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags:
|
||||
'-u, --application-universal-identifier <application_universal_identifier>',
|
||||
description: 'Application universal identifier',
|
||||
required: true,
|
||||
})
|
||||
parseApplicationUniversalIdentifier(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-y, --yes',
|
||||
description: 'Skip the confirmation prompt (for non-interactive usage)',
|
||||
required: false,
|
||||
})
|
||||
parseYes(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override async run(
|
||||
passedParams: string[],
|
||||
options: ApplicationKillSwitchCommandOptions,
|
||||
): Promise<void> {
|
||||
const action = (passedParams[0] ?? 'stop') as KillSwitchAction;
|
||||
|
||||
if (!KILL_SWITCH_ACTIONS.includes(action)) {
|
||||
throw new Error(
|
||||
`Invalid action "${passedParams[0]}". Expected one of: ${KILL_SWITCH_ACTIONS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: options.applicationUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(registration)) {
|
||||
throw new Error(
|
||||
`Application registration with universal identifier ${options.applicationUniversalIdentifier} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(options.yes ?? false)) {
|
||||
const isConfirmed = await this.askForConfirmation(
|
||||
action,
|
||||
options.applicationUniversalIdentifier,
|
||||
registration.id,
|
||||
);
|
||||
|
||||
if (!isConfirmed) {
|
||||
this.logger.log('Aborted, kill switch left unchanged');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
await this.applicationStopService.stop(
|
||||
options.applicationUniversalIdentifier,
|
||||
);
|
||||
} else {
|
||||
await this.applicationStopService.remove(
|
||||
options.applicationUniversalIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Kill switch ${action === 'stop' ? 'enabled' : 'removed'} for "${registration.name}" (${options.applicationUniversalIdentifier}). Workers pick it up within a minute.`,
|
||||
);
|
||||
|
||||
this.logger.log(chalk.blue('Command completed!'));
|
||||
}
|
||||
|
||||
private async askForConfirmation(
|
||||
action: KillSwitchAction,
|
||||
applicationUniversalIdentifier: string,
|
||||
applicationRegistrationId: string,
|
||||
): Promise<boolean> {
|
||||
const installationCount = await this.applicationRepository.count({
|
||||
where: { applicationRegistrationId },
|
||||
});
|
||||
|
||||
const actionLabel =
|
||||
action === 'stop' ? 'stopping' : 'removing the kill switch of';
|
||||
|
||||
return askCommandConfirmation(
|
||||
`Confirm ${actionLabel} application ${applicationUniversalIdentifier} on ${installationCount} workspace(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -7,6 +7,7 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationUpgradeResolver } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.resolver';
|
||||
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
|
||||
import { UpgradeApplicationCommand } from 'src/engine/core-modules/application/application-upgrade/commands/upgrade-application.command';
|
||||
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job';
|
||||
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
@@ -30,6 +31,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
ApplicationUpgradeResolver,
|
||||
ApplicationVersionCheckCronJob,
|
||||
ApplicationVersionCheckCronCommand,
|
||||
UpgradeApplicationCommand,
|
||||
],
|
||||
exports: [ApplicationUpgradeService, ApplicationVersionCheckCronCommand],
|
||||
})
|
||||
|
||||
+77
-10
@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import axios from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
@@ -111,15 +111,21 @@ export class ApplicationUpgradeService {
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeAllApplications({
|
||||
async findApplicationsToUpgrade({
|
||||
applicationRegistrationId,
|
||||
onlyAutoUpgrade = false,
|
||||
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
|
||||
workspaceIds,
|
||||
workspaceCountLimit,
|
||||
}: {
|
||||
applicationRegistrationId: string;
|
||||
onlyAutoUpgrade?: boolean;
|
||||
batchSize?: number;
|
||||
}): Promise<void> {
|
||||
workspaceIds?: string[];
|
||||
workspaceCountLimit?: number;
|
||||
}): Promise<{
|
||||
appRegistration: ApplicationRegistrationEntity;
|
||||
targetVersion: string | null;
|
||||
applicationsToUpgrade: ApplicationEntity[];
|
||||
}> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: applicationRegistrationId },
|
||||
});
|
||||
@@ -127,28 +133,56 @@ export class ApplicationUpgradeService {
|
||||
const targetVersion = appRegistration.latestAvailableVersion;
|
||||
|
||||
if (!isDefined(targetVersion)) {
|
||||
return;
|
||||
return {
|
||||
appRegistration,
|
||||
targetVersion: null,
|
||||
applicationsToUpgrade: [],
|
||||
};
|
||||
}
|
||||
|
||||
const applications = await this.applicationRepository.find({
|
||||
where: {
|
||||
applicationRegistrationId,
|
||||
...(onlyAutoUpgrade ? { autoUpgrade: true } : {}),
|
||||
...(isNonEmptyArray(workspaceIds)
|
||||
? { workspaceId: In(workspaceIds) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const applicationsToUpgrade = applications.filter(
|
||||
let applicationsToUpgrade = applications.filter(
|
||||
(application) => application.version !== targetVersion,
|
||||
);
|
||||
|
||||
if (isDefined(workspaceCountLimit)) {
|
||||
applicationsToUpgrade = applicationsToUpgrade.slice(
|
||||
0,
|
||||
workspaceCountLimit,
|
||||
);
|
||||
}
|
||||
|
||||
return { appRegistration, targetVersion, applicationsToUpgrade };
|
||||
}
|
||||
|
||||
async upgradeApplications({
|
||||
appRegistration,
|
||||
targetVersion,
|
||||
applications,
|
||||
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
|
||||
}: {
|
||||
appRegistration: ApplicationRegistrationEntity;
|
||||
targetVersion: string;
|
||||
applications: ApplicationEntity[];
|
||||
batchSize?: number;
|
||||
}): Promise<void> {
|
||||
const sanitizedBatchSize = Math.max(1, Math.floor(batchSize));
|
||||
|
||||
for (
|
||||
let batchStart = 0;
|
||||
batchStart < applicationsToUpgrade.length;
|
||||
batchStart < applications.length;
|
||||
batchStart += sanitizedBatchSize
|
||||
) {
|
||||
const batch = applicationsToUpgrade.slice(
|
||||
const batch = applications.slice(
|
||||
batchStart,
|
||||
batchStart + sanitizedBatchSize,
|
||||
);
|
||||
@@ -172,6 +206,39 @@ export class ApplicationUpgradeService {
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeAllApplications({
|
||||
applicationRegistrationId,
|
||||
onlyAutoUpgrade = false,
|
||||
batchSize = UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE,
|
||||
workspaceIds,
|
||||
workspaceCountLimit,
|
||||
}: {
|
||||
applicationRegistrationId: string;
|
||||
onlyAutoUpgrade?: boolean;
|
||||
batchSize?: number;
|
||||
workspaceIds?: string[];
|
||||
workspaceCountLimit?: number;
|
||||
}): Promise<void> {
|
||||
const { appRegistration, targetVersion, applicationsToUpgrade } =
|
||||
await this.findApplicationsToUpgrade({
|
||||
applicationRegistrationId,
|
||||
onlyAutoUpgrade,
|
||||
workspaceIds,
|
||||
workspaceCountLimit,
|
||||
});
|
||||
|
||||
if (!isDefined(targetVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.upgradeApplications({
|
||||
appRegistration,
|
||||
targetVersion,
|
||||
applications: applicationsToUpgrade,
|
||||
batchSize,
|
||||
});
|
||||
}
|
||||
|
||||
async upgradeApplication(params: {
|
||||
appRegistrationId: string;
|
||||
targetVersion: string;
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CommandLogger } from 'src/database/commands/logger';
|
||||
import { askCommandConfirmation } from 'src/database/commands/utils/ask-command-confirmation.util';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
|
||||
|
||||
type UpgradeApplicationCommandOptions = {
|
||||
applicationRegistrationUniversalIdentifier: string;
|
||||
batchSize?: number;
|
||||
workspaceId?: Set<string>;
|
||||
workspaceCountLimit?: number;
|
||||
dryRun?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
const MAX_BATCH_SIZE = 50;
|
||||
const MAX_WORKSPACE_COUNT_LIMIT = 50;
|
||||
|
||||
const parseBoundedPositiveInteger = (
|
||||
value: string,
|
||||
optionName: string,
|
||||
maximum: number,
|
||||
): number => {
|
||||
const parsedValue = Number(value);
|
||||
|
||||
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||
throw new Error(
|
||||
`Invalid ${optionName} "${value}". Expected a positive integer`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedValue > maximum) {
|
||||
throw new Error(`Invalid ${optionName} "${value}". Maximum is ${maximum}`);
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'application:upgrade',
|
||||
description:
|
||||
'Upgrade an application to its latest available version on every workspace that already has it installed',
|
||||
})
|
||||
export class UpgradeApplicationCommand extends CommandRunner {
|
||||
protected logger: CommandLogger;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly applicationUpgradeService: ApplicationUpgradeService,
|
||||
) {
|
||||
super();
|
||||
this.logger = new CommandLogger({
|
||||
verbose: false,
|
||||
constructorName: this.constructor.name,
|
||||
});
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags:
|
||||
'-u, --application-registration-universal-identifier <application_registration_universal_identifier>',
|
||||
description: 'Application registration universal identifier',
|
||||
required: true,
|
||||
})
|
||||
parseApplicationRegistrationUniversalIdentifier(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-b, --batch-size <batch_size>',
|
||||
description: `Number of workspaces upgraded in parallel (defaults to 5, max ${MAX_BATCH_SIZE})`,
|
||||
required: false,
|
||||
})
|
||||
parseBatchSize(value: string): number {
|
||||
return parseBoundedPositiveInteger(value, 'batch size', MAX_BATCH_SIZE);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id <workspace_id>',
|
||||
description:
|
||||
'Only upgrade the given workspace id. Can be repeated to target several workspaces. Upgrades all workspaces if not provided.',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceId(value: string, previous?: Set<string>): Set<string> {
|
||||
const accumulator = previous ?? new Set<string>();
|
||||
|
||||
accumulator.add(value);
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--workspace-count-limit <count>',
|
||||
description: `Limit the number of workspaces to upgrade (max ${MAX_WORKSPACE_COUNT_LIMIT})`,
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceCountLimit(value: string): number {
|
||||
return parseBoundedPositiveInteger(
|
||||
value,
|
||||
'workspace count limit',
|
||||
MAX_WORKSPACE_COUNT_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run',
|
||||
description: 'List the workspaces that would be upgraded without upgrading',
|
||||
required: false,
|
||||
})
|
||||
parseDryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-y, --yes',
|
||||
description: 'Skip the confirmation prompt (for non-interactive usage)',
|
||||
required: false,
|
||||
})
|
||||
parseYes(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override async run(
|
||||
_passedParams: string[],
|
||||
options: UpgradeApplicationCommandOptions,
|
||||
): Promise<void> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: options.applicationRegistrationUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(registration)) {
|
||||
throw new Error(
|
||||
`Application registration with universal identifier ${options.applicationRegistrationUniversalIdentifier} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceIds = isDefined(options.workspaceId)
|
||||
? Array.from(options.workspaceId)
|
||||
: undefined;
|
||||
|
||||
const { appRegistration, targetVersion, applicationsToUpgrade } =
|
||||
await this.applicationUpgradeService.findApplicationsToUpgrade({
|
||||
applicationRegistrationId: registration.id,
|
||||
onlyAutoUpgrade: false,
|
||||
workspaceIds,
|
||||
workspaceCountLimit: options.workspaceCountLimit,
|
||||
});
|
||||
|
||||
if (!isDefined(targetVersion)) {
|
||||
this.logger.warn(
|
||||
`Application "${registration.name}" (${registration.universalIdentifier}) has no latest available version, nothing to upgrade`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const impactedWorkspaceIds = applicationsToUpgrade.map(
|
||||
(application) => application.workspaceId,
|
||||
);
|
||||
|
||||
if (options.dryRun ?? false) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would upgrade "${registration.name}" (${registration.universalIdentifier}) to version ${targetVersion} on ${impactedWorkspaceIds.length} workspace(s)${
|
||||
impactedWorkspaceIds.length > 0
|
||||
? `: ${impactedWorkspaceIds.join(', ')}`
|
||||
: ''
|
||||
}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (impactedWorkspaceIds.length === 0) {
|
||||
this.logger.log(
|
||||
`No workspace to upgrade, every targeted installation of "${registration.name}" already runs version ${targetVersion}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(options.yes ?? false)) {
|
||||
const confirmationTarget = isDefined(workspaceIds)
|
||||
? `workspace(s) ${workspaceIds.join(', ')}`
|
||||
: `${impactedWorkspaceIds.length} workspace(s)`;
|
||||
|
||||
const isConfirmed = await askCommandConfirmation(
|
||||
`Confirm upgrading application ${registration.universalIdentifier} to version ${targetVersion} on ${confirmationTarget}`,
|
||||
);
|
||||
|
||||
if (!isConfirmed) {
|
||||
this.logger.log('Aborted, no upgrade performed');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Upgrading "${registration.name}" (${registration.universalIdentifier}) to version ${targetVersion} on ${impactedWorkspaceIds.length} workspace(s)...`,
|
||||
);
|
||||
|
||||
// Runs on the exact set shown at confirmation time, so installations
|
||||
// created or versions published while the operator answered are excluded.
|
||||
await this.applicationUpgradeService.upgradeApplications({
|
||||
appRegistration,
|
||||
targetVersion,
|
||||
applications: applicationsToUpgrade,
|
||||
batchSize: options.batchSize,
|
||||
});
|
||||
|
||||
this.logger.log(chalk.blue('Command completed!'));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationGaugeService } from 'src/engine/core-modules/application/application-gauge.service';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
|
||||
import { ApplicationStopModule } from 'src/engine/core-modules/application/application-stop/application-stop.module';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
@@ -35,6 +35,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
ObjectMetadataEntity,
|
||||
ApplicationVariableEntity,
|
||||
]),
|
||||
ApplicationStopModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceCacheModule,
|
||||
TwentyConfigModule,
|
||||
@@ -43,13 +44,12 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
],
|
||||
exports: [
|
||||
ApplicationService,
|
||||
ApplicationStopService,
|
||||
ApplicationStopModule,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationService,
|
||||
ApplicationStopService,
|
||||
ApplicationGaugeService,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
provideWorkspaceScopedRepository(AgentEntity),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isAbsoluteUrl, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { SdkClientChecksumsDTO } from 'src/engine/core-modules/sdk-client/dtos/sdk-client-checksums.dto';
|
||||
import { getInstalledSdkMetadataModule } from 'src/engine/core-modules/sdk-client/utils/get-installed-sdk-metadata-module.util';
|
||||
@@ -21,6 +22,7 @@ export class ApplicationResolver {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly applicationStopService: ApplicationStopService,
|
||||
) {}
|
||||
|
||||
@Query(() => SdkClientChecksumsDTO, { nullable: true })
|
||||
@@ -46,6 +48,19 @@ export class ApplicationResolver {
|
||||
};
|
||||
}
|
||||
|
||||
// Surfaces the kill switch so clients can warn users that the app is
|
||||
// temporarily stopped and behaving in a degraded way. Kept as a dedicated
|
||||
// query so listing applications does not trigger one Redis read per app.
|
||||
@Query(() => Boolean)
|
||||
async isApplicationStopped(
|
||||
@Args('applicationUniversalIdentifier')
|
||||
applicationUniversalIdentifier: string,
|
||||
): Promise<boolean> {
|
||||
return this.applicationStopService.isApplicationStopped(
|
||||
applicationUniversalIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves the display url of the logo bundled in the installed
|
||||
// application's public assets, so clients never build file urls themselves.
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
export const BACKFILL_APPLICATION_INSTALLATION_JOB_NAME =
|
||||
'BackfillApplicationInstallationJob';
|
||||
|
||||
export type BackfillApplicationInstallationJobData = {
|
||||
applicationRegistrationId: string;
|
||||
};
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import {
|
||||
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
|
||||
type BackfillApplicationInstallationJobData,
|
||||
} from 'src/engine/core-modules/application/jobs/backfill-application-installation.job-constants';
|
||||
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
|
||||
|
||||
@Processor(MessageQueue.workspaceQueue)
|
||||
export class BackfillApplicationInstallationJob {
|
||||
constructor(
|
||||
private readonly preInstalledAppsService: PreInstalledAppsService,
|
||||
) {}
|
||||
|
||||
@Process(BACKFILL_APPLICATION_INSTALLATION_JOB_NAME)
|
||||
async handle(data: BackfillApplicationInstallationJobData): Promise<void> {
|
||||
await this.preInstalledAppsService.backfillApplicationOnAllWorkspaces(
|
||||
data.applicationRegistrationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import {
|
||||
import { buildApplicationLogEnvelopes } from 'src/engine/core-modules/event-logs/producers/application-log/build-application-log-envelopes';
|
||||
import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/producers/application-log/parse-application-log-lines';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
|
||||
@@ -15,9 +15,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
|
||||
import { BackfillApplicationInstallationJob } from 'src/engine/core-modules/application/jobs/backfill-application-installation.job';
|
||||
import { UpgradeApplicationsJob } from 'src/engine/core-modules/application/jobs/upgrade-applications.job';
|
||||
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
|
||||
import { InstallOnboardingAppsJob } from 'src/engine/core-modules/onboarding/jobs/install-onboarding-apps.job';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
|
||||
@@ -94,7 +92,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
LogicFunctionModule,
|
||||
EnterpriseModule,
|
||||
EmailingModule,
|
||||
PreInstalledAppsModule,
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationUpgradeModule,
|
||||
@@ -114,7 +111,6 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||
CleanWorkspaceDeletionWarningUserVarsJob,
|
||||
UpdateWorkspaceMemberEmailJob,
|
||||
GenerateSdkClientJob,
|
||||
BackfillApplicationInstallationJob,
|
||||
UpgradeApplicationsJob,
|
||||
InstallOnboardingAppsJob,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user