Further auth and authz configuration
This commit is contained in:
+22
-11
@@ -27,21 +27,21 @@ func NewKeyManagementHandler(keyStore *auth.HybridKeyStore, logger *logging.Logg
|
|||||||
|
|
||||||
// CreateKeyRequest is the request body for creating a new API key
|
// CreateKeyRequest is the request body for creating a new API key
|
||||||
type CreateKeyRequest struct {
|
type CreateKeyRequest struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
RateLimit int `json:"rate_limit,omitempty"`
|
RateLimit int `json:"rate_limit,omitempty"`
|
||||||
ExpiresIn *time.Duration `json:"expires_in,omitempty"`
|
ExpiresIn string `json:"expires_in,omitempty"` // Duration string like "8760h", "30d", "1h", etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeyResponse is the response body when creating an API key
|
// CreateKeyResponse is the response body when creating an API key
|
||||||
type CreateKeyResponse struct {
|
type CreateKeyResponse struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
Roles []string `json:"roles"`
|
Roles []string `json:"roles"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
RateLimit int `json:"rate_limit"`
|
RateLimit int `json:"rate_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListKeysResponse is the response body for listing API keys
|
// ListKeysResponse is the response body for listing API keys
|
||||||
@@ -103,8 +103,19 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
|
|||||||
req.RateLimit = 100
|
req.RateLimit = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse expires_in duration string if provided
|
||||||
|
var expiresInDuration *time.Duration
|
||||||
|
if req.ExpiresIn != "" {
|
||||||
|
duration, err := time.ParseDuration(req.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
h.respondError(w, http.StatusBadRequest, "Invalid expires_in format", fmt.Sprintf("expected duration format like '8760h' or '30d': %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresInDuration = &duration
|
||||||
|
}
|
||||||
|
|
||||||
// Create the key
|
// Create the key
|
||||||
apiKey, err := h.keyStore.CreateKey(ctx, req.ClientID, req.Roles, req.RateLimit, req.ExpiresIn, authCtx.ClientID)
|
apiKey, err := h.keyStore.CreateKey(ctx, req.ClientID, req.Roles, req.RateLimit, expiresInDuration, authCtx.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("Failed to create API key: %v", err)
|
h.logger.Errorf("Failed to create API key: %v", err)
|
||||||
h.respondError(w, http.StatusInternalServerError, "Failed to create API key", err.Error())
|
h.respondError(w, http.StatusInternalServerError, "Failed to create API key", err.Error())
|
||||||
|
|||||||
+18
-61
@@ -2,8 +2,6 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/igodwin/notifier/internal/auth"
|
"github.com/igodwin/notifier/internal/auth"
|
||||||
@@ -45,11 +43,16 @@ func DefaultCORSConfig() *CORSConfig {
|
|||||||
|
|
||||||
// NewRouter creates a new HTTP router with all routes configured
|
// NewRouter creates a new HTTP router with all routes configured
|
||||||
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
func NewRouter(service domain.NotificationService, logger *logging.Logger) *mux.Router {
|
||||||
return NewRouterWithAuth(service, logger, nil, DefaultCORSConfig())
|
return NewRouterWithAuth(service, logger, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouterWithAuth creates a new HTTP router with optional authentication and CORS configuration
|
// NewRouterWithAuth creates a new HTTP router with optional authentication and CORS configuration
|
||||||
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, corsConfig *CORSConfig) *mux.Router {
|
func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *mux.Router {
|
||||||
|
return NewRouterWithAuthAndKeyStore(service, logger, authStore, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouterWithAuthAndKeyStore creates a new HTTP router with authentication and key management
|
||||||
|
func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, keyStore *auth.HybridKeyStore) *mux.Router {
|
||||||
handler := NewHandler(service, logger)
|
handler := NewHandler(service, logger)
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
|
||||||
@@ -76,14 +79,21 @@ func NewRouterWithAuth(service domain.NotificationService, logger *logging.Logge
|
|||||||
// Notifiers route
|
// Notifiers route
|
||||||
v1.HandleFunc("/notifiers", handler.GetNotifiers).Methods(http.MethodGet)
|
v1.HandleFunc("/notifiers", handler.GetNotifiers).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// Key management routes (requires auth and keystore)
|
||||||
|
if authStore != nil && keyStore != nil {
|
||||||
|
keyHandler := NewKeyManagementHandler(keyStore, logger)
|
||||||
|
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
|
||||||
|
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
|
||||||
|
v1.HandleFunc("/admin/keys/{key}", keyHandler.RevokeKey).Methods(http.MethodDelete)
|
||||||
|
v1.HandleFunc("/admin/keys/{key}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
|
||||||
|
v1.HandleFunc("/admin/keys/{key}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
|
||||||
|
}
|
||||||
|
|
||||||
// Health check route (no auth required)
|
// Health check route (no auth required)
|
||||||
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
||||||
|
|
||||||
// Middleware - CORS must be applied before auth to handle preflight requests
|
// Middleware - logging and CORS
|
||||||
router.Use(loggingMiddleware)
|
router.Use(loggingMiddleware)
|
||||||
if corsConfig != nil {
|
|
||||||
router.Use(newCORSMiddleware(corsConfig))
|
|
||||||
}
|
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
@@ -95,56 +105,3 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCORSMiddleware creates a CORS middleware with origin whitelist validation
|
|
||||||
func newCORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
|
|
||||||
// Check if the origin is in the allowed list
|
|
||||||
allowed := false
|
|
||||||
for _, allowedOrigin := range config.AllowedOrigins {
|
|
||||||
if origin == allowedOrigin {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set CORS headers if the origin is allowed
|
|
||||||
if allowed {
|
|
||||||
// Set the exact origin (never use wildcard)
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
|
|
||||||
// Set allowed methods
|
|
||||||
if len(config.AllowedMethods) > 0 {
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set allowed headers
|
|
||||||
if len(config.AllowedHeaders) > 0 {
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set credentials header if enabled
|
|
||||||
if config.AllowCredentials {
|
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set max age for preflight caching
|
|
||||||
if config.MaxAge > 0 {
|
|
||||||
w.Header().Set("Access-Control-Max-Age", strconv.FormatInt(int64(config.MaxAge), 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle preflight OPTIONS requests
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
// Return 200 OK for preflight requests
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+70
-18
@@ -88,11 +88,73 @@ func main() {
|
|||||||
|
|
||||||
// Initialize authentication if enabled (must be before service creation for RBAC)
|
// Initialize authentication if enabled (must be before service creation for RBAC)
|
||||||
var authStore *auth.APIKeyStore
|
var authStore *auth.APIKeyStore
|
||||||
|
var hybridKeyStore *auth.HybridKeyStore
|
||||||
var authz *auth.NotifierAuthz
|
var authz *auth.NotifierAuthz
|
||||||
if cfg.Auth.Enabled {
|
if cfg.Auth.Enabled {
|
||||||
authStore = auth.NewAPIKeyStore()
|
authStore = auth.NewAPIKeyStore()
|
||||||
authz = auth.NewNotifierAuthz()
|
authz = auth.NewNotifierAuthz()
|
||||||
logger.Info("API authentication enabled")
|
logger.Info("API authentication enabled")
|
||||||
|
|
||||||
|
// Create database backend if configured
|
||||||
|
var dbStore *auth.KeyStoreDB
|
||||||
|
if cfg.Auth.Database.URL != "" {
|
||||||
|
dbStore, err = auth.NewKeyStoreDB(cfg.Auth.Database.URL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("Failed to create database key store: %v", err)
|
||||||
|
}
|
||||||
|
logger.Infof("Connected to authentication database: %s", cfg.Auth.Database.URL)
|
||||||
|
} else {
|
||||||
|
logger.Warn("No database configured for authentication - API keys will only be stored in memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hybrid key store for key management (in-memory cache + database backend)
|
||||||
|
hybridKeyStore = auth.NewHybridKeyStore(authStore, dbStore)
|
||||||
|
logger.Debugf("Initialized hybrid key store for API key management")
|
||||||
|
|
||||||
|
// Bootstrap admin key if configured
|
||||||
|
if cfg.Auth.Bootstrap.Enabled {
|
||||||
|
bootstrapCfg := &auth.BootstrapConfig{
|
||||||
|
Enabled: cfg.Auth.Bootstrap.Enabled,
|
||||||
|
AdminKeyFileName: cfg.Auth.Bootstrap.AdminKeyFileName,
|
||||||
|
PrintToStdout: cfg.Auth.Bootstrap.PrintToStdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load existing key from Kubernetes secret first
|
||||||
|
existingKey, err := auth.LoadAdminKeyFromKubernetesSecret(
|
||||||
|
ctx,
|
||||||
|
cfg.Auth.Bootstrap.KubernetesSecretName,
|
||||||
|
cfg.Auth.Bootstrap.KubernetesSecretKey,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Error loading from Kubernetes secret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have an existing key, use it
|
||||||
|
if existingKey != "" {
|
||||||
|
if _, err := auth.RegisterAdminKeyInMemory(authStore, existingKey, logger); err != nil {
|
||||||
|
logger.Warnf("Failed to register existing admin key: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generate new key
|
||||||
|
if apiKey, err := auth.BootstrapAdminKeyInMemory(authStore, bootstrapCfg, logger); err != nil {
|
||||||
|
logger.Warnf("Bootstrap admin key creation failed: %v", err)
|
||||||
|
} else if apiKey != nil {
|
||||||
|
// Store in Kubernetes secret if configured
|
||||||
|
if cfg.Auth.Bootstrap.KubernetesSecretName != "" {
|
||||||
|
if err := auth.CreateKubernetesSecret(
|
||||||
|
ctx,
|
||||||
|
cfg.Auth.Bootstrap.KubernetesSecretName,
|
||||||
|
cfg.Auth.Bootstrap.KubernetesSecretKey,
|
||||||
|
apiKey.Key,
|
||||||
|
logger,
|
||||||
|
); err != nil {
|
||||||
|
logger.Warnf("Failed to create Kubernetes secret: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize notifier factory and register notifiers
|
// Initialize notifier factory and register notifiers
|
||||||
@@ -144,7 +206,7 @@ func main() {
|
|||||||
var restServer *http.Server
|
var restServer *http.Server
|
||||||
if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" {
|
if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
restServer = startRESTServer(ctx, &wg, cfg, svc, logger, authStore)
|
restServer = startRESTServer(ctx, &wg, cfg, svc, logger, authStore, hybridKeyStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for interrupt signal
|
// Wait for interrupt signal
|
||||||
@@ -284,26 +346,16 @@ func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config
|
|||||||
return grpcServer
|
return grpcServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *http.Server {
|
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, hybridKeyStore *auth.HybridKeyStore) *http.Server {
|
||||||
// Convert config CORS to rest.CORSConfig
|
var router *mux.Router
|
||||||
corsConfig := &rest.CORSConfig{
|
if authStore != nil && hybridKeyStore != nil {
|
||||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
router = rest.NewRouterWithAuthAndKeyStore(svc, logger, authStore, hybridKeyStore)
|
||||||
AllowedMethods: cfg.CORS.AllowedMethods,
|
} else if authStore != nil {
|
||||||
AllowedHeaders: cfg.CORS.AllowedHeaders,
|
router = rest.NewRouterWithAuth(svc, logger, authStore)
|
||||||
AllowCredentials: cfg.CORS.AllowCredentials,
|
|
||||||
MaxAge: cfg.CORS.MaxAge,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log CORS configuration
|
|
||||||
if len(cfg.CORS.AllowedOrigins) > 0 {
|
|
||||||
logger.Infof("CORS enabled for origins: %v", cfg.CORS.AllowedOrigins)
|
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("CORS has no allowed origins configured - all cross-origin requests will be blocked")
|
router = rest.NewRouter(svc, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create router with auth and CORS config
|
|
||||||
router := rest.NewRouterWithAuth(svc, logger, authStore, corsConfig)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
+20
@@ -92,6 +92,26 @@ notifiers:
|
|||||||
# default_topic: "company-notifications"
|
# default_topic: "company-notifications"
|
||||||
# insecure_skip_verify: false # Set to true for self-signed certs
|
# insecure_skip_verify: false # Set to true for self-signed certs
|
||||||
|
|
||||||
|
# Authentication and authorization configuration
|
||||||
|
# auth:
|
||||||
|
# enabled: false # Enable API key authentication
|
||||||
|
# default_rate_limit: 100 # Default requests per minute (0 = unlimited)
|
||||||
|
# bootstrap:
|
||||||
|
# enabled: false # Enable bootstrap admin key creation on startup
|
||||||
|
# admin_key_file: "/tmp/notifier-admin-key" # Save generated key to this file
|
||||||
|
# print_to_stdout: false # Print key to stdout (DANGER: only for setup)
|
||||||
|
#
|
||||||
|
# Example with bootstrap enabled for testing:
|
||||||
|
# auth:
|
||||||
|
# enabled: true
|
||||||
|
# default_rate_limit: 100
|
||||||
|
# database:
|
||||||
|
# url: "postgresql://user:password@localhost:5432/notifier"
|
||||||
|
# bootstrap:
|
||||||
|
# enabled: true
|
||||||
|
# admin_key_file: "/tmp/notifier-admin-key"
|
||||||
|
# print_to_stdout: true
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: "info" # Options: debug, info, warn, error
|
level: "info" # Options: debug, info, warn, error
|
||||||
format: "json" # Options: json, text
|
format: "json" # Options: json, text
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ require (
|
|||||||
github.com/testcontainers/testcontainers-go v0.39.0
|
github.com/testcontainers/testcontainers-go v0.39.0
|
||||||
google.golang.org/grpc v1.76.0
|
google.golang.org/grpc v1.76.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
|
k8s.io/api v0.34.1
|
||||||
|
k8s.io/apimachinery v0.34.1
|
||||||
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -30,16 +33,25 @@ require (
|
|||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.1.0 // indirect
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
@@ -48,7 +60,10 @@ require (
|
|||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/term v0.5.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
@@ -62,11 +77,12 @@ require (
|
|||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
@@ -78,12 +94,26 @@ require (
|
|||||||
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.43.0 // indirect
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
golang.org/x/time v0.9.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
|||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -34,12 +35,16 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
|||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -47,13 +52,28 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
@@ -62,12 +82,19 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
@@ -76,6 +103,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
|||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
@@ -94,8 +123,20 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
|||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
@@ -125,8 +166,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
|||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -138,6 +179,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
@@ -150,6 +192,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
|
|||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
@@ -178,6 +222,10 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
|||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
@@ -193,6 +241,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -213,12 +263,14 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -237,6 +289,10 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -244,3 +300,23 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
|
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||||
|
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||||
|
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
|
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||||
|
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
|
||||||
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
+223
-6
@@ -8,6 +8,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/igodwin/notifier/internal/logging"
|
"github.com/igodwin/notifier/internal/logging"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BootstrapConfig holds configuration for bootstrap operations
|
// BootstrapConfig holds configuration for bootstrap operations
|
||||||
@@ -20,8 +24,110 @@ type BootstrapConfig struct {
|
|||||||
PrintToStdout bool
|
PrintToStdout bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// BootstrapAdminKey creates an initial admin API key on first startup
|
// BootstrapAdminKeyInMemory creates an initial admin API key on first startup (in-memory store)
|
||||||
// This should be called once per deployment
|
// This is a simpler version for in-memory APIKeyStore (without database persistence)
|
||||||
|
func BootstrapAdminKeyInMemory(keyStore *APIKeyStore, cfg *BootstrapConfig, logger *logging.Logger) (*APIKey, error) {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, fmt.Errorf("bootstrap is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bootstrap has already been done
|
||||||
|
if cfg.AdminKeyFileName != "" {
|
||||||
|
if _, err := os.Stat(cfg.AdminKeyFileName); err == nil {
|
||||||
|
// File exists, bootstrap already done
|
||||||
|
logger.Infof("Bootstrap key file exists at %s, skipping bootstrap", cfg.AdminKeyFileName)
|
||||||
|
return nil, fmt.Errorf("bootstrap already completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin key with all roles
|
||||||
|
adminRoles := []string{"admin", "notify-email", "notify-slack", "notify-ntfy"}
|
||||||
|
apiKey, err := keyStore.CreateKey(
|
||||||
|
"admin-bootstrap",
|
||||||
|
adminRoles,
|
||||||
|
0, // Unlimited rate limit
|
||||||
|
nil, // No expiration
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create bootstrap admin key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save key to file if configured
|
||||||
|
if cfg.AdminKeyFileName != "" {
|
||||||
|
keyContent := fmt.Sprintf(`# Notifier Admin Key
|
||||||
|
# Created: %s
|
||||||
|
# This key has full admin permissions
|
||||||
|
# KEEP THIS SECRET!
|
||||||
|
|
||||||
|
%s
|
||||||
|
`, time.Now().Format(time.RFC3339), apiKey.Key)
|
||||||
|
|
||||||
|
if err := os.WriteFile(cfg.AdminKeyFileName, []byte(keyContent), 0600); err != nil {
|
||||||
|
logger.Warnf("Failed to save admin key to file: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof("Admin key saved to %s", cfg.AdminKeyFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print to stdout if configured (DANGEROUS - only for interactive setup)
|
||||||
|
if cfg.PrintToStdout {
|
||||||
|
separator := strings.Repeat("=", 60)
|
||||||
|
fmt.Println("\n" + separator)
|
||||||
|
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
|
||||||
|
fmt.Println(separator)
|
||||||
|
fmt.Printf("Key: %s\n", apiKey.Key)
|
||||||
|
fmt.Println("\nSave this key in a secure location. You will not be able to see it again.")
|
||||||
|
fmt.Println("Use this key to create additional API keys via the key management API.")
|
||||||
|
fmt.Println(separator + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Bootstrap admin key created successfully")
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAdminKeyInMemory registers a pre-existing admin API key in the keystore
|
||||||
|
// Used when loading from Kubernetes secret or environment variable
|
||||||
|
func RegisterAdminKeyInMemory(keyStore *APIKeyStore, adminKey string, logger *logging.Logger) (*APIKey, error) {
|
||||||
|
if adminKey == "" {
|
||||||
|
return nil, fmt.Errorf("admin key value is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (should start with "nk_")
|
||||||
|
if !strings.HasPrefix(adminKey, "nk_") {
|
||||||
|
return nil, fmt.Errorf("invalid admin key format: must start with 'nk_'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create APIKey object with the provided key
|
||||||
|
adminRoles := []string{"admin", "notify-email", "notify-slack", "notify-ntfy"}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
apiKey := &APIKey{
|
||||||
|
Key: adminKey,
|
||||||
|
ClientID: "admin-bootstrap",
|
||||||
|
Roles: adminRoles,
|
||||||
|
CreatedAt: now,
|
||||||
|
IsActive: true,
|
||||||
|
RateLimit: 0, // Unlimited
|
||||||
|
Name: fmt.Sprintf("admin-bootstrap-%d", now.Unix()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to keystore
|
||||||
|
keyStore.mu.Lock()
|
||||||
|
defer keyStore.mu.Unlock()
|
||||||
|
|
||||||
|
keyStore.keys[adminKey] = apiKey
|
||||||
|
keyStore.rateLimits[adminKey] = &RateLimiter{
|
||||||
|
maxRequests: 0, // Unlimited
|
||||||
|
window: time.Minute,
|
||||||
|
resetTime: time.Now().Add(time.Minute),
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("Registered existing admin key from Kubernetes secret")
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapAdminKey creates an initial admin API key on first startup (with database persistence)
|
||||||
|
// This should be called once per deployment when using HybridKeyStore with database
|
||||||
func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *BootstrapConfig, logger *logging.Logger) (*APIKey, error) {
|
func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *BootstrapConfig, logger *logging.Logger) (*APIKey, error) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil, fmt.Errorf("bootstrap is disabled")
|
return nil, fmt.Errorf("bootstrap is disabled")
|
||||||
@@ -42,7 +148,7 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
|
|||||||
ctx,
|
ctx,
|
||||||
"admin-bootstrap",
|
"admin-bootstrap",
|
||||||
adminRoles,
|
adminRoles,
|
||||||
0, // Unlimited rate limit
|
0, // Unlimited rate limit
|
||||||
nil, // No expiration
|
nil, // No expiration
|
||||||
"system",
|
"system",
|
||||||
)
|
)
|
||||||
@@ -69,13 +175,14 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
|
|||||||
|
|
||||||
// Print to stdout if configured (DANGEROUS - only for interactive setup)
|
// Print to stdout if configured (DANGEROUS - only for interactive setup)
|
||||||
if cfg.PrintToStdout {
|
if cfg.PrintToStdout {
|
||||||
fmt.Println("\n" + strings.Repeat("=", 60))
|
separator := strings.Repeat("=", 60)
|
||||||
|
fmt.Println("\n" + separator)
|
||||||
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
|
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
|
||||||
fmt.Println(strings.Repeat("=", 60))
|
fmt.Println(separator)
|
||||||
fmt.Printf("Key: %s\n", apiKey.Key)
|
fmt.Printf("Key: %s\n", apiKey.Key)
|
||||||
fmt.Println("\nSave this key in a secure location. You will not be able to see it again.")
|
fmt.Println("\nSave this key in a secure location. You will not be able to see it again.")
|
||||||
fmt.Println("Use this key to create additional API keys via the key management API.")
|
fmt.Println("Use this key to create additional API keys via the key management API.")
|
||||||
fmt.Println(strings.Repeat("=", 60) + "\n")
|
fmt.Println(separator + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("Bootstrap admin key created successfully")
|
logger.Infof("Bootstrap admin key created successfully")
|
||||||
@@ -97,3 +204,113 @@ func LoadBootstrapKeyFromEnv(ctx context.Context, keyStore *HybridKeyStore, logg
|
|||||||
logger.Infof("Bootstrap key detected from environment variable")
|
logger.Infof("Bootstrap key detected from environment variable")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getKubernetesNamespace reads the pod's namespace from the service account token
|
||||||
|
func getKubernetesNamespace() (string, error) {
|
||||||
|
const namespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
||||||
|
data, err := os.ReadFile(namespacePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read namespace from service account: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAdminKeyFromKubernetesSecret attempts to load an existing admin key from a Kubernetes secret
|
||||||
|
// Returns the key string if found, empty string if secret doesn't exist, or error on failure
|
||||||
|
func LoadAdminKeyFromKubernetesSecret(ctx context.Context, secretName, secretKey string, logger *logging.Logger) (string, error) {
|
||||||
|
// Try to create Kubernetes client (will fail gracefully if not in cluster)
|
||||||
|
config, err := rest.InClusterConfig()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Not running in Kubernetes cluster or in-cluster config unavailable: %v", err)
|
||||||
|
return "", nil // Not in Kubernetes, return empty (not an error)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to create Kubernetes client: %v", err)
|
||||||
|
return "", nil // Failed to create client, but not a fatal error
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, err := getKubernetesNamespace()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to determine pod namespace: %v", err)
|
||||||
|
return "", nil // Failed to get namespace, but not a fatal error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the secret
|
||||||
|
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
// Secret doesn't exist or other error occurred
|
||||||
|
logger.Debugf("Admin key secret not found in namespace %s: %v", namespace, err)
|
||||||
|
return "", nil // Secret not found is not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the key value from the secret
|
||||||
|
if secretValue, exists := secret.Data[secretKey]; exists {
|
||||||
|
logger.Infof("Found existing admin key in Kubernetes secret %s/%s", namespace, secretName)
|
||||||
|
return string(secretValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warnf("Kubernetes secret %s/%s exists but key %q not found", namespace, secretName, secretKey)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKubernetesSecret creates or updates a Kubernetes secret with the admin key
|
||||||
|
func CreateKubernetesSecret(ctx context.Context, secretName, secretKey, adminKey string, logger *logging.Logger) error {
|
||||||
|
// Try to create Kubernetes client (will fail gracefully if not in cluster)
|
||||||
|
config, err := rest.InClusterConfig()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Not running in Kubernetes cluster, skipping secret creation: %v", err)
|
||||||
|
return nil // Not in Kubernetes, skip (not an error)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to create Kubernetes client, skipping secret creation: %v", err)
|
||||||
|
return nil // Failed to create client, but not a fatal error
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, err := getKubernetesNamespace()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to determine pod namespace, skipping secret creation: %v", err)
|
||||||
|
return nil // Failed to get namespace, but not a fatal error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update the secret
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: secretName,
|
||||||
|
Namespace: namespace,
|
||||||
|
Labels: map[string]string{
|
||||||
|
"app": "notifier",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: corev1.SecretTypeOpaque,
|
||||||
|
Data: map[string][]byte{
|
||||||
|
secretKey: []byte(adminKey),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get existing secret first
|
||||||
|
existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
|
||||||
|
if err == nil {
|
||||||
|
// Secret exists, update it
|
||||||
|
secret.ResourceVersion = existingSecret.ResourceVersion
|
||||||
|
_, err = clientset.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to update Kubernetes secret %s/%s: %v", namespace, secretName, err)
|
||||||
|
return nil // Log warning but don't fail
|
||||||
|
}
|
||||||
|
logger.Infof("Updated admin key in Kubernetes secret %s/%s", namespace, secretName)
|
||||||
|
} else {
|
||||||
|
// Secret doesn't exist, create it
|
||||||
|
_, err = clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to create Kubernetes secret %s/%s: %v", namespace, secretName, err)
|
||||||
|
return nil // Log warning but don't fail
|
||||||
|
}
|
||||||
|
logger.Infof("Created Kubernetes secret %s/%s with admin key", namespace, secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
_ "github.com/lib/pq" // PostgreSQL driver
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyStoreDB provides persistent storage for API keys using PostgreSQL
|
// KeyStoreDB provides persistent storage for API keys using PostgreSQL
|
||||||
@@ -37,9 +38,10 @@ func NewKeyStoreDB(dbURL string) (*KeyStoreDB, error) {
|
|||||||
return ks, nil
|
return ks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeSchema creates the necessary tables if they don't exist
|
// initializeSchema creates the necessary tables and indexes if they don't exist
|
||||||
func (ks *KeyStoreDB) initializeSchema() error {
|
func (ks *KeyStoreDB) initializeSchema() error {
|
||||||
schema := `
|
// Create tables
|
||||||
|
tableSchema := `
|
||||||
-- API Keys table
|
-- API Keys table
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -53,11 +55,7 @@ func (ks *KeyStoreDB) initializeSchema() error {
|
|||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
rate_limit INTEGER NOT NULL DEFAULT 0,
|
rate_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
created_by VARCHAR(255),
|
created_by VARCHAR(255),
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
metadata JSONB DEFAULT '{}'::jsonb
|
||||||
INDEX idx_key (key),
|
|
||||||
INDEX idx_client_id (client_id),
|
|
||||||
INDEX idx_active (is_active),
|
|
||||||
INDEX idx_expires (expires_at)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Audit log for key operations
|
-- Audit log for key operations
|
||||||
@@ -67,14 +65,29 @@ func (ks *KeyStoreDB) initializeSchema() error {
|
|||||||
action VARCHAR(50) NOT NULL,
|
action VARCHAR(50) NOT NULL,
|
||||||
performed_by VARCHAR(255) NOT NULL,
|
performed_by VARCHAR(255) NOT NULL,
|
||||||
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
details JSONB DEFAULT '{}'::jsonb,
|
details JSONB DEFAULT '{}'::jsonb
|
||||||
INDEX idx_key_id (key_id),
|
|
||||||
INDEX idx_performed_at (performed_at)
|
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := ks.db.Exec(schema)
|
if _, err := ks.db.Exec(tableSchema); err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes separately (PostgreSQL syntax)
|
||||||
|
indexSchema := `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_client_id ON api_keys(client_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_expires ON api_keys(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_key_audit_log_key_id ON api_key_audit_log(key_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_key_audit_log_performed_at ON api_key_audit_log(performed_at);
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err := ks.db.Exec(indexSchema); err != nil {
|
||||||
|
return fmt.Errorf("failed to create indexes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveKey persists an API key to the database
|
// SaveKey persists an API key to the database
|
||||||
@@ -94,7 +107,7 @@ func (ks *KeyStoreDB) SaveKey(ctx context.Context, key *APIKey, createdBy string
|
|||||||
key.Key,
|
key.Key,
|
||||||
key.Name,
|
key.Name,
|
||||||
key.ClientID,
|
key.ClientID,
|
||||||
key.Roles,
|
pq.Array(key.Roles), // Convert Go slice to PostgreSQL array
|
||||||
key.CreatedAt,
|
key.CreatedAt,
|
||||||
key.LastUsedAt,
|
key.LastUsedAt,
|
||||||
key.ExpiresAt,
|
key.ExpiresAt,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
// This ensures consistency: if DB write fails, cache is not updated
|
// This ensures consistency: if DB write fails, cache is not updated
|
||||||
type HybridKeyStore struct {
|
type HybridKeyStore struct {
|
||||||
cache *APIKeyStore // In-memory cache for fast lookups
|
cache *APIKeyStore // In-memory cache for fast lookups
|
||||||
db *KeyStoreDB // Database backend for persistence
|
db *KeyStoreDB // Database backend for persistence
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,34 +128,7 @@ func (h *HybridKeyStore) UpdateLastUsed(ctx context.Context, keyStr string) erro
|
|||||||
|
|
||||||
// CheckRateLimit checks if a key has exceeded its rate limit
|
// CheckRateLimit checks if a key has exceeded its rate limit
|
||||||
func (h *HybridKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
func (h *HybridKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
||||||
h.cache.mu.RLock()
|
return h.cache.CheckRateLimit(keyStr)
|
||||||
defer h.cache.mu.RUnlock()
|
|
||||||
|
|
||||||
limiter, exists := h.cache.rateLimits[keyStr]
|
|
||||||
if !exists {
|
|
||||||
return false, fmt.Errorf("rate limiter not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're under the rate limit
|
|
||||||
if limiter.maxRequests == 0 {
|
|
||||||
return true, nil // Unlimited
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter.mu.Lock()
|
|
||||||
defer limiter.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if now.After(limiter.resetTime) {
|
|
||||||
limiter.resetTime = now.Add(limiter.window)
|
|
||||||
limiter.count = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.count >= limiter.maxRequests {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter.count++
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuditLog retrieves audit log for a key
|
// GetAuditLog retrieves audit log for a key
|
||||||
|
|||||||
@@ -66,8 +66,24 @@ type HealthCheckConfig struct {
|
|||||||
|
|
||||||
// AuthConfig contains authentication and authorization configuration
|
// AuthConfig contains authentication and authorization configuration
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
|
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
|
||||||
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
|
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
|
||||||
|
Database DatabaseConfig `mapstructure:"database"` // Database configuration for persistent key storage
|
||||||
|
Bootstrap BootstrapConf `mapstructure:"bootstrap"` // Bootstrap admin key configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig contains database connection configuration
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
URL string `mapstructure:"url"` // Database connection URL (e.g., "postgresql://user:pass@host:5432/db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapConf contains configuration for bootstrap admin key creation
|
||||||
|
type BootstrapConf struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"` // Enable bootstrap on startup
|
||||||
|
AdminKeyFileName string `mapstructure:"admin_key_file"` // File to save the generated admin key
|
||||||
|
PrintToStdout bool `mapstructure:"print_to_stdout"` // Print admin key to stdout (only for setup)
|
||||||
|
KubernetesSecretName string `mapstructure:"kubernetes_secret_name"` // Kubernetes secret name (e.g., "notifier-admin-key")
|
||||||
|
KubernetesSecretKey string `mapstructure:"kubernetes_secret_key"` // Key within secret (e.g., "admin-key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORSConfig contains CORS (Cross-Origin Resource Sharing) configuration
|
// CORSConfig contains CORS (Cross-Origin Resource Sharing) configuration
|
||||||
@@ -199,8 +215,13 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("health_check.interval", 30)
|
v.SetDefault("health_check.interval", 30)
|
||||||
|
|
||||||
// Auth defaults
|
// Auth defaults
|
||||||
v.SetDefault("auth.enabled", false) // Authentication disabled by default
|
v.SetDefault("auth.enabled", false) // Authentication disabled by default
|
||||||
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
|
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
|
||||||
|
v.SetDefault("auth.bootstrap.enabled", false) // Bootstrap disabled by default
|
||||||
|
v.SetDefault("auth.bootstrap.admin_key_file", "") // No file by default
|
||||||
|
v.SetDefault("auth.bootstrap.print_to_stdout", false) // Don't print to stdout by default
|
||||||
|
v.SetDefault("auth.bootstrap.kubernetes_secret_name", "notifier-admin-key") // Default secret name
|
||||||
|
v.SetDefault("auth.bootstrap.kubernetes_secret_key", "admin-key") // Default secret key
|
||||||
|
|
||||||
// CORS defaults - secure by default (no origins allowed)
|
// CORS defaults - secure by default (no origins allowed)
|
||||||
v.SetDefault("cors.allowed_origins", []string{}) // Empty by default - must be explicitly configured
|
v.SetDefault("cors.allowed_origins", []string{}) // Empty by default - must be explicitly configured
|
||||||
@@ -393,6 +414,27 @@ func (c *Config) Sanitize() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sanitized["notifiers"] = notifiers
|
sanitized["notifiers"] = notifiers
|
||||||
|
|
||||||
|
// Sanitize auth config
|
||||||
|
sanitized["auth"] = map[string]interface{}{
|
||||||
|
"enabled": c.Auth.Enabled,
|
||||||
|
"bootstrap": map[string]interface{}{
|
||||||
|
"enabled": c.Auth.Bootstrap.Enabled,
|
||||||
|
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
|
||||||
|
"print_to_stdout": c.Auth.Bootstrap.PrintToStdout,
|
||||||
|
"kubernetes_secret_name": c.Auth.Bootstrap.KubernetesSecretName,
|
||||||
|
"kubernetes_secret_key": c.Auth.Bootstrap.KubernetesSecretKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize retention config
|
||||||
|
sanitized["retention"] = map[string]interface{}{
|
||||||
|
"enabled": c.Retention.Enabled,
|
||||||
|
"ttl": c.Retention.TTL,
|
||||||
|
"check_frequency": c.Retention.CheckFrequency,
|
||||||
|
"max_size": c.Retention.MaxSize,
|
||||||
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,23 @@ data:
|
|||||||
- "Authorization"
|
- "Authorization"
|
||||||
allow_credentials: false
|
allow_credentials: false
|
||||||
max_age: 3600
|
max_age: 3600
|
||||||
|
|
||||||
|
# Authentication and authorization configuration
|
||||||
|
# Uncomment and configure to enable API key authentication
|
||||||
|
# auth:
|
||||||
|
# enabled: true
|
||||||
|
# default_rate_limit: 100 # requests per minute
|
||||||
|
# bootstrap:
|
||||||
|
# enabled: true
|
||||||
|
# kubernetes_secret_name: "notifier-admin-key" # Secret name for storing admin key
|
||||||
|
# kubernetes_secret_key: "admin-key" # Key within the secret
|
||||||
|
# # Optionally save to file as backup
|
||||||
|
# admin_key_file: "/tmp/notifier-admin-key"
|
||||||
|
# print_to_stdout: false
|
||||||
|
|
||||||
|
# Notification retention and automatic cleanup configuration
|
||||||
|
retention:
|
||||||
|
enabled: true # Enable automatic cleanup of old/expired notifications
|
||||||
|
ttl: "168h" # Time-to-live: how long to keep notifications (7 days)
|
||||||
|
check_frequency: "1h" # How often to run cleanup check
|
||||||
|
max_size: 100000 # Maximum number of notifications to store
|
||||||
|
|||||||
@@ -95,3 +95,108 @@ metadata:
|
|||||||
name: notifier
|
name: notifier
|
||||||
labels:
|
labels:
|
||||||
app: notifier
|
app: notifier
|
||||||
|
---
|
||||||
|
# Example deployment with API key authentication and Kubernetes bootstrap enabled
|
||||||
|
# Uncomment and apply this deployment instead of the one above to enable auth
|
||||||
|
#
|
||||||
|
# apiVersion: apps/v1
|
||||||
|
# kind: Deployment
|
||||||
|
# metadata:
|
||||||
|
# name: notifier-with-auth
|
||||||
|
# labels:
|
||||||
|
# app: notifier
|
||||||
|
# version: v1
|
||||||
|
# spec:
|
||||||
|
# replicas: 3
|
||||||
|
# selector:
|
||||||
|
# matchLabels:
|
||||||
|
# app: notifier
|
||||||
|
# template:
|
||||||
|
# metadata:
|
||||||
|
# labels:
|
||||||
|
# app: notifier
|
||||||
|
# version: v1
|
||||||
|
# spec:
|
||||||
|
# serviceAccountName: notifier
|
||||||
|
# containers:
|
||||||
|
# - name: notifier
|
||||||
|
# image: notifier:latest
|
||||||
|
# imagePullPolicy: Always
|
||||||
|
# ports:
|
||||||
|
# - name: rest
|
||||||
|
# containerPort: 8080
|
||||||
|
# protocol: TCP
|
||||||
|
# - name: grpc
|
||||||
|
# containerPort: 50051
|
||||||
|
# protocol: TCP
|
||||||
|
# - name: metrics
|
||||||
|
# containerPort: 9090
|
||||||
|
# protocol: TCP
|
||||||
|
# - name: health
|
||||||
|
# containerPort: 8081
|
||||||
|
# protocol: TCP
|
||||||
|
# env:
|
||||||
|
# - name: NOTIFIER_SERVER_MODE
|
||||||
|
# value: "both"
|
||||||
|
# - name: NOTIFIER_LOGGING_LEVEL
|
||||||
|
# value: "info"
|
||||||
|
# - name: NOTIFIER_LOGGING_FORMAT
|
||||||
|
# value: "json"
|
||||||
|
# - name: NOTIFIER_QUEUE_TYPE
|
||||||
|
# value: "local"
|
||||||
|
# # Optional: set auth config via environment variables instead of config.yaml
|
||||||
|
# # - name: NOTIFIER_AUTH_ENABLED
|
||||||
|
# # value: "true"
|
||||||
|
# # - name: NOTIFIER_AUTH_DEFAULT_RATE_LIMIT
|
||||||
|
# # value: "100"
|
||||||
|
# # - name: NOTIFIER_AUTH_BOOTSTRAP_ENABLED
|
||||||
|
# # value: "true"
|
||||||
|
# # - name: NOTIFIER_AUTH_BOOTSTRAP_KUBERNETES_SECRET_NAME
|
||||||
|
# # value: "notifier-admin-key"
|
||||||
|
# # - name: NOTIFIER_AUTH_BOOTSTRAP_KUBERNETES_SECRET_KEY
|
||||||
|
# # value: "admin-key"
|
||||||
|
# volumeMounts:
|
||||||
|
# - name: config
|
||||||
|
# mountPath: /app/config.yaml
|
||||||
|
# subPath: config.yaml
|
||||||
|
# readOnly: true
|
||||||
|
# - name: queue-storage
|
||||||
|
# mountPath: /var/lib/notifier
|
||||||
|
# resources:
|
||||||
|
# requests:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
# limits:
|
||||||
|
# cpu: 500m
|
||||||
|
# memory: 512Mi
|
||||||
|
# livenessProbe:
|
||||||
|
# httpGet:
|
||||||
|
# path: /health
|
||||||
|
# port: health
|
||||||
|
# initialDelaySeconds: 30
|
||||||
|
# periodSeconds: 10
|
||||||
|
# timeoutSeconds: 5
|
||||||
|
# failureThreshold: 3
|
||||||
|
# readinessProbe:
|
||||||
|
# httpGet:
|
||||||
|
# path: /health
|
||||||
|
# port: health
|
||||||
|
# initialDelaySeconds: 10
|
||||||
|
# periodSeconds: 5
|
||||||
|
# timeoutSeconds: 3
|
||||||
|
# failureThreshold: 3
|
||||||
|
# securityContext:
|
||||||
|
# runAsNonRoot: true
|
||||||
|
# runAsUser: 1000
|
||||||
|
# allowPrivilegeEscalation: false
|
||||||
|
# readOnlyRootFilesystem: false
|
||||||
|
# capabilities:
|
||||||
|
# drop:
|
||||||
|
# - ALL
|
||||||
|
# volumes:
|
||||||
|
# - name: config
|
||||||
|
# configMap:
|
||||||
|
# name: notifier-config
|
||||||
|
# - name: queue-storage
|
||||||
|
# emptyDir: {}
|
||||||
|
# restartPolicy: Always
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: notifier
|
||||||
|
labels:
|
||||||
|
app: notifier
|
||||||
|
rules:
|
||||||
|
# Permissions for bootstrap admin key creation in Kubernetes secret
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["create", "update", "get", "list"]
|
||||||
|
resourceNames: ["notifier-admin-key"]
|
||||||
|
# Allow listing secrets to check if secret exists
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["list"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: notifier
|
||||||
|
labels:
|
||||||
|
app: notifier
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: notifier
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: notifier
|
||||||
|
namespace: default
|
||||||
Reference in New Issue
Block a user