diff options
| -rw-r--r-- | .dockerignore | 4 | ||||
| -rw-r--r-- | .gitkeep | 0 | ||||
| -rw-r--r-- | Dockerfile | 8 | ||||
| -rw-r--r-- | Makefile | 5 | ||||
| -rw-r--r-- | README.md | 236 | ||||
| -rw-r--r-- | cmd/server/main.go | 93 | ||||
| -rw-r--r-- | config.example.yaml | 32 | ||||
| -rw-r--r-- | config.toml | 22 | ||||
| -rw-r--r-- | config.yaml | 33 | ||||
| -rw-r--r-- | docker-compose.yml | 21 | ||||
| -rw-r--r-- | go.mod | 70 | ||||
| -rw-r--r-- | go.sum | 235 | ||||
| -rw-r--r-- | internal/config/config.go | 76 | ||||
| -rw-r--r-- | internal/middleware/cors.go | 122 | ||||
| -rw-r--r-- | internal/middleware/cors_test.go | 305 | ||||
| -rw-r--r-- | internal/orchestrator/orchestrator.go | 551 | ||||
| -rw-r--r-- | internal/orchestrator/test_orchestrator_api.go | 297 | ||||
| -rw-r--r-- | pkg/proto/sovrabase.pb.go | 215 | ||||
| -rw-r--r-- | pkg/proto/sovrabase_grpc.pb.go | 125 | ||||
| -rw-r--r-- | proto/sovrabase.proto | 22 |
20 files changed, 1962 insertions, 510 deletions
diff --git a/.dockerignore b/.dockerignore index 5509e3c..929b30e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,10 @@ README.md docs/ LICENSE +# Configuration (should be mounted as volume) +config.yaml +config.*.yaml + # Development .devenv .direnv diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index e69de29..0000000 --- a/.gitkeep +++ /dev/null @@ -38,12 +38,6 @@ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo # Copy binary COPY --from=builder /build/sovrabase /sovrabase - -# Copy config file -COPY --from=builder /build/config.toml /config.toml - -# Expose gRPC port (adjust based on your config) -EXPOSE 50051 - # Run the application +# The config file must be provided via volume mount at /config/config.yaml ENTRYPOINT ["/sovrabase"] @@ -7,7 +7,7 @@ run: go run ./cmd/server test: - go test ./... + go test ./... -v proto: protoc \ @@ -15,6 +15,5 @@ proto: --go-grpc_out=pkg --go-grpc_opt=paths=source_relative \ proto/*.proto - clean: - rm -rf bin/ + rm -rf bin/* @@ -1,23 +1,65 @@ # Sovrabase -> Une plateforme Backend-as-a-Service (BaaS) open source, souveraine et composable — conçue pour reprendre le contrôle de votre infrastructure. +> Une plateforme Backend-as-a-Service (BaaS) open source, souveraine et composable. [](LICENSE) -[](https://go.dev/) - ---- ## 🎯 Vision -**Sovrabase** est une alternative moderne et souveraine aux plateformes BaaS existantes (Firebase, Supabase, Appwrite). Elle répond aux besoins des entreprises et développeurs qui cherchent : +Sovrabase est une alternative souveraine à Firebase/Supabase : contrôle total, multi-tenant, multi-région, et extensible. -- **L'indépendance technologique** : aucun vendor lock-in, aucune dépendance à Google Cloud ou AWS -- **La souveraineté des données** : hébergement on-premises ou cloud privé (RGPD-friendly) -- **La flexibilité architecturale** : infrastructure modulaire et composable -- **La scalabilité multi-région** : distribution géographique native des données -- **La transparence totale** : open source, auditable, gouvernance claire +## 🚀 Quick Start ---- +```bash +# 1. Cloner le dépôt +git clone https://github.com/ketsuna-org/sovrabase.git +cd sovrabase + +# 2. Créer votre fichier de configuration +cp config.example.yaml config.yaml + +# 3. Éditer config.yaml et modifier au minimum : +# - super_user.password (OBLIGATOIRE !) +# - api.api_addr (par défaut "0.0.0.0:8080") +# - orchestrator.docker_host (par défaut "unix:///var/run/docker.sock") + +# 4. Lancer Sovrabase avec Docker Compose +docker compose up -d + +# 5. Vérifier que tout fonctionne +curl http://localhost:8080/health +``` + +> **⚠️ Important** : Modifiez impérativement le mot de passe du super utilisateur dans `config.yaml` avant le premier lancement ! + +Voir [docs/config.md](docs/config.md) pour la configuration détaillée. + +## 📦 Fonctionnalités principales + +- Authentication & Authorization +- Database Management (PostgreSQL, MongoDB) +- Storage S3-compatible +- Real-time (WebSocket) +- Multi-tenancy & Multi-region +- RBAC avancé + +## 🛠️ Technologies + +- Backend : Go 1.25+ +- Infra : Docker, Kubernetes-ready +- DB : PostgreSQL, MongoDB, Redis + +## 🚧 Statut + +En développement. Roadmap : [Phase 1-4 détaillée dans docs](docs/ROADMAP.md). + +## 🤝 Contribution + +Fork, branche, commit, PR. Voir [CONTRIBUTING.md](CONTRIBUTING.md). + +## 📄 Licence + +AGPLv3. ## 🚀 Pourquoi Sovrabase ? @@ -116,7 +158,177 @@ Sovrabase est conçu pour les entreprises exigeantes en matière de conformité --- -## 🚧 Statut du projet +## 🐳 Installation et Déploiement + +Sovrabase utilise Docker pour orchestrer les bases de données des projets. L'application s'exécute dans un conteneur et communique avec le daemon Docker de l'hôte. + +### Prérequis + +- Docker Engine 20.10+ +- Docker Compose V2 + +### Déploiement avec Docker Compose (recommandé) + +Le projet inclut un fichier `docker-compose.yml` prêt à l'emploi : + +```bash +# 1. Cloner le dépôt +git clone https://github.com/ketsuna-org/sovrabase.git +cd sovrabase + +# 2. Créer votre fichier de configuration +cp config.example.yaml config.yaml + +# 3. Éditer config.yaml et modifier : +# - super_user.password (OBLIGATOIRE !) +# - api.api_addr (par défaut "0.0.0.0:8080") +# - orchestrator.docker_host (par défaut "unix:///var/run/docker.sock") +# - region (ex: "eu-west-1") + +# 4. Lancer Sovrabase +docker compose up -d + +# 5. Vérifier que tout fonctionne +curl http://localhost:8080/health +``` + +> **⚠️ Important** : Modifiez impérativement le mot de passe du super utilisateur dans `config.yaml` avant le premier lancement ! + +### Configuration des volumes + +Le `docker-compose.yml` configure automatiquement les volumes nécessaires : + +#### 1. Fichier de configuration : `config.yaml` +- **Montage** : `./config.yaml:/config.yaml:ro` (lecture seule) +- **Contenu** : configuration de l'API, orchestrateur, base de données interne, super utilisateur + +#### 2. Socket Docker +- **Montage** : `/var/run/docker.sock:/var/run/docker.sock` +- **Rôle** : permet à Sovrabase de créer et gérer les conteneurs de bases de données pour chaque projet + +#### 3. Volume de données : `sovrabase-data` +- **Montage** : `sovrabase-data:/data` +- **Contenu** : base de données interne SQLite (`/data/sovrabase.db` par défaut) +- **Persistance** : les données survivent aux redémarrages et suppressions du conteneur + +### Structure du fichier `config.yaml` + +Voici les paramètres principaux à configurer : + +```yaml +# Configuration de l'API +api: + api_addr: "0.0.0.0:8080" # Adresse d'écoute + domain: "api.example.com" # Domaine public (optionnel) + cors_allow: # Origines CORS autorisées + - "http://localhost:3000" + - "https://example.com" + +# Orchestrateur (Docker ou Kubernetes) +orchestrator: + type: "docker" + docker_host: "unix:///var/run/docker.sock" + +# Base de données interne (SQLite par défaut) +internal_db: + manager: "sqlite" + uri: "/data/sovrabase.db" + +# Super utilisateur (à modifier OBLIGATOIREMENT) +super_user: + username: "admin" + password: "CHANGE-THIS-TO-A-SECURE-PASSWORD" + email: "admin@example.com" + +# Région de déploiement +region: "eu-west-1" +``` + +Voir [docs/config.md](docs/config.md) pour la documentation complète. + +### Commandes utiles + +```bash +# Voir les logs en temps réel +docker compose logs -f + +# Voir les logs du conteneur Sovrabase uniquement +docker compose logs -f sovrabase + +# Arrêter Sovrabase +docker compose down + +# Arrêter et supprimer les volumes (⚠️ perte de données) +docker compose down -v + +# Redémarrer après modification de config.yaml +docker compose restart + +# Mettre à jour vers la dernière version +docker compose pull +docker compose up -d +``` + +### Vérification de l'installation + +Une fois lancé, testez l'API : + +```bash +# Health check +curl http://localhost:8080/health +# Réponse attendue : {"status":"ok"} + +# Vérifier les logs +docker compose logs sovrabase +``` + +### ⚠️ Considérations de sécurité + +**Socket Docker** : Monter `/var/run/docker.sock` donne un accès privilégié au daemon Docker. Le conteneur peut créer, modifier et supprimer d'autres conteneurs. + +**Recommandations pour la production** : + +1. **Proxy Docker** : utilisez [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) pour limiter les permissions +2. **Mot de passe fort** : changez `super_user.password` dans `config.yaml` +3. **HTTPS** : utilisez un reverse proxy (Nginx, Caddy, Traefik) pour le TLS +4. **Firewall** : restreignez l'accès au port 8080 aux IPs autorisées +5. **Monitoring** : surveillez les logs et les actions Docker + +### Déploiement avec Docker Run + +Si vous préférez utiliser `docker run` directement : + +```bash +# Créer un volume pour les données +docker volume create sovrabase-data + +# Lancer Sovrabase +docker run -d \ + --name sovrabase \ + --restart unless-stopped \ + -p 8080:8080 \ + -v $(pwd)/config.yaml:/config.yaml:ro \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v sovrabase-data:/data \ + ghcr.io/ketsuna-org/sovrabase:latest +``` + +### Utilisation avec Podman + +Sovrabase est compatible avec Podman. Modifiez `orchestrator.docker_host` dans `config.yaml` : + +```yaml +orchestrator: + type: "docker" + # Podman rootless + docker_host: "unix:///run/user/1000/podman/podman.sock" + # Podman root + # docker_host: "unix:///run/podman/podman.sock" +``` + +--- + +## �🚧 Statut du projet **⚠️ En développement actif** — Sovrabase est actuellement en phase de conception et développement. diff --git a/cmd/server/main.go b/cmd/server/main.go index 532a059..870bd7c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,80 +1,51 @@ package main import ( - "context" - "fmt" "log" - "net" + "net/http" + "os" + mux "github.com/gorilla/mux" "github.com/ketsuna-org/sovrabase/internal/config" - pb "github.com/ketsuna-org/sovrabase/pkg/proto" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/ketsuna-org/sovrabase/internal/middleware" ) -type server struct { - pb.UnimplementedForwardCommandServiceServer - nodeClients map[string]pb.ForwardCommandServiceClient -} - -func (s *server) ForwardCommand(ctx context.Context, req *pb.ForwardCommandRequest) (*pb.ForwardCommandResponse, error) { - log.Printf("Received command: %s for node: %s", req.Command, req.TargetNode) - - // Si c'est pour ce node, traiter localement - if req.TargetNode == "" || req.TargetNode == "self" { - return &pb.ForwardCommandResponse{ - Success: true, - Result: fmt.Sprintf("Command '%s' executed locally", req.Command), - }, nil +func main() { + // Get config path from environment variable or use default + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "config.yaml" } - // Sinon, forwarder au node cible - if client, exists := s.nodeClients[req.TargetNode]; exists { - resp, err := client.ForwardCommand(ctx, req) - if err != nil { - return &pb.ForwardCommandResponse{ - Success: false, - ErrorMessage: fmt.Sprintf("Failed to forward to %s: %v", req.TargetNode, err), - }, nil - } - return resp, nil + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Fatalf("failed to load config from %s: %v", configPath, err) } - // Node inconnu - return &pb.ForwardCommandResponse{ - Success: false, - ErrorMessage: fmt.Sprintf("Unknown target node: %s", req.TargetNode), - }, nil -} + // Setup HTTP Server -func main() { - cfg, err := config.LoadConfig("config.toml") - if err != nil { - log.Fatalf("failed to load config: %v", err) + router := mux.NewRouter() + log.Printf("Starting API server on %s", cfg.API.APIAddr) + if cfg.API.Domain != "" { + log.Printf(" - Configured domain: %s", cfg.API.Domain) } - - // Initialiser les connexions aux autres nodes - nodeClients := make(map[string]pb.ForwardCommandServiceClient) - for _, addr := range cfg.Cluster.RPCServers { - if addr != cfg.RPC.RPCAddr { // Ne pas se connecter à soi-même - conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - log.Printf("Failed to connect to node %s: %v", addr, err) - continue - } - nodeClients[addr] = pb.NewForwardCommandServiceClient(conn) - log.Printf("Connected to node: %s", addr) - } + if len(cfg.API.CORSAllow) > 0 { + log.Printf(" - CORS allowed origins: %v", cfg.API.CORSAllow) } - lis, err := net.Listen("tcp", cfg.RPC.RPCAddr) - if err != nil { - log.Fatalf("failed to listen: %v", err) + // Appliquer le middleware CORS + corsConfig := &middleware.CORSConfig{ + Domain: cfg.API.Domain, + AllowedOrigins: cfg.API.CORSAllow, } - s := grpc.NewServer() - pb.RegisterForwardCommandServiceServer(s, &server{nodeClients: nodeClients}) - log.Printf("server listening at %v", lis.Addr()) - if err := s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) + router.Use(middleware.CORSMiddleware(corsConfig)) + + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Sovrabase API is running")) + }) + + if err := http.ListenAndServe(cfg.API.APIAddr, router); err != nil { + log.Fatalf("failed to start API server: %v", err) } } diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..a3a6dba --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,32 @@ +# Sovrabase Configuration Example +# Copy this file to config.yaml and adjust the values + +# API Configuration +api: + api_addr: "0.0.0.0:8080" + cors_allow: + - "http://localhost:3000" + - "https://example.com" + domain: "api.example.com" + +# Orchestrator Configuration +orchestrator: + type: "docker" + docker_host: "unix:///var/run/docker.sock" + kube_api: "https://kubernetes.default.svc" + kube_token: "your-kubernetes-api-token" + namespace: "sovrabase-databases" + +# Internal Database Configuration +internal_db: + manager: "sqlite" + uri: "/data/sovrabase.db" + +# Super User Configuration +super_user: + username: "admin" + password: "CHANGE-THIS-TO-A-SECURE-PASSWORD" + email: "admin@example.com" + +# Region +region: "eu-west-1" diff --git a/config.toml b/config.toml deleted file mode 100644 index fae791d..0000000 --- a/config.toml +++ /dev/null @@ -1,22 +0,0 @@ -region = "supabase" - -[rpc] -rpc_secret = "random_secret_12345" -rpc_addr = "[::]:8080" - -[api] -api_addr = "[::]:3000" -api_domain = "example.com" - -[internal_db] -manager = "sqlite" -uri = "./database/internal.db" - -[external_db] -manager = "postgres" -uri = "postgres://root:password@localhost:5432/" - -[cluster] -node_id = "node-01" -is_rpc_server = true -rpc_servers = ["[::]:8080"] diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a5bef10 --- /dev/null +++ b/config.yaml @@ -0,0 +1,33 @@ +region: supabase + +api: + api_addr: "[::]:3000" + domain: api.example.com + cors_allow: + - example.com + +internal_db: + manager: sqlite + uri: ./database/internal.db + +orchestrator: + # Type d'orchestrateur: "docker" ou "kubernetes" + type: docker + + # Configuration Docker/Podman + docker_host: unix:///mnt/wsl/podman-sockets/podman-machine-default/podman-root.sock + # Autres exemples: + # Docker standard: "unix:///var/run/docker.sock" + # Podman rootless: "unix:///run/user/1000/podman/podman.sock" + # Podman root: "unix:///run/podman/podman.sock" + # Hôte distant: "tcp://remote-host:2375" + + # Configuration Kubernetes (si type = "kubernetes") + # kube_api: https://kubernetes.default.svc + # kube_token: your-kubernetes-api-token + # namespace: sovrabase-databases + +super_user: + username: "admin" + password: "CHANGE-THIS-TO-A-SECURE-PASSWORD" + email: "admin@example.com" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..95c7ab6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + sovrabase: + image: ghcr.io/ketsuna-org/sovrabase:latest + # Pour build local, décommentez : + # build: + # context: . + # dockerfile: Dockerfile + container_name: sovrabase + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./config.yaml:/config.yaml:ro + - /var/run/docker.sock:/var/run/docker.sock + - sovrabase-data:/data + +volumes: + sovrabase-data: + driver: local @@ -2,13 +2,73 @@ module github.com/ketsuna-org/sovrabase go 1.25.2 -require github.com/BurntSushi/toml v1.5.0 +require ( + github.com/docker/docker v28.5.1+incompatible + github.com/docker/go-connections v0.6.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.34.1 + k8s.io/client-go v0.34.1 +) require ( - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // 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/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + k8s.io/apimachinery v0.34.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 ) @@ -1,14 +1,229 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +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/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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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-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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +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/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +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/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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +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/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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +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-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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +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-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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +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/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= diff --git a/internal/config/config.go b/internal/config/config.go index c872203..ce442a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,55 +2,66 @@ package config import ( "fmt" + "os" - "github.com/BurntSushi/toml" -) + "gopkg.in/yaml.v3" -// RPC holds RPC configuration -type RPC struct { - RPCSecret string - RPCAddr string -} + // Import des packages Docker et Kubernetes pour la gestion des conteneurs + _ "github.com/docker/docker/client" + _ "github.com/docker/go-connections/nat" + _ "k8s.io/api/core/v1" + _ "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/rest" +) // API holds API configuration type API struct { - APIAddr string - APIDomain string + APIAddr string `yaml:"api_addr"` + CORSAllow []string `yaml:"cors_allow"` + Domain string `yaml:"domain"` } // InternalDB holds internal database configuration type InternalDB struct { - Manager string - URI string + Manager string `yaml:"manager"` + URI string `yaml:"uri"` } -// ExternalDB holds external database configuration -type ExternalDB struct { - Manager string - URI string +// Orchestrator holds container orchestration configuration +type Orchestrator struct { + Type string `yaml:"type"` // "docker" or "kubernetes" + DockerHost string `yaml:"docker_host"` // Docker/Podman socket or remote host + KubeAPI string `yaml:"kube_api"` // Kubernetes API endpoint + KubeToken string `yaml:"kube_token"` // Kubernetes API token + Namespace string `yaml:"namespace"` // Kubernetes namespace for database deployments } -// Cluster holds cluster/distributed configuration -type Cluster struct { - NodeID string - IsRPCServer bool - RPCServers []string +// SuperUser holds super user configuration +type SuperUser struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + Email string `yaml:"email"` } // Config holds the application configuration type Config struct { - Region string - RPC RPC - API API - InternalDB InternalDB - ExternalDB ExternalDB - Cluster Cluster + Region string + API API + InternalDB InternalDB + Orchestrator Orchestrator + SuperUser SuperUser } -// LoadConfig loads configuration from a TOML file +// LoadConfig loads configuration from a YAML file func LoadConfig(filePath string) (*Config, error) { var config Config - if _, err := toml.DecodeFile(filePath, &config); err != nil { + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("failed to decode config file: %w", err) } @@ -58,6 +69,15 @@ func LoadConfig(filePath string) (*Config, error) { if config.Region == "" { config.Region = "supabase" } + if config.Orchestrator.Type == "" { + config.Orchestrator.Type = "docker" + } + if config.Orchestrator.DockerHost == "" && config.Orchestrator.Type == "docker" { + config.Orchestrator.DockerHost = "unix:///var/run/docker.sock" + } + if config.Orchestrator.Namespace == "" && config.Orchestrator.Type == "kubernetes" { + config.Orchestrator.Namespace = "sovrabase-databases" + } return &config, nil } diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..182f9fc --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,122 @@ +package middleware + +import ( + "log" + "net/http" + "strings" +) + +// CORSConfig holds the configuration for CORS middleware +type CORSConfig struct { + Domain string // Domain principal de l'API (si configuré) + AllowedOrigins []string // Origins autorisées pour CORS +} + +// CORSMiddleware creates a middleware that validates the Host header +// and sets appropriate CORS headers based on the allowed origins +func CORSMiddleware(config *CORSConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Récupérer le Host header + host := r.Host + + // Récupérer l'Origin header (pour les requêtes CORS) + origin := r.Header.Get("Origin") + + // Extraire le hostname du Host (sans le port) + hostWithoutPort := strings.Split(host, ":")[0] + + // Vérification 1: Si un Domain principal est configuré, le Host doit correspondre + if config.Domain != "" { + // Autoriser localhost en développement même si un Domain est configuré + isLocalhost := hostWithoutPort == "localhost" || hostWithoutPort == "127.0.0.1" || strings.HasPrefix(host, "[::") + + if !isLocalhost && hostWithoutPort != config.Domain && host != config.Domain { + log.Printf("❌ Blocked request: host '%s' does not match configured domain '%s'", host, config.Domain) + http.Error(w, "Forbidden: Invalid domain", http.StatusForbidden) + return + } + + if isLocalhost { + log.Printf("⚠️ Allowing localhost/loopback address despite domain restriction: %s", host) + } + } + + // Vérification 2: CORS - vérifier les origins autorisées + // Si aucune restriction CORS n'est configurée, on autorise tout + if len(config.AllowedOrigins) == 0 { + if origin != "" { + log.Printf("⚠️ No CORS origins configured, allowing all origins") + } + log.Printf("✅ Allowed request from: %s (origin: %s)", host, origin) + next.ServeHTTP(w, r) + return + } + + // Vérifier les origins CORS (quand il y a un header Origin) + allowed := false + matchedOrigin := "" + + if origin != "" { + // Extraire le hostname de l'Origin (peut être avec protocole http:// ou https://) + originHost := strings.TrimPrefix(origin, "http://") + originHost = strings.TrimPrefix(originHost, "https://") + originHost = strings.Split(originHost, ":")[0] + + for _, allowedOrigin := range config.AllowedOrigins { + if originHost == allowedOrigin { + allowed = true + matchedOrigin = origin + break + } + } + + if !allowed { + log.Printf("❌ Blocked CORS request from unauthorized origin: %s (host: %s)", origin, host) + http.Error(w, "Forbidden: Invalid origin", http.StatusForbidden) + return + } + + // Définir les headers CORS appropriés + w.Header().Set("Access-Control-Allow-Origin", matchedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Max-Age", "3600") + + // Gérer les requêtes preflight OPTIONS + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + } else { + // Pas de header Origin - ce n'est pas une requête CORS + // Si un Domain est configuré, la vérification a déjà été faite plus haut + // Si pas de Domain, on vérifie que le Host correspond à un des AllowedOrigins + if config.Domain == "" { + for _, allowedOrigin := range config.AllowedOrigins { + if hostWithoutPort == allowedOrigin || host == allowedOrigin { + allowed = true + break + } + } + + // Si localhost est utilisé en développement, on peut l'autoriser + if !allowed && (hostWithoutPort == "localhost" || hostWithoutPort == "127.0.0.1" || strings.HasPrefix(host, "[::")) { + log.Printf("⚠️ Allowing localhost/loopback address: %s", host) + allowed = true + } + + if !allowed { + log.Printf("❌ Blocked request from unauthorized host: %s", host) + http.Error(w, "Forbidden: Invalid host", http.StatusForbidden) + return + } + } + } + + log.Printf("✅ Allowed request from: %s (origin: %s)", host, origin) + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/cors_test.go b/internal/middleware/cors_test.go new file mode 100644 index 0000000..c36b6ef --- /dev/null +++ b/internal/middleware/cors_test.go @@ -0,0 +1,305 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// handlerMock est un handler simple pour les tests +func handlerMock(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func TestCORSMiddleware_AllowedHost(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com", "api.example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "example.com" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + expected := "OK" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestCORSMiddleware_UnauthorizedHost(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com", "api.example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "unauthorized.com" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusForbidden { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusForbidden) + } +} + +func TestCORSMiddleware_HostWithPort(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "example.com:3000" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code for host with port: got %v want %v", status, http.StatusOK) + } +} + +func TestCORSMiddleware_LocalhostAllowed(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + testCases := []string{ + "localhost", + "localhost:3000", + "127.0.0.1", + "127.0.0.1:8080", + "[::1]", + "[::1]:3000", + } + + for _, host := range testCases { + req := httptest.NewRequest("GET", "/", nil) + req.Host = host + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("localhost %s should be allowed: got %v want %v", host, status, http.StatusOK) + } + } +} + +func TestCORSMiddleware_CORSHeaders(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "api.example.com" + req.Header.Set("Origin", "https://example.com") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + // Vérifier les headers CORS + if origin := rr.Header().Get("Access-Control-Allow-Origin"); origin != "https://example.com" { + t.Errorf("Access-Control-Allow-Origin = %v, want %v", origin, "https://example.com") + } + + if methods := rr.Header().Get("Access-Control-Allow-Methods"); methods == "" { + t.Error("Access-Control-Allow-Methods should be set") + } + + if headers := rr.Header().Get("Access-Control-Allow-Headers"); headers == "" { + t.Error("Access-Control-Allow-Headers should be set") + } + + if credentials := rr.Header().Get("Access-Control-Allow-Credentials"); credentials != "true" { + t.Errorf("Access-Control-Allow-Credentials = %v, want %v", credentials, "true") + } +} + +func TestCORSMiddleware_PreflightRequest(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("OPTIONS", "/", nil) + req.Host = "api.example.com" + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("preflight request returned wrong status code: got %v want %v", status, http.StatusOK) + } + + // Le body ne devrait pas contenir "OK" car on répond directement à la preflight + if rr.Body.String() == "OK" { + t.Error("preflight request should not execute the handler") + } +} + +func TestCORSMiddleware_NoConfig(t *testing.T) { + config := &CORSConfig{ + AllowedOrigins: []string{}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "any-domain.com" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // Sans configuration, tout devrait être autorisé + if status := rr.Code; status != http.StatusOK { + t.Errorf("with no config, all should be allowed: got %v want %v", status, http.StatusOK) + } +} + +// Tests pour la validation du Domain configuré + +func TestCORSMiddleware_DomainMatch(t *testing.T) { + config := &CORSConfig{ + Domain: "api.example.com", + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "api.example.com" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("request to configured domain should be allowed: got %v want %v", status, http.StatusOK) + } +} + +func TestCORSMiddleware_DomainMismatch(t *testing.T) { + config := &CORSConfig{ + Domain: "api.example.com", + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "wrong.example.com" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusForbidden { + t.Errorf("request to wrong domain should be blocked: got %v want %v", status, http.StatusForbidden) + } +} + +func TestCORSMiddleware_DomainWithPort(t *testing.T) { + config := &CORSConfig{ + Domain: "api.example.com", + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "api.example.com:3000" + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("request to configured domain with port should be allowed: got %v want %v", status, http.StatusOK) + } +} + +func TestCORSMiddleware_LocalhostWithDomainRestriction(t *testing.T) { + config := &CORSConfig{ + Domain: "api.example.com", + AllowedOrigins: []string{"example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + testCases := []string{ + "localhost", + "localhost:3000", + "127.0.0.1", + "[::1]", + } + + for _, host := range testCases { + req := httptest.NewRequest("GET", "/", nil) + req.Host = host + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("localhost %s should be allowed even with domain restriction: got %v want %v", host, status, http.StatusOK) + } + } +} + +func TestCORSMiddleware_DomainAndCORS(t *testing.T) { + config := &CORSConfig{ + Domain: "api.example.com", + AllowedOrigins: []string{"example.com", "app.example.com"}, + } + + middleware := CORSMiddleware(config) + handler := middleware(http.HandlerFunc(handlerMock)) + + req := httptest.NewRequest("GET", "/", nil) + req.Host = "api.example.com" + req.Header.Set("Origin", "https://example.com") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("request with valid domain and origin should be allowed: got %v want %v", status, http.StatusOK) + } + + // Vérifier les headers CORS + if origin := rr.Header().Get("Access-Control-Allow-Origin"); origin != "https://example.com" { + t.Errorf("Access-Control-Allow-Origin = %v, want %v", origin, "https://example.com") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go new file mode 100644 index 0000000..bb72614 --- /dev/null +++ b/internal/orchestrator/orchestrator.go @@ -0,0 +1,551 @@ +package orchestrator + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/ketsuna-org/sovrabase/internal/config" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// Orchestrator interface pour gérer les conteneurs de bases de données +type Orchestrator interface { + // CreateDatabase crée une nouvelle instance de base de données pour un projet + CreateDatabase(ctx context.Context, projectID string, options *DatabaseOptions) (*DatabaseInfo, error) + + // DeleteDatabase supprime une instance de base de données + DeleteDatabase(ctx context.Context, projectID string) error + + // GetDatabaseInfo retourne les informations de connexion à la base de données + GetDatabaseInfo(ctx context.Context, projectID string) (*DatabaseInfo, error) + + // ListDatabases liste toutes les bases de données gérées + ListDatabases(ctx context.Context) ([]*DatabaseInfo, error) + + // DatabaseExists vérifie si une base de données existe déjà + DatabaseExists(ctx context.Context, projectID string) (bool, error) +} + +// DatabaseOptions contient les options pour créer une base de données +type DatabaseOptions struct { + PostgresVersion string // Version de PostgreSQL (défaut: "16-alpine") + Password string // Mot de passe (généré si vide) + Port int // Port hôte (auto-assigné si 0) + Memory string // Limite mémoire (ex: "512m") + CPUs string // Limite CPU (ex: "0.5") +} + +// DatabaseInfo contient les informations d'une base de données +type DatabaseInfo struct { + ProjectID string + ContainerID string + ContainerName string + Status string + PostgresVersion string + Host string + Port string + Database string + User string + Password string + ConnectionString string + CreatedAt time.Time +} + +// DockerOrchestrator gère les bases de données via Docker/Podman +type DockerOrchestrator struct { + client *client.Client + config *config.Orchestrator +} + +// KubernetesOrchestrator gère les bases de données via Kubernetes +type KubernetesOrchestrator struct { + client *kubernetes.Clientset + config *config.Orchestrator +} + +// NewOrchestrator crée un orchestrateur basé sur la configuration +func NewOrchestrator(cfg *config.Orchestrator) (Orchestrator, error) { + switch cfg.Type { + case "docker": + return NewDockerOrchestrator(cfg) + case "kubernetes": + return NewKubernetesOrchestrator(cfg) + default: + return nil, fmt.Errorf("type d'orchestrateur non supporté: %s", cfg.Type) + } +} + +// NewDockerOrchestrator crée un orchestrateur Docker +func NewDockerOrchestrator(cfg *config.Orchestrator) (*DockerOrchestrator, error) { + cli, err := client.NewClientWithOpts( + client.WithHost(cfg.DockerHost), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("échec de connexion à Docker: %w", err) + } + + return &DockerOrchestrator{ + client: cli, + config: cfg, + }, nil +} + +// NewKubernetesOrchestrator crée un orchestrateur Kubernetes +func NewKubernetesOrchestrator(cfg *config.Orchestrator) (*KubernetesOrchestrator, error) { + kubeConfig := &rest.Config{ + Host: cfg.KubeAPI, + BearerToken: cfg.KubeToken, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: false, // À configurer selon vos besoins + }, + } + + clientset, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("échec de connexion à Kubernetes: %w", err) + } + + return &KubernetesOrchestrator{ + client: clientset, + config: cfg, + }, nil +} + +// Implémentations des méthodes pour DockerOrchestrator + +// CreateDatabase crée une nouvelle instance PostgreSQL dans un conteneur +func (d *DockerOrchestrator) CreateDatabase(ctx context.Context, projectID string, options *DatabaseOptions) (*DatabaseInfo, error) { + // Vérifier si la base existe déjà + exists, err := d.DatabaseExists(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("erreur lors de la vérification de l'existence: %w", err) + } + if exists { + return nil, fmt.Errorf("une base de données existe déjà pour le projet: %s", projectID) + } + + // Définir les valeurs par défaut + if options == nil { + options = &DatabaseOptions{} + } + if options.PostgresVersion == "" { + options.PostgresVersion = "16-alpine" + } + if options.Password == "" { + options.Password = generatePassword(projectID) + } + if options.Port == 0 { + options.Port = findAvailablePort(ctx, d.client) + } + + containerName := fmt.Sprintf("sovrabase-db-%s", projectID) + imageName := fmt.Sprintf("docker.io/library/postgres:%s", options.PostgresVersion) + dbName := sanitizeDBName(projectID) + dbUser := sanitizeDBName(projectID) + + // Pull l'image PostgreSQL + reader, err := d.client.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return nil, fmt.Errorf("erreur lors du pull de l'image: %w", err) + } + // Consommer la sortie pour attendre la fin du pull + _, _ = io.Copy(io.Discard, reader) + reader.Close() + + // Configuration du conteneur + containerConfig := &container.Config{ + Image: imageName, + Env: []string{ + fmt.Sprintf("POSTGRES_PASSWORD=%s", options.Password), + fmt.Sprintf("POSTGRES_DB=%s", dbName), + fmt.Sprintf("POSTGRES_USER=%s", dbUser), + }, + ExposedPorts: nat.PortSet{ + "5432/tcp": struct{}{}, + }, + Labels: map[string]string{ + "sovrabase.managed": "true", + "sovrabase.project_id": projectID, + "sovrabase.type": "postgres", + "sovrabase.version": options.PostgresVersion, + "sovrabase.created_at": time.Now().UTC().Format(time.RFC3339), + }, + } + + // Configuration de l'hôte + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + "5432/tcp": []nat.PortBinding{ + { + HostIP: "127.0.0.1", + HostPort: fmt.Sprintf("%d", options.Port), + }, + }, + }, + AutoRemove: false, + RestartPolicy: container.RestartPolicy{ + Name: "unless-stopped", + }, + } + + // Ajouter les limites de ressources si spécifiées + if options.Memory != "" { + hostConfig.Resources.Memory = parseMemory(options.Memory) + } + if options.CPUs != "" { + hostConfig.Resources.NanoCPUs = parseCPUs(options.CPUs) + } + + // Créer le conteneur + resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName) + if err != nil { + return nil, fmt.Errorf("erreur lors de la création du conteneur: %w", err) + } + + // Gérer les warnings + if len(resp.Warnings) > 0 { + for _, warning := range resp.Warnings { + fmt.Printf("Warning: %s\n", warning) + } + } + + // Démarrer le conteneur + if err := d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + // En cas d'erreur, nettoyer le conteneur créé + _ = d.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}) + return nil, fmt.Errorf("erreur lors du démarrage du conteneur: %w", err) + } + + // Attendre que PostgreSQL soit prêt (max 30 secondes) + if err := d.waitForPostgres(ctx, resp.ID, 30*time.Second); err != nil { + return nil, fmt.Errorf("PostgreSQL n'a pas démarré correctement: %w", err) + } + + // Créer les informations de la base de données + dbInfo := &DatabaseInfo{ + ProjectID: projectID, + ContainerID: resp.ID, + ContainerName: containerName, + Status: "running", + PostgresVersion: options.PostgresVersion, + Host: "localhost", + Port: fmt.Sprintf("%d", options.Port), + Database: dbName, + User: dbUser, + Password: options.Password, + ConnectionString: fmt.Sprintf("postgresql://%s:%s@localhost:%d/%s?sslmode=disable", dbUser, options.Password, options.Port, dbName), + CreatedAt: time.Now().UTC(), + } + + return dbInfo, nil +} + +// DeleteDatabase supprime le conteneur de base de données +func (d *DockerOrchestrator) DeleteDatabase(ctx context.Context, projectID string) error { + containerName := fmt.Sprintf("sovrabase-db-%s", projectID) + + // Vérifier si le conteneur existe + exists, err := d.DatabaseExists(ctx, projectID) + if err != nil { + return fmt.Errorf("erreur lors de la vérification: %w", err) + } + if !exists { + return fmt.Errorf("aucune base de données trouvée pour le projet: %s", projectID) + } + + // Arrêter le conteneur (timeout de 10 secondes) + timeout := 10 + stopOptions := container.StopOptions{ + Timeout: &timeout, + } + if err := d.client.ContainerStop(ctx, containerName, stopOptions); err != nil { + // Ignorer si déjà arrêté + if !strings.Contains(err.Error(), "is not running") { + return fmt.Errorf("erreur lors de l'arrêt du conteneur: %w", err) + } + } + + // Supprimer le conteneur et ses volumes + removeOptions := container.RemoveOptions{ + Force: true, + RemoveVolumes: true, + } + if err := d.client.ContainerRemove(ctx, containerName, removeOptions); err != nil { + return fmt.Errorf("erreur lors de la suppression du conteneur: %w", err) + } + + return nil +} + +// GetDatabaseInfo récupère les informations d'une base de données +func (d *DockerOrchestrator) GetDatabaseInfo(ctx context.Context, projectID string) (*DatabaseInfo, error) { + containerName := fmt.Sprintf("sovrabase-db-%s", projectID) + + // Inspecter le conteneur + containerJSON, err := d.client.ContainerInspect(ctx, containerName) + if err != nil { + if client.IsErrNotFound(err) { + return nil, fmt.Errorf("base de données non trouvée pour le projet: %s", projectID) + } + return nil, fmt.Errorf("erreur lors de l'inspection du conteneur: %w", err) + } + + // Extraire les informations + labels := containerJSON.Config.Labels + env := parseEnvVars(containerJSON.Config.Env) + + port := "unknown" + if bindings, ok := containerJSON.NetworkSettings.Ports["5432/tcp"]; ok && len(bindings) > 0 { + port = bindings[0].HostPort + } + + dbName := env["POSTGRES_DB"] + dbUser := env["POSTGRES_USER"] + dbPassword := env["POSTGRES_PASSWORD"] + + status := "stopped" + if containerJSON.State.Running { + status = "running" + } + + createdAt, _ := time.Parse(time.RFC3339, labels["sovrabase.created_at"]) + + dbInfo := &DatabaseInfo{ + ProjectID: projectID, + ContainerID: containerJSON.ID, + ContainerName: containerName, + Status: status, + PostgresVersion: labels["sovrabase.version"], + Host: "localhost", + Port: port, + Database: dbName, + User: dbUser, + Password: dbPassword, + ConnectionString: fmt.Sprintf("postgresql://%s:%s@localhost:%s/%s?sslmode=disable", dbUser, dbPassword, port, dbName), + CreatedAt: createdAt, + } + + return dbInfo, nil +} + +// ListDatabases liste toutes les bases de données gérées +func (d *DockerOrchestrator) ListDatabases(ctx context.Context) ([]*DatabaseInfo, error) { + // Filtrer les conteneurs avec le label sovrabase.managed=true + filterArgs := filters.NewArgs() + filterArgs.Add("label", "sovrabase.managed=true") + filterArgs.Add("label", "sovrabase.type=postgres") + + containers, err := d.client.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return nil, fmt.Errorf("erreur lors de la liste des conteneurs: %w", err) + } + + databases := make([]*DatabaseInfo, 0, len(containers)) + for _, cont := range containers { + projectID := cont.Labels["sovrabase.project_id"] + if projectID == "" { + continue + } + + dbInfo, err := d.GetDatabaseInfo(ctx, projectID) + if err != nil { + // Logger l'erreur mais continuer + fmt.Printf("Warning: impossible de récupérer les infos pour %s: %v\n", projectID, err) + continue + } + + databases = append(databases, dbInfo) + } + + return databases, nil +} + +// DatabaseExists vérifie si une base de données existe +func (d *DockerOrchestrator) DatabaseExists(ctx context.Context, projectID string) (bool, error) { + containerName := fmt.Sprintf("sovrabase-db-%s", projectID) + + _, err := d.client.ContainerInspect(ctx, containerName) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, fmt.Errorf("erreur lors de la vérification: %w", err) + } + + return true, nil +} + +// Implémentations des méthodes pour KubernetesOrchestrator +func (k *KubernetesOrchestrator) CreateDatabase(ctx context.Context, projectID string, options *DatabaseOptions) (*DatabaseInfo, error) { + // TODO: Créer un StatefulSet PostgreSQL + return nil, fmt.Errorf("non implémenté pour Kubernetes") +} + +func (k *KubernetesOrchestrator) DeleteDatabase(ctx context.Context, projectID string) error { + // TODO: Supprimer le StatefulSet et PVC + return fmt.Errorf("non implémenté pour Kubernetes") +} + +func (k *KubernetesOrchestrator) GetDatabaseInfo(ctx context.Context, projectID string) (*DatabaseInfo, error) { + // TODO: Récupérer l'URL via le Service Kubernetes + return nil, fmt.Errorf("non implémenté pour Kubernetes") +} + +func (k *KubernetesOrchestrator) ListDatabases(ctx context.Context) ([]*DatabaseInfo, error) { + // TODO: Lister tous les StatefulSets de bases de données + return nil, fmt.Errorf("non implémenté pour Kubernetes") +} + +func (k *KubernetesOrchestrator) DatabaseExists(ctx context.Context, projectID string) (bool, error) { + // TODO: Vérifier l'existence du StatefulSet + return false, fmt.Errorf("non implémenté pour Kubernetes") +} + +// Fonctions utilitaires + +// waitForPostgres attend que PostgreSQL soit prêt +func (d *DockerOrchestrator) waitForPostgres(ctx context.Context, containerID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + // Vérifier si le conteneur est toujours en cours d'exécution + inspect, err := d.client.ContainerInspect(ctx, containerID) + if err != nil { + return fmt.Errorf("erreur lors de l'inspection: %w", err) + } + + if !inspect.State.Running { + return fmt.Errorf("le conteneur s'est arrêté de manière inattendue") + } + + // Tenter une connexion PostgreSQL via exec + execConfig := container.ExecOptions{ + Cmd: []string{"pg_isready", "-U", "postgres"}, + AttachStdout: true, + AttachStderr: true, + } + + execResp, err := d.client.ContainerExecCreate(ctx, containerID, execConfig) + if err == nil { + attachResp, err := d.client.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) + if err == nil { + attachResp.Close() + + execInspect, err := d.client.ContainerExecInspect(ctx, execResp.ID) + if err == nil && execInspect.ExitCode == 0 { + return nil // PostgreSQL est prêt + } + } + } + + time.Sleep(1 * time.Second) + } + + return fmt.Errorf("timeout en attendant que PostgreSQL démarre") +} + +// generatePassword génère un mot de passe sécurisé +func generatePassword(projectID string) string { + // Pour la production, utiliser crypto/rand + // Ici, génération simple pour l'exemple + return fmt.Sprintf("secure_%s_%d", projectID, time.Now().Unix()) +} + +// sanitizeDBName nettoie un nom pour l'utiliser comme nom de DB/user +func sanitizeDBName(name string) string { + // Remplacer les caractères non alphanumériques par des underscores + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, name) + + // Limiter à 63 caractères (limite PostgreSQL) + if len(result) > 63 { + result = result[:63] + } + + return strings.ToLower(result) +} + +// findAvailablePort trouve un port disponible +func findAvailablePort(ctx context.Context, cli *client.Client) int { + // Liste des ports utilisés + usedPorts := make(map[int]bool) + + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) + if err == nil { + for _, cont := range containers { + for _, port := range cont.Ports { + if port.PublicPort > 0 { + usedPorts[int(port.PublicPort)] = true + } + } + } + } + + // Chercher un port libre à partir de 5433 + for port := 5433; port < 6000; port++ { + if !usedPorts[port] { + return port + } + } + + return 5433 // Par défaut +} + +// parseMemory convertit une chaîne mémoire en bytes +func parseMemory(mem string) int64 { + // Exemples: "512m", "1g", "256M" + mem = strings.ToLower(strings.TrimSpace(mem)) + + multiplier := int64(1) + if strings.HasSuffix(mem, "k") { + multiplier = 1024 + mem = strings.TrimSuffix(mem, "k") + } else if strings.HasSuffix(mem, "m") { + multiplier = 1024 * 1024 + mem = strings.TrimSuffix(mem, "m") + } else if strings.HasSuffix(mem, "g") { + multiplier = 1024 * 1024 * 1024 + mem = strings.TrimSuffix(mem, "g") + } + + var value int64 + fmt.Sscanf(mem, "%d", &value) + return value * multiplier +} + +// parseCPUs convertit une chaîne CPU en nanoCPUs +func parseCPUs(cpus string) int64 { + // Exemple: "0.5" = 500000000 nanoCPUs + var value float64 + fmt.Sscanf(cpus, "%f", &value) + return int64(value * 1e9) +} + +// parseEnvVars convertit un tableau d'env vars en map +func parseEnvVars(envVars []string) map[string]string { + result := make(map[string]string) + for _, env := range envVars { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + } + return result +} diff --git a/internal/orchestrator/test_orchestrator_api.go b/internal/orchestrator/test_orchestrator_api.go new file mode 100644 index 0000000..d66bde5 --- /dev/null +++ b/internal/orchestrator/test_orchestrator_api.go @@ -0,0 +1,297 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/ketsuna-org/sovrabase/internal/config" +) + +// setupOrchestrator crée un orchestrateur pour les tests +func setupOrchestrator(t *testing.T) Orchestrator { + t.Helper() + + cfg, err := config.LoadConfig("../../config.yaml") + if err != nil { + t.Fatalf("Erreur de chargement de la config: %v", err) + } + + orch, err := NewOrchestrator(&cfg.Orchestrator) + if err != nil { + t.Fatalf("Erreur de création de l'orchestrateur: %v", err) + } + + return orch +} + +// cleanupDatabase supprime la base de données de test si elle existe +func cleanupDatabase(t *testing.T, orch Orchestrator, projectID string) { + t.Helper() + ctx := context.Background() + + exists, err := orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Logf("Avertissement lors de la vérification d'existence: %v", err) + return + } + + if exists { + if err := orch.DeleteDatabase(ctx, projectID); err != nil { + t.Logf("Avertissement lors du nettoyage: %v", err) + } + } +} + +func TestDatabaseExists(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-exists-project" + + defer cleanupDatabase(t, orch, projectID) + + // La base ne devrait pas exister initialement + exists, err := orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la vérification d'existence: %v", err) + } + if exists { + t.Error("La base de données ne devrait pas exister initialement") + } +} + +func TestCreateDatabase(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-create-project" + + defer cleanupDatabase(t, orch, projectID) + + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5434, + Memory: "512m", + CPUs: "0.5", + } + + dbInfo, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la création de la base de données: %v", err) + } + + if dbInfo.ProjectID != projectID { + t.Errorf("ProjectID attendu: %s, obtenu: %s", projectID, dbInfo.ProjectID) + } + + if dbInfo.Status == "" { + t.Error("Le status ne devrait pas être vide") + } + + if dbInfo.ConnectionString == "" { + t.Error("La chaîne de connexion ne devrait pas être vide") + } +} + +func TestGetDatabaseInfo(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-getinfo-project" + + defer cleanupDatabase(t, orch, projectID) + + // Créer d'abord une base de données + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5435, + Memory: "512m", + CPUs: "0.5", + } + + _, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la création de la base de données: %v", err) + } + + // Récupérer les informations + dbInfo, err := orch.GetDatabaseInfo(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la récupération des informations: %v", err) + } + + if dbInfo.ProjectID != projectID { + t.Errorf("ProjectID attendu: %s, obtenu: %s", projectID, dbInfo.ProjectID) + } + + if dbInfo.ContainerName == "" { + t.Error("Le nom du conteneur ne devrait pas être vide") + } +} + +func TestListDatabases(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-list-project" + + defer cleanupDatabase(t, orch, projectID) + + // Créer une base de données + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5436, + Memory: "512m", + CPUs: "0.5", + } + + _, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la création de la base de données: %v", err) + } + + // Lister les bases de données + databases, err := orch.ListDatabases(ctx) + if err != nil { + t.Fatalf("Erreur lors du listage des bases de données: %v", err) + } + + if len(databases) == 0 { + t.Error("Au moins une base de données devrait être listée") + } + + // Vérifier que notre base de données est dans la liste + found := false + for _, db := range databases { + if db.ProjectID == projectID { + found = true + break + } + } + + if !found { + t.Errorf("La base de données %s devrait être dans la liste", projectID) + } +} + +func TestCreateDatabaseConflict(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-conflict-project" + + defer cleanupDatabase(t, orch, projectID) + + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5437, + Memory: "512m", + CPUs: "0.5", + } + + // Première création + _, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la première création: %v", err) + } + + // Deuxième création (devrait échouer) + _, err = orch.CreateDatabase(ctx, projectID, options) + if err == nil { + t.Error("La création d'une base existante devrait échouer") + } +} + +func TestDeleteDatabase(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-delete-project" + + // Créer une base de données + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5438, + Memory: "512m", + CPUs: "0.5", + } + + _, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la création de la base de données: %v", err) + } + + // Supprimer la base de données + err = orch.DeleteDatabase(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la suppression: %v", err) + } + + // Vérifier que la base n'existe plus + exists, err := orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la vérification d'existence: %v", err) + } + + if exists { + t.Error("La base de données devrait avoir été supprimée") + } +} + +func TestDatabaseLifecycle(t *testing.T) { + orch := setupOrchestrator(t) + ctx := context.Background() + projectID := "test-lifecycle-project" + + defer cleanupDatabase(t, orch, projectID) + + // 1. Vérifier que la base n'existe pas + exists, err := orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la vérification d'existence: %v", err) + } + if exists { + t.Error("La base ne devrait pas exister initialement") + } + + // 2. Créer la base + options := &DatabaseOptions{ + PostgresVersion: "16-alpine", + Port: 5439, + Memory: "512m", + CPUs: "0.5", + } + + dbInfo, err := orch.CreateDatabase(ctx, projectID, options) + if err != nil { + t.Fatalf("Erreur lors de la création: %v", err) + } + + // 3. Vérifier que la base existe + exists, err = orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la vérification d'existence: %v", err) + } + if !exists { + t.Error("La base devrait exister après création") + } + + // 4. Récupérer les informations + dbInfo2, err := orch.GetDatabaseInfo(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la récupération des informations: %v", err) + } + + if dbInfo.ProjectID != dbInfo2.ProjectID { + t.Error("Les informations récupérées ne correspondent pas") + } + + // 5. Supprimer la base + err = orch.DeleteDatabase(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la suppression: %v", err) + } + + // 6. Vérifier que la base n'existe plus + exists, err = orch.DatabaseExists(ctx, projectID) + if err != nil { + t.Fatalf("Erreur lors de la vérification finale: %v", err) + } + if exists { + t.Error("La base ne devrait plus exister après suppression") + } +} diff --git a/pkg/proto/sovrabase.pb.go b/pkg/proto/sovrabase.pb.go deleted file mode 100644 index f3038e3..0000000 --- a/pkg/proto/sovrabase.pb.go +++ /dev/null @@ -1,215 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.10 -// protoc v6.32.1 -// source: proto/sovrabase.proto - -package sovrabase - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type ForwardCommandRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` - TargetNode string `protobuf:"bytes,2,opt,name=target_node,json=targetNode,proto3" json:"target_node,omitempty"` - Params map[string]string `protobuf:"bytes,3,rep,name=params,proto3" json:"params,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ForwardCommandRequest) Reset() { - *x = ForwardCommandRequest{} - mi := &file_proto_sovrabase_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ForwardCommandRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ForwardCommandRequest) ProtoMessage() {} - -func (x *ForwardCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_sovrabase_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ForwardCommandRequest.ProtoReflect.Descriptor instead. -func (*ForwardCommandRequest) Descriptor() ([]byte, []int) { - return file_proto_sovrabase_proto_rawDescGZIP(), []int{0} -} - -func (x *ForwardCommandRequest) GetCommand() string { - if x != nil { - return x.Command - } - return "" -} - -func (x *ForwardCommandRequest) GetTargetNode() string { - if x != nil { - return x.TargetNode - } - return "" -} - -func (x *ForwardCommandRequest) GetParams() map[string]string { - if x != nil { - return x.Params - } - return nil -} - -type ForwardCommandResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - Result string `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` - ErrorMessage string `protobuf:"bytes,3,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ForwardCommandResponse) Reset() { - *x = ForwardCommandResponse{} - mi := &file_proto_sovrabase_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ForwardCommandResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ForwardCommandResponse) ProtoMessage() {} - -func (x *ForwardCommandResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_sovrabase_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ForwardCommandResponse.ProtoReflect.Descriptor instead. -func (*ForwardCommandResponse) Descriptor() ([]byte, []int) { - return file_proto_sovrabase_proto_rawDescGZIP(), []int{1} -} - -func (x *ForwardCommandResponse) GetSuccess() bool { - if x != nil { - return x.Success - } - return false -} - -func (x *ForwardCommandResponse) GetResult() string { - if x != nil { - return x.Result - } - return "" -} - -func (x *ForwardCommandResponse) GetErrorMessage() string { - if x != nil { - return x.ErrorMessage - } - return "" -} - -var File_proto_sovrabase_proto protoreflect.FileDescriptor - -const file_proto_sovrabase_proto_rawDesc = "" + - "\n" + - "\x15proto/sovrabase.proto\x12\tsovrabase\"\xd3\x01\n" + - "\x15ForwardCommandRequest\x12\x18\n" + - "\acommand\x18\x01 \x01(\tR\acommand\x12\x1f\n" + - "\vtarget_node\x18\x02 \x01(\tR\n" + - "targetNode\x12D\n" + - "\x06params\x18\x03 \x03(\v2,.sovrabase.ForwardCommandRequest.ParamsEntryR\x06params\x1a9\n" + - "\vParamsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"o\n" + - "\x16ForwardCommandResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x16\n" + - "\x06result\x18\x02 \x01(\tR\x06result\x12#\n" + - "\rerror_message\x18\x03 \x01(\tR\ferrorMessage2n\n" + - "\x15ForwardCommandService\x12U\n" + - "\x0eForwardCommand\x12 .sovrabase.ForwardCommandRequest\x1a!.sovrabase.ForwardCommandResponseB\"Z github.com/ketsuna-org/sovrabaseb\x06proto3" - -var ( - file_proto_sovrabase_proto_rawDescOnce sync.Once - file_proto_sovrabase_proto_rawDescData []byte -) - -func file_proto_sovrabase_proto_rawDescGZIP() []byte { - file_proto_sovrabase_proto_rawDescOnce.Do(func() { - file_proto_sovrabase_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_sovrabase_proto_rawDesc), len(file_proto_sovrabase_proto_rawDesc))) - }) - return file_proto_sovrabase_proto_rawDescData -} - -var file_proto_sovrabase_proto_msgTypes = make([]protoimpl.MessageInfo, 3) -var file_proto_sovrabase_proto_goTypes = []any{ - (*ForwardCommandRequest)(nil), // 0: sovrabase.ForwardCommandRequest - (*ForwardCommandResponse)(nil), // 1: sovrabase.ForwardCommandResponse - nil, // 2: sovrabase.ForwardCommandRequest.ParamsEntry -} -var file_proto_sovrabase_proto_depIdxs = []int32{ - 2, // 0: sovrabase.ForwardCommandRequest.params:type_name -> sovrabase.ForwardCommandRequest.ParamsEntry - 0, // 1: sovrabase.ForwardCommandService.ForwardCommand:input_type -> sovrabase.ForwardCommandRequest - 1, // 2: sovrabase.ForwardCommandService.ForwardCommand:output_type -> sovrabase.ForwardCommandResponse - 2, // [2:3] is the sub-list for method output_type - 1, // [1:2] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_proto_sovrabase_proto_init() } -func file_proto_sovrabase_proto_init() { - if File_proto_sovrabase_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_sovrabase_proto_rawDesc), len(file_proto_sovrabase_proto_rawDesc)), - NumEnums: 0, - NumMessages: 3, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_proto_sovrabase_proto_goTypes, - DependencyIndexes: file_proto_sovrabase_proto_depIdxs, - MessageInfos: file_proto_sovrabase_proto_msgTypes, - }.Build() - File_proto_sovrabase_proto = out.File - file_proto_sovrabase_proto_goTypes = nil - file_proto_sovrabase_proto_depIdxs = nil -} diff --git a/pkg/proto/sovrabase_grpc.pb.go b/pkg/proto/sovrabase_grpc.pb.go deleted file mode 100644 index a25a392..0000000 --- a/pkg/proto/sovrabase_grpc.pb.go +++ /dev/null @@ -1,125 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v6.32.1 -// source: proto/sovrabase.proto - -package sovrabase - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - ForwardCommandService_ForwardCommand_FullMethodName = "/sovrabase.ForwardCommandService/ForwardCommand" -) - -// ForwardCommandServiceClient is the client API for ForwardCommandService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// ForwardCommand service -type ForwardCommandServiceClient interface { - ForwardCommand(ctx context.Context, in *ForwardCommandRequest, opts ...grpc.CallOption) (*ForwardCommandResponse, error) -} - -type forwardCommandServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewForwardCommandServiceClient(cc grpc.ClientConnInterface) ForwardCommandServiceClient { - return &forwardCommandServiceClient{cc} -} - -func (c *forwardCommandServiceClient) ForwardCommand(ctx context.Context, in *ForwardCommandRequest, opts ...grpc.CallOption) (*ForwardCommandResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ForwardCommandResponse) - err := c.cc.Invoke(ctx, ForwardCommandService_ForwardCommand_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ForwardCommandServiceServer is the server API for ForwardCommandService service. -// All implementations must embed UnimplementedForwardCommandServiceServer -// for forward compatibility. -// -// ForwardCommand service -type ForwardCommandServiceServer interface { - ForwardCommand(context.Context, *ForwardCommandRequest) (*ForwardCommandResponse, error) - mustEmbedUnimplementedForwardCommandServiceServer() -} - -// UnimplementedForwardCommandServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedForwardCommandServiceServer struct{} - -func (UnimplementedForwardCommandServiceServer) ForwardCommand(context.Context, *ForwardCommandRequest) (*ForwardCommandResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ForwardCommand not implemented") -} -func (UnimplementedForwardCommandServiceServer) mustEmbedUnimplementedForwardCommandServiceServer() {} -func (UnimplementedForwardCommandServiceServer) testEmbeddedByValue() {} - -// UnsafeForwardCommandServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ForwardCommandServiceServer will -// result in compilation errors. -type UnsafeForwardCommandServiceServer interface { - mustEmbedUnimplementedForwardCommandServiceServer() -} - -func RegisterForwardCommandServiceServer(s grpc.ServiceRegistrar, srv ForwardCommandServiceServer) { - // If the following call pancis, it indicates UnimplementedForwardCommandServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&ForwardCommandService_ServiceDesc, srv) -} - -func _ForwardCommandService_ForwardCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ForwardCommandRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ForwardCommandServiceServer).ForwardCommand(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ForwardCommandService_ForwardCommand_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ForwardCommandServiceServer).ForwardCommand(ctx, req.(*ForwardCommandRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ForwardCommandService_ServiceDesc is the grpc.ServiceDesc for ForwardCommandService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ForwardCommandService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "sovrabase.ForwardCommandService", - HandlerType: (*ForwardCommandServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "ForwardCommand", - Handler: _ForwardCommandService_ForwardCommand_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "proto/sovrabase.proto", -} diff --git a/proto/sovrabase.proto b/proto/sovrabase.proto deleted file mode 100644 index d8ef2d4..0000000 --- a/proto/sovrabase.proto +++ /dev/null @@ -1,22 +0,0 @@ -syntax = "proto3"; - -package sovrabase; - -option go_package = "github.com/ketsuna-org/sovrabase"; - -// ForwardCommand service -service ForwardCommandService { - rpc ForwardCommand(ForwardCommandRequest) returns (ForwardCommandResponse); -} - -message ForwardCommandRequest { - string command = 1; - string target_node = 2; - map<string, string> params = 3; -} - -message ForwardCommandResponse { - bool success = 1; - string result = 2; - string error_message = 3; -} |
