07 - Update
This section implements the Update method. Update is a synchronous operation that modifies an existing resource.
UpdateRequest
The agent sends an UpdateRequest when modifying an existing resource:
type UpdateRequest struct {
NativeID string // Infrastructure identifier
ResourceType string // e.g., "SFTP::Files::File"
PriorProperties json.RawMessage // What formae believes the current state is
DesiredProperties json.RawMessage // What the user wants the state to be
TargetConfig json.RawMessage // Connection details for the target
}
Update Semantics
Having both prior and desired properties lets your plugin optimize updates. Rather than blindly rewriting everything, you can compare the two and only change what's different.
For our SFTP plugin:
- Content changes require rewriting the file
- Permission changes can be applied directly with chmod
Update returns a ProgressResult (like Create). Even though our implementation completes synchronously, we still return OperationStatusSuccess with the updated ResourceProperties.
Test
Add a test to sftp_test.go:
// =============================================================================
// Update Tests
// =============================================================================
// TestUpdate verifies that the Plugin.Update method:
// 1. Updates file content and/or permissions
// 2. Returns Success with updated properties
//
// Setup: Create file using asyncsftp
// Execute: Update using Plugin
// Verify: Read back using asyncsftp to confirm changes
func TestUpdate(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-update.txt"
originalContent := "Original content"
originalPermissions := os.FileMode(0644)
opID := client.StartUpload(filePath, originalContent, originalPermissions)
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")
// --- Execute: Update using Plugin ---
plugin := &Plugin{}
priorProps, _ := json.Marshal(map[string]any{
"path": filePath,
"content": originalContent,
"permissions": "0644",
})
updatedContent := "Updated content"
updatedPermissions := "0600"
desiredProps, _ := json.Marshal(map[string]any{
"path": filePath,
"content": updatedContent,
"permissions": updatedPermissions,
})
req := &resource.UpdateRequest{
NativeID: filePath,
ResourceType: "SFTP::Files::File",
PriorProperties: priorProps,
DesiredProperties: desiredProps,
TargetConfig: testTargetConfig(),
}
result, err := plugin.Update(ctx, req)
require.NoError(t, err, "Update should not return error")
require.NotNil(t, result.ProgressResult, "Update should return ProgressResult")
assert.Equal(t, resource.OperationStatusSuccess, result.ProgressResult.OperationStatus,
"Update should return Success status")
// --- Verify: Read back using asyncsftp ---
fileInfo, err := client.ReadFile(filePath)
require.NoError(t, err, "file should exist after Update")
assert.Equal(t, updatedContent, fileInfo.Content, "content should be updated")
assert.Equal(t, updatedPermissions, fileInfo.Permissions, "permissions should be updated")
t.Logf("File updated successfully: content=%q, permissions=%s", fileInfo.Content, fileInfo.Permissions)
// --- Cleanup ---
_ = client.StartDelete(filePath)
}
Test Pattern
| Step | Action | Tool |
|---|---|---|
| Setup | Create file with original content | asyncsftp |
| Execute | Update content and permissions | Plugin |
| Verify | Read file and check changes | asyncsftp |
Implementation
// Update modifies an existing resource.
// Updates are synchronous - we update content and/or permissions directly.
func (p *Plugin) Update(ctx context.Context, req *resource.UpdateRequest) (*resource.UpdateResult, error) {
// Get SFTP client
client, err := p.getClient(req.TargetConfig)
if err != nil {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
// Parse desired properties
desiredProps, err := parseFileProperties(req.DesiredProperties)
if err != nil {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInvalidRequest,
StatusMessage: err.Error(),
},
}, nil
}
// Parse prior properties to detect changes
priorProps, _ := parseFileProperties(req.PriorProperties)
// Check if content changed - need to rewrite file
if priorProps == nil || priorProps.Content != desiredProps.Content {
var perm os.FileMode = 0644
if desiredProps.Permissions != "" {
_, _ = fmt.Sscanf(desiredProps.Permissions, "%o", &perm)
}
// Upload new content (blocks until complete)
opID := client.StartUpload(req.NativeID, desiredProps.Content, perm)
// Wait for completion
for {
op, err := client.GetStatus(opID)
if err != nil {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
if op.State == asyncsftp.StateCompleted {
break
}
if op.State == asyncsftp.StateFailure {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: op.Error,
},
}, nil
}
}
} else if priorProps.Permissions != desiredProps.Permissions {
// Only permissions changed - use chmod
var perm os.FileMode = 0644
_, _ = fmt.Sscanf(desiredProps.Permissions, "%o", &perm)
if err := client.SetPermissions(req.NativeID, perm); err != nil {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
}
// Read back the updated file to return current state
fileInfo, err := client.ReadFile(req.NativeID)
if err != nil {
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusFailure,
ErrorCode: resource.OperationErrorCodeInternalFailure,
StatusMessage: err.Error(),
},
}, nil
}
resourceProps, _ := json.Marshal(FileProperties{
Path: fileInfo.Path,
Content: fileInfo.Content,
Permissions: fileInfo.Permissions,
Size: fileInfo.Size,
ModifiedAt: fileInfo.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"),
})
return &resource.UpdateResult{
ProgressResult: &resource.ProgressResult{
Operation: resource.OperationUpdate,
OperationStatus: resource.OperationStatusSuccess,
NativeID: req.NativeID,
ResourceProperties: resourceProps,
},
}, nil
}
Key Points
- Compare prior and desired - Only update what changed
- Content change requires rewrite - Upload the entire file again
- Permission-only change is faster - Just call
chmod - Return updated properties - Read back the file to confirm the final state
Verify
Run the test to confirm Update modifies both content and permissions correctly:
SFTP_USERNAME=testuser SFTP_PASSWORD=testpass go test -tags=integration -run TestUpdate -v
The test creates a file with original content, updates it through the plugin, then verifies the changes using a separate SFTP client:
=== RUN TestUpdate
sftp_test.go:327: File updated successfully: content="Updated content", permissions=0600
--- PASS: TestUpdate (0.52s)
PASS
Summary
Update compares prior and desired state to apply minimal changes:
| Change Type | Action |
|---|---|
| Content changed | Rewrite entire file |
| Permissions only | Use chmod |
| Nothing changed | No-op |
Next: 08 - Delete - Implement file deletion with NotFound semantics