1082 lines
35 KiB
JavaScript
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");
|
|
}
|