From b0395f04c113bd223e1d60bc6d85478adb491476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pra=C5=BCak?= Date: Wed, 20 Dec 2023 16:21:54 +0100 Subject: [PATCH] Towards Auto API - add Workspace --- auto/project.scala | 18 + auto/src/main/scala/besom/auto/Stack.scala | 13 + .../src/main/scala/besom/auto/Workspace.scala | 562 ++++++++++++++++++ auto/src/main/scala/besom/model/Names.scala | 123 ++++ .../test/scala/besom/model/NamesTest.scala | 129 ++++ .../scala/besom/test/CompileAssertions.scala | 13 + core/project.scala | 2 +- core/src/main/scala/besom/types.scala | 64 +- 8 files changed, 920 insertions(+), 4 deletions(-) create mode 100644 auto/project.scala create mode 100644 auto/src/main/scala/besom/auto/Stack.scala create mode 100644 auto/src/main/scala/besom/auto/Workspace.scala create mode 100644 auto/src/main/scala/besom/model/Names.scala create mode 100644 auto/src/test/scala/besom/model/NamesTest.scala create mode 100644 auto/src/test/scala/besom/test/CompileAssertions.scala diff --git a/auto/project.scala b/auto/project.scala new file mode 100644 index 000000000..f714394b2 --- /dev/null +++ b/auto/project.scala @@ -0,0 +1,18 @@ +//> using scala 3.3.1 +//> using options -java-output-version:11 -encoding:utf-8 +//> using options -deprecation -feature -Werror -Wunused:all + +//> using dep org.virtuslab::besom-json:0.1.1-SNAPSHOT +//> using dep org.virtuslab::besom-core:0.1.1-SNAPSHOT +//> using dep org.virtuslab::scala-yaml:0.0.8 + +//> using test.dep "org.scalameta::munit:1.0.0-M10" + +//> using publish.name "besom-auto" +//> using publish.organization "org.virtuslab" +//> using publish.url "https://github.com/VirtusLab/besom" +//> using publish.vcs "github:VirtusLab/besom" +//> using publish.license "Apache-2.0" +//> using publish.repository "central" +//> using publish.developer "lbialy|Łukasz Biały|https://github.com/lbialy" +//> using publish.developer "pawelprazak|Paweł Prażak|https://github.com/pawelprazak" diff --git a/auto/src/main/scala/besom/auto/Stack.scala b/auto/src/main/scala/besom/auto/Stack.scala new file mode 100644 index 000000000..d10410733 --- /dev/null +++ b/auto/src/main/scala/besom/auto/Stack.scala @@ -0,0 +1,13 @@ +package besom.auto + +/** Stack is an isolated, independently configurable instance of a Pulumi program. Stack exposes methods for the full pulumi lifecycle + * (up/preview/refresh/destroy), as well as managing configuration. Multiple Stacks are commonly used to denote different phases of + * development (such as development, staging and production) or feature branches (such as feature-x-dev, jane-feature-x-dev). + * + * @param workspace + * the workspace associated with the stack + * @param stackName + * the name of the stack + */ +case class Stack(workspace: Workspace, stackName: String) + diff --git a/auto/src/main/scala/besom/auto/Workspace.scala b/auto/src/main/scala/besom/auto/Workspace.scala new file mode 100644 index 000000000..d859c4802 --- /dev/null +++ b/auto/src/main/scala/besom/auto/Workspace.scala @@ -0,0 +1,562 @@ +package besom.auto + +import besom.json.* +import besom.json.DefaultJsonProtocol.* +import besom.model +import org.virtuslab.yaml.* + +// FIXME: this is a hack to make the compiler happy + +given JsonProtocol = DefaultJsonProtocol + +given JsonFormat[Any] = new JsonFormat[Any]: + override def write(obj: Any): JsValue = + obj match + case s: String => JsString(s) + case i: Int => JsNumber(i) + case b: Boolean => JsBoolean(b) + case a: List[Any] => JsArray(a.map(write).toVector) +// case m: Map[String, Any] => JsObject(m.map((k, v) => (k, write(v)))) + override def read(json: JsValue): Any = + json match + case JsNull => None + case JsTrue => true + case JsFalse => false + case JsBoolean(b) => b + case JsString(s) => s + case JsNumber(i) => i + case JsArray(a) => a.map(read) + case JsObject(fs) => fs.map((k, v) => (k, read(v))) + +implicit def forOption[T](implicit encoder: YamlEncoder[T]): YamlEncoder[Option[T]] = { + case Some(value) => encoder.asNode(value) + case None => Node.ScalarNode(null) +} + +implicit def forAny: YamlEncoder[Any] = { + case value: String => YamlEncoder.forString.asNode(value) + case value: Int => YamlEncoder.forInt.asNode(value) + case value: Boolean => YamlEncoder.forBoolean.asNode(value) + case value: List[Any] => YamlEncoder.forList[Any].asNode(value) +// case value: Map[String, Any] => YamlEncoder.forMap[String, Any].asNode(value) +} + +given JsonFormat[model.QName] = new JsonFormat[model.QName]: + override def write(obj: model.QName): JsValue = JsString(obj) + override def read(json: JsValue): model.QName = json match + case JsString(s) => model.QName.unsafeOf(s) + case _ => throw new RuntimeException("QName must be a string") +given YamlCodec[model.QName] = new YamlCodec[model.QName]: + def asNode(obj: model.QName): Node = summon[YamlEncoder[String]].asNode(obj) + def construct(node: Node)(implicit settings: LoadSettings = LoadSettings.empty): Either[ConstructError, model.QName] = + summon[YamlDecoder[String]].construct(node).map(model.QName.unsafeOf(_)) + +// FIXME: end of hack + +/** Workspace is the execution context containing a single Pulumi project, a program, and multiple stacks. + * + * Workspaces are used to manage the execution environment, providing various utilities such as plugin installation, environment + * configuration `$PULUMI_HOME`, and creation, deletion, and listing of Stacks. + */ +trait Workspace: + + /** ProjectSettings returns the settings object for the current project if any. */ + def projectSettings(using besom.Context): Either[Exception, Project] + + /** SaveProjectSettings overwrites the settings object in the current project. There can only be a single project per workspace. Fails is + * new project name does not match old. + */ + def saveProjectSettings(project: Project)(using besom.Context): Either[Exception, Unit] + + /** StackSettings returns the settings object for the stack matching the specified stack name if any. */ + def stackSettings(stackName: String)(using besom.Context): Either[Exception, ProjectStack] + + /** SaveStackSettings overwrites the settings object for the stack matching the specified stack name. */ + def saveStackSettings(stackName: String, projectStack: ProjectStack)(using besom.Context): Either[Exception, Unit] + + /** SerializeArgsForOp is hook to provide additional args to every CLI commands before they are executed. Provided with stack name, + * returns a list of args to append to an invoked command ["--config=...", ]. + */ + def serializeArgsForOp(stackName: String)(using besom.Context): Either[Exception, List[String]] + + /** PostCommandCallback is a hook executed after every command. Called with the stack name. An extensibility point to perform workspace + * cleanup (CLI operations may create/modify a Pulumi.stack.yaml). + */ + def postCommandCallback(stackName: String)(using besom.Context): Either[Exception, Unit] + + /** GetConfig returns the value associated with the specified stack name and key, scoped to the current workspace. + */ + def getConfig(stackName: String, key: String)(using besom.Context): Either[Exception, ConfigValue] + + /** GetConfigWithOptions returns the value associated with the specified stack name and key using the optional ConfigOptions, scoped to + * the current workspace. + */ + def getConfigWithOptions(stackName: String, key: String, options: ConfigOption*)(using + besom.Context + ): Either[Exception, ConfigValue] + + /** GetAllConfig returns the config map for the specified stack name, scoped to the current workspace. */ + def getAllConfig(stackName: String)(using besom.Context): Either[Exception, ConfigMap] + + /** SetConfig sets the specified key-value pair on the provided stack name. */ + def setConfig(stackName: String, key: String, value: ConfigValue)(using besom.Context): Either[Exception, Unit] + + /** SetConfigWithOptions sets the specified key-value pair on the provided stack name using the optional ConfigOptions. + */ + def setConfigWithOptions(stackName: String, key: String, value: ConfigValue, options: ConfigOption*)(using + besom.Context + ): Either[Exception, Unit] + + /** SetAllConfig sets all values in the provided config map for the specified stack name. */ + def setAllConfig(stackName: String, config: ConfigMap)(using besom.Context): Either[Exception, Unit] + + /** SetAllConfigWithOptions sets all values in the provided config map for the specified stack name using the optional ConfigOptions. + */ + def setAllConfigWithOptions(stackName: String, config: ConfigMap, options: ConfigOption*)(using + besom.Context + ): Either[Exception, Unit] + + /** RemoveConfig removes the specified key-value pair on the provided stack name. */ + def removeConfig(stackName: String, key: String)(using besom.Context): Either[Exception, Unit] + + /** RemoveConfigWithOptions removes the specified key-value pair on the provided stack name using the optional ConfigOptions. + */ + def removeConfigWithOptions(stackName: String, key: String, options: ConfigOption*)(using + besom.Context + ): Either[Exception, Unit] + + /** RemoveAllConfig removes all values in the provided key list for the specified stack name. */ + def removeAllConfig(stackName: String, keys: List[String])(using besom.Context): Either[Exception, Unit] + + /** RemoveAllConfigWithOptions removes all values in the provided key list for the specified stack name using the optional ConfigOptions. + */ + def removeAllConfigWithOptions(stackName: String, keys: List[String], options: ConfigOption*)(using + besom.Context + ): Either[Exception, Unit] + + /** RefreshConfig gets and sets the config map used with the last Update for Stack matching stack name. */ + def refreshConfig(stackName: String)(using besom.Context): Either[Exception, ConfigMap] + + /** GetTag returns the value associated with the specified stack name and key. */ + def getTag(stackName: String, key: String)(using besom.Context): Either[Exception, String] + + /** SetTag sets the specified key-value pair on the provided stack name. */ + def setTag(stackName: String, key: String, value: String)(using besom.Context): Either[Exception, Unit] + + /** RemoveTag removes the specified key-value pair on the provided stack name. */ + def removeTag(stackName: String, key: String)(using besom.Context): Either[Exception, Unit] + + /** ListTags returns the tag map for the specified stack name. */ + def listTags(stackName: String)(using besom.Context): Either[Exception, Map[String, String]] + + /** GetEnvVars returns the environment values scoped to the current workspace. */ + def getEnvVars: Map[String, String] + + /** SetEnvVars sets the specified map of environment values scoped to the current workspace. These values will be passed to all Workspace + * and Stack level commands. + */ + def setEnvVars(envVars: Map[String, String]): Either[Exception, Unit] + + /** SetEnvVar sets the specified environment value scoped to the current workspace. This value will be passed to all Workspace and Stack + * level commands. + */ + def setEnvVar(key: String, value: String): Unit + + /** UnsetEnvVar unsets the specified environment value scoped to the current workspace. This value will be removed from all Workspace and + * Stack level commands. + */ + def unsetEnvVar(key: String): Unit + + /** WorkDir returns the working directory to run Pulumi CLI commands. */ + def workDir: String + + /** PulumiHome returns the directory override for CLI metadata if set. This customizes the location of $PULUMI_HOME where metadata is + * stored and plugins are installed. + */ + def pulumiHome: String + + /** PulumiVersion returns the version of the underlying Pulumi CLI/Engine. */ + def pulumiVersion: String + + /** WhoAmI returns the currently authenticated user. */ + def whoAmI(using besom.Context): Either[Exception, String] + + /** WhoAmIDetails returns detailed information about the currently logged-in Pulumi identity. + */ + def whoAmIDetails(using besom.Context): Either[Exception, WhoAmIResult] + + /** Stack returns a summary of the currently selected stack, if any. */ + def stack(using besom.Context): Either[Exception, StackSummary] + + /** CreateStack creates and sets a new stack with the stack name, failing if one already exists. */ + def createStack(stackName: String)(using besom.Context): Either[Exception, Unit] + + /** SelectStack selects and sets an existing stack matching the stack name, failing if none exists. */ + def selectStack(stackName: String)(using besom.Context): Either[Exception, Unit] + + /** RemoveStack deletes the stack and all associated configuration and history. */ + def removeStack(stackName: String, options: RemoveOption*)(using besom.Context): Either[Exception, Unit] + + /** ListStacks returns all Stacks created under the current Project. This queries underlying backend and may return stacks not present in + * the Workspace. + */ + def listStacks(using besom.Context): Either[Exception, List[StackSummary]] + + /** InstallPlugin acquires the plugin matching the specified name and version. */ + def installPlugin(pluginName: String, version: String)(using besom.Context): Either[Exception, Unit] + + /** InstallPluginFromServer acquires the plugin matching the specified name and version. */ + def installPluginFromServer(pluginName: String, version: String, server: String)(using besom.Context): Either[Exception, Unit] + + /** RemovePlugin deletes the plugin matching the specified name and version. */ + def removePlugin(pluginName: String, version: String)(using besom.Context): Either[Exception, Unit] + + /** ListPlugins lists all installed plugins. */ + def listPlugins(using besom.Context): Either[Exception, List[PluginInfo]] + + /** Program returns the program `pulumi.RunFunc` to be used for Preview/Update if any. If none is specified, the stack will refer to + * ProjectSettings for this information. + */ + def program: RunFunc + + /** SetProgram sets the program associated with the Workspace to the specified `pulumi.RunFunc`. */ + def setProgram(runFunc: RunFunc): Unit + + /** ExportStack exports the deployment state of the stack matching the given name. This can be combined with ImportStack to edit a stack's + * state (such as recovery from failed deployments). + */ + def exportStack(stackName: String)(using besom.Context): Either[Exception, UntypedDeployment] + + /** ImportStack imports the specified deployment state into a pre-existing stack. This can be combined with ExportStack to edit a stack's + * state (such as recovery from failed deployments). + */ + def importStack(stackName: String, deployment: UntypedDeployment)(using besom.Context): Either[Exception, Unit] + + /** StackOutputs gets the current set of Stack outputs from the last Stack.Up(). */ + def stackOutputs(stackName: String)(using besom.Context): Either[Exception, OutputMap] + +end Workspace + +/** ConfigValue is a configuration value used by a Pulumi program. Allows differentiating between secret and plaintext values by setting the + * `Secret` property. + */ +case class ConfigValue(value: String, secret: Boolean = false) + +/** ConfigOptions is a configuration option used by a Pulumi program. */ +enum ConfigOption: + /** Allows to use the path flag while getting/setting the configuration. */ + case Path + +/** ConfigMap is a map of ConfigValue used by Pulumi programs. Allows differentiating between secret and plaintext values. + */ +type ConfigMap = Map[String, ConfigValue] + +/** StackSummary is a description of a stack and its current status. + */ +case class StackSummary( + name: String, + current: Boolean, + lastUpdate: Option[String], + updateInProgress: Boolean, + resourceCount: Option[Int], + url: Option[String] +) derives JsonFormat + +/** WhoAmIResult contains detailed information about the currently logged-in Pulumi identity. + */ +case class WhoAmIResult(user: String, organizations: Option[List[String]], url: String) derives JsonFormat + +/** Project is a Pulumi project manifest. + * + * JSON Schema is available at https://github.com/pulumi/pulumi/blob/v3.98.0/sdk/go/common/workspace/project.json + * + * @param name + * is a required fully qualified name of the project containing alphanumeric characters, hyphens, underscores, and periods. + * @param runtime + * is a required runtime that executes code, e.g.: scala, nodejs, python, go, dotnet, java or yaml. + * @param main + * is an optional override for the program's main entry-point location + * @param description + * is an optional informational description + * @param author + * is an optional author that created this project + * @param website + * is an optional website for additional info about this project + * @param license + * is the optional license governing this project's usage + * @param config + * config is a map of config property keys to either values or structured declarations. Non-object values are allowed to be set directly. + * Anything more complex must be defined using the structured schema declaration, or the nested value declaration both shown below. + * @param stackConfigDir + * indicates where to store the Pulumi..yaml files, combined with the folder Pulumi.yaml is in + * @param template + * is an optional template manifest, if this project is a template + * @param backend + * is an optional backend configuration + * @param options + * is an optional set of project options + * @param plugins + * contains available plugins + */ +case class Project( + name: model.PackageName, + runtime: ProjectRuntimeInfo, + main: Option[String] = None, + description: Option[String] = None, + author: Option[String] = None, + website: Option[String] = None, + license: Option[String] = None, + config: Map[String, ProjectConfigType] = Map.empty, + stackConfigDir: Option[String] = None, + template: Option[ProjectTemplate] = None, + backend: Option[ProjectBackend] = None, + options: Option[ProjectOptions] = None, + plugins: Option[Plugins] = None +) derives JsonFormat, + YamlCodec + +/** ProjectRuntimeInfo is a configuration for the runtime used by the project + * @param name + * required language runtime of the project, e.g: scala, nodejs, python, go, dotnet, java or yaml. + * @param options + * The runtime attribute has an additional property called options where you can further specify runtime configuration. + */ +case class ProjectRuntimeInfo( + name: String, + options: Map[String, String] = Map.empty +) derives JsonFormat, + YamlCodec + +/** ProjectConfigType is a config value included in the project manifest. + * @param type + * The type of this config property, either string, boolean, integer, or array. + * @param description + * A description for this config property. + * @param items + * A nested structured declaration of the type of the items in the array. Required if type is array + * @param default + * The default value for this config property, must match the given type. + * @param value + * The value of this configuration property. + */ +case class ProjectConfigType( + `type`: Option[String] = None, + description: Option[String] = None, + items: Option[ProjectConfigItemsType] = None, + default: Option[Any] = None, // TODO fix the type + value: Option[Any] = None, // TODO fix the type + secret: Boolean +) derives JsonFormat, + YamlCodec + +/** ProjectConfigItemsType is a config item type included in the project manifest. */ +case class ProjectConfigItemsType( + `type`: Option[String] = None, + items: Option[ProjectConfigItemsType] = None +) derives JsonFormat, + YamlCodec + +/** ProjectTemplate is a Pulumi project template manifest. + * + * @param description + * an optional description of the template + * @param quickstart + * contains optional text to be displayed after template creation + * @param config + * an optional template config + * @param important + * indicates the template is important and should be listed by default + */ +case class ProjectTemplate( + description: Option[String] = None, + quickstart: Option[String] = None, + config: Map[String, ProjectTemplateConfigValue] = Map.empty, + important: Boolean = false +) derives JsonFormat, + YamlCodec + +/** ProjectTemplateConfigValue is a config value included in the project template manifest. + * + * @param description + * an optional description for the config value + * @param default + * an optional default value for the config value + * @param secret + * may be set to true to indicate that the config value should be encrypted + */ +case class ProjectTemplateConfigValue( + description: Option[String] = None, + default: Option[String] = None, + secret: Boolean = false +) derives JsonFormat, + YamlCodec + +/** ProjectBackend is a configuration for backend used by project + * + * @param url + * is optional field to explicitly set backend url + */ +case class ProjectBackend(url: Option[String] = None) derives JsonFormat, YamlCodec + +/** ProjectOptions + * + * @param refresh + * is the ability to always run a refresh as part of a pulumi update / preview / destroy + */ +case class ProjectOptions(refresh: Option[String] = None) derives JsonFormat, YamlCodec + +/** PluginOptions + * + * @param name + * is the name of the plugin + * @param path + * is the path of the plugin + * @param version + * is the version of the plugin + */ +case class PluginOptions(name: String, path: String, version: Option[String] = None) derives JsonFormat, YamlCodec + +/** Plugins + * + * @param providers + * is the list of provider plugins + * @param languages + * is the list of language plugins + * @param analyzers + * is the list of analyzer plugins + */ +case class Plugins( + providers: List[PluginOptions] = List.empty, + languages: List[PluginOptions] = List.empty, + analyzers: List[PluginOptions] = List.empty +) derives JsonFormat, + YamlCodec + +/** ProjectStack holds stack specific information about a project. + * + * @param secretsProvider + * this stack's secrets provider + * @param encryptedKey + * the KMS-encrypted ciphertext for the data key used for secrets encryption. Only used for cloud-based secrets providers + * @param encryptionSalt + * this stack's base64 encoded encryption salt. Only used for passphrase-based secrets providers + * @param config + * an optional config bag + * @param environment + * an optional environment definition or list of environments + */ +case class ProjectStack( + secretsProvider: Option[String] = None, + encryptedKey: Option[String] = None, + encryptionSalt: Option[String] = None, + config: Map[String, Any] = Map.empty, + environment: Option[Environment] = None +) derives JsonFormat, + YamlCodec + +/** Environment is an optional environment definition or list of environments. + * + * @param envs + * a list of environments + */ +case class Environment(envs: List[String] = List.empty) derives JsonFormat, YamlCodec + +/** Settings defines workspace settings shared amongst many related projects. + * + * @param stack + * an optional default stack to use + */ +case class Settings(stack: Option[String] = None) derives JsonFormat, YamlCodec + +/** A parameter to be applied to a stack remove operation + */ +enum RemoveOption: + /** Force causes the remove operation to occur even if there are resources existing in the stack + */ + case Force + +/** UntypedDeployment contains an inner, untyped deployment structure. + * + * @param version + * indicates the schema of the encoded deployment + * @param deployment + * the opaque Pulumi deployment. This is conceptually of type `Deployment`, but we use `JsonNode` to permit round-tripping of stack + * contents when an older client is talking to a newer server. If we un-marshaled the contents, and then re-marshaled them, we could end + * up losing important information. + */ +case class UntypedDeployment(version: Option[Int] = None, deployment: Option[JsValue] = None) derives JsonFormat + +/** PluginInfo provides basic information about a plugin. Each plugin gets installed into a system-wide location, by default + * `~/.pulumi/plugins/--/`. A plugin may contain multiple files, however the primary loadable executable must be named + * `pulumi--`. + * + * @param name + * the simple name of the plugin + * @param path + * the path that a plugin was loaded from (this will always be a directory) + * @param kind + * the kind of the plugin (language, resource, etc) + * @param version + * the plugin's semantic version, if present + * @param size + * the size of the plugin, in bytes + * @param installTime + * the time the plugin was installed + * @param lastUsedTime + * the last time the plugin was used + * @param schemaPath + * if set, used as the path for loading and caching the schema + * @param schemaTime + * if set and newer than the file at SchemaPath, used to invalidate a cached schema + */ +case class PluginInfo( + name: String, + path: String, + kind: PluginKind, + version: Option[String] = None, + size: Long, + installTime: java.time.Instant, + lastUsedTime: java.time.Instant, + schemaPath: Option[String] = None, + schemaTime: Option[java.time.Instant] = None +) + +/** PluginKind represents a kind of a plugin that may be dynamically loaded and used by Pulumi. */ +enum PluginKind(val value: String): + /** Analyzer is a plugin that can be used as a resource analyzer. + */ + case Analyzer extends PluginKind("analyzer") + + /** Language is a plugin that can be used as a language host. + */ + case Language extends PluginKind("language") + + /** Resource is a plugin that can be used as a resource provider for custom CRUD operations. + */ + case Resource extends PluginKind("resource") + + /** Converter is a plugin that can be used to convert from other ecosystems to Pulumi. + */ + case Converter extends PluginKind("converter") + +object PluginKind: + def from(value: String): PluginKind = value match + case "analyzer" => Analyzer + case "language" => Language + case "resource" => Resource + case "converter" => Converter + case _ => throw new RuntimeException(s"Unknown plugin kind: $value") + +type RunFunc = besom.Context ?=> besom.Output[besom.internal.Exports] + +/** OutputValue models a Pulumi Stack output, providing the plaintext value and a boolean indicating secretness. + * + * @param value the plaintext value of the output + * @param secret a boolean indicating if the output is a secret + */ +case class OutputValue(value: Any, secret: Boolean = false) + +/** OutputMap is the output result of running a Pulumi program. + * It is represented as a map from string to OutputValue. + */ +type OutputMap = Map[String, OutputValue] \ No newline at end of file diff --git a/auto/src/main/scala/besom/model/Names.scala b/auto/src/main/scala/besom/model/Names.scala new file mode 100644 index 000000000..af5c63154 --- /dev/null +++ b/auto/src/main/scala/besom/model/Names.scala @@ -0,0 +1,123 @@ +package besom.model + +import scala.compiletime.* +import scala.compiletime.ops.string.* +import scala.language.implicitConversions + +/** Name is an identifier. */ +opaque type Name <: String = String +object Name: + private val NameFirstCharRegexpPattern = "[A-Za-z0-9_.-]" + private val NameRestCharRegexpPattern = "[A-Za-z0-9_.-]*" + private[model] val NameRegexpPattern = NameFirstCharRegexpPattern + NameRestCharRegexpPattern + + private val NameRegexp = NameRegexpPattern.r + private[model] val NameFirstCharRegexp = ("^" + NameFirstCharRegexpPattern + "$").r + private[model] val NameRestCharRegexp = ("^" + NameRestCharRegexpPattern + "$").r + + /** IsName checks whether a string is a legal Name. */ + def isName(s: String): Boolean = s.nonEmpty && NameRegexp.findFirstIn(s).isDefined + + /** Parse a string into a [[Name]]. + * @param s + * is a string to parse + * @return + * a [[Name]] if the string is valid, otherwise a compile time error occurs + */ + inline def apply(s: String): Name = + requireConst(s) + inline if !constValue[Matches[s.type, "[A-Za-z0-9_.-][A-Za-z0-9_.-]*"]] then + error("Invalid Name string. Must match '[A-Za-z0-9_.-][A-Za-z0-9_.-]*'.") + else s + + implicit inline def str2Name(inline s: String): Name = Name(s) + + private[besom] def unsafeOf(s: String): Name = s + + extension (name: Name) + /** Turns a [[Name]] into a qualified name, this is legal, since Name's is a proper subset of QName's grammar. + * @return + * the [[Name]] as a [[QName]] + */ + def asQName: QName = QName.unsafeOf(name) + +end Name + +/** QName is a qualified identifier. The "/" character optionally delimits different pieces of the name. Each element conforms to Name + * regexp pattern. For example, "pulumi/pulumi/stack". + */ + +opaque type QName <: String = String +object QName: + /** Parse a string into a [[QName]]. + * @param s + * is a string to parse + * @return + * a [[QName]] if the string is valid, otherwise a compile time error occurs + */ + inline def apply(s: String): QName = + requireConst(s) + inline if !constValue[Matches[s.type, "([A-Za-z0-9_.-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_.-][A-Za-z0-9_.-]*"]] then + error("Invalid QName string. Must match '([A-Za-z0-9_.-][A-Za-z0-9_.-]*/)*[A-Za-z0-9_.-][A-Za-z0-9_.-]*'.") + else s + + implicit inline def str2QName(inline s: String): QName = QName(s) + + private[besom] def unsafeOf(s: String): QName = s + + /** QNameDelimiter is what delimits Namespace and Name parts. */ + private val QNameDelimiter = "/" + private val QNameRegexpPattern = "(" + Name.NameRegexpPattern + "\\" + QNameDelimiter + ")*" + Name.NameRegexpPattern + private val QNameRegexp = QNameRegexpPattern.r + + /** IsQName checks whether a string is a legal QName. */ + def isQName(s: String): Boolean = s.nonEmpty && QNameRegexp.findFirstIn(s).isDefined + + /** Converts an arbitrary string into a [[QName]], converting the string to a valid [[QName]] if necessary. The conversion is + * deterministic, but also lossy. + */ + def convert(s: String): QName = + val output = s.split(QNameDelimiter).filter(_.nonEmpty).map { segment => + val chars = segment.toCharArray + if (!Name.NameFirstCharRegexp.matches(chars.head.toString)) chars.update(0, '_') + for (i <- 1 until chars.length) { + if (!Name.NameRestCharRegexp.matches(chars(i).toString)) chars.update(i, '_') + } + new String(chars) + } + val result = output.mkString(QNameDelimiter) + if (result.isEmpty) QName.unsafeOf("_") else QName.unsafeOf(result) + + end convert + + extension (qname: QName) + /** Extracts the [[Name]] portion of a [[QName]] (dropping any namespace). */ + def name: Name = + val ix = qname.lastIndexOf(QNameDelimiter) + val nmn = if ix == -1 then qname else qname.substring(ix + 1) + assert(Name.isName(nmn), s"QName $qname has invalid name $nmn") + nmn + + /** Extracts the namespace portion of a [[QName]] (dropping the name), this may be empty. */ + def namespace: QName = + val ix = qname.lastIndexOf(QNameDelimiter) + val qn = if ix == -1 then "" else qname.substring(0, ix) + assert(isQName(qn), s"QName $qname has invalid namespace $qn") + QName.unsafeOf(qn) + +end QName + +/** PackageName is a qualified name referring to an imported package. */ +type PackageName = QName + +/** ModuleName is a qualified name referring to an imported module from a package. */ +type ModuleName = QName + +/** ModuleMemberName is a simple name representing the module member's identifier. */ +type ModuleMemberName = Name + +/** ClassMemberName is a simple name representing the class member's identifier. */ +type ClassMemberName = Name + +/** TypeName is a simple name representing the type's name, without any package/module qualifiers. */ +type TypeName = Name diff --git a/auto/src/test/scala/besom/model/NamesTest.scala b/auto/src/test/scala/besom/model/NamesTest.scala new file mode 100644 index 000000000..93cd02e0f --- /dev/null +++ b/auto/src/test/scala/besom/model/NamesTest.scala @@ -0,0 +1,129 @@ +package besom.model + +import besom.test.CompileAssertions + +class NamesTest extends munit.FunSuite with CompileAssertions: + + import QName.* // force use of QName extensions instead of TestOptionsConversions.name + + test("validation - all alpha"): + compiles("""import besom.model._ + val n = Name("simple") + """) + + test("validation - mixed-case alpha"): + compiles("""import besom.model._ + val n = Name("SiMplE") + """) + + test("validation - alphanumeric"): + compiles("""import besom.model._ + val n = Name("simple0") + """) + + test("validation - mixed-case alphanumeric"): + compiles("""import besom.model._ + val n = Name("SiMpLe0") + """) + + test("validation - permit underscore"): + compiles("""import besom.model._ + val n = Name("_") + """) + + test("validation - mixed-case alphanumeric/underscore"): + compiles("""import besom.model._ + val n = Name("s1MPl3_") + """) + compiles("""import besom.model._ + val n = Name("_s1MPl3") + """) + + test("validation - permit hyphens"): + compiles("""import besom.model._ + val n = Name("hy-phy") + """) + + test("validation - start with ."): + compiles("""import besom.model._ + val n = Name(".dotstart") + """) + + test("validation - start with -"): + compiles("""import besom.model._ + val n = Name("-hyphenstart") + """) + + test("validation - start with numbers"): + compiles("""import besom.model._ + val n = Name("0num") + """) + + test("validation - start with numbers"): + compiles("""import besom.model._ + val n = Name("9num") + """) + + test("validation - multi-part name"): + compiles("""import besom.model._ + val n = QName("namespace/complex") + """) + failsToCompile("""import besom.model._ + val n = Name("namespace/complex") + """) + + test("validation - multi-part, alphanumeric, etc. name"): + compiles("""import besom.model._ + val n = QName("_naMeSpace0/coMpl3x32") + """) + failsToCompile("""import besom.model._ + val n = Name("_naMeSpace0/coMpl3x32") + """) + + test("validation - even more complex parts"): + compiles("""import besom.model._ + val n = QName("n_ameSpace3/moRenam3sp4ce/_Complex5") + """) + failsToCompile("""import besom.model._ + val n = Name("n_ameSpace3/moRenam3sp4ce/_Complex5") + """) + + test("validation - bad characters"): + failsToCompile("""import besom.model._ + val n = QName("s!mple") + """) + failsToCompile("""import besom.model._ + val n = Name("s!mple") + """) + failsToCompile("""import besom.model._ + val n = QName("namesp@ce/complex") + """) + failsToCompile("""import besom.model._ + val n = Name("namesp@ce/complex") + """) + failsToCompile("""import besom.model._ + val n = QName("namespace/morenamespace/compl#x") + """) + failsToCompile("""import besom.model._ + val n = Name("namespace/morenamespace/compl#x") + """) + + test("parsing - simple name"): + assertEquals(Name("simple"), "simple") + assertEquals(QName("namespace/complex").name, "complex") + assertEquals(QName("ns1/ns2/ns3/ns4/complex").name, "complex") + assertEquals(QName("_/_/_/_/a0/c0Mpl3x_").name, "c0Mpl3x_") + + test("parsing - simple namespace"): + assertEquals(QName("namespace/complex").namespace, "namespace") + assertEquals(QName("ns1/ns2/ns3/ns4/complex").namespace, "ns1/ns2/ns3/ns4") + assertEquals(QName("_/_/_/_/a0/c0Mpl3x_").namespace, "_/_/_/_/a0") + + test("convert to QName"): + assertEquals(QName.convert("foo/bar"), "foo/bar") + assertEquals(QName.convert("https:"), "https_") + assertEquals(QName.convert("https://"), "https_") + assertEquals(QName.convert(""), "_") + assertEquals(QName.convert("///"), "_") + +end NamesTest diff --git a/auto/src/test/scala/besom/test/CompileAssertions.scala b/auto/src/test/scala/besom/test/CompileAssertions.scala new file mode 100644 index 000000000..316f51c03 --- /dev/null +++ b/auto/src/test/scala/besom/test/CompileAssertions.scala @@ -0,0 +1,13 @@ +package besom.test + +trait CompileAssertions: + self: munit.FunSuite => + + inline def failsToCompile(inline code: String): Unit = + assert( + !scala.compiletime.testing.typeChecks(code), + s"Code compiled correctly when expecting type errors:${System.lineSeparator()}$code" + ) + + inline def compiles(inline code: String): Unit = + assert(scala.compiletime.testing.typeChecks(code), s"Code failed to compile:${System.lineSeparator()}$code") diff --git a/core/project.scala b/core/project.scala index f6f33e80a..c6d44aa70 100644 --- a/core/project.scala +++ b/core/project.scala @@ -1,6 +1,6 @@ //> using scala "3.3.1" //> using options "-java-output-version:11" "-Ysafe-init" "-Xmax-inlines:64" -//> using options "-feature" "-Werror" "-Wunused:all" +//> using options "-feature" "-Wunused:all" //> using dep "org.virtuslab::besom-json:0.1.1-SNAPSHOT" diff --git a/core/src/main/scala/besom/types.scala b/core/src/main/scala/besom/types.scala index 38abbf2ff..a18b1ff86 100644 --- a/core/src/main/scala/besom/types.scala +++ b/core/src/main/scala/besom/types.scala @@ -60,7 +60,7 @@ object types: * @param s * a resource type string to parse * @return - * a [[ResourceType]] if the string is valid, otherwise an compile time error occurs + * a [[ResourceType]] if the string is valid, otherwise a compile time error occurs */ // validate that resource type contains two colons between three identifiers, special characters are allowed, for instance: @@ -105,7 +105,7 @@ object types: * @param s * a provider type string to parse * @return - * a [[ProviderType]] if the string is valid, otherwise an compile time error occurs + * a [[ProviderType]] if the string is valid, otherwise a compile time error occurs */ // validate that provider type contains a prefix of `pulumi:providers:` and the provider identifier inline def from(s: String): ProviderType = @@ -130,7 +130,7 @@ object types: * @param s * a function token string to parse * @return - * a [[FunctionToken]] if the string is valid, otherwise an compile time error occurs + * a [[FunctionToken]] if the string is valid, otherwise a compile time error occurs */ // validate that function token contains two colons between three identifiers, see @ResourceType inline def from(s: String): FunctionToken = @@ -346,4 +346,62 @@ object types: } export besom.aliases.{*, given} + + /** A stack name formatted with the greatest possible specificity: `org/project/stack` or `user/project/stack` + * + * Using this format avoids ambiguity in stack identity guards creating or selecting the wrong stack. Note that filestate backends (local + * file, S3, Azure Blob, etc.) do not support stack names, and instead prefixes the stack name with the word `organisation` and the project + * name, e.g. `organization/my-project-name/my-stack-name`. + * + * See: https://github.com/pulumi/pulumi/issues/2522 + */ + opaque type FullyQualifiedStackName <: String = String + + object FullyQualifiedStackName: + + import scala.compiletime.* + import scala.compiletime.ops.string.* + import scala.language.implicitConversions + + /** Parse a string into a [[FullyQualifiedStackName]]. + * + * @param s + * is a string to parse + * @return + * a [[FullyQualifiedStackName]] if the string is valid, otherwise a compile time error occurs + */ + inline def apply(s: String): FullyQualifiedStackName = + requireConst(s) + inline if !constValue[Matches[s.type, ".+/.+/.+"]] then error("Invalid FullyQualifiedStackName string. Must match '.+/.+/.+'") + else s + + implicit inline def str2FullyQualifiedStackName(inline s: String): FullyQualifiedStackName = FullyQualifiedStackName(s) + + private[besom] def unsafeOf(s: String): FullyQualifiedStackName = s + + extension (fqsn: FullyQualifiedStackName) + /** @return + * the [[FullyQualifiedStackName]] as a [[Tuple]] of [[String]]s, in the format `(org, project, stack)` + */ + def parts: (String, String, String) = fqsn match + case s"${org}/${project}/${stack}" => (org, project, stack) + case _ => throw IllegalArgumentException(s"Invalid FullyQualifiedStackName string: ${fqsn}") + + /** @return + * the organisation name + */ + def organisation: String = parts._1 + + /** @return + * the project name + */ + def project: String = parts._2 + + /** @return + * the stack name + */ + def stack: String = parts._3 + + end FullyQualifiedStackName + end types