gowalk-cicd 1.0.147 → 1.0.149
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/CLAUDE.md +10 -0
- package/README.md +2 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/mobile_inputs.cjs +1 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +39 -0
- package/backend-action/configure_vhost.py +28 -5
- package/backend-action/edge_health.py +72 -0
- package/backend-action/edge_inputs.py +115 -0
- package/backend-action/edge_vhost.py +140 -0
- package/backend-action/nginx_test_fixture.py +98 -0
- package/backend-action/remote_deploy.sh +26 -4
- package/backend-action/test_edge_health.py +75 -0
- package/backend-action/test_edge_inputs.py +79 -0
- package/backend-action/test_edge_nginx.py +125 -0
- package/backend-action/test_edge_vhost.py +72 -0
- package/docs/BACKEND_EDGE.md +45 -0
- package/docs/CONFIGURATION.md +11 -0
- package/package.json +2 -1
- package/src/config/settings.mjs +2 -1
- package/templates/deploy-backend.yml +5 -0
- package/templates/deploy.yml +5 -0
- package/templates/history.json.gz +0 -0
package/CLAUDE.md
CHANGED
|
@@ -242,6 +242,16 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
242
242
|
Docker port changes update only the managed nginx upstream, preserving existing TLS directives.
|
|
243
243
|
Existing certificate inventory is followed by domain-scoped `certbot install`, so a legacy
|
|
244
244
|
HTTP-only vhost also recovers without reissuance. Installation shares the bounded lock-only retry.
|
|
245
|
+
- Optional backend edge delivery keeps `api-domain` as infrastructure and verifies the separate
|
|
246
|
+
`public-health-url` against exact health JSON `build_sha`. `public-base-url` supplies
|
|
247
|
+
`BACKEND_PUBLIC_BASE_URL` to Compose interpolation; app services consume it for generated URLs.
|
|
248
|
+
`ingress-required` requires a private `ingress-token` before deployment. Protect only the distinct
|
|
249
|
+
`backend-<app>-edge.conf`; preserve a legacy vhost's TLS/public access and update both managed
|
|
250
|
+
ports. A missing token/settings cannot downgrade an already protected app. Keep tokens in private
|
|
251
|
+
stdin/file transport, strip the ingress header before app forwarding, and validate real nginx in
|
|
252
|
+
focused tests. Managed locations forward WebSocket upgrades with a domain-scoped Connection map
|
|
253
|
+
and disable response buffering; verify a real frame exchange and a first SSE event before upstream
|
|
254
|
+
completion. Preserve legacy deployments that have not opted in and existing failure phases.
|
|
245
255
|
- The Android action serves two build systems. `android_config.project_kind()`
|
|
246
256
|
is the only place that decides which; every downstream step branches on the
|
|
247
257
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
package/README.md
CHANGED
|
@@ -201,6 +201,8 @@ branch yet, manually dispatching **Mobile Deploy** on the feature branch runs
|
|
|
201
201
|
the same backend action as a `backend-preview` job. The job is feature-branch
|
|
202
202
|
only; normal default-branch backend deploys remain in `deploy-backend.yml`.
|
|
203
203
|
|
|
204
|
+
For optional app-owned Cloudflare ingress, follow [backend edge delivery](docs/BACKEND_EDGE.md).
|
|
205
|
+
|
|
204
206
|
## Android credentials
|
|
205
207
|
|
|
206
208
|
Place these files under `creds/`:
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -24,6 +24,7 @@ function input(name, platform) {
|
|
|
24
24
|
// checked against the template, and share the projection with the freeze CLI.
|
|
25
25
|
const BUILD_VARIABLES = [
|
|
26
26
|
'BACKEND_API_DOMAIN', 'BACKEND_APP_NAME', 'BACKEND_DEPLOY_HOST', 'BACKEND_HEALTH_PATH',
|
|
27
|
+
'BACKEND_INGRESS_REQUIRED', 'BACKEND_PUBLIC_BASE_URL', 'BACKEND_PUBLIC_HEALTH_URL',
|
|
27
28
|
'DEPLOY_PLATFORMS', 'FIREBASE_APP_ID', 'GOOGLE_PLAY_STATUS', 'GOOGLE_PLAY_TRACK',
|
|
28
29
|
'GOOGLE_PLAY_USER_FRACTION', 'JAVA_VERSION', 'ONBOARDING_PUBLIC_ORIGIN',
|
|
29
30
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -53,6 +53,26 @@ inputs:
|
|
|
53
53
|
description: HTTP path the service answers 2xx on once healthy.
|
|
54
54
|
required: false
|
|
55
55
|
default: "/health"
|
|
56
|
+
public-health-url:
|
|
57
|
+
description: App-owned HTTPS edge health URL; empty preserves the infrastructure health probe.
|
|
58
|
+
required: false
|
|
59
|
+
default: ""
|
|
60
|
+
public-base-url:
|
|
61
|
+
description: Canonical client API base supplied as BACKEND_PUBLIC_BASE_URL to Compose interpolation.
|
|
62
|
+
required: false
|
|
63
|
+
default: ""
|
|
64
|
+
expected-build-sha:
|
|
65
|
+
description: Exact 40-character lowercase commit SHA required in health JSON build_sha.
|
|
66
|
+
required: false
|
|
67
|
+
default: ""
|
|
68
|
+
ingress-required:
|
|
69
|
+
description: Protect a distinct edge-only infrastructure vhost, retaining an existing legacy vhost.
|
|
70
|
+
required: false
|
|
71
|
+
default: "false"
|
|
72
|
+
ingress-token:
|
|
73
|
+
description: Backend-only per-app token sent by the edge in X-App-Robot-Backend-Token.
|
|
74
|
+
required: false
|
|
75
|
+
default: ""
|
|
56
76
|
cert-email:
|
|
57
77
|
description: Email for the Let's Encrypt registration.
|
|
58
78
|
required: false
|
|
@@ -82,10 +102,16 @@ runs:
|
|
|
82
102
|
env:
|
|
83
103
|
BACKEND_DIR: ${{ inputs.backend-dir }}
|
|
84
104
|
COMPOSE_FILE_INPUT: ${{ inputs.compose-file }}
|
|
105
|
+
BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
|
|
106
|
+
BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
|
|
107
|
+
BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
|
|
108
|
+
BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
|
|
109
|
+
BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
|
|
85
110
|
run: |
|
|
86
111
|
set -euo pipefail
|
|
87
112
|
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
88
113
|
BACKEND_DEPLOY_PHASE=validate_inputs
|
|
114
|
+
python3 "${{ github.action_path }}/edge_inputs.py" check
|
|
89
115
|
COMPOSE_FILE="${COMPOSE_FILE_INPUT:-$BACKEND_DIR/docker-compose.yml}"
|
|
90
116
|
test -d "$BACKEND_DIR" \
|
|
91
117
|
|| { echo "::error::no backend directory $BACKEND_DIR"; exit 1; }
|
|
@@ -138,6 +164,7 @@ runs:
|
|
|
138
164
|
# (generated DB password, etc.) survive across deploys.
|
|
139
165
|
if [ "$COMPOSE_FILE" = "$BACKEND_DIR/docker-compose.yml" ]; then
|
|
140
166
|
rsync -az --delete --exclude '.env' --exclude '.git' \
|
|
167
|
+
--exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
|
|
141
168
|
-e "$SSH" "$BACKEND_DIR/" \
|
|
142
169
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
143
170
|
else
|
|
@@ -156,6 +183,9 @@ runs:
|
|
|
156
183
|
"${{ github.action_path }}/select_certbot_account.sh" \
|
|
157
184
|
"${{ github.action_path }}/ensure_certificate.sh" \
|
|
158
185
|
"${{ github.action_path }}/configure_vhost.py" \
|
|
186
|
+
"${{ github.action_path }}/edge_inputs.py" \
|
|
187
|
+
"${{ github.action_path }}/edge_vhost.py" \
|
|
188
|
+
"${{ github.action_path }}/edge_health.py" \
|
|
159
189
|
"${{ github.action_path }}/deploy_evidence.sh" \
|
|
160
190
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
161
191
|
|
|
@@ -164,12 +194,21 @@ runs:
|
|
|
164
194
|
shell: bash
|
|
165
195
|
env:
|
|
166
196
|
RUNTIME_ENV: ${{ inputs.runtime-env }}
|
|
197
|
+
BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
|
|
198
|
+
BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
|
|
199
|
+
BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
|
|
200
|
+
BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
|
|
201
|
+
BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
|
|
167
202
|
run: |
|
|
168
203
|
set -euo pipefail
|
|
169
204
|
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
170
205
|
BACKEND_DEPLOY_PHASE=write_runtime_env
|
|
171
206
|
SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
|
|
172
207
|
DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
|
|
208
|
+
EDGE_DIR="/opt/gowalk-backend-ingress/${{ inputs.app-name }}"
|
|
209
|
+
python3 "${{ github.action_path }}/edge_inputs.py" export | $SSH \
|
|
210
|
+
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
211
|
+
"umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
|
|
173
212
|
if [ -n "${RUNTIME_ENV:-}" ]; then
|
|
174
213
|
printf '%s\n' "$RUNTIME_ENV" | $SSH \
|
|
175
214
|
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Update
|
|
1
|
+
"""Update managed nginx transport without discarding Certbot's HTTPS configuration."""
|
|
2
|
+
import hashlib
|
|
2
3
|
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
import re
|
|
@@ -7,6 +8,29 @@ import sys
|
|
|
7
8
|
import tempfile
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
def transport(text, domain):
|
|
12
|
+
variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
|
|
13
|
+
mapping = f"map $http_upgrade {variable} {{\n default upgrade;\n '' close;\n}}\n\n"
|
|
14
|
+
result = text.removeprefix(mapping)
|
|
15
|
+
directives = (r"proxy_http_version", r"proxy_set_header[ \t]+Upgrade",
|
|
16
|
+
r"proxy_set_header[ \t]+Connection", r"proxy_buffering")
|
|
17
|
+
for directive in directives:
|
|
18
|
+
pattern = rf"(?mi)^[ \t]*{directive}[ \t]+[^;\n]+;[^\n]*\n"
|
|
19
|
+
result, count = re.subn(pattern, "", result)
|
|
20
|
+
if count > 1:
|
|
21
|
+
raise ValueError("managed transport directive is not unique")
|
|
22
|
+
if variable in result:
|
|
23
|
+
raise ValueError("managed connection map is not recognized")
|
|
24
|
+
settings = ("proxy_http_version 1.1;", "proxy_set_header Upgrade $http_upgrade;",
|
|
25
|
+
f"proxy_set_header Connection {variable};", "proxy_buffering off;")
|
|
26
|
+
pattern = r"(?m)^([ \t]*)proxy_pass[ \t]+http://127\.0\.0\.1:\d+;[^\n]*$"
|
|
27
|
+
result, count = re.subn(pattern, lambda match: match[0] + "\n"
|
|
28
|
+
+ "\n".join(match[1] + setting for setting in settings), result)
|
|
29
|
+
if count != 1:
|
|
30
|
+
raise ValueError("managed upstream is not unique")
|
|
31
|
+
return mapping + result
|
|
32
|
+
|
|
33
|
+
|
|
10
34
|
def content(previous, domain, port):
|
|
11
35
|
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain) or not 0 < port < 65536:
|
|
12
36
|
raise ValueError("invalid domain or port")
|
|
@@ -18,14 +42,13 @@ def content(previous, domain, port):
|
|
|
18
42
|
result, count = re.subn(pattern, lambda match: f"{match[1]}{port}{match[2]}", previous)
|
|
19
43
|
if count != 1:
|
|
20
44
|
raise ValueError("managed upstream is not unique")
|
|
21
|
-
return result
|
|
22
|
-
return f"""server {{
|
|
45
|
+
return transport(result, domain)
|
|
46
|
+
return transport(f"""server {{
|
|
23
47
|
listen 80;
|
|
24
48
|
server_name {domain};
|
|
25
49
|
client_max_body_size 100m;
|
|
26
50
|
location / {{
|
|
27
51
|
proxy_pass http://127.0.0.1:{port};
|
|
28
|
-
proxy_http_version 1.1;
|
|
29
52
|
proxy_set_header Host $host;
|
|
30
53
|
proxy_set_header X-Real-IP $remote_addr;
|
|
31
54
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
@@ -33,7 +56,7 @@ def content(previous, domain, port):
|
|
|
33
56
|
proxy_read_timeout 120s;
|
|
34
57
|
}}
|
|
35
58
|
}}
|
|
36
|
-
"""
|
|
59
|
+
""", domain)
|
|
37
60
|
|
|
38
61
|
|
|
39
62
|
def configure(path, domain, port):
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Verify private-origin refusal, retained aliases and the public edge's exact build."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from edge_inputs import load, TOKEN_HEADER
|
|
10
|
+
from edge_vhost import NGINX_DIR, paths, recorded_domain
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def request(url, *, headers=None, run=subprocess.run):
|
|
14
|
+
# curl retains its strict TLS/time bounds. Private headers and bounded response
|
|
15
|
+
# bytes live only in an owned mode-0700 directory, never argv or diagnostics.
|
|
16
|
+
with tempfile.TemporaryDirectory(prefix="backend-edge-health-") as directory:
|
|
17
|
+
root = Path(directory)
|
|
18
|
+
config, body = root / "curl.conf", root / "body"
|
|
19
|
+
config.write_text("".join(f'header = "{key}: {value}"\n' for key, value in (headers or {}).items()))
|
|
20
|
+
config.chmod(0o600)
|
|
21
|
+
result = run(["curl", "--disable", "--silent", "--config", str(config), "--output", str(body),
|
|
22
|
+
"--write-out", "%{http_code}", "--proto", "=https", "--connect-timeout", "5",
|
|
23
|
+
"--max-time", "10", "--max-filesize", "65536", url],
|
|
24
|
+
capture_output=True, timeout=15)
|
|
25
|
+
status = result.stdout.decode("ascii", errors="replace")
|
|
26
|
+
data = body.read_bytes() if body.exists() and body.stat().st_size <= 65536 else b""
|
|
27
|
+
return result.returncode, int(status) if status.isdigit() else 0, data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def healthy(url, sha="", *, headers=None, fetch=request, sleep=time.sleep):
|
|
31
|
+
for attempt in range(15):
|
|
32
|
+
try:
|
|
33
|
+
code, status, body = fetch(url, headers=headers)
|
|
34
|
+
valid = code == 0 and 200 <= status < 300
|
|
35
|
+
if valid and sha:
|
|
36
|
+
value = json.loads(body)
|
|
37
|
+
valid = isinstance(value, dict) and value.get("build_sha") == sha
|
|
38
|
+
if valid:
|
|
39
|
+
return
|
|
40
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
41
|
+
pass
|
|
42
|
+
if attempt != 14:
|
|
43
|
+
sleep(2)
|
|
44
|
+
raise ValueError("backend_public_health_failed")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify(config, app, domain, health, *, nginx_dir=NGINX_DIR, fetch=request, sleep=time.sleep):
|
|
48
|
+
value = load(config)
|
|
49
|
+
if not health.startswith("/") or any(character in health for character in "\r\n?#"):
|
|
50
|
+
raise ValueError("backend_health_path_invalid")
|
|
51
|
+
origin_url = f"https://{domain}{health}"
|
|
52
|
+
if value["ingress_required"] == "true":
|
|
53
|
+
for headers in (None, {TOKEN_HEADER: "invalid-backend-ingress-token"}):
|
|
54
|
+
code, status, _body = fetch(origin_url, headers=headers)
|
|
55
|
+
if code != 0 or status != 403:
|
|
56
|
+
raise ValueError("backend_origin_ingress_unverified")
|
|
57
|
+
healthy(origin_url, value["expected_build_sha"], headers={TOKEN_HEADER: value["ingress_token"]},
|
|
58
|
+
fetch=fetch, sleep=sleep)
|
|
59
|
+
legacy = recorded_domain(paths(config, app, nginx_dir)[0])
|
|
60
|
+
if legacy:
|
|
61
|
+
healthy(f"https://{legacy}{health}", fetch=fetch, sleep=sleep)
|
|
62
|
+
public = value["public_health_url"] or origin_url
|
|
63
|
+
healthy(public, value["expected_build_sha"], fetch=fetch, sleep=sleep)
|
|
64
|
+
return {"ok": True, "public_health_verified": True, "build_sha": value["expected_build_sha"],
|
|
65
|
+
"ingress_verified": value["ingress_required"] == "true"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
try:
|
|
70
|
+
print(json.dumps(verify(*sys.argv[1:5])))
|
|
71
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
72
|
+
sys.exit("backend_edge_health_failed")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Validate optional edge delivery inputs without exposing credentials or response bodies."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
CONFIG_NAME = ".backend-edge.json"
|
|
11
|
+
TOKEN_HEADER = "X-App-Robot-Backend-Token"
|
|
12
|
+
FIELDS = ("public_health_url", "public_base_url", "expected_build_sha", "ingress_required", "ingress_token")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(value):
|
|
16
|
+
if not isinstance(value, dict) or set(value) != set(FIELDS):
|
|
17
|
+
raise ValueError("backend_edge_inputs_invalid")
|
|
18
|
+
if any(not isinstance(value[key], str) for key in FIELDS):
|
|
19
|
+
raise ValueError("backend_edge_inputs_invalid")
|
|
20
|
+
public, base, sha, required, token = (value[key] for key in FIELDS)
|
|
21
|
+
if required not in {"true", "false"}:
|
|
22
|
+
raise ValueError("backend_ingress_required_invalid")
|
|
23
|
+
if sha and not re.fullmatch(r"[a-f0-9]{40}", sha):
|
|
24
|
+
raise ValueError("backend_build_sha_invalid")
|
|
25
|
+
if public:
|
|
26
|
+
parsed = urlsplit(public)
|
|
27
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
|
|
28
|
+
or parsed.query or parsed.fragment or re.search(r"[\s\\]", public) or not sha):
|
|
29
|
+
raise ValueError("backend_public_health_invalid")
|
|
30
|
+
if parsed.port not in {None, 443}:
|
|
31
|
+
raise ValueError("backend_public_health_invalid")
|
|
32
|
+
if base:
|
|
33
|
+
parsed = urlsplit(base)
|
|
34
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
|
|
35
|
+
or parsed.query or parsed.fragment or parsed.port not in {None, 443}
|
|
36
|
+
or not re.fullmatch(r"[a-zA-Z0-9._~:/%-]+", base) or base.endswith("/")):
|
|
37
|
+
raise ValueError("backend_public_base_invalid")
|
|
38
|
+
if public and not public.startswith(base + "/"):
|
|
39
|
+
raise ValueError("backend_public_health_base_mismatch")
|
|
40
|
+
if required == "true" and (not public or not base or not re.fullmatch(r"[a-zA-Z0-9_-]{43,128}", token)):
|
|
41
|
+
raise ValueError("backend_ingress_configuration_required")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def environment():
|
|
46
|
+
return validate({key: os.environ.get("BACKEND_" + key.upper(), "false" if key == "ingress_required" else "")
|
|
47
|
+
for key in FIELDS})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(path):
|
|
51
|
+
path = Path(path)
|
|
52
|
+
if path.is_symlink():
|
|
53
|
+
raise ValueError("backend_edge_config_symlink")
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return validate(dict(zip(FIELDS, ("", "", "", "false", ""))))
|
|
56
|
+
if path.stat().st_size > 8192:
|
|
57
|
+
raise ValueError("backend_edge_config_invalid")
|
|
58
|
+
return validate(json.loads(path.read_text()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mode(value):
|
|
62
|
+
if value["ingress_required"] == "true":
|
|
63
|
+
return "edge"
|
|
64
|
+
return "verify" if value["public_health_url"] or value["expected_build_sha"] else "legacy"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def runtime(path):
|
|
68
|
+
value = load(path)["public_base_url"]
|
|
69
|
+
if value:
|
|
70
|
+
target = Path(path).parent / ".backend-public.env"
|
|
71
|
+
if target.is_symlink():
|
|
72
|
+
raise ValueError("backend_public_env_symlink")
|
|
73
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
|
74
|
+
with os.fdopen(descriptor, "w") as output:
|
|
75
|
+
os.fchmod(output.fileno(), 0o600)
|
|
76
|
+
output.write("BACKEND_PUBLIC_BASE_URL=" + json.dumps(value) + "\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def receive(path):
|
|
80
|
+
target = Path(path)
|
|
81
|
+
value = validate(json.loads(sys.stdin.buffer.read(8193)))
|
|
82
|
+
if target.is_symlink() or target.parent.is_symlink():
|
|
83
|
+
raise ValueError("backend_edge_config_symlink")
|
|
84
|
+
descriptor, temporary = tempfile.mkstemp(prefix=target.name + ".", dir=target.parent)
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(descriptor, "w") as output:
|
|
87
|
+
os.fchmod(output.fileno(), 0o600)
|
|
88
|
+
json.dump(value, output)
|
|
89
|
+
output.flush()
|
|
90
|
+
os.fsync(output.fileno())
|
|
91
|
+
os.replace(temporary, target)
|
|
92
|
+
finally:
|
|
93
|
+
Path(temporary).unlink(missing_ok=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main():
|
|
97
|
+
if sys.argv[1] == "check":
|
|
98
|
+
environment()
|
|
99
|
+
elif sys.argv[1] == "export":
|
|
100
|
+
print(json.dumps(environment()))
|
|
101
|
+
elif sys.argv[1] == "mode":
|
|
102
|
+
print(mode(load(sys.argv[2])))
|
|
103
|
+
elif sys.argv[1] == "runtime":
|
|
104
|
+
runtime(sys.argv[2])
|
|
105
|
+
elif sys.argv[1] == "receive":
|
|
106
|
+
receive(sys.argv[2])
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError("backend_edge_operation_invalid")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
try:
|
|
113
|
+
main()
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
sys.exit("backend_edge_configuration_invalid")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Protect a distinct edge vhost while retaining an app's released direct-host address."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from configure_vhost import content
|
|
11
|
+
from edge_inputs import load
|
|
12
|
+
|
|
13
|
+
NGINX_DIR = Path("/etc/nginx/conf.d")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def paths(config, app, nginx_dir=NGINX_DIR):
|
|
17
|
+
if not re.fullmatch(r"[a-z0-9-]+", app):
|
|
18
|
+
raise ValueError("backend_app_invalid")
|
|
19
|
+
return (nginx_dir / f"backend-{app}.conf", nginx_dir / f"backend-{app}-edge.conf",
|
|
20
|
+
Path(config).parent / ".backend-ingress.conf")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def previous(path):
|
|
24
|
+
if path.is_symlink():
|
|
25
|
+
raise ValueError("backend_vhost_symlink")
|
|
26
|
+
return path.read_text() if path.exists() else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def recorded_domain(path):
|
|
30
|
+
text = previous(path)
|
|
31
|
+
if not text:
|
|
32
|
+
return ""
|
|
33
|
+
names = set(re.findall(r"(?m)^\s*server_name\s+([^;]+);", text))
|
|
34
|
+
if len(names) != 1:
|
|
35
|
+
raise ValueError("backend_vhost_identity_invalid")
|
|
36
|
+
domain = names.pop().strip()
|
|
37
|
+
content(text, domain, 1)
|
|
38
|
+
return domain
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate(config, app, domain, nginx_dir=NGINX_DIR):
|
|
42
|
+
value = load(config)
|
|
43
|
+
legacy, edge, include = paths(config, app, nginx_dir)
|
|
44
|
+
protected = bool(previous(edge) or previous(include))
|
|
45
|
+
if value["ingress_required"] != "true":
|
|
46
|
+
if protected:
|
|
47
|
+
raise ValueError("backend_ingress_downgrade_refused")
|
|
48
|
+
return value
|
|
49
|
+
content("", domain, 1)
|
|
50
|
+
old_domain = recorded_domain(legacy)
|
|
51
|
+
if old_domain == domain:
|
|
52
|
+
raise ValueError("backend_edge_domain_must_be_distinct")
|
|
53
|
+
if previous(edge) and recorded_domain(edge) != domain:
|
|
54
|
+
raise ValueError("backend_edge_domain_changed")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def edge_content(text, domain, port, include):
|
|
59
|
+
updated = content(text, domain, port)
|
|
60
|
+
directive = f" include {include};\n"
|
|
61
|
+
if re.search(r"[\s;{}]", str(include)):
|
|
62
|
+
raise ValueError("backend_ingress_path_invalid")
|
|
63
|
+
if updated.count(directive) == 1:
|
|
64
|
+
return updated
|
|
65
|
+
if ".backend-ingress.conf" in updated or len(re.findall(r"\bproxy_pass\b", updated)) != 1:
|
|
66
|
+
raise ValueError("backend_edge_vhost_invalid")
|
|
67
|
+
pattern = r"(?m)^(\s*location / \{\n)"
|
|
68
|
+
updated, count = re.subn(pattern, lambda match: match[1] + directive, updated)
|
|
69
|
+
if count != 1:
|
|
70
|
+
raise ValueError("backend_edge_location_invalid")
|
|
71
|
+
return updated
|
|
72
|
+
|
|
73
|
+
|
|
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
|
+
def planned(config, app, domain, port, nginx_dir=NGINX_DIR):
|
|
88
|
+
value = validate(config, app, domain, nginx_dir)
|
|
89
|
+
if value["ingress_required"] != "true":
|
|
90
|
+
raise ValueError("backend_ingress_configuration_required")
|
|
91
|
+
legacy, edge, include = paths(config, app, nginx_dir)
|
|
92
|
+
token = value["ingress_token"]
|
|
93
|
+
guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
|
|
94
|
+
'proxy_set_header X-App-Robot-Backend-Token "";\n')
|
|
95
|
+
files = {include: (guard.encode(), 0o600),
|
|
96
|
+
edge: (edge_content(previous(edge), domain, port, include).encode(), 0o644)}
|
|
97
|
+
old_domain = recorded_domain(legacy)
|
|
98
|
+
if old_domain:
|
|
99
|
+
files[legacy] = (content(previous(legacy), old_domain, port).encode(),
|
|
100
|
+
stat.S_IMODE(legacy.stat().st_mode))
|
|
101
|
+
return files
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def configure(config, app, domain, port, nginx_dir=NGINX_DIR, run=subprocess.run):
|
|
105
|
+
files = planned(config, app, domain, port, nginx_dir)
|
|
106
|
+
saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
|
|
107
|
+
for path in files}
|
|
108
|
+
changes = {path: value for path, value in files.items() if saved[path] != value}
|
|
109
|
+
if not changes:
|
|
110
|
+
return "unchanged"
|
|
111
|
+
try:
|
|
112
|
+
for path, (data, mode) in changes.items():
|
|
113
|
+
replace(path, data, mode)
|
|
114
|
+
run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
|
|
115
|
+
run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
|
|
116
|
+
except (OSError, subprocess.SubprocessError):
|
|
117
|
+
for path in changes:
|
|
118
|
+
if saved[path] is None:
|
|
119
|
+
path.unlink(missing_ok=True)
|
|
120
|
+
else:
|
|
121
|
+
replace(path, *saved[path])
|
|
122
|
+
raise ValueError("backend_edge_nginx_failed") from None
|
|
123
|
+
return "changed"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
operation, config, app, domain = sys.argv[1:5]
|
|
128
|
+
if operation == "validate":
|
|
129
|
+
validate(config, app, domain)
|
|
130
|
+
elif operation == "configure":
|
|
131
|
+
print(configure(config, app, domain, int(sys.argv[5])))
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("backend_edge_operation_invalid")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
try:
|
|
138
|
+
main()
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
sys.exit("backend_edge_vhost_refused")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""A task-owned HTTP server for real nginx protocol and streaming checks."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
LEGACY_VHOST = """server {
|
|
10
|
+
listen 80;
|
|
11
|
+
server_name legacy.example;
|
|
12
|
+
location / {
|
|
13
|
+
proxy_pass http://127.0.0.1:31000;
|
|
14
|
+
proxy_http_version 1.1;
|
|
15
|
+
proxy_set_header Host $host;
|
|
16
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
17
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
18
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
19
|
+
proxy_read_timeout 120s;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BackendServer(ThreadingHTTPServer):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__(("127.0.0.1", 0), Backend)
|
|
28
|
+
self.stream_release = threading.Event()
|
|
29
|
+
self.stream_finished = threading.Event()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Backend(BaseHTTPRequestHandler):
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
|
|
35
|
+
def do_GET(self):
|
|
36
|
+
if self.path == "/ws":
|
|
37
|
+
return self.websocket()
|
|
38
|
+
if self.path == "/stream":
|
|
39
|
+
return self.stream()
|
|
40
|
+
payload = json.dumps({"build_sha": "a" * 40, "path": self.path,
|
|
41
|
+
"upgrade": self.headers.get("Upgrade"),
|
|
42
|
+
"connection": self.headers.get("Connection"),
|
|
43
|
+
"ingress_received": "X-App-Robot-Backend-Token" in self.headers}).encode()
|
|
44
|
+
self.send_response(200)
|
|
45
|
+
self.send_header("Content-Type", "application/json")
|
|
46
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
47
|
+
self.end_headers()
|
|
48
|
+
self.wfile.write(payload)
|
|
49
|
+
|
|
50
|
+
def websocket(self):
|
|
51
|
+
self.close_connection = True
|
|
52
|
+
if (self.headers.get("Upgrade", "").lower() != "websocket"
|
|
53
|
+
or self.headers.get("Connection", "").lower() != "upgrade"
|
|
54
|
+
or self.request_version != "HTTP/1.1"
|
|
55
|
+
or "X-App-Robot-Backend-Token" in self.headers):
|
|
56
|
+
self.send_error(400)
|
|
57
|
+
return
|
|
58
|
+
key = self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
59
|
+
self.send_response(101)
|
|
60
|
+
self.send_header("Upgrade", "websocket")
|
|
61
|
+
self.send_header("Connection", "Upgrade")
|
|
62
|
+
self.send_header("Sec-WebSocket-Accept", base64.b64encode(hashlib.sha1(key.encode()).digest()).decode())
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.connection.settimeout(2)
|
|
65
|
+
frame = self.rfile.read(2)
|
|
66
|
+
if len(frame) != 2 or frame[0] != 0x81 or frame[1] & 0x80 == 0 or frame[1] & 0x7F > 125:
|
|
67
|
+
return
|
|
68
|
+
mask = self.rfile.read(4)
|
|
69
|
+
payload = self.rfile.read(frame[1] & 0x7F)
|
|
70
|
+
decoded = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
71
|
+
self.wfile.write(bytes((0x81, len(decoded))) + decoded)
|
|
72
|
+
self.wfile.flush()
|
|
73
|
+
|
|
74
|
+
def stream(self):
|
|
75
|
+
self.close_connection = True
|
|
76
|
+
self.send_response(200)
|
|
77
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
78
|
+
self.end_headers()
|
|
79
|
+
self.wfile.write(b"data: first\n\n")
|
|
80
|
+
self.wfile.flush()
|
|
81
|
+
try:
|
|
82
|
+
if self.server.stream_release.wait(5):
|
|
83
|
+
self.wfile.write(b"data: second\n\n")
|
|
84
|
+
self.wfile.flush()
|
|
85
|
+
finally:
|
|
86
|
+
self.server.stream_finished.set()
|
|
87
|
+
|
|
88
|
+
def log_message(self, *_args):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def stop(process):
|
|
93
|
+
process.terminate()
|
|
94
|
+
try:
|
|
95
|
+
process.communicate(timeout=5)
|
|
96
|
+
except subprocess.TimeoutExpired:
|
|
97
|
+
process.kill()
|
|
98
|
+
process.communicate(timeout=5)
|
|
@@ -17,6 +17,15 @@ source "$DIR/ensure_certificate.sh"
|
|
|
17
17
|
|
|
18
18
|
log() { echo "[deploy $APP] $*"; }
|
|
19
19
|
|
|
20
|
+
# Refuse a missing ingress token or an older unprotected workflow before Compose changes.
|
|
21
|
+
EDGE_CONFIG="/opt/gowalk-backend-ingress/$APP/.backend-edge.json"
|
|
22
|
+
EDGE_MODE=legacy
|
|
23
|
+
if [ -f "$EDGE_CONFIG" ] || [ -e "/etc/nginx/conf.d/backend-$APP-edge.conf" ]; then
|
|
24
|
+
python3 "$DIR/edge_vhost.py" validate "$EDGE_CONFIG" "$APP" "$DOMAIN"
|
|
25
|
+
EDGE_MODE="$(python3 "$DIR/edge_inputs.py" mode "$EDGE_CONFIG")"
|
|
26
|
+
python3 "$DIR/edge_inputs.py" runtime "$EDGE_CONFIG"
|
|
27
|
+
fi
|
|
28
|
+
|
|
20
29
|
# ── secrets: generate a per-app .env once, then preserve it (rsync excludes it)
|
|
21
30
|
BACKEND_DEPLOY_PHASE=initialize_environment
|
|
22
31
|
if [ ! -f .env ]; then
|
|
@@ -33,6 +42,10 @@ if [ -s .runtime.env ]; then
|
|
|
33
42
|
chmod 600 .runtime.env
|
|
34
43
|
COMPOSE_ENV+=(--env-file .runtime.env)
|
|
35
44
|
fi
|
|
45
|
+
EDGE_PUBLIC="$(dirname "$EDGE_CONFIG")/.backend-public.env"
|
|
46
|
+
if [ -s "$EDGE_PUBLIC" ]; then
|
|
47
|
+
COMPOSE_ENV+=(--env-file "$EDGE_PUBLIC")
|
|
48
|
+
fi
|
|
36
49
|
|
|
37
50
|
# ── bring the stack up
|
|
38
51
|
BACKEND_DEPLOY_PHASE=compose_up
|
|
@@ -80,10 +93,14 @@ fi
|
|
|
80
93
|
# ── nginx vhost → proxy the domain to the published port
|
|
81
94
|
BACKEND_DEPLOY_PHASE=configure_vhost
|
|
82
95
|
VHOST="/etc/nginx/conf.d/backend-$APP.conf"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if [ "${EDGE_MODE:-legacy}" = edge ]; then
|
|
97
|
+
python3 "$DIR/edge_vhost.py" configure "$EDGE_CONFIG" "$APP" "$DOMAIN" "$PORT"
|
|
98
|
+
else
|
|
99
|
+
VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
|
|
100
|
+
if [ "$VHOST_STATE" = changed ]; then
|
|
101
|
+
log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
|
|
102
|
+
nginx -t && systemctl reload nginx
|
|
103
|
+
fi
|
|
87
104
|
fi
|
|
88
105
|
|
|
89
106
|
# ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
|
|
@@ -99,6 +116,11 @@ fi
|
|
|
99
116
|
# ── public health-check. This validates DNS, the certificate, nginx routing,
|
|
100
117
|
# and the application together; a green deploy must mean the public API works.
|
|
101
118
|
BACKEND_DEPLOY_PHASE=public_health
|
|
119
|
+
if [ "$EDGE_MODE" != legacy ]; then
|
|
120
|
+
python3 "$DIR/edge_health.py" "$EDGE_CONFIG" "$APP" "$DOMAIN" "$HEALTH"
|
|
121
|
+
log "done"
|
|
122
|
+
exit 0
|
|
123
|
+
fi
|
|
102
124
|
PUBLIC_HEALTHY=""
|
|
103
125
|
for i in $(seq 1 15); do
|
|
104
126
|
CURL_EXIT=0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import configure_vhost
|
|
8
|
+
import edge_health
|
|
9
|
+
from test_edge_inputs import configured
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Health(unittest.TestCase):
|
|
13
|
+
def test_exact_edge_build_and_legacy_access_with_no_client_ingress_token(self):
|
|
14
|
+
calls = []
|
|
15
|
+
|
|
16
|
+
def fetch(url, *, headers=None):
|
|
17
|
+
calls.append((url, headers))
|
|
18
|
+
if url.startswith("https://edge.example") and (headers or {}).get(
|
|
19
|
+
edge_health.TOKEN_HEADER) != configured()["ingress_token"]:
|
|
20
|
+
return 0, 403, b""
|
|
21
|
+
return 0, 200, json.dumps({"build_sha": "a" * 40}).encode()
|
|
22
|
+
|
|
23
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
24
|
+
root = Path(directory)
|
|
25
|
+
config = root / ".backend-edge.json"
|
|
26
|
+
config.write_text(json.dumps(configured()))
|
|
27
|
+
(root / "backend-fixture.conf").write_text(configure_vhost.content("", "legacy.example", 32000))
|
|
28
|
+
result = edge_health.verify(config, "fixture", "edge.example", "/healthz",
|
|
29
|
+
nginx_dir=root, fetch=fetch)
|
|
30
|
+
self.assertTrue(result["ok"])
|
|
31
|
+
self.assertEqual(calls[-2:], [("https://legacy.example/healthz", None),
|
|
32
|
+
("https://app.pages.dev/api/healthz", None)])
|
|
33
|
+
self.assertEqual(len(calls), 5)
|
|
34
|
+
|
|
35
|
+
def test_redirect_partial_response_missing_build_and_mismatch_fail(self):
|
|
36
|
+
cases = [(0, 302, b""), (28, 200, b'{"build_sha":"' + b"a" * 40 + b'"}'),
|
|
37
|
+
(0, 200, b"{}"), (0, 200, b'{"build_sha":"wrong"}'), (0, 200, b"invalid")]
|
|
38
|
+
for response in cases:
|
|
39
|
+
calls = []
|
|
40
|
+
|
|
41
|
+
def fetch(url, **kwargs):
|
|
42
|
+
calls.append(url)
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
with self.subTest(response=response), self.assertRaises(ValueError):
|
|
46
|
+
edge_health.healthy("https://app.pages.dev/api/healthz", "a" * 40,
|
|
47
|
+
fetch=fetch, sleep=lambda _seconds: None)
|
|
48
|
+
self.assertEqual(len(calls), 15)
|
|
49
|
+
|
|
50
|
+
def test_origin_that_accepts_a_missing_token_fails_before_public_probe(self):
|
|
51
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
52
|
+
config = Path(directory) / ".backend-edge.json"
|
|
53
|
+
config.write_text(json.dumps(configured()))
|
|
54
|
+
with self.assertRaisesRegex(ValueError, "ingress_unverified"):
|
|
55
|
+
edge_health.verify(config, "fixture", "edge.example", "/healthz",
|
|
56
|
+
fetch=lambda *args, **kwargs: (0, 200, b"{}"))
|
|
57
|
+
|
|
58
|
+
def test_curl_keeps_token_out_of_argv_and_deletes_private_temporary_files(self):
|
|
59
|
+
retained = []
|
|
60
|
+
|
|
61
|
+
def run(command, **kwargs):
|
|
62
|
+
self.assertNotIn(configured()["ingress_token"], str(command))
|
|
63
|
+
config = Path(command[command.index("--config") + 1])
|
|
64
|
+
retained.append(config.parent)
|
|
65
|
+
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
|
|
66
|
+
self.assertIn(configured()["ingress_token"], config.read_text())
|
|
67
|
+
for unsafe in ("--insecure", "-k", "--location", "-L"):
|
|
68
|
+
self.assertNotIn(unsafe, command)
|
|
69
|
+
Path(command[command.index("--output") + 1]).write_text('{"build_sha":"' + "a" * 40 + '"}')
|
|
70
|
+
return subprocess.CompletedProcess(command, 0, stdout=b"200", stderr=b"")
|
|
71
|
+
|
|
72
|
+
result = edge_health.request("https://edge.example/healthz",
|
|
73
|
+
headers={edge_health.TOKEN_HEADER: configured()["ingress_token"]}, run=run)
|
|
74
|
+
self.assertEqual(result[:2], (0, 200))
|
|
75
|
+
self.assertFalse(retained[0].exists())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
import edge_inputs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configured(**changes):
|
|
13
|
+
return {"public_health_url": "https://app.pages.dev/api/healthz",
|
|
14
|
+
"public_base_url": "https://app.pages.dev/api", "expected_build_sha": "a" * 40,
|
|
15
|
+
"ingress_required": "true", "ingress_token": "synthetic-token-" + "a" * 43, **changes}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Inputs(unittest.TestCase):
|
|
19
|
+
def test_edge_and_legacy_configurations(self):
|
|
20
|
+
self.assertEqual(edge_inputs.mode(edge_inputs.validate(configured())), "edge")
|
|
21
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
22
|
+
value = edge_inputs.load(Path(directory) / "absent")
|
|
23
|
+
self.assertEqual(edge_inputs.mode(value), "legacy")
|
|
24
|
+
self.assertEqual(edge_inputs.mode(configured(ingress_required="false")), "verify")
|
|
25
|
+
|
|
26
|
+
def test_invalid_inputs_refuse_without_credential_output(self):
|
|
27
|
+
cases = [{"ingress_token": ""}, {"ingress_token": 'private\"; return 200;'},
|
|
28
|
+
{"ingress_required": "yes"}, {"expected_build_sha": "A" * 40},
|
|
29
|
+
{"public_health_url": "http://app.pages.dev/api/healthz"},
|
|
30
|
+
{"public_health_url": "https://other.pages.dev/api/healthz"},
|
|
31
|
+
{"public_base_url": "https://app.pages.dev/api/$TOKEN"},
|
|
32
|
+
{"public_base_url": ""}, {"expected_build_sha": ""},
|
|
33
|
+
{"public_health_url": "https://user:private@app.pages.dev/api/healthz"}]
|
|
34
|
+
for changes in cases:
|
|
35
|
+
with self.subTest(changes=changes), self.assertRaises(ValueError):
|
|
36
|
+
edge_inputs.validate(configured(**changes))
|
|
37
|
+
env = {"BACKEND_" + key.upper(): value for key, value in configured(ingress_token="private").items()}
|
|
38
|
+
result = subprocess.run([sys.executable, str(Path(edge_inputs.__file__)), "check"],
|
|
39
|
+
env={**os.environ, **env}, capture_output=True, text=True)
|
|
40
|
+
self.assertNotEqual(result.returncode, 0)
|
|
41
|
+
self.assertNotIn("private", result.stdout + result.stderr)
|
|
42
|
+
|
|
43
|
+
def test_runtime_input_is_private_and_separate_from_secret_dotenv(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
45
|
+
root = Path(directory)
|
|
46
|
+
config = root / edge_inputs.CONFIG_NAME
|
|
47
|
+
config.write_text(json.dumps(configured()))
|
|
48
|
+
secrets = root / ".runtime.env"
|
|
49
|
+
secrets.write_text("PASSWORD=synthetic-private\n")
|
|
50
|
+
edge_inputs.runtime(config)
|
|
51
|
+
output = root / ".backend-public.env"
|
|
52
|
+
self.assertEqual(output.read_text(), 'BACKEND_PUBLIC_BASE_URL="https://app.pages.dev/api"\n')
|
|
53
|
+
self.assertEqual(output.stat().st_mode & 0o777, 0o600)
|
|
54
|
+
self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
|
|
55
|
+
edge_inputs.runtime(config)
|
|
56
|
+
output.unlink()
|
|
57
|
+
output.symlink_to(secrets)
|
|
58
|
+
with self.assertRaises(ValueError):
|
|
59
|
+
edge_inputs.runtime(config)
|
|
60
|
+
self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
|
|
61
|
+
|
|
62
|
+
def test_private_input_receive_is_atomic_and_refuses_symlinks(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
64
|
+
path = Path(directory) / ".backend-edge.json"
|
|
65
|
+
command = [sys.executable, str(Path(edge_inputs.__file__)), "receive", str(path)]
|
|
66
|
+
value = json.dumps(configured())
|
|
67
|
+
result = subprocess.run(command, input=value, capture_output=True, text=True)
|
|
68
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
69
|
+
self.assertEqual(result.stdout, "")
|
|
70
|
+
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
|
71
|
+
invalid = subprocess.run(command, input="broken", capture_output=True, text=True)
|
|
72
|
+
self.assertNotEqual(invalid.returncode, 0)
|
|
73
|
+
self.assertEqual(json.loads(path.read_text()), configured())
|
|
74
|
+
other = Path(directory) / "preserved"
|
|
75
|
+
path.rename(other)
|
|
76
|
+
path.symlink_to(other)
|
|
77
|
+
refused = subprocess.run(command, input=value, capture_output=True, text=True)
|
|
78
|
+
self.assertNotEqual(refused.returncode, 0)
|
|
79
|
+
self.assertEqual(json.loads(other.read_text()), configured())
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Exercise generated ingress with an owned nginx process and real HTTP, WebSocket and SSE."""
|
|
2
|
+
from http.client import HTTPResponse
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
import edge_vhost
|
|
16
|
+
from nginx_test_fixture import BackendServer, LEGACY_VHOST, stop
|
|
17
|
+
from test_edge_inputs import configured
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Ingress(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.assertIsNotNone(shutil.which("nginx"), "nginx is required for the backend ingress contract")
|
|
23
|
+
directory = tempfile.TemporaryDirectory()
|
|
24
|
+
self.addCleanup(directory.cleanup)
|
|
25
|
+
self.root = Path(directory.name)
|
|
26
|
+
server = self.server = BackendServer()
|
|
27
|
+
self.addCleanup(server.server_close)
|
|
28
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
29
|
+
thread.start()
|
|
30
|
+
self.addCleanup(server.shutdown)
|
|
31
|
+
with socket.socket() as listener:
|
|
32
|
+
listener.bind(("127.0.0.1", 0))
|
|
33
|
+
self.port = listener.getsockname()[1]
|
|
34
|
+
self.config = self.root / ".backend-edge.json"
|
|
35
|
+
self.config.write_text(json.dumps(configured()))
|
|
36
|
+
legacy = self.root / "backend-fixture.conf"
|
|
37
|
+
legacy.write_text(LEGACY_VHOST.replace(":31000;", f":{server.server_port};"))
|
|
38
|
+
files = edge_vhost.planned(self.config, "fixture", "edge.example", server.server_port, self.root)
|
|
39
|
+
for path, (data, mode) in files.items():
|
|
40
|
+
path.write_bytes(data.replace(b"listen 80;", f"listen 127.0.0.1:{self.port};".encode()))
|
|
41
|
+
path.chmod(mode)
|
|
42
|
+
nginx = (f"pid {self.root}/nginx.pid;\nerror_log {self.root}/error.log;\n"
|
|
43
|
+
"events { worker_connections 64; }\nhttp { access_log off;\n"
|
|
44
|
+
f"include {self.root}/backend-fixture.conf;\n"
|
|
45
|
+
f"include {self.root}/backend-fixture-edge.conf;\n}}\n")
|
|
46
|
+
(self.root / "nginx.conf").write_text(nginx)
|
|
47
|
+
process = subprocess.Popen(["nginx", "-p", str(self.root) + "/", "-c", "nginx.conf",
|
|
48
|
+
"-g", "daemon off;"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
49
|
+
self.addCleanup(stop, process)
|
|
50
|
+
for _ in range(50):
|
|
51
|
+
try:
|
|
52
|
+
self.fetch("legacy.example")
|
|
53
|
+
break
|
|
54
|
+
except URLError:
|
|
55
|
+
self.assertIsNone(process.poll(), "owned nginx did not start")
|
|
56
|
+
time.sleep(0.05)
|
|
57
|
+
else:
|
|
58
|
+
self.fail("owned nginx did not become ready")
|
|
59
|
+
|
|
60
|
+
def fetch(self, host, token=None):
|
|
61
|
+
headers = {"Host": host}
|
|
62
|
+
if token is not None:
|
|
63
|
+
headers["X-App-Robot-Backend-Token"] = token
|
|
64
|
+
request = Request(f"http://127.0.0.1:{self.port}/healthz?probe=owned", headers=headers)
|
|
65
|
+
try:
|
|
66
|
+
with urlopen(request, timeout=2) as response:
|
|
67
|
+
return response.status, json.load(response)
|
|
68
|
+
except HTTPError as error:
|
|
69
|
+
with error:
|
|
70
|
+
return error.code, None
|
|
71
|
+
|
|
72
|
+
def test_real_nginx_rejects_missing_and_forged_tokens_and_retains_legacy_access(self):
|
|
73
|
+
self.assertEqual(self.fetch("edge.example"), (403, None))
|
|
74
|
+
self.assertEqual(self.fetch("edge.example", "wrong-token"), (403, None))
|
|
75
|
+
for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
|
|
76
|
+
status, body = self.fetch(host, token)
|
|
77
|
+
self.assertEqual(status, 200)
|
|
78
|
+
self.assertEqual(body["build_sha"], "a" * 40)
|
|
79
|
+
self.assertEqual(body["path"], "/healthz?probe=owned")
|
|
80
|
+
self.assertFalse(body["ingress_received"])
|
|
81
|
+
self.assertIsNone(body["upgrade"])
|
|
82
|
+
self.assertEqual(body["connection"], "close")
|
|
83
|
+
|
|
84
|
+
def test_real_nginx_exchanges_websocket_frames_and_enforces_ingress(self):
|
|
85
|
+
cases = (("edge.example", None, 403), ("edge.example", "wrong-token", 403),
|
|
86
|
+
("edge.example", configured()["ingress_token"], 101), ("legacy.example", None, 101))
|
|
87
|
+
for host, token, status in cases:
|
|
88
|
+
with self.subTest(host=host, status=status), socket.create_connection(("127.0.0.1", self.port), 2) as peer:
|
|
89
|
+
request = (f"GET /ws HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
|
|
90
|
+
"Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n"
|
|
91
|
+
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n")
|
|
92
|
+
if token is not None:
|
|
93
|
+
request += f"X-App-Robot-Backend-Token: {token}\r\n"
|
|
94
|
+
peer.sendall((request + "\r\n").encode())
|
|
95
|
+
with HTTPResponse(peer) as response:
|
|
96
|
+
response.begin()
|
|
97
|
+
self.assertEqual(response.status, status)
|
|
98
|
+
if status != 101:
|
|
99
|
+
continue
|
|
100
|
+
self.assertEqual(response.getheader("Upgrade").lower(), "websocket")
|
|
101
|
+
self.assertEqual(response.getheader("Sec-WebSocket-Accept"), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
|
|
102
|
+
payload, mask = b"ping-through-nginx", b"\x01\x02\x03\x04"
|
|
103
|
+
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
104
|
+
peer.sendall(bytes((0x81, 0x80 | len(payload))) + mask + masked)
|
|
105
|
+
self.assertEqual(response.fp.read(2), bytes((0x81, len(payload))))
|
|
106
|
+
self.assertEqual(response.fp.read(len(payload)), payload)
|
|
107
|
+
|
|
108
|
+
def test_real_nginx_delivers_sse_before_upstream_completion(self):
|
|
109
|
+
for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
|
|
110
|
+
with self.subTest(host=host):
|
|
111
|
+
self.server.stream_release.clear()
|
|
112
|
+
self.server.stream_finished.clear()
|
|
113
|
+
headers = {"Host": host}
|
|
114
|
+
if token is not None:
|
|
115
|
+
headers["X-App-Robot-Backend-Token"] = token
|
|
116
|
+
request = Request(f"http://127.0.0.1:{self.port}/stream", headers=headers)
|
|
117
|
+
try:
|
|
118
|
+
with urlopen(request, timeout=2) as response:
|
|
119
|
+
self.assertEqual(response.readline(), b"data: first\n")
|
|
120
|
+
self.assertFalse(self.server.stream_finished.is_set())
|
|
121
|
+
self.server.stream_release.set()
|
|
122
|
+
self.assertEqual(response.read(), b"\ndata: second\n\n")
|
|
123
|
+
finally:
|
|
124
|
+
self.server.stream_release.set()
|
|
125
|
+
self.assertTrue(self.server.stream_finished.wait(2))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import configure_vhost
|
|
8
|
+
import edge_vhost
|
|
9
|
+
from test_edge_inputs import configured
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Vhosts(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.directory = tempfile.TemporaryDirectory()
|
|
15
|
+
self.addCleanup(self.directory.cleanup)
|
|
16
|
+
self.root = Path(self.directory.name)
|
|
17
|
+
self.config = self.root / ".backend-edge.json"
|
|
18
|
+
self.config.write_text(json.dumps(configured()))
|
|
19
|
+
self.legacy, self.edge, self.include = edge_vhost.paths(self.config, "fixture", self.root)
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def run_nginx(self, command, **kwargs):
|
|
23
|
+
self.calls.append(command)
|
|
24
|
+
|
|
25
|
+
def test_legacy_tls_port_changes_and_replay(self):
|
|
26
|
+
original = configure_vhost.content("", "legacy.example", 31000).replace(
|
|
27
|
+
"listen 80;", "listen 443 ssl;\n ssl_certificate /existing/cert;")
|
|
28
|
+
self.legacy.write_text(original)
|
|
29
|
+
self.legacy.chmod(0o640)
|
|
30
|
+
for port in (32000, 33000, 33000):
|
|
31
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", port, self.root, self.run_nginx)
|
|
32
|
+
self.assertEqual(self.legacy.read_text(), original.replace(":31000;", f":{port};"))
|
|
33
|
+
self.assertEqual(self.legacy.stat().st_mode & 0o777, 0o640)
|
|
34
|
+
self.assertIn(f":{port};", self.edge.read_text())
|
|
35
|
+
self.assertNotIn("backend-ingress", self.legacy.read_text())
|
|
36
|
+
self.assertEqual(self.calls, [["nginx", "-t"], ["systemctl", "reload", "nginx"]] * 2)
|
|
37
|
+
self.assertEqual(self.include.stat().st_mode & 0o777, 0o600)
|
|
38
|
+
self.assertNotIn(configured()["ingress_token"], self.edge.read_text())
|
|
39
|
+
|
|
40
|
+
def test_new_app_has_no_public_legacy_vhost_and_cannot_downgrade(self):
|
|
41
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
|
|
42
|
+
self.assertFalse(self.legacy.exists())
|
|
43
|
+
self.config.write_text(json.dumps(configured(ingress_required="false", ingress_token="")))
|
|
44
|
+
with self.assertRaisesRegex(ValueError, "downgrade"):
|
|
45
|
+
edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
|
|
46
|
+
self.config.unlink()
|
|
47
|
+
with self.assertRaisesRegex(ValueError, "downgrade"):
|
|
48
|
+
edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
|
|
49
|
+
|
|
50
|
+
def test_domain_collision_and_ambiguous_legacy_refuse_without_changes(self):
|
|
51
|
+
for original in (configure_vhost.content("", "edge.example", 31000),
|
|
52
|
+
"server {\n server_name ambiguous.example another.example;\n}"):
|
|
53
|
+
self.legacy.write_text(original)
|
|
54
|
+
with self.assertRaises(ValueError):
|
|
55
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
|
|
56
|
+
self.assertEqual(self.legacy.read_text(), original)
|
|
57
|
+
self.assertFalse(self.edge.exists())
|
|
58
|
+
self.assertFalse(self.include.exists())
|
|
59
|
+
self.assertEqual(self.calls, [])
|
|
60
|
+
|
|
61
|
+
def test_failed_nginx_check_restores_only_the_owned_files(self):
|
|
62
|
+
original = configure_vhost.content("", "legacy.example", 31000)
|
|
63
|
+
self.legacy.write_text(original)
|
|
64
|
+
|
|
65
|
+
def failure(command, **kwargs):
|
|
66
|
+
raise subprocess.CalledProcessError(1, command, stderr=b"private-output")
|
|
67
|
+
|
|
68
|
+
with self.assertRaisesRegex(ValueError, "backend_edge_nginx_failed"):
|
|
69
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, failure)
|
|
70
|
+
self.assertEqual(self.legacy.read_text(), original)
|
|
71
|
+
self.assertFalse(self.edge.exists())
|
|
72
|
+
self.assertFalse(self.include.exists())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# App-owned Cloudflare API ingress
|
|
2
|
+
|
|
3
|
+
Provision the app's Pages/Workers route and its per-app ingress token before deploying the backend.
|
|
4
|
+
`BACKEND_API_DOMAIN` remains a distinct infrastructure hostname pointing to the deploy host; never
|
|
5
|
+
set it to a Pages/Workers hostname. Set these repository values for edge delivery:
|
|
6
|
+
|
|
7
|
+
- `BACKEND_PUBLIC_BASE_URL`: the client API base, for example `https://app.pages.dev/api`.
|
|
8
|
+
- `BACKEND_PUBLIC_HEALTH_URL`: that base plus the backend health path, for example `/healthz`.
|
|
9
|
+
- `BACKEND_INGRESS_REQUIRED`: `true`.
|
|
10
|
+
- Secret `BACKEND_INGRESS_TOKEN`: the same 43–128 character URL-safe token the edge sends in
|
|
11
|
+
`X-App-Robot-Backend-Token`; never ship it in clients or app source.
|
|
12
|
+
|
|
13
|
+
The workflow supplies the exact commit SHA, and the public health endpoint must return JSON
|
|
14
|
+
with `build_sha` equal to that SHA. A completed HTTPS 2xx is required; redirects, partial transfers,
|
|
15
|
+
missing identity and another build fail deployment. The same check rejects missing/incorrect tokens
|
|
16
|
+
at the infrastructure hostname, verifies authenticated origin health and probes the public URL
|
|
17
|
+
without passing the token. The edge must add it itself.
|
|
18
|
+
|
|
19
|
+
The action adds `BACKEND_PUBLIC_BASE_URL` to Compose interpolation through a separate host-side
|
|
20
|
+
dotenv file, after `.env` and `.runtime.env`. The app's Compose service must explicitly consume it:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
environment:
|
|
24
|
+
BACKEND_PUBLIC_BASE_URL: ${BACKEND_PUBLIC_BASE_URL:?required}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use that value for redirects and generated API links. Preserve the action's private runtime files;
|
|
28
|
+
the ingress token is transported on SSH stdin and used in a mode-0600 nginx include, not CLI arguments.
|
|
29
|
+
Ingress configuration and public dotenv live in `/opt/gowalk-backend-ingress/<app>/`, outside the
|
|
30
|
+
application's Docker build context; broad application COPY instructions cannot include the token.
|
|
31
|
+
The new protected vhost is `backend-<app>-edge.conf`. An existing `backend-<app>.conf` keeps its
|
|
32
|
+
legacy domain and TLS configuration, and both upstream ports follow Compose on every deployment.
|
|
33
|
+
The old public hostname is health-checked without authentication so released clients remain usable.
|
|
34
|
+
An edge alias matching the existing legacy hostname is refused; provision a new alias instead.
|
|
35
|
+
Missing ingress settings on an already protected app cannot reopen its origin.
|
|
36
|
+
|
|
37
|
+
Managed backend locations forward WebSocket upgrades and disable response buffering so SSE and
|
|
38
|
+
streaming responses arrive incrementally. The Connection header becomes `upgrade` only when the
|
|
39
|
+
request carries an Upgrade header; ordinary HTTP requests use `close`. Each vhost owns a distinct
|
|
40
|
+
connection map, so protected and legacy domains coexist. Deployment updates existing locations
|
|
41
|
+
while preserving their domains, TLS settings and ingress checks.
|
|
42
|
+
|
|
43
|
+
These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
|
|
44
|
+
`public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
|
|
45
|
+
by name. Deployments without these options retain public origin access, certificate handling and health checks.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -19,6 +19,11 @@ mobile:
|
|
|
19
19
|
backend:
|
|
20
20
|
host: ${{ vars.BACKEND_DEPLOY_HOST }}
|
|
21
21
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
22
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
23
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
24
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
25
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
26
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
22
27
|
workflows:
|
|
23
28
|
deploy.yml:
|
|
24
29
|
overrides: []
|
|
@@ -30,6 +35,12 @@ until the app explicitly replaces their references in this file. Keep passwords,
|
|
|
30
35
|
signing material, tokens and private keys in GitHub Secrets. Reference their names;
|
|
31
36
|
never put their values in this configuration.
|
|
32
37
|
|
|
38
|
+
Backend edge settings are optional and preserve legacy deployments when absent. When enabled,
|
|
39
|
+
the infrastructure `api-domain` must be a fresh alias distinct from the retained legacy hostname.
|
|
40
|
+
Provision its Cloudflare route/token first. Public health proves the exact `build_sha`; the app's
|
|
41
|
+
Compose service must pass `BACKEND_PUBLIC_BASE_URL` into its container and use it in generated URLs.
|
|
42
|
+
The same named repository inputs reach the feature-branch backend preview in `deploy.yml`.
|
|
43
|
+
|
|
33
44
|
The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
|
|
34
45
|
`deploy-web.yml` and `deploy-backend.yml`. App-specific values outside the named
|
|
35
46
|
mobile/backend inputs, and custom jobs or steps embedded in those workflows,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.149",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"CLAUDE.md",
|
|
24
24
|
"docs/GRADLE_CACHE.md",
|
|
25
25
|
"docs/CANDIDATE_ARTIFACTS.md",
|
|
26
|
+
"docs/BACKEND_EDGE.md",
|
|
26
27
|
"scripts/upgrade-org.mjs",
|
|
27
28
|
"scripts/lib/",
|
|
28
29
|
"docs/CONFIGURATION.md"
|
package/src/config/settings.mjs
CHANGED
|
@@ -8,7 +8,8 @@ const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'wit
|
|
|
8
8
|
const inputs = {
|
|
9
9
|
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'],
|
|
10
10
|
android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
|
|
11
|
-
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env'
|
|
11
|
+
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
|
|
12
|
+
'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export function settingsPaths(file) {
|
|
@@ -72,4 +72,9 @@ jobs:
|
|
|
72
72
|
backend-dir: ${{ steps.dir.outputs.path }}
|
|
73
73
|
compose-file: ${{ steps.dir.outputs.compose }}
|
|
74
74
|
health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
|
|
75
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
76
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
77
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
78
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
79
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
75
80
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
package/templates/deploy.yml
CHANGED
|
@@ -714,4 +714,9 @@ jobs:
|
|
|
714
714
|
backend-dir: ${{ steps.dir.outputs.path }}
|
|
715
715
|
compose-file: ${{ steps.dir.outputs.compose }}
|
|
716
716
|
health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
|
|
717
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
718
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
719
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
720
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
721
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
717
722
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
Binary file
|