diff --git a/DESCRIPTION b/DESCRIPTION index 00d3929..f30347c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,12 +1,12 @@ Package: AzureRMR Title: Interface to 'Azure Resource Manager' -Version: 2.1.1.9000 +Version: 2.1.2 Authors@R: c( person("Hong", "Ooi", , "hongooi@microsoft.com", role = c("aut", "cre")), person("Microsoft", role="cph") ) -Description: A lightweight but powerful R interface to the 'Azure Resource Manager' REST API. The package exposes classes and methods for 'OAuth' authentication and working with subscriptions and resource groups. It also provides functionality for creating and deleting 'Azure' resources and deploying templates. While 'AzureRMR' can be used to manage any 'Azure' service, it can also be extended by other packages to provide extra functionality for specific services. -URL: https://github.com/Azure/AzureRMR +Description: A lightweight but powerful R interface to the 'Azure Resource Manager' REST API. The package exposes classes and methods for 'OAuth' authentication and working with subscriptions and resource groups. It also provides functionality for creating and deleting 'Azure' resources and deploying templates. While 'AzureRMR' can be used to manage any 'Azure' service, it can also be extended by other packages to provide extra functionality for specific services. Part of the 'AzureR' family of packages. +URL: https://github.com/Azure/AzureRMR https://github.com/Azure/AzureR BugReports: https://github.com/Azure/AzureRMR/issues License: MIT + file LICENSE VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index c8d3512..8e6adf0 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,7 @@ # Generated by roxygen2: do not edit by hand +S3method(build_template_definition,default) +S3method(build_template_parameters,default) export(AzureR_dir) export(az_resource) export(az_resource_group) @@ -8,6 +10,8 @@ export(az_role_assignment) export(az_role_definition) export(az_subscription) export(az_template) +export(build_template_definition) +export(build_template_parameters) export(call_azure_rm) export(call_azure_url) export(clean_token_directory) diff --git a/NEWS.md b/NEWS.md index 3fa4921..013f101 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,7 @@ -# AzureRMR 2.1.1.9000 +# AzureRMR 2.1.2 - Fix a bug in template deployment where null fields were not handled correctly. +- New `build_template_definition` and `build_parameters_parameters` generics to help in template deployment. These can take as inputs R lists, JSON text strings, or file connections, and can also be extended by other packages. # AzureRMR 2.1.1 diff --git a/R/az_template.R b/R/az_template.R index a051bb8..128c549 100644 --- a/R/az_template.R +++ b/R/az_template.R @@ -28,8 +28,10 @@ #' - `parameters`: The parameters for the template. This can be provided using any of the same methods as the `template` argument. #' - `wait`: Optionally, whether to wait until the deployment is complete. Defaults to FALSE, in which case the method will return immediately. #' +#' You can use the `build_template_definition` and `build_template_parameters` helper functions to construct the inputs for deploying a template. These can take as inputs R lists, JSON text strings, or file connections, and can also be extended by other packages. +#' #' @seealso -#' [az_resource_group], [az_resource], +#' [az_resource_group], [az_resource], [build_template_definition], [build_template_parameters] #' [Template overview](https://docs.microsoft.com/en-us/azure/templates/), #' [Template API reference](https://docs.microsoft.com/en-us/rest/api/resources/deployments) #' @@ -200,6 +202,8 @@ private=list( # fold template data into properties properties <- if(is.list(template)) append_json(properties, template=generate_json(template)) + else if(is_file_spec(template)) + append_json(properties, template=readLines(template)) else if(is_url(template)) append_json(properties, templateLink=generate_json(list(uri=template))) else append_json(properties, template=template) @@ -207,13 +211,15 @@ private=list( # handle case of missing or empty parameters arg # must be a _named_ list for jsonlite to turn into an object, not an array if(missing(parameters) || is_empty(parameters)) - parameters <- structure(list(), names=character(0)) + parameters <- named_list() # fold parameter data into properties properties <- if(is_empty(parameters)) append_json(properties, parameters=generate_json(parameters)) else if(is.list(parameters)) - append_json(properties, parameters=generate_json(private$make_param_list(parameters))) + append_json(properties, parameters=do.call(build_template_parameters, parameters)) + else if(is_file_spec(parameters)) + append_json(properties, parameters=readLines(parameters)) else if(is_url(parameters)) append_json(properties, parametersLink=generate_json(list(uri=parameters))) else append_json(properties, parameters=parameters) @@ -279,12 +285,6 @@ private=list( } }, - # params for templates require lists of (value=x) rather than vectors as inputs - make_param_list=function(params) - { - lapply(params, function(x) if(is.list(x)) x else list(value=x)) - }, - tpl_op=function(op="", ...) { op <- construct_path("resourcegroups", self$resource_group, @@ -293,29 +293,3 @@ private=list( } )) - -generate_json <- function(object) -{ - jsonlite::toJSON(object, pretty=TRUE, auto_unbox=TRUE, null="null", digits=22) -} - - -append_json <- function(props, ...) -{ - lst <- list(...) - lst_names <- names(lst) - if(is.null(lst_names) || any(lst_names == "")) - stop("Deployment properties must be named", call.=FALSE) - - for(i in seq_along(lst)) - { - lst_i <- lst[[i]] - if(inherits(lst_i, "connection") || (length(lst_i) == 1 && file.exists(lst_i))) - lst_i <- readLines(lst_i) - - newprop <- sprintf(', "%s": %s}', lst_names[i], paste0(lst_i, collapse="\n")) - props <- sub("\\}$", newprop, props) - } - - props -} diff --git a/R/build_tpl_json.R b/R/build_tpl_json.R new file mode 100644 index 0000000..99b3137 --- /dev/null +++ b/R/build_tpl_json.R @@ -0,0 +1,218 @@ +#' Build the JSON for a template and its parameters +#' +#' @param ... For `build_template_parameters`, named arguments giving the values of each template parameter. For `build_template_definition`, further arguments passed to class methods. +#' @param parameters For `build_template_definition`, the parameter names and types for the template. See 'Details' below. +#' @param variables Internal variables used by the template. +#' @param resources List of resources that the template should deploy. +#' @param outputs The template outputs. +#' +#' @details +#' `build_template_definition` is used to generate a template from its components. The arguments can be specified in various ways: +#' - As character strings containing unparsed JSON text. +#' - As an R list of (nested) objects representing the parsed JSON. +#' - A connection pointing to a JSON file or object. +#' - For the `parameters` argument, this can also be a character vector containing the types of each parameter. +#' +#' `build_template_parameters` is for creating the list of parameters to be passed along with the template. Its arguments should all be named, and contain either the JSON text or an R list giving the parsed JSON. +#' +#' Both of these are generics and can be extended by other packages to handle specific deployment scenarios, eg virtual machines. +#' +#' @return +#' The JSON text for the template definition and its parameters. +#' +#' @examples +#' # dummy example +#' # note that 'resources' arg should be a _list_ of resources +#' build_template_definition(resources=list(list(name="resource here"))) +#' +#' # specifying parameters as a list +#' build_template_definition(parameters=list(par1=list(type="string")), +#' resources=list(list(name="resource here"))) +#' +#' # specifying parameters as a vector +#' build_template_definition(parameters=c(par1="string"), +#' resources=list(list(name="resource here"))) +#' +#' # realistic example: storage account +#' build_template_definition( +#' parameters=c( +#' name="string", +#' location="string", +#' sku="string" +#' ), +#' variables=list( +#' id="[resourceId('Microsoft.Storage/storageAccounts', parameters('name'))]" +#' ), +#' resources=list( +#' list( +#' name="[parameters('name')]", +#' location="[parameters('location')]", +#' type="Microsoft.Storage/storageAccounts", +#' apiVersion="2018-07-01", +#' sku=list( +#' name="[parameters('sku')]" +#' ), +#' kind="Storage" +#' ) +#' ), +#' outputs=list( +#' storageId="[variables('id')]" +#' ) +#' ) +#' +#' # providing JSON text as input +#' build_template_definition( +#' parameters=c(name="string", location="string", sku="string"), +#' resources='[ +#' { +#' "name": "[parameters(\'name\')]", +#' "location": "[parameters(\'location\')]", +#' "type": "Microsoft.Storage/storageAccounts", +#' "apiVersion": "2018-07-01", +#' "sku": { +#' "name": "[parameters(\'sku\')]" +#' }, +#' "kind": "Storage" +#' } +#' ]' +#' ) +#' +#' # parameter values +#' build_template_parameters(name="mystorageacct", location="westus", sku="Standard_LRS") +#' +#' build_template_parameters( +#' param='{ +#' "name": "myname", +#' "properties": { "prop1": 42, "prop2": "hello" } +#' }' +#' ) +#' +#' param_json <- '{ +#' "name": "myname", +#' "properties": { "prop1": 42, "prop2": "hello" } +#' }' +#' build_template_parameters(param=textConnection(param_json)) +#' +#' \dontrun{ +#' # reading JSON definitions from a file +#' build_template_definition( +#' parameters=file("parameter_def.json"), +#' resources=file("resource_def.json") +#' +#' build_template_parameters(name="myres_name", complex_type=file("myres_params.json")) +#' ) +#' } +#' +#' @rdname build_template +#' @aliases build_template +#' @export +build_template_definition <- function(...) +{ + UseMethod("build_template_definition") +} + + +#' @rdname build_template +#' @export +build_template_definition.default <- function(parameters=NULL, variables=NULL, resources=NULL, outputs=NULL, ...) +{ + # special treatment for parameters arg: convert 'c(name="type")' to 'list(name=list(type="type"))' + if(is.character(parameters)) + parameters <- sapply(parameters, function(type) list(type=type), simplify=FALSE) + + parts <- lapply( + list(parameters=parameters, variables=variables, resources=resources, outputs=outputs, ...), + function(x) + { + if(inherits(x, "connection")) + { + on.exit(close(x)) + readLines(x) + } + else generate_json(if(is.null(x)) named_list() else x) + } + ) + json <- generate_json(list( + `$schema`="http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + contentVersion="1.0.0.0" + )) + for(i in seq_along(parts)) + json <- do.call(append_json, c(json, parts[i])) + + jsonlite::prettify(json) +} + + +#' @rdname build_template +#' @export +build_template_parameters <- function(...) +{ + UseMethod("build_template_parameters") +} + + +#' @rdname build_template +#' @export +build_template_parameters.default <- function(...) +{ + dots <- list(...) + + # handle no-parameter case + if(is_empty(dots)) + return("{}") + + parms <- lapply(dots, function(value) + { + # need to duplicate functionality of generate_json, one level down + if(inherits(value, "connection")) + { + on.exit(close(value)) + generate_json(list(value=jsonlite::fromJSON(readLines(value), simplifyVector=FALSE))) + } + else if(is.character(value) && jsonlite::validate(value)) + generate_json(list(value=jsonlite::fromJSON(value, simplifyVector=FALSE))) + else generate_json(list(value=value)) + }) + + jsonlite::prettify(do.call(append_json, c(list("{}"), parms))) +} + + +generate_json <- function(object) +{ + if(is.character(object) && jsonlite::validate(object)) + object + else jsonlite::toJSON(object, auto_unbox=TRUE, null="null", digits=22) +} + + +append_json <- function(props, ...) +{ + lst <- list(...) + lst_names <- names(lst) + if(is.null(lst_names) || any(lst_names == "")) + stop("Deployment properties and parameters must be named", call.=FALSE) + + for(i in seq_along(lst)) + { + lst_i <- lst[[i]] + if(inherits(lst_i, "connection")) + { + on.exit(close(lst_i)) + lst_i <- readLines(lst_i) + } + + newprop <- sprintf('"%s": %s}', lst_names[i], paste0(lst_i, collapse="\n")) + if(!grepl("^\\{[[:space:]]*\\}$", props)) + newprop <- paste(",", newprop) + props <- sub("\\}$", newprop, props) + } + + props +} + + +is_file_spec <- function(x) +{ + inherits(x, "connection") || (is.character(x) && length(x) == 1 && file.exists(x)) +} diff --git a/man/az_template.Rd b/man/az_template.Rd index 2624aec..6acb26d 100644 --- a/man/az_template.Rd +++ b/man/az_template.Rd @@ -46,6 +46,8 @@ If you also supply the following arguments to \code{new()}, a new template will \item \code{parameters}: The parameters for the template. This can be provided using any of the same methods as the \code{template} argument. \item \code{wait}: Optionally, whether to wait until the deployment is complete. Defaults to FALSE, in which case the method will return immediately. } + +You can use the \code{build_template_definition} and \code{build_template_parameters} helper functions to construct the inputs for deploying a template. These can take as inputs R lists, JSON text strings, or file connections, and can also be extended by other packages. } \examples{ @@ -69,7 +71,7 @@ tpl$delete(free_resources=TRUE) } } \seealso{ -\link{az_resource_group}, \link{az_resource}, +\link{az_resource_group}, \link{az_resource}, \link{build_template_definition}, \link{build_template_parameters} \href{https://docs.microsoft.com/en-us/azure/templates/}{Template overview}, \href{https://docs.microsoft.com/en-us/rest/api/resources/deployments}{Template API reference} } diff --git a/man/build_template.Rd b/man/build_template.Rd new file mode 100644 index 0000000..b927707 --- /dev/null +++ b/man/build_template.Rd @@ -0,0 +1,133 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/build_tpl_json.R +\name{build_template_definition} +\alias{build_template_definition} +\alias{build_template} +\alias{build_template_definition.default} +\alias{build_template_parameters} +\alias{build_template_parameters.default} +\title{Build the JSON for a template and its parameters} +\usage{ +build_template_definition(...) + +\method{build_template_definition}{default}(parameters = NULL, + variables = NULL, resources = NULL, outputs = NULL, ...) + +build_template_parameters(...) + +\method{build_template_parameters}{default}(...) +} +\arguments{ +\item{...}{For \code{build_template_parameters}, named arguments giving the values of each template parameter. For \code{build_template_definition}, further arguments passed to class methods.} + +\item{parameters}{For \code{build_template_definition}, the parameter names and types for the template. See 'Details' below.} + +\item{variables}{Internal variables used by the template.} + +\item{resources}{List of resources that the template should deploy.} + +\item{outputs}{The template outputs.} +} +\value{ +The JSON text for the template definition and its parameters. +} +\description{ +Build the JSON for a template and its parameters +} +\details{ +\code{build_template_definition} is used to generate a template from its components. The arguments can be specified in various ways: +\itemize{ +\item As character strings containing unparsed JSON text. +\item As an R list of (nested) objects representing the parsed JSON. +\item A connection pointing to a JSON file or object. +\item For the \code{parameters} argument, this can also be a character vector containing the types of each parameter. +} + +\code{build_template_parameters} is for creating the list of parameters to be passed along with the template. Its arguments should all be named, and contain either the JSON text or an R list giving the parsed JSON. + +Both of these are generics and can be extended by other packages to handle specific deployment scenarios, eg virtual machines. +} +\examples{ +# dummy example +# note that 'resources' arg should be a _list_ of resources +build_template_definition(resources=list(list(name="resource here"))) + +# specifying parameters as a list +build_template_definition(parameters=list(par1=list(type="string")), + resources=list(list(name="resource here"))) + +# specifying parameters as a vector +build_template_definition(parameters=c(par1="string"), + resources=list(list(name="resource here"))) + +# realistic example: storage account +build_template_definition( + parameters=c( + name="string", + location="string", + sku="string" + ), + variables=list( + id="[resourceId('Microsoft.Storage/storageAccounts', parameters('name'))]" + ), + resources=list( + list( + name="[parameters('name')]", + location="[parameters('location')]", + type="Microsoft.Storage/storageAccounts", + apiVersion="2018-07-01", + sku=list( + name="[parameters('sku')]" + ), + kind="Storage" + ) + ), + outputs=list( + storageId="[variables('id')]" + ) +) + +# providing JSON text as input +build_template_definition( + parameters=c(name="string", location="string", sku="string"), + resources='[ + { + "name": "[parameters(\\'name\\')]", + "location": "[parameters(\\'location\\')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2018-07-01", + "sku": { + "name": "[parameters(\\'sku\\')]" + }, + "kind": "Storage" + } + ]' +) + +# parameter values +build_template_parameters(name="mystorageacct", location="westus", sku="Standard_LRS") + +build_template_parameters( + param='{ + "name": "myname", + "properties": { "prop1": 42, "prop2": "hello" } + }' +) + +param_json <- '{ + "name": "myname", + "properties": { "prop1": 42, "prop2": "hello" } + }' +build_template_parameters(param=textConnection(param_json)) + +\dontrun{ +# reading JSON definitions from a file +build_template_definition( + parameters=file("parameter_def.json"), + resources=file("resource_def.json") + +build_template_parameters(name="myres_name", complex_type=file("myres_params.json")) +) +} + +} diff --git a/tests/resources/parameter_values.json b/tests/resources/parameter_values.json new file mode 100644 index 0000000..c5f6856 --- /dev/null +++ b/tests/resources/parameter_values.json @@ -0,0 +1,14 @@ +{ + "name": "Allow-HTTP", + "properties": { + "protocol": "Tcp", + "access": "Allow", + "direction": "Inbound", + "destinationPortRange": "80", + "destinationAddressPrefix": "*", + "destinationApplicationSecurityGroups": [], + "sourcePortRange": "*", + "sourceAddressPrefix": "*", + "sourceApplicationSecurityGroups": [] + } +} diff --git a/tests/resources/parameters.json b/tests/resources/parameters.json new file mode 100644 index 0000000..60ea61b --- /dev/null +++ b/tests/resources/parameters.json @@ -0,0 +1,8 @@ +{ + "location": { + "type": "string" + }, + "name": { + "type": "string" + } +} diff --git a/tests/resources/resources.json b/tests/resources/resources.json new file mode 100644 index 0000000..8d1055e --- /dev/null +++ b/tests/resources/resources.json @@ -0,0 +1,16 @@ +[ + { + "name": "[parameters('name')]", + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2018-07-01", + "location": "[parameters('location')]", + "properties": { + "supportsHttpsTrafficOnly": true + }, + "dependsOn": [], + "sku": { + "name": "Standard_LRS" + }, + "kind": "Storage" + } +] diff --git a/tests/resources/template.json b/tests/resources/template.json index 189a8b4..9d364e3 100644 --- a/tests/resources/template.json +++ b/tests/resources/template.json @@ -9,7 +9,9 @@ "type": "string" } }, - "variables": {}, + "variables": { + + }, "resources": [ { "name": "[parameters('name')]", @@ -19,12 +21,17 @@ "properties": { "supportsHttpsTrafficOnly": true }, - "dependsOn": [], + "dependsOn": [ + + ], "sku": { "name": "Standard_LRS" }, "kind": "Storage" } ], - "outputs": {} + "outputs": { + + } } + diff --git a/tests/testthat/test05a_template_builders.R b/tests/testthat/test05a_template_builders.R new file mode 100644 index 0000000..e9ccdbf --- /dev/null +++ b/tests/testthat/test05a_template_builders.R @@ -0,0 +1,59 @@ +context("Template builders") + + +test_that("Template definition builder works", +{ + expect_silent(build_template_definition()) + + expect_silent(tpl1 <- build_template_definition(parameters=c(parm1="string", parm2="string"))) + + tpl2 <- build_template_definition( + parameters=list(parm1=list(type="string"), parm2=list(type="string"))) + expect_identical(tpl1, tpl2) + + expect_silent(tpl3 <- build_template_definition( + resource=list( + list( + name="resname", type="resprovider/type", properties=list(prop1=42, prop2="hello") + ) + ) + )) + + res_str <- '[ + { + "name":"resname", "type":"resprovider/type", "properties":{ "prop1": 42, "prop2": "hello" } + } + ]' + tpl4 <- build_template_definition(resource=res_str) + expect_identical(tpl3, tpl4) + + tpl5 <- build_template_definition(resource=textConnection(res_str)) + expect_identical(tpl3, tpl5) + + expect_silent(tpl6 <- build_template_definition( + parameters=file("../resources/parameters.json"), + resources=file("../resources/resources.json") + )) + expect_identical(unclass(tpl6), paste0(readLines("../resources/template.json"), collapse="\n")) +}) + + +test_that("Template parameters builder works", +{ + expect_identical(build_template_parameters(), "{}") + + expect_silent(build_template_parameters(parm1="foo", parm2=list(bar="hello"))) + + expect_silent(par1 <- build_template_parameters(parm=file("../resources/parameter_values.json"))) + + par2 <- build_template_parameters(parm=readLines("../resources/parameter_values.json")) + expect_identical(par1, par2) + + parm_str <- paste0(readLines("../resources/parameter_values.json"), collapse="\n") + par3 <- build_template_parameters(parm=textConnection(parm_str)) + expect_identical(par1, par3) + + par4 <- build_template_parameters( + parm=jsonlite::fromJSON("../resources/parameter_values.json", simplifyVector=FALSE)) + expect_identical(par1, par4) +}) diff --git a/tests/testthat/test05_template.R b/tests/testthat/test05b_template.R similarity index 82% rename from tests/testthat/test05_template.R rename to tests/testthat/test05b_template.R index 93e4985..42d206f 100644 --- a/tests/testthat/test05_template.R +++ b/tests/testthat/test05b_template.R @@ -61,6 +61,17 @@ test_that("Template methods work", tpl3$check() expect_is(tpl3, "az_template") expect_false(is_empty(rg$list_resources())) + + # from template and parameter builder + tplname4 <- paste(sample(letters, 10, replace=TRUE), collapse="") + tpl_def <- build_template_definition( + parameters=file("../resources/parameters.json"), + resources=file("../resources/resources.json") + ) + par_def <- build_template_parameters(location="australiaeast", name=tplname4) + tpl4 <- rg$deploy_template(tplname4, template=tpl_def, parameters=par_def, wait=TRUE) + tpl4$check() + expect_is(tpl4, "az_template") }) rg$delete(confirm=FALSE)