Skip to content

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 apply is 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