Skip to content

Commit 5e74a0a

Browse files
authored
[minor] Add container file lock support (#125)
1 parent 4eb53f7 commit 5e74a0a

3 files changed

Lines changed: 417 additions & 1 deletion

File tree

pkg/config/local_context_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ func TestPromptAndSaveLocalContextResolvesRelativeProjectDirFromWorkingDirectory
254254
if err := os.Chdir(tempHome); err != nil {
255255
t.Fatalf("Chdir(tempHome) error = %v", err)
256256
}
257+
workingDir, err := os.Getwd()
258+
if err != nil {
259+
t.Fatalf("Getwd() after Chdir error = %v", err)
260+
}
257261
t.Cleanup(func() {
258262
_ = os.Chdir(oldWd)
259263
})
@@ -270,7 +274,7 @@ func TestPromptAndSaveLocalContextResolvesRelativeProjectDirFromWorkingDirectory
270274
t.Fatalf("PromptAndSaveLocalContext() error = %v", err)
271275
}
272276

273-
expected := filepath.Join(tempHome, "foo", "bar")
277+
expected := filepath.Join(workingDir, "foo", "bar")
274278
if ctx.ProjectDir != expected {
275279
t.Fatalf("expected project dir %q, got %q", expected, ctx.ProjectDir)
276280
}

pkg/docker/container_file_lock.go

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
package docker
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"path"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
dockertypes "github.com/docker/docker/api/types"
16+
dockercontainer "github.com/docker/docker/api/types/container"
17+
"github.com/docker/docker/pkg/stdcopy"
18+
)
19+
20+
const (
21+
containerFileLockHandshake = "sitectl-container-file-lock-acquired"
22+
containerFileLockHeartbeat = 5 * time.Second
23+
containerFileLockReleaseTimeout = 10 * time.Second
24+
containerFileLockMaxErrorMessage = 4096
25+
containerFileLockScript = `( IFS= read -r -t 0 _ </dev/null ); status=$?
26+
if [ "$status" -gt 1 ]; then
27+
printf 'container shell does not support read timeouts\n' >&2
28+
exit 64
29+
fi
30+
printf '` + containerFileLockHandshake + `\n'
31+
while IFS= read -r -t 30 message; do
32+
[ "$message" = release ] && exit 0
33+
done
34+
exit 0`
35+
)
36+
37+
var ErrContainerFileLockHeld = errors.New("container file lock is already held")
38+
39+
// ContainerFileLockOptions identifies an advisory lock file inside a container.
40+
type ContainerFileLockOptions struct {
41+
Container string
42+
Path string
43+
}
44+
45+
// ContainerFileLock holds an exclusive advisory lock through a connected
46+
// container exec process. Release must be called when the protected operation
47+
// finishes. A renewable lease releases an abandoned holder after 30 seconds.
48+
type ContainerFileLock struct {
49+
exec containerFileLockAPI
50+
execID string
51+
response *dockertypes.HijackedResponse
52+
stdout *io.PipeReader
53+
outputDone <-chan error
54+
context context.Context
55+
cancel context.CancelFunc
56+
57+
heartbeatDone chan struct{}
58+
writeMu sync.Mutex
59+
stateMu sync.Mutex
60+
heartbeatErr error
61+
once sync.Once
62+
releaseErr error
63+
}
64+
65+
type containerFileLockAPI interface {
66+
ContainerExecCreate(ctx context.Context, containerID string, options dockercontainer.ExecOptions) (dockercontainer.ExecCreateResponse, error)
67+
ContainerExecAttach(ctx context.Context, execID string, options dockercontainer.ExecAttachOptions) (dockertypes.HijackedResponse, error)
68+
ContainerExecInspect(ctx context.Context, execID string) (dockercontainer.ExecInspect, error)
69+
}
70+
71+
// AcquireContainerFileLock takes a non-blocking exclusive flock inside the
72+
// selected container. The lock path's parent directory must already exist. The
73+
// container must provide flock and a shell whose read builtin supports -t.
74+
func (d *DockerClient) AcquireContainerFileLock(ctx context.Context, options ContainerFileLockOptions) (*ContainerFileLock, error) {
75+
if ctx == nil {
76+
return nil, fmt.Errorf("context is nil")
77+
}
78+
if d == nil || d.CLI == nil {
79+
return nil, fmt.Errorf("docker client is unavailable")
80+
}
81+
container := strings.TrimSpace(options.Container)
82+
lockPath := path.Clean(strings.TrimSpace(options.Path))
83+
if container == "" {
84+
return nil, fmt.Errorf("container is required for container file lock")
85+
}
86+
if !path.IsAbs(lockPath) || lockPath == "/" || strings.ContainsRune(lockPath, '\x00') {
87+
return nil, fmt.Errorf("container file lock path %q must be an absolute file path", options.Path)
88+
}
89+
execAPI, ok := d.CLI.(containerFileLockAPI)
90+
if !ok {
91+
return nil, fmt.Errorf("docker client does not support container exec locking")
92+
}
93+
94+
execResponse, err := execAPI.ContainerExecCreate(ctx, container, dockercontainer.ExecOptions{
95+
AttachStdin: true,
96+
AttachStdout: true,
97+
AttachStderr: true,
98+
Cmd: []string{"flock", "-n", lockPath, "sh", "-c", containerFileLockScript},
99+
})
100+
if err != nil {
101+
return nil, fmt.Errorf("create container file lock holder: %w", err)
102+
}
103+
response, err := execAPI.ContainerExecAttach(ctx, execResponse.ID, dockercontainer.ExecAttachOptions{})
104+
if err != nil {
105+
return nil, fmt.Errorf("attach container file lock holder: %w", err)
106+
}
107+
stdoutReader, stdoutWriter := io.Pipe()
108+
stderr := &boundedLockErrorWriter{remaining: containerFileLockMaxErrorMessage}
109+
outputDone := make(chan error, 1)
110+
go func() {
111+
_, copyErr := stdcopy.StdCopy(stdoutWriter, stderr, response.Reader)
112+
_ = stdoutWriter.CloseWithError(copyErr)
113+
outputDone <- copyErr
114+
}()
115+
stopContextClose := context.AfterFunc(ctx, response.Close)
116+
line, readErr := bufio.NewReader(stdoutReader).ReadString('\n')
117+
if strings.TrimSpace(line) != containerFileLockHandshake {
118+
stopContextClose()
119+
response.Close()
120+
_ = stdoutReader.Close()
121+
inspection, inspectErr := inspectContainerFileLock(execAPI, execResponse.ID)
122+
if inspectErr != nil {
123+
return nil, errors.Join(containerFileLockAcquireError(line, stderr.String(), readErr, -1), inspectErr)
124+
}
125+
if inspection.ExitCode == 1 {
126+
return nil, fmt.Errorf("%w: %s", ErrContainerFileLockHeld, lockPath)
127+
}
128+
return nil, containerFileLockAcquireError(line, stderr.String(), readErr, inspection.ExitCode)
129+
}
130+
stopContextClose()
131+
lockContext, cancel := context.WithCancel(ctx)
132+
lock := &ContainerFileLock{
133+
exec: execAPI,
134+
execID: execResponse.ID,
135+
response: &response,
136+
stdout: stdoutReader,
137+
outputDone: outputDone,
138+
context: lockContext,
139+
cancel: cancel,
140+
heartbeatDone: make(chan struct{}),
141+
}
142+
go lock.heartbeat()
143+
return lock, nil
144+
}
145+
146+
func inspectContainerFileLock(execAPI containerFileLockAPI, execID string) (dockercontainer.ExecInspect, error) {
147+
inspectCtx, cancel := context.WithTimeout(context.Background(), containerFileLockReleaseTimeout)
148+
defer cancel()
149+
inspection, err := execAPI.ContainerExecInspect(inspectCtx, execID)
150+
if err != nil {
151+
return dockercontainer.ExecInspect{}, fmt.Errorf("inspect container file lock holder: %w", err)
152+
}
153+
return inspection, nil
154+
}
155+
156+
func containerFileLockAcquireError(stdout, stderr string, readErr error, exitCode int) error {
157+
detail := strings.TrimSpace(stderr)
158+
if detail == "" {
159+
detail = strings.TrimSpace(stdout)
160+
}
161+
if detail == "" {
162+
detail = "lock holder exited before acquisition"
163+
}
164+
if readErr != nil && !errors.Is(readErr, io.EOF) {
165+
return fmt.Errorf("acquire container file lock: %s (exit code %d): %w", detail, exitCode, readErr)
166+
}
167+
return fmt.Errorf("acquire container file lock: %s (exit code %d)", detail, exitCode)
168+
}
169+
170+
// Context is canceled if the parent context is canceled or the lock heartbeat
171+
// is lost. Callers should use it for every operation protected by the lock.
172+
func (l *ContainerFileLock) Context() context.Context {
173+
if l == nil || l.context == nil {
174+
return context.Background()
175+
}
176+
return l.context
177+
}
178+
179+
func (l *ContainerFileLock) heartbeat() {
180+
defer close(l.heartbeatDone)
181+
ticker := time.NewTicker(containerFileLockHeartbeat)
182+
defer ticker.Stop()
183+
for {
184+
select {
185+
case <-l.context.Done():
186+
return
187+
case <-ticker.C:
188+
if err := l.writeMessage("heartbeat"); err != nil {
189+
l.stateMu.Lock()
190+
l.heartbeatErr = fmt.Errorf("renew container file lock lease: %w", err)
191+
l.stateMu.Unlock()
192+
l.cancel()
193+
return
194+
}
195+
}
196+
}
197+
}
198+
199+
func (l *ContainerFileLock) writeMessage(message string) error {
200+
l.writeMu.Lock()
201+
defer l.writeMu.Unlock()
202+
_ = l.response.Conn.SetWriteDeadline(time.Now().Add(containerFileLockHeartbeat))
203+
_, err := io.WriteString(l.response.Conn, message+"\n")
204+
return err
205+
}
206+
207+
// Release relinquishes the lock. It is safe to call more than once.
208+
func (l *ContainerFileLock) Release() error {
209+
if l == nil {
210+
return nil
211+
}
212+
l.once.Do(func() {
213+
l.cancel()
214+
<-l.heartbeatDone
215+
writeErr := l.writeMessage("release")
216+
closeWriteErr := l.response.CloseWrite()
217+
218+
var outputErr error
219+
select {
220+
case outputErr = <-l.outputDone:
221+
case <-time.After(containerFileLockReleaseTimeout):
222+
outputErr = fmt.Errorf("timed out waiting for container file lock holder to exit")
223+
}
224+
l.response.Close()
225+
_ = l.stdout.Close()
226+
inspection, inspectErr := inspectContainerFileLock(l.exec, l.execID)
227+
var exitErr error
228+
if inspectErr == nil && (inspection.Running || inspection.ExitCode != 0) {
229+
exitErr = fmt.Errorf("container file lock holder exited with code %d (running: %t)", inspection.ExitCode, inspection.Running)
230+
}
231+
l.stateMu.Lock()
232+
heartbeatErr := l.heartbeatErr
233+
l.stateMu.Unlock()
234+
l.releaseErr = errors.Join(
235+
wrapContainerFileLockIOError("signal lock holder", writeErr),
236+
wrapContainerFileLockIOError("close lock holder input", closeWriteErr),
237+
wrapContainerFileLockIOError("read lock holder output", outputErr),
238+
heartbeatErr,
239+
inspectErr,
240+
exitErr,
241+
)
242+
})
243+
return l.releaseErr
244+
}
245+
246+
func wrapContainerFileLockIOError(operation string, err error) error {
247+
if err == nil || isIgnorableExecStreamError(err) {
248+
return nil
249+
}
250+
return fmt.Errorf("%s: %w", operation, err)
251+
}
252+
253+
type boundedLockErrorWriter struct {
254+
buffer bytes.Buffer
255+
remaining int
256+
}
257+
258+
func (w *boundedLockErrorWriter) Write(data []byte) (int, error) {
259+
written := len(data)
260+
if w.remaining > 0 {
261+
chunk := data
262+
if len(chunk) > w.remaining {
263+
chunk = chunk[:w.remaining]
264+
}
265+
_, _ = w.buffer.Write(chunk)
266+
w.remaining -= len(chunk)
267+
}
268+
return written, nil
269+
}
270+
271+
func (w *boundedLockErrorWriter) String() string {
272+
return w.buffer.String()
273+
}

0 commit comments

Comments
 (0)