diff --git a/extensions/vscode/src/api/resources/ContentRecords.ts b/extensions/vscode/src/api/resources/ContentRecords.ts index d76cf82b7..09e2ab536 100644 --- a/extensions/vscode/src/api/resources/ContentRecords.ts +++ b/extensions/vscode/src/api/resources/ContentRecords.ts @@ -145,4 +145,22 @@ export class ContentRecords { }, ); } + + // Returns: + // 200 - success + // 404 - not found + // 500 - internal server error + cancelDeployment(deploymentName: string, dir: string, localId: string) { + const encodedName = encodeURIComponent(deploymentName); + const encodedLocalId = encodeURIComponent(localId); + return this.client.post( + `deployments/${encodedName}/cancel/${encodedLocalId}`, + {}, + { + params: { + dir, + }, + }, + ); + } } diff --git a/extensions/vscode/src/api/types/contentRecords.ts b/extensions/vscode/src/api/types/contentRecords.ts index e93734e17..91488d190 100644 --- a/extensions/vscode/src/api/types/contentRecords.ts +++ b/extensions/vscode/src/api/types/contentRecords.ts @@ -44,6 +44,8 @@ type ContentRecordRecord = { serverUrl: string; saveName: string; createdAt: string; + localId: string; + abortedAt: string; configurationName: string; type: ContentType; deploymentError: AgentError | null; diff --git a/extensions/vscode/src/api/types/events.ts b/extensions/vscode/src/api/types/events.ts index 07a628037..31857c960 100644 --- a/extensions/vscode/src/api/types/events.ts +++ b/extensions/vscode/src/api/types/events.ts @@ -1254,7 +1254,7 @@ export interface PublishFailure extends EventStreamMessage { data: { dashboardUrl: string; url: string; - canceled?: string; // not defined if not user cancelled. Value of "true" if true. + canceled?: string; // not defined if not user canceled. Value of "true" if true. // and other non-defined attributes }; error: string; // translated internally diff --git a/extensions/vscode/src/events.ts b/extensions/vscode/src/events.ts index 0d09258e1..c61f3c40e 100644 --- a/extensions/vscode/src/events.ts +++ b/extensions/vscode/src/events.ts @@ -76,6 +76,9 @@ export function displayEventStreamMessage(msg: EventStreamMessage): string { if (msg.data.dashboardUrl) { return `Deployment failed, click to view Connect logs: ${msg.data.dashboardUrl}`; } + if (msg.data.canceled === "true") { + return "Deployment dismissed"; + } return "Deployment failed"; } if (msg.error !== undefined) { @@ -95,8 +98,8 @@ export class EventStream extends Readable implements Disposable { private messages: EventStreamMessage[] = []; // Map to store event callbacks private callbacks: Map = new Map(); - // Cancelled Event Streams - Suppressed when received - private cancelledLocalIDs: string[] = []; + // Canceled Event Streams - Suppressed when received + private canceledLocalIDs: string[] = []; /** * Creates a new instance of the EventStream class. @@ -170,19 +173,25 @@ export class EventStream extends Readable implements Disposable { * @returns undefined */ public suppressMessages(localId: string) { - this.cancelledLocalIDs.push(localId); + this.canceledLocalIDs.push(localId); } private processMessage(msg: EventStreamMessage) { - const localId = msg.data.localId; - if (localId && this.cancelledLocalIDs.includes(localId)) { + // Some log messages passed on from Connect include + // the localId using snake_case, rather than pascalCase. + // To filter correctly, we need to check for both. + + const localId = msg.data.localId || msg.data.local_id; + if (localId && this.canceledLocalIDs.includes(localId)) { // suppress and ignore return; } + // Trace message - // console.debug( - // `eventSource trace: ${event.type}: ${JSON.stringify(event)}`, - // ); + // Uncomment the following code if you want to dump every message to the + // console as it is received. + // console.debug(`eventSource trace: ${msg.type}: ${JSON.stringify(msg)}`); + // Add the message to the messages array this.messages.push(msg); // Emit a 'message' event with the message as the payload diff --git a/extensions/vscode/src/multiStepInputs/newCredential.ts b/extensions/vscode/src/multiStepInputs/newCredential.ts index e2210d55c..145116ea9 100644 --- a/extensions/vscode/src/multiStepInputs/newCredential.ts +++ b/extensions/vscode/src/multiStepInputs/newCredential.ts @@ -54,7 +54,7 @@ export async function newCredential( totalSteps: -1, data: { // each attribute is initialized to undefined - // to be returned when it has not been cancelled + // to be returned when it has not been canceled url: startingServerUrl, // eventual type is string apiKey: undefined, // eventual type is string name: undefined, // eventual type is string diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index d80bf812e..7d6d09a27 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -357,7 +357,7 @@ export async function newDeployment( title: "Select Entrypoint File (main file for your project)", }); if (!fileUris || !fileUris[0]) { - // cancelled. + // canceled. continue; } const fileUri = fileUris[0]; diff --git a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts index f6a1134f2..435db01c3 100644 --- a/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts +++ b/extensions/vscode/src/multiStepInputs/selectNewOrExistingConfig.ts @@ -245,7 +245,7 @@ export async function selectNewOrExistingConfig( totalSteps: -1, data: { // each attribute is initialized to undefined - // to be returned when it has not been cancelled to assist type guards + // to be returned when it has not been canceled to assist type guards // Note: We can't initialize existingConfigurationName to a specific initial // config, as we then wouldn't be able to detect if the user hit ESC to exit // the selection. :-( diff --git a/extensions/vscode/src/state.ts b/extensions/vscode/src/state.ts index 0280ff576..944f587f4 100644 --- a/extensions/vscode/src/state.ts +++ b/extensions/vscode/src/state.ts @@ -167,6 +167,26 @@ export class PublisherState implements Disposable { } } + updateContentRecord( + newValue: ContentRecord | PreContentRecord | PreContentRecordWithConfig, + ) { + const existingContentRecord = this.findContentRecord( + newValue.saveName, + newValue.projectDir, + ); + if (existingContentRecord) { + const crIndex = this.contentRecords.findIndex( + (contentRecord) => + contentRecord.deploymentPath === existingContentRecord.deploymentPath, + ); + if (crIndex !== -1) { + this.contentRecords[crIndex] = newValue; + } else { + this.contentRecords.push(newValue); + } + } + } + async getSelectedConfiguration() { const contentRecord = await this.getSelectedContentRecord(); if (!contentRecord) { diff --git a/extensions/vscode/src/test/unit-test-utils/factories.ts b/extensions/vscode/src/test/unit-test-utils/factories.ts index bd15e3354..29964d6a0 100644 --- a/extensions/vscode/src/test/unit-test-utils/factories.ts +++ b/extensions/vscode/src/test/unit-test-utils/factories.ts @@ -43,6 +43,8 @@ export const preContentRecordFactory = Factory.define( serverUrl: `https://connect-test-${sequence}/connect`, saveName: `Report ${sequence}`, createdAt: new Date().toISOString(), + localId: "", + abortedAt: "", configurationName: `report-GUD${sequence}`, type: ContentType.RMD, deploymentError: null, @@ -67,6 +69,8 @@ export const contentRecordFactory = Factory.define( serverUrl: `https://connect-test-${sequence}/connect`, saveName: `Report ${sequence}`, createdAt: new Date().toISOString(), + localId: "", + abortedAt: "", deployedAt: new Date().toISOString(), configurationName: `report-GUD${sequence}`, type: ContentType.RMD, diff --git a/extensions/vscode/src/views/deployProgress.ts b/extensions/vscode/src/views/deployProgress.ts index 573dd5c02..5305d3f9b 100644 --- a/extensions/vscode/src/views/deployProgress.ts +++ b/extensions/vscode/src/views/deployProgress.ts @@ -1,10 +1,28 @@ // Copyright (C) 2024 by Posit Software, PBC. import { ProgressLocation, Uri, env, window } from "vscode"; -import { EventStreamMessage, eventMsgToString } from "src/api"; +import { + EventStreamMessage, + eventMsgToString, + useApi, + ContentRecord, + PreContentRecord, + PreContentRecordWithConfig, +} from "src/api"; import { EventStream, UnregisterCallback } from "src/events"; +import { getSummaryStringFromError } from "src/utils/errors"; -export function deployProject(localID: string, stream: EventStream) { +type UpdateActiveContentRecordCB = ( + contentRecord: ContentRecord | PreContentRecord | PreContentRecordWithConfig, +) => void; + +export function deployProject( + deploymentName: string, + dir: string, + localID: string, + stream: EventStream, + updateActiveContentRecordCB: UpdateActiveContentRecordCB, +) { window.withProgress( { location: ProgressLocation.Notification, @@ -27,25 +45,44 @@ export function deployProject(localID: string, stream: EventStream) { registrations.forEach((cb) => cb.unregister()); }; - token.onCancellationRequested(() => { + token.onCancellationRequested(async () => { + const api = await useApi(); streamID = "NEVER_A_VALID_STREAM"; unregisterAll(); - // inject a psuedo end of publishing event - stream.injectMessage({ - type: "publish/failure", - time: Date.now().toString(), - data: { - dashboardUrl: "", - url: "", - // and other non-defined attributes - localId: localID, - cancelled: "true", - message: - "Deployment has been dismissed (but will continue to be processed on the Connect Server). Deployment status will be reset to the prior known state.", - }, - error: "Deployment has been dismissed.", - }); - stream.suppressMessages(localID); + try { + const response = await api.contentRecords.cancelDeployment( + deploymentName, + dir, + localID, + ); + + // update the UX locally + updateActiveContentRecordCB(response.data); + + // we must have been successful... + // inject a psuedo end of publishing event + stream.injectMessage({ + type: "publish/failure", + time: Date.now().toString(), + data: { + dashboardUrl: "", + url: "", + // and other non-defined attributes + localId: localID, + canceled: "true", + message: + "Deployment has been dismissed, but will continue to be processed on the Connect Server.", + }, + error: "Deployment has been dismissed.", + }); + stream.suppressMessages(localID); + } catch (error: unknown) { + const summary = getSummaryStringFromError( + "deployProject, token.onCancellationRequested", + error, + ); + window.showErrorMessage(`Unable to dismiss deployment: ${summary}`); + } resolveCB(); }); diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index b9c61b5e8..c474aabb1 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -36,6 +36,7 @@ import { useApi, AllContentRecordTypes, EnvironmentConfig, + PreContentRecordWithConfig, } from "src/api"; import { EventStream } from "src/events"; import { getPythonInterpreterPath, getRInterpreterPath } from "../utils/vscode"; @@ -208,7 +209,13 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { secrets, r, ); - deployProject(response.data.localId, this.stream); + deployProject( + deploymentName, + projectDir, + response.data.localId, + this.stream, + this.updateActiveContentRecordLocally.bind(this), + ); } catch (error: unknown) { // Most failures will occur on the event stream. These are the ones which // are immediately rejected as part of the API request to initiate deployment. @@ -310,6 +317,19 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { } } + private updateActiveContentRecordLocally( + activeContentRecord: + | ContentRecord + | PreContentRecord + | PreContentRecordWithConfig, + ) { + // update our local state, so we don't wait on file refreshes + this.state.updateContentRecord(activeContentRecord); + + // refresh the webview + this.updateWebViewViewContentRecords(); + } + private onPublishStart() { this.webviewConduit.sendMsg({ kind: HostToWebviewMessageType.PUBLISH_START, @@ -949,7 +969,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { activeConfig.configuration.environment, ); if (name === undefined) { - // Cancelled by the user + // Canceled by the user return; } diff --git a/extensions/vscode/src/views/logs.ts b/extensions/vscode/src/views/logs.ts index 3ed65f493..72e0450e2 100644 --- a/extensions/vscode/src/views/logs.ts +++ b/extensions/vscode/src/views/logs.ts @@ -41,6 +41,7 @@ enum LogStageStatus { inProgress, completed, failed, + canceled, } type LogStage = { @@ -182,14 +183,17 @@ export class LogsTreeDataProvider implements TreeDataProvider { }); this.stream.register("publish/failure", async (msg: EventStreamMessage) => { - this.publishingStage.status = LogStageStatus.failed; + const failedOrCanceledStatus = msg.data.canceled + ? LogStageStatus.canceled + : LogStageStatus.failed; + this.publishingStage.status = failedOrCanceledStatus; this.publishingStage.events.push(msg); this.stages.forEach((stage) => { if (stage.status === LogStageStatus.notStarted) { stage.status = LogStageStatus.neverStarted; } else if (stage.status === LogStageStatus.inProgress) { - stage.status = LogStageStatus.failed; + stage.status = failedOrCanceledStatus; } }); @@ -204,11 +208,19 @@ export class LogsTreeDataProvider implements TreeDataProvider { errorMessage = handleEventCodedError(msg); } else { errorMessage = - msg.data.cancelled === "true" - ? `Deployment cancelled: ${msg.data.message}` + msg.data.canceled === "true" + ? msg.data.message : `Deployment failed: ${msg.data.message}`; } - const selection = await window.showErrorMessage(errorMessage, ...options); + let selection: string | undefined; + if (msg.data.canceled === "true") { + selection = await window.showInformationMessage( + errorMessage, + ...options, + ); + } else { + selection = await window.showErrorMessage(errorMessage, ...options); + } if (selection === showLogsOption) { await commands.executeCommand(Commands.Logs.Focus); } else if (selection === enhancedError?.buttonStr) { @@ -259,7 +271,11 @@ export class LogsTreeDataProvider implements TreeDataProvider { (msg: EventStreamMessage) => { const stage = this.stages.get(stageName); if (stage) { - stage.status = LogStageStatus.failed; + if (msg.data.canceled === "true") { + stage.status = LogStageStatus.canceled; + } else { + stage.status = LogStageStatus.failed; + } stage.events.push(msg); } this.refresh(); @@ -413,6 +429,11 @@ export class LogsTreeStageItem extends TreeItem { this.iconPath = new ThemeIcon("error"); this.collapsibleState = TreeItemCollapsibleState.Expanded; break; + case LogStageStatus.canceled: + this.label = this.stage.inactiveLabel; + this.iconPath = new ThemeIcon("circle-slash"); + this.collapsibleState = TreeItemCollapsibleState.Expanded; + break; } } } diff --git a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue index f25d095c2..839a97739 100644 --- a/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue +++ b/extensions/vscode/webviews/homeView/src/components/EvenEasierDeploy.vue @@ -168,38 +168,45 @@ :context-menu="contextMenuVSCodeContext" /> -
- Is this already deployed to a Connect server? You can - update that previous deployment. -
-
- This deployment +
+ {{ formatDateString(home.selectedContentRecord.abortedAt) }} +
+ +
{ ? "Not Yet Updated" : "Not Yet Deployed"; } + if (isAbortedContentRecord.value) { + return "Last Deployment Dismissed"; + } return "Last Deployment Successful"; }); @@ -420,6 +430,10 @@ const isPreContentRecordWithoutID = computed(() => { ); }); +const isAbortedContentRecord = computed(() => { + return Boolean(home.selectedContentRecord?.abortedAt); +}); + const toolTipText = computed(() => { let entrypoint = "unknown"; if ( @@ -603,7 +617,7 @@ const viewContent = () => { margin-bottom: 5px; } -.last-deployment-time { +.date-time { margin-bottom: 20px; } diff --git a/internal/deployment/deployment.go b/internal/deployment/deployment.go index f59b61e78..e4f4054e5 100644 --- a/internal/deployment/deployment.go +++ b/internal/deployment/deployment.go @@ -3,21 +3,27 @@ package deployment // Copyright (C) 2023 by Posit Software, PBC. import ( + "errors" "fmt" "io" + "io/fs" "strings" + "sync" "time" "github.com/pelletier/go-toml/v2" "github.com/posit-dev/publisher/internal/accounts" "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/inspect/dependencies/renv" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/project" "github.com/posit-dev/publisher/internal/schema" "github.com/posit-dev/publisher/internal/types" "github.com/posit-dev/publisher/internal/util" ) +var DeploymentRecordMutex sync.Mutex + type Deployment struct { // Predeployment and full deployment fields Schema string `toml:"$schema" json:"$schema"` @@ -25,6 +31,8 @@ type Deployment struct { ServerURL string `toml:"server_url" json:"serverUrl"` ClientVersion string `toml:"client_version" json:"-"` CreatedAt string `toml:"created_at" json:"createdAt"` + LocalID string `toml:"local_id" json:"localId"` + AbortedAt string `toml:"aborted_at" json:"abortedAt"` Type config.ContentType `toml:"type" json:"type"` ConfigName string `toml:"configuration_name" json:"configurationName"` ID types.ContentID `toml:"id,omitempty" json:"id"` @@ -135,19 +143,60 @@ func (d *Deployment) Write(w io.Writer) error { return enc.Encode(d) } -func (d *Deployment) WriteFile(path util.AbsolutePath) error { +func (d *Deployment) WriteFile( + path util.AbsolutePath, + localId string, + forceUpdate bool, + log logging.Logger, +) (*Deployment, error) { + log.Debug("Attempting to update deployment record", "path", path, "localId", localId, "forceUpdate", forceUpdate) + + // We single task our updates to the all deployment records in + // order to handle parallel access control + DeploymentRecordMutex.Lock() + defer DeploymentRecordMutex.Unlock() + + // Allow bypass of ownership control checks. This allows a thread + // to establish ownership of a deployment record, as is done at beginning + // of each publishing run. + if !forceUpdate { + // we will only update the deployment record, if the local id + // matches (which confirms the ownership of the record vs. another deployment thread) + existingDeployment, err := FromFile(path) + if err != nil { + // If the deployment file doesn't exist, that's ok + if !errors.Is(err, fs.ErrNotExist) { + // we have an invalid deployment file. + // we'll have to fail in error. + return nil, err + } + } + if existingDeployment != nil { + if existingDeployment.LocalID != "" && existingDeployment.LocalID != string(localId) { + log.Debug("Skipping deployment record update since existing record is being updated by another thread.") + return existingDeployment, nil + } + if existingDeployment.AbortedAt != "" { + log.Debug("Skipping deployment record update since deployment has been canceled") + return existingDeployment, nil + } + } + } + log.Debug("Updating deployment record", "path", path) + err := path.Dir().MkdirAll(0777) if err != nil { - return err + return nil, err } f, err := path.Create() if err != nil { - return err + return nil, err } + defer f.Close() err = d.Write(f) if err != nil { - return err + return nil, err } - return nil + return d, nil } diff --git a/internal/deployment/deployment_test.go b/internal/deployment/deployment_test.go index f9d719b7a..1cfce0e55 100644 --- a/internal/deployment/deployment_test.go +++ b/internal/deployment/deployment_test.go @@ -10,6 +10,7 @@ import ( "github.com/posit-dev/publisher/internal/accounts" "github.com/posit-dev/publisher/internal/config" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/schema" "github.com/posit-dev/publisher/internal/types" "github.com/posit-dev/publisher/internal/util" @@ -21,6 +22,7 @@ import ( type DeploymentSuite struct { utiltest.Suite cwd util.AbsolutePath + log logging.Logger } func TestDeploymentSuite(t *testing.T) { @@ -33,6 +35,7 @@ func (s *DeploymentSuite) SetupTest() { s.Nil(err) s.cwd = cwd s.cwd.MkdirAll(0700) + s.log = logging.New() } func (s *DeploymentSuite) createDeploymentFile(name string) *Deployment { @@ -47,7 +50,8 @@ func (s *DeploymentSuite) createDeploymentFile(name string) *Deployment { Version: "3.4.5", PackageManager: "pip", } - err := d.WriteFile(path) + d.LocalID = "abc" + _, err := d.WriteFile(path, d.LocalID, false, s.log) s.NoError(err) return d } @@ -109,25 +113,179 @@ func (s *DeploymentSuite) TestFromFileErr() { func (s *DeploymentSuite) TestWriteFile() { deploymentFile := GetDeploymentPath(s.cwd, "myTargetName") d := New() - err := d.WriteFile(deploymentFile) + _, err := d.WriteFile(deploymentFile, "", false, s.log) s.NoError(err) content, err := deploymentFile.ReadFile() s.NoError(err) stringContent := string(content) - firstLine := strings.Split(stringContent, "\n")[0] + lines := strings.Split(stringContent, "\n") + firstLine := lines[0] s.Equal(autogenHeader, firstLine+"\n") // This is a pre-deployment, so should not contain certain fields. - s.NotContains(stringContent, "id") - s.NotContains(stringContent, "deployed_at") - s.NotContains(stringContent, "bundle_id") - s.NotContains(stringContent, "bundle_url") - s.NotContains(stringContent, "dashboard_url") - s.NotContains(stringContent, "direct_url") - s.NotContains(stringContent, "deployment_error") - s.NotContains(stringContent, "files") - s.NotContains(stringContent, "[configuration") + unexpected := [...]string{ + "id", + "deployed_at", + "bundle_id", + "bundle_url", + "dashboard_url", + "direct_url", + "deployment_error", + "files", + "[configuration", + } + for _, line := range lines { + for _, field := range unexpected { + s.False(strings.HasPrefix(line, field), "Unexpected prefix \"%s\" in line: \"%s\"", field, line) + } + } +} + +func (s *DeploymentSuite) TestWriteFileOptions() { + + type testOptions struct { + existingLocalID string + updateLocalID string + existingAborted string + override bool + expectedSuccess bool + } + + tests := [...]testOptions{ + { + existingLocalID: "123", + updateLocalID: "123", + existingAborted: "", + override: false, + expectedSuccess: true, + }, + { + existingLocalID: "123", + updateLocalID: "123", + existingAborted: "2025-01-08T17:10:22-08:00", + override: false, + expectedSuccess: false, + }, + { + existingLocalID: "123", + updateLocalID: "123", + existingAborted: "", + override: true, + expectedSuccess: true, + }, + { + existingLocalID: "123", + updateLocalID: "123", + existingAborted: "2025-01-08T17:10:22-08:00", + override: true, + expectedSuccess: true, + }, + { + existingLocalID: "", + updateLocalID: "123", + existingAborted: "", + override: false, + expectedSuccess: true, + }, + { + existingLocalID: "", + updateLocalID: "123", + existingAborted: "2025-01-08T17:10:22-08:00", + override: false, + expectedSuccess: false, + }, + { + existingLocalID: "", + updateLocalID: "123", + existingAborted: "", + override: true, + expectedSuccess: true, + }, + { + existingLocalID: "", + updateLocalID: "123", + existingAborted: "2025-01-08T17:10:22-08:00", + override: true, + expectedSuccess: true, + }, + { + existingLocalID: "123", + updateLocalID: "456", + existingAborted: "", + override: false, + expectedSuccess: false, + }, + { + existingLocalID: "123", + updateLocalID: "456", + existingAborted: "2025-01-08T17:10:22-08:00", + override: false, + expectedSuccess: false, + }, + { + existingLocalID: "123", + updateLocalID: "456", + existingAborted: "", + override: true, + expectedSuccess: true, + }, + { + existingLocalID: "123", + updateLocalID: "456", + existingAborted: "2025-01-08T17:10:22-08:00", + override: true, + expectedSuccess: true, + }, + } + + for ndx, test := range tests { + i := ndx + 1 + j := 1 + s.SetupTest() + + // create original file + deploymentFile := GetDeploymentPath(s.cwd, "myTargetName") + d := New() + d.ConfigName = "original" // Tests use this field to detect changes in file on disk + d.LocalID = test.existingLocalID + d.AbortedAt = test.existingAborted + returnedD, err := d.WriteFile(deploymentFile, test.existingLocalID, false, s.log) + s.NoError(err) + s.Equal("original", returnedD.ConfigName, "Failed iteration %d of test (location 1)", i) + j++ + + // confirm it was written + origD, err := FromFile(deploymentFile) + s.NoError(err) + s.Equal(test.existingLocalID, origD.LocalID, "Failed iteration %d of test (location 2)", i) + j++ + s.Equal("original", origD.ConfigName, "Failed iteration %d of test (location 3)", i) + j++ + + // try and update it + origD.ConfigName = "updated" + returnedD, err = origD.WriteFile(deploymentFile, test.updateLocalID, test.override, s.log) + s.NoError(err) + if test.expectedSuccess { + s.Equal("updated", returnedD.ConfigName, "Failed iteration %d of test (location 4)", i) + j++ + } else { + s.NotEqual("updated", returnedD.ConfigName, "Failed iteration %d of test (location 5)", i) + j++ + } + + // determine test success based on test array + updatedD, err := FromFile(deploymentFile) + s.NoError(err) + if test.expectedSuccess { + s.Equal("updated", updatedD.ConfigName, "Failed iteration %d of test (location 6)", i) + j++ + } else { + s.NotEqual("updated", updatedD.ConfigName, "Failed iteration %d of test (location 7)", i) + j++ + } + } } func (s *DeploymentSuite) TestWriteFileErr() { @@ -135,7 +293,7 @@ func (s *DeploymentSuite) TestWriteFileErr() { readonlyFs := afero.NewReadOnlyFs(deploymentFile.Fs()) readonlyFile := deploymentFile.WithFs(readonlyFs) deployment := New() - err := deployment.WriteFile(readonlyFile) + _, err := deployment.WriteFile(readonlyFile, "", false, s.log) s.NotNil(err) } diff --git a/internal/publish/bundle.go b/internal/publish/bundle.go index 80847da36..9115d14db 100644 --- a/internal/publish/bundle.go +++ b/internal/publish/bundle.go @@ -106,7 +106,7 @@ func (p *defaultPublisher) createAndUploadBundle( p.Target.Renv = lockfile } - err = p.writeDeploymentRecord() + _, err = p.writeDeploymentRecord(false) if err != nil { return "", err } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 3efc7a20f..59504c410 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -134,7 +134,7 @@ func (p *defaultPublisher) emitErrorEvents(err error) { // Record the error in the deployment record if p.Target != nil { p.Target.Error = agentErr - writeErr := p.writeDeploymentRecord() + _, writeErr := p.writeDeploymentRecord(false) if writeErr != nil { p.log.Warn("failed to write updated deployment record", "name", p.TargetName, "err", writeErr) } @@ -170,7 +170,7 @@ func (p *defaultPublisher) emitErrorEvents(err error) { } func (p *defaultPublisher) PublishDirectory() error { - p.log.Info("Publishing from directory", logging.LogKeyOp, events.AgentOp, "path", p.Dir) + p.log.Info("Publishing from directory", logging.LogKeyOp, events.AgentOp, "path", p.Dir, "localID", p.State.LocalID) p.emitter.Emit(events.New(events.PublishOp, events.StartPhase, events.NoError, publishStartData{ Server: p.Account.URL, Title: p.Config.Title, @@ -201,7 +201,7 @@ func (p *defaultPublisher) PublishDirectory() error { return err } -func (p *defaultPublisher) writeDeploymentRecord() error { +func (p *defaultPublisher) writeDeploymentRecord(forceUpdate bool) (*deployment.Deployment, error) { if p.SaveName == "" { // Redeployment p.log.Debug("No SaveName found while redeploying.", "deployment", p.TargetName) @@ -218,8 +218,30 @@ func (p *defaultPublisher) writeDeploymentRecord() error { p.Target.Configuration = p.Config recordPath := deployment.GetDeploymentPath(p.Dir, p.SaveName) - p.log.Debug("Writing deployment record", "path", recordPath) - return p.Target.WriteFile(recordPath) + + return p.Target.WriteFile(recordPath, string(p.State.LocalID), forceUpdate, p.log) +} + +func CancelDeployment( + deploymentPath util.AbsolutePath, + localID string, + log logging.Logger, +) (*deployment.Deployment, error) { + // This function only marks the deployment record as being canceled. + // It does not cancel the anonymous function which is publishing to the server + // This is because the server API does not support cancellation at this time. + + target, err := deployment.FromFile(deploymentPath) + if err != nil { + return nil, err + } + + // mark the deployment as aborted + target.AbortedAt = time.Now().Format(time.RFC3339) + + // Possibly update the deployment file + d, err := target.WriteFile(deploymentPath, localID, false, log) + return d, err } func (p *defaultPublisher) createDeploymentRecord( @@ -252,6 +274,8 @@ func (p *defaultPublisher) createDeploymentRecord( ClientVersion: project.Version, Type: contentType, CreatedAt: created, + LocalID: string(p.State.LocalID), + AbortedAt: "", ID: contentID, ConfigName: p.ConfigName, Files: nil, @@ -265,13 +289,37 @@ func (p *defaultPublisher) createDeploymentRecord( } // Save current deployment information for this target - return p.writeDeploymentRecord() + // Note: Using forced option when writing to cause our new + // localID to be recorded into the deployment record file, which + // then keeps old threads from updating the file for previous + // publishing attempts + _, err := p.writeDeploymentRecord(true) + return err } func (p *defaultPublisher) publishWithClient( account *accounts.Account, client connect.APIClient) error { + var err error + var contentID types.ContentID + + if p.isDeployed() { + contentID = p.Target.ID + p.log.Info("Updating deployment", "content_id", contentID) + } else { + // Create a new deployment; we will update it with details later. + contentID, err = p.createDeployment(client) + } + if err != nil { + return err + } + + err = p.createDeploymentRecord(contentID, account) + if err != nil { + return types.OperationError(events.PublishCreateNewDeploymentOp, err) + } + manifest := bundles.NewManifestFromConfig(p.Config) p.log.Debug("Built manifest from config", "config", p.ConfigName) @@ -292,23 +340,6 @@ func (p *defaultPublisher) publishWithClient( return err } - var contentID types.ContentID - if p.isDeployed() { - contentID = p.Target.ID - p.log.Info("Updating deployment", "content_id", contentID) - } else { - // Create a new deployment; we will update it with details later. - contentID, err = p.createDeployment(client) - } - if err != nil { - return err - } - - err = p.createDeploymentRecord(contentID, account) - if err != nil { - return types.OperationError(events.PublishCreateNewDeploymentOp, err) - } - bundleID, err := p.createAndUploadBundle(client, bundler, contentID) if err != nil { return err diff --git a/internal/schema/schemas/draft/posit-publishing-record-schema-v3.json b/internal/schema/schemas/draft/posit-publishing-record-schema-v3.json index 4134286a6..b7f0e1474 100644 --- a/internal/schema/schemas/draft/posit-publishing-record-schema-v3.json +++ b/internal/schema/schemas/draft/posit-publishing-record-schema-v3.json @@ -69,6 +69,16 @@ "description": "Date and time the deployment was created.", "examples": ["2024-01-19T09:33:33.131481-05:00"] }, + "local_id": { + "type": "string", + "description": "ID of active/latest deployment sequence." + }, + "aborted_at": { + "type": "string", + "format": "date-time", + "description": "Date and time that the current deployment flow was aborted. Is empty if flow was not aborted.", + "examples": ["2024-01-19T09:33:33.131481-05:00"] + }, "deployed_at": { "type": "string", "format": "date-time", diff --git a/internal/schema/schemas/posit-publishing-record-schema-v3.json b/internal/schema/schemas/posit-publishing-record-schema-v3.json index 1dfba0502..a85abba5e 100644 --- a/internal/schema/schemas/posit-publishing-record-schema-v3.json +++ b/internal/schema/schemas/posit-publishing-record-schema-v3.json @@ -70,6 +70,16 @@ "description": "Date and time the deployment was created.", "examples": ["2024-01-19T09:33:33.131481-05:00"] }, + "local_id": { + "type": "string", + "description": "ID of active/latest deployment sequence." + }, + "aborted_at": { + "type": "string", + "format": "date-time", + "description": "Date and time that the current deployment flow was aborted. Is empty if flow was not aborted.", + "examples": ["2024-01-19T09:33:33.131481-05:00"] + }, "deployed_at": { "type": "string", "format": "date-time", diff --git a/internal/services/api/api_service.go b/internal/services/api/api_service.go index e8c59f6d7..a7cbe961d 100644 --- a/internal/services/api/api_service.go +++ b/internal/services/api/api_service.go @@ -165,10 +165,14 @@ func RouterHandlerFunc(base util.AbsolutePath, lister accounts.AccountList, log r.Handle(ToPath("deployments", "{name}"), GetDeploymentHandlerFunc(base, log)). Methods(http.MethodGet) - // POST /api/deployments/$NAME intiates a deployment + // POST /api/deployments/$NAME initiates a deployment r.Handle(ToPath("deployments", "{name}"), PostDeploymentHandlerFunc(base, log, lister, emitter)). Methods(http.MethodPost) + // POST /api/deployments/$NAME/cancel/$LOCALID cancels a deployment + r.Handle(ToPath("deployments", "{name}", "cancel", "{localid}"), PostDeploymentCancelHandlerFunc(base, log)). + Methods(http.MethodPost) + // DELETE /api/deployments/$NAME r.Handle(ToPath("deployments", "{name}"), DeleteDeploymentHandlerFunc(base, log)). Methods(http.MethodDelete) diff --git a/internal/services/api/delete_deployment_test.go b/internal/services/api/delete_deployment_test.go index 3daf27b6c..62b35151f 100644 --- a/internal/services/api/delete_deployment_test.go +++ b/internal/services/api/delete_deployment_test.go @@ -60,7 +60,8 @@ func createSampleDeployment(root util.AbsolutePath, name string) (*deployment.De d.DashboardURL = "/connect/#/apps/12345678" d.DirectURL = "/content/12345678/" d.LogsURL = "/connect/#/apps/12345678/logs" - return d, d.WriteFile(path) + _, err := d.WriteFile(path, "", false, logging.New()) + return d, err } func (s *DeleteDeploymentSuite) fileExists(path util.AbsolutePath) { diff --git a/internal/services/api/deployment_dto.go b/internal/services/api/deployment_dto.go index 61be0d50b..0b3063efb 100644 --- a/internal/services/api/deployment_dto.go +++ b/internal/services/api/deployment_dto.go @@ -32,6 +32,8 @@ type preDeploymentDTO struct { ServerURL string `json:"serverUrl"` SaveName string `json:"saveName"` CreatedAt string `json:"createdAt"` + LocalID string `toml:"local_id,omitempty" json:"localId"` + AbortedAt string `toml:"aborted_at,omitempty" json:"abortedAt"` ConfigName string `json:"configurationName,omitempty"` ConfigPath string `json:"configurationPath,omitempty"` Error *types.AgentError `json:"deploymentError,omitempty"` @@ -105,6 +107,8 @@ func deploymentAsDTO(d *deployment.Deployment, err error, projectDir util.Absolu ServerURL: d.ServerURL, SaveName: saveName, // TODO: remove this duplicate (remove frontend references first) CreatedAt: d.CreatedAt, + AbortedAt: d.AbortedAt, + LocalID: d.LocalID, ConfigName: d.ConfigName, ConfigPath: configPath, Error: d.Error, diff --git a/internal/services/api/get_deployment_env_test.go b/internal/services/api/get_deployment_env_test.go index a34e0e127..d22a16953 100644 --- a/internal/services/api/get_deployment_env_test.go +++ b/internal/services/api/get_deployment_env_test.go @@ -54,7 +54,7 @@ func (s *GetDeploymentEnvSuite) TestGetDeploymentEnv() { d := deployment.New() d.ID = "123" d.ServerURL = "https://connect.example.com" - d.WriteFile(path) + d.WriteFile(path, "", false, s.log) lister := &accounts.MockAccountList{} acct := &accounts.Account{ @@ -120,7 +120,7 @@ func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvFileError() { func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvDeploymentNotDeployed() { path := deployment.GetDeploymentPath(s.cwd, "dep") d := deployment.New() - d.WriteFile(path) + d.WriteFile(path, "", false, s.log) h := GetDeploymentEnvironmentHandlerFunc(s.cwd, s.log, &accounts.MockAccountList{}) @@ -140,7 +140,7 @@ func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvNoCredential() { d := deployment.New() d.ID = "123" d.ServerURL = "https://connect.example.com" - d.WriteFile(path) + d.WriteFile(path, "", false, s.log) lister := &accounts.MockAccountList{} lister.On("GetAccountByServerURL", "https://connect.example.com").Return(nil, errors.New("no such account")) @@ -163,7 +163,7 @@ func (s *GetDeploymentEnvSuite) TestGetDeploymentEnvPassesStatusFromServer() { d := deployment.New() d.ID = "123" d.ServerURL = "https://connect.example.com" - d.WriteFile(path) + d.WriteFile(path, "", false, s.log) lister := &accounts.MockAccountList{} acct := &accounts.Account{ diff --git a/internal/services/api/get_deployment_test.go b/internal/services/api/get_deployment_test.go index de7ccdb1e..ee3beb771 100644 --- a/internal/services/api/get_deployment_test.go +++ b/internal/services/api/get_deployment_test.go @@ -119,7 +119,7 @@ func (s *GetDeploymentSuite) TestGetPreDeployment() { d.ServerType = accounts.ServerTypeConnect testError := errors.New("test error") d.Error = types.AsAgentError(testError) - err := d.WriteFile(path) + _, err := d.WriteFile(path, "", false, s.log) s.NoError(err) h := GetDeploymentHandlerFunc(s.cwd, s.log) diff --git a/internal/services/api/get_deployments_test.go b/internal/services/api/get_deployments_test.go index cb04e6c63..b5abb476c 100644 --- a/internal/services/api/get_deployments_test.go +++ b/internal/services/api/get_deployments_test.go @@ -178,7 +178,8 @@ func createAlternateDeployment(root util.AbsolutePath, name string) (*deployment cfg.Type = config.ContentTypeHTML cfg.Entrypoint = "index.html" d.Configuration = cfg - return d, d.WriteFile(path) + _, err := d.WriteFile(path, "", false, logging.New()) + return d, err } func (s *GetDeploymentsSuite) TestGetDeploymentsByEntrypoint() { @@ -231,7 +232,8 @@ func (s *GetDeploymentsSuite) makeSubdirDeployment(name string, subdir string) ( d.DashboardURL = "/connect/#/apps/abc123" d.DirectURL = "/content/abc123/" d.LogsURL = "/connect/#/apps/abc123/logs" - return d, d.WriteFile(path) + _, err = d.WriteFile(path, "", false, s.log) + return d, err } func (s *GetDeploymentsSuite) TestGetDeploymentsRecursive() { diff --git a/internal/services/api/patch_deployment.go b/internal/services/api/patch_deployment.go index 45cd076b6..6d97034cb 100644 --- a/internal/services/api/patch_deployment.go +++ b/internal/services/api/patch_deployment.go @@ -86,12 +86,12 @@ func PatchDeploymentHandlerFunc( d.DirectURL = util.GetDirectURL(d.ServerURL, b.ID) } - err = d.WriteFile(path) + latest, err := d.WriteFile(path, "", true, log) if err != nil { InternalError(w, req, log, err) return } - response := deploymentAsDTO(d, err, projectDir, relProjectDir, path) + response := deploymentAsDTO(latest, err, projectDir, relProjectDir, path) w.Header().Set("content-type", "application/json") json.NewEncoder(w).Encode(response) } diff --git a/internal/services/api/patch_deployment_test.go b/internal/services/api/patch_deployment_test.go index 3ec9d7ac6..c16c8308f 100644 --- a/internal/services/api/patch_deployment_test.go +++ b/internal/services/api/patch_deployment_test.go @@ -24,6 +24,7 @@ import ( type PatchDeploymentHandlerFuncSuite struct { utiltest.Suite cwd util.AbsolutePath + log logging.Logger } func TestPatchDeploymentHandlerFuncSuite(t *testing.T) { @@ -36,6 +37,7 @@ func (s *PatchDeploymentHandlerFuncSuite) SetupTest() { s.Nil(err) s.cwd = cwd s.cwd.MkdirAll(0700) + s.log = logging.New() } func (s *PatchDeploymentHandlerFuncSuite) TestPatchDeploymentHandlerFuncWithConfig() { @@ -48,7 +50,7 @@ func (s *PatchDeploymentHandlerFuncSuite) TestPatchDeploymentHandlerFuncWithConf path := deployment.GetDeploymentPath(s.cwd, "myTargetName") d := deployment.New() - err = d.WriteFile(path) + _, err = d.WriteFile(path, "", false, s.log) s.NoError(err) cfg := config.New() @@ -77,7 +79,7 @@ func (s *PatchDeploymentHandlerFuncSuite) TestPatchDeploymentHandlerFuncWithID() path := deployment.GetDeploymentPath(s.cwd, "myTargetName") d := deployment.New() - err = d.WriteFile(path) + _, err = d.WriteFile(path, "", false, s.log) s.NoError(err) cfg := config.New() @@ -138,7 +140,8 @@ func (s *PatchDeploymentHandlerFuncSuite) TestPatchDeploymentHandlerConfigNotFou req = mux.SetURLVars(req, map[string]string{"name": "myTargetName"}) d := deployment.New() - err = d.WriteFile(deployment.GetDeploymentPath(s.cwd, "myTargetName")) + + _, err = d.WriteFile(deployment.GetDeploymentPath(s.cwd, "myTargetName"), "", false, s.log) s.NoError(err) req.Body = io.NopCloser(strings.NewReader(`{"configurationName": "myConfig"}`)) @@ -187,7 +190,7 @@ func (s *PatchDeploymentHandlerFuncSuite) TestPatchDeploymentSubdir() { path := deployment.GetDeploymentPath(s.cwd, "myTargetName") d := deployment.New() - err = d.WriteFile(path) + _, err = d.WriteFile(path, "", false, s.log) s.NoError(err) cfg := config.New() diff --git a/internal/services/api/post_deployment_cancel.go b/internal/services/api/post_deployment_cancel.go new file mode 100644 index 000000000..7123ce455 --- /dev/null +++ b/internal/services/api/post_deployment_cancel.go @@ -0,0 +1,41 @@ +package api + +// Copyright (C) 2023 by Posit Software, PBC. + +import ( + "encoding/json" + "errors" + "io/fs" + "net/http" + + "github.com/gorilla/mux" + "github.com/posit-dev/publisher/internal/deployment" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/publish" + "github.com/posit-dev/publisher/internal/util" +) + +func PostDeploymentCancelHandlerFunc(base util.AbsolutePath, log logging.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + name := mux.Vars(req)["name"] + localId := mux.Vars(req)["localid"] + projectDir, relProjectDir, err := ProjectDirFromRequest(base, w, req, log) + if err != nil { + // Response already returned by ProjectDirFromRequest + return + } + path := deployment.GetDeploymentPath(projectDir, name) + latest, err := publish.CancelDeployment(path, localId, log) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + http.NotFound(w, req) + } else { + InternalError(w, req, log, err) + } + return + } + response := deploymentAsDTO(latest, err, projectDir, relProjectDir, path) + w.Header().Set("content-type", "application/json") + json.NewEncoder(w).Encode(response) + } +} diff --git a/internal/services/api/post_deployment_cancel_test.go b/internal/services/api/post_deployment_cancel_test.go new file mode 100644 index 000000000..bef0e3c4e --- /dev/null +++ b/internal/services/api/post_deployment_cancel_test.go @@ -0,0 +1,124 @@ +// Copyright (C) 2024 by Posit Software, PBC. + +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/posit-dev/publisher/internal/deployment" + "github.com/posit-dev/publisher/internal/logging" + "github.com/posit-dev/publisher/internal/util" + "github.com/posit-dev/publisher/internal/util/utiltest" + "github.com/spf13/afero" + "github.com/stretchr/testify/suite" +) + +type PostDeploymentCancelTestSuite struct { + utiltest.Suite + log logging.Logger + fs afero.Fs + cwd util.AbsolutePath +} + +func TestPostDeploymentCancelTestSuite(t *testing.T) { + suite.Run(t, new(PostDeploymentCancelTestSuite)) +} + +func (s *PostDeploymentCancelTestSuite) SetupSuite() { + s.log = logging.New() +} + +func (s *PostDeploymentCancelTestSuite) SetupTest() { + s.fs = afero.NewMemMapFs() + cwd, err := util.Getwd(s.fs) + s.Nil(err) + s.cwd = cwd + s.cwd.MkdirAll(0700) +} + +func (s *PostDeploymentCancelTestSuite) Test200WithLocalIDMatch() { + deploymentName := "newDeployment" + localId := "abc" + + d := deployment.New() + d.LocalID = localId + + _, err := d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName), "abc", true, s.log) + s.NoError(err) + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/"+localId, nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": localId, + }) + h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + res := preDeploymentDTO{} + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + + s.NotEmpty(res.AbortedAt) +} + +func (s *PostDeploymentCancelTestSuite) Test200WithoutLocalIDMatch() { + deploymentName := "newDeployment" + + d := deployment.New() + d.LocalID = "abc" + + _, err := d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName), "abc", true, s.log) + s.NoError(err) + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/xyz", nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": "xyz", // not current localID in file + }) + h(rec, req) + + s.Equal(http.StatusOK, rec.Result().StatusCode) + s.Equal("application/json", rec.Header().Get("content-type")) + + res := preDeploymentDTO{} + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + s.NoError(dec.Decode(&res)) + + // request was successful but not applied + s.Empty(res.AbortedAt) +} + +func (s *PostDeploymentCancelTestSuite) Test404() { + deploymentName := "newDeployment" + localId := "abc" + + h := PostDeploymentCancelHandlerFunc(s.cwd, s.log) + + rec := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/deployments/"+deploymentName+"/cancel/"+localId, nil) + s.NoError(err) + req = mux.SetURLVars(req, map[string]string{ + "name": deploymentName, + "localid": localId, + }) + h(rec, req) + + s.Equal(http.StatusNotFound, rec.Result().StatusCode) + s.Equal("text/plain; charset=utf-8", rec.Header().Get("content-type")) +} diff --git a/internal/services/api/post_deployment_test.go b/internal/services/api/post_deployment_test.go index 420480b82..577e54729 100644 --- a/internal/services/api/post_deployment_test.go +++ b/internal/services/api/post_deployment_test.go @@ -29,6 +29,7 @@ import ( type PostDeploymentHandlerFuncSuite struct { utiltest.Suite cwd util.AbsolutePath + log logging.Logger } func TestPostDeploymentHandlerFuncSuite(t *testing.T) { @@ -44,6 +45,7 @@ func (s *PostDeploymentHandlerFuncSuite) SetupTest() { s.Nil(err) s.cwd = cwd s.cwd.MkdirAll(0700) + s.log = logging.New() } type mockPublisher struct { @@ -156,7 +158,7 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncWrongServe d := deployment.New() d.ServerURL = originalAcct.URL - err = d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName)) + _, err = d.WriteFile(deployment.GetDeploymentPath(s.cwd, deploymentName), "", false, s.log) s.NoError(err) cfg := config.New() diff --git a/internal/services/api/post_deployments.go b/internal/services/api/post_deployments.go index c0eb6a1c7..b394a1aa4 100644 --- a/internal/services/api/post_deployments.go +++ b/internal/services/api/post_deployments.go @@ -101,12 +101,12 @@ func PostDeploymentsHandlerFunc( } log.Debug("Writing deployment file", "path", path.String()) - err = d.WriteFile(path) + latest, err := d.WriteFile(path, "", true, log) if err != nil { InternalError(w, req, log, err) return } - response := deploymentAsDTO(d, err, projectDir, relProjectDir, path) + response := deploymentAsDTO(latest, err, projectDir, relProjectDir, path) w.Header().Set("content-type", "application/json") json.NewEncoder(w).Encode(response) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 5ee473e3e..1d087eb32 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -11,6 +11,7 @@ import ( "github.com/posit-dev/publisher/internal/accounts" "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/deployment" + "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/util" "github.com/posit-dev/publisher/internal/util/utiltest" "github.com/spf13/afero" @@ -22,6 +23,7 @@ type StateSuite struct { fs afero.Fs cwd util.AbsolutePath + log logging.Logger } func (s *StateSuite) SetupTest() { @@ -30,6 +32,7 @@ func (s *StateSuite) SetupTest() { s.Nil(err) s.cwd = cwd s.cwd.MkdirAll(0700) + s.log = logging.New() } func TestStateSuite(t *testing.T) { @@ -375,7 +378,7 @@ func (s *StateSuite) TestNewWithTarget() { d.ConfigName = "savedConfigName" d.ServerURL = "https://saved.server.example.com" d.Configuration = cfg - err := d.WriteFile(targetPath) + _, err := d.WriteFile(targetPath, "", false, s.log) s.NoError(err) state, err := New(s.cwd, "", "", "myTargetName", "", accts, nil, false) @@ -416,7 +419,7 @@ func (s *StateSuite) TestNewWithTargetAndAccount() { d.ConfigName = "savedConfigName" d.ServerURL = "https://saved.server.example.com" d.Configuration = cfg - err := d.WriteFile(targetPath) + _, err := d.WriteFile(targetPath, "", false, s.log) s.NoError(err) state, err := New(s.cwd, "acct2", "", "myTargetName", "mySaveName", accts, nil, false)