Skip to content

Commit f41ee56

Browse files
authored
feat(helm): publish a chart alongside the image (#179)
The backend is deployed from a generic `micronaut` chart that lives in the cluster repository and is shared with otis, so nothing here describes how this service runs. Every deployment therefore waits on someone editing an image tag by hand in that repository -- which is how dev ended up on 2.3.0 while prod ran 2.4.1. A chart of our own, versioned from the same release as the image, removes that step: image.tag is left empty and falls back to .Chart.AppVersion, so pinning the chart pins the image and an environment that tracks the newest chart release tracks the image with it. The chart is a fork of helm/micronaut rather than a rewrite -- the template helpers keep their `micronaut.*` names and the files are otherwise unchanged, so a later fix over there carries across with `diff -r`. Only what identifies this application is different: image repository, the 8080 the Micronaut server binds, and the Prometheus endpoint it always exposes. Packaged and pushed to the same Harbor project as the image, under charts/, chained into the release run for the same reason the docker job is: a GITHUB_TOKEN tag starts no `on: push: tags` workflow.
1 parent afa7066 commit f41ee56

18 files changed

Lines changed: 898 additions & 0 deletions

.github/workflows/release-please.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,59 @@ jobs:
7676
req-concurrent: "4"
7777
secrets: inherit
7878

79+
# Chained into this run rather than triggered by the tag: release-please tags
80+
# with GITHUB_TOKEN, and a tag pushed that way starts no `on: push: tags`
81+
# workflow. The release is also the only place that already knows the
82+
# version, so nothing has to derive one.
83+
#
84+
# Charts are published on a release only. A branch build is deployed by
85+
# pointing an existing release of the chart at the branch's image tag, which
86+
# needs no chart of its own.
87+
helm:
88+
name: Publish the Helm chart
89+
needs: release-please
90+
if: needs.release-please.outputs.release_created == 'true'
91+
runs-on: ubuntu-latest
92+
permissions:
93+
contents: read
94+
steps:
95+
- name: Checkout
96+
uses: actions/checkout@v7
97+
with:
98+
ref: ${{ needs.release-please.outputs.tag_name }}
99+
100+
- name: Set up Helm
101+
uses: azure/setup-helm@v5.0.1
102+
103+
# Both versions come from the release rather than from Chart.yaml.
104+
# Release Please keeps that file current anyway, but stating them here
105+
# makes a mismatch impossible instead of something to check for: the
106+
# chart cannot be packaged pointing at an image tag nobody built.
107+
- name: Package
108+
run: helm package charts/vulpes-backend --destination dist --version "${{ needs.release-please.outputs.version }}" --app-version "${{ needs.release-please.outputs.version }}"
109+
110+
- name: Log in to OneLiteFeather Harbor
111+
env:
112+
REGISTRY: ${{ secrets.HARBOR_REGISTRY }}
113+
USERNAME: ${{ secrets.HARBOR_USERNAME }}
114+
PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
115+
run: printf '%s' "$PASSWORD" | helm registry login "$REGISTRY" --username "$USERNAME" --password-stdin
116+
117+
# Into the same Harbor project as the image, under charts/ - the layout
118+
# apus and stelaris-ui already use, so a Flux OCIRepository pointing here
119+
# looks like every other one in the cluster repository.
120+
- name: Push
121+
env:
122+
REGISTRY: ${{ secrets.HARBOR_REGISTRY }}
123+
VERSION: ${{ needs.release-please.outputs.version }}
124+
run: helm push "dist/vulpes-backend-${VERSION}.tgz" "oci://${REGISTRY}/onelitefeather/charts"
125+
126+
- name: Log out
127+
if: always()
128+
env:
129+
REGISTRY: ${{ secrets.HARBOR_REGISTRY }}
130+
run: helm registry logout "$REGISTRY" || true
131+
79132
sbom:
80133
name: Attach SBOM to release
81134
needs: release-please

charts/vulpes-backend/Chart.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
apiVersion: v2
2+
name: vulpes-backend
3+
description: The Vulpes backend, a Micronaut service deployed to Kubernetes
4+
type: application
5+
6+
# Both lines are kept current by Release Please, which is why the chart version
7+
# and the app version move together: the chart only ever describes the image
8+
# built from the same commit, so a separate chart version would be a second
9+
# number to reason about with nothing extra to say. The cluster repository's
10+
# OCIRepository pins the chart, and image.tag is left empty so it falls back to
11+
# this appVersion -- pinning the chart therefore pins the image.
12+
version: 2.5.0 # x-release-please-version
13+
appVersion: "2.5.0" # x-release-please-version
14+
15+
home: https://github.com/OneLiteFeatherNET/Vulpes-Backend
16+
sources:
17+
- https://github.com/OneLiteFeatherNET/Vulpes-Backend
18+
maintainers:
19+
- name: OneLiteFeatherNET
20+
email: contact@onelitefeather.net
21+
url: https://onelitefeather.net
22+
keywords:
23+
- vulpes
24+
- micronaut
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
1. Get the application URL by running these commands:
2+
{{- if .Values.ingress.enabled }}
3+
{{- range $host := .Values.ingress.hosts }}
4+
{{- range .paths }}
5+
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
6+
{{- end }}
7+
{{- end }}
8+
{{- else if contains "NodePort" .Values.service.type }}
9+
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "micronaut.fullname" . }})
10+
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
11+
echo http://$NODE_IP:$NODE_PORT
12+
{{- else if contains "LoadBalancer" .Values.service.type }}
13+
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
14+
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "micronaut.fullname" . }}'
15+
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "micronaut.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
16+
echo http://$SERVICE_IP:{{ .Values.service.port }}
17+
{{- else if contains "ClusterIP" .Values.service.type }}
18+
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "micronaut.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
19+
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
20+
echo "Visit http://127.0.0.1:8080 to use your application"
21+
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
22+
{{- end }}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{{/*
2+
The helpers below are still named `micronaut.*`, not `vulpes-backend.*`, and
3+
that is deliberate: this chart is a fork of helm/micronaut in the cluster
4+
repository, and keeping the templates byte-identical is what lets a later fix
5+
over there be carried across with `diff -r`. Renaming them would touch every
6+
template file and turn that diff into noise.
7+
8+
The rendered resource names are unaffected either way -- they come from
9+
.Chart.Name/.Release.Name, and the cluster overlays pin them with
10+
nameOverride/fullnameOverride so the Deployment's immutable selector survives
11+
this chart move.
12+
*/}}
13+
14+
{{/*
15+
Expand the name of the chart.
16+
*/}}
17+
{{- define "micronaut.name" -}}
18+
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
19+
{{- end }}
20+
21+
{{/*
22+
Create a default fully qualified app name.
23+
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
24+
If release name contains chart name it will be used as a full name.
25+
*/}}
26+
{{- define "micronaut.fullname" -}}
27+
{{- if .Values.fullnameOverride }}
28+
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
29+
{{- else }}
30+
{{- if contains .Chart.Name .Release.Name }}
31+
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
32+
{{- else }}
33+
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
34+
{{- end }}
35+
{{- end }}
36+
{{- end }}
37+
38+
{{/*
39+
Chart name and version
40+
*/}}
41+
{{- define "micronaut.chart" -}}
42+
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
43+
{{- end }}
44+
45+
{{/*
46+
Common labels
47+
*/}}
48+
{{- define "micronaut.labels" -}}
49+
app.kubernetes.io/name: {{ include "micronaut.name" . }}
50+
helm.sh/chart: {{ include "micronaut.chart" . }}
51+
app.kubernetes.io/instance: {{ .Release.Name }}
52+
app.kubernetes.io/managed-by: {{ .Release.Service }}
53+
{{- end }}
54+
55+
56+
{{/*
57+
Create the name of the service account to use
58+
*/}}
59+
{{- define "micronaut.serviceAccountName" -}}
60+
{{- if .Values.serviceAccount.create }}
61+
{{- default (include "micronaut.fullname" .) .Values.serviceAccount.name }}
62+
{{- else }}
63+
{{- default "default" .Values.serviceAccount.name }}
64+
{{- end }}
65+
{{- end }}
66+
67+
{{/*
68+
Selector labels
69+
*/}}
70+
{{- define "micronaut.selectorLabels" -}}
71+
app.kubernetes.io/name: {{ include "micronaut.name" . }}
72+
app.kubernetes.io/instance: {{ .Release.Name }}
73+
{{- end }}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: {{ include "micronaut.fullname" . }}-config
5+
labels:
6+
{{- include "micronaut.labels" . | nindent 4 }}
7+
data:
8+
application.yml: |-
9+
{{- nindent 4 .Values.config.base }}
10+
{{- range $p, $content := .Values.config.profiles }}
11+
application-{{ $p }}.yml: |-
12+
{{- nindent 4 $content }}
13+
{{- end }}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: {{ include "micronaut.fullname" . }}
5+
labels:
6+
{{- include "micronaut.labels" . | nindent 4 }}
7+
spec:
8+
{{- if not .Values.autoscaling.enabled }}
9+
replicas: {{ .Values.replicaCount }}
10+
{{- end }}
11+
{{- with .Values.strategy }}
12+
strategy:
13+
{{- toYaml . | nindent 4 }}
14+
{{- end }}
15+
selector:
16+
matchLabels:
17+
app.kubernetes.io/name: {{ include "micronaut.name" . }}
18+
app.kubernetes.io/instance: {{ .Release.Name }}
19+
template:
20+
metadata:
21+
labels:
22+
app.kubernetes.io/name: {{ include "micronaut.name" . }}
23+
app.kubernetes.io/instance: {{ .Release.Name }}
24+
{{- with .Values.podLabels }}
25+
{{ toYaml . | indent 8 }}
26+
{{- end }}
27+
annotations:
28+
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
29+
{{- if and .Values.secrets.enabled .Values.secrets.files }}
30+
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
31+
{{- end }}
32+
{{- with .Values.podAnnotations }}
33+
{{ toYaml . | indent 8 }}
34+
{{- end }}
35+
spec:
36+
{{- if .Values.imagePullSecrets }}
37+
imagePullSecrets:
38+
{{ toYaml .Values.imagePullSecrets | indent 8 }}
39+
{{- end }}
40+
serviceAccountName: {{- if .Values.serviceAccount.create }} {{ include "micronaut.fullname" . }} {{- else }} {{ default "default" .Values.serviceAccount.name }} {{- end }}
41+
{{- if .Values.securityContext.enabled }}
42+
securityContext:
43+
{{- omit .Values.securityContext "enabled" | toYaml | nindent 8 }}
44+
{{- end }}
45+
{{- with .Values.priorityClassName }}
46+
priorityClassName: {{ . }}
47+
{{- end }}
48+
{{- with .Values.nodeSelector }}
49+
nodeSelector:
50+
{{- toYaml . | nindent 8 }}
51+
{{- end }}
52+
{{- with .Values.affinity }}
53+
affinity:
54+
{{- toYaml . | nindent 8 }}
55+
{{- end }}
56+
{{- with .Values.tolerations }}
57+
tolerations:
58+
{{- toYaml . | nindent 8 }}
59+
{{- end }}
60+
{{- if .Values.tracing.enabled }}
61+
initContainers:
62+
- name: otel-agent
63+
image: {{ .Values.tracing.image }}
64+
command: ["cp", "/javaagent.jar", "/otel/javaagent.jar"]
65+
{{- if .Values.containerSecurityContext.enabled }}
66+
securityContext:
67+
{{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 12 }}
68+
{{- end }}
69+
resources:
70+
{{- toYaml .Values.tracing.resources | nindent 12 }}
71+
volumeMounts:
72+
- name: otel-agent
73+
mountPath: /otel
74+
{{- end }}
75+
containers:
76+
- name: app
77+
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
78+
imagePullPolicy: {{ .Values.image.pullPolicy }}
79+
{{- if .Values.containerSecurityContext.enabled }}
80+
securityContext:
81+
{{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 12 }}
82+
{{- end }}
83+
ports:
84+
- name: http
85+
containerPort: {{ .Values.service.port }}
86+
env:
87+
- name: MICRONAUT_ENVIRONMENTS
88+
value: "{{ join "," .Values.profiles }}"
89+
- name: MICRONAUT_CONFIG_FILES
90+
value: "/config/application.yml{{- range $p := .Values.profiles }},/config/application-{{$p}}.yml{{- end }}{{- if and .Values.secrets.enabled .Values.secrets.files }}{{- range $p := .Values.profiles }},/secrets/application-{{$p}}.yml{{- end }}{{- end }}"
91+
{{- range .Values.env }}
92+
- name: {{ .name }}
93+
value: {{ .value | quote }}
94+
{{- end }}
95+
{{- if .Values.tracing.enabled }}
96+
# JDK_JAVA_OPTIONS is independent of JAVA_TOOL_OPTIONS (which
97+
# apps commonly override wholesale via .Values.env above), so
98+
# the agent attaches regardless of what that list contains.
99+
- name: JDK_JAVA_OPTIONS
100+
value: "-javaagent:/otel/javaagent.jar"
101+
- name: OTEL_SERVICE_NAME
102+
value: {{ .Release.Name | quote }}
103+
- name: OTEL_TRACES_EXPORTER
104+
value: "otlp"
105+
- name: OTEL_METRICS_EXPORTER
106+
value: "none"
107+
- name: OTEL_LOGS_EXPORTER
108+
value: "none"
109+
- name: OTEL_EXPORTER_OTLP_ENDPOINT
110+
value: {{ .Values.tracing.endpoint | quote }}
111+
- name: OTEL_EXPORTER_OTLP_PROTOCOL
112+
value: "grpc"
113+
{{- end }}
114+
{{- if .Values.envFrom }}
115+
envFrom:
116+
{{ toYaml .Values.envFrom | indent 12 }}
117+
{{- end }}
118+
{{- if .Values.startupProbe.enabled }}
119+
startupProbe:
120+
httpGet:
121+
path: {{ .Values.readinessProbe.path }}
122+
port: http
123+
failureThreshold: {{ .Values.startupProbe.failureThreshold }}
124+
periodSeconds: {{ .Values.startupProbe.periodSeconds }}
125+
{{- end }}
126+
readinessProbe:
127+
httpGet:
128+
path: {{ .Values.readinessProbe.path }}
129+
port: http
130+
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
131+
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
132+
livenessProbe:
133+
httpGet:
134+
path: {{ .Values.livenessProbe.path }}
135+
port: http
136+
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
137+
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
138+
resources:
139+
{{ toYaml .Values.resources | indent 12 }}
140+
volumeMounts:
141+
# The root filesystem is read-only; the JVM still wants a scratch
142+
# dir (java.io.tmpdir, Netty, JIT dumps).
143+
- name: tmp
144+
mountPath: /tmp
145+
- name: config
146+
mountPath: /config
147+
{{- if and .Values.secrets.enabled .Values.secrets.files }}
148+
- name: secrets
149+
mountPath: /secrets
150+
{{- end }}
151+
{{- if .Values.tracing.enabled }}
152+
- name: otel-agent
153+
mountPath: /otel
154+
readOnly: true
155+
{{- end }}
156+
volumes:
157+
- name: tmp
158+
emptyDir: {}
159+
- name: config
160+
configMap:
161+
name: {{ include "micronaut.fullname" . }}-config
162+
{{- if and .Values.secrets.enabled .Values.secrets.files }}
163+
- name: secrets
164+
secret:
165+
secretName: {{ include "micronaut.fullname" . }}-secrets
166+
{{- end }}
167+
{{- if .Values.tracing.enabled }}
168+
- name: otel-agent
169+
emptyDir: {}
170+
{{- end }}

0 commit comments

Comments
 (0)