Infrastructure-as-Code support
June 24, 2026 · View on GitHub
OpenLore models a codebase as a graph of nodes (functions/resources) and edges (calls/references). Infrastructure-as-Code maps cleanly onto that same graph: IaC is dependency-graph-shaped, and OpenLore already serves dependency queries.
Because IaC is projected onto the existing graph primitives
(FunctionNode / CallEdge / ClassNode), every graph tool works on
infrastructure with no changes: orient, search_code, get_subgraph,
analyze_impact, trace_execution_path, the SCIP export, and the federation
manifest.
The mapping
| IaC concept | OpenLore graph primitive |
|---|---|
| A resource / manifest object / play / stack resource | a node (FunctionNode) |
A reference (interpolation, Ref, selector, notify, depends_on) | an edge (CallEdge), dependent → dependency |
| A module / chart / role / stack | a grouping (ClassNode) |
| A provider / registry module / remote chart / base image | an external node (isExternal) |
Edge direction is dependent → dependency. So for any resource node:
- depth-1 callers = "who depends on this?" → blast radius (
analyze_impact) - fanOut = "what does this resource need?"
FunctionNode.language carries the ecosystem tag (Terraform, Kubernetes,
Helm, CloudFormation, Ansible, Pulumi, CDK, CDKTF, Dockerfile,
Docker Compose, GitHub Actions, Bicep); className carries the resource
type, so clustering and architecture overviews group by type.
What is extracted, per ecosystem
This is static analysis only. OpenLore never runs terraform plan, renders
Helm, executes Ansible, or calls a cloud API. No external CLI is required.
Terraform / HCL (*.tf, *.tfvars, *.tf.json)
- Nodes:
resource,data,module,variable,output,locals(each local),provider(external). - Edges: interpolation and bare references (
type.name.attr,var.x,local.y,module.m.out,data.t.n.attr) and explicitdepends_on. References to custom resource types without an underscore resolve too — candidates that match no declared resource are dropped, never invented. - Modules: local
source = "./…"links the module to the resources declared under that directory; registry/git sources become external module nodes. - JSON variant:
*.tf.jsonis parsed structurally (the same blocks as JSON objects;${…}strings carry references). - Notes: HCL is parsed with a tolerant, hand-rolled scanner (no native dependency).
count/for_eachproduce a single node for the block (noted in the signature).
Kubernetes (*.yaml/*.yml manifests; multi-doc supported)
- Nodes: one per document,
address = <kind>/<name>(namespaced as<ns>/<kind>/<name>). - Edges:
Servicelabel selectors → matching workloads,configMapKeyRef/secretKeyRef/envFrom, volume refs,StatefulSet.serviceName→Service,ownerReferences,Ingress→Service,serviceAccountName. - CRDs: unknown kinds still become nodes (typed by the CRD kind), just with fewer typed edges.
Helm (a directory containing Chart.yaml)
- Nodes: the chart (grouping), named templates (
define), referencedvalues.yamlkeys, and best-effort template-defined resources (via a tolerant{{ … }}masking pre-pass — templates are never executed). - Edges: chart → subchart dependencies (external unless vendored under
charts/), template → named-template (include/template→define), and template → values (.Values.xresolved to the longest matchingvalues.yamlkey).
CloudFormation / SAM (*.yaml/*.yml/*.json)
- Nodes:
Resources.<LogicalId>(typed byType),Parameters,Outputs,Mappings,Conditions. - Edges:
Ref,Fn::GetAtt,Fn::Sub${…},DependsOn,Fn::ImportValue(cross-stack → external), nested stacks (AWS::CloudFormation::StackTemplateURL→ external). - Notes: YAML short-form intrinsics (
!Ref,!GetAtt,!Sub, …) parse via a CFN-aware tag set. Pseudo-parameters (AWS::Region, …) are ignored.
Ansible (playbooks, roles/<name>/{tasks,handlers,defaults,vars,meta}/main.yml)
- Nodes: plays, named tasks, handlers, roles, role vars/defaults.
- Edges:
notify(task → handler),include_tasks/import_tasks/include_role/import_role/import_playbook,roles:(play → role), rolemetadependencies (role → role). - Notes: Jinja2
{{ }}is tolerated. A templated include target backed by a staticloop/with_itemslist resolves to each literal item; fully dynamic targets are dropped.
Pulumi (TS/JS/Python/Go programs)
- A framework detector over existing source — no new grammar. Recognizes provider SDK resource constructions (
@pulumi/aws,/gcp,/azure-native,/kubernetes; Go:github.com/pulumi/pulumi-*). - Nodes: each detected resource (
address = <Service>:<name>,language = Pulumi). - Edges: when one resource's args reference another resource's variable.
- The normal call graph for those files is unchanged.
AWS CDK & CDKTF (TS/JS/Python/Go programs)
- Framework detectors like Pulumi. The distinguishing shape is that a construct's first argument is the scope and the second is the logical id:
new s3.Bucket(this, "MyBucket", { … })(CDK),new S3Bucket(scope, "logs", { … })(CDKTF). - Imports: CDK =
aws-cdk-lib/@aws-cdk/*/aws_cdk(Python) /aws/aws-cdk-go(Go); CDKTF =cdktf/@cdktf/*/terraform-cdk-go(Go). Go ids wrapped injsii.String("…")are unwrapped. - Nodes: each construct (
address = <Service>:<id>,language = CDKorCDKTF). - Edges: when one construct's args reference another construct's variable.
- Static detection only — OpenLore never runs
cdk synth/cdktf synth.
Docker (Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile; docker-compose*.y?ml, compose*.y?ml)
- Nodes: one per Dockerfile build stage (
FROM … AS x, orstage<index>when anonymous); one per compose service; base/registry images are external nodes (node:20,postgres:16), deduped across all files. - Dockerfile edges:
FROM→ an earlier same-file stage (when the base names one, case-insensitively) or the external base image;COPY/ADD --from=→ a stage (by name or build index) or an external image.FROM scratchproduces no edge; a base parameterized by a build arg with a known default (ARG NODE_VERSION=20…FROM node:${NODE_VERSION}) resolves to the default; a fully dynamic base (a${VAR}/$VARwith no inline or ARG default) produces no edge. - Compose edges:
depends_onandlinks→ service→service;build:→ the resolved Dockerfile stage (the final stage, ortarget:when given) — a cross-file edge;image:→ external image (only when the service has nobuild:, since withbuild:the image is the output tag, not a dependency). - The high-value chain: compose service → Dockerfile stage → base image, so a single
analyze_impacton a base image surfaces every stage and service that would rebuild — across files, deterministically, no LLM. - Notes: Dockerfiles and compose files are parsed together (they cross-reference). One extractor, both ecosystems. Static only — no
docker build, no environment-based interpolation, no registry access — but inline${VAR:-default}defaults are resolved statically (see below). - Robustness (real-world syntax): the Dockerfile scanner joins
\line continuations, skips heredoc bodies (RUN <<EOF … EOF— aFROMinside a script is never a stage), ignores whole-line and trailing inline comments, treats stage names case-insensitively (BuildKit semantics), and handles CRLF,--platform=…, lowercasefrom … as …, digest pins, and numericCOPY --from=0. Variable references resolve to a known default — both inline (image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:3.0.0},FROM ${BASE:-node:20},dockerfile: ${DF:-Dockerfile}) and from a globalARG NAME=defaultdeclared before the firstFROM(FROM node:${NODE_VERSION}); a${VAR}/$VARwith no inline or ARG default stays edge-less. The compose parser expands YAML merge keys (x-*: &anchor/<<: *anchor, the Airflow-style extension pattern) and ignores recoverable-but-malformed YAML rather than minting a garbage node.
GitHub Actions (.github/workflows/*.yml, action.yml/action.yaml)
The CI/CD layer — the dependency graph nearly every repository on GitHub has. Modeled as dependent → dependency, like the rest of IaC, so the same tools answer "which jobs break if I change this?".
- Nodes: one workflow handle per workflow file (
<path>::workflow, signature carries theon:triggers), one job perjobs.<id>(<path>::job.<id>), one action peraction.yml/action.yaml(<path>::action, typed byruns.using—compositevsaction), and external action nodes for marketplace/remote refs (actions/checkout@v4), deduped by reference so one action used by ten jobs is one node with fan-in 10. - Edges:
needs:→ job→job (depends_on); a step'suses:→ job→action (references); a job-leveluses:(reusable workflow) → job→target workflow; a composite action'sruns.steps[].uses:→ action→action. So oneanalyze_impacton a shared composite action lists every job in every workflow that would break if it changed, and the reverse — a job's fanOut — is "everything this job pulls in". uses:resolution: a./-prefixed ref is local (resolved relative to the repo root) — a.yml/.yamltarget is a reusable workflow, anything else is a local action directory; every other ref is external. Dynamic refs (uses: ${{ matrix.action }}, a partially-templatedorg/action@${{ ver }}) and unresolvable local refs emit no edge, never a wrong one. Static parse only — no${{ }}evaluation, no matrix expansion.- Real-world YAML robustness:
${{ … }}expressions are masked before parsing (GitHub tolerates them anywhere, but strict YAML rejects them inside a flow mapping likewith: { x: ${{ y }} }, and the error would otherwise drop later jobs); YAML merge keys (<<: *anchor, shared job config) are expanded so an anchored job inherits itssteps/needsedges; SHA pins with trailing comments,docker://actions, and.yml/.yaml/CRLF variants are all handled.
Azure Bicep (*.bicep)
- Nodes: every
resource(typed byMicrosoft.Foo/bar, the@apiVersionstripped),param,var,output, andmodule. Anexistingresource is kinddata; a nested childresourcedeclared inside another resource's body is its own node; a[for … : { … }]loop is a single node (noted in the signature). The node name is the bare symbol (stg), even though the resolution address is file-scoped. - Edges:
parent:→ child → parent;dependsOn: [ … ]→ dependent → each listed symbol; every other bare symbol used in a value → declaring symbol → referenced symbol. References are caught inside${…}string interpolations, as the base of a.propertyaccess (stg.id→stg), on both sides of the::nested-resource accessor (vnet::subnet→vnetandsubnet), and as the source of a spread ({ ...commonTags }→commonTags). A conditionalresource x '…' = if (cond) { … }keeps the condition's symbols as dependencies and still attributes nested children. Property keys, function names (resourceGroup()), and string-literal text are not references. - Modules: a local
module './net.bicep'links (cross-file) to every resource declared in the target file and groups them as aClassNode; a registry/template-spec module (br/…,ts/…) is an external node. - File-scoped resolution: Bicep resolves bare identifiers against a flat per-file symbol table (no
var./type.nameprefixes), so the same name (location,vnet) recurs across files. Addresses are scoped by file (<file>::<symbol>) and references resolve within the declaring file only — two files each declaringparam locationnever cross-link. The single cross-file edge is the local-modulelink above. - Notes: parsed with a tolerant, hand-rolled scanner (no Bicep compiler, no native dependency). Static only — no
bicep build, no ARM emit, no Azure/registry access. A multi-line ternary value (= cond\n ? a\n : b) resolves only the first line's symbols; the others are dropped rather than guessed.
Discovery & disambiguation
.tf/.tfvars/.tf.json map to Terraform by extension; .bicep maps to Bicep
by extension (both via detectLanguage). .yaml/.yml/.json are ambiguous, so
they route through a small pure function, classifyYaml(path, content):
apiVersion:+kind:→ KubernetesAWSTemplateFormatVersion/Transform: AWS::Serverless/Resources:withType: AWS::…→ CloudFormationChart.yaml, or a{{ … }}template undertemplates/, or any file under a chart directory → Helm- top-level
hosts:/playbook list, or a file underroles/*/{tasks,handlers,…}/→ Ansible - a
.github/workflows/*.y?mlwithon:+jobs:, or anaction.y?mlwithruns:→ GitHub Actions - a
docker-compose*.y?ml/compose*.y?mlfilename with a top-levelservices:key → Docker Compose - otherwise →
null(left as generic config — app config and other CI systems are never misclassified as IaC)
Dockerfiles have no extension to switch on, so they are recognized by name in the
analyze-time resolution layer (not detectLanguage), keeping the incremental
watcher's path unchanged — consistent with how all IaC YAML is resolved.
Limits & conservatism
- A reference becomes an edge only when the target is unambiguous within the project. Computed names,
for_eachkeys, fully-templated values, and dynamic includes produce no edge (markedTODO(spec-07-followup)), never a wrong one. - Declared resources are never dropped — only unresolved edges are.
- Re-analyzing an unchanged IaC tree yields an identical graph (stable ids, sorted edges).
Out of scope (future specs)
ARM JSON, Kustomize, Crossplane, Jsonnet/CUE, Nix, Bazel/Starlark, Packer,
Vagrant, and non-GitHub CI systems (GitLab CI, CircleCI, Azure Pipelines). For
GitHub Actions specifically: ${{ matrix }} fan-out as distinct nodes,
step-level granularity (the job is the unit), and remote reusable-workflow
contents (an external ref is a node, not fetched). Also deferred for Docker
specifically: compose volumes/networks as first-class nodes, cross-file
compose extends, and incremental-watch of container files (matches all IaC
YAML today). Also deferred for Bicep specifically: .bicepparam parameter
files, import/user-defined functions and types, and cross-file module
output type-resolution (the module links to the target file's resources, not
to specific output symbols).