diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..826c0b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Только локальные значения. Не используйте их в dev, stage или production. +COMPOSE_PROJECT_NAME=portable-agent + +POSTGRES_ADMIN_USER=platform_admin +POSTGRES_ADMIN_PASSWORD=local_admin_change_me +POSTGRES_PORT=5432 + +KEYCLOAK_DB_USER=keycloak +KEYCLOAK_DB_PASSWORD=local_keycloak_db_change_me +KEYCLOAK_ADMIN_USER=admin +KEYCLOAK_ADMIN_PASSWORD=local_keycloak_admin_change_me +KEYCLOAK_PORT=8081 + +TEMPORAL_DB_USER=temporal +TEMPORAL_DB_PASSWORD=local_temporal_db_change_me +TEMPORAL_NAMESPACE=portable-agent-local +TEMPORAL_PORT=7233 +TEMPORAL_UI_PORT=8080 + +REDPANDA_PORT=19092 +OPA_PORT=8181 +OTEL_GRPC_PORT=4317 +OTEL_HTTP_PORT=4318 +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3000 +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=local_grafana_change_me +TEMPO_PORT=3200 +LOKI_PORT=3100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec96d6..45dd7e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: chart: - name: Helm and k3d quality gate + name: Config quality gate runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -20,16 +20,11 @@ jobs: version: v4.2.4 - name: Проверить Helm chart shell: pwsh - run: ./scripts/check-chart.ps1 - - name: Установить k3d - env: - K3D_VERSION: v5.9.0 - run: | - curl -fsSLo /tmp/k3d-install.sh \ - "https://raw.githubusercontent.com/k3d-io/k3d/${K3D_VERSION}/install.sh" - TAG="${K3D_VERSION}" bash /tmp/k3d-install.sh - - name: Проверить установку в k3d + run: ./scripts/check-config.ps1 + - name: Проверить Compose shell: pwsh - run: ./scripts/smoke-k3d.ps1 -ClusterName "pa-${{ github.run_id }}" + run: ./scripts/check-compose.ps1 docs: uses: portable-agent/.github/.github/workflows/reusable-docs.yml@main + security: + uses: portable-agent/.github/.github/workflows/reusable-security.yml@main diff --git a/.github/workflows/full-smoke.yml b/.github/workflows/full-smoke.yml new file mode 100644 index 0000000..efa3b9c --- /dev/null +++ b/.github/workflows/full-smoke.yml @@ -0,0 +1,29 @@ +name: Full infrastructure smoke + +on: + workflow_dispatch: + schedule: + - cron: "20 2 * * 1" + +permissions: + contents: read + +jobs: + k3d: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: azure/setup-helm@v5.0.1 + with: + version: v4.2.4 + - name: Установить k3d + env: + K3D_VERSION: v5.9.0 + run: | + curl -fsSLo /tmp/k3d-install.sh "https://raw.githubusercontent.com/k3d-io/k3d/${K3D_VERSION}/install.sh" + TAG="${K3D_VERSION}" bash /tmp/k3d-install.sh + - name: Запустить smoke + shell: pwsh + run: ./scripts/smoke-k3d.ps1 -ClusterName "pa-${{ github.run_id }}" + diff --git a/.gitignore b/.gitignore index 299f861..7ba29ff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ site/ .tmp-site/ *.tgz *.log +.env diff --git a/README.md b/README.md index 9ba026d..cea0b5e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # Deploy -Репозиторий хранит Helm charts и локальный k3d smoke-тест Portable Agent. Здесь нет бизнес-кода, -секретов и настроек реального production. +Репозиторий хранит локальную инфраструктуру, общий Helm chart и GitOps-описание Portable Agent. +Здесь нет бизнес-кода, секретов и настроек реального production. ## Текущий пакет -Первый пакет проверяет только путь `Helm -> Kubernetes`: +Платформа разработки включает: -- `helm lint` проверяет chart; -- `helm template` проверяет render; -- временный k3d-кластер устанавливает chart; -- smoke-тест читает контрольный ConfigMap; -- созданный тестом кластер всегда удаляется. +- Compose-профиль `core`: PostgreSQL, Redpanda, Keycloak, Temporal и OPA; +- Compose-профиль `observe`: OpenTelemetry, Prometheus, Grafana, Tempo и Loki; +- безопасный общий chart `charts/service`; +- Argo CD bootstrap `charts/gitops`; +- каталог окружений и генератор нового сервиса; +- быстрые проверки в каждом PR и полный k3d smoke по расписанию. ## Требования @@ -21,10 +22,20 @@ - kubectl; - PowerShell 7. -Проверка chart без кластера: +Создай локальный файл настроек и запусти инфраструктуру: ```powershell -pwsh ./scripts/check-chart.ps1 +Copy-Item .env.example .env +pwsh ./scripts/start-local.ps1 -Observe +``` + +Файл `.env` локальный и не коммитится. Значения `dev` и `stage` должны приходить из secret +manager, а не из Git. + +Все быстрые проверки: + +```powershell +pwsh ./scripts/check-config.ps1 ``` Полная проверка: @@ -33,5 +44,11 @@ pwsh ./scripts/check-chart.ps1 pwsh ./scripts/smoke-k3d.ps1 ``` +Подготовить delivery-файлы нового сервиса: + +```powershell +pwsh ./scripts/new-service.ps1 -Name sample-api -Image ghcr.io/portable-agent/sample-api -Port 8080 +``` + Архитектура и следующий шаг описаны в [docs/index.md](docs/index.md). Правила для разработчиков и AI-агентов находятся в [AGENTS.md](AGENTS.md). diff --git a/charts/gitops/Chart.yaml b/charts/gitops/Chart.yaml new file mode 100644 index 0000000..f16ee7d --- /dev/null +++ b/charts/gitops/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: gitops +description: Argo CD bootstrap для окружений Portable Agent +type: application +version: 0.1.0 + diff --git a/charts/gitops/templates/project.yaml b/charts/gitops/templates/project.yaml new file mode 100644 index 0000000..ae463e5 --- /dev/null +++ b/charts/gitops/templates/project.yaml @@ -0,0 +1,17 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: {{ .Values.projectName }} + namespace: argocd +spec: + sourceRepos: + - {{ .Values.source.repoUrl | quote }} + destinations: + {{- range .Values.environments }} + - namespace: {{ printf "portable-agent-%s" .name | quote }} + server: {{ $.Values.destinationServer | quote }} + {{- end }} + clusterResourceWhitelist: [] + namespaceResourceWhitelist: + - group: "*" + kind: "*" diff --git a/charts/gitops/templates/services.yaml b/charts/gitops/templates/services.yaml new file mode 100644 index 0000000..aa666eb --- /dev/null +++ b/charts/gitops/templates/services.yaml @@ -0,0 +1,40 @@ +{{- range $environment := .Values.environments }} +--- +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: {{ $.Values.projectName }}-{{ $environment.name }} + namespace: argocd +spec: + generators: + - git: + repoURL: {{ $.Values.source.repoUrl | quote }} + revision: {{ $.Values.source.revision | quote }} + directories: + - path: {{ printf "%s/%s/services/*" $.Values.servicesPath $environment.name | quote }} + template: + metadata: + name: '{{`{{path.basename}}`}}-{{ $environment.name }}' + spec: + project: {{ $.Values.projectName }} + sources: + - repoURL: {{ $.Values.source.repoUrl | quote }} + targetRevision: {{ $.Values.source.revision | quote }} + path: {{ $.Values.source.chartPath | quote }} + helm: + valueFiles: + - '$values/{{`{{path}}`}}/values.yaml' + - repoURL: {{ $.Values.source.repoUrl | quote }} + targetRevision: {{ $.Values.source.revision | quote }} + ref: values + destination: + server: {{ $.Values.destinationServer | quote }} + namespace: {{ printf "portable-agent-%s" $environment.name | quote }} + syncPolicy: + syncOptions: [CreateNamespace=true] + {{- if $environment.autoSync }} + automated: + prune: true + selfHeal: true + {{- end }} +{{- end }} diff --git a/charts/gitops/tests/smoke-values.yaml b/charts/gitops/tests/smoke-values.yaml new file mode 100644 index 0000000..180e748 --- /dev/null +++ b/charts/gitops/tests/smoke-values.yaml @@ -0,0 +1,8 @@ +source: + repoUrl: https://github.com/example/deploy.git + revision: main + chartPath: charts/service +environments: + - {name: local, autoSync: true} + - {name: dev, autoSync: true} + - {name: stage, autoSync: false} diff --git a/charts/gitops/values.schema.json b/charts/gitops/values.schema.json new file mode 100644 index 0000000..e257480 --- /dev/null +++ b/charts/gitops/values.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["projectName", "source", "environments"], + "properties": { + "projectName": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "source": { + "type": "object", + "required": ["repoUrl", "revision", "chartPath"], + "properties": { + "repoUrl": {"type": "string", "minLength": 1}, + "revision": {"type": "string", "minLength": 1}, + "chartPath": {"type": "string", "minLength": 1} + } + }, + "environments": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "autoSync"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "autoSync": {"type": "boolean"} + } + } + } + } +} diff --git a/charts/gitops/values.yaml b/charts/gitops/values.yaml new file mode 100644 index 0000000..91f35d9 --- /dev/null +++ b/charts/gitops/values.yaml @@ -0,0 +1,8 @@ +projectName: portable-agent +source: + repoUrl: "" + revision: main + chartPath: charts/service +servicesPath: environments +environments: [] +destinationServer: https://kubernetes.default.svc diff --git a/charts/service/Chart.yaml b/charts/service/Chart.yaml new file mode 100644 index 0000000..fe479b4 --- /dev/null +++ b/charts/service/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: service +description: Общий Helm chart для stateless-сервисов Portable Agent +type: application +version: 0.1.0 +appVersion: "1.0.0" + diff --git a/charts/service/templates/_helpers.tpl b/charts/service/templates/_helpers.tpl new file mode 100644 index 0000000..e6551dc --- /dev/null +++ b/charts/service/templates/_helpers.tpl @@ -0,0 +1,15 @@ +{{- define "service.name" -}} +{{- default .Release.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- define "service.fullname" -}} +{{- if .Values.fullnameOverride }}{{ .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}{{ else }}{{ include "service.name" . }}{{ end }} +{{- end }} +{{- define "service.labels" -}} +app.kubernetes.io/name: {{ include "service.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +{{- end }} +{{- define "service.account" -}} +{{- default (include "service.fullname" .) .Values.serviceAccount.name }} +{{- end }} diff --git a/charts/service/templates/configmap.yaml b/charts/service/templates/configmap.yaml new file mode 100644 index 0000000..aebdb9d --- /dev/null +++ b/charts/service/templates/configmap.yaml @@ -0,0 +1,13 @@ +{{- if .Values.config }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "service.fullname" . }} + labels: + {{- include "service.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end }} + diff --git a/charts/service/templates/deployment.yaml b/charts/service/templates/deployment.yaml new file mode 100644 index 0000000..7f20275 --- /dev/null +++ b/charts/service/templates/deployment.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "service.fullname" . }} + labels: + {{- include "service.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + annotations: + checksum/config: {{ toJson .Values.config | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml .Values.podAnnotations | nindent 8 }} + {{- end }} + labels: + {{- include "service.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml .Values.podLabels | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "service.account" . }} + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + {{- with .Values.imagePullSecrets }} + imagePullSecrets: {{ toYaml . | nindent 8 }} + {{- end }} + containers: + - name: service + image: {{ printf "%s:%s" .Values.image.repository .Values.image.tag | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + ports: + - name: http + containerPort: {{ .Values.containerPort }} + {{- if .Values.config }} + envFrom: + - configMapRef: + name: {{ include "service.fullname" . }} + {{- end }} + env: + {{- if .Values.otel.enabled }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.otel.endpoint | quote }} + - name: OTEL_SERVICE_NAME + value: {{ include "service.fullname" . | quote }} + {{- end }} + {{- range .Values.secretRefs }} + - name: {{ .env }} + valueFrom: + secretKeyRef: + name: {{ .name }} + key: {{ .key }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml .Values.extraEnv | nindent 12 }} + {{- end }} + {{- if .Values.probes.enabled }} + readinessProbe: + httpGet: {path: {{ .Values.probes.path }}, port: http} + initialDelaySeconds: {{ .Values.probes.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.periodSeconds }} + livenessProbe: + httpGet: {path: {{ .Values.probes.livenessPath }}, port: http} + initialDelaySeconds: {{ .Values.probes.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.periodSeconds }} + {{- end }} + resources: {{ toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: temp + mountPath: /tmp + volumes: + - name: temp + emptyDir: {} + nodeSelector: {{ toYaml .Values.nodeSelector | nindent 8 }} + tolerations: {{ toYaml .Values.tolerations | nindent 8 }} + affinity: {{ toYaml .Values.affinity | nindent 8 }} diff --git a/charts/service/templates/hpa.yaml b/charts/service/templates/hpa.yaml new file mode 100644 index 0000000..df882d9 --- /dev/null +++ b/charts/service/templates/hpa.yaml @@ -0,0 +1,21 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "service.fullname" . }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "service.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} + diff --git a/charts/service/templates/ingress.yaml b/charts/service/templates/ingress.yaml new file mode 100644 index 0000000..37c74fb --- /dev/null +++ b/charts/service/templates/ingress.yaml @@ -0,0 +1,27 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "service.fullname" . }} + annotations: {{ toYaml .Values.ingress.annotations | nindent 4 }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ default "Prefix" .pathType }} + backend: + service: + name: {{ include "service.fullname" $ }} + port: {name: http} + {{- end }} + {{- end }} + tls: {{ toYaml .Values.ingress.tls | nindent 4 }} +{{- end }} + diff --git a/charts/service/templates/networkpolicy.yaml b/charts/service/templates/networkpolicy.yaml new file mode 100644 index 0000000..6e7b277 --- /dev/null +++ b/charts/service/templates/networkpolicy.yaml @@ -0,0 +1,31 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "service.fullname" . }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {{ include "service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + policyTypes: [Ingress, Egress] + {{- if .Values.networkPolicy.ingressFrom }} + ingress: + - from: {{ toYaml .Values.networkPolicy.ingressFrom | nindent 8 }} + {{- else }} + ingress: [] + {{- end }} + egress: + {{- if .Values.networkPolicy.allowDns }} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + {{- end }} + {{- if .Values.networkPolicy.egressTo }} + - to: {{ toYaml .Values.networkPolicy.egressTo | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/service/templates/pdb.yaml b/charts/service/templates/pdb.yaml new file mode 100644 index 0000000..361d109 --- /dev/null +++ b/charts/service/templates/pdb.yaml @@ -0,0 +1,13 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "service.fullname" . }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + diff --git a/charts/service/templates/service.yaml b/charts/service/templates/service.yaml new file mode 100644 index 0000000..d111d0c --- /dev/null +++ b/charts/service/templates/service.yaml @@ -0,0 +1,18 @@ +{{- if .Values.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "service.fullname" . }} + labels: + {{- include "service.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http +{{- end }} + diff --git a/charts/service/templates/serviceaccount.yaml b/charts/service/templates/serviceaccount.yaml new file mode 100644 index 0000000..85f2036 --- /dev/null +++ b/charts/service/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "service.account" . }} + labels: + {{- include "service.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} diff --git a/charts/service/templates/servicemonitor.yaml b/charts/service/templates/servicemonitor.yaml new file mode 100644 index 0000000..8da1a87 --- /dev/null +++ b/charts/service/templates/servicemonitor.yaml @@ -0,0 +1,16 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "service.fullname" . }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ include "service.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + endpoints: + - port: http + path: {{ .Values.serviceMonitor.path }} + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} + diff --git a/charts/service/tests/smoke-values.yaml b/charts/service/tests/smoke-values.yaml new file mode 100644 index 0000000..9a1f399 --- /dev/null +++ b/charts/service/tests/smoke-values.yaml @@ -0,0 +1,16 @@ +fullnameOverride: smoke-service +image: + repository: registry.k8s.io/pause + tag: "3.10" +containerPort: 8080 +service: + enabled: false +probes: + enabled: false +networkPolicy: + enabled: false +otel: + enabled: false +config: + READY: "true" + diff --git a/charts/service/values.schema.json b/charts/service/values.schema.json new file mode 100644 index 0000000..a232028 --- /dev/null +++ b/charts/service/values.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["image", "containerPort", "resources"], + "properties": { + "image": { + "type": "object", + "required": ["repository", "tag"], + "properties": { + "repository": {"type": "string", "minLength": 1}, + "tag": {"type": "string", "minLength": 1}, + "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]} + } + }, + "containerPort": {"type": "integer", "minimum": 1, "maximum": 65535}, + "replicaCount": {"type": "integer", "minimum": 1}, + "secretRefs": { + "type": "array", + "items": { + "type": "object", + "required": ["env", "name", "key"], + "properties": { + "env": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]+$"}, + "name": {"type": "string", "minLength": 1}, + "key": {"type": "string", "minLength": 1} + } + } + } + } +} diff --git a/charts/service/values.yaml b/charts/service/values.yaml new file mode 100644 index 0000000..73818cc --- /dev/null +++ b/charts/service/values.yaml @@ -0,0 +1,79 @@ +nameOverride: "" +fullnameOverride: "" + +image: + repository: "" + tag: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] + +replicaCount: 1 +containerPort: 8080 + +service: + enabled: true + type: ClusterIP + port: 80 + +config: {} +# Каждый элемент: {env: DATABASE_PASSWORD, name: service-secret, key: database-password} +secretRefs: [] +extraEnv: [] + +probes: + enabled: true + path: /actuator/health/readiness + livenessPath: /actuator/health/liveness + initialDelaySeconds: 10 + periodSeconds: 10 + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +networkPolicy: + enabled: true + allowDns: true + ingressFrom: [] + egressTo: [] + +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +serviceMonitor: + enabled: false + path: /actuator/prometheus + interval: 30s + +serviceAccount: + create: true + annotations: {} + name: "" + +otel: + enabled: true + endpoint: http://otel-collector.observability.svc.cluster.local:4318 + +podAnnotations: {} +podLabels: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/compose/compose.yaml b/compose/compose.yaml new file mode 100644 index 0000000..7cd1a07 --- /dev/null +++ b/compose/compose.yaml @@ -0,0 +1,242 @@ +name: ${COMPOSE_PROJECT_NAME:-portable-agent} + +services: + postgres: + profiles: [core] + image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE} + environment: + POSTGRES_USER: ${POSTGRES_ADMIN_USER:?set POSTGRES_ADMIN_USER} + POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?set POSTGRES_ADMIN_PASSWORD} + POSTGRES_DB: platform + KEYCLOAK_DB_USER: ${KEYCLOAK_DB_USER:?set KEYCLOAK_DB_USER} + KEYCLOAK_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD} + TEMPORAL_DB_USER: ${TEMPORAL_DB_USER:?set TEMPORAL_DB_USER} + TEMPORAL_DB_PASSWORD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD} + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql + - ./postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: [CMD-SHELL, "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 30 + networks: [platform] + + redpanda: + profiles: [core] + image: ${REDPANDA_IMAGE:?set REDPANDA_IMAGE} + command: + - redpanda + - start + - --mode + - dev-container + - --smp + - "1" + - --memory + - 1G + - --reserve-memory + - 0M + - --kafka-addr + - internal://0.0.0.0:9092,external://0.0.0.0:19092 + - --advertise-kafka-addr + - internal://redpanda:9092,external://localhost:${REDPANDA_PORT:-19092} + ports: + - "127.0.0.1:${REDPANDA_PORT:-19092}:19092" + volumes: + - redpanda-data:/var/lib/redpanda/data + healthcheck: + test: [CMD-SHELL, "rpk cluster health --exit-when-healthy"] + interval: 5s + timeout: 5s + retries: 30 + networks: [platform] + + keycloak: + profiles: [core] + image: ${KEYCLOAK_IMAGE:?set KEYCLOAK_IMAGE} + command: [start-dev, --health-enabled=true] + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: ${KEYCLOAK_DB_USER:?set KEYCLOAK_DB_USER} + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:?set KEYCLOAK_ADMIN_USER} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD} + KC_HTTP_PORT: 8080 + KC_METRICS_ENABLED: "true" + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:${KEYCLOAK_PORT:-8081}:8080" + networks: [platform] + + temporal-setup: + profiles: [core] + image: ${TEMPORAL_ADMIN_IMAGE:?set TEMPORAL_ADMIN_IMAGE} + restart: on-failure:5 + entrypoint: [/bin/sh, /scripts/setup-postgres.sh] + environment: + POSTGRES_SEEDS: postgres + POSTGRES_USER: ${TEMPORAL_DB_USER:?set TEMPORAL_DB_USER} + SQL_PASSWORD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD} + volumes: + - ./temporal/scripts:/scripts:ro + depends_on: + postgres: + condition: service_healthy + networks: [platform] + + temporal: + profiles: [core] + image: ${TEMPORAL_SERVER_IMAGE:?set TEMPORAL_SERVER_IMAGE} + environment: + DB: postgres12 + DB_PORT: 5432 + POSTGRES_SEEDS: postgres + POSTGRES_USER: ${TEMPORAL_DB_USER:?set TEMPORAL_DB_USER} + POSTGRES_PWD: ${TEMPORAL_DB_PASSWORD:?set TEMPORAL_DB_PASSWORD} + BIND_ON_IP: 0.0.0.0 + DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/development-sql.yaml + volumes: + - ./temporal/dynamicconfig:/etc/temporal/config/dynamicconfig:ro + depends_on: + temporal-setup: + condition: service_completed_successfully + ports: + - "127.0.0.1:${TEMPORAL_PORT:-7233}:7233" + healthcheck: + test: [CMD, nc, -z, localhost, "7233"] + interval: 5s + timeout: 3s + retries: 30 + networks: [platform] + + temporal-namespace: + profiles: [core] + image: ${TEMPORAL_ADMIN_IMAGE:?set TEMPORAL_ADMIN_IMAGE} + restart: on-failure:5 + entrypoint: [/bin/sh, /scripts/create-namespace.sh] + environment: + TEMPORAL_ADDRESS: temporal:7233 + TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:?set TEMPORAL_NAMESPACE} + volumes: + - ./temporal/scripts:/scripts:ro + depends_on: + temporal: + condition: service_healthy + networks: [platform] + + temporal-ui: + profiles: [core] + image: ${TEMPORAL_UI_IMAGE:?set TEMPORAL_UI_IMAGE} + environment: + TEMPORAL_ADDRESS: temporal:7233 + TEMPORAL_CORS_ORIGINS: http://localhost:3000 + depends_on: + temporal: + condition: service_healthy + ports: + - "127.0.0.1:${TEMPORAL_UI_PORT:-8080}:8080" + networks: [platform] + + opa: + profiles: [core] + image: ${OPA_IMAGE:?set OPA_IMAGE} + command: [run, --server, --addr=0.0.0.0:8181, /policies] + volumes: + - ./opa:/policies:ro + ports: + - "127.0.0.1:${OPA_PORT:-8181}:8181" + healthcheck: + test: [CMD, /opa, eval, "1 == 1"] + interval: 5s + timeout: 3s + retries: 20 + networks: [platform] + + tempo: + profiles: [observe] + image: ${TEMPO_IMAGE:?set TEMPO_IMAGE} + command: [-config.file=/etc/tempo/config.yaml] + volumes: + - ./observability/tempo.yaml:/etc/tempo/config.yaml:ro + - tempo-data:/var/tempo + ports: + - "127.0.0.1:${TEMPO_PORT:-3200}:3200" + networks: [platform] + + loki-init: + profiles: [observe] + image: ${BUSYBOX_IMAGE:?set BUSYBOX_IMAGE} + command: [sh, -c, "chown -R 10001:10001 /data"] + volumes: + - loki-data:/data + networks: [platform] + + loki: + profiles: [observe] + image: ${LOKI_IMAGE:?set LOKI_IMAGE} + command: [-config.file=/etc/loki/config.yaml] + volumes: + - ./observability/loki.yaml:/etc/loki/config.yaml:ro + - loki-data:/tmp/loki + depends_on: + loki-init: + condition: service_completed_successfully + ports: + - "127.0.0.1:${LOKI_PORT:-3100}:3100" + networks: [platform] + + otel-collector: + profiles: [observe] + image: ${OTEL_COLLECTOR_IMAGE:?set OTEL_COLLECTOR_IMAGE} + command: [--config=/etc/otel/config.yaml] + volumes: + - ./observability/otel-collector.yaml:/etc/otel/config.yaml:ro + depends_on: [tempo, loki] + ports: + - "127.0.0.1:${OTEL_GRPC_PORT:-4317}:4317" + - "127.0.0.1:${OTEL_HTTP_PORT:-4318}:4318" + networks: [platform] + + prometheus: + profiles: [observe] + image: ${PROMETHEUS_IMAGE:?set PROMETHEUS_IMAGE} + command: [--config.file=/etc/prometheus/prometheus.yaml, --storage.tsdb.path=/prometheus] + volumes: + - ./observability/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro + - prometheus-data:/prometheus + depends_on: [otel-collector] + ports: + - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" + networks: [platform] + + grafana: + profiles: [observe] + image: ${GRAFANA_IMAGE:?set GRAFANA_IMAGE} + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?set GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD} + GF_AUTH_ANONYMOUS_ENABLED: "false" + volumes: + - ./observability/grafana:/etc/grafana/provisioning:ro + - grafana-data:/var/lib/grafana + depends_on: [prometheus, tempo, loki] + ports: + - "127.0.0.1:${GRAFANA_PORT:-3000}:3000" + networks: [platform] + +networks: + platform: + driver: bridge + +volumes: + postgres-data: + redpanda-data: + prometheus-data: + grafana-data: + tempo-data: + loki-data: diff --git a/compose/observability/grafana/datasources/datasources.yaml b/compose/observability/grafana/datasources/datasources.yaml new file mode 100644 index 0000000..2c700fa --- /dev/null +++ b/compose/observability/grafana/datasources/datasources.yaml @@ -0,0 +1,24 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + - name: Tempo + uid: tempo + type: tempo + access: proxy + url: http://tempo:3200 + jsonData: + tracesToLogsV2: + datasourceUid: loki + serviceMap: + datasourceUid: prometheus + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:3100 diff --git a/compose/observability/loki.yaml b/compose/observability/loki.yaml new file mode 100644 index 0000000..dc23f78 --- /dev/null +++ b/compose/observability/loki.yaml @@ -0,0 +1,29 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /tmp/loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /tmp/loki/chunks + +limits_config: + allow_structured_metadata: true + volume_enabled: true diff --git a/compose/observability/otel-collector.yaml b/compose/observability/otel-collector.yaml new file mode 100644 index 0000000..64eb2d0 --- /dev/null +++ b/compose/observability/otel-collector.yaml @@ -0,0 +1,46 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + memory_limiter: + check_interval: 1s + limit_mib: 256 + batch: {} + resource: + attributes: + - key: deployment.environment.name + action: upsert + value: local + +exporters: + prometheus: + endpoint: 0.0.0.0:8889 + otlp_http/tempo: + endpoint: http://tempo:4318 + otlp_http/loki: + endpoint: http://loki:3100/otlp + debug: + verbosity: basic + +service: + telemetry: + logs: + level: info + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, resource, batch] + exporters: [otlp_http/tempo, debug] + metrics: + receivers: [otlp] + processors: [memory_limiter, resource, batch] + exporters: [prometheus] + logs: + receivers: [otlp] + processors: [memory_limiter, resource, batch] + exporters: [otlp_http/loki, debug] diff --git a/compose/observability/prometheus.yaml b/compose/observability/prometheus.yaml new file mode 100644 index 0000000..90d6d48 --- /dev/null +++ b/compose/observability/prometheus.yaml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: [prometheus:9090] + - job_name: otel-collector + static_configs: + - targets: [otel-collector:8889] diff --git a/compose/observability/tempo.yaml b/compose/observability/tempo.yaml new file mode 100644 index 0000000..4e83a42 --- /dev/null +++ b/compose/observability/tempo.yaml @@ -0,0 +1,25 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +storage: + trace: + backend: local + local: + path: /var/tempo/blocks + wal: + path: /var/tempo/wal + +backend_scheduler: + provider: + compaction: + compaction: + block_retention: 24h diff --git a/compose/opa/base.rego b/compose/opa/base.rego new file mode 100644 index 0000000..01d31f8 --- /dev/null +++ b/compose/opa/base.rego @@ -0,0 +1,7 @@ +package portable_agent.base + +default ready := false + +ready if { + input.check == "ready" +} diff --git a/compose/postgres/init/01-users.sh b/compose/postgres/init/01-users.sh new file mode 100644 index 0000000..60fe89c --- /dev/null +++ b/compose/postgres/init/01-users.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +: "${KEYCLOAK_DB_USER:?KEYCLOAK_DB_USER is required}" +: "${KEYCLOAK_DB_PASSWORD:?KEYCLOAK_DB_PASSWORD is required}" +: "${TEMPORAL_DB_USER:?TEMPORAL_DB_USER is required}" +: "${TEMPORAL_DB_PASSWORD:?TEMPORAL_DB_PASSWORD is required}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ + --set=keycloak_user="$KEYCLOAK_DB_USER" \ + --set=keycloak_password="$KEYCLOAK_DB_PASSWORD" \ + --set=temporal_user="$TEMPORAL_DB_USER" \ + --set=temporal_password="$TEMPORAL_DB_PASSWORD" <<-'SQL' +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'keycloak_user', :'keycloak_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'keycloak_user') \gexec + +SELECT format('CREATE DATABASE keycloak OWNER %I', :'keycloak_user') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'keycloak') \gexec + +SELECT format('CREATE ROLE %I LOGIN CREATEDB PASSWORD %L', :'temporal_user', :'temporal_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'temporal_user') \gexec +SQL diff --git a/compose/temporal/dynamicconfig/development-sql.yaml b/compose/temporal/dynamicconfig/development-sql.yaml new file mode 100644 index 0000000..3e28e75 --- /dev/null +++ b/compose/temporal/dynamicconfig/development-sql.yaml @@ -0,0 +1,3 @@ +system.forceSearchAttributesCacheRefreshOnRead: + - value: true + constraints: {} diff --git a/compose/temporal/scripts/create-namespace.sh b/compose/temporal/scripts/create-namespace.sh new file mode 100644 index 0000000..b5f8bc2 --- /dev/null +++ b/compose/temporal/scripts/create-namespace.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +: "${TEMPORAL_ADDRESS:?TEMPORAL_ADDRESS is required}" +: "${TEMPORAL_NAMESPACE:?TEMPORAL_NAMESPACE is required}" + +until temporal operator cluster health --address "$TEMPORAL_ADDRESS"; do sleep 2; done +temporal operator namespace describe --address "$TEMPORAL_ADDRESS" --namespace "$TEMPORAL_NAMESPACE" \ + >/dev/null 2>&1 || temporal operator namespace create --address "$TEMPORAL_ADDRESS" \ + --namespace "$TEMPORAL_NAMESPACE" --retention 24h diff --git a/compose/temporal/scripts/setup-postgres.sh b/compose/temporal/scripts/setup-postgres.sh new file mode 100644 index 0000000..7f5531c --- /dev/null +++ b/compose/temporal/scripts/setup-postgres.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +: "${POSTGRES_SEEDS:?POSTGRES_SEEDS is required}" +: "${POSTGRES_USER:?POSTGRES_USER is required}" +: "${SQL_PASSWORD:?SQL_PASSWORD is required}" + +export SQL_PASSWORD +until nc -z "$POSTGRES_SEEDS" "${DB_PORT:-5432}"; do sleep 2; done + +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal create || true +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal setup-schema -v 0.0 +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal update-schema \ + -d /etc/temporal/schema/postgresql/v12/temporal/versioned + +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal_visibility create || true +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal_visibility setup-schema -v 0.0 +temporal-sql-tool --plugin postgres12 --ep "$POSTGRES_SEEDS" -u "$POSTGRES_USER" \ + -p "${DB_PORT:-5432}" --db temporal_visibility update-schema \ + -d /etc/temporal/schema/postgresql/v12/visibility/versioned diff --git a/config/versions.env b/config/versions.env new file mode 100644 index 0000000..a9f0a9c --- /dev/null +++ b/config/versions.env @@ -0,0 +1,13 @@ +POSTGRES_IMAGE=postgres:18.6-alpine3.23 +REDPANDA_IMAGE=redpandadata/redpanda:v26.2.2 +KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:26.7.2 +TEMPORAL_SERVER_IMAGE=temporalio/server:1.31.2 +TEMPORAL_ADMIN_IMAGE=temporalio/admin-tools:1.31.2 +TEMPORAL_UI_IMAGE=temporalio/ui:2.53.3 +OPA_IMAGE=openpolicyagent/opa:1.20.1-static +OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib:0.159.0 +PROMETHEUS_IMAGE=prom/prometheus:v3.14.0 +GRAFANA_IMAGE=grafana/grafana:13.2.0 +TEMPO_IMAGE=grafana/tempo:3.0.3 +LOKI_IMAGE=grafana/loki:3.7.7 +BUSYBOX_IMAGE=busybox:1.37.0 diff --git a/docs/architecture.md b/docs/architecture.md index 81fbfbf..3e55033 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,21 @@ # Архитектура ```text -check-chart.ps1 -> helm lint/template -smoke-k3d.ps1 -> temporary k3d -> helm install -> ConfigMap check -> delete cluster +репа сервиса -> общий CI -> container + SBOM + signature + | +services/catalog.json -> environments//services//values.yaml + | + Argo CD ApplicationSet + | + charts/service -> Kubernetes + | + OpenTelemetry -> metrics/logs/traces ``` -Chart `platform-base` пока не разворачивает сервисы. Он доказывает, что базовый путь доставки работает. -Это оставляет первый тест быстрым и не связывает deploy с незавершённой бизнес-архитектурой. +`services/catalog.json` — реестр сервисов, `environments` — различия окружений, а +`charts/service` — единые правила запуска. В values нет секретов: указываются только ссылки на +ключи Kubernetes Secret. Production намеренно не создан до отдельного ADR и выбора облака. + +Локальная среда работает через Compose и использует те же классы зависимостей, что будущий +кластер. Полный smoke создаёт только временный k3d-кластер и всегда удаляет лишь созданный им +кластер. diff --git a/docs/decisions/0002-developer-platform.md b/docs/decisions/0002-developer-platform.md new file mode 100644 index 0000000..ff4415e --- /dev/null +++ b/docs/decisions/0002-developer-platform.md @@ -0,0 +1,21 @@ +# ADR 0002: единая платформа разработки и доставки + +Статус: принято. + +## Решение + +Для локальных зависимостей используем Docker Compose, для доставки — общий Helm chart и Argo CD +ApplicationSet, для телеметрии — OpenTelemetry. Версии контейнеров находятся в одном файле +`config/versions.env`. Новые сервисы регистрируются генератором, а не копированием YAML. + +## Почему + +Это сохраняет отдельную репу у каждого сервиса, но не размножает CI и Kubernetes-настройки. +Конфигурация окружений видна в Git, секреты остаются вне Git. Production появится только после +выбора инфраструктуры, модели угроз и стратегии восстановления. + +## Последствия + +Изменение общего chart затрагивает много сервисов и требует smoke-теста. Автосинхронизация +разрешена только для local/dev; stage требует ручного решения. Chaos-тесты запускаются отдельно. + diff --git a/docs/new-service.md b/docs/new-service.md new file mode 100644 index 0000000..2bd042f --- /dev/null +++ b/docs/new-service.md @@ -0,0 +1,13 @@ +# Как добавить сервис + +1. Создай отдельную репу через фабрику в `portable-agent/infra`. +2. Подключи подходящий общий workflow из `portable-agent/.github`. +3. Собери и опубликуй образ в GHCR. +4. Запусти `scripts/new-service.ps1` в этом репозитории. +5. Замени `change-me` на immutable tag или digest и укажи только нужные `secretRefs`. +6. Открой PR. После проверок Argo CD увидит новый каталог автоматически. + +Генератор не перезаписывает существующий сервис и проверяется отдельным тестом. Общий chart +даёт непривилегированный контейнер, read-only filesystem, лимиты ресурсов, probes и закрытую +сетевую политику по умолчанию. + diff --git a/environments/environments.json b/environments/environments.json new file mode 100644 index 0000000..7dcdd17 --- /dev/null +++ b/environments/environments.json @@ -0,0 +1,9 @@ +{ + "$schema": "./environments.schema.json", + "items": [ + {"name": "local", "autoSync": true, "replicas": 1}, + {"name": "dev", "autoSync": true, "replicas": 1}, + {"name": "stage", "autoSync": false, "replicas": 2} + ] +} + diff --git a/environments/environments.schema.json b/environments/environments.schema.json new file mode 100644 index 0000000..14a5de4 --- /dev/null +++ b/environments/environments.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "autoSync", "replicas"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z0-9-]+$"}, + "autoSync": {"type": "boolean"}, + "replicas": {"type": "integer", "minimum": 1} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} + diff --git a/mkdocs.yml b/mkdocs.yml index 9af1608..7420bae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,8 @@ nav: - Главная: index.md - Архитектура: architecture.md - Разработка: development.md + - Новый сервис: new-service.md - Эксплуатация: runbook.md - Решения: - Отдельный deploy-репозиторий: decisions/0001-deploy-repository.md + - Платформа разработки: decisions/0002-developer-platform.md diff --git a/scripts/check-chart.ps1 b/scripts/check-chart.ps1 index c95951e..29c7c41 100644 --- a/scripts/check-chart.ps1 +++ b/scripts/check-chart.ps1 @@ -1,21 +1,18 @@ -param( - [string]$ChartPath = "charts/platform-base" -) - $ErrorActionPreference = "Stop" if (-not (Get-Command helm -ErrorAction SilentlyContinue)) { throw "Helm не найден. Установи Helm 4.2.4 или запусти проверку в CI." } -& helm lint $ChartPath --strict -if ($LASTEXITCODE -ne 0) { - throw "helm lint завершился с ошибкой." -} - -& helm template platform-base $ChartPath --namespace portable-agent-smoke --set environment=smoke | Out-Null -if ($LASTEXITCODE -ne 0) { - throw "helm template завершился с ошибкой." +foreach ($item in @( + @{ Name = "platform-base"; Path = "charts/platform-base"; Args = @("--set", "environment=smoke") }, + @{ Name = "service"; Path = "charts/service"; Args = @("-f", "charts/service/tests/smoke-values.yaml") }, + @{ Name = "gitops"; Path = "charts/gitops"; Args = @("-f", "charts/gitops/tests/smoke-values.yaml") } +)) { + & helm lint $item.Path --strict @($item.Args) + if ($LASTEXITCODE -ne 0) { throw "helm lint $($item.Name) завершился с ошибкой." } + & helm template $item.Name $item.Path --namespace portable-agent-smoke @($item.Args) | Out-Null + if ($LASTEXITCODE -ne 0) { throw "helm template $($item.Name) завершился с ошибкой." } } -Write-Host "Helm chart прошёл lint и render." +Write-Host "Все Helm charts прошли lint и render." diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 new file mode 100644 index 0000000..224aff5 --- /dev/null +++ b/scripts/check-compose.ps1 @@ -0,0 +1,8 @@ +$ErrorActionPreference = "Stop" +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw "Docker не найден. Запусти проверку в CI или установи Docker Desktop." +} +& docker compose --env-file .env.example --env-file config/versions.env -f compose/compose.yaml --profile core --profile observe config --quiet +if ($LASTEXITCODE -ne 0) { throw "Compose config содержит ошибку." } +Write-Host "Compose config прошёл проверку." + diff --git a/scripts/check-config.ps1 b/scripts/check-config.ps1 new file mode 100644 index 0000000..f11963a --- /dev/null +++ b/scripts/check-config.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" +& "$PSScriptRoot/check-chart.ps1" +& "$PSScriptRoot/test-new-service.ps1" +if (Get-Command docker -ErrorAction SilentlyContinue) { & "$PSScriptRoot/check-compose.ps1" } + +$secretPatterns = @('ghp_[A-Za-z0-9]+', 'github_pat_', 'AKIA[0-9A-Z]{16}', '-----BEGIN .*PRIVATE KEY-----') +$files = & git ls-files | Where-Object { $_ -ne 'scripts/check-config.ps1' -and (Test-Path -LiteralPath $_) } +foreach ($pattern in $secretPatterns) { + if ($files | Select-String -Pattern $pattern -Quiet) { throw "Найден фрагмент, похожий на секрет: $pattern" } +} +Write-Host "Быстрые проверки инфраструктуры прошли." diff --git a/scripts/new-service.ps1 b/scripts/new-service.ps1 new file mode 100644 index 0000000..d9d5da4 --- /dev/null +++ b/scripts/new-service.ps1 @@ -0,0 +1,49 @@ +param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Image, + [int]$Port = 8080, + [string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +) + +$ErrorActionPreference = "Stop" +if ($Name -notmatch '^[a-z][a-z0-9-]{1,61}[a-z0-9]$') { + throw "Name должен быть DNS-именем: маленькие латинские буквы, цифры и дефис." +} +if ($Image -notmatch '^[a-zA-Z0-9._/-]+$') { throw "Image содержит недопустимые символы." } +if ($Port -lt 1 -or $Port -gt 65535) { throw "Port должен быть от 1 до 65535." } + +$catalogPath = Join-Path $Root "services/catalog.json" +$environmentPath = Join-Path $Root "environments/environments.json" +$catalog = Get-Content $catalogPath -Raw | ConvertFrom-Json +$environmentList = Get-Content $environmentPath -Raw | ConvertFrom-Json +if ($catalog.items.name -contains $Name) { throw "Сервис $Name уже зарегистрирован." } + +foreach ($environment in $environmentList.items) { + $folder = Join-Path $Root "environments/$($environment.name)/services/$Name" + if (Test-Path $folder) { throw "Папка уже существует: $folder" } +} + +foreach ($environment in $environmentList.items) { + $folder = Join-Path $Root "environments/$($environment.name)/services/$Name" + New-Item -ItemType Directory -Path $folder -Force | Out-Null + $values = @" +fullnameOverride: $Name +replicaCount: $($environment.replicas) +image: + repository: $Image + tag: change-me +containerPort: $Port +service: + port: 80 +config: + APP_ENV: $($environment.name) +secretRefs: [] +"@ + Set-Content -Path (Join-Path $folder "values.yaml") -Value $values -Encoding utf8NoBOM +} + +$catalog.items += [pscustomobject]@{ name = $Name; image = $Image; port = $Port } +$catalog.items = @($catalog.items | Sort-Object name) +$catalog | ConvertTo-Json -Depth 10 | Set-Content $catalogPath -Encoding utf8NoBOM +Write-Host "Сервис $Name добавлен во все окружения. Проверь tag и secretRefs перед commit." + diff --git a/scripts/smoke-k3d.ps1 b/scripts/smoke-k3d.ps1 index 6a05ba5..377b36d 100644 --- a/scripts/smoke-k3d.ps1 +++ b/scripts/smoke-k3d.ps1 @@ -1,6 +1,7 @@ param( [string]$ClusterName = "portable-agent-smoke", - [string]$ChartPath = "charts/platform-base" + [string]$ChartPath = "charts/platform-base", + [string]$ServiceChartPath = "charts/service" ) $ErrorActionPreference = "Stop" @@ -49,7 +50,17 @@ try { throw "Smoke-проверка ожидала ready=true, получено: $ready" } - Write-Host "Helm chart успешно установлен и проверен в k3d." + & helm upgrade --install smoke-service $ServiceChartPath ` + --kube-context $context ` + --namespace portable-agent-smoke ` + --values "$ServiceChartPath/tests/smoke-values.yaml" ` + --wait + if ($LASTEXITCODE -ne 0) { throw "Не удалось установить общий service chart." } + + & kubectl --context $context --namespace portable-agent-smoke rollout status deployment/smoke-service --timeout=90s + if ($LASTEXITCODE -ne 0) { throw "Deployment общего service chart не стал Ready." } + + Write-Host "Базовый и общий service charts успешно установлены и проверены в k3d." } finally { if ($created) { diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 new file mode 100644 index 0000000..b7600bc --- /dev/null +++ b/scripts/start-local.ps1 @@ -0,0 +1,9 @@ +param([switch]$Observe) +$ErrorActionPreference = "Stop" +$envFiles = @("--env-file", ".env.example", "--env-file", "config/versions.env") +if (Test-Path .env) { $envFiles += @("--env-file", ".env") } +$profiles = @("--profile", "core") +if ($Observe) { $profiles += @("--profile", "observe") } +& docker compose @envFiles -f compose/compose.yaml @profiles up -d --wait +if ($LASTEXITCODE -ne 0) { throw "Локальная инфраструктура не запустилась." } +Write-Host "Локальная инфраструктура готова. Для остановки: pwsh ./scripts/stop-local.ps1" diff --git a/scripts/stop-local.ps1 b/scripts/stop-local.ps1 new file mode 100644 index 0000000..1d8efd7 --- /dev/null +++ b/scripts/stop-local.ps1 @@ -0,0 +1,8 @@ +param([switch]$DeleteData) +$ErrorActionPreference = "Stop" +$args = @("compose", "--env-file", ".env.example", "--env-file", "config/versions.env") +if (Test-Path .env) { $args += @("--env-file", ".env") } +$args += @("-f", "compose/compose.yaml", "--profile", "core", "--profile", "observe", "down") +if ($DeleteData) { $args += "--volumes" } +& docker @args +if ($LASTEXITCODE -ne 0) { throw "Не удалось остановить локальную инфраструктуру." } diff --git a/scripts/test-new-service.ps1 b/scripts/test-new-service.ps1 new file mode 100644 index 0000000..de86c6e --- /dev/null +++ b/scripts/test-new-service.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = "Stop" +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("pa-service-" + [guid]::NewGuid()) +try { + New-Item -ItemType Directory -Path "$tempRoot/services", "$tempRoot/environments" | Out-Null + Copy-Item "$PSScriptRoot/../services/catalog.json" "$tempRoot/services/catalog.json" + Copy-Item "$PSScriptRoot/../environments/environments.json" "$tempRoot/environments/environments.json" + & "$PSScriptRoot/new-service.ps1" -Name sample-api -Image ghcr.io/example/sample-api -Port 8080 -Root $tempRoot + foreach ($envName in @("local", "dev", "stage")) { + if (-not (Test-Path "$tempRoot/environments/$envName/services/sample-api/values.yaml")) { + throw "Нет values для $envName." + } + } + $duplicateFailed = $false + try { & "$PSScriptRoot/new-service.ps1" -Name sample-api -Image ghcr.io/example/sample-api -Root $tempRoot } catch { $duplicateFailed = $true } + if (-not $duplicateFailed) { throw "Повторная регистрация должна завершаться ошибкой." } + Write-Host "Генератор нового сервиса прошёл тест." +} +finally { + if (Test-Path $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } +} diff --git a/services/catalog.json b/services/catalog.json new file mode 100644 index 0000000..2c5d482 --- /dev/null +++ b/services/catalog.json @@ -0,0 +1,5 @@ +{ + "$schema": "./catalog.schema.json", + "items": [] +} + diff --git a/services/catalog.schema.json b/services/catalog.schema.json new file mode 100644 index 0000000..2397063 --- /dev/null +++ b/services/catalog.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "image", "port"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,61}[a-z0-9]$"}, + "image": {"type": "string", "minLength": 3}, + "port": {"type": "integer", "minimum": 1, "maximum": 65535} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} +