Skip to content

Commit

Permalink
Merge pull request #2334 from posit-dev/mnv-preflight-errs-on-deploy
Browse files Browse the repository at this point in the history
Deployment preflight errors due to bad content GUID
  • Loading branch information
marcosnav authored Oct 4, 2024
2 parents acc6af1 + 221f6e0 commit 14c2dd7
Show file tree
Hide file tree
Showing 18 changed files with 441 additions and 59 deletions.
7 changes: 5 additions & 2 deletions extensions/vscode/src/api/types/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (C) 2023 by Posit Software, PBC.

import { ErrorCode } from "../../utils/errorTypes";

export enum EventSourceReadyState {
CONNECTING = 0,
OPEN = 1,
Expand Down Expand Up @@ -298,10 +300,11 @@ export function getLocalId(arg: EventStreamMessage) {
return arg.data.localId;
}

export interface EventStreamMessage {
export interface EventStreamMessage<T = Record<string, string>> {
type: EventSubscriptionTarget;
time: string;
data: Record<string, string>;
data: T;
errCode?: ErrorCode;
error?: string;
}

Expand Down
72 changes: 72 additions & 0 deletions extensions/vscode/src/eventErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (C) 2024 by Posit Software, PBC.

import { describe, expect, test } from "vitest";
import { EventStreamMessage } from "./api";
import {
EventStreamMessageErrorCoded,
isCodedEventErrorMessage,
isEvtErrDeploymentFailed,
handleEventCodedError,
} from "./eventErrors";
import { ErrorCode } from "./utils/errorTypes";

function mkEventStreamMsg(data: Record<PropertyKey, any>): EventStreamMessage;
function mkEventStreamMsg(
data: Record<PropertyKey, any>,
errCode: ErrorCode,
): EventStreamMessageErrorCoded;
function mkEventStreamMsg(data: Record<PropertyKey, any>, errCode?: ErrorCode) {
const smsg: EventStreamMessage = {
type: "publish/failure",
time: "Tue Oct 01 2024 10:00:00 GMT-0600",
data,
error: "failed to publish",
};
if (errCode) {
smsg.errCode = errCode;
}
return smsg;
}

describe("Event errors", () => {
test("isCodedEventErrorMessage", () => {
// Message without error code
let streamMsg = mkEventStreamMsg({});
let result = isCodedEventErrorMessage(streamMsg);
expect(result).toBe(false);

// Message with error code
streamMsg = mkEventStreamMsg({}, "deployFailed");
result = isCodedEventErrorMessage(streamMsg);
expect(result).toBe(true);
});

test("isEvtErrDeploymentFailed", () => {
// Message with another error code
let streamMsg = mkEventStreamMsg({}, "unknown");
let result = isEvtErrDeploymentFailed(streamMsg);
expect(result).toBe(false);

// Message with error code
streamMsg = mkEventStreamMsg({}, "deployFailed");
result = isEvtErrDeploymentFailed(streamMsg);
expect(result).toBe(true);
});

test("handleEventCodedError", () => {
const msgData = {
dashboardUrl: "https://here.it.is/content/abcdefg",
message: "Deployment failed - structured message from the agent",
error: "A possum on the fridge",
};
let streamMsg = mkEventStreamMsg(msgData, "unknown");
let resultMsg = handleEventCodedError(streamMsg);
expect(resultMsg).toBe("Unknown error: A possum on the fridge");

streamMsg = mkEventStreamMsg(msgData, "deployFailed");
resultMsg = handleEventCodedError(streamMsg);
expect(resultMsg).toBe(
"Deployment failed - structured message from the agent",
);
});
});
39 changes: 39 additions & 0 deletions extensions/vscode/src/eventErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (C) 2024 by Posit Software, PBC.

import { EventStreamMessage } from "./api/types/events";
import { ErrorCode } from "./utils/errorTypes";

export interface EventStreamMessageErrorCoded<T = Record<string, string>>
extends EventStreamMessage<T> {
errCode: ErrorCode;
}

export function isCodedEventErrorMessage(
msg: EventStreamMessage,
): msg is EventStreamMessageErrorCoded {
return msg.errCode !== undefined;
}

type deploymentEvtErr = {
dashboardUrl: string;
localId: string;
message: string;
error: string;
};

export const isEvtErrDeploymentFailed = (
emsg: EventStreamMessageErrorCoded,
): emsg is EventStreamMessageErrorCoded<deploymentEvtErr> => {
return emsg.errCode === "deployFailed";
};

export const handleEventCodedError = (
emsg: EventStreamMessageErrorCoded,
): string => {
if (isEvtErrDeploymentFailed(emsg)) {
return emsg.data.message;
}

const unknownErrMsg = emsg.data.error || emsg.data.message;
return `Unknown error: ${unknownErrMsg}`;
};
5 changes: 3 additions & 2 deletions extensions/vscode/src/utils/errorTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import { AxiosError, AxiosResponse, isAxiosError } from "axios";

type ErrorCode =
export type ErrorCode =
| "unknown"
| "resourceNotFound"
| "invalidTOML"
| "unknownTOMLKey"
| "invalidConfigFile";
| "invalidConfigFile"
| "deployFailed";

export type axiosErrorWithJson<T = { code: ErrorCode; details: unknown }> =
AxiosError & {
Expand Down
20 changes: 14 additions & 6 deletions extensions/vscode/src/views/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
} from "vscode";

import { EventStream, displayEventStreamMessage } from "src/events";
import {
isCodedEventErrorMessage,
handleEventCodedError,
} from "src/eventErrors";

import {
EventStreamMessage,
Expand Down Expand Up @@ -195,12 +199,16 @@ export class LogsTreeDataProvider implements TreeDataProvider<LogsTreeItem> {
if (enhancedError && enhancedError.buttonStr) {
options.push(enhancedError.buttonStr);
}
const selection = await window.showErrorMessage(
msg.data.cancelled === "true"
? `Deployment cancelled: ${msg.data.message}`
: `Deployment failed: ${msg.data.message}`,
...options,
);
let errorMessage = "";
if (isCodedEventErrorMessage(msg)) {
errorMessage = handleEventCodedError(msg);
} else {
errorMessage =
msg.data.cancelled === "true"
? `Deployment cancelled: ${msg.data.message}`
: `Deployment failed: ${msg.data.message}`;
}
const selection = await window.showErrorMessage(errorMessage, ...options);
if (selection === showLogsOption) {
await commands.executeCommand(Commands.Logs.Focus);
} else if (selection === enhancedError?.buttonStr) {
Expand Down
1 change: 1 addition & 0 deletions internal/clients/connect/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type User struct {

type APIClient interface {
TestAuthentication(logging.Logger) (*User, error)
ContentDetails(contentID types.ContentID, body *ConnectContent, log logging.Logger) error
CreateDeployment(*ConnectContent, logging.Logger) (types.ContentID, error)
UpdateDeployment(types.ContentID, *ConnectContent, logging.Logger) error
SetEnvVars(types.ContentID, config.Environment, logging.Logger) error
Expand Down
5 changes: 5 additions & 0 deletions internal/clients/connect/client_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ type connectGetContentDTO struct {
// Owner *ownerOutputDTO `json:"owner,omitempty"`
}

func (c *ConnectClient) ContentDetails(contentID types.ContentID, body *ConnectContent, log logging.Logger) error {
url := fmt.Sprintf("/__api__/v1/content/%s", contentID)
return c.client.Get(url, body, log)
}

func (c *ConnectClient) CreateDeployment(body *ConnectContent, log logging.Logger) (types.ContentID, error) {
content := connectGetContentDTO{}
err := c.client.Post("/__api__/v1/content", body, &content, log)
Expand Down
22 changes: 22 additions & 0 deletions internal/clients/connect/client_connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,25 @@ func (s *ConnectClientSuite) TestTestAuthenticationNotPublisher() {
s.NotNil(err)
s.ErrorContains(err, "user account bob with role 'viewer' does not have permission to publish content")
}

func (s *ConnectClientSuite) TestContentDetails() {
lgr := logging.New()
content := &ConnectContent{}
httpClient := &http_client.MockHTTPClient{}
httpClient.On("Get", "/__api__/v1/content/e8922765-4880-43cd-abc0-d59fe59b8b4b", content, lgr).Return(nil)

client := &ConnectClient{
client: httpClient,
}

err := client.ContentDetails("e8922765-4880-43cd-abc0-d59fe59b8b4b", content, lgr)
s.NoError(err)
httpClient.AssertExpectations(s.T())

expectedErr := errors.New("unreachable")
httpClient.On("Get", "/__api__/v1/content/cf3d3afe-2076-4812-825a-28237252030b", content, lgr).Return(expectedErr)

err = client.ContentDetails("cf3d3afe-2076-4812-825a-28237252030b", content, lgr)
s.ErrorIs(err, expectedErr)
httpClient.AssertExpectations(s.T())
}
12 changes: 12 additions & 0 deletions internal/clients/connect/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package connect
// Copyright (C) 2023 by Posit Software, PBC.

import (
"fmt"

"github.com/posit-dev/publisher/internal/config"
"github.com/posit-dev/publisher/internal/types"
)

type ConnectContent struct {
Name types.ContentName `json:"name"`
Title string `json:"title,omitempty"`
GUID string `json:"guid,omitempty"`
Description string `json:"description,omitempty"`
AccessType string `json:"access_type,omitempty"`
ConnectionTimeout *int32 `json:"connection_timeout,omitempty"`
Expand All @@ -32,6 +35,15 @@ type ConnectContent struct {
DefaultImageName string `json:"default_image_name,omitempty"`
DefaultREnvironmentManagement *bool `json:"default_r_environment_management,omitempty"`
DefaultPyEnvironmentManagement *bool `json:"default_py_environment_management,omitempty"`
Locked bool `json:"locked,omitempty"`
}

// Returns and error if content is locked
func (c *ConnectContent) LockedError() error {
if c.Locked {
return fmt.Errorf("content with ID %s is locked", c.GUID)
}
return nil
}

func copy[T any](src *T) *T {
Expand Down
10 changes: 10 additions & 0 deletions internal/clients/connect/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ func (m *MockClient) TestAuthentication(log logging.Logger) (*User, error) {
}
}

func (m *MockClient) ContentDetails(id types.ContentID, s *ConnectContent, log logging.Logger) error {
// Updates content as locked when needed
if id == "myLockedContentID" {
s.GUID = "myLockedContentID"
s.Locked = true
}
args := m.Called(id, s, log)
return args.Error(0)
}

func (m *MockClient) CreateDeployment(s *ConnectContent, log logging.Logger) (types.ContentID, error) {
args := m.Called(s, log)
return args.Get(0).(types.ContentID), args.Error(1)
Expand Down
9 changes: 9 additions & 0 deletions internal/clients/http_client/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,12 @@ func NewHTTPClientForAccount(account *accounts.Account, timeout time.Duration, l
Transport: authTransport,
}, nil
}

func IsHTTPAgentErrorStatusOf(err error, status int) (*types.AgentError, bool) {
if aerr, isAgentErr := err.(*types.AgentError); isAgentErr {
if httperr, isHttpErr := aerr.Err.(*HTTPError); isHttpErr {
return aerr, httperr.Status == status
}
}
return nil, false
}
45 changes: 45 additions & 0 deletions internal/clients/http_client/http_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package http_client

import (
"errors"
"net/http"
"testing"

"github.com/posit-dev/publisher/internal/events"
"github.com/posit-dev/publisher/internal/types"
"github.com/posit-dev/publisher/internal/util/utiltest"
"github.com/stretchr/testify/suite"
)

// Copyright (C) 2024 by Posit Software, PBC.

type HttpClientSuite struct {
utiltest.Suite
}

func TestHttpClientSuite(t *testing.T) {
suite.Run(t, new(HttpClientSuite))
}

func (s *HttpClientSuite) TestIsHTTPAgentErrorStatusOf() {
agentErr := types.NewAgentError(
events.DeploymentFailedCode,
NewHTTPError("", "", http.StatusNotFound),
nil,
)

// With a true agent error
resultingErr, yesItIs := IsHTTPAgentErrorStatusOf(agentErr, http.StatusNotFound)
s.Equal(yesItIs, true)
s.Equal(agentErr, resultingErr)

// With a true agent error, but a status that it is not
resultingErr, yesItIs = IsHTTPAgentErrorStatusOf(agentErr, http.StatusBadGateway)
s.Equal(yesItIs, false)
s.Equal(agentErr, resultingErr)

// With a non agent error
resultingErr, yesItIs = IsHTTPAgentErrorStatusOf(errors.New("nope"), http.StatusNotFound)
s.Equal(yesItIs, false)
s.Nil(resultingErr)
}
2 changes: 1 addition & 1 deletion internal/events/cli_emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (e *cliEmitter) Emit(event *Event) error {
fmt.Fprintln(e.writer, "[OK]", formatEventData(event.Data))
case FailurePhase:
e.writer.NeedNewline = false
fmt.Fprintln(e.writer, "[ERROR]", event.errCode, formatEventData(event.Data))
fmt.Fprintln(e.writer, "[ERROR]", event.ErrCode, formatEventData(event.Data))
}
return nil
}
14 changes: 7 additions & 7 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ type EventData = types.ErrorData
var NoData = struct{}{}

type Event struct {
Time time.Time
Type EventType
Data EventData
Time time.Time
Type EventType
Data EventData
ErrCode ErrorCode

op Operation
phase Phase
errCode ErrorCode
op Operation
phase Phase
}

// We use Operation and Phase to construct the event Type.
Expand Down Expand Up @@ -72,9 +72,9 @@ func New(op Operation, phase Phase, errCode ErrorCode, data any) *Event {
Time: time.Now(),
Type: EventTypeOf(op, phase),
Data: eventData,
ErrCode: errCode,
op: op,
phase: phase,
errCode: errCode,
}
}

Expand Down
Loading

0 comments on commit 14c2dd7

Please sign in to comment.