Skip to content

Commit e884add

Browse files
author
abhishek-dubey_sreeram
committed
added http config api to PluginManager for synchronous connection config updates
1 parent d1d8fb7 commit e884add

4 files changed

Lines changed: 648 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package pluginmanager_service
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"os"
10+
"sync"
11+
12+
sdkproto "github.com/turbot/steampipe-plugin-sdk/v5/grpc/proto"
13+
"github.com/turbot/steampipe/v2/pkg/connection"
14+
"github.com/turbot/steampipe/v2/pkg/db/db_common"
15+
)
16+
17+
// SetConnectionConfigRequest is the JSON request body for the config API endpoint.
18+
type SetConnectionConfigRequest struct {
19+
Connection string `json:"connection"`
20+
Plugin string `json:"plugin"`
21+
PluginShortName string `json:"plugin_short_name"`
22+
Config string `json:"config"`
23+
PluginInstance string `json:"plugin_instance"`
24+
}
25+
26+
// SetConnectionConfigResponse is the JSON response from the config API endpoint.
27+
type SetConnectionConfigResponse struct {
28+
Success bool `json:"success"`
29+
Error string `json:"error,omitempty"`
30+
}
31+
32+
// startConfigAPIServer starts the HTTP config API server if STEAMPIPE_CONFIG_API_PORT is set.
33+
// The server runs in a goroutine and provides a synchronous endpoint for updating
34+
// connection configs, bypassing the file watcher.
35+
func (m *PluginManager) startConfigAPIServer() {
36+
port := os.Getenv("STEAMPIPE_CONFIG_API_PORT")
37+
if port == "" || port == "0" {
38+
return
39+
}
40+
41+
mux := http.NewServeMux()
42+
mux.HandleFunc("/v1/connection/config", m.handleSetConnectionConfig)
43+
44+
addr := fmt.Sprintf("127.0.0.1:%s", port)
45+
server := &http.Server{
46+
Addr: addr,
47+
Handler: mux,
48+
}
49+
50+
go func() {
51+
log.Printf("[INFO] Config API server listening on %s", addr)
52+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
53+
log.Printf("[WARN] Config API server error: %s", err.Error())
54+
}
55+
}()
56+
}
57+
58+
// getConnLock returns the per-connection mutex for the given connection name,
59+
// creating one if it doesn't exist. This serializes concurrent requests for the
60+
// same connection so that ensureConnectionSchema completes before any concurrent
61+
// request can return success. Different connections are fully parallel.
62+
func (m *PluginManager) getConnLock(connectionName string) *sync.Mutex {
63+
m.connLocksMu.Lock()
64+
mu, ok := m.connLocks[connectionName]
65+
if !ok {
66+
mu = &sync.Mutex{}
67+
m.connLocks[connectionName] = mu
68+
}
69+
m.connLocksMu.Unlock()
70+
return mu
71+
}
72+
73+
// handleSetConnectionConfig handles POST /v1/connection/config.
74+
// It updates a single connection's config in the plugin manager synchronously,
75+
// sending the update to the running plugin via gRPC before returning.
76+
//
77+
// Per-connection locking ensures that when multiple concurrent requests arrive
78+
// for the same connection, only the first creates the schema — the rest wait
79+
// until it's done, then proceed (as credential-rotation updates, not new connections).
80+
func (m *PluginManager) handleSetConnectionConfig(w http.ResponseWriter, r *http.Request) {
81+
if r.Method != http.MethodPost {
82+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
83+
return
84+
}
85+
86+
var req SetConnectionConfigRequest
87+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
88+
writeConfigAPIResponse(w, http.StatusBadRequest, SetConnectionConfigResponse{
89+
Error: fmt.Sprintf("invalid JSON: %s", err.Error()),
90+
})
91+
return
92+
}
93+
94+
if err := validateSetConnectionConfigRequest(&req); err != nil {
95+
writeConfigAPIResponse(w, http.StatusBadRequest, SetConnectionConfigResponse{
96+
Error: err.Error(),
97+
})
98+
return
99+
}
100+
101+
// Acquire per-connection lock. This serializes the entire config-update +
102+
// schema-creation sequence for the same connection name. Without this, 30
103+
// concurrent requests all see isNewConnection=true (first) or false (rest),
104+
// but the "rest" return 200 before the first thread's ensureConnectionSchema
105+
// has finished, causing "relation does not exist" errors.
106+
connLock := m.getConnLock(req.Connection)
107+
connLock.Lock()
108+
defer connLock.Unlock()
109+
110+
// Build the new ConnectionConfig proto
111+
newConfig := &sdkproto.ConnectionConfig{
112+
Connection: req.Connection,
113+
Plugin: req.Plugin,
114+
PluginShortName: req.PluginShortName,
115+
Config: req.Config,
116+
PluginInstance: req.PluginInstance,
117+
}
118+
119+
// Acquire the PluginManager state lock and update the config map
120+
m.mut.Lock()
121+
122+
// Track whether this is a new connection (needs schema creation) or an update (creds only)
123+
_, isNewConnection := m.connectionConfigMap[req.Connection]
124+
isNewConnection = !isNewConnection
125+
126+
// Resolve PluginInstance and Plugin to match the full image refs used as keys
127+
// in m.plugins (e.g., "hub.steampipe.io/plugins/turbot/aws@latest"), not short
128+
// names like "aws". For existing connections, inherit from the current config.
129+
// For new connections, inherit from any existing connection using the same plugin.
130+
if existing, ok := m.connectionConfigMap[req.Connection]; ok {
131+
if newConfig.PluginInstance != existing.PluginInstance {
132+
newConfig.PluginInstance = existing.PluginInstance
133+
}
134+
if newConfig.Plugin == "" {
135+
newConfig.Plugin = existing.Plugin
136+
}
137+
} else {
138+
// New connection: resolve PluginInstance from any existing connection
139+
// that uses the same plugin, so the FDW can find the running plugin process.
140+
for _, existing := range m.connectionConfigMap {
141+
if existing.PluginShortName == req.PluginShortName {
142+
newConfig.PluginInstance = existing.PluginInstance
143+
if newConfig.Plugin == "" {
144+
newConfig.Plugin = existing.Plugin
145+
}
146+
break
147+
}
148+
}
149+
}
150+
151+
newConfigMap := m.buildMergedConfigMap(newConfig)
152+
err := m.handleConnectionConfigChanges(context.Background(), newConfigMap)
153+
m.mut.Unlock()
154+
155+
if err != nil {
156+
log.Printf("[WARN] Config API: handleConnectionConfigChanges failed for connection '%s': %s", req.Connection, err.Error())
157+
writeConfigAPIResponse(w, http.StatusInternalServerError, SetConnectionConfigResponse{
158+
Error: fmt.Sprintf("failed to update connection config: %s", err.Error()),
159+
})
160+
return
161+
}
162+
163+
// For new connections, create the FDW schema in postgres so the connection
164+
// is immediately queryable. For existing connections (credential rotation),
165+
// the schema already exists and only the credentials needed updating.
166+
// This runs under connLock, so concurrent requests for the same connection
167+
// wait until schema creation is complete before proceeding.
168+
if isNewConnection && m.pool != nil {
169+
if err := m.ensureConnectionSchema(context.Background(), req.Connection, req.PluginShortName); err != nil {
170+
log.Printf("[WARN] Config API: schema creation failed for connection '%s': %s", req.Connection, err.Error())
171+
writeConfigAPIResponse(w, http.StatusInternalServerError, SetConnectionConfigResponse{
172+
Error: fmt.Sprintf("connection config updated but schema creation failed: %s", err.Error()),
173+
})
174+
return
175+
}
176+
}
177+
178+
log.Printf("[INFO] Config API: updated connection '%s' (plugin: %s)", req.Connection, req.PluginShortName)
179+
writeConfigAPIResponse(w, http.StatusOK, SetConnectionConfigResponse{Success: true})
180+
}
181+
182+
// buildMergedConfigMap creates a new ConnectionConfigMap by copying the existing map
183+
// and adding or updating the given connection config.
184+
func (m *PluginManager) buildMergedConfigMap(config *sdkproto.ConnectionConfig) connection.ConnectionConfigMap {
185+
newMap := make(connection.ConnectionConfigMap, len(m.connectionConfigMap)+1)
186+
for k, v := range m.connectionConfigMap {
187+
newMap[k] = v
188+
}
189+
newMap[config.Connection] = config
190+
return newMap
191+
}
192+
193+
func validateSetConnectionConfigRequest(req *SetConnectionConfigRequest) error {
194+
if req.Connection == "" {
195+
return fmt.Errorf("'connection' is required")
196+
}
197+
if req.PluginShortName == "" {
198+
return fmt.Errorf("'plugin_short_name' is required")
199+
}
200+
if req.Config == "" {
201+
return fmt.Errorf("'config' is required")
202+
}
203+
if req.PluginInstance == "" {
204+
return fmt.Errorf("'plugin_instance' is required")
205+
}
206+
return nil
207+
}
208+
209+
// ensureConnectionSchema creates the FDW schema for a new connection.
210+
// The plugin manager config was already updated in-memory via
211+
// handleConnectionConfigChanges(), so the FDW import path can resolve this
212+
// connection directly through plugin manager gRPC without any disk config files.
213+
func (m *PluginManager) ensureConnectionSchema(ctx context.Context, connectionName, pluginShortName string) error {
214+
// Create the FDW schema in postgres
215+
sql := db_common.GetUpdateConnectionQuery(connectionName, pluginShortName)
216+
log.Printf("[INFO] Config API: creating schema for connection '%s' (plugin schema: %s)", connectionName, pluginShortName)
217+
218+
conn, err := m.pool.Acquire(ctx)
219+
if err != nil {
220+
return fmt.Errorf("failed to acquire db connection: %w", err)
221+
}
222+
defer conn.Release()
223+
224+
_, err = conn.Exec(ctx, sql)
225+
if err != nil {
226+
return fmt.Errorf("failed to create connection schema: %w", err)
227+
}
228+
229+
// Notify listeners that the schema has changed
230+
if notifyErr := m.SendPostgresSchemaNotification(ctx); notifyErr != nil {
231+
log.Printf("[WARN] Config API: failed to send schema notification: %s", notifyErr.Error())
232+
}
233+
234+
return nil
235+
}
236+
237+
func writeConfigAPIResponse(w http.ResponseWriter, status int, resp SetConnectionConfigResponse) {
238+
w.Header().Set("Content-Type", "application/json")
239+
w.WriteHeader(status)
240+
json.NewEncoder(w).Encode(resp)
241+
}

0 commit comments

Comments
 (0)