08 - Delete
This section implements the Delete method. Delete is a synchronous operation that removes a resource from the infrastructure.
What Delete Does
The agent calls Delete to remove a resource when:
- A user runs
formae destroy - A resource is removed from a forma file and
formae applyis run - A stack is being torn down
┌─────────┐ ┌────────┐ ┌──────────────┐
│ Agent │ │ Plugin │ │ Infrastructure│
└────┬────┘ └───┬────┘ └──────┬───────┘
│ │ │
│ Delete(nativeID)│ │
│─────────────────>│ Remove file │
│ │────────────────────>│
│ Success │ File deleted │
│<─────────────────│<────────────────────│
DeleteRequest
The agent sends a DeleteRequest when removing a resource:
type DeleteRequest struct {
NativeID string // Infrastructure identifier for the resource to delete
ResourceType string // e.g., "SFTP::Files::File"
TargetConfig json.RawMessage // Connection details for the target
}
Delete returns a ProgressResult. On success, set OperationStatusSuccess. On failure, set the appropriate ErrorCode - notably, if the resource doesn't exist, return NotFound (the agent treats this as success since the desired state is achieved).
Test
Add a test to sftp_test.go:
// =============================================================================
// Delete Tests
// =============================================================================
// TestDelete verifies that the Plugin.Delete method:
// 1. Deletes the file from the server
// 2. Returns Success status
//
// Setup: Create file using asyncsftp
// Execute: Delete using Plugin
// Verify: Confirm file is gone using asyncsftp
func TestDelete(t *testing.T) {
if os.Getenv("SFTP_USERNAME") == "" || os.Getenv("SFTP_PASSWORD") == "" {
t.Skip("SFTP_USERNAME and SFTP_PASSWORD must be set")
}
ctx := context.Background()
// --- Setup: Create file using asyncsftp ---
client, err := asyncsftp.NewClient(testConfig())
require.NoError(t, err, "failed to create asyncsftp client")
defer client.Close()
filePath := "/upload/test-delete.txt"
fileContent := "File to be deleted"
opID := client.StartUpload(filePath, fileContent, 0644)
require.Eventually(t, func() bool {
op, _ := client.GetStatus(opID)
return op != nil && op.State == asyncsftp.StateCompleted
}, 10*time.Second, 100*time.Millisecond, "file upload should complete")
// Verify file exists
_, err = client.ReadFile(filePath)
require.NoError(t, err, "file should exist before Delete")
// --- Execute: Delete using Plugin ---
plugin := &Plugin{}
req := &resource.DeleteRequest{
NativeID: filePath,
ResourceType: "SFTP::Files::File",
TargetConfig: testTargetConfig(),
}
result, err := plugin.Delete(ctx, req)
require.NoError(t, err, "Delete should not return error")
require.NotNil(t, result.ProgressResult, "Delete should return ProgressResult")
assert.Equal(t, resource.OperationStatusSuccess, result.ProgressResult.OperationStatus,
"Delete should return Success status")
// --- Verify: Confirm file is gone using asyncsftp ---
_, err = client.ReadFile(filePath)
assert.ErrorIs(t, err, asyncsftp.ErrNotFound, "file should not exist after Delete")
t.Log("File deleted successfully")
}
Test Pattern
| Step | Action | Tool |
|---|---|---|
| Setup | Create file | asyncsftp |
| Execute | Delete file | Plugin |
| Verify | Confirm file is gone | asyncsftp |
Implementation
// Delete removes a resource.
func (p *Plugin) Delete(ctx context.Context, req *resource.DeleteRequest) (*resource.DeleteResult, error) {
// Get SFTP client
client, err := p.getClient(req.TargetConfig)
if err != nil {
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
// Start delete operation
opID := client.StartDelete(req.NativeID)
// Wait for completion (delete is fast, we wait synchronously)
for {
op, err := client.GetStatus(opID)
if err != nil {
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
if op.State == asyncsftp.StateCompleted {
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusSuccess,
NativeID: req.NativeID,
},
}, nil
}
if op.State == asyncsftp.StateFailure {
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: op.Error,
},
}, nil
}
}
}
Verify
Run the test to confirm Delete removes the file successfully:
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestDelete$" -v
The test creates a file, deletes it through the plugin, then confirms it's gone by attempting to read it:
=== RUN TestDelete
sftp_test.go:390: File deleted successfully
--- PASS: TestDelete (0.52s)
PASS
Handling NotFound
Now we need to handle another edge case: what happens when deleting a resource that doesn't exist?
Why NotFound on Delete is Special
The agent treats NotFound on Delete as success. Why? Because the desired outcome (resource gone) is already achieved - whether formae deleted it or someone deleted it out-of-band, the end state is the same.
This makes Delete idempotent: calling Delete multiple times has the same effect as calling it once.
┌─────────┐ ┌────────┐
│ Agent │ │ Plugin │
└────┬────┘ └───┬────┘
│ │
│ Delete(fileA) │
│─────────────────>│
│ │
│ [file doesn't exist]
│ │
│ Failure + │
│ NotFound │
│<─────────────────│
│ │
│ [agent treats as success - desired state achieved]
NotFound Test
// TestDeleteNotFound verifies that Plugin.Delete returns Failure with NotFound
// when the file doesn't exist.
//
// The agent treats NotFound on Delete as success - the desired state (file gone)
// is already achieved.
func TestDeleteNotFound(t *testing.T) {
if os.Getenv("SFTP_USERNAME") == "" || os.Getenv("SFTP_PASSWORD") == "" {
t.Skip("SFTP_USERNAME and SFTP_PASSWORD must be set")
}
ctx := context.Background()
plugin := &Plugin{}
// Delete a file that does not exist
req := &resource.DeleteRequest{
NativeID: "/upload/this-file-does-not-exist-for-delete.txt",
ResourceType: "SFTP::Files::File",
TargetConfig: testTargetConfig(),
}
result, err := plugin.Delete(ctx, req)
// Delete should NOT return an error for NotFound
require.NoError(t, err, "Delete should not return error for missing file")
require.NotNil(t, result.ProgressResult, "Delete should return ProgressResult")
// Should return Failure status with NotFound error code
assert.Equal(t, resource.OperationStatusFailure, result.ProgressResult.OperationStatus,
"Delete should return Failure status for missing file")
assert.Equal(t, resource.OperationErrorCodeNotFound, result.ProgressResult.ErrorCode,
"Delete should return NotFound error code for missing file")
t.Log("Delete correctly returned Failure with NotFound for non-existent file")
}
NotFound Implementation
Add a check at the beginning of Delete to detect if the file exists:
// Delete removes a resource.
// Returns Failure with NotFound error code if file doesn't exist (agent treats this as success).
func (p *Plugin) Delete(ctx context.Context, req *resource.DeleteRequest) (*resource.DeleteResult, error) {
// Get SFTP client
client, err := p.getClient(req.TargetConfig)
if err != nil {
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
// Check if file exists first
_, err = client.ReadFile(req.NativeID)
if err != nil {
if errors.Is(err, asyncsftp.ErrNotFound) {
// File doesn't exist - return Failure with NotFound
// The agent treats NotFound on Delete as success
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeNotFound,
NativeID: req.NativeID,
},
}, nil
}
return &resource.DeleteResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationDelete,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
// File exists, proceed with delete...
// (rest of implementation)
}
Verify
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestDelete" -v
=== RUN TestDelete
sftp_test.go:390: File deleted successfully
--- PASS: TestDelete (0.52s)
=== RUN TestDeleteNotFound
sftp_test.go:425: Delete correctly returned Failure with NotFound for non-existent file
--- PASS: TestDeleteNotFound (0.20s)
PASS
Summary
| Scenario | Status | ErrorCode | Agent Interpretation |
|---|---|---|---|
| File exists, deleted | Success | - | Success |
| File not found | Failure | NotFound | Success (idempotent) |
| Other error | Failure | InternalFailure | Failure |
The idempotent behavior ensures that: - Retrying a delete is safe - Out-of-band deletions don't cause errors - The agent can reconcile state correctly
Next: 09 - List - Implement List for resource discovery