gowalk-cicd 1.0.164 → 1.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,8 +210,14 @@ it rsyncs the backend dir to `/opt/gowalk-backends/<app>/`, runs
210
210
  wires an nginx vhost + Let's Encrypt cert for the API domain, and health-checks.
211
211
  Hosts with several existing Let's Encrypt accounts select one deterministically,
212
212
  and a domain deploy fails unless its public HTTPS certificate and health route validate.
213
- Changing a container's published port preserves its existing HTTPS configuration. An existing
214
- certificate is installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
213
+ Changing a container's published port preserves its existing HTTPS configuration; nginx must accept
214
+ the change, or the previous vhost is restored and the deploy fails. An existing certificate is
215
+ installed for the requested domain on redeploy, repairing older HTTP-only vhosts too.
216
+ A redeploy whose vhost already holds that certificate behind Certbot's redirect, whose certificate and
217
+ key form a pair, whose running nginx presents it for the domain, and whose name no other server block in
218
+ nginx's loaded configuration declares, skips the install, which would otherwise save a checkpoint copy of
219
+ every nginx file on the host. It still tests and reloads nginx as the install did, and fails the deploy
220
+ when nginx refuses either.
215
221
 
216
222
  Requirements on the consumer repo:
217
223
 
@@ -225,6 +231,13 @@ Requirements on the consumer repo:
225
231
  - Repo **secret** `BACKEND_RUNTIME_ENV` (optional): newline-delimited `KEY=value`
226
232
  runtime secrets. The action writes it as mode 0600 on the host and passes it
227
233
  to Compose without copying values into the repository or action log.
234
+ Existing quoted values, comments and ordinary dotenv assignments retain their syntax.
235
+ Raw multiline PEM blocks and JSON objects/arrays
236
+ are converted to quoted literal values, preserving embedded dollars and backslashes.
237
+ Already quoted values and single-line JSON retain their existing interpolation semantics.
238
+ Malformed or ambiguous multiline structures refuse before deployment. Transport is bounded
239
+ to 1 MiB of UTF-8 input and 512 KiB per value, uses private SSH stdin, and atomically replaces
240
+ `.runtime.env`; application sync preserves the last installed file if validation fails.
228
241
  - Repo **variables** (optional): `BACKEND_API_DOMAIN` (its DNS A record must point
229
242
  at the host for the cert; empty = deploy the container only, skip nginx/cert),
230
243
  `BACKEND_APP_NAME` (defaults to the repo name), `BACKEND_HEALTH_PATH`
@@ -87,13 +87,14 @@ def _exists(name: str) -> bool | None:
87
87
  return None
88
88
 
89
89
 
90
- def normalize(version: Version, probe=_exists) -> str:
90
+ def normalize(version: Version, probe=None) -> str:
91
91
  """The distribution name to write into distributionUrl.
92
92
 
93
93
  Probes services.gradle.org so a future naming change corrects itself; falls
94
94
  back to the ordering in `candidates` when the network is unavailable, which
95
95
  is no worse than not probing at all.
96
96
  """
97
+ probe = probe or _exists
97
98
  options = candidates(version)
98
99
  for name in options:
99
100
  if probe(name) is True:
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import tempfile
7
7
  import unittest
8
8
  from pathlib import Path
9
+ from unittest import mock
9
10
 
10
11
  import gradle_wrapper as gw
11
12
 
@@ -29,6 +30,12 @@ def never(_name: str) -> bool:
29
30
  return False
30
31
 
31
32
 
33
+ def setUpModule():
34
+ patcher = mock.patch.object(gw, "_exists", never)
35
+ patcher.start()
36
+ unittest.addModuleCleanup(patcher.stop)
37
+
38
+
32
39
  class RenderTest(unittest.TestCase):
33
40
  def test_eight_x_drops_a_trailing_zero_patch(self):
34
41
  self.assertEqual(gw.candidates((8, 14, 0))[0], "8.14")
@@ -149,17 +149,17 @@ runs:
149
149
  SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
150
150
  DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
151
151
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" "mkdir -p '$DEST'"
152
- # --delete keeps the mirror true; exclude .env so server-side secrets
153
- # (generated DB password, etc.) survive across deploys.
152
+ # --delete keeps the mirror true; preserve the private server-side env files
153
+ # until their replacement validates so failed credential updates retain the old file.
154
154
  if [ "${COMPOSE_FILE%/*}" = "$BACKEND_DIR" ]; then
155
- rsync -az --delete --exclude '.env' --exclude '.git' \
155
+ rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
156
156
  --exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
157
157
  -e "$SSH" "$BACKEND_DIR/" \
158
158
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
159
159
  else
160
160
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
161
161
  "mkdir -p '$DEST/$BACKEND_DIR'"
162
- rsync -az --delete --exclude '.env' --exclude '.git' \
162
+ rsync -az --delete --exclude '.env' --exclude '.runtime.env' --exclude '.git' \
163
163
  -e "$SSH" "$BACKEND_DIR/" \
164
164
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/$BACKEND_DIR/"
165
165
  rsync -az -e "$SSH" "$COMPOSE_FILE" \
@@ -171,8 +171,11 @@ runs:
171
171
  "${{ github.action_path }}/remote_deploy.sh" \
172
172
  "${{ github.action_path }}/select_certbot_account.sh" \
173
173
  "${{ github.action_path }}/ensure_certificate.sh" \
174
+ "${{ github.action_path }}/certificate_vhost.py" \
174
175
  "${{ github.action_path }}/configure_vhost.py" \
175
176
  "${{ github.action_path }}/edge_inputs.py" \
177
+ "${{ github.action_path }}/runtime_env.py" \
178
+ "${{ github.action_path }}/runtime_env_parse.py" \
176
179
  "${{ github.action_path }}/edge_vhost.py" \
177
180
  "${{ github.action_path }}/edge_health.py" \
178
181
  "${{ github.action_path }}/identity_diagnostics.py" \
@@ -208,9 +211,9 @@ runs:
208
211
  "${{ inputs.ssh-user }}@${{ inputs.host }}" \
209
212
  "umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
210
213
  if [ -n "${RUNTIME_ENV:-}" ]; then
211
- printf '%s\n' "$RUNTIME_ENV" | $SSH \
214
+ python3 "${{ github.action_path }}/runtime_env.py" export | $SSH \
212
215
  "${{ inputs.ssh-user }}@${{ inputs.host }}" \
213
- "umask 077; cat > '$DEST/.runtime.env'"
216
+ "python3 '$DEST/runtime_env.py' receive '$DEST/.runtime.env'"
214
217
  fi
215
218
  BACKEND_DEPLOY_PHASE=remote_deploy
216
219
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
@@ -0,0 +1,259 @@
1
+ """Tell whether a domain's vhost already serves its lineage exactly as `certbot install --redirect` leaves it.
2
+
3
+ Every `certbot install --nginx` saves a checkpoint copy of every nginx file on the host, even when it
4
+ changes nothing. The deploy skips that install only when this prints `current`: the managed file holds
5
+ Certbot's TLS block for exactly that lineage behind Certbot's redirect, the lineage's certificate and key
6
+ load as a pair, the running nginx answers the name with that lineage's certificate, and no other server
7
+ block in the whole configuration nginx loads names it. Every other state, including any doubt, keeps the
8
+ install. Only fixed state tokens are printed, never file content.
9
+
10
+ A skipped install still tests and reloads nginx (`ensure_certificate.sh`), as the install did.
11
+ """
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import socket
16
+ import ssl
17
+ import subprocess
18
+ import sys
19
+
20
+ DOMAIN = re.compile(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?")
21
+ TLS_LISTENS = {("443", "ssl"), ("[::]:443", "ssl", "ipv6only=on")}
22
+ HTTP_LISTENS = {("80",), ("[::]:80",)}
23
+ REDIRECT = [("return", ("301", "https://$host$request_uri"), None)]
24
+ WORD_END = " \t\r\n;{"
25
+ # Wherever nginx could start a token spelled `server_name`, bare or quoted, and in comments too.
26
+ SERVER_NAME = re.compile(rb"""(?<![^\s;{}#"'])server_name(?=[\s"'])""")
27
+ # A word nginx reads as one, or whose content a pair of quotes holds whole: no `#` or other quote, and no
28
+ # backslash that could escape the blank or `;` after it. A word holding a backslash is never a domain name.
29
+ READABLE_NAME = re.compile(rb"""(?:[^#"'\\]|\\[^#"'])*|(["'])[^#"'\\]*\1""")
30
+ WORD = re.compile(rb"[^ \t\r\n]+") # nginx separates words by these four only
31
+ PEM_CERTIFICATE = re.compile(r"-----BEGIN CERTIFICATE-----\n.+?\n-----END CERTIFICATE-----", re.S)
32
+ ORIGIN = ("127.0.0.1", 443)
33
+ LOADED = ("nginx", "-T")
34
+ DUMPED_FILE = re.compile(rb"^# configuration file (.*):$", re.M)
35
+
36
+
37
+ class Unparsable(ValueError):
38
+ pass
39
+
40
+
41
+ def tokens(text):
42
+ """Yield (is_word, value); refuse escapes, variables in braces and stray quotes or braces in words."""
43
+ index, size = 0, len(text)
44
+ while index < size:
45
+ char = text[index]
46
+ if char in " \t\r\n":
47
+ index += 1
48
+ elif char == "#":
49
+ end = text.find("\n", index)
50
+ index = size if end < 0 else end
51
+ elif char in ";{}":
52
+ yield False, char
53
+ index += 1
54
+ elif char in "\"'":
55
+ end = text.find(char, index + 1)
56
+ if end < 0 or "\\" in text[index + 1:end] or text[end + 1:end + 2] not in ("",) + tuple(WORD_END):
57
+ raise Unparsable
58
+ yield True, text[index + 1:end]
59
+ index = end + 1
60
+ else:
61
+ end = index
62
+ while end < size and text[end] not in WORD_END:
63
+ end += 1
64
+ word = text[index:end]
65
+ if any(mark in word for mark in ("}", "\"", "'", "\\", "${")):
66
+ raise Unparsable
67
+ yield True, word
68
+ index = end
69
+
70
+
71
+ def parse(text):
72
+ """Return top-level (name, arguments, children) entries; children is None for a simple directive."""
73
+ stack, words = [[]], []
74
+ for is_word, value in tokens(text):
75
+ if is_word:
76
+ words.append(value)
77
+ elif value == "}":
78
+ if words or len(stack) == 1:
79
+ raise Unparsable
80
+ stack.pop()
81
+ elif not words:
82
+ raise Unparsable
83
+ else:
84
+ children = [] if value == "{" else None
85
+ stack[-1].append((words[0], tuple(words[1:]), children))
86
+ words = []
87
+ if children is not None:
88
+ stack.append(children)
89
+ if words or len(stack) != 1:
90
+ raise Unparsable
91
+ return stack[0]
92
+
93
+
94
+ def values(block, name):
95
+ found = [entry for entry in block if entry[0] == name]
96
+ if any(children is not None for _, _, children in found):
97
+ raise Unparsable
98
+ return [arguments for _, arguments, _ in found]
99
+
100
+
101
+ def role(block, domain):
102
+ """Classify one server block as Certbot leaves it: `tls` on 443, `http` on 80, else `ambiguous`."""
103
+ listens = values(block, "listen")
104
+ if values(block, "server_name") != [(domain,)] or values(block, "ssl") or len(set(listens)) != len(listens):
105
+ return "ambiguous"
106
+ if listens and set(listens) <= TLS_LISTENS and ("443", "ssl") in listens:
107
+ return "tls"
108
+ if listens and set(listens) <= HTTP_LISTENS and ("80",) in listens:
109
+ return "http"
110
+ return "ambiguous"
111
+
112
+
113
+ def redirected(block, domain):
114
+ """Certbot's own redirect test: `if ($host = <domain>) { return 301 https://$host$request_uri; }`."""
115
+ condition = ("($host", "=", domain + ")")
116
+ return any(name == "if" and arguments == condition and children == REDIRECT
117
+ for name, arguments, children in block)
118
+
119
+
120
+ def loaded_configuration(command=LOADED):
121
+ """`nginx -T`: every file nginx loads from disk, each after a `# configuration file <path>:` line, and the
122
+ test's warnings. The dump holds secrets such as ingress tokens, so it stays in this process's memory."""
123
+ result = subprocess.run(command, capture_output=True, timeout=30, check=True)
124
+ return result.stdout, result.stderr
125
+
126
+
127
+ def named(text, domain):
128
+ """How many `server_name` words name the domain, or None when that count could be wrong.
129
+
130
+ Directives are only scanned, never parsed. nginx loads a `server_name` directive only where a token can start,
131
+ spelled bare or quoted, and ends it at a `;`; the scan takes every such place, in comments and strings too, so
132
+ a commented-out name counts, while a reference such as an upstream host is not a server_name. Up to the first
133
+ `;`, nginx reads exactly the words between blanks, unquoting a word a pair of quotes holds whole, unless a
134
+ `#`, another quote or a backslash before a blank or that `;` could end the statement later or change a name
135
+ (`server_name a # b;` goes on to the next line). Any of those is doubt, whatever the statement names, and so is
136
+ a quoted directive name, whose closing quote stands as a word of its own; a regular expression's own escapes
137
+ are not. nginx and Certbot take `.<domain>` for the domain itself. Only exact names count: a wildcard or a
138
+ regular expression that also matches the domain is not a duplicate, because Certbot (like nginx) ranks the
139
+ exact-name block above it for both the certificate and the port-80 redirect, so the install would edit the
140
+ managed block anyway and skipping it changes neither.
141
+ """
142
+ wanted, count = {domain.encode(), b"." + domain.encode()}, 0
143
+ for match in SERVER_NAME.finditer(text):
144
+ end = match.end()
145
+ stop = text.find(b";", end)
146
+ if stop < 0:
147
+ continue # no directive nginx loads lacks its `;`
148
+ for word in WORD.findall(text, end, stop):
149
+ if not READABLE_NAME.fullmatch(word):
150
+ return None
151
+ count += word.strip(b"\"'").lower() in wanted
152
+ return count
153
+
154
+
155
+ def named_elsewhere(path, domain, loaded):
156
+ """Doubt unless the loaded configuration names the domain only in this file, loaded once.
157
+
158
+ Certbot weighs every server block nginx loads, whichever `include` reaches it, so this reads the whole
159
+ configuration as `nginx -T` dumps it, not one directory, and a statement `named` cannot count anywhere in it,
160
+ this file included, is doubt. The whole dump is counted against this file, so a section line written inside
161
+ another file cannot hide a name; nginx dumps a file once however often it is included, and warns when a
162
+ second copy conflicts.
163
+ """
164
+ dump, warnings = loaded
165
+ here = named(path.read_bytes(), domain)
166
+ return (DUMPED_FILE.findall(dump).count(os.fsencode(path)) != 1 or here is None or here != named(dump, domain)
167
+ or b'conflicting server name "' + domain.encode() + b'"' in warnings.lower())
168
+
169
+
170
+ def configured(servers, domain, fullchain, privkey):
171
+ roles = [role(block, domain) for block in servers]
172
+ if not servers or "ambiguous" in roles or roles.count("tls") > 1 or roles.count("http") > 1:
173
+ return "ambiguous"
174
+ if "tls" not in roles:
175
+ return "http_only"
176
+ tls = servers[roles.index("tls")]
177
+ certificates, keys = values(tls, "ssl_certificate"), values(tls, "ssl_certificate_key")
178
+ if len(certificates) != 1 or len(keys) != 1:
179
+ return "ambiguous"
180
+ if certificates != [(fullchain,)] or keys != [(privkey,)]:
181
+ return "other_certificate"
182
+ if "http" not in roles or not redirected(servers[roles.index("http")], domain):
183
+ return "no_redirect"
184
+ return "current"
185
+
186
+
187
+ def leaf_certificate(fullchain):
188
+ """DER of the lineage's first certificate, which nginx presents as the leaf."""
189
+ match = PEM_CERTIFICATE.search(Path(fullchain).read_text(encoding="ascii"))
190
+ return ssl.PEM_cert_to_DER_cert(match.group(0)) if match else b""
191
+
192
+
193
+ def paired(fullchain, privkey):
194
+ """Load the lineage as nginx does: a key that does not match the leaf fails the next reload host-wide.
195
+
196
+ The empty password refuses an encrypted key at once instead of prompting for one.
197
+ """
198
+ try:
199
+ ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(fullchain, privkey, password=b"")
200
+ except ssl.SSLError:
201
+ return False
202
+ return True
203
+
204
+
205
+ def served_certificate(domain, origin=ORIGIN):
206
+ """DER of the certificate the running nginx presents for this name; the file alone may not be loaded."""
207
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
208
+ context.check_hostname = False
209
+ context.verify_mode = ssl.CERT_NONE
210
+ with socket.create_connection(origin, timeout=5) as connection:
211
+ with context.wrap_socket(connection, server_hostname=domain) as tls:
212
+ return tls.getpeercert(binary_form=True)
213
+
214
+
215
+ def state(vhost, domain, fullchain, privkey, probe=served_certificate, dump=loaded_configuration):
216
+ path = Path(vhost)
217
+ if not DOMAIN.fullmatch(domain) or not all(value.startswith("/") for value in (vhost, fullchain, privkey)):
218
+ return "ambiguous"
219
+ if path.is_symlink() or (path.exists() and not path.is_file()):
220
+ return "ambiguous"
221
+ if not path.exists():
222
+ return "absent"
223
+ entries = parse(path.read_text(encoding="utf-8"))
224
+ if any(name not in ("map", "server") or children is None for name, _, children in entries):
225
+ return "ambiguous"
226
+ servers = [children for name, _, children in entries if name == "server"]
227
+ result = configured(servers, domain, fullchain, privkey)
228
+ if result != "current":
229
+ return result
230
+ leaf = leaf_certificate(fullchain) if os.path.isfile(fullchain) and os.path.isfile(privkey) else b""
231
+ if not leaf or not paired(fullchain, privkey):
232
+ return "lineage_unreadable"
233
+ try:
234
+ live = probe(domain)
235
+ except OSError:
236
+ return "unreachable"
237
+ if live != leaf:
238
+ return "not_served"
239
+ # Last: the handshake is cheap, while the dump loads and tests the host's whole configuration.
240
+ try:
241
+ loaded = dump()
242
+ except (OSError, subprocess.SubprocessError):
243
+ return "config_unknown"
244
+ return "duplicate" if named_elsewhere(path, domain, loaded) else "current"
245
+
246
+
247
+ def main(arguments, probe=served_certificate, dump=loaded_configuration):
248
+ if len(arguments) != 4:
249
+ return "ambiguous"
250
+ try:
251
+ return state(*arguments, probe=probe, dump=dump)
252
+ except ValueError:
253
+ return "unparsable"
254
+ except OSError:
255
+ return "unreadable"
256
+
257
+
258
+ if __name__ == "__main__":
259
+ print(main(sys.argv[1:]))
@@ -4,10 +4,15 @@ import os
4
4
  from pathlib import Path
5
5
  import re
6
6
  import stat
7
+ import subprocess
7
8
  import sys
8
9
  import tempfile
9
10
 
10
11
 
12
+ class NginxRejected(Exception):
13
+ """nginx refused the updated vhost; the previous file is back in place."""
14
+
15
+
11
16
  def transport(text, domain):
12
17
  variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
13
18
  mapping = f"map $http_upgrade {variable} {{\n default upgrade;\n '' close;\n}}\n\n"
@@ -59,29 +64,52 @@ def content(previous, domain, port):
59
64
  """, domain)
60
65
 
61
66
 
62
- def configure(path, domain, port):
63
- if path.is_symlink():
64
- raise ValueError("managed vhost must not be a symlink")
65
- previous = path.read_text() if path.exists() else ""
66
- updated = content(previous, domain, port)
67
- if previous == updated:
68
- return "unchanged"
69
- mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
67
+ def replace(path, data, mode):
70
68
  fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
71
69
  try:
72
- with os.fdopen(fd, "w") as output:
70
+ with os.fdopen(fd, "wb") as output:
73
71
  os.fchmod(output.fileno(), mode)
74
- output.write(updated)
72
+ output.write(data)
75
73
  output.flush()
76
74
  os.fsync(output.fileno())
77
75
  os.replace(temporary, path)
78
76
  finally:
79
77
  Path(temporary).unlink(missing_ok=True)
78
+
79
+
80
+ def configure(path, domain, port, run=subprocess.run):
81
+ """Write the vhost, then test and reload nginx; on any failure restore the previous file, as edge does.
82
+
83
+ A change nginx refuses must not stay on disk: every later `nginx -t` on the host, this deploy's own
84
+ certificate step included, would fail on it, and the next deploy would find the file unchanged. The test
85
+ and reload run here, in one process that writes nothing to the deploy's output, so a cancelled deploy's
86
+ dropped SSH session cannot stop between them.
87
+ """
88
+ if path.is_symlink():
89
+ raise ValueError("managed vhost must not be a symlink")
90
+ previous = path.read_text() if path.exists() else ""
91
+ updated = content(previous, domain, port)
92
+ if previous == updated:
93
+ return "unchanged"
94
+ saved = path.read_bytes() if path.exists() else None
95
+ mode = stat.S_IMODE(path.stat().st_mode) if saved is not None else 0o644
96
+ replace(path, updated.encode(), mode)
97
+ try:
98
+ run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
99
+ run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
100
+ except (OSError, subprocess.SubprocessError):
101
+ if saved is None:
102
+ path.unlink(missing_ok=True)
103
+ else:
104
+ replace(path, saved, mode)
105
+ raise NginxRejected from None
80
106
  return "changed"
81
107
 
82
108
 
83
109
  if __name__ == "__main__":
84
110
  try:
85
111
  print(configure(Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])))
112
+ except NginxRejected:
113
+ sys.exit("nginx rejected the managed vhost update; the previous vhost is restored")
86
114
  except (OSError, ValueError):
87
115
  sys.exit("Cannot update the managed nginx vhost; its domain and upstream must be unambiguous")
@@ -1,13 +1,11 @@
1
1
  """Protect a distinct edge vhost while retaining an app's released direct-host address."""
2
- import os
3
2
  from pathlib import Path
4
3
  import re
5
4
  import stat
6
5
  import subprocess
7
6
  import sys
8
- import tempfile
9
7
 
10
- from configure_vhost import content
8
+ from configure_vhost import content, replace
11
9
  from edge_inputs import load
12
10
 
13
11
  NGINX_DIR = Path("/etc/nginx/conf.d")
@@ -71,19 +69,6 @@ def edge_content(text, domain, port, include):
71
69
  return updated
72
70
 
73
71
 
74
- def replace(path, data, mode):
75
- fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
76
- try:
77
- with os.fdopen(fd, "wb") as output:
78
- os.fchmod(output.fileno(), mode)
79
- output.write(data)
80
- output.flush()
81
- os.fsync(output.fileno())
82
- os.replace(temporary, path)
83
- finally:
84
- Path(temporary).unlink(missing_ok=True)
85
-
86
-
87
72
  def planned(config, app, domain, port, nginx_dir=NGINX_DIR):
88
73
  value = validate(config, app, domain, nginx_dir)
89
74
  if value["ingress_required"] != "true":
@@ -54,6 +54,23 @@ certbot_attempt() {
54
54
  return "$status"
55
55
  }
56
56
 
57
+ # A skipped install still does to nginx what the install did on every deploy: test the configuration, then
58
+ # reload it, so a change on disk the running nginx never loaded is loaded now. The commands' own output stays
59
+ # private; a failure ends the deploy as a failed install did, with its status and the fixed cause Certbot
60
+ # reports for a failed nginx test or reload.
61
+ nginx_attempt() {
62
+ local phase="$1" attempt="$2" status
63
+ shift 2
64
+ if "$@" > "$CERTBOT_WORK/stdout" 2> "$CERTBOT_WORK/stderr"; then
65
+ log "certificate phase=$phase attempt=$attempt state=ok exit=0"
66
+ return 0
67
+ else
68
+ status=$?
69
+ fi
70
+ log "certificate phase=$phase attempt=$attempt state=error exit=$status cause=nginx_restart"
71
+ return "$status"
72
+ }
73
+
57
74
  certificate_present() {
58
75
  # 0=present, 1=confirmed absent, 2=unrecognized/partial inventory; preserve other awk failures.
59
76
  awk -v domain="$1" -v emit_name="${2:-}" '
@@ -109,6 +126,41 @@ certificate_present() {
109
126
  ' "$CERTBOT_WORK/stdout"
110
127
  }
111
128
 
129
+ # Print the lineage's certificate and key paths, one per line, exactly as `certbot install` deploys them.
130
+ certificate_paths() {
131
+ awk -v name="$1" '
132
+ $1 == "Certificate" && $2 == "Name:" {
133
+ current = $0; sub(/^[[:space:]]*Certificate Name:[[:space:]]*/, "", current)
134
+ if (current == name) records++
135
+ next
136
+ }
137
+ current != name { next }
138
+ $1 == "Certificate" && $2 == "Path:" { chains++; chain = NF == 3 ? $3 : "" }
139
+ $1 == "Private" && $2 == "Key" && $3 == "Path:" { keys++; key = NF == 4 ? $4 : "" }
140
+ END {
141
+ if (records != 1 || chains != 1 || keys != 1 || chain == "" || key == "") exit 1
142
+ print chain; print key
143
+ }
144
+ ' "$CERTBOT_WORK/stdout"
145
+ }
146
+
147
+ # A repeated `install --nginx` saves a checkpoint of every nginx file on the host even when it changes
148
+ # nothing. Only `current` skips it: the vhost holds exactly this lineage behind Certbot's redirect, the
149
+ # running nginx presents that certificate for the name, and no other loaded server block names it.
150
+ certificate_vhost_state() {
151
+ local domain="$1" name="$2" vhost="$3" paths state
152
+ [ -n "$vhost" ] || { printf 'no_vhost'; return 0; }
153
+ paths="$(certificate_paths "$name")" || { printf 'lineage_unknown'; return 0; }
154
+ state="$(python3 "$(dirname "${BASH_SOURCE[0]}")/certificate_vhost.py" "$vhost" "$domain" \
155
+ "${paths%%$'\n'*}" "${paths#*$'\n'}" 2>/dev/null)" || state=check_failed
156
+ case "$state" in
157
+ current|absent|http_only|other_certificate|no_redirect|lineage_unreadable|duplicate) ;;
158
+ config_unknown|not_served|unreachable|ambiguous|unparsable|unreadable) ;;
159
+ *) state=check_failed ;;
160
+ esac
161
+ printf '%s' "$state"
162
+ }
163
+
112
164
  request_certificate() {
113
165
  local domain="$1" email="$2" attempt="$3" account
114
166
  # Certbot tells the CA to validate one second after reloading nginx by default. A host with
@@ -125,7 +177,7 @@ request_certificate() {
125
177
  }
126
178
 
127
179
  ensure_certificate() (
128
- local domain="$1" email="$2" delay name attempt=0 status=1 CERTBOT_WORK
180
+ local domain="$1" email="$2" vhost="${3:-}" delay name vhost_state attempt=0 status=1 CERTBOT_WORK
129
181
  CERTBOT_WORK="$(mktemp -d "${TMPDIR:-/tmp}/gowalk-certbot.XXXXXXXX")" || return $?
130
182
  trap 'rm -rf -- "$CERTBOT_WORK"' EXIT
131
183
  trap 'exit 130' INT
@@ -140,6 +192,14 @@ ensure_certificate() (
140
192
  if certbot_attempt inventory "$attempt" certificates; then
141
193
  if name="$(certificate_present "$domain" name)"; then
142
194
  log "cert for $domain already present"
195
+ vhost_state="$(certificate_vhost_state "$domain" "$name" "$vhost")"
196
+ if [ "$vhost_state" = current ]; then
197
+ log "certificate phase=installation attempt=$attempt state=skipped vhost=current"
198
+ nginx_attempt nginx_test "$attempt" nginx -t || return $?
199
+ nginx_attempt nginx_reload "$attempt" systemctl reload nginx || return $?
200
+ return 0
201
+ fi
202
+ log "certificate phase=installation attempt=$attempt state=required vhost=$vhost_state"
143
203
  # Inventory proves possession, not nginx installation. Repair an HTTP-only legacy vhost too.
144
204
  if certbot_attempt installation "$attempt" install --nginx --cert-name "$name" \
145
205
  -d "$domain" --non-interactive --redirect; then
@@ -26,8 +26,9 @@ log "host $(python3 --version)"
26
26
 
27
27
  # Refuse a missing ingress token or an older unprotected workflow before Compose changes.
28
28
  EDGE_CONFIG="/opt/gowalk-backend-ingress/$APP/.backend-edge.json"
29
+ EDGE_VHOST="/etc/nginx/conf.d/backend-$APP-edge.conf"
29
30
  EDGE_MODE=legacy
30
- if [ -f "$EDGE_CONFIG" ] || [ -e "/etc/nginx/conf.d/backend-$APP-edge.conf" ]; then
31
+ if [ -f "$EDGE_CONFIG" ] || [ -e "$EDGE_VHOST" ]; then
31
32
  python3 "$DIR/edge_vhost.py" validate "$EDGE_CONFIG" "$APP" "$DOMAIN"
32
33
  EDGE_MODE="$(python3 "$DIR/edge_inputs.py" mode "$EDGE_CONFIG")"
33
34
  python3 "$DIR/edge_inputs.py" runtime "$EDGE_CONFIG"
@@ -101,19 +102,23 @@ fi
101
102
  # ── nginx vhost → proxy the domain to the published port
102
103
  BACKEND_DEPLOY_PHASE=configure_vhost
103
104
  VHOST="/etc/nginx/conf.d/backend-$APP.conf"
105
+ CERT_VHOST="$VHOST"
104
106
  if [ "${EDGE_MODE:-legacy}" = edge ]; then
107
+ CERT_VHOST="$EDGE_VHOST"
105
108
  python3 "$DIR/edge_vhost.py" configure "$EDGE_CONFIG" "$APP" "$DOMAIN" "$PORT"
106
109
  else
110
+ # Tests and reloads nginx itself after a change, restoring the previous file on failure, so a change nginx
111
+ # refuses never stays on disk to fail the host's next test.
107
112
  VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
108
113
  if [ "$VHOST_STATE" = changed ]; then
109
114
  log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
110
- nginx -t && systemctl reload nginx
111
115
  fi
112
116
  fi
113
117
 
114
118
  # ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
119
+ # The domain's own vhost tells whether `certbot install` would change anything.
115
120
  BACKEND_DEPLOY_PHASE=certificate
116
- if ensure_certificate "$DOMAIN" "$CERT_EMAIL"; then
121
+ if ensure_certificate "$DOMAIN" "$CERT_EMAIL" "$CERT_VHOST"; then
117
122
  log "certificate ready for $DOMAIN"
118
123
  else
119
124
  status=$?