-
Notifications
You must be signed in to change notification settings - Fork 0
318 lines (295 loc) · 14.3 KB
/
Copy pathdeploy.yml
File metadata and controls
318 lines (295 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# Reusable: deploy one environment.
#
# Replaces deploy-theia.yml. Differences that matter:
#
# - the environment is described by environments/<name>/env.yaml, not by
# GitHub Environment variables, so a change is reviewable in a diff
# - the cluster is asserted against the kubeconfig before anything is applied
# - the pending change is shown as a helm diff before it is applied
# - --wait --atomic, so a green job means a healthy rollout
# - the summary is read from the cluster afterwards, not echoed from inputs
# - nothing cluster-scoped is installed here; see bootstrap-cluster.yml
#
# Preloading is a separate release deliberately. It pulls ~10 multi-GB images
# on every node, so including it under --wait would time out and --atomic would
# then roll back a perfectly healthy deploy.
name: Deploy
on:
workflow_call:
inputs:
environment:
type: string
required: true
description: "Name under environments/"
chart_version:
type: string
default: ""
description: "Overrides spec.platform.chartVersion"
image_overrides:
type: string
default: "{}"
description: 'JSON, e.g. {"controlPlane":"pr-451"}. Never sets a tag for images that do not exist.'
clean_install:
type: boolean
default: false
dry_run:
type: boolean
default: false
description: "Render and diff, apply nothing"
permissions:
contents: read
jobs:
# runs-on is evaluated before a job can read a file, so the runner label has
# to be resolved from the cluster manifest in a job of its own.
resolve:
runs-on: ubuntu-latest
outputs:
runner: ${{ steps.r.outputs.runner }}
cluster: ${{ steps.r.outputs.cluster }}
steps:
- uses: actions/checkout@v4
- name: Install yq
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- id: r
run: |
set -euo pipefail
M="environments/${{ inputs.environment }}/env.yaml"
[[ -f "$M" ]] || { echo "::error::no such environment: ${{ inputs.environment }}"; exit 1; }
C=$(yq -r '.spec.cluster' "$M")
[[ -f "clusters/$C.yaml" ]] || { echo "::error::unknown cluster: $C"; exit 1; }
{
echo "cluster=$C"
echo "runner=$(yq -r '.spec.runner // "ubuntu-latest"' "clusters/$C.yaml")"
} >> "$GITHUB_OUTPUT"
deploy:
needs: resolve
runs-on: ${{ needs.resolve.outputs.runner }}
environment:
name: ${{ inputs.environment }}
url: ${{ steps.url.outputs.url }}
concurrency:
# One deploy at a time per environment. Never cancel in progress: a
# half-applied Helm release is worse than a queued one.
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
env:
KUBECONFIG: ${{ github.workspace }}/kubeconfig
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
with:
version: v3.16.3
- uses: azure/setup-kubectl@v4
- name: Install helm-diff and yq
run: |
set -euo pipefail
# Pinned: helm-diff's newer releases declare `platformHooks`, which the
# pinned helm v3.16.3 cannot parse, so the plugin fails to load and the
# preview quietly renders nothing at all.
helm plugin install https://github.com/databus23/helm-diff --version v3.9.11 >/dev/null 2>&1 || true
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- name: Resolve the environment manifest
id: env
run: |
set -euo pipefail
M="environments/${{ inputs.environment }}/env.yaml"
[[ -f "$M" ]] || { echo "::error::no such environment: ${{ inputs.environment }}"; exit 1; }
{
echo "cluster=$(yq -r '.spec.cluster' "$M")"
echo "namespace=$(yq -r '.spec.namespace' "$M")"
echo "tier=$(yq -r '.metadata.tier' "$M")"
echo "channel=$(yq -r '.spec.platform.channel' "$M")"
V="environments/${{ inputs.environment }}/values.yaml"
echo "landing=$(yq -r '.hosts.configuration | .landing + "." + .baseHost' "$V")"
v="${{ inputs.chart_version }}"
if [[ -z "$v" ]]; then v="$(yq -r '.spec.platform.chartVersion' "$M")"; fi
echo "chart_version=$v"
} >> "$GITHUB_OUTPUT"
- name: Cluster defaults
run: |
set -euo pipefail
# Storage class is a property of the cluster, not of the environment,
# so it is stated once in clusters/<name>.yaml. An environment that
# sets its own is rejected by test-deploy-logic.sh. On the test
# cluster BOTH csi-rbd-sc and longhorn exist and both are marked
# default, so a PVC that names no class gets an arbitrary one; test3
# ran on longhorn and the rest on csi-rbd-sc for no recorded reason.
# Naming it explicitly is what makes that deterministic.
C="clusters/${{ steps.env.outputs.cluster }}.yaml"
SC=$(yq -r '.spec.storageClassName' "$C")
[[ -n "$SC" && "$SC" != "null" ]] || { echo "::error::${C} has no spec.storageClassName"; exit 1; }
# Two subcharts claim storage independently. theia-shared-cache's
# vendored reposilite chart has its own key and defaults to
# csi-rbd-sc, so setting only the operator's would leave a PVC that
# never binds on a cluster that does not offer that class.
cat > cluster-defaults.yaml <<EOF
operator:
storageClassName: ${SC}
eduide-shared-cache:
reposilite:
persistence:
storageClass: ${SC}
EOF
echo "storageClassName: ${SC}"
- name: Set up kubeconfig
run: |
set -euo pipefail
install -m 600 /dev/null "$KUBECONFIG"
printf '%s' '${{ secrets.KUBECONFIG }}' > "$KUBECONFIG"
kubectl version --client >/dev/null
- name: Assert we are pointed at the right cluster
run: |
set -euo pipefail
CLUSTER="${{ steps.env.outputs.cluster }}"
# The cluster tells us which one it is. Bootstrap stamped this, so
# nobody transcribes an identifier and there is nothing to keep in
# sync. Comparing the API server URL would not work here: every
# cluster sits behind the same Rancher endpoint, so that URL is
# identical for all of them and a comparison would always pass.
FOUND=$(kubectl -n eduide-system get configmap eduide-cluster-identity \
-o jsonpath='{.data.clusterName}' 2>/dev/null || true)
if [[ -z "$FOUND" ]]; then
echo "::error::this cluster carries no identity, so it has not been bootstrapped."
echo "::error::Run the 'Bootstrap cluster' workflow for '${CLUSTER}' first."
exit 1
fi
if [[ "$FOUND" != "$CLUSTER" ]]; then
echo "::error::environment '${{ inputs.environment }}' targets cluster '${CLUSTER}',"
echo "::error::but this KUBECONFIG reaches a cluster that calls itself '${FOUND}'."
echo "::error::Refusing to deploy. Check the KUBECONFIG secret on this GitHub Environment."
exit 1
fi
echo "cluster '${CLUSTER}' confirmed"
kubectl get nodes -o name | wc -l | xargs echo "nodes:"
- name: Verify the cluster has been bootstrapped
run: |
set -euo pipefail
if ! kubectl get crd appdefinitions.theia.cloud >/dev/null 2>&1; then
echo "::error::AppDefinition CRD not present. Run the Bootstrap cluster workflow for ${{ steps.env.outputs.cluster }} first."
exit 1
fi
- name: Resolve image overrides
id: images
run: |
set -euo pipefail
# A PR only builds the images of the repo it came from, so an
# override names one source repository. The chart carries one version
# knob per repository, so this is a straight mapping rather than a
# list of image strings that has to know every repository name.
# Never set a blanket tag: it would point every image at a tag that
# mostly does not exist.
: > image-args.txt
cp="$(jq -r '.controlPlane // empty' <<<'${{ inputs.image_overrides }}')"
ide="$(jq -r '.ide // empty' <<<'${{ inputs.image_overrides }}')"
lp="$(jq -r '.landingPage // empty' <<<'${{ inputs.image_overrides }}')"
# if-blocks, not `A && B`: under set -e a trailing false test would
# fail the step when no override is given, which is the common case.
# One token per line. `mapfile` makes each line a single array element,
# so writing "--set versions.ide=latest" as one line hands helm one
# argument containing a space and it dies with
# `unknown flag: --set versions.ide`. The flag and its value have to
# be separate elements.
if [[ -n "$cp" ]]; then printf '%s\n' --set "versions.cloud=${cp}" >> image-args.txt; fi
if [[ -n "$ide" ]]; then printf '%s\n' --set "versions.ide=${ide}" >> image-args.txt; fi
if [[ -n "$lp" ]]; then printf '%s\n' --set "versions.landingPage=${lp}" >> image-args.txt; fi
if [[ -s image-args.txt ]]; then
echo "::notice::image overrides: $(tr '\n' ' ' < image-args.txt)"
fi
- name: Secrets file
run: |
set -euo pipefail
# A values file, not --set: --set puts secrets in the process list and
# in Actions debug logs.
install -m 600 /dev/null secrets.yaml
cat > secrets.yaml <<EOF
keycloak:
cookieSecret: "${{ secrets.THEIA_KEYCLOAK_COOKIE_SECRET }}"
service:
adminApiToken: "$(printf '%s' '${{ secrets.THEIA_ADMIN_API_TOKEN }}' | base64 | tr -d '\n')"
EOF
- name: Log in to GHCR
run: |
set -euo pipefail
# The chart is published, not vendored. helm resolves its own
# dependencies from the same registry when it pulls.
echo '${{ secrets.GITHUB_TOKEN }}' | helm registry login ghcr.io -u '${{ github.actor }}' --password-stdin
- name: Preview the change
run: |
set -euo pipefail
mapfile -t IMG < image-args.txt || true
helm diff upgrade eduide oci://ghcr.io/eduide/charts/eduide \
--namespace "${{ steps.env.outputs.namespace }}" \
--version "${{ steps.env.outputs.chart_version }}" \
-f cluster-defaults.yaml -f environments/_base.yaml -f "environments/${{ inputs.environment }}/values.yaml" -f secrets.yaml "${IMG[@]}" \
--allow-unreleased --no-color > helm.diff 2>&1 || true
{
echo "<details><summary>Pending change (helm diff)</summary>"
echo ""
echo '```diff'
head -c 50000 helm.diff
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Purge before a clean install
if: ${{ inputs.clean_install && !inputs.dry_run }}
run: |
set -euo pipefail
NS="${{ steps.env.outputs.namespace }}"
case "$NS" in
""|default|kube-system|kube-public|kube-node-lease)
echo "::error::refusing to purge namespace '$NS'"; exit 1 ;;
esac
if [[ "${{ steps.env.outputs.tier }}" == "production" ]]; then
echo "::error::clean_install is not permitted for a production environment"; exit 1
fi
kubectl -n "$NS" delete sessions.theia.cloud,workspaces.theia.cloud --all --ignore-not-found
kubectl -n "$NS" delete appdefinitions.theia.cloud --all --ignore-not-found
kubectl -n "$NS" delete deploy,daemonset,statefulset --all --ignore-not-found
kubectl -n "$NS" wait --for=delete pod --all --timeout=120s || true
kubectl -n "$NS" delete pvc --all --ignore-not-found
- name: Deploy
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
mapfile -t IMG < image-args.txt || true
helm upgrade --install eduide oci://ghcr.io/eduide/charts/eduide \
--namespace "${{ steps.env.outputs.namespace }}" --create-namespace \
--version "${{ steps.env.outputs.chart_version }}" \
-f cluster-defaults.yaml -f environments/_base.yaml -f "environments/${{ inputs.environment }}/values.yaml" -f secrets.yaml "${IMG[@]}" \
--wait --atomic --timeout 15m
- name: Force a pull when the tag is floating
if: ${{ !inputs.dry_run && steps.env.outputs.channel == 'main' }}
run: |
set -euo pipefail
NS="${{ steps.env.outputs.namespace }}"
kubectl -n "$NS" rollout restart deploy/operator-deployment deploy/service-deployment deploy/landing-page-deployment 2>/dev/null || true
kubectl -n "$NS" rollout status deploy/operator-deployment --timeout=5m || true
- id: url
run: echo "url=https://${{ steps.env.outputs.landing }}" >> "$GITHUB_OUTPUT"
- name: What is live
if: ${{ always() && !inputs.dry_run }}
run: |
./scripts/live-summary.sh \
"${{ steps.env.outputs.namespace }}" \
"${{ inputs.environment }}" \
"${{ steps.env.outputs.cluster }}" >> "$GITHUB_STEP_SUMMARY" || true
- name: Service health
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
# The service has no /health endpoint - that probe returned 404 on
# every environment and warned on every deploy. /service is the real
# endpoint; it only accepts POST, so a GET returning 405 is the proof
# that the pod is up and the route resolves. 200 would do too.
URL="https://service.${{ steps.env.outputs.landing }}/service"
for _ in $(seq 1 12); do
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 "$URL" || echo 000)
case "$code" in
200|405) echo "$URL -> $code (service reachable)"; exit 0 ;;
esac
sleep 10
done
echo "::warning::$URL answered ${code:-nothing} within two minutes; expected 200 or 405"