mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 12:40:12 +00:00
* E2E: run the shared channels tests on CI instead of skipping them CI servers now boot with Shared Channels running (matterwick#106), so the suite runs there and fails setup if the service is missing. * E2E: run demo plugin v0.12.2 in DialogOnlyMode Moves the demo plugin to v0.12.2 and writes DialogOnlyMode: true into its settings entry before it is installed on server B, so it no longer creates a demo user and channels or posts in every team on every server event. Only /dialog is kept, which is all the dialog specs use. The entry still keeps an older build on a reused server out of config saves. * E2E: give a neutral reason when Shared Channels is unavailable hasSharedChannels is false both when the license lacks Shared Channels and when GET /remotecluster fails; point to the globalSetup log line that names the actual reason. * E2E: run the read-only dialog specs on the pool servers With demo plugin v0.12.2 in DialogOnlyMode it no longer posts in every team, so it is installed on every full-suite server (pool A and mutation B) instead of B only. interactive_dialog_plugin and interactive_dialog_collapsible change no server-wide state, so they leave the mutation manifest and run on the pool. interactive_dialog_mobile_upload_disabled stays on B. The shared SITE_3 and smoke servers still get no demo plugin, and CMT still rejects a site 2 that is also a pool site 1. * E2E: remove unused demo plugin helpers apiUploadAndEnablePlugin and apiClearDemoPluginFileUploadKV had no callers (the KV route does not exist). Provisioning installs the plugin, so the install plan they used goes too. * fix(e2e): say why a spec has no result (#10280) * chore: Update NOTICE.txt file with updated dependencies (#10277) Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com> * fix(e2e): say whether a spec with no result had a silent shard or a stopped one A spec that produced no result now says which: no spec of its shard reported (the shard ran or uploaded nothing), or other specs of its shard reported (its Jest process stopped before reaching this spec). E2E triage re-runs the first and judges the second by the spec's own history. The shard of each spec comes from the generate-specs matrix rows; without them the old wording stays. --------- Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com> --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
572 lines
24 KiB
TypeScript
572 lines
24 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
/* eslint-disable no-process-env */
|
|
|
|
import https from 'node:https';
|
|
import path from 'node:path';
|
|
|
|
import {DEMO_PLUGIN_VERSION, ensureDemoPluginFixture} from '../shared/demo-plugin-fixture';
|
|
import PREPACKAGED_PLUGINS from '../shared/prepackaged-plugins.json';
|
|
|
|
import {
|
|
AGENTS_PLUGIN_ID,
|
|
AGENTS_PLUGIN_ASSET_NAME,
|
|
AGENTS_PLUGIN_FALLBACK_VERSION,
|
|
AGENTS_PLUGIN_REPO,
|
|
DEMO_PLUGIN_ID,
|
|
PLUGIN_STATE_FAILED,
|
|
PLUGIN_STATE_RUNNING,
|
|
} from './constants';
|
|
import {getAgentsPluginDownloadUrl} from './env';
|
|
import {sleep, uploadMultipartFile} from './http-client';
|
|
import {logInfo, logWarn} from './log';
|
|
|
|
import type {
|
|
MattermostClient,
|
|
PluginInstallResult,
|
|
PluginListEntry,
|
|
PluginStatus,
|
|
PluginsPayload,
|
|
RequiredPlugin,
|
|
} from './types';
|
|
|
|
type ApiErrorBody = {message?: string};
|
|
type GitHubLatestRelease = {tag_name?: string};
|
|
type SlashCommand = {trigger?: string};
|
|
type Team = {id: string};
|
|
|
|
const DEMO_PLUGIN_READY_TIMEOUT_MS = 60_000;
|
|
const DEMO_PLUGIN_POLL_INTERVAL_MS = 1_000;
|
|
|
|
function fetchLatestPluginVersion(repo: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = https.request({
|
|
hostname: 'api.github.com',
|
|
port: 443,
|
|
path: `/repos/${repo}/releases/latest`,
|
|
method: 'GET',
|
|
headers: {
|
|
'User-Agent': 'mattermost-mobile-provision',
|
|
Accept: 'application/vnd.github+json',
|
|
...(process.env.GITHUB_TOKEN ? {Authorization: `Bearer ${process.env.GITHUB_TOKEN}`} : {}),
|
|
},
|
|
timeout: 30_000,
|
|
}, (res) => {
|
|
let data = '';
|
|
res.on('data', (chunk: string) => {
|
|
data += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
if ((res.statusCode || 0) >= 400) {
|
|
reject(new Error(`GitHub API ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(data) as GitHubLatestRelease;
|
|
if (!parsed.tag_name) {
|
|
reject(new Error(`GitHub API response missing tag_name: ${data.slice(0, 200)}`));
|
|
return;
|
|
}
|
|
|
|
const tag = parsed.tag_name;
|
|
resolve(tag.startsWith('v') ? tag.slice(1) : tag);
|
|
} catch (err) {
|
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
reject(new Error(`Request to GitHub releases/latest timed out for ${repo}`));
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function buildPluginDownloadUrl(repo: string, assetName: string, version: string): string {
|
|
return `https://github.com/${repo}/releases/download/v${version}/${assetName}-v${version}-linux-amd64.tar.gz`;
|
|
}
|
|
|
|
async function resolveAgentsPluginUrl(): Promise<string> {
|
|
const override = getAgentsPluginDownloadUrl();
|
|
if (override) {
|
|
return override;
|
|
}
|
|
|
|
try {
|
|
const version = await fetchLatestPluginVersion(AGENTS_PLUGIN_REPO);
|
|
logInfo(`${AGENTS_PLUGIN_REPO}: latest=v${version}`);
|
|
return buildPluginDownloadUrl(AGENTS_PLUGIN_REPO, AGENTS_PLUGIN_ASSET_NAME, version);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
logWarn(`${AGENTS_PLUGIN_REPO}: GitHub API lookup failed (${message}); falling back to v${AGENTS_PLUGIN_FALLBACK_VERSION}.`);
|
|
return buildPluginDownloadUrl(AGENTS_PLUGIN_REPO, AGENTS_PLUGIN_ASSET_NAME, AGENTS_PLUGIN_FALLBACK_VERSION);
|
|
}
|
|
}
|
|
|
|
async function getPluginStatus(client: MattermostClient, token: string, pluginId: string): Promise<PluginStatus | null> {
|
|
const res = await client.request<PluginStatus[]>('GET', '/api/v4/plugins/statuses', undefined, token);
|
|
if (res.status >= 400 || !Array.isArray(res.data)) {
|
|
return null;
|
|
}
|
|
|
|
return res.data.find((plugin) => plugin.plugin_id === pluginId) || null;
|
|
}
|
|
|
|
function failDemoPluginReadiness(message: string): never {
|
|
logWarn(message);
|
|
throw new Error(message);
|
|
}
|
|
|
|
async function waitForDemoPluginRunning(client: MattermostClient, token: string): Promise<void> {
|
|
const deadline = Date.now() + DEMO_PLUGIN_READY_TIMEOUT_MS;
|
|
|
|
/* eslint-disable no-await-in-loop -- poll plugin status until it reports running */
|
|
for (;;) {
|
|
const status: PluginStatus | null = await getPluginStatus(client, token, DEMO_PLUGIN_ID);
|
|
if (status?.state === PLUGIN_STATE_RUNNING) {
|
|
break;
|
|
}
|
|
|
|
if (Date.now() >= deadline) {
|
|
const detail = status ?
|
|
`state=${status.state}${status.error ? `, error=${status.error}` : ''}` :
|
|
'status unavailable';
|
|
failDemoPluginReadiness(`Demo plugin did not reach running state (${PLUGIN_STATE_RUNNING}) within ${DEMO_PLUGIN_READY_TIMEOUT_MS}ms: ${detail}`);
|
|
}
|
|
|
|
await sleep(DEMO_PLUGIN_POLL_INTERVAL_MS);
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
|
|
logInfo(`Plugin ${DEMO_PLUGIN_ID} is running.`);
|
|
}
|
|
|
|
async function getCommandVerificationTeamId(client: MattermostClient, token: string): Promise<string> {
|
|
const teamsRes = await client.request<Team[]>('GET', '/api/v4/teams?page=0&per_page=1', undefined, token);
|
|
if (teamsRes.status < 400 && Array.isArray(teamsRes.data) && teamsRes.data[0]?.id) {
|
|
return teamsRes.data[0].id;
|
|
}
|
|
|
|
const suffix = Date.now().toString(36);
|
|
const createRes = await client.request<Team | ApiErrorBody>('POST', '/api/v4/teams', {
|
|
name: `e2e-provision-${suffix}`,
|
|
display_name: `E2E Provision ${suffix}`,
|
|
type: 'O',
|
|
}, token);
|
|
const createdTeam = createRes.data as Team | null;
|
|
if (createRes.status >= 400 || !createdTeam?.id) {
|
|
const error = createRes.data as ApiErrorBody | null;
|
|
failDemoPluginReadiness(`Could not get or create a team for /dialog verification (HTTP ${createRes.status}): ${error?.message || JSON.stringify(createRes.data)}`);
|
|
}
|
|
|
|
return createdTeam.id;
|
|
}
|
|
|
|
async function waitForDialogCommand(client: MattermostClient, token: string, teamId: string): Promise<void> {
|
|
const deadline = Date.now() + DEMO_PLUGIN_READY_TIMEOUT_MS;
|
|
|
|
const hasDialog = (commands: SlashCommand[]) => commands.some((command) => command.trigger === 'dialog');
|
|
|
|
/* eslint-disable no-await-in-loop -- poll both command APIs until /dialog registers */
|
|
for (;;) {
|
|
// Collect a diagnostic per API so a failure on one is not lost when the other succeeds.
|
|
const diagnostics: string[] = [];
|
|
|
|
const commandsRes = await client.request<SlashCommand[]>('GET', `/api/v4/commands?team_id=${encodeURIComponent(teamId)}`, undefined, token);
|
|
if (commandsRes.status < 400 && Array.isArray(commandsRes.data)) {
|
|
if (hasDialog(commandsRes.data)) {
|
|
break;
|
|
}
|
|
diagnostics.push(`${commandsRes.data.length} commands`);
|
|
} else {
|
|
diagnostics.push(`commands HTTP ${commandsRes.status}: ${(commandsRes.data as ApiErrorBody)?.message || JSON.stringify(commandsRes.data)}`);
|
|
}
|
|
|
|
const autocompleteRes = await client.request<SlashCommand[]>(
|
|
'GET',
|
|
`/api/v4/teams/${encodeURIComponent(teamId)}/commands/autocomplete?page=0&per_page=200`,
|
|
undefined,
|
|
token,
|
|
);
|
|
if (autocompleteRes.status < 400 && Array.isArray(autocompleteRes.data)) {
|
|
if (hasDialog(autocompleteRes.data)) {
|
|
break;
|
|
}
|
|
diagnostics.push(`${autocompleteRes.data.length} autocomplete commands`);
|
|
} else {
|
|
diagnostics.push(`autocomplete HTTP ${autocompleteRes.status}: ${(autocompleteRes.data as ApiErrorBody)?.message || JSON.stringify(autocompleteRes.data)}`);
|
|
}
|
|
|
|
if (Date.now() >= deadline) {
|
|
failDemoPluginReadiness(`/dialog was not registered within ${DEMO_PLUGIN_READY_TIMEOUT_MS}ms after ${DEMO_PLUGIN_ID} started (${diagnostics.join('; ')}).`);
|
|
}
|
|
|
|
await sleep(DEMO_PLUGIN_POLL_INTERVAL_MS);
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
|
|
logInfo('/dialog command is registered.');
|
|
}
|
|
|
|
/**
|
|
* The demo plugin settings entry to write so DialogOnlyMode is on, or null when it already is. Setting
|
|
* keys are matched case-insensitively, so any spelling of DialogOnlyMode is replaced; other settings are kept.
|
|
*/
|
|
export function demoPluginSettingsPatch(entry: Record<string, unknown>): Record<string, unknown> | null {
|
|
const isDialogOnlyModeKey = (key: string) => key.toLowerCase() === 'dialogonlymode';
|
|
const values = Object.entries(entry).filter(([key]) => isDialogOnlyModeKey(key)).map(([, value]) => value);
|
|
if (values.length > 0 && values.every((value) => value === true)) {
|
|
return null;
|
|
}
|
|
const otherSettings = Object.fromEntries(Object.entries(entry).filter(([key]) => !isDialogOnlyModeKey(key)));
|
|
return {...otherSettings, DialogOnlyMode: true};
|
|
}
|
|
|
|
/**
|
|
* Sets DialogOnlyMode in the demo plugin's settings entry before the plugin is installed, so it starts
|
|
* without the demo user, the demo channels and the posts for every server event, keeping only /dialog
|
|
* (demo plugin v0.12.2+). The entry also keeps an older build still running on a reused server (v0.12.1)
|
|
* out of config saves: without an entry it hands every save back to the server, and that round trip
|
|
* resets settings it does not know, or that are false, to their defaults.
|
|
*/
|
|
export async function ensureDemoPluginSettingsEntry(client: MattermostClient, token: string): Promise<void> {
|
|
const res = await client.request<{PluginSettings?: {Plugins?: Record<string, Record<string, unknown> | undefined>}}>('GET', '/api/v4/config', undefined, token);
|
|
if (res.status !== 200) {
|
|
throw new Error(`Could not read config to check the ${DEMO_PLUGIN_ID} settings entry (HTTP ${res.status})`);
|
|
}
|
|
const settings = demoPluginSettingsPatch(res.data?.PluginSettings?.Plugins?.[DEMO_PLUGIN_ID] ?? {});
|
|
if (!settings) {
|
|
return;
|
|
}
|
|
const patchRes = await client.request<ApiErrorBody>('PUT', '/api/v4/config/patch', {PluginSettings: {Plugins: {[DEMO_PLUGIN_ID]: settings}}}, token);
|
|
if (patchRes.status !== 200) {
|
|
throw new Error(`Could not set DialogOnlyMode in the ${DEMO_PLUGIN_ID} settings entry (HTTP ${patchRes.status})`);
|
|
}
|
|
logInfo(`Set DialogOnlyMode in the ${DEMO_PLUGIN_ID} settings entry so it posts nothing and its config hook keeps other settings intact.`);
|
|
}
|
|
|
|
export async function ensureDemoPluginReady(client: MattermostClient, token: string): Promise<void> {
|
|
await waitForDemoPluginRunning(client, token);
|
|
const teamId = await getCommandVerificationTeamId(client, token);
|
|
await waitForDialogCommand(client, token, teamId);
|
|
}
|
|
|
|
async function installPluginFromUrl(
|
|
client: MattermostClient,
|
|
token: string,
|
|
pluginId: string,
|
|
pluginUrl: string,
|
|
{force = false}: {force?: boolean} = {},
|
|
): Promise<PluginInstallResult> {
|
|
const forceParam = force ? '&force=true' : '';
|
|
const installRes = await client.request<ApiErrorBody>(
|
|
'POST',
|
|
`/api/v4/plugins/install_from_url?plugin_download_url=${encodeURIComponent(pluginUrl)}${forceParam}`,
|
|
undefined,
|
|
token,
|
|
);
|
|
|
|
if (installRes.status >= 400) {
|
|
return {
|
|
ok: false,
|
|
status: installRes.status,
|
|
message: installRes.data.message || JSON.stringify(installRes.data),
|
|
};
|
|
}
|
|
|
|
const enableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`, {}, token);
|
|
if (enableRes.status >= 400) {
|
|
return {
|
|
ok: false,
|
|
status: enableRes.status,
|
|
message: enableRes.data.message || JSON.stringify(enableRes.data),
|
|
};
|
|
}
|
|
|
|
await sleep(3000);
|
|
return {ok: true};
|
|
}
|
|
|
|
async function installAgentsPluginFromMarketplace(client: MattermostClient, token: string): Promise<boolean> {
|
|
logInfo(`Installing ${AGENTS_PLUGIN_ID} from Marketplace...`);
|
|
const installRes = await client.request<ApiErrorBody>('POST', '/api/v4/plugins/marketplace', {id: AGENTS_PLUGIN_ID}, token);
|
|
if (installRes.status >= 400) {
|
|
logWarn(`Marketplace install failed for ${AGENTS_PLUGIN_ID} (HTTP ${installRes.status}): ${installRes.data.message || JSON.stringify(installRes.data)}`);
|
|
return false;
|
|
}
|
|
|
|
const enableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(AGENTS_PLUGIN_ID)}/enable`, {}, token);
|
|
if (enableRes.status >= 400) {
|
|
logWarn(`Failed to enable ${AGENTS_PLUGIN_ID} after Marketplace install (HTTP ${enableRes.status}): ${enableRes.data.message || JSON.stringify(enableRes.data)}`);
|
|
return false;
|
|
}
|
|
|
|
await sleep(3000);
|
|
return true;
|
|
}
|
|
|
|
export async function ensureAgentsPlugin(client: MattermostClient, token: string): Promise<boolean> {
|
|
const pluginsRes = await client.request<PluginsPayload>('GET', '/api/v4/plugins', undefined, token);
|
|
const status = await getPluginStatus(client, token, AGENTS_PLUGIN_ID);
|
|
const isActive = pluginsRes.data?.active?.some((plugin) => plugin.id === AGENTS_PLUGIN_ID);
|
|
const isFailed = status?.state === PLUGIN_STATE_FAILED;
|
|
|
|
if (isActive && !isFailed && status?.state === PLUGIN_STATE_RUNNING) {
|
|
logInfo(`Plugin ${AGENTS_PLUGIN_ID} is already installed and running (${status.version}).`);
|
|
return true;
|
|
}
|
|
|
|
if (isFailed) {
|
|
logWarn(`Plugin ${AGENTS_PLUGIN_ID} is in failed state: ${status?.error || 'unknown error'}`);
|
|
}
|
|
|
|
const pluginUrl = await resolveAgentsPluginUrl();
|
|
logInfo(`Installing ${AGENTS_PLUGIN_ID} from URL...`);
|
|
const installResult = await installPluginFromUrl(client, token, AGENTS_PLUGIN_ID, pluginUrl, {force: true});
|
|
if (installResult.ok) {
|
|
const postStatus = await getPluginStatus(client, token, AGENTS_PLUGIN_ID);
|
|
if (postStatus?.state !== PLUGIN_STATE_FAILED) {
|
|
logInfo(`Plugin ${AGENTS_PLUGIN_ID} installed (${postStatus?.version || 'unknown version'}).`);
|
|
return true;
|
|
}
|
|
logWarn(`Plugin ${AGENTS_PLUGIN_ID} failed after URL install: ${postStatus?.error}`);
|
|
} else {
|
|
logWarn(`URL install failed for ${AGENTS_PLUGIN_ID} (HTTP ${installResult.status}): ${installResult.message}`);
|
|
}
|
|
|
|
if (await installAgentsPluginFromMarketplace(client, token)) {
|
|
const postStatus = await getPluginStatus(client, token, AGENTS_PLUGIN_ID);
|
|
if (postStatus?.state !== PLUGIN_STATE_FAILED) {
|
|
logInfo(`Plugin ${AGENTS_PLUGIN_ID} installed from Marketplace (${postStatus?.version || 'unknown version'}).`);
|
|
return true;
|
|
}
|
|
logWarn(`Plugin ${AGENTS_PLUGIN_ID} failed after Marketplace install: ${postStatus?.error}`);
|
|
}
|
|
|
|
logWarn('Could not install a compatible mattermost-ai plugin build.');
|
|
return false;
|
|
}
|
|
|
|
async function installPluginFromFile(
|
|
client: MattermostClient,
|
|
token: string,
|
|
pluginId: string,
|
|
filePath: string,
|
|
{force = false}: {force?: boolean} = {},
|
|
): Promise<PluginInstallResult> {
|
|
const fs = await import('node:fs');
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
message: `Plugin fixture not found: ${filePath}`,
|
|
};
|
|
}
|
|
|
|
let res: Awaited<ReturnType<typeof uploadMultipartFile<ApiErrorBody>>>;
|
|
try {
|
|
// The upload endpoint reads `force` from the multipart form, not the query string
|
|
// (api4.uploadPlugin), so a query param is silently dropped and the server answers
|
|
// "A plugin with the same ID is already installed."
|
|
res = await uploadMultipartFile<ApiErrorBody>(
|
|
client,
|
|
'POST',
|
|
'/api/v4/plugins',
|
|
filePath,
|
|
'plugin',
|
|
token,
|
|
force ? {force: 'true'} : {},
|
|
);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
|
|
if (res.status >= 400) {
|
|
return {
|
|
ok: false,
|
|
status: res.status,
|
|
message: res.data.message || JSON.stringify(res.data),
|
|
};
|
|
}
|
|
|
|
const enableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`, {}, token).catch((err: unknown) => ({
|
|
data: {message: err instanceof Error ? err.message : String(err)},
|
|
status: 0,
|
|
headers: {},
|
|
}));
|
|
if (enableRes.status === 0 || enableRes.status >= 400) {
|
|
return {
|
|
ok: false,
|
|
status: enableRes.status,
|
|
message: enableRes.data.message || JSON.stringify(enableRes.data),
|
|
};
|
|
}
|
|
|
|
await sleep(3000);
|
|
return {ok: true};
|
|
}
|
|
|
|
export async function installRequiredPlugin(
|
|
client: MattermostClient,
|
|
token: string,
|
|
{id: pluginId, url: pluginUrl, fixture: fixtureFilename}: RequiredPlugin,
|
|
): Promise<void> {
|
|
const pluginsRes = await client.request<PluginsPayload>('GET', '/api/v4/plugins', undefined, token);
|
|
if (pluginsRes.status >= 400) {
|
|
logWarn(`Could not list plugins: ${(pluginsRes.data as ApiErrorBody).message || ''}`);
|
|
return;
|
|
}
|
|
|
|
const {active = [], inactive = []} = pluginsRes.data;
|
|
const activeEntry = active.find((p: PluginListEntry) => p.id === pluginId);
|
|
const isActive = Boolean(activeEntry);
|
|
const isInactive = inactive.some((p: PluginListEntry) => p.id === pluginId);
|
|
|
|
// Matterwick hands over servers with the demo plugin already running, so "active" alone
|
|
// would keep whatever version it installed and silently ignore the pinned fixture. Compare
|
|
// the version and replace when it differs; the upload below runs with force.
|
|
const wrongVersion = pluginId === DEMO_PLUGIN_ID &&
|
|
activeEntry?.version !== undefined &&
|
|
activeEntry.version !== DEMO_PLUGIN_VERSION;
|
|
|
|
if (isActive && !wrongVersion) {
|
|
logInfo(`Plugin ${pluginId} is already installed and active.`);
|
|
return;
|
|
}
|
|
|
|
if (wrongVersion) {
|
|
logInfo(`Plugin ${pluginId} is active at ${activeEntry?.version}, replacing with the pinned ${DEMO_PLUGIN_VERSION}.`);
|
|
}
|
|
|
|
if (isInactive && !wrongVersion) {
|
|
logInfo(`Plugin ${pluginId} is installed but inactive, enabling...`);
|
|
|
|
// A transient enable timeout (e.g. demo-plugin on a freshly-provisioned
|
|
// server) must not crash the whole provision run.
|
|
const enableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`, {}, token).catch((err: unknown) => ({
|
|
data: {message: err instanceof Error ? err.message : String(err)} as ApiErrorBody,
|
|
status: 0,
|
|
headers: {},
|
|
}));
|
|
if (enableRes.status === 0 || enableRes.status >= 400) {
|
|
logWarn(`Failed to enable ${pluginId} (HTTP ${enableRes.status}): ${enableRes.data.message || JSON.stringify(enableRes.data)}`);
|
|
} else {
|
|
logInfo(`Plugin ${pluginId} enabled.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fixtureFilename) {
|
|
if (pluginId === DEMO_PLUGIN_ID) {
|
|
try {
|
|
await ensureDemoPluginFixture();
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
logWarn(`Could not place demo plugin fixture on the runner: ${detail}`);
|
|
}
|
|
}
|
|
|
|
const fixturePath = path.resolve(__dirname, `../e2e/support/fixtures/${fixtureFilename}`);
|
|
logInfo(`Installing ${pluginId} from fixture: ${fixturePath}`);
|
|
const installResult = await installPluginFromFile(client, token, pluginId, fixturePath, {force: true});
|
|
if (installResult.ok) {
|
|
logInfo(`Plugin ${pluginId} installed and enabled from fixture.`);
|
|
return;
|
|
}
|
|
logWarn(`Fixture install failed for ${pluginId}: ${installResult.message}`);
|
|
|
|
if (pluginId === DEMO_PLUGIN_ID) {
|
|
logWarn('Not falling back to install_from_url for the demo plugin (Cloudflare 524 while origin fetches GitHub).');
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (pluginUrl) {
|
|
logInfo(`Installing ${pluginId} from URL: ${pluginUrl}`);
|
|
const installResult = await installPluginFromUrl(client, token, pluginId, pluginUrl, {force: true});
|
|
if (installResult.ok) {
|
|
logInfo(`Plugin ${pluginId} installed and enabled from URL.`);
|
|
return;
|
|
}
|
|
logWarn(`URL install failed for ${pluginId} (HTTP ${installResult.status}): ${installResult.message}`);
|
|
}
|
|
|
|
logInfo(`Installing ${pluginId} from Marketplace...`);
|
|
const installRes = await client.request<ApiErrorBody>('POST', '/api/v4/plugins/marketplace', {id: pluginId}, token);
|
|
if (installRes.status >= 400) {
|
|
logWarn(`Marketplace install failed for ${pluginId} (HTTP ${installRes.status}): ${installRes.data.message || JSON.stringify(installRes.data)}`);
|
|
return;
|
|
}
|
|
|
|
logInfo(`Enabling ${pluginId}...`);
|
|
|
|
// Same transient-enable-timeout guard as the inactive branch above.
|
|
const postInstallEnableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`, {}, token).catch((err: unknown) => ({
|
|
data: {message: err instanceof Error ? err.message : String(err)} as ApiErrorBody,
|
|
status: 0,
|
|
headers: {},
|
|
}));
|
|
if (postInstallEnableRes.status === 0 || postInstallEnableRes.status >= 400) {
|
|
logWarn(`Failed to enable ${pluginId} after install (HTTP ${postInstallEnableRes.status}): ${postInstallEnableRes.data.message || JSON.stringify(postInstallEnableRes.data)}`);
|
|
} else {
|
|
logInfo(`Plugin ${pluginId} installed and enabled from Marketplace.`);
|
|
}
|
|
}
|
|
|
|
/** Active plugins that are neither prepackaged nor provisioned for this server. */
|
|
export function strayPluginIds(active: PluginListEntry[] | undefined, provisioned: readonly RequiredPlugin[]): string[] {
|
|
const keep = new Set<string>([...PREPACKAGED_PLUGINS, ...provisioned.map((plugin) => plugin.id)]);
|
|
return (active ?? []).map((plugin) => plugin.id).filter((id) => !keep.has(id));
|
|
}
|
|
|
|
/** Disables stray plugins once per run, so shards on read-only pool servers never have to. */
|
|
export async function disableStrayPlugins(client: MattermostClient, token: string, provisioned: readonly RequiredPlugin[]): Promise<void> {
|
|
const res = await client.request<PluginsPayload>('GET', '/api/v4/plugins', undefined, token);
|
|
if (res.status !== 200) {
|
|
logWarn(`Could not list plugins to disable stray ones (HTTP ${res.status})`);
|
|
return;
|
|
}
|
|
/* eslint-disable no-await-in-loop -- one plugin at a time for clear provisioning logs */
|
|
for (const id of strayPluginIds(res.data?.active, provisioned)) {
|
|
const disableRes = await client.request<ApiErrorBody>('POST', `/api/v4/plugins/${encodeURIComponent(id)}/disable`, {}, token);
|
|
if (disableRes.status === 200) {
|
|
logInfo(`Disabled stray plugin ${id}`);
|
|
} else {
|
|
logWarn(`Failed to disable stray plugin ${id} (HTTP ${disableRes.status}): ${disableRes.data?.message || ''}`);
|
|
}
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
}
|
|
|
|
/** A violation message when the demo plugin is active on a server that was not provisioned with it. */
|
|
export function demoPluginViolation(active: PluginListEntry[] | undefined, demoPlugin: boolean): string | undefined {
|
|
if (demoPlugin || !active?.some((plugin) => plugin.id === DEMO_PLUGIN_ID)) {
|
|
return undefined;
|
|
}
|
|
return `${DEMO_PLUGIN_ID} is active on a server provisioned without it; provisioning cannot finish`;
|
|
}
|
|
|
|
/** Fails provisioning when a server without the demo plugin still runs it, since the baseline no longer covers it. */
|
|
export async function assertDemoPluginAbsent(client: MattermostClient, token: string, demoPlugin: boolean): Promise<void> {
|
|
if (demoPlugin) {
|
|
return;
|
|
}
|
|
const res = await client.request<PluginsPayload>('GET', '/api/v4/plugins', undefined, token);
|
|
if (res.status !== 200) {
|
|
throw new Error(`Could not list plugins to verify ${DEMO_PLUGIN_ID} is absent (HTTP ${res.status})`);
|
|
}
|
|
const violation = demoPluginViolation(res.data?.active, demoPlugin);
|
|
if (violation) {
|
|
throw new Error(violation);
|
|
}
|
|
}
|