This commit is contained in:
frostebite
2026-01-28 07:36:04 +00:00
parent e6b14c766d
commit c9af2e7562
17 changed files with 162 additions and 99 deletions

81
dist/index.js generated vendored
View File

@@ -1828,7 +1828,7 @@ class AwsClientFactory {
};
}
// Return undefined to let AWS SDK use default credential chain
return undefined;
return;
}
static getCloudFormation() {
if (!this.cloudFormation) {
@@ -3939,23 +3939,18 @@ class Kubernetes {
if (process.env['cloudRunnerTests'] === 'true') {
try {
cloud_runner_logger_1.default.log('Cleaning up old images in k3d node before pulling new image...');
const { CloudRunnerSystem } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(4197)));
const { CloudRunnerSystem: CloudRunnerSystemModule } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(4197)));
// Aggressive cleanup: remove stopped containers and non-Unity images
// IMPORTANT: Preserve Unity images (unityci/editor) to avoid re-pulling the 3.9GB image
const K3D_NODE_CONTAINERS = ['k3d-unity-builder-agent-0', 'k3d-unity-builder-server-0'];
const cleanupCommands = [];
for (const NODE of K3D_NODE_CONTAINERS) {
// Remove all stopped containers (this frees runtime space but keeps images)
cleanupCommands.push(`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`);
// Remove non-Unity images only (preserve unityci/editor images to avoid re-pulling 3.9GB)
// This is safe because we explicitly exclude Unity images from deletion
cleanupCommands.push(`docker exec ${NODE} sh -c "for img in \$(crictl images -q 2>/dev/null); do repo=\$(crictl inspecti \$img --format '{{.repo}}' 2>/dev/null || echo ''); if echo \"\$repo\" | grep -qvE 'unityci/editor|unity'; then crictl rmi \$img 2>/dev/null || true; fi; done" || true`);
// Clean up unused layers (prune should preserve referenced images)
cleanupCommands.push(`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`);
cleanupCommands.push(`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`, `docker exec ${NODE} sh -c "for img in $(crictl images -q 2>/dev/null); do repo=$(crictl inspecti $img --format '{{.repo}}' 2>/dev/null || echo ''); if echo "$repo" | grep -qvE 'unityci/editor|unity'; then crictl rmi $img 2>/dev/null || true; fi; done" || true`, `docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`);
}
for (const cmd of cleanupCommands) {
try {
await CloudRunnerSystem.Run(cmd, true, true);
await CloudRunnerSystemModule.Run(cmd, true, true);
}
catch (cmdError) {
// Ignore individual command failures - cleanup is best effort
@@ -3975,14 +3970,14 @@ class Kubernetes {
// If not cached, try to ensure it's available to avoid disk pressure during pull
if (process.env['cloudRunnerTests'] === 'true' && image.includes('unityci/editor')) {
try {
const { CloudRunnerSystem } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(4197)));
const { CloudRunnerSystem: CloudRunnerSystemModule2 } = await Promise.resolve().then(() => __importStar(__nccwpck_require__(4197)));
// Check if image is cached on agent node (where pods run)
const agentImageCheck = await CloudRunnerSystem.Run(`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`, true, true);
const agentImageCheck = await CloudRunnerSystemModule2.Run(`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`, true, true);
if (agentImageCheck.includes('not_cached')) {
// Check if image is on server node
const serverImageCheck = await CloudRunnerSystem.Run(`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`, true, true);
const serverImageCheck = await CloudRunnerSystemModule2.Run(`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`, true, true);
// Check available disk space on agent node
const diskInfo = await CloudRunnerSystem.Run('docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown', true, true);
const diskInfo = await CloudRunnerSystemModule2.Run('docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown', true, true);
cloud_runner_logger_1.default.logWarning(`Unity image not cached on agent node (where pods run). Server node: ${serverImageCheck.includes('cached') ? 'has image' : 'no image'}. Disk info: ${diskInfo.trim()}. Pod will attempt to pull image (3.9GB) which may fail due to disk pressure.`);
// If image is on server but not agent, log a warning
// NOTE: We don't attempt to pull here because:
@@ -3996,9 +3991,9 @@ class Kubernetes {
else {
// Image not on either node - check if we have enough space to pull
// Extract available space from disk info
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([GMK]?i?B)/i);
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([gkm]?i?b)/i);
if (availableSpaceMatch) {
const availableValue = parseFloat(availableSpaceMatch[1]);
const availableValue = Number.parseFloat(availableSpaceMatch[1]);
const availableUnit = availableSpaceMatch[2].toUpperCase();
let availableGB = availableValue;
if (availableUnit.includes('M')) {
@@ -4247,8 +4242,8 @@ class KubernetesJobSpecFactory {
const memoryMB = Number.parseInt(buildParameters.containerMemory);
const cpuMB = Number.parseInt(buildParameters.containerCpu);
return {
memory: !isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
memory: !Number.isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !Number.isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
};
})(),
},
@@ -4784,14 +4779,14 @@ class KubernetesStorage {
if (pvcEvents.length > 0) {
cloud_runner_logger_1.default.log(`PVC Events: ${JSON.stringify(pvcEvents, undefined, 2)}`);
// Check if event indicates WaitForFirstConsumer
const waitForConsumerEvent = pvcEvents.find((e) => e.reason === 'WaitForFirstConsumer' || e.message?.includes('waiting for first consumer'));
const waitForConsumerEvent = pvcEvents.find((event) => event.reason === 'WaitForFirstConsumer' || event.message?.includes('waiting for first consumer'));
if (waitForConsumerEvent) {
cloud_runner_logger_1.default.log(`PVC is waiting for first consumer. This is normal for WaitForFirstConsumer storage classes. Proceeding without waiting.`);
return true; // Exit wait loop - PVC will bind when pod is created
}
}
}
catch (eventError) {
catch {
// Ignore event fetch errors
}
}
@@ -4824,7 +4819,7 @@ class KubernetesStorage {
count: x.count || 0,
}));
}
catch (eventError) {
catch {
// Ignore event fetch errors
}
// Check if storage class exists
@@ -4841,12 +4836,10 @@ class KubernetesStorage {
storageClassInfo = `StorageClass "${storageClassName}" exists. Provisioner: ${sc.body.provisioner || 'unknown'}`;
}
catch (scError) {
if (scError.statusCode === 404) {
storageClassInfo = `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`;
}
else {
storageClassInfo = `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
storageClassInfo =
scError.statusCode === 404
? `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`
: `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
}
}
@@ -4949,8 +4942,8 @@ class KubernetesTaskRunner {
// split output chunk and handle per line
for (const chunk of outputChunk.split(`\n`)) {
// Skip empty chunks and kubectl error messages (case-insensitive)
const lowerChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerChunk.includes('unable to retrieve container logs')) {
const lowerCaseChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerCaseChunk.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = follow_log_stream_service_1.FollowLogStreamService.handleIteration(chunk, shouldReadLogs, shouldCleanup, output));
}
}
@@ -5122,13 +5115,13 @@ class KubernetesTaskRunner {
break;
}
try {
cloud_runner_logger_1.default.log(`Trying fallback method: ${attempt.substring(0, 80)}...`);
cloud_runner_logger_1.default.log(`Trying fallback method: ${attempt.slice(0, 80)}...`);
const result = await cloud_runner_system_1.CloudRunnerSystem.Run(attempt, true, true);
if (result && result.trim()) {
// Prefer content that has "Collected Logs" over content that doesn't
if (!logFileContent || !logFileContent.includes('Collected Logs')) {
logFileContent = result;
cloud_runner_logger_1.default.log(`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.substring(0, 50)}...`);
cloud_runner_logger_1.default.log(`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.slice(0, 50)}...`);
// If this content has "Collected Logs", we're done
if (logFileContent.includes('Collected Logs')) {
cloud_runner_logger_1.default.log('Fallback method successfully captured "Collected Logs".');
@@ -5140,11 +5133,11 @@ class KubernetesTaskRunner {
}
}
else {
cloud_runner_logger_1.default.log(`Fallback method returned empty result: ${attempt.substring(0, 50)}...`);
cloud_runner_logger_1.default.log(`Fallback method returned empty result: ${attempt.slice(0, 50)}...`);
}
}
catch (attemptError) {
cloud_runner_logger_1.default.log(`Fallback method failed: ${attempt.substring(0, 50)}... Error: ${attemptError?.message || attemptError}`);
cloud_runner_logger_1.default.log(`Fallback method failed: ${attempt.slice(0, 50)}... Error: ${attemptError?.message || attemptError}`);
// Continue to next attempt
}
}
@@ -5269,7 +5262,7 @@ class KubernetesTaskRunner {
message += `\nRecent Events:\n${JSON.stringify(podEvents.slice(-5), undefined, 2)}`;
}
}
catch (eventError) {
catch {
// Ignore event fetch errors
}
cloud_runner_logger_1.default.logWarning(message);
@@ -5365,12 +5358,9 @@ class KubernetesTaskRunner {
message += `\n\nScheduling Issue: ${unschedulable.reason || 'Unknown'} - ${unschedulable.message || 'No message'}`;
}
// Check if pod is assigned to a node
if (podStatusDetails?.hostIP) {
message += `\n\nPod assigned to node: ${podStatusDetails.hostIP}`;
}
else {
message += `\n\nPod not yet assigned to a node (scheduling pending)`;
}
message += podStatusDetails?.hostIP
? `\n\nPod assigned to node: ${podStatusDetails.hostIP}`
: `\n\nPod not yet assigned to a node (scheduling pending)`;
}
// Check node resources if pod is assigned
if (podStatusDetails?.hostIP) {
@@ -5380,7 +5370,6 @@ class KubernetesTaskRunner {
const assignedNode = nodes.body.items.find((n) => n.status?.addresses?.some((a) => a.address === hostIP));
if (assignedNode?.status && assignedNode.metadata?.name) {
const allocatable = assignedNode.status.allocatable || {};
const capacity = assignedNode.status.capacity || {};
message += `\n\nNode Resources (${assignedNode.metadata.name}):\n Allocatable CPU: ${allocatable.cpu || 'unknown'}\n Allocatable Memory: ${allocatable.memory || 'unknown'}\n Allocatable Ephemeral Storage: ${allocatable['ephemeral-storage'] || 'unknown'}`;
// Check for taints that might prevent scheduling
if (assignedNode.spec?.taints && assignedNode.spec.taints.length > 0) {
@@ -5391,12 +5380,12 @@ class KubernetesTaskRunner {
}
}
}
catch (nodeError) {
catch {
// Ignore node check errors
}
}
}
catch (podStatusError) {
catch {
// Ignore pod status fetch errors
}
}
@@ -5466,7 +5455,7 @@ class KubernetesTaskRunner {
}
cloud_runner_logger_1.default.logWarning(message);
}
catch (statusError) {
catch {
message = `Pod ${podName} timed out and could not retrieve final status: ${waitError?.message || waitError}`;
cloud_runner_logger_1.default.logWarning(message);
}
@@ -6507,7 +6496,7 @@ class Caching {
// This will fail if the archive is corrupted
await cloud_runner_system_1.CloudRunnerSystem.Run(`tar -tf ${cacheSelection}.tar${compressionSuffix} > /dev/null 2>&1 || (echo "Tar file validation failed" && exit 1)`);
}
catch (validationError) {
catch {
const message = `Cache archive ${cacheSelection}.tar${compressionSuffix} appears to be corrupted or incomplete. Skipping cache extraction.`;
cloud_runner_logger_1.default.logWarning(message);
remote_client_logger_1.RemoteClientLogger.logWarning(message);
@@ -6854,8 +6843,8 @@ class RemoteClient {
await cloud_runner_system_1.CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await cloud_runner_system_1.CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
try {
const depthArg = cloud_runner_options_1.default.cloneDepth !== '0' ? `--depth ${cloud_runner_options_1.default.cloneDepth}` : '';
await cloud_runner_system_1.CloudRunnerSystem.Run(`git clone ${depthArg} ${cloud_runner_folders_1.CloudRunnerFolders.targetBuildRepoUrl} ${node_path_1.default.basename(cloud_runner_folders_1.CloudRunnerFolders.repoPathAbsolute)}`.trim());
const depthArgument = cloud_runner_options_1.default.cloneDepth !== '0' ? `--depth ${cloud_runner_options_1.default.cloneDepth}` : '';
await cloud_runner_system_1.CloudRunnerSystem.Run(`git clone ${depthArgument} ${cloud_runner_folders_1.CloudRunnerFolders.targetBuildRepoUrl} ${node_path_1.default.basename(cloud_runner_folders_1.CloudRunnerFolders.repoPathAbsolute)}`.trim());
}
catch (error) {
throw error;

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,7 @@ class CloudRunner {
private static cloudRunnerEnvironmentVariables: CloudRunnerEnvironmentVariable[];
static lockedWorkspace: string = ``;
public static readonly retainedWorkspacePrefix: string = `retained-workspace`;
// When true, validates AWS CloudFormation templates even when using local-docker execution
// This is set by AWS_FORCE_PROVIDER=aws-local mode
public static validateAwsTemplates: boolean = false;
@@ -132,6 +133,7 @@ class CloudRunner {
break;
case 'aws':
CloudRunner.Provider = new AwsBuildPlatform(CloudRunner.buildParameters);
// Validate that AWS provider is actually being used when expected
if (isLocalStack && forceAwsProvider) {
CloudRunnerLogger.log('✓ AWS provider initialized with LocalStack - AWS functionality will be validated');

View File

@@ -27,6 +27,7 @@ function getStackWaitTime(): number {
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}

View File

@@ -27,7 +27,7 @@ export class AwsClientFactory {
}
// Return undefined to let AWS SDK use default credential chain
return undefined;
return;
}
static getCloudFormation(): CloudFormation {

View File

@@ -25,6 +25,7 @@ function getStackWaitTime(): number {
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}

View File

@@ -24,6 +24,7 @@ function getStackWaitTime(): number {
if (!Number.isNaN(overrideValue) && overrideValue > 0) {
return overrideValue;
}
return DEFAULT_STACK_WAIT_TIME_SECONDS;
}

View File

@@ -165,7 +165,9 @@ class Kubernetes implements ProviderInterface {
if (process.env['cloudRunnerTests'] === 'true') {
try {
CloudRunnerLogger.log('Cleaning up old images in k3d node before pulling new image...');
const { CloudRunnerSystem } = await import('../../services/core/cloud-runner-system');
const { CloudRunnerSystem: CloudRunnerSystemModule } = await import(
'../../services/core/cloud-runner-system'
);
// Aggressive cleanup: remove stopped containers and non-Unity images
// IMPORTANT: Preserve Unity images (unityci/editor) to avoid re-pulling the 3.9GB image
@@ -174,19 +176,16 @@ class Kubernetes implements ProviderInterface {
for (const NODE of K3D_NODE_CONTAINERS) {
// Remove all stopped containers (this frees runtime space but keeps images)
cleanupCommands.push(`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`);
// Remove non-Unity images only (preserve unityci/editor images to avoid re-pulling 3.9GB)
// This is safe because we explicitly exclude Unity images from deletion
cleanupCommands.push(
`docker exec ${NODE} sh -c "for img in \$(crictl images -q 2>/dev/null); do repo=\$(crictl inspecti \$img --format '{{.repo}}' 2>/dev/null || echo ''); if echo \"\$repo\" | grep -qvE 'unityci/editor|unity'; then crictl rmi \$img 2>/dev/null || true; fi; done" || true`,
`docker exec ${NODE} sh -c "crictl rm --all 2>/dev/null || true" || true`,
`docker exec ${NODE} sh -c "for img in $(crictl images -q 2>/dev/null); do repo=$(crictl inspecti $img --format '{{.repo}}' 2>/dev/null || echo ''); if echo "$repo" | grep -qvE 'unityci/editor|unity'; then crictl rmi $img 2>/dev/null || true; fi; done" || true`,
`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`,
);
// Clean up unused layers (prune should preserve referenced images)
cleanupCommands.push(`docker exec ${NODE} sh -c "crictl rmi --prune 2>/dev/null || true" || true`);
}
for (const cmd of cleanupCommands) {
try {
await CloudRunnerSystem.Run(cmd, true, true);
await CloudRunnerSystemModule.Run(cmd, true, true);
} catch (cmdError) {
// Ignore individual command failures - cleanup is best effort
CloudRunnerLogger.log(`Cleanup command failed (non-fatal): ${cmdError}`);
@@ -195,6 +194,7 @@ class Kubernetes implements ProviderInterface {
CloudRunnerLogger.log('Cleanup completed (containers and non-Unity images removed, Unity images preserved)');
} catch (cleanupError) {
CloudRunnerLogger.logWarning(`Failed to cleanup images before job creation: ${cleanupError}`);
// Continue anyway - image might already be cached
}
}
@@ -205,10 +205,12 @@ class Kubernetes implements ProviderInterface {
// If not cached, try to ensure it's available to avoid disk pressure during pull
if (process.env['cloudRunnerTests'] === 'true' && image.includes('unityci/editor')) {
try {
const { CloudRunnerSystem } = await import('../../services/core/cloud-runner-system');
const { CloudRunnerSystem: CloudRunnerSystemModule2 } = await import(
'../../services/core/cloud-runner-system'
);
// Check if image is cached on agent node (where pods run)
const agentImageCheck = await CloudRunnerSystem.Run(
const agentImageCheck = await CloudRunnerSystemModule2.Run(
`docker exec k3d-unity-builder-agent-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
@@ -216,14 +218,14 @@ class Kubernetes implements ProviderInterface {
if (agentImageCheck.includes('not_cached')) {
// Check if image is on server node
const serverImageCheck = await CloudRunnerSystem.Run(
const serverImageCheck = await CloudRunnerSystemModule2.Run(
`docker exec k3d-unity-builder-server-0 sh -c "crictl images | grep -q unityci/editor && echo 'cached' || echo 'not_cached'" || echo 'not_cached'`,
true,
true,
);
// Check available disk space on agent node
const diskInfo = await CloudRunnerSystem.Run(
const diskInfo = await CloudRunnerSystemModule2.Run(
'docker exec k3d-unity-builder-agent-0 sh -c "df -h /var/lib/rancher/k3s 2>/dev/null | tail -1 || df -h / 2>/dev/null | tail -1 || echo unknown" || echo unknown',
true,
true,
@@ -248,9 +250,9 @@ class Kubernetes implements ProviderInterface {
} else {
// Image not on either node - check if we have enough space to pull
// Extract available space from disk info
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([GMK]?i?B)/i);
const availableSpaceMatch = diskInfo.match(/(\d+(?:\.\d+)?)\s*([gkm]?i?b)/i);
if (availableSpaceMatch) {
const availableValue = parseFloat(availableSpaceMatch[1]);
const availableValue = Number.parseFloat(availableSpaceMatch[1]);
const availableUnit = availableSpaceMatch[2].toUpperCase();
let availableGB = availableValue;

View File

@@ -68,6 +68,7 @@ class KubernetesJobSpecFactory {
buildGuid,
},
};
// Reduce TTL for tests to free up resources faster (default 9999s = ~2.8 hours)
// For CI/test environments, use shorter TTL (300s = 5 minutes) to prevent disk pressure
const jobTTL = process.env['cloudRunnerTests'] === 'true' ? 300 : 9999;
@@ -116,9 +117,10 @@ class KubernetesJobSpecFactory {
// For main build containers, use the configured resources
const memoryMB = Number.parseInt(buildParameters.containerMemory);
const cpuMB = Number.parseInt(buildParameters.containerCpu);
return {
memory: !isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
memory: !Number.isNaN(memoryMB) && memoryMB > 0 ? `${memoryMB / 1024}G` : '750M',
cpu: !Number.isNaN(cpuMB) && cpuMB > 0 ? `${cpuMB / 1024}` : '1',
};
})(),
},
@@ -163,6 +165,7 @@ class KubernetesJobSpecFactory {
},
],
restartPolicy: 'Never',
// Add tolerations for CI/test environments to allow scheduling even with disk pressure
// This is acceptable for CI where we aggressively clean up disk space
tolerations: [
@@ -196,6 +199,7 @@ class KubernetesJobSpecFactory {
// Only set ephemeral-storage request for production builds
job.spec.template.spec.containers[0].resources.requests[`ephemeral-storage`] = '2Gi';
}
// For tests, don't set ephemeral-storage request - let Kubernetes use available space
return job;

View File

@@ -89,6 +89,7 @@ class KubernetesStorage {
if (shouldSkipWait) {
CloudRunnerLogger.log(`Skipping PVC wait - will bind when pod is created`);
return;
}
@@ -123,16 +124,18 @@ class KubernetesStorage {
// Check if event indicates WaitForFirstConsumer
const waitForConsumerEvent = pvcEvents.find(
(e) => e.reason === 'WaitForFirstConsumer' || e.message?.includes('waiting for first consumer'),
(event) =>
event.reason === 'WaitForFirstConsumer' || event.message?.includes('waiting for first consumer'),
);
if (waitForConsumerEvent) {
CloudRunnerLogger.log(
`PVC is waiting for first consumer. This is normal for WaitForFirstConsumer storage classes. Proceeding without waiting.`,
);
return true; // Exit wait loop - PVC will bind when pod is created
}
}
} catch (eventError) {
} catch {
// Ignore event fetch errors
}
}
@@ -169,7 +172,7 @@ class KubernetesStorage {
type: x.type || '',
count: x.count || 0,
}));
} catch (eventError) {
} catch {
// Ignore event fetch errors
}
@@ -189,11 +192,10 @@ class KubernetesStorage {
sc.body.provisioner || 'unknown'
}`;
} catch (scError: any) {
if (scError.statusCode === 404) {
storageClassInfo = `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`;
} else {
storageClassInfo = `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
storageClassInfo =
scError.statusCode === 404
? `StorageClass "${storageClassName}" does NOT exist! This is likely why the PVC is stuck in Pending.`
: `Failed to check StorageClass "${storageClassName}": ${scError.message || scError}`;
}
}
} catch (scCheckError) {

View File

@@ -38,6 +38,7 @@ class KubernetesTaskRunner {
const lowerChunk = outputChunk.toLowerCase();
if (lowerChunk.includes('unable to retrieve container logs')) {
CloudRunnerLogger.log(`Filtered kubectl error: ${outputChunk.trim()}`);
return;
}
@@ -46,8 +47,8 @@ class KubernetesTaskRunner {
// split output chunk and handle per line
for (const chunk of outputChunk.split(`\n`)) {
// Skip empty chunks and kubectl error messages (case-insensitive)
const lowerChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerChunk.includes('unable to retrieve container logs')) {
const lowerCaseChunk = chunk.toLowerCase();
if (chunk.trim() && !lowerCaseChunk.includes('unable to retrieve container logs')) {
({ shouldReadLogs, shouldCleanup, output } = FollowLogStreamService.handleIteration(
chunk,
shouldReadLogs,
@@ -66,6 +67,7 @@ class KubernetesTaskRunner {
true,
callback,
);
// Reset failure count on success
kubectlLogsFailedCount = 0;
} catch (error: any) {
@@ -105,6 +107,7 @@ class KubernetesTaskRunner {
// Pod is terminated, try to create a temporary pod to read from the PVC
// First, check if we can still access the pod's filesystem
CloudRunnerLogger.log(`Pod is terminated, attempting to read log file via temporary pod...`);
// For terminated pods, we might not be able to exec, so we'll skip this fallback
// and rely on the log file being written to the PVC (if mounted)
CloudRunnerLogger.logWarning(`Cannot read log file from terminated pod via exec`);
@@ -112,6 +115,7 @@ class KubernetesTaskRunner {
if (logFileContent && logFileContent.trim()) {
CloudRunnerLogger.log(`Successfully read log file from pod (${logFileContent.length} chars)`);
// Process the log file content line by line
for (const line of logFileContent.split(`\n`)) {
const lowerLine = line.toLowerCase();
@@ -132,12 +136,14 @@ class KubernetesTaskRunner {
}
} else {
CloudRunnerLogger.logWarning(`Log file read returned empty content, continuing with available logs`);
// If we can't read the log file, break out of the loop to return whatever logs we have
// This prevents infinite retries when kubectl logs consistently fails
break;
}
} catch (execError: any) {
CloudRunnerLogger.logWarning(`Failed to read log file from pod: ${execError}`);
// If we've exhausted all options, break to return whatever logs we have
break;
}
@@ -220,6 +226,7 @@ class KubernetesTaskRunner {
if (needsFallback) {
CloudRunnerLogger.log('Output is empty, attempting aggressive log collection fallback...');
// Give the pod a moment to finish writing logs before we try to read them
await new Promise((resolve) => setTimeout(resolve, 5000));
}
@@ -251,11 +258,14 @@ class KubernetesTaskRunner {
const attempts = [
// For terminated pods, try --previous first
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try current container
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /home/job-log.txt 2>/dev/null || echo ""`,
// Try reading from PVC (/data) in case log was copied there
`kubectl exec ${podName} -c ${containerName} -n ${namespace} --previous -- cat /data/job-log.txt 2>/dev/null || echo ""`,
`kubectl exec ${podName} -c ${containerName} -n ${namespace} -- cat /data/job-log.txt 2>/dev/null || echo ""`,
// Try kubectl logs as fallback (might capture stdout even if exec fails)
`kubectl logs ${podName} -c ${containerName} -n ${namespace} --previous 2>/dev/null || echo ""`,
`kubectl logs ${podName} -c ${containerName} -n ${namespace} 2>/dev/null || echo ""`,
@@ -268,18 +278,19 @@ class KubernetesTaskRunner {
break;
}
try {
CloudRunnerLogger.log(`Trying fallback method: ${attempt.substring(0, 80)}...`);
CloudRunnerLogger.log(`Trying fallback method: ${attempt.slice(0, 80)}...`);
const result = await CloudRunnerSystem.Run(attempt, true, true);
if (result && result.trim()) {
// Prefer content that has "Collected Logs" over content that doesn't
if (!logFileContent || !logFileContent.includes('Collected Logs')) {
logFileContent = result;
CloudRunnerLogger.log(
`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.substring(
`Successfully read logs using fallback method (${logFileContent.length} chars): ${attempt.slice(
0,
50,
)}...`,
);
// If this content has "Collected Logs", we're done
if (logFileContent.includes('Collected Logs')) {
CloudRunnerLogger.log('Fallback method successfully captured "Collected Logs".');
@@ -289,14 +300,13 @@ class KubernetesTaskRunner {
CloudRunnerLogger.log(`Skipping this result - already have content with "Collected Logs".`);
}
} else {
CloudRunnerLogger.log(`Fallback method returned empty result: ${attempt.substring(0, 50)}...`);
CloudRunnerLogger.log(`Fallback method returned empty result: ${attempt.slice(0, 50)}...`);
}
} catch (attemptError: any) {
CloudRunnerLogger.log(
`Fallback method failed: ${attempt.substring(0, 50)}... Error: ${
attemptError?.message || attemptError
}`,
`Fallback method failed: ${attempt.slice(0, 50)}... Error: ${attemptError?.message || attemptError}`,
);
// Continue to next attempt
}
}
@@ -311,12 +321,15 @@ class KubernetesTaskRunner {
CloudRunnerLogger.log(
`Read log file from pod as fallback (${logFileContent.length} chars) to capture missing messages`,
);
// Get the lines we already have in output to avoid duplicates
const existingLines = new Set(output.split('\n').map((line) => line.trim()));
// Process the log file content line by line and add missing lines
for (const line of logFileContent.split(`\n`)) {
const trimmedLine = line.trim();
const lowerLine = trimmedLine.toLowerCase();
// Skip empty lines, kubectl errors, and lines we already have
if (
trimmedLine &&
@@ -338,6 +351,7 @@ class KubernetesTaskRunner {
CloudRunnerLogger.logWarning(
`Could not read log file from pod as fallback: ${logFileError?.message || logFileError}`,
);
// Continue with existing output - this is a best-effort fallback
}
}
@@ -348,6 +362,7 @@ class KubernetesTaskRunner {
CloudRunnerLogger.logWarning(
'Could not retrieve "Collected Logs" from pod after all attempts. Pod may have been killed before logs were written.',
);
// Add a minimal message so BuildResults is not completely empty
// This helps with debugging and prevents test failures due to empty results
if (output.trim().length === 0) {
@@ -362,10 +377,12 @@ class KubernetesTaskRunner {
CloudRunnerLogger.logWarning(
`Error checking pod status for log file fallback: ${fallbackError?.message || fallbackError}`,
);
// If output is empty and we hit an error, still add a message so BuildResults isn't empty
if (needsFallback && output.trim().length === 0) {
output = `Error retrieving logs: ${fallbackError?.message || fallbackError}\n`;
}
// Continue with existing output - this is a best-effort fallback
}
@@ -460,14 +477,16 @@ class KubernetesTaskRunner {
if (podEvents.length > 0) {
message += `\nRecent Events:\n${JSON.stringify(podEvents.slice(-5), undefined, 2)}`;
}
} catch (eventError) {
} catch {
// Ignore event fetch errors
}
CloudRunnerLogger.logWarning(message);
// For permanent failures, mark as incomplete and store the error message
// We'll throw an error after the wait loop exits
waitComplete = false;
return true; // Return true to exit wait loop
}
@@ -498,6 +517,7 @@ class KubernetesTaskRunner {
message = `Pod ${podName} cannot be scheduled:\n${schedulingMessage}`;
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
@@ -513,6 +533,7 @@ class KubernetesTaskRunner {
message = `Pod ${podName} failed to pull image. Check image availability and credentials.`;
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
@@ -522,6 +543,7 @@ class KubernetesTaskRunner {
CloudRunnerLogger.log(
`Pod ${podName} is pulling image (check ${consecutivePendingCount}). This may take several minutes for large images.`,
);
// Don't increment consecutivePendingCount if we're actively pulling
consecutivePendingCount = Math.max(4, consecutivePendingCount - 1);
}
@@ -542,6 +564,7 @@ class KubernetesTaskRunner {
if (consecutivePendingCount >= maxPendingChecks) {
message = `Pod ${podName} stuck in Pending state for too long (${consecutivePendingCount} checks). This indicates a scheduling problem.`;
// Get events for context
try {
const events = await kubeClient.listNamespacedEvent(namespace);
@@ -595,11 +618,9 @@ class KubernetesTaskRunner {
}
// Check if pod is assigned to a node
if (podStatusDetails?.hostIP) {
message += `\n\nPod assigned to node: ${podStatusDetails.hostIP}`;
} else {
message += `\n\nPod not yet assigned to a node (scheduling pending)`;
}
message += podStatusDetails?.hostIP
? `\n\nPod assigned to node: ${podStatusDetails.hostIP}`
: `\n\nPod not yet assigned to a node (scheduling pending)`;
}
// Check node resources if pod is assigned
@@ -612,7 +633,6 @@ class KubernetesTaskRunner {
);
if (assignedNode?.status && assignedNode.metadata?.name) {
const allocatable = assignedNode.status.allocatable || {};
const capacity = assignedNode.status.capacity || {};
message += `\n\nNode Resources (${assignedNode.metadata.name}):\n Allocatable CPU: ${
allocatable.cpu || 'unknown'
}\n Allocatable Memory: ${allocatable.memory || 'unknown'}\n Allocatable Ephemeral Storage: ${
@@ -627,11 +647,11 @@ class KubernetesTaskRunner {
message += `\n Node Taints: ${taints}`;
}
}
} catch (nodeError) {
} catch {
// Ignore node check errors
}
}
} catch (podStatusError) {
} catch {
// Ignore pod status fetch errors
}
} catch {
@@ -639,6 +659,7 @@ class KubernetesTaskRunner {
}
CloudRunnerLogger.logWarning(message);
waitComplete = false;
return true; // Exit wait loop to throw error
}
@@ -706,7 +727,7 @@ class KubernetesTaskRunner {
}
CloudRunnerLogger.logWarning(message);
} catch (statusError) {
} catch {
message = `Pod ${podName} timed out and could not retrieve final status: ${waitError?.message || waitError}`;
CloudRunnerLogger.logWarning(message);
}
@@ -725,6 +746,7 @@ class KubernetesTaskRunner {
CloudRunnerLogger.logWarning(
`Pod ${podName} completed with phase ${finalPhase} before reaching Running state. Will attempt to retrieve logs.`,
);
return true; // Allow workflow to continue and try to get logs
}
} catch {

View File

@@ -110,10 +110,12 @@ export class Caching {
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// Try with sudo if available
await CloudRunnerSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +360 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +360 -exec rm -f {} + 2>/dev/null || true`,
@@ -125,6 +127,7 @@ export class Caching {
// If disk is still very high (>95%), be even more aggressive
if (diskUsagePercent > 95) {
CloudRunnerLogger.log(`Disk usage is very high (${diskUsagePercent}%), performing aggressive cleanup...`);
// Remove files older than 1 hour
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
@@ -156,6 +159,7 @@ export class Caching {
const message = `Cannot create cache archive: disk is still at ${diskUsageAfterCleanup}% after cleanup. Tar operation would hang. Skipping cache push; please free up disk space manually if this persists.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.log(message);
// Restore working directory before early return
process.chdir(`${startPath}`);
@@ -187,6 +191,7 @@ export class Caching {
try {
// Check if timeout command is available
await CloudRunnerSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
// Use timeout if available (600 seconds = 10 minutes)
tarCommandToRun = `timeout 600 ${tarCommand}`;
} catch {
@@ -224,6 +229,7 @@ export class Caching {
await CloudRunnerSystem.Run(
`sudo find ${cacheParent} -name "*.tar*" -type f -mmin +60 -delete 2>/dev/null || true`,
);
// As last resort, try to remove files one by one
await CloudRunnerSystem.Run(
`find ${cacheParent} -name "*.tar*" -type f -mmin +60 -exec rm -f {} + 2>/dev/null || true`,
@@ -239,6 +245,7 @@ export class Caching {
await CloudRunnerSystem.Run(
`chmod -R u+w ${cacheRoot} 2>/dev/null || chown -R $(whoami) ${cacheRoot} 2>/dev/null || true`,
);
// Remove cache entries older than 30 minutes
await CloudRunnerSystem.Run(
`find ${cacheRoot} -name "*.tar*" -type f -mmin +30 -delete 2>/dev/null || true`,
@@ -365,8 +372,10 @@ export class Caching {
const message = `Disk is at ${diskUsagePercent}% - skipping cache extraction to prevent hang. Cache may be incomplete or corrupted.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
@@ -377,12 +386,14 @@ export class Caching {
await CloudRunnerSystem.Run(
`tar -tf ${cacheSelection}.tar${compressionSuffix} > /dev/null 2>&1 || (echo "Tar file validation failed" && exit 1)`,
);
} catch (validationError) {
} catch {
const message = `Cache archive ${cacheSelection}.tar${compressionSuffix} appears to be corrupted or incomplete. Skipping cache extraction.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
@@ -394,6 +405,7 @@ export class Caching {
// Extract with timeout to prevent infinite hangs
try {
let tarExtractCommand = `tar -xf ${cacheSelection}.tar${compressionSuffix} -C ${fullResultsFolder}`;
// Add timeout if available (600 seconds = 10 minutes)
try {
await CloudRunnerSystem.Run(`which timeout > /dev/null 2>&1`, true, true);
@@ -405,6 +417,7 @@ export class Caching {
await CloudRunnerSystem.Run(tarExtractCommand);
} catch (extractError: any) {
const errorMessage = extractError?.message || extractError?.toString() || '';
// Check for common tar errors that indicate corruption or disk issues
if (
errorMessage.includes('Unexpected EOF') ||
@@ -416,10 +429,13 @@ export class Caching {
const message = `Cache extraction failed (likely due to corrupted archive or disk space): ${errorMessage}. Continuing without cache.`;
CloudRunnerLogger.logWarning(message);
RemoteClientLogger.logWarning(message);
// Continue without cache - build will proceed without cached Library
process.chdir(startPath);
return;
}
// Re-throw other errors
throw extractError;
}

View File

@@ -306,9 +306,11 @@ export class RemoteClient {
await CloudRunnerSystem.Run(`git config --global filter.lfs.smudge "git-lfs smudge --skip -- %f"`);
await CloudRunnerSystem.Run(`git config --global filter.lfs.process "git-lfs filter-process --skip"`);
try {
const depthArg = CloudRunnerOptions.cloneDepth !== '0' ? `--depth ${CloudRunnerOptions.cloneDepth}` : '';
const depthArgument = CloudRunnerOptions.cloneDepth !== '0' ? `--depth ${CloudRunnerOptions.cloneDepth}` : '';
await CloudRunnerSystem.Run(
`git clone ${depthArg} ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(CloudRunnerFolders.repoPathAbsolute)}`.trim(),
`git clone ${depthArgument} ${CloudRunnerFolders.targetBuildRepoUrl} ${path.basename(
CloudRunnerFolders.repoPathAbsolute,
)}`.trim(),
);
} catch (error: any) {
throw error;

View File

@@ -52,9 +52,10 @@ describe('Cloud Runner pre-built S3 steps', () => {
projectPath: 'test-project',
unityVersion: UnityVersioning.determineUnityVersion('test-project', UnityVersioning.read('test-project')),
targetPlatform: 'StandaloneLinux64',
cacheKey: cacheKey,
buildGuid: buildGuid,
cacheKey,
buildGuid,
cloudRunnerDebug: true,
// Use customJob to run a minimal job that sets up test data and then runs S3 hooks
customJob: `
- name: setup-test-data
@@ -139,7 +140,7 @@ describe('Cloud Runner pre-built S3 steps', () => {
s3Endpoint = s3Endpoint.replace('host.docker.internal', 'localhost');
CloudRunnerLogger.log(`Converted endpoint from host.docker.internal to localhost: ${s3Endpoint}`);
}
const endpointArgs = s3Endpoint ? `--endpoint-url ${s3Endpoint}` : '';
const endpointArguments = s3Endpoint ? `--endpoint-url ${s3Endpoint}` : '';
// Configure AWS credentials if available (needed for LocalStack)
// LocalStack accepts any credentials, but they must be provided
@@ -179,7 +180,7 @@ describe('Cloud Runner pre-built S3 steps', () => {
try {
const results = await CloudRunnerSystem.RunAndReadLines(
`aws ${endpointArgs} s3 ls s3://${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/`,
`aws ${endpointArguments} s3 ls s3://${CloudRunner.buildParameters.awsStackName}/cloud-runner-cache/`,
);
CloudRunnerLogger.log(`S3 verification successful: ${results.join(`,`)}`);
} catch (s3Error: any) {
@@ -188,6 +189,7 @@ describe('Cloud Runner pre-built S3 steps', () => {
CloudRunnerLogger.log(
`S3 verification failed (this is expected if upload failed during build): ${s3Error?.message || s3Error}`,
);
// Check if the error is due to missing credentials or connection issues
const errorMessage = (s3Error?.message || s3Error?.toString() || '').toLowerCase();
if (errorMessage.includes('invalidaccesskeyid') || errorMessage.includes('could not connect')) {

View File

@@ -106,23 +106,29 @@ describe('Cloud Runner Caching', () => {
if (fs.existsSync(cachePath)) {
try {
CloudRunnerLogger.log(`Cleaning up cache directory: ${cachePath}`);
// Try to change ownership first (if running as root or with sudo)
// Then try multiple cleanup methods to handle permission issues
await CloudRunnerSystem.Run(
`chmod -R u+w ${cachePath} 2>/dev/null || chown -R $(whoami) ${cachePath} 2>/dev/null || true`,
);
// Try regular rm first
await CloudRunnerSystem.Run(`rm -rf ${cachePath}/* 2>/dev/null || true`);
// If that fails, try with sudo if available
await CloudRunnerSystem.Run(`sudo rm -rf ${cachePath}/* 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await CloudRunnerSystem.Run(
`find ${cachePath} -type f -exec rm -f {} + 2>/dev/null || find ${cachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await CloudRunnerSystem.Run(`find ${cachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
CloudRunnerLogger.log(`Failed to cleanup cache: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}

View File

@@ -65,6 +65,7 @@ describe('Cloud Runner Retain Workspace', () => {
CloudRunnerLogger.log('Cleanup between builds completed (containers removed, images preserved)');
} catch (cleanupError) {
CloudRunnerLogger.logWarning(`Failed to cleanup between builds: ${cleanupError}`);
// Continue anyway
}
}
@@ -111,22 +112,28 @@ describe('Cloud Runner Retain Workspace', () => {
const workspaceCachePath = `./cloud-runner-cache/${path.basename(
CloudRunnerFolders.uniqueCloudRunnerJobFolderAbsolute,
)}`;
// Try to fix permissions first to avoid permission denied errors
await CloudRunnerSystem.Run(
`chmod -R u+w ${workspaceCachePath} 2>/dev/null || chown -R $(whoami) ${workspaceCachePath} 2>/dev/null || true`,
);
// Try regular rm first
await CloudRunnerSystem.Run(`rm -rf ${workspaceCachePath} 2>/dev/null || true`);
// If that fails, try with sudo if available
await CloudRunnerSystem.Run(`sudo rm -rf ${workspaceCachePath} 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await CloudRunnerSystem.Run(
`find ${workspaceCachePath} -type f -exec rm -f {} + 2>/dev/null || find ${workspaceCachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await CloudRunnerSystem.Run(`find ${workspaceCachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
CloudRunnerLogger.log(`Failed to cleanup workspace: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}
@@ -136,23 +143,29 @@ describe('Cloud Runner Retain Workspace', () => {
if (fs.existsSync(cachePath)) {
try {
CloudRunnerLogger.log(`Cleaning up cache directory: ${cachePath}`);
// Try to change ownership first (if running as root or with sudo)
// Then try multiple cleanup methods to handle permission issues
await CloudRunnerSystem.Run(
`chmod -R u+w ${cachePath} 2>/dev/null || chown -R $(whoami) ${cachePath} 2>/dev/null || true`,
);
// Try regular rm first
await CloudRunnerSystem.Run(`rm -rf ${cachePath}/* 2>/dev/null || true`);
// If that fails, try with sudo if available
await CloudRunnerSystem.Run(`sudo rm -rf ${cachePath}/* 2>/dev/null || true`);
// As last resort, try to remove files one by one, ignoring permission errors
await CloudRunnerSystem.Run(
`find ${cachePath} -type f -exec rm -f {} + 2>/dev/null || find ${cachePath} -type f -delete 2>/dev/null || true`,
);
// Remove empty directories
await CloudRunnerSystem.Run(`find ${cachePath} -type d -empty -delete 2>/dev/null || true`);
} catch (error: any) {
CloudRunnerLogger.log(`Failed to cleanup cache: ${error.message}`);
// Don't throw - cleanup failures shouldn't fail the test suite
}
}

View File

@@ -63,7 +63,7 @@ describe('Cloud Runner Kubernetes', () => {
throw new Error(
`Test failed: Pod was evicted due to resource constraints (ephemeral-storage). ` +
`This indicates the test environment doesn't have enough disk space. ` +
`Results: ${results.substring(0, 500)}`,
`Results: ${results.slice(0, 500)}`,
);
}
@@ -83,7 +83,7 @@ describe('Cloud Runner Kubernetes', () => {
throw new Error(
`Build did not complete successfully: ${incompleteLogsMessage}\n` +
`This indicates the pod was evicted or killed before post-build completed.\n` +
`Build results:\n${results.substring(0, 500)}`,
`Build results:\n${results.slice(0, 500)}`,
);
} else {
// Normal case - logs are complete