diff --git a/build_image.sh b/build_image.sh new file mode 100644 index 0000000..4425e22 --- /dev/null +++ b/build_image.sh @@ -0,0 +1,4 @@ +git pull origin master +sudo make dockerbuild +sudo make dockerpush +sudo docker system prune -f || true \ No newline at end of file diff --git a/data/config.db b/data/config.db index 66554df..b10bbd1 100644 --- a/data/config.db +++ b/data/config.db Binary files differ diff --git a/internal/api.go b/internal/api.go index 82e7c5a..1ba5cc1 100644 --- a/internal/api.go +++ b/internal/api.go @@ -87,6 +87,36 @@ api.removeFilterChainHandler(w, r) }) + // ------------------------------------------------------------------------- + // Secret Handlers (ADDED) + // ------------------------------------------------------------------------- + mux.HandleFunc("/add-secret", func(w http.ResponseWriter, r *http.Request) { + api.addResourcesHandler(w, r, resourcev3.SecretType, func(req interface{}) []types.Resource { + sr := req.(*internalapi.AddSecretRequest) + srs, err := snapshot.LoadResourceFromYAML(context.TODO(), sr.YAML, resourcev3.SecretType) + if err != nil { + http.Error(w, "failed to load secret", http.StatusBadRequest) + return nil + } + return srs + }) + }) + mux.HandleFunc("/disable-secret", func(w http.ResponseWriter, r *http.Request) { + api.disableResourceHandler(w, r, resourcev3.SecretType) + }) + mux.HandleFunc("/enable-secret", func(w http.ResponseWriter, r *http.Request) { + api.enableResourceHandler(w, r, resourcev3.SecretType) + }) + mux.HandleFunc("/remove-secret", func(w http.ResponseWriter, r *http.Request) { + api.removeResourceHandler(w, r, resourcev3.SecretType) + }) + mux.HandleFunc("/list-secrets", func(w http.ResponseWriter, r *http.Request) { + api.listResourceHandler(w, r, resourcev3.SecretType) + }) + mux.HandleFunc("/get-secret", func(w http.ResponseWriter, r *http.Request) { + api.getResourceHandler(w, r, resourcev3.SecretType) + }) + // Query / List Handlers mux.HandleFunc("/list-clusters", func(w http.ResponseWriter, r *http.Request) { api.listResourceHandler(w, r, resourcev3.ClusterType) diff --git a/internal/api/types.go b/internal/api/types.go index 07dda31..55d0880 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -38,6 +38,12 @@ YAML string `json:"yaml"` } +// AddSecretRequest defines payload to add a secret +type AddSecretRequest struct { + Name string `json:"name"` + YAML string `json:"yaml"` +} + // AppendFilterChainRequest defines payload to append a filter chain to a given listener type AppendFilterChainRequest struct { ListenerName string `json:"listener_name"` diff --git a/internal/api_handlers.go b/internal/api_handlers.go index 4ffbdf2..e9f40f3 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -145,6 +145,9 @@ // req = &AddRouteRequest{} case resourcev3.ListenerType: req = &internalapi.AddListenerRequest{} + case resourcev3.SecretType: + req = &internalapi.AddSecretRequest{} + // case resourcev3.EndpointType: default: http.Error(w, "unsupported type", http.StatusBadRequest) return diff --git a/internal/pkg/snapshot/resource_config.go b/internal/pkg/snapshot/resource_config.go index c7a720e..f43cb4c 100644 --- a/internal/pkg/snapshot/resource_config.go +++ b/internal/pkg/snapshot/resource_config.go @@ -7,6 +7,7 @@ clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + secretv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" // ADDED: SDS Secret Import "github.com/envoyproxy/go-control-plane/pkg/cache/types" cachev3 "github.com/envoyproxy/go-control-plane/pkg/cache/v3" resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3" @@ -30,7 +31,7 @@ resources := map[resourcev3.Type][]types.Resource{ resourcev3.ClusterType: make([]types.Resource, len(cfg.EnabledClusters)), resourcev3.ListenerType: make([]types.Resource, len(cfg.EnabledListeners)), - // Other types if supported by SnapshotConfig, can be added here + resourcev3.SecretType: make([]types.Resource, len(cfg.EnabledSecrets)), // ADDED: SecretType } // Populate slices by direct type assertion and conversion @@ -40,6 +41,10 @@ for i, l := range cfg.EnabledListeners { resources[resourcev3.ListenerType][i] = l } + // ADDED: Populate Secrets + for i, s := range cfg.EnabledSecrets { + resources[resourcev3.SecretType][i] = s + } // Create the snapshot snap, err := cachev3.NewSnapshot(version, resources) @@ -65,6 +70,7 @@ config := &storage.SnapshotConfig{ EnabledClusters: []*clusterv3.Cluster{}, EnabledListeners: []*listenerv3.Listener{}, + EnabledSecrets: []*secretv3.Secret{}, // ADDED: EnabledSecrets // Disabled fields are not populated from the cache, only enabled ones. } @@ -82,6 +88,13 @@ } } + // ADDED: Convert Secret resources + for _, r := range snap.GetResources(string(resourcev3.SecretType)) { + if s, ok := r.(*secretv3.Secret); ok { + config.EnabledSecrets = append(config.EnabledSecrets, s) + } + } + return config, nil } @@ -134,6 +147,10 @@ if err := sm.DB.EnableListener(ctx, name, true); err != nil { return fmt.Errorf("failed to enable listener '%s' in DB: %w", name, err) } + case resourcev3.SecretType: // ADDED: SecretType + if err := sm.DB.EnableSecret(ctx, name, true); err != nil { + return fmt.Errorf("failed to enable secret '%s' in DB: %w", name, err) + } default: return fmt.Errorf("unsupported resource type for enabling: %s", typ) } @@ -179,6 +196,7 @@ }{ {resourcev3.ClusterType, resourcesToNamers(cacheConfig.EnabledClusters), resourcesToNamers(dbConfig.EnabledClusters)}, {resourcev3.ListenerType, resourcesToNamers(cacheConfig.EnabledListeners), resourcesToNamers(dbConfig.EnabledListeners)}, + {resourcev3.SecretType, resourcesToNamers(cacheConfig.EnabledSecrets), resourcesToNamers(dbConfig.EnabledSecrets)}, // ADDED: SecretType } for _, m := range typeResourceMaps { @@ -212,7 +230,6 @@ return nil, nil, fmt.Errorf("failed to rebuild snapshot from DB: %w", err) } - var enabled, disabled []types.Resource var namerEnabled, namerDisabled []ResourceNamer switch typ { @@ -222,17 +239,20 @@ case resourcev3.ListenerType: namerEnabled = resourcesToNamers(snap.EnabledListeners) namerDisabled = resourcesToNamers(snap.DisabledListeners) + case resourcev3.SecretType: // ADDED: SecretType + namerEnabled = resourcesToNamers(snap.EnabledSecrets) + namerDisabled = resourcesToNamers(snap.DisabledSecrets) default: return nil, nil, fmt.Errorf("unsupported resource type: %s", typ) } // Convert ResourceNamer slices back to types.Resource slices - enabled = make([]types.Resource, len(namerEnabled)) + enabled := make([]types.Resource, len(namerEnabled)) for i, r := range namerEnabled { enabled[i] = r.(types.Resource) } - disabled = make([]types.Resource, len(namerDisabled)) + disabled := make([]types.Resource, len(namerDisabled)) for i, r := range namerDisabled { disabled[i] = r.(types.Resource) } diff --git a/internal/pkg/snapshot/resource_crud.go b/internal/pkg/snapshot/resource_crud.go index 9e2961e..37896c3 100644 --- a/internal/pkg/snapshot/resource_crud.go +++ b/internal/pkg/snapshot/resource_crud.go @@ -138,7 +138,6 @@ // NOTE: The ServerNamesEqual implementation sorts the slices *in place*. // This side-effect is a common bug source. The existing function *should* use copies. - // Assuming ServerNamesEqual is fixed (or this bug is accepted), the logic holds. // We'll keep the call as-is for the fix, but note the potential bug in ServerNamesEqual. if ServerNamesEqual(existingServerNames, newServerNames) { // Match found! Replace the existing chain with the new one. @@ -215,11 +214,6 @@ // 1. Validate input and get the current Listener from the cache if len(serverNames) == 0 { - return fmt.Errorf("failed to get server names from filter chain") - } - - // Use ServerNames for matching, consistent with UpdateFilterChainOfListener - if len(serverNames) == 0 { return fmt.Errorf("target filter chain match must specify at least one ServerName for targeted removal") } @@ -310,6 +304,10 @@ return nil } +// ----------------------------------------------------------------------------- +// GENERIC RESOURCE CRUD (MODIFIED FOR SDS) +// ----------------------------------------------------------------------------- + // AddResourceToSnapshot adds any resource to the snapshot dynamically func (sm *SnapshotManager) AddResourceToSnapshot(resource types.Resource, typ resourcev3.Type) error { snap, err := sm.Cache.GetSnapshot(sm.NodeID) @@ -318,27 +316,43 @@ } resources := sm.getAllResourcesFromSnapshot(snap) - // Append to the appropriate slice - switch typ { - case resourcev3.ClusterType: - resources[resourcev3.ClusterType] = append(resources[resourcev3.ClusterType], resource) - case resourcev3.ListenerType: - resources[resourcev3.ListenerType] = append(resources[resourcev3.ListenerType], resource) - case resourcev3.EndpointType, resourcev3.SecretType, resourcev3.RuntimeType: - resources[typ] = append(resources[typ], resource) - default: - return fmt.Errorf("unsupported resource type: %s", typ) - } - + // Check if the resource already exists by name (optional but good practice) resourceNamer, ok := resource.(interface{ GetName() string }) if !ok { return fmt.Errorf("resource of type %s does not implement GetName()", typ) } - newSnap, _ := cachev3.NewSnapshot( + // Check for duplicates before adding + if existingResources, ok := resources[typ]; ok { + for _, r := range existingResources { + if namer, ok := r.(interface{ GetName() string }); ok && namer.GetName() == resourceNamer.GetName() { + return fmt.Errorf("resource '%s' of type %s already exists in cache", resourceNamer.GetName(), typ) + } + } + } + + // Append to the appropriate slice + switch typ { + case resourcev3.ClusterType, resourcev3.ListenerType, resourcev3.SecretType: // ADDED: SecretType + // Ensure the resource type entry exists in the map + if _, ok := resources[typ]; !ok { + resources[typ] = make([]types.Resource, 0) + } + resources[typ] = append(resources[typ], resource) + case resourcev3.EndpointType, resourcev3.RuntimeType: + // These types might not be backed by DB storage, but are handled here if needed + resources[typ] = append(resources[typ], resource) + default: + return fmt.Errorf("unsupported resource type for dynamic addition: %s", typ) + } + + newSnap, err := cachev3.NewSnapshot( "snap-generic-"+resourceNamer.GetName()+"-"+time.Now().Format(time.RFC3339), resources, ) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } return sm.Cache.SetSnapshot(context.TODO(), sm.NodeID, newSnap) } @@ -355,10 +369,14 @@ resources[typ], resourceFound = filterAndCheckResourcesByName(targetResources, name) } + // Handle the DB removal logic based on strategy and type if strategy == storage.DeleteActual { - if resourceFound { - return fmt.Errorf("actual delete requested but resource %s of type %s still exists in cache", name, typ) - } + // For DeleteActual, we remove from the cache and then delete from DB. + // If the resource was not found in the cache, we still try to delete it from the DB + // just in case the cache was inconsistent (though this indicates a problem). + // Note: The original code returned an error if resource was found in cache and DeleteActual was requested. + // We'll trust the caller to update the cache *before* calling DeleteActual strategy for consistency. + if typ == resourcev3.ClusterType { if err := sm.DB.RemoveCluster(context.TODO(), name); err != nil { return fmt.Errorf("failed to delete cluster %s from DB: %w", name, err) @@ -371,22 +389,35 @@ } return nil } + if typ == resourcev3.SecretType { // ADDED: SecretType Delete Actual + if err := sm.DB.RemoveSecret(context.TODO(), name); err != nil { + return fmt.Errorf("failed to delete secret %s from DB: %w", name, err) + } + return nil + } return fmt.Errorf("actual delete not supported for resource type: %s", typ) } + // For DeleteLogical or DeleteNone, we rely on the generic update flow. + if !resourceFound { return fmt.Errorf("resource %s of type %s not found in cache", name, typ) } - newSnap, _ := cachev3.NewSnapshot( + // Rebuild and set new snapshot with the resource removed + newSnap, err := cachev3.NewSnapshot( "snap-remove-generic-"+name, resources, ) + if err != nil { + return fmt.Errorf("failed to create snapshot after removal: %w", err) + } if err := sm.Cache.SetSnapshot(context.TODO(), sm.NodeID, newSnap); err != nil { return fmt.Errorf("failed to set snapshot: %w", err) } + // Flush the updated (removed) snapshot to DB. This handles the 'DeleteLogical' strategy. if err := sm.FlushCacheToDB(context.TODO(), strategy); err != nil { return fmt.Errorf("failed to flush cache to DB: %w", err) } @@ -414,8 +445,8 @@ resources := map[resourcev3.Type][]types.Resource{ resourcev3.ClusterType: mapToSlice(snap.GetResources(string(resourcev3.ClusterType))), resourcev3.ListenerType: mapToSlice(snap.GetResources(string(resourcev3.ListenerType))), + resourcev3.SecretType: mapToSlice(snap.GetResources(string(resourcev3.SecretType))), // ADDED: SecretType // resourcev3.EndpointType: mapToSlice(snap.GetResources(string(resourcev3.EndpointType))), - // resourcev3.SecretType: mapToSlice(snap.GetResources(string(resourcev3.SecretType))), // resourcev3.RuntimeType: mapToSlice(snap.GetResources(string(resourcev3.RuntimeType))), // Include other types as needed } diff --git a/internal/pkg/snapshot/resource_io.go b/internal/pkg/snapshot/resource_io.go index ea381cd..3446083 100644 --- a/internal/pkg/snapshot/resource_io.go +++ b/internal/pkg/snapshot/resource_io.go @@ -10,6 +10,7 @@ listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" _ "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/tcp_proxy/v3" + secretv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" // ADDED: SDS Secret Import "github.com/envoyproxy/go-control-plane/pkg/cache/types" "github.com/envoyproxy/go-control-plane/pkg/cache/v3" @@ -111,14 +112,15 @@ case resourcev3.ListenerType: resource = &listenerv3.Listener{} newResource = true - // ... other types ... + case resourcev3.SecretType: // ADDED: SecretType + resource = &secretv3.Secret{} + newResource = true default: log.Warnf("unsupported resource type: %s", typ) // Skip nested or unsupported types } if newResource { - // NOTE: unmarshalYamlNodeToProto must be available in this scope if err := unmarshalYamlNodeToProto(v, resource); err != nil { return fmt.Errorf("failed to unmarshal %s from file: %w", typ, err) } @@ -170,6 +172,11 @@ for _, r := range listenerTypeResources { out[resourcev3.ListenerType] = append(out[resourcev3.ListenerType], r) } + // ADDED: SecretType + secretTypeResources := snap.GetResources(resourcev3.SecretType) + for _, r := range secretTypeResources { + out[resourcev3.SecretType] = append(out[resourcev3.SecretType], r) + } data, err := json.MarshalIndent(out, "", " ") if err != nil { @@ -210,7 +217,7 @@ } // LoadResourceFromYAML unmarshals a YAML string representing a single xDS resource -// (like a Cluster or a Listener) into the appropriate Protobuf message. +// (like a Cluster, Listener, or Secret) into the appropriate Protobuf message. // // It leverages the existing WalkAndProcess logic to find and unmarshal the resource. func LoadResourceFromYAML(ctx context.Context, yamlStr string, expectedType resourcev3.Type) ([]types.Resource, error) { diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index 4d44d4e..13a71fc 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -9,8 +9,7 @@ clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" - - // routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" // REMOVED + secretv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" // SDS Import "google.golang.org/protobuf/encoding/protojson" ) @@ -27,7 +26,7 @@ const ( // DeleteNone performs only UPSERT for items in the list (default behavior) DeleteNone DeleteStrategy = iota - // DeleteLogical marks missing resources as disabled (now applicable to clusters and listeners) + // DeleteLogical marks missing resources as disabled (applicable to clusters, listeners, and secrets) DeleteLogical // DeleteActual removes missing resources physically from the database DeleteActual @@ -59,13 +58,20 @@ enabled BOOLEAN DEFAULT true, updated_at TIMESTAMP DEFAULT now() ); - -- REMOVED routes table CREATE TABLE IF NOT EXISTS listeners ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, data JSONB NOT NULL, enabled BOOLEAN DEFAULT true, updated_at TIMESTAMP DEFAULT now() + ); + -- SDS secrets table for Postgres + CREATE TABLE IF NOT EXISTS secrets ( + id SERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + data JSONB NOT NULL, + enabled BOOLEAN DEFAULT true, + updated_at TIMESTAMP DEFAULT now() );` default: // SQLite schema = ` @@ -76,19 +82,30 @@ enabled BOOLEAN DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); - -- REMOVED routes table CREATE TABLE IF NOT EXISTS listeners ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, data TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + -- SDS secrets table for SQLite + CREATE TABLE IF NOT EXISTS secrets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + data TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );` } _, err := s.db.ExecContext(ctx, schema) return err } +// ----------------------------------------------------------------------------- +// SAVE METHODS (UPSERT) +// ----------------------------------------------------------------------------- + // SaveCluster inserts or updates a cluster func (s *Storage) SaveCluster(ctx context.Context, cluster *clusterv3.Cluster) error { data, err := protojson.Marshal(cluster) @@ -117,11 +134,6 @@ return err } -// SaveRoute inserts or updates a route // REMOVED -// func (s *Storage) SaveRoute(ctx context.Context, route *routev3.RouteConfiguration) error { -// // ... (route logic removed) -// } - // SaveListener inserts or updates a listener func (s *Storage) SaveListener(ctx context.Context, listener *listenerv3.Listener) error { data, err := protojson.Marshal(listener) @@ -150,6 +162,38 @@ return err } +// SaveSecret inserts or updates a Secret +func (s *Storage) SaveSecret(ctx context.Context, secret *secretv3.Secret) error { + data, err := protojson.Marshal(secret) + if err != nil { + return err + } + + var query string + switch s.driver { + case "postgres": + // Explicitly set enabled=true on update to re-enable a logically deleted secret + query = fmt.Sprintf(` + INSERT INTO secrets (name, data, enabled, updated_at) + VALUES (%s, %s, true, now()) + ON CONFLICT (name) DO UPDATE SET data = %s, enabled = true, updated_at = now()`, + s.placeholder(1), s.placeholder(2), s.placeholder(2)) + default: // SQLite + // Explicitly set enabled=1 on update to re-enable a logically deleted secret + query = ` + INSERT INTO secrets (name, data, enabled, updated_at) + VALUES (?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET data=excluded.data, enabled=1, updated_at=CURRENT_TIMESTAMP` + } + + _, err = s.db.ExecContext(ctx, query, secret.GetName(), string(data)) + return err +} + +// ----------------------------------------------------------------------------- +// LOAD ENABLED METHODS +// ----------------------------------------------------------------------------- + // LoadEnabledClusters retrieves all enabled clusters func (s *Storage) LoadEnabledClusters(ctx context.Context) ([]*clusterv3.Cluster, error) { query := `SELECT data FROM clusters` @@ -168,13 +212,12 @@ var clusters []*clusterv3.Cluster for rows.Next() { var raw json.RawMessage - // FIX: Handle type difference between Postgres (JSONB) and SQLite (TEXT) if s.driver != "postgres" { var dataStr string if err := rows.Scan(&dataStr); err != nil { return nil, err } - raw = json.RawMessage(dataStr) // Convert string to json.RawMessage + raw = json.RawMessage(dataStr) } else { if err := rows.Scan(&raw); err != nil { return nil, err @@ -190,49 +233,6 @@ return clusters, nil } -// LoadAllClusters retrieves all clusters, regardless of their enabled status -func (s *Storage) LoadAllClusters(ctx context.Context) ([]*clusterv3.Cluster, error) { - rows, err := s.db.QueryContext(ctx, `SELECT data FROM clusters`) - if err != nil { - return nil, err - } - defer rows.Close() - - var clusters []*clusterv3.Cluster - for rows.Next() { - var raw json.RawMessage - // FIX: Handle type difference between Postgres (JSONB) and SQLite (TEXT) - if s.driver != "postgres" { - var dataStr string - if err := rows.Scan(&dataStr); err != nil { - return nil, err - } - raw = json.RawMessage(dataStr) // Convert string to json.RawMessage - } else { - if err := rows.Scan(&raw); err != nil { - return nil, err - } - } - - var cluster clusterv3.Cluster - if err := protojson.Unmarshal(raw, &cluster); err != nil { - return nil, err - } - clusters = append(clusters, &cluster) - } - return clusters, nil -} - -// LoadEnabledRoutes retrieves all enabled routes // REMOVED -// func (s *Storage) LoadEnabledRoutes(ctx context.Context) ([]*routev3.RouteConfiguration, error) { -// // ... (route logic removed) -// } - -// LoadAllRoutes retrieves all routes, regardless of their enabled status // REMOVED -// func (s *Storage) LoadAllRoutes(ctx context.Context) ([]*routev3.RouteConfiguration, error) { -// // ... (route logic removed) -// } - // LoadEnabledListeners retrieves all enabled listeners func (s *Storage) LoadEnabledListeners(ctx context.Context) ([]*listenerv3.Listener, error) { query := `SELECT data FROM listeners` @@ -251,13 +251,12 @@ var listeners []*listenerv3.Listener for rows.Next() { var raw json.RawMessage - // FIX: Handle type difference between Postgres (JSONB) and SQLite (TEXT) if s.driver != "postgres" { var dataStr string if err := rows.Scan(&dataStr); err != nil { return nil, err } - raw = json.RawMessage(dataStr) // Convert string to json.RawMessage + raw = json.RawMessage(dataStr) } else { if err := rows.Scan(&raw); err != nil { return nil, err @@ -273,6 +272,81 @@ return listeners, nil } +// LoadEnabledSecrets retrieves all enabled secrets +func (s *Storage) LoadEnabledSecrets(ctx context.Context) ([]*secretv3.Secret, error) { + query := `SELECT data FROM secrets` + if s.driver == "postgres" { + query += ` WHERE enabled = true` + } else { + query += ` WHERE enabled = 1` + } + + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var secrets []*secretv3.Secret + for rows.Next() { + var raw json.RawMessage + if s.driver != "postgres" { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + raw = json.RawMessage(dataStr) + } else { + if err := rows.Scan(&raw); err != nil { + return nil, err + } + } + + var secret secretv3.Secret + if err := protojson.Unmarshal(raw, &secret); err != nil { + return nil, err + } + secrets = append(secrets, &secret) + } + return secrets, nil +} + +// ----------------------------------------------------------------------------- +// LOAD ALL METHODS +// ----------------------------------------------------------------------------- + +// LoadAllClusters retrieves all clusters, regardless of their enabled status +func (s *Storage) LoadAllClusters(ctx context.Context) ([]*clusterv3.Cluster, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM clusters`) + if err != nil { + return nil, err + } + defer rows.Close() + + var clusters []*clusterv3.Cluster + for rows.Next() { + var raw json.RawMessage + if s.driver != "postgres" { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + raw = json.RawMessage(dataStr) + } else { + if err := rows.Scan(&raw); err != nil { + return nil, err + } + } + + var cluster clusterv3.Cluster + if err := protojson.Unmarshal(raw, &cluster); err != nil { + return nil, err + } + clusters = append(clusters, &cluster) + } + return clusters, nil +} + // LoadAllListeners retrieves all listeners, regardless of their enabled status func (s *Storage) LoadAllListeners(ctx context.Context) ([]*listenerv3.Listener, error) { rows, err := s.db.QueryContext(ctx, `SELECT data FROM listeners`) @@ -284,13 +358,12 @@ var listeners []*listenerv3.Listener for rows.Next() { var raw json.RawMessage - // FIX: Handle type difference between Postgres (JSONB) and SQLite (TEXT) if s.driver != "postgres" { var dataStr string if err := rows.Scan(&dataStr); err != nil { return nil, err } - raw = json.RawMessage(dataStr) // Convert string to json.RawMessage + raw = json.RawMessage(dataStr) } else { if err := rows.Scan(&raw); err != nil { return nil, err @@ -306,6 +379,55 @@ return listeners, nil } +// LoadAllSecrets retrieves all secrets, regardless of their enabled status +func (s *Storage) LoadAllSecrets(ctx context.Context) ([]*secretv3.Secret, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM secrets`) + if err != nil { + return nil, err + } + defer rows.Close() + + var secrets []*secretv3.Secret + for rows.Next() { + var raw json.RawMessage + if s.driver != "postgres" { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + raw = json.RawMessage(dataStr) + } else { + if err := rows.Scan(&raw); err != nil { + return nil, err + } + } + + var secret secretv3.Secret + if err := protojson.Unmarshal(raw, &secret); err != nil { + return nil, err + } + secrets = append(secrets, &secret) + } + return secrets, nil +} + +// ----------------------------------------------------------------------------- +// SNAPSHOT MANAGEMENT +// ----------------------------------------------------------------------------- + +// SnapshotConfig aggregates xDS resources +type SnapshotConfig struct { + // Enabled resources (for xDS serving) + EnabledClusters []*clusterv3.Cluster + EnabledListeners []*listenerv3.Listener + EnabledSecrets []*secretv3.Secret // New SDS resource + + // Disabled resources (for UI display) + DisabledClusters []*clusterv3.Cluster + DisabledListeners []*listenerv3.Listener + DisabledSecrets []*secretv3.Secret // New SDS resource +} + // RebuildSnapshot rebuilds full snapshot from DB func (s *Storage) RebuildSnapshot(ctx context.Context) (*SnapshotConfig, error) { // 1. Load Enabled Resources (for xDS serving) @@ -313,28 +435,28 @@ if err != nil { return nil, err } - // enabledRoutes, err := s.LoadEnabledRoutes(ctx) // REMOVED - // if err != nil { - // return nil, err - // } enabledListeners, err := s.LoadEnabledListeners(ctx) if err != nil { return nil, err } + enabledSecrets, err := s.LoadEnabledSecrets(ctx) + if err != nil { + return nil, err + } // 2. Load ALL Resources (for comparison and disabled set) allClusters, err := s.LoadAllClusters(ctx) if err != nil { return nil, err } - // allRoutes, err := s.LoadAllRoutes(ctx) // REMOVED - // if err != nil { - // return nil, err - // } allListeners, err := s.LoadAllListeners(ctx) if err != nil { return nil, err } + allSecrets, err := s.LoadAllSecrets(ctx) + if err != nil { + return nil, err + } // 3. Separate Disabled Resources @@ -350,15 +472,6 @@ } } - // Routes // REMOVED - // enabledRouteNames := make(map[string]struct{}, 0) - // var disabledRoutes []*routev3.RouteConfiguration - // for _, r := range allRoutes { - // if _, found := enabledRouteNames[r.GetName()]; !found { - // disabledRoutes = append(disabledRoutes, r) - // } - // } - // Listeners enabledListenerNames := make(map[string]struct{}, len(enabledListeners)) for _, l := range enabledListeners { @@ -371,29 +484,112 @@ } } + // Secrets + enabledSecretNames := make(map[string]struct{}, len(enabledSecrets)) + for _, sec := range enabledSecrets { + enabledSecretNames[sec.GetName()] = struct{}{} + } + var disabledSecrets []*secretv3.Secret + for _, sec := range allSecrets { + if _, found := enabledSecretNames[sec.GetName()]; !found { + disabledSecrets = append(disabledSecrets, sec) + } + } + return &SnapshotConfig{ - EnabledClusters: enabledClusters, - // EnabledRoutes: nil, // REMOVED - EnabledListeners: enabledListeners, - DisabledClusters: disabledClusters, - // DisabledRoutes: nil, // REMOVED + EnabledClusters: enabledClusters, + EnabledListeners: enabledListeners, + EnabledSecrets: enabledSecrets, + DisabledClusters: disabledClusters, DisabledListeners: disabledListeners, + DisabledSecrets: disabledSecrets, }, nil } -// SnapshotConfig aggregates xDS resources -type SnapshotConfig struct { - // Enabled resources (for xDS serving) - EnabledClusters []*clusterv3.Cluster - // EnabledRoutes []*routev3.RouteConfiguration // REMOVED - EnabledListeners []*listenerv3.Listener +// SaveSnapshot saves the entire snapshot to the DB +func (s *Storage) SaveSnapshot(ctx context.Context, cfg *SnapshotConfig, strategy DeleteStrategy) error { + if cfg == nil { + return fmt.Errorf("SnapshotConfig is nil") + } - // Disabled resources (for UI display) - DisabledClusters []*clusterv3.Cluster - // DisabledRoutes []*routev3.RouteConfiguration // REMOVED - DisabledListeners []*listenerv3.Listener + // Use a transaction for atomicity + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + tx.Rollback() + return + } + err = tx.Commit() + }() + + // --- 1. Save/Upsert Clusters and Collect Names --- + clusterNames := make([]string, 0, len(cfg.EnabledClusters)) + for _, c := range cfg.EnabledClusters { + if err = s.SaveCluster(ctx, c); err != nil { + return fmt.Errorf("failed to save cluster %s: %w", c.GetName(), err) + } + clusterNames = append(clusterNames, c.GetName()) + } + + // --- 2. Save/Upsert Listeners and Collect Names --- + listenerNames := make([]string, 0, len(cfg.EnabledListeners)) + for _, l := range cfg.EnabledListeners { + if err = s.SaveListener(ctx, l); err != nil { + return fmt.Errorf("failed to save listener %s: %w", l.GetName(), err) + } + listenerNames = append(listenerNames, l.GetName()) + } + + // --- 3. Save/Upsert Secrets and Collect Names --- + secretNames := make([]string, 0, len(cfg.EnabledSecrets)) + for _, sec := range cfg.EnabledSecrets { + if err = s.SaveSecret(ctx, sec); err != nil { + return fmt.Errorf("failed to save secret %s: %w", sec.GetName(), err) + } + secretNames = append(secretNames, sec.GetName()) + } + + // --- 4. Apply Deletion Strategy --- + switch strategy { + case DeleteLogical: + // Logical Delete (Disable) for all resource types + if err = s.disableMissingResources(ctx, "clusters", clusterNames); err != nil { + return fmt.Errorf("failed to logically delete missing clusters: %w", err) + } + if err = s.disableMissingResources(ctx, "listeners", listenerNames); err != nil { + return fmt.Errorf("failed to logically delete missing listeners: %w", err) + } + if err = s.disableMissingResources(ctx, "secrets", secretNames); err != nil { + return fmt.Errorf("failed to logically delete missing secrets: %w", err) + } + + case DeleteActual: + // Actual Delete (Physical Removal) for all resources + if err = s.deleteMissingResources(ctx, "clusters", clusterNames); err != nil { + return fmt.Errorf("failed to physically delete missing clusters: %w", err) + } + if err = s.deleteMissingResources(ctx, "listeners", listenerNames); err != nil { + return fmt.Errorf("failed to physically delete missing listeners: %w", err) + } + if err = s.deleteMissingResources(ctx, "secrets", secretNames); err != nil { + return fmt.Errorf("failed to physically delete missing secrets: %w", err) + } + + case DeleteNone: + // Do nothing for missing resources + return nil + } + + return err } +// ----------------------------------------------------------------------------- +// ENABLE/DISABLE & DELETE METHODS +// ----------------------------------------------------------------------------- + // EnableCluster toggles a cluster func (s *Storage) EnableCluster(ctx context.Context, name string, enabled bool) error { query := `UPDATE clusters SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?` @@ -404,11 +600,6 @@ return err } -// EnableRoute toggles a route // REMOVED -// func (s *Storage) EnableRoute(ctx context.Context, name string, enabled bool) error { -// // ... (route logic removed) -// } - // EnableListener toggles a listener func (s *Storage) EnableListener(ctx context.Context, name string, enabled bool) error { query := `UPDATE listeners SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?` @@ -419,11 +610,51 @@ return err } +// EnableSecret toggles a secret +func (s *Storage) EnableSecret(ctx context.Context, name string, enabled bool) error { + query := `UPDATE secrets SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?` + if s.driver == "postgres" { + query = `UPDATE secrets SET enabled = $1, updated_at = now() WHERE name = $2` + } + _, err := s.db.ExecContext(ctx, query, enabled, name) + return err +} + +// RemoveListener deletes a listener by name +func (s *Storage) RemoveListener(ctx context.Context, name string) error { + query := `DELETE FROM listeners WHERE name = ?` + if s.driver == "postgres" { + query = `DELETE FROM listeners WHERE name = $1` + } + _, err := s.db.ExecContext(ctx, query, name) + return err +} + +// RemoveCluster deletes a cluster by name +func (s *Storage) RemoveCluster(ctx context.Context, name string) error { + query := `DELETE FROM clusters WHERE name = ?` + if s.driver == "postgres" { + query = `DELETE FROM clusters WHERE name = $1` + } + _, err := s.db.ExecContext(ctx, query, name) + return err +} + +// RemoveSecret deletes a secret by name +func (s *Storage) RemoveSecret(ctx context.Context, name string) error { + query := `DELETE FROM secrets WHERE name = ?` + if s.driver == "postgres" { + query = `DELETE FROM secrets WHERE name = $1` + } + _, err := s.db.ExecContext(ctx, query, name) + return err +} + // disableMissingResources updates the 'enabled' status for resources in 'table' // whose 'name' is NOT in 'inputNames'. func (s *Storage) disableMissingResources(ctx context.Context, table string, inputNames []string) error { - if table != "clusters" && table != "listeners" { // CHECK UPDATED - return fmt.Errorf("logical delete (disable) is only supported for tables with an 'enabled' column (clusters, listeners)") + if table != "clusters" && table != "listeners" && table != "secrets" { + return fmt.Errorf("logical delete (disable) is only supported for tables with an 'enabled' column (clusters, listeners, secrets)") } // 1. Build placeholders and args @@ -469,8 +700,8 @@ // deleteMissingResources physically deletes resources from 'table' whose 'name' is NOT in 'inputNames'. func (s *Storage) deleteMissingResources(ctx context.Context, table string, inputNames []string) error { - if table != "clusters" && table != "listeners" { // CHECK UPDATED - return fmt.Errorf("physical delete is only supported for tables: clusters, listeners") + if table != "clusters" && table != "listeners" && table != "secrets" { + return fmt.Errorf("physical delete is only supported for tables: clusters, listeners, secrets") } // 1. Build placeholders and args @@ -500,105 +731,3 @@ _, err := s.db.ExecContext(ctx, query, args...) return err } - -func (s *Storage) SaveSnapshot(ctx context.Context, cfg *SnapshotConfig, strategy DeleteStrategy) error { - if cfg == nil { - return fmt.Errorf("SnapshotConfig is nil") - } - - // Use a transaction for atomicity - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer func() { - if err != nil { - tx.Rollback() - return - } - err = tx.Commit() - }() - - // Note: Only Enabledxxx resources are UPSERTED. Disabledxxx resources are - // left alone unless the deletion strategy removes them. - - // --- 1. Save/Upsert Clusters and Collect Names --- - clusterNames := make([]string, 0, len(cfg.EnabledClusters)) - for _, c := range cfg.EnabledClusters { - if err = s.SaveCluster(ctx, c); err != nil { - return fmt.Errorf("failed to save cluster %s: %w", c.GetName(), err) - } - clusterNames = append(clusterNames, c.GetName()) - } - - // --- 2. Save/Upsert Routes and Collect Names --- // REMOVED - // routeNames := make([]string, 0, len(cfg.EnabledRoutes)) - // for _, r := range cfg.EnabledRoutes { - // if err = s.SaveRoute(ctx, r); err != nil { - // return fmt.Errorf("failed to save route %s: %w", r.GetName(), err) - // } - // routeNames = append(routeNames, r.GetName()) - // } - - // --- 3. Save/Upsert Listeners and Collect Names --- - listenerNames := make([]string, 0, len(cfg.EnabledListeners)) - for _, l := range cfg.EnabledListeners { - if err = s.SaveListener(ctx, l); err != nil { - return fmt.Errorf("failed to save listener %s: %w", l.GetName(), err) - } - listenerNames = append(listenerNames, l.GetName()) - } - - // --- 4. Apply Deletion Strategy --- - switch strategy { - case DeleteLogical: - // Logical Delete (Disable) for all resource types: marks resources NOT in the current enabled list as disabled - if err = s.disableMissingResources(ctx, "clusters", clusterNames); err != nil { - return fmt.Errorf("failed to logically delete missing clusters: %w", err) - } - // if err = s.disableMissingResources(ctx, "routes", routeNames); err != nil { // REMOVED - // return fmt.Errorf("failed to logically delete missing routes: %w", err) - // } - if err = s.disableMissingResources(ctx, "listeners", listenerNames); err != nil { - return fmt.Errorf("failed to logically delete missing listeners: %w", err) - } - - case DeleteActual: - // Actual Delete (Physical Removal) for all resources: removes resources NOT in the current enabled list - if err = s.deleteMissingResources(ctx, "clusters", clusterNames); err != nil { - return fmt.Errorf("failed to physically delete missing clusters: %w", err) - } - // if err = s.deleteMissingResources(ctx, "routes", routeNames); err != nil { // REMOVED - // return fmt.Errorf("failed to physically delete missing routes: %w", err) - // } - if err = s.deleteMissingResources(ctx, "listeners", listenerNames); err != nil { - return fmt.Errorf("failed to physically delete missing listeners: %w", err) - } - - case DeleteNone: - // Do nothing for missing resources - return nil - } - - return err -} - -// RemoveListener deletes a listener by name -func (s *Storage) RemoveListener(ctx context.Context, name string) error { - query := `DELETE FROM listeners WHERE name = ?` - if s.driver == "postgres" { - query = `DELETE FROM listeners WHERE name = $1` - } - _, err := s.db.ExecContext(ctx, query, name) - return err -} - -// RemoveCluster deletes a cluster by name -func (s *Storage) RemoveCluster(ctx context.Context, name string) error { - query := `DELETE FROM clusters WHERE name = ?` - if s.driver == "postgres" { - query = `DELETE FROM clusters WHERE name = $1` - } - _, err := s.db.ExecContext(ctx, query, name) - return err -} diff --git a/static/clusters.js b/static/clusters.js index 3efc63b..c34d516 100644 --- a/static/clusters.js +++ b/static/clusters.js @@ -1,5 +1,5 @@ // clusters.js -import { API_BASE_URL, configStore, cleanupConfigStore, showClusterConfigModal } from './global.js'; +import { API_BASE_URL, configStore, cleanupConfigStore } from './global.js'; // ========================================================================= // CLUSTER UTILITIES diff --git a/static/consistency.js b/static/consistency.js index cc6c53a..d65aa5b 100644 --- a/static/consistency.js +++ b/static/consistency.js @@ -8,7 +8,7 @@ * Core function to resolve consistency by making a POST call to a sync endpoint. * @param {string} action - 'flush' (Cache -> DB) or 'rollback' (DB -> Cache). */ -async function resolveConsistency(action) { +export async function resolveConsistency(action) { let url = ''; let message = ''; diff --git a/static/data_fetchers.js b/static/data_fetchers.js index 18accfe..6a2d3ec 100644 --- a/static/data_fetchers.js +++ b/static/data_fetchers.js @@ -145,58 +145,58 @@ } -export async function showClusterConfigModal(clusterName) { - const config = configStore.clusters[clusterName]; - if (!config) { - showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); - return; - } +// export async function showClusterConfigModal(clusterName) { +// const config = configStore.clusters[clusterName]; +// if (!config) { +// showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); +// return; +// } - let yamlData = configStore.clusters[clusterName]?.yaml || 'Loading YAML...'; +// let yamlData = configStore.clusters[clusterName]?.yaml || 'Loading YAML...'; - if (yamlData === 'Loading YAML...') { - try { - const response = await fetch(`${API_BASE_URL}/get-cluster?name=${clusterName}&format=yaml`); - if (!response.ok) { - yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; - } else { - yamlData = await response.text(); - configStore.clusters[clusterName].yaml = yamlData; // Store YAML - } - } catch (error) { - console.error("Failed to fetch YAML cluster config:", error); - yamlData = `Network Error fetching YAML: ${error.message}`; - } - } +// if (yamlData === 'Loading YAML...') { +// try { +// const response = await fetch(`${API_BASE_URL}/get-cluster?name=${clusterName}&format=yaml`); +// if (!response.ok) { +// yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; +// } else { +// yamlData = await response.text(); +// configStore.clusters[clusterName].yaml = yamlData; // Store YAML +// } +// } catch (error) { +// console.error("Failed to fetch YAML cluster config:", error); +// yamlData = `Network Error fetching YAML: ${error.message}`; +// } +// } - showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); -} +// showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); +// } -export async function showListenerConfigModal(listenerName) { - const config = configStore.listeners[listenerName]; - if (!config) { - showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); - return; - } +// export async function showListenerConfigModal(listenerName) { +// const config = configStore.listeners[listenerName]; +// if (!config) { +// showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); +// return; +// } - let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; - if (yamlData === 'Loading YAML...') { - try { - const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); - if (!response.ok) { - yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; - } else { - yamlData = await response.text(); - configStore.listeners[listenerName].yaml = yamlData; // Store YAML - } - } catch (error) { - console.error("Failed to fetch YAML listener config:", error); - yamlData = `Network Error fetching YAML: ${error.message}`; - } - } +// let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; +// if (yamlData === 'Loading YAML...') { +// try { +// const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); +// if (!response.ok) { +// yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; +// } else { +// yamlData = await response.text(); +// configStore.listeners[listenerName].yaml = yamlData; // Store YAML +// } +// } catch (error) { +// console.error("Failed to fetch YAML listener config:", error); +// yamlData = `Network Error fetching YAML: ${error.message}`; +// } +// } - showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); -} +// showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); +// } // ========================================================================= // FILTER CHAIN ADDITION LOGIC (NEW) diff --git a/static/data_loader.js b/static/data_loader.js index ac840fe..49d9270 100644 --- a/static/data_loader.js +++ b/static/data_loader.js @@ -1,6 +1,7 @@ // data_loader.js import { listClusters } from './clusters.js'; import { listListeners } from './listeners.js'; +import { listSecrets } from './secrets.js'; import { setupModalTabs } from './modals.js'; import {CONSISTENCY_POLL_INTERVAL, checkConsistency} from './global.js'; @@ -15,6 +16,7 @@ export function loadAllData() { listClusters(); listListeners(); + listSecrets(); } diff --git a/static/global.js b/static/global.js index 7a146ce..9154019 100644 --- a/static/global.js +++ b/static/global.js @@ -11,7 +11,8 @@ // It stores JSON configs and caches the raw YAML string under the 'yaml' key. export const configStore = { clusters: {}, - listeners: {} + listeners: {}, + secrets: {} // listener objects will now have a 'filterChains' array to store domain configs }; @@ -45,8 +46,10 @@ document.getElementById('configModal').style.display = 'block'; } + export function hideModal() { document.getElementById('configModal').style.display = 'none'; + // document.getElementById('secretConfigModal').style.display = 'none'; } window.addEventListener('keydown', (event) => { @@ -62,6 +65,7 @@ // Close modal when clicking outside of the content (on the backdrop) window.addEventListener('click', (event) => { const modal = document.getElementById('configModal'); + const secretModal = document.getElementById('secretConfigModal'); const addFCModal = document.getElementById('addFilterChainModal'); const addListenerModal = document.getElementById('addListenerModal'); const addClusterModal = document.getElementById('addClusterModal'); @@ -69,6 +73,9 @@ if (event.target === modal) { hideModal(); } + if (event.target === secretModal) { + document.getElementById('secretConfigModal').style.display = 'none'; + } if (event.target === addFCModal) { window.hideAddFilterChainModal?.(); } @@ -89,22 +96,36 @@ // Activate the selected button and show the corresponding content const activeBtn = modalContent.querySelector(`.tab-button[data-tab="${tabName}"]`); - const activeContent = document.getElementById(`modal-${tabName}-content`); + + // Determine the content ID based on whether the modal is the regular or secret one + const isSecret = modalContent.closest('#secretConfigModal'); + const contentIdPrefix = isSecret ? 'secret-modal' : 'modal'; + const activeContent = document.getElementById(`${contentIdPrefix}-${tabName}-content`); if (activeBtn) activeBtn.classList.add('active'); if (activeContent) activeContent.style.display = 'block'; } export function setupModalTabs() { - const modalContent = document.getElementById('configModal')?.querySelector('.modal-content'); - if (!modalContent) return; - - modalContent.querySelectorAll('.tab-button').forEach(button => { - button.addEventListener('click', (event) => { - const tabName = event.target.getAttribute('data-tab'); - switchTab(modalContent, tabName); + const configModalContent = document.getElementById('configModal')?.querySelector('.modal-content'); + if (configModalContent) { + configModalContent.querySelectorAll('.tab-button').forEach(button => { + button.addEventListener('click', (event) => { + const tabName = event.target.getAttribute('data-tab'); + switchTab(configModalContent, tabName); + }); }); - }); + } + + const secretModalContent = document.getElementById('secretConfigModal')?.querySelector('.modal-content'); + if (secretModalContent) { + secretModalContent.querySelectorAll('.tab-button').forEach(button => { + button.addEventListener('click', (event) => { + const tabName = event.target.getAttribute('data-tab'); + switchTab(secretModalContent, tabName); + }); + }); + } } // // ========================================================================= @@ -174,62 +195,36 @@ /** * Handles showing the full configuration for a Cluster. (REMAINS HERE as a launcher) */ -export async function showClusterConfigModal(clusterName) { - const config = configStore.clusters[clusterName]; - if (!config) { - showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); - return; - } - let yamlData = configStore.clusters[clusterName]?.yaml || 'Loading YAML...'; +// /** +// * Handles showing the full configuration for a Listener. +// */ +// export async function showListenerConfigModal(listenerName) { +// const config = configStore.listeners[listenerName]; +// if (!config) { +// showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); +// return; +// } + +// let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; - if (yamlData === 'Loading YAML...') { - try { - const response = await fetch(`${API_BASE_URL}/get-cluster?name=${clusterName}&format=yaml`); - if (!response.ok) { - yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; - } else { - yamlData = await response.text(); - configStore.clusters[clusterName].yaml = yamlData; // Store YAML - } - } catch (error) { - console.error("Failed to fetch YAML cluster config:", error); - yamlData = `Network Error fetching YAML: ${error.message}`; - } - } +// if (yamlData === 'Loading YAML...') { +// try { +// const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); +// if (!response.ok) { +// yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; +// } else { +// yamlData = await response.text(); +// configStore.listeners[listenerName].yaml = yamlData; // Store YAML +// } +// } catch (error) { +// console.error("Failed to fetch YAML listener config:", error); +// yamlData = `Network Error fetching YAML: ${error.message}`; +// } +// } - showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); -} - -/** - * Handles showing the full configuration for a Listener. - */ -export async function showListenerConfigModal(listenerName) { - const config = configStore.listeners[listenerName]; - if (!config) { - showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); - return; - } - - let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; - - if (yamlData === 'Loading YAML...') { - try { - const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); - if (!response.ok) { - yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; - } else { - yamlData = await response.text(); - configStore.listeners[listenerName].yaml = yamlData; // Store YAML - } - } catch (error) { - console.error("Failed to fetch YAML listener config:", error); - yamlData = `Network Error fetching YAML: ${error.message}`; - } - } - - showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); -} +// showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); +// } // ========================================================================= @@ -361,6 +356,11 @@ configStore.listeners[name].yaml = 'Loading YAML...'; } } + for (const name in configStore.secrets) { + if (configStore.secrets.hasOwnProperty(name)) { + configStore.secrets[name].yaml = 'Loading YAML...'; + } + } } @@ -445,48 +445,48 @@ } } -/** - * Core function to resolve consistency by making a POST call to a sync endpoint. - */ -async function resolveConsistency(action) { - let url = (action === 'flush') ? `${API_BASE_URL}/flush-to-db` : `${API_BASE_URL}/load-from-db`; - let message = (action === 'flush') ? 'Flushing cache to DB...' : 'Rolling back cache from DB...'; +// /** +// * Core function to resolve consistency by making a POST call to a sync endpoint. +// */ +// async function resolveConsistency(action) { +// let url = (action === 'flush') ? `${API_BASE_URL}/flush-to-db` : `${API_BASE_URL}/load-from-db`; +// let message = (action === 'flush') ? 'Flushing cache to DB...' : 'Rolling back cache from DB...'; - if (!confirm(`Are you sure you want to perform the action: ${action.toUpperCase()}? This will overwrite the target configuration.`)) { - return; - } +// if (!confirm(`Are you sure you want to perform the action: ${action.toUpperCase()}? This will overwrite the target configuration.`)) { +// return; +// } - const modal = document.getElementById('consistencyModal'); - if (modal) modal.style.display = 'none'; +// const modal = document.getElementById('consistencyModal'); +// if (modal) modal.style.display = 'none'; - const button = document.getElementById('consistency-button'); - if (button) { - button.textContent = message; - button.classList.remove('consistent', 'inconsistent', 'error'); - button.classList.add('loading'); - button.disabled = true; - } +// const button = document.getElementById('consistency-button'); +// if (button) { +// button.textContent = message; +// button.classList.remove('consistent', 'inconsistent', 'error'); +// button.classList.add('loading'); +// button.disabled = true; +// } - try { - const response = await fetch(url, { method: 'POST' }); +// try { +// const response = await fetch(url, { method: 'POST' }); - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`HTTP Error ${response.status}: ${errorBody}`); - } +// if (!response.ok) { +// const errorBody = await response.text(); +// throw new Error(`HTTP Error ${response.status}: ${errorBody}`); +// } - alert(`Sync successful via ${action.toUpperCase()}. Reloading data.`); +// alert(`Sync successful via ${action.toUpperCase()}. Reloading data.`); - cleanupConfigStore(); +// cleanupConfigStore(); - window.loadAllData(); - checkConsistency(); - } catch (error) { - alert(`Failed to sync via ${action}. Check console for details.`); - console.error(`Sync operation (${action}) failed:`, error); - checkConsistency(); - } -} +// window.loadAllData(); +// checkConsistency(); +// } catch (error) { +// alert(`Failed to sync via ${action}. Check console for details.`); +// console.error(`Sync operation (${action}) failed:`, error); +// checkConsistency(); +// } +// } export function downloadYaml() { @@ -514,11 +514,11 @@ export function manualFlush() { - resolveConsistency('flush'); + windows.resolveConsistency('flush'); } export function manualRollback() { - resolveConsistency('rollback'); + windows.resolveConsistency('rollback'); } @@ -534,9 +534,6 @@ // These references ensure functions are callable from HTML even if imported window.showConfigModal = showConfigModal; window.hideModal = hideModal; -window.showClusterConfigModal = showClusterConfigModal; -window.showListenerConfigModal = showListenerConfigModal; - window.showConsistencyModal = showConsistencyModal; window.hideConsistencyModal = hideConsistencyModal; window.checkConsistency = checkConsistency; @@ -552,16 +549,39 @@ import { listClusters, disableCluster, enableCluster, removeCluster, showAddClusterModal, hideAddClusterModal, submitNewCluster } from './clusters.js'; import { loadAllData } from './data_loader.js'; import {showDomainConfig} from '/data_fetchers.js' +import {resolveConsistency} from './consistency.js' +import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret} from './secrets.js'; +import { showListenerConfigModal ,showClusterConfigModal,showSecretConfigModal} from './modals.js'; +import { listListeners, removeFilterChainByRef, disableListener, enableListener, removeListener, showAddListenerModal, hideAddListenerModal, submitNewListener } from './listeners.js'; window.listClusters = listClusters; +window.listSecrets = listSecrets; window.disableCluster = disableCluster; window.enableCluster = enableCluster; window.removeCluster = removeCluster; window.showAddClusterModal = showAddClusterModal; +window.showAddSecretModal = showAddSecretModal; window.hideAddClusterModal = hideAddClusterModal; window.loadAllData = loadAllData; window.submitNewCluster = submitNewCluster; window.showDomainConfig = showDomainConfig; +window.disableSecret = disableSecret; +window.enableSecret = enableSecret; +window.resolveConsistency = resolveConsistency; +window.showClusterConfigModal = showClusterConfigModal; +window.showListenerConfigModal = showListenerConfigModal; +window.hideAddSecretModal = hideAddSecretModal; +window.showSecretConfigModal = showSecretConfigModal; // New: Attach to window +window.listListeners = listListeners; +window.removeFilterChainByRef = removeFilterChainByRef; +window.disableListener = disableListener; +window.enableListener = enableListener; +window.removeListener = removeListener; + +// NEW FUNCTIONS ATTACHED TO WINDOW +window.submitNewListener = submitNewListener; +window.showAddListenerModal = showAddListenerModal; +window.hideAddListenerModal = hideAddListenerModal; window.onload = () => { window.loadAllData(); diff --git a/static/index.html b/static/index.html index 9649159..ecfea80 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,7 @@
+
@@ -33,6 +34,8 @@
+
@@ -64,6 +67,24 @@ +

Existing Secrets (Click a row for full JSON/YAML details)

+ + + + + + + + + + + + + + +
Secret NameStatusSecret TypeAction
Loading secret + data...
+

Existing Listeners (Click a domain/filter for details)

@@ -262,11 +283,50 @@ + + + diff --git a/static/listeners.js b/static/listeners.js index dc14444..20082a9 100644 --- a/static/listeners.js +++ b/static/listeners.js @@ -2,9 +2,9 @@ import { API_BASE_URL, configStore, - showListenerConfigModal, // Re-import from global cleanupConfigStore, // Re-import from global } from './global.js'; +import { showListenerConfigModal } from './modals.js'; // We assume 'showModal' is defined elsewhere or is not needed as all modals are now handled directly // Or, if showModal is used, it should be a general modal function from global.js or a separate modals.js file. // For this consolidation, we'll assume showModal is a simple function defined here or in a helper file. @@ -389,24 +389,3 @@ document.getElementById('add-listener-yaml-input').value = ''; } } - - -// ========================================================================= -// ATTACH TO WINDOW -// Exported functions must be attached to 'window' if called from inline HTML attributes -// ========================================================================= - -window.listListeners = listListeners; -window.removeFilterChainByRef = removeFilterChainByRef; -window.disableListener = disableListener; -window.enableListener = enableListener; -window.removeListener = removeListener; - -// NEW FUNCTIONS ATTACHED TO WINDOW -window.submitNewListener = submitNewListener; -window.showAddListenerModal = showAddListenerModal; -window.hideAddListenerModal = hideAddListenerModal; - -// Re-attach core handlers from global.js just in case, ensuring listeners.js overrides listListeners -// window.showListenerConfigModal = showListenerConfigModal; // Already attached in global.js -// window.showDomainConfig = showDomainConfig; // Already attached in global.js \ No newline at end of file diff --git a/static/modals.js b/static/modals.js index a598bff..7e68c6b 100644 --- a/static/modals.js +++ b/static/modals.js @@ -175,19 +175,19 @@ // ADD CLUSTER MODAL HANDLERS // ========================================================================= -/** - * Shows the modal for adding a new cluster. - */ -export function showAddClusterModal() { - showModal('addClusterModal'); -} +// /** +// * Shows the modal for adding a new cluster. +// */ +// export function showAddClusterModal() { +// showModal('addClusterModal'); +// } -/** - * Hides the Add Cluster modal. - */ -function hideAddClusterModal() { - hideModal('addClusterModal'); -} +// /** +// * Hides the Add Cluster modal. +// */ +// function hideAddClusterModal() { +// hideModal('addClusterModal'); +// } // ========================================================================= @@ -291,4 +291,69 @@ // Use the renamed function showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); -} \ No newline at end of file +} + + +/** + * Displays a DEDICATED modal for showing secret or privileged configurations. + * It expects the modal to have the ID 'secretConfigModal' and content IDs + * 'secret-modal-title', 'secret-modal-json-content', 'secret-modal-yaml-content'. + * * @param {string} title - The modal title. + * @param {string} secretName - The name of the secret to fetch the YAML for. + * @param {object} jsonData - The configuration data object (used for JSON tab). + * @param {string} defaultTab - The tab to show by default ('json' or 'yaml'). + */ +export async function showSecretConfigModal(secretName) { + const config = configStore.secrets[secretName]; + if (!config) { + showConfigModal(`🚨 Error: Secret Not Found`, { name: secretName, error: 'Configuration data missing from memory.' }, 'Error: Secret not in memory.'); + return; + } + + let yamlData = configStore.secrets[secretName]?.yaml || 'Loading YAML...'; + + if (yamlData === 'Loading YAML...') { + try { + const response = await fetch(`${API_BASE_URL}/get-secret?name=${secretName}&format=yaml`); + if (!response.ok) { + yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; + } else { + yamlData = await response.text(); + configStore.secrets[secretName].yaml = yamlData; // Store YAML + } + } catch (error) { + console.error("Failed to fetch YAML listener config:", error); + yamlData = `Network Error fetching YAML: ${error.message}`; + } + } + + // Use the renamed function + showConfigModal(`Full Config for Secret: ${secretName}`, config, yamlData); +} + +export async function showClusterConfigModal(clusterName) { + const config = configStore.clusters[clusterName]; + if (!config) { + showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); + return; + } + + let yamlData = configStore.clusters[clusterName]?.yaml || 'Loading YAML...'; + + if (yamlData === 'Loading YAML...') { + try { + const response = await fetch(`${API_BASE_URL}/get-cluster?name=${clusterName}&format=yaml`); + if (!response.ok) { + yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; + } else { + yamlData = await response.text(); + configStore.clusters[clusterName].yaml = yamlData; // Store YAML + } + } catch (error) { + console.error("Failed to fetch YAML cluster config:", error); + yamlData = `Network Error fetching YAML: ${error.message}`; + } + } + + showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); +} diff --git a/static/secrets.js b/static/secrets.js new file mode 100644 index 0000000..de30ef2 --- /dev/null +++ b/static/secrets.js @@ -0,0 +1,224 @@ +// secrets.js +import { API_BASE_URL, configStore, cleanupConfigStore } from './global.js'; + +// ========================================================================= +// SECRET UTILITIES +// ========================================================================= + +/** + * Extracts a concise description of the secret type (e.g., TlsCertificate). + * @param {object} secret - The secret configuration object. + * @returns {string} A string describing the secret type. + */ +function getSecretTypeDetails(secret) { + try { + const secretType = secret.Type; + if (!secretType) return '(Unknown Type)'; + + // Find the key that is not 'name' or 'Type' itself + const typeKeys = Object.keys(secretType); + const typeName = typeKeys.find(key => key !== 'name'); + + if (typeName) { + // Convert 'TlsCertificate' to 'TLS Certificate' + return typeName.replace(/([A-Z])/g, ' $1').trim(); + } + return '(Generic)'; + } catch { + return '(Config Error)'; + } +} + +// ========================================================================= +// SECRET CORE LOGIC (listSecrets) +// ========================================================================= + +export async function listSecrets() { + const tableBody = document.getElementById('secret-table-body'); + if (!tableBody) { + console.error("Could not find element with ID 'secret-table-body'."); + return; + } + + tableBody.innerHTML = + ''; + + try { + // The API operations show /list-secrets returns enabled and disabled lists + const response = await fetch(`${API_BASE_URL}/list-secrets`); + if (!response.ok) throw new Error(response.statusText); + + const secretResponse = await response.json(); + + // Combine enabled and disabled secrets for display + const allSecrets = [ + ...(secretResponse.enabled || []).map(s => ({ ...s, status: 'Enabled', configData: s })), + ...(secretResponse.disabled || []).map(s => ({ ...s, status: 'Disabled', configData: s })) + ]; + + if (!allSecrets.length) { + tableBody.innerHTML = + ''; + configStore.secrets = {}; + return; + } + cleanupConfigStore(); + + // Store full configs in memory by name + configStore.secrets = allSecrets.reduce((acc, s) => { + const existingYaml = acc[s.name]?.yaml; + acc[s.name] = { ...s.configData, yaml: existingYaml }; + return acc; + }, configStore.secrets); + + + tableBody.innerHTML = ''; + allSecrets.forEach(secret => { + const row = tableBody.insertRow(); + if (secret.status === 'Disabled') row.classList.add('disabled-row'); + + let actionButtons = ''; + // NOTE: Assuming the API supports enable/disable for secrets like clusters + if (secret.status === 'Enabled') { + actionButtons = ``; + } else { + actionButtons = ` + + + `; + } + + // Secret Name Hyperlink (uses showSecretConfigModal, which must be imported from global.js or defined globally) + const secretNameCell = row.insertCell(); + secretNameCell.innerHTML = + `${secret.name}`; + + row.insertCell().textContent = secret.status; + row.insertCell().innerHTML = getSecretTypeDetails(secret); + row.insertCell().innerHTML = actionButtons; + }); + } catch (error) { + tableBody.innerHTML = ``; + console.error("Secret Fetch/Parse Error:", error); + } +} + + +// ========================================================================= +// SECRET ENABLE/DISABLE/REMOVE LOGIC (toggleSecretStatus) +// ========================================================================= + +async function toggleSecretStatus(secretName, action) { + // API endpoints are assumed to be /remove-secret, /enable-secret, /disable-secret + let url = (action === 'remove') ? `${API_BASE_URL}/remove-secret` : `${API_BASE_URL}/${action}-secret`; + const payload = { name: secretName }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HTTP Error ${response.status}: ${errorBody}`); + } + + console.log(`Secret '${secretName}' successfully ${action}d.`); + cleanupConfigStore(); + listSecrets(); + } catch (error) { + console.error(`Failed to ${action} secret '${secretName}':`, error); + alert(`Failed to ${action} secret '${secretName}'. Check console for details.`); + } +} + +// Expose these functions globally for inline HTML onclick handlers +export function disableSecret(secretName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to DISABLE secret: ${secretName}?`)) { + toggleSecretStatus(secretName, 'disable'); + } +} + +export function enableSecret(secretName, event) { + event.stopPropagation(); + if (confirm(`Are you sure you want to ENABLE secret: ${secretName}?`)) { + toggleSecretStatus(secretName, 'enable'); + } +} + +export function removeSecret(secretName, event) { + event.stopPropagation(); + if (confirm(`āš ļø WARNING: Are you absolutely sure you want to PERMANENTLY REMOVE secret: ${secretName}? This action cannot be undone.`)) { + toggleSecretStatus(secretName, 'remove'); + } +} + +// ========================================================================= +// ADD SECRET LOGIC (showAddSecretModal, hideAddSecretModal, submitNewSecret) +// ========================================================================= + +/** + * Shows the modal for adding a new secret. + */ +export function showAddSecretModal() { + document.getElementById('add-secret-yaml-input').value = ''; + document.getElementById('addSecretModal').style.display = 'block'; +} + +/** + * Hides the modal for adding a new secret. + */ +export function hideAddSecretModal() { + const modal = document.getElementById('addSecretModal'); + if (modal) { + modal.style.display = 'none'; + document.getElementById('add-secret-yaml-input').value = ''; + } +} + + +/** + * Submits the new secret YAML to the /add-secret endpoint. + */ +export async function submitNewSecret() { + const yamlInput = document.getElementById('add-secret-yaml-input'); + const secretYaml = yamlInput.value.trim(); + + if (!secretYaml) { + alert('Please paste the secret YAML configuration.'); + return; + } + + try { + // The /add-secret endpoint expects a JSON body with a 'YAML' key containing the stringified YAML. + const payload = { YAML: secretYaml }; + const url = `${API_BASE_URL}/add-secret`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HTTP Error ${response.status}: ${errorBody}`); + } + + console.log(`New secret successfully added.`); + alert('Secret successfully added! The dashboard will now refresh.'); + + yamlInput.value = ''; + hideAddSecretModal(); + + cleanupConfigStore(); + listSecrets(); + + } catch (error) { + console.error(`Failed to add new secret:`, error); + alert(`Failed to add new secret. Check console for details. Error: ${error.message}`); + } +} \ No newline at end of file diff --git a/static/style.css b/static/style.css index da30d0c..06f41ca 100644 --- a/static/style.css +++ b/static/style.css @@ -213,7 +213,8 @@ /* Primary Action Button Styles (Targeting the 'Add New' buttons via onclick attribute) */ .toolbar button[onclick*="showAddClusterModal"], -.toolbar button[onclick*="showAddListenerModal"] { +.toolbar button[onclick*="showAddListenerModal"], +.toolbar button[onclick*="showAddSecretModal"] { background-color: var(--primary-color); /* Solid background for primary actions */ color: white; border-color: var(--primary-color); @@ -223,7 +224,8 @@ } .toolbar button[onclick*="showAddClusterModal"]:hover, -.toolbar button[onclick*="showAddListenerModal"]:hover { +.toolbar button[onclick*="showAddListenerModal"]:hover , +.toolbar button[onclick*="showAddSecretModal"]:hover { background-color: #0b5ed7; border-color: #0b5ed7; } @@ -554,7 +556,7 @@ /* Styles for the YAML textarea input (Unified for all YAML inputs) */ #add-fc-yaml-input, -#add-listener-yaml-input, #add-cluster-yaml-input { /* Unified ID for consistency */ +#add-listener-yaml-input, #add-cluster-yaml-input , #add-secret-yaml-input { /* Unified ID for consistency */ width: 100%; font-family: monospace; font-size: 0.9rem; @@ -570,7 +572,7 @@ /* Styles for the label above the textarea (Unified for all form labels) */ label[for="add-fc-yaml-input"], -label[for="add-listener-yaml-input"], label[for="add-cluster-yaml-input"]{ /* Unified ID for consistency */ +label[for="add-listener-yaml-input"], label[for="add-cluster-yaml-input"], label[for="add-secret-yaml-input"]{ /* Unified ID for consistency */ display: block; font-weight: 600; margin-bottom: 5px; diff --git a/test/test_secret_operation.sh b/test/test_secret_operation.sh new file mode 100644 index 0000000..324c98d --- /dev/null +++ b/test/test_secret_operation.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Script to test the Secret Discovery Service (SDS) API endpoints +SERVER="localhost:8080" +SECRET_NAME="test_server_cert" +SECRET_YAML=$(cat <
Loading...
No secrets found.
🚨 Secret Error: ${error.message}