planpalto-helm: App-of-Apps (staging+prod) + gitea

This commit is contained in:
planpal 2026-09-02 13:45:57 +07:00
commit 0e877eac63
18 changed files with 1315 additions and 0 deletions

View File

@ -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"

164
planpalto-helm/README.md Normal file
View File

@ -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.
```

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,467 @@
<title>planpalto-helm — render pipeline</title>
<style>
:root {
--paper: #E7EAEE;
--paper-2: #DDE1E7;
--rule: #A4ADB8;
--ink: #242A31;
--ink-dim: #5C6672;
--accent: #1F5FA8;
--accent-weak: rgba(31, 95, 168, 0.14);
--grid: rgba(164, 173, 184, 0.14);
--font-display: "IBM Plex Mono", ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #161A1F; --paper-2: #1D232A; --rule: #39424D;
--ink: #D6DBE1; --ink-dim: #828C98; --accent: #4C93E0;
--accent-weak: rgba(76, 147, 224, 0.18);
--grid: rgba(57, 66, 77, 0.22);
}
}
:root[data-theme="dark"] {
--paper: #161A1F; --paper-2: #1D232A; --rule: #39424D;
--ink: #D6DBE1; --ink-dim: #828C98; --accent: #4C93E0;
--accent-weak: rgba(76, 147, 224, 0.18);
--grid: rgba(57, 66, 77, 0.22);
}
:root[data-theme="light"] {
--paper: #E7EAEE; --paper-2: #DDE1E7; --rule: #A4ADB8;
--ink: #242A31; --ink-dim: #5C6672; --accent: #1F5FA8;
--accent-weak: rgba(31, 95, 168, 0.14);
--grid: rgba(164, 173, 184, 0.14);
}
* { box-sizing: border-box; border-radius: 0; }
body {
margin: 0; padding: 20px 12px;
background: var(--paper); color: var(--ink);
font-family: var(--font-body); font-size: 14px; line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Double-rule drafting frame */
.frame-outer { max-width: 1100px; margin: 0 auto; border: 2px solid var(--ink); padding: 6px; }
.frame-inner {
border: 1px solid var(--ink); padding: 24px;
background-color: var(--paper);
background-image:
repeating-linear-gradient(to right, var(--grid) 0 1px, transparent 1px 24px),
repeating-linear-gradient(to bottom, var(--grid) 0 1px, transparent 1px 24px);
}
.mono { font-family: var(--font-mono); }
.disp { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; }
.dim { color: var(--ink-dim); }
.acc { color: var(--accent); }
.ms { font-family: var(--font-mono); font-size: 11px; line-height: 1.35; }
.sep { border: 0; border-top: 1px solid var(--rule); margin: 20px 0; }
/* ── (a) title / context strip ── */
.strip { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; flex-wrap: wrap; }
.strip h1 { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; font-size: 15px; margin: 0 0 4px; text-wrap: balance; }
.strip .sub { font-size: 14px; color: var(--ink-dim); max-width: 60ch; margin: 0; }
.legend { display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }
.legend .item { display: flex; align-items: center; gap: 6px; }
.sw-acc { width: 22px; height: 0; border-top: 2px solid var(--accent); }
.sw-ink { width: 22px; height: 0; border-top: 1px solid var(--ink); }
.stamp { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: 1px solid var(--ink); font-family: var(--font-display); font-size: 11px; flex: none; }
.secthead { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; font-size: 12px; margin: 0 0 12px; }
.secthead .st-sub { font-family: var(--font-body); text-transform: none; letter-spacing: 0; color: var(--ink-dim); font-size: 12px; margin-left: 10px; }
/* ── (b→h) pipeline spine ── */
.spine { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; position: relative; }
.stage { padding: 0 12px 0 0; position: relative; }
.stage:not(:last-child)::after {
/* dimension arrow between stages */
content: ""; position: absolute; top: 9px; right: 4px; width: 20px; height: 0;
border-top: 1.5px solid var(--accent);
}
.stage:not(:last-child) .flow-note {
position: absolute; top: -6px; right: 2px; color: var(--accent); font-size: 10px;
}
.stage-hd { display: flex; gap: 8px; align-items: flex-start; }
.stage-stamp { width: 20px; height: 20px; border: 1px solid var(--ink); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-size: 11px; flex: none; }
.stage-name { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; }
.stage-desc { font-size: 11px; color: var(--ink-dim); margin-top: 6px; padding-left: 0; line-height: 1.4; }
.stage-desc code { font-family: var(--font-mono); color: var(--ink); }
/* recessed panels / wells */
.well { background: var(--paper-2); border: 1px solid var(--rule); border-top: 1px solid var(--ink); padding: 14px; }
.well-hd { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; margin: 0 0 10px; }
.subgrid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; margin-top: 14px; }
.filetree { font-family: var(--font-mono); font-size: 12px; line-height: 1.55; white-space: pre; }
.filetree .an { color: var(--ink-dim); }
/* command callout */
.cmd-wrap { margin-top: 14px; }
.cmd-lead { color: var(--accent); font-size: 10px; font-family: var(--font-mono); margin-bottom: 4px; }
.cmd-box { position: relative; border: 1px solid var(--ink); background: var(--paper); padding: 10px 12px; font-family: var(--font-mono); font-size: 13px; overflow-x: auto; }
.cmd-box::before { content: ""; position: absolute; left: -8px; top: 50%; width: 8px; height: 0; border-top: 1px solid var(--accent); }
.cmd-note { font-family: var(--font-mono); font-size: 11px; color: var(--ink-dim); margin-top: 6px; }
/* render well 3 columns */
.render3 { display: grid; grid-template-columns: 1fr 1.15fr 1fr; gap: 0; }
.render3 .rcol { padding: 0 16px; border-left: 1px solid var(--rule); position: relative; }
.render3 .rcol:first-child { border-left: 0; padding-left: 0; }
.render3 .rcol:last-child { padding-right: 0; }
.render3 .rcol::after { content: attr(data-cross); position: absolute; top: 24px; right: -6px; z-index: 2; color: var(--accent); font-size: 9px; font-family: var(--font-mono); background: var(--paper-2); padding: 0 2px; }
.render3 .rcol:last-child::after { content: none; }
.rcol .rhd { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; margin: 0 0 8px; }
.rcol pre { font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5; margin: 0; white-space: pre-wrap; word-break: break-word; }
.mech { list-style: none; margin: 0; padding: 0; }
.mech li { font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5; margin-bottom: 6px; }
.mech .tok { color: var(--ink); }
.mech .gl { color: var(--ink-dim); }
.twin { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
.chain { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11.5px; }
.chain .bx { border: 1px solid var(--rule); padding: 3px 7px; }
.chain .ar { color: var(--accent); }
.addon-row { display: grid; grid-template-columns: auto auto 1fr; gap: 8px; align-items: baseline; font-family: var(--font-mono); font-size: 11.5px; padding: 5px 0; border-top: 1px solid var(--rule); }
.addon-row:first-of-type { border-top: 0; }
.addon-row .ar { color: var(--accent); }
.addon-row .obj { color: var(--ink); }
.addon-row .gl { color: var(--ink-dim); }
/* ── (e) BOM table ── */
.bom { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 13px; }
.bom th, .bom td { text-align: left; padding: 7px 10px; border: 1px solid var(--rule); vertical-align: top; }
.bom thead th { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.06em; font-size: 11px; color: var(--ink-dim); background: var(--paper-2); }
.bom tbody tr:nth-child(even) td { background: var(--paper-2); }
.bom .note { color: var(--accent); font-size: 11px; }
.bom .note-dim { color: var(--ink-dim); font-size: 11px; }
.bom .sum td { background: var(--accent-weak); border-top: 1px solid var(--accent); border-bottom: 1px solid var(--accent); color: var(--accent); font-size: 12.5px; }
.bom-wrap { overflow-x: auto; }
/* ── lower band ── */
.lower { display: grid; grid-template-columns: 62fr 38fr; gap: 24px; align-items: start; }
.detail { border: 1px solid var(--ink); padding: 16px 16px 14px; position: relative; }
.detail-tab { position: absolute; top: -1px; left: -1px; border: 1px solid var(--ink); background: var(--paper); font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; padding: 3px 8px; }
.detail-cap { font-size: 11.5px; color: var(--ink-dim); margin: 22px 0 16px; line-height: 1.45; }
.detail-cap code { font-family: var(--font-mono); color: var(--ink); }
/* DETAIL A fan */
.fan { position: relative; }
.fan svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.fan-grid { display: grid; grid-template-columns: minmax(150px,1.1fr) 78px minmax(210px,1.5fr); gap: 0; align-items: stretch; }
.lanes { display: flex; flex-direction: column; gap: 8px; }
.srcbox { border: 1px solid var(--rule); padding: 6px 8px; min-height: 44px; display: flex; flex-direction: column; justify-content: center; }
.srcbox .nm { font-family: var(--font-mono); font-size: 12.5px; }
.srcbox .tags { margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
.srcbox .tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--accent); }
.bracket-col { display: flex; align-items: center; justify-content: center; position: relative; }
.bracket-lbl { font-family: var(--font-mono); font-size: 10px; color: var(--accent); writing-mode: vertical-rl; text-orientation: mixed; letter-spacing: 0.04em; }
.outlanes { display: flex; flex-direction: column; gap: 8px; }
.oline { min-height: 44px; display: flex; align-items: center; gap: 5px; flex-wrap: nowrap; }
.objbox { border: 1px solid var(--rule); padding: 4px 7px; font-family: var(--font-mono); font-size: 11.5px; white-space: nowrap; }
.objbox.dep { border-color: var(--ink); }
.gate { font-family: var(--font-mono); font-size: 10px; color: var(--ink-dim); white-space: nowrap; }
.gate::before { content: "├ "; color: var(--accent); }
.fan-recon { font-family: var(--font-mono); font-size: 11px; color: var(--ink-dim); margin-top: 14px; line-height: 1.45; }
.fan-recon code { color: var(--ink); }
.fan-x5 { position: absolute; font-family: var(--font-mono); font-size: 10px; color: var(--accent); }
/* DETAIL B multi-release */
.rel-cols { display: flex; gap: 48px; justify-content: flex-start; }
.rel-col { flex: 1; min-width: 0; }
.rel-title { font-family: var(--font-mono); font-size: 12px; color: var(--accent); text-align: center; margin-bottom: 6px; }
.rel-stack { border: 1px solid var(--rule); }
.rel-cell { border-top: 1px solid var(--rule); padding: 7px 9px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.4; }
.rel-cell:first-child { border-top: 0; }
.rel-cell .v { color: var(--accent); }
.rel-note { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-dim); margin-top: 14px; line-height: 1.55; white-space: pre-wrap; }
.rel-note .v { color: var(--accent); }
/* ── title block ── */
.tblock-wrap { display: flex; justify-content: flex-end; margin-top: 20px; }
.tblock { width: 340px; border: 1px solid var(--ink); }
.tblock .tb-row { display: grid; grid-template-columns: 128px 1fr; border-top: 1px solid var(--rule); }
.tblock .tb-row:first-child { border-top: 0; }
.tblock .tb-k { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.06em; font-size: 10px; color: var(--ink-dim); padding: 5px 8px; border-right: 1px solid var(--rule); }
.tblock .tb-v { font-family: var(--font-mono); font-size: 11px; padding: 5px 8px; }
.tblock .tb-v.v-acc { color: var(--accent); }
@media (max-width: 959px) {
.spine { grid-template-columns: repeat(3, 1fr); row-gap: 20px; }
.stage:nth-child(3)::after { content: none; }
.render3 { grid-template-columns: 1fr; }
.render3 .rcol { border-left: 0; padding: 12px 0 0; border-top: 1px solid var(--rule); }
.render3 .rcol:first-child { border-top: 0; padding-top: 0; }
.render3 .rcol::after { content: none; }
}
@media (max-width: 719px) {
.strip { flex-direction: column; }
.spine { grid-template-columns: 1fr; }
.stage::after { content: none !important; }
.stage { border-top: 1px solid var(--rule); padding-top: 12px; }
.stage:first-child { border-top: 0; padding-top: 0; }
.subgrid, .twin, .lower { grid-template-columns: 1fr; }
.fan-grid { grid-template-columns: 1fr; gap: 10px; }
.fan svg { display: none; }
.bracket-col { justify-content: flex-start; }
.bracket-lbl { writing-mode: horizontal-tb; }
.tblock { width: 100%; }
}
@media (prefers-reduced-motion: no-preference) {
.draw { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); animation: draw 0.9s ease-out forwards; }
.fan .draw { animation-duration: 0.7s; }
@keyframes draw { to { stroke-dashoffset: 0; } }
}
</style>
<div class="frame-outer"><div class="frame-inner">
<!-- (a) title / context strip -->
<div class="strip">
<div>
<h1>planpalto-helm — render pipeline</h1>
<p class="sub">How <span class="mono">helm install</span> turns the chart on disk into running objects on the fakhri-k8 EKS cluster.</p>
</div>
<div class="legend">
<div class="item"><span class="sw-acc"></span><span class="ms dim">live / rendered value</span></div>
<div class="item"><span class="sw-ink"></span><span class="ms dim">static linework</span></div>
<div class="item"><span class="stamp">N</span><span class="ms dim">pipeline stage</span></div>
</div>
</div>
<hr class="sep">
<!-- (b→h) pipeline spine -->
<p class="secthead">Render pipeline — 6 stages</p>
<div class="spine">
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">1</span><span class="stage-name">Inputs</span></div>
<p class="stage-desc"><code>Chart.yaml</code> · <code>values.yaml</code> · <code>templates/*</code> · <code>_helpers.tpl</code> · optional <code>-f envs/a.yaml</code></p>
<span class="flow-note">paths</span>
</div>
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">2</span><span class="stage-name">Command</span></div>
<p class="stage-desc">You run one <code>helm</code> command. Helm reads the chart and any override file.</p>
<span class="flow-note">flags+values</span>
</div>
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">3</span><span class="stage-name">Render</span></div>
<p class="stage-desc">Go templating merges values + <code>.Release.*</code>, walks <code>templates/</code>, emits one multi-doc YAML.</p>
<span class="flow-note">YAML</span>
</div>
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">4</span><span class="stage-name">Submit</span></div>
<p class="stage-desc">Manifest sent to the k8s API server. Helm records the revision in a Secret.</p>
<span class="flow-note">objects</span>
</div>
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">5</span><span class="stage-name">Reconcile</span></div>
<p class="stage-desc">Controllers act: Deployment → ReplicaSet → Pod. Scheduler places Pods on EKS nodes.</p>
<span class="flow-note">pods</span>
</div>
<div class="stage">
<div class="stage-hd"><span class="stage-stamp">6</span><span class="stage-name">Add-ons</span></div>
<p class="stage-desc">ESO, AWS Load Balancer Controller, and metrics-server finish the job.</p>
</div>
</div>
<!-- (b) inputs + (c) command -->
<div class="subgrid">
<div class="well">
<p class="well-hd">1 · Inputs on disk</p>
<div class="filetree">planpalto-helm/
├─ Chart.yaml
├─ values.yaml <span class="an">apps: map (5 entries) + deps + global</span>
├─ templates/
│ ├─ namespace.yaml
│ ├─ apps.yaml <span class="acc">{{ range .Values.apps }}</span>
│ ├─ deps.yaml <span class="an">redis, nats</span>
│ ├─ external-secrets.yaml
│ ├─ ingress.yaml
│ └─ seed-job.yaml
└─ _helpers.tpl <span class="an">planpal.namespace / .storeName / .image</span>
+ override: <span class="acc">-f envs/a.yaml</span></div>
</div>
<div>
<div class="cmd-wrap">
<p class="cmd-lead">2 · command ↓</p>
<div class="cmd-box"><span>helm upgrade --install </span><span class="acc">planpal</span><span> ./planpalto-helm -f </span><span class="acc">envs/a.yaml</span></div>
<p class="cmd-note">upgrade --install = create if absent, else new revision.</p>
</div>
</div>
</div>
<!-- (d) render well -->
<div class="well" style="margin-top:16px">
<p class="well-hd">3 · Render — Go template engine (client-side, in Helm)</p>
<div class="render3">
<div class="rcol" data-cross="merge">
<p class="rhd">Merged values</p>
<pre>values.yaml
+ <span class="acc">-f envs/a.yaml</span> (override)
+ <span class="acc">.Release.Name planpal</span>
+ <span class="acc">.Release.Namespace</span></pre>
</div>
<div class="rcol" data-cross="walk templates/">
<p class="rhd">Go template engine</p>
<ul class="mech">
<li><span class="tok">{{ .Values.x }}</span> <span class="gl">substitute</span></li>
<li><span class="tok">{{ if }} / {{ with }}</span> <span class="gl">conditional / scope</span></li>
<li><span class="tok">{{ range }}</span> <span class="gl">loop over apps map</span></li>
<li><span class="tok">{{ include "planpal.namespace" . }}</span> <span class="gl">named template from _helpers.tpl</span></li>
<li><span class="tok">| default | toJson | toString</span> <span class="gl">pipes</span></li>
</ul>
</div>
<div class="rcol">
<p class="rhd">Multi-doc YAML</p>
<pre>---
kind: Namespace
...
---
kind: Deployment
metadata:
name: <span class="acc">planpal-backend</span>
...
--- <span class="dim">(× many docs)</span></pre>
</div>
</div>
</div>
<!-- (g) reconcile + (h) add-ons -->
<div class="twin">
<div class="well">
<p class="well-hd">5 · Reconcile</p>
<div class="chain">
<span class="bx">Deployment</span><span class="ar"></span>
<span class="bx">ReplicaSet</span><span class="ar"></span>
<span class="bx">Pod</span><span class="ar"></span>
<span class="bx">scheduler</span><span class="ar"></span>
<span class="bx">EKS node</span>
</div>
<p class="cmd-note">Controllers reconcile the declared state. HPA scales replicas via metrics-server.</p>
</div>
<div class="well">
<p class="well-hd">6 · Cluster add-ons</p>
<div class="addon-row"><span class="obj">ExternalSecret</span><span class="ar">▶ ESO</span><span class="gl">reads AWS Secrets Manager → real k8s Secret</span></div>
<div class="addon-row"><span class="obj">Ingress</span><span class="ar">▶ ALB ctrl</span><span class="gl">provisions an ALB</span></div>
<div class="addon-row"><span class="obj">HPA</span><span class="ar">▶ metrics-server</span><span class="gl">feeds CPU/mem → scale</span></div>
</div>
</div>
<hr class="sep">
<!-- (e) BOM table -->
<p class="secthead">Template → object map<span class="st-sub">bill of materials · rendered from the chart, exact counts</span></p>
<div class="bom-wrap">
<table class="bom">
<thead><tr><th style="width:32%">Source file</th><th>Rendered objects</th></tr></thead>
<tbody>
<tr><td>templates/namespace.yaml</td><td>1 Namespace · 1 ServiceAccount <span class="dim">planpal-backend</span></td></tr>
<tr><td>templates/apps.yaml</td><td>5 Deployment · 2 Service · 5 HPA · 2 PDB &nbsp;<span class="note">← one {{ range }} loop, see DETAIL A</span></td></tr>
<tr><td>templates/deps.yaml</td><td>2 Deployment <span class="dim">redis, nats</span> · 2 Service &nbsp;<span class="note-dim">(these 2 also count toward the 7 Deployment total)</span></td></tr>
<tr><td>templates/external-secrets.yaml</td><td>1 ClusterSecretStore · 4 ExternalSecret</td></tr>
<tr><td>templates/ingress.yaml</td><td>1 Ingress <span class="dim">(ALB)</span></td></tr>
<tr><td>templates/seed-job.yaml</td><td>1 Job <span class="dim">seed-admin (runs once)</span></td></tr>
<tr class="sum"><td>Σ total</td><td>7 Deployment · 4 Service · 5 HPA · 2 PDB · 1 Ingress · 1 Job · 1 Namespace · 1 ServiceAccount · 1 ClusterSecretStore · 4 ExternalSecret</td></tr>
</tbody>
</table>
</div>
<hr class="sep">
<!-- lower band: DETAIL A | DETAIL B -->
<div class="lower">
<!-- (f) DETAIL A -->
<div class="detail">
<span class="detail-tab">Detail A</span>
<p class="detail-cap">One <code>{{ range .Values.apps }}</code> over a 5-entry map builds 5 Deployments. Conditionals add the extras: a Service <code>if service:</code>, an HPA <code>if hpa:</code>, a PDB <code>if pdb:</code>.</p>
<div class="fan">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<defs>
<marker id="ah" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="var(--accent)"/>
</marker>
</defs>
<!-- fan: single origin at bracket right-mid (x~40.5), 5 lines to lane mids -->
<line class="draw" style="--len:60" x1="40.5" y1="50" x2="52" y2="10" stroke="var(--accent)" stroke-width="0.5" marker-end="url(#ah)"/>
<line class="draw" style="--len:55" x1="40.5" y1="50" x2="52" y2="30" stroke="var(--accent)" stroke-width="0.5" marker-end="url(#ah)"/>
<line class="draw" style="--len:50" x1="40.5" y1="50" x2="52" y2="50" stroke="var(--accent)" stroke-width="0.5" marker-end="url(#ah)"/>
<line class="draw" style="--len:55" x1="40.5" y1="50" x2="52" y2="70" stroke="var(--accent)" stroke-width="0.5" marker-end="url(#ah)"/>
<line class="draw" style="--len:60" x1="40.5" y1="50" x2="52" y2="90" stroke="var(--accent)" stroke-width="0.5" marker-end="url(#ah)"/>
</svg>
<div class="fan-grid">
<!-- Zone L: apps map -->
<div class="lanes">
<div class="srcbox"><span class="nm">backend</span><span class="tags"><span class="tag">service:</span><span class="tag">hpa:</span><span class="tag">pdb:</span></span></div>
<div class="srcbox"><span class="nm">frontend</span><span class="tags"><span class="tag">service:</span><span class="tag">hpa:</span><span class="tag">pdb:</span></span></div>
<div class="srcbox"><span class="nm">schedule-worker</span><span class="tags"><span class="tag">hpa:</span></span></div>
<div class="srcbox"><span class="nm">notification-worker</span><span class="tags"><span class="tag">hpa:</span></span></div>
<div class="srcbox"><span class="nm">ai-worker</span><span class="tags"><span class="tag">hpa:</span></span></div>
</div>
<!-- Zone C: range bracket -->
<div class="bracket-col">
<span class="bracket-lbl">{{ range .Values.apps }} &nbsp;×5</span>
</div>
<!-- Zone R: emitted objects -->
<div class="outlanes">
<div class="oline"><span class="objbox dep">Deployment</span><span class="gate">if service:</span><span class="objbox">Service</span><span class="gate">if hpa:</span><span class="objbox">HPA</span><span class="gate">if pdb:</span><span class="objbox">PDB</span></div>
<div class="oline"><span class="objbox dep">Deployment</span><span class="gate">if service:</span><span class="objbox">Service</span><span class="gate">if hpa:</span><span class="objbox">HPA</span><span class="gate">if pdb:</span><span class="objbox">PDB</span></div>
<div class="oline"><span class="objbox dep">Deployment</span><span class="gate">if hpa:</span><span class="objbox">HPA</span></div>
<div class="oline"><span class="objbox dep">Deployment</span><span class="gate">if hpa:</span><span class="objbox">HPA</span></div>
<div class="oline"><span class="objbox dep">Deployment</span><span class="gate">if hpa:</span><span class="objbox">HPA</span></div>
</div>
</div>
</div>
<p class="fan-recon"><code>apps.yaml → 5 Deployment · 2 Service · 5 HPA · 2 PDB.</code> The redis + nats Deployments come from <code>deps.yaml</code>, not this loop — they are the other 2 in the total of 7.</p>
</div>
<!-- (i) DETAIL B -->
<div class="detail">
<span class="detail-tab">Detail B</span>
<p class="detail-cap">Each <code>helm install</code> lands in its own namespace + ALB + ClusterSecretStore, all defaulting to <code>.Release.Name</code> via <code>_helpers.tpl</code>.</p>
<div class="rel-cols">
<div class="rel-col">
<p class="rel-title">planpal-a</p>
<div class="rel-stack">
<div class="rel-cell">Namespace<br><span class="v">planpal-a</span></div>
<div class="rel-cell">Ingress → ALB<br><span class="dim">(own LB)</span></div>
<div class="rel-cell">ClusterSecretStore<br><span class="v">planpal-a-aws-planpal</span></div>
</div>
</div>
<div class="rel-col">
<p class="rel-title">planpal-b</p>
<div class="rel-stack">
<div class="rel-cell">Namespace<br><span class="v">planpal-b</span></div>
<div class="rel-cell">Ingress → ALB<br><span class="dim">(own LB)</span></div>
<div class="rel-cell">ClusterSecretStore<br><span class="v">planpal-b-aws-planpal</span></div>
</div>
</div>
</div>
<p class="rel-note">planpal.namespace = default .Release.Name → <span class="v">planpal-a</span> / <span class="v">planpal-b</span>
planpal.storeName = per-release CSS
The empty gutter is the isolation: no
connector crosses it. Two releases share
nothing on the cluster.</p>
</div>
</div>
<!-- title block -->
<div class="tblock-wrap">
<div class="tblock">
<div class="tb-row"><div class="tb-k">Chart</div><div class="tb-v">planpalto-helm</div></div>
<div class="tb-row"><div class="tb-k">Release</div><div class="tb-v v-acc">planpal</div></div>
<div class="tb-row"><div class="tb-k">Cluster</div><div class="tb-v">fakhri-k8 (EKS)</div></div>
<div class="tb-row"><div class="tb-k">Release state</div><div class="tb-v">Secret sh.helm.release.v1</div></div>
<div class="tb-row"><div class="tb-k">Scale</div><div class="tb-v">NTS</div></div>
<div class="tb-row"><div class="tb-k">Sheet</div><div class="tb-v">1 of 1</div></div>
<div class="tb-row"><div class="tb-k">Date</div><div class="tb-v">2026-09-01</div></div>
</div>
</div>
</div></div>

View File

@ -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

View File

@ -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 }

View File

@ -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 "<release>-aws-planpal". */}}
{{- define "planpal.storeName" -}}
{{- .Values.externalSecrets.storeName | default (printf "%s-aws-planpal" .Release.Name) -}}
{{- end -}}

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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

140
planpalto-helm/values.yaml Normal file
View File

@ -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 -> "<release>-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