gowalk-cicd 1.0.150 → 1.0.152
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 +6 -0
- package/README.md +4 -0
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +35 -2
- package/backend-action/network_contract.py +119 -0
- package/backend-action/network_diagnostics.py +165 -0
- package/backend-action/network_inventory.py +235 -0
- package/backend-action/network_owned_inventory.py +118 -0
- package/backend-action/network_pool_facts.json +214 -0
- package/backend-action/network_pools.py +273 -0
- package/backend-action/network_prepare.py +151 -0
- package/backend-action/network_readback.py +25 -0
- package/backend-action/network_runtime.py +62 -0
- package/backend-action/network_test_fixture.py +56 -0
- package/backend-action/remote_deploy.sh +1 -0
- package/backend-action/test_network_builtins.py +28 -0
- package/backend-action/test_network_diagnostics.py +57 -0
- package/backend-action/test_network_docker.py +129 -0
- package/backend-action/test_network_fixture_evidence.py +23 -0
- package/backend-action/test_network_inventory.py +241 -0
- package/backend-action/test_network_ipam_gateway.py +46 -0
- package/backend-action/test_network_noop.py +64 -0
- package/backend-action/test_network_owned_inventory.py +128 -0
- package/backend-action/test_network_prepare.py +99 -0
- package/backend-action/test_network_races.py +73 -0
- package/backend-action/test_network_rule_diagnostics.py +53 -0
- package/backend-action/test_network_runtime.py +49 -0
- package/docs/BACKEND_NETWORKS.md +102 -0
- package/package.json +2 -1
- package/templates/deploy-backend.yml +3 -3
- package/templates/history.json.gz +0 -0
package/CLAUDE.md
CHANGED
|
@@ -441,6 +441,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
441
441
|
|
|
442
442
|
## Scoped backend diagnosis
|
|
443
443
|
|
|
444
|
+
Backend network preparation and `operation: network-diagnostics` follow `docs/BACKEND_NETWORKS.md`.
|
|
445
|
+
Keep allocation inside verified existing daemon pool bases, preserve all existing and authored networks,
|
|
446
|
+
and hold the host allocation lock only through creation/readback. Never restart/reconfigure Docker,
|
|
447
|
+
prune networks, or invent a free private range. Remote modules and source facts remain Python 3.8 compatible.
|
|
448
|
+
Tests include the explicit CI-only six-project Docker coexistence proof; ordinary local tests contact no daemon.
|
|
449
|
+
|
|
444
450
|
The existing backend action accepts `operation: identity-diagnostics` with a UTC `diagnostic-since`
|
|
445
451
|
from the last24hours and exact `diagnostic-service` (default`api`). It performs no deploy, rsync,
|
|
446
452
|
Compose mutation or environment read. `identity_diagnostics.py` uses the existing CI SSH binding,
|
package/README.md
CHANGED
|
@@ -156,6 +156,10 @@ in App Store Connect; see [First-time app setup](#first-time-app-setup).
|
|
|
156
156
|
|
|
157
157
|
## Backend deploy (Python + Postgres, Docker)
|
|
158
158
|
|
|
159
|
+
New ordinary Compose projects receive small isolated networks inside Docker's verified existing pools.
|
|
160
|
+
Existing/custom/external networks and authored IPAM retain their contracts. Read-only
|
|
161
|
+
`network-diagnostics` uses the same deployment identity; see [capacity and diagnosis](docs/BACKEND_NETWORKS.md).
|
|
162
|
+
|
|
159
163
|
When a repo carries a Dockerized backend, the installer also vendors a backend deploy path:
|
|
160
164
|
`.github/actions/backend-app/` and `.github/workflows/deploy-backend.yml`. A
|
|
161
165
|
mobile-only repo is unaffected (the backend path is not installed).
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.152
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.152
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.152
|
|
@@ -7,7 +7,7 @@ description: >
|
|
|
7
7
|
|
|
8
8
|
inputs:
|
|
9
9
|
operation:
|
|
10
|
-
description: deploy (default)
|
|
10
|
+
description: deploy (default), identity-diagnostics, or read-only network-diagnostics.
|
|
11
11
|
required: false
|
|
12
12
|
default: deploy
|
|
13
13
|
diagnostic-service:
|
|
@@ -93,7 +93,7 @@ runs:
|
|
|
93
93
|
env:
|
|
94
94
|
OPERATION: ${{ inputs.operation }}
|
|
95
95
|
run: |
|
|
96
|
-
case "$OPERATION" in deploy|identity-diagnostics) ;; *) exit 1;; esac
|
|
96
|
+
case "$OPERATION" in deploy|identity-diagnostics|network-diagnostics) ;; *) exit 1;; esac
|
|
97
97
|
|
|
98
98
|
- name: Validate inputs
|
|
99
99
|
if: inputs.operation == 'deploy'
|
|
@@ -186,6 +186,14 @@ runs:
|
|
|
186
186
|
"${{ github.action_path }}/edge_inputs.py" \
|
|
187
187
|
"${{ github.action_path }}/edge_vhost.py" \
|
|
188
188
|
"${{ github.action_path }}/edge_health.py" \
|
|
189
|
+
"${{ github.action_path }}/identity_diagnostics.py" \
|
|
190
|
+
"${{ github.action_path }}/network_runtime.py" \
|
|
191
|
+
"${{ github.action_path }}/network_contract.py" \
|
|
192
|
+
"${{ github.action_path }}/network_inventory.py" \
|
|
193
|
+
"${{ github.action_path }}/network_owned_inventory.py" \
|
|
194
|
+
"${{ github.action_path }}/network_readback.py" \
|
|
195
|
+
"${{ github.action_path }}/network_pools.py" \
|
|
196
|
+
"${{ github.action_path }}/network_prepare.py" \
|
|
189
197
|
"${{ github.action_path }}/deploy_evidence.sh" \
|
|
190
198
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
191
199
|
|
|
@@ -248,6 +256,31 @@ runs:
|
|
|
248
256
|
path: ${{ steps.identity_diagnostics.outputs.result }}
|
|
249
257
|
if-no-files-found: error
|
|
250
258
|
|
|
259
|
+
- name: Read backend network diagnostics
|
|
260
|
+
id: network_diagnostics
|
|
261
|
+
if: inputs.operation == 'network-diagnostics'
|
|
262
|
+
shell: bash
|
|
263
|
+
env:
|
|
264
|
+
DIAGNOSTIC_APP: ${{ inputs.app-name }}
|
|
265
|
+
DIAGNOSTIC_HOST: ${{ inputs.host }}
|
|
266
|
+
DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
|
|
267
|
+
run: |
|
|
268
|
+
RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
|
|
269
|
+
echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
|
|
270
|
+
python3 "${{ github.action_path }}/network_diagnostics.py" \
|
|
271
|
+
--app "$DIAGNOSTIC_APP" --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
|
|
272
|
+
--output "$RESULT_DIR/result.json"
|
|
273
|
+
|
|
274
|
+
- name: Retain backend network diagnostics
|
|
275
|
+
if: always() && inputs.operation == 'network-diagnostics'
|
|
276
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
277
|
+
env:
|
|
278
|
+
NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
|
|
279
|
+
with:
|
|
280
|
+
name: backend-network-diagnostics-${{ github.job }}-${{ github.run_attempt }}
|
|
281
|
+
path: ${{ steps.network_diagnostics.outputs.result }}
|
|
282
|
+
if-no-files-found: error
|
|
283
|
+
|
|
251
284
|
- name: Cleanup key
|
|
252
285
|
if: always()
|
|
253
286
|
shell: bash
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Select only ordinary, automatically named Compose networks; never rewrite authored IPAM."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
OWNER = 'com.gowalk.cicd.network-contract'
|
|
8
|
+
SCHEMA = 'com.gowalk.cicd.network-schema'
|
|
9
|
+
PROJECT = 'com.docker.compose.project'
|
|
10
|
+
NETWORK = 'com.docker.compose.network'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContractFailure(Exception):
|
|
14
|
+
def __init__(self, code):
|
|
15
|
+
self.code = code
|
|
16
|
+
super().__init__(code)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def identity(value, pattern):
|
|
20
|
+
if not isinstance(value, str) or not re.fullmatch(pattern, value):
|
|
21
|
+
raise ContractFailure('invalid_project_contract')
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def disposition(key, value, project, services):
|
|
26
|
+
if value.get('external') is True:
|
|
27
|
+
return 'external'
|
|
28
|
+
if value.get('name') != project + '_' + key:
|
|
29
|
+
return 'custom_name'
|
|
30
|
+
if value.get('ipam'):
|
|
31
|
+
return 'authored_ipam'
|
|
32
|
+
allowed = {'name', 'driver', 'internal', 'attachable', 'enable_ipv4', 'enable_ipv6', 'labels', 'ipam'}
|
|
33
|
+
if set(value) - allowed or value.get('driver', 'bridge') not in ('', 'bridge'):
|
|
34
|
+
return 'custom_settings'
|
|
35
|
+
if value.get('enable_ipv6') or value.get('enable_ipv4') is False:
|
|
36
|
+
return 'address_family'
|
|
37
|
+
for service in services.values():
|
|
38
|
+
options = (service.get('networks') or {}).get(key) or {}
|
|
39
|
+
if options.get('ipv4_address') or options.get('ipv6_address'):
|
|
40
|
+
return 'static_address'
|
|
41
|
+
return 'eligible'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plans(config, project):
|
|
45
|
+
identity(project, r'[a-z0-9][a-z0-9-]{0,79}')
|
|
46
|
+
if not isinstance(config, dict) or config.get('name') != project:
|
|
47
|
+
raise ContractFailure('invalid_project_contract')
|
|
48
|
+
services, networks = config.get('services', {}), config.get('networks', {})
|
|
49
|
+
if not isinstance(services, dict) or not isinstance(networks, dict) or len(networks) > 64:
|
|
50
|
+
raise ContractFailure('invalid_project_contract')
|
|
51
|
+
result = []
|
|
52
|
+
for key, value in sorted(networks.items()):
|
|
53
|
+
identity(key, r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}')
|
|
54
|
+
if not isinstance(value, dict) or not isinstance(value.get('name'), str):
|
|
55
|
+
raise ContractFailure('invalid_project_contract')
|
|
56
|
+
selected = disposition(key, value, project, services)
|
|
57
|
+
labels = value.get('labels', {})
|
|
58
|
+
if selected == 'eligible':
|
|
59
|
+
if not isinstance(labels, dict) or any(not isinstance(v, str) for v in labels.values()):
|
|
60
|
+
raise ContractFailure('invalid_project_contract')
|
|
61
|
+
if any(k.startswith('com.docker.compose.') or k.startswith('com.gowalk.cicd.') for k in labels):
|
|
62
|
+
raise ContractFailure('reserved_network_label')
|
|
63
|
+
canonical = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True)
|
|
64
|
+
result.append({'key': key, 'name': value['name'], 'project': project, 'disposition': selected,
|
|
65
|
+
'contract': hashlib.sha256(canonical.encode()).hexdigest(), 'settings': value})
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def existing(plan, networks):
|
|
70
|
+
matches = [item for item in networks if item['Name'] == plan['name']]
|
|
71
|
+
if len(matches) > 1:
|
|
72
|
+
raise ContractFailure('network_name_ambiguous')
|
|
73
|
+
if not matches:
|
|
74
|
+
return None
|
|
75
|
+
network = matches[0]
|
|
76
|
+
labels = network.get('Labels') or {}
|
|
77
|
+
if plan['disposition'] in ('external', 'custom_name') and not labels.get(OWNER):
|
|
78
|
+
return network
|
|
79
|
+
if labels.get(PROJECT) != plan['project'] or labels.get(NETWORK) != plan['key']:
|
|
80
|
+
raise ContractFailure('network_ownership_mismatch')
|
|
81
|
+
if labels.get(OWNER) and (labels[OWNER] != plan['contract'] or labels.get(SCHEMA) != '1'):
|
|
82
|
+
raise ContractFailure('network_contract_changed')
|
|
83
|
+
return network
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_arguments(plan, subnet, compose_version):
|
|
87
|
+
value = plan['settings']
|
|
88
|
+
labels = dict(value.get('labels') or {})
|
|
89
|
+
labels.update({PROJECT: plan['project'], NETWORK: plan['key'], 'com.docker.compose.version': compose_version,
|
|
90
|
+
OWNER: plan['contract'], SCHEMA: '1'})
|
|
91
|
+
gateway = str(ipaddress.IPv4Network(subnet).network_address + 1)
|
|
92
|
+
args = ['docker', 'network', 'create', '--driver', 'bridge', '--subnet', subnet, '--gateway', gateway]
|
|
93
|
+
for flag in ('internal', 'attachable'):
|
|
94
|
+
if value.get(flag):
|
|
95
|
+
args.append('--' + flag)
|
|
96
|
+
for key, value in sorted(labels.items()):
|
|
97
|
+
args.extend(['--label', key + '=' + value])
|
|
98
|
+
return args + [plan['name']]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify_created(plan, network, subnet):
|
|
102
|
+
value, labels = plan['settings'], network.get('Labels') or {}
|
|
103
|
+
if (network['Name'] != plan['name'] or labels.get(OWNER) != plan['contract']
|
|
104
|
+
or labels.get(SCHEMA) != '1' or labels.get(PROJECT) != plan['project']
|
|
105
|
+
or labels.get(NETWORK) != plan['key'] or network.get('Driver') != 'bridge'
|
|
106
|
+
or network.get('EnableIPv6') or network.get('Scope') != 'local'
|
|
107
|
+
or labels.get('com.docker.compose.config-hash')):
|
|
108
|
+
raise ContractFailure('created_network_unverified')
|
|
109
|
+
if any(bool(network.get(k.title())) != bool(value.get(k)) for k in ('internal', 'attachable')):
|
|
110
|
+
raise ContractFailure('created_network_unverified')
|
|
111
|
+
pools = (network.get('IPAM') or {}).get('Config') or []
|
|
112
|
+
if len(pools) != 1 or pools[0].get('Subnet') != subnet:
|
|
113
|
+
raise ContractFailure('created_network_unverified')
|
|
114
|
+
if (network['IPAM'].get('Driver') != 'default' or network['IPAM'].get('Options')
|
|
115
|
+
or set(pools[0]) - {'Subnet', 'Gateway'}
|
|
116
|
+
or pools[0].get('Gateway') != str(ipaddress.IPv4Network(subnet).network_address + 1)):
|
|
117
|
+
raise ContractFailure('created_network_unverified')
|
|
118
|
+
if any(labels.get(k) != v for k, v in (value.get('labels') or {}).items()):
|
|
119
|
+
raise ContractFailure('created_network_unverified')
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read bounded deployment-host network evidence without syncing files or changing a network."""
|
|
3
|
+
import argparse
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
|
|
10
|
+
from identity_diagnostics import ReaderFailure, bounded, retain_result
|
|
11
|
+
from network_pools import FAILURES as INVENTORY_FAILURES
|
|
12
|
+
|
|
13
|
+
SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v1'
|
|
14
|
+
MODULES = ('identity_diagnostics', 'network_runtime', 'network_pools', 'network_inventory')
|
|
15
|
+
FAILURES = INVENTORY_FAILURES | frozenset({'network_inventory_unverified', 'network_command_unavailable', ''})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def remote_source(app):
|
|
19
|
+
"""Send only package-owned Python sources; no environment, source checkout or private inputs."""
|
|
20
|
+
sources = {name: Path(__file__).with_name(name + '.py').read_text() for name in MODULES}
|
|
21
|
+
program = '''import json, sys, types
|
|
22
|
+
sources = SOURCES
|
|
23
|
+
for name, source in sources.items():
|
|
24
|
+
module = types.ModuleType(name)
|
|
25
|
+
sys.modules[name] = module
|
|
26
|
+
exec(compile(source, name + '.py', 'exec'), module.__dict__)
|
|
27
|
+
from network_inventory import collect, InventoryFailure
|
|
28
|
+
from network_runtime import run, NetworkFailure
|
|
29
|
+
result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': ''}
|
|
30
|
+
try:
|
|
31
|
+
inventory = collect(run, require_pools=False)
|
|
32
|
+
own = [item for item in inventory['networks']
|
|
33
|
+
if item['Labels'].get('com.docker.compose.project') == APP]
|
|
34
|
+
result.update({key: inventory[key] for key in
|
|
35
|
+
('docker_version', 'compose_version', 'python_version', 'pool_source',
|
|
36
|
+
'pool_failure', 'pools', 'observed_at')})
|
|
37
|
+
result.update(ok=True, complete=True, network_count=len(inventory['networks']),
|
|
38
|
+
excluded_subnets=inventory['excluded'],
|
|
39
|
+
networks=[{'id': n['Id'], 'name': n['Name'], 'driver': n['Driver'],
|
|
40
|
+
'key': n['Labels'].get('com.docker.compose.network', ''),
|
|
41
|
+
'subnets': [p['Subnet'] for p in n['IPAM']['Config']],
|
|
42
|
+
'endpoints': len(n['Containers'])} for n in own])
|
|
43
|
+
except (InventoryFailure, NetworkFailure) as error:
|
|
44
|
+
result['failure'] = error.code
|
|
45
|
+
result.update(getattr(error, 'details', {}))
|
|
46
|
+
except Exception:
|
|
47
|
+
result['failure'] = 'network_inventory_unverified'
|
|
48
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
49
|
+
sys.exit(0 if result['ok'] else 1)
|
|
50
|
+
'''
|
|
51
|
+
return program.replace('SCHEMA', repr(SCHEMA)).replace('APP', repr(app)).replace('SOURCES', repr(sources)).encode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def decode(status, raw, app):
|
|
55
|
+
try:
|
|
56
|
+
result = json.loads(raw)
|
|
57
|
+
except (ValueError, TypeError):
|
|
58
|
+
raise ReaderFailure('remote_invalid') from None
|
|
59
|
+
allowed = {'schema', 'ok', 'project', 'failure', 'docker_version', 'compose_version', 'python_version',
|
|
60
|
+
'pool_source',
|
|
61
|
+
'pool_failure', 'pools', 'observed_at', 'complete', 'network_count', 'excluded_subnets', 'networks'}
|
|
62
|
+
if (not isinstance(result, dict) or set(result) - allowed or result.get('schema') != SCHEMA
|
|
63
|
+
or result.get('project') != app or type(result.get('ok')) is not bool
|
|
64
|
+
or status != (0 if result['ok'] else 1)):
|
|
65
|
+
raise ReaderFailure('remote_invalid')
|
|
66
|
+
if result.get('failure') not in FAILURES:
|
|
67
|
+
raise ReaderFailure('remote_invalid')
|
|
68
|
+
if result['ok']:
|
|
69
|
+
validate_inventory(result)
|
|
70
|
+
elif set(result) != {'schema', 'ok', 'project', 'failure'}:
|
|
71
|
+
partial = {'schema', 'ok', 'project', 'failure', 'docker_version', 'compose_version', 'python_version', 'pools',
|
|
72
|
+
'pool_source', 'pool_failure', 'observed_at', 'complete'}
|
|
73
|
+
if set(result) != partial or result['complete'] is not False:
|
|
74
|
+
raise ReaderFailure('remote_invalid')
|
|
75
|
+
validate_metadata(result)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_metadata(result):
|
|
80
|
+
for key in ('docker_version', 'compose_version', 'python_version'):
|
|
81
|
+
pattern = r'\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]{1,64})?'
|
|
82
|
+
if not isinstance(result.get(key), str) or not re.fullmatch(pattern, result[key]):
|
|
83
|
+
raise ReaderFailure('remote_invalid')
|
|
84
|
+
if result.get('pool_source') not in ('engine_info', 'versioned_moby_builtin', 'unverified'):
|
|
85
|
+
raise ReaderFailure('remote_invalid')
|
|
86
|
+
if (result.get('pool_failure') not in INVENTORY_FAILURES | {''}
|
|
87
|
+
or not isinstance(result.get('observed_at'), str)
|
|
88
|
+
or not re.fullmatch(r'[0-9T:+.\-Z]{20,40}', result['observed_at'])):
|
|
89
|
+
raise ReaderFailure('remote_invalid')
|
|
90
|
+
if not isinstance(result.get('pools'), list) or len(result['pools']) > 64:
|
|
91
|
+
raise ReaderFailure('remote_invalid')
|
|
92
|
+
try:
|
|
93
|
+
for pool in result['pools']:
|
|
94
|
+
if set(pool) != {'base', 'size'} or type(pool['size']) is not int or not 0 <= pool['size'] <= 32:
|
|
95
|
+
raise ValueError()
|
|
96
|
+
ipaddress.IPv4Network(pool['base'], strict=True)
|
|
97
|
+
except (ValueError, TypeError, KeyError):
|
|
98
|
+
raise ReaderFailure('remote_invalid') from None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_inventory(result):
|
|
102
|
+
validate_metadata(result)
|
|
103
|
+
if (result.get('complete') is not True or type(result.get('network_count')) is not int
|
|
104
|
+
or not 0 <= result['network_count'] <= 1024 or result['failure']):
|
|
105
|
+
raise ReaderFailure('remote_invalid')
|
|
106
|
+
for field, limit in (('excluded_subnets', 16384), ('networks', 1024)):
|
|
107
|
+
if not isinstance(result.get(field), list) or len(result[field]) > limit:
|
|
108
|
+
raise ReaderFailure('remote_invalid')
|
|
109
|
+
try:
|
|
110
|
+
for value in result['excluded_subnets']:
|
|
111
|
+
ipaddress.IPv4Network(value, strict=True)
|
|
112
|
+
for network in result['networks']:
|
|
113
|
+
validate_network(network, result['project'])
|
|
114
|
+
except (ValueError, TypeError, KeyError):
|
|
115
|
+
raise ReaderFailure('remote_invalid') from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_network(network, project):
|
|
119
|
+
if (not isinstance(network, dict) or set(network) != {'id', 'name', 'driver', 'key', 'subnets', 'endpoints'}
|
|
120
|
+
or not isinstance(network['id'], str) or not re.fullmatch('[0-9a-f]{64}', network['id'])
|
|
121
|
+
or not isinstance(network['name'], str) or not re.fullmatch('[A-Za-z0-9_.-]{1,256}', network['name'])
|
|
122
|
+
or not isinstance(network['key'], str) or not re.fullmatch('[A-Za-z0-9_.-]{0,128}', network['key'])
|
|
123
|
+
or network['driver'] not in ('bridge', 'host', 'null', 'overlay', 'macvlan', 'ipvlan')
|
|
124
|
+
or type(network['endpoints']) is not int or not 0 <= network['endpoints'] <= 100000
|
|
125
|
+
or not isinstance(network['subnets'], list) or len(network['subnets']) > 64):
|
|
126
|
+
raise ValueError()
|
|
127
|
+
for value in network['subnets']:
|
|
128
|
+
ipaddress.ip_network(value, strict=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def diagnose(app, host, user):
|
|
132
|
+
if (not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,79}', app)
|
|
133
|
+
or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', host)
|
|
134
|
+
or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', user)):
|
|
135
|
+
raise ReaderFailure('invalid_destination')
|
|
136
|
+
remote = shlex.join(['python3', '-'])
|
|
137
|
+
argv = ['ssh', '-i', str(Path.home() / '.ssh/backend_deploy'), '-o', 'IdentitiesOnly=yes',
|
|
138
|
+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5',
|
|
139
|
+
'-o', 'ServerAliveCountMax=2', user + '@' + host, remote]
|
|
140
|
+
status, raw = bounded(argv, payload=remote_source(app), seconds=60, with_status=True)
|
|
141
|
+
return decode(status, raw, app)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
146
|
+
parser.add_argument('--app', required=True)
|
|
147
|
+
parser.add_argument('--host', required=True)
|
|
148
|
+
parser.add_argument('--user', default='root')
|
|
149
|
+
parser.add_argument('--output', required=True)
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
try:
|
|
152
|
+
result = diagnose(args.app, args.host, args.user)
|
|
153
|
+
except Exception as error:
|
|
154
|
+
code = error.code if isinstance(error, ReaderFailure) else 'network_diagnostic_unverified'
|
|
155
|
+
result = {'schema': SCHEMA, 'ok': False, 'project': args.app, 'failure': code}
|
|
156
|
+
try:
|
|
157
|
+
retain_result(args.output, result)
|
|
158
|
+
except ReaderFailure:
|
|
159
|
+
return 1
|
|
160
|
+
print(json.dumps(result, separators=(',', ':')))
|
|
161
|
+
return 0 if result['ok'] else 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == '__main__':
|
|
165
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Bounded private Docker/host inventory for allocation and scoped diagnostics (Python 3.8)."""
|
|
2
|
+
import datetime
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from network_pools import (InventoryFailure, address_exclusions, decode, effective_pools, prefix,
|
|
9
|
+
resolver_exclusions, route_exclusions, validate_rules)
|
|
10
|
+
|
|
11
|
+
LIMIT = 1024 * 1024
|
|
12
|
+
INFO_FORMAT = ('{"version":{{json .ServerVersion}},"os":{{json .OSType}},'
|
|
13
|
+
'"security":{{json .SecurityOptions}},"pools":{{json .DefaultAddressPools}}}')
|
|
14
|
+
HOST_PROBE = '''import json, os, socket, stat, struct, sys
|
|
15
|
+
path = sys.argv[1]
|
|
16
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
17
|
+
connection.settimeout(3)
|
|
18
|
+
connection.connect(path)
|
|
19
|
+
peer, uid, gid = struct.unpack('3i', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
|
20
|
+
record = os.lstat('/run/docker.pid')
|
|
21
|
+
with open('/run/docker.pid', encoding='ascii') as source:
|
|
22
|
+
value = source.read(32).strip()
|
|
23
|
+
if not value.isdigit() or not 1 < int(value) < 4194304:
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
pid = int(value)
|
|
26
|
+
with open('/proc/%d/comm' % pid, encoding='ascii') as source:
|
|
27
|
+
command = source.read(32).strip()
|
|
28
|
+
own = os.stat('/proc/self/ns/net').st_ino
|
|
29
|
+
daemon = os.stat('/proc/%d/ns/net' % pid).st_ino
|
|
30
|
+
init = os.stat('/proc/1/ns/net').st_ino
|
|
31
|
+
rootful = uid == 0 and os.stat('/proc/%d' % pid).st_uid == 0 and peer in (1, pid)
|
|
32
|
+
pidfile = stat.S_ISREG(record.st_mode) and record.st_uid == 0 and not record.st_mode & 0o022
|
|
33
|
+
print(json.dumps({'local_rootful': rootful and pidfile and command == 'dockerd',
|
|
34
|
+
'same_namespace': own == daemon == init}))
|
|
35
|
+
'''
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Reader:
|
|
39
|
+
def __init__(self, run):
|
|
40
|
+
self.run, self.deadline, self.total = run, time.monotonic() + 45, 0
|
|
41
|
+
|
|
42
|
+
def __call__(self, argv, *, parsed=True):
|
|
43
|
+
remaining = self.deadline - time.monotonic()
|
|
44
|
+
if remaining <= 0:
|
|
45
|
+
raise InventoryFailure('inventory_timeout')
|
|
46
|
+
try:
|
|
47
|
+
raw = self.run(argv, seconds=min(10, remaining))
|
|
48
|
+
except Exception as error:
|
|
49
|
+
code = {'command_timeout': 'inventory_timeout', 'output_limit': 'inventory_output_limit'}
|
|
50
|
+
raise InventoryFailure(code.get(getattr(error, 'code', ''), 'inventory_command_failed')) from None
|
|
51
|
+
if not isinstance(raw, bytes) or len(raw) > LIMIT:
|
|
52
|
+
raise InventoryFailure('inventory_output_limit')
|
|
53
|
+
self.total += len(raw)
|
|
54
|
+
if self.total > 8 * LIMIT:
|
|
55
|
+
raise InventoryFailure('inventory_output_limit')
|
|
56
|
+
return decode(raw) if parsed else raw
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def host_proof(read):
|
|
60
|
+
if (os.environ.get('DOCKER_HOST', '') not in ('', 'unix:///var/run/docker.sock', 'unix:///run/docker.sock')
|
|
61
|
+
or os.environ.get('DOCKER_CONTEXT', '') not in ('', 'default')
|
|
62
|
+
or any(os.environ.get(key) for key in
|
|
63
|
+
('DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH', 'DOCKER_API_VERSION', 'DOCKER_CONFIG'))):
|
|
64
|
+
raise InventoryFailure('docker_endpoint_unsupported')
|
|
65
|
+
endpoint = read(['docker', 'context', 'inspect', '--format', '{{json .Endpoints.docker.Host}}'])
|
|
66
|
+
if endpoint not in ('unix:///var/run/docker.sock', 'unix:///run/docker.sock'):
|
|
67
|
+
raise InventoryFailure('docker_endpoint_unsupported')
|
|
68
|
+
try:
|
|
69
|
+
proof = read([sys.executable, '-c', HOST_PROBE, endpoint[len('unix://'):]])
|
|
70
|
+
except InventoryFailure:
|
|
71
|
+
raise InventoryFailure('host_namespace_unverified') from None
|
|
72
|
+
if (not isinstance(proof, dict) or set(proof) != {'local_rootful', 'same_namespace'}
|
|
73
|
+
or any(value is not True for value in proof.values())):
|
|
74
|
+
raise InventoryFailure('host_namespace_unverified')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def engine_info(read, require_pools):
|
|
78
|
+
info = read(['docker', 'info', '--format', INFO_FORMAT])
|
|
79
|
+
compose = read(['docker', 'compose', 'version', '--short'], parsed=False)
|
|
80
|
+
try:
|
|
81
|
+
compose = compose.decode('ascii').strip()
|
|
82
|
+
if compose.startswith('v'):
|
|
83
|
+
compose = compose[1:]
|
|
84
|
+
except UnicodeError:
|
|
85
|
+
raise InventoryFailure('docker_engine_unsupported') from None
|
|
86
|
+
if (not isinstance(info, dict) or set(info) != {'version', 'os', 'security', 'pools'}
|
|
87
|
+
or not isinstance(info['version'], str)
|
|
88
|
+
or not re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]{1,64})?', info['version'])
|
|
89
|
+
or not re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][A-Za-z0-9._-]{1,64})?', compose) or info['os'] != 'linux'
|
|
90
|
+
or not isinstance(info['security'], list)
|
|
91
|
+
or any(not isinstance(value, str) or 'rootless' in value for value in info['security'])):
|
|
92
|
+
raise InventoryFailure('docker_engine_unsupported')
|
|
93
|
+
result = {'docker_version': info['version'], 'compose_version': compose,
|
|
94
|
+
'python_version': '.'.join(str(value) for value in sys.version_info[:3]),
|
|
95
|
+
'pools': [], 'pool_source': 'unverified', 'pool_failure': ''}
|
|
96
|
+
try:
|
|
97
|
+
result['pools'], result['pool_source'] = effective_pools(info['pools'], info['version'])
|
|
98
|
+
except InventoryFailure as error:
|
|
99
|
+
if require_pools:
|
|
100
|
+
raise
|
|
101
|
+
result['pool_failure'] = error.code
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def network_ipam(value):
|
|
106
|
+
if (not isinstance(value, dict) or set(value) - {'Driver', 'Options', 'Config'}
|
|
107
|
+
or value.get('Driver') not in ('default', 'null') or not isinstance(value.get('Config'), list)):
|
|
108
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
109
|
+
options = value.get('Options')
|
|
110
|
+
if options is not None and (not isinstance(options, dict) or options):
|
|
111
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
112
|
+
result, excluded = [], set()
|
|
113
|
+
for row in value['Config']:
|
|
114
|
+
if not isinstance(row, dict) or set(row) - {'Subnet', 'Gateway', 'IPRange', 'AuxiliaryAddresses'}:
|
|
115
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
116
|
+
subnet = prefix(row.get('Subnet'), strict=True)
|
|
117
|
+
if subnet.version == 4:
|
|
118
|
+
excluded.add(str(subnet))
|
|
119
|
+
for key in ('Gateway', 'IPRange'):
|
|
120
|
+
if row.get(key):
|
|
121
|
+
address = prefix(row[key])
|
|
122
|
+
if address.version != subnet.version or not address.subnet_of(subnet):
|
|
123
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
124
|
+
auxiliary = row.get('AuxiliaryAddresses') or {}
|
|
125
|
+
if not isinstance(auxiliary, dict):
|
|
126
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
127
|
+
for address in auxiliary.values():
|
|
128
|
+
if not prefix(address).subnet_of(subnet):
|
|
129
|
+
raise InventoryFailure('network_ipam_unsupported')
|
|
130
|
+
result.append(dict(row))
|
|
131
|
+
return {'Driver': value['Driver'], 'Options': options or {}, 'Config': result}, excluded
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def network_record(row):
|
|
135
|
+
if not isinstance(row, dict):
|
|
136
|
+
raise InventoryFailure('inventory_invalid')
|
|
137
|
+
result = {key: row.get(key) for key in ('Id', 'Name', 'Driver', 'Scope', 'Internal', 'Attachable', 'EnableIPv6')}
|
|
138
|
+
if (not isinstance(result['Id'], str) or not re.fullmatch(r'[a-f0-9]{64}', result['Id'])
|
|
139
|
+
or not isinstance(result['Name'], str) or not 1 <= len(result['Name']) <= 256
|
|
140
|
+
or result['Driver'] not in ('bridge', 'host', 'null', 'overlay', 'macvlan', 'ipvlan')
|
|
141
|
+
or result['Scope'] not in ('local', 'swarm', 'global')
|
|
142
|
+
or any(type(result[key]) is not bool for key in ('Internal', 'Attachable', 'EnableIPv6'))):
|
|
143
|
+
raise InventoryFailure('inventory_invalid')
|
|
144
|
+
for key in ('Labels', 'Options'):
|
|
145
|
+
value = row.get(key)
|
|
146
|
+
if value is not None and (not isinstance(value, dict) or any(
|
|
147
|
+
not isinstance(k, str) or not isinstance(v, str) for k, v in value.items())):
|
|
148
|
+
raise InventoryFailure('inventory_invalid')
|
|
149
|
+
result[key] = value or {}
|
|
150
|
+
containers = row.get('Containers')
|
|
151
|
+
if not isinstance(containers, dict) or any(not isinstance(item, dict) for item in containers.values()):
|
|
152
|
+
raise InventoryFailure('inventory_invalid')
|
|
153
|
+
result['Containers'] = {key: {} for key in containers}
|
|
154
|
+
ipam = row.get('IPAM')
|
|
155
|
+
if result['Driver'] in ('host', 'null') and isinstance(ipam, dict) and ipam.get('Config', []) is None:
|
|
156
|
+
ipam = dict(ipam, Config=[])
|
|
157
|
+
result['IPAM'], excluded = network_ipam(ipam)
|
|
158
|
+
return result, excluded
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def network_ids(raw):
|
|
162
|
+
try:
|
|
163
|
+
ids = raw.decode('ascii').splitlines()
|
|
164
|
+
except UnicodeError:
|
|
165
|
+
raise InventoryFailure('inventory_invalid') from None
|
|
166
|
+
if (not ids or len(ids) > 1024 or len(set(ids)) != len(ids)
|
|
167
|
+
or any(not re.fullmatch(r'[a-f0-9]{64}', value) for value in ids)):
|
|
168
|
+
raise InventoryFailure('inventory_invalid')
|
|
169
|
+
return ids
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def network_scan(read):
|
|
173
|
+
listing = ['docker', 'network', 'ls', '--no-trunc', '--format', '{{.ID}}']
|
|
174
|
+
ids = network_ids(read(listing, parsed=False))
|
|
175
|
+
networks, excluded = [], set()
|
|
176
|
+
for offset in range(0, len(ids), 32):
|
|
177
|
+
batch = ids[offset:offset + 32]
|
|
178
|
+
rows = read(['docker', 'network', 'inspect', *batch])
|
|
179
|
+
if not isinstance(rows, list) or len(rows) != len(batch):
|
|
180
|
+
raise InventoryFailure('inventory_invalid')
|
|
181
|
+
observed = []
|
|
182
|
+
for row in rows:
|
|
183
|
+
network, prefixes = network_record(row)
|
|
184
|
+
networks.append(network)
|
|
185
|
+
observed.append(network['Id'])
|
|
186
|
+
excluded.update(prefixes)
|
|
187
|
+
if sorted(observed) != sorted(batch):
|
|
188
|
+
raise InventoryFailure('inventory_invalid')
|
|
189
|
+
if sorted(network_ids(read(listing, parsed=False))) != sorted(ids):
|
|
190
|
+
raise InventoryFailure('network_inventory_changed')
|
|
191
|
+
return networks, excluded
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def host_networks(read):
|
|
195
|
+
routes = read(['ip', '-j', '-4', 'route', 'show', 'table', 'all'])
|
|
196
|
+
rules = read(['ip', '-j', '-4', 'rule', 'show'])
|
|
197
|
+
addresses = read(['ip', '-j', '-4', 'address', 'show'])
|
|
198
|
+
resolver = read(['cat', '/etc/resolv.conf'], parsed=False)
|
|
199
|
+
validate_rules(rules)
|
|
200
|
+
records = []
|
|
201
|
+
excluded = route_exclusions(routes, records) | address_exclusions(addresses, records)
|
|
202
|
+
dns, stub = resolver_exclusions(resolver, records=records)
|
|
203
|
+
if stub:
|
|
204
|
+
try:
|
|
205
|
+
upstream = read(['resolvectl', 'dns'], parsed=False)
|
|
206
|
+
except InventoryFailure:
|
|
207
|
+
raise InventoryFailure('resolver_upstream_unknown') from None
|
|
208
|
+
dns.update(resolver_exclusions(upstream, resolved=True, records=records)[0])
|
|
209
|
+
return excluded | dns, sorted(records, key=lambda row: (row['subnet'], row['kind'], row['device']))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def collect(run, *, require_pools=True):
|
|
213
|
+
"""Return private fields only; callers must project their own closed public receipt."""
|
|
214
|
+
read = Reader(run)
|
|
215
|
+
host_proof(read)
|
|
216
|
+
result = engine_info(read, False)
|
|
217
|
+
try:
|
|
218
|
+
if require_pools and result['pool_failure']:
|
|
219
|
+
raise InventoryFailure(result['pool_failure'])
|
|
220
|
+
before = host_networks(read)
|
|
221
|
+
networks, occupied = network_scan(read)
|
|
222
|
+
after = host_networks(read)
|
|
223
|
+
if before != after:
|
|
224
|
+
raise InventoryFailure('host_inventory_changed')
|
|
225
|
+
host_proof(read)
|
|
226
|
+
if engine_info(read, False) != result:
|
|
227
|
+
raise InventoryFailure('host_inventory_changed')
|
|
228
|
+
except InventoryFailure as error:
|
|
229
|
+
error.details = dict(result, complete=False,
|
|
230
|
+
observed_at=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
|
231
|
+
raise
|
|
232
|
+
result.update({'networks': networks, 'excluded': sorted(occupied | after[0]), 'host_excluded': sorted(after[0]),
|
|
233
|
+
'host_records': after[1],
|
|
234
|
+
'complete': True, 'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat()})
|
|
235
|
+
return result
|