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

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
2# Author: Marcel Herrguth, Claude Code
3set -euo pipefail
4
5usage() {
6 cat <<EOF
7Usage: $0 -u <url> -U <user> -p <password> [-k]
8
9 -u Elasticsearch URL (default: https://127.0.0.1:9200)
10 -U Username (default: elastic)
11 -p Password (required, or set ES_PASSWORD env var)
12 -k Allow insecure TLS (skip cert verification)
13EOF
14 exit 1
15}
16
17url="https://127.0.0.1:9200"
18user="elastic"
19password="${ES_PASSWORD:-}"
20insecure=()
21
22while getopts "u:U:p:kh" opt; do
23 case "$opt" in
24 u) url="$OPTARG" ;;
25 U) user="$OPTARG" ;;
26 p) password="$OPTARG" ;;
27 k) insecure=(--insecure) ;;
28 h|*) usage ;;
29 esac
30done
31
32if [[ -z "$password" ]]; then
33 echo "Password required (-p or ES_PASSWORD)"
34 usage
35fi
36
37CURL=(curl "${insecure[@]}" -s -u "${user}:${password}")
38
39fetch_settings_mappings() {
40 # $1 = index to read from
41 settings=$("${CURL[@]}" "${url}/$1/_settings" | jq ".[\"$1\"].settings.index | del(.uuid, .creation_date, .version, .provided_name, .resize, .blocks)")
42 mappings=$("${CURL[@]}" "${url}/$1/_mapping" | jq ".[\"$1\"].mappings")
43}
44
45create_index() {
46 # $1 = index to create, uses $settings/$mappings from fetch_settings_mappings
47 body=$(jq -n --argjson settings "$settings" --argjson mappings "$mappings" '{settings: {index: $settings}, mappings: $mappings}')
48 "${CURL[@]}" -X PUT "${url}/$1" -H 'Content-Type: application/json' -d "$body" >/dev/null
49}
50
51reindex() {
52 # $1 = source, $2 = dest; prints the summary line, sets $reindex_ok
53 result=$("${CURL[@]}" -X POST "${url}/_reindex" -H 'Content-Type: application/json' -d "{\"source\":{\"index\":\"$1\"},\"dest\":{\"index\":\"$2\"}}")
54 echo "$result" | jq -c '{took, total, created, failures: (.failures | length)}'
55
56 failure_count=$(echo "$result" | jq '(.failures // []) | length')
57 has_error=$(echo "$result" | jq 'has("error")')
58 if [[ "$failure_count" != "0" || "$has_error" == "true" ]]; then
59 reindex_ok=false
60 else
61 reindex_ok=true
62 fi
63}
64
65indices=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.index_settings // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .key')
66
67failed=()
68
69if [[ -z "$indices" ]]; then
70 echo "No plain indices require reindexing."
71else
72 indices_csv=$(echo "$indices" | paste -sd, -)
73 sizes=$("${CURL[@]}" "${url}/_cat/indices/${indices_csv}?h=index,store.size&bytes=b")
74
75 echo "Indices to rebuild in place:"
76 total_bytes=0
77 max_bytes=0
78 while read -r name bytes; do
79 [[ -n "$name" ]] || continue
80 human=$(numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B")
81 printf ' - %-60s %s\n' "$name" "$human"
82 total_bytes=$(( total_bytes + bytes ))
83 (( bytes > max_bytes )) && max_bytes=$bytes
84 done <<< "$sizes"
85
86 echo
87 echo "Total on-disk size across all listed indices: $(numfmt --to=iec --suffix=B "$total_bytes")"
88 echo "Each index is rebuilt one at a time, so the extra headroom you actually need is roughly the size"
89 echo "of the single largest index below (briefly held twice during its own rebuild), not the sum of all:"
90 echo " largest single index: $(numfmt --to=iec --suffix=B "$max_bytes")"
91 echo
92
93 read -rp "Proceed with index rebuild? Each index is rebuilt under its original name (no aliases left behind). [y/N] " ok
94 if [[ "$ok" == "y" ]]; then
95 for idx in $indices; do
96 tmp="${idx}-tmp-reindex"
97 echo "=== ${idx}: rebuilding via ${tmp} ==="
98
99 fetch_settings_mappings "$idx"
100 "${CURL[@]}" -X PUT "${url}/${idx}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": true}' >/dev/null
101
102 create_index "$tmp"
103 reindex "$idx" "$tmp"
104 if [[ "$reindex_ok" != "true" ]]; then
105 echo "!!! first-hop reindex failed for ${idx} — leaving original untouched, removing ${tmp}"
106 "${CURL[@]}" -X DELETE "${url}/${tmp}" >/dev/null
107 "${CURL[@]}" -X PUT "${url}/${idx}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": false}' >/dev/null
108 failed+=("$idx (first hop)")
109 continue
110 fi
111
112 "${CURL[@]}" -X DELETE "${url}/${idx}" >/dev/null
113 create_index "$idx"
114 reindex "$tmp" "$idx"
115 if [[ "$reindex_ok" != "true" ]]; then
116 echo "!!! second-hop reindex failed for ${idx} — data is safe in ${tmp}, NOT deleting it. ${idx} may be partially populated."
117 failed+=("$idx (second hop — recover from ${tmp})")
118 continue
119 fi
120
121 "${CURL[@]}" -X DELETE "${url}/${tmp}" >/dev/null
122 echo "=== done: ${idx} rebuilt (real index, no alias) ==="
123 done
124 else
125 echo "Skipping index rebuild."
126 fi
127fi
128
129migrate_data_stream() {
130 local ds="$1"
131 echo "=== data stream: ${ds} ==="
132
133 start_resp=$("${CURL[@]}" -X POST "${url}/_migration/reindex" -H 'Content-Type: application/json' -d "{\"source\":{\"index\":\"${ds}\"},\"mode\":\"upgrade\"}")
134 if echo "$start_resp" | jq -e 'has("error")' >/dev/null; then
135 echo "!!! failed to start migration for data stream ${ds}:"
136 echo "$start_resp" | jq -c .
137 ds_failed+=("$ds")
138 return
139 fi
140
141 while true; do
142 status=$("${CURL[@]}" "${url}/_migration/reindex/${ds}/_status")
143 complete=$(echo "$status" | jq -r '.complete // false')
144 successes=$(echo "$status" | jq -r '.successes // 0')
145 total=$(echo "$status" | jq -r '.total_indices_requiring_upgrade // 0')
146 pending=$(echo "$status" | jq -r '.pending // 0')
147 echo " progress: ${successes}/${total} backing indices upgraded, ${pending} pending"
148 [[ "$complete" == "true" ]] && break
149 sleep 5
150 done
151
152 err_count=$(echo "$status" | jq '(.errors // []) | length')
153 if [[ "$err_count" != "0" ]]; then
154 echo "!!! data stream ${ds} finished with errors:"
155 echo "$status" | jq -c '.errors'
156 ds_failed+=("$ds")
157 else
158 echo "=== done: ${ds} (${successes}/${total} backing indices upgraded, history preserved) ==="
159 fi
160}
161
162data_streams=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.data_streams // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .key')
163
164ds_failed=()
165
166if [[ -n "$data_streams" ]]; then
167 backing_indices=$("${CURL[@]}" "${url}/_migration/deprecations" | jq -r '(.data_streams // {}) | to_entries[] | select(.value[]?._meta.reindex_required == true) | .value[]._meta.indices_requiring_upgrade[]')
168 backing_csv=$(echo "$backing_indices" | paste -sd, -)
169 ds_sizes=$("${CURL[@]}" "${url}/_cat/indices/${backing_csv}?h=index,store.size&bytes=b")
170
171 echo
172 echo "Data streams to migrate (native _migration/reindex, no data discarded):"
173 ds_total_bytes=0
174 ds_max_bytes=0
175 while read -r name bytes; do
176 [[ -n "$name" ]] || continue
177 human=$(numfmt --to=iec --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B")
178 printf ' - %-60s %s\n' "$name" "$human"
179 ds_total_bytes=$(( ds_total_bytes + bytes ))
180 (( bytes > ds_max_bytes )) && ds_max_bytes=$bytes
181 done <<< "$ds_sizes"
182 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"))"
183 echo
184
185 read -rp "Migrate these data streams in place? [y/N] " ds_ok
186 if [[ "$ds_ok" == "y" ]]; then
187 for ds in $data_streams; do
188 migrate_data_stream "$ds"
189 done
190 else
191 echo "Skipping data stream migration."
192 fi
193fi
194
195sf_failed=()
196
197before_sf=$("${CURL[@]}" "${url}/_migration/system_features")
198sf_status=$(echo "$before_sf" | jq -r '.migration_status')
199
200if [[ "$sf_status" == "NO_MIGRATION_NEEDED" ]]; then
201 echo
202 echo "No system feature migration needed."
203else
204 echo
205 echo "System features requiring migration:"
206 echo "$before_sf" | jq -r '.features[] | select(.migration_status=="MIGRATION_NEEDED") | " - \(.feature_name): \(.indices | map(.index) | join(", "))"'
207 echo
208
209 read -rp "Trigger system feature migration (POST /_migration/system_features)? [y/N] " sf_ok
210 if [[ "$sf_ok" == "y" ]]; then
211 before_map=$(echo "$before_sf" | jq -c '[.features[] | {feature: .feature_name, indices: [.indices[].index]}]')
212
213 post_resp=$("${CURL[@]}" -X POST "${url}/_migration/system_features")
214 echo "$post_resp" | jq -c .
215
216 if echo "$post_resp" | jq -e '.accepted == false' >/dev/null; then
217 echo "!!! system feature migration was not accepted"
218 sf_failed+=("system_features (not accepted)")
219 else
220 after_sf=""
221 while true; do
222 after_sf=$("${CURL[@]}" "${url}/_migration/system_features")
223 top=$(echo "$after_sf" | jq -r '.migration_status')
224 echo " status: ${top}"
225 if [[ "$top" == "ERROR" ]]; then
226 echo "!!! system feature migration reported ERROR:"
227 echo "$after_sf" | jq -c '.features[] | select(.migration_status=="ERROR")'
228 sf_failed+=("system_features (error)")
229 break
230 fi
231 [[ "$top" == "NO_MIGRATION_NEEDED" ]] && break
232 sleep 5
233 done
234
235 if [[ "$top" == "NO_MIGRATION_NEEDED" ]]; then
236 after_map=$(echo "$after_sf" | jq -c '[.features[] | {feature: .feature_name, indices: [.indices[].index]}]')
237
238 orphans=$(jq -n --argjson before "$before_map" --argjson after "$after_map" '
239 ($after | map({(.feature): .indices}) | add) as $afterByFeature |
240 [ $before[] | . as $b |
241 ($afterByFeature[$b.feature] // []) as $a |
242 select(($a | length) > 0) |
243 ($b.indices - $a)[]
244 ]')
245
246 echo "Old pre-migration indices confirmed superseded (locking read-only, not deleting):"
247 for old in $(echo "$orphans" | jq -r '.[]'); do
248 exists=$("${CURL[@]}" -o /dev/null -w '%{http_code}' "${url}/${old}")
249 if [[ "$exists" == "200" ]]; then
250 "${CURL[@]}" -X PUT "${url}/${old}/_settings" -H 'Content-Type: application/json' -d '{"index.blocks.write": true}' >/dev/null
251 echo " - ${old}: locked"
252 fi
253 done
254 fi
255 fi
256 else
257 echo "Skipping system feature migration."
258 fi
259fi
260
261if (( ${#failed[@]} > 0 || ${#ds_failed[@]} > 0 || ${#sf_failed[@]} > 0 )); then
262 echo
263 echo "Finished with failures:"
264 printf ' - %s\n' "${failed[@]}" "${ds_failed[@]}" "${sf_failed[@]}"
265 exit 1
266fi
267
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 roles=$(echo "$lookup" | jq -r --arg u "$new_user" --arg ro "${new_user}_ro" --arg rw "${new_user}_rw" \
102 '.[$u].roles[] | select(. == $ro or . == $rw)')
103
104 read -rp "Delete user '${new_user}'? [y/N] " ok
105 [[ "$ok" == "y" ]] || exit 1
106 "${CURL[@]}" -X DELETE "${url}/_security/user/${new_user}" | jq -c .
107
108 for role in $roles; do
109 "${CURL[@]}" -X DELETE "${url}/_security/role/${role}" | jq -c --arg role "$role" '{role: $role, result: .}'
110 done
111 ;;
112
113 *)
114 usage
115 ;;
116esac
117
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