|
| 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