1 import Base 2 import ../lib/effs/io.bend as X 3 import ../lib/vstr.bend as S 4 import ./project.bend as P 5 import bend-net-json@0.3.0.0/json.bend as Json 6 7 # Only ARM/DevOps read commands with narrow JMESPath projections appear here. 8 # Disable Azure CLI command logs: even metadata must not create files outside the project. 9 def parsed(m: Maybe<&2, Json.Val>, label: String) -> IO(Json.Val): 10 match m: 11 case None{}: IO.die(Json.Val, 1, "snapshot: " ++ label ++ " returned invalid JSON") 12 case Some{value}: IO.pure(Json.Val, value) 13 14 def result_json(result: Result<&1, &1, U32 & String, String>, label: String) -> IO(Json.Val): 15 match result: 16 case Fail{_}: IO.die(Json.Val, 1, "snapshot: " ++ label ++ " read failed") 17 case Done{raw}: parsed(Json.parse(raw), label) 18 19 def read_json(label: String, command: String) -> IO(Json.Val): 20 do IO: 21 result : Result<&1, &1, U32 & String, String> <- X.Proc.run("AZURE_LOGGING_ENABLE_LOG_FILE=false " ++ command ++ " 2>/dev/null") 22 result_json(result, label) 23 24 def json_or_null(m: Maybe<&2, Json.Val>) -> Json.Val: 25 match m: 26 case None{}: Json.Null{} 27 case Some{v}: v 28 29 def at(v: Json.Val, k: String) -> Json.Val: 30 json_or_null(Json.get(v, k)) 31 32 def string(v: Json.Val) -> String: 33 match v: 34 case Json.Str{s}: s 35 case Json.Num{s}: s 36 case _: "" 37 38 def array(v: Json.Val) -> List<&2, Json.Val>: 39 match v: 40 case Json.Arr{xs}: xs 41 case _: [] 42 43 def lit(s: String) -> String: 44 "\"" ++ S.esc(s) ++ "\"" 45 46 def quoted(xs: List<&2, String>) -> List<&2, String>: 47 match xs: 48 case []: [] 49 case x <> rest: lit(x) <> quoted(rest) 50 51 def strings(xs: List<&2, Json.Val>) -> List<&2, String>: 52 match xs: 53 case []: [] 54 case x <> rest: string(x) <> strings(rest) 55 56 def str_list(v: Json.Val) -> String: 57 "[" ++ String.join(quoted(strings(array(v))), ", ") ++ "]" 58 59 def resource(+v: Json.Val) -> String: 60 "A.Resource{" ++ lit(string(at(v, "name"))) ++ ", " ++ lit(string(at(v, "type"))) ++ ", " ++ lit(string(at(v, "resourceGroup"))) ++ ", " ++ lit(string(at(v, "location"))) ++ "}" 61 62 def project(v: Json.Val) -> String: 63 "A.Project{" ++ lit(string(at(v, "name"))) ++ "}" 64 65 def repository(v: Json.Val) -> String: 66 "A.Repository{" ++ lit(string(at(v, "name"))) ++ "}" 67 68 def pipeline(+v: Json.Val) -> String: 69 "A.Pipeline{" ++ lit(string(at(v, "name"))) ++ ", " ++ lit(string(at(v, "repository"))) ++ ", " ++ lit(string(at(v, "yaml"))) ++ "}" 70 71 def connection(+v: Json.Val) -> String: 72 "A.ServiceConnection{" ++ lit(string(at(v, "name"))) ++ ", " ++ lit(string(at(v, "type"))) ++ ", " ++ lit(string(at(v, "scheme"))) ++ "}" 73 74 def resource_items(xs: List<&2, Json.Val>) -> List<&2, String>: 75 match xs: 76 case []: [] 77 case x <> rest: resource(x) <> resource_items(rest) 78 79 def group_names(xs: List<&2, Json.Val>) -> List<&2, String>: 80 match xs: 81 case []: [] 82 case x <> rest: string(at(x, "name")) <> group_names(rest) 83 84 def project_items(xs: List<&2, Json.Val>) -> List<&2, String>: 85 match xs: 86 case []: [] 87 case x <> rest: project(x) <> project_items(rest) 88 89 def repository_items(xs: List<&2, Json.Val>) -> List<&2, String>: 90 match xs: 91 case []: [] 92 case x <> rest: repository(x) <> repository_items(rest) 93 94 def pipeline_items(xs: List<&2, Json.Val>) -> List<&2, String>: 95 match xs: 96 case []: [] 97 case x <> rest: pipeline(x) <> pipeline_items(rest) 98 99 def connection_items(xs: List<&2, Json.Val>) -> List<&2, String>: 100 match xs: 101 case []: [] 102 case x <> rest: connection(x) <> connection_items(rest) 103 104 def permission(scope: String, +v: Json.Val) -> String: 105 "A.Permission{" ++ lit(scope) ++ ", " ++ str_list(at(v, "actions")) ++ ", " ++ str_list(at(v, "notActions")) ++ "}" 106 107 def permission_items(+scope: String, xs: List<&2, Json.Val>) -> List<&2, String>: 108 match xs: 109 case []: [] 110 case x <> rest: permission(scope, x) <> permission_items(scope, rest) 111 112 type Rows is Data: 113 Rows{items: List<&2, String>, count: Nat} 114 115 def row_items(r: Rows) -> List<&2, String>: 116 match r: 117 case Rows{items, _}: items 118 119 def row_count(r: Rows) -> Nat: 120 match r: 121 case Rows{_, count}: count 122 123 def add_rows(a: Rows, b: Rows) -> Rows: 124 match a b: 125 case Rows{as, ac} Rows{bs, bc}: 126 Rows{List.append(&2, String, as, bs), Nat.add(ac, bc)} 127 128 # Identifiers come from Azure or a project's target fact. Reject shell metacharacters. 129 def safe_chars(cs: List<&2, Char>) -> Bool: 130 match cs: 131 case []: True{} 132 case +c <> rest: 133 (Char.is_lower(c) || Char.is_upper(c) || Char.is_digit(c) || 134 Char.is_eq(c, '-') || Char.is_eq(c, '_') || Char.is_eq(c, '.') || 135 Char.is_eq(c, ':') || Char.is_eq(c, '/')) && safe_chars(rest) 136 137 def safe(+s: String) -> Bool: 138 Bool.not(String.is_empty(s)) && safe_chars(String.to_list(s)) 139 140 def require_safe_bool(ok: Bool, label: String) -> IO(Unit): 141 match ok: 142 case True{}: IO.pure(Unit, Unit{}) 143 case False{}: IO.die(Unit, 1, "snapshot: unsafe or empty " ++ label) 144 145 def require_safe(label: String, s: String) -> IO(Unit): 146 require_safe_bool(safe(s), label) 147 148 def permissions(+subscription: String, groups: List<&2, Json.Val>) -> IO(Rows): 149 match groups: 150 case []: IO.pure(Rows, Rows{[], 0n}) 151 case group <> rest: 152 do IO: 153 +name : String = string(at(group, "name")) 154 require_safe("resource group", name) 155 +url : String = "https://management.azure.com/subscriptions/" ++ subscription ++ "/resourceGroups/" ++ name ++ "/providers/Microsoft.Authorization/permissions?api-version=2015-07-01" 156 +data : Json.Val <- read_json("ARM permissions", "az rest --method get --url '" ++ url ++ "' --query 'value[].{actions:actions,notActions:notActions}' -o json") 157 more : Rows <- permissions(subscription, rest) 158 return add_rows(Rows{permission_items(name, array(data)), List.length(&2, Json.Val, array(data))}, more) 159 160 def pipeline_details(+org: String, +project_name: String, xs: List<&2, Json.Val>) -> IO(Rows): 161 match xs: 162 case []: IO.pure(Rows, Rows{[], 0n}) 163 case x <> rest: 164 do IO: 165 +id : String = string(at(x, "id")) 166 require_safe("pipeline id", id) 167 detail : Json.Val <- read_json("DevOps pipeline", "az-devops pipelines show --organization '" ++ org ++ "' --project '" ++ project_name ++ "' --id " ++ id ++ " --query '{name:name,repository:repository.name || configuration.repository.name,yaml:process.yamlFilename || configuration.path}' -o json") 168 more : Rows <- pipeline_details(org, project_name, rest) 169 return add_rows(Rows{[pipeline(detail)], 1n}, more) 170 171 def facts_text(+vlib: String, +org: String, +project_name: String, +resources: List<&2, String>, +groups: List<&2, String>, +permissions: List<&2, String>, +projects: List<&2, String>, +repos: List<&2, String>, +pipelines: List<&2, String>, +connections: List<&2, String>, +arm_identity: String, +devops_identity: String) -> String: 172 "import Base\nimport " ++ vlib ++ "/azure.bend as A\n\n# Read-only Azure and Azure DevOps facts. Generated by v system snapshot; do not edit.\n" ++ 173 "def organization() -> String:\n " ++ lit(org) ++ "\n\ndef devops_project() -> String:\n " ++ lit(project_name) ++ "\n\n" ++ 174 "def resources() -> List<&2, A.Resource>:\n [" ++ String.join(resources, ", ") ++ "]\n\n" ++ 175 "def resource_groups() -> List<&2, String>:\n [" ++ String.join(groups, ", ") ++ "]\n\n" ++ 176 "def permissions() -> List<&2, A.Permission>:\n [" ++ String.join(permissions, ", ") ++ "]\n\n" ++ 177 "def projects() -> List<&2, A.Project>:\n [" ++ String.join(projects, ", ") ++ "]\n\n" ++ 178 "def repositories() -> List<&2, A.Repository>:\n [" ++ String.join(repos, ", ") ++ "]\n\n" ++ 179 "def pipelines() -> List<&2, A.Pipeline>:\n [" ++ String.join(pipelines, ", ") ++ "]\n\n" ++ 180 "def service_connections() -> List<&2, A.ServiceConnection>:\n [" ++ String.join(connections, ", ") ++ "]\n\n" ++ 181 "def identities() -> List<&2, A.Identity>:\n [A.Identity{\"azure\", " ++ lit(arm_identity) ++ "}, A.Identity{\"devops\", " ++ lit(devops_identity) ++ "}]\n" 182 183 def snapshot(+f: P.Facts) -> IO(Unit): 184 do IO: 185 +org : String = P.azure_organization(f) 186 +project_name : String = P.azure_project(f) 187 require_safe("DevOps organization", org) 188 require_safe("DevOps project", project_name) 189 +account : Json.Val <- read_json("Azure account", "az account show --query '{name:user.name,id:id}' -o json") 190 +subscription : String = string(at(account, "id")) 191 require_safe("subscription", subscription) 192 +resources : Json.Val <- read_json("ARM resources", "az resource list --query '[].{name:name,type:type,resourceGroup:resourceGroup,location:location}' -o json") 193 +groups : Json.Val <- read_json("ARM resource groups", "az group list --query '[].{name:name}' -o json") 194 +grants : Rows <- permissions(subscription, array(groups)) 195 +projects : Json.Val <- read_json("DevOps projects", "az-devops devops project list --organization '" ++ org ++ "' --query 'value[].{name:name}' -o json") 196 +repos : Json.Val <- read_json("DevOps repositories", "az-devops repos list --organization '" ++ org ++ "' --project '" ++ project_name ++ "' --query '[].{name:name}' -o json") 197 pipeline_list : Json.Val <- read_json("DevOps pipelines", "az-devops pipelines list --organization '" ++ org ++ "' --project '" ++ project_name ++ "' --query '[].{id:id,name:name}' -o json") 198 +pipeline_rows : Rows <- pipeline_details(org, project_name, array(pipeline_list)) 199 +connections : Json.Val <- read_json("DevOps service connections", "az-devops devops service-endpoint list --organization '" ++ org ++ "' --project '" ++ project_name ++ "' --query '[].{name:name,type:type,scheme:authorization.scheme}' -o json") 200 profile : Json.Val <- read_json("DevOps profile", "cat ~/.config/azure-plugin/profiles.json") 201 +devops_identity : String = string(at(at(profile, "devops"), "username")) 202 X.write_file("azure.bend", facts_text(P.vlib_of(f), org, project_name, resource_items(array(resources)), quoted(group_names(array(groups))), row_items(grants), project_items(array(projects)), repository_items(array(repos)), row_items(pipeline_rows), connection_items(array(connections)), string(at(account, "name")), devops_identity)) 203 IO.print("snapshot: " ++ Nat.show(List.length(&2, Json.Val, array(resources))) ++ " Azure resources, " ++ Nat.show(row_count(grants)) ++ " permission entries, " ++ Nat.show(List.length(&2, Json.Val, array(groups))) ++ " resource groups; " ++ Nat.show(List.length(&2, Json.Val, array(projects))) ++ " DevOps projects, " ++ Nat.show(List.length(&2, Json.Val, array(repos))) ++ " repos, " ++ Nat.show(row_count(pipeline_rows)) ++ " pipelines, " ++ Nat.show(List.length(&2, Json.Val, array(connections))) ++ " service connections")