Azure Service Compatibility Advisor
Mix.install([
# {:datalog, path: Path.join(__DIR__, "..")},
{:datalog, github: "chgeuer/ex_datalog"},
{:kino, "~> 0.18"}
])
Setup
π Other Examples: Cocktail Advisor | SecPAL DSL Tutorial
Motivation: As an Azure field engineer, I constantly check whether a certain technology permutation is supported β βCan I integrate App Service into a VNET with Private Link to talk to Blob Storage in West Europe, and is it GA?β This search is labor-intensive.
Worse, when a permutation isnβt supported during a customer conversation, and then months later a product group ships a preview, the feedback loop to the original customer is pure luck β you have to (a) notice the announcement and (b) remember which customer needed it.
This notebook models that problem using Datalog β the same engine that powers the cocktail advisor. Swap cocktails for Azure architectures, ingredients for service capabilities, and you get a βWhat can I build?β / βWhatβs missing?β / βWhoβs unblocked now?β engine.
alias Datalog.{Database, Clause, Literal, Variable, Constant, Resolution, Substitution}
The Knowledge Base
We model Azure as a set of facts and rules.
Facts are ground truths β things we know from docs, release notes, or announcements:
| Predicate | Meaning |
|---|---|
service(S) |
S is an Azure service |
region(R) |
R is an Azure region |
feature(F) |
F is a networking/integration feature |
available_in(S, R) |
Service S is deployed in region R |
supports(S, F, Status) |
Service S supports feature F at maturity Status (ga/preview) |
compatible(S1, S2, F) |
Services S1 and S2 can be connected via feature F |
customer_needs(C, S1, S2, F, R) |
Customer C needs S1βS2 via F in region R |
Rules derive new knowledge from existing facts:
| Rule | Meaning |
|---|---|
can_use(S1, S2, F, R) |
The full permutation works (both available, compatible, feature GA) |
can_use_preview(S1, S2, F, R) |
Same, but allowing preview features |
blocked(C, S1, S2, F, R) |
Customer C needs something that doesnβt work yet |
unblocked(C, S1, S2, F, R) |
Customer C was blocked, but it works now! |
# --- Variables (reused across rules) ---
s = Variable.new(:s)
s1 = Variable.new(:s1)
s2 = Variable.new(:s2)
f = Variable.new(:f)
r = Variable.new(:r)
c = Variable.new(:c)
status = Variable.new(:status)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Azure Services β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
services = ~w(app_service blob_storage sql_database cosmos_db
key_vault container_apps aks event_hubs service_bus)
service_facts =
for svc <- services do
Clause.fact(Literal.from_terms(:service, [svc]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Regions β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
regions = ~w(west_europe north_europe east_us west_us southeast_asia)
region_facts =
for reg <- regions do
Clause.fact(Literal.from_terms(:region, [reg]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Features β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
features = ~w(private_link vnet_integration service_endpoint managed_identity)
feature_facts =
for feat <- features do
Clause.fact(Literal.from_terms(:feature, [feat]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Regional availability β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
availability = [
# service regions where it's available
{"app_service", ~w(west_europe north_europe east_us west_us southeast_asia)},
{"blob_storage", ~w(west_europe north_europe east_us west_us southeast_asia)},
{"sql_database", ~w(west_europe north_europe east_us west_us southeast_asia)},
{"cosmos_db", ~w(west_europe north_europe east_us west_us)},
{"key_vault", ~w(west_europe north_europe east_us west_us southeast_asia)},
{"container_apps", ~w(west_europe east_us west_us)},
{"aks", ~w(west_europe north_europe east_us west_us southeast_asia)},
{"event_hubs", ~w(west_europe north_europe east_us west_us)},
{"service_bus", ~w(west_europe north_europe east_us west_us southeast_asia)},
]
availability_facts =
for {svc, regs} <- availability, reg <- regs do
Clause.fact(Literal.from_terms(:available_in, [svc, reg]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Feature support (service, feature, maturity) β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
support = [
{"app_service", "private_link", "ga"},
{"app_service", "vnet_integration", "ga"},
{"app_service", "managed_identity", "ga"},
{"blob_storage", "private_link", "ga"},
{"blob_storage", "service_endpoint", "ga"},
{"blob_storage", "managed_identity", "ga"},
{"sql_database", "private_link", "ga"},
{"sql_database", "service_endpoint", "ga"},
{"sql_database", "managed_identity", "ga"},
{"cosmos_db", "private_link", "ga"},
{"cosmos_db", "service_endpoint", "ga"},
{"cosmos_db", "managed_identity", "preview"},
{"key_vault", "private_link", "ga"},
{"key_vault", "service_endpoint", "ga"},
{"key_vault", "managed_identity", "ga"},
{"container_apps", "vnet_integration", "ga"},
{"container_apps", "managed_identity", "ga"},
# Container Apps Private Link is only preview
{"container_apps", "private_link", "preview"},
{"aks", "private_link", "ga"},
{"aks", "vnet_integration", "ga"},
{"aks", "managed_identity", "ga"},
{"event_hubs", "private_link", "ga"},
{"event_hubs", "service_endpoint", "ga"},
{"event_hubs", "managed_identity", "ga"},
{"service_bus", "private_link", "ga"},
{"service_bus", "service_endpoint", "ga"},
{"service_bus", "managed_identity", "ga"},
]
support_facts =
for {svc, feat, maturity} <- support do
Clause.fact(Literal.from_terms(:supports, [svc, feat, maturity]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Facts: Pairwise compatibility (which services can talk via what) β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
compatibility = [
{"app_service", "blob_storage", "private_link"},
{"app_service", "blob_storage", "service_endpoint"},
{"app_service", "sql_database", "private_link"},
{"app_service", "sql_database", "service_endpoint"},
{"app_service", "cosmos_db", "private_link"},
{"app_service", "key_vault", "private_link"},
{"app_service", "event_hubs", "private_link"},
{"app_service", "service_bus", "private_link"},
{"container_apps", "blob_storage", "private_link"},
{"container_apps", "sql_database", "private_link"},
{"container_apps", "cosmos_db", "private_link"},
{"container_apps", "key_vault", "private_link"},
{"container_apps", "event_hubs", "private_link"},
{"container_apps", "service_bus", "private_link"},
{"aks", "blob_storage", "private_link"},
{"aks", "sql_database", "private_link"},
{"aks", "cosmos_db", "private_link"},
{"aks", "key_vault", "private_link"},
{"aks", "event_hubs", "private_link"},
{"aks", "service_bus", "private_link"},
]
compatibility_facts =
for {from, to, feat} <- compatibility do
Clause.fact(Literal.from_terms(:compatible, [from, to, feat]))
end
:ok
Deriving Knowledge: Rules
This is where Datalog shines. Instead of writing imperative queries, we declare what it means for a permutation to work, and the engine figures out which ones do.
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Rule: can_use(S1, S2, F, R) β full GA permutation works β
# β β
# β can_use(S1, S2, F, R) :- β
# β compatible(S1, S2, F), β
# β available_in(S1, R), β
# β available_in(S2, R), β
# β supports(S1, F, "ga"), β
# β supports(S2, F, "ga"). β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
can_use_rule = Clause.new(
Literal.from_terms(:can_use, [s1, s2, f, r]),
[
Literal.from_terms(:compatible, [s1, s2, f]),
Literal.from_terms(:available_in, [s1, r]),
Literal.from_terms(:available_in, [s2, r]),
Literal.from_terms(:supports, [s1, f, "ga"]),
Literal.from_terms(:supports, [s2, f, "ga"]),
]
)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Rule: can_use_any β works with any maturity (incl. preview) β
# β β
# β can_use_any(S1, S2, F, R) :- β
# β compatible(S1, S2, F), β
# β available_in(S1, R), β
# β available_in(S2, R), β
# β supports(S1, F, _Status1), β
# β supports(S2, F, _Status2). β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
can_use_any_rule = Clause.new(
Literal.from_terms(:can_use_any, [s1, s2, f, r]),
[
Literal.from_terms(:compatible, [s1, s2, f]),
Literal.from_terms(:available_in, [s1, r]),
Literal.from_terms(:available_in, [s2, r]),
Literal.from_terms(:supports, [s1, f, Variable.new(:_status1)]),
Literal.from_terms(:supports, [s2, f, Variable.new(:_status2)]),
]
)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Rule: preview_only β works, but only because of preview features β
# β β
# β preview_only(S1, S2, F, R) :- β
# β can_use_any(S1, S2, F, R), β
# β not can_use(S1, S2, F, R). β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
preview_only_rule = Clause.new(
Literal.from_terms(:preview_only, [s1, s2, f, r]),
[
Literal.from_terms(:can_use_any, [s1, s2, f, r]),
Literal.negate(Literal.from_terms(:can_use, [s1, s2, f, r])),
]
)
rules = [can_use_rule, can_use_any_rule, preview_only_rule]
:ok
Assembling the Database
all_clauses =
service_facts ++
region_facts ++
feature_facts ++
availability_facts ++
support_facts ++
compatibility_facts ++
rules
db = Database.new(all_clauses)
IO.puts("Database loaded: #{Database.size(db)} clauses")
IO.puts(" Services: #{length(services)}")
IO.puts(" Regions: #{length(regions)}")
IO.puts(" Compatibility: #{length(compatibility)} pairs")
IO.puts(" Rules: #{length(rules)}")
Query 1: What GA permutations work in West Europe?
This is the bread-and-butter question: βWhat can I actually recommend to a customer in West Europe today?β
ctx = %Resolution.Context{database: db, max_depth: 100}
goal = Literal.from_terms(:can_use, [s1, s2, f, Constant.new("west_europe")])
results = Resolution.query_substitutions(goal, ctx)
results
|> Enum.map(fn subst ->
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
%{from: svc1.value, to: svc2.value, via: feat.value}
end)
|> Enum.sort_by(&{&1.from, &1.to, &1.via})
|> Kino.DataTable.new(name: "GA permutations in West Europe")
Query 2: Whatβs only available in preview?
These are the risky recommendations β works, but not production-blessed yet.
goal = Literal.from_terms(:preview_only, [s1, s2, f, r])
results = Resolution.query_substitutions(goal, ctx)
results
|> Enum.map(fn subst ->
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
%{from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Enum.sort_by(&{&1.from, &1.to})
|> Kino.DataTable.new(name: "Preview-only permutations (not yet GA)")
Query 3: Can I use App Service + Blob Storage via Private Link in West Europe?
The specific question from the journal entry.
goal = Literal.from_terms(:can_use, [
Constant.new("app_service"),
Constant.new("blob_storage"),
Constant.new("private_link"),
Constant.new("west_europe")
])
results = Resolution.query_substitutions(goal, ctx)
case results do
[_ | _] -> IO.puts("β
Yes! App Service β Blob Storage via Private Link in West Europe is GA.")
[] -> IO.puts("β No. This permutation is not available (or not GA) yet.")
end
The Customer Notification Scenario
This is the second (and arguably more valuable) scenario: tracking what customers need, and automatically detecting when a previously-blocked requirement becomes possible.
We model customer requirements as facts, then use rules and negation-as-failure to find who is blocked and β after adding new capabilities β who is newly unblocked.
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Customer requirements β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
customer_needs = [
# Contoso wants Container Apps β Blob Storage via Private Link in West Europe
# (this requires Container Apps Private Link to go GA β currently preview!)
{"contoso", "container_apps", "blob_storage", "private_link", "west_europe"},
# Fabrikam wants Container Apps β SQL Database via Private Link in East US
{"fabrikam", "container_apps", "sql_database", "private_link", "east_us"},
# Northwind wants App Service β Cosmos DB via Private Link in Southeast Asia
# (Cosmos DB not available in Southeast Asia yet!)
{"northwind", "app_service", "cosmos_db", "private_link", "southeast_asia"},
# Woodgrove wants AKS β Event Hubs via Private Link in West Europe (should work!)
{"woodgrove", "aks", "event_hubs", "private_link", "west_europe"},
]
customer_facts =
for {cust, svc1, svc2, feat, reg} <- customer_needs do
Clause.fact(Literal.from_terms(:customer_needs, [cust, svc1, svc2, feat, reg]))
end
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Rule: blocked(C, S1, S2, F, R) β customer needs it, can't do it β
# β β
# β blocked(C, S1, S2, F, R) :- β
# β customer_needs(C, S1, S2, F, R), β
# β not can_use(S1, S2, F, R). β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
blocked_rule = Clause.new(
Literal.from_terms(:blocked, [c, s1, s2, f, r]),
[
Literal.from_terms(:customer_needs, [c, s1, s2, f, r]),
Literal.negate(Literal.from_terms(:can_use, [s1, s2, f, r])),
]
)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# β Rule: satisfied β customer needs it and it works at GA β
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
satisfied_rule = Clause.new(
Literal.from_terms(:satisfied, [c, s1, s2, f, r]),
[
Literal.from_terms(:customer_needs, [c, s1, s2, f, r]),
Literal.from_terms(:can_use, [s1, s2, f, r]),
]
)
db_with_customers = Database.new(
all_clauses ++ customer_facts ++ [blocked_rule, satisfied_rule]
)
ctx2 = %Resolution.Context{database: db_with_customers, max_depth: 100}
:ok
Whoβs blocked right now?
goal = Literal.from_terms(:blocked, [c, s1, s2, f, r])
blocked_results = Resolution.query_substitutions(goal, ctx2)
blocked_results
|> Enum.map(fn subst ->
{:ok, cust} = Substitution.lookup(subst, c)
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
%{customer: cust.value, from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Kino.DataTable.new(name: "π« Blocked customers")
Whoβs satisfied right now?
goal = Literal.from_terms(:satisfied, [c, s1, s2, f, r])
satisfied_results = Resolution.query_substitutions(goal, ctx2)
satisfied_results
|> Enum.map(fn subst ->
{:ok, cust} = Substitution.lookup(subst, c)
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
%{customer: cust.value, from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Kino.DataTable.new(name: "β
Satisfied customers")
Simulating an Announcement: Container Apps Private Link goes GA!
Months later, the Container Apps team announces GA for Private Link. We add the new fact and re-evaluate β who just got unblocked?
# The announcement: Container Apps Private Link is now GA!
new_fact = Clause.fact(Literal.from_terms(:supports, ["container_apps", "private_link", "ga"]))
db_after_announcement = Database.add(db_with_customers, new_fact)
ctx3 = %Resolution.Context{database: db_after_announcement, max_depth: 100}
# Re-query blocked customers
goal = Literal.from_terms(:blocked, [c, s1, s2, f, r])
still_blocked = Resolution.query_substitutions(goal, ctx3)
still_blocked_set =
still_blocked
|> Enum.map(fn subst ->
{:ok, cust} = Substitution.lookup(subst, c)
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
{cust.value, svc1.value, svc2.value, feat.value, reg.value}
end)
|> MapSet.new()
# Compare: who was blocked before but isn't anymore?
previously_blocked_set =
blocked_results
|> Enum.map(fn subst ->
{:ok, cust} = Substitution.lookup(subst, c)
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
{cust.value, svc1.value, svc2.value, feat.value, reg.value}
end)
|> MapSet.new()
newly_unblocked = MapSet.difference(previously_blocked_set, still_blocked_set)
IO.puts("π’ Announcement: Container Apps Private Link is now GA!\n")
if MapSet.size(newly_unblocked) > 0 do
IO.puts("π Newly unblocked customers β send them an email!\n")
newly_unblocked
|> Enum.each(fn {cust, svc1, svc2, feat, reg} ->
IO.puts(" π§ #{cust}: #{svc1} β #{svc2} via #{feat} in #{reg} now works!")
end)
else
IO.puts("No customers were unblocked by this announcement.")
end
IO.puts("\nπ« Still blocked:")
still_blocked
|> Enum.each(fn subst ->
{:ok, cust} = Substitution.lookup(subst, c)
{:ok, svc1} = Substitution.lookup(subst, s1)
{:ok, svc2} = Substitution.lookup(subst, s2)
{:ok, feat} = Substitution.lookup(subst, f)
{:ok, reg} = Substitution.lookup(subst, r)
IO.puts(" β³ #{cust.value}: #{svc1.value} β #{svc2.value} via #{feat.value} in #{reg.value}")
end)
Whatβs Next?
This is a proof of concept. In a real system, youβd extend it with:
-
Temporal facts β
supports(S, F, ga, ~D[2025-03-15])to track when something became available -
Source tracking β
documented_at(S, F, "https://learn.microsoft.com/...")to link back to docs - Transitive connectivity β if A talks to B via Private Link and B talks to C via Private Link, can A reach C?
- Constraint predicates β SKU requirements, throughput limits, pricing tier prerequisites
- Feed ingestion β parse Azure updates RSS / change logs into new facts, auto-detect unblocked customers
- A Phoenix LiveView UI β browse the knowledge graph, subscribe to customer notifications