Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.

Commit bbfa731

Browse files
authored
Merge pull request #7530 from systeminit/clover/azure/mgmt-funcs
feat(clover): add azure mgmt funcs
2 parents 7d550b5 + 8bad123 commit bbfa731

4 files changed

Lines changed: 478 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/clover/src/pipelines/azure/funcs.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,24 @@ export const CODE_GENERATION_FUNC_SPECS = {} as const satisfies Record<
4545
FuncSpecInfo
4646
>;
4747

48-
export const MANAGEMENT_FUNCS = {} as const satisfies Record<
48+
export const MANAGEMENT_FUNCS = {
49+
"Discover on Azure": {
50+
id: "a82d730eac534eac4ce84954a8c1a19a817553c23bdccfcc5fc33f14c21ca923",
51+
backendKind: "management",
52+
responseType: "management",
53+
displayName: "Discover on Azure",
54+
path: "./src/pipelines/azure/funcs/management/discover.ts",
55+
handlers: ["list", "read"],
56+
},
57+
"Import from Azure": {
58+
id: "61d66b00cf1db372a49903bdd9c2f864ad0da606c320604623acdd72c4df6c37",
59+
backendKind: "management",
60+
responseType: "management",
61+
displayName: "Import from Azure",
62+
path: "./src/pipelines/azure/funcs/management/import.ts",
63+
handlers: ["read"],
64+
},
65+
} as const satisfies Record<
4966
string,
5067
FuncSpecInfo & { handlers: CfHandlerKind[] }
5168
>;
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
async function main({
2+
thisComponent,
3+
}: Input): Promise<Output> {
4+
const component = thisComponent;
5+
const tenantId = requestStorage.getEnv("AZURE_TENANT_ID");
6+
const clientId = requestStorage.getEnv("AZURE_CLIENT_ID");
7+
const clientSecret = requestStorage.getEnv("AZURE_CLIENT_SECRET");
8+
9+
if (!tenantId || !clientId || !clientSecret) {
10+
throw new Error("Azure credentials not found");
11+
}
12+
13+
const subscriptionId = _.get(
14+
component.properties,
15+
["domain", "subscriptionId"],
16+
"",
17+
);
18+
const resourceGroup = _.get(
19+
component.properties,
20+
["domain", "resourceGroup"],
21+
"",
22+
);
23+
const resourceType = _.get(
24+
component.properties,
25+
["domain", "extra", "AzureResourceType"],
26+
"",
27+
);
28+
const apiVersion = _.get(
29+
component.properties,
30+
["domain", "extra", "apiVersion"],
31+
"2023-01-01",
32+
);
33+
const propUsageMapJson = _.get(
34+
component.properties,
35+
["domain", "extra", "PropUsageMap"],
36+
"{}",
37+
);
38+
39+
if (!subscriptionId) {
40+
return {
41+
status: "error",
42+
message: "subscriptionId is required in domain",
43+
};
44+
}
45+
46+
if (!resourceType) {
47+
return {
48+
status: "error",
49+
message: "AzureResourceType not found in domain.extra",
50+
};
51+
}
52+
53+
// Convert Azure::Service::Resource to Microsoft.Service/resources format
54+
const parts = resourceType.split("::");
55+
if (parts.length !== 3 || parts[0] !== "Azure") {
56+
return {
57+
status: "error",
58+
message: `Invalid Azure resource type format: ${resourceType}`,
59+
};
60+
}
61+
62+
const providerNamespace = `Microsoft.${parts[1]}`;
63+
const resourceTypeName = parts[2];
64+
65+
// Parse PropUsageMap to get updatable properties
66+
let updatableProperties: Set<string>;
67+
let createOnlyProperties: Set<string>;
68+
try {
69+
const propUsageMap = JSON.parse(propUsageMapJson);
70+
updatableProperties = new Set(propUsageMap.updatable || []);
71+
createOnlyProperties = new Set(propUsageMap.createOnly || []);
72+
} catch (e) {
73+
console.warn(
74+
`Failed to parse PropUsageMap for ${resourceType}, using empty set:`,
75+
e,
76+
);
77+
updatableProperties = new Set();
78+
createOnlyProperties = new Set();
79+
}
80+
81+
console.log(`Discovering ${resourceType} resources...`);
82+
83+
const token = await getAzureToken(tenantId, clientId, clientSecret);
84+
85+
// Build refinement filter from domain properties
86+
const refinement = _.cloneDeep(thisComponent.properties.domain);
87+
delete refinement["extra"];
88+
delete refinement["location"];
89+
// Remove any empty values, as they are never refinements
90+
for (const [key, value] of Object.entries(refinement)) {
91+
if (_.isEmpty(value)) {
92+
delete refinement[key];
93+
} else if (_.isPlainObject(value)) {
94+
refinement[key] = _.pickBy(
95+
value,
96+
(v) => !_.isEmpty(v) || _.isNumber(v) || _.isBoolean(v),
97+
);
98+
if (_.isEmpty(refinement[key])) {
99+
delete refinement[key];
100+
}
101+
}
102+
}
103+
104+
const listUrl =
105+
`https://management.azure.com/subscriptions/${subscriptionId}/providers/${providerNamespace}/${resourceTypeName}?api-version=${apiVersion}`;
106+
107+
// Handle pagination with nextLink
108+
let resources: any[] = [];
109+
let nextLink: string | null = listUrl;
110+
111+
while (nextLink) {
112+
const listResponse = await fetch(nextLink, {
113+
method: "GET",
114+
headers: {
115+
"Authorization": `Bearer ${token}`,
116+
},
117+
});
118+
119+
if (!listResponse.ok) {
120+
const errorText = await listResponse.text();
121+
return {
122+
status: "error",
123+
message:
124+
`Azure API Error: ${listResponse.status} ${listResponse.statusText} - ${errorText}`,
125+
};
126+
}
127+
128+
const listData = await listResponse.json();
129+
resources = resources.concat(listData.value || []);
130+
nextLink = listData.nextLink || null;
131+
132+
if (nextLink) {
133+
console.log(`Fetching next page: ${nextLink}`);
134+
}
135+
}
136+
137+
console.log(`Found ${resources.length} resources`);
138+
139+
const create: Output["ops"]["create"] = {};
140+
const actions = {};
141+
let importCount = 0;
142+
143+
for (const resource of resources) {
144+
const resourceId = resource.id;
145+
146+
console.log(`Importing ${resourceId}`);
147+
148+
// Fetch the full resource details
149+
const resourceUrl =
150+
`https://management.azure.com${resourceId}?api-version=${apiVersion}`;
151+
const resourceResponse = await fetch(resourceUrl, {
152+
method: "GET",
153+
headers: {
154+
"Authorization": `Bearer ${token}`,
155+
},
156+
});
157+
158+
if (!resourceResponse.ok) {
159+
console.log(
160+
`Failed to fetch ${resourceId}, skipping (status: ${resourceResponse.status})`,
161+
);
162+
continue;
163+
}
164+
165+
const fullResource = await resourceResponse.json();
166+
167+
// Build domain by only including updatable properties from the resource
168+
// CreateOnly properties are immutable on existing resources, so we don't copy them
169+
const domainProperties: Record<string, any> = {
170+
subscriptionId,
171+
resourceGroup,
172+
};
173+
174+
// Copy updatable properties from the resource
175+
for (const [key, value] of Object.entries(fullResource)) {
176+
if (updatableProperties.has(key) && value != null) {
177+
domainProperties[key] = value;
178+
}
179+
}
180+
181+
const properties = {
182+
si: {
183+
resourceId,
184+
},
185+
domain: {
186+
...domainProperties,
187+
extra: component.properties?.domain?.extra || {
188+
AzureResourceType: resourceType,
189+
apiVersion: apiVersion,
190+
},
191+
},
192+
resource: fullResource,
193+
};
194+
195+
// Apply refinement filter
196+
if (_.isEmpty(refinement) || _.isMatch(properties.domain, refinement)) {
197+
const newAttributes: Output["ops"]["create"][string]["attributes"] = {};
198+
for (const [skey, svalue] of Object.entries(component.sources || {})) {
199+
// Skip createOnly attributes - they can only be set on new components
200+
// Extract the property name from the path (e.g., "/domain/location" -> "location")
201+
const propName = skey.split("/").pop();
202+
if (propName && createOnlyProperties.has(propName)) {
203+
continue;
204+
}
205+
newAttributes[skey] = {
206+
$source: svalue,
207+
};
208+
}
209+
210+
create[resourceId] = {
211+
kind: resourceType,
212+
properties,
213+
attributes: newAttributes,
214+
};
215+
actions[resourceId] = {
216+
remove: ["create"],
217+
};
218+
importCount++;
219+
} else {
220+
console.log(
221+
`Skipping import of ${resourceId}; it did not match refinements`,
222+
);
223+
}
224+
}
225+
226+
return {
227+
status: "ok",
228+
message: `Discovered ${importCount} ${resourceType} resources`,
229+
ops: {
230+
create,
231+
actions,
232+
},
233+
};
234+
}
235+
236+
async function getAzureToken(
237+
tenantId: string,
238+
clientId: string,
239+
clientSecret: string,
240+
): Promise<string> {
241+
const tokenUrl =
242+
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
243+
const body = new URLSearchParams({
244+
client_id: clientId,
245+
client_secret: clientSecret,
246+
scope: "https://management.azure.com/.default",
247+
grant_type: "client_credentials",
248+
});
249+
250+
const response = await fetch(tokenUrl, {
251+
method: "POST",
252+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
253+
body: body.toString(),
254+
});
255+
256+
if (!response.ok) {
257+
throw new Error(
258+
`Failed to get Azure token: ${response.status} ${response.statusText}`,
259+
);
260+
}
261+
262+
const data = await response.json();
263+
return data.access_token;
264+
}

0 commit comments

Comments
 (0)