From 34213918fe1e96be1339b2b833fecfc87410e155 Mon Sep 17 00:00:00 2001 From: alessandro barucci Date: Sun, 11 Oct 2026 23:49:35 +0200 Subject: [PATCH] varie --- .../IDP Agent Platform scenario.txt | 154 +++++++++++ add-on/AgentPlatform/files.zip | Bin 0 -> 4912 bytes .../idp-architect-agent.yaml | 146 +++++++++++ .../idp-architect-slack-harness.yaml | 22 ++ .../idp-lista-template-schema-example.yaml | 15 ++ .../idp-template-schema-example.yaml | 86 +++++++ add-on/Database-mysql.svg | 45 ++++ add-on/laravel-512-511x400.png | Bin 0 -> 15927 bytes add-on/laravel.jpg | Bin 0 -> 29962 bytes add-on/monitoring/files/alert.sh | 52 ++++ add-on/mysql-database-operator/README.md | 88 +++++++ add-on/mysql-database-operator/SHA256SUMS | 23 ++ .../api/v1alpha1/groupversion_info.go | 15 ++ .../api/v1alpha1/mysqldatabase_types.go | 68 +++++ .../api/v1alpha1/zz_generated.deepcopy.go | 37 +++ add-on/mysql-database-operator/cmd/main.go | 45 ++++ .../config/crd/mysqldatabase-crd.yaml | 93 +++++++ .../controllers/mysqldatabase_controller.go | 242 ++++++++++++++++++ .../controllers/setup.go | 13 + .../examples/admin-secret.yaml | 9 + .../examples/mysql-database.yaml | 28 ++ .../helm/mysql-database-operator/Chart.yaml | 6 + .../templates/_helpers.tpl | 2 + .../templates/crd.yaml | 93 +++++++ .../templates/deployment.yaml | 28 ++ .../templates/namespace.yaml | 6 + .../templates/rbac.yaml | 33 +++ .../templates/serviceaccount.yaml | 5 + .../helm/mysql-database-operator/values.yaml | 16 ++ add-on/mysql.png | Bin 0 -> 13676 bytes pipeline/build_container.sh | 44 +++- pipeline/deploy.sh | 6 + 32 files changed, 1411 insertions(+), 9 deletions(-) create mode 100644 add-on/AgentPlatform/IDP Agent Platform scenario.txt create mode 100644 add-on/AgentPlatform/files.zip create mode 100644 add-on/AgentPlatform/platform-architect/idp-architect-agent.yaml create mode 100644 add-on/AgentPlatform/platform-architect/idp-architect-slack-harness.yaml create mode 100644 add-on/AgentPlatform/platform-architect/idp-lista-template-schema-example.yaml create mode 100644 add-on/AgentPlatform/platform-architect/idp-template-schema-example.yaml create mode 100644 add-on/Database-mysql.svg create mode 100644 add-on/laravel-512-511x400.png create mode 100644 add-on/laravel.jpg create mode 100644 add-on/monitoring/files/alert.sh create mode 100644 add-on/mysql-database-operator/README.md create mode 100644 add-on/mysql-database-operator/SHA256SUMS create mode 100644 add-on/mysql-database-operator/api/v1alpha1/groupversion_info.go create mode 100644 add-on/mysql-database-operator/api/v1alpha1/mysqldatabase_types.go create mode 100644 add-on/mysql-database-operator/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 add-on/mysql-database-operator/cmd/main.go create mode 100644 add-on/mysql-database-operator/config/crd/mysqldatabase-crd.yaml create mode 100644 add-on/mysql-database-operator/controllers/mysqldatabase_controller.go create mode 100644 add-on/mysql-database-operator/controllers/setup.go create mode 100644 add-on/mysql-database-operator/examples/admin-secret.yaml create mode 100644 add-on/mysql-database-operator/examples/mysql-database.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/Chart.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/_helpers.tpl create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/crd.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/deployment.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/namespace.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/rbac.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/templates/serviceaccount.yaml create mode 100644 add-on/mysql-database-operator/helm/mysql-database-operator/values.yaml create mode 100644 add-on/mysql.png diff --git a/add-on/AgentPlatform/IDP Agent Platform scenario.txt b/add-on/AgentPlatform/IDP Agent Platform scenario.txt new file mode 100644 index 0000000..727589d --- /dev/null +++ b/add-on/AgentPlatform/IDP Agent Platform scenario.txt @@ -0,0 +1,154 @@ +| | **Langflow** | **Kagent** | +| --------------- | -------------------------------------------- | --------------------------------------- | +| **Fase** | **Build time** | **Run time** | +| **Ruolo** | AI Agent Builder | AI Agent Runtime / Deployment | +| **Funzione** | Creare, configurare e testare agenti AI | Distribuire ed eseguire agenti AI | +| **Interfaccia** | Visuale / low-code | Kubernetes-native / YAML / CRD | +| **Deployment** | Può essere installato come servizio dell'IDP | È parte del runtime Kubernetes dell'IDP | +| **Output** | Flow/agent e relativo artefatto | Agent in esecuzione su Kubernetes | + +Quindi: + +### Langflow — Build Time + +> **Software per progettare, costruire e testare AI Agent.** + +Può essere installato **centralmente come servizio dell'IDP**, accessibile tramite browser dai developer. + +Il developer può costruire, ad esempio: + +```text +LLM + │ + ├── Knowledge Base + ├── MCP Tool + ├── API + └── Business Logic + │ + ▼ + Agent +``` + +Il risultato può poi diventare un **artefatto software versionabile e deployabile**. + +--- + +### Kagent — Run Time + +> **Piattaforma Kubernetes-native per il deployment e l'esecuzione degli AI Agent.** + +E qui la tua osservazione è importante: ci sono **due modalità**. + +#### 1. Agent semplice definito direttamente + +Per agenti relativamente semplici, la definizione può essere direttamente nella configurazione Kubernetes/Kagent: + +```yaml +kind: Agent +metadata: + name: my-agent +spec: + model: ... + tools: + - ... +``` + +Quindi: + +```text +YAML + ↓ +Kagent + ↓ +Agent Runtime + ↓ +Kubernetes +``` + +#### 2. Agent sviluppato con Langflow + +Per agenti più complessi: + +```text +Langflow + │ + │ Build + ▼ +Agent Artifact + │ + │ CI/CD + ▼ +Registry / Git + │ + │ Deploy + ▼ +Kagent + │ + ▼ +Kubernetes + │ + ▼ +Running Agent +``` + +### La precisazione più importante + +Direi però **"artefatti costruiti con Langflow"** piuttosto che: + +> "Kagent esegue Langflow" + +perché sono due cose diverse. + +Kagent **non è il runtime di Langflow**. Il concetto corretto è: + +> **Langflow è uno strumento di authoring/build; Kagent è uno strumento di deployment/runtime.** + +L'artefatto prodotto da Langflow deve essere compatibile con il modello di deployment/runtime che hai scelto. + +--- + +### Quindi il modello del tuo IDP diventa molto chiaro + +```text + IDP + │ + ┌──────────┴──────────┐ + │ │ + BUILD TIME RUN TIME + │ │ + ┌─────────┐ ┌─────────┐ + │Langflow │ │ Kagent │ + │ │ │ │ + │ Build │──────────►│ Deploy │ + │ Test │ Artifact │ Execute │ + └─────────┘ └────┬────┘ + │ + ▼ + Kubernetes +``` + +E aggiungerei un **terzo livello trasversale**: + +```text + IDP GOVERNANCE + ┌────────────────────────────┐ + │ Security │ + │ RBAC │ + │ Secrets │ + │ Network Policy │ + │ Resource Quotas │ + │ Approved Models │ + │ Approved MCP │ + │ Versioning │ + │ CI/CD │ + │ Observability │ + │ Compliance │ + └────────────────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ + Langflow Kagent + Build Time Run Time +``` + +**Questa secondo me è la rappresentazione più corretta per il tuo progetto IDP:** Langflow e Kagent non sono concorrenti, ma **componenti di due fasi diverse del lifecycle dell'Agent**. diff --git a/add-on/AgentPlatform/files.zip b/add-on/AgentPlatform/files.zip new file mode 100644 index 0000000000000000000000000000000000000000..017c57d377d91b38a1c97deacc3a405dace60201 GIT binary patch literal 4912 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 literal 0 HcmV?d00001 diff --git a/add-on/AgentPlatform/platform-architect/idp-architect-agent.yaml b/add-on/AgentPlatform/platform-architect/idp-architect-agent.yaml new file mode 100644 index 0000000..2bc22df --- /dev/null +++ b/add-on/AgentPlatform/platform-architect/idp-architect-agent.yaml @@ -0,0 +1,146 @@ +# ===================================================================== +# ToolServer — Gitea MCP (ufficiale), token con permessi SOLO lettura +# ===================================================================== +apiVersion: kagent.dev/v1alpha2 +kind: ToolServer +metadata: + name: gitea-mcp + namespace: platform-architect +spec: + type: RemoteMCPServer + remoteMCPServer: + url: "http://gitea-mcp.platform-tools.svc.cluster.local:8080" + # Il token Gitea sottostante (configurato nel deployment di + # gitea-mcp, non qui) deve avere SOLO permesso di lettura + # (repo:read) sull'organizzazione idp-templates — l'agente non + # deve mai poter scrivere/modificare i template + +--- +# ===================================================================== +# Agent dichiarativo — l'architetto esperto della IDP +# ===================================================================== +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: idp-architect + namespace: platform-architect +spec: + description: > + Architetto esperto della Internal Developer Platform. Analizza le + specifiche applicative di un dev team e propone lo scenario + architetturale compliant, basandosi sul catalogo ufficiale dei + template della piattaforma. + type: Declarative + declarative: + modelConfig: gpt4o-config + + a2aConfig: + skills: + - id: propose-architecture + name: Propose Architecture + description: > + Data una specifica applicativa, propone il template IDP + più adatto con motivazione e piano di customizzazione. + inputModes: ["text"] + outputModes: ["text"] + tags: ["architecture", "idp", "platform-engineering"] + + systemMessage: | + # Ruolo + + Sei l'architetto esperto della Internal Developer Platform (IDP) + di pigreco66.it. Il tuo compito è aiutare i dev team a scegliere + e adottare il template architetturale più corretto tra quelli + ufficialmente supportati dalla piattaforma, a fronte delle loro + specifiche applicative. + + Il tuo obiettivo non è solo tecnico: sei anche lo strumento + principale di ADOPTION della piattaforma. Un dev team che parla + con te deve percepire che usare l'IDP è PIÙ VELOCE e PIÙ SICURO + che costruire da zero, non un vincolo burocratico. + + # Fonte di verità — NON INVENTARE TEMPLATE + + L'unica fonte di verità sui template disponibili è il catalogo + ufficiale, accessibile tramite i tool Gitea MCP: + + 1. Leggi SEMPRE prima l'indice aggregato: + repo "idp-templates-catalog", file "catalog-index.yaml" + 2. Per i template candidati migliori (massimo 2-3), leggi il + dettaglio completo nel rispettivo repo, file + "idp-template.yaml", per verificare compliance, parametri e + vincoli (not_suitable_for) prima di confermare la proposta. + + Non proporre MAI un'architettura o un componente che non esiste + nel catalogo. Se nessun template è adeguato, dillo esplicitamente + (vedi sezione "Quando non c'è un template adatto"). + + # Processo da seguire + + 1. **Raccogli le specifiche**: se l'input del dev team è + incompleto (manca almeno una di: tipo di workload, + persistenza dati, pattern di comunicazione, volumetria + attesa, classificazione dati/PII), fai domande di + chiarimento mirate PRIMA di proporre. Non indovinare requisiti + critici per sicurezza/compliance. + + 2. **Fai matching contro il catalogo** usando i tool Gitea MCP + come descritto sopra. + + 3. **Verifica i servizi cross della piattaforma**: controlla il + campo "cross_platform_services" del template candidato — se + il dev team descrive un bisogno già coperto da un servizio + condiviso (es. "mi serve una cache" quando esiste + shared-redis-cache), segnalalo esplicitamente: NON serve + che lo costruiscano, è già disponibile. + + 4. **Proponi**, con questa struttura: + - Template consigliato (id + nome + link al repo) + - Perché è compliant con le specifiche (motivazione tecnica) + - Eventuali servizi cross della piattaforma da riusare + - Parametri da personalizzare (dai file in env/) + - Eventuali gap o customizzazioni necessarie oltre al + template base + - Se pertinente, alternative scartate e perché + + 5. Se richiesto, genera il comando/istruzioni operative per + clonare/istanziare il template (basati sulla struttura reale + del repo: kubernetes/, containers/, src/, env/). + + # Quando NON c'è un template adatto + + Se nessun template del catalogo copre adeguatamente le + specifiche (es. gap reale, non solo mancanza di dettagli), + dillo chiaramente al dev team e segnala che si tratta di un + caso da portare al platform engineering team per valutare + l'introduzione di un nuovo template. NON improvvisare + un'architettura custom spacciandola per "compliant" — altrimenti + perdi la fiducia che è il tuo scopo costruire. + + # Perimetro di azione — SOLO CONSULTIVO + + In questa fase operi in modalità puramente consultiva: la tua + proposta è testuale e informativa, non esegui mai direttamente + azioni che modificano repository, pipeline o infrastruttura + (non crei repo, non apri PR, non modifichi file). Se il dev team + ti chiede di "farlo per loro" (es. "creamelo tu il repo"), + rispondi spiegando i passi operativi che devono seguire loro + stessi (clonare il template, personalizzare env/), senza + eseguirli al posto loro. + + # Tono + + Sei un collega esperto, non un gatekeeper burocratico. Spiega + il "perché" delle scelte, non solo il "cosa" — un dev team che + capisce il ragionamento adotta la piattaforma più volentieri di + uno a cui viene solo imposto un verdetto. + + tools: + - type: McpServer + mcpServer: + name: gitea-mcp + kind: RemoteMCPServer + toolNames: + - get_file_content + - get_dir_content + - list_repo_commits diff --git a/add-on/AgentPlatform/platform-architect/idp-architect-slack-harness.yaml b/add-on/AgentPlatform/platform-architect/idp-architect-slack-harness.yaml new file mode 100644 index 0000000..3c10766 --- /dev/null +++ b/add-on/AgentPlatform/platform-architect/idp-architect-slack-harness.yaml @@ -0,0 +1,22 @@ +apiVersion: kagent.dev/v1alpha2 +kind: AgentHarness +metadata: + name: idp-architect-slack + namespace: platform-architect +spec: + backend: hermes + agentRef: + name: idp-architect + namespace: platform-architect + channels: + - name: idp-architect-chat + type: slack + slack: + botToken: + valueFrom: { type: Secret, name: slack-tokens, key: bot-token } + appToken: + valueFrom: { type: Secret, name: slack-tokens, key: app-token } + # Canale dedicato consigliato: #idp-architect-support + # così tutti i dev team sanno dove chiedere, e le conversazioni + # restano visibili/searchable per il resto del team (effetto + # knowledge-sharing, non solo 1:1 privato) diff --git a/add-on/AgentPlatform/platform-architect/idp-lista-template-schema-example.yaml b/add-on/AgentPlatform/platform-architect/idp-lista-template-schema-example.yaml new file mode 100644 index 0000000..5d9a804 --- /dev/null +++ b/add-on/AgentPlatform/platform-architect/idp-lista-template-schema-example.yaml @@ -0,0 +1,15 @@ +# idp-templates-catalog/catalog-index.yaml +templates: + - id: tpl-java-springboot-rest-api + name: "Java Spring Boot — REST API sincrona" + workload_type: [sync-api, rest] + tags: [java, spring-boot, rest, sync, stateless] + status: active + repo_url: "https://gitea.pigreco66.it/idp-templates/java-springboot-rest-api" + - id: tpl-node-kafka-consumer + name: "Node.js Kafka Consumer" + workload_type: [async-consumer, event-driven] + tags: [nodejs, kafka, async] + status: active + repo_url: "..." + # ... altri 18 \ No newline at end of file diff --git a/add-on/AgentPlatform/platform-architect/idp-template-schema-example.yaml b/add-on/AgentPlatform/platform-architect/idp-template-schema-example.yaml new file mode 100644 index 0000000..9289d86 --- /dev/null +++ b/add-on/AgentPlatform/platform-architect/idp-template-schema-example.yaml @@ -0,0 +1,86 @@ +# idp-template.yaml — da posizionare nella root di ogni repo Gitea +# di tipo "template". Questo è l'esempio compilato per un template +# "REST API sincrona Java/Spring Boot". + +id: tpl-java-springboot-rest-api +name: "Java Spring Boot — REST API sincrona" +version: "2.1.0" +status: active # active | beta | deprecated +last_updated: "2026-09-01" +maintainer: "platform-engineering@pigreco66.it" +repo_url: "https://gitea.pigreco66.it/idp-templates/java-springboot-rest-api" + +# --- Classificazione, usata dall'agente per il primo filtro --- +category: api-service +workload_type: + - sync-api + - rest + +tech_stack: + language: Java 21 + framework: Spring Boot 3.3 + build_tool: Maven + +tags: ["java", "spring-boot", "rest", "sync", "stateless"] + +description: > + Template per esporre una REST API sincrona stateless. Include + healthcheck, metrics endpoint Prometheus-ready, struttura a layer + controller/service/repository, logging strutturato JSON. + +# --- Cosa contiene esattamente la struttura del repo --- +components_included: + kubernetes: + - Deployment (con probe readiness/liveness preconfigurate) + - Service (ClusterIP) + - HorizontalPodAutoscaler + - NetworkPolicy (default-deny + regole minime) + containers: + - Dockerfile multi-stage (build + runtime minimale distroless) + src: + - hello-world Spring Boot funzionante, con un endpoint /health e + un endpoint di esempio /api/v1/ping + env: + - application-dev.properties + - application-staging.properties + - application-prod.properties + +# --- Integrazione con servizi cross della piattaforma --- +# Fondamentale: dice all'agente COSA NON riproporre perché già +# disponibile centralmente (evita duplicazione di servizi) +cross_platform_services: + - service: shared-postgres + how: "via env PLATFORM_DB_CONNECTION_STRING, iniettata dalla pipeline" + - service: shared-redis-cache + how: "opzionale, abilitabile via env ENABLE_CACHE=true" + - service: centralized-logging + how: "automatico, nessuna configurazione richiesta" + +# --- Vincoli di compliance soddisfatti dal template --- +compliance: + data_classification_supported: ["public", "internal", "confidential"] + # NON supporta dati PII senza customizzazioni aggiuntive + pii_ready: false + network_policy_baseline: "default-deny" + security_scanning: "incluso in pipeline (SAST + container scan)" + +scaling: + default_replicas: 2 + autoscaling: hpa + min_replicas: 2 + max_replicas: 10 + +# --- Parametri personalizzabili (quelli nei file in env/) --- +parameters: + - name: PLATFORM_DB_CONNECTION_STRING + required: false + description: "Richiesta solo se il servizio necessita persistenza" + - name: ENABLE_CACHE + required: false + default: "false" + +# --- Quando NON usare questo template (altrettanto utile del 'quando sì') --- +not_suitable_for: + - "Workload asincroni basati su eventi (usa tpl-java-kafka-consumer)" + - "Batch job schedulati (usa tpl-batch-job)" + - "Dati classificati come PII senza data masking aggiuntivo" diff --git a/add-on/Database-mysql.svg b/add-on/Database-mysql.svg new file mode 100644 index 0000000..39feb91 --- /dev/null +++ b/add-on/Database-mysql.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/add-on/laravel-512-511x400.png b/add-on/laravel-512-511x400.png new file mode 100644 index 0000000000000000000000000000000000000000..b4fb226af32014af367f38c20126ccb51c75c502 GIT binary patch literal 15927 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 literal 0 HcmV?d00001 diff --git a/add-on/laravel.jpg b/add-on/laravel.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae31432477cb2f078aeb4dbea86cb3024adfa949 GIT binary patch literal 29962 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 blackbox-email-alert.yaml <<'EOF' +apiVersion: monitoring.coreos.com/v1alpha1 +kind: AlertmanagerConfig +metadata: + name: blackbox-email-routing + namespace: monitoring + labels: + release: kube-prometheus-stack +spec: + route: + groupBy: ["alertname", "instance"] + groupWait: 30s + groupInterval: 5m + repeatInterval: 1h + receiver: "email-default" + routes: + - matchers: + - name: alertname + value: BlackboxHttpProbeFailed + matchType: "=" + - name: instance + value: "https://idcidp.pigreco66.it" + matchType: "=" + receiver: "email-servizio-idcidp" + + receivers: + - name: "email-default" + emailConfigs: [] + + - name: "email-servizio-idcidp" + emailConfigs: + - to: "alessandro.barucci66@gmail.com" + from: "alert@italiadatacenter.com" + smarthost: "mail.tophost.it:587" + authUsername: "italiadatacenter.com62374" + authPassword: + name: alertmanager-smtp + key: password + requireTLS: true + sendResolved: true + headers: + - key: subject + value: "[ALERT] Servizio IDCIDP non raggiungibile" + html: | +

Il servizio {{ .CommonLabels.instance }} non risponde.

+

{{ .CommonAnnotations.description }}

+EOF + +kubectl apply -f blackbox-email-alert.yaml \ No newline at end of file diff --git a/add-on/mysql-database-operator/README.md b/add-on/mysql-database-operator/README.md new file mode 100644 index 0000000..5b6afc5 --- /dev/null +++ b/add-on/mysql-database-operator/README.md @@ -0,0 +1,88 @@ +# MySQL Database Operator + +A small Kubernetes controller that provides a declarative `MySQLDatabase` CRD on top of an existing MySQL service, including Oracle MySQL Operator / InnoDB Cluster deployments. + +## Scope + +This MVP manages: + +- `CREATE DATABASE IF NOT EXISTS` +- optional application user creation +- application credentials Secret generation +- database-scoped grants +- `Retain` or `Delete` deletion policy +- status/conditions + +It does **not** create or manage the MySQL/InnoDBCluster itself. The Oracle MySQL Operator remains responsible for the MySQL infrastructure. + +## Important production notes + +1. `adminSecretRef` must contain `username` and `password`. +2. The controller currently connects to the host in `spec.connection.host`; if omitted it uses `spec.clusterRef.name`. +3. The generated application Secret contains `username`, `password`, and `database`. +4. The user is created as `'user'@'%'` because Kubernetes workloads normally connect over the cluster network. Tighten this later if your network model supports it. +5. The controller intentionally supports a small allow-list of privileges; do not accept arbitrary SQL from the CRD. +6. For production, add TLS support, NetworkPolicy, Secret rotation policy, metrics, admission validation, and tests before broad multi-team rollout. + +## Build + +```bash +go mod tidy +go build ./cmd + +docker build -t harbor.example.com/platform/mysql-database-operator:0.1.0 . +docker push harbor.example.com/platform/mysql-database-operator:0.1.0 +``` + +## Deploy on RKE2 + +```bash +kubectl create namespace mysql-database-operator-system +kubectl apply -f config/crd/mysqldatabase-crd.yaml +kubectl apply -f deploy/rbac.yaml +sed 's#ghcr.io/example/mysql-database-operator:0.1.0#harbor.example.com/platform/mysql-database-operator:0.1.0#' deploy/deployment.yaml | kubectl apply -f - +``` + +Or Helm: + +```bash +helm upgrade --install mysql-database-operator ./helm/mysql-database-operator \ + --namespace mysql-database-operator-system \ + --create-namespace \ + --set image.repository=harbor.example.com/platform/mysql-database-operator \ + --set image.tag=0.1.0 +``` + +Then create the admin Secret and database: + +```bash +kubectl create namespace team-a +kubectl apply -f examples/admin-secret.yaml +kubectl apply -f examples/mysql-database.yaml +kubectl get mysqldatabase -n team-a +kubectl describe mysqldatabase orders -n team-a +kubectl get secret orders-db-credentials -n team-a +``` + +## Oracle MySQL Operator integration + +Example architecture: + +```text +InnoDBCluster mysql-prod + | + | Service mysql-prod:3306 + v +MySQLDatabase orders + | + +-- CREATE DATABASE orders + +-- CREATE USER orders-app + +-- GRANT SELECT, INSERT, UPDATE, DELETE + +-- Secret orders-db-credentials +``` + +Use the actual Router/primary Service exposed by your Oracle MySQL Operator installation in `spec.connection.host` if it is not the same as `clusterRef.name`. + +## Verification + +The source tree was formatted with `gofmt`. Full `go test ./...` could not be executed in this environment because external Go module downloads are disabled; run `go mod tidy && go test ./...` in a network-enabled build environment before publishing the image. diff --git a/add-on/mysql-database-operator/SHA256SUMS b/add-on/mysql-database-operator/SHA256SUMS new file mode 100644 index 0000000..415a635 --- /dev/null +++ b/add-on/mysql-database-operator/SHA256SUMS @@ -0,0 +1,23 @@ +bcdb6dd502904deb1982acff76dbcecef61fe64c1edce33dbc535fe714809d0f ./Dockerfile +811616c4bfe6df79795d4cfe845926b9e1af47501421d092f67bf511f4cdd673 ./README.md +ffe83a66f937391f9c6b79450f5161095bdba5c794838b2bc8965c715c5849ca ./api/v1alpha1/groupversion_info.go +12f4d95346545e4dd390dcd5718ae0e5104ffd3e6153b9ead6bfaf78e7293d09 ./api/v1alpha1/mysqldatabase_types.go +89e9b851001dcb3a3bf88de546fbde273d9cab9951b389d9a07bf106adc570f5 ./api/v1alpha1/zz_generated.deepcopy.go +08be703122880eb86372acdbe9d560280f9c2428cf2279cc05061bbf79cb8836 ./cmd/main.go +78813f202aa5a135a25f1c1524fdc8e09174f13f601985923a00cf6d9fcc6f28 ./config/crd/mysqldatabase-crd.yaml +c9f3d6c121d19bf780f6a35b69eb242202164c38fc201ad67f6789e363620bf4 ./controllers/mysqldatabase_controller.go +b164fdebc25270c033e6b5608a6b7eb005007757913ff3f7dea692f2ff306777 ./controllers/setup.go +75d21caeb9f41976855fa8421e07f31cee39877844ca30d9f0a3104863090458 ./deploy/deployment.yaml +18f1896539466ce1d0707ca42877d94e97b6e50af72924ecb236cbc276dd4690 ./deploy/namespace.yaml +0de7361b72bcd016c49d44e1bc1a292d6ceba4ab2836218732bd133380151472 ./deploy/rbac.yaml +f31d3c24dc3f666ddd62af0bc8dde2b2623edfa370099e4b759e3cfc87a40cf5 ./examples/admin-secret.yaml +9f954df207eebfa0f1551bdeb05ec97f33ea7184a8c69aad7526b25ffb98cb99 ./examples/mysql-database.yaml +a17f89658ffb2d16a6c64e6bae3a99f8b3fc5130a6c00fdeb6bbd231cde0488f ./go.mod +3266515bc31fd4465da3eacc34d98ddbb18e67444d855438c683a8875f7bce03 ./helm/mysql-database-operator/Chart.yaml +dcb57bf001e957e3abad642ca56c76ba2b65b6c6419dc9148b95be3293f29a4b ./helm/mysql-database-operator/templates/_helpers.tpl +78813f202aa5a135a25f1c1524fdc8e09174f13f601985923a00cf6d9fcc6f28 ./helm/mysql-database-operator/templates/crd.yaml +55159600ff11ee14ab542d6ad647129fb1321fc591e17693340d481af4467ab2 ./helm/mysql-database-operator/templates/deployment.yaml +dd8a29aa027dfc40a77f295fd4d70ab950c14bcc8a3c8bf7efc98f2ff77ee75d ./helm/mysql-database-operator/templates/namespace.yaml +e06e41e699589b14609bb5f32555c01851ba4a7e514d647f3191623832e09e01 ./helm/mysql-database-operator/templates/rbac.yaml +107ddf20ea52d35fbd2dfbbbcadc86e8848b6013566e4fd4229326d9087cce7e ./helm/mysql-database-operator/templates/serviceaccount.yaml +8d6267287c8b41e1c30b11d13c73273aaf48d2a69f717ea0de1f7dda72a26c78 ./helm/mysql-database-operator/values.yaml diff --git a/add-on/mysql-database-operator/api/v1alpha1/groupversion_info.go b/add-on/mysql-database-operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000..c815250 --- /dev/null +++ b/add-on/mysql-database-operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,15 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var GroupVersion = schema.GroupVersion{Group: "platform.example.com", Version: "v1alpha1"} +var SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} +var AddToScheme = SchemeBuilder.AddToScheme + +func init() { SchemeBuilder.Register(&MySQLDatabase{}, &MySQLDatabaseList{}) } + +var _ = metav1.Now diff --git a/add-on/mysql-database-operator/api/v1alpha1/mysqldatabase_types.go b/add-on/mysql-database-operator/api/v1alpha1/mysqldatabase_types.go new file mode 100644 index 0000000..ba991a2 --- /dev/null +++ b/add-on/mysql-database-operator/api/v1alpha1/mysqldatabase_types.go @@ -0,0 +1,68 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// MySQLDatabaseSpec defines the desired state of a logical MySQL database. +type MySQLDatabaseSpec struct { + ClusterRef ClusterReference `json:"clusterRef"` + DatabaseName string `json:"databaseName"` + AdminSecretRef SecretReference `json:"adminSecretRef"` + Connection ConnectionSpec `json:"connection,omitempty"` + User *UserSpec `json:"user,omitempty"` + Credentials *CredentialsSpec `json:"credentials,omitempty"` + Privileges []string `json:"privileges,omitempty"` + Charset string `json:"charset,omitempty"` + Collation string `json:"collation,omitempty"` + DeletionPolicy string `json:"deletionPolicy,omitempty"` +} + +type ClusterReference struct { + Name string `json:"name"` +} +type SecretReference struct { + Name string `json:"name"` +} + +type ConnectionSpec struct { + Host string `json:"host,omitempty"` + Port int32 `json:"port,omitempty"` +} + +type UserSpec struct { + Name string `json:"name"` + Create bool `json:"create,omitempty"` +} + +type CredentialsSpec struct { + SecretName string `json:"secretName"` +} + +type MySQLDatabaseStatus struct { + Phase string `json:"phase,omitempty"` + Message string `json:"message,omitempty"` + DatabaseName string `json:"databaseName,omitempty"` + UserName string `json:"userName,omitempty"` + SecretName string `json:"secretName,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Database",type="string",JSONPath=".status.databaseName" +// +kubebuilder:printcolumn:name="User",type="string",JSONPath=".status.userName" +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +type MySQLDatabase struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec MySQLDatabaseSpec `json:"spec"` + Status MySQLDatabaseStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +type MySQLDatabaseList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []MySQLDatabase `json:"items"` +} diff --git a/add-on/mysql-database-operator/api/v1alpha1/zz_generated.deepcopy.go b/add-on/mysql-database-operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..a005213 --- /dev/null +++ b/add-on/mysql-database-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,37 @@ +// Code generated by controller-gen style deepcopy implementation. DO NOT EDIT. +package v1alpha1 + +func (in *MySQLDatabase) DeepCopyInto(out *MySQLDatabase) { + *out = *in + out.ObjectMeta = *in.ObjectMeta.DeepCopy() + out.Status = in.Status + if in.Status.Conditions != nil { + out.Status.Conditions = make([]metav1.Condition, len(in.Status.Conditions)) + copy(out.Status.Conditions, in.Status.Conditions) + } +} +func (in *MySQLDatabase) DeepCopy() *MySQLDatabase { + if in == nil { + return nil + } + out := new(MySQLDatabase) + in.DeepCopyInto(out) + return out +} +func (in *MySQLDatabaseList) DeepCopyInto(out *MySQLDatabaseList) { + *out = *in + if in.Items != nil { + out.Items = make([]MySQLDatabase, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} +func (in *MySQLDatabaseList) DeepCopy() *MySQLDatabaseList { + if in == nil { + return nil + } + out := new(MySQLDatabaseList) + in.DeepCopyInto(out) + return out +} diff --git a/add-on/mysql-database-operator/cmd/main.go b/add-on/mysql-database-operator/cmd/main.go new file mode 100644 index 0000000..a93b175 --- /dev/null +++ b/add-on/mysql-database-operator/cmd/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "github.com/example/mysql-database-operator/api/v1alpha1" + "github.com/example/mysql-database-operator/controllers" + "k8s.io/apimachinery/pkg/runtime" + "os" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +func main() { + var metricsAddr, probeAddr string + var leader bool + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "metrics address") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "health probe address") + flag.BoolVar(&leader, "leader-elect", true, "enable leader election") + var zo zap.Options + zo.BindFlags(flag.CommandLine) + flag.Parse() + controller.SetLogger(zap.New(zap.UseFlagOptions(&zo))) + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{Scheme: scheme, Metrics: server.Options{BindAddress: metricsAddr}, HealthProbeBindAddress: probeAddr, LeaderElection: leader, LeaderElectionID: "mysql-database-operator"}) + if err != nil { + os.Exit(1) + } + if err := (&controllers.MySQLDatabaseReconciler{Client: mgr.GetClient(), Scheme: scheme}).SetupWithManager(mgr); err != nil { + os.Exit(1) + } + if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil { + os.Exit(1) + } + if err := mgr.AddReadyzCheck("ready", healthz.Ping); err != nil { + os.Exit(1) + } + if err := mgr.Start(controller.SetupSignalHandler()); err != nil { + os.Exit(1) + } +} diff --git a/add-on/mysql-database-operator/config/crd/mysqldatabase-crd.yaml b/add-on/mysql-database-operator/config/crd/mysqldatabase-crd.yaml new file mode 100644 index 0000000..0fe94e0 --- /dev/null +++ b/add-on/mysql-database-operator/config/crd/mysqldatabase-crd.yaml @@ -0,0 +1,93 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: mysqldatabases.platform.example.com +spec: + group: platform.example.com + scope: Namespaced + names: + plural: mysqldatabases + singular: mysqldatabase + kind: MySQLDatabase + shortNames: [mysqldb] + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Database + type: string + jsonPath: .status.databaseName + - name: User + type: string + jsonPath: .status.userName + - name: Phase + type: string + jsonPath: .status.phase + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [clusterRef,databaseName,adminSecretRef] + properties: + clusterRef: + type: object + required: [name] + properties: {name: {type: string}} + databaseName: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[A-Za-z0-9_$-]+$' + adminSecretRef: + type: object + required: [name] + properties: {name: {type: string}} + connection: + type: object + properties: + host: {type: string} + port: {type: integer, minimum: 1, maximum: 65535} + user: + type: object + properties: + name: {type: string, maxLength: 32} + create: {type: boolean} + credentials: + type: object + properties: + secretName: {type: string} + privileges: + type: array + items: {type: string} + charset: {type: string} + collation: {type: string} + deletionPolicy: + type: string + enum: [Retain, Delete] + default: Retain + status: + type: object + properties: + phase: {type: string} + message: {type: string} + databaseName: {type: string} + userName: {type: string} + secretName: {type: string} + observedGeneration: {type: integer, format: int64} + conditions: + type: array + items: + type: object + required: [type,status,reason,message,lastTransitionTime] + properties: + type: {type: string} + status: {type: string} + reason: {type: string} + message: {type: string} + lastTransitionTime: {type: string, format: date-time} diff --git a/add-on/mysql-database-operator/controllers/mysqldatabase_controller.go b/add-on/mysql-database-operator/controllers/mysqldatabase_controller.go new file mode 100644 index 0000000..585916d --- /dev/null +++ b/add-on/mysql-database-operator/controllers/mysqldatabase_controller.go @@ -0,0 +1,242 @@ +package controllers + +import ( + "context" + "crypto/rand" + "database/sql" + "fmt" + "math/big" + "strings" + "time" + + platformv1alpha1 "github.com/example/mysql-database-operator/api/v1alpha1" + _ "github.com/go-sql-driver/mysql" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const finalizer = "platform.example.com/mysqldatabase-finalizer" + +type MySQLDatabaseReconciler struct{ client.Client } + +func (r *MySQLDatabaseReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx) + var obj platformv1alpha1.MySQLDatabase + if err := r.Get(ctx, req.NamespacedName, &obj); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, err + } + if obj.Spec.DatabaseName == "" { + return r.setError(ctx, &obj, "InvalidSpec", "databaseName is required") + } + if obj.Spec.AdminSecretRef.Name == "" { + return r.setError(ctx, &obj, "InvalidSpec", "adminSecretRef.name is required") + } + + if !obj.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(&obj, finalizer) { + return reconcile.Result{}, nil + } + if strings.EqualFold(obj.Spec.DeletionPolicy, "Delete") { + if err := r.withDB(ctx, &obj, func(db *sql.DB) error { + _, err := db.ExecContext(ctx, "DROP DATABASE IF EXISTS `"+quoteIdent(obj.Spec.DatabaseName)+"`") + return err + }); err != nil { + return reconcile.Result{RequeueAfter: 10 * time.Second}, err + } + } + controllerutil.RemoveFinalizer(&obj, finalizer) + return reconcile.Result{}, r.Update(ctx, &obj) + } + if !controllerutil.ContainsFinalizer(&obj, finalizer) { + controllerutil.AddFinalizer(&obj, finalizer) + return reconcile.Result{}, r.Update(ctx, &obj) + } + if err := r.withDB(ctx, &obj, func(db *sql.DB) error { return ensureDatabase(ctx, db, &obj) }); err != nil { + return r.setError(ctx, &obj, "ConnectionError", err.Error()) + } + if obj.Spec.User != nil && obj.Spec.User.Create { + username := obj.Spec.User.Name + if username == "" { + return r.setError(ctx, &obj, "InvalidSpec", "user.name is required when user.create is true") + } + password, err := r.ensureCredentials(ctx, &obj, username) + if err != nil { + return r.setError(ctx, &obj, "CredentialsError", err.Error()) + } + if err := r.withDB(ctx, &obj, func(db *sql.DB) error { return ensureUserAndGrants(ctx, db, &obj, password) }); err != nil { + return r.setError(ctx, &obj, "UserError", err.Error()) + } + } + obj.Status.Phase = "Ready" + obj.Status.Message = "Database reconciled" + obj.Status.DatabaseName = obj.Spec.DatabaseName + if obj.Spec.User != nil { + obj.Status.UserName = obj.Spec.User.Name + } + if obj.Spec.Credentials != nil { + obj.Status.SecretName = obj.Spec.Credentials.SecretName + } + obj.Status.ObservedGeneration = obj.Generation + obj.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue, Reason: "Reconciled", Message: "MySQL database is ready", LastTransitionTime: metav1.Now()}} + if err := r.Status().Update(ctx, &obj); err != nil { + logger.Error(err, "status update failed") + return reconcile.Result{}, err + } + return reconcile.Result{RequeueAfter: 5 * time.Minute}, nil +} + +func (r *MySQLDatabaseReconciler) setError(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, reason, msg string) (reconcile.Result, error) { + obj.Status.Phase = "Error" + obj.Status.Message = msg + obj.Status.ObservedGeneration = obj.Generation + obj.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse, Reason: reason, Message: msg, LastTransitionTime: metav1.Now()}} + _ = r.Status().Update(ctx, obj) + return reconcile.Result{RequeueAfter: 15 * time.Second}, fmt.Errorf("%s: %s", reason, msg) +} + +func (r *MySQLDatabaseReconciler) withDB(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, fn func(*sql.DB) error) error { + var admin corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Name: obj.Spec.AdminSecretRef.Name, Namespace: obj.Namespace}, &admin); err != nil { + return err + } + username, password := string(admin.Data["username"]), string(admin.Data["password"]) + if username == "" || password == "" { + return fmt.Errorf("admin secret %q must contain username and password", obj.Spec.AdminSecretRef.Name) + } + host := obj.Spec.Connection.Host + if host == "" { + host = obj.Spec.ClusterRef.Name + } + port := obj.Spec.Connection.Port + if port == 0 { + port = 3306 + } + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s", username, password, host, port) + db, err := sql.Open("mysql", dsn) + if err != nil { + return err + } + defer db.Close() + pingCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := db.PingContext(pingCtx); err != nil { + return err + } + return fn(db) +} + +func ensureDatabase(ctx context.Context, db *sql.DB, obj *platformv1alpha1.MySQLDatabase) error { + q := "CREATE DATABASE IF NOT EXISTS `" + quoteIdent(obj.Spec.DatabaseName) + "`" + if obj.Spec.Charset != "" { + q += " CHARACTER SET " + safeSQLWord(obj.Spec.Charset) + } + if obj.Spec.Collation != "" { + q += " COLLATE " + safeSQLWord(obj.Spec.Collation) + } + _, err := db.ExecContext(ctx, q) + return err +} + +func ensureUserAndGrants(ctx context.Context, db *sql.DB, obj *platformv1alpha1.MySQLDatabase, password string) error { + u := quoteIdent(obj.Spec.User.Name) + // User is intentionally scoped to localhost wildcard host. For networked K8s apps, '%' is needed. + if _, err := db.ExecContext(ctx, "CREATE USER IF NOT EXISTS '"+quoteString(obj.Spec.User.Name)+"'@'%' IDENTIFIED BY '"+quoteString(password)+"'"); err != nil { + return err + } + if _, err := db.ExecContext(ctx, "ALTER USER '"+quoteString(obj.Spec.User.Name)+"'@'%' IDENTIFIED BY '"+quoteString(password)+"'"); err != nil { + return err + } + grants := obj.Spec.Privileges + if len(grants) == 0 { + grants = []string{"SELECT", "INSERT", "UPDATE", "DELETE"} + } + for _, g := range grants { + if !isAllowedPrivilege(g) { + return fmt.Errorf("unsupported privilege %q", g) + } + } + grant := strings.Join(grants, ", ") + _, err := db.ExecContext(ctx, "GRANT "+grant+" ON `"+quoteIdent(obj.Spec.DatabaseName)+"`.* TO '"+quoteString(obj.Spec.User.Name)+"'@'%'") + _ = u + return err +} + +func (r *MySQLDatabaseReconciler) ensureCredentials(ctx context.Context, obj *platformv1alpha1.MySQLDatabase, username string) (string, error) { + if obj.Spec.Credentials == nil || obj.Spec.Credentials.SecretName == "" { + return "", fmt.Errorf("credentials.secretName is required when user.create is true") + } + key := types.NamespacedName{Name: obj.Spec.Credentials.SecretName, Namespace: obj.Namespace} + var s corev1.Secret + err := r.Get(ctx, key, &s) + if err == nil { + p := string(s.Data["password"]) + if p == "" { + return "", fmt.Errorf("credentials secret must contain password") + } + if string(s.Data["username"]) == "" { + s.Data["username"] = []byte(username) + if err := r.Update(ctx, &s); err != nil { + return "", err + } + } + return p, nil + } + if !apierrors.IsNotFound(err) { + return "", err + } + p, err := randomPassword(32) + if err != nil { + return "", err + } + s = corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Type: corev1.SecretTypeOpaque, StringData: map[string]string{"username": username, "password": p, "database": obj.Spec.DatabaseName}} + if err := controllerutil.SetControllerReference(obj, &s, r.Scheme); err != nil { + return "", err + } + if err := r.Create(ctx, &s); err != nil { + return "", err + } + return p, nil +} + +func randomPassword(n int) (string, error) { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*_-" + b := make([]byte, n) + for i := range b { + x, e := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if e != nil { + return "", e + } + b[i] = chars[x.Int64()] + } + return string(b), nil +} +func quoteIdent(s string) string { return strings.ReplaceAll(s, "`", "``") } +func quoteString(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "\\", "\\\\"), "'", "\\'") +} +func safeSQLWord(s string) string { + for _, r := range s { + if !(r == '_' || r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return "utf8mb4" + } + } + return s +} +func isAllowedPrivilege(s string) bool { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "INDEX", "DROP", "REFERENCES", "EXECUTE", "SHOW VIEW", "TRIGGER": + return true + default: + return false + } +} diff --git a/add-on/mysql-database-operator/controllers/setup.go b/add-on/mysql-database-operator/controllers/setup.go new file mode 100644 index 0000000..7c71019 --- /dev/null +++ b/add-on/mysql-database-operator/controllers/setup.go @@ -0,0 +1,13 @@ +package controllers + +import ( + platformv1alpha1 "github.com/example/mysql-database-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +func (r *MySQLDatabaseReconciler) SetupWithManager(mgr manager.Manager) error { + r.Client = mgr.GetClient() + return builder.ControllerManagedBy(mgr).For(&platformv1alpha1.MySQLDatabase{}).Owns(&corev1.Secret{}).Complete(r) +} diff --git a/add-on/mysql-database-operator/examples/admin-secret.yaml b/add-on/mysql-database-operator/examples/admin-secret.yaml new file mode 100644 index 0000000..5eda46a --- /dev/null +++ b/add-on/mysql-database-operator/examples/admin-secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: mysql-prod-admin + namespace: team-a +type: Opaque +stringData: + username: root + password: CHANGE-ME diff --git a/add-on/mysql-database-operator/examples/mysql-database.yaml b/add-on/mysql-database-operator/examples/mysql-database.yaml new file mode 100644 index 0000000..5a0995a --- /dev/null +++ b/add-on/mysql-database-operator/examples/mysql-database.yaml @@ -0,0 +1,28 @@ +apiVersion: platform.example.com/v1alpha1 +kind: MySQLDatabase +metadata: + name: orders + namespace: team-a +spec: + clusterRef: + name: mysql-prod + # If omitted, the controller uses clusterRef.name as the Service DNS name. + connection: + host: mysql-prod + port: 3306 + databaseName: orders + adminSecretRef: + name: mysql-prod-admin + user: + name: orders-app + create: true + credentials: + secretName: orders-db-credentials + privileges: + - SELECT + - INSERT + - UPDATE + - DELETE + charset: utf8mb4 + collation: utf8mb4_0900_ai_ci + deletionPolicy: Retain diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/Chart.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/Chart.yaml new file mode 100644 index 0000000..026d905 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: mysql-database-operator +description: Declarative MySQL database/user controller for Kubernetes +version: 0.1.0 +appVersion: "0.1.0" +type: application diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/_helpers.tpl b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/_helpers.tpl new file mode 100644 index 0000000..8d96329 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/_helpers.tpl @@ -0,0 +1,2 @@ +{{- define "mysql-database-operator.name" -}}mysql-database-operator{{- end }} +{{- define "mysql-database-operator.fullname" -}}{{ include "mysql-database-operator.name" . }}{{- end }} diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/crd.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/crd.yaml new file mode 100644 index 0000000..0fe94e0 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/crd.yaml @@ -0,0 +1,93 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: mysqldatabases.platform.example.com +spec: + group: platform.example.com + scope: Namespaced + names: + plural: mysqldatabases + singular: mysqldatabase + kind: MySQLDatabase + shortNames: [mysqldb] + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Database + type: string + jsonPath: .status.databaseName + - name: User + type: string + jsonPath: .status.userName + - name: Phase + type: string + jsonPath: .status.phase + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [clusterRef,databaseName,adminSecretRef] + properties: + clusterRef: + type: object + required: [name] + properties: {name: {type: string}} + databaseName: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[A-Za-z0-9_$-]+$' + adminSecretRef: + type: object + required: [name] + properties: {name: {type: string}} + connection: + type: object + properties: + host: {type: string} + port: {type: integer, minimum: 1, maximum: 65535} + user: + type: object + properties: + name: {type: string, maxLength: 32} + create: {type: boolean} + credentials: + type: object + properties: + secretName: {type: string} + privileges: + type: array + items: {type: string} + charset: {type: string} + collation: {type: string} + deletionPolicy: + type: string + enum: [Retain, Delete] + default: Retain + status: + type: object + properties: + phase: {type: string} + message: {type: string} + databaseName: {type: string} + userName: {type: string} + secretName: {type: string} + observedGeneration: {type: integer, format: int64} + conditions: + type: array + items: + type: object + required: [type,status,reason,message,lastTransitionTime] + properties: + type: {type: string} + status: {type: string} + reason: {type: string} + message: {type: string} + lastTransitionTime: {type: string, format: date-time} diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/deployment.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/deployment.yaml new file mode 100644 index 0000000..5fb1b44 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mysql-database-operator + namespace: mysql-database-operator-system +spec: + replicas: {{ .Values.replicaCount }} + selector: {matchLabels: {app: mysql-database-operator}} + template: + metadata: {labels: {app: mysql-database-operator}} + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + securityContext: {runAsNonRoot: true} + containers: + - name: manager + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: ["--leader-elect={{ .Values.leaderElect }}"] + ports: + - {name: metrics, containerPort: 8080} + - {name: health, containerPort: 8081} + resources: {{- toYaml .Values.resources | nindent 10 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + seccompProfile: {type: RuntimeDefault} + readinessProbe: {httpGet: {path: /readyz, port: health}} + livenessProbe: {httpGet: {path: /healthz, port: health}} diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/namespace.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/namespace.yaml new file mode 100644 index 0000000..7d11428 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/namespace.yaml @@ -0,0 +1,6 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: Namespace +metadata: + name: mysql-database-operator-system +{{- end }} diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/rbac.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/rbac.yaml new file mode 100644 index 0000000..c71a20c --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/rbac.yaml @@ -0,0 +1,33 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: {name: mysql-database-operator} +rules: +- apiGroups: [platform.example.com] + resources: [mysqldatabases] + verbs: [get,list,watch,create,update,patch,delete] +- apiGroups: [platform.example.com] + resources: [mysqldatabases/status] + verbs: [get,update,patch] +- apiGroups: [platform.example.com] + resources: [mysqldatabases/finalizers] + verbs: [update] +- apiGroups: [""] + resources: [secrets] + verbs: [get,list,watch,create,update,patch,delete] +- apiGroups: [""] + resources: [events] + verbs: [create,patch] +- apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get,list,watch,create,update,patch,delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: {name: mysql-database-operator} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: mysql-database-operator} +subjects: +- kind: ServiceAccount + name: {{ .Values.serviceAccount.name }} + namespace: mysql-database-operator-system +{{- end }} diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/templates/serviceaccount.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..6a71a58 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/templates/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name }} + namespace: mysql-database-operator-system diff --git a/add-on/mysql-database-operator/helm/mysql-database-operator/values.yaml b/add-on/mysql-database-operator/helm/mysql-database-operator/values.yaml new file mode 100644 index 0000000..14d6814 --- /dev/null +++ b/add-on/mysql-database-operator/helm/mysql-database-operator/values.yaml @@ -0,0 +1,16 @@ +image: + repository: ghcr.io/example/mysql-database-operator + tag: 0.1.0 + pullPolicy: IfNotPresent +replicaCount: 1 +leaderElect: true +resources: + requests: {cpu: 50m, memory: 64Mi} + limits: {cpu: 500m, memory: 256Mi} +serviceAccount: + create: true + name: mysql-database-operator +rbac: + create: true +crd: + install: true diff --git a/add-on/mysql.png b/add-on/mysql.png new file mode 100644 index 0000000000000000000000000000000000000000..5093cf85781d53cb7110fcab79c114dd9b92af60 GIT binary patch literal 13676 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

d|D*{%N)mxEowyo{_$ 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/*.env) +ALERT_EMAIL=$(cat "env/$ENVIRONMENT"/*.env 2>/dev/null | grep -E '^[[:space:]]*alert_notification_email[[:space:]]*=' | tail -n 1 | sed -E 's/^[^=]*=//' | tr -d '\r"'"'"'[:space:]' || true) # --------------------------------------------------------------------------- @@ -15,6 +17,10 @@ YAML_DIR="kubernetes" find "$YAML_DIR" -type d | while read DIR; do if ls "$DIR"/*.yaml 1> /dev/null 2>&1; then if [ "$(basename "$DIR")" = "monitoring" ]; then + if [ -z "$ALERT_EMAIL" ]; then + echo "alert_notification_email non valorizzata per l'ambiente $ENVIRONMENT: deploy di $DIR saltato." + continue + fi echo "Deploy delle risorse di monitoring nella directory $DIR con kubeconfig dedicato..." kubectl --kubeconfig=/root/work/pipeline/kc apply -f "$DIR" else