Skip to content

Commit 416ed9f

Browse files
committed
feat: Base Model + FieldRef + utils (+ fix pre-commit + fix eslintconf)
1 parent 436e36d commit 416ed9f

File tree

9 files changed

+375
-6
lines changed

9 files changed

+375
-6
lines changed

.husky/pre-commit

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
#!/usr/bin/env sh
2-
. "$(dirname -- "$0")/_/husky.sh"
1+
# Ajouter le PATH de base pour avoir accès à direnv
2+
export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
3+
4+
# Si direnv est disponible, récupérer le PATH du projet via bash
5+
if [ -f ".envrc" ] && command -v direnv >/dev/null 2>&1; then
6+
# Utiliser bash pour exécuter direnv et extraire seulement le PATH
7+
PROJECT_PATH=$(bash -c 'eval "$(direnv export bash 2>/dev/null)" && echo "$PATH"')
8+
if [ -n "$PROJECT_PATH" ]; then
9+
export PATH="$PROJECT_PATH"
10+
fi
11+
fi
312

413
yarn lint-staged

eslint.config.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,14 @@ export default defineConfig([
8181

8282
rules: {
8383
"@typescript-eslint/explicit-function-return-type": "off",
84-
"@typescript-eslint/no-explicit-any": "warn",
84+
"@typescript-eslint/no-explicit-any": "off",
8585

8686
"@typescript-eslint/no-unused-vars": [
8787
"error",
8888
{
89-
argsIgnorePattern: "^_",
89+
'argsIgnorePattern': '^_',
90+
'varsIgnorePattern': '^_',
91+
'caughtErrorsIgnorePattern': '^_',
9092
},
9193
],
9294

packages/sdk/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22
export { logger, createLogger } from "@crossplane-js/libs"
33

44
// Export types
5-
export type * from "./types"
5+
export type * from "./src/types"
6+
7+
export * from "./src/Model"
8+
export * from "./src/utils/FieldRef"
9+
export * from "./src/utils/secretUtils"

packages/sdk/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"main": "index.ts",
77
"dependencies": {
88
"@crossplane-js/libs": "workspace:^",
9+
"@kubernetes-models/apimachinery": "^2.1.0",
10+
"@kubernetes-models/base": "^5.0.1",
911
"lodash": "^4.17.21",
1012
"pino": "^9.6.0",
1113
"yaml": "^2.7.0"

packages/sdk/src/Model/index.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { IObjectMeta } from "@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta"
2+
import { Model as BaseModel } from "@kubernetes-models/base"
3+
4+
type Condition = Array<{
5+
type: string
6+
status: string
7+
lastTransitionTime: string
8+
message?: string
9+
reason: string
10+
}>
11+
type Status = {
12+
conditions?: Condition
13+
[key: string]: any
14+
}
15+
16+
export class Model<T> extends BaseModel<T> {
17+
getMetadata(): IObjectMeta {
18+
const self = this as any
19+
if (!self.metadatata) {
20+
throw new Error("No metadata found")
21+
}
22+
return self.metadata
23+
}
24+
25+
getStatus(): Status {
26+
const self = this as any
27+
return self.status || {}
28+
}
29+
30+
/**
31+
* Get the claim namespace from annotations (legacy method)
32+
* @returns The claim namespace
33+
* @throws Error if the resource wasn't created via a claim
34+
*/
35+
getClaimNamespace(): string {
36+
const { name } = this.getMetadata()
37+
const claimNamespace = this.getMetadata().labels?.["crossplane.io/claim-namespace"]
38+
39+
if (!claimNamespace) {
40+
throw new Error(`Resource ${name} wasn't created via a claim`)
41+
}
42+
43+
return claimNamespace
44+
}
45+
46+
/**
47+
* Check if this resource is ready based on its status conditions
48+
* @returns true if the resource is ready, false otherwise
49+
*/
50+
isReady(): boolean {
51+
try {
52+
// ProviderConfigs don't have status conditions, we assume they're always ready
53+
if ((this as any).kind === "ProviderConfig") {
54+
return true
55+
}
56+
57+
// Check for Ready condition in status.conditions
58+
const conditions = this.getStatus()?.conditions
59+
if (!conditions || !Array.isArray(conditions)) {
60+
return false
61+
}
62+
63+
const readyCondition = conditions.find((condition: any) => condition.type === "Ready")
64+
return readyCondition?.status === "True"
65+
} catch (_error) {
66+
return false
67+
}
68+
}
69+
70+
/**
71+
* Get a specific condition from the resource status
72+
* @param conditionType - The type of condition to find
73+
* @returns The condition object or undefined if not found
74+
*/
75+
getCondition(
76+
conditionType: string
77+
):
78+
| { type: string; status: string; lastTransitionTime: string; message?: string; reason: string }
79+
| undefined {
80+
return this.getStatus()?.conditions?.find(condition => condition.type === conditionType)
81+
}
82+
83+
/**
84+
* Check if a specific condition is true
85+
* @param conditionType - The type of condition to check
86+
* @returns true if the condition exists and its status is "True"
87+
*/
88+
hasCondition(conditionType: string): boolean {
89+
const condition = this.getCondition(conditionType)
90+
return condition?.status === "True"
91+
}
92+
93+
/**
94+
* Get the resource name
95+
* @returns The resource name
96+
*/
97+
getName(): string | undefined {
98+
return this.getMetadata().name
99+
}
100+
101+
/**
102+
* Get the resource namespace
103+
* @returns The resource namespace or undefined if not set
104+
*/
105+
getNamespace(): string | undefined {
106+
return this.getMetadata().namespace
107+
}
108+
109+
/**
110+
* Get an annotation value
111+
* @param key - The annotation key
112+
* @returns The annotation value or undefined if not found
113+
*/
114+
getAnnotation(key: string): string | undefined {
115+
return this.getMetadata().annotations?.[key]
116+
}
117+
118+
/**
119+
* Get a label value
120+
* @param key - The label key
121+
* @returns The label value or undefined if not found
122+
*/
123+
getLabel(key: string): string | undefined {
124+
return this.getMetadata().labels?.[key]
125+
}
126+
127+
/**
128+
* Check if the resource is paused
129+
* @returns true if the resource has the pause annotation set to "true"
130+
*/
131+
isPaused(): boolean {
132+
return this.getAnnotation("crossplane.io/paused") === "true"
133+
}
134+
135+
/**
136+
* Create a Usage resource to establish dependency relationships between resources
137+
* @param byResource - Optional resource that uses this resource
138+
* @returns Usage resource object
139+
*/
140+
makeUsage(byResource?: Model<any>): any {
141+
const usageName = byResource
142+
? `${byResource.getMetadata().name}-uses-${this.getMetadata().name}`
143+
: `protect-${this.getMetadata().name}`
144+
145+
const usage: any = {
146+
apiVersion: "apiextensions.crossplane.io/v1alpha1",
147+
kind: "Usage",
148+
getMetadata() {
149+
return usageName
150+
},
151+
spec: {
152+
replayDeletion: true,
153+
of: {
154+
apiVersion: (this as any).apiVersion,
155+
kind: (this as any).kind,
156+
resourceRef: { name: this.getMetadata().name },
157+
},
158+
},
159+
}
160+
161+
if (byResource) {
162+
usage.spec.by = {
163+
apiVersion: (byResource as any).apiVersion,
164+
kind: (byResource as any).kind,
165+
resourceRef: { name: byResource.getMetadata().name },
166+
}
167+
}
168+
169+
return usage
170+
}
171+
}
File renamed without changes.

packages/sdk/src/utils/FieldRef.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import lodash from "lodash"
2+
3+
/**
4+
* A reference to a field in an object, with fallback in case the field is not found.
5+
* Used to reference a value when declaring a composed resource. If the field is not found,
6+
* the resource will be set with a wrong value, but it will be paused, until a compose
7+
* function is called again with the correct values.
8+
*/
9+
export class FieldRef<T> extends String {
10+
private resolved: boolean
11+
12+
static getValue<T>(
13+
valueContainer: Record<string, any>,
14+
path: string,
15+
fallback: T,
16+
valueTransformer?: (value: any) => T
17+
): [T, boolean] {
18+
const obj = lodash.get(valueContainer, path)
19+
if (obj === undefined) {
20+
return [fallback, false]
21+
}
22+
const transformedValue = valueTransformer ? valueTransformer(obj) : (obj as T)
23+
return [transformedValue, true]
24+
}
25+
26+
constructor(
27+
valueContainer: Record<string, any>,
28+
path: string,
29+
fallback: T,
30+
valueTransformer?: (value: any) => T
31+
) {
32+
const value = FieldRef.getValue<T>(valueContainer, path, fallback, valueTransformer)
33+
super(value[0])
34+
this.resolved = value[1]
35+
}
36+
37+
/**
38+
* Check if the field reference can be resolved
39+
* @returns true if the field can be resolved, false otherwise
40+
*/
41+
canResolve(): boolean {
42+
return this.resolved
43+
}
44+
}
45+
46+
/**
47+
* Type that recursively transforms all string properties to accept either string or FieldRef<string>
48+
*/
49+
type WithFieldRefs<T> = T extends string
50+
? string | FieldRef<string>
51+
: T extends number
52+
? number | FieldRef<number>
53+
: T extends boolean
54+
? boolean | FieldRef<boolean>
55+
: T extends (infer U)[]
56+
? WithFieldRefs<U>[]
57+
: T extends object
58+
? { [K in keyof T]: WithFieldRefs<T[K]> }
59+
: T
60+
61+
/**
62+
* Factory function that creates a new class allowing FieldRef values in place of primitive types
63+
* @param BaseClass The original Kubernetes model class
64+
* @returns A new class that accepts FieldRef instances for primitive properties
65+
*/
66+
export function withFieldRefsClassFactory<T extends new (data?: any) => any>(
67+
BaseClass: T
68+
): new (data?: WithFieldRefs<ConstructorParameters<T>[0]>) => InstanceType<T> {
69+
return class extends (BaseClass as any) {
70+
constructor(data?: WithFieldRefs<ConstructorParameters<T>[0]>) {
71+
// Process the data to resolve FieldRef instances
72+
const processedData = processFieldRefs(data)
73+
super(processedData)
74+
}
75+
76+
// Override toJSON to ensure FieldRefs are properly serialized
77+
toJSON() {
78+
const json = super.toJSON()
79+
return processFieldRefs(json)
80+
}
81+
} as any
82+
}
83+
84+
/**
85+
* Recursively process an object to resolve FieldRef instances to their string values
86+
*/
87+
function processFieldRefs(obj: any): any {
88+
if (obj === null || obj === undefined) {
89+
return obj
90+
}
91+
92+
// If it's a FieldRef, return its string value
93+
if (obj instanceof FieldRef) {
94+
return obj.toString()
95+
}
96+
97+
// If it's an array, process each element
98+
if (Array.isArray(obj)) {
99+
return obj.map(item => processFieldRefs(item))
100+
}
101+
102+
// If it's an object, process each property
103+
if (typeof obj === "object") {
104+
const processed: any = {}
105+
for (const key in obj) {
106+
// if (obj.hasOwnProperty(key)) {
107+
if (Object.hasOwn(obj, key)) {
108+
processed[key] = processFieldRefs(obj[key])
109+
}
110+
}
111+
return processed
112+
}
113+
114+
// For primitive values, return as-is
115+
return obj
116+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Encode string data to base64 for Kubernetes secret storage
3+
* @param data - Object with string keys and values to encode
4+
* @returns Object with base64-encoded values
5+
*/
6+
export const toSecretData = (data: Record<string, string>): Record<string, string> => {
7+
const result: Record<string, string> = {}
8+
for (const [key, value] of Object.entries(data)) {
9+
result[key] = Buffer.from(value, "utf8").toString("base64")
10+
}
11+
return result
12+
}
13+
14+
/**
15+
* Decode base64 data from Kubernetes secret storage
16+
* @param data - Object with string keys and base64-encoded values
17+
* @returns Object with decoded string values
18+
*/
19+
export const fromSecretData = (data: Record<string, string>): Record<string, string> => {
20+
const result: Record<string, string> = {}
21+
for (const [key, value] of Object.entries(data)) {
22+
result[key] = Buffer.from(value, "base64").toString("utf8")
23+
}
24+
return result
25+
}

0 commit comments

Comments
 (0)