Skip to content

Commit e2c56d1

Browse files
committed
Volume plugin interface and mock volume implementation.
This allows for different storage backends to be supported. Right now it only contains the mock volume plugin, which is only intended for testing purposes right now.
1 parent c737f32 commit e2c56d1

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)