Skip to content

Commit

Permalink
Introduced Stack() and Stack.exports (#348)
Browse files Browse the repository at this point in the history
* Introduced Stack() and Stack.exports to replace faulty final for-comprehension. Updated integration tests, docs, examples and templates.
  • Loading branch information
lbialy authored Jan 25, 2024
1 parent 5f8bbe7 commit add6553
Show file tree
Hide file tree
Showing 52 changed files with 461 additions and 335 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ import besom.*
import besom.api.aws
@main def run = Pulumi.run {
for
bucket <- aws.s3.Bucket("my-bucket")
yield exports(
val bucket <- aws.s3.Bucket("my-bucket")
Stack.exports(
bucketUrl = bucket.websiteEndpoint
)
}
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/scala/besom/aliases.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ object aliases:
object StackReferenceArgs extends besom.internal.StackReferenceArgsFactory
type StackReferenceResourceOptions = besom.internal.StackReferenceResourceOptions
object StackReferenceResourceOptions extends besom.internal.StackReferenceResourceOptionsFactory
type Stack = besom.internal.Stack
object Stack extends besom.internal.StackFactory

export besom.internal.InvokeOptions
end aliases
12 changes: 5 additions & 7 deletions core/src/main/scala/besom/future.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,20 @@ trait FutureMonadModule extends BesomModule:
*
* Most notable methods exposed by [[besom.Pulumi]] are:
* - [[besom.internal.BesomModule.run]] - the Pulumi program function
* - [[besom.internal.BesomSyntax.exports]] - the Pulumi Stack outputs
* - [[besom.internal.BesomSyntax.config]] - configuration and secrets
* - [[besom.internal.BesomSyntax.log]] - all your logging needs
*
* Inside `Pulumi.run` block you can use all methods without `Pulumi.` prefix. All functions that belong to Besom
* program but are defined outside the `Pulumi.run` block should have the following using clause: `(using Context)` or
* `(using besom.Context)` using a fully qualified name of the type.
* Inside `Pulumi.run` block you can use all methods without `Pulumi.` prefix. All functions that belong to Besom program but are defined
* outside the `Pulumi.run` block should have the following using clause: `(using Context)` or `(using besom.Context)` using a fully
* qualified name of the type.
*
* The hello world example:
* {{{
* import besom.*
*
* @main def main = Pulumi.run {
* for
* _ <- log.warn("Nothing's here yet, it's waiting for you to write some code!")
* yield exports()
* val message = log.warn("Nothing's here yet, it's waiting for you to write some code!")
* Stack(dependsOn = message)
* }
* }}}
*/
Expand Down
16 changes: 7 additions & 9 deletions core/src/main/scala/besom/internal/BesomModule.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package besom.internal

import com.google.protobuf.struct.*
import besom.internal.logging.{LocalBesomLogger => logger, BesomLogger}

/** An abstract effect Besom module, which can be implemented for different effect types.
Expand Down Expand Up @@ -30,18 +29,16 @@ trait EffectBesomModule extends BesomSyntax:
* import besom.api.aws
*
* @main def run = Pulumi.run {
* for
* bucket <- aws.s3.Bucket("my-bucket")
* yield exports(
* bucketUrl = bucket.websiteEndpoint
* )
* val bucket = aws.s3.Bucket("my-bucket")
*
* Stack.exports(bucketUrl = bucket.websiteEndpoint)
* }
* }}}
*
* @param program
* the program to run
*/
def run(program: Context ?=> Output[Exports]): Unit =
def run(program: Context ?=> Stack): Unit =
val everything: Result[Unit] = Result.scoped {
for
_ <- BesomLogger.setupLogger()
Expand All @@ -57,8 +54,9 @@ trait EffectBesomModule extends BesomSyntax:
_ <- logger.trace(s"Environment:\n${sys.env.toSeq.sortBy(_._1).map((k, v) => s"$k: $v").mkString("\n")}")
_ <- logger.debug(s"Resolved feature support, spawning context and executing user program.")
ctx <- Context(runInfo, taskTracker, monitor, engine, logger, featureSupport, config)
userOutputs <- program(using ctx).map(_.toResult).getValueOrElse(Result.pure(Struct()))
_ <- Stack.registerStackOutputs(runInfo, userOutputs)(using ctx)
stack <- Result.defer(program(using ctx)) // for formatting ffs
_ <- stack.evaluateDependencies(using ctx)
_ <- StackResource.registerStackOutputs(runInfo, stack.getExports.result)(using ctx)
_ <- ctx.waitForAllTasks
yield ()
}
Expand Down
20 changes: 1 addition & 19 deletions core/src/main/scala/besom/internal/BesomSyntax.scala
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,6 @@ trait BesomSyntax:
*/
def pulumiStack(using ctx: Context): NonEmptyString = ctx.pulumiStack

/** The [[Export]] instance that exposes [[besom.aliases.Output]] instances as Pulumi Stack outputs.
*
* All arguments of `exports(...)` must be explicitly named, because [[besom.internal.Exports]] are dynamic, e.g.:
*
* {{{
* import besom.*
* import besom.api.aws
*
* @main def run = Pulumi.run {
* for
* bucket <- aws.s3.Bucket("my-bucket")
* yield exports(
* bucketUrl = bucket.websiteEndpoint
* )
* }
* }}}
*/
val exports: Export.type = Export

/** Creates a new component resource.
* @param name
* a unique resource name for this component
Expand Down Expand Up @@ -134,3 +115,4 @@ trait BesomSyntax:
}
.map(OutputData(_))
}
end BesomSyntax
8 changes: 4 additions & 4 deletions core/src/main/scala/besom/internal/Context.scala
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class ContextImpl(
private[besom] val engine: Engine,
private[besom] val taskTracker: TaskTracker,
private[besom] val resources: Resources,
private val stackPromise: Promise[Stack]
private val stackPromise: Promise[StackResource]
) extends Context
with TaskTracker:

Expand All @@ -103,7 +103,7 @@ class ContextImpl(
}

override private[besom] def initializeStack: Result[Unit] =
Stack.initializeStack(runInfo)(using this).flatMap(stackPromise.fulfill)
StackResource.initializeStack(runInfo)(using this).flatMap(stackPromise.fulfill)

override private[besom] def registerComponentResource(
name: NonEmptyString,
Expand Down Expand Up @@ -184,7 +184,7 @@ object Context:
engine: Engine,
taskTracker: TaskTracker,
resources: Resources,
stackPromise: Promise[Stack]
stackPromise: Promise[StackResource]
): Context =
new ContextImpl(runInfo, featureSupport, config, logger, monitor, engine, taskTracker, resources, stackPromise)

Expand All @@ -199,7 +199,7 @@ object Context:
): Result[Context] =
for
resources <- Resources()
stackPromise <- Promise[Stack]()
stackPromise <- Promise[StackResource]()
yield apply(runInfo, featureSupport, config, logger, monitor, engine, taskTracker, resources, stackPromise)

def apply(
Expand Down
75 changes: 46 additions & 29 deletions core/src/main/scala/besom/internal/Exports.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,80 @@ import scala.language.dynamics
import scala.quoted.*
import com.google.protobuf.struct.Struct

object Export extends Dynamic:
inline def applyDynamic(name: "apply")(inline args: Any*)(using ctx: Context): Exports = ${ applyDynamicImpl('args) }
inline def applyDynamicNamed(name: "apply")(inline args: (String, Any)*)(using ctx: Context): Exports = ${ applyDynamicNamedImpl('args, 'ctx) }
object EmptyExport extends Dynamic:
inline def applyDynamic(name: "apply")(inline args: Any*)(using ctx: Context): Stack = ${
Export.applyDynamicImpl('args, 'None)
}
inline def applyDynamicNamed(name: "apply")(inline args: (String, Any)*)(using ctx: Context): Stack = ${
Export.applyDynamicNamedImpl('args, 'ctx, 'None)
}

def applyDynamicImpl(args: Expr[Seq[Any]])(using Quotes): Expr[Exports] =
class Export(private val stack: Stack) extends Dynamic:
private val maybeStack: Option[Stack] = Some(stack)
inline def applyDynamic(name: "apply")(inline args: Any*)(using ctx: Context): Stack = ${
Export.applyDynamicImpl('args, 'maybeStack)
}
inline def applyDynamicNamed(name: "apply")(inline args: (String, Any)*)(using ctx: Context): Stack = ${
Export.applyDynamicNamedImpl('args, 'ctx, 'maybeStack)
}

object Export:
def applyDynamicImpl(args: Expr[Seq[Any]], stack: Expr[Option[Stack]])(using Quotes): Expr[Stack] =
import quotes.reflect.*

args match
case Varargs(arguments) =>
if arguments.isEmpty then
'{ Exports.fromStructResult(Result(Struct(Map.empty))) }
else
report.errorAndAbort("All arguments of `exports(...)` must be explicitly named.")
if arguments.isEmpty then '{ ${ stack }.getOrElse(Stack.empty) }
else report.errorAndAbort("All arguments of `exports(...)` must be explicitly named.")
case _ =>
report.errorAndAbort("Expanding arguments of `exports(...)` with `*` is not allowed.")

def applyDynamicNamedImpl(args: Expr[Seq[(String, Any)]], ctx: Expr[Context])(using Quotes): Expr[Exports] =
def applyDynamicNamedImpl(args: Expr[Seq[(String, Any)]], ctx: Expr[Context], stack: Expr[Option[Stack]])(using Quotes): Expr[Stack] =
import quotes.reflect.*

// TODO: check if parameter names are unique

val (errorReports, results) = args match
case Varargs(arguments) => arguments.partitionMap {
case '{ ($name: String, $value: v) } =>
if name.valueOrAbort.isEmpty then
Left(() => report.error(s"All arguments of `exports(...)` must be explicitly named.", value))
else
case Varargs(arguments) =>
arguments.partitionMap { case '{ ($name: String, $value: v) } =>
if name.valueOrAbort.isEmpty then Left(() => report.error(s"All arguments of `exports(...)` must be explicitly named.", value))
else
Expr.summon[Encoder[v]] match
case Some(encoder) =>
// TODO make sure we don't need deps here (replaced with _)
Right('{ ${encoder}.encode(${value}).map { (_, value1) => (${name}, value1) }})
Right('{ ${ encoder }.encode(${ value }).map { (_, value1) => (${ name }, value1) } })
case None =>
Left(() => report.error(s"Encoder[${Type.show[v]}] is missing", value))
}
}
case _ =>
report.errorAndAbort("Expanding arguments of `exports(...)` with `*` is not allowed.")

errorReports.foreach(_.apply)

if errorReports.nonEmpty then
report.errorAndAbort("Some of arguments of `exports` cannot be encoded.")
if errorReports.nonEmpty then report.errorAndAbort("Some of arguments of `exports` cannot be encoded.")

val resultsExpr = Expr.ofSeq(results)
'{
Exports.fromStructResult(
Result.sequence(${resultsExpr}).map { seq =>
val previousStack = ${ stack }.getOrElse(Stack.empty)

val exports = Exports(
Result.sequence(${ resultsExpr }).map { seq =>
Struct(fields = seq.toMap)
}
)
}

object ExportsOpaque:
opaque type Exports = Result[Struct]

object Exports:
def fromStructResult(result: Result[Struct]): Exports = result
extension (exports: Exports)
def toResult: Result[Struct] = exports
val mergedExports = previousStack.getExports.merge(exports)

Stack(mergedExports, previousStack.getDependsOn)
}
end applyDynamicNamedImpl
end Export

export ExportsOpaque.Exports
case class Exports(result: Result[Struct]):
private[besom] def merge(other: Exports): Exports =
Exports {
for
struct <- result
otherStruct <- other.result
yield Struct(fields = struct.fields ++ otherStruct.fields)
}
8 changes: 4 additions & 4 deletions core/src/main/scala/besom/internal/Resource.scala
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ trait ProviderResource extends CustomResource:

case class DependencyResource(urn: Output[URN]) extends Resource derives ResourceDecoder

case class Stack()(using ComponentBase) extends ComponentResource
object Stack:
case class StackResource()(using ComponentBase) extends ComponentResource
object StackResource:
val RootPulumiStackTypeName: ResourceType = "pulumi:pulumi:Stack"

def stackName(runInfo: RunInfo): NonEmptyString =
Expand All @@ -67,8 +67,8 @@ object Stack:
userOutputs
)

def initializeStack(runInfo: RunInfo)(using ctx: Context): Result[Stack] =
def initializeStack(runInfo: RunInfo)(using ctx: Context): Result[StackResource] =
for given ComponentBase <- ctx.registerComponentResource(stackName(runInfo), RootPulumiStackTypeName)
yield Stack()
yield StackResource()

case class ComponentBase(urn: Output[URN]) extends Resource derives ResourceDecoder
2 changes: 1 addition & 1 deletion core/src/main/scala/besom/internal/ResourceOps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ class ResourceOps(using ctx: Context, mdc: BesomMDC[Label]):
// This method returns an Option of Resource because for Stack there is no parent resource,
// for any other resource the parent is either explicitly set in ResourceOptions or the stack is the parent.
private def resolveParentUrn(typ: ResourceType, resourceOptions: ResourceOptions): Result[Option[URN]] =
if typ == Stack.RootPulumiStackTypeName then Result.pure(None)
if typ == StackResource.RootPulumiStackTypeName then Result.pure(None)
else
resourceOptions.parent match
case Some(parent) =>
Expand Down
25 changes: 25 additions & 0 deletions core/src/main/scala/besom/internal/Stack.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package besom.internal

import com.google.protobuf.struct.Struct

/** The Stack is the final result of a Pulumi program. It contains the exports and dependencies of the program. * Exports are the values
* that are exposed to the Pulumi runtime and available to other stacks via StackReference * Dependencies are the values that have to be
* evaluated (and thus created) for the Stack to be created.
*
* The Stack is created in user's code using [[StackFactory]] and not directly to offer a nicer API.
*
* @param _exports
* @param dependsOn
*/
case class Stack private[besom] (private val _exports: Exports, private val dependsOn: Vector[Output[?]]):
private[besom] def evaluateDependencies(using Context): Result[Unit] =
Output.sequence(dependsOn).getData.void

private[besom] def getExports: Exports = _exports

private[besom] def getDependsOn: Vector[Output[?]] = dependsOn

def exports: Export = Export(this)

object Stack:
def empty: Stack = Stack(Exports(Result.pure(Struct(Map.empty))), Vector.empty)
19 changes: 19 additions & 0 deletions core/src/main/scala/besom/internal/StackFactory.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package besom.internal

/** The Stack is the final result of a Pulumi program. It contains the exports and dependencies of the program. * Exports are the values
* that are exposed to the Pulumi runtime and available to other stacks via StackReference * Dependencies are the values that have to be
* evaluated (and thus created) for the Stack to be created
*
* There are three ways to create a Stack in user's code:
*
* * Stack(a, b) - creates a stack with dependencies a and b
*
* * Stack.exports(a = x, b = y) - creates a stack with exports a and b
*
* * Stack(a, b).exports(c = x, d = y) - creates a stack with dependencies a and b and exports c and d
*/
trait StackFactory:
val exports: EmptyExport.type = EmptyExport

def apply(dependsOn: Output[?]*)(using Context): Stack =
Stack.empty.copy(dependsOn = dependsOn.toVector)
2 changes: 1 addition & 1 deletion core/src/test/scala/besom/internal/DummyContext.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ object DummyContext:
): Result[Context] =
for
taskTracker <- TaskTracker()
stackPromise <- Promise[Stack]()
stackPromise <- Promise[StackResource]()
logger <- BesomLogger.local()
config <- Config(runInfo.project, isProjectName = true, configMap = configMap, configSecretKeys = configSecretKeys)
resources <- Resources()
Expand Down
Loading

0 comments on commit add6553

Please sign in to comment.