gowalk-cicd 1.0.149 → 1.0.151

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 (34) hide show
  1. package/CLAUDE.md +9 -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/configure_vhost.py +1 -1
  8. package/backend-action/network_contract.py +119 -0
  9. package/backend-action/network_diagnostics.py +165 -0
  10. package/backend-action/network_inventory.py +235 -0
  11. package/backend-action/network_owned_inventory.py +118 -0
  12. package/backend-action/network_pool_facts.json +214 -0
  13. package/backend-action/network_pools.py +261 -0
  14. package/backend-action/network_prepare.py +151 -0
  15. package/backend-action/network_readback.py +25 -0
  16. package/backend-action/network_runtime.py +62 -0
  17. package/backend-action/network_test_fixture.py +56 -0
  18. package/backend-action/remote_deploy.sh +2 -0
  19. package/backend-action/test_network_builtins.py +28 -0
  20. package/backend-action/test_network_diagnostics.py +57 -0
  21. package/backend-action/test_network_docker.py +129 -0
  22. package/backend-action/test_network_fixture_evidence.py +23 -0
  23. package/backend-action/test_network_inventory.py +241 -0
  24. package/backend-action/test_network_ipam_gateway.py +46 -0
  25. package/backend-action/test_network_noop.py +64 -0
  26. package/backend-action/test_network_owned_inventory.py +128 -0
  27. package/backend-action/test_network_prepare.py +99 -0
  28. package/backend-action/test_network_races.py +73 -0
  29. package/backend-action/test_network_runtime.py +49 -0
  30. package/docs/BACKEND_EDGE.md +1 -0
  31. package/docs/BACKEND_NETWORKS.md +99 -0
  32. package/package.json +2 -1
  33. package/templates/deploy-backend.yml +3 -3
  34. package/templates/history.json.gz +0 -0
@@ -0,0 +1,118 @@
1
+ """Read exact requested network ownership without admitting any address-space allocation."""
2
+ import re
3
+
4
+ from network_inventory import Reader
5
+ from network_pools import InventoryFailure, decode, prefix
6
+
7
+ LISTING = ['docker', 'network', 'ls', '--no-trunc', '--format',
8
+ '{"Id":{{json .ID}},"Name":{{json .Name}}}']
9
+
10
+
11
+ def listing(raw, names):
12
+ rows, identifiers, selected = raw.splitlines(), set(), {}
13
+ if len(rows) > 1024:
14
+ raise InventoryFailure('inventory_invalid')
15
+ for raw_row in rows:
16
+ row = decode(raw_row)
17
+ if (not isinstance(row, dict) or set(row) != {'Id', 'Name'}
18
+ or not isinstance(row['Id'], str) or not re.fullmatch('[a-f0-9]{64}', row['Id'])
19
+ or not isinstance(row['Name'], str)
20
+ or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,255}', row['Name'])
21
+ or row['Id'] in identifiers):
22
+ raise InventoryFailure('inventory_invalid')
23
+ identifiers.add(row['Id'])
24
+ if row['Name'] in names:
25
+ if row['Name'] in selected:
26
+ raise InventoryFailure('inventory_invalid')
27
+ selected[row['Name']] = row['Id']
28
+ return selected
29
+
30
+
31
+ def string_mapping(value):
32
+ if value is None:
33
+ return {}
34
+ if (not isinstance(value, dict)
35
+ or any(not isinstance(k, str) or not isinstance(v, str) for k, v in value.items())):
36
+ raise InventoryFailure('inventory_invalid')
37
+ return dict(value)
38
+
39
+
40
+ def ownership_ipam(ipam):
41
+ """Validate subnet evidence without assuming the selected network's IPAM driver."""
42
+ if (not isinstance(ipam, dict) or set(ipam) - {'Driver', 'Options', 'Config'}
43
+ or not isinstance(ipam.get('Driver'), str) or not 1 <= len(ipam['Driver']) <= 256
44
+ or 'Config' not in ipam):
45
+ raise InventoryFailure('inventory_invalid')
46
+ pools = ipam['Config']
47
+ if pools is None:
48
+ pools = []
49
+ if not isinstance(pools, list) or len(pools) > 64:
50
+ raise InventoryFailure('inventory_invalid')
51
+ subnets = []
52
+ for pool in pools:
53
+ if not isinstance(pool, dict) or set(pool) - {'Subnet', 'Gateway', 'IPRange', 'AuxiliaryAddresses'}:
54
+ raise InventoryFailure('inventory_invalid')
55
+ subnet = prefix(pool.get('Subnet'), strict=True)
56
+ for key in ('Gateway', 'IPRange'):
57
+ if pool.get(key):
58
+ address = prefix(pool[key])
59
+ if address.version != subnet.version or not address.subnet_of(subnet):
60
+ raise InventoryFailure('inventory_invalid')
61
+ for value in string_mapping(pool.get('AuxiliaryAddresses')).values():
62
+ address = prefix(value)
63
+ if address.version != subnet.version or not address.subnet_of(subnet):
64
+ raise InventoryFailure('inventory_invalid')
65
+ subnets.append(dict(pool))
66
+ return {'Driver': ipam['Driver'], 'Options': string_mapping(ipam.get('Options')), 'Config': subnets}
67
+
68
+
69
+ def ownership_record(row):
70
+ """Keep the private contract fields; public receipts must project this record separately."""
71
+ if not isinstance(row, dict):
72
+ raise InventoryFailure('inventory_invalid')
73
+ result = {key: row.get(key) for key in ('Id', 'Name', 'Driver', 'Scope', 'Internal', 'Attachable', 'EnableIPv6')}
74
+ if (not isinstance(result['Driver'], str) or not 1 <= len(result['Driver']) <= 256
75
+ or result['Scope'] not in ('local', 'swarm', 'global')
76
+ or any(type(result[key]) is not bool for key in ('Internal', 'Attachable', 'EnableIPv6'))):
77
+ raise InventoryFailure('inventory_invalid')
78
+ result.update(Labels=string_mapping(row.get('Labels')), Options=string_mapping(row.get('Options')),
79
+ IPAM=ownership_ipam(row.get('IPAM')))
80
+ return result
81
+
82
+
83
+ def inspect_selected(read, selected):
84
+ networks = []
85
+ identities = sorted(selected.values())
86
+ for offset in range(0, len(identities), 32):
87
+ batch = identities[offset:offset + 32]
88
+ rows = read(['docker', 'network', 'inspect', *batch])
89
+ if not isinstance(rows, list) or len(rows) != len(batch):
90
+ raise InventoryFailure('inventory_invalid')
91
+ observed = set()
92
+ for row in rows:
93
+ network = ownership_record(row)
94
+ name, identity = network['Name'], network['Id']
95
+ if (not isinstance(name, str) or not isinstance(identity, str)
96
+ or selected.get(name) != identity or identity not in batch or identity in observed):
97
+ raise InventoryFailure('network_inventory_changed')
98
+ observed.add(identity)
99
+ networks.append(network)
100
+ return networks
101
+
102
+
103
+ def ownership_inventory(run, plans):
104
+ """Inspect complete exact-name matches and recheck them; unrelated details stay unread."""
105
+ if not isinstance(plans, list) or len(plans) > 64:
106
+ raise InventoryFailure('inventory_invalid')
107
+ names = set()
108
+ for plan in plans:
109
+ if (not isinstance(plan, dict) or not isinstance(plan.get('name'), str)
110
+ or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,255}', plan['name'])):
111
+ raise InventoryFailure('inventory_invalid')
112
+ names.add(plan['name'])
113
+ read = Reader(run)
114
+ selected = listing(read(LISTING, parsed=False), names)
115
+ networks = inspect_selected(read, selected)
116
+ if listing(read(LISTING, parsed=False), names) != selected:
117
+ raise InventoryFailure('network_inventory_changed')
118
+ return {'networks': networks}
@@ -0,0 +1,214 @@
1
+ {
2
+ "schema": "gowalk-cicd/moby-pool-source-facts.v1",
3
+ "source_root": "https://raw.githubusercontent.com/moby/moby/v{version}/",
4
+ "review": "Exact Moby stable tags: localScopeDefaultNetworks constants and configured runtime pool loop.",
5
+ "fallback_sources": {
6
+ "20.10.24": "vendor/github.com/docker/libnetwork/ipams/builtin/builtin_unix.go",
7
+ "28.5.2": "daemon/daemon.go"
8
+ },
9
+ "groups": [
10
+ {
11
+ "path": "vendor/github.com/docker/libnetwork/ipamutils/utils.go",
12
+ "sha256": "f82bc0d96c8fdbc63e81432458d6349739dc87110e06ba5f50b000cb7d088271",
13
+ "versions": [
14
+ "20.10.0", "20.10.1", "20.10.2", "20.10.3", "20.10.4", "20.10.5", "20.10.6",
15
+ "20.10.7", "20.10.8", "20.10.9", "20.10.10", "20.10.11", "20.10.12", "20.10.13",
16
+ "20.10.14", "20.10.15", "20.10.16", "20.10.17", "20.10.18", "20.10.19", "20.10.20",
17
+ "20.10.21", "20.10.22", "20.10.23", "20.10.24", "20.10.25", "20.10.26", "20.10.27"
18
+ ]
19
+ },
20
+ {
21
+ "path": "libnetwork/ipamutils/utils.go",
22
+ "sha256": "c678632dff4f85e5b26c2b61e4f70fd24ab359c43c2400f9e95241ab4a0a1b44",
23
+ "versions": [
24
+ "23.0.0", "23.0.1", "23.0.2", "23.0.3", "23.0.4", "23.0.5", "23.0.6",
25
+ "23.0.7", "23.0.8", "23.0.9", "23.0.10", "23.0.11", "23.0.12", "23.0.13",
26
+ "23.0.14", "23.0.15", "23.0.16", "23.0.17", "23.0.18"
27
+ ]
28
+ },
29
+ {
30
+ "path": "libnetwork/ipamutils/utils.go",
31
+ "sha256": "344fafa24387c51705e6ef48901d02d0f49a2c1c03a88786579d28cc50f7f345",
32
+ "versions": [
33
+ "24.0.0", "24.0.1", "24.0.2", "24.0.3", "24.0.4", "24.0.5", "24.0.6",
34
+ "24.0.7", "24.0.8", "24.0.9"
35
+ ]
36
+ },
37
+ {
38
+ "path": "libnetwork/ipamutils/utils.go",
39
+ "sha256": "a966d617cfb7ff2521fc9043f8a690f96b9cc2cc4cc94adcf1e101ad65dcbd88",
40
+ "versions": [
41
+ "25.0.0", "25.0.1", "25.0.2", "25.0.3", "25.0.4", "25.0.5", "25.0.7",
42
+ "25.0.8", "25.0.9", "25.0.10", "25.0.11", "25.0.12", "25.0.14", "25.0.15",
43
+ "25.0.16", "26.0.0", "26.0.1", "26.0.2", "26.1.0", "26.1.1", "26.1.2",
44
+ "26.1.3", "26.1.4"
45
+ ]
46
+ },
47
+ {
48
+ "path": "libnetwork/ipamutils/utils.go",
49
+ "sha256": "60604c3228b54984b0ed53f866d53d98f58d4cce143f2323aea2e3ca54b24c62",
50
+ "versions": [
51
+ "27.0.1", "27.0.2", "27.0.3", "27.1.0", "27.1.1", "27.1.2", "27.2.0",
52
+ "27.2.1"
53
+ ]
54
+ },
55
+ {
56
+ "path": "libnetwork/ipamutils/utils.go",
57
+ "sha256": "66fffb43db9e9337501b6d6da335ab164dd6abee1dadee0be1a373c228419606",
58
+ "versions": [
59
+ "27.3.0", "27.3.1"
60
+ ]
61
+ },
62
+ {
63
+ "path": "libnetwork/ipamutils/utils.go",
64
+ "sha256": "438b42fe2dc96a33deb77fdc29c97691e6a046cf3026e3c1967a64b2360126c2",
65
+ "versions": [
66
+ "27.4.0", "27.4.1", "27.5.0", "27.5.1", "28.0.0", "28.0.1", "28.0.2",
67
+ "28.0.3", "28.0.4", "28.1.0"
68
+ ]
69
+ },
70
+ {
71
+ "path": "libnetwork/ipamutils/utils.go",
72
+ "sha256": "f28ba6c14feffabb9b83e85aa6d1e440ff1f028f0984d4a926bd0c36b8521815",
73
+ "versions": [
74
+ "28.1.1", "28.2.0", "28.2.1", "28.2.2", "28.3.0", "28.3.1", "28.3.2",
75
+ "28.3.3", "28.4.0", "28.5.0", "28.5.1", "28.5.2"
76
+ ]
77
+ },
78
+ {
79
+ "path": "daemon/info.go",
80
+ "sha256": "1f3c7eed3365f856aff5aa7fe1ec1ceba6d00dcdb15cdc338b171d51426d9819",
81
+ "versions": [
82
+ "20.10.0", "20.10.1"
83
+ ]
84
+ },
85
+ {
86
+ "path": "daemon/info.go",
87
+ "sha256": "3c2a38fd2ff8a71ac65d53328c7fce24b04e32c518df078cccaeac4a54eaf607",
88
+ "versions": [
89
+ "20.10.2", "20.10.3", "20.10.4", "20.10.5"
90
+ ]
91
+ },
92
+ {
93
+ "path": "daemon/info.go",
94
+ "sha256": "fa03341c85ee1299c9808ccd121112285bb851a5b77b2534cf5f655f9985e8a1",
95
+ "versions": [
96
+ "20.10.6", "20.10.7", "20.10.8", "20.10.9", "20.10.10", "20.10.11", "20.10.12",
97
+ "20.10.13", "20.10.14", "20.10.15", "20.10.16", "20.10.17", "20.10.18", "20.10.19",
98
+ "20.10.20", "20.10.21", "20.10.22", "20.10.23", "20.10.24", "20.10.25", "20.10.26",
99
+ "20.10.27"
100
+ ]
101
+ },
102
+ {
103
+ "path": "daemon/info.go",
104
+ "sha256": "197bc9eac1618225c0ed001360c1b57b651adbb8af5293b124703cf701d115c2",
105
+ "versions": [
106
+ "23.0.0", "23.0.1", "23.0.2", "23.0.3", "23.0.4", "23.0.5", "23.0.6",
107
+ "23.0.7", "23.0.8", "23.0.9", "23.0.10", "23.0.11", "23.0.12", "23.0.13",
108
+ "23.0.14", "23.0.15", "23.0.16", "23.0.17", "23.0.18"
109
+ ]
110
+ },
111
+ {
112
+ "path": "daemon/info.go",
113
+ "sha256": "857b5dc11753d2f6098dccf3d9af61f6f4d20e8369d3e1f86419ba610ea11ac5",
114
+ "versions": [
115
+ "24.0.0", "24.0.1", "24.0.2", "24.0.3", "24.0.4", "24.0.5", "24.0.6",
116
+ "24.0.7"
117
+ ]
118
+ },
119
+ {
120
+ "path": "daemon/info.go",
121
+ "sha256": "3772ab50c23edf7257243bd58746148978ae0dcc4e637b5ef120c10879373578",
122
+ "versions": [
123
+ "24.0.8", "24.0.9"
124
+ ]
125
+ },
126
+ {
127
+ "path": "daemon/info.go",
128
+ "sha256": "08fbe5e83aec2b83c89a1dcf1af426d68a494570fff8358c3772a31470890204",
129
+ "versions": [
130
+ "25.0.0", "25.0.1", "25.0.2", "25.0.3", "25.0.4", "25.0.5", "25.0.7",
131
+ "25.0.8", "25.0.9"
132
+ ]
133
+ },
134
+ {
135
+ "path": "daemon/info.go",
136
+ "sha256": "82955e39ef8cbd1defdd1b74c14433fbbe455cb944b8d6d8aeae983105fe09f6",
137
+ "versions": [
138
+ "25.0.10", "25.0.11", "25.0.12", "25.0.14", "25.0.15", "25.0.16"
139
+ ]
140
+ },
141
+ {
142
+ "path": "daemon/info.go",
143
+ "sha256": "158836396d4f4f17756f932028e74b253bf089478f257a78a0df29781770a426",
144
+ "versions": [
145
+ "26.0.0", "26.0.1", "26.0.2", "26.1.0", "26.1.1", "26.1.2", "26.1.3",
146
+ "26.1.4"
147
+ ]
148
+ },
149
+ {
150
+ "path": "daemon/info.go",
151
+ "sha256": "0d3fc255e4fe964851a74ee6396b33ae0f5d35fd8ab9699f9849bd4816c8de0a",
152
+ "versions": [
153
+ "27.0.1", "27.0.2", "27.0.3", "27.1.0", "27.1.1", "27.1.2"
154
+ ]
155
+ },
156
+ {
157
+ "path": "daemon/info.go",
158
+ "sha256": "4561f3eafe327c5bfac7c6a9cba25833dece6e540013a1f8dbc1e05d4008feab",
159
+ "versions": [
160
+ "27.2.0", "27.2.1", "27.3.0", "27.3.1"
161
+ ]
162
+ },
163
+ {
164
+ "path": "daemon/info.go",
165
+ "sha256": "482bb3b0336a2d6245e364e4572eadb09bfd19c8a1f4bbf431f82a86fce7e2cc",
166
+ "versions": [
167
+ "27.4.0", "27.4.1"
168
+ ]
169
+ },
170
+ {
171
+ "path": "daemon/info.go",
172
+ "sha256": "edebebe8c9d4fe716ff055e8b2ce5eb541aa949e1fc3f36c7bf86630317ff843",
173
+ "versions": [
174
+ "27.5.0", "27.5.1"
175
+ ]
176
+ },
177
+ {
178
+ "path": "daemon/info.go",
179
+ "sha256": "47b912bc65d9f1ef3f2136c6c294fc67f894a10b41d8795e0fe23ac0e9724821",
180
+ "versions": [
181
+ "28.0.0", "28.0.1", "28.0.2", "28.0.3", "28.0.4"
182
+ ]
183
+ },
184
+ {
185
+ "path": "daemon/info.go",
186
+ "sha256": "23a585ec2407b912aa527f474b76accde8ed27b617b2003dfb81417de694b1f9",
187
+ "versions": [
188
+ "28.1.0"
189
+ ]
190
+ },
191
+ {
192
+ "path": "daemon/info.go",
193
+ "sha256": "a261057379a12afaf9d5648960c614affb1b155778559e933b2d5910d372525f",
194
+ "versions": [
195
+ "28.1.1"
196
+ ]
197
+ },
198
+ {
199
+ "path": "daemon/info.go",
200
+ "sha256": "3f0eebc794400b091c4a7dc858630691b2bf26cea4305dc7656ce93c1052e8f6",
201
+ "versions": [
202
+ "28.2.0", "28.2.1", "28.2.2"
203
+ ]
204
+ },
205
+ {
206
+ "path": "daemon/info.go",
207
+ "sha256": "6bc4ef7084e3a0b7a523630039d2c560fb5cb1b367bc0b8021606e60a40916be",
208
+ "versions": [
209
+ "28.3.0", "28.3.1", "28.3.2", "28.3.3", "28.4.0", "28.5.0", "28.5.1",
210
+ "28.5.2"
211
+ ]
212
+ }
213
+ ]
214
+ }
@@ -0,0 +1,261 @@
1
+ """Strict address-space observations; no guessed ranges or host mutations (Python 3.8)."""
2
+ import ipaddress
3
+ import json
4
+ import re
5
+
6
+ FAILURES = frozenset({
7
+ 'inventory_command_failed', 'inventory_timeout', 'inventory_output_limit', 'inventory_invalid',
8
+ 'network_inventory_changed', 'host_inventory_changed', 'network_ipam_unsupported',
9
+ 'host_namespace_unverified', 'docker_endpoint_unsupported', 'docker_engine_unsupported',
10
+ 'pool_provenance_unknown', 'pool_inventory_invalid', 'host_routes_unsupported',
11
+ 'host_rules_unsupported', 'host_addresses_invalid', 'resolver_inventory_invalid',
12
+ 'resolver_upstream_unknown',
13
+ })
14
+ PRIVATE = tuple(ipaddress.ip_network(value) for value in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'))
15
+ DEVICE = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,14}')
16
+
17
+ # Exact tags were read and compared; source hashes are in network_pool_facts.json.
18
+ BUILTIN_VERSIONS = frozenset({
19
+ '20.10.0', '20.10.1', '20.10.2', '20.10.3', '20.10.4', '20.10.5', '20.10.6', '20.10.7',
20
+ '20.10.8', '20.10.9', '20.10.10', '20.10.11', '20.10.12', '20.10.13', '20.10.14', '20.10.15',
21
+ '20.10.16', '20.10.17', '20.10.18', '20.10.19', '20.10.20', '20.10.21', '20.10.22', '20.10.23',
22
+ '20.10.24', '20.10.25', '20.10.26', '20.10.27', '23.0.0', '23.0.1', '23.0.2', '23.0.3',
23
+ '23.0.4', '23.0.5', '23.0.6', '23.0.7', '23.0.8', '23.0.9', '23.0.10', '23.0.11',
24
+ '23.0.12', '23.0.13', '23.0.14', '23.0.15', '23.0.16', '23.0.17', '23.0.18', '24.0.0',
25
+ '24.0.1', '24.0.2', '24.0.3', '24.0.4', '24.0.5', '24.0.6', '24.0.7', '24.0.8',
26
+ '24.0.9', '25.0.0', '25.0.1', '25.0.2', '25.0.3', '25.0.4', '25.0.5', '25.0.7',
27
+ '25.0.8', '25.0.9', '25.0.10', '25.0.11', '25.0.12', '25.0.14', '25.0.15', '25.0.16',
28
+ '26.0.0', '26.0.1', '26.0.2', '26.1.0', '26.1.1', '26.1.2', '26.1.3', '26.1.4',
29
+ '27.0.1', '27.0.2', '27.0.3', '27.1.0', '27.1.1', '27.1.2', '27.2.0', '27.2.1',
30
+ '27.3.0', '27.3.1', '27.4.0', '27.4.1', '27.5.0', '27.5.1', '28.0.0', '28.0.1',
31
+ '28.0.2', '28.0.3', '28.0.4', '28.1.0', '28.1.1', '28.2.0', '28.2.1', '28.2.2',
32
+ '28.3.0', '28.3.1', '28.3.2', '28.3.3', '28.4.0', '28.5.0', '28.5.1', '28.5.2',
33
+ })
34
+ BUILTIN_POOLS = (
35
+ {'Base': '172.17.0.0/16', 'Size': 16},
36
+ {'Base': '172.18.0.0/16', 'Size': 16},
37
+ {'Base': '172.19.0.0/16', 'Size': 16},
38
+ {'Base': '172.20.0.0/14', 'Size': 16},
39
+ {'Base': '172.24.0.0/14', 'Size': 16},
40
+ {'Base': '172.28.0.0/14', 'Size': 16},
41
+ {'Base': '192.168.0.0/16', 'Size': 20},
42
+ )
43
+
44
+
45
+ class InventoryFailure(Exception):
46
+ def __init__(self, code):
47
+ self.code = code if code in FAILURES else 'inventory_invalid'
48
+ self.details = {}
49
+ super().__init__(self.code)
50
+
51
+
52
+ def unique_object(pairs):
53
+ result = {}
54
+ for key, value in pairs:
55
+ if key in result:
56
+ raise InventoryFailure('inventory_invalid')
57
+ result[key] = value
58
+ return result
59
+
60
+
61
+ def decode(raw):
62
+ try:
63
+ return json.loads(raw, object_pairs_hook=unique_object, parse_constant=reject_constant)
64
+ except (ValueError, TypeError, UnicodeError, RecursionError):
65
+ raise InventoryFailure('inventory_invalid') from None
66
+
67
+
68
+ def reject_constant(value):
69
+ raise InventoryFailure('inventory_invalid')
70
+
71
+
72
+ def prefix(value, *, strict=False):
73
+ if not isinstance(value, str) or len(value) > 128 or '%' in value:
74
+ raise InventoryFailure('inventory_invalid')
75
+ try:
76
+ return ipaddress.ip_network(value, strict=strict)
77
+ except ValueError:
78
+ raise InventoryFailure('inventory_invalid') from None
79
+
80
+
81
+ def effective_pools(raw, version):
82
+ """Only exact reviewed release versions permit the empty-info builtin fallback."""
83
+ source = 'engine_info'
84
+ if raw is None or raw == []:
85
+ if version not in BUILTIN_VERSIONS:
86
+ raise InventoryFailure('pool_provenance_unknown')
87
+ raw, source = list(BUILTIN_POOLS), 'versioned_moby_builtin'
88
+ if not isinstance(raw, list) or not raw or len(raw) > 64:
89
+ raise InventoryFailure('pool_inventory_invalid')
90
+ result = []
91
+ for row in raw:
92
+ if not isinstance(row, dict) or set(row) != {'Base', 'Size'}:
93
+ raise InventoryFailure('pool_inventory_invalid')
94
+ base, size = prefix(row['Base'], strict=True), row['Size']
95
+ if type(size) is not int or not base.prefixlen <= size <= base.max_prefixlen:
96
+ raise InventoryFailure('pool_inventory_invalid')
97
+ if base.version == 6:
98
+ continue
99
+ if not any(base.subnet_of(space) for space in PRIVATE):
100
+ raise InventoryFailure('pool_inventory_invalid')
101
+ if any(base.overlaps(prefix(other['base'])) for other in result):
102
+ raise InventoryFailure('pool_inventory_invalid')
103
+ result.append({'base': str(base), 'size': size})
104
+ if not result:
105
+ raise InventoryFailure('pool_inventory_invalid')
106
+ return result, source
107
+
108
+
109
+ def route_exclusions(rows, records=None):
110
+ """All IPv4 tables are observed; unsupported forwarding mechanisms refuse."""
111
+ allowed = {'dst', 'type', 'gateway', 'prefsrc', 'src', 'dev', 'protocol', 'scope', 'table',
112
+ 'metric', 'flags', 'tos', 'mtu', 'advmss', 'initcwnd', 'initrwnd', 'rto_min',
113
+ 'quickack', 'congctl', 'features', 'expires', 'pref', 'nexthops', 'multipath'}
114
+ result, records = set(), records if records is not None else []
115
+ if not isinstance(rows, list) or not rows or len(rows) > 8192:
116
+ raise InventoryFailure('host_routes_unsupported')
117
+ for row in rows:
118
+ if (not isinstance(row, dict) or set(row) - allowed or 'dst' not in row
119
+ or row.get('type', 'unicast') not in
120
+ {'unicast', 'local', 'broadcast', 'multicast', 'blackhole', 'unreachable', 'prohibit', 'throw'}):
121
+ raise InventoryFailure('host_routes_unsupported')
122
+ device, kind = row.get('dev', ''), 'route_other'
123
+ if (not isinstance(device, str) or (device and not DEVICE.fullmatch(device))
124
+ or any(type(row.get(key, '')) not in (str, int) for key in ('scope', 'table', 'protocol'))):
125
+ raise InventoryFailure('host_routes_unsupported')
126
+ table = {254: 'main', 255: 'local'}.get(row.get('table'), row.get('table', 'main'))
127
+ signature = (row.get('type', 'unicast'), row.get('scope'), table)
128
+ if row.get('protocol') == 'kernel' and not set(row) - {
129
+ 'dst', 'type', 'dev', 'protocol', 'scope', 'table', 'prefsrc', 'flags'}:
130
+ kind = {('unicast', 'link', 'main'): 'route_connected', ('local', 'host', 'local'): 'route_local',
131
+ ('broadcast', 'link', 'local'): 'route_broadcast'}.get(signature, 'route_other')
132
+ if row['dst'] != 'default':
133
+ destination = prefix(row['dst'])
134
+ if destination.version != 4 or destination.prefixlen == 0:
135
+ raise InventoryFailure('host_routes_unsupported')
136
+ result.add(str(destination))
137
+ records.append({'subnet': str(destination), 'kind': kind, 'device': device})
138
+ elif row.get('table') in (52, '52'):
139
+ # A Tailscale exit route precedes main and can divert a new bridge's own traffic.
140
+ raise InventoryFailure('host_routes_unsupported')
141
+ paths = row.get('nexthops', row.get('multipath', []))
142
+ if not isinstance(paths, list) or len(paths) > 256:
143
+ raise InventoryFailure('host_routes_unsupported')
144
+ for path in [row] + paths:
145
+ if not isinstance(path, dict) or (path is not row and set(path) - {'gateway', 'dev', 'weight', 'flags'}):
146
+ raise InventoryFailure('host_routes_unsupported')
147
+ device = path.get('dev', '')
148
+ if not isinstance(device, str) or (device and not DEVICE.fullmatch(device)):
149
+ raise InventoryFailure('host_routes_unsupported')
150
+ for key in ('gateway', 'prefsrc', 'src'):
151
+ if key in path:
152
+ address = prefix(path[key])
153
+ if address.version != 4:
154
+ raise InventoryFailure('host_routes_unsupported')
155
+ result.add(str(address))
156
+ observed = kind + '_source' if key == 'prefsrc' else 'route_' + key
157
+ records.append({'subnet': str(address), 'kind': observed, 'device': device})
158
+ return result
159
+
160
+
161
+ def address_exclusions(rows, records=None):
162
+ result, records = set(), records if records is not None else []
163
+ if not isinstance(rows, list) or not rows or len(rows) > 4096:
164
+ raise InventoryFailure('host_addresses_invalid')
165
+ for row in rows:
166
+ if not isinstance(row, dict) or not isinstance(row.get('addr_info'), list):
167
+ raise InventoryFailure('host_addresses_invalid')
168
+ device = row.get('ifname')
169
+ if not isinstance(device, str) or not DEVICE.fullmatch(device):
170
+ raise InventoryFailure('host_addresses_invalid')
171
+ for address in row['addr_info']:
172
+ if (not isinstance(address, dict) or address.get('family') != 'inet'
173
+ or type(address.get('prefixlen')) is not int or not 0 <= address['prefixlen'] <= 32):
174
+ raise InventoryFailure('host_addresses_invalid')
175
+ local = prefix(address.get('local'))
176
+ if local.version != 4 or local.prefixlen != 32:
177
+ raise InventoryFailure('host_addresses_invalid')
178
+ subnet = str(prefix(str(local.network_address) + '/' + str(address['prefixlen'])))
179
+ result.add(subnet)
180
+ records.append({'subnet': subnet, 'kind': 'address_network', 'device': device})
181
+ records.append({'subnet': str(local), 'kind': 'address_local', 'device': device})
182
+ for key in ('peer', 'broadcast'):
183
+ if key in address:
184
+ peer = prefix(address[key])
185
+ if peer.version != 4:
186
+ raise InventoryFailure('host_addresses_invalid')
187
+ result.add(str(peer))
188
+ records.append({'subnet': str(peer), 'kind': 'address_' + key, 'device': device})
189
+ if not result:
190
+ raise InventoryFailure('host_addresses_invalid')
191
+ return result
192
+
193
+
194
+ def validate_rules(rows):
195
+ """Admit ordinary Linux rules or the exact Tailscale v1.88.2 baseIPRules chain."""
196
+ # https://github.com/tailscale/tailscale/blob/v1.88.2/wgengine/router/router_linux.go
197
+ if not isinstance(rows, list) or len(rows) not in (3, 7):
198
+ raise InventoryFailure('host_rules_unsupported')
199
+ expected = [{'priority': p, 'src': 'all', 'table': t}
200
+ for p, t in ((0, 'local'), (32766, 'main'), (32767, 'default'))]
201
+ if len(rows) == 7:
202
+ marked = {'src': 'all', 'fwmark': '0x80000', 'fwmask': '0xff0000'}
203
+ expected += [dict(marked, priority=5210, table='main'), dict(marked, priority=5230, table='default'),
204
+ dict(marked, priority=5250, action='unreachable'), {'priority': 5270, 'src': 'all', 'table': 52}]
205
+ actual = []
206
+ tables = {255: 'local', 254: 'main', 253: 'default'}
207
+ for row in rows:
208
+ if (not isinstance(row, dict) or set(row) - {'priority', 'src', 'table', 'protocol',
209
+ 'fwmark', 'fwmask', 'action'}
210
+ or type(row.get('priority')) is not int or row.get('src') != 'all'):
211
+ raise InventoryFailure('host_rules_unsupported')
212
+ normalized = {key: value for key, value in row.items() if key != 'protocol'}
213
+ table = normalized.get('table')
214
+ if table is not None:
215
+ if type(table) not in (str, int):
216
+ raise InventoryFailure('host_rules_unsupported')
217
+ normalized['table'] = tables.get(table, table)
218
+ if normalized not in expected or normalized in actual:
219
+ raise InventoryFailure('host_rules_unsupported')
220
+ actual.append(normalized)
221
+ if len(actual) != len(expected):
222
+ raise InventoryFailure('host_rules_unsupported')
223
+
224
+
225
+ def resolver_exclusions(raw, *, resolved=False, records=None):
226
+ """Parse resolver addresses only; local stubs require authoritative upstream data."""
227
+ try:
228
+ lines = raw.decode('utf-8').splitlines()
229
+ except (AttributeError, UnicodeError):
230
+ raise InventoryFailure('resolver_inventory_invalid') from None
231
+ result, stub, observed = set(), False, False
232
+ records = records if records is not None else []
233
+ for line in lines:
234
+ if resolved:
235
+ match = re.fullmatch(r'(?:Global|Link [0-9]+ \([^\r\n:]+\)):\s*(.*)', line.strip())
236
+ if not match:
237
+ raise InventoryFailure('resolver_inventory_invalid')
238
+ words = match[1].split()
239
+ else:
240
+ words = line.split('#', 1)[0].split(';', 1)[0].split()
241
+ if not words or words[0] != 'nameserver':
242
+ continue
243
+ if len(words) != 2:
244
+ raise InventoryFailure('resolver_inventory_invalid')
245
+ words = words[1:]
246
+ for word in words:
247
+ address = prefix(word)
248
+ observed = True
249
+ if address.prefixlen != address.max_prefixlen:
250
+ raise InventoryFailure('resolver_inventory_invalid')
251
+ if address.version != 4:
252
+ continue
253
+ if address.network_address.is_loopback:
254
+ if resolved or str(address.network_address) not in {'127.0.0.53', '127.0.0.54'}:
255
+ raise InventoryFailure('resolver_upstream_unknown')
256
+ stub = True
257
+ result.add(str(address))
258
+ records.append({'subnet': str(address), 'kind': 'resolver', 'device': ''})
259
+ if not observed:
260
+ raise InventoryFailure('resolver_inventory_invalid')
261
+ return result, stub