Files
pleno-vue/tests/e2e/coolify-infrastructure.spec.js
T

1082 lines
35 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const permissions = [
"superuser",
"user",
"superuser_replication_view",
"superuser_replication_manage",
"superuser_replication_promote",
"superuser_replication_remove",
"superuser_coolify_view",
"superuser_coolify_manage",
"superuser_coolify_reconcile",
"superuser_coolify_failover",
];
function json(body, status = 200) {
return {
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
};
}
async function boot(page, state = createCoolifyState()) {
await mockApi(page, {
authenticated: true,
permissions,
});
await installCoolifyMocks(page, state);
await seedAuthenticatedState(page, "coolify-infrastructure-token");
return state;
}
function createCoolifyState() {
const primaryDatabase = {
id: 1,
kind: "database",
label: "mariadb-primary",
host: "db-primary.internal",
port: 3306,
role: "primary",
status: "ok",
replication_percent: 100,
last_status: { status: "ok", replication_percent: 100, blockers: [] },
last_checked_at: "2026-05-18T08:00:00.000Z",
};
const summary = {
database: {
primary: primaryDatabase,
hosts: [primaryDatabase],
replication: {
status: "ok",
min_percent: 100,
average_percent: 100,
replicas: [],
},
},
redis: {
primary: null,
hosts: [],
replication: {
status: "not_configured",
min_percent: 0,
average_percent: 0,
replicas: [],
},
},
minio: {
primary: null,
hosts: [],
replication: {
status: "not_configured",
min_percent: 0,
average_percent: 0,
replicas: [],
},
},
};
return {
configEnabled: true,
loadBalancerConfig: {
lb_automation_enabled: false,
lb_automation_mode: "report_only",
hetzner_load_balancer_id: "123456",
hetzner_cloud_api_token: "[redacted]",
public_gateway_host: "api-v2.truckwash.io",
token_set: true,
},
loadBalancer: {
configured: true,
status: "degraded",
config: {
automation_enabled: false,
automation_mode: "report_only",
load_balancer_id: "123456",
public_gateway_host: "api-v2.truckwash.io",
token_set: true,
token_source: "config",
required_services: [
{ protocol: "http", listen_port: 80, destination_port: 80 },
{ protocol: "tcp", listen_port: 443, destination_port: 443 },
],
},
gateways: [
{
id: 1,
hostname: "node1.truckwash.io",
target_ip: "94.130.142.41",
enabled: true,
priority: 10,
health_state: "ok",
lb_state: "in_lb",
},
{
id: 2,
hostname: "node2.truckwash.io",
target_ip: "65.21.214.30",
enabled: true,
priority: 20,
health_state: "ok",
lb_state: "missing",
},
{
id: 3,
hostname: "node3.truckwash.io",
target_ip: "23.88.23.183",
enabled: true,
priority: 30,
health_state: "ok",
lb_state: "in_lb",
},
],
load_balancer: {
id: 123456,
name: "truckwash-gateway",
ipv4: "203.0.113.10",
ipv6: null,
location: "fsn1",
algorithm: "round_robin",
targets: ["94.130.142.41", "23.88.23.183"],
services: [{ protocol: "http", listen_port: 80, destination_port: 80, proxyprotocol: false }],
},
drift: {
has_drift: true,
actions: [
{ type: "add_target", target_ip: "65.21.214.30", hostname: "node2.truckwash.io" },
{ type: "add_service", protocol: "tcp", listen_port: 443, destination_port: 443 },
],
},
last_error: null,
},
instances: [
{
id: 1,
label: "Production Coolify",
base_url: "https://coolify.example.test",
api_token_set: true,
status: "ok",
last_checked_at: "2026-05-18T08:00:00.000Z",
},
],
targets: [
{
id: 7,
instance_id: 1,
instance_label: "Production Coolify",
kind: "database",
label: "mariadb-replica-1",
role: "replica",
server_uuid: "server-uuid",
project_uuid: "project-uuid",
environment_uuid: "environment-uuid",
environment_name: "production",
resource_name: "truckwash-database-7",
resource_uuid: "resource-uuid",
deployment_status: "provisioned",
availability_state: "failover_ready",
last_reconcile_status: "ok",
replication: {
host_id: 2,
label: "mariadb-replica-1",
host: "db-replica.internal",
port: 3307,
role: "replica",
status: "ok",
},
},
],
replicationSummary: summary,
createdKinds: [],
createdPayloads: [],
provisionRequests: [],
deployRequests: [],
renamedHosts: [],
removedHosts: [],
reconciledTargets: [],
lbReconciles: [],
routeDeploys: [],
apiCodeDeploys: [],
routePlans: [],
apiCodePlans: [],
placement: {
generated_at: "2026-05-18T08:00:00.000Z",
servers: [
{
id: 1,
uuid: "server-uuid",
name: "Production Server",
ip: "host.docker.internal",
public_ip: "94.130.142.41",
is_reachable: true,
is_usable: true,
},
{
id: 2,
uuid: "fallback-server-uuid",
name: "Fallback Server",
ip: "10.0.0.11",
public_ip: "94.130.142.42",
is_reachable: true,
is_usable: true,
},
],
projects: [
{
id: 1,
uuid: "project-uuid",
name: "Truckwash Infrastructure",
description: "",
},
],
environments: [
{
id: 1,
uuid: "environment-uuid",
name: "production",
project_uuid: "project-uuid",
project_name: "Truckwash Infrastructure",
},
],
destination_discovery_supported: false,
errors: {},
},
};
}
async function installCoolifyMocks(page, state) {
const coolifyConfigRows = () => [
{
module: "Coolify",
variable: "enabled",
type: "bool",
value: state.configEnabled,
},
{
module: "Coolify",
variable: "lb_automation_enabled",
type: "bool",
value: state.loadBalancerConfig.lb_automation_enabled,
},
{
module: "Coolify",
variable: "lb_automation_mode",
type: "string",
value: state.loadBalancerConfig.lb_automation_mode,
},
{
module: "Coolify",
variable: "hetzner_load_balancer_id",
type: "string",
value: state.loadBalancerConfig.hetzner_load_balancer_id,
},
{
module: "Coolify",
variable: "hetzner_cloud_api_token",
type: "string",
value: state.loadBalancerConfig.hetzner_cloud_api_token,
secret_set: state.loadBalancerConfig.token_set,
},
{
module: "Coolify",
variable: "public_gateway_host",
type: "string",
value: state.loadBalancerConfig.public_gateway_host,
},
];
await page.route(/\/coolify\/config(?:\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON?.() || {};
const updates = Object.prototype.hasOwnProperty.call(payload, "variable")
? { [payload.variable]: payload.value }
: payload;
for (const [variable, value] of Object.entries(updates)) {
if (variable === "enabled") {
state.configEnabled = Boolean(value);
} else if (variable === "hetzner_cloud_api_token") {
state.loadBalancerConfig.hetzner_cloud_api_token = "[redacted]";
state.loadBalancerConfig.token_set = String(value || "").trim() !== "";
} else if (Object.prototype.hasOwnProperty.call(state.loadBalancerConfig, variable)) {
state.loadBalancerConfig[variable] = value;
}
}
state.loadBalancer.config = {
...state.loadBalancer.config,
automation_enabled: Boolean(state.loadBalancerConfig.lb_automation_enabled),
automation_mode: state.loadBalancerConfig.lb_automation_mode,
load_balancer_id: state.loadBalancerConfig.hetzner_load_balancer_id,
public_gateway_host: state.loadBalancerConfig.public_gateway_host,
token_set: state.loadBalancerConfig.token_set,
};
await route.fulfill(json({ data: { updated: true } }));
return;
}
await route.fulfill(
json({
data: coolifyConfigRows(),
})
);
});
await page.route(/\/superuser\/coolify\/load-balancer$/, async (route) => {
await route.fulfill(json({ data: state.loadBalancer }));
});
await page.route(/\/superuser\/coolify\/load-balancer\/reconcile$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
state.lbReconciles.push({ dry_run: dryRun });
const planned = [...(state.loadBalancer.drift.actions || [])];
if (!dryRun) {
state.loadBalancer.status = "ok";
state.loadBalancer.drift = { has_drift: false, actions: [] };
state.loadBalancer.load_balancer.targets = ["94.130.142.41", "65.21.214.30", "23.88.23.183"];
state.loadBalancer.load_balancer.services = [
{ protocol: "http", listen_port: 80, destination_port: 80, proxyprotocol: false },
{ protocol: "tcp", listen_port: 443, destination_port: 443, proxyprotocol: false },
];
state.loadBalancer.gateways = state.loadBalancer.gateways.map((gateway) => ({
...gateway,
lb_state: gateway.enabled ? "in_lb" : "disabled_absent",
}));
}
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
...state.loadBalancer,
},
})
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/routes\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
state.routeDeploys.push({ dry_run: dryRun });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_route",
target_id: 13,
channel_slug: "internal",
app: "api",
resource_uuid: "api-application-uuid",
resource_type: "application",
public_url: `${gatewayBaseUrl}/internal/api`,
frontend_public_url: `${gatewayBaseUrl}/internal/frontend`,
target_ip: "65.21.214.30",
},
];
state.routePlans.push(planned);
const warnings = [
"No managed Coolify API application route was found for gateway targets: 94.130.142.41, 23.88.23.183.",
];
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings,
coverage: {
enabled_gateway_ips: ["94.130.142.41", "65.21.214.30", "23.88.23.183"],
covered_target_ips: ["65.21.214.30"],
uncovered_gateway_ips: ["94.130.142.41", "23.88.23.183"],
},
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/load-balancer\/api\/deploy$/, async (route) => {
const payload = route.request().postDataJSON?.() || {};
const dryRun = payload.dry_run !== false;
const deployRoutes = payload.deploy_routes !== false;
state.apiCodeDeploys.push({ dry_run: dryRun, deploy_routes: deployRoutes });
const gatewayBaseUrl = `https://${state.loadBalancer.config.public_gateway_host || "api-v2.truckwash.io"}`;
const planned = [
{
type: "deploy_gateway_api_code",
target_id: 13,
channel_slug: "internal",
app: "api",
repository: "copenhagentruckwash/api",
branch: "master",
resource_uuid: "api-application-uuid",
public_url: `${gatewayBaseUrl}/internal/api`,
commit_mode: "latest",
},
];
state.apiCodePlans.push(planned);
const routeDeploy = deployRoutes
? {
ok: true,
dry_run: false,
mutated: true,
planned,
applied: planned,
skipped: [],
errors: [],
warnings: [],
}
: null;
await route.fulfill(
json({
data: {
ok: true,
dry_run: dryRun,
mutated: !dryRun,
deploy_routes: deployRoutes,
public_host: state.loadBalancer.config.public_gateway_host,
public_url: `${gatewayBaseUrl}/internal/api`,
planned,
applied: dryRun ? [] : planned,
skipped: [],
errors: [],
warnings: [],
deployment_wait: {
ok: true,
results: [
{
deployment_uuid: "deployment-uuid",
status: "finished_or_not_running",
},
],
pending: [],
},
route_deploy: routeDeploy,
gateways: state.loadBalancer.gateways,
},
})
);
});
await page.route(/\/superuser\/coolify\/gateways$/, async (route) => {
await route.fulfill(json({ data: state.loadBalancer.gateways }));
});
await page.route(/\/superuser\/coolify\/gateways\/\d+\/test$/, async (route) => {
const id = Number(
route
.request()
.url()
.match(/\/gateways\/(\d+)\/test$/)?.[1] || 0
);
const gateway = state.loadBalancer.gateways.find((entry) => entry.id === id);
if (gateway) {
gateway.health_state = "ok";
gateway.last_probe = { ok: true, status_code: 200, path: "/ping" };
}
await route.fulfill(json({ data: { gateway, test: { ok: true, status_code: 200 } } }));
});
await page.route(/\/superuser\/coolify\/instances\/\d+\/test$/, async (route) => {
const instance = state.instances[0];
instance.status = "ok";
instance.last_checked_at = "2026-05-18T08:05:00.000Z";
await route.fulfill(
json({
data: {
ok: true,
instance,
response: { data: [] },
},
})
);
});
await page.route(/\/superuser\/coolify\/instances\/\d+\/placement$/, async (route) => {
await route.fulfill(
json({
data: {
...state.placement,
instance: state.instances[0],
},
})
);
});
await page.route(/\/superuser\/coolify\/targets\/\d+\/reconcile$/, async (route) => {
const id = Number(
route
.request()
.url()
.match(/\/targets\/(\d+)\/reconcile$/)?.[1] || 0
);
state.reconciledTargets.push(id);
const target = state.targets.find((entry) => entry.id === id);
if (target) {
target.last_reconcile_status = "reconciled";
target.deployment_status = "provisioned";
target.availability_state = "failover_ready";
}
await route.fulfill(json({ data: { ok: true, target } }));
});
await page.route(/\/superuser\/coolify\/targets\/\d+\/(?:deploy|restart|failover)$/, async (route) => {
const match = route
.request()
.url()
.match(/\/targets\/(\d+)\/(deploy|restart|failover)$/);
const id = Number(match?.[1] || 0);
const action = match?.[2] || "";
if (action === "deploy") {
state.deployRequests.push(id);
}
await route.fulfill(json({ data: { ok: true } }));
});
await page.route(/\/superuser\/replication\/databases\/\d+\/provision$/, async (route) => {
const id = Number(
route
.request()
.url()
.match(/\/databases\/(\d+)\/provision$/)?.[1] || 0
);
state.provisionRequests.push(id);
const host = state.replicationSummary.database.hosts.find((entry) => Number(entry.id) === id);
if (host) {
host.status = "ok";
host.replication_percent = 100;
host.last_status = { status: "ok", replication_percent: 100, blockers: [] };
host.active_operation = null;
if (host.coolify) {
host.coolify.deployment_status = "provisioned";
host.coolify.availability_state = "failover_ready";
}
}
await route.fulfill(
json({
data: {
ok: true,
message: "MariaDB replication was configured with GTID slave_pos.",
replication_percent: 100,
blockers: [],
host,
},
})
);
});
await page.route(/\/superuser\/replication\/(?:databases|redis|minio)\/\d+$/, async (route) => {
const method = route.request().method();
const match = route
.request()
.url()
.match(/\/superuser\/replication\/(databases|redis|minio)\/(\d+)$/);
const pathKind = match?.[1] || "databases";
const kind = pathKind === "databases" ? "database" : pathKind;
const id = Number(match?.[2] || 0);
if (method === "DELETE") {
state.replicationSummary[kind].hosts = state.replicationSummary[kind].hosts.filter(
(entry) => Number(entry.id) !== id
);
state.replicationSummary[kind].replication = {
...state.replicationSummary[kind].replication,
replicas: state.replicationSummary[kind].replication.replicas.filter((entry) => Number(entry.id) !== id),
};
state.targets = state.targets.filter((entry) => Number(entry.replication?.host_id) !== id);
state.removedHosts.push({ kind, id });
await route.fulfill(json({ data: { ok: true, id } }));
return;
}
if (method !== "PATCH") {
await route.fulfill(json({ data: { ok: true, id } }));
return;
}
const payload = route.request().postDataJSON?.() || {};
const label = String(payload.label || "").trim();
const host = state.replicationSummary[kind]?.hosts?.find((entry) => Number(entry.id) === id);
if (host && label) {
host.label = label;
if (host.coolify) {
host.coolify.resource_name = host.coolify.resource_name || label;
}
}
const target = state.targets.find((entry) => Number(entry.replication?.host_id) === id);
if (target && label) {
target.label = label;
target.replication.label = label;
}
state.renamedHosts.push({ kind, id, label });
await route.fulfill(json({ data: host || { id, kind, label } }));
});
await page.route(/\/superuser\/coolify\/targets\/\d+$/, async (route) => {
if (route.request().method() !== "DELETE") {
await route.fulfill(json({ data: {} }));
return;
}
const id = Number(
route
.request()
.url()
.match(/\/targets\/(\d+)$/)?.[1] || 0
);
state.targets = state.targets.filter((entry) => entry.id !== id);
await route.fulfill(json({ data: { ok: true, target_id: id } }));
});
await page.route(/\/superuser\/coolify\/instances$/, async (route) => {
if (route.request().method() !== "POST") {
await route.fulfill(json({ data: state.instances }));
return;
}
const payload = route.request().postDataJSON?.() || {};
const instance = {
id: state.instances.length + 1,
label: payload.label || "Coolify",
base_url: payload.base_url || "https://coolify.example.test",
api_token_set: true,
status: "unknown",
last_checked_at: null,
};
state.instances.push(instance);
await route.fulfill(json({ data: instance }, 201));
});
await page.route(/\/superuser\/coolify\/targets$/, async (route) => {
if (route.request().method() !== "POST") {
await route.fulfill(json({ data: state.targets }));
return;
}
const payload = route.request().postDataJSON?.() || {};
state.createdPayloads.push(payload);
const targetId = state.targets.length + 10;
const hostId = targetId + 100;
const host = {
id: hostId,
kind: payload.kind,
label: payload.label || payload.service_name || `${payload.kind}-replica-coolify`,
host: payload.host,
port: payload.port,
role: "replica",
status: "ok",
replication_percent: 100,
last_status: { status: "ok", replication_percent: 100, blockers: [] },
last_checked_at: "2026-05-18T08:10:00.000Z",
deployment_provider: "coolify",
availability_state: "failover_ready",
coolify: {
target_id: targetId,
instance_id: Number(payload.instance_id || 1),
instance_label: "Production Coolify",
deployment_status: "provisioned",
availability_state: "failover_ready",
},
};
const target = {
id: targetId,
instance_id: Number(payload.instance_id || 1),
instance_label: "Production Coolify",
kind: payload.kind,
label: host.label,
role: "replica",
server_uuid: payload.server_uuid || "",
project_uuid: payload.project_uuid || "",
environment_uuid: payload.environment_uuid || "",
environment_name: payload.environment_name || "",
resource_name: `truckwash-${payload.kind}-${targetId}`,
resource_uuid: `resource-${targetId}`,
deployment_status: "provisioned",
availability_state: "failover_ready",
last_reconcile_status: "created",
replication: {
host_id: hostId,
label: host.label,
host: host.host,
port: host.port,
role: "replica",
status: "ok",
},
};
state.targets.push(target);
state.createdKinds.push(payload.kind);
state.replicationSummary[payload.kind].hosts.push(host);
state.replicationSummary[payload.kind].replication = {
status: "ok",
min_percent: 100,
average_percent: 100,
replicas: [host],
};
await route.fulfill(json({ data: { target, host } }, 201));
});
await page.route(/\/superuser\/coolify(?:\?.*)?$/, async (route) => {
await route.fulfill(
json({
data: {
instances: state.instances,
targets: state.targets,
availability: {
database: { status: "protected", targets: 1, protected: 1, blocked: 0 },
redis: { status: "not_configured", targets: 0, protected: 0, blocked: 0 },
minio: { status: "not_configured", targets: 0, protected: 0, blocked: 0 },
},
load_balancer: state.loadBalancer,
},
})
);
});
await page.route(/\/superuser\/replication(?:\?.*)?$/, async (route) => {
await route.fulfill(json({ data: state.replicationSummary }));
});
}
test.describe("Coolify infrastructure management", () => {
test("@smoke opens the current Coolify integration surface from the legacy route", async ({ page }) => {
await boot(page);
await page.goto("/superuser/configuration/coolify");
await expect(page).toHaveURL(/\/superuser\/configuration\/releases\/integrations\?panel=coolify/);
await expect(page.getByRole("heading", { name: "Release Manager" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("release-coolify-panel")).toContainText("Coolify");
});
test("@smoke creates Coolify-backed database, Redis, and MinIO replicas from replication management", async ({
page,
}) => {
const state = await boot(page);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await createDatabaseReplica(page);
await createRedisReplica(page);
await createMinioReplica(page);
expect(state.createdKinds).toEqual(expect.arrayContaining(["database", "redis", "minio"]));
expect(state.createdPayloads).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "database",
host: "94.130.142.42",
port: 3307,
database: "nnks_db",
username: "nnks_db_user",
project_uuid: "project-uuid",
environment_name: "production",
server_uuid: "fallback-server-uuid",
}),
expect.objectContaining({
kind: "redis",
host: "94.130.142.41",
port: 6380,
server_uuid: "server-uuid",
}),
expect.objectContaining({
kind: "minio",
host: "94.130.142.41",
port: 9010,
replication_transfer_limit: "15Mi",
options: expect.objectContaining({
replication_transfer_limit: "15Mi",
}),
server_uuid: "server-uuid",
}),
])
);
await expect(page.locator("body")).toContainText("Coolify: provisioned / failover_ready");
const coolifyHost = page
.locator('[data-testid^="replication-host-database-"]')
.filter({ hasText: "coolify-db-replica" });
await expect(coolifyHost).toContainText("Coolify: provisioned / failover_ready");
const actions = page.locator('[data-testid^="replication-actions-database-"]').last();
await actions.locator(".action-settings-wheel-trigger").click();
await expect(actions.getByRole("button", { name: "Reconcile Coolify" })).toBeVisible();
await expect(actions.getByRole("button", { name: "Coolify failover" })).toBeVisible();
await actions.getByRole("button", { name: "Reconcile Coolify" }).click();
expect(state.reconciledTargets.length).toBeGreaterThan(0);
});
test("resumes Coolify-backed provisioning after page reload", async ({ page }) => {
const state = createCoolifyState();
const provisioningHost = {
id: 2,
kind: "database",
label: "mariadb-replica-1",
host: "10.0.0.11",
port: 3307,
role: "replica",
status: "provisioning",
replication_percent: 13.8,
last_status: { status: "provisioning", replication_percent: 13.8, blockers: [] },
active_operation: {
id: 29,
operation: "provision",
status: "running",
progress_percent: 13.8,
message: "Creating replica schema 49 of 111.",
},
deployment_provider: "coolify",
coolify: {
target_id: 7,
instance_id: 1,
instance_label: "Production Coolify",
deployment_status: "deploying",
availability_state: "failover_blocked",
},
};
state.replicationSummary.database.hosts.push(provisioningHost);
state.replicationSummary.database.replication = {
status: "degraded",
min_percent: 13.8,
average_percent: 56.9,
replicas: [provisioningHost],
};
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
await expect.poll(() => state.provisionRequests.includes(2)).toBeTruthy();
await expect(page.getByTestId("replication-host-database-2")).toContainText(
"Coolify: provisioned / failover_ready"
);
});
test("shows Coolify deployment progress while waiting for the service endpoint", async ({ page }) => {
const state = createCoolifyState();
const deployingHost = {
id: 22,
kind: "minio",
label: "minio-replica-2",
host: "10.0.0.11",
port: 9010,
role: "replica",
status: "unknown",
replication_percent: 0,
last_status: { status: "unknown", replication_percent: 0, blockers: [] },
last_checked_at: null,
deployment_provider: "coolify",
coolify: {
target_id: 17,
instance_id: 1,
instance_label: "Production Coolify",
deployment_status: "deploying",
last_reconcile_status: "provision_deferred",
availability_state: "failover_blocked",
},
};
state.targets.push({
id: 17,
instance_id: 1,
instance_label: "Production Coolify",
kind: "minio",
label: "minio-replica-2",
role: "replica",
server_uuid: "fallback-server-uuid",
project_uuid: "project-uuid",
environment_uuid: "environment-uuid",
environment_name: "production",
resource_name: "truckwash-minio-17",
resource_uuid: "resource-17",
deployment_status: "deploying",
availability_state: "failover_blocked",
last_reconcile_status: "provision_deferred",
replication: {
host_id: 22,
label: "minio-replica-2",
host: "10.0.0.11",
port: 9010,
role: "replica",
status: "unknown",
},
});
state.replicationSummary.minio.hosts.push(deployingHost);
state.replicationSummary.minio.replication = {
status: "degraded",
min_percent: 0,
average_percent: 0,
replicas: [deployingHost],
};
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: 60_000 });
const row = page.getByTestId("replication-host-minio-22");
await expect(row).toContainText("Coolify: deploying / failover_blocked");
await expect(row.getByTestId("replication-host-progress")).toBeVisible();
await expect(row).toContainText("45.0%");
await expect(row).toContainText(/Waiting for the service port|Venter på serviceporten/);
await expect.poll(() => state.deployRequests.includes(17)).toBeTruthy();
});
test("removes a Coolify-backed replica after its Coolify service disappears", async ({ page }) => {
const state = createCoolifyState();
const missingServiceHost = {
id: 22,
kind: "minio",
label: "minio-replica-missing-service",
host: "94.130.142.41",
port: 9010,
endpoint: "http://94.130.142.41:9010",
role: "replica",
status: "provisioning",
replication_percent: 45,
last_status: { status: "provisioning", replication_percent: 45, blockers: ["MinIO replica has not caught up."] },
last_checked_at: "2026-05-18T08:13:00.000Z",
deployment_provider: "coolify",
availability_state: "failover_blocked",
coolify: {
target_id: 17,
instance_id: 1,
instance_label: "Production Coolify",
deployment_status: "reconcile_failed",
availability_state: "failover_blocked",
last_reconcile_status: "reconcile_failed",
},
};
state.replicationSummary.minio.hosts.push(missingServiceHost);
state.replicationSummary.minio.replication = {
status: "degraded",
min_percent: 45,
average_percent: 45,
replicas: [missingServiceHost],
};
state.targets.push({
id: 17,
instance_id: 1,
instance_label: "Production Coolify",
kind: "minio",
label: "minio-replica-missing-service",
role: "replica",
resource_name: "truckwash-minio-17",
resource_uuid: "missing-resource-uuid",
deployment_status: "reconcile_failed",
availability_state: "failover_blocked",
last_reconcile_status: "reconcile_failed",
replication: {
host_id: 22,
label: "minio-replica-missing-service",
host: "94.130.142.41",
port: 9010,
role: "replica",
status: "provisioning",
},
});
await boot(page, state);
await page.goto("/superuser/system/replication");
const row = page.getByTestId("replication-host-minio-22");
await expect(row).toContainText("Coolify: reconcile_failed / failover_blocked");
const actions = page.getByTestId("replication-actions-minio-22");
await actions.locator(".action-settings-wheel-trigger").click();
await actions.getByRole("button", { name: /Remove|Fjern/ }).click();
expect(state.removedHosts).toContainEqual({ kind: "minio", id: 22 });
await expect(page.getByTestId("replication-host-minio-22")).toHaveCount(0);
});
test("renames a replication host from the action menu", async ({ page }) => {
const state = createCoolifyState();
const manualReplica = {
id: 2,
kind: "database",
label: "mariadb-replica-1",
host: "db-replica.internal",
port: 3307,
role: "replica",
status: "ok",
replication_percent: 100,
last_status: { status: "ok", replication_percent: 100, blockers: [] },
last_checked_at: "2026-05-18T08:10:00.000Z",
};
state.replicationSummary.database.hosts.push(manualReplica);
state.replicationSummary.database.replication = {
status: "ok",
min_percent: 100,
average_percent: 100,
replicas: [manualReplica],
};
await boot(page, state);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-host-database-2")).toContainText("mariadb-replica-1");
page.once("dialog", async (dialog) => {
expect(dialog.message()).toMatch(/new host label|nyt navn/);
expect(dialog.defaultValue()).toBe("mariadb-replica-1");
await dialog.accept("mariadb-manual-replica");
});
const actions = page.getByTestId("replication-actions-database-2");
await actions.locator(".action-settings-wheel-trigger").click();
await actions.getByRole("button", { name: /Rename|Omdøb/ }).click();
expect(state.renamedHosts).toContainEqual({ kind: "database", id: 2, label: "mariadb-manual-replica" });
await expect(page.getByTestId("replication-host-database-2")).toContainText("mariadb-manual-replica");
});
});
async function createDatabaseReplica(page) {
const panel = page.getByTestId("replication-add-database");
await panel.locator('input[type="text"]').nth(0).fill("coolify-db-replica");
await page.getByTestId("replication-coolify-database").locator('input[type="checkbox"]').first().check();
await expect(page.getByTestId("replication-coolify-database")).toContainText("Fallback Server");
await panel.locator('button[type="submit"]').click();
await expect(page.locator("body")).toContainText("coolify-db-replica");
}
async function createRedisReplica(page) {
await page.getByTestId("replication-add-host-tabs").locator(".tabs").getByText("Redis", { exact: true }).click();
const panel = page.getByTestId("replication-add-redis");
await panel.locator('input[type="text"]').nth(0).fill("coolify-redis-replica");
await page.getByTestId("replication-coolify-redis").locator('input[type="checkbox"]').first().check();
await expect(page.getByTestId("replication-coolify-redis")).toContainText("Production Server");
await panel.locator('button[type="submit"]').click();
await expect(page.locator("body")).toContainText("coolify-redis-replica");
}
async function createMinioReplica(page) {
await page.getByTestId("replication-add-host-tabs").locator(".tabs").getByText("MinIO", { exact: true }).click();
const panel = page.getByTestId("replication-add-minio");
await panel.locator('input[type="text"]').nth(0).fill("coolify-minio-replica");
await page.getByTestId("replication-minio-transfer-limit").selectOption("15Mi");
await expect(page.getByTestId("replication-minio-transfer-limit")).toHaveValue("15Mi");
await page.getByTestId("replication-coolify-minio").locator('input[type="checkbox"]').first().check();
await expect(page.getByTestId("replication-coolify-minio")).toContainText("Production Server");
await panel.locator('button[type="submit"]').click();
await expect(page.locator("body")).toContainText("coolify-minio-replica");
}