@pax8-cta/core 0.1.0
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/LICENSE +198 -0
- package/dist/auth/device-code-login.d.ts +40 -0
- package/dist/auth/device-code-login.d.ts.map +1 -0
- package/dist/auth/device-code-login.js +59 -0
- package/dist/auth/device-code-login.js.map +1 -0
- package/dist/auth/gdap-client.d.ts +81 -0
- package/dist/auth/gdap-client.d.ts.map +1 -0
- package/dist/auth/gdap-client.js +128 -0
- package/dist/auth/gdap-client.js.map +1 -0
- package/dist/auth/index.d.ts +19 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +19 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/token-manager.d.ts +54 -0
- package/dist/auth/token-manager.d.ts.map +1 -0
- package/dist/auth/token-manager.js +150 -0
- package/dist/auth/token-manager.js.map +1 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +27 -0
- package/dist/client.js.map +1 -0
- package/dist/config/client.d.ts +24 -0
- package/dist/config/client.d.ts.map +1 -0
- package/dist/config/client.js +18 -0
- package/dist/config/client.js.map +1 -0
- package/dist/config/index.d.ts +18 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +18 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +81 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +271 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +751 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +556 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/constants.d.ts +116 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +170 -0
- package/dist/constants.js.map +1 -0
- package/dist/dataverse/agent-resolver.d.ts +98 -0
- package/dist/dataverse/agent-resolver.d.ts.map +1 -0
- package/dist/dataverse/agent-resolver.js +185 -0
- package/dist/dataverse/agent-resolver.js.map +1 -0
- package/dist/dataverse/client.d.ts +104 -0
- package/dist/dataverse/client.d.ts.map +1 -0
- package/dist/dataverse/client.js +272 -0
- package/dist/dataverse/client.js.map +1 -0
- package/dist/dataverse/connection-refs.d.ts +115 -0
- package/dist/dataverse/connection-refs.d.ts.map +1 -0
- package/dist/dataverse/connection-refs.js +203 -0
- package/dist/dataverse/connection-refs.js.map +1 -0
- package/dist/dataverse/index.d.ts +20 -0
- package/dist/dataverse/index.d.ts.map +1 -0
- package/dist/dataverse/index.js +20 -0
- package/dist/dataverse/index.js.map +1 -0
- package/dist/dataverse/solution-ops.d.ts +100 -0
- package/dist/dataverse/solution-ops.d.ts.map +1 -0
- package/dist/dataverse/solution-ops.js +288 -0
- package/dist/dataverse/solution-ops.js.map +1 -0
- package/dist/errors.d.ts +171 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +178 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/mock/demo-data.d.ts +213 -0
- package/dist/mock/demo-data.d.ts.map +1 -0
- package/dist/mock/demo-data.js +1096 -0
- package/dist/mock/demo-data.js.map +1 -0
- package/dist/mock/demo-deployment-store.d.ts +77 -0
- package/dist/mock/demo-deployment-store.d.ts.map +1 -0
- package/dist/mock/demo-deployment-store.js +85 -0
- package/dist/mock/demo-deployment-store.js.map +1 -0
- package/dist/powerplatform/admin-client.d.ts +226 -0
- package/dist/powerplatform/admin-client.d.ts.map +1 -0
- package/dist/powerplatform/admin-client.js +315 -0
- package/dist/powerplatform/admin-client.js.map +1 -0
- package/dist/powerplatform/index.d.ts +18 -0
- package/dist/powerplatform/index.d.ts.map +1 -0
- package/dist/powerplatform/index.js +18 -0
- package/dist/powerplatform/index.js.map +1 -0
- package/dist/powerplatform/tenant-discovery.d.ts +100 -0
- package/dist/powerplatform/tenant-discovery.d.ts.map +1 -0
- package/dist/powerplatform/tenant-discovery.js +205 -0
- package/dist/powerplatform/tenant-discovery.js.map +1 -0
- package/dist/preconditions/check.d.ts +41 -0
- package/dist/preconditions/check.d.ts.map +1 -0
- package/dist/preconditions/check.js +173 -0
- package/dist/preconditions/check.js.map +1 -0
- package/dist/preconditions/index.d.ts +20 -0
- package/dist/preconditions/index.d.ts.map +1 -0
- package/dist/preconditions/index.js +20 -0
- package/dist/preconditions/index.js.map +1 -0
- package/dist/preconditions/loader.d.ts +33 -0
- package/dist/preconditions/loader.d.ts.map +1 -0
- package/dist/preconditions/loader.js +65 -0
- package/dist/preconditions/loader.js.map +1 -0
- package/dist/preconditions/schema.d.ts +103 -0
- package/dist/preconditions/schema.d.ts.map +1 -0
- package/dist/preconditions/schema.js +93 -0
- package/dist/preconditions/schema.js.map +1 -0
- package/dist/preconditions/types.d.ts +118 -0
- package/dist/preconditions/types.d.ts.map +1 -0
- package/dist/preconditions/types.js +17 -0
- package/dist/preconditions/types.js.map +1 -0
- package/dist/queue/index.d.ts +17 -0
- package/dist/queue/index.d.ts.map +1 -0
- package/dist/queue/index.js +17 -0
- package/dist/queue/index.js.map +1 -0
- package/dist/queue/memory-queue.d.ts +86 -0
- package/dist/queue/memory-queue.d.ts.map +1 -0
- package/dist/queue/memory-queue.js +221 -0
- package/dist/queue/memory-queue.js.map +1 -0
- package/dist/services/audit-log.d.ts +59 -0
- package/dist/services/audit-log.d.ts.map +1 -0
- package/dist/services/audit-log.js +193 -0
- package/dist/services/audit-log.js.map +1 -0
- package/dist/services/auth-error-parser.d.ts +36 -0
- package/dist/services/auth-error-parser.d.ts.map +1 -0
- package/dist/services/auth-error-parser.js +90 -0
- package/dist/services/auth-error-parser.js.map +1 -0
- package/dist/services/deployment-doctor.d.ts +109 -0
- package/dist/services/deployment-doctor.d.ts.map +1 -0
- package/dist/services/deployment-doctor.js +476 -0
- package/dist/services/deployment-doctor.js.map +1 -0
- package/dist/services/deployment-notifications.d.ts +41 -0
- package/dist/services/deployment-notifications.d.ts.map +1 -0
- package/dist/services/deployment-notifications.js +161 -0
- package/dist/services/deployment-notifications.js.map +1 -0
- package/dist/services/deployment-progress.d.ts +89 -0
- package/dist/services/deployment-progress.d.ts.map +1 -0
- package/dist/services/deployment-progress.js +244 -0
- package/dist/services/deployment-progress.js.map +1 -0
- package/dist/services/deployment-service.d.ts +97 -0
- package/dist/services/deployment-service.d.ts.map +1 -0
- package/dist/services/deployment-service.js +375 -0
- package/dist/services/deployment-service.js.map +1 -0
- package/dist/services/drift-analyzer.d.ts +86 -0
- package/dist/services/drift-analyzer.d.ts.map +1 -0
- package/dist/services/drift-analyzer.js +273 -0
- package/dist/services/drift-analyzer.js.map +1 -0
- package/dist/services/environment-setup.d.ts +97 -0
- package/dist/services/environment-setup.d.ts.map +1 -0
- package/dist/services/environment-setup.js +250 -0
- package/dist/services/environment-setup.js.map +1 -0
- package/dist/services/health-check.d.ts +168 -0
- package/dist/services/health-check.d.ts.map +1 -0
- package/dist/services/health-check.js +705 -0
- package/dist/services/health-check.js.map +1 -0
- package/dist/services/index.d.ts +39 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +39 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/logger.d.ts +139 -0
- package/dist/services/logger.d.ts.map +1 -0
- package/dist/services/logger.js +268 -0
- package/dist/services/logger.js.map +1 -0
- package/dist/services/notification-service.d.ts +55 -0
- package/dist/services/notification-service.d.ts.map +1 -0
- package/dist/services/notification-service.js +184 -0
- package/dist/services/notification-service.js.map +1 -0
- package/dist/services/risk-analyzer.d.ts +252 -0
- package/dist/services/risk-analyzer.d.ts.map +1 -0
- package/dist/services/risk-analyzer.js +866 -0
- package/dist/services/risk-analyzer.js.map +1 -0
- package/dist/services/rollback.d.ts +57 -0
- package/dist/services/rollback.d.ts.map +1 -0
- package/dist/services/rollback.js +270 -0
- package/dist/services/rollback.js.map +1 -0
- package/dist/services/scheduler.d.ts +80 -0
- package/dist/services/scheduler.d.ts.map +1 -0
- package/dist/services/scheduler.js +350 -0
- package/dist/services/scheduler.js.map +1 -0
- package/dist/services/secrets.d.ts +31 -0
- package/dist/services/secrets.d.ts.map +1 -0
- package/dist/services/secrets.js +206 -0
- package/dist/services/secrets.js.map +1 -0
- package/dist/services/settings-service.d.ts +132 -0
- package/dist/services/settings-service.d.ts.map +1 -0
- package/dist/services/settings-service.js +378 -0
- package/dist/services/settings-service.js.map +1 -0
- package/dist/services/solution-diff.d.ts +127 -0
- package/dist/services/solution-diff.d.ts.map +1 -0
- package/dist/services/solution-diff.js +260 -0
- package/dist/services/solution-diff.js.map +1 -0
- package/dist/services/solution-mode-detector.d.ts +35 -0
- package/dist/services/solution-mode-detector.d.ts.map +1 -0
- package/dist/services/solution-mode-detector.js +84 -0
- package/dist/services/solution-mode-detector.js.map +1 -0
- package/dist/services/tenant-resolver.d.ts +55 -0
- package/dist/services/tenant-resolver.d.ts.map +1 -0
- package/dist/services/tenant-resolver.js +126 -0
- package/dist/services/tenant-resolver.js.map +1 -0
- package/dist/services/unmanaged-customizations.d.ts +104 -0
- package/dist/services/unmanaged-customizations.d.ts.map +1 -0
- package/dist/services/unmanaged-customizations.js +521 -0
- package/dist/services/unmanaged-customizations.js.map +1 -0
- package/dist/services/url-templater.d.ts +184 -0
- package/dist/services/url-templater.d.ts.map +1 -0
- package/dist/services/url-templater.js +327 -0
- package/dist/services/url-templater.js.map +1 -0
- package/dist/services/version-checker.d.ts +108 -0
- package/dist/services/version-checker.d.ts.map +1 -0
- package/dist/services/version-checker.js +403 -0
- package/dist/services/version-checker.js.map +1 -0
- package/dist/services/waves.d.ts +90 -0
- package/dist/services/waves.d.ts.map +1 -0
- package/dist/services/waves.js +222 -0
- package/dist/services/waves.js.map +1 -0
- package/dist/services/webhook.d.ts +95 -0
- package/dist/services/webhook.d.ts.map +1 -0
- package/dist/services/webhook.js +244 -0
- package/dist/services/webhook.js.map +1 -0
- package/dist/utils/deployment-tools.d.ts +110 -0
- package/dist/utils/deployment-tools.d.ts.map +1 -0
- package/dist/utils/deployment-tools.js +121 -0
- package/dist/utils/deployment-tools.js.map +1 -0
- package/dist/utils/index.d.ts +17 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +18 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright 2024 Pax8, Inc.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Deployment Risk Analyzer
|
|
18
|
+
* Analyzes deployment risk before execution
|
|
19
|
+
*/
|
|
20
|
+
import { getDemoTenantMetadata } from "../mock/demo-data.js";
|
|
21
|
+
import { loadPreconditionManifest, checkPreconditions, PreconditionManifestValidationError, } from "../preconditions/index.js";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
/**
|
|
24
|
+
* Default parallelism when no wave config is supplied. Five concurrent
|
|
25
|
+
* deploys is what `deploy --all` does in practice and matches the
|
|
26
|
+
* Dataverse `importSolutionAsync` rate-limit headroom we have observed.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_MAX_PARALLEL = 5;
|
|
29
|
+
/**
|
|
30
|
+
* Per-tenant Dataverse `importSolutionAsync` median, in minutes. Real
|
|
31
|
+
* deploys land in the 30-90 second range; 1 minute is the median we use
|
|
32
|
+
* as the calibration point. Tighten with history once we collect it.
|
|
33
|
+
*/
|
|
34
|
+
const PER_TENANT_MINUTES = 1;
|
|
35
|
+
/**
|
|
36
|
+
* Build a Phase 1 state resolver against `DEMO_TENANTS[].metadata.preconditionState`.
|
|
37
|
+
*
|
|
38
|
+
* - If a tenant has no `preconditionState` set, we synthesize a state where
|
|
39
|
+
* every requirement passes. "We didn't snapshot this tenant" is treated
|
|
40
|
+
* as "assume good"; this keeps non-demo tenants and tenants that didn't
|
|
41
|
+
* opt into preflight quiet.
|
|
42
|
+
* - If a tenant has `preconditionState` defined, we look for a snapshot
|
|
43
|
+
* matching every key/value in the precondition's `matcher`. Found → real
|
|
44
|
+
* state. Not found → "missing-resource".
|
|
45
|
+
*
|
|
46
|
+
* Phase 2: replace this whole factory with one that calls Microsoft Graph
|
|
47
|
+
* TCM endpoints. The diff engine and manifest format don't change.
|
|
48
|
+
*/
|
|
49
|
+
function synthesizeTenantStateResolver() {
|
|
50
|
+
return (tenant, precondition) => {
|
|
51
|
+
const meta = getDemoTenantMetadata(tenant.tenantId);
|
|
52
|
+
const states = meta?.preconditionState;
|
|
53
|
+
if (!states) {
|
|
54
|
+
// No preflight snapshot at all — synthesize a passing state. Each
|
|
55
|
+
// requirement's required value lands at its `property` dot-path.
|
|
56
|
+
const props = {};
|
|
57
|
+
for (const req of precondition.requirements) {
|
|
58
|
+
setByDotPath(props, req.property, req.value);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
resourceType: precondition.resourceType,
|
|
62
|
+
resourceMatcher: precondition.matcher,
|
|
63
|
+
resourceDisplayName: precondition.matcher.displayName ??
|
|
64
|
+
Object.values(precondition.matcher)[0] ??
|
|
65
|
+
"(synthesized)",
|
|
66
|
+
currentProperties: props,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Look for a snapshot whose matcher fields all line up.
|
|
70
|
+
const match = states.find((s) => s.resourceType === precondition.resourceType &&
|
|
71
|
+
Object.entries(precondition.matcher).every(([k, v]) => s.resourceMatcher[k] === v));
|
|
72
|
+
if (!match)
|
|
73
|
+
return "missing-resource";
|
|
74
|
+
return match;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Walk into `obj` along a dot-path and assign `value` at the leaf, creating
|
|
79
|
+
* intermediate objects as needed. Used by the synthesized passing-state.
|
|
80
|
+
*/
|
|
81
|
+
function setByDotPath(obj, path, value) {
|
|
82
|
+
const parts = path.split(".");
|
|
83
|
+
let current = obj;
|
|
84
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
85
|
+
const key = parts[i];
|
|
86
|
+
const next = current[key];
|
|
87
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
88
|
+
current[key] = {};
|
|
89
|
+
}
|
|
90
|
+
current = current[key];
|
|
91
|
+
}
|
|
92
|
+
current[parts[parts.length - 1]] = value;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Map a numeric confidence value to its coarse qualifier bucket.
|
|
96
|
+
* Boundaries: < 70 -> limited, 70..89 -> moderate, >= 90 -> high.
|
|
97
|
+
* Exposed (not just internal) so CLI rendering can render
|
|
98
|
+
* `Risk Score: LOW (limited data)` without re-implementing thresholds.
|
|
99
|
+
*/
|
|
100
|
+
export function confidenceQualifier(confidence) {
|
|
101
|
+
if (confidence >= 90)
|
|
102
|
+
return "high";
|
|
103
|
+
if (confidence >= 70)
|
|
104
|
+
return "moderate";
|
|
105
|
+
return "limited";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Map a confidence qualifier to the human-readable suffix shown next to
|
|
109
|
+
* the risk score (e.g. `LOW (limited data)`).
|
|
110
|
+
*/
|
|
111
|
+
export function confidenceQualifierLabel(qualifier) {
|
|
112
|
+
switch (qualifier) {
|
|
113
|
+
case "limited":
|
|
114
|
+
return "limited data";
|
|
115
|
+
case "moderate":
|
|
116
|
+
return "moderate confidence";
|
|
117
|
+
case "high":
|
|
118
|
+
return "high confidence";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Main risk analyzer class
|
|
123
|
+
*/
|
|
124
|
+
export class DeploymentRiskAnalyzer {
|
|
125
|
+
/**
|
|
126
|
+
* Analyze deployment risk
|
|
127
|
+
*/
|
|
128
|
+
async analyze(context) {
|
|
129
|
+
const issues = [];
|
|
130
|
+
const recommendations = [];
|
|
131
|
+
// Run all checks in parallel. Each check returns its issues plus a
|
|
132
|
+
// `dataAvailable` flag — used to compute coverage for confidence.
|
|
133
|
+
const [gdap, connections, health, history, preconditions] = await Promise.all([
|
|
134
|
+
this.checkGDAPPermissions(context),
|
|
135
|
+
this.checkConnections(context),
|
|
136
|
+
this.checkTenantHealth(context),
|
|
137
|
+
this.analyzeHistory(context),
|
|
138
|
+
this.checkPreconditions(context),
|
|
139
|
+
]);
|
|
140
|
+
issues.push(...gdap.issues, ...connections.issues, ...health.issues, ...history.issues, ...preconditions.issues);
|
|
141
|
+
const coverage = [gdap, connections, health, history, preconditions].filter((r) => r.dataAvailable).length;
|
|
142
|
+
// Calculate risk score
|
|
143
|
+
const riskScore = this.calculateRiskScore(issues);
|
|
144
|
+
// Identify blockers (critical issues)
|
|
145
|
+
const blockers = issues.filter((i) => i.severity === "critical");
|
|
146
|
+
// Generate recommendations
|
|
147
|
+
const recs = this.generateRecommendations(issues, context);
|
|
148
|
+
recommendations.push(...recs);
|
|
149
|
+
// Estimate duration (wave-aware)
|
|
150
|
+
const duration = this.estimateDuration(context, issues);
|
|
151
|
+
// Calculate success probability
|
|
152
|
+
const successProbability = this.calculateSuccessProbability(context, issues);
|
|
153
|
+
// Confidence factors in coverage + per-tenant sample size
|
|
154
|
+
const confidence = this.calculateConfidence(context, coverage);
|
|
155
|
+
const qualifier = confidenceQualifier(confidence);
|
|
156
|
+
// Per-tenant rollup of dimensions. Issues are already attributed via
|
|
157
|
+
// `affectedTenants`; we just project them per-tenant and pick the
|
|
158
|
+
// highest-severity finding as the "top factor".
|
|
159
|
+
const perTenantBreakdown = this.buildPerTenantBreakdown(context, issues);
|
|
160
|
+
// Determine if deployment can proceed
|
|
161
|
+
const canProceed = blockers.length === 0;
|
|
162
|
+
// Determine if approval is required
|
|
163
|
+
const requiresApproval = riskScore === "high" || riskScore === "critical" || context.isProduction;
|
|
164
|
+
return {
|
|
165
|
+
score: riskScore,
|
|
166
|
+
confidence,
|
|
167
|
+
confidenceQualifier: qualifier,
|
|
168
|
+
confidence_qualifier: qualifier,
|
|
169
|
+
estimatedDuration: duration,
|
|
170
|
+
successProbability,
|
|
171
|
+
issues,
|
|
172
|
+
recommendations,
|
|
173
|
+
blockers,
|
|
174
|
+
canProceed,
|
|
175
|
+
requiresApproval,
|
|
176
|
+
perTenantBreakdown,
|
|
177
|
+
preconditions: {
|
|
178
|
+
manifestFound: preconditions.manifestFound,
|
|
179
|
+
solution: preconditions.manifest?.solution,
|
|
180
|
+
manifestVersion: preconditions.manifest?.version,
|
|
181
|
+
failures: preconditions.failures,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Check GDAP permissions for all tenants. Returns `dataAvailable=true`
|
|
187
|
+
* when at least one tenant produced a determination — used as a
|
|
188
|
+
* coverage signal for `calculateConfidence`.
|
|
189
|
+
*/
|
|
190
|
+
async checkGDAPPermissions(context) {
|
|
191
|
+
const issues = [];
|
|
192
|
+
let dataAvailable = false;
|
|
193
|
+
// In demo mode, use tenant metadata for deterministic GDAP scenarios
|
|
194
|
+
if (process.env.DEMO_MODE === "true") {
|
|
195
|
+
// Coverage proxy: any tenant with demo metadata counts as "checked".
|
|
196
|
+
dataAvailable = context.tenants.some((t) => getDemoTenantMetadata(t.id) !== undefined);
|
|
197
|
+
const missingRoleTenants = [];
|
|
198
|
+
const expiredTenants = [];
|
|
199
|
+
const propagatingTenants = [];
|
|
200
|
+
const expiringSoonTenants = [];
|
|
201
|
+
for (const tenant of context.tenants) {
|
|
202
|
+
const meta = getDemoTenantMetadata(tenant.id);
|
|
203
|
+
if (!meta)
|
|
204
|
+
continue;
|
|
205
|
+
switch (meta.gdapStatus) {
|
|
206
|
+
case "missing_role":
|
|
207
|
+
missingRoleTenants.push(tenant.name);
|
|
208
|
+
break;
|
|
209
|
+
case "expired":
|
|
210
|
+
expiredTenants.push(tenant.name);
|
|
211
|
+
break;
|
|
212
|
+
case "propagating":
|
|
213
|
+
propagatingTenants.push(tenant.name);
|
|
214
|
+
break;
|
|
215
|
+
case "expiring_soon":
|
|
216
|
+
expiringSoonTenants.push(tenant.name);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (missingRoleTenants.length > 0) {
|
|
221
|
+
issues.push({
|
|
222
|
+
severity: "critical",
|
|
223
|
+
category: "permissions",
|
|
224
|
+
message: `${missingRoleTenants.length} tenant${missingRoleTenants.length > 1 ? "s" : ""} missing Power Platform Admin role`,
|
|
225
|
+
affectedTenants: missingRoleTenants,
|
|
226
|
+
resolution: "Add Power Platform Admin role to GDAP relationship in Partner Center",
|
|
227
|
+
link: "https://partner.microsoft.com/en-us/dashboard/commerce2/customers/delegatedadmin",
|
|
228
|
+
details: {
|
|
229
|
+
missingRole: "Power Platform Administrator",
|
|
230
|
+
requiredFor: ["Solution import", "Connection management", "Flow activation"],
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (expiredTenants.length > 0) {
|
|
235
|
+
issues.push({
|
|
236
|
+
severity: "critical",
|
|
237
|
+
category: "permissions",
|
|
238
|
+
message: `${expiredTenants.length} tenant${expiredTenants.length > 1 ? "s" : ""} with expired GDAP relationship`,
|
|
239
|
+
affectedTenants: expiredTenants,
|
|
240
|
+
resolution: "Renew GDAP relationship in Partner Center",
|
|
241
|
+
link: "https://partner.microsoft.com/en-us/dashboard/commerce2/customers/delegatedadmin",
|
|
242
|
+
details: {
|
|
243
|
+
issue: "GDAP relationship has ended",
|
|
244
|
+
impact: "Cannot perform any delegated admin operations",
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (propagatingTenants.length > 0) {
|
|
249
|
+
issues.push({
|
|
250
|
+
severity: "warning",
|
|
251
|
+
category: "permissions",
|
|
252
|
+
message: `${propagatingTenants.length} tenant${propagatingTenants.length > 1 ? "s" : ""} with recently added GDAP (permissions may not be propagated)`,
|
|
253
|
+
affectedTenants: propagatingTenants,
|
|
254
|
+
resolution: "Wait 24-48 hours for GDAP permissions to fully propagate",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (expiringSoonTenants.length > 0) {
|
|
258
|
+
issues.push({
|
|
259
|
+
severity: "warning",
|
|
260
|
+
category: "permissions",
|
|
261
|
+
message: `${expiringSoonTenants.length} tenant${expiringSoonTenants.length > 1 ? "s" : ""} with GDAP relationship expiring within 7 days`,
|
|
262
|
+
affectedTenants: expiringSoonTenants,
|
|
263
|
+
resolution: "Renew GDAP relationships before they expire",
|
|
264
|
+
link: "https://partner.microsoft.com/en-us/dashboard/commerce2/customers/delegatedadmin",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Live GDAP risk checks via Microsoft Graph API are planned for a future release.
|
|
269
|
+
// Currently uses demo metadata and deployment history for risk assessment.
|
|
270
|
+
return { issues, dataAvailable };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Check connection references. Returns `dataAvailable=true` when we had
|
|
274
|
+
* any signal (currently: demo metadata).
|
|
275
|
+
*/
|
|
276
|
+
async checkConnections(context) {
|
|
277
|
+
const issues = [];
|
|
278
|
+
let dataAvailable = false;
|
|
279
|
+
// In demo mode, use tenant metadata for deterministic connection scenarios
|
|
280
|
+
if (process.env.DEMO_MODE === "true") {
|
|
281
|
+
dataAvailable = context.tenants.some((t) => getDemoTenantMetadata(t.id) !== undefined);
|
|
282
|
+
const expiredTenants = [];
|
|
283
|
+
const missingTenants = [];
|
|
284
|
+
const expiringCertTenants = [];
|
|
285
|
+
for (const tenant of context.tenants) {
|
|
286
|
+
const meta = getDemoTenantMetadata(tenant.id);
|
|
287
|
+
if (!meta)
|
|
288
|
+
continue;
|
|
289
|
+
switch (meta.connectionStatus) {
|
|
290
|
+
case "expired":
|
|
291
|
+
expiredTenants.push(tenant.name);
|
|
292
|
+
break;
|
|
293
|
+
case "missing":
|
|
294
|
+
missingTenants.push(tenant.name);
|
|
295
|
+
break;
|
|
296
|
+
case "expiring_certificate":
|
|
297
|
+
expiringCertTenants.push(tenant.name);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (expiredTenants.length > 0) {
|
|
302
|
+
issues.push({
|
|
303
|
+
severity: "critical",
|
|
304
|
+
category: "connections",
|
|
305
|
+
message: `${expiredTenants.length} tenant${expiredTenants.length > 1 ? "s" : ""} with expired connection references`,
|
|
306
|
+
affectedTenants: expiredTenants,
|
|
307
|
+
resolution: "Renew expired connections in the Connections page of each affected tenant",
|
|
308
|
+
details: {
|
|
309
|
+
connectionType: "OAuth",
|
|
310
|
+
impact: "Solution import will fail without valid connections",
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (missingTenants.length > 0) {
|
|
315
|
+
issues.push({
|
|
316
|
+
severity: "critical",
|
|
317
|
+
category: "connections",
|
|
318
|
+
message: `${missingTenants.length} tenant${missingTenants.length > 1 ? "s" : ""} with missing required connections`,
|
|
319
|
+
affectedTenants: missingTenants,
|
|
320
|
+
resolution: "Configure required connections before deploying",
|
|
321
|
+
details: {
|
|
322
|
+
impact: "Solution cannot function without required connections",
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (expiringCertTenants.length > 0) {
|
|
327
|
+
issues.push({
|
|
328
|
+
severity: "warning",
|
|
329
|
+
category: "connections",
|
|
330
|
+
message: `${expiringCertTenants.length} tenant${expiringCertTenants.length > 1 ? "s" : ""} with certificates expiring within 30 days`,
|
|
331
|
+
affectedTenants: expiringCertTenants,
|
|
332
|
+
resolution: "Rotate certificates before they expire to avoid deployment failures",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Live connection reference checks via Dataverse API are planned for a future release.
|
|
337
|
+
// Currently uses demo metadata for connection risk assessment.
|
|
338
|
+
return { issues, dataAvailable };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Check tenant health. `dataAvailable=true` when we had health-relevant
|
|
342
|
+
* input (deployment history with at least one failure-bearing entry, or
|
|
343
|
+
* demo metadata). Note: an empty deploymentHistory still counts as "we
|
|
344
|
+
* looked but found nothing," not "we couldn't check."
|
|
345
|
+
*/
|
|
346
|
+
async checkTenantHealth(context) {
|
|
347
|
+
const issues = [];
|
|
348
|
+
let dataAvailable = false;
|
|
349
|
+
// Check for recurring failures (same error appearing multiple times)
|
|
350
|
+
if (context.deploymentHistory) {
|
|
351
|
+
dataAvailable = true;
|
|
352
|
+
const recentFailures = context.deploymentHistory.filter((h) => h.status === "failure" &&
|
|
353
|
+
new Date(h.completedAt) > new Date(Date.now() - 24 * 60 * 60 * 1000));
|
|
354
|
+
if (recentFailures.length > 0) {
|
|
355
|
+
// Group failures by error message to identify recurring patterns
|
|
356
|
+
const errorCounts = new Map();
|
|
357
|
+
for (const failure of recentFailures) {
|
|
358
|
+
if (!failure.error)
|
|
359
|
+
continue;
|
|
360
|
+
// Extract error type (first line or first 100 chars for grouping)
|
|
361
|
+
const errorType = failure.error.split("\n")[0].substring(0, 100);
|
|
362
|
+
if (!errorCounts.has(errorType)) {
|
|
363
|
+
errorCounts.set(errorType, {
|
|
364
|
+
count: 0,
|
|
365
|
+
tenantIds: new Set(),
|
|
366
|
+
fullError: failure.error,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const errorInfo = errorCounts.get(errorType);
|
|
370
|
+
errorInfo.count++;
|
|
371
|
+
errorInfo.tenantIds.add(failure.tenantId);
|
|
372
|
+
}
|
|
373
|
+
// Only warn about recurring errors (appeared 2+ times)
|
|
374
|
+
const recurringErrors = Array.from(errorCounts.entries())
|
|
375
|
+
.filter(([_, info]) => info.count >= 2)
|
|
376
|
+
.sort((a, b) => b[1].count - a[1].count); // Sort by frequency
|
|
377
|
+
if (recurringErrors.length > 0) {
|
|
378
|
+
const [mostCommonError, errorInfo] = recurringErrors[0];
|
|
379
|
+
const affectedTenantIds = Array.from(errorInfo.tenantIds);
|
|
380
|
+
const tenantNames = context.tenants
|
|
381
|
+
.filter((t) => affectedTenantIds.includes(t.id))
|
|
382
|
+
.map((t) => t.name);
|
|
383
|
+
issues.push({
|
|
384
|
+
severity: "warning",
|
|
385
|
+
category: "health",
|
|
386
|
+
message: `Recurring deployment failure detected (${errorInfo.count}x in last 24 hours)`,
|
|
387
|
+
affectedTenants: tenantNames,
|
|
388
|
+
resolution: "This error has occurred multiple times. Fix the root cause before deploying again",
|
|
389
|
+
details: {
|
|
390
|
+
errorType: mostCommonError,
|
|
391
|
+
occurrences: errorInfo.count,
|
|
392
|
+
affectedTenantCount: affectedTenantIds.length,
|
|
393
|
+
fullError: errorInfo.fullError,
|
|
394
|
+
allRecurringErrors: recurringErrors.map(([err, info]) => ({
|
|
395
|
+
error: err,
|
|
396
|
+
count: info.count,
|
|
397
|
+
})),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Demo mode supplies tenant-level health hints via metadata even when
|
|
404
|
+
// no deploymentHistory was provided.
|
|
405
|
+
if (process.env.DEMO_MODE === "true" && !dataAvailable) {
|
|
406
|
+
dataAvailable = context.tenants.some((t) => getDemoTenantMetadata(t.id) !== undefined);
|
|
407
|
+
}
|
|
408
|
+
return { issues, dataAvailable };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Analyze deployment history. `dataAvailable=true` when we had real
|
|
412
|
+
* deploymentHistory entries or demo metadata to draw from.
|
|
413
|
+
*/
|
|
414
|
+
async analyzeHistory(context) {
|
|
415
|
+
const issues = [];
|
|
416
|
+
let dataAvailable = false;
|
|
417
|
+
// In demo mode, generate history issues from tenant metadata
|
|
418
|
+
if (process.env.DEMO_MODE === "true" && !context.deploymentHistory) {
|
|
419
|
+
dataAvailable = context.tenants.some((t) => getDemoTenantMetadata(t.id) !== undefined);
|
|
420
|
+
const tenantsWithNoDeployments = [];
|
|
421
|
+
const tenantsWithHighFailRate = [];
|
|
422
|
+
const tenantsWithRecentFailures = [];
|
|
423
|
+
const tenantsWithStaleData = [];
|
|
424
|
+
for (const tenant of context.tenants) {
|
|
425
|
+
const meta = getDemoTenantMetadata(tenant.id);
|
|
426
|
+
if (!meta) {
|
|
427
|
+
tenantsWithNoDeployments.push(tenant.name);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!meta.lastSuccessfulDeployment) {
|
|
431
|
+
tenantsWithNoDeployments.push(tenant.name);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
// Check for stale deployments (> 30 days since last success)
|
|
435
|
+
const lastSuccess = new Date(meta.lastSuccessfulDeployment);
|
|
436
|
+
const daysSinceSuccess = (Date.now() - lastSuccess.getTime()) / (24 * 60 * 60 * 1000);
|
|
437
|
+
if (daysSinceSuccess > 30) {
|
|
438
|
+
tenantsWithStaleData.push(tenant.name);
|
|
439
|
+
}
|
|
440
|
+
// Check for high failure rate
|
|
441
|
+
if (meta.recentFailures >= 3) {
|
|
442
|
+
tenantsWithHighFailRate.push(tenant.name);
|
|
443
|
+
}
|
|
444
|
+
else if (meta.recentFailures >= 1 && meta.lastDeploymentError) {
|
|
445
|
+
tenantsWithRecentFailures.push(tenant.name);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (tenantsWithNoDeployments.length > 0) {
|
|
449
|
+
issues.push({
|
|
450
|
+
severity: "info",
|
|
451
|
+
category: "history",
|
|
452
|
+
message: `${tenantsWithNoDeployments.length} tenant${tenantsWithNoDeployments.length > 1 ? "s" : ""} with no deployment history`,
|
|
453
|
+
affectedTenants: tenantsWithNoDeployments,
|
|
454
|
+
resolution: "Consider deploying to a test tenant first",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (tenantsWithHighFailRate.length > 0) {
|
|
458
|
+
issues.push({
|
|
459
|
+
severity: "warning",
|
|
460
|
+
category: "history",
|
|
461
|
+
message: `${tenantsWithHighFailRate.length} tenant${tenantsWithHighFailRate.length > 1 ? "s" : ""} with high recent failure rate`,
|
|
462
|
+
affectedTenants: tenantsWithHighFailRate,
|
|
463
|
+
resolution: "Review and fix common failure patterns before deploying",
|
|
464
|
+
details: {
|
|
465
|
+
threshold: "3+ recent failures",
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (tenantsWithRecentFailures.length > 0) {
|
|
470
|
+
issues.push({
|
|
471
|
+
severity: "info",
|
|
472
|
+
category: "history",
|
|
473
|
+
message: `${tenantsWithRecentFailures.length} tenant${tenantsWithRecentFailures.length > 1 ? "s" : ""} with recent deployment failures`,
|
|
474
|
+
affectedTenants: tenantsWithRecentFailures,
|
|
475
|
+
resolution: "Monitor these tenants during deployment",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (tenantsWithStaleData.length > 0) {
|
|
479
|
+
issues.push({
|
|
480
|
+
severity: "warning",
|
|
481
|
+
category: "history",
|
|
482
|
+
message: `${tenantsWithStaleData.length} tenant${tenantsWithStaleData.length > 1 ? "s" : ""} not deployed to in over 30 days`,
|
|
483
|
+
affectedTenants: tenantsWithStaleData,
|
|
484
|
+
resolution: "Validate environment connectivity before deploying to stale tenants",
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return { issues, dataAvailable };
|
|
488
|
+
}
|
|
489
|
+
if (!context.deploymentHistory || context.deploymentHistory.length === 0) {
|
|
490
|
+
issues.push({
|
|
491
|
+
severity: "info",
|
|
492
|
+
category: "history",
|
|
493
|
+
message: "No deployment history available",
|
|
494
|
+
resolution: "Consider deploying to test tenant first",
|
|
495
|
+
});
|
|
496
|
+
return { issues, dataAvailable };
|
|
497
|
+
}
|
|
498
|
+
dataAvailable = true;
|
|
499
|
+
const totalDeployments = context.deploymentHistory.length;
|
|
500
|
+
const successCount = context.deploymentHistory.filter((h) => h.status === "success").length;
|
|
501
|
+
const successRate = (successCount / totalDeployments) * 100;
|
|
502
|
+
// Minimum sample size threshold: need at least 20 deployments for meaningful statistics
|
|
503
|
+
const MIN_SAMPLE_SIZE = 20;
|
|
504
|
+
if (totalDeployments < MIN_SAMPLE_SIZE) {
|
|
505
|
+
// Not enough data for statistical analysis
|
|
506
|
+
issues.push({
|
|
507
|
+
severity: "info",
|
|
508
|
+
category: "history",
|
|
509
|
+
message: `Limited deployment history (${totalDeployments} deployments)`,
|
|
510
|
+
resolution: "Build more deployment history for accurate risk assessment",
|
|
511
|
+
details: {
|
|
512
|
+
totalDeployments,
|
|
513
|
+
successfulDeployments: successCount,
|
|
514
|
+
successRate: `${successRate.toFixed(0)}%`,
|
|
515
|
+
minimumRequired: MIN_SAMPLE_SIZE,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
else if (successRate < 70) {
|
|
520
|
+
// Enough data and success rate is low - this is a real warning
|
|
521
|
+
issues.push({
|
|
522
|
+
severity: "warning",
|
|
523
|
+
category: "history",
|
|
524
|
+
message: `Low historical success rate: ${successRate.toFixed(0)}%`,
|
|
525
|
+
resolution: "Review and fix common failure patterns before deploying",
|
|
526
|
+
details: {
|
|
527
|
+
totalDeployments,
|
|
528
|
+
successfulDeployments: successCount,
|
|
529
|
+
successRate: `${successRate.toFixed(0)}%`,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return { issues, dataAvailable };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Fifth dimension — tenant-config preflight. Loads a sibling YAML
|
|
537
|
+
* `<solution>.preconditions.yaml`, runs it against synthetic per-tenant
|
|
538
|
+
* state (Phase 1), and emits one `RiskIssue` per failed requirement plus a
|
|
539
|
+
* structured `failures[]` payload the CLI can render with deep-link / CLI
|
|
540
|
+
* / manual remediation steps.
|
|
541
|
+
*
|
|
542
|
+
* When no manifest is found we emit a single `info` issue ("preflight
|
|
543
|
+
* skipped") and return — deploy still proceeds.
|
|
544
|
+
*
|
|
545
|
+
* Phase 2 will replace `synthesizeTenantStateResolver` with a real
|
|
546
|
+
* Microsoft Graph TCM client; the manifest format and the diff engine
|
|
547
|
+
* stay the same.
|
|
548
|
+
*/
|
|
549
|
+
async checkPreconditions(context) {
|
|
550
|
+
const issues = [];
|
|
551
|
+
const failures = [];
|
|
552
|
+
if (!context.solutionFile) {
|
|
553
|
+
return { issues, dataAvailable: false, manifestFound: false, failures };
|
|
554
|
+
}
|
|
555
|
+
const searchDirs = context.preconditionSearchDirs ?? [
|
|
556
|
+
join(process.cwd(), "agent packages"),
|
|
557
|
+
process.cwd(),
|
|
558
|
+
];
|
|
559
|
+
let manifest;
|
|
560
|
+
try {
|
|
561
|
+
manifest = await loadPreconditionManifest(context.solutionFile, searchDirs);
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
// Surface validation errors as a non-blocking warning rather than
|
|
565
|
+
// crashing analyze — the user has a manifest but it's malformed and
|
|
566
|
+
// they need to know why.
|
|
567
|
+
const message = error instanceof PreconditionManifestValidationError
|
|
568
|
+
? error.message
|
|
569
|
+
: `Failed to load precondition manifest: ${error instanceof Error ? error.message : String(error)}`;
|
|
570
|
+
issues.push({
|
|
571
|
+
severity: "warning",
|
|
572
|
+
category: "preconditions",
|
|
573
|
+
message: "Precondition manifest failed to load",
|
|
574
|
+
resolution: "Fix the manifest schema errors above and re-run analyze",
|
|
575
|
+
details: { error: message },
|
|
576
|
+
});
|
|
577
|
+
return { issues, dataAvailable: false, manifestFound: false, failures };
|
|
578
|
+
}
|
|
579
|
+
if (!manifest) {
|
|
580
|
+
// Visible note — preflight skipped because there's no manifest. Not
|
|
581
|
+
// a warning, not blocking; just transparency about coverage.
|
|
582
|
+
issues.push({
|
|
583
|
+
severity: "info",
|
|
584
|
+
category: "preconditions",
|
|
585
|
+
message: "No precondition manifest for this solution; preflight skipped.",
|
|
586
|
+
});
|
|
587
|
+
return { issues, dataAvailable: false, manifestFound: false, failures };
|
|
588
|
+
}
|
|
589
|
+
// Build the Phase 1 state resolver from demo metadata. This is the
|
|
590
|
+
// single seam Phase 2 will replace with a Graph TCM client.
|
|
591
|
+
const resolver = synthesizeTenantStateResolver();
|
|
592
|
+
// The check engine wants TenantConfig[]; map our richer-but-stripped
|
|
593
|
+
// `Tenant` view back. We fall back to a minimal TenantConfig-shaped
|
|
594
|
+
// value so the resolver can read tenantId/name without a deeper lookup.
|
|
595
|
+
const tenantConfigs = context.tenants.map((t) => ({
|
|
596
|
+
name: t.name,
|
|
597
|
+
tenantId: t.id,
|
|
598
|
+
environmentUrl: t.environmentUrl,
|
|
599
|
+
tags: t.tags ?? [],
|
|
600
|
+
enabled: true,
|
|
601
|
+
autoSetup: true,
|
|
602
|
+
}));
|
|
603
|
+
failures.push(...checkPreconditions(manifest, tenantConfigs, resolver));
|
|
604
|
+
// Mirror each failure into a RiskIssue so the existing aggregate score,
|
|
605
|
+
// recommendations, and per-tenant breakdown all see preflight findings.
|
|
606
|
+
for (const failure of failures) {
|
|
607
|
+
issues.push({
|
|
608
|
+
severity: failure.severity === "error" ? "error" : "warning",
|
|
609
|
+
category: "preconditions",
|
|
610
|
+
message: `${failure.description} — ${failure.resourceDisplayName} (${failure.failedProperty})`,
|
|
611
|
+
affectedTenants: [failure.tenantName],
|
|
612
|
+
resolution: failure.remediation.title,
|
|
613
|
+
details: {
|
|
614
|
+
preconditionId: failure.preconditionId,
|
|
615
|
+
resourceType: failure.resourceType,
|
|
616
|
+
resourceDisplayName: failure.resourceDisplayName,
|
|
617
|
+
failedProperty: failure.failedProperty,
|
|
618
|
+
currentValue: failure.currentValue,
|
|
619
|
+
requiredValue: failure.requiredValue,
|
|
620
|
+
comparisonOp: failure.comparisonOp,
|
|
621
|
+
remediation: failure.remediation,
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
issues,
|
|
627
|
+
dataAvailable: true,
|
|
628
|
+
manifestFound: true,
|
|
629
|
+
manifest: { solution: manifest.solution, version: manifest.version },
|
|
630
|
+
failures,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Calculate overall risk score
|
|
635
|
+
*/
|
|
636
|
+
calculateRiskScore(issues) {
|
|
637
|
+
let score = 0;
|
|
638
|
+
for (const issue of issues) {
|
|
639
|
+
switch (issue.severity) {
|
|
640
|
+
case "critical":
|
|
641
|
+
score += 40;
|
|
642
|
+
break;
|
|
643
|
+
case "error":
|
|
644
|
+
score += 25;
|
|
645
|
+
break;
|
|
646
|
+
case "warning":
|
|
647
|
+
score += 10;
|
|
648
|
+
break;
|
|
649
|
+
case "info":
|
|
650
|
+
score += 2;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (score >= 80)
|
|
655
|
+
return "critical";
|
|
656
|
+
if (score >= 50)
|
|
657
|
+
return "high";
|
|
658
|
+
if (score >= 25)
|
|
659
|
+
return "medium";
|
|
660
|
+
return "low";
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Calculate confidence in the risk assessment.
|
|
664
|
+
*
|
|
665
|
+
* Two inputs drive confidence:
|
|
666
|
+
* - **Coverage** — number of dimensions (GDAP / connections / health /
|
|
667
|
+
* history) that returned data. Each adds 10 points.
|
|
668
|
+
* - **Sample size** — median per-tenant deploy history count, log-scaled
|
|
669
|
+
* and capped at 40 points.
|
|
670
|
+
*
|
|
671
|
+
* Formula: `50 + 10 * coverage + min(40, ceil(log2(median_history + 1)) * 10)`.
|
|
672
|
+
* Clamped to [0, 100]. Lands in the 50–95% range for realistic inputs:
|
|
673
|
+
* - 0 coverage, 0 history -> 50
|
|
674
|
+
* - 4 coverage, 0 history -> 90
|
|
675
|
+
* - 4 coverage, 5 history -> 90 + log2(6)*10 ≈ 90, capped to 95 by
|
|
676
|
+
* the 40-cap fallback (40 + 50 = 90, plus +5 if median ≥ 16).
|
|
677
|
+
* - 4 coverage, 50+ history -> 50 + 40 + 40 = 100, clamped to 95.
|
|
678
|
+
*/
|
|
679
|
+
calculateConfidence(context, coverageCount) {
|
|
680
|
+
const sampleSize = this.medianSampleSize(context);
|
|
681
|
+
const coveragePoints = 10 * coverageCount;
|
|
682
|
+
// log2(n+1)*10, capped at 40 — diminishing returns past ~16 deploys.
|
|
683
|
+
const samplePoints = Math.min(40, Math.ceil(Math.log2(sampleSize + 1)) * 10);
|
|
684
|
+
const confidence = 50 + coveragePoints + samplePoints;
|
|
685
|
+
// Cap at 95 in non-demo to leave headroom for "perfect" only when we
|
|
686
|
+
// actually have live API data; cap at 95 in demo too — demo is by
|
|
687
|
+
// construction simulated and shouldn't ever brag.
|
|
688
|
+
return Math.max(0, Math.min(95, confidence));
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Median per-tenant historical deploy count for the requested solution
|
|
692
|
+
* (best-effort — falls back to overall demo `totalDeploys` or to the
|
|
693
|
+
* supplied `deploymentHistory` length / tenant count).
|
|
694
|
+
*/
|
|
695
|
+
medianSampleSize(context) {
|
|
696
|
+
const counts = [];
|
|
697
|
+
if (process.env.DEMO_MODE === "true") {
|
|
698
|
+
for (const tenant of context.tenants) {
|
|
699
|
+
const meta = getDemoTenantMetadata(tenant.id);
|
|
700
|
+
if (meta?.deploymentHistory) {
|
|
701
|
+
counts.push(meta.deploymentHistory.totalDeploys);
|
|
702
|
+
}
|
|
703
|
+
else if (meta) {
|
|
704
|
+
counts.push(0);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (context.deploymentHistory && counts.length === 0) {
|
|
709
|
+
// Distribute history entries evenly across tenants as a coarse proxy.
|
|
710
|
+
const perTenant = context.deploymentHistory.length / Math.max(context.tenants.length, 1);
|
|
711
|
+
for (let i = 0; i < context.tenants.length; i++)
|
|
712
|
+
counts.push(perTenant);
|
|
713
|
+
}
|
|
714
|
+
if (counts.length === 0)
|
|
715
|
+
return 0;
|
|
716
|
+
const sorted = [...counts].sort((a, b) => a - b);
|
|
717
|
+
const mid = Math.floor(sorted.length / 2);
|
|
718
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Estimate deployment duration with wave-aware parallelism.
|
|
722
|
+
*
|
|
723
|
+
* Today's deploys are not sequential — `WaveService` runs up to
|
|
724
|
+
* `maxParallel` tenants concurrently per wave. The previous estimate
|
|
725
|
+
* (2 min × N tenants, sequential) overstated duration ~5×.
|
|
726
|
+
*
|
|
727
|
+
* Compute: `Σ_per_wave (ceil(wave.tenants / wave.maxParallel) ×
|
|
728
|
+
* PER_TENANT_MINUTES) + Σ wave.waitAfterCompletion`. When no wave plan
|
|
729
|
+
* is supplied, fall back to a single wave with `DEFAULT_MAX_PARALLEL`.
|
|
730
|
+
*/
|
|
731
|
+
estimateDuration(context, issues) {
|
|
732
|
+
const waves = context.waves?.waves ?? [
|
|
733
|
+
{
|
|
734
|
+
waveNumber: 1,
|
|
735
|
+
name: "Default",
|
|
736
|
+
tenants: context.tenants.map((t) => ({
|
|
737
|
+
name: t.name,
|
|
738
|
+
tenantId: t.id,
|
|
739
|
+
environmentUrl: t.environmentUrl,
|
|
740
|
+
tags: t.tags,
|
|
741
|
+
enabled: true,
|
|
742
|
+
})),
|
|
743
|
+
maxParallel: context.maxParallel ?? DEFAULT_MAX_PARALLEL,
|
|
744
|
+
waitAfterCompletion: undefined,
|
|
745
|
+
continueOnFailure: false,
|
|
746
|
+
},
|
|
747
|
+
];
|
|
748
|
+
let baseMinutes = 0;
|
|
749
|
+
for (const wave of waves) {
|
|
750
|
+
const parallel = wave.maxParallel ?? wave.tenants.length;
|
|
751
|
+
const batches = Math.ceil(wave.tenants.length / Math.max(parallel, 1));
|
|
752
|
+
baseMinutes += batches * PER_TENANT_MINUTES;
|
|
753
|
+
// waitAfterCompletion is in milliseconds; convert to minutes.
|
|
754
|
+
if (wave.waitAfterCompletion) {
|
|
755
|
+
baseMinutes += wave.waitAfterCompletion / 60_000;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// Critical/error issues add real overhead (validation, retries). Was 3
|
|
759
|
+
// min each; calibrated down to 2 to match observed retry costs.
|
|
760
|
+
const issueOverhead = issues.filter((i) => i.severity === "critical" || i.severity === "error").length * 2;
|
|
761
|
+
// Large solutions take longer to upload and import. Was 10 min flat;
|
|
762
|
+
// calibrated to 5 min for >50 MB to match real Dataverse upload medians.
|
|
763
|
+
const sizeOverhead = context.solutionSize && context.solutionSize > 50_000_000 ? 5 : 0;
|
|
764
|
+
const min = Math.max(1, Math.ceil(baseMinutes + issueOverhead + sizeOverhead));
|
|
765
|
+
const max = Math.ceil(min * 1.5); // 50% buffer for tail latency
|
|
766
|
+
return { min, max };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Build per-tenant rollup. For each tenant, find issues that name them
|
|
770
|
+
* (or that have no `affectedTenants` field — those apply to the whole
|
|
771
|
+
* fleet) and pick the highest-severity one as the top factor.
|
|
772
|
+
*/
|
|
773
|
+
buildPerTenantBreakdown(context, issues) {
|
|
774
|
+
const SEVERITY_ORDER = {
|
|
775
|
+
critical: 4,
|
|
776
|
+
error: 3,
|
|
777
|
+
warning: 2,
|
|
778
|
+
info: 1,
|
|
779
|
+
};
|
|
780
|
+
return context.tenants.map((tenant) => {
|
|
781
|
+
const tenantIssues = issues.filter((issue) => {
|
|
782
|
+
if (!issue.affectedTenants || issue.affectedTenants.length === 0) {
|
|
783
|
+
// Fleet-wide issue — include with reduced weight (info-tier).
|
|
784
|
+
return issue.severity === "info";
|
|
785
|
+
}
|
|
786
|
+
return issue.affectedTenants.includes(tenant.name);
|
|
787
|
+
});
|
|
788
|
+
// Pick the single highest-severity issue as "top factor"
|
|
789
|
+
const top = tenantIssues
|
|
790
|
+
.slice()
|
|
791
|
+
.sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity])[0];
|
|
792
|
+
const score = this.calculateRiskScore(tenantIssues);
|
|
793
|
+
return {
|
|
794
|
+
tenantId: tenant.id,
|
|
795
|
+
tenantName: tenant.name,
|
|
796
|
+
score,
|
|
797
|
+
topFactor: top
|
|
798
|
+
? { severity: top.severity, category: top.category, message: top.message }
|
|
799
|
+
: undefined,
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Calculate success probability
|
|
805
|
+
*/
|
|
806
|
+
calculateSuccessProbability(context, issues) {
|
|
807
|
+
let probability = 95; // Start optimistic
|
|
808
|
+
// Reduce based on issues
|
|
809
|
+
for (const issue of issues) {
|
|
810
|
+
switch (issue.severity) {
|
|
811
|
+
case "critical":
|
|
812
|
+
probability -= 25;
|
|
813
|
+
break;
|
|
814
|
+
case "error":
|
|
815
|
+
probability -= 15;
|
|
816
|
+
break;
|
|
817
|
+
case "warning":
|
|
818
|
+
probability -= 8;
|
|
819
|
+
break;
|
|
820
|
+
case "info":
|
|
821
|
+
probability -= 2;
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// Adjust based on history
|
|
826
|
+
if (context.deploymentHistory && context.deploymentHistory.length > 0) {
|
|
827
|
+
const successCount = context.deploymentHistory.filter((h) => h.status === "success").length;
|
|
828
|
+
const historicalRate = (successCount / context.deploymentHistory.length) * 100;
|
|
829
|
+
// Blend historical rate with calculated probability
|
|
830
|
+
probability = probability * 0.6 + historicalRate * 0.4;
|
|
831
|
+
}
|
|
832
|
+
return Math.max(Math.min(Math.round(probability), 100), 0);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Generate actionable recommendations
|
|
836
|
+
*/
|
|
837
|
+
generateRecommendations(issues, context) {
|
|
838
|
+
const recommendations = [];
|
|
839
|
+
const criticalIssues = issues.filter((i) => i.severity === "critical");
|
|
840
|
+
const warningIssues = issues.filter((i) => i.severity === "warning");
|
|
841
|
+
if (criticalIssues.length > 0) {
|
|
842
|
+
recommendations.push(`Fix ${criticalIssues.length} critical issues before deploying`);
|
|
843
|
+
// Add specific recommendations for each critical issue
|
|
844
|
+
for (const issue of criticalIssues) {
|
|
845
|
+
if (issue.resolution) {
|
|
846
|
+
recommendations.push(issue.resolution);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (warningIssues.length > 0 && context.tenants.length > 5) {
|
|
851
|
+
recommendations.push("Consider deploying to a subset of tenants first");
|
|
852
|
+
}
|
|
853
|
+
if (context.isProduction && criticalIssues.length === 0) {
|
|
854
|
+
recommendations.push("Deploy to test environment first to validate");
|
|
855
|
+
}
|
|
856
|
+
if (recommendations.length === 0) {
|
|
857
|
+
recommendations.push("All checks passed - ready to deploy");
|
|
858
|
+
}
|
|
859
|
+
return recommendations;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Singleton instance
|
|
864
|
+
*/
|
|
865
|
+
export const riskAnalyzer = new DeploymentRiskAnalyzer();
|
|
866
|
+
//# sourceMappingURL=risk-analyzer.js.map
|