Skip to content

06 - Read

This section implements the Read method. Read is a synchronous operation that retrieves the current state of a resource.

What Read Does

The agent calls Read to get the current state of a resource from the infrastructure. This happens:

  • When a user runs formae status or formae inventory
  • During synchronization (the agent periodically reads all managed resources)
  • Before an update (to detect out-of-band changes)
┌─────────┐         ┌────────┐         ┌──────────────┐
│  Agent  │         │ Plugin │         │ Infrastructure│
└────┬────┘         └───┬────┘         └──────┬───────┘
     │                  │                     │
     │  Read(nativeID)  │                     │
     │─────────────────>│  Get file           │
     │                  │────────────────────>│
     │  Properties      │  File contents      │
     │<─────────────────│<────────────────────│

ReadRequest

The agent sends a ReadRequest to retrieve the current state of a resource:

type ReadRequest struct {
    NativeID     string          // Infrastructure identifier (returned from Create)
    ResourceType string          // e.g., "SFTP::Files::File"
    TargetConfig json.RawMessage // Connection details for the target
}

ReadResult

Unlike mutating operations that return ProgressResult, Read returns a simpler ReadResult directly:

type ReadResult struct {
    ResourceType string             // Echo back the resource type
    Properties   string             // JSON string of current properties
    ErrorCode    OperationErrorCode // Set if resource cannot be read
}

Read is synchronous - it returns the current state immediately. If the resource doesn't exist, set ErrorCode to NotFound (don't return a Go error). See 10 - Error Handling for more on error codes.

Test

Add a test to sftp_test.go:

// =============================================================================
// Read Tests
// =============================================================================

// TestRead verifies that the Plugin.Read method:
// 1. Returns the current state of an existing file
// 2. Properties match what was written to the server
//
// Setup: Create file using asyncsftp
// Execute: Read using Plugin
// Verify: Assert on the returned properties
func TestRead(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-read.txt"
    fileContent := "Hello from Read test"
    filePermissions := os.FileMode(0644)

    opID := client.StartUpload(filePath, fileContent, filePermissions)

    // Wait for upload to complete
    require.Eventually(t, func() bool {
        op, err := client.GetStatus(opID)
        if err != nil {
            return false
        }
        return op.State == asyncsftp.StateCompleted
    }, 10*time.Second, 100*time.Millisecond, "file upload should complete")

    // --- Execute: Read using Plugin ---
    plugin := &Plugin{}

    req := &resource.ReadRequest{
        NativeID:     filePath,
        ResourceType: "SFTP::Files::File",
        TargetConfig: testTargetConfig(),
    }

    result, err := plugin.Read(ctx, req)
    require.NoError(t, err, "Read should not return error")

    // ErrorCode should be empty for existing file
    assert.Empty(t, result.ErrorCode, "Read should not return error code for existing file")

    // --- Verify: Assert on returned properties ---
    require.NotEmpty(t, result.Properties, "Read should return properties")

    var props map[string]any
    err = json.Unmarshal([]byte(result.Properties), &props)
    require.NoError(t, err, "Properties should be valid JSON")

    assert.Equal(t, filePath, props["path"], "path should match")
    assert.Equal(t, fileContent, props["content"], "content should match")
    assert.Equal(t, "0644", props["permissions"], "permissions should match")
    assert.NotEmpty(t, props["size"], "size should be set")
    assert.NotEmpty(t, props["modifiedAt"], "modifiedAt should be set")

    t.Logf("Read returned properties: path=%s, size=%v", props["path"], props["size"])

    // --- Cleanup ---
    _ = client.StartDelete(filePath)
}

Test Pattern

Step Action Tool
Setup Create file asyncsftp
Execute Read file Plugin
Verify Assert properties match assert

Implementation

// Read retrieves the current state of a resource.
func (p *Plugin) Read(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResult, error) {
    // Get SFTP client
    client, err := p.getClient(req.TargetConfig)
    if err != nil {
        return &resource.ReadResult{
            ResourceType: req.ResourceType,
            ErrorCode:    resource.OperationErrorCodeInternalFailure,
        }, nil
    }

    // Read file from SFTP server
    fileInfo, err := client.ReadFile(req.NativeID)
    if err != nil {
        return &resource.ReadResult{
            ResourceType: req.ResourceType,
            ErrorCode:    resource.OperationErrorCodeInternalFailure,
        }, nil
    }

    // Convert to JSON properties
    props := FileProperties{
        Path:        fileInfo.Path,
        Content:     fileInfo.Content,
        Permissions: fileInfo.Permissions,
        Size:        fileInfo.Size,
        ModifiedAt:  fileInfo.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"),
    }
    propsJSON, _ := json.Marshal(props)

    return &resource.ReadResult{
        ResourceType: req.ResourceType,
        Properties:   string(propsJSON),
    }, nil
}

Verify

Run the test to confirm Read retrieves file properties correctly:

SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestRead$" -v
=== RUN   TestRead
    sftp_test.go:207: Read returned properties: path=/upload/test-read.txt, size=20
--- PASS: TestRead (0.52s)
PASS

Handling NotFound

Now we need to handle a critical edge case: what happens when the resource doesn't exist?

Why NotFound Matters

The agent periodically calls Read on all managed resources to synchronize formae's state with the actual infrastructure. This is how formae detects out-of-band changes - modifications made outside of formae (e.g., someone manually deleted a file via SSH).

When a resource no longer exists: - The plugin must return a result with ErrorCode = NotFound - The plugin must NOT return an error - The agent then marks the resource as deleted in its inventory

┌─────────┐         ┌────────┐         ┌──────────────┐
│  Agent  │         │ Plugin │         │ Infrastructure│
└────┬────┘         └───┬────┘         └──────┬───────┘
     │                  │                     │
     │  [file deleted out-of-band]            │
     │                  │                     │
     │  Read(fileA)     │                     │
     │─────────────────>│  Get file           │
     │                  │────────────────────>│
     │  NotFound        │  File not found     │
     │<─────────────────│<────────────────────│
     │                  │                     │
     │  [agent marks fileA as deleted]        │

NotFound Test

// TestReadNotFound verifies that Plugin.Read returns OperationErrorCodeNotFound
// when the file does not exist.
//
// This is critical for formae's synchronization mechanism. The agent periodically
// calls Read on all managed resources to sync formae's state with the actual
// infrastructure state. When a resource is deleted out-of-band (outside of formae),
// the plugin must return NotFound (not an error). The agent then marks the resource
// as deleted in its inventory.
//
// Important: Plugins must return a result with ErrorCode=NotFound, NOT an error.
func TestReadNotFound(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{}

    // Read a file that does not exist
    req := &resource.ReadRequest{
        NativeID:     "/upload/this-file-does-not-exist.txt",
        ResourceType: "SFTP::Files::File",
        TargetConfig: testTargetConfig(),
    }

    result, err := plugin.Read(ctx, req)

    // IMPORTANT: Read should NOT return an error for NotFound
    require.NoError(t, err, "Read should not return error for missing file")

    // Should return NotFound error code
    assert.Equal(t, resource.OperationErrorCodeNotFound, result.ErrorCode,
        "Read should return NotFound error code for missing file")

    // Properties should be empty
    assert.Empty(t, result.Properties, "Properties should be empty for missing file")

    t.Log("Read correctly returned NotFound for non-existent file")
}

NotFound Implementation

Update the error handling in Read:

// Read file from SFTP server
fileInfo, err := client.ReadFile(req.NativeID)
if err != nil {
    // NotFound is not an error - return result with ErrorCode
    if errors.Is(err, asyncsftp.ErrNotFound) {
        return &resource.ReadResult{
            ResourceType: req.ResourceType,
            ErrorCode:    resource.OperationErrorCodeNotFound,
        }, nil
    }
    return &resource.ReadResult{
        ResourceType: req.ResourceType,
        ErrorCode:    resource.OperationErrorCodeInternalFailure,
    }, nil
}

Verify

SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run "TestRead" -v
=== RUN   TestRead
    sftp_test.go:207: Read returned properties: path=/upload/test-read.txt, size=20
--- PASS: TestRead (0.52s)
=== RUN   TestReadNotFound
    sftp_test.go:250: Read correctly returned NotFound for non-existent file
--- PASS: TestReadNotFound (0.20s)
PASS

Summary

Scenario Return Value
File exists ReadResult{Properties: "..."}
File not found ReadResult{ErrorCode: NotFound}
Other error ReadResult{ErrorCode: InternalFailure}

The NotFound handling is critical - the agent relies on it to detect out-of-band deletions and keep its inventory in sync with reality.


Next: 07 - Update - Implement file updates