Like 0

This gist holds all required commands to upgrade Elasticsearch from 7 to 9 without data loss. It also holds helper scripts for this process. The order is crucial and not reflected independently in this gist. As shown on https://www.youtube.com/watch?v=sb3VBkaHGz4

Revision 0ab62aa97fec7a8057d877e9c988ede5f8be8f24

1-requiired-preperation-commands.sh Raw
1# Ensure dependencies
2apt update && apt install unzip jq -y
3
4# The below part is relevant, if you have Elasticsearch running with HTTP
5# Create certificate directory
6mkdir -p /etc/elasticsearch/certs/
7
8# Generate a CA certificate for Elasticsearch
9/usr/share/elasticsearch/bin/elasticsearch-certutil ca
10
11# Generate a certificate package for Elasticsearchs internal transport (not facing to users)
12/usr/share/elasticsearch/bin/elasticsearch-certutil cert --ca /etc/elasticsearch/certs/elastic-stack-ca.p12
13
14# Generate a HTTP certificate for Elasticsearch (user facing)
15# Either as CSR to sign with your own Certificate Authority -or- self-signed from the above CA file
16/usr/share/elasticsearch/bin/elasticsearch-certutil http
17
18# Safely store the certificate file passwords to Elasticsearch, so it can open the files
19# Transport
20/usr/share/elasticsearch/bin/elasticsearch-keystore add xpack.security.transport.ssl.keystore.secure_password
21/usr/share/elasticsearch/bin/elasticsearch-keystore add xpack.security.transport.ssl.truststore.secure_password
22# HTTP
23/usr/share/elasticsearch/bin/elasticsearch-keystore add xpack.security.http.ssl.keystore.secure_password
24/usr/share/elasticsearch/bin/elasticsearch-keystore add xpack.security.http.ssl.truststore.secure_password
25
26# Generate passwords for Elasticsearch
27# (automatic)
28/usr/share/elasticsearch/bin/elasticsearch-setup-passwords auto
29
30# (you define them manually)
31/usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive
2-needed-commands-prior-upgrading.sh Raw
1# Check for upcoming deprecations you have to adjust before ugprading
2curl --insecure -uelastic:${es_password} https://127.0.0.1:9200/_migration/deprecations| jq
3
4# Check if indexes need migration
5curl --insecure -uelastic:${es_password} https://127.0.0.1:9200/_migration/system_features| jq
6
7# Migrate endpoints, if they say "MIGRATION NEEDED" or "ERROR"
8curl --insecure -uelastic:${es_password} -XPOST https://127.0.0.1:9200/_migration/system_features | jq
9
10# Remove ingest-attachment plugin (Elasticsearch has that on board starting with version 8)
11/usr/share/elasticsearch/bin/elasticsearch-plugin remove ingest-attachment
12
13# Switch repository source from Elasticsearch 7 to 8
14rm /etc/apt/sources.list.d/elastic-7.x.list
15curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | \
16 gpg --dearmor | tee /usr/share/keyrings/elasticsearch-keyring.gpg \
17 && chmod 644 /usr/share/keyrings/elasticsearch-keyring.gpg
18echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" | tee /etc/apt/sources.list.d/elastic-8.x.list
19
20# Switch repository source from Elasticsearch 8 to 9
21rm /etc/apt/sources.list.d/elastic-8.x.list
22echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/9.x/apt stable main" | tee /etc/apt/sources.list.d/elastic-9.x.list
3-re-attach-zammad.sh Raw
1# Switch Elasticsearch to HTTPs
2zammad run rails r "Setting.set('es_url', 'https://localhost:9200')"
3
4# Set Username and Password for Elasticsearch access
5zammad run rails r "Setting.set('es_user', '<username>')"
6zammad run rails r "Setting.set('es_password', '<password>')"
7
8# Disable SSL verification for Elasticsearch
9zammad run rails r "Setting.set('es_ssl_verify', false)"
10
11# -OR- provide Zammad with the certificate for proper SSL verification
12openssl pkcs12 -in /etc/elasticsearch/certs/elastic-stack-ca.p12 -nokeys -out - \
13 | zammad run rails r 'SSLCertificate.create!(certificate: STDIN.read)'
14zammad run rails r "Setting.set('es_ssl_verify', true)"
es-reindex-outdated.sh Raw
1#!/usr/bin/env bash
2set -euo pipefail
3
4usage() {
5 cat <<EOF
6Usage: $0 -u <url> -U <user> -p <password> [-k]
7
8 -u Elasticsearch URL (default: https://127.0.0.1:9200)
9 -U Username (default: elastic)
10 -p Password (required, or set ES_PASSWORD env var)
11 -k Allow insecure TLS (skip cert verification)
12EOF
13 exit 1
14}
15
16url="https://127.0.0.1:9200"
17user="elastic"
18password="${ES_PASSWORD:-}"
19insecure=()
20
21while getopts "u:U:p:kh" opt; do
22 case "$opt" in
23 u) url="$OPTARG" ;;
24 U) user="$OPTARG" ;;
25 p) password="$OPTARG" ;;
26 k) insecure=(--insecure) ;;
27 h|*) usage ;;
28 esac
29done
30
31if [[ -z "$password" ]]; then
32 echo "Password required (-p or ES_PASSWORD)"
33 usage
34fi
35
36CURL=(curl "${insecure[@]}" -s -u "${user}:${password}")
37
38fetch_settings_mappings() {
39 # $1 = index to read from
40 settings=$("${CURL[@]}" "${url}/$1/_settings" | jq ".[\"$1\"].settings.index | del(.uuid, .creation_date, .version, .provided_name, .resize, .blocks)")
41 mappings=$("${CURL[@]}" "${url}/$1/_mapping" | jq ".[\"$1\"].mappings")
42}
43
44create_index() {
45 # $1 = index to create, uses $settings/$mappings from fetch_settings_mappings
46 body=$(jq -n --argjson settings "$settings" --argjson mappings "$mappings" '{settings: {index: $settings}, mappings: $mappings}')
47 "${CURL[@]}" -X PUT "${url}/$1" -H 'Content-Type: application/json' -d "$body" >/dev/null
48}
49
50reindex() {
51 # $1 = source, $2 = dest; prints the summary line, sets $reindex_ok
52 result=$("${CURL[@]}" -X POST "${url}/_reindex" -H 'Content-Type: application/json' -d "{\"source\":{\"index\":\"$1\"},\"dest\":{\"index\":\"$2\"}}")
53 echo "$result" | jq -c '{took, total, created, failures: (.failures | length)}'
54
55 failure_count=$(echo "$result" | jq '(.failures // []) | length')
56 has_error=$(echo "$result" | jq 'has("error")')
57 if [[ "$failure_count" != "0" || "$has_error" == "true" ]]; then
58 reindex_ok=false
59 else
60 reindex_ok=true
61 fi
62}
63
64indices=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.index_settings // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .key')
65
66failed=()
67
68if [[ -z "$indices" ]]; then
69 echo "No plain indices require reindexing."
70else
71 indices_csv=$(echo "$indices" | paste -sd, -)
72 sizes=$("${CURL[@]}" "${url}/_cat/indices/${indices_csv}?h=index,store.size&bytes=b")
73
74 echo "Indices to rebuild in place:"
75 total_bytes=0
76 max_bytes=0
77 while read -r name bytes; do
78 [[ -n "$name" ]] || continue
79 human=$(numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B")
80 printf ' - %-60s %s\n' "$name" "$human"
81 total_bytes=$(( total_bytes + bytes ))
82 (( bytes > max_bytes )) && max_bytes=$bytes
83 done <<< "$sizes"
84
85 echo
86 echo "Total on-disk size across all listed indices: $(numfmt --to=iec --suffix=B "$total_bytes")"
87 echo "Each index is rebuilt one at a time, so the extra headroom you actually need is roughly the size"
88 echo "of the single largest index below (briefly held twice during its own rebuild), not the sum of all:"
89 echo " largest single index: $(numfmt --to=iec --suffix=B "$max_bytes")"
90 echo
91
92 read -rp "Proceed with index rebuild? Each index is rebuilt under its original name (no aliases left behind). [y/N] " ok
93 if [[ "$ok" == "y" ]]; then
94 for idx in $indices; do
95 tmp="${idx}-tmp-reindex"
96 echo "=== ${idx}: rebuilding via ${tmp} ==="
97
98 fetch_settings_mappings "$idx"
99 "${CURL[@]}" -X PUT "${url}/${idx}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": true}' >/dev/null
100
101 create_index "$tmp"
102 reindex "$idx" "$tmp"
103 if [[ "$reindex_ok" != "true" ]]; then
104 echo "!!! first-hop reindex failed for ${idx} — leaving original untouched, removing ${tmp}"
105 "${CURL[@]}" -X DELETE "${url}/${tmp}" >/dev/null
106 "${CURL[@]}" -X PUT "${url}/${idx}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": false}' >/dev/null
107 failed+=("$idx (first hop)")
108 continue
109 fi
110
111 "${CURL[@]}" -X DELETE "${url}/${idx}" >/dev/null
112 create_index "$idx"
113 reindex "$tmp" "$idx"
114 if [[ "$reindex_ok" != "true" ]]; then
115 echo "!!! second-hop reindex failed for ${idx} — data is safe in ${tmp}, NOT deleting it. ${idx} may be partially populated."
116 failed+=("$idx (second hop — recover from ${tmp})")
117 continue
118 fi
119
120 "${CURL[@]}" -X DELETE "${url}/${tmp}" >/dev/null
121 echo "=== done: ${idx} rebuilt (real index, no alias) ==="
122 done
123 else
124 echo "Skipping index rebuild."
125 fi
126fi
127
128migrate_data_stream() {
129 local ds="$1"
130 echo "=== data stream: ${ds} ==="
131
132 start_resp=$("${CURL[@]}" -X POST "${url}/_migration/reindex" -H 'Content-Type: application/json' -d "{\"source\":{\"index\":\"${ds}\"},\"mode\":\"upgrade\"}")
133 if echo "$start_resp" | jq -e 'has("error")' >/dev/null; then
134 echo "!!! failed to start migration for data stream ${ds}:"
135 echo "$start_resp" | jq -c .
136 ds_failed+=("$ds")
137 return
138 fi
139
140 while true; do
141 status=$("${CURL[@]}" "${url}/_migration/reindex/${ds}/_status")
142 complete=$(echo "$status" | jq -r '.complete // false')
143 successes=$(echo "$status" | jq -r '.successes // 0')
144 total=$(echo "$status" | jq -r '.total_indices_requiring_upgrade // 0')
145 pending=$(echo "$status" | jq -r '.pending // 0')
146 echo " progress: ${successes}/${total} backing indices upgraded, ${pending} pending"
147 [[ "$complete" == "true" ]] && break
148 sleep 5
149 done
150
151 err_count=$(echo "$status" | jq '(.errors // []) | length')
152 if [[ "$err_count" != "0" ]]; then
153 echo "!!! data stream ${ds} finished with errors:"
154 echo "$status" | jq -c '.errors'
155 ds_failed+=("$ds")
156 else
157 echo "=== done: ${ds} (${successes}/${total} backing indices upgraded, history preserved) ==="
158 fi
159}
160
161data_streams=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.data_streams // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .key')
162
163ds_failed=()
164
165if [[ -n "$data_streams" ]]; then
166 backing_indices=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.data_streams // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .value[]._meta.indices_requiring_upgrade[]')
167 backing_csv=$(echo "$backing_indices" | paste -sd, -)
168 ds_sizes=$("${CURL[@]}" "${url}/_cat/indices/${backing_csv}?h=index,store.size&bytes=b")
169
170 echo
171 echo "Data streams to migrate (native _migration/reindex, no data discarded):"
172 ds_total_bytes=0
173 ds_max_bytes=0
174 while read -r name bytes; do
175 [[ -n "$name" ]] || continue
176 human=$(numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B")
177 printf ' - %-60s %s\n' "$name" "$human"
178 ds_total_bytes=$(( ds_total_bytes + bytes ))
179 (( bytes > ds_max_bytes )) && ds_max_bytes=$bytes
180 done <<< "$ds_sizes"
181 echo " ($(echo "$data_streams" | wc -l | tr -d ' ') data stream(s), backing indices totaling $(numfmt --to=iec --suffix=B "$ds_total_bytes"), largest single backing index $(numfmt --to=iec --suffix=B "$ds_max_bytes"))"
182 echo
183
184 read -rp "Migrate these data streams in place? [y/N] " ds_ok
185 if [[ "$ds_ok" == "y" ]]; then
186 for ds in $data_streams; do
187 migrate_data_stream "$ds"
188 done
189 else
190 echo "Skipping data stream migration."
191 fi
192fi
193
194sf_failed=()
195
196before_sf=$("${CURL[@]}" "${url}/_migration/system_features")
197sf_status=$(echo "$before_sf" | jq -r '.migration_status')
198
199if [[ "$sf_status" == "NO_MIGRATION_NEEDED" ]]; then
200 echo
201 echo "No system feature migration needed."
202else
203 echo
204 echo "System features requiring migration:"
205 echo "$before_sf" | jq -r '.features[] | select(.migration_status=="MIGRATION_NEEDED") | " - \(.feature_name): \(.indices | map(.index) | join(", "))"'
206 echo
207
208 read -rp "Trigger system feature migration (POST /_migration/system_features)? [y/N] " sf_ok
209 if [[ "$sf_ok" == "y" ]]; then
210 before_map=$(echo "$before_sf" | jq -c '[.features[] | {feature: .feature_name, indices: [.indices[].index]}]')
211
212 post_resp=$("${CURL[@]}" -X POST "${url}/_migration/system_features")
213 echo "$post_resp" | jq -c .
214
215 if echo "$post_resp" | jq -e '.accepted == false' >/dev/null; then
216 echo "!!! system feature migration was not accepted"
217 sf_failed+=("system_features (not accepted)")
218 else
219 after_sf=""
220 while true; do
221 after_sf=$("${CURL[@]}" "${url}/_migration/system_features")
222 top=$(echo "$after_sf" | jq -r '.migration_status')
223 echo " status: ${top}"
224 if [[ "$top" == "ERROR" ]]; then
225 echo "!!! system feature migration reported ERROR:"
226 echo "$after_sf" | jq -c '.features[] | select(.migration_status=="ERROR")'
227 sf_failed+=("system_features (error)")
228 break
229 fi
230 [[ "$top" == "NO_MIGRATION_NEEDED" ]] && break
231 sleep 5
232 done
233
234 if [[ "$top" == "NO_MIGRATION_NEEDED" ]]; then
235 after_map=$(echo "$after_sf" | jq -c '[.features[] | {feature: .feature_name, indices: [.indices[].index]}]')
236
237 orphans=$(jq -n --argjson before "$before_map" --argjson after "$after_map" '
238 ($after | map({(.feature): .indices}) | add) as $afterByFeature |
239 [ $before[] | . as $b |
240 ($afterByFeature[$b.feature] // []) as $a |
241 select(($a | length) > 0) |
242 ($b.indices - $a)[]
243 ]')
244
245 echo "Old pre-migration indices confirmed superseded (locking read-only, not deleting):"
246 for old in $(echo "$orphans" | jq -r '.[]'); do
247 exists=$("${CURL[@]}" -o /dev/null -w '%{http_code}' "${url}/${old}")
248 if [[ "$exists" == "200" ]]; then
249 "${CURL[@]}" -X PUT "${url}/${old}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": true}' >/dev/null
250 echo " - ${old}: locked"
251 fi
252 done
253 fi
254 fi
255 else
256 echo "Skipping system feature migration."
257 fi
258fi
259
260if (( ${#failed[@]} > 0 || ${#ds_failed[@]} > 0 || ${#sf_failed[@]} > 0 )); then
261 echo
262 echo "Finished with failures:"
263 printf ' - %s\n' "${failed[@]}" "${ds_failed[@]}" "${sf_failed[@]}"
264 exit 1
265fi
266
es-user-mgm.sh Raw
1#!/usr/bin/env bash
2# Author: Marcel Herrguth, Claude Code
3set -euo pipefail
4
5usage() {
6 cat <<EOF
7Usage: $0 <action> [options]
8
9Actions:
10 create-ro Create a read-only user (and a matching role)
11 create-rw Create a read-write user (and a matching role)
12 remove Remove a single named user (refuses default/reserved users)
13
14Options:
15 -u <url> Elasticsearch URL (default: https://127.0.0.1:9200)
16 -U <user> Admin username to authenticate with (default: elastic)
17 -p <password> Admin password (required, or set ES_PASSWORD env var)
18 -k Allow insecure TLS (skip cert verification)
19 -n <name> Username (required for all actions)
20 -w <password> New user's password (required for create-ro / create-rw, or set ES_NEW_PASSWORD)
21 -i <pattern> Restrict access to indices matching "<pattern>*" (default: "*", all indices)
22
23Examples:
24 $0 create-ro -U elastic -p "\$ES_PASSWORD" -n zammad_reader -w "\$NEW_PW" -i zammad_production -k
25 $0 create-rw -U elastic -p "\$ES_PASSWORD" -n zammad_writer -w "\$NEW_PW" -i zammad_production -k
26 $0 remove -U elastic -p "\$ES_PASSWORD" -n zammad_reader -k
27EOF
28 exit 1
29}
30
31[[ $# -ge 1 ]] || usage
32action="$1"; shift
33
34url="https://127.0.0.1:9200"
35admin_user="elastic"
36admin_password="${ES_PASSWORD:-}"
37insecure=()
38new_user=""
39new_password="${ES_NEW_PASSWORD:-}"
40index_pattern="*"
41
42while getopts "u:U:p:kn:w:i:h" opt; do
43 case "$opt" in
44 u) url="$OPTARG" ;;
45 U) admin_user="$OPTARG" ;;
46 p) admin_password="$OPTARG" ;;
47 k) insecure=(--insecure) ;;
48 n) new_user="$OPTARG" ;;
49 w) new_password="$OPTARG" ;;
50 i) index_pattern="${OPTARG}*" ;;
51 h|*) usage ;;
52 esac
53done
54
55[[ -n "$admin_password" ]] || { echo "Admin password required (-p or ES_PASSWORD)"; usage; }
56
57CURL=(curl "${insecure[@]}" -s -u "${admin_user}:${admin_password}")
58
59create_user() {
60 local role_name="$1" privileges_json="$2"
61
62 [[ -n "$new_user" ]] || { echo "New username required (-n)"; usage; }
63 [[ -n "$new_password" ]] || { echo "New user's password required (-w or ES_NEW_PASSWORD)"; usage; }
64
65 role_body=$(jq -n --argjson privileges "$privileges_json" --arg pattern "$index_pattern" \
66 '{indices: [{names: [$pattern], privileges: $privileges}]}')
67 role_resp=$("${CURL[@]}" -X PUT "${url}/_security/role/${role_name}" -H 'Content-Type: application/json' -d "$role_body")
68 echo "role ${role_name}: $(echo "$role_resp" | jq -c .)"
69
70 user_body=$(jq -n --arg password "$new_password" --arg role "$role_name" \
71 '{password: $password, roles: [$role]}')
72 user_resp=$("${CURL[@]}" -X PUT "${url}/_security/user/${new_user}" -H 'Content-Type: application/json' -d "$user_body")
73 echo "user ${new_user}: $(echo "$user_resp" | jq -c .)"
74}
75
76case "$action" in
77 create-ro)
78 create_user "${new_user}_ro" '["read","view_index_metadata"]'
79 echo "Created read-only user '${new_user}', scoped to indices matching '${index_pattern}'."
80 ;;
81
82 create-rw)
83 create_user "${new_user}_rw" '["read","write","create_index","view_index_metadata"]'
84 echo "Created read-write user '${new_user}', scoped to indices matching '${index_pattern}'."
85 ;;
86
87 remove)
88 [[ -n "$new_user" ]] || { echo "Username required (-n)"; usage; }
89
90 lookup=$("${CURL[@]}" "${url}/_security/user/${new_user}")
91 if echo "$lookup" | jq -e 'has("error")' >/dev/null; then
92 echo "User '${new_user}' not found."
93 exit 1
94 fi
95
96 if echo "$lookup" | jq -e --arg u "$new_user" '.[$u].metadata._reserved == true' >/dev/null; then
97 echo "'${new_user}' is a default/reserved user — refusing to delete."
98 exit 1
99 fi
100
101 read -rp "Delete user '${new_user}'? [y/N] " ok
102 [[ "$ok" == "y" ]] || exit 1
103 "${CURL[@]}" -X DELETE "${url}/_security/user/${new_user}" | jq -c .
104 ;;
105
106 *)
107 usage
108 ;;
109esac
110
zzz-elasticsearch.yml Raw
1# What we ultimatively add to the Elasticsearch configuration during the upgrades
2# xpack
3xpack.security.enabled: true
4xpack.security.transport.ssl.enabled: true
5xpack.security.transport.ssl.verification_mode: certificate
6xpack.security.transport.ssl.keystore.path: certs/elastic-certificates.p12
7xpack.security.transport.ssl.truststore.path: certs/elastic-certificates.p12
8xpack.security.http.ssl.enabled: true
9xpack.security.http.ssl.keystore.path: certs/http.p12