Phase 4 — Performance: - F1: SSH DialPool with key-by-host pooling and 5min idle TTL - F2: In-memory entity lookup cache (TTL 60s, HTTP resolveEntityID) - F3: Trigram GIN indexes on entities.slug and entities.name (migration 031) - F4: Partial index on executions(classification_id) for auto-act (migration 032) - Added missing RunOutput and RunStreaming in actuator/ (E3 gap fill) Phase 6 — Infrastructure: - H1: Infisical image pinned to v0.99.1 - H2: execworker daemon — polls pending executions with per-execution advisory locks, recovers orphaned running executions, wired as docker-compose service - H3: splitSQL hardened with block comment and string-literal support, 6 new edge-case tests (11 total) - H4: Scheduler acquires pg_try_advisory_lock(0x01c05e6) at startup
122 lines
2.2 KiB
Go
122 lines
2.2 KiB
Go
package actuator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
type poolEntry struct {
|
|
client *ssh.Client
|
|
createdAt time.Time
|
|
}
|
|
|
|
type DialPool struct {
|
|
mu sync.RWMutex
|
|
entries map[string]*poolEntry
|
|
ttl time.Duration
|
|
done chan struct{}
|
|
stopped bool
|
|
}
|
|
|
|
func NewDialPool(ttl time.Duration) *DialPool {
|
|
p := &DialPool{
|
|
entries: make(map[string]*poolEntry),
|
|
ttl: ttl,
|
|
done: make(chan struct{}),
|
|
}
|
|
if ttl > 0 {
|
|
go p.evictLoop()
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (p *DialPool) key(opts DialOptions) string {
|
|
port := opts.Port
|
|
if port <= 0 {
|
|
port = 22
|
|
}
|
|
return fmt.Sprintf("%s:%d", opts.Host, port)
|
|
}
|
|
|
|
func (p *DialPool) Get(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
|
|
k := p.key(opts)
|
|
p.mu.RLock()
|
|
entry, ok := p.entries[k]
|
|
p.mu.RUnlock()
|
|
if ok {
|
|
// Quick health check: a session can be created without running a
|
|
// command — if it fails, the connection is dead and we evict it.
|
|
testSession, err := entry.client.NewSession()
|
|
if err == nil {
|
|
testSession.Close()
|
|
return entry.client, nil
|
|
}
|
|
p.mu.Lock()
|
|
if p.entries[k] == entry {
|
|
entry.client.Close()
|
|
delete(p.entries, k)
|
|
}
|
|
p.mu.Unlock()
|
|
// Fall through to dial below
|
|
}
|
|
client, err := Dial(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.mu.Lock()
|
|
if p.stopped {
|
|
p.mu.Unlock()
|
|
client.Close()
|
|
return nil, fmt.Errorf("ssh dial pool: closed")
|
|
}
|
|
if existing, ok2 := p.entries[k]; ok2 {
|
|
p.mu.Unlock()
|
|
client.Close()
|
|
return existing.client, nil
|
|
}
|
|
p.entries[k] = &poolEntry{client: client, createdAt: time.Now()}
|
|
p.mu.Unlock()
|
|
return client, nil
|
|
}
|
|
|
|
func (p *DialPool) Close() {
|
|
p.mu.Lock()
|
|
p.stopped = true
|
|
for k, entry := range p.entries {
|
|
entry.client.Close()
|
|
delete(p.entries, k)
|
|
}
|
|
p.mu.Unlock()
|
|
if p.ttl > 0 {
|
|
close(p.done)
|
|
}
|
|
}
|
|
|
|
func (p *DialPool) evictLoop() {
|
|
ticker := time.NewTicker(p.ttl / 2)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-p.done:
|
|
return
|
|
case <-ticker.C:
|
|
p.evict()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *DialPool) evict() {
|
|
deadline := time.Now().Add(-p.ttl)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for k, entry := range p.entries {
|
|
if entry.createdAt.Before(deadline) {
|
|
entry.client.Close()
|
|
delete(p.entries, k)
|
|
}
|
|
}
|
|
} |