399 lines
13 KiB
Go
399 lines
13 KiB
Go
// Copyright 2025 Dolthub, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package dbfactory
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/xtaci/smux"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
|
|
"github.com/dolthub/dolt/go/libraries/doltcore/remotesrv"
|
|
"github.com/dolthub/dolt/go/libraries/doltcore/remotestorage"
|
|
"github.com/dolthub/dolt/go/store/datas"
|
|
"github.com/dolthub/dolt/go/store/prolly/tree"
|
|
"github.com/dolthub/dolt/go/store/types"
|
|
)
|
|
|
|
// SSHRemoteFactory creates databases backed by SSH remotes using the dolt
|
|
// transfer command for multiplexed gRPC+HTTP over stdin/stdout.
|
|
type SSHRemoteFactory struct{}
|
|
|
|
func (SSHRemoteFactory) PrepareDB(ctx context.Context, nbf *types.NomsBinFormat, urlObj *url.URL, params map[string]interface{}) error {
|
|
return fmt.Errorf("ssh scheme does not support PrepareDB")
|
|
}
|
|
|
|
// CreateDB creates a database backed by an SSH remote. It spawns a subprocess
|
|
// (either SSH or dolt transfer directly for localhost) and multiplexes gRPC
|
|
// and HTTP over the subprocess's stdin/stdout using SMUX.
|
|
func (SSHRemoteFactory) CreateDB(ctx context.Context, nbf *types.NomsBinFormat, urlObj *url.URL, params map[string]interface{}) (datas.Database, types.ValueReadWriter, tree.NodeStore, error) {
|
|
host, port, path, user := parseSSHURL(urlObj)
|
|
|
|
cmd, err := buildTransferCommand(host, port, path, user)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to create stdin pipe: %w", err)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
|
}
|
|
|
|
// Read stderr via a pipe so we control when it is fully consumed.
|
|
// stderrDone channel is closed once all stderr has been read, sending signal that the subcommand has terminated.
|
|
stderrDone := make(chan struct{})
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
|
}
|
|
var stderrBuf bytes.Buffer
|
|
go func() {
|
|
io.Copy(&stderrBuf, stderrPipe)
|
|
close(stderrDone)
|
|
}()
|
|
|
|
// Start the subprocess. From this point forward we must ensure that we call conn.CloseAndTerminate()
|
|
// on all error paths to avoid leaking subprocesses.
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to start transfer subprocess: %w", err)
|
|
}
|
|
|
|
// Wait for the subprocess in a background goroutine so we can be
|
|
// notified of exit via a channel. cmd.Wait() can only be called once,
|
|
// so this goroutine owns that call.
|
|
waitCh := make(chan error, 1)
|
|
go func() {
|
|
waitCh <- cmd.Wait()
|
|
close(waitCh)
|
|
}()
|
|
|
|
procCtx, procCancel := context.WithCancelCause(ctx)
|
|
|
|
// Construct sshConnection early so error paths can use conn.CloseWithErr()
|
|
// for coordinated cleanup regardless of how far initialization got.
|
|
conn := &sshConnection{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
procCancel: procCancel,
|
|
waitCh: waitCh,
|
|
}
|
|
|
|
// Create SMUX client session over the subprocess pipes.
|
|
pConn := &pipeConn{
|
|
r: stdout,
|
|
w: stdin,
|
|
}
|
|
smuxConfig := smux.DefaultConfig()
|
|
smuxConfig.MaxReceiveBuffer = remotesrv.MaxGRPCMessageSize
|
|
smuxConfig.MaxStreamBuffer = remotesrv.MaxGRPCMessageSize
|
|
|
|
session, err := smux.Client(pConn, smuxConfig)
|
|
if err != nil {
|
|
conn.CloseWithErr(err)
|
|
return nil, nil, nil, sshRemoteError(stderrDone, &stderrBuf, path, "failed to create SMUX client session", err)
|
|
}
|
|
conn.session = session
|
|
|
|
// Monitor the SMUX session in a background goroutine. When the remote
|
|
// subprocess exits (bad path, missing dolt, SSH failure, etc.), the pipe
|
|
// gets EOF and SMUX closes the session. This cancels our context so that
|
|
// gRPC calls unblock immediately instead of hanging forever.
|
|
//
|
|
// AcceptStream forces SMUX to actively read the connection. Without it,
|
|
// SMUX only discovers EOF on the next read/write attempt -- which never
|
|
// comes while gRPC is stuck in the WaitForReady picker loop.
|
|
go func() {
|
|
session.AcceptStream()
|
|
procCancel(fmt.Errorf("remote process exited"))
|
|
}()
|
|
|
|
// Create a per-connection HTTP client that routes table file requests
|
|
// through the SMUX session. This is injected into the chunk store via
|
|
// WithHTTPFetcher so each SSH connection has its own transport.
|
|
httpClient := &http.Client{Transport: &smuxHTTPTransport{session: session}}
|
|
|
|
// Create gRPC client connection through SMUX streams.
|
|
grpcConn, err := grpc.NewClient(
|
|
"passthrough:///stdio",
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithContextDialer(func(ctx context.Context, target string) (net.Conn, error) {
|
|
return session.OpenStream()
|
|
}),
|
|
grpc.WithDisableHealthCheck(),
|
|
grpc.WithDefaultCallOptions(grpc.WaitForReady(true)),
|
|
)
|
|
if err != nil {
|
|
conn.CloseWithErr(err)
|
|
return nil, nil, nil, sshRemoteError(stderrDone, &stderrBuf, path, "failed to create gRPC client", err)
|
|
}
|
|
conn.grpcConn = grpcConn
|
|
|
|
// Create chunk store backed by the remote gRPC service.
|
|
client := remotesapi.NewChunkStoreServiceClient(grpcConn)
|
|
cs, err := remotestorage.NewDoltChunkStoreFromPath(procCtx, nbf, urlObj.Path, path, false, client)
|
|
if err != nil {
|
|
conn.CloseWithErr(err)
|
|
return nil, nil, nil, sshRemoteError(stderrDone, &stderrBuf, path, "failed to create chunk store", err)
|
|
}
|
|
|
|
cs = cs.WithHTTPFetcher(httpClient)
|
|
wrappedCS := &sshChunkStore{DoltChunkStore: cs, conn: conn}
|
|
|
|
vrw := types.NewValueStore(wrappedCS)
|
|
ns := tree.NewNodeStore(wrappedCS)
|
|
db := datas.NewTypesDatabase(vrw, ns)
|
|
|
|
return db, vrw, ns, nil
|
|
}
|
|
|
|
// sshRemoteError builds an error message for SSH remote failures. It first uses
|
|
// the text of stderr, and if there is none if returns the provided error.
|
|
// uses it to produce a more informative message than the raw gRPC/SMUX error.
|
|
func sshRemoteError(stderrDone <-chan struct{}, stderrBuf *bytes.Buffer, path, msg string, err error) error {
|
|
<-stderrDone
|
|
errMsg := filterSSHNoise(stderrBuf.String())
|
|
if errMsg != "" {
|
|
if strings.Contains(errMsg, "no such file or directory") || strings.Contains(errMsg, "failed to load database") {
|
|
return fmt.Errorf("repository not found at %s", path)
|
|
}
|
|
return fmt.Errorf("%s: remote: %s", msg, errMsg)
|
|
}
|
|
// If no stderr output, return the original error.
|
|
return fmt.Errorf("%s: %w", msg, err)
|
|
}
|
|
|
|
// filterSSHNoise removes common SSH informational messages from stderr output
|
|
// so they are not mistaken for real errors.
|
|
func filterSSHNoise(s string) string {
|
|
var lines []string
|
|
for _, line := range strings.Split(s, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "Warning: Permanently added") {
|
|
continue
|
|
}
|
|
lines = append(lines, trimmed)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
const (
|
|
// EnvSSHCommand is the environment variable for the SSH command/script
|
|
// used to connect to remote hosts. Mirrors git's GIT_SSH_COMMAND.
|
|
// Supports a full command with arguments (split on whitespace).
|
|
// Default: "ssh".
|
|
EnvSSHCommand = "DOLT_SSH_COMMAND"
|
|
|
|
// EnvSSHExecPath is the environment variable for the path to the dolt
|
|
// binary on the remote host. Default: "dolt".
|
|
EnvSSHExecPath = "DOLT_SSH_EXEC_PATH"
|
|
)
|
|
|
|
// parseSSHURL extracts host, port, path, and user from an SSH remote URL.
|
|
// It strips trailing "/" and "/.dolt" from the path, and handles user info
|
|
// from both the standard URL userinfo and user@host formats.
|
|
func parseSSHURL(urlObj *url.URL) (host, port, path, user string) {
|
|
host = urlObj.Hostname()
|
|
port = urlObj.Port()
|
|
path = urlObj.Path
|
|
path = strings.TrimSuffix(path, "/")
|
|
path = strings.TrimSuffix(path, "/.dolt")
|
|
if urlObj.User != nil {
|
|
user = urlObj.User.Username()
|
|
}
|
|
if atIdx := strings.LastIndex(host, "@"); atIdx != -1 {
|
|
user = host[:atIdx]
|
|
host = host[atIdx+1:]
|
|
}
|
|
return
|
|
}
|
|
|
|
// buildTransferCommand constructs the exec.Cmd for the transfer subprocess.
|
|
// It runs ssh [-p port] [user@]host "<dolt> --data-dir <path> transfer",
|
|
// using DOLT_SSH_COMMAND as the SSH command if set (default "ssh"), and
|
|
// DOLT_SSH_EXEC_PATH as the remote dolt binary path if set (default "dolt").
|
|
func buildTransferCommand(host, port, path, user string) (*exec.Cmd, error) {
|
|
sshCommand := os.Getenv(EnvSSHCommand)
|
|
if sshCommand == "" {
|
|
sshCommand = "ssh"
|
|
}
|
|
|
|
remoteDolt := os.Getenv(EnvSSHExecPath)
|
|
if remoteDolt == "" {
|
|
remoteDolt = "dolt"
|
|
}
|
|
|
|
sshTarget := host
|
|
if user != "" {
|
|
sshTarget = user + "@" + host
|
|
}
|
|
|
|
remoteCmd := fmt.Sprintf("%s --data-dir %s transfer", remoteDolt, path)
|
|
sshArgs := strings.Fields(sshCommand)
|
|
if len(sshArgs) == 0 {
|
|
return nil, fmt.Errorf("invalid %s: empty", EnvSSHCommand)
|
|
}
|
|
|
|
args := append(sshArgs[1:], sshTarget, remoteCmd)
|
|
if port != "" {
|
|
args = append(sshArgs[1:], "-p", port, sshTarget, remoteCmd)
|
|
}
|
|
return exec.Command(sshArgs[0], args...), nil
|
|
}
|
|
|
|
// sshConnection holds all resources for an SSH transfer connection and
|
|
// implements coordinated cleanup.
|
|
type sshConnection struct {
|
|
cmd *exec.Cmd
|
|
session *smux.Session
|
|
grpcConn *grpc.ClientConn
|
|
stdin io.WriteCloser
|
|
procCancel context.CancelCauseFunc
|
|
waitCh <-chan error
|
|
}
|
|
|
|
// Close releases all resources: cancels the context, closes the SMUX
|
|
// session, gRPC connection, stdin pipe, and waits for the subprocess to terminate.
|
|
func (c *sshConnection) Close() error {
|
|
c.CloseWithErr(fmt.Errorf("connection closed"))
|
|
return nil
|
|
}
|
|
|
|
// CloseWithErr is like Close but provides a specific cause for the context
|
|
// cancellation, which surfaces in gRPC errors and diagnostics.
|
|
func (c *sshConnection) CloseWithErr(err error) {
|
|
if c.procCancel != nil {
|
|
c.procCancel(err)
|
|
}
|
|
if c.session != nil {
|
|
c.session.Close()
|
|
}
|
|
if c.grpcConn != nil {
|
|
c.grpcConn.Close()
|
|
}
|
|
if c.stdin != nil {
|
|
c.stdin.Close()
|
|
}
|
|
if c.waitCh != nil {
|
|
// Wait for the subprocess to exit gracefully. If it hangs,
|
|
// forcefully kill it after a short timeout because it's really supposed to be done when we reach this point.
|
|
select {
|
|
case <-c.waitCh:
|
|
case <-time.After(1 * time.Second):
|
|
if c.cmd != nil && c.cmd.Process != nil {
|
|
c.cmd.Process.Kill()
|
|
}
|
|
<-c.waitCh
|
|
}
|
|
}
|
|
}
|
|
|
|
// sshChunkStore wraps a DoltChunkStore and closes the SSH connection when
|
|
// the chunk store is closed.
|
|
type sshChunkStore struct {
|
|
*remotestorage.DoltChunkStore
|
|
conn *sshConnection
|
|
}
|
|
|
|
func (s *sshChunkStore) Close() error {
|
|
err := s.DoltChunkStore.Close()
|
|
connErr := s.conn.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return connErr
|
|
}
|
|
|
|
// smuxHTTPTransport implements http.RoundTripper by sending HTTP requests
|
|
// over SMUX streams. Each request gets its own stream.
|
|
type smuxHTTPTransport struct {
|
|
session *smux.Session
|
|
}
|
|
|
|
func (t *smuxHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
stream, err := t.session.OpenStream()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open SMUX stream for HTTP: %w", err)
|
|
}
|
|
|
|
if err := req.Write(stream); err != nil {
|
|
stream.Close()
|
|
return nil, fmt.Errorf("failed to write HTTP request: %w", err)
|
|
}
|
|
|
|
resp, err := http.ReadResponse(bufio.NewReader(stream), req)
|
|
if err != nil {
|
|
stream.Close()
|
|
return nil, fmt.Errorf("failed to read HTTP response: %w", err)
|
|
}
|
|
|
|
// Wrap the response body to close the SMUX stream when the body is closed.
|
|
resp.Body = &streamBodyCloser{ReadCloser: resp.Body, stream: stream}
|
|
return resp, nil
|
|
}
|
|
|
|
// streamBodyCloser wraps a response body and closes the underlying SMUX
|
|
// stream when the body is closed.
|
|
type streamBodyCloser struct {
|
|
io.ReadCloser
|
|
stream net.Conn
|
|
}
|
|
|
|
func (s *streamBodyCloser) Close() error {
|
|
err := s.ReadCloser.Close()
|
|
s.stream.Close()
|
|
return err
|
|
}
|
|
|
|
// pipeConn implements net.Conn over a subprocess's stdin/stdout pipes.
|
|
type pipeConn struct {
|
|
r io.ReadCloser
|
|
w io.WriteCloser
|
|
}
|
|
|
|
func (c *pipeConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
|
func (c *pipeConn) Write(p []byte) (int, error) { return c.w.Write(p) }
|
|
func (c *pipeConn) Close() error { return nil }
|
|
func (c *pipeConn) LocalAddr() net.Addr { return pipeAddr{} }
|
|
func (c *pipeConn) RemoteAddr() net.Addr { return pipeAddr{} }
|
|
func (c *pipeConn) SetDeadline(_ time.Time) error { return nil }
|
|
func (c *pipeConn) SetReadDeadline(_ time.Time) error { return nil }
|
|
func (c *pipeConn) SetWriteDeadline(_ time.Time) error { return nil }
|
|
|
|
type pipeAddr struct{}
|
|
|
|
func (pipeAddr) Network() string { return "pipe" }
|
|
func (pipeAddr) String() string { return "pipe" }
|