Skip to content

Commit e140785

Browse files
committed
Volume plugin interface and mock volume implementation
1 parent dabeb1e commit e140785

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

internal/volume/mock.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package volume
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"log/slog"
21+
"os"
22+
"path/filepath"
23+
"sync"
24+
25+
"github.com/agent-substrate/substrate/internal/ateompath"
26+
)
27+
28+
// Use a directory that is shared between atelet and ateom but not cleaned up by atelet
29+
var mockVolumeDirectories string = filepath.Join(ateompath.BasePath, "mockvolumes")
30+
31+
// MockVolumePlugin is a simple implementation of VolumePlugin for testing purposes.
32+
//
33+
// It creates a subdirectory on the host for each actor. This only persists data if the actor
34+
// is scheduled to the same host.
35+
//
36+
// This plugin also does not cleanup the subdirectories, so that has to be done by the test infrastructure.
37+
type MockVolumePlugin struct {
38+
mu sync.Mutex
39+
volumes map[string]*MockVolumeState
40+
counter int
41+
}
42+
43+
// MockVolumeState tracks the state of a mock volume.
44+
type MockVolumeState struct {
45+
ID string
46+
Name string
47+
Capacity string
48+
StorageClass string
49+
Node string
50+
Mounts map[string]bool // targetPath -> mounted
51+
}
52+
53+
// NewMockVolumePlugin creates a new MockVolumePlugin.
54+
func NewMockVolumePlugin() *MockVolumePlugin {
55+
return &MockVolumePlugin{
56+
volumes: make(map[string]*MockVolumeState),
57+
}
58+
}
59+
60+
// CreateVolume simulates volume provisioning.
61+
func (p *MockVolumePlugin) CreateVolume(ctx context.Context, name string, capacity string, storageClass string) (string, error) {
62+
p.mu.Lock()
63+
defer p.mu.Unlock()
64+
p.counter++
65+
volumeID := fmt.Sprintf("mock-vol-%d", p.counter)
66+
slog.InfoContext(ctx, "MockVolumePlugin.CreateVolume", slog.String("name", name), slog.String("capacity", capacity), slog.String("storageClass", storageClass), slog.String("volumeID", volumeID))
67+
p.volumes[volumeID] = &MockVolumeState{
68+
ID: volumeID,
69+
Name: name,
70+
Capacity: capacity,
71+
StorageClass: storageClass,
72+
Mounts: make(map[string]bool),
73+
}
74+
return volumeID, nil
75+
}
76+
77+
// DeleteVolume simulates volume deletion.
78+
func (p *MockVolumePlugin) DeleteVolume(ctx context.Context, volumeID string) error {
79+
p.mu.Lock()
80+
defer p.mu.Unlock()
81+
slog.InfoContext(ctx, "MockVolumePlugin.DeleteVolume", slog.String("volumeID", volumeID))
82+
if _, ok := p.volumes[volumeID]; !ok {
83+
slog.ErrorContext(ctx, "MockVolumePlugin.DeleteVolume failed: volume not found", slog.String("volumeID", volumeID))
84+
return fmt.Errorf("volume %s not found", volumeID)
85+
}
86+
delete(p.volumes, volumeID)
87+
return nil
88+
}
89+
90+
// AttachVolume simulates volume attachment to a node.
91+
func (p *MockVolumePlugin) AttachVolume(ctx context.Context, volumeID string, node string) error {
92+
p.mu.Lock()
93+
defer p.mu.Unlock()
94+
slog.InfoContext(ctx, "MockVolumePlugin.AttachVolume", slog.String("volumeID", volumeID), slog.String("node", node))
95+
vol, ok := p.volumes[volumeID]
96+
if !ok {
97+
slog.ErrorContext(ctx, "MockVolumePlugin.AttachVolume failed: volume not found", slog.String("volumeID", volumeID))
98+
return fmt.Errorf("volume %s not found", volumeID)
99+
}
100+
vol.Node = node
101+
return nil
102+
}
103+
104+
// DetachVolume simulates volume detachment from a node.
105+
func (p *MockVolumePlugin) DetachVolume(ctx context.Context, volumeID string, node string) error {
106+
p.mu.Lock()
107+
defer p.mu.Unlock()
108+
slog.InfoContext(ctx, "MockVolumePlugin.DetachVolume", slog.String("volumeID", volumeID), slog.String("node", node))
109+
vol, ok := p.volumes[volumeID]
110+
if !ok {
111+
slog.ErrorContext(ctx, "MockVolumePlugin.DetachVolume failed: volume not found", slog.String("volumeID", volumeID))
112+
return fmt.Errorf("volume %s not found", volumeID)
113+
}
114+
if vol.Node != node {
115+
slog.ErrorContext(ctx, "MockVolumePlugin.DetachVolume failed: volume not attached to node", slog.String("volumeID", volumeID), slog.String("node", node), slog.String("attachedNode", vol.Node))
116+
return fmt.Errorf("volume %s not attached to node %s", volumeID, node)
117+
}
118+
vol.Node = ""
119+
return nil
120+
}
121+
122+
// MountVolume simulates mounting volume on the host.
123+
func (p *MockVolumePlugin) MountVolume(ctx context.Context, volumeID string, targetPath string) error {
124+
slog.InfoContext(ctx, "MockVolumePlugin.MountVolume", slog.String("volumeID", volumeID), slog.String("targetPath", targetPath))
125+
126+
volumeDir := filepath.Join(mockVolumeDirectories, volumeID)
127+
if err := os.MkdirAll(volumeDir, 0755); err != nil {
128+
slog.ErrorContext(ctx, "MockVolumePlugin.MountVolume failed: mkdir error", slog.String("volumeID", volumeID), slog.Any("error", err))
129+
return fmt.Errorf("failed to create mock volume directory %q: %w", volumeDir, err)
130+
}
131+
132+
testFilePath := filepath.Join(volumeDir, "test.txt")
133+
if err := os.WriteFile(testFilePath, []byte("test content\n"), 0644); err != nil {
134+
slog.ErrorContext(ctx, "MockVolumePlugin.MountVolume failed: create test file error", slog.String("volumeID", volumeID), slog.Any("error", err))
135+
return fmt.Errorf("failed to create test file in %q: %w", volumeDir, err)
136+
}
137+
138+
// Use symlink instead of bind mount to avoid atelet requiring bidirectional mount propagation.
139+
_ = os.Remove(targetPath)
140+
if err := os.Symlink(volumeDir, targetPath); err != nil {
141+
return fmt.Errorf("failed to symlink %q to %q: %w", volumeDir, targetPath, err)
142+
}
143+
return nil
144+
}
145+
146+
// UnmountVolume simulates unmounting volume from the host.
147+
func (p *MockVolumePlugin) UnmountVolume(ctx context.Context, volumeID string, targetPath string) error {
148+
slog.InfoContext(ctx, "MockVolumePlugin.UnmountVolume", slog.String("volumeID", volumeID), slog.String("targetPath", targetPath))
149+
150+
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
151+
slog.ErrorContext(ctx, "MockVolumePlugin.UnmountVolume failed: remove error", slog.String("volumeID", volumeID), slog.String("targetPath", targetPath), slog.Any("error", err))
152+
return fmt.Errorf("failed to remove target path %q: %w", targetPath, err)
153+
}
154+
return nil
155+
}
156+
157+
// GetVolumeState returns the state of a mock volume for verification in tests.
158+
// This only works for controller methods.
159+
func (p *MockVolumePlugin) GetVolumeState(volumeID string) (*MockVolumeState, error) {
160+
p.mu.Lock()
161+
defer p.mu.Unlock()
162+
slog.Info("MockVolumePlugin.GetVolumeState", slog.String("volumeID", volumeID))
163+
vol, ok := p.volumes[volumeID]
164+
if !ok {
165+
slog.Error("MockVolumePlugin.GetVolumeState failed: volume not found", slog.String("volumeID", volumeID))
166+
return nil, fmt.Errorf("volume %s not found", volumeID)
167+
}
168+
// Return a copy to avoid concurrent access issues in tests
169+
mountsCopy := make(map[string]bool)
170+
for k, v := range vol.Mounts {
171+
mountsCopy[k] = v
172+
}
173+
return &MockVolumeState{
174+
ID: vol.ID,
175+
Name: vol.Name,
176+
Capacity: vol.Capacity,
177+
StorageClass: vol.StorageClass,
178+
Node: vol.Node,
179+
Mounts: mountsCopy,
180+
}, nil
181+
}

internal/volume/plugin.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package volume
16+
17+
import (
18+
"context"
19+
)
20+
21+
// VolumePlugin abstracts storage operations.
22+
type VolumePlugin interface {
23+
CreateVolume(ctx context.Context, name string, capacity string, storageClass string) (volumeID string, err error)
24+
DeleteVolume(ctx context.Context, volumeID string) error
25+
AttachVolume(ctx context.Context, volumeID string, node string) error
26+
DetachVolume(ctx context.Context, volumeID string, node string) error
27+
MountVolume(ctx context.Context, volumeID string, targetPath string) error
28+
UnmountVolume(ctx context.Context, volumeID string, targetPath string) error
29+
}

0 commit comments

Comments
 (0)