gowalk-cicd 1.0.190 → 1.0.192
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 +9 -0
- package/backend-action/action.yml +30 -3
- package/backend-action/install_runtime.py +160 -0
- package/backend-action/runtime_diagnostics.py +63 -0
- package/backend-action/runtime_projection.py +118 -0
- package/backend-action/runtime_readback.py +163 -0
- package/backend-action/ssh_gate.py +7 -0
- package/docs/BACKEND_SSH_GATE.md +53 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1363,3 +1363,12 @@ Its `result.json` contains the validated envelope on success and classified fail
|
|
|
1363
1363
|
headers, environment values, provider exceptions or identities. Normal GitHub artifact integrity checks
|
|
1364
1364
|
apply. The console copy is supplementary: secret masking can replace JSON punctuation in logs.
|
|
1365
1365
|
Each invocation uses a fresh local directory so a failed reader cannot publish a prior result.
|
|
1366
|
+
|
|
1367
|
+
### Applied backend runtime diagnostics
|
|
1368
|
+
|
|
1369
|
+
The backend action's `operation: runtime-diagnostics` reads the exact app project and expected deployed SHA,
|
|
1370
|
+
returning actual service count, effective limits, two bounded resource samples and private database-binding
|
|
1371
|
+
provenance. Call it from the app's read-only diagnostic workflow with `diagnostic-service` and
|
|
1372
|
+
`expected-build-sha`, using its existing host/key/pin inputs. It requires the released host forced command as well
|
|
1373
|
+
as the updated action. See [the scoped runtime contract and host installer](docs/BACKEND_SSH_GATE.md#applied-runtime-readback).
|
|
1374
|
+
Identity failure logs and source Compose files do not establish this applied state.
|
|
@@ -7,11 +7,11 @@ description: >
|
|
|
7
7
|
|
|
8
8
|
inputs:
|
|
9
9
|
operation:
|
|
10
|
-
description: deploy (default) or
|
|
10
|
+
description: deploy (default), identity-diagnostics or runtime-diagnostics.
|
|
11
11
|
required: false
|
|
12
12
|
default: deploy
|
|
13
13
|
diagnostic-service:
|
|
14
|
-
description: Exact Compose service
|
|
14
|
+
description: Exact Compose service for identity logs or runtime build/database binding proof.
|
|
15
15
|
required: false
|
|
16
16
|
default: api
|
|
17
17
|
diagnostic-since:
|
|
@@ -106,7 +106,7 @@ runs:
|
|
|
106
106
|
API_DOMAIN: ${{ inputs.api-domain }}
|
|
107
107
|
DEPLOY_HOST: ${{ inputs.host }}
|
|
108
108
|
run: |
|
|
109
|
-
case "$OPERATION" in deploy|identity-diagnostics) ;; *) exit 1;; esac
|
|
109
|
+
case "$OPERATION" in deploy|identity-diagnostics|runtime-diagnostics) ;; *) exit 1;; esac
|
|
110
110
|
# Only a repository with a bound origin holds the deploy key; the panel sets the domain when it binds one.
|
|
111
111
|
if [ "$OPERATION" = deploy ] && [ -z "$API_DOMAIN" ]; then
|
|
112
112
|
echo "::notice title=backend_origin_unbound::No api-domain: the backend origin is not bound yet, nothing deployed"
|
|
@@ -279,6 +279,33 @@ runs:
|
|
|
279
279
|
path: ${{ steps.identity_diagnostics.outputs.result }}
|
|
280
280
|
if-no-files-found: error
|
|
281
281
|
|
|
282
|
+
- name: Read scoped backend runtime
|
|
283
|
+
id: runtime_diagnostics
|
|
284
|
+
if: inputs.operation == 'runtime-diagnostics'
|
|
285
|
+
shell: bash
|
|
286
|
+
env:
|
|
287
|
+
DIAGNOSTIC_APP: ${{ inputs.app-name }}
|
|
288
|
+
DIAGNOSTIC_SERVICE: ${{ inputs.diagnostic-service }}
|
|
289
|
+
DIAGNOSTIC_HOST: ${{ inputs.host }}
|
|
290
|
+
DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
|
|
291
|
+
DIAGNOSTIC_BUILD: ${{ inputs.expected-build-sha }}
|
|
292
|
+
run: |
|
|
293
|
+
RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-runtime-diagnostics.XXXXXX")"
|
|
294
|
+
echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
|
|
295
|
+
python3 "${{ github.action_path }}/runtime_diagnostics.py" \
|
|
296
|
+
--app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --expected-build-sha "$DIAGNOSTIC_BUILD" \
|
|
297
|
+
--host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" --output "$RESULT_DIR/result.json"
|
|
298
|
+
|
|
299
|
+
- name: Retain scoped backend runtime result
|
|
300
|
+
if: always() && inputs.operation == 'runtime-diagnostics'
|
|
301
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
302
|
+
env:
|
|
303
|
+
NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
|
|
304
|
+
with:
|
|
305
|
+
name: backend-runtime-diagnostics-${{ github.job }}-${{ github.run_attempt }}
|
|
306
|
+
path: ${{ steps.runtime_diagnostics.outputs.result }}
|
|
307
|
+
if-no-files-found: error
|
|
308
|
+
|
|
282
309
|
- name: Cleanup key
|
|
283
310
|
if: always()
|
|
284
311
|
shell: bash
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Install an integrity-verified published runtime and retarget only named panel forced commands."""
|
|
2
|
+
import argparse
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path('/opt/gowalk-cicd-runtime')
|
|
16
|
+
AUTHORIZED_KEYS = Path('/root/.ssh/authorized_keys')
|
|
17
|
+
ROOT_UID = 0
|
|
18
|
+
REQUIRED = (
|
|
19
|
+
'ssh_gate.py', 'native_profile.py', 'native_deploy.py', 'runtime_host.py', 'runtime_launch.py',
|
|
20
|
+
'runtime_model.py', 'runtime_readback.py', 'runtime_projection.py', 'identity_diagnostics.py',
|
|
21
|
+
'runtime_env.py', 'runtime_env_parse.py', 'edge_inputs.py', 'edge_vhost.py', 'edge_health.py',
|
|
22
|
+
'configure_vhost.py', 'vhost_diagnostics.py', 'remote_deploy.sh', 'deploy_evidence.sh',
|
|
23
|
+
'ensure_certificate.sh', 'select_certbot_account.sh',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def archive_files(archive, integrity, version):
|
|
28
|
+
if not re.fullmatch(r'\d+\.\d+\.\d+', version) or not re.fullmatch(r'sha512-[A-Za-z0-9+/]+=*', integrity):
|
|
29
|
+
raise ValueError('release_identity_invalid')
|
|
30
|
+
if archive.stat().st_size > 20 * 1024 * 1024:
|
|
31
|
+
raise ValueError('release_archive_too_large')
|
|
32
|
+
raw = archive.read_bytes()
|
|
33
|
+
actual = 'sha512-' + base64.b64encode(hashlib.sha512(raw).digest()).decode()
|
|
34
|
+
if actual != integrity:
|
|
35
|
+
raise ValueError('release_integrity_mismatch')
|
|
36
|
+
with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as source:
|
|
37
|
+
metadata = json.load(source.extractfile('package/package.json'))
|
|
38
|
+
if metadata.get('name') != 'gowalk-cicd' or metadata.get('version') != version:
|
|
39
|
+
raise ValueError('release_identity_mismatch')
|
|
40
|
+
result = {}
|
|
41
|
+
for member in source.getmembers():
|
|
42
|
+
if not member.name.startswith('package/backend-action/'):
|
|
43
|
+
continue
|
|
44
|
+
path = Path(member.name).relative_to('package')
|
|
45
|
+
if member.isdir():
|
|
46
|
+
continue
|
|
47
|
+
if not member.isfile() or '..' in path.parts or member.size > 1024 * 1024 or str(path) in result:
|
|
48
|
+
raise ValueError('release_archive_invalid')
|
|
49
|
+
result[str(path)] = source.extractfile(member).read()
|
|
50
|
+
if len(result) > 256 or sum(len(content) for content in result.values()) > 8 * 1024 * 1024:
|
|
51
|
+
raise ValueError('release_archive_too_large')
|
|
52
|
+
if not all('backend-action/' + name in result for name in REQUIRED):
|
|
53
|
+
raise ValueError('release_reader_missing')
|
|
54
|
+
for name, content in result.items():
|
|
55
|
+
if name.endswith('.py'):
|
|
56
|
+
compile(content, name, 'exec')
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def trusted(path, *, directory=False):
|
|
61
|
+
info = path.lstat()
|
|
62
|
+
expected = stat.S_ISDIR if directory else stat.S_ISREG
|
|
63
|
+
if not expected(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022:
|
|
64
|
+
raise ValueError('release_path_untrusted')
|
|
65
|
+
return info
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def install_files(version, files):
|
|
69
|
+
ROOT.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
70
|
+
trusted(ROOT, directory=True)
|
|
71
|
+
target = ROOT / version
|
|
72
|
+
if target.exists():
|
|
73
|
+
trusted(target, directory=True)
|
|
74
|
+
for name, content in files.items():
|
|
75
|
+
trusted(target / name)
|
|
76
|
+
if (target / name).read_bytes() != content:
|
|
77
|
+
raise ValueError('release_existing_mismatch')
|
|
78
|
+
return target
|
|
79
|
+
staging = Path(tempfile.mkdtemp(prefix='.runtime-', dir=str(ROOT)))
|
|
80
|
+
try:
|
|
81
|
+
for name, content in files.items():
|
|
82
|
+
path = staging / name
|
|
83
|
+
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
|
84
|
+
path.write_bytes(content)
|
|
85
|
+
path.chmod(0o644)
|
|
86
|
+
staging.chmod(0o755)
|
|
87
|
+
staging.rename(target)
|
|
88
|
+
finally:
|
|
89
|
+
if staging.exists():
|
|
90
|
+
shutil.rmtree(staging)
|
|
91
|
+
return target
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def update_commands(content, target, owners):
|
|
95
|
+
lines = content.splitlines(keepends=True)
|
|
96
|
+
for owner in owners:
|
|
97
|
+
if not re.fullmatch(r'[a-z0-9-]{1,40}', owner):
|
|
98
|
+
raise ValueError('release_owner_invalid')
|
|
99
|
+
pattern = re.compile(r'command="(?P<python>/usr/bin/python3(?: -B)?) /opt/gowalk-cicd-runtime/\d+\.\d+\.\d+'
|
|
100
|
+
r'/backend-action/ssh_gate\.py ' + re.escape(owner) + r'"')
|
|
101
|
+
matches = [index for index, line in enumerate(lines) if pattern.search(line)]
|
|
102
|
+
if len(matches) != 1 or 'restrict' not in lines[matches[0]].split(' ')[0].split(','):
|
|
103
|
+
raise ValueError('release_owner_command_ambiguous')
|
|
104
|
+
destination = str(target / 'backend-action/ssh_gate.py') + ' ' + owner + '"'
|
|
105
|
+
lines[matches[0]] = pattern.sub(lambda match: 'command="' + match['python'] + ' ' + destination,
|
|
106
|
+
lines[matches[0]])
|
|
107
|
+
return ''.join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def atomic_keys(content):
|
|
111
|
+
descriptor, filename = tempfile.mkstemp(prefix='.runtime-keys-', dir=str(AUTHORIZED_KEYS.parent))
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(descriptor, 'w') as stream:
|
|
114
|
+
stream.write(content)
|
|
115
|
+
stream.flush()
|
|
116
|
+
os.fsync(stream.fileno())
|
|
117
|
+
os.replace(filename, str(AUTHORIZED_KEYS))
|
|
118
|
+
finally:
|
|
119
|
+
if Path(filename).exists():
|
|
120
|
+
Path(filename).unlink()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def install(archive, integrity, version, owners):
|
|
124
|
+
if not owners or len(set(owners)) != len(owners):
|
|
125
|
+
raise ValueError('release_owners_invalid')
|
|
126
|
+
files = archive_files(archive, integrity, version)
|
|
127
|
+
trusted(AUTHORIZED_KEYS)
|
|
128
|
+
original = AUTHORIZED_KEYS.read_text()
|
|
129
|
+
target = ROOT / version
|
|
130
|
+
updated = update_commands(original, target, owners)
|
|
131
|
+
install_files(version, files)
|
|
132
|
+
if AUTHORIZED_KEYS.read_text() != original:
|
|
133
|
+
raise ValueError('release_keys_changed')
|
|
134
|
+
if updated != original:
|
|
135
|
+
atomic_keys(updated)
|
|
136
|
+
if AUTHORIZED_KEYS.read_text() != updated:
|
|
137
|
+
raise ValueError('release_keys_unconfirmed')
|
|
138
|
+
return {'schema': 'gowalk-cicd/backend-runtime-install.v1', 'version': version,
|
|
139
|
+
'owners': owners, 'changed': updated != original, 'verified': True}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main():
|
|
143
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
144
|
+
parser.add_argument('--archive', type=Path, required=True)
|
|
145
|
+
parser.add_argument('--integrity', required=True)
|
|
146
|
+
parser.add_argument('--version', required=True)
|
|
147
|
+
parser.add_argument('--owner', action='append', required=True)
|
|
148
|
+
args = parser.parse_args()
|
|
149
|
+
try:
|
|
150
|
+
result = install(args.archive, args.integrity, args.version, args.owner)
|
|
151
|
+
except Exception as error:
|
|
152
|
+
code = str(error) if isinstance(error, ValueError) and re.fullmatch('[a-z_]+', str(error)) else 'unclassified'
|
|
153
|
+
print('backend_runtime_install_refused code=' + code)
|
|
154
|
+
return 1
|
|
155
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == '__main__':
|
|
160
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Call the released host runtime reader through the existing restricted CI identity."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from identity_diagnostics import ReaderFailure, bounded, retain_result
|
|
9
|
+
from runtime_projection import RuntimeFailure, envelope, scope, validate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def local(args):
|
|
13
|
+
scope(args.app, args.service, args.expected_build_sha)
|
|
14
|
+
if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', args.host or '')
|
|
15
|
+
or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', args.user)):
|
|
16
|
+
raise RuntimeFailure('invalid_scope')
|
|
17
|
+
command = shlex.join(['gowalk-backend', 'runtime-diagnostics', args.app, args.service, args.expected_build_sha])
|
|
18
|
+
try:
|
|
19
|
+
status, raw = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
|
|
20
|
+
'-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
21
|
+
'-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
|
|
22
|
+
'-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command], seconds=75, with_status=True)
|
|
23
|
+
except ReaderFailure as error:
|
|
24
|
+
raise RuntimeFailure(error.code) from None
|
|
25
|
+
except OSError:
|
|
26
|
+
raise RuntimeFailure('transport_failure') from None
|
|
27
|
+
if status not in (0, 1):
|
|
28
|
+
raise RuntimeFailure('transport_failure')
|
|
29
|
+
if status == 1 and not raw.strip():
|
|
30
|
+
raise RuntimeFailure('runtime_reader_unavailable')
|
|
31
|
+
try:
|
|
32
|
+
result = validate(json.loads(raw), args.app, args.service, args.expected_build_sha)
|
|
33
|
+
except (ValueError, TypeError, KeyError):
|
|
34
|
+
raise RuntimeFailure('remote_invalid') from None
|
|
35
|
+
if result['ok'] != (status == 0):
|
|
36
|
+
raise RuntimeFailure('remote_invalid')
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
42
|
+
parser.add_argument('--app', required=True)
|
|
43
|
+
parser.add_argument('--service', default='api')
|
|
44
|
+
parser.add_argument('--expected-build-sha', required=True)
|
|
45
|
+
parser.add_argument('--host', required=True)
|
|
46
|
+
parser.add_argument('--user', default='root')
|
|
47
|
+
parser.add_argument('--output', required=True)
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
try:
|
|
50
|
+
result = local(args)
|
|
51
|
+
except Exception as error:
|
|
52
|
+
result = envelope(args.app, args.service, args.expected_build_sha,
|
|
53
|
+
error.code if isinstance(error, RuntimeFailure) else 'unclassified', reader_version=None)
|
|
54
|
+
try:
|
|
55
|
+
retain_result(args.output, result)
|
|
56
|
+
except ReaderFailure:
|
|
57
|
+
result = envelope(args.app, args.service, args.expected_build_sha, 'artifact_write_failed', reader_version=None)
|
|
58
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
59
|
+
return 0 if result['ok'] else 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == '__main__':
|
|
63
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Closed, credential-free envelope for one app's deployed runtime observations."""
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
SCHEMA = 'gowalk-cicd/backend-runtime-diagnostics.v1'
|
|
6
|
+
READER_VERSION = '1.0.192'
|
|
7
|
+
PROJECT = r'[a-z0-9][a-z0-9-]{0,79}'
|
|
8
|
+
SERVICE = r'[a-z0-9][a-z0-9_-]{0,63}'
|
|
9
|
+
SHA = r'[a-f0-9]{40}'
|
|
10
|
+
CID = r'[a-f0-9]{64}'
|
|
11
|
+
FAILURES = frozenset({
|
|
12
|
+
'invalid_scope', 'runtime_absent', 'runtime_ambiguous', 'runtime_scope_mismatch', 'runtime_changed',
|
|
13
|
+
'runtime_invalid', 'build_mismatch', 'profile_invalid', 'profile_changed', 'binding_mismatch',
|
|
14
|
+
'command_timeout', 'output_limit', 'command_failed', 'transport_failure', 'remote_invalid',
|
|
15
|
+
'artifact_write_failed', 'runtime_reader_unavailable', 'unclassified',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RuntimeFailure(Exception):
|
|
20
|
+
def __init__(self, code):
|
|
21
|
+
self.code = code if isinstance(code, str) and code in FAILURES else 'unclassified'
|
|
22
|
+
super().__init__(self.code)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scope(app, service, expected):
|
|
26
|
+
if not all(isinstance(value, str) and re.fullmatch(pattern, value)
|
|
27
|
+
for value, pattern in ((app, PROJECT), (service, SERVICE), (expected, SHA))):
|
|
28
|
+
raise RuntimeFailure('invalid_scope')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def number(value, *, integer=False, positive=False):
|
|
32
|
+
if (isinstance(value, bool) or not isinstance(value, int if integer else (int, float))
|
|
33
|
+
or not math.isfinite(value) or value < 0 or (positive and value == 0)):
|
|
34
|
+
raise RuntimeFailure('remote_invalid')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def service_record(row):
|
|
38
|
+
fields = {'service', 'container_id', 'image_id', 'running', 'limits', 'samples'}
|
|
39
|
+
if not isinstance(row, dict) or set(row) != fields:
|
|
40
|
+
raise RuntimeFailure('remote_invalid')
|
|
41
|
+
for name, pattern in (('service', SERVICE), ('container_id', CID), ('image_id', 'sha256:' + CID)):
|
|
42
|
+
if not isinstance(row[name], str) or not re.fullmatch(pattern, row[name]):
|
|
43
|
+
raise RuntimeFailure('remote_invalid')
|
|
44
|
+
if type(row['running']) is not bool or set(row['limits']) != {
|
|
45
|
+
'memory_bytes', 'reservation_bytes', 'cpu_limit', 'pids_limit'}:
|
|
46
|
+
raise RuntimeFailure('remote_invalid')
|
|
47
|
+
for name, value in row['limits'].items():
|
|
48
|
+
number(value, integer=name != 'cpu_limit', positive=name != 'reservation_bytes')
|
|
49
|
+
if row['limits']['reservation_bytes'] > row['limits']['memory_bytes']:
|
|
50
|
+
raise RuntimeFailure('remote_invalid')
|
|
51
|
+
samples = row['samples']
|
|
52
|
+
if samples == [] and not row['running']:
|
|
53
|
+
return
|
|
54
|
+
if not isinstance(samples, list) or len(samples) != 2:
|
|
55
|
+
raise RuntimeFailure('remote_invalid')
|
|
56
|
+
for usage in samples:
|
|
57
|
+
if not isinstance(usage, dict) or set(usage) != {'memory_bytes', 'cpu_percent', 'pids', 'observed_at'}:
|
|
58
|
+
raise RuntimeFailure('remote_invalid')
|
|
59
|
+
if not isinstance(usage['observed_at'], str) or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ',
|
|
60
|
+
usage['observed_at']):
|
|
61
|
+
raise RuntimeFailure('remote_invalid')
|
|
62
|
+
for name in ('memory_bytes', 'cpu_percent', 'pids'):
|
|
63
|
+
number(usage[name], integer=name != 'cpu_percent')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def database_record(row):
|
|
67
|
+
if not isinstance(row, dict) or set(row) != {
|
|
68
|
+
'mode', 'profile_present', 'binding_matches_profile', 'removed_services', 'database_services'}:
|
|
69
|
+
raise RuntimeFailure('remote_invalid')
|
|
70
|
+
if row['mode'] not in {'host_profile', 'app_service', 'external_unverified', 'unconfigured'}:
|
|
71
|
+
raise RuntimeFailure('remote_invalid')
|
|
72
|
+
if (type(row['profile_present']) is not bool
|
|
73
|
+
or row['binding_matches_profile'] is not None and type(row['binding_matches_profile']) is not bool):
|
|
74
|
+
raise RuntimeFailure('remote_invalid')
|
|
75
|
+
for key in ('removed_services', 'database_services'):
|
|
76
|
+
if (not isinstance(row[key], list) or len(row[key]) > 32
|
|
77
|
+
or any(not isinstance(value, str) or not re.fullmatch(SERVICE, value) for value in row[key])):
|
|
78
|
+
raise RuntimeFailure('remote_invalid')
|
|
79
|
+
if row['mode'] == 'host_profile' and (not row['profile_present'] or row['binding_matches_profile'] is not True):
|
|
80
|
+
raise RuntimeFailure('remote_invalid')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate(result, app, service, expected):
|
|
84
|
+
scope(app, service, expected)
|
|
85
|
+
base = {'schema', 'reader_version', 'ok', 'failure', 'project', 'build_service', 'expected_build_sha'}
|
|
86
|
+
if (not isinstance(result, dict) or result.get('schema') != SCHEMA or type(result.get('ok')) is not bool
|
|
87
|
+
or result.get('project') != app or result.get('build_service') != service
|
|
88
|
+
or result.get('expected_build_sha') != expected
|
|
89
|
+
or result.get('reader_version') != READER_VERSION):
|
|
90
|
+
raise RuntimeFailure('remote_invalid')
|
|
91
|
+
if not result['ok']:
|
|
92
|
+
if set(result) != base or result['failure'] not in FAILURES:
|
|
93
|
+
raise RuntimeFailure('remote_invalid')
|
|
94
|
+
return result
|
|
95
|
+
if (set(result) != base | {'observed_build_sha', 'services', 'container_count', 'database', 'observed_at'}
|
|
96
|
+
or result['failure'] != '' or result['observed_build_sha'] != expected
|
|
97
|
+
or not isinstance(result['observed_at'], str)
|
|
98
|
+
or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', result['observed_at'])):
|
|
99
|
+
raise RuntimeFailure('remote_invalid')
|
|
100
|
+
rows = result['services']
|
|
101
|
+
if not isinstance(rows, list) or not 1 <= len(rows) <= 32:
|
|
102
|
+
raise RuntimeFailure('remote_invalid')
|
|
103
|
+
number(result['container_count'], integer=True)
|
|
104
|
+
if result['container_count'] != len(rows):
|
|
105
|
+
raise RuntimeFailure('remote_invalid')
|
|
106
|
+
for row in rows:
|
|
107
|
+
service_record(row)
|
|
108
|
+
if len({row['container_id'] for row in rows}) != len(rows) or len({row['service'] for row in rows}) != len(rows):
|
|
109
|
+
raise RuntimeFailure('remote_invalid')
|
|
110
|
+
if not any(row['service'] == service and row['running'] for row in rows):
|
|
111
|
+
raise RuntimeFailure('remote_invalid')
|
|
112
|
+
database_record(result['database'])
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def envelope(app, service, expected, failure='', *, reader_version=READER_VERSION):
|
|
117
|
+
return {'schema': SCHEMA, 'reader_version': reader_version, 'ok': not failure, 'failure': failure, 'project': app,
|
|
118
|
+
'build_service': service, 'expected_build_sha': expected}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Observe one authorized Compose project; private bindings never leave the host."""
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from identity_diagnostics import ReaderFailure, bounded
|
|
11
|
+
from runtime_projection import CID, SERVICE, RuntimeFailure, envelope, scope, validate
|
|
12
|
+
|
|
13
|
+
APPS = Path('/opt/gowalk-backends')
|
|
14
|
+
PROFILES = Path('/opt/gowalk-backend-runtime')
|
|
15
|
+
ROOT_UID = 0
|
|
16
|
+
INSPECT = ('{"id":{{json .Id}},"image":{{json .Image}},"running":{{json .State.Running}},'
|
|
17
|
+
'"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
18
|
+
'"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
|
|
19
|
+
'"directory":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},'
|
|
20
|
+
'"memory":{{.HostConfig.Memory}},"reservation":{{.HostConfig.MemoryReservation}},'
|
|
21
|
+
'"nano_cpus":{{.HostConfig.NanoCpus}},"quota":{{.HostConfig.CpuQuota}},'
|
|
22
|
+
'"period":{{.HostConfig.CpuPeriod}},"pids":{{json .HostConfig.PidsLimit}},'
|
|
23
|
+
'"binding":[""{{range .Config.Env}}{{$key := index (split . "=") 0}}'
|
|
24
|
+
'{{if or (eq $key "BUILD_SHA") (eq $key "DATABASE_URL")}},{{json .}}{{end}}{{end}}]}')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(arguments):
|
|
28
|
+
try:
|
|
29
|
+
return bounded(arguments, seconds=10)
|
|
30
|
+
except ReaderFailure as error:
|
|
31
|
+
raise RuntimeFailure(error.code) from None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def containers(app):
|
|
35
|
+
raw = run(['docker', 'ps', '--all', '--no-trunc', '--filter',
|
|
36
|
+
'label=com.docker.compose.project=' + app, '--format', '{{.ID}}']).decode().splitlines()
|
|
37
|
+
if not raw:
|
|
38
|
+
raise RuntimeFailure('runtime_absent')
|
|
39
|
+
if len(raw) > 32 or len(set(raw)) != len(raw) or any(not re.fullmatch(CID, value) for value in raw):
|
|
40
|
+
raise RuntimeFailure('runtime_ambiguous')
|
|
41
|
+
return sorted(raw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inspect(app, identifiers):
|
|
45
|
+
rows = [json.loads(line) for line in run(['docker', 'inspect', '--format', INSPECT, *identifiers]).splitlines()]
|
|
46
|
+
if sorted(row['id'] for row in rows) != identifiers:
|
|
47
|
+
raise RuntimeFailure('runtime_changed')
|
|
48
|
+
services = set()
|
|
49
|
+
for row in rows:
|
|
50
|
+
if (row['project'] != app or row['directory'] != str(APPS / app)
|
|
51
|
+
or not isinstance(row['service'], str) or not re.fullmatch(SERVICE, row['service'])):
|
|
52
|
+
raise RuntimeFailure('runtime_scope_mismatch')
|
|
53
|
+
if row['service'] in services:
|
|
54
|
+
raise RuntimeFailure('runtime_ambiguous')
|
|
55
|
+
services.add(row['service'])
|
|
56
|
+
values = [item.split('=', 1) for item in row['binding'] if item]
|
|
57
|
+
if len({item[0] for item in values}) != len(values):
|
|
58
|
+
raise RuntimeFailure('runtime_invalid')
|
|
59
|
+
row['binding'] = dict(values)
|
|
60
|
+
return sorted(rows, key=lambda row: row['service'])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def profile(app):
|
|
64
|
+
path = PROFILES / (app + '.json')
|
|
65
|
+
try:
|
|
66
|
+
info = path.lstat()
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022 or info.st_size > 65536:
|
|
70
|
+
raise RuntimeFailure('profile_invalid')
|
|
71
|
+
raw = path.read_bytes()
|
|
72
|
+
value = json.loads(raw)
|
|
73
|
+
if (not isinstance(value, dict) or value.get('schema') != 'gowalk-cicd/backend-runtime.v1'
|
|
74
|
+
or set(value) - {'schema', 'disabled', 'remove_services', 'services'}):
|
|
75
|
+
raise RuntimeFailure('profile_invalid')
|
|
76
|
+
return raw
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def database(raw_profile, selected, rows):
|
|
80
|
+
document = json.loads(raw_profile) if raw_profile is not None else {}
|
|
81
|
+
removed = document.get('remove_services', [])
|
|
82
|
+
service = document.get('services', {}).get(selected['service'], {})
|
|
83
|
+
declared = service.get('environment', {}).get('DATABASE_URL')
|
|
84
|
+
actual = selected['binding'].get('DATABASE_URL')
|
|
85
|
+
if not isinstance(removed, list) or any(not isinstance(x, str) or not re.fullmatch(SERVICE, x) for x in removed):
|
|
86
|
+
raise RuntimeFailure('profile_invalid')
|
|
87
|
+
if set(removed) & {row['service'] for row in rows}:
|
|
88
|
+
raise RuntimeFailure('binding_mismatch')
|
|
89
|
+
matched = None if declared is None else isinstance(declared, str) and bool(declared) and actual == declared
|
|
90
|
+
if matched is False:
|
|
91
|
+
raise RuntimeFailure('binding_mismatch')
|
|
92
|
+
hostname = urlsplit(actual).hostname if actual else None
|
|
93
|
+
owned = [row['service'] for row in rows if row['service'] == hostname]
|
|
94
|
+
mode = 'host_profile' if matched else 'app_service' if owned else (
|
|
95
|
+
'external_unverified' if actual else 'unconfigured')
|
|
96
|
+
return {'mode': mode, 'profile_present': raw_profile is not None, 'binding_matches_profile': matched,
|
|
97
|
+
'removed_services': sorted(removed), 'database_services': owned}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def usage(rows):
|
|
101
|
+
identifiers = [row['id'] for row in rows if row['running']]
|
|
102
|
+
if not identifiers:
|
|
103
|
+
raise RuntimeFailure('runtime_absent')
|
|
104
|
+
samples = [json.loads(line) for line in run(['docker', 'stats', '--no-stream', '--no-trunc',
|
|
105
|
+
'--format', '{{json .}}', *identifiers]).splitlines()]
|
|
106
|
+
if sorted(row['ID'] for row in samples) != sorted(identifiers):
|
|
107
|
+
raise RuntimeFailure('runtime_changed')
|
|
108
|
+
units = {'B': 1, 'kB': 1000, 'KiB': 1024, 'MB': 1000 ** 2, 'MiB': 1024 ** 2,
|
|
109
|
+
'GB': 1000 ** 3, 'GiB': 1024 ** 3}
|
|
110
|
+
result = {}
|
|
111
|
+
observed = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
112
|
+
for row in samples:
|
|
113
|
+
memory = re.fullmatch(r'([0-9.]+)([A-Za-z]+)', row['MemUsage'].split(' / ')[0].strip())
|
|
114
|
+
if not memory or memory[2] not in units or not re.fullmatch(r'[0-9.]+%', row['CPUPerc']):
|
|
115
|
+
raise RuntimeFailure('runtime_invalid')
|
|
116
|
+
result[row['ID']] = {'memory_bytes': int(float(memory[1]) * units[memory[2]]),
|
|
117
|
+
'cpu_percent': float(row['CPUPerc'][:-1]), 'pids': int(row['PIDs']),
|
|
118
|
+
'observed_at': observed}
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def public_row(row, samples):
|
|
123
|
+
cpus = row['nano_cpus'] / 1e9 if row['nano_cpus'] else (
|
|
124
|
+
row['quota'] / row['period'] if row['quota'] > 0 and row['period'] > 0 else 0)
|
|
125
|
+
return {'service': row['service'], 'container_id': row['id'], 'image_id': row['image'],
|
|
126
|
+
'running': row['running'], 'limits': {'memory_bytes': row['memory'],
|
|
127
|
+
'reservation_bytes': row['reservation'], 'cpu_limit': cpus, 'pids_limit': row['pids']},
|
|
128
|
+
'samples': [sample[row['id']] for sample in samples] if row['running'] else []}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def collect(app, service, expected):
|
|
132
|
+
scope(app, service, expected)
|
|
133
|
+
original_profile = profile(app)
|
|
134
|
+
identifiers = containers(app)
|
|
135
|
+
rows = inspect(app, identifiers)
|
|
136
|
+
selected = next((row for row in rows if row['service'] == service), None)
|
|
137
|
+
if not selected or not selected['running'] or selected['binding'].get('BUILD_SHA') != expected:
|
|
138
|
+
raise RuntimeFailure('build_mismatch')
|
|
139
|
+
binding = database(original_profile, selected, rows)
|
|
140
|
+
samples = [usage(rows), usage(rows)]
|
|
141
|
+
if containers(app) != identifiers or inspect(app, identifiers) != rows:
|
|
142
|
+
raise RuntimeFailure('runtime_changed')
|
|
143
|
+
if profile(app) != original_profile:
|
|
144
|
+
raise RuntimeFailure('profile_changed')
|
|
145
|
+
result = dict(envelope(app, service, expected), observed_build_sha=expected,
|
|
146
|
+
services=[public_row(row, samples) for row in rows], container_count=len(rows),
|
|
147
|
+
database=binding,
|
|
148
|
+
observed_at=datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
|
|
149
|
+
return validate(result, app, service, expected)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
app, service, expected = (sys.argv[1:] + ['', '', ''])[:3]
|
|
154
|
+
try:
|
|
155
|
+
result = collect(app, service, expected)
|
|
156
|
+
except Exception as error:
|
|
157
|
+
result = envelope(app, service, expected, error.code if isinstance(error, RuntimeFailure) else 'unclassified')
|
|
158
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
159
|
+
return 0 if result['ok'] else 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
sys.exit(main())
|
|
@@ -80,6 +80,13 @@ def authorize(owner, project, policy, may_claim):
|
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def helper_command(words):
|
|
83
|
+
if len(words) == 5 and words[:2] == ['gowalk-backend', 'runtime-diagnostics']:
|
|
84
|
+
from runtime_projection import RuntimeFailure, scope
|
|
85
|
+
try:
|
|
86
|
+
scope(*words[2:])
|
|
87
|
+
except RuntimeFailure:
|
|
88
|
+
raise ValueError('ssh_arguments_refused') from None
|
|
89
|
+
return words[2], False, [sys.executable, str(HELPERS / 'runtime_readback.py'), *words[2:]]
|
|
83
90
|
if len(words) == 3 and words[:2] == ['gowalk-backend', 'native-deploy']:
|
|
84
91
|
if not re.fullmatch('[a-z0-9-]+', words[2]):
|
|
85
92
|
raise ValueError('ssh_project_refused')
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -56,3 +56,56 @@ mutations refuse; after the gate opens, the key can transfer only its own app's
|
|
|
56
56
|
project refuses. A disabled runtime profile must still refuse a legacy deploy invocation. Verify a new
|
|
57
57
|
task-owned project can be claimed and replayed, then remove only that verification project and binding.
|
|
58
58
|
Failures emit only `backend_ssh_refused`, never the original command or private stdin.
|
|
59
|
+
|
|
60
|
+
## Applied runtime readback
|
|
61
|
+
|
|
62
|
+
The released backend action accepts `operation: runtime-diagnostics`, `diagnostic-service` (default `api`) and
|
|
63
|
+
`expected-build-sha`. Its existing pinned CI SSH identity issues only
|
|
64
|
+
`gowalk-backend runtime-diagnostics <project> <service> <40-character-SHA>`. The forced command checks the same
|
|
65
|
+
panel/project ownership and global freeze, then executes its own released `runtime_readback.py`. It never runs
|
|
66
|
+
consumer code or an uploaded script. An older/refusing gate has no reader receipt and the client returns
|
|
67
|
+
`runtime_reader_unavailable`; locally synthesized failures carry `reader_version: null`, never host-adoption proof.
|
|
68
|
+
A collector refusal carries its actual `reader_version` and fixed failure code.
|
|
69
|
+
|
|
70
|
+
The `backend-runtime-diagnostics-<job>-<attempt>` artifact contains only `result.json`, schema
|
|
71
|
+
`gowalk-cicd/backend-runtime-diagnostics.v1`. Success binds the expected/observed build to the running API's
|
|
72
|
+
`BUILD_SHA`, enumerates at most 32 exact-project containers (including stopped ones), and rechecks their exact
|
|
73
|
+
project/service/working-directory labels. It returns container and image IDs, effective memory/reservation/CPU/PID
|
|
74
|
+
limits and two timestamped `docker stats --no-stream` samples for running app containers. Memory samples retain
|
|
75
|
+
Docker's display precision. CPU samples are Docker's bounded observations, not a whole-load benchmark. The
|
|
76
|
+
collector never launches workload, reads logs, executes in containers or inspects a shared database's cgroup.
|
|
77
|
+
Both container identity/configuration and the private profile are rechecked before returning success.
|
|
78
|
+
|
|
79
|
+
`database.mode: host_profile` with `binding_matches_profile: true` proves this app's root-owned profile supplied
|
|
80
|
+
the API's current private `DATABASE_URL`. `removed_services` describes that profile's applied removals; a remaining
|
|
81
|
+
removed service refuses. `app_service` means the runtime connection names another reported service.
|
|
82
|
+
`external_unverified` and `unconfigured` establish no managed database ownership. No DSN, username, password,
|
|
83
|
+
other environment variable or raw host output leaves the host. This is connection provenance, not proof of SQL
|
|
84
|
+
grants, migrated data or application behavior: still verify the app's authenticated data path through its public API.
|
|
85
|
+
|
|
86
|
+
The complete inventory is scoped to this app before inspect/stats; no unfiltered Docker inventory is used.
|
|
87
|
+
A missing/ambiguous API, stale build, changed container/profile, binding mismatch, unbounded service limits or
|
|
88
|
+
malformed result refuses proof. A replay is another read and makes no mutation. Empty identity-diagnostic log
|
|
89
|
+
records never establish database ownership or resources, and an absent source `db` container alone proves no
|
|
90
|
+
shared-database substitution.
|
|
91
|
+
|
|
92
|
+
## Install a published host runtime
|
|
93
|
+
|
|
94
|
+
Use the published package's `backend-action/install_runtime.py` with its npm archive, the registry's exact
|
|
95
|
+
`dist.integrity`, version and existing panel owners. Verify the archive's sha512 before extracting the installer.
|
|
96
|
+
For example, after transferring those verified public files to task-owned host scratch:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
python3 install_runtime.py --archive gowalk-cicd-1.0.192.tgz --integrity "$PACKAGE_INTEGRITY" \
|
|
100
|
+
--version 1.0.192 --owner robot --owner vpnrobot
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The root-only installer verifies integrity/name/version again, installs only package-owned backend helpers under
|
|
104
|
+
`/opt/gowalk-cicd-runtime/<version>`, and atomically retargets each named owner's exact existing forced command in
|
|
105
|
+
`/root/.ssh/authorized_keys`. It refuses missing/duplicate owners, nonrestricted entries, untrusted paths and
|
|
106
|
+
changed existing release bytes; it never provisions keys or changes another entry. Exact replay verifies the
|
|
107
|
+
release and leaves keys unchanged. A fixed receipt confirms the version and owners without key contents.
|
|
108
|
+
The established `/usr/bin/python3 -B` spelling is accepted and preserves `-B`; other interpreter flags refuse.
|
|
109
|
+
It changes no app source, runtime profile, container, database, service timer or deployment freeze. Preserve the
|
|
110
|
+
previous release directory; this is ordinary released-helper installation, not app-state recovery.
|
|
111
|
+
Verify a fresh scoped readback through each owner and keep that receipt with the exact npm integrity and release.
|