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 0000000..017c57d Binary files /dev/null and b/add-on/AgentPlatform/files.zip differ 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 0000000..b4fb226 Binary files /dev/null and b/add-on/laravel-512-511x400.png differ diff --git a/add-on/laravel.jpg b/add-on/laravel.jpg new file mode 100644 index 0000000..ae31432 Binary files /dev/null and b/add-on/laravel.jpg differ diff --git a/add-on/monitoring/files/alert.sh b/add-on/monitoring/files/alert.sh new file mode 100644 index 0000000..24a9016 --- /dev/null +++ b/add-on/monitoring/files/alert.sh @@ -0,0 +1,52 @@ +kubectl create secret generic alertmanager-smtp -n monitoring --from-literal=password='pX8XpxrNT8' + + +cat > 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 0000000..5093cf8 Binary files /dev/null and b/add-on/mysql.png differ diff --git a/pipeline/build_container.sh b/pipeline/build_container.sh index 11d3b15..885c4f0 100644 --- a/pipeline/build_container.sh +++ b/pipeline/build_container.sh @@ -47,8 +47,10 @@ for dir in containers/*/; do PLATFORM_CONF="$dir/platform.conf" BUILD_PLATFORM="" + MULTISTAGE="" if [ -f "$PLATFORM_CONF" ]; then BUILD_PLATFORM=$(grep -E '^[[:space:]]*platform[[:space:]]*=' "$PLATFORM_CONF" | tail -n 1 | cut -d '=' -f 2- | tr -d '[:space:]') + MULTISTAGE=$(grep -E '^[[:space:]]*multistage[[:space:]]*=' "$PLATFORM_CONF" | tail -n 1 | cut -d '=' -f 2- | tr -d '[:space:]') if [ -n "$BUILD_PLATFORM" ]; then echo "platform.conf trovato in $dir: uso platform=$BUILD_PLATFORM" else @@ -57,15 +59,39 @@ for dir in containers/*/; do fi if [ -f "$DOCKERFILE" ]; then - if [ -n "$BUILD_PLATFORM" ]; then - docker buildx build --platform "$BUILD_PLATFORM" -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" -f "$DOCKERFILE" "$dir" --push - else - docker buildx build \ - --cache-from=type=registry,ref="${REGISTRY_URL}/${REPO_NAME}/${CONTAINER_NAME}:cache" \ - --cache-to=type=registry,ref="${REGISTRY_URL}/${REPO_NAME}/${CONTAINER_NAME}:cache",mode=max \ - -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" \ - -f "$DOCKERFILE" "$dir" --push - fi + if [ -n "$MULTISTAGE" ] && ! [[ "$MULTISTAGE" =~ ^[1-9][0-9]*$ ]]; then + echo "Valore multistage non valido in $PLATFORM_CONF: $MULTISTAGE" + exit 1 + fi + + BUILD_COUNT="${MULTISTAGE:-1}" + for ((target_index = 1; target_index <= BUILD_COUNT; target_index++)); do + BUILD_TARGET="" + if [ -n "$MULTISTAGE" ]; then + BUILD_TARGET=$(grep -E "^[[:space:]]*target${target_index}[[:space:]]*=" "$PLATFORM_CONF" | tail -n 1 | cut -d '=' -f 2- | tr -d '[:space:]') + if [ -z "$BUILD_TARGET" ]; then + echo "target${target_index} non valorizzato in $PLATFORM_CONF" + exit 1 + fi + fi + + echo "Build $target_index/$BUILD_COUNT${BUILD_TARGET:+ target=$BUILD_TARGET}" + BUILD_TARGET_ARGS=() + if [ -n "$BUILD_TARGET" ]; then + BUILD_TARGET_ARGS=(--target "$BUILD_TARGET") + fi + + if [ -n "$BUILD_PLATFORM" ]; then + docker buildx build --platform "$BUILD_PLATFORM" "${BUILD_TARGET_ARGS[@]}" -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" -f "$DOCKERFILE" "$dir" --push + else + docker buildx build \ + --cache-from=type=registry,ref="${REGISTRY_URL}/${REPO_NAME}/${CONTAINER_NAME}:cache" \ + --cache-to=type=registry,ref="${REGISTRY_URL}/${REPO_NAME}/${CONTAINER_NAME}:cache",mode=max \ + "${BUILD_TARGET_ARGS[@]}" \ + -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" \ + -f "$DOCKERFILE" "$dir" --push + fi + done echo "Build e push completate: $IMAGE_TAG" # Cleanup immagini locali dopo il push (buildx --push potrebbe non averle in locale) diff --git a/pipeline/deploy.sh b/pipeline/deploy.sh index 869d222..9fd956c 100644 --- a/pipeline/deploy.sh +++ b/pipeline/deploy.sh @@ -7,6 +7,8 @@ ENVIRONMENT="${1:-dev}" YAML_DIR="kubernetes" +# Valore di alert_notification_email per l'ambiente (ultima occorrenza in env//*.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