Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions source/compose.manager/include/AutoUpdate.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@
// Resolve project name - always use compose-safe sanitized projectName.
$stackInfo = StackInfo::fromComposePath($compose_root, $path);
if ($stackInfo !== null) {
if (!$stackInfo->hasResolvedIdentity()) {
composeLogger(
"Blocked manual auto-update for '{$stackInfo->projectFolder}': compose project identity is unresolved",
['identity' => $stackInfo->identity->toArray()],
'user',
'warning',
'identity'
);
http_response_code(409);
echo json_encode(array('error' => $stackInfo->getIdentityBlockReason()));
break;
}
$projectName = $stackInfo->projectName;
} else {
$projectName = basename($path);
Expand Down
10 changes: 10 additions & 0 deletions source/compose.manager/include/AutoUpdateRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@
// because docker compose requires lowercase alphanumeric project names.
$stackInfo = StackInfo::fromComposePath($compose_root, $path);
if ($stackInfo !== null) {
if (!$stackInfo->hasResolvedIdentity()) {
composeLogger(
"Skipped scheduled auto-update for '{$stackInfo->projectFolder}': compose project identity is unresolved",
['identity' => $stackInfo->identity->toArray()],
'daemon',
'warning',
'identity'
);
continue;
}
$projectName = $stackInfo->projectName;
} else {
$projectName = StackInfo::sanitizeProjectString(basename($path));
Expand Down
17 changes: 17 additions & 0 deletions source/compose.manager/include/ComposeCommandBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static function buildForAction(StackInfo $stackInfo, string $action, ?str
{
$action = strtolower(trim($action));
self::assertSupportedAction($action);
self::assertResolvedIdentity($stackInfo, $action);

$args = $stackInfo->buildComposeArgs();

Expand Down Expand Up @@ -85,4 +86,20 @@ private static function assertSupportedAction(string $action): void
throw new \InvalidArgumentException('Unsupported compose action: ' . $action);
}
}

/**
* Refuse to build mutating arguments for a stack whose runtime project
* identity could not be proven (see {@see ProjectIdentity}).
*/
private static function assertResolvedIdentity(StackInfo $stackInfo, string $action): void
{
if ($action === 'logs' || $stackInfo->hasResolvedIdentity()) {
return;
}

throw new \RuntimeException(
"Compose project identity for '{$stackInfo->projectFolder}' is unresolved: "
. (string) $stackInfo->getIdentityBlockReason()
);
}
}
9 changes: 8 additions & 1 deletion source/compose.manager/include/ComposeList.php
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str
$invalidIndirectPath = $stackInfo->invalidIndirectPath;
$hasInvalidIndirect = ($invalidIndirectPath !== null && trim($invalidIndirectPath) !== '');
$invalidIndirectPathHtml = htmlspecialchars($invalidIndirectPath ?? '', ENT_QUOTES, 'UTF-8');
$identityBlocked = !$stackInfo->hasResolvedIdentity();
$identityMessageHtml = htmlspecialchars((string) $stackInfo->getIdentityBlockReason(), ENT_QUOTES, 'UTF-8');
$identityFolderHtml = htmlspecialchars($stackInfo->identity->folderCandidate, ENT_QUOTES, 'UTF-8');
$identityLegacyHtml = htmlspecialchars($stackInfo->identity->legacyCandidate, ENT_QUOTES, 'UTF-8');

// Status icon, label, color — derived from centralized getStackState()
$status = $stackState['state'];
Expand Down Expand Up @@ -342,7 +346,7 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str
$hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0';

// Main row - Docker tab structure with expand arrow on left
$o .= "<tr class='compose-sortable' id='stack-row-$id' data-project='$projectHtml' data-projectname='$projectNameHtml' data-path='$pathHtml' data-isup='$isup' data-profiles='$profilesJson' data-running-profile='$runningProfilesHtml' data-default-profile='$defaultProfilesHtml' data-webui='$webuiUrlHtml' data-containers='$containerNamesAttr' data-ctids='$containerIdsAttr' data-hasbuild='$hasBuild' data-invalid-indirect='" . ($hasInvalidIndirect ? '1' : '0') . "' data-invalid-indirect-path='$invalidIndirectPathHtml'>";
$o .= "<tr class='compose-sortable' id='stack-row-$id' data-project='$projectHtml' data-projectname='$projectNameHtml' data-path='$pathHtml' data-isup='$isup' data-profiles='$profilesJson' data-running-profile='$runningProfilesHtml' data-default-profile='$defaultProfilesHtml' data-webui='$webuiUrlHtml' data-containers='$containerNamesAttr' data-ctids='$containerIdsAttr' data-hasbuild='$hasBuild' data-invalid-indirect='" . ($hasInvalidIndirect ? '1' : '0') . "' data-invalid-indirect-path='$invalidIndirectPathHtml' data-identity-blocked='" . ($identityBlocked ? '1' : '0') . "' data-identity-message='$identityMessageHtml' data-identity-folder='$identityFolderHtml' data-identity-legacy='$identityLegacyHtml'>";

// Arrow column
$o .= "<td class='col-arrow'>";
Expand Down Expand Up @@ -376,6 +380,9 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str
], 'user', 'debug', 'stack-list');
$o .= " <i class='fa fa-warning orange-text' title='External compose path is invalid or unavailable: $invalidIndirectPathHtml'></i>";
}
if ($identityBlocked) {
$o .= " <i class='fa fa-exclamation-triangle red-text compose-identity-warning hand' title='$identityMessageHtml'></i>";
}
$o .= "<div class='compose-text-muted' style='margin-top:4px;font-size:0.85em;'>";
$o .= "Project: $projectHtml";
$o .= "</div>";
Expand Down
40 changes: 40 additions & 0 deletions source/compose.manager/include/Exec.php
Original file line number Diff line number Diff line change
Expand Up @@ -2428,4 +2428,44 @@ function composeResolveContainerIcon(string $containerName, string $service, arr
}
echo json_encode($out);
break;

case 'getProjectIdentities':
// Read-only migration preview: folder, display name, effective runtime
// project, live-container match, and whether owner input is required.
$identities = [];
foreach (StackInfo::allFromRoot($compose_root, true) as $stackInfo) {
$identity = $stackInfo->identity->toArray();
$identity['displayName'] = $stackInfo->getName();
$identities[] = $identity;
}
echo json_encode([
'result' => 'success',
'identities' => $identities,
'blocked' => count(array_filter($identities, static fn(array $i): bool => !$i['resolved'])),
]);
break;

case 'setProjectIdentity':
// Owner decision for a stack whose runtime identity could not be proven.
$stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : '';
$chosen = isset($_POST['projectName']) ? trim($_POST['projectName']) : '';
if ($stackName === '' || $chosen === '') {
echo json_encode(['result' => 'error', 'message' => 'Stack and project name are required.']);
break;
}
try {
$stackInfo = StackInfo::fromProject($compose_root, $stackName);
$stackInfo->applyIdentityChoice($chosen);
} catch (\Throwable $e) {
composeLogger('Failed to apply project identity choice', [
'stackName' => $stackName,
'projectName' => $chosen,
'error' => $e->getMessage(),
], 'user', 'error', 'identity');
echo json_encode(['result' => 'error', 'message' => $e->getMessage()]);
break;
}
composeLogger("Owner pinned compose project identity '$chosen' for '$stackName'", null, 'user', 'info', 'identity');
echo json_encode(['result' => 'success', 'identity' => $stackInfo->identity->toArray()]);
break;
}
50 changes: 49 additions & 1 deletion source/compose.manager/include/Helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,26 @@ function echoComposeCommand($action, array $options = [])
echo '';
return;
}

// Fail closed for mutating actions only; logs remains read-only.
if ($action !== 'logs' && !$stackInfo->hasResolvedIdentity()) {
composeLogger(
"Blocked '$action' for '{$stackInfo->projectFolder}': compose project identity is unresolved",
['action' => $action, 'path' => $path, 'identity' => $stackInfo->identity->toArray()],
'user',
'warning',
'identity'
);
echo json_encode([
'error' => 'identity',
'project' => $stackInfo->projectFolder,
'folderCandidate' => $stackInfo->identity->folderCandidate,
'legacyCandidate' => $stackInfo->identity->legacyCandidate,
'message' => $stackInfo->getIdentityBlockReason(),
]);
return;
}

$args = $stackInfo->buildComposeArgs();

$composeCommand[] = "-c" . $action;
Expand Down Expand Up @@ -278,6 +298,7 @@ function echoComposeCommandMultiple($action, array $options = [])
// Build a combined command that runs compose up/down for each stack sequentially
$commands = array();
$stackNames = array();
$blockedStacks = array();

foreach ($paths as $path) {
composeLogger("Processing stack for multi-compose action: " . $path, ['path' => $path, 'action' => $action], 'user', 'debug', 'compose-multi');
Expand All @@ -292,6 +313,20 @@ function echoComposeCommandMultiple($action, array $options = [])
composeLogger("Skipping invalid stack during multi-compose action", ['action' => $action, 'path' => $path, 'error' => $e->getMessage()], 'user', 'warning', 'compose-multi');
continue;
}

// Fail closed: never hand Docker Compose a project name we could not prove.
if (!$stackInfo->hasResolvedIdentity()) {
composeLogger(
"Skipping '{$stackInfo->projectFolder}' during multi-compose action: compose project identity is unresolved",
['action' => $action, 'path' => $path, 'identity' => $stackInfo->identity->toArray()],
'user',
'warning',
'identity'
);
$blockedStacks[] = $stackInfo->getName();
continue;
}

$stackNames[] = $stackInfo->getName();
$args = $stackInfo->buildComposeArgs();

Expand Down Expand Up @@ -345,7 +380,15 @@ function echoComposeCommandMultiple($action, array $options = [])
}

if (empty($commands)) {
composeLogger("Multi Compose operation aborted: no valid stacks resolved", ['action' => $action], 'user', 'warning', 'compose-multi');
composeLogger("Multi Compose operation aborted: no valid stacks resolved", ['action' => $action, 'blocked' => $blockedStacks], 'user', 'warning', 'compose-multi');
if (!empty($blockedStacks)) {
echo json_encode([
'error' => 'identity',
'stacks' => $blockedStacks,
'message' => 'Compose project identity is unresolved for: ' . implode(', ', $blockedStacks),
]);
return;
}
echo '';
return;
}
Expand Down Expand Up @@ -386,6 +429,11 @@ function echoComposeCommandMultiple($action, array $options = [])
$scriptContent = "#!/bin/bash\n";
$scriptContent .= "# Multi-stack compose script (ttyd) - auto-generated\n\n";

foreach ($blockedStacks as $blocked) {
$blockedTitle = str_replace(['\\', '"'], ['\\\\', '\\"'], $blocked);
$scriptContent .= "echo \"! Skipped " . $blockedTitle . ": compose project identity is unresolved\"\n";
}

foreach ($commands as $idx => $cmd) {
$cmdStr = implode(" ", array_map('escapeshellarg', $cmd));
$stackTitle = str_replace(['\\', '"'], ['\\\\', '\\"'], $stackNames[$idx]);
Expand Down
Loading
Loading