Conversation
Co-authored-by: Radiergummi <6115429+Radiergummi@users.noreply.github.com>
Co-authored-by: Radiergummi <6115429+Radiergummi@users.noreply.github.com>
Co-authored-by: Radiergummi <6115429+Radiergummi@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request improves error diagnostics for Docker Swarm deployment failures by adding task-level failure information and consolidating error annotations. The changes address a key usability issue where deployment failures provided unclear error messages without actionable information.
Changes:
- Adds
listServiceTasks()function to fetch task details from Docker Swarm - Enhances error handling in
monitorDeployment()to include task failure diagnostics - Reduces error annotations from 3 to 1, moving detailed information to job summary
- Adds comprehensive test coverage for new functionality
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/engine.ts | Adds listServiceTasks() function to retrieve task information via Docker CLI |
| src/monitoring.ts | Enhances error handling to fetch and display task failure details, consolidates error reporting to single annotation with supporting details in summary |
| tests/engine.test.ts | Adds 3 tests for listServiceTasks() covering success, empty results, and error cases |
| tests/monitoring.test.ts | Adds 2 tests for improved error handling with task details and graceful error recovery |
|
|
||
| // Build comprehensive error message with task details | ||
| const errorMessage = taskFailureDetails | ||
| ? `Service "${serviceIdentifier}" failed to update: ${message}. ${taskFailureDetails}` |
There was a problem hiding this comment.
The error message construction could result in double periods if the original error message already ends with a period. While this is a minor formatting issue, consider normalizing the message format.
For example:
const errorMessage = taskFailureDetails
? `Service "${serviceIdentifier}" failed to update: ${message}${message.endsWith('.') ? '' : '.'} ${taskFailureDetails}`
: `Service "${serviceIdentifier}" failed to update: ${message}`;Alternatively, you could simply use a space without a period between the messages:
const errorMessage = taskFailureDetails
? `Service "${serviceIdentifier}" failed to update: ${message} ${taskFailureDetails}`
: `Service "${serviceIdentifier}" failed to update: ${message}`;| ? `Service "${serviceIdentifier}" failed to update: ${message}. ${taskFailureDetails}` | |
| ? `Service "${serviceIdentifier}" failed to update: ${message}${message.endsWith(".") ? "" : "."} ${taskFailureDetails}` |
| // Filter to only failed or rejected tasks | ||
| const failedTasks = tasks.filter( | ||
| (task) => | ||
| task.Status.State === "failed" || | ||
| task.Status.State === "rejected" || | ||
| task.DesiredState === "shutdown", |
There was a problem hiding this comment.
The filter logic may incorrectly identify non-failed tasks as failures. The condition task.DesiredState === "shutdown" will match any task being shut down, including successfully completed old tasks during a normal rolling update. This could result in misleading error messages that reference successfully completed tasks rather than the actual failed tasks.
Consider refining the filter to only include tasks where Status.State indicates an actual failure (failed, rejected) or where DesiredState is shutdown AND Status.Err is present. For example:
const failedTasks = tasks.filter(
(task) =>
task.Status.State === "failed" ||
task.Status.State === "rejected" ||
(task.DesiredState === "shutdown" && task.Status.Err)
);This ensures that shutdown tasks are only considered failures if they have an associated error message.
| // Filter to only failed or rejected tasks | |
| const failedTasks = tasks.filter( | |
| (task) => | |
| task.Status.State === "failed" || | |
| task.Status.State === "rejected" || | |
| task.DesiredState === "shutdown", | |
| // Filter to only failed or rejected tasks, or shutdown tasks with an error | |
| const failedTasks = tasks.filter( | |
| (task) => | |
| task.Status.State === "failed" || | |
| task.Status.State === "rejected" || | |
| (task.DesiredState === "shutdown" && task.Status.Err), |
| it("should print the service logs on failure", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
||
| const serviceHistory = [ | ||
| [ | ||
| { | ||
| ID: "web_service", | ||
| Spec: { Name: "test" }, | ||
| UpdateStatus: { State: "updating" }, | ||
| } as ServiceWithMetadata, | ||
| ], | ||
| [ | ||
| { | ||
| ID: "web_service", | ||
| Spec: { Name: "test" }, | ||
| UpdateStatus: { State: "updating" }, | ||
| } as ServiceWithMetadata, | ||
| ], | ||
| [ | ||
| { | ||
| ID: "web_service", | ||
| Spec: { Name: "test" }, | ||
| UpdateStatus: { State: "rollback_started" }, | ||
| } as ServiceWithMetadata, | ||
| ], | ||
| ]; | ||
| const listServices = vi | ||
| .spyOn(engine, "listServices") | ||
| .mockResolvedValueOnce(serviceHistory[0]) | ||
| .mockResolvedValueOnce(serviceHistory[1]) | ||
| .mockResolvedValueOnce(serviceHistory[2]); | ||
| vi.spyOn(engine, "getServiceLogs").mockResolvedValueOnce([ | ||
| { | ||
| message: "Error occurred during service update", | ||
| timestamp: new Date(), | ||
| metadata: { | ||
| "com.docker.swarm.task": "task1", | ||
| "com.docker.swarm.service": "web_service", | ||
| }, | ||
| }, | ||
| { | ||
| message: "Service is rolling back", | ||
| timestamp: new Date(), | ||
| metadata: { | ||
| "com.docker.swarm.task": "task2", | ||
| "com.docker.swarm.service": "web_service", | ||
| }, | ||
| }, | ||
| ]); | ||
|
|
||
| // noinspection JSVoidFunctionReturnValueUsed | ||
| const promise = expect(monitorDeployment(settings)).rejects.toThrowError(); | ||
| await vi.runAllTimersAsync(); | ||
| await promise; | ||
|
|
||
| expect(listServices).toHaveBeenCalledTimes(serviceHistory.length); | ||
| expect(engine.getServiceLogs).toHaveBeenCalledWith( | ||
| serviceHistory[2][0].ID, | ||
| expect.objectContaining({ | ||
| since: expect.any(Date), | ||
| }), | ||
| ); | ||
| }); |
There was a problem hiding this comment.
The existing tests "should fail if a service is rolled back" and "should print the service logs on failure" will now call listServiceTasks as part of the error handling flow (line 73 in monitoring.ts), but they don't mock this function. This will likely cause these tests to fail or behave unexpectedly.
These tests should be updated to mock listServiceTasks similar to how the new test "should include task failure details in error message" does. For example:
vi.spyOn(engine, "listServiceTasks").mockResolvedValueOnce([]);Or mock it to return relevant task data if testing the task failure details behavior is desired.
| async function getTaskFailureDetails( | ||
| tasks: TaskInfo[], | ||
| ): Promise<string | undefined> { |
There was a problem hiding this comment.
The function is declared as async but doesn't use await anywhere and doesn't return a Promise directly. The async keyword is unnecessary here and can be removed.
Change the function signature from:
async function getTaskFailureDetails(
tasks: TaskInfo[],
): Promise<string | undefined>to:
function getTaskFailureDetails(
tasks: TaskInfo[],
): string | undefinedAnd update the call site at line 74 to remove the await keyword.
| async function getTaskFailureDetails( | |
| tasks: TaskInfo[], | |
| ): Promise<string | undefined> { | |
| function getTaskFailureDetails( | |
| tasks: TaskInfo[], | |
| ): string | undefined { |
| } | ||
|
|
||
| // Get the most recent failed task | ||
| const recentFailedTask = failedTasks.sort( |
There was a problem hiding this comment.
The sort operation mutates the failedTasks array in place. While this works correctly, it's better practice to avoid mutating the filtered array. Consider using toSorted() instead of sort() to create a new sorted array without mutating the original, or make a copy before sorting.
For example:
const recentFailedTask = [...failedTasks].sort(
(a, b) =>
new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime(),
)[0];or:
const recentFailedTask = failedTasks.toSorted(
(a, b) =>
new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime(),
)[0];Note: toSorted() requires Node.js 20+ and TypeScript 5.2+.
| const recentFailedTask = failedTasks.sort( | |
| const recentFailedTask = failedTasks.toSorted( |
| it("should include task failure details in error message", async () => { | ||
| vi.useFakeTimers(); | ||
|
|
||
| const mockTasks = [ | ||
| { | ||
| ID: "task1abcdefghijklmnop", | ||
| ServiceID: "web_service", | ||
| NodeID: "node1", | ||
| DesiredState: "shutdown", | ||
| Labels: {}, | ||
| Status: { | ||
| State: "failed", | ||
| Err: "task: non-zero exit (1)", | ||
| Message: "started", | ||
| }, | ||
| Spec: {}, | ||
| CreatedAt: "2024-01-01T00:00:00Z", | ||
| UpdatedAt: "2024-01-01T00:00:02Z", | ||
| }, | ||
| ]; | ||
|
|
||
| const serviceHistory = [ | ||
| [ | ||
| { | ||
| ID: "web_service", | ||
| Spec: { Name: "test" }, | ||
| UpdateStatus: { State: "paused", Message: "update paused" }, | ||
| } as ServiceWithMetadata, | ||
| ], | ||
| ]; | ||
|
|
||
| vi.spyOn(engine, "listServices") | ||
| .mockResolvedValueOnce(serviceHistory[0]); | ||
| vi.spyOn(engine, "listServiceTasks").mockResolvedValueOnce(mockTasks); | ||
| vi.spyOn(engine, "getServiceLogs").mockResolvedValueOnce([]); | ||
|
|
||
| // Should include task details in error | ||
| // noinspection JSVoidFunctionReturnValueUsed | ||
| const promise = expect(monitorDeployment(settings)).rejects.toThrow(); | ||
| await vi.runAllTimersAsync(); | ||
| await promise; | ||
|
|
||
| // Verify listServiceTasks was called to fetch task details | ||
| expect(engine.listServiceTasks).toHaveBeenCalledWith("web_service"); | ||
| }); |
There was a problem hiding this comment.
The test verifies that listServiceTasks is called but doesn't verify that the task failure details are actually included in the error message or summary. Consider adding assertions to verify the expected behavior more thoroughly.
For example, you could verify the error message contains the task ID and error details:
await expect(monitorDeployment(settings)).rejects.toThrowError(
/task: non-zero exit \(1\)/
);Or verify that the summary includes the task failure details using core.summary mocks.
Remaining Tasks - ALL COMPLETED ✅
listServiceTasks()in engine.test.ts (3 tests)Summary
All tasks completed successfully! The PR:
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.