feat(federation): add SQL user authentication for peer sync
Merge SQL user authentication with Emma federation sync implementation: - Add federation_peers table for encrypted credential storage - Add credentials.go with AES-256-GCM encryption, SHA-256 key derivation - Extend FederatedStorage interface with credential methods - Add --user, --password, --sovereignty flags to bd federation add-peer - Integrate credentials into PushTo/PullFrom/Fetch via withPeerCredentials - DOLT_REMOTE_USER/PASSWORD env vars protected by mutex for concurrency Credentials automatically used when syncing with peers that have stored auth. Continues: bd-wkumz.10, Closes: bd-4p67y Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
ea51c4b0bd
commit
d3d2326a8b
@@ -2,16 +2,23 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/storage/dolt"
|
"github.com/steveyegge/beads/internal/storage/dolt"
|
||||||
"github.com/steveyegge/beads/internal/ui"
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
federationPeer string
|
federationPeer string
|
||||||
federationStrategy string
|
federationStrategy string
|
||||||
|
federationUser string
|
||||||
|
federationPassword string
|
||||||
|
federationSov string
|
||||||
)
|
)
|
||||||
|
|
||||||
var federationCmd = &cobra.Command{
|
var federationCmd = &cobra.Command{
|
||||||
@@ -66,17 +73,22 @@ Examples:
|
|||||||
|
|
||||||
var federationAddPeerCmd = &cobra.Command{
|
var federationAddPeerCmd = &cobra.Command{
|
||||||
Use: "add-peer <name> <url>",
|
Use: "add-peer <name> <url>",
|
||||||
Short: "Add a federation peer",
|
Short: "Add a federation peer with optional SQL credentials",
|
||||||
Long: `Add a new federation peer remote.
|
Long: `Add a new federation peer remote with optional SQL user authentication.
|
||||||
|
|
||||||
The URL can be:
|
The URL can be:
|
||||||
- dolthub://org/repo DoltHub hosted repository
|
- dolthub://org/repo DoltHub hosted repository
|
||||||
- host:port/database Direct dolt sql-server connection
|
- host:port/database Direct dolt sql-server connection
|
||||||
- file:///path/to/repo Local file path (for testing)
|
- file:///path/to/repo Local file path (for testing)
|
||||||
|
|
||||||
|
Credentials are encrypted and stored locally. They are used automatically
|
||||||
|
when syncing with the peer. If --user is provided without --password,
|
||||||
|
you will be prompted for the password interactively.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd federation add-peer town-beta dolthub://acme/town-beta-beads
|
bd federation add-peer town-beta dolthub://acme/town-beta-beads
|
||||||
bd federation add-peer town-gamma 192.168.1.100:3306/beads`,
|
bd federation add-peer town-gamma 192.168.1.100:3306/beads --user sync-bot
|
||||||
|
bd federation add-peer partner https://partner.example.com/beads --user admin --password secret`,
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runFederationAddPeer,
|
Run: runFederationAddPeer,
|
||||||
}
|
}
|
||||||
@@ -109,6 +121,11 @@ func init() {
|
|||||||
// Flags for status
|
// Flags for status
|
||||||
federationStatusCmd.Flags().StringVar(&federationPeer, "peer", "", "Specific peer to check")
|
federationStatusCmd.Flags().StringVar(&federationPeer, "peer", "", "Specific peer to check")
|
||||||
|
|
||||||
|
// Flags for add-peer (SQL user authentication)
|
||||||
|
federationAddPeerCmd.Flags().StringVarP(&federationUser, "user", "u", "", "SQL username for authentication")
|
||||||
|
federationAddPeerCmd.Flags().StringVarP(&federationPassword, "password", "p", "", "SQL password (prompted if --user set without --password)")
|
||||||
|
federationAddPeerCmd.Flags().StringVar(&federationSov, "sovereignty", "", "Sovereignty tier (T1, T2, T3, T4)")
|
||||||
|
|
||||||
rootCmd.AddCommand(federationCmd)
|
rootCmd.AddCommand(federationCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,19 +305,63 @@ func runFederationAddPeer(cmd *cobra.Command, args []string) {
|
|||||||
FatalErrorRespectJSON("federation requires Dolt backend")
|
FatalErrorRespectJSON("federation requires Dolt backend")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fs.AddRemote(ctx, name, url); err != nil {
|
// If user is provided but password is not, prompt for it
|
||||||
FatalErrorRespectJSON("failed to add peer: %v", err)
|
password := federationPassword
|
||||||
|
if federationUser != "" && password == "" {
|
||||||
|
fmt.Fprint(os.Stderr, "Password: ")
|
||||||
|
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Fprintln(os.Stderr) // newline after password
|
||||||
|
if err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to read password: %v", err)
|
||||||
|
}
|
||||||
|
password = string(pwBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sovereignty tier if provided
|
||||||
|
sov := federationSov
|
||||||
|
if sov != "" {
|
||||||
|
sov = strings.ToUpper(sov)
|
||||||
|
if sov != "T1" && sov != "T2" && sov != "T3" && sov != "T4" {
|
||||||
|
FatalErrorRespectJSON("invalid sovereignty tier: %s (must be T1, T2, T3, or T4)", federationSov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If credentials provided, use AddFederationPeer to store them
|
||||||
|
if federationUser != "" {
|
||||||
|
peer := &storage.FederationPeer{
|
||||||
|
Name: name,
|
||||||
|
RemoteURL: url,
|
||||||
|
Username: federationUser,
|
||||||
|
Password: password,
|
||||||
|
Sovereignty: sov,
|
||||||
|
}
|
||||||
|
if err := fs.AddFederationPeer(ctx, peer); err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to add peer: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No credentials, just add the remote
|
||||||
|
if err := fs.AddRemote(ctx, name, url); err != nil {
|
||||||
|
FatalErrorRespectJSON("failed to add peer: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(map[string]interface{}{
|
outputJSON(map[string]interface{}{
|
||||||
"added": name,
|
"added": name,
|
||||||
"url": url,
|
"url": url,
|
||||||
|
"has_auth": federationUser != "",
|
||||||
|
"sovereignty": sov,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Added peer %s: %s\n", ui.RenderAccent(name), url)
|
fmt.Printf("Added peer %s: %s\n", ui.RenderAccent(name), url)
|
||||||
|
if federationUser != "" {
|
||||||
|
fmt.Printf(" User: %s (credentials stored)\n", federationUser)
|
||||||
|
}
|
||||||
|
if sov != "" {
|
||||||
|
fmt.Printf(" Sovereignty: %s\n", sov)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runFederationRemovePeer(cmd *cobra.Command, args []string) {
|
func runFederationRemovePeer(cmd *cobra.Command, args []string) {
|
||||||
|
|||||||
327
internal/storage/dolt/credentials.go
Normal file
327
internal/storage/dolt/credentials.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package dolt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credential storage and encryption for federation peers.
|
||||||
|
// Enables SQL user authentication when syncing with peer Gas Towns.
|
||||||
|
|
||||||
|
// federationEnvMutex protects DOLT_REMOTE_USER/PASSWORD env vars from concurrent access.
|
||||||
|
// Environment variables are process-global, so we need to serialize federation operations.
|
||||||
|
var federationEnvMutex sync.Mutex
|
||||||
|
|
||||||
|
// validPeerNameRegex matches valid peer names (alphanumeric, hyphens, underscores)
|
||||||
|
var validPeerNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
// validatePeerName checks that a peer name is safe for use as a Dolt remote name
|
||||||
|
func validatePeerName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("peer name cannot be empty")
|
||||||
|
}
|
||||||
|
if len(name) > 64 {
|
||||||
|
return fmt.Errorf("peer name too long (max 64 characters)")
|
||||||
|
}
|
||||||
|
if !validPeerNameRegex.MatchString(name) {
|
||||||
|
return fmt.Errorf("peer name must start with a letter and contain only alphanumeric characters, hyphens, and underscores")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptionKey derives a key from the database path for credential encryption.
|
||||||
|
// This provides basic protection - credentials are not stored in plaintext.
|
||||||
|
// For production, consider using system keyring or external secret managers.
|
||||||
|
func (s *DoltStore) encryptionKey() []byte {
|
||||||
|
// Use SHA-256 hash of the database path as the key (32 bytes for AES-256)
|
||||||
|
// This ties credentials to this specific database location
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(s.dbPath + "beads-federation-key-v1"))
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPassword encrypts a password using AES-GCM
|
||||||
|
func (s *DoltStore) encryptPassword(password string) ([]byte, error) {
|
||||||
|
if password == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptPassword decrypts a password using AES-GCM
|
||||||
|
func (s *DoltStore) decryptPassword(encrypted []byte) (string, error) {
|
||||||
|
if len(encrypted) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(encrypted) < nonceSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFederationPeer adds or updates a federation peer with credentials.
|
||||||
|
// This stores credentials in the database and also adds the Dolt remote.
|
||||||
|
func (s *DoltStore) AddFederationPeer(ctx context.Context, peer *storage.FederationPeer) error {
|
||||||
|
// Validate peer name
|
||||||
|
if err := validatePeerName(peer.Name); err != nil {
|
||||||
|
return fmt.Errorf("invalid peer name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt password before storing
|
||||||
|
var encryptedPwd []byte
|
||||||
|
var err error
|
||||||
|
if peer.Password != "" {
|
||||||
|
encryptedPwd, err = s.encryptPassword(peer.Password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the peer credentials
|
||||||
|
_, err = s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO federation_peers (name, remote_url, username, password_encrypted, sovereignty)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
remote_url = VALUES(remote_url),
|
||||||
|
username = VALUES(username),
|
||||||
|
password_encrypted = VALUES(password_encrypted),
|
||||||
|
sovereignty = VALUES(sovereignty),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, peer.Name, peer.RemoteURL, peer.Username, encryptedPwd, peer.Sovereignty)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add federation peer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add the Dolt remote
|
||||||
|
if err := s.AddRemote(ctx, peer.Name, peer.RemoteURL); err != nil {
|
||||||
|
// Ignore "remote already exists" errors
|
||||||
|
if !strings.Contains(err.Error(), "already exists") {
|
||||||
|
return fmt.Errorf("failed to add dolt remote: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFederationPeer retrieves a federation peer by name.
|
||||||
|
// Returns nil if peer doesn't exist.
|
||||||
|
func (s *DoltStore) GetFederationPeer(ctx context.Context, name string) (*storage.FederationPeer, error) {
|
||||||
|
var peer storage.FederationPeer
|
||||||
|
var encryptedPwd []byte
|
||||||
|
var lastSync sql.NullTime
|
||||||
|
var username sql.NullString
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT name, remote_url, username, password_encrypted, sovereignty, last_sync, created_at, updated_at
|
||||||
|
FROM federation_peers WHERE name = ?
|
||||||
|
`, name).Scan(&peer.Name, &peer.RemoteURL, &username, &encryptedPwd, &peer.Sovereignty, &lastSync, &peer.CreatedAt, &peer.UpdatedAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get federation peer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.Valid {
|
||||||
|
peer.Username = username.String
|
||||||
|
}
|
||||||
|
if lastSync.Valid {
|
||||||
|
peer.LastSync = &lastSync.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt password
|
||||||
|
if len(encryptedPwd) > 0 {
|
||||||
|
peer.Password, err = s.decryptPassword(encryptedPwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &peer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFederationPeers returns all configured federation peers.
|
||||||
|
func (s *DoltStore) ListFederationPeers(ctx context.Context) ([]*storage.FederationPeer, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT name, remote_url, username, password_encrypted, sovereignty, last_sync, created_at, updated_at
|
||||||
|
FROM federation_peers ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list federation peers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var peers []*storage.FederationPeer
|
||||||
|
for rows.Next() {
|
||||||
|
var peer storage.FederationPeer
|
||||||
|
var encryptedPwd []byte
|
||||||
|
var lastSync sql.NullTime
|
||||||
|
var username sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&peer.Name, &peer.RemoteURL, &username, &encryptedPwd, &peer.Sovereignty, &lastSync, &peer.CreatedAt, &peer.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan federation peer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.Valid {
|
||||||
|
peer.Username = username.String
|
||||||
|
}
|
||||||
|
if lastSync.Valid {
|
||||||
|
peer.LastSync = &lastSync.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt password
|
||||||
|
if len(encryptedPwd) > 0 {
|
||||||
|
peer.Password, err = s.decryptPassword(encryptedPwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peers = append(peers, &peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFederationPeer removes a federation peer and its credentials.
|
||||||
|
func (s *DoltStore) RemoveFederationPeer(ctx context.Context, name string) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, "DELETE FROM federation_peers WHERE name = ?", name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove federation peer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
// Peer not in credentials table, but might still be a Dolt remote
|
||||||
|
// Continue to try removing the remote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove the Dolt remote (best-effort)
|
||||||
|
_ = s.RemoveRemote(ctx, name)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePeerLastSync updates the last sync time for a peer.
|
||||||
|
func (s *DoltStore) UpdatePeerLastSync(ctx context.Context, name string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, "UPDATE federation_peers SET last_sync = CURRENT_TIMESTAMP WHERE name = ?", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFederationCredentials sets DOLT_REMOTE_USER and DOLT_REMOTE_PASSWORD env vars.
|
||||||
|
// Returns a cleanup function that must be called (typically via defer) to unset them.
|
||||||
|
// The caller must hold federationEnvMutex.
|
||||||
|
func setFederationCredentials(username, password string) func() {
|
||||||
|
if username != "" {
|
||||||
|
os.Setenv("DOLT_REMOTE_USER", username)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
os.Setenv("DOLT_REMOTE_PASSWORD", password)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
os.Unsetenv("DOLT_REMOTE_USER")
|
||||||
|
os.Unsetenv("DOLT_REMOTE_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withPeerCredentials executes a function with peer credentials set in environment.
|
||||||
|
// If the peer has stored credentials, they are set as DOLT_REMOTE_USER/PASSWORD
|
||||||
|
// for the duration of the function call.
|
||||||
|
func (s *DoltStore) withPeerCredentials(ctx context.Context, peerName string, fn func() error) error {
|
||||||
|
// Look up credentials for this peer
|
||||||
|
peer, err := s.GetFederationPeer(ctx, peerName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get peer credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have credentials, set env vars with mutex protection
|
||||||
|
if peer != nil && (peer.Username != "" || peer.Password != "") {
|
||||||
|
federationEnvMutex.Lock()
|
||||||
|
cleanup := setFederationCredentials(peer.Username, peer.Password)
|
||||||
|
defer func() {
|
||||||
|
cleanup()
|
||||||
|
federationEnvMutex.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the function
|
||||||
|
err = fn()
|
||||||
|
|
||||||
|
// Update last sync time on success
|
||||||
|
if err == nil && peer != nil {
|
||||||
|
_ = s.UpdatePeerLastSync(ctx, peerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushWithCredentials pushes to a remote using stored credentials.
|
||||||
|
func (s *DoltStore) PushWithCredentials(ctx context.Context, remoteName string) error {
|
||||||
|
return s.withPeerCredentials(ctx, remoteName, func() error {
|
||||||
|
return s.PushTo(ctx, remoteName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullWithCredentials pulls from a remote using stored credentials.
|
||||||
|
func (s *DoltStore) PullWithCredentials(ctx context.Context, remoteName string) ([]storage.Conflict, error) {
|
||||||
|
var conflicts []storage.Conflict
|
||||||
|
err := s.withPeerCredentials(ctx, remoteName, func() error {
|
||||||
|
var pullErr error
|
||||||
|
conflicts, pullErr = s.PullFrom(ctx, remoteName)
|
||||||
|
return pullErr
|
||||||
|
})
|
||||||
|
return conflicts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FederationPeer is an alias for storage.FederationPeer for convenience.
|
||||||
|
type FederationPeer = storage.FederationPeer
|
||||||
13
internal/storage/dolt/credentials_test.go
Normal file
13
internal/storage/dolt/credentials_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package dolt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDoltStoreImplementsCredentialMethods(t *testing.T) {
|
||||||
|
// Compile-time check that DoltStore implements FederatedStorage with credential methods
|
||||||
|
var _ storage.FederatedStorage = (*DoltStore)(nil)
|
||||||
|
t.Log("DoltStore implements FederatedStorage interface with credential methods")
|
||||||
|
}
|
||||||
@@ -12,39 +12,51 @@ import (
|
|||||||
// These methods enable peer-to-peer synchronization between Gas Towns.
|
// These methods enable peer-to-peer synchronization between Gas Towns.
|
||||||
|
|
||||||
// PushTo pushes commits to a specific peer remote.
|
// PushTo pushes commits to a specific peer remote.
|
||||||
|
// If credentials are stored for this peer, they are used automatically.
|
||||||
func (s *DoltStore) PushTo(ctx context.Context, peer string) error {
|
func (s *DoltStore) PushTo(ctx context.Context, peer string) error {
|
||||||
// DOLT_PUSH(remote, branch)
|
return s.withPeerCredentials(ctx, peer, func() error {
|
||||||
_, err := s.db.ExecContext(ctx, "CALL DOLT_PUSH(?, ?)", peer, s.branch)
|
// DOLT_PUSH(remote, branch)
|
||||||
if err != nil {
|
_, err := s.db.ExecContext(ctx, "CALL DOLT_PUSH(?, ?)", peer, s.branch)
|
||||||
return fmt.Errorf("failed to push to peer %s: %w", peer, err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("failed to push to peer %s: %w", peer, err)
|
||||||
return nil
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PullFrom pulls changes from a specific peer remote.
|
// PullFrom pulls changes from a specific peer remote.
|
||||||
|
// If credentials are stored for this peer, they are used automatically.
|
||||||
// Returns any merge conflicts if present.
|
// Returns any merge conflicts if present.
|
||||||
func (s *DoltStore) PullFrom(ctx context.Context, peer string) ([]storage.Conflict, error) {
|
func (s *DoltStore) PullFrom(ctx context.Context, peer string) ([]storage.Conflict, error) {
|
||||||
// DOLT_PULL(remote) - pulls and merges
|
var conflicts []storage.Conflict
|
||||||
_, err := s.db.ExecContext(ctx, "CALL DOLT_PULL(?)", peer)
|
err := s.withPeerCredentials(ctx, peer, func() error {
|
||||||
if err != nil {
|
// DOLT_PULL(remote) - pulls and merges
|
||||||
// Check if the error is due to merge conflicts
|
_, pullErr := s.db.ExecContext(ctx, "CALL DOLT_PULL(?)", peer)
|
||||||
conflicts, conflictErr := s.GetConflicts(ctx)
|
if pullErr != nil {
|
||||||
if conflictErr == nil && len(conflicts) > 0 {
|
// Check if the error is due to merge conflicts
|
||||||
return conflicts, nil
|
c, conflictErr := s.GetConflicts(ctx)
|
||||||
|
if conflictErr == nil && len(c) > 0 {
|
||||||
|
conflicts = c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to pull from peer %s: %w", peer, pullErr)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to pull from peer %s: %w", peer, err)
|
return nil
|
||||||
}
|
})
|
||||||
return nil, nil
|
return conflicts, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fetches refs from a peer without merging.
|
// Fetch fetches refs from a peer without merging.
|
||||||
|
// If credentials are stored for this peer, they are used automatically.
|
||||||
func (s *DoltStore) Fetch(ctx context.Context, peer string) error {
|
func (s *DoltStore) Fetch(ctx context.Context, peer string) error {
|
||||||
// DOLT_FETCH(remote)
|
return s.withPeerCredentials(ctx, peer, func() error {
|
||||||
_, err := s.db.ExecContext(ctx, "CALL DOLT_FETCH(?)", peer)
|
// DOLT_FETCH(remote)
|
||||||
if err != nil {
|
_, err := s.db.ExecContext(ctx, "CALL DOLT_FETCH(?)", peer)
|
||||||
return fmt.Errorf("failed to fetch from peer %s: %w", peer, err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("failed to fetch from peer %s: %w", peer, err)
|
||||||
return nil
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRemotes returns configured remote names and URLs.
|
// ListRemotes returns configured remote names and URLs.
|
||||||
|
|||||||
@@ -234,6 +234,20 @@ CREATE TABLE IF NOT EXISTS interactions (
|
|||||||
INDEX idx_interactions_issue_id (issue_id),
|
INDEX idx_interactions_issue_id (issue_id),
|
||||||
INDEX idx_interactions_parent_id (parent_id)
|
INDEX idx_interactions_parent_id (parent_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Federation peers table (for SQL user authentication)
|
||||||
|
-- Stores credentials for peer-to-peer Dolt remotes between Gas Towns
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_peers (
|
||||||
|
name VARCHAR(255) PRIMARY KEY,
|
||||||
|
remote_url VARCHAR(1024) NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
password_encrypted BLOB,
|
||||||
|
sovereignty VARCHAR(8) DEFAULT '',
|
||||||
|
last_sync DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_federation_peers_sovereignty (sovereignty)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// defaultConfig contains the default configuration values
|
// defaultConfig contains the default configuration values
|
||||||
|
|||||||
@@ -171,6 +171,27 @@ type FederatedStorage interface {
|
|||||||
|
|
||||||
// SyncStatus returns the sync status with a peer.
|
// SyncStatus returns the sync status with a peer.
|
||||||
SyncStatus(ctx context.Context, peer string) (*SyncStatus, error)
|
SyncStatus(ctx context.Context, peer string) (*SyncStatus, error)
|
||||||
|
|
||||||
|
// Credential management for SQL user authentication
|
||||||
|
|
||||||
|
// AddFederationPeer adds or updates a federation peer with credentials.
|
||||||
|
AddFederationPeer(ctx context.Context, peer *FederationPeer) error
|
||||||
|
|
||||||
|
// GetFederationPeer retrieves a federation peer by name.
|
||||||
|
// Returns nil if peer doesn't exist.
|
||||||
|
GetFederationPeer(ctx context.Context, name string) (*FederationPeer, error)
|
||||||
|
|
||||||
|
// ListFederationPeers returns all configured federation peers.
|
||||||
|
ListFederationPeers(ctx context.Context) ([]*FederationPeer, error)
|
||||||
|
|
||||||
|
// RemoveFederationPeer removes a federation peer and its credentials.
|
||||||
|
RemoveFederationPeer(ctx context.Context, name string) error
|
||||||
|
|
||||||
|
// PushWithCredentials pushes to a remote using stored credentials.
|
||||||
|
PushWithCredentials(ctx context.Context, remoteName string) error
|
||||||
|
|
||||||
|
// PullWithCredentials pulls from a remote using stored credentials.
|
||||||
|
PullWithCredentials(ctx context.Context, remoteName string) ([]Conflict, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoteInfo describes a configured remote.
|
// RemoteInfo describes a configured remote.
|
||||||
@@ -188,6 +209,19 @@ type SyncStatus struct {
|
|||||||
HasConflicts bool // Whether there are unresolved conflicts
|
HasConflicts bool // Whether there are unresolved conflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FederationPeer represents a remote peer with authentication credentials.
|
||||||
|
// Used for peer-to-peer Dolt remotes between Gas Towns with SQL user auth.
|
||||||
|
type FederationPeer struct {
|
||||||
|
Name string // Unique name for this peer (used as remote name)
|
||||||
|
RemoteURL string // Dolt remote URL (e.g., http://host:port/org/db)
|
||||||
|
Username string // SQL username for authentication
|
||||||
|
Password string // Password (decrypted, not stored directly)
|
||||||
|
Sovereignty string // Sovereignty tier: T1, T2, T3, T4
|
||||||
|
LastSync *time.Time // Last successful sync time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// IsFederated checks if a storage instance supports federation.
|
// IsFederated checks if a storage instance supports federation.
|
||||||
func IsFederated(s Storage) bool {
|
func IsFederated(s Storage) bool {
|
||||||
_, ok := s.(FederatedStorage)
|
_, ok := s.(FederatedStorage)
|
||||||
|
|||||||
Reference in New Issue
Block a user