gowalk-cicd 1.0.154 → 1.0.156

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.
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.156
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.156
@@ -1 +1 @@
1
- 1.0.154
1
+ 1.0.156
@@ -18,6 +18,10 @@ inputs:
18
18
  description: UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours; required for diagnostics.
19
19
  required: false
20
20
  default: ""
21
+ diagnostic-provider:
22
+ description: Optional fixed metadata source for network-diagnostics (none or digitalocean).
23
+ required: false
24
+ default: none
21
25
  host:
22
26
  description: SSH host of the deploy server.
23
27
  required: false
@@ -264,11 +268,13 @@ runs:
264
268
  DIAGNOSTIC_APP: ${{ inputs.app-name }}
265
269
  DIAGNOSTIC_HOST: ${{ inputs.host }}
266
270
  DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
271
+ DIAGNOSTIC_PROVIDER: ${{ inputs.diagnostic-provider }}
267
272
  run: |
268
273
  RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
269
274
  echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
270
275
  python3 "${{ github.action_path }}/network_diagnostics.py" \
271
276
  --app "$DIAGNOSTIC_APP" --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
277
+ --provider "$DIAGNOSTIC_PROVIDER" \
272
278
  --output "$RESULT_DIR/result.json"
273
279
 
274
280
  - name: Retain backend network diagnostics
@@ -9,13 +9,16 @@ import shlex
9
9
 
10
10
  from identity_diagnostics import ReaderFailure, bounded, retain_result
11
11
  from network_pools import FAILURES as INVENTORY_FAILURES
12
+ from network_selected_receipt import validate_selected
13
+ from network_provider import validate_provider
12
14
 
13
- SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v1'
14
- MODULES = ('identity_diagnostics', 'network_runtime', 'network_pools', 'network_inventory')
15
+ SCHEMA = 'gowalk-cicd/backend-network-diagnostics.v2'
16
+ MODULES = ('identity_diagnostics', 'network_runtime', 'network_pools', 'network_inventory',
17
+ 'network_readback', 'network_selected', 'network_provider')
15
18
  FAILURES = INVENTORY_FAILURES | frozenset({'network_inventory_unverified', 'network_command_unavailable', ''})
16
19
 
17
20
 
18
- def remote_source(app):
21
+ def remote_source(app, host='', provider='none'):
19
22
  """Send only package-owned Python sources; no environment, source checkout or private inputs."""
20
23
  sources = {name: Path(__file__).with_name(name + '.py').read_text() for name in MODULES}
21
24
  program = '''import json, sys, types
@@ -26,6 +29,8 @@ for name, source in sources.items():
26
29
  exec(compile(source, name + '.py', 'exec'), module.__dict__)
27
30
  from network_inventory import collect, InventoryFailure
28
31
  from network_runtime import run, NetworkFailure
32
+ from network_selected import selected_validation
33
+ from network_provider import observe
29
34
  result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': ''}
30
35
  try:
31
36
  inventory = collect(run, require_pools=False)
@@ -36,19 +41,24 @@ try:
36
41
  'pool_failure', 'pools', 'observed_at')})
37
42
  result.update(ok=True, complete=True, network_count=len(inventory['networks']),
38
43
  excluded_subnets=inventory['excluded'],
44
+ host_identity=observe(inventory, HOST) if PROVIDER == 'digitalocean' else
45
+ {'provider': 'none', 'status': 'not_requested'},
39
46
  networks=[{'id': n['Id'], 'name': n['Name'], 'driver': n['Driver'],
40
47
  'key': n['Labels'].get('com.docker.compose.network', ''),
41
48
  'subnets': [p['Subnet'] for p in n['IPAM']['Config']],
42
- 'endpoints': len(n['Containers'])} for n in own])
49
+ 'endpoints': len(n['Containers']),
50
+ 'validation': selected_validation(inventory, n, APP)} for n in own])
43
51
  except (InventoryFailure, NetworkFailure) as error:
44
- result['failure'] = error.code
52
+ result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': error.code}
45
53
  result.update(getattr(error, 'details', {}))
46
54
  except Exception:
47
- result['failure'] = 'network_inventory_unverified'
55
+ result = {'schema': SCHEMA, 'ok': False, 'project': APP, 'failure': 'network_inventory_unverified'}
48
56
  print(json.dumps(result, separators=(',', ':')))
49
57
  sys.exit(0 if result['ok'] else 1)
50
58
  '''
51
- return program.replace('SCHEMA', repr(SCHEMA)).replace('APP', repr(app)).replace('SOURCES', repr(sources)).encode()
59
+ program = program.replace('SCHEMA', repr(SCHEMA)).replace('APP', repr(app))
60
+ return program.replace('HOST', repr(host)).replace('PROVIDER', repr(provider)).replace(
61
+ 'SOURCES', repr(sources)).encode()
52
62
 
53
63
 
54
64
  def decode(status, raw, app):
@@ -57,7 +67,7 @@ def decode(status, raw, app):
57
67
  except (ValueError, TypeError):
58
68
  raise ReaderFailure('remote_invalid') from None
59
69
  allowed = {'schema', 'ok', 'project', 'failure', 'docker_version', 'compose_version', 'python_version',
60
- 'pool_source',
70
+ 'pool_source', 'host_identity',
61
71
  'pool_failure', 'pools', 'observed_at', 'complete', 'network_count', 'excluded_subnets', 'networks'}
62
72
  if (not isinstance(result, dict) or set(result) - allowed or result.get('schema') != SCHEMA
63
73
  or result.get('project') != app or type(result.get('ok')) is not bool
@@ -107,16 +117,19 @@ def validate_inventory(result):
107
117
  if not isinstance(result.get(field), list) or len(result[field]) > limit:
108
118
  raise ReaderFailure('remote_invalid')
109
119
  try:
120
+ if result.get('host_identity') != {'provider': 'none', 'status': 'not_requested'}:
121
+ validate_provider(result.get('host_identity'))
110
122
  for value in result['excluded_subnets']:
111
123
  ipaddress.IPv4Network(value, strict=True)
112
124
  for network in result['networks']:
113
- validate_network(network, result['project'])
125
+ validate_network(network, result)
114
126
  except (ValueError, TypeError, KeyError):
115
127
  raise ReaderFailure('remote_invalid') from None
116
128
 
117
129
 
118
- def validate_network(network, project):
119
- if (not isinstance(network, dict) or set(network) != {'id', 'name', 'driver', 'key', 'subnets', 'endpoints'}
130
+ def validate_network(network, inventory):
131
+ fields = {'id', 'name', 'driver', 'key', 'subnets', 'endpoints', 'validation'}
132
+ if (not isinstance(network, dict) or set(network) != fields
120
133
  or not isinstance(network['id'], str) or not re.fullmatch('[0-9a-f]{64}', network['id'])
121
134
  or not isinstance(network['name'], str) or not re.fullmatch('[A-Za-z0-9_.-]{1,256}', network['name'])
122
135
  or not isinstance(network['key'], str) or not re.fullmatch('[A-Za-z0-9_.-]{0,128}', network['key'])
@@ -125,19 +138,22 @@ def validate_network(network, project):
125
138
  or not isinstance(network['subnets'], list) or len(network['subnets']) > 64):
126
139
  raise ValueError()
127
140
  for value in network['subnets']:
141
+ if not isinstance(value, str):
142
+ raise ValueError()
128
143
  ipaddress.ip_network(value, strict=True)
144
+ validate_selected(network['validation'], network, inventory)
129
145
 
130
146
 
131
- def diagnose(app, host, user):
147
+ def diagnose(app, host, user, provider='none'):
132
148
  if (not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,79}', app)
133
149
  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)):
150
+ or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', user) or provider not in ('none', 'digitalocean')):
135
151
  raise ReaderFailure('invalid_destination')
136
152
  remote = shlex.join(['python3', '-'])
137
153
  argv = ['ssh', '-i', str(Path.home() / '.ssh/backend_deploy'), '-o', 'IdentitiesOnly=yes',
138
154
  '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5',
139
155
  '-o', 'ServerAliveCountMax=2', user + '@' + host, remote]
140
- status, raw = bounded(argv, payload=remote_source(app), seconds=60, with_status=True)
156
+ status, raw = bounded(argv, payload=remote_source(app, host, provider), seconds=60, with_status=True)
141
157
  return decode(status, raw, app)
142
158
 
143
159
 
@@ -146,10 +162,11 @@ def main():
146
162
  parser.add_argument('--app', required=True)
147
163
  parser.add_argument('--host', required=True)
148
164
  parser.add_argument('--user', default='root')
165
+ parser.add_argument('--provider', choices=('none', 'digitalocean'), default='none')
149
166
  parser.add_argument('--output', required=True)
150
167
  args = parser.parse_args()
151
168
  try:
152
- result = diagnose(args.app, args.host, args.user)
169
+ result = diagnose(args.app, args.host, args.user, args.provider)
153
170
  except Exception as error:
154
171
  code = error.code if isinstance(error, ReaderFailure) else 'network_diagnostic_unverified'
155
172
  result = {'schema': SCHEMA, 'ok': False, 'project': args.app, 'failure': code}
@@ -122,7 +122,8 @@ def prepare(config, app, *, reader=collect, owner_reader=ownership_inventory, co
122
122
  inventory = reader(command, require_pools=False)
123
123
  if inventory.get('pool_failure'):
124
124
  raise NetworkFailure(inventory['pool_failure'])
125
- if not re.fullmatch(r'v?2\.\d+\.\d+', inventory['compose_version']):
125
+ # Compose 2 and 5 reuse a project network that carries no config-hash label (docs/BACKEND_NETWORKS.md).
126
+ if not re.fullmatch(r'v?(?:2|5)\.\d+\.\d+', inventory['compose_version']):
126
127
  raise NetworkFailure('compose_version_unsupported')
127
128
  rows.append(create_one(plan, inventory, reader, command))
128
129
  inventory = reader(command, require_pools=False)
@@ -0,0 +1,213 @@
1
+ """Optional fixed DigitalOcean metadata readback; identity evidence never delegates Docker address space."""
2
+ import ipaddress
3
+ import re
4
+ import socket
5
+ import time
6
+
7
+ ORIGIN = ('169.254.169.254', 80)
8
+ PATHS = {
9
+ 'droplet_id': '/metadata/v1/id',
10
+ 'region': '/metadata/v1/region',
11
+ 'public_ipv4': '/metadata/v1/interfaces/public/0/ipv4/address',
12
+ 'private_ipv4': '/metadata/v1/interfaces/private/0/ipv4/address',
13
+ 'netmask': '/metadata/v1/interfaces/private/0/ipv4/netmask',
14
+ 'private_gateway': '/metadata/v1/interfaces/private/0/ipv4/gateway',
15
+ }
16
+ IDENTITY = ('droplet_id', 'region', 'public_ipv4', 'private_ipv4', 'private_subnet', 'private_gateway')
17
+ PRIVATE = tuple(ipaddress.IPv4Network(value) for value in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'))
18
+ REASONS = {
19
+ 'verified': {''},
20
+ 'mismatch': {'public_address_mismatch', 'public_address_unobserved', 'private_address_mismatch'},
21
+ 'unavailable': {'metadata_missing', 'metadata_unavailable', 'metadata_timeout'},
22
+ 'unverified': {'expected_host_unverified', 'host_inventory_unverified',
23
+ 'metadata_invalid', 'metadata_response_limit'},
24
+ }
25
+
26
+
27
+ class MetadataFailure(ValueError):
28
+ pass
29
+
30
+
31
+ def _timeout(deadline):
32
+ remaining = deadline - time.monotonic()
33
+ if remaining <= 0:
34
+ raise MetadataFailure('metadata_timeout')
35
+ return remaining
36
+
37
+
38
+ def _headers(connection, deadline):
39
+ raw = bytearray()
40
+ while not raw.endswith(b'\r\n\r\n'):
41
+ if len(raw) >= 8192:
42
+ raise MetadataFailure('metadata_response_limit')
43
+ connection.settimeout(_timeout(deadline))
44
+ value = connection.recv(1)
45
+ if not value:
46
+ raise MetadataFailure('metadata_invalid')
47
+ raw.extend(value)
48
+ lines = bytes(raw[:-4]).split(b'\r\n')
49
+ if len(lines) > 17:
50
+ raise MetadataFailure('metadata_response_limit')
51
+ match = re.fullmatch(rb'HTTP/1\.[01] ([1-5][0-9]{2})(?: [\x20-\x7e]*)?', lines[0])
52
+ if not match:
53
+ raise MetadataFailure('metadata_invalid')
54
+ if match[1] != b'200':
55
+ raise MetadataFailure('metadata_missing' if match[1] == b'404' else 'metadata_unavailable')
56
+ headers = {}
57
+ for line in lines[1:]:
58
+ match = re.fullmatch(rb"([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)", line)
59
+ if not match or match[1].lower() in headers:
60
+ raise MetadataFailure('metadata_invalid')
61
+ headers[match[1].lower()] = match[2].strip()
62
+ if b'transfer-encoding' in headers or headers.get(b'content-encoding', b'identity') != b'identity':
63
+ raise MetadataFailure('metadata_invalid')
64
+ length = headers.get(b'content-length')
65
+ if length is not None and not re.fullmatch(rb'[0-9]{1,3}', length):
66
+ raise MetadataFailure('metadata_invalid')
67
+ if length is not None and int(length) > 128:
68
+ raise MetadataFailure('metadata_response_limit')
69
+ return None if length is None else int(length)
70
+
71
+
72
+ def _fetch(path, deadline):
73
+ """One HTTP/1.0 request, no proxy, DNS, redirect, authentication, discovery or retry."""
74
+ if path not in PATHS.values():
75
+ raise MetadataFailure('metadata_invalid')
76
+ deadline = min(deadline, time.monotonic() + 2)
77
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connection:
78
+ connection.settimeout(_timeout(deadline))
79
+ connection.connect(ORIGIN)
80
+ connection.settimeout(_timeout(deadline))
81
+ request = 'GET ' + path + ' HTTP/1.0\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n'
82
+ connection.sendall(request.encode('ascii'))
83
+ length = _headers(connection, deadline)
84
+ body = bytearray()
85
+ limit = 129 if length is None else length
86
+ while len(body) < limit:
87
+ connection.settimeout(_timeout(deadline))
88
+ value = connection.recv(limit - len(body))
89
+ if not value:
90
+ break
91
+ body.extend(value)
92
+ _timeout(deadline)
93
+ if len(body) > 128:
94
+ raise MetadataFailure('metadata_response_limit')
95
+ if length is not None and len(body) != length:
96
+ raise MetadataFailure('metadata_invalid')
97
+ return bytes(body)
98
+
99
+
100
+ def _address(value):
101
+ if not isinstance(value, str):
102
+ raise ValueError()
103
+ address = ipaddress.IPv4Address(value)
104
+ if str(address) != value:
105
+ raise ValueError()
106
+ return address
107
+
108
+
109
+ def _identity(values):
110
+ if (not re.fullmatch(r'[1-9][0-9]{0,19}', values['droplet_id'])
111
+ or not re.fullmatch(r'[a-z][a-z0-9-]{1,31}', values['region'])):
112
+ raise ValueError()
113
+ _address(values['public_ipv4'])
114
+ private, gateway = _address(values['private_ipv4']), _address(values['private_gateway'])
115
+ subnet = ipaddress.IPv4Network(values['private_subnet'], strict=True)
116
+ if (str(subnet) != values['private_subnet'] or subnet.prefixlen > 30
117
+ or not any(subnet.subnet_of(space) for space in PRIVATE)
118
+ or any(address not in subnet or address in (subnet.network_address, subnet.broadcast_address)
119
+ for address in (private, gateway)) or private == gateway):
120
+ raise ValueError()
121
+
122
+
123
+ def _read_values(fetch, deadline):
124
+ values = {}
125
+ for field, path in PATHS.items():
126
+ leaf_deadline = min(deadline, time.monotonic() + 2)
127
+ _timeout(leaf_deadline)
128
+ raw = fetch(path, leaf_deadline)
129
+ _timeout(leaf_deadline)
130
+ if not isinstance(raw, bytes):
131
+ raise MetadataFailure('metadata_invalid')
132
+ if len(raw) > 128:
133
+ raise MetadataFailure('metadata_response_limit')
134
+ values[field] = raw.decode('ascii').strip(' \t\r\n')
135
+ mask = values.pop('netmask')
136
+ mask_network = ipaddress.IPv4Network('0.0.0.0/' + mask)
137
+ if str(mask_network.netmask) != mask:
138
+ raise ValueError()
139
+ values['private_subnet'] = str(ipaddress.IPv4Network(values['private_ipv4'] + '/' + mask, strict=False))
140
+ _identity(values)
141
+ return values
142
+
143
+
144
+ def _binding(inventory, values, expected_host):
145
+ records = inventory['host_records']
146
+ bridges = {'docker0'}
147
+ for network in inventory['networks']:
148
+ if network['Driver'] == 'bridge':
149
+ bridges.add('br-' + network['Id'][:12])
150
+ bridges.add((network.get('Options') or {}).get('com.docker.network.bridge.name', ''))
151
+ facts = {}
152
+ for row in records:
153
+ facts.setdefault((row['kind'], row['subnet']), set()).add(row['device'])
154
+ if values['public_ipv4'] != expected_host:
155
+ return 'public_address_mismatch', 'unverified'
156
+ public = facts.get(('address_local', values['public_ipv4'] + '/32'), set())
157
+ if len(public) != 1 or public & bridges or any(not device or device.startswith('br-') for device in public):
158
+ return 'public_address_unobserved', 'unverified'
159
+ local = facts.get(('address_local', values['private_ipv4'] + '/32'), set())
160
+ network = facts.get(('address_network', values['private_subnet']), set())
161
+ if (len(local) != 1 or local != network or local & bridges
162
+ or any(not device or device.startswith('br-') for device in local)):
163
+ return 'private_address_mismatch', 'unverified'
164
+ gateways = facts.get(('route_gateway', values['private_gateway'] + '/32'), set())
165
+ return '', 'observed' if local & gateways else 'not_observed'
166
+
167
+
168
+ def observe(inventory, expected_host, fetch=None):
169
+ """Called only by explicit provider diagnostics; fetch(path, deadline) permits offline fixtures."""
170
+ result = dict.fromkeys(IDENTITY, '')
171
+ result.update(provider='digitalocean', status='unverified', reason='expected_host_unverified',
172
+ private_gateway_route='unverified')
173
+ try:
174
+ _address(expected_host)
175
+ except (ValueError, TypeError):
176
+ return result
177
+ if (not isinstance(inventory, dict) or inventory.get('complete') is not True
178
+ or not isinstance(inventory.get('host_records'), list) or not isinstance(inventory.get('networks'), list)):
179
+ return dict(result, reason='host_inventory_unverified')
180
+ try:
181
+ values = _read_values(fetch or _fetch, time.monotonic() + 10)
182
+ except Exception as error:
183
+ reason = str(error) if isinstance(error, MetadataFailure) else 'metadata_invalid'
184
+ if isinstance(error, (socket.timeout, TimeoutError)):
185
+ reason = 'metadata_timeout'
186
+ elif isinstance(error, OSError):
187
+ reason = 'metadata_unavailable'
188
+ if reason not in REASONS['unavailable'] | REASONS['unverified']:
189
+ reason = 'metadata_invalid'
190
+ return dict(result, reason=reason, status='unavailable' if reason in REASONS['unavailable'] else 'unverified')
191
+ try:
192
+ reason, route = _binding(inventory, values, expected_host)
193
+ except Exception:
194
+ return dict(result, reason='host_inventory_unverified')
195
+ result.update(values, status='mismatch' if reason else 'verified', reason=reason, private_gateway_route=route)
196
+ return result
197
+
198
+
199
+ def validate_provider(receipt):
200
+ fields = set(IDENTITY) | {'provider', 'status', 'reason', 'private_gateway_route'}
201
+ if (not isinstance(receipt, dict) or set(receipt) != fields
202
+ or any(not isinstance(value, str) for value in receipt.values())
203
+ or receipt['provider'] != 'digitalocean' or receipt['status'] not in REASONS
204
+ or receipt['reason'] not in REASONS[receipt['status']]):
205
+ raise ValueError()
206
+ if receipt['status'] in ('unavailable', 'unverified'):
207
+ if any(receipt[field] for field in IDENTITY) or receipt['private_gateway_route'] != 'unverified':
208
+ raise ValueError()
209
+ return
210
+ _identity(receipt)
211
+ routes = ('observed', 'not_observed') if receipt['status'] == 'verified' else ('unverified',)
212
+ if receipt['private_gateway_route'] not in routes:
213
+ raise ValueError()
@@ -0,0 +1,30 @@
1
+ """Safe in-memory metadata and HTTP socket fixtures; no external reads."""
2
+ import copy
3
+ import io
4
+ from unittest.mock import MagicMock, Mock
5
+
6
+ from network_provider import PATHS, observe, validate_provider
7
+
8
+ PUBLIC = '138.197.36.107'
9
+ VALUES = {'droplet_id': b'123456789', 'region': b'nyc3\n', 'public_ipv4': PUBLIC.encode(),
10
+ 'private_ipv4': b'10.116.0.2', 'netmask': b'255.255.240.0', 'private_gateway': b'10.116.0.1'}
11
+ INVENTORY = {'complete': True, 'networks': [], 'host_records': [
12
+ {'subnet': PUBLIC + '/32', 'kind': 'address_local', 'device': 'eth0'},
13
+ {'subnet': '10.116.0.2/32', 'kind': 'address_local', 'device': 'eth1'},
14
+ {'subnet': '10.116.0.0/20', 'kind': 'address_network', 'device': 'eth1'},
15
+ ]}
16
+
17
+
18
+ def fixture(values=None, inventory=None, expected=PUBLIC):
19
+ answers = dict(VALUES, **(values or {}))
20
+ fetch = Mock(side_effect=lambda path, deadline: answers[next(key for key in PATHS if PATHS[key] == path)])
21
+ result = observe(copy.deepcopy(INVENTORY) if inventory is None else inventory, expected, fetch)
22
+ validate_provider(result)
23
+ return result, fetch
24
+
25
+
26
+ def connection(wire):
27
+ fake = MagicMock()
28
+ fake.__enter__.return_value = fake
29
+ fake.recv.side_effect = io.BytesIO(wire).read
30
+ return fake
@@ -4,13 +4,10 @@ import ipaddress
4
4
  from network_runtime import NetworkFailure
5
5
 
6
6
 
7
- def verify_host_overlap(inventory, created, subnet):
7
+ def expected_host_records(subnet, gateway):
8
+ """Exact kernel/address records of an ordinary IPv4 bridge, without a device exemption."""
8
9
  selected = ipaddress.IPv4Network(subnet)
9
- records = inventory.get('host_records', [])
10
- if set(inventory.get('host_excluded', [])) - {row['subnet'] for row in records}:
11
- raise NetworkFailure('created_network_overlap')
12
- pool = created['IPAM']['Config'][0]
13
- gateway = str(ipaddress.IPv4Network(pool['Gateway']))
10
+ gateway = str(ipaddress.IPv4Network(gateway))
14
11
  broadcast = str(selected.broadcast_address) + '/32'
15
12
  first = str(selected.network_address) + '/32'
16
13
  expected = {'route_connected': {subnet}, 'route_local': {gateway},
@@ -18,6 +15,15 @@ def verify_host_overlap(inventory, created, subnet):
18
15
  'address_local': {gateway}, 'address_broadcast': {broadcast}}
19
16
  for kind in ('route_connected_source', 'route_local_source', 'route_broadcast_source'):
20
17
  expected[kind] = {gateway}
18
+ return expected
19
+
20
+
21
+ def verify_host_overlap(inventory, created, subnet):
22
+ selected = ipaddress.IPv4Network(subnet)
23
+ records = inventory.get('host_records', [])
24
+ if set(inventory.get('host_excluded', [])) - {row['subnet'] for row in records}:
25
+ raise NetworkFailure('created_network_overlap')
26
+ expected = expected_host_records(subnet, created['IPAM']['Config'][0]['Gateway'])
21
27
  bridge = 'br-' + created['Id'][:12]
22
28
  for row in records:
23
29
  if selected.overlaps(ipaddress.IPv4Network(row['subnet'])):
@@ -0,0 +1,96 @@
1
+ """Project-owned existing bridge overlap evidence; never allocation or address-policy authorization."""
2
+ import ipaddress
3
+ import re
4
+
5
+ from network_pools import PRIVATE
6
+ from network_readback import expected_host_records
7
+
8
+ LIMIT = 256
9
+ REASONS = frozenset({'', 'selected_ownership_unverified', 'selected_bridge_unsupported',
10
+ 'host_records_incomplete', 'selected_bridge_records_missing', 'overlap_evidence_limit'})
11
+ KINDS = frozenset({'route_other', 'route_connected', 'route_local', 'route_broadcast',
12
+ 'route_other_source', 'route_connected_source', 'route_local_source', 'route_broadcast_source',
13
+ 'route_gateway', 'route_src', 'address_network', 'address_local', 'address_peer',
14
+ 'address_broadcast', 'resolver'})
15
+
16
+
17
+ def profile(network, inventory, project):
18
+ """Bind only an unambiguous ordinary Compose name/project/key and its actual IPAM gateway."""
19
+ labels = network['Labels']
20
+ key = labels.get('com.docker.compose.network', '')
21
+ if (labels.get('com.docker.compose.project') != project or not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', key)
22
+ or network['Name'] != project + '_' + key
23
+ or sum(n['Name'] == network['Name'] for n in inventory['networks']) != 1
24
+ or sum(n['Id'] == network['Id'] for n in inventory['networks']) != 1):
25
+ return None, 'selected_ownership_unverified'
26
+ ipam = network['IPAM']
27
+ if (network['Driver'] != 'bridge' or network['Scope'] != 'local' or network['EnableIPv6']
28
+ or network['Options'] or ipam['Driver'] != 'default' or ipam['Options']
29
+ or len(ipam['Config']) != 1):
30
+ return None, 'selected_bridge_unsupported'
31
+ pool = ipam['Config'][0]
32
+ try:
33
+ subnet = ipaddress.IPv4Network(pool['Subnet'], strict=True)
34
+ gateway = ipaddress.IPv4Address(pool['Gateway'])
35
+ if (set(pool) - {'Subnet', 'Gateway', 'IPRange', 'AuxiliaryAddresses'}
36
+ or pool.get('IPRange') not in (None, '') or pool.get('AuxiliaryAddresses') not in (None, {})
37
+ or subnet.prefixlen > 30
38
+ or not any(subnet.subnet_of(space) for space in PRIVATE) or gateway not in subnet
39
+ or gateway in (subnet.network_address, subnet.broadcast_address)):
40
+ raise ValueError()
41
+ except (KeyError, ValueError, TypeError):
42
+ return None, 'selected_bridge_unsupported'
43
+ return {'subnet': subnet, 'gateway': str(gateway), 'bridge': 'br-' + network['Id'][:12]}, ''
44
+
45
+
46
+ def pool_membership(inventory, subnet):
47
+ if inventory['pool_failure'] or inventory['pool_source'] not in ('engine_info', 'versioned_moby_builtin'):
48
+ return 'unverified'
49
+ return ('inside_verified_pools' if any(subnet.subnet_of(ipaddress.IPv4Network(p['base']))
50
+ for p in inventory['pools']) else 'outside_verified_pools')
51
+
52
+
53
+ def host_overlaps(records, selected):
54
+ expected = expected_host_records(str(selected['subnet']), selected['gateway'])
55
+ own, conflicts = set(), set()
56
+ for row in records:
57
+ if not selected['subnet'].overlaps(ipaddress.IPv4Network(row['subnet'])):
58
+ continue
59
+ if row['device'] == selected['bridge'] and row['subnet'] in expected.get(row['kind'], set()):
60
+ own.add((row['subnet'], row['kind']))
61
+ else:
62
+ relation = 'selected_bridge' if row['device'] == selected['bridge'] else 'foreign_device'
63
+ conflicts.add((row['subnet'], row['kind'], relation if row['device'] else 'unattributed'))
64
+ return own, [{'subnet': subnet, 'kind': kind, 'device_relation': relation}
65
+ for subnet, kind, relation in sorted(conflicts)]
66
+
67
+
68
+ def selected_validation(inventory, network, project):
69
+ """A clear snapshot does not establish a reservation outside the verified daemon pool bases."""
70
+ selected, reason = profile(network, inventory, project)
71
+ result = {'ownership': 'unverified' if reason == 'selected_ownership_unverified' else 'matched',
72
+ 'overlap': 'unverified', 'reason': reason, 'pool_membership': 'unverified',
73
+ 'bridge': '', 'gateway': '', 'self_record_count': 0, 'docker_conflicts': [], 'host_conflicts': []}
74
+ if selected is None:
75
+ return result
76
+ subnet = selected['subnet']
77
+ result.update(bridge=selected['bridge'], gateway=selected['gateway'],
78
+ pool_membership=pool_membership(inventory, subnet))
79
+ docker = sorted({p['Subnet'] for n in inventory['networks'] if n['Id'] != network['Id']
80
+ for p in n['IPAM']['Config'] if ipaddress.ip_network(p['Subnet']).version == 4
81
+ and subnet.overlaps(ipaddress.IPv4Network(p['Subnet']))})
82
+ records = inventory['host_records']
83
+ own, host = host_overlaps(records, selected)
84
+ required = {(str(subnet), 'route_connected'), (str(subnet), 'address_network'),
85
+ (selected['gateway'] + '/32', 'route_local'), (selected['gateway'] + '/32', 'address_local')}
86
+ if (inventory.get('complete') is not True
87
+ or set(inventory['host_excluded']) != {row['subnet'] for row in records}):
88
+ reason = 'host_records_incomplete'
89
+ elif not required.issubset(own):
90
+ reason = 'selected_bridge_records_missing'
91
+ elif len(docker) > LIMIT or len(host) > LIMIT:
92
+ reason = 'overlap_evidence_limit'
93
+ result.update(reason=reason, self_record_count=len(own), docker_conflicts=docker[:LIMIT],
94
+ host_conflicts=host[:LIMIT], overlap='conflict' if docker or host else
95
+ ('unverified' if reason else 'clear'))
96
+ return result
@@ -0,0 +1,60 @@
1
+ """Closed public validation for selected-network overlap evidence, with no raw host records."""
2
+ import ipaddress
3
+
4
+ from network_selected import KINDS, LIMIT, REASONS, pool_membership
5
+ from network_pools import PRIVATE
6
+
7
+
8
+ def validate_selected(result, network, inventory):
9
+ fields = {'ownership', 'overlap', 'reason', 'pool_membership', 'bridge', 'gateway',
10
+ 'self_record_count', 'docker_conflicts', 'host_conflicts'}
11
+ if (not isinstance(result, dict) or set(result) != fields or result['reason'] not in REASONS
12
+ or result['ownership'] not in ('matched', 'unverified')
13
+ or result['overlap'] not in ('clear', 'conflict', 'unverified')
14
+ or result['pool_membership'] not in ('inside_verified_pools', 'outside_verified_pools', 'unverified')
15
+ or type(result['self_record_count']) is not int or not 0 <= result['self_record_count'] <= 16384):
16
+ raise ValueError()
17
+ for field in ('docker_conflicts', 'host_conflicts'):
18
+ if not isinstance(result[field], list) or len(result[field]) > LIMIT:
19
+ raise ValueError()
20
+ if (result['ownership'] == 'matched'
21
+ and network['name'] != inventory['project'] + '_' + network['key']):
22
+ raise ValueError()
23
+ unsupported = result['reason'] in ('selected_ownership_unverified', 'selected_bridge_unsupported')
24
+ if unsupported:
25
+ if (result['overlap'] != 'unverified' or result['bridge'] != '' or result['gateway'] != ''
26
+ or result['self_record_count'] or result['docker_conflicts'] or result['host_conflicts']
27
+ or result['pool_membership'] != 'unverified'):
28
+ raise ValueError()
29
+ if (result['ownership'] == 'unverified') != (result['reason'] == 'selected_ownership_unverified'):
30
+ raise ValueError()
31
+ return
32
+ if (result['ownership'] != 'matched' or network['driver'] != 'bridge'
33
+ or result['bridge'] != 'br-' + network['id'][:12] or not isinstance(result['gateway'], str)
34
+ or len(network['subnets']) != 1):
35
+ raise ValueError()
36
+ subnet = ipaddress.IPv4Network(network['subnets'][0], strict=True)
37
+ gateway = ipaddress.IPv4Address(result['gateway'])
38
+ if (subnet.prefixlen > 30 or not any(subnet.subnet_of(space) for space in PRIVATE)
39
+ or gateway not in subnet or gateway in (subnet.network_address, subnet.broadcast_address)
40
+ or result['pool_membership'] != pool_membership(inventory, subnet)):
41
+ raise ValueError()
42
+ validate_overlaps(result, subnet)
43
+
44
+
45
+ def validate_overlaps(result, subnet):
46
+ for value in result['docker_conflicts']:
47
+ if not isinstance(value, str) or not subnet.overlaps(ipaddress.IPv4Network(value, strict=True)):
48
+ raise ValueError()
49
+ for row in result['host_conflicts']:
50
+ if (not isinstance(row, dict) or set(row) != {'subnet', 'kind', 'device_relation'}
51
+ or not isinstance(row['subnet'], str)
52
+ or row['kind'] not in KINDS
53
+ or row['device_relation'] not in ('selected_bridge', 'foreign_device', 'unattributed')
54
+ or not subnet.overlaps(ipaddress.IPv4Network(row['subnet'], strict=True))):
55
+ raise ValueError()
56
+ conflict = bool(result['docker_conflicts'] or result['host_conflicts'])
57
+ if ((result['overlap'] == 'conflict') != conflict
58
+ or (result['overlap'] == 'clear' and (result['reason'] or result['self_record_count'] < 4))
59
+ or (result['overlap'] == 'unverified' and not result['reason'])):
60
+ raise ValueError()
@@ -28,9 +28,10 @@ class Host:
28
28
  self.calls = []
29
29
  self.pool_failure = ''
30
30
  self.lose_reply = False
31
+ self.compose_version = '2.39.4'
31
32
 
32
33
  def inventory(self, command, require_pools=True):
33
- return {'docker_version': '28.0.4', 'compose_version': '2.39.4', 'observed_at': '2026-09-15T00:00:00Z',
34
+ return {'docker_version': '28.0.4', 'compose_version': self.compose_version, 'observed_at': '2026-09-15T00:00:00Z',
34
35
  'python_version': '3.8.20', 'pool_source': 'fixture', 'pool_failure': self.pool_failure,
35
36
  'pools': [{'base': '172.28.0.0/22', 'size': 16}], 'networks': copy.deepcopy(self.networks),
36
37
  'host_excluded': [], 'host_records': [],
@@ -7,6 +7,7 @@ from unittest.mock import patch
7
7
  from identity_diagnostics import ReaderFailure
8
8
  from network_diagnostics import SCHEMA, decode, diagnose, remote_source
9
9
  from network_test_fixture import Host
10
+ from test_network_selected import fixture
10
11
 
11
12
 
12
13
  class DiagnosticTests(unittest.TestCase):
@@ -43,6 +44,25 @@ class DiagnosticTests(unittest.TestCase):
43
44
  'observed_at': '2026-09-15T00:00:00+00:00', 'complete': False}
44
45
  self.assertEqual(decode(1, json.dumps(evidence).encode(), 'fixture'), evidence)
45
46
 
47
+ def test_optional_metadata_failure_keeps_independent_selected_network_observation(self):
48
+ inventory, selected = fixture()
49
+ metadata = dict.fromkeys(('droplet_id', 'region', 'public_ipv4', 'private_ipv4',
50
+ 'private_subnet', 'private_gateway'), '')
51
+ metadata.update(provider='digitalocean', status='unavailable', reason='metadata_timeout',
52
+ private_gateway_route='unverified')
53
+ source = remote_source('fixture', '138.197.36.107', 'digitalocean').decode().replace(
54
+ 'inventory = collect(run, require_pools=False)', 'inventory = ' + repr(inventory)).replace(
55
+ "observe(inventory, '138.197.36.107')", repr(metadata))
56
+ result = subprocess.run([sys.executable, '-'], input=source.encode(), stdout=subprocess.PIPE,
57
+ stderr=subprocess.PIPE, timeout=5)
58
+ self.assertEqual(result.returncode, 0, result.stderr.decode())
59
+ evidence = decode(0, result.stdout, 'fixture')
60
+ self.assertEqual(evidence['host_identity'], metadata)
61
+ self.assertEqual(evidence['networks'][0]['validation']['overlap'], 'clear')
62
+ evidence['host_identity']['secret'] = 'PRIVATE_SENTINEL'
63
+ with self.assertRaises(ReaderFailure):
64
+ decode(0, json.dumps(evidence).encode(), 'fixture')
65
+
46
66
  def test_unrecognized_fields_codes_and_transport_results_are_not_published(self):
47
67
  evidence = {'schema': SCHEMA, 'ok': False, 'project': 'fixture', 'failure': 'host_rules_unsupported'}
48
68
  for changed in ({**evidence, 'secret': 'PRIVATE_SENTINEL'}, {**evidence, 'failure': 'PRIVATE_SENTINEL'},
@@ -85,6 +85,27 @@ class PreparationTests(unittest.TestCase):
85
85
  self.assertEqual(set(values), {'172.28.1.0/24', '172.28.2.0/24'})
86
86
  self.assertEqual(len(host.calls), 2)
87
87
 
88
+ def test_reviewed_compose_majors_allocate_and_others_refuse_before_create(self):
89
+ for version in ('2.39.4', 'v2.40.3', '5.0.0', '5.3.1', 'v5.5.1'):
90
+ host = Host()
91
+ host.compose_version = version
92
+ result = prepare(config('first'), 'first', **host.arguments())
93
+ self.assertTrue(result['ok'])
94
+ self.assertEqual(result['compose_version'], version)
95
+ self.assertEqual(host.networks[-1]['Labels']['com.docker.compose.version'], version)
96
+ self.assertNotIn('com.docker.compose.config-hash', host.networks[-1]['Labels'])
97
+ self.assertEqual(len(host.calls), 1)
98
+ for version in ('1.29.2', '3.0.0', '4.1.0', '6.0.0', '5.3', '25.0.0'):
99
+ host = Host()
100
+ host.compose_version = version
101
+ with self.assertRaisesRegex(NetworkFailure, 'compose_version_unsupported'):
102
+ prepare(config('first'), 'first', **host.arguments())
103
+ self.assertEqual(host.calls, [])
104
+ host.networks.append(network('existing', '172.29.0.0/16'))
105
+ preserved = prepare(config('existing'), 'existing', **host.arguments())
106
+ self.assertEqual(preserved['networks'][0]['state'], 'preserved')
107
+ self.assertEqual(host.calls, [])
108
+
88
109
  def test_full_effective_pool_and_unknown_provenance_refuse_expansion(self):
89
110
  for unknown in (False, True):
90
111
  host = Host()
@@ -0,0 +1,162 @@
1
+ """Offline metadata fixtures: no real socket, deployment host, cloud API or credentials."""
2
+ import copy
3
+ import json
4
+ import socket
5
+ import unittest
6
+ from unittest.mock import Mock, patch
7
+
8
+ from network_provider import MetadataFailure, ORIGIN, PATHS, _fetch, observe, validate_provider
9
+ from network_provider_test_fixture import INVENTORY, PUBLIC, VALUES, connection, fixture
10
+
11
+
12
+ class ProviderTests(unittest.TestCase):
13
+ def test_identity_and_private_gateway_route_are_separate(self):
14
+ result, fetch = fixture()
15
+ self.assertEqual(result['status'], 'verified')
16
+ self.assertEqual(result['private_subnet'], '10.116.0.0/20')
17
+ self.assertEqual(result['private_gateway_route'], 'not_observed')
18
+ self.assertEqual([call.args[0] for call in fetch.call_args_list], list(PATHS.values()))
19
+ self.assertEqual(len(fetch.call_args_list), 6)
20
+ inventory = copy.deepcopy(INVENTORY)
21
+ inventory['host_records'].append({'subnet': '10.116.0.1/32', 'kind': 'route_gateway', 'device': 'eth1'})
22
+ self.assertEqual(fixture(inventory=inventory)[0]['private_gateway_route'], 'observed')
23
+ inventory['host_records'][-1]['device'] = 'eth2'
24
+ result = fixture(inventory=inventory)[0]
25
+ self.assertEqual((result['status'], result['private_gateway_route']), ('verified', 'not_observed'))
26
+ self.assertNotIn('policy', json.dumps(result))
27
+
28
+ def test_expected_host_requires_literal_ipv4_and_matching_kernel_address(self):
29
+ for expected in ('host.example', '::1', '138.197.036.107', 123, None):
30
+ result, fetch = fixture(expected=expected)
31
+ self.assertEqual(result['reason'], 'expected_host_unverified')
32
+ fetch.assert_not_called()
33
+ result, _ = fixture(expected='138.197.36.108')
34
+ self.assertEqual((result['status'], result['reason']), ('mismatch', 'public_address_mismatch'))
35
+ self.assertEqual(result['droplet_id'], '123456789')
36
+ inventory = copy.deepcopy(INVENTORY)
37
+ inventory['host_records'].pop(0)
38
+ self.assertEqual(fixture(inventory=inventory)[0]['reason'], 'public_address_unobserved')
39
+ for inventory in ({}, dict(INVENTORY, complete=False)):
40
+ result, fetch = fixture(inventory=inventory)
41
+ self.assertEqual(result['reason'], 'host_inventory_unverified')
42
+ fetch.assert_not_called()
43
+
44
+ def test_private_identity_requires_one_matching_nonbridge_device(self):
45
+ variants = []
46
+ for index, device in ((1, 'eth2'), (2, 'eth2'), (1, ''), (1, 'br-fixture')):
47
+ inventory = copy.deepcopy(INVENTORY)
48
+ inventory['host_records'][index]['device'] = device
49
+ variants.append(inventory)
50
+ inventory = copy.deepcopy(INVENTORY)
51
+ inventory['host_records'].append(dict(inventory['host_records'][1], device='eth2'))
52
+ variants.append(inventory)
53
+ inventory = copy.deepcopy(INVENTORY)
54
+ inventory['networks'] = [{'Driver': 'bridge', 'Id': 'a' * 64,
55
+ 'Options': {'com.docker.network.bridge.name': 'eth1'}}]
56
+ variants.append(inventory)
57
+ inventory = copy.deepcopy(INVENTORY)
58
+ inventory['host_records'][2]['subnet'] = '10.116.0.0/24'
59
+ variants.append(inventory)
60
+ for inventory in variants:
61
+ with self.subTest(inventory=inventory):
62
+ self.assertEqual(fixture(inventory=inventory)[0]['reason'], 'private_address_mismatch')
63
+
64
+ def test_malformed_leaves_are_closed_and_never_retained(self):
65
+ changes = [('droplet_id', b'0'), ('droplet_id', b'123456789012345678901'),
66
+ ('region', b'PRIVATE_SENTINEL'), ('region', b'nyc3\x00'),
67
+ ('public_ipv4', b'138.197.036.107'), ('public_ipv4', b'::1'),
68
+ ('private_ipv4', b'8.8.8.8'), ('private_ipv4', b'10.116.0.0'),
69
+ ('private_gateway', b'10.116.16.1'), ('private_gateway', b'10.116.0.2'),
70
+ ('private_gateway', b'10.116.15.255'), ('netmask', b'255.0.255.0'),
71
+ ('netmask', b'0.0.15.255'), ('netmask', b'20'), ('netmask', b'255.255.255.255'),
72
+ ('droplet_id', b'PRIVATE_SENTINEL' * 10), ('region', b'\xff')]
73
+ for field, value in changes:
74
+ with self.subTest(field=field, value=value):
75
+ result, _ = fixture({field: value})
76
+ self.assertEqual(result['status'], 'unverified')
77
+ self.assertEqual(result['droplet_id'], '')
78
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
79
+ for error, reason in ((socket.timeout('PRIVATE_SENTINEL'), 'metadata_timeout'),
80
+ (OSError('PRIVATE_SENTINEL'), 'metadata_unavailable'),
81
+ (MetadataFailure('PRIVATE_SENTINEL'), 'metadata_invalid')):
82
+ result = observe(INVENTORY, PUBLIC, Mock(side_effect=error))
83
+ validate_provider(result)
84
+ self.assertEqual(result['reason'], reason)
85
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
86
+
87
+ def test_transport_is_fixed_bounded_and_has_no_redirect_or_proxy_path(self):
88
+ fake = connection(b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\n123')
89
+ with patch('network_provider.socket.socket', return_value=fake) as factory, \
90
+ patch('network_provider.time.monotonic', return_value=0), \
91
+ patch.dict('os.environ', {'HTTP_PROXY': 'http://PRIVATE_SENTINEL:80'}):
92
+ self.assertEqual(_fetch(PATHS['droplet_id'], 10), b'123')
93
+ factory.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM)
94
+ fake.connect.assert_called_once_with(ORIGIN)
95
+ fake.sendall.assert_called_once_with(
96
+ b'GET /metadata/v1/id HTTP/1.0\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n')
97
+ self.assertTrue(all(0 < call.args[0] <= 2 for call in fake.settimeout.call_args_list))
98
+ self.assertTrue(all(call.args[0] <= 129 for call in fake.recv.call_args_list))
99
+ for path in ('/metadata/v1.json', '/metadata/v1/user-data', '/metadata/v1/auth-token',
100
+ '/metadata/v1/id?x=1', 'http://other/metadata/v1/id'):
101
+ with patch('network_provider.socket.socket') as factory, self.assertRaises(MetadataFailure):
102
+ _fetch(path, 10)
103
+ factory.assert_not_called()
104
+
105
+ def test_http_refusals_headers_and_body_limits_do_not_leak_content(self):
106
+ cases = [(b'HTTP/1.0 404 Missing\r\n\r\nPRIVATE_SENTINEL', 'metadata_missing'),
107
+ (b'HTTP/1.0 302 Found\r\nLocation: http://other/\r\n\r\n', 'metadata_unavailable'),
108
+ (b'HTTP/1.0 200 OK\r\nX: ' + b'a' * 8192 + b'\r\n\r\n', 'metadata_response_limit'),
109
+ (b'HTTP/1.0 200 OK\r\n' + b'X: a\r\n' * 17 + b'\r\n', 'metadata_response_limit'),
110
+ (b'HTTP/1.0 200 OK\r\n\r\n' + b'a' * 129, 'metadata_response_limit'),
111
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 129\r\n\r\n', 'metadata_response_limit'),
112
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\n12', 'metadata_invalid'),
113
+ (b'HTTP/1.0 200 OK\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\n1', 'metadata_invalid'),
114
+ (b'HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n', 'metadata_invalid'),
115
+ (b'HTTP/1.0 200 OK\r\nContent-Encoding: gzip\r\n\r\n', 'metadata_invalid')]
116
+ for wire, reason in cases:
117
+ with self.subTest(reason=reason), patch('network_provider.socket.socket', return_value=connection(wire)), \
118
+ patch('network_provider.time.monotonic', return_value=0):
119
+ result = observe(INVENTORY, PUBLIC)
120
+ validate_provider(result)
121
+ self.assertEqual(result['reason'], reason)
122
+ self.assertNotIn('PRIVATE_SENTINEL', json.dumps(result))
123
+
124
+ def test_deadlines_stop_slow_leaves_and_bound_the_whole_sequence(self):
125
+ clock = [0]
126
+ def slow(path, deadline):
127
+ clock[0] += 2.1
128
+ return b'123'
129
+ with patch('network_provider.time.monotonic', side_effect=lambda: clock[0]):
130
+ fetch = Mock(side_effect=slow)
131
+ self.assertEqual(observe(INVENTORY, PUBLIC, fetch)['reason'], 'metadata_timeout')
132
+ self.assertEqual(fetch.call_count, 1)
133
+ clock[0] = 0
134
+ def steady(path, deadline):
135
+ clock[0] += 1.9
136
+ return VALUES[next(key for key in PATHS if PATHS[key] == path)]
137
+ with patch('network_provider.time.monotonic', side_effect=lambda: clock[0]):
138
+ fetch = Mock(side_effect=steady)
139
+ self.assertEqual(observe(INVENTORY, PUBLIC, fetch)['reason'], 'metadata_timeout')
140
+ self.assertEqual(fetch.call_count, 6)
141
+ self.assertEqual(fetch.call_args.args[1], 10)
142
+
143
+ def test_receipt_rejects_extra_fields_bad_types_and_contradictory_states(self):
144
+ result, _ = fixture()
145
+ changes = ({'secret': 'PRIVATE_SENTINEL'}, {'droplet_id': 123}, {'region': []},
146
+ {'private_gateway': 1}, {'private_subnet': '10.116.0.2/20'},
147
+ {'provider': 'other'}, {'reason': 'PRIVATE_SENTINEL'}, {'status': 'unverified'},
148
+ {'private_gateway_route': 'unverified'}, {'public_ipv4': '138.197.036.107'})
149
+ for changed in changes:
150
+ with self.subTest(changed=changed), self.assertRaises(ValueError):
151
+ validate_provider(dict(result, **changed))
152
+ failure = observe(INVENTORY, 'hostname', Mock())
153
+ for changed in ({'droplet_id': '123'}, {'private_gateway_route': 'not_observed'}, {'public_ipv4': None}):
154
+ with self.assertRaises(ValueError):
155
+ validate_provider(dict(failure, **changed))
156
+ mismatch, _ = fixture(expected='138.197.36.108')
157
+ with self.assertRaises(ValueError):
158
+ validate_provider(dict(mismatch, private_gateway_route='observed'))
159
+
160
+
161
+ if __name__ == '__main__':
162
+ unittest.main()
@@ -0,0 +1,153 @@
1
+ """Existing bridge evidence with full pools, foreign collisions and untrusted public receipts."""
2
+ import copy
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import unittest
7
+
8
+ from identity_diagnostics import ReaderFailure
9
+ from network_diagnostics import decode, remote_source
10
+ from network_selected import KINDS, selected_validation
11
+ from network_test_fixture import Host, network
12
+
13
+
14
+ def fixture():
15
+ inventory = Host().inventory(None)
16
+ selected = network('fixture', '10.253.55.0/28')
17
+ inventory.update(complete=True, pool_source='engine_info', networks=inventory['networks'] + [selected])
18
+ bridge = 'br-' + selected['Id'][:12]
19
+ records = [('10.253.55.0/28', 'route_connected'), ('10.253.55.1/32', 'route_connected_source'),
20
+ ('10.253.55.1/32', 'route_local'), ('10.253.55.15/32', 'route_broadcast'),
21
+ ('10.253.55.0/28', 'address_network'), ('10.253.55.1/32', 'address_local'),
22
+ ('10.253.55.15/32', 'address_broadcast')]
23
+ inventory['host_records'] = [{'subnet': subnet, 'kind': kind, 'device': bridge} for subnet, kind in records]
24
+ inventory['host_excluded'] = sorted({row['subnet'] for row in inventory['host_records']})
25
+ inventory['excluded'] += inventory['host_excluded']
26
+ return inventory, selected
27
+
28
+
29
+ class SelectedTests(unittest.TestCase):
30
+ def test_self_routes_do_not_conflict_or_prove_an_outside_pool_reservation(self):
31
+ inventory, selected = fixture()
32
+ # No spare capacity in the only pool; that does not determine existing-network overlap.
33
+ inventory['pools'] = [{'base': '172.28.0.0/24', 'size': 24}]
34
+ result = selected_validation(inventory, selected, 'fixture')
35
+ self.assertEqual((result['ownership'], result['overlap'], result['reason']), ('matched', 'clear', ''))
36
+ self.assertEqual(result['self_record_count'], 7)
37
+ self.assertEqual(result['pool_membership'], 'outside_verified_pools')
38
+ inventory['pools'] = [{'base': '10.253.55.0/28', 'size': 28}]
39
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['pool_membership'],
40
+ 'inside_verified_pools')
41
+ inventory['pool_failure'] = 'pool_provenance_unknown'
42
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'clear')
43
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['pool_membership'], 'unverified')
44
+
45
+ def test_identical_foreign_docker_prefix_is_preserved_after_self_exclusions(self):
46
+ inventory, selected = fixture()
47
+ inventory['networks'].append(network('foreign', '10.253.55.0/28'))
48
+ result = selected_validation(inventory, selected, 'fixture')
49
+ self.assertEqual(result['overlap'], 'conflict')
50
+ self.assertEqual(result['docker_conflicts'], ['10.253.55.0/28'])
51
+ self.assertNotIn('foreign', json.dumps(result))
52
+
53
+ def test_foreign_routes_and_dns_inside_self_subnet_are_never_excused(self):
54
+ for kind, device in (('route_connected', 'vpn0'), ('route_other', 'tailscale0'),
55
+ ('route_gateway', 'eth0'), ('resolver', ''), ('route_other', 'self')):
56
+ inventory, selected = fixture()
57
+ bridge = 'br-' + selected['Id'][:12]
58
+ inventory['host_records'].append({'subnet': '10.253.55.1/32', 'kind': kind,
59
+ 'device': bridge if device == 'self' else device})
60
+ with self.subTest(kind=kind, device=device):
61
+ result = selected_validation(inventory, selected, 'fixture')
62
+ self.assertEqual(result['overlap'], 'conflict')
63
+ self.assertEqual(result['host_conflicts'][0]['kind'], kind)
64
+ self.assertNotIn('device', result['host_conflicts'][0])
65
+
66
+ def test_missing_route_provenance_and_missing_bridge_facts_do_not_claim_clear(self):
67
+ for change, reason in (('missing', 'selected_bridge_records_missing'),
68
+ ('incomplete', 'host_records_incomplete')):
69
+ inventory, selected = fixture()
70
+ if change == 'missing':
71
+ inventory['host_records'] = []
72
+ inventory['host_excluded'] = []
73
+ else:
74
+ inventory['host_excluded'].append('10.108.0.0/20')
75
+ result = selected_validation(inventory, selected, 'fixture')
76
+ self.assertEqual((result['overlap'], result['reason']), ('unverified', reason))
77
+ inventory['networks'].append(network('foreign', '10.253.55.0/24'))
78
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'conflict')
79
+
80
+ def test_ownership_and_unsupported_bridge_shapes_never_exempt_host_records(self):
81
+ changes = [({'Name': 'custom'}, 'selected_ownership_unverified'),
82
+ ({'Labels': {'com.docker.compose.project': 'fixture'}}, 'selected_ownership_unverified'),
83
+ ({'Driver': 'overlay'}, 'selected_bridge_unsupported'),
84
+ ({'EnableIPv6': True}, 'selected_bridge_unsupported'),
85
+ ({'Options': {'com.docker.network.bridge.name': 'custom0'}}, 'selected_bridge_unsupported')]
86
+ for settings, reason in changes:
87
+ inventory, selected = fixture()
88
+ selected.update(settings)
89
+ with self.subTest(settings=settings):
90
+ result = selected_validation(inventory, selected, 'fixture')
91
+ self.assertEqual((result['overlap'], result['reason']), ('unverified', reason))
92
+ self.assertEqual(result['self_record_count'], 0)
93
+ inventory, selected = fixture()
94
+ inventory['networks'].append(copy.deepcopy(selected))
95
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['reason'],
96
+ 'selected_ownership_unverified')
97
+ inventory, selected = fixture()
98
+ selected['IPAM']['Config'][0].update(IPRange='', AuxiliaryAddresses={})
99
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['overlap'], 'clear')
100
+ selected['IPAM']['Config'][0]['IPRange'] = '10.253.55.8/29'
101
+ self.assertEqual(selected_validation(inventory, selected, 'fixture')['reason'],
102
+ 'selected_bridge_unsupported')
103
+
104
+ def test_truncated_evidence_retains_conflict_without_unbounded_host_records(self):
105
+ inventory, selected = fixture()
106
+ for index in range(300):
107
+ inventory['host_records'].append({'subnet': '10.253.55.1/32', 'kind': 'route_other',
108
+ 'device': 'vpn%d' % index})
109
+ # Different device names collapse to the same safe public relation, not raw private inventory.
110
+ result = selected_validation(inventory, selected, 'fixture')
111
+ self.assertEqual(result['overlap'], 'conflict')
112
+ self.assertEqual(len(result['host_conflicts']), 1)
113
+ self.assertNotIn('vpn', json.dumps(result))
114
+ for index in range(16):
115
+ for kind in KINDS:
116
+ inventory['host_records'].append({'subnet': '10.253.55.%d/32' % index,
117
+ 'kind': kind, 'device': 'vpn0'})
118
+ for kind in KINDS:
119
+ inventory['host_records'].append({'subnet': '10.253.55.0/31', 'kind': kind, 'device': 'vpn0'})
120
+ inventory['host_records'].append({'subnet': '10.253.55.0/30', 'kind': kind, 'device': 'vpn0'})
121
+ inventory['host_excluded'] = sorted({row['subnet'] for row in inventory['host_records']})
122
+ result = selected_validation(inventory, selected, 'fixture')
123
+ self.assertEqual((result['overlap'], result['reason']), ('conflict', 'overlap_evidence_limit'))
124
+ self.assertEqual(len(result['host_conflicts']), 256)
125
+
126
+ def test_streamed_v2_receipt_binds_selected_network_and_rejects_false_clear_or_secret_fields(self):
127
+ inventory, selected = fixture()
128
+ source = remote_source('fixture').decode().replace(
129
+ 'inventory = collect(run, require_pools=False)', 'inventory = ' + repr(inventory))
130
+ process = subprocess.run([sys.executable, '-'], input=source.encode(), stdout=subprocess.PIPE,
131
+ stderr=subprocess.PIPE, timeout=5)
132
+ self.assertEqual(process.returncode, 0, process.stderr.decode())
133
+ receipt = decode(0, process.stdout, 'fixture')
134
+ self.assertEqual(receipt['networks'][0]['validation']['overlap'], 'clear')
135
+ for changed in ({'bridge': 'br-' + 'f' * 12}, {'self_record_count': 0},
136
+ {'pool_membership': 'inside_verified_pools'},
137
+ {'gateway': 184352513},
138
+ {'secret': 'PRIVATE_SENTINEL'}, {'docker_conflicts': ['10.253.55.0/28']},
139
+ {'host_conflicts': [{'subnet': '10.253.55.1/32', 'kind': 'PRIVATE_SENTINEL',
140
+ 'device_relation': 'foreign_device'}]}):
141
+ bad = copy.deepcopy(receipt)
142
+ bad['networks'][0]['validation'].update(changed)
143
+ with self.subTest(changed=changed), self.assertRaises(ReaderFailure):
144
+ decode(0, json.dumps(bad).encode(), 'fixture')
145
+ for changed in ({'name': 'another_default'}, {'driver': 'overlay'}, {'subnets': [184352512]}):
146
+ bad = copy.deepcopy(receipt)
147
+ bad['networks'][0].update(changed)
148
+ with self.subTest(changed=changed), self.assertRaises(ReaderFailure):
149
+ decode(0, json.dumps(bad).encode(), 'fixture')
150
+
151
+
152
+ if __name__ == '__main__':
153
+ unittest.main()
@@ -28,10 +28,21 @@ fingerprint. A later change to its network contract refuses before Compose rathe
28
28
  ignoring authored IPAM or replacing a network that may carry active workloads. An app requiring a
29
29
  different network must implement that deliberate migration in its own deployment.
30
30
 
31
- The helper intentionally does not manufacture `com.docker.compose.config-hash`. Compose 2 accepts a
32
- network with the correct project/network labels and no hash. A wrong hash can trigger replacement.
33
- Its own fingerprint protects replay, including configuration changes. Other Compose major versions
34
- require compatibility review before new-network preparation is admitted.
31
+ The helper intentionally does not manufacture `com.docker.compose.config-hash`. Compose 2 and Compose 5
32
+ accept a network with the correct project/network labels and no hash. A wrong hash can trigger
33
+ replacement. Its own fingerprint protects replay, including configuration changes. New-network
34
+ preparation admits Compose 2.x and 5.x; any other major version refuses with
35
+ `compose_version_unsupported` until it has been reviewed. Existing selected networks are preserved
36
+ under every Compose version because preservation never allocates.
37
+
38
+ Compose 5 review (2026-09-17, docker/compose tags v5.0.0, v5.3.1 and v5.5.1 against v2.40.3):
39
+ `resolveOrCreateNetwork` in `pkg/compose/create.go` at v5.0.0 and v5.3.1 returns the inspected
40
+ network when its `com.docker.compose.network` label matches and its config-hash label is empty or
41
+ equal (`hash == "" || hash == expected`), the same branch as v2.40.3; the remaining differences are
42
+ Docker client types, IPAM parsing and event emission. v5.5.1 moved reuse into `reconcileNetworks`
43
+ in `pkg/compose/reconcile.go`, whose observed state is scoped by the project label and which leaves a
44
+ network with no recorded hash untouched (`observed.ConfigHash == ""`). docker/compose carries no v3 or
45
+ v4 tags, and Compose 1 (`docker-compose`) stays unreviewed.
35
46
 
36
47
  ## Address-space proof
37
48
 
@@ -68,12 +79,55 @@ existing deployment identity but skips source sync, environment installation and
68
79
  package modules travel over SSH stdin and execute in memory; no helper file is installed on the host.
69
80
 
70
81
  The `backend-network-diagnostics-<job>-<attempt>` artifact contains `result.json` with schema
71
- `gowalk-cicd/backend-network-diagnostics.v1`. It records:
82
+ `gowalk-cicd/backend-network-diagnostics.v2`. It records:
72
83
 
73
84
  - Docker, Compose and Python versions with a UTC timestamp;
74
85
  - effective pool bases/sizes, their provenance and any typed pool refusal;
75
86
  - inventory completeness, network count and excluded IPv4 prefixes;
76
- - only the selected project's network IDs, names, Compose keys, subnets and endpoint counts.
87
+ - only the selected project's network IDs, names, Compose keys, subnets and endpoint counts;
88
+ - a `validation` for each selected network, separating observed IPv4 overlap from verified pool membership.
89
+
90
+ Selected-network validation binds an unambiguous automatic Compose name to its project/key labels.
91
+ It supports local ordinary IPv4 bridges with one RFC1918 subnet, an explicit usable gateway and no
92
+ custom options. Other shapes report `unverified`; labels alone never exempt a host route. The exact
93
+ network ID determines the bridge device, and only its expected kernel connected/local/broadcast and
94
+ address records count as self records. Connected and local routes and addresses must actually appear.
95
+ Foreign Docker prefixes, foreign host routes, resolver addresses and unexpected records on the selected
96
+ bridge remain conflicts even when their prefix is identical to a self record. The public receipt retains
97
+ only overlapping prefixes, fixed host-record kinds and device relationships, never foreign project names
98
+ or raw host inventory. Each conflict list is limited to 256 entries; a truncated list retains `conflict`.
99
+
100
+ `validation.overlap` is `clear`, `conflict` or `unverified`. `pool_membership` independently reports
101
+ `inside_verified_pools`, `outside_verified_pools` or `unverified`; it does not measure spare capacity.
102
+ Top-level `ok:true` means the inventory read completed, so consumers must inspect each validation.
103
+ An outside-pool network can have a clear observed overlap check while still lacking an address-policy
104
+ reservation. This receipt does not authorize that range or certify destinations hidden behind a default
105
+ route. Establish any deliberate host-local reservation through the owning infrastructure policy and
106
+ recheck the live network before asserting permanent suitability. Provider metadata can establish a
107
+ host's identity and attached private subnet; it does not delegate another range to Docker.
108
+
109
+ For a DigitalOcean deployment, select `diagnostic-provider: digitalocean` on that same workflow.
110
+ The default `none` performs no provider read. The optional `host_identity` reads only six fixed leaves
111
+ from `169.254.169.254:80` on the already verified deployment host: Droplet ID, region, public interface 0
112
+ IPv4 address, and private interface 0 IPv4 address/netmask/gateway. It never requests an index, bulk
113
+ metadata, user data, tokens or credentials. The reader allows no proxy, redirect, retry, authentication
114
+ or destination override; it bounds each response to 128 body bytes and 8 KiB/16 header fields, with
115
+ two seconds per request and ten seconds overall.
116
+
117
+ Positive binding requires the returned public address to equal the configured literal IPv4 deployment
118
+ host and a kernel local address. The returned private address and derived subnet must match the same
119
+ unique non-bridge kernel interface. A usable private gateway is retained as a metadata fact; its absence
120
+ from kernel gateway records does not invalidate host identity or claim that the gateway is being used.
121
+ `host_identity.status` separates `verified`, `mismatch`, `unavailable` and `unverified`; failure of this
122
+ optional read never rewrites the independently observed network-overlap result. Interface slot 0 is
123
+ not a VPC/peering inventory. Current supported Tailscale kernel routes remain part of the overlap check;
124
+ no Tailscale preferences, state files, account data or control-plane route claims are fetched.
125
+
126
+ These facts can support a deliberate current host-local reservation by the authorized infrastructure
127
+ owner, recorded against the exact host, existing network ID, subnet, gateway and diagnostic revision.
128
+ Such a decision must state its host-local routing scope and require revalidation before future routing
129
+ or advertisement changes. It does not invent a historical provider delegation, expand daemon pools,
130
+ modify a network or turn a route snapshot into global address-space proof.
77
131
 
78
132
  A routing refusal can retain safe engine/pool metadata with `complete:false` and `ok:false`. Such
79
133
  partial evidence never admits allocation. A successful diagnostic is a readback, not a deployment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.154",
3
+ "version": "1.0.156",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,11 @@ on:
24
24
  type: string
25
25
  description: For diagnostics, UTC YYYY-MM-DDTHH:MM:SSZ within the previous 24 hours
26
26
  required: false
27
+ diagnostic-provider:
28
+ type: choice
29
+ description: Optional host metadata for network diagnostics; never address-space authorization
30
+ default: none
31
+ options: [none, digitalocean]
27
32
 
28
33
  permissions:
29
34
  contents: read
@@ -65,6 +70,7 @@ jobs:
65
70
  with:
66
71
  operation: ${{ inputs.operation || 'deploy' }}
67
72
  diagnostic-since: ${{ inputs.diagnostic-since }}
73
+ diagnostic-provider: ${{ inputs.diagnostic-provider || 'none' }}
68
74
  ssh-key: ${{ secrets.BACKEND_DEPLOY_SSH_KEY }}
69
75
  host: ${{ vars.BACKEND_DEPLOY_HOST || '138.197.36.107' }}
70
76
  app-name: ${{ vars.BACKEND_APP_NAME || github.event.repository.name }}
Binary file