gowalk-cicd 1.0.190 → 1.0.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 identity-diagnostics.
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 to inspect; used only for identity-diagnostics.
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,159 @@
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="/usr/bin/python3 /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
+ replacement = 'command="/usr/bin/python3 ' + str(target / 'backend-action/ssh_gate.py') + ' ' + owner + '"'
105
+ lines[matches[0]] = pattern.sub(lambda _match: replacement, lines[matches[0]])
106
+ return ''.join(lines)
107
+
108
+
109
+ def atomic_keys(content):
110
+ descriptor, filename = tempfile.mkstemp(prefix='.runtime-keys-', dir=str(AUTHORIZED_KEYS.parent))
111
+ try:
112
+ with os.fdopen(descriptor, 'w') as stream:
113
+ stream.write(content)
114
+ stream.flush()
115
+ os.fsync(stream.fileno())
116
+ os.replace(filename, str(AUTHORIZED_KEYS))
117
+ finally:
118
+ if Path(filename).exists():
119
+ Path(filename).unlink()
120
+
121
+
122
+ def install(archive, integrity, version, owners):
123
+ if not owners or len(set(owners)) != len(owners):
124
+ raise ValueError('release_owners_invalid')
125
+ files = archive_files(archive, integrity, version)
126
+ trusted(AUTHORIZED_KEYS)
127
+ original = AUTHORIZED_KEYS.read_text()
128
+ target = ROOT / version
129
+ updated = update_commands(original, target, owners)
130
+ install_files(version, files)
131
+ if AUTHORIZED_KEYS.read_text() != original:
132
+ raise ValueError('release_keys_changed')
133
+ if updated != original:
134
+ atomic_keys(updated)
135
+ if AUTHORIZED_KEYS.read_text() != updated:
136
+ raise ValueError('release_keys_unconfirmed')
137
+ return {'schema': 'gowalk-cicd/backend-runtime-install.v1', 'version': version,
138
+ 'owners': owners, 'changed': updated != original, 'verified': True}
139
+
140
+
141
+ def main():
142
+ parser = argparse.ArgumentParser(description=__doc__)
143
+ parser.add_argument('--archive', type=Path, required=True)
144
+ parser.add_argument('--integrity', required=True)
145
+ parser.add_argument('--version', required=True)
146
+ parser.add_argument('--owner', action='append', required=True)
147
+ args = parser.parse_args()
148
+ try:
149
+ result = install(args.archive, args.integrity, args.version, args.owner)
150
+ except Exception as error:
151
+ code = str(error) if isinstance(error, ValueError) and re.fullmatch('[a-z_]+', str(error)) else 'unclassified'
152
+ print('backend_runtime_install_refused code=' + code)
153
+ return 1
154
+ print(json.dumps(result, separators=(',', ':')))
155
+ return 0
156
+
157
+
158
+ if __name__ == '__main__':
159
+ 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.191'
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')
@@ -56,3 +56,55 @@ 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.191.tgz --integrity "$PACKAGE_INTEGRITY" \
100
+ --version 1.0.191 --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
+ It changes no app source, runtime profile, container, database, service timer or deployment freeze. Preserve the
109
+ previous release directory; this is ordinary released-helper installation, not app-state recovery.
110
+ Verify a fresh scoped readback through each owner and keep that receipt with the exact npm integrity and release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.190",
3
+ "version": "1.0.191",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {