Add tests for Coolify app payload handling, e-conomic customer fields, and expand Coolify API client capabilities

This commit is contained in:
Jeppe Bundgaard
2026-05-19 16:55:45 +02:00
parent cbf3c2d2b9
commit c2263b8c98
11 changed files with 684 additions and 41 deletions
File diff suppressed because one or more lines are too long
@@ -64,6 +64,11 @@ class coolify_api_client
return $this->request('GET', '/services');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
}
public function getService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid));
@@ -110,6 +115,12 @@ class coolify_api_client
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
}
public function deployResource(string $uuid, bool $force = false): array
{
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
return $this->request('GET', $path);
}
public function startService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
+60 -3
View File
@@ -165,9 +165,17 @@ class economic implements economic_i
/**
* Create a customer in e-conomic and return the raw upstream payload.
*/
public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): object
public function createCustomer(
int $customer_number,
string $name,
int $cvr_number,
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
): object
{
return $this->customers->customers->create([
$payload = [
'customerNumber' => $customer_number,
'corporateIdentificationNumber' => (string)$cvr_number,
'customerGroup' => [
@@ -179,11 +187,60 @@ class economic implements economic_i
'name' => $name,
'email' => $email,
'phone' => $phone,
'telephoneAndFaxNumber' => (string)$phone,
'mobilePhone' => (string)($mobile_phone ?? $phone),
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
]
]);
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
return $this->customers->customers->create($payload);
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
return [];
}
$payload = [];
$field_map = [
'address' => 'address',
'zipcode' => 'zip',
'city' => 'city',
'website' => 'website',
];
foreach ($field_map as $source_field => $economic_field) {
$value = $this->companyInformationValue($company_information, $source_field);
if ($value === null) {
continue;
}
$payload[$economic_field] = $value;
}
return $payload;
}
private function companyInformationValue(object|array $company_information, string $field): ?string
{
if (is_array($company_information)) {
$value = $company_information[$field] ?? null;
} else {
$value = $company_information->{$field} ?? null;
}
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/**
+448 -32
View File
@@ -12,6 +12,7 @@ class release_manager
private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack'];
private const STACK_DATA_KINDS = ['database', 'redis', 'minio'];
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod', 'beta'];
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
private const SUBJECT_TYPES = ['user', 'subuser', 'customer'];
private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full'];
private const MODULE_KEYS = [
@@ -1066,6 +1067,73 @@ class release_manager
return $this->publicServiceSet($this->getServiceSet($id));
}
public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
$serviceSet = $this->getServiceSet($id);
if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') {
throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.');
}
if ($this->serviceSetIsActive($id)) {
throw new RuntimeException('The active release service set cannot be removed.');
}
$frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null);
$apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null);
$dataTargetIds = [];
foreach (self::STACK_DATA_KINDS as $kind) {
$dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null);
}
$this->execute(
"UPDATE release_bundles
SET status = 'removed', deleted_at = NOW()
WHERE service_set_id = ? AND deleted_at IS NULL",
'i',
[$id]
);
$this->execute(
"UPDATE release_deployments
SET status = 'removed'
WHERE service_set_id = ?",
'i',
[$id]
);
$this->execute(
"UPDATE release_service_sets
SET status = 'removed', deleted_at = NOW(), actor_user_id = ?
WHERE id = ?",
'ii',
[$actorUserId, $id]
);
foreach ([$frontendTargetId, $apiTargetId] as $targetId) {
if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) {
$this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
}
}
foreach ($dataTargetIds as $targetId) {
if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) {
$this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
}
}
$this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [
'service_set_id' => $id,
'mode' => 'isolated_stack',
'provider_resources_deleted' => false,
'frontend_target_id' => $frontendTargetId,
'api_target_id' => $apiTargetId,
'data_target_ids' => $dataTargetIds,
]);
return [
'id' => $id,
'removed' => true,
'provider_resources_deleted' => false,
];
}
public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array
{
$this->ensureSchema();
@@ -1308,6 +1376,20 @@ class release_manager
$serviceSetId = (int)$bundle['service_set_id'];
$this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
$this->execute(
"UPDATE release_bundles
SET status = 'superseded'
WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL",
'ii',
[$channelId, $bundleId]
);
$this->execute(
"UPDATE release_deployments
SET status = 'superseded'
WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'",
'ii',
[$channelId, $bundleId]
);
$this->execute(
"INSERT INTO release_channel_versions (
channel_id, frontend_version_id, api_version_id, deployment_id,
@@ -1478,7 +1560,12 @@ class release_manager
$result = ['message' => 'Deployment recorded; no Coolify service target is configured.'];
$status = 'queued';
if ($target !== null && !empty($target['coolify_instance_id'])) {
$result = $this->deployCoolifyReleaseTarget($target);
$coolifyTarget = array_replace($target, [
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha ?? '',
]);
$result = $this->deployCoolifyReleaseTarget($coolifyTarget);
$status = 'deployed';
}
@@ -2304,10 +2391,15 @@ class release_manager
$created = null;
if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) {
if ($this->releaseCoolifyGithubAppUuid($context) !== '') {
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
if ($githubAppUuid !== '') {
$context['coolify_github_app_uuid'] = $githubAppUuid;
$created = $client->createPrivateGithubAppApplication($this->releaseCoolifyApplicationPayload($target, $context, $instance));
$resourceType = 'application';
} else {
if ($this->releaseCoolifyServiceSourceIsMissing($context)) {
throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.');
}
$created = $client->createService($this->releaseCoolifyServicePayload($target, $context, $instance));
$resourceType = 'service';
}
@@ -2329,28 +2421,29 @@ class release_manager
$update = null;
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
if ($resourceType === 'application') {
$update = $client->updateApplication($serviceUuid, [
'domains' => $publicUrl,
'force_domain_override' => true,
]);
} else {
$update = $client->updateService($serviceUuid, [
'urls' => [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => $publicUrl,
],
],
'force_domain_override' => true,
]);
if ($resourceType === 'application') {
$applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
$applicationUpdate['domains'] = $publicUrl;
$applicationUpdate['is_force_https_enabled'] = true;
$applicationUpdate['force_domain_override'] = true;
}
if ($applicationUpdate !== []) {
$update = $client->updateApplication($serviceUuid, $applicationUpdate);
}
} elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
$update = $client->updateService($serviceUuid, [
'urls' => [
[
'name' => (string)($target['app'] ?? 'release'),
'url' => $publicUrl,
],
],
'force_domain_override' => true,
]);
}
$restart = $resourceType === 'application'
? $client->restartApplication($serviceUuid)
: $client->restartService($serviceUuid);
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
return [
'service_uuid' => $serviceUuid,
'resource_type' => $resourceType,
@@ -2358,7 +2451,7 @@ class release_manager
'public_url' => $publicUrl,
'created' => self::redactPayload($created ?? []),
'updated' => self::redactPayload($update ?? []),
'restart' => self::redactPayload($restart),
'deployment' => self::redactPayload($deployment),
];
}
@@ -2370,9 +2463,9 @@ class release_manager
throw new RuntimeException('Select a Coolify project for this release target before creating an application.');
}
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context);
$githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
if ($githubAppUuid === '') {
throw new RuntimeException('Coolify GitHub App UUID is required so Coolify can pull with the app token.');
throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.');
}
$serverUuid = $this->releaseCoolifyServerUuid($context, $instance);
@@ -2397,12 +2490,18 @@ class release_manager
'github_app_uuid' => $githubAppUuid,
'git_repository' => $repository,
'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
'build_pack' => $this->releaseCoolifyBuildPack($target, $context),
'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true),
'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true),
'force_domain_override' => true,
];
foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
$payload[$key] = $value;
}
if ($publicUrl !== null) {
$payload['domains'] = $publicUrl;
$payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false);
@@ -2415,6 +2514,27 @@ class release_manager
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array
{
$payload = [
'git_repository' => trim((string)($target['repository'] ?? '')),
'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
'build_pack' => $this->releaseCoolifyBuildPack($target, $context),
'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
];
foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
$payload[$key] = $value;
}
foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) {
$payload[$key] = $value;
}
return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
}
private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array
{
$publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
@@ -2480,16 +2600,101 @@ class release_manager
);
}
private function releaseCoolifyGithubAppUuid(array $context): string
private function releaseCoolifyServiceSourceIsMissing(array $context): bool
{
foreach (['coolify_github_app_uuid', 'github_app_uuid', 'coolify_git_app_uuid', 'git_app_uuid'] as $key) {
return trim((string)($context['docker_compose_raw'] ?? '')) === ''
&& $this->releaseCoolifyExplicitImage($context) === ''
&& !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false);
}
private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string
{
foreach ([
'coolify_github_app_uuid',
'github_app_uuid',
'coolify_git_app_uuid',
'git_app_uuid',
'default_github_app_uuid',
'default_coolify_github_app_uuid',
] as $key) {
$value = trim((string)($context[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
if (!$discover) {
return '';
}
return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance);
}
private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string
{
foreach ([
'default_github_app_uuid',
'default_coolify_github_app_uuid',
'coolify_github_app_uuid',
'github_app_uuid',
] as $key) {
$value = trim((string)($instance[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
foreach ([
getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null),
getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null),
$this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''),
$this->moduleConfigValue('Coolify', 'github_app_uuid', ''),
] as $value) {
$value = trim((string)$value);
if ($value !== '') {
return $value;
}
}
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return '';
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
} catch (Throwable) {
return '';
}
$rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool {
return is_array($row) && trim((string)($row['uuid'] ?? '')) !== '';
}));
if (count($rows) === 1) {
return trim((string)$rows[0]['uuid']);
}
$repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
$owner = strtolower(trim(strtok($repository, '/') ?: ''));
if ($owner === '') {
return '';
}
$matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool {
foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) {
$value = strtolower(trim((string)($row[$key] ?? '')));
if ($value === '') {
continue;
}
if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) {
return true;
}
}
return false;
}));
return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : '';
}
private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string
@@ -2507,12 +2712,90 @@ class release_manager
private function releaseCoolifyBuildPack(array $target, array $context): string
{
$app = strtolower(trim((string)($target['app'] ?? '')));
$buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? '')));
if ($buildPack !== '') {
if ($app === 'frontend' && $buildPack === 'nixpacks') {
return 'static';
}
return $buildPack;
}
return (string)($target['app'] ?? '') === 'api' ? 'dockerfile' : 'nixpacks';
return $app === 'api' ? 'dockerfile' : 'static';
}
private function releaseCoolifyPortsExposes(array $target, array $context): string
{
foreach ([
'coolify_ports_exposes',
'ports_exposes',
'coolify_exposed_port',
'exposed_port',
'coolify_port',
'port',
] as $key) {
$value = trim((string)($context[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
$app = strtolower(trim((string)($target['app'] ?? '')));
$envKeys = $app === 'api'
? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES']
: ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES'];
foreach ($envKeys as $key) {
$value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
if ($value !== '') {
return $value;
}
}
return self::DEFAULT_COOLIFY_APPLICATION_PORT;
}
private function releaseCoolifyGitCommitSha(array $target, array $context): string
{
foreach ([
'coolify_git_commit_sha',
'git_commit_sha',
'commit_sha',
'commit',
] as $key) {
$value = trim((string)($context[$key] ?? $target[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function releaseCoolifyForceRebuild(array $context): bool
{
if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) {
return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false);
}
return true;
}
private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array
{
if (strtolower(trim((string)($target['app'] ?? ''))) !== 'frontend') {
return [];
}
if ($this->releaseCoolifyBuildPack($target, $context) !== 'static') {
return [];
}
return [
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'publish_directory' => 'dist',
'is_static' => true,
'is_spa' => true,
];
}
private function releaseCoolifyApplicationOptionalFields(array $context): array
@@ -3244,6 +3527,7 @@ class release_manager
$coolifyInstances = [];
$coolifyProjects = [];
$coolifyServices = [];
$coolifyGithubApps = [];
if ($this->tableExists('coolify_instances')) {
$instanceRows = $this->selectRows(
'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid
@@ -3268,6 +3552,9 @@ class release_manager
foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) {
$coolifyProjects[] = $project;
}
foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) {
$coolifyGithubApps[] = $githubApp;
}
foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) {
$coolifyServices[] = $service;
$this->appendSuggestion($serviceUuids, $service['uuid'] ?? null);
@@ -3335,6 +3622,7 @@ class release_manager
'load_balancer_domains' => array_values($loadBalancerDomains),
'coolify_instances' => $coolifyInstances,
'coolify_projects' => $coolifyProjects,
'coolify_github_apps' => $coolifyGithubApps,
'coolify_services' => $coolifyServices,
'coolify_service_uuids' => array_values($serviceUuids),
'channel_presets' => $channelPresets,
@@ -3470,6 +3758,44 @@ class release_manager
return array_slice($suggestions, 0, 50);
}
private function coolifyGithubAppSuggestions(array $instance): array
{
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
if ($tokenSecret === '') {
return [];
}
try {
$token = replication_secret_box::decrypt($tokenSecret);
$apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
} catch (Throwable) {
return [];
}
$suggestions = [];
foreach ($this->payloadRows($apps) as $row) {
if (!is_array($row)) {
continue;
}
$uuid = trim((string)($row['uuid'] ?? ''));
if ($uuid === '') {
continue;
}
$suggestions[] = [
'instance_id' => (int)($instance['id'] ?? 0),
'instance_label' => (string)($instance['label'] ?? ''),
'uuid' => $uuid,
'name' => (string)($row['name'] ?? $uuid),
'organization' => (string)($row['organization'] ?? ''),
'type' => (string)($row['type'] ?? ''),
'is_system_wide' => (bool)($row['is_system_wide'] ?? false),
'html_url' => (string)($row['html_url'] ?? ''),
];
}
return array_slice($suggestions, 0, 50);
}
private function coolifyServiceSuggestions(array $instance): array
{
$tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
@@ -3532,7 +3858,7 @@ class release_manager
if (array_keys($payload) === range(0, count($payload) - 1)) {
return $payload;
}
foreach (['data', 'services', 'projects', 'servers', 'results'] as $key) {
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) {
if (is_array($payload[$key] ?? null)) {
return $this->payloadRows($payload[$key]);
}
@@ -4126,9 +4452,10 @@ class release_manager
}
$attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : [];
$serviceSetId = (int)($serviceSet['id'] ?? 0);
return [
'id' => (int)($serviceSet['id'] ?? 0),
'id' => $serviceSetId,
'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null,
'channel_slug' => $serviceSet['channel_slug'] ?? null,
'channel_name' => $serviceSet['channel_name'] ?? null,
@@ -4137,6 +4464,7 @@ class release_manager
'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'),
'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null,
'status' => (string)($serviceSet['status'] ?? 'unknown'),
'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId),
'targets' => [
'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)),
'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)),
@@ -4160,13 +4488,19 @@ class release_manager
private function publicBundle(array $bundle, bool $includeServiceSet = true): array
{
$bundleId = (int)($bundle['id'] ?? 0);
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
$apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null);
$active = $bundleId > 0 && $this->bundleIsActive($bundleId);
$status = (string)($bundle['status'] ?? 'draft');
if ($status === 'promoted' && !$active) {
$status = 'superseded';
}
return [
'id' => (int)($bundle['id'] ?? 0),
'id' => $bundleId,
'channel_id' => (int)($bundle['channel_id'] ?? 0),
'channel_slug' => (string)($bundle['channel_slug'] ?? ''),
'channel_name' => (string)($bundle['channel_name'] ?? ''),
@@ -4175,7 +4509,8 @@ class release_manager
'service_set_slug' => $bundle['service_set_slug'] ?? null,
'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null,
'version_label' => $bundle['version_label'] ?? null,
'status' => (string)($bundle['status'] ?? 'draft'),
'status' => $status,
'active' => $active,
'apps' => [
'frontend' => [
'version_id' => $frontendVersionId,
@@ -4228,6 +4563,87 @@ class release_manager
);
}
private function serviceSetIsActive(int $serviceSetId): bool
{
return $this->selectOne(
'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1',
'i',
[$serviceSetId]
) !== null;
}
private function bundleIsActive(int $bundleId): bool
{
return $this->selectOne(
'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1',
'i',
[$bundleId]
) !== null;
}
private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
{
if ($targetId === null || $targetId <= 0) {
return false;
}
$target = $this->nullableDeploymentTarget($targetId);
if ($target === null) {
return false;
}
$context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : [];
if (!$this->toBool($context['isolated_stack'] ?? false)) {
return false;
}
if ($this->toBool($context['production_data_attached'] ?? false)) {
return false;
}
return $this->selectOne(
"SELECT id
FROM release_service_sets
WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?)
LIMIT 1",
'iii',
[$serviceSetId, $targetId, $targetId]
) === null;
}
private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
{
if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) {
return false;
}
$target = $this->nullableCoolifyTarget($targetId);
if ($target === null) {
return false;
}
$options = is_array($target['options'] ?? null) ? $target['options'] : [];
if (!$this->toBool($options['isolated_stack'] ?? false)) {
return false;
}
if ($this->toBool($options['production_data_attached'] ?? false)) {
return false;
}
return $this->selectOne(
"SELECT id
FROM release_service_sets
WHERE id <> ? AND deleted_at IS NULL
AND (
database_coolify_target_id = ?
OR redis_coolify_target_id = ?
OR minio_coolify_target_id = ?
)
LIMIT 1",
'iiii',
[$serviceSetId, $targetId, $targetId, $targetId]
) === null;
}
private function nullableDeploymentTarget(?int $id): ?array
{
if ($id === null || $id <= 0) {
+5 -2
View File
@@ -451,14 +451,17 @@ class authRoute
$response->error('Company phone number already registered', 400);
}
// Get the customer name
$name = (new virkdata())->getCompanyInformation($cvr, '', [])->name;
// Get the CVR company information used for the e-conomic customer payload.
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
$name = (string)($companyInformation->name ?? '');
$result = $economic->createCustomer(
(int)$companyPhone,
$name,
(int)$cvr,
(string)$invoiceEmail,
(int)$companyPhone,
(int)$contactPhone,
$companyInformation,
);
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
@@ -245,6 +245,24 @@ class releaseManagerRoute
'superuser_release_manager_deploy' => 'Create reusable Release Manager service sets',
]);
$this->delete('/superuser/releases/service-sets/{id}', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
try {
$response->success(
(new release_manager())->deleteServiceSet(
$this->routeId(),
$this->requestPayload(),
$this->actorUserId()
)
);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 400);
}
}, [
'superuser_release_manager_deploy' => 'Remove inactive isolated Release Manager service sets',
]);
$this->post('/superuser/releases/service-sets/{id}/isolated-data-services', function () {
global $response;
$this->requirePermission('superuser_release_manager_deploy');
@@ -60,4 +60,40 @@ it('returns the raw upstream create response and preserves the requested payload
expect($probe->inner->lastPayload['customerNumber'])->toBe(42331123);
expect($probe->inner->lastPayload['corporateIdentificationNumber'])->toBe('37781258');
expect($probe->inner->lastPayload['phone'])->toBe(42331123);
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
expect($probe->inner->lastPayload['mobilePhone'])->toBe('42331123');
});
it('adds supported CVR company fields to the e-conomic customer payload', function (): void {
$stubResponse = (object)[
'customerNumber' => 42331123,
'name' => 'Truckwash ApS',
];
$companyInformation = (object)[
'address' => 'Testvej 12',
'zipcode' => 2630,
'city' => 'Taastrup',
'website' => 'https://truckwash.test',
'industrycode' => 953190,
];
$probe = new EconomicCreateCustomerProbe($stubResponse);
$result = $probe->createCustomer(
42331123,
'Truckwash ApS',
37781258,
'invoice@truckwash.test',
42331123,
55667788,
$companyInformation,
);
expect($result)->toBe($stubResponse);
expect($probe->inner->lastPayload['address'])->toBe('Testvej 12');
expect($probe->inner->lastPayload['zip'])->toBe('2630');
expect($probe->inner->lastPayload['city'])->toBe('Taastrup');
expect($probe->inner->lastPayload['website'])->toBe('https://truckwash.test');
expect($probe->inner->lastPayload['telephoneAndFaxNumber'])->toBe('42331123');
expect($probe->inner->lastPayload['mobilePhone'])->toBe('55667788');
expect(array_key_exists('industrycode', $probe->inner->lastPayload))->toBeFalse();
});
@@ -301,7 +301,9 @@ it('defines Coolify schema, route permissions, and replication integration hooks
$client = file_get_contents(app_path('classes/coolify_api_client.php'));
expect($client)->toContain("request('GET', '/health', null, false)");
expect($client)->toContain('/github-apps');
expect($client)->toContain('/applications/private-github-app');
expect($client)->toContain("'/deploy?uuid=' . rawurlencode(\$uuid)");
expect($client)->toContain('/applications/\' . rawurlencode($uuid) . \'/restart');
expect($client)->toContain('CURL_HTTP_VERSION_1_1');
expect($client)->toContain('validationErrorSummary');
@@ -163,11 +163,70 @@ it('creates Coolify GitHub App application payloads so pulls use the app token',
expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github');
expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue');
expect($payload['git_branch'])->toBe('release/canary');
expect($payload['build_pack'])->toBe('nixpacks');
expect($payload['build_pack'])->toBe('static');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['install_command'])->toBe('npm ci');
expect($payload['build_command'])->toBe('npm run build');
expect($payload['publish_directory'])->toBe('dist');
expect($payload['is_static'])->toBeTrue();
expect($payload['is_spa'])->toBeTrue();
expect($payload['domains'])->toBe('https://canary.example.test');
expect($payload)->not->toHaveKey('docker_compose_raw');
});
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'frontend',
'repository' => 'copenhagentruckwash/pleno-vue',
'branch' => 'master',
'commit_sha' => '1132c8c2560e44478d1bb777c88c762a5e1d0b20',
], [
'coolify_build_pack' => 'nixpacks',
]);
expect($payload['git_repository'])->toBe('copenhagentruckwash/pleno-vue');
expect($payload['git_branch'])->toBe('master');
expect($payload['git_commit_sha'])->toBe('1132c8c2560e44478d1bb777c88c762a5e1d0b20');
expect($payload['build_pack'])->toBe('static');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['install_command'])->toBe('npm ci');
expect($payload['build_command'])->toBe('npm run build');
expect($payload['publish_directory'])->toBe('dist');
expect($payload['is_static'])->toBeTrue();
expect($payload['is_spa'])->toBeTrue();
});
it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'api',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'coolify_project_uuid' => 'project-internal',
'coolify_deploy_now' => true,
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-default',
'default_github_app_uuid' => 'github-app-copenhagentruckwash-github',
]);
expect($payload['github_app_uuid'])->toBe('github-app-copenhagentruckwash-github');
expect($payload['git_repository'])->toBe('copenhagentruckwash/api');
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload)->not->toHaveKey('docker_compose_raw');
});
it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void {
$manager = new release_manager();
$resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType');
@@ -324,6 +383,7 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($route)->toContain('/superuser/releases/channels');
expect($route)->toContain('/superuser/releases/assignments');
expect($route)->toContain('/superuser/releases/service-sets');
expect($route)->toContain("\$this->delete('/superuser/releases/service-sets/{id}'");
expect($route)->toContain('/superuser/releases/service-sets/{id}/isolated-data-services');
expect($route)->toContain('/superuser/releases/bundles');
expect($route)->toContain('/superuser/releases/bundles/{id}/deploy');
@@ -355,11 +415,19 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('commit_mode');
expect($manager)->toContain('restartCoolifyService');
expect($manager)->toContain('deployCoolifyReleaseTarget');
expect($manager)->toContain('deployResource');
expect($manager)->toContain('releaseCoolifyGitCommitSha');
expect($manager)->toContain('releaseCoolifyForceRebuild');
expect($manager)->toContain('listServiceSets');
expect($manager)->toContain('createServiceSet');
expect($manager)->toContain('deleteServiceSet');
expect($manager)->toContain('createBundle');
expect($manager)->toContain('deployBundle');
expect($manager)->toContain('promoteBundle');
expect($manager)->toContain("status = 'superseded'");
expect($manager)->toContain('serviceSetIsActive');
expect($manager)->toContain('bundleIsActive');
expect($manager)->toContain('service_set_removed');
expect($manager)->toContain('clone_replica_from_source');
expect($manager)->toContain('register_isolated_empty_service');
expect($manager)->toContain('isolated_stack');
@@ -378,7 +446,10 @@ it('defines release manager schema, routes, permissions, and system-status integ
expect($manager)->toContain('coolify_project_uuid');
expect($manager)->toContain('releaseCoolifyServicePayload');
expect($manager)->toContain('releaseCoolifyApplicationPayload');
expect($manager)->toContain('releaseCoolifyApplicationUpdatePayload');
expect($manager)->toContain('createPrivateGithubAppApplication');
expect($manager)->toContain('coolifyGithubAppSuggestions');
expect($manager)->toContain('coolify_github_apps');
expect($manager)->toContain('release_deployment_targets');
expect($manager)->toContain('resolveChannel');
expect($manager)->toContain('capturePolicyFor');
@@ -101,7 +101,7 @@ namespace classes {
};
}
public function createCustomer($number, $name, $cvr, $email, $phone): object
public function createCustomer($number, $name, $cvr, $email, $phone, $mobilePhone = null, $companyInformation = null): object
{
self::$create_calls[] = [
'number' => (int)$number,
@@ -109,6 +109,8 @@ namespace classes {
'cvr' => (string)$cvr,
'email' => (string)$email,
'phone' => (int)$phone,
'mobile_phone' => $mobilePhone === null ? null : (int)$mobilePhone,
'company_information' => $companyInformation,
];
$response = self::$mock_create_response ?? (object)[
@@ -127,11 +129,19 @@ namespace classes {
class virkdata
{
public static string $mock_name = 'Mock Company';
public static string $mock_address = 'Demo Street 1';
public static int $mock_zipcode = 2630;
public static string $mock_city = 'Taastrup';
public static string $mock_website = 'https://demo.test';
public function getCompanyInformation($cvr, $endpoint, $data): object
{
$result = new \stdClass();
$result->name = self::$mock_name;
$result->address = self::$mock_address;
$result->zipcode = self::$mock_zipcode;
$result->city = self::$mock_city;
$result->website = self::$mock_website;
return $result;
}
@@ -426,7 +436,7 @@ namespace {
],
[
'name' => 'Successful registration bootstraps local user before welcome emails',
'params' => $baseParams,
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
'setup' => static function (): void {
\classes\economic::$mock_create_response = (object)[
'customerNumber' => 12345678,
@@ -440,6 +450,14 @@ namespace {
'expected_status' => 201,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 1, 'Fresh registration must call create exactly once.');
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
$companyInformation = \classes\economic::$create_calls[0]['company_information'];
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');
assert_true($companyInformation->zipcode === 2630, 'Fresh registration must pass the CVR zipcode to e-conomic.');
assert_true($companyInformation->city === 'Taastrup', 'Fresh registration must pass the CVR city to e-conomic.');
assert_true($companyInformation->website === 'https://demo.test', 'Fresh registration must pass the CVR website to e-conomic.');
assert_true(count(\classes\email::$sent) === 2, 'Fresh registration must send two welcome emails.');
assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Fresh registration must bootstrap the local user before emails.');
assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Fresh registration should notify the internal welcome recipient first.');
@@ -473,6 +491,10 @@ namespace {
\classes\economic::reset();
\classes\email::reset();
\classes\virkdata::$mock_name = 'Mock Company';
\classes\virkdata::$mock_address = 'Demo Street 1';
\classes\virkdata::$mock_zipcode = 2630;
\classes\virkdata::$mock_city = 'Taastrup';
\classes\virkdata::$mock_website = 'https://demo.test';
\objects\users_o::reset();
\objects\logs_o::reset();
+7
View File
@@ -85,6 +85,13 @@ Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb206
"to_date": "2026-04-30",
"closed_at": "2026-04-30"
}
### GET request to https://api.truckwash.io:4433/superuser/invoicing/period?dateFrom=2026-05-12&dateTo=2026-05-12&periodView=all&page=1&limit=100&search=&includeRequiresAction=1&includeBooked=1
GET https://api.truckwash.io/superuser/invoicing/period?dateFrom=2026-04-01&dateTo=2026-04-30&periodView=all&page=1&limit=all&search=&includeRequiresAction=1&includeBooked=1
Accept: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
Content-Type: application/json
### GET request to /subusers/setup
GET https://api.truckwash.dk:4433/subusers/setup?token=1c1be8280bac3937487e5c77b76bb839
Accept: application/json