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.
Files changed (33) hide show
  1. package/CLAUDE.md +6 -0
  2. package/README.md +4 -0
  3. package/action/.daemux-version +1 -1
  4. package/android-action/.daemux-version +1 -1
  5. package/backend-action/.daemux-version +1 -1
  6. package/backend-action/action.yml +35 -2
  7. package/backend-action/network_contract.py +119 -0
  8. package/backend-action/network_diagnostics.py +165 -0
  9. package/backend-action/network_inventory.py +235 -0
  10. package/backend-action/network_owned_inventory.py +118 -0
  11. package/backend-action/network_pool_facts.json +214 -0
  12. package/backend-action/network_pools.py +273 -0
  13. package/backend-action/network_prepare.py +151 -0
  14. package/backend-action/network_readback.py +25 -0
  15. package/backend-action/network_runtime.py +62 -0
  16. package/backend-action/network_test_fixture.py +56 -0
  17. package/backend-action/remote_deploy.sh +1 -0
  18. package/backend-action/test_network_builtins.py +28 -0
  19. package/backend-action/test_network_diagnostics.py +57 -0
  20. package/backend-action/test_network_docker.py +129 -0
  21. package/backend-action/test_network_fixture_evidence.py +23 -0
  22. package/backend-action/test_network_inventory.py +241 -0
  23. package/backend-action/test_network_ipam_gateway.py +46 -0
  24. package/backend-action/test_network_noop.py +64 -0
  25. package/backend-action/test_network_owned_inventory.py +128 -0
  26. package/backend-action/test_network_prepare.py +99 -0
  27. package/backend-action/test_network_races.py +73 -0
  28. package/backend-action/test_network_rule_diagnostics.py +53 -0
  29. package/backend-action/test_network_runtime.py +49 -0
  30. package/docs/BACKEND_NETWORKS.md +102 -0
  31. package/package.json +2 -1
  32. package/templates/deploy-backend.yml +3 -3
  33. 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).
@@ -1 +1 @@
1
- 1.0.150
1
+ 1.0.152
@@ -1 +1 @@
1
- 1.0.150
1
+ 1.0.152
@@ -1 +1 @@
1
- 1.0.150
1
+ 1.0.152
@@ -7,7 +7,7 @@ description: >
7
7
 
8
8
  inputs:
9
9
  operation:
10
- description: deploy (default) or identity-diagnostics (read-only fixed Firebase refusal labels).
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