Skip to content

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

  1. Compare prior and desired - Only update what changed
  2. Content change requires rewrite - Upload the entire file again
  3. Permission-only change is faster - Just call chmod
  4. 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