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 statusorformae 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