@oneuptime/common 12.0.21 → 12.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +4 -0
- package/Models/DatabaseModels/NetworkDevice.ts +44 -0
- package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +564 -0
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +38 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.ts +60 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.ts +26 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.ts +26 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +917 -0
- package/Server/Services/NetworkDeviceAutoImportRuleService.ts +181 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +39 -1
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Services/AutoImportScanCredentialSelect.test.ts +205 -0
- package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +933 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +97 -1
- package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
- package/Tests/Utils/NetworkDiscovery/AutoImportRuleMatcher.test.ts +516 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +272 -0
- package/Tests/Utils/NetworkDiscovery/ScanTargetUtil.test.ts +164 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NetworkAutomation/RuleRunResult.ts +84 -0
- package/Types/Permission.ts +47 -0
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +257 -0
- package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +183 -0
- package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +95 -0
- package/Utils/NetworkDiscovery/DiscoveryImportEligibility.ts +66 -0
- package/Utils/NetworkDiscovery/ScanTargetUtil.ts +82 -0
- package/build/dist/Models/DatabaseModels/Index.js +4 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +589 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +28 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js +35 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js +19 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js +19 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +711 -0
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +155 -0
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -0
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +26 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js +24 -0
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
- package/build/dist/Types/Permission.js +41 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js +149 -0
- package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +121 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +75 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js +59 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js +60 -0
- package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
import NetworkDevice from "../../Models/DatabaseModels/NetworkDevice";
|
|
2
|
+
import NetworkDeviceAutoImportRule from "../../Models/DatabaseModels/NetworkDeviceAutoImportRule";
|
|
3
|
+
import NetworkDeviceDiscoveryScan, {
|
|
4
|
+
DiscoveredNetworkDevice,
|
|
5
|
+
} from "../../Models/DatabaseModels/NetworkDeviceDiscoveryScan";
|
|
6
|
+
import NetworkDeviceAutoImportRuleService from "./NetworkDeviceAutoImportRuleService";
|
|
7
|
+
import NetworkDeviceDiscoveryScanService from "./NetworkDeviceDiscoveryScanService";
|
|
8
|
+
import NetworkDeviceService from "./NetworkDeviceService";
|
|
9
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
10
|
+
import { JSONObject } from "../../Types/JSON";
|
|
11
|
+
import LIMIT_MAX from "../../Types/Database/LimitMax";
|
|
12
|
+
import ObjectID from "../../Types/ObjectID";
|
|
13
|
+
import OneUptimeDate from "../../Types/Date";
|
|
14
|
+
import QueryDeepPartialEntity from "../../Types/Database/PartialEntity";
|
|
15
|
+
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
|
16
|
+
import {
|
|
17
|
+
AutoImportRuleRunResult,
|
|
18
|
+
MAX_MATCHED_IP_SAMPLE,
|
|
19
|
+
} from "../../Types/NetworkAutomation/RuleRunResult";
|
|
20
|
+
import AutoImportRuleMatcher, {
|
|
21
|
+
AutoImportHostEvaluation,
|
|
22
|
+
} from "../../Utils/NetworkDiscovery/AutoImportRuleMatcher";
|
|
23
|
+
import {
|
|
24
|
+
buildFallbackDeviceName,
|
|
25
|
+
buildNetworkDeviceFromDiscoveredHost,
|
|
26
|
+
} from "../../Utils/NetworkDiscovery/DiscoveredDeviceBuilder";
|
|
27
|
+
import { normalizeDiscoveredHosts } from "../../Utils/NetworkDiscovery/DiscoveredHostUtil";
|
|
28
|
+
import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
|
|
29
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
30
|
+
import logger, { LogAttributes } from "../Utils/Logger";
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* The engine behind network device auto-import rules (issue #3378): reads a
|
|
34
|
+
* completed discovery scan's results, asks the project's rules which hosts
|
|
35
|
+
* import, and creates the Network Devices the manual Review dialog would
|
|
36
|
+
* have — through the same builder, so a rule-imported host and a
|
|
37
|
+
* hand-imported host are the same device. Site assignment, owners and labels
|
|
38
|
+
* then apply themselves through NetworkDeviceService.onCreateSuccess's
|
|
39
|
+
* existing rule chain, exactly as they do for a manual import.
|
|
40
|
+
*
|
|
41
|
+
* Two entry points, mirroring the label-rule engine:
|
|
42
|
+
*
|
|
43
|
+
* - processCompletedScan: the automatic path, called by the
|
|
44
|
+
* NetworkDeviceDiscovery worker for each Completed scan whose
|
|
45
|
+
* autoImportProcessedAt marker is NULL.
|
|
46
|
+
* - applyRuleToCompletedScans: the manual "Run Now" (and its dry run),
|
|
47
|
+
* applying ONE rule to the project's existing completed scans.
|
|
48
|
+
*
|
|
49
|
+
* Everything here is idempotent by construction: a device exists per
|
|
50
|
+
* (project, address), recurring scans re-report the same hosts every
|
|
51
|
+
* interval, and both paths skip hosts whose address is already registered —
|
|
52
|
+
* so seeing the same results twice creates nothing twice.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* The scan columns an import copies onto an SNMP device — the same set the
|
|
57
|
+
* probe claim endpoint selects, and for the same reason: an unselected
|
|
58
|
+
* credential column arrives undefined and would be silently NOT copied,
|
|
59
|
+
* leaving the device unable to poll forever. Exported so a test can pin this
|
|
60
|
+
* list against the builder's DiscoveredDeviceScanSource shape.
|
|
61
|
+
*/
|
|
62
|
+
export const AUTO_IMPORT_SCAN_CREDENTIAL_SELECT: {
|
|
63
|
+
[key: string]: boolean;
|
|
64
|
+
} = {
|
|
65
|
+
probeId: true,
|
|
66
|
+
snmpVersion: true,
|
|
67
|
+
snmpCommunityString: true,
|
|
68
|
+
snmpPort: true,
|
|
69
|
+
snmpV3SecurityLevel: true,
|
|
70
|
+
snmpV3Username: true,
|
|
71
|
+
snmpV3AuthProtocol: true,
|
|
72
|
+
snmpV3AuthKey: true,
|
|
73
|
+
snmpV3PrivProtocol: true,
|
|
74
|
+
snmpV3PrivKey: true,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* Devices one pass will create from one scan. Deliberately conservative:
|
|
79
|
+
* every create runs the full service pipeline plus a detached
|
|
80
|
+
* label/owner/site rule chain, so hundreds per pass is already a burst of
|
|
81
|
+
* thousands of queries. The cap loses nothing — a truncated pass leaves the
|
|
82
|
+
* scan's marker unstamped, the next worker tick resumes, and idempotency
|
|
83
|
+
* skips what this pass already created — but it turns "a typo'd rule meets a
|
|
84
|
+
* /16" from an outage into a few paced ticks an operator can catch.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_DEVICES_PER_AUTO_IMPORT_RUN: number = 500;
|
|
87
|
+
|
|
88
|
+
// Scans one manual "Run Now" will read, oldest results last.
|
|
89
|
+
export const MAX_SCANS_PER_AUTO_IMPORT_RULE_RUN: number = 100;
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* Results older than this are stamped processed WITHOUT importing. A late
|
|
93
|
+
* result can land on a reaper-Failed scan hours after its sweep ran (the
|
|
94
|
+
* ingest endpoint accepts it as the truth about that run), and a worker
|
|
95
|
+
* outage can leave a backlog — in both cases silently auto-importing an
|
|
96
|
+
* hours-old host list is the wrong surprise. The operator can still reach
|
|
97
|
+
* old results deliberately, through "Run Now".
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_RESULT_AGE_IN_HOURS: number = 24;
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
* The Redis lock every path that CREATES devices from scan results runs
|
|
103
|
+
* under — the worker sweep and the manual Run Now alike. The engine's
|
|
104
|
+
* idempotency is check-then-create with no DB backstop (device names are
|
|
105
|
+
* app-level unique, addresses not unique at all), so two concurrent passes
|
|
106
|
+
* over the same results can both pass the check and both insert. The worker
|
|
107
|
+
* holds this lock for its whole sweep; a real (non-dry) manual run takes
|
|
108
|
+
* the SAME lock, so manual-vs-sweep and manual-vs-manual can never
|
|
109
|
+
* interleave — which also means the scan jsonb write-back has exactly one
|
|
110
|
+
* engine writer at a time. Defined here, imported by the worker, so the two
|
|
111
|
+
* can never drift onto different locks.
|
|
112
|
+
*/
|
|
113
|
+
export const AUTO_IMPORT_SWEEP_LOCK_KEY: string =
|
|
114
|
+
"NetworkDeviceDiscovery:ProcessAutoImportRules";
|
|
115
|
+
export const AUTO_IMPORT_SWEEP_LOCK_NAMESPACE: string = "Workers.Cron";
|
|
116
|
+
export const AUTO_IMPORT_SWEEP_LOCK_TIMEOUT_MS: number = 11 * 60 * 1000;
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* Hostname sets shared across one worker sweep, keyed by project id. One
|
|
120
|
+
* sweep can process several scans of the same project (overlapping subnets
|
|
121
|
+
* on different probes are normal), and a device created from scan A must
|
|
122
|
+
* read as registered when scan B reports the same address seconds later —
|
|
123
|
+
* before any re-read of the device table would show it.
|
|
124
|
+
*/
|
|
125
|
+
export type ExistingHostnamesByProjectId = Map<string, Set<string>>;
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* How many imports one run has attempted, shared across every scan the run
|
|
129
|
+
* touches. Real runs count creates and failures; dry runs count the imports
|
|
130
|
+
* they WOULD have attempted — so a dry run over a /16 is bounded by the same
|
|
131
|
+
* cap as the real run it predicts, instead of walking unbounded work inside
|
|
132
|
+
* an API request.
|
|
133
|
+
*/
|
|
134
|
+
interface ImportAttemptBudget {
|
|
135
|
+
count: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class NetworkDeviceAutoImportRuleEngineServiceClass {
|
|
139
|
+
/**
|
|
140
|
+
* The automatic path: apply the project's enabled rules to one Completed
|
|
141
|
+
* scan, then stamp the scan's autoImportProcessedAt marker.
|
|
142
|
+
*
|
|
143
|
+
* Returns null when there was nothing to do (scan gone, superseded,
|
|
144
|
+
* already processed, no import rules, or results too old) and the run's
|
|
145
|
+
* counters otherwise. Throws only on unexpected failures — the caller
|
|
146
|
+
* (the worker sweep) isolates those per scan.
|
|
147
|
+
*
|
|
148
|
+
* The marker protocol, and why every stamp is a compare-and-set on
|
|
149
|
+
* (status, completedAt): the ingest endpoint clears the marker in the same
|
|
150
|
+
* write that stores new results, so "marker is NULL" always means "the
|
|
151
|
+
* results now on the row have not been processed". If new results land
|
|
152
|
+
* while this pass is reading the old ones, the CAS misses, the marker
|
|
153
|
+
* stays NULL, and the next tick processes the new upload — the stamp can
|
|
154
|
+
* never retire results it did not see, and the jsonb write-back can never
|
|
155
|
+
* clobber a newer host list. A truncated pass that created something
|
|
156
|
+
* stamps nothing on purpose: the NULL marker is what makes the next tick
|
|
157
|
+
* resume. (A truncated pass where every create FAILED stamps anyway — see
|
|
158
|
+
* the zero-progress note at the stamp below.)
|
|
159
|
+
*/
|
|
160
|
+
@CaptureSpan()
|
|
161
|
+
public async processCompletedScan(data: {
|
|
162
|
+
scanId: ObjectID;
|
|
163
|
+
existingHostnamesByProjectId: ExistingHostnamesByProjectId;
|
|
164
|
+
}): Promise<AutoImportRuleRunResult | null> {
|
|
165
|
+
const scan: NetworkDeviceDiscoveryScan | null =
|
|
166
|
+
await NetworkDeviceDiscoveryScanService.findOneBy({
|
|
167
|
+
query: {
|
|
168
|
+
_id: data.scanId,
|
|
169
|
+
status: "Completed",
|
|
170
|
+
},
|
|
171
|
+
select: {
|
|
172
|
+
_id: true,
|
|
173
|
+
projectId: true,
|
|
174
|
+
status: true,
|
|
175
|
+
completedAt: true,
|
|
176
|
+
autoImportProcessedAt: true,
|
|
177
|
+
discoveredDevices: true,
|
|
178
|
+
...AUTO_IMPORT_SCAN_CREDENTIAL_SELECT,
|
|
179
|
+
},
|
|
180
|
+
props: {
|
|
181
|
+
isRoot: true,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/*
|
|
186
|
+
* Re-checked on this fresh read even though the sweep query already
|
|
187
|
+
* filtered: the sweep's snapshot ages while earlier scans in it are
|
|
188
|
+
* processed, and a scan can complete a new run (or be re-queued) in
|
|
189
|
+
* that window.
|
|
190
|
+
*/
|
|
191
|
+
if (!scan || !scan.projectId || scan.autoImportProcessedAt) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const projectId: ObjectID = scan.projectId;
|
|
196
|
+
|
|
197
|
+
const rules: Array<NetworkDeviceAutoImportRule> =
|
|
198
|
+
await this.loadEnabledRules(projectId);
|
|
199
|
+
|
|
200
|
+
const hasImportRules: boolean = rules.some(
|
|
201
|
+
(rule: NetworkDeviceAutoImportRule) => {
|
|
202
|
+
return !rule.isExclusion;
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (!hasImportRules) {
|
|
207
|
+
// Nothing can import; retire these results so they never accumulate.
|
|
208
|
+
await this.stampScan({ scan: scan, createdIpAddresses: [] });
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (this.isTooOldToAutoImport(scan)) {
|
|
213
|
+
logger.warn(
|
|
214
|
+
`Auto-import: scan ${scan.id?.toString()} completed more than ${MAX_RESULT_AGE_IN_HOURS} hours ago; stamping it processed without importing. Use the rule's Run Now to import old results deliberately.`,
|
|
215
|
+
{ projectId: projectId.toString() } as LogAttributes,
|
|
216
|
+
);
|
|
217
|
+
await this.stampScan({ scan: scan, createdIpAddresses: [] });
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const existingHostnames: Set<string> = await this.getExistingHostnames({
|
|
222
|
+
projectId: projectId,
|
|
223
|
+
cache: data.existingHostnamesByProjectId,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result: AutoImportRuleRunResult = this.emptyResult(false);
|
|
227
|
+
const attempts: ImportAttemptBudget = { count: 0 };
|
|
228
|
+
|
|
229
|
+
const createdIpAddresses: Array<string> = await this.importHostsFromScan({
|
|
230
|
+
scan: scan,
|
|
231
|
+
rules: rules,
|
|
232
|
+
existingHostnames: existingHostnames,
|
|
233
|
+
result: result,
|
|
234
|
+
attempts: attempts,
|
|
235
|
+
isDryRun: false,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* A truncated pass that made progress leaves the marker NULL so the
|
|
240
|
+
* next tick resumes where the cap stopped it. A truncated pass that
|
|
241
|
+
* created NOTHING — every attempt failed — is stamped anyway: resuming
|
|
242
|
+
* it would repeat the identical doomed pass every minute forever, and
|
|
243
|
+
* the sub-cap case already retires all-failing hosts un-imported in one
|
|
244
|
+
* pass, so stamping keeps the two consistent. Run Now remains the
|
|
245
|
+
* deliberate way back into a stamped scan.
|
|
246
|
+
*/
|
|
247
|
+
const shouldResume: boolean =
|
|
248
|
+
result.isTruncated && result.devicesCreated > 0;
|
|
249
|
+
|
|
250
|
+
if (result.isTruncated && result.devicesCreated === 0) {
|
|
251
|
+
logger.error(
|
|
252
|
+
`Auto-import: scan ${scan.id?.toString()} hit the per-pass cap with every create failing (${result.devicesFailed} failure(s)); stamping it processed so the sweep does not retry it forever. Inspect the failures and use the rule's Run Now to retry deliberately.`,
|
|
253
|
+
{ projectId: projectId.toString() } as LogAttributes,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await this.stampScan({
|
|
258
|
+
scan: scan,
|
|
259
|
+
createdIpAddresses: createdIpAddresses,
|
|
260
|
+
leaveMarkerUnstamped: shouldResume,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* The manual path: apply ONE rule to the project's completed scans, newest
|
|
268
|
+
* first — or, as a dry run, evaluate everything and write nothing. The
|
|
269
|
+
* project's enabled exclusion rules always ride along: "Run Now" must not
|
|
270
|
+
* be a way around a veto.
|
|
271
|
+
*
|
|
272
|
+
* Markers are deliberately not stamped here. They belong to the automatic
|
|
273
|
+
* path's bookkeeping, and a manual run of one rule has not done what the
|
|
274
|
+
* full rule set would.
|
|
275
|
+
*/
|
|
276
|
+
@CaptureSpan()
|
|
277
|
+
public async applyRuleToCompletedScans(data: {
|
|
278
|
+
ruleId: ObjectID;
|
|
279
|
+
projectId: ObjectID;
|
|
280
|
+
isDryRun: boolean;
|
|
281
|
+
}): Promise<AutoImportRuleRunResult> {
|
|
282
|
+
const rule: NetworkDeviceAutoImportRule | null =
|
|
283
|
+
await NetworkDeviceAutoImportRuleService.findOneBy({
|
|
284
|
+
query: {
|
|
285
|
+
_id: data.ruleId,
|
|
286
|
+
projectId: data.projectId,
|
|
287
|
+
},
|
|
288
|
+
select: {
|
|
289
|
+
_id: true,
|
|
290
|
+
name: true,
|
|
291
|
+
isEnabled: true,
|
|
292
|
+
isExclusion: true,
|
|
293
|
+
ipMatchTarget: true,
|
|
294
|
+
sysNamePattern: true,
|
|
295
|
+
sysDescrPattern: true,
|
|
296
|
+
sysObjectIdPattern: true,
|
|
297
|
+
includePingOnlyHosts: true,
|
|
298
|
+
},
|
|
299
|
+
props: { isRoot: true },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!rule) {
|
|
303
|
+
throw new BadDataException("Auto-import rule not found.");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/*
|
|
307
|
+
* A DISABLED rule can still be dry-run — that is the point of the dry
|
|
308
|
+
* run: answer "what would this rule import" BEFORE enabling it against
|
|
309
|
+
* live scans. Only a real run of a disabled rule contradicts the toggle
|
|
310
|
+
* the user can see next to the button.
|
|
311
|
+
*/
|
|
312
|
+
if (!rule.isEnabled && !data.isDryRun) {
|
|
313
|
+
throw new BadDataException(
|
|
314
|
+
"This auto-import rule is disabled. Enable it before running it, or use Dry Run to preview what it would import.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (rule.isExclusion) {
|
|
319
|
+
throw new BadDataException(
|
|
320
|
+
"An exclusion rule only vetoes other rules and imports nothing by itself, so running it alone would do nothing. Run one of the import rules it applies to instead.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/*
|
|
325
|
+
* A real run creates devices, so it must hold the same lock the worker
|
|
326
|
+
* sweep holds (see AUTO_IMPORT_SWEEP_LOCK_KEY): without it, a Run Now
|
|
327
|
+
* clicked while the every-minute sweep is processing the same
|
|
328
|
+
* marker-NULL scan races the check-then-create idempotency and both
|
|
329
|
+
* paths insert the same host. A dry run writes nothing and skips the
|
|
330
|
+
* lock.
|
|
331
|
+
*/
|
|
332
|
+
let mutex: SemaphoreMutex | null = null;
|
|
333
|
+
|
|
334
|
+
if (!data.isDryRun) {
|
|
335
|
+
try {
|
|
336
|
+
mutex = await Semaphore.lock({
|
|
337
|
+
key: AUTO_IMPORT_SWEEP_LOCK_KEY,
|
|
338
|
+
namespace: AUTO_IMPORT_SWEEP_LOCK_NAMESPACE,
|
|
339
|
+
lockTimeout: AUTO_IMPORT_SWEEP_LOCK_TIMEOUT_MS,
|
|
340
|
+
acquireAttemptsLimit: 3,
|
|
341
|
+
});
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logger.debug(
|
|
344
|
+
`Auto-import Run Now: could not acquire the sweep lock: ${err}`,
|
|
345
|
+
);
|
|
346
|
+
throw new BadDataException(
|
|
347
|
+
"An automatic import sweep is currently running. Try again in a minute — hosts it imports are skipped by your run anyway.",
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
return await this.runRuleAgainstCompletedScans({
|
|
354
|
+
rule: rule,
|
|
355
|
+
projectId: data.projectId,
|
|
356
|
+
isDryRun: data.isDryRun,
|
|
357
|
+
});
|
|
358
|
+
} finally {
|
|
359
|
+
if (mutex) {
|
|
360
|
+
try {
|
|
361
|
+
await Semaphore.release(mutex);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
logger.error(
|
|
364
|
+
`Auto-import Run Now: error releasing the sweep lock: ${err}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async runRuleAgainstCompletedScans(data: {
|
|
372
|
+
rule: NetworkDeviceAutoImportRule;
|
|
373
|
+
projectId: ObjectID;
|
|
374
|
+
isDryRun: boolean;
|
|
375
|
+
}): Promise<AutoImportRuleRunResult> {
|
|
376
|
+
const exclusionRules: Array<NetworkDeviceAutoImportRule> = (
|
|
377
|
+
await this.loadEnabledRules(data.projectId)
|
|
378
|
+
).filter((candidate: NetworkDeviceAutoImportRule) => {
|
|
379
|
+
return Boolean(candidate.isExclusion);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const rules: Array<NetworkDeviceAutoImportRule> = [
|
|
383
|
+
data.rule,
|
|
384
|
+
...exclusionRules,
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
const existingHostnames: Set<string> = await this.loadExistingHostnames(
|
|
388
|
+
data.projectId,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const result: AutoImportRuleRunResult = this.emptyResult(data.isDryRun);
|
|
392
|
+
const attempts: ImportAttemptBudget = { count: 0 };
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* Newest results first: a manual run is "bring the estate up to date",
|
|
396
|
+
* and the newest scan of a subnet supersedes its older runs — with
|
|
397
|
+
* idempotency, whichever result mentions a host first imports it and
|
|
398
|
+
* later mentions skip. Minimal columns only — the full row, with its
|
|
399
|
+
* multi-megabyte discoveredDevices jsonb, is re-read one scan at a time
|
|
400
|
+
* inside the loop so only one result set is ever resident (the same
|
|
401
|
+
* two-phase shape the worker sweep uses, for the same reason).
|
|
402
|
+
*/
|
|
403
|
+
const scanStubs: Array<NetworkDeviceDiscoveryScan> =
|
|
404
|
+
await NetworkDeviceDiscoveryScanService.findBy({
|
|
405
|
+
query: {
|
|
406
|
+
projectId: data.projectId,
|
|
407
|
+
status: "Completed",
|
|
408
|
+
},
|
|
409
|
+
select: {
|
|
410
|
+
_id: true,
|
|
411
|
+
},
|
|
412
|
+
sort: {
|
|
413
|
+
completedAt: SortOrder.Descending,
|
|
414
|
+
},
|
|
415
|
+
limit: MAX_SCANS_PER_AUTO_IMPORT_RULE_RUN + 1,
|
|
416
|
+
skip: 0,
|
|
417
|
+
props: { isRoot: true },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// One extra row settles "were there more scans" honestly.
|
|
421
|
+
result.hasMoreScans = scanStubs.length > MAX_SCANS_PER_AUTO_IMPORT_RULE_RUN;
|
|
422
|
+
|
|
423
|
+
if (result.hasMoreScans) {
|
|
424
|
+
scanStubs.pop();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const scanStub of scanStubs) {
|
|
428
|
+
const scan: NetworkDeviceDiscoveryScan | null =
|
|
429
|
+
await NetworkDeviceDiscoveryScanService.findOneBy({
|
|
430
|
+
query: {
|
|
431
|
+
_id: scanStub.id!,
|
|
432
|
+
projectId: data.projectId,
|
|
433
|
+
status: "Completed",
|
|
434
|
+
},
|
|
435
|
+
select: {
|
|
436
|
+
_id: true,
|
|
437
|
+
projectId: true,
|
|
438
|
+
status: true,
|
|
439
|
+
completedAt: true,
|
|
440
|
+
discoveredDevices: true,
|
|
441
|
+
...AUTO_IMPORT_SCAN_CREDENTIAL_SELECT,
|
|
442
|
+
},
|
|
443
|
+
props: { isRoot: true },
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Re-queued or deleted since the stub query — nothing to evaluate.
|
|
447
|
+
if (!scan) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const createdIpAddresses: Array<string> = await this.importHostsFromScan({
|
|
452
|
+
scan: scan,
|
|
453
|
+
rules: rules,
|
|
454
|
+
existingHostnames: existingHostnames,
|
|
455
|
+
result: result,
|
|
456
|
+
attempts: attempts,
|
|
457
|
+
isDryRun: data.isDryRun,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
if (createdIpAddresses.length > 0) {
|
|
461
|
+
/*
|
|
462
|
+
* Same CAS write-back as the automatic path, so the Review dialog
|
|
463
|
+
* does not re-offer (pre-checked!) hosts this run just imported. A
|
|
464
|
+
* miss only means fresher results arrived, which recomputed the
|
|
465
|
+
* flags anyway. No concurrent engine writer can clobber this: the
|
|
466
|
+
* write happens only on real runs, which hold the sweep lock.
|
|
467
|
+
*/
|
|
468
|
+
await this.stampScan({
|
|
469
|
+
scan: scan,
|
|
470
|
+
createdIpAddresses: createdIpAddresses,
|
|
471
|
+
leaveMarkerUnstamped: true,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The device cap is shared across the whole run, dry or real.
|
|
476
|
+
if (result.isTruncated) {
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/*
|
|
481
|
+
* Yield the event loop between scans: this runs inside an API request
|
|
482
|
+
* handler, and evaluating scan after scan of jsonb back-to-back would
|
|
483
|
+
* starve every other request the process is serving. setTimeout
|
|
484
|
+
* rather than setImmediate because the latter is a bare Node global
|
|
485
|
+
* that the jsdom-based test environment does not define.
|
|
486
|
+
*/
|
|
487
|
+
await new Promise<void>((resolve: () => void) => {
|
|
488
|
+
setTimeout(resolve, 0);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/*
|
|
496
|
+
* ------------------------------------------------------------------
|
|
497
|
+
* Internals
|
|
498
|
+
* ------------------------------------------------------------------
|
|
499
|
+
*/
|
|
500
|
+
|
|
501
|
+
private emptyResult(isDryRun: boolean): AutoImportRuleRunResult {
|
|
502
|
+
return {
|
|
503
|
+
hostsEvaluated: 0,
|
|
504
|
+
hostsMatched: 0,
|
|
505
|
+
hostsExcluded: 0,
|
|
506
|
+
hostsSkippedAlreadyRegistered: 0,
|
|
507
|
+
devicesCreated: 0,
|
|
508
|
+
devicesFailed: 0,
|
|
509
|
+
isTruncated: false,
|
|
510
|
+
hasMoreScans: false,
|
|
511
|
+
isDryRun: isDryRun,
|
|
512
|
+
matchedIpAddressSample: [],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/*
|
|
517
|
+
* ALL enabled rules of the project, paged to completeness — never a capped
|
|
518
|
+
* subset. The label/owner engines cap their fetch at 100 and a dropped
|
|
519
|
+
* rule there merely adds nothing; here a dropped EXCLUSION rule fails
|
|
520
|
+
* OPEN, importing the exact hosts the operator vetoed, nondeterministically
|
|
521
|
+
* (an uncapped unsorted fetch returns Postgres's choice of rows). Rule
|
|
522
|
+
* rows are a handful of scalar columns, so completeness is one query in
|
|
523
|
+
* any realistic project.
|
|
524
|
+
*/
|
|
525
|
+
private async loadEnabledRules(
|
|
526
|
+
projectId: ObjectID,
|
|
527
|
+
): Promise<Array<NetworkDeviceAutoImportRule>> {
|
|
528
|
+
const rules: Array<NetworkDeviceAutoImportRule> = [];
|
|
529
|
+
|
|
530
|
+
for (let skip: number = 0; ; skip += LIMIT_MAX) {
|
|
531
|
+
const page: Array<NetworkDeviceAutoImportRule> =
|
|
532
|
+
await NetworkDeviceAutoImportRuleService.findBy({
|
|
533
|
+
query: {
|
|
534
|
+
projectId: projectId,
|
|
535
|
+
isEnabled: true,
|
|
536
|
+
},
|
|
537
|
+
select: {
|
|
538
|
+
_id: true,
|
|
539
|
+
name: true,
|
|
540
|
+
isExclusion: true,
|
|
541
|
+
ipMatchTarget: true,
|
|
542
|
+
sysNamePattern: true,
|
|
543
|
+
sysDescrPattern: true,
|
|
544
|
+
sysObjectIdPattern: true,
|
|
545
|
+
includePingOnlyHosts: true,
|
|
546
|
+
},
|
|
547
|
+
sort: {
|
|
548
|
+
createdAt: SortOrder.Ascending,
|
|
549
|
+
},
|
|
550
|
+
limit: LIMIT_MAX,
|
|
551
|
+
skip: skip,
|
|
552
|
+
props: { isRoot: true },
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
rules.push(...page);
|
|
556
|
+
|
|
557
|
+
if (page.length < LIMIT_MAX) {
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return rules;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private isTooOldToAutoImport(scan: NetworkDeviceDiscoveryScan): boolean {
|
|
566
|
+
if (!scan.completedAt) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return OneUptimeDate.isBefore(
|
|
571
|
+
OneUptimeDate.fromString(scan.completedAt),
|
|
572
|
+
OneUptimeDate.getSomeHoursAgo(MAX_RESULT_AGE_IN_HOURS),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private async getExistingHostnames(data: {
|
|
577
|
+
projectId: ObjectID;
|
|
578
|
+
cache: ExistingHostnamesByProjectId;
|
|
579
|
+
}): Promise<Set<string>> {
|
|
580
|
+
const key: string = data.projectId.toString();
|
|
581
|
+
|
|
582
|
+
const cached: Set<string> | undefined = data.cache.get(key);
|
|
583
|
+
|
|
584
|
+
if (cached) {
|
|
585
|
+
return cached;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const loaded: Set<string> = await this.loadExistingHostnames(
|
|
589
|
+
data.projectId,
|
|
590
|
+
);
|
|
591
|
+
data.cache.set(key, loaded);
|
|
592
|
+
|
|
593
|
+
return loaded;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/*
|
|
597
|
+
* Every device address in the project — the same paged walk the probe
|
|
598
|
+
* ingest endpoint does to stamp isAlreadyRegistered, for the same reason:
|
|
599
|
+
* a truncated answer produces duplicate devices, which is worse than a
|
|
600
|
+
* slow answer. Sorted for stable paging.
|
|
601
|
+
*/
|
|
602
|
+
private async loadExistingHostnames(
|
|
603
|
+
projectId: ObjectID,
|
|
604
|
+
): Promise<Set<string>> {
|
|
605
|
+
const existingHostnames: Set<string> = new Set<string>();
|
|
606
|
+
|
|
607
|
+
for (let skip: number = 0; ; skip += LIMIT_MAX) {
|
|
608
|
+
const existing: Array<NetworkDevice> = await NetworkDeviceService.findBy({
|
|
609
|
+
query: {
|
|
610
|
+
projectId: projectId,
|
|
611
|
+
},
|
|
612
|
+
select: {
|
|
613
|
+
hostname: true,
|
|
614
|
+
},
|
|
615
|
+
sort: {
|
|
616
|
+
createdAt: SortOrder.Ascending,
|
|
617
|
+
},
|
|
618
|
+
limit: LIMIT_MAX,
|
|
619
|
+
skip: skip,
|
|
620
|
+
props: {
|
|
621
|
+
isRoot: true,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
for (const device of existing) {
|
|
626
|
+
if (device.hostname) {
|
|
627
|
+
existingHostnames.add(device.hostname);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (existing.length < LIMIT_MAX) {
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return existingHostnames;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/*
|
|
640
|
+
* The core loop: evaluate every discovered host of one scan against the
|
|
641
|
+
* rule set, create what should import, and account for every host in the
|
|
642
|
+
* shared result. Returns the addresses actually created (for the jsonb
|
|
643
|
+
* write-back). Mutates `existingHostnames` as it creates, which is what
|
|
644
|
+
* makes duplicate rows, overlapping scans in one sweep, and repeat runs
|
|
645
|
+
* idempotent.
|
|
646
|
+
*/
|
|
647
|
+
private async importHostsFromScan(data: {
|
|
648
|
+
scan: NetworkDeviceDiscoveryScan;
|
|
649
|
+
rules: Array<NetworkDeviceAutoImportRule>;
|
|
650
|
+
existingHostnames: Set<string>;
|
|
651
|
+
result: AutoImportRuleRunResult;
|
|
652
|
+
attempts: ImportAttemptBudget;
|
|
653
|
+
isDryRun: boolean;
|
|
654
|
+
}): Promise<Array<string>> {
|
|
655
|
+
const scan: NetworkDeviceDiscoveryScan = data.scan;
|
|
656
|
+
const result: AutoImportRuleRunResult = data.result;
|
|
657
|
+
|
|
658
|
+
if (!scan.projectId) {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
* The jsonb is the probe's payload stored verbatim; normalisation fixes
|
|
664
|
+
* the null rows, non-string addresses and duplicate-row flags the
|
|
665
|
+
* Review dialog already learned to survive. See DiscoveredHostUtil.
|
|
666
|
+
*/
|
|
667
|
+
const hosts: Array<DiscoveredNetworkDevice> = normalizeDiscoveredHosts(
|
|
668
|
+
(scan.discoveredDevices as Array<DiscoveredNetworkDevice>) || [],
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const createdIpAddresses: Array<string> = [];
|
|
672
|
+
|
|
673
|
+
for (const host of hosts) {
|
|
674
|
+
/*
|
|
675
|
+
* The address becomes the device's varchar(100) hostname, on which
|
|
676
|
+
* the create THROWS rather than truncates — and no real address is
|
|
677
|
+
* anywhere near that long, so an over-long "address" is junk that
|
|
678
|
+
* would fail identically on every pass. Refusing it here (instead of
|
|
679
|
+
* counting a devicesFailed per pass forever) is what keeps a
|
|
680
|
+
* pathological scan from failing the same way every worker tick.
|
|
681
|
+
*/
|
|
682
|
+
if (!host.ipAddress || host.ipAddress.length > 100) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
result.hostsEvaluated++;
|
|
687
|
+
|
|
688
|
+
const evaluation: AutoImportHostEvaluation =
|
|
689
|
+
AutoImportRuleMatcher.evaluateHost(data.rules, host);
|
|
690
|
+
|
|
691
|
+
if (evaluation.excludedByRule) {
|
|
692
|
+
result.hostsExcluded++;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!evaluation.shouldImport) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
result.hostsMatched++;
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* The frozen isAlreadyRegistered flag AND the live set: the flag is a
|
|
704
|
+
* point-in-time answer from the last upload, the set covers devices
|
|
705
|
+
* created since — including by this very run, which is what collapses
|
|
706
|
+
* duplicate rows and overlapping scans into one device.
|
|
707
|
+
*/
|
|
708
|
+
if (
|
|
709
|
+
host.isAlreadyRegistered ||
|
|
710
|
+
data.existingHostnames.has(host.ipAddress)
|
|
711
|
+
) {
|
|
712
|
+
result.hostsSkippedAlreadyRegistered++;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (data.attempts.count >= MAX_DEVICES_PER_AUTO_IMPORT_RUN) {
|
|
717
|
+
result.isTruncated = true;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (result.matchedIpAddressSample.length < MAX_MATCHED_IP_SAMPLE) {
|
|
722
|
+
result.matchedIpAddressSample.push(host.ipAddress);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (data.isDryRun) {
|
|
726
|
+
/*
|
|
727
|
+
* A dry run reports the device as "created" in no counter at all —
|
|
728
|
+
* hostsMatched minus hostsSkippedAlreadyRegistered is exactly what a
|
|
729
|
+
* real run would attempt, and the sample above says which hosts.
|
|
730
|
+
* The address still joins the set, simulating the create's dedupe:
|
|
731
|
+
* without this, a host on duplicate rows (or in two overlapping
|
|
732
|
+
* scans) would be counted as importable once per appearance, and
|
|
733
|
+
* the dry run would promise more than the real run does.
|
|
734
|
+
*/
|
|
735
|
+
data.attempts.count++;
|
|
736
|
+
data.existingHostnames.add(host.ipAddress);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const wasCreated: boolean = await this.createDeviceForHost({
|
|
741
|
+
projectId: scan.projectId,
|
|
742
|
+
scan: scan,
|
|
743
|
+
host: host,
|
|
744
|
+
existingHostnames: data.existingHostnames,
|
|
745
|
+
result: result,
|
|
746
|
+
attempts: data.attempts,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
if (wasCreated) {
|
|
750
|
+
createdIpAddresses.push(host.ipAddress);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return createdIpAddresses;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/*
|
|
758
|
+
* One host -> one device, with the collision protocol the estate needs:
|
|
759
|
+
*
|
|
760
|
+
* Device names are unique per project (an app-level check, not a DB
|
|
761
|
+
* constraint) while addresses are not unique at all, so the failure modes
|
|
762
|
+
* split. A create that fails re-checks the ADDRESS first — losing a race
|
|
763
|
+
* to a concurrent import of the same host must read as "already
|
|
764
|
+
* registered", not spawn a renamed twin. Only when the address is still
|
|
765
|
+
* free is the name the problem (two hosts legitimately sharing a sysName),
|
|
766
|
+
* and one retry under the address-suffixed fallback name settles it.
|
|
767
|
+
*/
|
|
768
|
+
private async createDeviceForHost(data: {
|
|
769
|
+
projectId: ObjectID;
|
|
770
|
+
scan: NetworkDeviceDiscoveryScan;
|
|
771
|
+
host: DiscoveredNetworkDevice;
|
|
772
|
+
existingHostnames: Set<string>;
|
|
773
|
+
result: AutoImportRuleRunResult;
|
|
774
|
+
attempts: ImportAttemptBudget;
|
|
775
|
+
}): Promise<boolean> {
|
|
776
|
+
data.attempts.count++;
|
|
777
|
+
|
|
778
|
+
const attemptCreate: (name?: string) => Promise<void> = async (
|
|
779
|
+
name?: string,
|
|
780
|
+
): Promise<void> => {
|
|
781
|
+
const device: NetworkDevice = buildNetworkDeviceFromDiscoveredHost({
|
|
782
|
+
projectId: data.projectId,
|
|
783
|
+
host: data.host,
|
|
784
|
+
scan: data.scan,
|
|
785
|
+
name: name,
|
|
786
|
+
/*
|
|
787
|
+
* Zero-touch all the way down: nobody reviews an auto-imported
|
|
788
|
+
* device, so nobody would ever click the vendor-template banner.
|
|
789
|
+
* The first poll that fingerprints the vendor seeds the Health
|
|
790
|
+
* OIDs instead (NetworkInventoryUtil).
|
|
791
|
+
*/
|
|
792
|
+
autoApplyVendorHealthTemplate: true,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await NetworkDeviceService.create({
|
|
796
|
+
data: device,
|
|
797
|
+
props: {
|
|
798
|
+
isRoot: true,
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
await attemptCreate();
|
|
805
|
+
} catch (firstError) {
|
|
806
|
+
const registeredMeanwhile: NetworkDevice | null =
|
|
807
|
+
await NetworkDeviceService.findOneBy({
|
|
808
|
+
query: {
|
|
809
|
+
projectId: data.projectId,
|
|
810
|
+
hostname: data.host.ipAddress,
|
|
811
|
+
},
|
|
812
|
+
select: { _id: true },
|
|
813
|
+
props: { isRoot: true },
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
if (registeredMeanwhile) {
|
|
817
|
+
// Skipped is a sub-bucket of matched, same as the other run results.
|
|
818
|
+
data.result.hostsSkippedAlreadyRegistered++;
|
|
819
|
+
data.existingHostnames.add(data.host.ipAddress);
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
await attemptCreate(buildFallbackDeviceName(data.host));
|
|
825
|
+
} catch (secondError) {
|
|
826
|
+
data.result.devicesFailed++;
|
|
827
|
+
logger.error(
|
|
828
|
+
`Auto-import: could not create a device for ${data.host.ipAddress} (scan ${data.scan.id?.toString()}): ${firstError} / retry: ${secondError}`,
|
|
829
|
+
{ projectId: data.projectId.toString() } as LogAttributes,
|
|
830
|
+
);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
data.result.devicesCreated++;
|
|
836
|
+
data.existingHostnames.add(data.host.ipAddress);
|
|
837
|
+
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/*
|
|
842
|
+
* The one write this engine makes to the scan row: retire the consumed
|
|
843
|
+
* hosts in the stored jsonb (so the Review dialog stops offering them) and
|
|
844
|
+
* stamp the processed marker — in a single hook-free compare-and-set on
|
|
845
|
+
* (status, completedAt), for the reasons on processCompletedScan. The
|
|
846
|
+
* claim endpoint's hook-free contract is why this must never go through
|
|
847
|
+
* updateOneById: the scan service deliberately has no update-success hooks
|
|
848
|
+
* to piggyback on, and this write must not fire the full pipeline on every
|
|
849
|
+
* worker tick.
|
|
850
|
+
*
|
|
851
|
+
* The CAS defends against exactly one concurrent writer — the ingest
|
|
852
|
+
* endpoint, which rewrites completedAt with every upload. It cannot
|
|
853
|
+
* defend against another ENGINE write (which changes neither status nor
|
|
854
|
+
* completedAt), and does not need to: every path that reaches this method
|
|
855
|
+
* holds the sweep lock (the worker for its whole sweep, Run Now for a
|
|
856
|
+
* real run; dry runs never write), so the full-array replace below always
|
|
857
|
+
* has the row's only engine writer. Weaken that invariant and this
|
|
858
|
+
* becomes last-writer-wins on the isAlreadyRegistered flips.
|
|
859
|
+
*/
|
|
860
|
+
private async stampScan(data: {
|
|
861
|
+
scan: NetworkDeviceDiscoveryScan;
|
|
862
|
+
createdIpAddresses: Array<string>;
|
|
863
|
+
leaveMarkerUnstamped?: boolean;
|
|
864
|
+
}): Promise<void> {
|
|
865
|
+
const payload: JSONObject = {};
|
|
866
|
+
|
|
867
|
+
if (!data.leaveMarkerUnstamped) {
|
|
868
|
+
payload["autoImportProcessedAt"] = OneUptimeDate.getCurrentDate();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (data.createdIpAddresses.length > 0) {
|
|
872
|
+
const createdSet: Set<string> = new Set<string>(data.createdIpAddresses);
|
|
873
|
+
|
|
874
|
+
/*
|
|
875
|
+
* Mapped over the RAW stored rows, not the normalised copies: the
|
|
876
|
+
* write-back must preserve whatever shape the probe sent, only
|
|
877
|
+
* flipping isAlreadyRegistered on rows whose (trimmed) address was
|
|
878
|
+
* imported. Junk rows pass through untouched.
|
|
879
|
+
*/
|
|
880
|
+
const restamped: Array<unknown> = (
|
|
881
|
+
(data.scan.discoveredDevices as Array<unknown>) || []
|
|
882
|
+
).map((row: unknown): unknown => {
|
|
883
|
+
if (!row || typeof row !== "object") {
|
|
884
|
+
return row;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const hostRow: DiscoveredNetworkDevice = row as DiscoveredNetworkDevice;
|
|
888
|
+
|
|
889
|
+
const ipAddress: string = String(hostRow.ipAddress ?? "").trim();
|
|
890
|
+
|
|
891
|
+
if (!ipAddress || !createdSet.has(ipAddress)) {
|
|
892
|
+
return row;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return { ...hostRow, isAlreadyRegistered: true };
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
payload["discoveredDevices"] = restamped as unknown as Array<JSONObject>;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (Object.keys(payload).length === 0) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
await NetworkDeviceDiscoveryScanService.updateColumnsByIdWithoutHooks({
|
|
906
|
+
id: data.scan.id!,
|
|
907
|
+
// Cast: the model's JSON column makes DeepPartial recursion blow up.
|
|
908
|
+
data: payload as unknown as QueryDeepPartialEntity<NetworkDeviceDiscoveryScan>,
|
|
909
|
+
expectedData: {
|
|
910
|
+
status: "Completed",
|
|
911
|
+
completedAt: data.scan.completedAt || null,
|
|
912
|
+
} as unknown as QueryDeepPartialEntity<NetworkDeviceDiscoveryScan>,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export default new NetworkDeviceAutoImportRuleEngineServiceClass();
|