gowalk-cicd 1.0.175 → 1.0.176
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 +3 -0
- package/backend-action/action.yml +1 -0
- package/backend-action/edge_vhost.py +44 -32
- package/backend-action/vhost_diagnostics.py +90 -0
- package/docs/BACKEND_EDGE.md +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -521,6 +521,9 @@ The backend message is `gowalk-cicd/backend-deploy-failed.v1 phase=<phase> exit_
|
|
|
521
521
|
fixed fields without JSON braces, which GitHub can mask when a multiline secret has brace-only lines.
|
|
522
522
|
Consumers validate the complete envelope, the known phase and exit code 1–255; older JSON messages
|
|
523
523
|
remain readable by the panel. Masked fields are never reconstructed and credential masking stays enabled.
|
|
524
|
+
Protected vhost failures add `backend_vhost_failed` (`gowalk-cicd/backend-vhost-failed.v1`) with fixed
|
|
525
|
+
brace-free `operation`, `stage`, `reason`, `child_exit` and `signals`; see `docs/BACKEND_EDGE.md`.
|
|
526
|
+
It preserves the existing refusal and records only bounded observed facts, never private child output.
|
|
524
527
|
An interrupted locked Pod fetch can retry once within the existing 900-second install budget.
|
|
525
528
|
`native_pods_retry` (`gowalk-cicd/native-pods-retry.v1`) reports `attempt:2` and `reason:partial_transfer`;
|
|
526
529
|
successful preparation reports `pod_attempts`. Dependency locks, relay and TLS checks remain enforced.
|
|
@@ -207,6 +207,7 @@ runs:
|
|
|
207
207
|
"${{ github.action_path }}/runtime_env.py" \
|
|
208
208
|
"${{ github.action_path }}/runtime_env_parse.py" \
|
|
209
209
|
"${{ github.action_path }}/edge_vhost.py" \
|
|
210
|
+
"${{ github.action_path }}/vhost_diagnostics.py" \
|
|
210
211
|
"${{ github.action_path }}/edge_health.py" \
|
|
211
212
|
"${{ github.action_path }}/deploy_evidence.sh" \
|
|
212
213
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
from configure_vhost import content, replace
|
|
9
9
|
from edge_inputs import load
|
|
10
|
+
from vhost_diagnostics import observing
|
|
10
11
|
|
|
11
12
|
NGINX_DIR = Path("/etc/nginx/conf.d")
|
|
12
13
|
|
|
@@ -70,56 +71,67 @@ def edge_content(text, domain, port, include, stage="update"):
|
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
def planned(config, app, domain, port, nginx_dir=NGINX_DIR, stage="update"):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
with observing("validate", stage):
|
|
75
|
+
value = validate(config, app, domain, nginx_dir)
|
|
76
|
+
with observing("plan", stage):
|
|
77
|
+
if value["ingress_required"] != "true":
|
|
78
|
+
raise ValueError("backend_ingress_configuration_required")
|
|
79
|
+
legacy, edge, include = paths(config, app, nginx_dir)
|
|
80
|
+
token = value["ingress_token"]
|
|
81
|
+
guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
|
|
82
|
+
'proxy_set_header X-App-Robot-Backend-Token "";\n')
|
|
83
|
+
files = {include: (guard.encode(), 0o600),
|
|
84
|
+
edge: (edge_content(previous(edge), domain, port, include, stage).encode(), 0o644)}
|
|
85
|
+
old_domain = recorded_domain(legacy)
|
|
86
|
+
if old_domain:
|
|
87
|
+
files[legacy] = (content(previous(legacy), old_domain, port).encode(),
|
|
88
|
+
stat.S_IMODE(legacy.stat().st_mode))
|
|
89
|
+
return files
|
|
87
90
|
|
|
88
91
|
|
|
89
92
|
def configure(config, app, domain, port, nginx_dir=NGINX_DIR, run=subprocess.run, stage="update"):
|
|
90
93
|
files = planned(config, app, domain, port, nginx_dir, stage)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
with observing("plan", stage):
|
|
95
|
+
saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
|
|
96
|
+
for path in files}
|
|
97
|
+
changes = {path: value for path, value in files.items() if saved[path] != value}
|
|
94
98
|
if not changes:
|
|
95
99
|
return "unchanged"
|
|
96
100
|
try:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
with observing("write", stage):
|
|
102
|
+
for path, (data, mode) in changes.items():
|
|
103
|
+
replace(path, data, mode)
|
|
104
|
+
with observing("nginx_test", stage):
|
|
105
|
+
run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
|
|
106
|
+
with observing("nginx_reload", stage):
|
|
107
|
+
run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
|
|
101
108
|
except (OSError, subprocess.SubprocessError):
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
path
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
with observing("rollback", stage):
|
|
110
|
+
for path in changes:
|
|
111
|
+
if saved[path] is None:
|
|
112
|
+
path.unlink(missing_ok=True)
|
|
113
|
+
else:
|
|
114
|
+
replace(path, *saved[path])
|
|
107
115
|
raise ValueError("backend_edge_nginx_failed") from None
|
|
108
116
|
return "changed"
|
|
109
117
|
|
|
110
118
|
|
|
111
119
|
def main():
|
|
112
|
-
|
|
120
|
+
stage = (sys.argv[6:7] or ["update"])[0]
|
|
121
|
+
with observing("validate", stage):
|
|
122
|
+
operation, config, app, domain = sys.argv[1:5]
|
|
123
|
+
if operation not in {"validate", "configure"}:
|
|
124
|
+
raise ValueError("backend_edge_operation_invalid")
|
|
125
|
+
port = int(sys.argv[5]) if operation == "configure" else None
|
|
113
126
|
if operation == "validate":
|
|
114
|
-
validate
|
|
127
|
+
with observing("validate", stage):
|
|
128
|
+
validate(config, app, domain)
|
|
115
129
|
elif operation == "configure":
|
|
116
|
-
print(configure(config, app, domain,
|
|
117
|
-
else:
|
|
118
|
-
raise ValueError("backend_edge_operation_invalid")
|
|
130
|
+
print(configure(config, app, domain, port, stage=stage))
|
|
119
131
|
|
|
120
132
|
|
|
121
133
|
if __name__ == "__main__":
|
|
122
134
|
try:
|
|
123
135
|
main()
|
|
124
|
-
except (OSError, ValueError):
|
|
136
|
+
except (OSError, ValueError, IndexError):
|
|
125
137
|
sys.exit("backend_edge_vhost_refused")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Fixed vhost failure facts; private child output and exception text never leave this process."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
import errno
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
OPERATIONS = frozenset({"validate", "plan", "write", "nginx_test", "nginx_reload", "rollback"})
|
|
8
|
+
STAGES = frozenset({"update", "acme", "tls"})
|
|
9
|
+
REFUSALS = frozenset({
|
|
10
|
+
"backend_app_invalid", "backend_vhost_symlink", "backend_vhost_identity_invalid",
|
|
11
|
+
"backend_ingress_downgrade_refused", "backend_edge_domain_must_be_distinct", "backend_edge_domain_changed",
|
|
12
|
+
"backend_ingress_path_invalid", "backend_edge_vhost_invalid", "backend_edge_location_invalid",
|
|
13
|
+
"backend_ingress_configuration_required", "backend_edge_operation_invalid", "backend_edge_inputs_invalid",
|
|
14
|
+
"backend_ingress_required_invalid", "backend_build_sha_invalid", "backend_public_health_invalid",
|
|
15
|
+
"backend_public_base_invalid", "backend_public_health_base_mismatch", "backend_edge_config_symlink",
|
|
16
|
+
"backend_edge_config_invalid",
|
|
17
|
+
})
|
|
18
|
+
CONTENT_REFUSALS = {
|
|
19
|
+
"invalid domain, port or stage": "input_invalid",
|
|
20
|
+
"vhost belongs to another domain": "vhost_identity_invalid",
|
|
21
|
+
"managed transport directive is not unique": "transport_ambiguous",
|
|
22
|
+
"managed connection map is not recognized": "connection_map_invalid",
|
|
23
|
+
"managed upstream is not unique": "upstream_ambiguous",
|
|
24
|
+
}
|
|
25
|
+
FILESYSTEM = {
|
|
26
|
+
errno.ENOENT: "file_missing", errno.EACCES: "permission_denied", errno.EPERM: "permission_denied",
|
|
27
|
+
errno.EROFS: "filesystem_readonly", errno.ENOSPC: "storage_full", errno.EIO: "io_failure",
|
|
28
|
+
}
|
|
29
|
+
NGINX_SIGNALS = (
|
|
30
|
+
("certificate_load", ("cannot load certificate",)),
|
|
31
|
+
("certificate_key_mismatch", ("key values mismatch",)),
|
|
32
|
+
("configuration_syntax", ("unknown directive", "unexpected end of file", "invalid number of arguments",
|
|
33
|
+
"is not allowed here", "unexpected \"}", "unexpected \";")),
|
|
34
|
+
("listener_in_use", ("address already in use",)),
|
|
35
|
+
("permission_denied", ("permission denied",)),
|
|
36
|
+
("upstream_resolution", ("host not found in upstream",)),
|
|
37
|
+
)
|
|
38
|
+
OUTPUT_LIMIT = 16 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def classify(error):
|
|
42
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
43
|
+
return "timeout", "none"
|
|
44
|
+
if isinstance(error, subprocess.CalledProcessError):
|
|
45
|
+
code = error.returncode
|
|
46
|
+
return "command_failed", str(code) if type(code) is int and -255 <= code <= 255 and code else "none"
|
|
47
|
+
if isinstance(error, OSError):
|
|
48
|
+
code = error.errno
|
|
49
|
+
return FILESYSTEM.get(code, "unclassified") if type(code) is int else "unclassified", "none"
|
|
50
|
+
message = error.args[0] if len(error.args) == 1 and type(error.args[0]) is str else ""
|
|
51
|
+
message = message if len(message) <= 96 else ""
|
|
52
|
+
return message if message in REFUSALS else CONTENT_REFUSALS.get(message, "unclassified"), "none"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def nginx_signals(error, operation):
|
|
56
|
+
if operation != "nginx_test" or not isinstance(error, subprocess.CalledProcessError):
|
|
57
|
+
return "none"
|
|
58
|
+
output = error.stderr
|
|
59
|
+
if type(output) is bytes:
|
|
60
|
+
output = output[:OUTPUT_LIMIT].decode("utf-8", errors="replace")
|
|
61
|
+
elif type(output) is str:
|
|
62
|
+
output = output[:OUTPUT_LIMIT].encode("utf-8", errors="replace")[:OUTPUT_LIMIT]
|
|
63
|
+
output = output.decode("utf-8", errors="replace")
|
|
64
|
+
else:
|
|
65
|
+
return "none"
|
|
66
|
+
output = output.lower()
|
|
67
|
+
signals = [code for code, patterns in NGINX_SIGNALS if any(pattern in output for pattern in patterns)]
|
|
68
|
+
return ",".join(signals) or "none"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def report(operation, stage, error):
|
|
72
|
+
operation = operation if type(operation) is str and operation in OPERATIONS else "unclassified"
|
|
73
|
+
stage = stage if type(stage) is str and stage in STAGES else "unclassified"
|
|
74
|
+
reason, child_exit = classify(error)
|
|
75
|
+
signals = nginx_signals(error, operation)
|
|
76
|
+
message = ("::error title=backend_vhost_failed::gowalk-cicd/backend-vhost-failed.v1 "
|
|
77
|
+
"operation={} stage={} reason={} child_exit={} signals={}\n")
|
|
78
|
+
try:
|
|
79
|
+
sys.stderr.write(message.format(operation, stage, reason, child_exit, signals))
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
pass # Diagnostic delivery must not replace the original refusal.
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def observing(operation, stage):
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
except (OSError, ValueError, IndexError, subprocess.SubprocessError) as error:
|
|
89
|
+
report(operation, stage, error)
|
|
90
|
+
raise
|
package/docs/BACKEND_EDGE.md
CHANGED
|
@@ -44,3 +44,16 @@ while preserving their domains, TLS settings and ingress checks.
|
|
|
44
44
|
These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
|
|
45
45
|
`public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
|
|
46
46
|
by name. Deployments without these options retain public origin access, certificate handling and health checks.
|
|
47
|
+
|
|
48
|
+
Edge-vhost refusals also emit the fixed stderr annotation `backend_vhost_failed` with schema
|
|
49
|
+
`gowalk-cicd/backend-vhost-failed.v1`. Its brace-free fields are `operation`, `stage`, `reason`,
|
|
50
|
+
`child_exit` and `signals`. Operations are validate, plan, write, nginx_test, nginx_reload and rollback;
|
|
51
|
+
stages are update, acme and tls. Unknown metadata or reasons remain unclassified. `child_exit` is the
|
|
52
|
+
observed nonzero child status (-255 to 255), or `none`; it does not change the CLI's failed exit 1.
|
|
53
|
+
Reasons retain allowlisted planning refusals or fixed filesystem, timeout and command-failure facts.
|
|
54
|
+
Signals inspect only the first 16 KiB of failed nginx-test stderr and emit certificate_load,
|
|
55
|
+
certificate_key_mismatch, configuration_syntax, listener_in_use, permission_denied or upstream_resolution.
|
|
56
|
+
They are observed patterns, not a unique cause or retry permission. No raw error, command, host, path,
|
|
57
|
+
token or child output is included. A failed restoration adds a separate rollback refusal while keeping
|
|
58
|
+
the original failure visible; absence of that extra line alone does not prove successful restoration.
|
|
59
|
+
The generic `backend_edge_vhost_refused`, changed/unchanged stdout, ingress, TLS and deploy gates remain.
|