Skip to content

Commit 67d4cd8

Browse files
committed
Add dependency graph and algorithm (kahn's) to turn the graph into prioritized list
1 parent ccbe144 commit 67d4cd8

15 files changed

Lines changed: 383 additions & 231 deletions

File tree

codify-core/src/commands/plan/orchestrator.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export const PlanOrchestrator = {
99
const resourceDefinitions = await pluginCollection.getAllResourceDefinitions();
1010

1111
await ConfigCompiler.analyzeProject(project, resourceDefinitions);
12+
const dependencyList = ConfigCompiler.buildDependencyList(project);
13+
console.log(dependencyList);
14+
1215
const plan = await pluginCollection.getPlan(project);
1316

1417
await pluginCollection.destroy();

codify-core/src/config-compiler/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { ResourceDefinitions } from '../entities/resource-definition';
22
import { InternalError } from '../utils/errors';
33
import { ConfigBlockType } from './language-definition';
44
import { ConfigLoader } from './loader';
5+
import { ConfigSemanticAnalyzer } from './output/config-semantic-analyzer';
6+
import { DependencyBuilder } from './output/dependency-builder';
57
import { FileParser } from './parser';
68
import { ParsedModule, ParsedProject } from './parser/entities';
79
import { ProjectConfig } from './parser/entities/project';
810
import { ResourceConfig } from './parser/entities/resource';
911
import { JsonFileParser } from './parser/json/file-parser';
10-
import { ConfigSemanticAnalyzer } from './semantic-analysis/config-semantic-analyzer';
1112

1213
export class ConfigCompiler {
1314

@@ -44,8 +45,12 @@ export class ConfigCompiler {
4445

4546
static async analyzeProject(parsedProject: ParsedProject, resourceDefinitions: ResourceDefinitions): Promise<void> {
4647
ConfigSemanticAnalyzer.validate(parsedProject, resourceDefinitions);
47-
ConfigSemanticAnalyzer.parseResourceDependencies(parsedProject.coreModule.configBlocks
48+
}
49+
50+
static async buildDependencyList(parsedProject: ParsedProject): Promise<ResourceConfig[]> {
51+
const dependencyGraph = DependencyBuilder.buildDependencyGraph(parsedProject.coreModule.configBlocks
4852
.filter((u) => u.configType === ConfigBlockType.RESOURCE) as ResourceConfig[]
4953
)
54+
return DependencyBuilder.generateDependencyList(dependencyGraph);
5055
}
5156
}

codify-core/src/config-compiler/semantic-analysis/config-semantic-analyzer.test.ts renamed to codify-core/src/config-compiler/output/config-semantic-analyzer.test.ts

Lines changed: 2 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -35,34 +35,6 @@ describe('Config semantic analyzer tests', () => {
3535
})
3636
}
3737

38-
const createResource5 = () => {
39-
return new ResourceConfig({
40-
type: 'homebrew_options',
41-
directory: '${homebrew_installation.directory}'
42-
})
43-
}
44-
45-
const createResource6 = () => {
46-
return new ResourceConfig({
47-
type: 'homebrew_options',
48-
directory: '${homebrew_installation_invalid.directory}'
49-
})
50-
}
51-
52-
const createResource7 = () => {
53-
return new ResourceConfig({
54-
type: 'homebrew_options',
55-
directory: '${homebrew_installation.directory_invalid}'
56-
})
57-
}
58-
59-
const createResource8 = () => {
60-
return new ResourceConfig({
61-
type: 'homebrew_options',
62-
directory: `$\{homebrew_installation.directory} and $\{homebrew_installation.directory}`
63-
})
64-
}
65-
6638
const createResourceDefinition1 = () => {
6739
return new ResourceDefinition({
6840
name: 'homebrew_installation',
@@ -71,7 +43,8 @@ describe('Config semantic analyzer tests', () => {
7143
name: 'directory',
7244
type: ResourceParameterType.STRING,
7345
})
74-
}))
46+
})),
47+
pluginName: 'homebrew',
7548
})
7649
}
7750

@@ -104,36 +77,4 @@ describe('Config semantic analyzer tests', () => {
10477

10578
expect(() => ConfigSemanticAnalyzer.validateResourceConfigs([resource], new Map([[definition.name, definition] as const]))).to.throw();
10679
})
107-
108-
it('parses and replace resource references', () => {
109-
const resource1 = createResource1();
110-
const resource2 = createResource5();
111-
112-
expect(() => ConfigSemanticAnalyzer.parseResourceDependencies([resource1, resource2])).to.not.throw()
113-
expect(resource1).to.deep.eq(createResource1())
114-
expect(resource2.parameters['directory']).to.eq(resource1.parameters.directory);
115-
})
116-
117-
it('validates invalid resources', () => {
118-
const resource1 = createResource1();
119-
const resource2 = createResource6();
120-
121-
expect(() => ConfigSemanticAnalyzer.parseResourceDependencies([resource1, resource2])).to.throw()
122-
})
123-
124-
it('validates invalid parameters', () => {
125-
const resource1 = createResource1();
126-
const resource2 = createResource7();
127-
128-
expect(() => ConfigSemanticAnalyzer.parseResourceDependencies([resource1, resource2])).to.throw()
129-
})
130-
131-
it('handles multiple resource references', () => {
132-
const resource1 = createResource1();
133-
const resource2 = createResource8();
134-
135-
expect(() => ConfigSemanticAnalyzer.parseResourceDependencies([resource1, resource2])).to.not.throw()
136-
expect(resource1).to.deep.eq(createResource1())
137-
expect(resource2.parameters['directory']).to.eq(`${resource1.parameters.directory} and ${resource1.parameters.directory}`);
138-
})
13980
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ResourceDefinitions } from '../../entities/resource-definition';
2+
import { validateResourceParameterType } from '../../utils/validator';
3+
import { ConfigBlockType } from '../language-definition';
4+
import { ParsedProject } from '../parser/entities';
5+
import { ResourceConfig } from '../parser/entities/resource';
6+
7+
export const ConfigSemanticAnalyzer = {
8+
9+
validate(project: ParsedProject, resourceDefinitions: ResourceDefinitions): void {
10+
const resourceConfigs = project
11+
.coreModule
12+
.configBlocks
13+
.filter((u) => u.configType === ConfigBlockType.RESOURCE) as ResourceConfig[];
14+
15+
this.validateResourceConfigs(resourceConfigs, resourceDefinitions);
16+
},
17+
18+
// TODO: Move this logic to ResourceConfig entity when plugin initialization is moved to be the first step
19+
validateResourceConfigs(configs: ResourceConfig[], resourceDefinitions: ResourceDefinitions) {
20+
for (const configBlock of configs) {
21+
const { parameters, type } = configBlock;
22+
const definition = resourceDefinitions.get(type);
23+
if (!definition) {
24+
throw new Error(`Invalid resource type specified ${type}. Type is not found in any plugins`);
25+
}
26+
27+
const { parameters: parameterDefinitions, pluginName } = definition;
28+
for (const [key, value] of Object.entries(parameters)) {
29+
30+
if (!parameterDefinitions.has(key)) {
31+
throw new Error(`Invalid resource parameter: ${key}. Resource: ${type}`)
32+
}
33+
34+
if (!validateResourceParameterType(value, parameterDefinitions.get(key)!.type)) {
35+
throw new Error(`Invalid parameter type: ${key}:${value} is not of type ${parameterDefinitions.get(key)!.type}`)
36+
}
37+
38+
const parameter = parameterDefinitions.get(key)!;
39+
// validate value here
40+
if (parameter.allowedValues && !(parameter.allowedValues as Array<unknown>).includes(value)) {
41+
throw new Error(`Invalid resource config ${type}. Allowed values are ${parameter.allowedValues} but ${value} was provided`)
42+
}
43+
}
44+
45+
configBlock.pluginName = pluginName;
46+
}
47+
},
48+
49+
};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { ResourceConfig } from '../parser/entities/resource';
2+
import { ResourceNode } from './entities/resource-node';
3+
4+
export const DependencyBuilder = {
5+
6+
/**
7+
* @param configs resource configs
8+
* @return a dependency graph in the form of an adjacency list
9+
*/
10+
buildDependencyGraph(configs: ResourceConfig[]): ResourceNode[] {
11+
const resourceReferenceRegex = /\${([\w.]+)}/g
12+
13+
// TODO: Support named resources in the future
14+
const nodeMap = new Map(configs.map((resource) =>
15+
[resource.id, { dependencies: [], id: resource.id, resource } as ResourceNode]
16+
));
17+
18+
for (const node of nodeMap.values()) {
19+
const referenceParameters = Object.entries(node.resource.parameters)
20+
.map(([name, value]) => [name, String(value), String(value).matchAll(resourceReferenceRegex)] as const)
21+
.filter(([, _, match]) => match)
22+
.flatMap(([name, _, matches]) =>
23+
[...matches].map(match => [name, match[1]] as const)
24+
);
25+
26+
for (const [name, match] of referenceParameters) {
27+
const parts = match.split('.');
28+
if (parts.length < 2) {
29+
throw new Error(`Only resource parameter references are allowed. ${match}`);
30+
}
31+
32+
if (!nodeMap.has(parts[0])) {
33+
throw new Error(`Unable to find resource being referenced. ${match}`);
34+
}
35+
36+
// TODO: Support named resources in the future
37+
const referencedResource = nodeMap.get(parts[0])!;
38+
const referencedParameter = referencedResource.resource.parameters[parts[1]];
39+
40+
if (!referencedParameter) {
41+
throw new Error(`Un-able to find parameter being referenced. ${match}`);
42+
}
43+
44+
// TODO: Add recursive check for parameters of type parameter
45+
46+
node.dependencies.push(referencedResource);
47+
48+
// Substitute with actual value
49+
node.resource.parameters[name] = String(node.resource.parameters[name]).replace(`\${${match}}`, String(referencedParameter));
50+
}
51+
}
52+
53+
return [...nodeMap.values()];
54+
},
55+
56+
/**
57+
* Generate a list in the order that resources should be applied. Uses Kahn's algorithm (topological sort).
58+
* @param nodes resource nodes
59+
* @returns a prioritized list
60+
*/
61+
generateDependencyList(nodes: ResourceNode[]): ResourceConfig[] {
62+
63+
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
64+
// In degree represents the number of incoming dependencies
65+
const inDegreeMap = initializeInDegreeMap();
66+
const queue: ResourceNode[] = [];
67+
const result: ResourceConfig[] = [];
68+
69+
do {
70+
const nonDependentNodeNames = findNonDependentNodes(inDegreeMap);
71+
72+
for (const name of nonDependentNodeNames) {
73+
queue.push(nodeMap.get(name)!);
74+
inDegreeMap.delete(name);
75+
}
76+
77+
const removedNode = queue.shift();
78+
if (!removedNode) {
79+
throw new Error('Cycle detected. Unable to find a node with in-degree 0');
80+
}
81+
82+
decrementInDegreeMap(removedNode, inDegreeMap);
83+
result.push(removedNode.resource);
84+
} while (queue.length > 0);
85+
86+
if (result.length !== nodes.length) {
87+
throw new Error('Cyclic dependency found');
88+
}
89+
90+
return result;
91+
92+
function initializeInDegreeMap() {
93+
const inDegreeMap = new Map(nodes.map((node) => [node.id, 0]));
94+
for (const node of nodes) {
95+
for (const dependentNode of node.dependencies) {
96+
const value = inDegreeMap.get(dependentNode.id)!;
97+
inDegreeMap.set(dependentNode.id, value + 1);
98+
}
99+
}
100+
101+
return inDegreeMap;
102+
}
103+
104+
function findNonDependentNodes(map: Map<string, number>): string[] {
105+
const result = [];
106+
for (const [type, num] of map.entries()) {
107+
if (num === 0) {
108+
result.push(type);
109+
}
110+
}
111+
112+
return result;
113+
}
114+
115+
function decrementInDegreeMap(removedNode: ResourceNode, map: Map<string, number>) {
116+
for (const node of removedNode.dependencies) {
117+
const value = map.get(node.id)!;
118+
map.set(node.id, value - 1);
119+
}
120+
}
121+
}
122+
123+
}

0 commit comments

Comments
 (0)