Add twenty sdk server upgrade command (#20158)
## The command pulls the image, compares it against the one the container was created from, and only recreates the container if the image actually changed. Your data volumes are preserved — only the container is replaced.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { serverStart } from '@/cli/operations/server-start';
|
||||
import { serverUpgrade } from '@/cli/operations/server-upgrade';
|
||||
import {
|
||||
CONTAINER_NAME,
|
||||
containerExists,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TEST_PORT,
|
||||
getContainerEnvVar,
|
||||
getContainerPort,
|
||||
isContainerRunning,
|
||||
TEST_CONTAINER_NAME,
|
||||
@@ -115,9 +117,15 @@ export const registerServerCommands = (program: Command): void => {
|
||||
? chalk.yellow('running (starting...)')
|
||||
: chalk.gray('stopped');
|
||||
|
||||
const appVersion = getContainerEnvVar('APP_VERSION', containerName);
|
||||
|
||||
console.log(` Status: ${statusText}`);
|
||||
console.log(` URL: http://localhost:${port}`);
|
||||
|
||||
if (appVersion) {
|
||||
console.log(` Version: ${chalk.gray(appVersion)}`);
|
||||
}
|
||||
|
||||
if (healthy) {
|
||||
console.log(chalk.gray(' Login: tim@apple.dev / tim@apple.dev'));
|
||||
}
|
||||
@@ -155,4 +163,41 @@ export const registerServerCommands = (program: Command): void => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
.command('upgrade [version]')
|
||||
.description('Upgrade the twenty-app-dev Docker image')
|
||||
.option('--test', 'Upgrade the test instance')
|
||||
.action(
|
||||
async (version: string | undefined, options: { test?: boolean }) => {
|
||||
const result = await serverUpgrade({
|
||||
version: version ?? 'latest',
|
||||
test: options.test,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { data } = result;
|
||||
|
||||
if (!data.imageUpdated) {
|
||||
console.log(chalk.green(` Already up to date (${data.image}).`));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green(` Upgraded to: ${data.image}`));
|
||||
|
||||
if (data.containerRecreated) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` Run 'yarn twenty server start${options.test ? ' --test' : ''}' to wait for the server to be ready.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,19 @@ export type { FunctionExecuteOptions } from './execute';
|
||||
// Server
|
||||
export { serverStart } from './server-start';
|
||||
export type { ServerStartOptions, ServerStartResult } from './server-start';
|
||||
export { serverUpgrade } from './server-upgrade';
|
||||
export type {
|
||||
ServerUpgradeOptions,
|
||||
ServerUpgradeResult,
|
||||
} from './server-upgrade';
|
||||
export { detectLocalServer } from '@/cli/utilities/server/detect-local-server';
|
||||
export {
|
||||
checkDockerRunning,
|
||||
containerExists,
|
||||
getContainerDigest,
|
||||
getImageDigest,
|
||||
getImageForVersion,
|
||||
} from '@/cli/utilities/server/docker-container';
|
||||
|
||||
// Config
|
||||
export { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { SERVER_ERROR_CODES, type CommandResult } from '@/cli/types';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import {
|
||||
checkDockerRunning,
|
||||
CONTAINER_NAME,
|
||||
containerExists,
|
||||
getContainerDigest,
|
||||
getContainerPort,
|
||||
getImageDigest,
|
||||
getImageForVersion,
|
||||
TEST_CONTAINER_NAME,
|
||||
} from '@/cli/utilities/server/docker-container';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
|
||||
export type ServerUpgradeOptions = {
|
||||
version?: string;
|
||||
test?: boolean;
|
||||
onProgress?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type ServerUpgradeResult = {
|
||||
image: string;
|
||||
imageUpdated: boolean;
|
||||
containerRecreated: boolean;
|
||||
};
|
||||
|
||||
const innerServerUpgrade = async (
|
||||
options: ServerUpgradeOptions = {},
|
||||
): Promise<CommandResult<ServerUpgradeResult>> => {
|
||||
const { version = 'latest', test: isTest, onProgress } = options;
|
||||
|
||||
if (!checkDockerRunning()) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: SERVER_ERROR_CODES.DOCKER_NOT_RUNNING,
|
||||
message: 'Docker is not running. Please start Docker and try again.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const containerName = isTest ? TEST_CONTAINER_NAME : CONTAINER_NAME;
|
||||
const image = getImageForVersion(version);
|
||||
const hasContainer = containerExists(containerName);
|
||||
const previousDigest = hasContainer
|
||||
? getContainerDigest(containerName)
|
||||
: null;
|
||||
|
||||
onProgress?.(`Pulling ${image}...`);
|
||||
|
||||
const pullResult = spawnSync('docker', ['pull', image], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (pullResult.status !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
|
||||
message: `Failed to pull image ${image}. Check that the version exists.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pulledDigest = getImageDigest(image);
|
||||
const imageUpdated = !previousDigest || previousDigest !== pulledDigest;
|
||||
|
||||
if (!hasContainer) {
|
||||
onProgress?.('Image pulled. No existing container to upgrade.');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { image, imageUpdated, containerRecreated: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (!imageUpdated) {
|
||||
onProgress?.('Already up to date.');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { image, imageUpdated: false, containerRecreated: false },
|
||||
};
|
||||
}
|
||||
|
||||
const port = getContainerPort(containerName);
|
||||
|
||||
onProgress?.('Removing existing container...');
|
||||
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
|
||||
|
||||
const volumeData = isTest
|
||||
? 'twenty-app-dev-test-data'
|
||||
: 'twenty-app-dev-data';
|
||||
const volumeStorage = isTest
|
||||
? 'twenty-app-dev-test-storage'
|
||||
: 'twenty-app-dev-storage';
|
||||
|
||||
onProgress?.('Starting container with new image...');
|
||||
|
||||
const runResult = spawnSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
containerName,
|
||||
'-p',
|
||||
`${port}:${port}`,
|
||||
'-e',
|
||||
`NODE_PORT=${port}`,
|
||||
'-e',
|
||||
`SERVER_URL=http://localhost:${port}`,
|
||||
'-v',
|
||||
`${volumeData}:/data/postgres`,
|
||||
'-v',
|
||||
`${volumeStorage}:/app/packages/twenty-server/.local-storage`,
|
||||
image,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
if (runResult.status !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
|
||||
message: 'Failed to start container with new image.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { image, imageUpdated: true, containerRecreated: true },
|
||||
};
|
||||
};
|
||||
|
||||
export const serverUpgrade = (
|
||||
options?: ServerUpgradeOptions,
|
||||
): Promise<CommandResult<ServerUpgradeResult>> =>
|
||||
runSafe(
|
||||
() => innerServerUpgrade(options),
|
||||
SERVER_ERROR_CODES.IMAGE_UPGRADE_FAILED,
|
||||
);
|
||||
@@ -31,6 +31,7 @@ export const SERVER_ERROR_CODES = {
|
||||
DOCKER_NOT_RUNNING: 'DOCKER_NOT_RUNNING',
|
||||
CONTAINER_START_FAILED: 'CONTAINER_START_FAILED',
|
||||
HEALTH_TIMEOUT: 'HEALTH_TIMEOUT',
|
||||
IMAGE_UPGRADE_FAILED: 'IMAGE_UPGRADE_FAILED',
|
||||
} as const;
|
||||
|
||||
export const FUNCTION_ERROR_CODES = {
|
||||
|
||||
@@ -49,6 +49,51 @@ export const containerExists = (containerName = CONTAINER_NAME): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getImageForVersion = (version = 'latest'): string =>
|
||||
`twentycrm/twenty-app-dev:${version}`;
|
||||
|
||||
export const getContainerDigest = (
|
||||
containerName = CONTAINER_NAME,
|
||||
): string | null => {
|
||||
try {
|
||||
return execSync(`docker inspect -f '{{.Image}}' ${containerName}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getImageDigest = (image: string): string | null => {
|
||||
try {
|
||||
return execSync(`docker inspect -f '{{.Id}}' ${image}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getContainerEnvVar = (
|
||||
envVar: string,
|
||||
containerName = CONTAINER_NAME,
|
||||
): string | null => {
|
||||
try {
|
||||
const result = execSync(
|
||||
`docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' ${containerName}`,
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
|
||||
);
|
||||
|
||||
const match = result.match(new RegExp(`^${envVar}=(.+)$`, 'm'));
|
||||
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkDockerRunning = (): boolean => {
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
|
||||
Reference in New Issue
Block a user