@kustodian/nodes 1.0.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@kustodian/nodes",
3
+ "version": "1.0.0",
4
+ "description": "Node definitions, roles, and labeling for Kustodian",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "test:watch": "bun test --watch",
20
+ "typecheck": "bun run tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "kustodian",
24
+ "nodes",
25
+ "kubernetes"
26
+ ],
27
+ "author": "Luca Silverentand <luca@onezero.company>",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/lucasilverentand/kustodian.git",
32
+ "directory": "packages/nodes"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://npm.pkg.github.com"
36
+ },
37
+ "dependencies": {
38
+ "@kustodian/core": "workspace:*",
39
+ "@kustodian/schema": "workspace:*"
40
+ },
41
+ "devDependencies": {}
42
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './labeler.js';
3
+ export * from './profile.js';
package/src/labeler.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { type ResultType, success } from '@kustodian/core';
2
+ import type { KustodianErrorType } from '@kustodian/core';
3
+
4
+ import { type NodeListType, type NodeType, format_node_labels } from './types.js';
5
+
6
+ /**
7
+ * Label operation types.
8
+ */
9
+ export type LabelOperationType = 'add' | 'update' | 'remove';
10
+
11
+ /**
12
+ * A single label change to be applied.
13
+ */
14
+ export interface LabelChangeType {
15
+ node: string;
16
+ key: string;
17
+ value?: string;
18
+ operation: LabelOperationType;
19
+ }
20
+
21
+ /**
22
+ * Options for the label sync operation.
23
+ */
24
+ export interface LabelSyncOptionsType {
25
+ dry_run?: boolean;
26
+ verify?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Result of a label sync operation.
31
+ */
32
+ export interface LabelSyncResultType {
33
+ changes: LabelChangeType[];
34
+ applied: number;
35
+ skipped: number;
36
+ }
37
+
38
+ /**
39
+ * Calculates the label changes needed for a node.
40
+ */
41
+ export function calculate_label_changes(
42
+ node: NodeType,
43
+ current_labels: Record<string, string>,
44
+ prefix: string,
45
+ ): LabelChangeType[] {
46
+ const changes: LabelChangeType[] = [];
47
+ const desired_labels = format_node_labels(node.labels, prefix);
48
+
49
+ // Find labels to add or update
50
+ for (const [key, value] of Object.entries(desired_labels)) {
51
+ const current_value = current_labels[key];
52
+
53
+ if (current_value === undefined) {
54
+ changes.push({
55
+ node: node.name,
56
+ key,
57
+ value,
58
+ operation: 'add',
59
+ });
60
+ } else if (current_value !== value) {
61
+ changes.push({
62
+ node: node.name,
63
+ key,
64
+ value,
65
+ operation: 'update',
66
+ });
67
+ }
68
+ }
69
+
70
+ // Find labels to remove (labels with prefix that aren't in desired)
71
+ for (const key of Object.keys(current_labels)) {
72
+ if (key.startsWith(`${prefix}/`) && !(key in desired_labels)) {
73
+ changes.push({
74
+ node: node.name,
75
+ key,
76
+ operation: 'remove',
77
+ });
78
+ }
79
+ }
80
+
81
+ return changes;
82
+ }
83
+
84
+ /**
85
+ * Calculates all label changes for a node list.
86
+ */
87
+ export function calculate_all_label_changes(
88
+ node_list: NodeListType,
89
+ current_labels_by_node: Map<string, Record<string, string>>,
90
+ ): LabelChangeType[] {
91
+ const prefix = node_list.label_prefix ?? 'kustodian.io';
92
+ const all_changes: LabelChangeType[] = [];
93
+
94
+ for (const node of node_list.nodes) {
95
+ const current_labels = current_labels_by_node.get(node.name) ?? {};
96
+ const changes = calculate_label_changes(node, current_labels, prefix);
97
+ all_changes.push(...changes);
98
+ }
99
+
100
+ return all_changes;
101
+ }
102
+
103
+ /**
104
+ * Formats a label change for display.
105
+ */
106
+ export function format_label_change(change: LabelChangeType): string {
107
+ switch (change.operation) {
108
+ case 'add':
109
+ return `[+] ${change.node}: ${change.key}=${change.value}`;
110
+ case 'update':
111
+ return `[~] ${change.node}: ${change.key}=${change.value}`;
112
+ case 'remove':
113
+ return `[-] ${change.node}: ${change.key}`;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Groups label changes by node.
119
+ */
120
+ export function group_changes_by_node(changes: LabelChangeType[]): Map<string, LabelChangeType[]> {
121
+ const grouped = new Map<string, LabelChangeType[]>();
122
+
123
+ for (const change of changes) {
124
+ const node_changes = grouped.get(change.node) ?? [];
125
+ node_changes.push(change);
126
+ grouped.set(change.node, node_changes);
127
+ }
128
+
129
+ return grouped;
130
+ }
131
+
132
+ /**
133
+ * Creates a dry-run result from label changes.
134
+ */
135
+ export function create_dry_run_result(changes: LabelChangeType[]): LabelSyncResultType {
136
+ return {
137
+ changes,
138
+ applied: 0,
139
+ skipped: changes.length,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Node labeler service interface.
145
+ * This is implemented by the actual Kubernetes client.
146
+ */
147
+ export interface NodeLabelerType {
148
+ /**
149
+ * Gets the current labels for a node.
150
+ */
151
+ get_labels(node_name: string): Promise<ResultType<Record<string, string>, KustodianErrorType>>;
152
+
153
+ /**
154
+ * Applies a label change to a node.
155
+ */
156
+ apply_change(change: LabelChangeType): Promise<ResultType<void, KustodianErrorType>>;
157
+
158
+ /**
159
+ * Syncs labels for all nodes in the list.
160
+ */
161
+ sync_labels(
162
+ node_list: NodeListType,
163
+ options?: LabelSyncOptionsType,
164
+ ): Promise<ResultType<LabelSyncResultType, KustodianErrorType>>;
165
+ }
166
+
167
+ /**
168
+ * Creates a mock labeler for testing.
169
+ */
170
+ export function create_mock_labeler(
171
+ labels_by_node: Map<string, Record<string, string>>,
172
+ ): NodeLabelerType {
173
+ return {
174
+ async get_labels(node_name) {
175
+ const labels = labels_by_node.get(node_name);
176
+ if (!labels) {
177
+ return success({});
178
+ }
179
+ return success(labels);
180
+ },
181
+
182
+ async apply_change(_change) {
183
+ return success(undefined);
184
+ },
185
+
186
+ async sync_labels(node_list, options) {
187
+ const changes = calculate_all_label_changes(node_list, labels_by_node);
188
+
189
+ if (options?.dry_run) {
190
+ return success(create_dry_run_result(changes));
191
+ }
192
+
193
+ // In mock, all changes are "applied"
194
+ return success({
195
+ changes,
196
+ applied: changes.length,
197
+ skipped: 0,
198
+ });
199
+ },
200
+ };
201
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { NodeType, TaintType } from './types.js';
2
+
3
+ /**
4
+ * Node profile type for profile resolution.
5
+ * This mirrors the NodeProfileType from @kustodian/schema but is defined here
6
+ * to avoid circular dependencies.
7
+ */
8
+ export interface NodeProfileType {
9
+ name: string;
10
+ display_name?: string;
11
+ description?: string;
12
+ labels?: Record<string, string | boolean | number>;
13
+ taints?: TaintType[];
14
+ annotations?: Record<string, string>;
15
+ }
16
+
17
+ /**
18
+ * Resolved node type with profile values merged.
19
+ * The profile field is removed after resolution.
20
+ */
21
+ export type ResolvedNodeType = Omit<NodeType, 'profile'>;
22
+
23
+ /**
24
+ * Merges taints from profile and node.
25
+ * Node taints are appended after profile taints.
26
+ * Duplicate taints (same key+effect) from node override profile taints.
27
+ */
28
+ function merge_taints(
29
+ profile_taints: TaintType[] | undefined,
30
+ node_taints: TaintType[] | undefined,
31
+ ): TaintType[] | undefined {
32
+ if (!profile_taints && !node_taints) {
33
+ return undefined;
34
+ }
35
+
36
+ if (!profile_taints) {
37
+ return node_taints;
38
+ }
39
+
40
+ if (!node_taints) {
41
+ return profile_taints;
42
+ }
43
+
44
+ // Create a map of node taints keyed by key+effect for deduplication
45
+ const node_taint_keys = new Set(
46
+ node_taints.map((t) => `${t.key}:${t.effect}`),
47
+ );
48
+
49
+ // Filter out profile taints that are overridden by node taints
50
+ const filtered_profile_taints = profile_taints.filter(
51
+ (t) => !node_taint_keys.has(`${t.key}:${t.effect}`),
52
+ );
53
+
54
+ return [...filtered_profile_taints, ...node_taints];
55
+ }
56
+
57
+ /**
58
+ * Resolves a node with its profile configuration.
59
+ * Profile values are merged with node values, where node values take precedence.
60
+ *
61
+ * Merge strategy:
62
+ * - labels: Profile labels are base, node labels override/extend
63
+ * - taints: Node taints override profile taints with same key+effect, others are combined
64
+ * - annotations: Profile annotations are base, node annotations override/extend
65
+ * - ssh: Not inherited from profile (kept as node-specific)
66
+ *
67
+ * @param node - The node to resolve
68
+ * @param profile - The profile to apply, or undefined if no profile
69
+ * @returns The resolved node with profile values merged
70
+ */
71
+ export function resolve_node_profile(
72
+ node: NodeType,
73
+ profile: NodeProfileType | undefined,
74
+ ): ResolvedNodeType {
75
+ if (!profile) {
76
+ // No profile to apply, return node without profile field
77
+ const { profile: _, ...resolved } = node;
78
+ return resolved;
79
+ }
80
+
81
+ // Merge labels: profile as base, node overrides
82
+ const merged_labels =
83
+ profile.labels || node.labels
84
+ ? { ...profile.labels, ...node.labels }
85
+ : undefined;
86
+
87
+ // Merge taints: deduplicate by key+effect, node wins
88
+ const merged_taints = merge_taints(profile.taints, node.taints);
89
+
90
+ // Merge annotations: profile as base, node overrides
91
+ const merged_annotations =
92
+ profile.annotations || node.annotations
93
+ ? { ...profile.annotations, ...node.annotations }
94
+ : undefined;
95
+
96
+ const resolved: ResolvedNodeType = {
97
+ name: node.name,
98
+ role: node.role,
99
+ address: node.address,
100
+ };
101
+
102
+ if (node.ssh !== undefined) {
103
+ resolved.ssh = node.ssh;
104
+ }
105
+ if (merged_labels !== undefined) {
106
+ resolved.labels = merged_labels;
107
+ }
108
+ if (merged_taints !== undefined) {
109
+ resolved.taints = merged_taints;
110
+ }
111
+ if (merged_annotations !== undefined) {
112
+ resolved.annotations = merged_annotations;
113
+ }
114
+
115
+ return resolved;
116
+ }
117
+
118
+ /**
119
+ * Resolves all nodes in a list with their profiles.
120
+ *
121
+ * @param nodes - The nodes to resolve
122
+ * @param profiles - Map of profile name to profile
123
+ * @returns Object with resolved nodes and any missing profile errors
124
+ */
125
+ export function resolve_all_node_profiles(
126
+ nodes: NodeType[],
127
+ profiles: Map<string, NodeProfileType>,
128
+ ): { resolved: ResolvedNodeType[]; errors: string[] } {
129
+ const resolved: ResolvedNodeType[] = [];
130
+ const errors: string[] = [];
131
+
132
+ for (const node of nodes) {
133
+ if (node.profile) {
134
+ const profile = profiles.get(node.profile);
135
+ if (!profile) {
136
+ errors.push(`Node '${node.name}' references unknown profile '${node.profile}'`);
137
+ // Still resolve the node without the profile
138
+ const { profile: _, ...node_without_profile } = node;
139
+ resolved.push(node_without_profile);
140
+ } else {
141
+ resolved.push(resolve_node_profile(node, profile));
142
+ }
143
+ } else {
144
+ resolved.push(resolve_node_profile(node, undefined));
145
+ }
146
+ }
147
+
148
+ return { resolved, errors };
149
+ }
150
+
151
+ /**
152
+ * Collects all unique profile names referenced by nodes.
153
+ */
154
+ export function get_referenced_profiles(nodes: NodeType[]): Set<string> {
155
+ const profiles = new Set<string>();
156
+ for (const node of nodes) {
157
+ if (node.profile) {
158
+ profiles.add(node.profile);
159
+ }
160
+ }
161
+ return profiles;
162
+ }
163
+
164
+ /**
165
+ * Validates that all referenced profiles exist.
166
+ *
167
+ * @param nodes - The nodes to check
168
+ * @param profiles - Map of available profiles
169
+ * @returns Array of error messages for missing profiles
170
+ */
171
+ export function validate_profile_references(
172
+ nodes: NodeType[],
173
+ profiles: Map<string, NodeProfileType>,
174
+ ): string[] {
175
+ const errors: string[] = [];
176
+ const referenced = get_referenced_profiles(nodes);
177
+
178
+ for (const profile_name of referenced) {
179
+ if (!profiles.has(profile_name)) {
180
+ const nodes_using = nodes
181
+ .filter((n) => n.profile === profile_name)
182
+ .map((n) => n.name);
183
+ errors.push(
184
+ `Profile '${profile_name}' not found, referenced by nodes: ${nodes_using.join(', ')}`,
185
+ );
186
+ }
187
+ }
188
+
189
+ return errors;
190
+ }
package/src/types.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Node role in the cluster.
3
+ */
4
+ export type NodeRoleType = 'controller' | 'worker' | 'controller+worker';
5
+
6
+ /**
7
+ * SSH configuration for connecting to a node.
8
+ */
9
+ export interface SshConfigType {
10
+ user?: string;
11
+ key_path?: string;
12
+ known_hosts_path?: string;
13
+ port?: number;
14
+ }
15
+
16
+ /**
17
+ * Kubernetes taint configuration.
18
+ */
19
+ export interface TaintType {
20
+ key: string;
21
+ value?: string;
22
+ effect: 'NoSchedule' | 'PreferNoSchedule' | 'NoExecute';
23
+ }
24
+
25
+ /**
26
+ * Node definition with its configuration.
27
+ */
28
+ export interface NodeType {
29
+ name: string;
30
+ role: NodeRoleType;
31
+ address: string;
32
+ profile?: string;
33
+ ssh?: SshConfigType;
34
+ labels?: Record<string, string | boolean | number>;
35
+ taints?: TaintType[];
36
+ annotations?: Record<string, string>;
37
+ }
38
+
39
+ /**
40
+ * Node list with default SSH configuration.
41
+ */
42
+ export interface NodeListType {
43
+ cluster: string;
44
+ label_prefix?: string;
45
+ ssh?: SshConfigType;
46
+ nodes: NodeType[];
47
+ }
48
+
49
+ /**
50
+ * Default label prefix.
51
+ */
52
+ export const DEFAULT_LABEL_PREFIX = 'kustodian.io';
53
+
54
+ /**
55
+ * Formats a label key with the configured prefix.
56
+ */
57
+ export function format_label_key(key: string, prefix: string = DEFAULT_LABEL_PREFIX): string {
58
+ // If key already has a prefix (contains /), return as-is
59
+ if (key.includes('/')) {
60
+ return key;
61
+ }
62
+ return `${prefix}/${key}`;
63
+ }
64
+
65
+ /**
66
+ * Formats a label value as a string.
67
+ */
68
+ export function format_label_value(value: string | boolean | number): string {
69
+ if (typeof value === 'boolean') {
70
+ return value ? 'true' : 'false';
71
+ }
72
+ return String(value);
73
+ }
74
+
75
+ /**
76
+ * Formats all labels for a node with the configured prefix.
77
+ */
78
+ export function format_node_labels(
79
+ labels: Record<string, string | boolean | number> | undefined,
80
+ prefix: string = DEFAULT_LABEL_PREFIX,
81
+ ): Record<string, string> {
82
+ if (!labels) {
83
+ return {};
84
+ }
85
+
86
+ const result: Record<string, string> = {};
87
+ for (const [key, value] of Object.entries(labels)) {
88
+ const formatted_key = format_label_key(key, prefix);
89
+ result[formatted_key] = format_label_value(value);
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Gets the SSH configuration for a node, merging with defaults.
96
+ */
97
+ export function get_node_ssh_config(node: NodeType, default_ssh?: SshConfigType): SshConfigType {
98
+ return {
99
+ ...default_ssh,
100
+ ...node.ssh,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Checks if a node has a controller role.
106
+ */
107
+ export function is_controller(node: NodeType): boolean {
108
+ return node.role === 'controller' || node.role === 'controller+worker';
109
+ }
110
+
111
+ /**
112
+ * Checks if a node has a worker role.
113
+ */
114
+ export function is_worker(node: NodeType): boolean {
115
+ return node.role === 'worker' || node.role === 'controller+worker';
116
+ }
117
+
118
+ /**
119
+ * Gets all controller nodes from a node list.
120
+ */
121
+ export function get_controllers(nodes: NodeType[]): NodeType[] {
122
+ return nodes.filter(is_controller);
123
+ }
124
+
125
+ /**
126
+ * Gets all worker nodes from a node list.
127
+ */
128
+ export function get_workers(nodes: NodeType[]): NodeType[] {
129
+ return nodes.filter(is_worker);
130
+ }
131
+
132
+ /**
133
+ * Gets the primary controller node (first controller).
134
+ */
135
+ export function get_primary_controller(nodes: NodeType[]): NodeType | undefined {
136
+ return nodes.find(is_controller);
137
+ }