commit 0e877eac63ee5aff063e2529ef2d95833662578f Author: planpal Date: Wed Sep 2 13:45:57 2026 +0700 planpalto-helm: App-of-Apps (staging+prod) + gitea diff --git a/planpalto-helm/Chart.yaml b/planpalto-helm/Chart.yaml new file mode 100644 index 0000000..bd4235c --- /dev/null +++ b/planpalto-helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: planpal +description: PlanPal app stack for the fakhri-k8 EKS cluster (ESO + ALB ingress). +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/planpalto-helm/README.md b/planpalto-helm/README.md new file mode 100644 index 0000000..dadf678 --- /dev/null +++ b/planpalto-helm/README.md @@ -0,0 +1,164 @@ +# planpalto-helm + +Helm chart for the PlanPal stack. Converted from `planpalto-infrato` (kustomize) +one-to-one, targeting the fakhri-k8 EKS cluster: ESO reads secrets from AWS +Secrets Manager, and an ALB ingress serves the app. + +## Two ways to run it + +1. **Direct Helm** (below) - one release, no Argo. Good for a quick test. +2. **Argo CD App-of-Apps** ([argocd/](argocd/)) - Git-driven, two environments + (staging + prod), self-healing. This is the deploy path. See + [Argo CD App-of-Apps](#argo-cd-app-of-apps). + +## Install (direct Helm) + +```bash +helm upgrade --install planpal ./planpalto-helm +``` + +The chart creates the `planpal` namespace and the `planpal-backend` service +account itself. It does NOT install the controllers those objects depend on - +External Secrets Operator, the AWS Load Balancer Controller, and metrics-server +must already run on the cluster (they do on fakhri-k8). + +## Argo CD App-of-Apps + +One **root** Application ([argocd/root-app.yaml](argocd/root-app.yaml)) syncs the +child Applications in [argocd/apps/](argocd/apps/): one per environment. Each +child installs *this same chart* with a different value-file, so the two envs +share nothing. + +``` +planpal-root (root App-of-Apps) + ├─ planpal-staging ns planpal-staging host staging.fakhri-k8... own ALB + └─ planpal-prod ns planpal-prod host fakhri-k8... planpal-helm ALB +``` + +Objects inside each env are ordered by `argocd.argoproj.io/sync-wave`: + +| wave | objects | +|------|---------| +| 0 | ClusterSecretStore + ExternalSecrets, redis, nats | +| 1 | app Deployments + Services + HPA + PDB | +| 2 | ALB ingress | +| 3 | seed-admin Job | + +Argo waits for a wave to be healthy before the next. So secrets and deps land +before app pods, ingress after pods exist, seed last. + +### Chart source: in-cluster Gitea + +Argo pulls the chart from Git, so the chart lives in a repo. This setup uses a +self-hosted **Gitea** on the cluster ([gitea/gitea.yaml](gitea/gitea.yaml)): +one pod, SQLite, one 5Gi PVC. Argo reaches it over the cluster network at +`http://gitea-http.gitea.svc.cluster.local:3000`, so no ingress or TLS is needed +for the Argo-to-Gitea hop. + +Gitea is installed **outside** Argo (plain `kubectl apply`) because Argo pulls +its own manifests *from* Gitea - it cannot manage the git host it depends on. + +### Bootstrap order (once) + +```bash +# 1. Git host +kubectl apply -f planpalto-helm/gitea/gitea.yaml +kubectl -n gitea rollout status deploy/gitea + +# 2. Create the admin user + an org/repo in Gitea, then push this tree. +# (port-forward for the browser, or use the API) +kubectl -n gitea exec deploy/gitea -- \ + gitea admin user create --admin --username planpal \ + --password 'CHANGE_ME' --email you@example.com --must-change-password=false +kubectl -n gitea port-forward svc/gitea-http 3000:3000 # open http://localhost:3000, make org "planpal" + repo "ch4" + +git init && git add . && git commit -m "init" +git remote add gitea http://planpal:CHANGE_ME@localhost:3000/planpal/ch4.git +git push gitea HEAD:main + +# 3. Point Argo at it: the repoURL in the three argocd/*.yaml files is already +# http://gitea-http.gitea.svc.cluster.local:3000/planpal/ch4.git . +# Add the repo credentials to Argo, then apply the root: +argocd repo add http://gitea-http.gitea.svc.cluster.local:3000/planpal/ch4.git \ + --username planpal --password CHANGE_ME +kubectl apply -f planpalto-helm/argocd/root-app.yaml +``` + +After that, `git push` to Gitea is the deploy. Argo notices, syncs, self-heals. + +> Change the `repoURL` org/repo (`planpal/ch4`) in all three +> [argocd/](argocd/) files if you name yours differently. + +## What it renders + +| Group | Objects | +|-------|---------| +| Apps | Deployments backend, frontend, schedule/notification/ai workers; Services for backend+frontend; HPAs for all five; PDBs for backend+frontend | +| Deps | redis, nats (Deployment + Service each) | +| Secrets | 1 ClusterSecretStore + 4 ExternalSecrets (backend-env, aws-env, seed-env, frontend-env) | +| Ingress | ALB ingress on `.Values.ingress.host` with the ACM cert | +| Seed | one-shot `seed-admin` Job (TTL 300s) | + +## Multiple installs on one cluster + +Each release is fully separated. `namespace`, the ALB `group.name`, and the +cluster-scoped `ClusterSecretStore` all default to the release name, so two +installs share nothing: + +```bash +helm install planpal-a ./planpalto-helm --set ingress.host=a.example.com +helm install planpal-b ./planpalto-helm --set ingress.host=b.example.com +``` + +| Per-release resource | planpal-a | planpal-b | +|----------------------|-----------|-----------| +| Namespace | planpal-a | planpal-b | +| ClusterSecretStore | planpal-a-aws-planpal | planpal-b-aws-planpal | +| ALB (group.name) | planpal-a | planpal-b | + +Give each release a distinct `ingress.host`. Each release gets its own ALB, so +each also gets its own DNS name. Set the same `ingress.groupName` on two +releases only if you later want them to share one ALB. + +Releases share the same AWS Secrets Manager keys (`planpal/*`) by default. Same +DB, admin seed, and config across releases, which you said is fine. To give a +release its own data, point `externalSecrets.secrets` at different keys and +allow the ESO controller role to read them. + +## external-dns + +Set `ingress.externalDNS=true` to add the external-dns hostname annotation. The +external-dns controller then creates the Route53 record for `ingress.host`. This +needs external-dns installed on the cluster and permitted on the target hosted +zone. Without it, create the DNS record by hand (CNAME/alias to the ALB). + +## Common overrides + +```bash +# different DNS name + cert +helm upgrade --install planpal ./planpalto-helm \ + --set ingress.host=my.example.com \ + --set ingress.certArn=arn:aws:acm:... + +# bump the frontend image tag +helm upgrade --install planpal ./planpalto-helm \ + --set image.frontend.tag=1.2 + +# skip the seed Job on a re-deploy +helm upgrade --install planpal ./planpalto-helm --set seedJob.enabled=false +``` + +## Notes on the conversion + +- The five app Deployments share one templated shape in `templates/apps.yaml`, + driven by the `apps:` map in `values.yaml`. The map key is the workload name. + An entry with `image: backend` gets the service account and backend image; an + entry with `service:` gets a Service; `hpa:`/`pdb:` blocks render only when + present. This is the kustomize base + per-app difference, expressed as data. +- ESO is fully templated: add a `target: smKey` pair under + `externalSecrets.secrets` and a new ExternalSecret appears. +- The kustomize `secretGenerator` for `planpal-tls` and the `bootstrap.yaml` + RDS-bootstrap Pod are NOT in this chart. Neither was in the kustomize + `resources:` list either. TLS now comes from ACM on the ALB, and RDS + bootstrap is a one-shot manual step. Run it by hand if a fresh DB needs it. +``` diff --git a/planpalto-helm/argocd/apps/planpal-prod.yaml b/planpalto-helm/argocd/apps/planpal-prod.yaml new file mode 100644 index 0000000..7bbdbba --- /dev/null +++ b/planpalto-helm/argocd/apps/planpal-prod.yaml @@ -0,0 +1,29 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: planpal-prod + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: http://gitea-http.gitea.svc.cluster.local:3000/planpal/ch4.git + targetRevision: HEAD + path: planpalto-helm + helm: + valueFiles: + - values.yaml + - values-prod.yaml + destination: + server: https://kubernetes.default.svc + namespace: planpal-prod + syncPolicy: + # ponytail: prod stays automated to match staging. Drop `automated` here and + # sync prod by hand from the Argo UI if you want a manual prod gate. + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ServerSideApply=true diff --git a/planpalto-helm/argocd/apps/planpal-staging.yaml b/planpalto-helm/argocd/apps/planpal-staging.yaml new file mode 100644 index 0000000..90b2d0b --- /dev/null +++ b/planpalto-helm/argocd/apps/planpal-staging.yaml @@ -0,0 +1,29 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: planpal-staging + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: http://gitea-http.gitea.svc.cluster.local:3000/planpal/ch4.git + targetRevision: HEAD + path: planpalto-helm + helm: + valueFiles: + - values.yaml + - values-staging.yaml + destination: + server: https://kubernetes.default.svc + namespace: planpal-staging + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + # ClusterSecretStore is cluster-scoped; without this Argo warns about the + # missing namespace on a cluster-scoped resource. Harmless, silences it. + - ServerSideApply=true diff --git a/planpalto-helm/argocd/root-app.yaml b/planpalto-helm/argocd/root-app.yaml new file mode 100644 index 0000000..856bfbf --- /dev/null +++ b/planpalto-helm/argocd/root-app.yaml @@ -0,0 +1,28 @@ +# Root App-of-Apps. Its only job is to sync the child Applications in +# argocd/apps/ (one per environment). Apply this ONCE by hand; Argo owns the +# rest. Bootstrap: kubectl apply -f planpalto-helm/argocd/root-app.yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: planpal-root + namespace: argocd + # keep the root around if you ever `kubectl delete` it by accident + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + # In-cluster Gitea. Argo talks to Gitea over the cluster network, so no + # ingress or TLS is needed for this hop. Change org/repo to match your push. + repoURL: http://gitea-http.gitea.svc.cluster.local:3000/planpal/ch4.git + targetRevision: HEAD + path: planpalto-helm/argocd/apps + directory: + recurse: true + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/planpalto-helm/docs/render-pipeline.html b/planpalto-helm/docs/render-pipeline.html new file mode 100644 index 0000000..90da4f0 --- /dev/null +++ b/planpalto-helm/docs/render-pipeline.html @@ -0,0 +1,467 @@ +planpalto-helm — render pipeline + + +
+ + +
+
+

planpalto-helm — render pipeline

+

How helm install turns the chart on disk into running objects on the fakhri-k8 EKS cluster.

+
+
+
live / rendered value
+
static linework
+
Npipeline stage
+
+
+ +
+ + +

Render pipeline — 6 stages

+
+
+
1Inputs
+

Chart.yaml · values.yaml · templates/* · _helpers.tpl · optional -f envs/a.yaml

+ paths +
+
+
2Command
+

You run one helm command. Helm reads the chart and any override file.

+ flags+values +
+
+
3Render
+

Go templating merges values + .Release.*, walks templates/, emits one multi-doc YAML.

+ YAML +
+
+
4Submit
+

Manifest sent to the k8s API server. Helm records the revision in a Secret.

+ objects +
+
+
5Reconcile
+

Controllers act: Deployment → ReplicaSet → Pod. Scheduler places Pods on EKS nodes.

+ pods +
+
+
6Add-ons
+

ESO, AWS Load Balancer Controller, and metrics-server finish the job.

+
+
+ + +
+
+

1 · Inputs on disk

+
planpalto-helm/ +├─ Chart.yaml +├─ values.yaml apps: map (5 entries) + deps + global +├─ templates/ +│ ├─ namespace.yaml +│ ├─ apps.yaml {{ range .Values.apps }} +│ ├─ deps.yaml redis, nats +│ ├─ external-secrets.yaml +│ ├─ ingress.yaml +│ └─ seed-job.yaml +└─ _helpers.tpl planpal.namespace / .storeName / .image + + override: -f envs/a.yaml
+
+
+
+

2 · command ↓

+
helm upgrade --install planpal ./planpalto-helm -f envs/a.yaml
+

upgrade --install = create if absent, else new revision.

+
+
+
+ + +
+

3 · Render — Go template engine (client-side, in Helm)

+
+
+

Merged values

+
values.yaml
+  + -f envs/a.yaml   (override)
+  + .Release.Name planpal
+  + .Release.Namespace
+
+
+

Go template engine

+
    +
  • {{ .Values.x }} substitute
  • +
  • {{ if }} / {{ with }} conditional / scope
  • +
  • {{ range }} loop over apps map
  • +
  • {{ include "planpal.namespace" . }} named template from _helpers.tpl
  • +
  • | default | toJson | toString pipes
  • +
+
+
+

Multi-doc YAML

+
---
+kind: Namespace
+...
+---
+kind: Deployment
+metadata:
+  name: planpal-backend
+...
+---   (× many docs)
+
+
+
+ + +
+
+

5 · Reconcile

+
+ Deployment + ReplicaSet + Pod + scheduler + EKS node +
+

Controllers reconcile the declared state. HPA scales replicas via metrics-server.

+
+
+

6 · Cluster add-ons

+
ExternalSecret▶ ESOreads AWS Secrets Manager → real k8s Secret
+
Ingress▶ ALB ctrlprovisions an ALB
+
HPA▶ metrics-serverfeeds CPU/mem → scale
+
+
+ +
+ + +

Template → object mapbill of materials · rendered from the chart, exact counts

+
+ + + + + + + + + + + +
Source fileRendered objects
templates/namespace.yaml1 Namespace · 1 ServiceAccount planpal-backend
templates/apps.yaml5 Deployment · 2 Service · 5 HPA · 2 PDB  ← one {{ range }} loop, see DETAIL A
templates/deps.yaml2 Deployment redis, nats · 2 Service  (these 2 also count toward the 7 Deployment total)
templates/external-secrets.yaml1 ClusterSecretStore · 4 ExternalSecret
templates/ingress.yaml1 Ingress (ALB)
templates/seed-job.yaml1 Job seed-admin (runs once)
Σ total7 Deployment · 4 Service · 5 HPA · 2 PDB · 1 Ingress · 1 Job · 1 Namespace · 1 ServiceAccount · 1 ClusterSecretStore · 4 ExternalSecret
+
+ +
+ + +
+ + +
+ Detail A +

One {{ range .Values.apps }} over a 5-entry map builds 5 Deployments. Conditionals add the extras: a Service if service:, an HPA if hpa:, a PDB if pdb:.

+
+ +
+ +
+
backendservice:hpa:pdb:
+
frontendservice:hpa:pdb:
+
schedule-workerhpa:
+
notification-workerhpa:
+
ai-workerhpa:
+
+ +
+ {{ range .Values.apps }}  ×5 +
+ +
+
Deploymentif service:Serviceif hpa:HPAif pdb:PDB
+
Deploymentif service:Serviceif hpa:HPAif pdb:PDB
+
Deploymentif hpa:HPA
+
Deploymentif hpa:HPA
+
Deploymentif hpa:HPA
+
+
+
+

apps.yaml → 5 Deployment · 2 Service · 5 HPA · 2 PDB. The redis + nats Deployments come from deps.yaml, not this loop — they are the other 2 in the total of 7.

+
+ + +
+ Detail B +

Each helm install lands in its own namespace + ALB + ClusterSecretStore, all defaulting to .Release.Name via _helpers.tpl.

+
+
+

planpal-a

+
+
Namespace
planpal-a
+
Ingress → ALB
(own LB)
+
ClusterSecretStore
planpal-a-aws-planpal
+
+
+
+

planpal-b

+
+
Namespace
planpal-b
+
Ingress → ALB
(own LB)
+
ClusterSecretStore
planpal-b-aws-planpal
+
+
+
+

planpal.namespace = default .Release.Name → planpal-a / planpal-b +planpal.storeName = per-release CSS + +The empty gutter is the isolation: no +connector crosses it. Two releases share +nothing on the cluster.

+
+ +
+ + +
+
+
Chart
planpalto-helm
+
Release
planpal
+
Cluster
fakhri-k8 (EKS)
+
Release state
Secret sh.helm.release.v1
+
Scale
NTS
+
Sheet
1 of 1
+
Date
2026-09-01
+
+
+ +
diff --git a/planpalto-helm/gitea/gitea-ingress.yaml b/planpalto-helm/gitea/gitea-ingress.yaml new file mode 100644 index 0000000..a04e07b --- /dev/null +++ b/planpalto-helm/gitea/gitea-ingress.yaml @@ -0,0 +1,30 @@ +# Gitea UI ingress. Reuses the planpal-helm ALB group, so no new load balancer. +# Own ACM cert (single-name, like the argocd and app certs). +# kubectl apply -f planpalto-helm/gitea/gitea-ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gitea + namespace: gitea + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/backend-protocol: HTTP + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]' + alb.ingress.kubernetes.io/ssl-redirect: '443' + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-southeast-3:134604498185:certificate/098ca0ec-65c8-44bb-952a-2cb86b6307d8 + alb.ingress.kubernetes.io/healthcheck-path: /api/healthz + alb.ingress.kubernetes.io/group.name: planpal-helm +spec: + ingressClassName: alb + rules: + - host: fakhri-gitea.sandbox.devopsinstitute.id + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gitea-http + port: + number: 3000 diff --git a/planpalto-helm/gitea/gitea.yaml b/planpalto-helm/gitea/gitea.yaml new file mode 100644 index 0000000..70d69bf --- /dev/null +++ b/planpalto-helm/gitea/gitea.yaml @@ -0,0 +1,75 @@ +# Gitea git host for Argo CD. SQLite, single pod, one PVC. Installed OUTSIDE +# Argo (plain kubectl apply) because Argo pulls its manifests FROM Gitea, so +# Gitea cannot be an Argo-managed app in that same repo. +# +# kubectl apply -f planpalto-helm/gitea/gitea.yaml +# +# Sizing: ~128Mi idle, fits a t3.medium next to PlanPal + Argo. +apiVersion: v1 +kind: Namespace +metadata: + name: gitea +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gitea-data + namespace: gitea +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 5Gi + # storageClassName omitted -> cluster default (gp2/gp3 on EKS). +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gitea + namespace: gitea +spec: + replicas: 1 + strategy: + type: Recreate # single RWO volume, no rolling two-pod overlap + selector: + matchLabels: { app: gitea } + template: + metadata: + labels: { app: gitea } + spec: + containers: + - name: gitea + image: gitea/gitea:1.22 + env: + - { name: GITEA__database__DB_TYPE, value: sqlite3 } + - { name: GITEA__server__ROOT_URL, value: "http://gitea-http.gitea.svc.cluster.local:3000/" } + - { name: GITEA__server__DISABLE_SSH, value: "true" } # HTTP-only; Argo uses HTTP + - { name: GITEA__service__DISABLE_REGISTRATION, value: "true" } + ports: + - { name: http, containerPort: 3000 } + volumeMounts: + - { name: data, mountPath: /data } + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 512Mi } + readinessProbe: + httpGet: { path: /api/healthz, port: 3000 } + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: { path: /api/healthz, port: 3000 } + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: data + persistentVolumeClaim: { claimName: gitea-data } +--- +apiVersion: v1 +kind: Service +metadata: + name: gitea-http + namespace: gitea +spec: + selector: { app: gitea } + ports: + - { name: http, port: 3000, targetPort: 3000 } diff --git a/planpalto-helm/templates/_helpers.tpl b/planpalto-helm/templates/_helpers.tpl new file mode 100644 index 0000000..8e491de --- /dev/null +++ b/planpalto-helm/templates/_helpers.tpl @@ -0,0 +1,17 @@ +{{/* Full image ref for an app entry: picks backend or frontend repo+tag. */}} +{{- define "planpal.image" -}} +{{- $img := index $.Values.image .which -}} +{{- printf "%s/%s:%s" $.Values.image.registry $img.repository (toString $img.tag) -}} +{{- end -}} + +{{/* Namespace: explicit value, else the release name. Lets each release be + self-contained so multiple installs don't collide. */}} +{{- define "planpal.namespace" -}} +{{- .Values.namespace | default .Release.Name -}} +{{- end -}} + +{{/* ClusterSecretStore name: cluster-scoped, so it must be unique per release. + Explicit value, else "-aws-planpal". */}} +{{- define "planpal.storeName" -}} +{{- .Values.externalSecrets.storeName | default (printf "%s-aws-planpal" .Release.Name) -}} +{{- end -}} diff --git a/planpalto-helm/templates/apps.yaml b/planpalto-helm/templates/apps.yaml new file mode 100644 index 0000000..1355ca9 --- /dev/null +++ b/planpalto-helm/templates/apps.yaml @@ -0,0 +1,90 @@ +{{- range $name, $app := .Values.apps }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + namespace: {{ include "planpal.namespace" $ }} + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + selector: + matchLabels: { app: {{ $name }} } + template: + metadata: + labels: { app: {{ $name }} } + spec: + {{- if eq $app.image "backend" }} + serviceAccountName: {{ $.Values.serviceAccount.name }} + {{- end }} + containers: + - name: {{ $name }} + image: {{ include "planpal.image" (dict "Values" $.Values "which" $app.image) }} + imagePullPolicy: {{ $.Values.image.pullPolicy }} + {{- with $app.command }} + command: {{ toJson . }} + {{- end }} + ports: + {{- range $app.ports }} + - {{ toJson . }} + {{- end }} + envFrom: + {{- range $app.envFrom }} + - secretRef: { name: {{ . }} } + {{- end }} + {{- if $app.probePath }} + readinessProbe: + httpGet: { path: {{ $app.probePath }}, port: {{ $app.probePort }} } + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: { path: {{ $app.probePath }}, port: {{ $app.probePort }} } + initialDelaySeconds: 15 + periodSeconds: 10 + {{- end }} + resources: {{ toJson $app.resources }} +--- +{{- if $app.service }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + namespace: {{ include "planpal.namespace" $ }} + {{- with $app.service.annotations }} + annotations: {{ toJson . }} + {{- end }} +spec: + selector: { app: {{ $name }} } + ports: + - name: http + port: {{ $app.service.port }} + targetPort: {{ $app.service.targetPort }} +--- +{{- end }} +{{- with $app.hpa }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ $name }} + namespace: {{ include "planpal.namespace" $ }} +spec: + scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: {{ $name }} } + minReplicas: {{ .min }} + maxReplicas: {{ .max }} + metrics: + - type: Resource + resource: { name: cpu, target: { type: Utilization, averageUtilization: {{ .cpu }} } } +--- +{{- end }} +{{- with $app.pdb }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ $name }} + namespace: {{ include "planpal.namespace" $ }} +spec: + maxUnavailable: {{ .maxUnavailable }} + selector: + matchLabels: { app: {{ $name }} } +--- +{{- end }} +{{- end }} diff --git a/planpalto-helm/templates/deps.yaml b/planpalto-helm/templates/deps.yaml new file mode 100644 index 0000000..70d4ccb --- /dev/null +++ b/planpalto-helm/templates/deps.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: {{ include "planpal.namespace" . }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + selector: + matchLabels: { app: redis } + template: + metadata: { labels: { app: redis } } + spec: + containers: + - name: redis + image: {{ .Values.redis.image }} + ports: [{ containerPort: 6379 }] + resources: {{ toJson .Values.redis.resources }} + readinessProbe: + exec: { command: ["redis-cli", "ping"] } + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: { command: ["redis-cli", "ping"] } + initialDelaySeconds: 20 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ include "planpal.namespace" . }} +spec: + selector: { app: redis } + ports: [{ port: 6379 }] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nats + namespace: {{ include "planpal.namespace" . }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + selector: + matchLabels: { app: nats } + template: + metadata: { labels: { app: nats } } + spec: + containers: + - name: nats + image: {{ .Values.nats.image }} + args: ["-m", "8222"] + ports: + - containerPort: 4222 + - containerPort: 8222 + readinessProbe: + httpGet: { path: "/healthz", port: 8222 } + periodSeconds: 5 + livenessProbe: + tcpSocket: { port: 4222 } + initialDelaySeconds: 15 + periodSeconds: 10 + resources: {{ toJson .Values.nats.resources }} +--- +apiVersion: v1 +kind: Service +metadata: + name: nats + namespace: {{ include "planpal.namespace" . }} +spec: + selector: { app: nats } + ports: + - port: 4222 + name: client diff --git a/planpalto-helm/templates/external-secrets.yaml b/planpalto-helm/templates/external-secrets.yaml new file mode 100644 index 0000000..49561de --- /dev/null +++ b/planpalto-helm/templates/external-secrets.yaml @@ -0,0 +1,32 @@ +{{- if .Values.externalSecrets.enabled }} +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: {{ include "planpal.storeName" . }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + provider: + aws: + service: SecretsManager + region: {{ .Values.externalSecrets.region }} + # credentials come from the ESO controller's Pod Identity role; no keys here. +{{- range $target, $key := .Values.externalSecrets.secrets }} +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ $target }} + namespace: {{ include "planpal.namespace" $ }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + refreshInterval: {{ $.Values.externalSecrets.refreshInterval }} + secretStoreRef: { name: {{ include "planpal.storeName" $ }}, kind: ClusterSecretStore } + target: + name: {{ $target }} + creationPolicy: Owner + dataFrom: + - extract: { key: {{ $key }} } +{{- end }} +{{- end }} diff --git a/planpalto-helm/templates/ingress.yaml b/planpalto-helm/templates/ingress.yaml new file mode 100644 index 0000000..83554b9 --- /dev/null +++ b/planpalto-helm/templates/ingress.yaml @@ -0,0 +1,37 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: planpal + namespace: {{ include "planpal.namespace" . }} + annotations: + argocd.argoproj.io/sync-wave: "2" + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80},{"HTTPS":443}]' + alb.ingress.kubernetes.io/certificate-arn: {{ .Values.ingress.certArn }} + alb.ingress.kubernetes.io/healthcheck-path: {{ .Values.ingress.healthcheckPath }} + # ALB per release: group.name defaults to the release name, so each install + # gets its own load balancer. Set the same ingress.groupName on two releases + # to make them share one ALB instead. + alb.ingress.kubernetes.io/group.name: {{ .Values.ingress.groupName | default .Release.Name }} + {{- if .Values.ingress.externalDNS }} + # external-dns creates the Route53 record for this host from the rule below. + # This annotation is only needed when the record name differs from the host. + external-dns.alb.ingress.kubernetes.io/hostname: {{ .Values.ingress.host }} + {{- end }} + # ponytail: HTTP:80 and HTTPS:443 both open, no forced redirect. Add the + # ssl-redirect annotation later to push 80 -> 443. +spec: + ingressClassName: {{ .Values.ingress.className }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: /api/v1 + pathType: Prefix + backend: { service: { name: backend, port: { number: {{ .Values.apps.backend.service.port }} } } } + - path: / + pathType: Prefix + backend: { service: { name: frontend, port: { number: {{ .Values.apps.frontend.service.port }} } } } +{{- end }} diff --git a/planpalto-helm/templates/namespace.yaml b/planpalto-helm/templates/namespace.yaml new file mode 100644 index 0000000..a2a6013 --- /dev/null +++ b/planpalto-helm/templates/namespace.yaml @@ -0,0 +1,14 @@ +{{- if .Values.createNamespace }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "planpal.namespace" . }} +{{- end }} +{{- if .Values.serviceAccount.create }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name }} + namespace: {{ include "planpal.namespace" . }} +{{- end }} diff --git a/planpalto-helm/templates/seed-job.yaml b/planpalto-helm/templates/seed-job.yaml new file mode 100644 index 0000000..0bc1dbf --- /dev/null +++ b/planpalto-helm/templates/seed-job.yaml @@ -0,0 +1,28 @@ +{{- if .Values.seedJob.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: seed-admin + namespace: {{ include "planpal.namespace" . }} + annotations: + argocd.argoproj.io/sync-wave: "3" + # Argo re-runs a Job by hash; a spec change makes a new Job. Delete the old + # one before sync if it lingers, or set this to a Sync hook if you want it + # to run every sync. ponytail: kept as a plain waved object, not a hook. +spec: + backoffLimit: 10 + ttlSecondsAfterFinished: 300 + template: + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + restartPolicy: OnFailure + containers: + - name: seed-admin + image: {{ include "planpal.image" (dict "Values" .Values "which" "backend") }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: {{ toJson .Values.seedJob.command }} + envFrom: + {{- range .Values.seedJob.envFrom }} + - secretRef: { name: {{ . }} } + {{- end }} +{{- end }} diff --git a/planpalto-helm/values-prod.yaml b/planpalto-helm/values-prod.yaml new file mode 100644 index 0000000..71570c9 --- /dev/null +++ b/planpalto-helm/values-prod.yaml @@ -0,0 +1,25 @@ +# Prod overrides. Everything else inherits values.yaml. +# Keeps the existing live host and ALB group (planpal-helm) so this env +# adopts the running deployment instead of standing up a second one. +namespace: planpal-prod + +image: + frontend: + tag: latest + backend: + tag: latest + +externalSecrets: + storeName: prod-aws-planpal + secrets: + backend-env: planpal/backend-env + aws-env: planpal/aws-env + seed-env: planpal/seed-env + frontend-env: planpal/frontend + +ingress: + host: fakhri-k8.sandbox.devopsinstitute.id + groupName: planpal-helm # existing live ALB group + +seedJob: + enabled: true diff --git a/planpalto-helm/values-staging.yaml b/planpalto-helm/values-staging.yaml new file mode 100644 index 0000000..6414f75 --- /dev/null +++ b/planpalto-helm/values-staging.yaml @@ -0,0 +1,29 @@ +# Staging overrides. Everything else inherits values.yaml. +# Pinned explicitly (not left to release-name defaults) so the two envs +# provably never share a namespace, ALB, or cluster-scoped store. +namespace: planpal-staging + +image: + frontend: + tag: latest + backend: + tag: latest + +externalSecrets: + storeName: staging-aws-planpal + # ponytail: staging points at the SAME planpal/* SM keys as prod for now. + # Give staging its own DB/config by pointing these at planpal-staging/* keys + # and letting the ESO role read them. + secrets: + backend-env: planpal/backend-env + aws-env: planpal/aws-env + seed-env: planpal/seed-env + frontend-env: planpal/frontend + +ingress: + host: staging.fakhri-k8.sandbox.devopsinstitute.id + groupName: planpal-staging # own ALB, separate from prod + +# Seed the admin on staging too. Flip to false once seeded. +seedJob: + enabled: true diff --git a/planpalto-helm/values.yaml b/planpalto-helm/values.yaml new file mode 100644 index 0000000..a9e928d --- /dev/null +++ b/planpalto-helm/values.yaml @@ -0,0 +1,140 @@ +# PlanPal Helm values. Mirrors planpalto-infrato/k8s + kustomization.yaml. +# namespace empty -> defaults to the release name, so multiple installs +# (helm install planpal-a / planpal-b) land in separate namespaces and don't +# collide. Set it to pin a fixed namespace. +namespace: "" +createNamespace: true + +image: + registry: 134604498185.dkr.ecr.ap-southeast-3.amazonaws.com + backend: + repository: fakhri-planpal-backend + tag: latest + frontend: + repository: fakhri-planpal-frontend + tag: latest # ECR frontend repo only publishes latest (1.1 was a minikube-local tag) + pullPolicy: IfNotPresent + +serviceAccount: + name: planpal-backend # Pod Identity -> role fakhri-app-planpal + create: true + +# App workloads. backend+workers share the backend image and mount backend-env+aws-env. +# frontend uses the frontend image and frontend-env. +apps: + backend: + command: [planpal-server] + image: backend + envFrom: [backend-env, aws-env] + ports: + - { name: http, containerPort: 8088 } + - { name: metrics, containerPort: 9090 } + service: + port: 8088 + targetPort: 8088 + probePath: /api/v1/health + probePort: 8088 + resources: + requests: { cpu: 25m, memory: 64Mi } + limits: { cpu: 250m, memory: 256Mi } + hpa: { min: 2, max: 5, cpu: 50 } + pdb: { maxUnavailable: 1 } + + frontend: + command: [] + image: frontend + envFrom: [frontend-env] + ports: + - { name: http, containerPort: 3000 } + service: + port: 3000 + targetPort: 3000 + annotations: + alb.ingress.kubernetes.io/healthcheck-path: "/" + alb.ingress.kubernetes.io/success-codes: "200" + probePath: / + probePort: 3000 + resources: + requests: { cpu: 25m, memory: 96Mi } + limits: { cpu: 300m, memory: 384Mi } + hpa: { min: 2, max: 5, cpu: 50 } + pdb: { maxUnavailable: 1 } + + schedule-worker: + command: [planpal-schedule-worker] + image: backend + envFrom: [backend-env, aws-env] + ports: [{ name: metrics, containerPort: 9091 }] + resources: + requests: { cpu: 25m, memory: 64Mi } + limits: { cpu: 200m, memory: 256Mi } + hpa: { min: 1, max: 3, cpu: 60 } + + notification-worker: + command: [planpal-notification-worker] + image: backend + envFrom: [backend-env, aws-env] + ports: [{ name: metrics, containerPort: 9092 }] + resources: + requests: { cpu: 25m, memory: 64Mi } + limits: { cpu: 200m, memory: 256Mi } + hpa: { min: 1, max: 3, cpu: 60 } + + ai-worker: + command: [planpal-ai-worker] + image: backend + envFrom: [backend-env, aws-env] + ports: [{ name: metrics, containerPort: 9093 }] + resources: + requests: { cpu: 25m, memory: 64Mi } + limits: { cpu: 200m, memory: 256Mi } + hpa: { min: 1, max: 3, cpu: 60 } + +# Stateless in-cluster deps. +redis: + image: redis:7-alpine + resources: + requests: { cpu: 250m, memory: 256Mi } + limits: { cpu: 500m, memory: 512Mi } +nats: + image: nats:2-alpine + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 200m, memory: 256Mi } + +# External Secrets Operator: pull each k8s Secret from AWS Secrets Manager. +externalSecrets: + enabled: true + region: ap-southeast-3 + # storeName empty -> "-aws-planpal". The ClusterSecretStore is + # cluster-scoped, so it must be unique per release. + storeName: "" + refreshInterval: 1h + # target k8s Secret name -> Secrets Manager key. Releases share these keys + # (same DB/seed/config is fine). Point a release at other keys to isolate it. + secrets: + backend-env: planpal/backend-env + aws-env: planpal/aws-env + seed-env: planpal/seed-env + frontend-env: planpal/frontend + +# Admin seed one-shot Job. +seedJob: + enabled: true + command: [seed_admin] + envFrom: [backend-env, seed-env] + +# ALB ingress. host is the external DNS name; certArn is the ACM cert. +ingress: + enabled: true + className: alb + host: fakhri-k8.sandbox.devopsinstitute.id + certArn: arn:aws:acm:ap-southeast-3:134604498185:certificate/c9fcfcb9-9d16-4dbb-989f-9f77f6496621 + # groupName empty -> release name, so each install gets its own ALB. Set the + # same value on two releases to make them share one ALB. + groupName: "" + healthcheckPath: /api/v1/health + # externalDNS true adds the external-dns hostname annotation. The external-dns + # controller then creates the Route53 record for host. Needs external-dns + # installed and permitted on the target hosted zone. + externalDNS: false