@skyhook-io/k8s-ui 1.3.2 → 1.4.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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditAlerts.tsx +88 -0
  3. package/src/components/audit/AuditCard.tsx +130 -0
  4. package/src/components/audit/AuditFindingsTable.tsx +526 -0
  5. package/src/components/audit/index.ts +3 -0
  6. package/src/components/dock/DockContext.tsx +5 -2
  7. package/src/components/dock/LocalTerminalTab.tsx +11 -0
  8. package/src/components/resources/ResourcesView.tsx +14 -0
  9. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +228 -0
  10. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +174 -0
  11. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  12. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +277 -0
  13. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +10 -1
  14. package/src/components/resources/renderers/ServiceRenderer.tsx +40 -28
  15. package/src/components/resources/renderers/index.ts +3 -0
  16. package/src/components/shared/CreateResourceDialog.tsx +236 -0
  17. package/src/components/shared/EditableYamlView.tsx +16 -1
  18. package/src/components/shared/ResourceActionsBar.tsx +9 -7
  19. package/src/components/shared/ResourceRendererDispatch.tsx +9 -2
  20. package/src/components/shared/index.ts +1 -0
  21. package/src/components/topology/K8sResourceNode.tsx +15 -0
  22. package/src/components/topology/TopologyControls.tsx +21 -1
  23. package/src/components/topology/TopologyGraph.tsx +1 -1
  24. package/src/components/ui/Badge.tsx +8 -0
  25. package/src/components/ui/drawer-components.tsx +20 -4
  26. package/src/components/workload/WorkloadView.tsx +55 -29
  27. package/src/index.ts +3 -0
  28. package/src/types/core.ts +2 -1
  29. package/src/utils/badge-colors.ts +7 -0
  30. package/src/utils/index.ts +2 -0
  31. package/src/utils/k8s-errors.ts +142 -0
  32. package/src/utils/resource-icons.ts +3 -0
  33. package/src/utils/skeleton-yaml.ts +302 -0
@@ -121,6 +121,12 @@ interface WorkloadViewProps {
121
121
  renderMetricsTab?: (props: { kind: string; namespace: string; name: string }) => ReactNode
122
122
  /** Whether metrics are available for this resource kind */
123
123
  isMetricsAvailable?: (kind: string, resource: any) => boolean
124
+ /** Render extra content at the bottom of the overview tab (e.g. audit findings) */
125
+ renderOverviewExtra?: (props: { kind: string; namespace: string; name: string }) => ReactNode
126
+
127
+ // ── Duplicate ────────────────────────────────────────────────────────────
128
+ /** Duplicate handler — opens create dialog with this resource's YAML */
129
+ onDuplicate?: (params: { kind: string; namespace: string; name: string; yaml: string }) => void
124
130
 
125
131
  // ── ResourceActionsBar props (passed through) ────────────────────────────
126
132
  /** All props for the actions bar (forwarded as-is) */
@@ -166,6 +172,9 @@ export function WorkloadView({
166
172
  renderLogsTab,
167
173
  renderMetricsTab,
168
174
  isMetricsAvailable,
175
+ // Duplicate
176
+ onDuplicate,
177
+ renderOverviewExtra,
169
178
  // Actions bar
170
179
  actionsBarProps,
171
180
  // Renderer overrides
@@ -430,22 +439,30 @@ export function WorkloadView({
430
439
  onSave={onUpdateResource}
431
440
  isSaving={isUpdatingResource}
432
441
  saveError={updateResourceError}
442
+ onDuplicate={onDuplicate}
433
443
  />
434
444
  ) : (
435
- <ResourceRendererDispatch
436
- resource={selectedResource}
437
- data={resource}
438
- relationships={relationships}
439
- certificateInfo={certificateInfo}
440
- onCopy={copyToClipboard}
441
- copied={copied}
442
- onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
443
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
444
- isSavingSecret={isUpdatingResource}
445
- rendererOverrides={rendererOverrides}
446
- resolvedEnvFrom={resolvedEnvFrom}
447
- renderMetrics={renderMetricsTab}
448
- />
445
+ <>
446
+ <ResourceRendererDispatch
447
+ resource={selectedResource}
448
+ data={resource}
449
+ relationships={relationships}
450
+ certificateInfo={certificateInfo}
451
+ onCopy={copyToClipboard}
452
+ copied={copied}
453
+ onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
454
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
455
+ isSavingSecret={isUpdatingResource}
456
+ rendererOverrides={rendererOverrides}
457
+ resolvedEnvFrom={resolvedEnvFrom}
458
+ renderMetrics={renderMetricsTab}
459
+ />
460
+ {renderOverviewExtra && (
461
+ <div className="px-4 pb-4">
462
+ {renderOverviewExtra({ kind, namespace, name })}
463
+ </div>
464
+ )}
465
+ </>
449
466
  )}
450
467
  </div>
451
468
  </div>
@@ -571,21 +588,22 @@ export function WorkloadView({
571
588
  {/* Tab Content */}
572
589
  <div className="flex-1 overflow-hidden relative">
573
590
  {activeTab === 'overview' && (
574
- <InfoTab
575
- resource={resource}
576
- selectedResource={selectedResource}
577
- relationships={relationships}
578
- isLoading={resourceLoading}
579
- onNavigate={onNavigateToResource}
580
- onCopy={copyToClipboard}
581
- copied={copied}
582
- onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
583
- isSavingSecret={isUpdatingResource}
584
- onOpenLogs={handleOpenLogs}
585
- onSwitchToTimeline={() => handleSetTab('timeline')}
586
- rendererOverrides={rendererOverrides}
587
- resolvedEnvFrom={resolvedEnvFrom}
588
- />
591
+ <InfoTab
592
+ resource={resource}
593
+ selectedResource={selectedResource}
594
+ relationships={relationships}
595
+ isLoading={resourceLoading}
596
+ onNavigate={onNavigateToResource}
597
+ onCopy={copyToClipboard}
598
+ copied={copied}
599
+ onSaveSecretValue={canUpdateSecrets ? handleSaveSecretValue : undefined}
600
+ isSavingSecret={isUpdatingResource}
601
+ onOpenLogs={handleOpenLogs}
602
+ onSwitchToTimeline={() => handleSetTab('timeline')}
603
+ rendererOverrides={rendererOverrides}
604
+ resolvedEnvFrom={resolvedEnvFrom}
605
+ extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
606
+ />
589
607
  )}
590
608
  {activeTab === 'timeline' && (
591
609
  <EventsTab
@@ -635,6 +653,7 @@ export function WorkloadView({
635
653
  onSave={onUpdateResource}
636
654
  isSaving={isUpdatingResource}
637
655
  saveError={updateResourceError}
656
+ onDuplicate={onDuplicate}
638
657
  />
639
658
  )}
640
659
  </div>
@@ -1068,6 +1087,7 @@ function InfoTab({
1068
1087
  onSwitchToTimeline,
1069
1088
  rendererOverrides,
1070
1089
  resolvedEnvFrom,
1090
+ extraContent,
1071
1091
  }: {
1072
1092
  resource: any
1073
1093
  selectedResource: SelectedResource
@@ -1082,6 +1102,7 @@ function InfoTab({
1082
1102
  onSwitchToTimeline?: () => void
1083
1103
  rendererOverrides?: RendererOverrides
1084
1104
  resolvedEnvFrom?: ResolvedEnvFrom
1105
+ extraContent?: ReactNode
1085
1106
  }) {
1086
1107
  if (isLoading) {
1087
1108
  return (
@@ -1132,6 +1153,11 @@ function InfoTab({
1132
1153
  </div>
1133
1154
  )}
1134
1155
  />
1156
+ {extraContent && (
1157
+ <div className="px-4 pb-4">
1158
+ {extraContent}
1159
+ </div>
1160
+ )}
1135
1161
  </div>
1136
1162
  )
1137
1163
  }
package/src/index.ts CHANGED
@@ -33,3 +33,6 @@ export * from './components/dock'
33
33
 
34
34
  // Topology (TopologyGraph, TopologySearch, TopologyFilterSidebar, layout utilities)
35
35
  export * from './components/topology'
36
+
37
+ // Cluster audit (AuditCard, AuditAlerts, AuditFindingsTable)
38
+ export * from './components/audit'
package/src/types/core.ts CHANGED
@@ -145,6 +145,7 @@ export interface TopologyEdge {
145
145
  type: EdgeType
146
146
  label?: string
147
147
  skipIfKindVisible?: string // Hide this edge if this kind is visible (for shortcut edges)
148
+ policyEffect?: 'allowed' | 'blocked' | 'unprotected'
148
149
  }
149
150
 
150
151
  export interface Topology {
@@ -846,7 +847,7 @@ export interface TrafficFilters {
846
847
  }
847
848
 
848
849
  // Main view type now includes 'traffic' and 'cost'
849
- export type ExtendedMainView = MainView | 'traffic' | 'cost'
850
+ export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'audit'
850
851
 
851
852
  // ============================================================================
852
853
  // Image Filesystem Types
@@ -52,6 +52,13 @@ export const HELM_STATUS_COLORS: Record<string, string> = {
52
52
  uninstalled: BADGE_SEVERITY_COLORS.neutral,
53
53
  }
54
54
 
55
+ // Best practices category colors
56
+ export const BP_CATEGORY_BADGE: Record<string, string> = {
57
+ Security: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-400 dark:border-purple-800/40',
58
+ Reliability: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/40 dark:text-sky-400 dark:border-sky-800/40',
59
+ Efficiency: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800/40',
60
+ }
61
+
55
62
  // Default fallback color
56
63
  export const DEFAULT_BADGE_COLOR = 'bg-theme-elevated text-theme-text-secondary'
57
64
 
@@ -8,3 +8,5 @@ export * from './resource-hierarchy'
8
8
  export * from './log-format'
9
9
  export * from './download'
10
10
  export * from './api-resources'
11
+ export * from './skeleton-yaml'
12
+ export * from './k8s-errors'
@@ -0,0 +1,142 @@
1
+ // Human-friendly summaries for common Kubernetes API error patterns.
2
+ // Falls back to null when no known pattern matches — caller shows raw error.
3
+
4
+ interface FriendlyError {
5
+ summary: string
6
+ suggestion: string
7
+ }
8
+
9
+ const patterns: Array<{ test: (msg: string) => boolean; friendly: (msg: string) => FriendlyError }> = [
10
+ // Admission webhook timeout
11
+ {
12
+ test: (msg) => /failed calling webhook.*context deadline exceeded/i.test(msg),
13
+ friendly: (msg) => {
14
+ const webhook = msg.match(/webhook "([^"]+)"/)?.[1] ?? 'unknown'
15
+ const service = webhook.split('.')[0] || webhook
16
+ return {
17
+ summary: `Admission webhook timed out (${service})`,
18
+ suggestion: 'A cluster validation webhook failed to respond. This is usually a temporary cluster issue, not a problem with your YAML. Try again or contact your cluster admin.',
19
+ }
20
+ },
21
+ },
22
+ // Admission webhook rejected
23
+ {
24
+ test: (msg) => /admission webhook.*denied the request/i.test(msg),
25
+ friendly: (msg) => {
26
+ const webhook = msg.match(/webhook "([^"]+)"/)?.[1] ?? 'unknown'
27
+ return {
28
+ summary: `Rejected by admission policy (${webhook.split('.')[0]})`,
29
+ suggestion: 'A cluster admission webhook rejected this resource. Check that your YAML meets the cluster\'s policies, or contact your cluster admin.',
30
+ }
31
+ },
32
+ },
33
+ // Validation error — invalid field value
34
+ {
35
+ test: (msg) => /is invalid:.*Invalid value/i.test(msg),
36
+ friendly: (msg) => {
37
+ const fields = [...msg.matchAll(/(\S+): Invalid value/g)].map(m => m[1])
38
+ const fieldList = fields.length > 0 ? fields.join(', ') : 'unknown field'
39
+ return {
40
+ summary: `Validation failed: invalid value for ${fieldList}`,
41
+ suggestion: 'One or more field values don\'t match what Kubernetes expects. Check the highlighted fields in the error details.',
42
+ }
43
+ },
44
+ },
45
+ // Validation error — selector doesn't match labels
46
+ {
47
+ test: (msg) => /`selector` does not match template `labels`/i.test(msg),
48
+ friendly: () => ({
49
+ summary: 'Selector doesn\'t match template labels',
50
+ suggestion: 'The spec.selector.matchLabels must match spec.template.metadata.labels exactly.',
51
+ }),
52
+ },
53
+ // Validation error — required field
54
+ {
55
+ test: (msg) => /Required value|is required/i.test(msg),
56
+ friendly: (msg) => {
57
+ const field = msg.match(/(\S+): Required value/)?.[1] ?? msg.match(/(\S+) is required/)?.[1] ?? ''
58
+ return {
59
+ summary: `Missing required field${field ? `: ${field}` : ''}`,
60
+ suggestion: 'Add the missing required field to your YAML.',
61
+ }
62
+ },
63
+ },
64
+ // RBAC forbidden — use "is forbidden:" to avoid matching "Forbidden: field is immutable" or "forbidden: exceeded quota"
65
+ {
66
+ test: (msg) => /is forbidden:|cannot .* in the namespace/i.test(msg),
67
+ friendly: (msg) => {
68
+ const verb = msg.match(/cannot (\w+)/)?.[1] ?? 'perform this action on'
69
+ return {
70
+ summary: `Permission denied`,
71
+ suggestion: `Your cluster role doesn't allow you to ${verb} this resource. Contact your cluster admin for access.`,
72
+ }
73
+ },
74
+ },
75
+ // Already exists (create mode)
76
+ {
77
+ test: (msg) => /already exists/i.test(msg),
78
+ friendly: () => ({
79
+ summary: 'Resource already exists',
80
+ suggestion: 'Switch to Apply mode to update the existing resource, or change the name to create a new one.',
81
+ }),
82
+ },
83
+ // Quota exceeded
84
+ {
85
+ test: (msg) => /exceeded quota|quota.*exceeded|forbidden.*quota/i.test(msg),
86
+ friendly: () => ({
87
+ summary: 'Resource quota exceeded',
88
+ suggestion: 'The namespace has reached its resource quota limit. Request a quota increase or reduce resource requests.',
89
+ }),
90
+ },
91
+ // Namespace not found
92
+ {
93
+ test: (msg) => /namespace.*not found|namespaces.*not found/i.test(msg),
94
+ friendly: (msg) => {
95
+ const ns = msg.match(/namespace[s]? "([^"]+)"/)?.[1] ?? ''
96
+ return {
97
+ summary: `Namespace "${ns}" not found`,
98
+ suggestion: 'Create the namespace first or change the namespace in your YAML.',
99
+ }
100
+ },
101
+ },
102
+ // Immutable field
103
+ {
104
+ test: (msg) => /field is immutable/i.test(msg),
105
+ friendly: () => ({
106
+ summary: 'Cannot change immutable field',
107
+ suggestion: 'Some fields can\'t be modified after creation. Delete and recreate the resource, or remove the immutable field change.',
108
+ }),
109
+ },
110
+ // Connection refused / cluster unreachable
111
+ {
112
+ test: (msg) => /connection refused|no such host|cluster.*unreachable/i.test(msg),
113
+ friendly: () => ({
114
+ summary: 'Cannot reach the cluster',
115
+ suggestion: 'The Kubernetes API server is unreachable. Check your cluster connection.',
116
+ }),
117
+ },
118
+ ]
119
+
120
+ // Strip the "failed to apply resource: " / "failed to create resource: " prefix our backend adds
121
+ function stripPrefix(msg: string): string {
122
+ return msg.replace(/^failed to (apply|create) resource:\s*/i, '')
123
+ }
124
+
125
+ export function getFriendlyError(rawError: string): { summary: string; suggestion: string; raw: string } | null {
126
+ const cleaned = stripPrefix(rawError)
127
+ for (const pattern of patterns) {
128
+ if (pattern.test(cleaned)) {
129
+ const { summary, suggestion } = pattern.friendly(cleaned)
130
+ return { summary, suggestion, raw: cleaned }
131
+ }
132
+ }
133
+ return null
134
+ }
135
+
136
+ // Always returns something displayable — friendly if matched, raw if not
137
+ export function formatApplyError(rawError: string): { summary: string; suggestion?: string; raw: string } {
138
+ const friendly = getFriendlyError(rawError)
139
+ if (friendly) return friendly
140
+ const cleaned = stripPrefix(rawError)
141
+ return { summary: cleaned, raw: rawError }
142
+ }
@@ -80,6 +80,9 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
80
80
  service: Plug,
81
81
  ingress: DoorOpen,
82
82
  networkpolicy: ShieldCheck,
83
+ ciliumnetworkpolicy: ShieldCheck,
84
+ ciliumclusterwidenetworkpolicy: ShieldCheck,
85
+ clusternetworkpolicy: ShieldCheck,
83
86
  endpoints: Radio,
84
87
  endpointslice: Radio,
85
88
  gateway: DoorOpen,
@@ -0,0 +1,302 @@
1
+ // Skeleton YAML templates for common Kubernetes resource kinds.
2
+ // Used by the Create Resource dialog to pre-fill the editor.
3
+
4
+ const skeletons: Record<string, string> = {
5
+ Pod: `apiVersion: v1
6
+ kind: Pod
7
+ metadata:
8
+ name: my-pod
9
+ namespace: default
10
+ spec:
11
+ containers:
12
+ - name: app
13
+ image: nginx:latest
14
+ ports:
15
+ - containerPort: 80`,
16
+
17
+ Deployment: `apiVersion: apps/v1
18
+ kind: Deployment
19
+ metadata:
20
+ name: my-deployment
21
+ namespace: default
22
+ spec:
23
+ replicas: 1
24
+ selector:
25
+ matchLabels:
26
+ app: my-deployment
27
+ template:
28
+ metadata:
29
+ labels:
30
+ app: my-deployment
31
+ spec:
32
+ containers:
33
+ - name: app
34
+ image: nginx:latest
35
+ ports:
36
+ - containerPort: 80`,
37
+
38
+ Service: `apiVersion: v1
39
+ kind: Service
40
+ metadata:
41
+ name: my-service
42
+ namespace: default
43
+ spec:
44
+ selector:
45
+ app: my-app
46
+ ports:
47
+ - port: 80
48
+ targetPort: 80
49
+ type: ClusterIP`,
50
+
51
+ ConfigMap: `apiVersion: v1
52
+ kind: ConfigMap
53
+ metadata:
54
+ name: my-configmap
55
+ namespace: default
56
+ data:
57
+ key: value`,
58
+
59
+ Secret: `apiVersion: v1
60
+ kind: Secret
61
+ metadata:
62
+ name: my-secret
63
+ namespace: default
64
+ type: Opaque
65
+ stringData:
66
+ key: value`,
67
+
68
+ Namespace: `apiVersion: v1
69
+ kind: Namespace
70
+ metadata:
71
+ name: my-namespace`,
72
+
73
+ Job: `apiVersion: batch/v1
74
+ kind: Job
75
+ metadata:
76
+ name: my-job
77
+ namespace: default
78
+ spec:
79
+ template:
80
+ spec:
81
+ containers:
82
+ - name: job
83
+ image: busybox:latest
84
+ command: ["echo", "hello"]
85
+ restartPolicy: Never
86
+ backoffLimit: 3`,
87
+
88
+ CronJob: `apiVersion: batch/v1
89
+ kind: CronJob
90
+ metadata:
91
+ name: my-cronjob
92
+ namespace: default
93
+ spec:
94
+ schedule: "*/5 * * * *"
95
+ jobTemplate:
96
+ spec:
97
+ template:
98
+ spec:
99
+ containers:
100
+ - name: job
101
+ image: busybox:latest
102
+ command: ["echo", "hello"]
103
+ restartPolicy: Never`,
104
+
105
+ Ingress: `apiVersion: networking.k8s.io/v1
106
+ kind: Ingress
107
+ metadata:
108
+ name: my-ingress
109
+ namespace: default
110
+ spec:
111
+ rules:
112
+ - host: example.com
113
+ http:
114
+ paths:
115
+ - path: /
116
+ pathType: Prefix
117
+ backend:
118
+ service:
119
+ name: my-service
120
+ port:
121
+ number: 80`,
122
+
123
+ PersistentVolumeClaim: `apiVersion: v1
124
+ kind: PersistentVolumeClaim
125
+ metadata:
126
+ name: my-pvc
127
+ namespace: default
128
+ spec:
129
+ accessModes:
130
+ - ReadWriteOnce
131
+ resources:
132
+ requests:
133
+ storage: 1Gi`,
134
+
135
+ ServiceAccount: `apiVersion: v1
136
+ kind: ServiceAccount
137
+ metadata:
138
+ name: my-service-account
139
+ namespace: default`,
140
+
141
+ StatefulSet: `apiVersion: apps/v1
142
+ kind: StatefulSet
143
+ metadata:
144
+ name: my-statefulset
145
+ namespace: default
146
+ spec:
147
+ replicas: 1
148
+ selector:
149
+ matchLabels:
150
+ app: my-statefulset
151
+ serviceName: my-statefulset
152
+ template:
153
+ metadata:
154
+ labels:
155
+ app: my-statefulset
156
+ spec:
157
+ containers:
158
+ - name: app
159
+ image: nginx:latest
160
+ ports:
161
+ - containerPort: 80`,
162
+
163
+ DaemonSet: `apiVersion: apps/v1
164
+ kind: DaemonSet
165
+ metadata:
166
+ name: my-daemonset
167
+ namespace: default
168
+ spec:
169
+ selector:
170
+ matchLabels:
171
+ app: my-daemonset
172
+ template:
173
+ metadata:
174
+ labels:
175
+ app: my-daemonset
176
+ spec:
177
+ containers:
178
+ - name: app
179
+ image: nginx:latest`,
180
+ }
181
+
182
+ // Returns skeleton YAML for a known kind, or a generic template for unknown kinds.
183
+ export function getSkeletonYaml(kind: string, group?: string): string {
184
+ const skeleton = skeletons[kind]
185
+ if (skeleton) return skeleton
186
+
187
+ // For unknown/CRD kinds, provide a minimal template
188
+ let apiVersion = 'v1'
189
+ if (group) {
190
+ apiVersion = `${group}/v1`
191
+ }
192
+
193
+ return `apiVersion: ${apiVersion}
194
+ kind: ${kind}
195
+ metadata:
196
+ name: my-${kind.toLowerCase()}
197
+ namespace: default`
198
+ }
199
+
200
+ // Strips cluster-managed metadata from a resource YAML for duplication.
201
+ export function cleanYamlForDuplicate(yamlContent: string): string {
202
+ const lines = yamlContent.split('\n')
203
+ const result: string[] = []
204
+ let skipUntilDedent = false
205
+ let skipIndent = 0
206
+
207
+ const stripTopLevelKeys = new Set([
208
+ 'status:',
209
+ ])
210
+
211
+ const stripMetadataKeys = new Set([
212
+ 'resourceVersion:',
213
+ 'uid:',
214
+ 'creationTimestamp:',
215
+ 'generation:',
216
+ 'managedFields:',
217
+ 'selfLink:',
218
+ ])
219
+
220
+ const stripAnnotationKeys = [
221
+ 'kubectl.kubernetes.io/last-applied-configuration:',
222
+ ]
223
+
224
+ let inMetadata = false
225
+ let inAnnotations = false
226
+ let metadataIndent = 0
227
+ let annotationsIndent = 0
228
+
229
+ for (const line of lines) {
230
+ const trimmed = line.trimStart()
231
+ const indent = line.length - trimmed.length
232
+
233
+ // Handle skip mode (for multi-line values like managedFields or status)
234
+ if (skipUntilDedent) {
235
+ if (indent > skipIndent || trimmed === '' || trimmed.startsWith('-')) {
236
+ continue
237
+ }
238
+ skipUntilDedent = false
239
+ }
240
+
241
+ // Track metadata section
242
+ if (trimmed === 'metadata:') {
243
+ inMetadata = true
244
+ metadataIndent = indent
245
+ result.push(line)
246
+ continue
247
+ }
248
+
249
+ if (inMetadata && indent <= metadataIndent && trimmed !== '' && trimmed !== 'metadata:') {
250
+ inMetadata = false
251
+ inAnnotations = false
252
+ }
253
+
254
+ // Track annotations within metadata
255
+ if (inMetadata && trimmed === 'annotations:') {
256
+ inAnnotations = true
257
+ annotationsIndent = indent
258
+ result.push(line)
259
+ continue
260
+ }
261
+
262
+ if (inAnnotations && indent <= annotationsIndent && trimmed !== '' && trimmed !== 'annotations:') {
263
+ inAnnotations = false
264
+ }
265
+
266
+ // Strip top-level keys (status)
267
+ if (indent === 0 && stripTopLevelKeys.has(trimmed.split(' ')[0])) {
268
+ skipUntilDedent = true
269
+ skipIndent = indent
270
+ continue
271
+ }
272
+
273
+ // Strip metadata keys
274
+ if (inMetadata && !inAnnotations) {
275
+ const key = trimmed.split(' ')[0]
276
+ if (stripMetadataKeys.has(key)) {
277
+ if (key === 'managedFields:') {
278
+ skipUntilDedent = true
279
+ skipIndent = indent
280
+ }
281
+ continue
282
+ }
283
+ // Clear the name value (user needs to provide a new one)
284
+ if (trimmed.startsWith('name:')) {
285
+ result.push(line.replace(/name:.*/, 'name: '))
286
+ continue
287
+ }
288
+ }
289
+
290
+ // Strip specific annotations
291
+ if (inAnnotations) {
292
+ const shouldStrip = stripAnnotationKeys.some(key => trimmed.startsWith(key))
293
+ if (shouldStrip) {
294
+ continue
295
+ }
296
+ }
297
+
298
+ result.push(line)
299
+ }
300
+
301
+ return result.join('\n')
302
+ }