From add6553bca87c30dbfcb2a43b0364a3191414f5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Thu, 25 Jan 2024 11:47:57 +0100 Subject: [PATCH] Introduced Stack() and Stack.exports (#348) * Introduced Stack() and Stack.exports to replace faulty final for-comprehension. Updated integration tests, docs, examples and templates. --- README.md | 6 +- core/src/main/scala/besom/aliases.scala | 2 + core/src/main/scala/besom/future.scala | 12 +-- .../scala/besom/internal/BesomModule.scala | 16 ++- .../scala/besom/internal/BesomSyntax.scala | 20 +--- .../main/scala/besom/internal/Context.scala | 8 +- .../main/scala/besom/internal/Exports.scala | 75 ++++++++------ .../main/scala/besom/internal/Resource.scala | 8 +- .../scala/besom/internal/ResourceOps.scala | 2 +- .../src/main/scala/besom/internal/Stack.scala | 25 +++++ .../scala/besom/internal/StackFactory.scala | 19 ++++ .../scala/besom/internal/DummyContext.scala | 2 +- .../scala/besom/internal/ExportsTest.scala | 98 +++++++++++++++++++ examples/aws-s3-folder/Main.scala | 14 +-- examples/aws-secrets-manager/Main.scala | 10 +- examples/aws-webserver/Main.scala | 6 +- .../infra/Main.scala | 6 +- examples/gcp-static-page/Main.scala | 23 ++--- examples/kubernetes-nginx/Main.scala | 4 +- experimental/project.scala | 2 +- .../src/main/scala/besom/liftoff.scala | 25 ++--- .../resources/cats-purrl-example/Main.scala | 16 ++- .../resources/compiler-plugin/Main.scala | 7 +- .../resources/config-example/Main.scala | 22 ++--- .../executors/sbt/src/main/scala/Main.scala | 5 +- .../resources/executors/scala-cli/Main.scala | 5 +- .../resources/logger-example/Main.scala | 10 +- .../resources/random-example/Main.scala | 14 +-- .../references/source-stack/Main.scala | 14 ++- .../references/target-stack/Main.scala | 7 +- .../resources/tls-example/Main.scala | 21 ++-- .../resources/zio-tls-example/Main.scala | 17 ++-- templates/aws/Main.scala | 8 +- templates/aws/project.scala | 1 + templates/default/Main.scala | 10 +- templates/default/project.scala | 1 + templates/gcp/Main.scala | 8 +- templates/gcp/project.scala | 1 + templates/kubernetes/Main.scala | 31 +++--- templates/kubernetes/project.scala | 1 + website/docs/architecture.md | 2 +- website/docs/basics.md | 12 ++- website/docs/changelog.md | 22 +++++ website/docs/components.md | 12 +-- website/docs/context.md | 10 +- website/docs/exports.md | 11 +-- website/docs/intro.md | 2 +- website/docs/laziness.md | 35 ++++--- website/docs/lifting.md | 4 +- website/docs/logging.md | 10 +- website/docs/tutorial.md | 89 ++++++++--------- website/sidebars.js | 5 + 52 files changed, 461 insertions(+), 335 deletions(-) create mode 100644 core/src/main/scala/besom/internal/Stack.scala create mode 100644 core/src/main/scala/besom/internal/StackFactory.scala create mode 100644 core/src/test/scala/besom/internal/ExportsTest.scala create mode 100644 website/docs/changelog.md diff --git a/README.md b/README.md index 5167c419..0a31edbb 100644 --- a/README.md +++ b/README.md @@ -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 ) } diff --git a/core/src/main/scala/besom/aliases.scala b/core/src/main/scala/besom/aliases.scala index 29113101..3f21818f 100644 --- a/core/src/main/scala/besom/aliases.scala +++ b/core/src/main/scala/besom/aliases.scala @@ -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 diff --git a/core/src/main/scala/besom/future.scala b/core/src/main/scala/besom/future.scala index fad87f2a..00e19612 100644 --- a/core/src/main/scala/besom/future.scala +++ b/core/src/main/scala/besom/future.scala @@ -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) * } * }}} */ diff --git a/core/src/main/scala/besom/internal/BesomModule.scala b/core/src/main/scala/besom/internal/BesomModule.scala index fda84ff0..adf27ed3 100644 --- a/core/src/main/scala/besom/internal/BesomModule.scala +++ b/core/src/main/scala/besom/internal/BesomModule.scala @@ -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. @@ -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() @@ -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 () } diff --git a/core/src/main/scala/besom/internal/BesomSyntax.scala b/core/src/main/scala/besom/internal/BesomSyntax.scala index 75d5d885..0898944b 100644 --- a/core/src/main/scala/besom/internal/BesomSyntax.scala +++ b/core/src/main/scala/besom/internal/BesomSyntax.scala @@ -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 @@ -134,3 +115,4 @@ trait BesomSyntax: } .map(OutputData(_)) } +end BesomSyntax diff --git a/core/src/main/scala/besom/internal/Context.scala b/core/src/main/scala/besom/internal/Context.scala index 503cc354..9689c996 100644 --- a/core/src/main/scala/besom/internal/Context.scala +++ b/core/src/main/scala/besom/internal/Context.scala @@ -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: @@ -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, @@ -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) @@ -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( diff --git a/core/src/main/scala/besom/internal/Exports.scala b/core/src/main/scala/besom/internal/Exports.scala index f55b1585..d3a7f2a9 100644 --- a/core/src/main/scala/besom/internal/Exports.scala +++ b/core/src/main/scala/besom/internal/Exports.scala @@ -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) + } diff --git a/core/src/main/scala/besom/internal/Resource.scala b/core/src/main/scala/besom/internal/Resource.scala index 4042ad26..370b30e3 100644 --- a/core/src/main/scala/besom/internal/Resource.scala +++ b/core/src/main/scala/besom/internal/Resource.scala @@ -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 = @@ -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 diff --git a/core/src/main/scala/besom/internal/ResourceOps.scala b/core/src/main/scala/besom/internal/ResourceOps.scala index 549fefd0..1b1e1704 100644 --- a/core/src/main/scala/besom/internal/ResourceOps.scala +++ b/core/src/main/scala/besom/internal/ResourceOps.scala @@ -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) => diff --git a/core/src/main/scala/besom/internal/Stack.scala b/core/src/main/scala/besom/internal/Stack.scala new file mode 100644 index 00000000..efbb6076 --- /dev/null +++ b/core/src/main/scala/besom/internal/Stack.scala @@ -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) diff --git a/core/src/main/scala/besom/internal/StackFactory.scala b/core/src/main/scala/besom/internal/StackFactory.scala new file mode 100644 index 00000000..8900ca84 --- /dev/null +++ b/core/src/main/scala/besom/internal/StackFactory.scala @@ -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) diff --git a/core/src/test/scala/besom/internal/DummyContext.scala b/core/src/test/scala/besom/internal/DummyContext.scala index 9cd4e639..61567110 100644 --- a/core/src/test/scala/besom/internal/DummyContext.scala +++ b/core/src/test/scala/besom/internal/DummyContext.scala @@ -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() diff --git a/core/src/test/scala/besom/internal/ExportsTest.scala b/core/src/test/scala/besom/internal/ExportsTest.scala new file mode 100644 index 00000000..43e342f0 --- /dev/null +++ b/core/src/test/scala/besom/internal/ExportsTest.scala @@ -0,0 +1,98 @@ +package besom.internal + +import RunResult.{given, *} +import besom.types.{Output => _, *} +import besom.internal.ProtobufUtil.* +import com.google.protobuf.struct.*, Value.Kind + +class ExportsTest extends munit.FunSuite with ValueAssertions: + + test("exports alone work as intended") { + given Context = DummyContext().unsafeRunSync() + + val stackOutputs = Stack.exports(foo = Output("bar")).getExports.result.unsafeRunSync() + + val encoded = Value(Kind.StructValue(stackOutputs)) + + val expected = Map( + "foo" -> "bar".asValue + ).asValue + + assertEqualsValue(encoded, expected) + } + + test("stack dependencies work as intended") { + given Context = DummyContext().unsafeRunSync() + + val atomicBoolean = new java.util.concurrent.atomic.AtomicBoolean(false) + + val stackDeps = Stack(Output("foo"), Output("bar"), Output(Result.defer { atomicBoolean.set(true); "baz" })).getDependsOn + + assertEquals(stackDeps.size, 3) + + val sequencedDataOfAll = Output.sequence(stackDeps).getData.unsafeRunSync() + + sequencedDataOfAll.getValue match + case Some(vec: Vector[String] @unchecked) => + assert(vec.size == 3) + assertEquals(vec(0), "foo") + assertEquals(vec(1), "bar") + assertEquals(vec(2), "baz") + assert(atomicBoolean.get(), "deferred value should be evaluated") + case x => fail(s"sequencedDataOfAll - Value should not be $x") + } + + test("exports with dependencies work as intended") { + given Context = DummyContext().unsafeRunSync() + + val stack = + Stack(Output("foo"), Output("bar")) + .exports(baz = Output("baz"), qux = Output(Result.defer { "qux" })) + + val stackOutputs = stack.getExports.result.unsafeRunSync() + + val encoded = Value(Kind.StructValue(stackOutputs)) + + val expected = Map( + "baz" -> "baz".asValue, + "qux" -> "qux".asValue + ).asValue + + assertEqualsValue(encoded, expected) + + val stackDeps = stack.getDependsOn + + assertEquals(stackDeps.size, 2) + + val sequencedDataOfAll = Output.sequence(stackDeps).getData.unsafeRunSync() + + sequencedDataOfAll.getValue match + case Some(vec: Vector[String] @unchecked) => + assert(vec.size == 2) + assertEquals(vec(0), "foo") + assertEquals(vec(1), "bar") + case x => fail(s"sequencedDataOfAll - Value should not be $x") + } + + test("multiple export clauses aggregate instead of replacing exported values") { + given Context = DummyContext().unsafeRunSync() + + val stack = + Stack + .exports(foo = Output("foo"), bar = Output("bar")) + .exports(baz = Output("baz"), qux = Output("qux")) + + val stackOutputs = stack.getExports.result.unsafeRunSync() + + val encoded = Value(Kind.StructValue(stackOutputs)) + + val expected = Map( + "foo" -> "foo".asValue, + "bar" -> "bar".asValue, + "baz" -> "baz".asValue, + "qux" -> "qux".asValue + ).asValue + + assertEqualsValue(encoded, expected) + } +end ExportsTest diff --git a/examples/aws-s3-folder/Main.scala b/examples/aws-s3-folder/Main.scala index 782322e4..a38e9af6 100644 --- a/examples/aws-s3-folder/Main.scala +++ b/examples/aws-s3-folder/Main.scala @@ -81,13 +81,9 @@ val siteDir = "www" } } - for - bucket <- siteBucket - _ <- siteBucketPublicAccessBlock - _ <- siteBucketPolicy - _ <- uploads - yield exports( - bucketName = bucket.bucket, - websiteUrl = bucket.websiteEndpoint - ) + Stack(siteBucket, siteBucketPublicAccessBlock, siteBucketPolicy, uploads) + .exports( + bucketName = siteBucket.bucket, + websiteUrl = siteBucket.websiteEndpoint + ) } diff --git a/examples/aws-secrets-manager/Main.scala b/examples/aws-secrets-manager/Main.scala index f115268e..ea74b859 100644 --- a/examples/aws-secrets-manager/Main.scala +++ b/examples/aws-secrets-manager/Main.scala @@ -18,10 +18,8 @@ import besom.api.aws.secretsmanager.SecretVersionArgs ) ) - for - secret <- secret - _ <- secretVersion - yield exports( - secretId = secret.id // Export secret ID (in this case the ARN) - ) + Stack(secretVersion) + .exports( + secretId = secret.id // Export secret ID (in this case the ARN) + ) } diff --git a/examples/aws-webserver/Main.scala b/examples/aws-webserver/Main.scala index c9c411c9..c2ed86b9 100644 --- a/examples/aws-webserver/Main.scala +++ b/examples/aws-webserver/Main.scala @@ -101,7 +101,7 @@ import besom.api.tls ) ) - for { + val displayInformation = for server: ec2.Instance <- server _ <- sshKey _ <- keyPair @@ -110,7 +110,9 @@ import besom.api.tls .flatMap(log.info(_)) _ <- log.info("Connect to SSH: ssh -i key_rsa ec2-user@$(pulumi stack output publicIp)") _ <- log.info("Connect to HTTP: open http://$(pulumi stack output publicHostName)") - } yield Pulumi.exports( + yield () + + Stack(displayInformation).exports( publicKey = publicKey, privateKey = privateKey, publicIp = server.publicIp, diff --git a/examples/docker-multi-container-app/infra/Main.scala b/examples/docker-multi-container-app/infra/Main.scala index 27fc003d..fd6a3ef0 100644 --- a/examples/docker-multi-container-app/infra/Main.scala +++ b/examples/docker-multi-container-app/infra/Main.scala @@ -74,11 +74,7 @@ import docker.inputs.* ) ) - for - _ <- redisImage - _ <- redisContainer - _ <- appContainer - yield exports( + Stack(redisImage, redisContainer, appContainer).exports( redis = p"redis://${redisHost}:${redisPort}", url = p"http://localhost:${appPort}" ) diff --git a/examples/gcp-static-page/Main.scala b/examples/gcp-static-page/Main.scala index 52817cb9..895a6a82 100644 --- a/examples/gcp-static-page/Main.scala +++ b/examples/gcp-static-page/Main.scala @@ -118,23 +118,14 @@ import besom.api.gcp.storage.inputs.* portRange = "80", loadBalancingScheme = "EXTERNAL_MANAGED", // will use envoy-based Application Load Balancer target = proxy.id, - ipAddress = ip.address, + ipAddress = ip.address ) ) - for - staticWebsite <- staticWebsite - _ <- indexPage - _ <- errorPage - _ <- publicRule - _ <- ip - _ <- backendBucket - _ <- urlPaths - _ <- proxy - _ <- forwardingRule - yield exports( - bucketName = staticWebsite.name, - bucketUrl = staticWebsite.url, - websiteIp = ip.address, - ) + Stack(staticWebsite, indexPage, errorPage, publicRule, ip, backendBucket, urlPaths, proxy, forwardingRule) + .exports( + bucketName = staticWebsite.name, + bucketUrl = staticWebsite.url, + websiteIp = ip.address + ) } diff --git a/examples/kubernetes-nginx/Main.scala b/examples/kubernetes-nginx/Main.scala index c19eb39f..7dfa50e3 100644 --- a/examples/kubernetes-nginx/Main.scala +++ b/examples/kubernetes-nginx/Main.scala @@ -39,9 +39,7 @@ import besom.api.kubernetes.meta.v1.inputs.* ) ) - for { - _ <- nginxDeployment - } yield exports( + Stack.exports( nginx = nginxDeployment.metadata.name ) } diff --git a/experimental/project.scala b/experimental/project.scala index d44b77cd..93cc22d4 100644 --- a/experimental/project.scala +++ b/experimental/project.scala @@ -2,4 +2,4 @@ //> using plugin "org.virtuslab::besom-compiler-plugin:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-core:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-kubernetes:4.7.1-core.0.1.1-SNAPSHOT" -//> using dep "io.github.iltotore::iron:2.1.0" +//> using dep "io.github.iltotore::iron:2.4.0" diff --git a/experimental/src/main/scala/besom/liftoff.scala b/experimental/src/main/scala/besom/liftoff.scala index ba149e64..1a29c60c 100644 --- a/experimental/src/main/scala/besom/liftoff.scala +++ b/experimental/src/main/scala/besom/liftoff.scala @@ -5,15 +5,7 @@ import k8s.core.v1.inputs.* import k8s.apps.v1.inputs.* import k8s.meta.v1.inputs.* import k8s.apps.v1.{Deployment, DeploymentArgs, StatefulSet, StatefulSetArgs} -import k8s.core.v1.{ - ConfigMap, - ConfigMapArgs, - Namespace, - Service, - ServiceArgs, - PersistentVolume, - PersistentVolumeArgs -} +import k8s.core.v1.{ConfigMap, ConfigMapArgs, Namespace, Service, ServiceArgs, PersistentVolume, PersistentVolumeArgs} import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.* @@ -252,8 +244,9 @@ def redisCluster(name: NonEmptyString, nodes: Int :| Positive)(using Context): O ServiceArgs( spec = ServiceSpecArgs( selector = labels, + `type` = "NodePort", ports = List( - ServicePortArgs(name = "http", port = 1337) + ServicePortArgs(name = "http", port = 30001, targetPort = 80, nodePort = 30001) ) ), metadata = ObjectMetaArgs( @@ -263,15 +256,13 @@ def redisCluster(name: NonEmptyString, nodes: Int :| Positive)(using Context): O ) ) - for - nginx <- nginxDeployment - service <- nginxService - redis <- redisCluster("cache", 3) - yield Pulumi.exports( + val redis = redisCluster("cache", 3) + + Stack.exports( namespace = appNamespace.metadata.name, - nginxDeploymentName = nginx.metadata.name, + nginxDeploymentName = nginxDeployment.metadata.name, serviceName = nginxService.metadata.name, serviceClusterIp = nginxService.spec.clusterIP, - redisConnString = redis.connectionString + redisConnString = redis.map(_.connectionString) ) } diff --git a/integration-tests/resources/cats-purrl-example/Main.scala b/integration-tests/resources/cats-purrl-example/Main.scala index 6ebc194a..dfa19931 100644 --- a/integration-tests/resources/cats-purrl-example/Main.scala +++ b/integration-tests/resources/cats-purrl-example/Main.scala @@ -13,6 +13,7 @@ def main(): Unit = Pulumi.run { IO.canceled } + // verifying cancelation semantics - we ignore cancelation val cancelledIOOutput2 = Output.eval(IO("Don't cancel me")).flatMap { _ => for fib <- (IO.sleep(3.seconds) *> IO("A valid result")).uncancelable.start @@ -26,7 +27,7 @@ def main(): Unit = Pulumi.run { yield res } - def purrlCommand(url: String) = Purrl( + def purrlCommand(url: Output[String]) = Purrl( name = "purrl", args = PurrlArgs( name = "purrl", @@ -46,13 +47,10 @@ def main(): Unit = Pulumi.run { ) ) - for - urlValue <- url - _ <- cancelledIOOutput1 - out2 <- cancelledIOOutput2 - _ = assert(out2 == "A valid result") - command <- purrlCommand(urlValue) - yield Pulumi.exports( - purrlCommand = command.response + Stack( + cancelledIOOutput1, + cancelledIOOutput2.map(out => assert(out == "A valid result")) + ).exports( + purrlCommand = purrlCommand(url).response ) } diff --git a/integration-tests/resources/compiler-plugin/Main.scala b/integration-tests/resources/compiler-plugin/Main.scala index c225da3d..49b95f89 100644 --- a/integration-tests/resources/compiler-plugin/Main.scala +++ b/integration-tests/resources/compiler-plugin/Main.scala @@ -8,10 +8,5 @@ def main = Pulumi.run { s"Joe ${name}" ) - for - _ <- stringOut - yield - Pulumi.exports( - stringOut = stringOut - ) + Stack.exports(stringOut = stringOut) } diff --git a/integration-tests/resources/config-example/Main.scala b/integration-tests/resources/config-example/Main.scala index 0ea3098e..adf76210 100644 --- a/integration-tests/resources/config-example/Main.scala +++ b/integration-tests/resources/config-example/Main.scala @@ -20,17 +20,15 @@ import besom.* val foo = config.requireObject[Foo]("foo") - Output( - exports( - name = name, - hush = hush, - notThere = notThere, - codeSecret = codeSecret, - viral1 = viral1, - viral2 = viral2, - viral3 = viral3, - names = names, - foo = foo - ) + Stack.exports( + name = name, + hush = hush, + notThere = notThere, + codeSecret = codeSecret, + viral1 = viral1, + viral2 = viral2, + viral3 = viral3, + names = names, + foo = foo ) } diff --git a/integration-tests/resources/executors/sbt/src/main/scala/Main.scala b/integration-tests/resources/executors/sbt/src/main/scala/Main.scala index 5db0f4f4..5adf0a75 100644 --- a/integration-tests/resources/executors/sbt/src/main/scala/Main.scala +++ b/integration-tests/resources/executors/sbt/src/main/scala/Main.scala @@ -8,6 +8,7 @@ import besom.* val y = besom.languageplugin.test.resourceplugin.external.customVal // Show that we were executed for tests to read - for _ <- log.warn("scala executor test got executed") - yield exports() + Stack( + log.warn("scala executor test got executed") + ) } diff --git a/integration-tests/resources/executors/scala-cli/Main.scala b/integration-tests/resources/executors/scala-cli/Main.scala index 5db0f4f4..5adf0a75 100644 --- a/integration-tests/resources/executors/scala-cli/Main.scala +++ b/integration-tests/resources/executors/scala-cli/Main.scala @@ -8,6 +8,7 @@ import besom.* val y = besom.languageplugin.test.resourceplugin.external.customVal // Show that we were executed for tests to read - for _ <- log.warn("scala executor test got executed") - yield exports() + Stack( + log.warn("scala executor test got executed") + ) } diff --git a/integration-tests/resources/logger-example/Main.scala b/integration-tests/resources/logger-example/Main.scala index 6b82f9fc..7db08e1f 100644 --- a/integration-tests/resources/logger-example/Main.scala +++ b/integration-tests/resources/logger-example/Main.scala @@ -1,8 +1,8 @@ import besom.* @main def run = Pulumi.run { - for - _ <- log.warn("Nothing here yet. It's waiting for you!") - _ <- p"Interpolated ${Output("value")}".flatMap(log.info(_)) - yield exports() -} \ No newline at end of file + Stack( + log.warn("Nothing here yet. It's waiting for you!"), + p"Interpolated ${Output("value")}".flatMap(log.info(_)) + ) +} diff --git a/integration-tests/resources/random-example/Main.scala b/integration-tests/resources/random-example/Main.scala index d2048c5f..d8b2b8a8 100644 --- a/integration-tests/resources/random-example/Main.scala +++ b/integration-tests/resources/random-example/Main.scala @@ -11,12 +11,14 @@ def main(): Unit = Pulumi.run { ) ) - for - str <- strOutput - str2 <- strOutput // checks memoization too - yield exports( - randomString = str.result, - resourceName = str.pulumiResourceName, + val str = strOutput + + Stack( + strOutput, + strOutput // checking memoization + ).exports( + randomString = str.map(_.result), + resourceName = str.map(_.pulumiResourceName), org = Pulumi.pulumiOrganization, proj = Pulumi.pulumiProject, stack = Pulumi.pulumiStack diff --git a/integration-tests/resources/references/source-stack/Main.scala b/integration-tests/resources/references/source-stack/Main.scala index a955cdcc..1673f0d4 100644 --- a/integration-tests/resources/references/source-stack/Main.scala +++ b/integration-tests/resources/references/source-stack/Main.scala @@ -16,12 +16,10 @@ case class DummyStructuredOutput( ) ) - Output { - exports( - sshKeyUrn = sshKey.urn, - value1 = 23, - value2 = "Hello world!", - structured = DummyStructuredOutput(Output.secret("ABCDEF"), 42.0) - ) - } + Stack.exports( + sshKeyUrn = sshKey.urn, + value1 = 23, + value2 = "Hello world!", + structured = DummyStructuredOutput(Output.secret("ABCDEF"), 42.0) + ) } diff --git a/integration-tests/resources/references/target-stack/Main.scala b/integration-tests/resources/references/target-stack/Main.scala index b6c515d9..5f720f62 100644 --- a/integration-tests/resources/references/target-stack/Main.scala +++ b/integration-tests/resources/references/target-stack/Main.scala @@ -31,7 +31,7 @@ import besom.json.* }) val structured = sourceStack.flatMap(_.getOutput("structured")) - val r = + val sanityCheck = Output { for sku <- sshKeyUrn.getData.map(_.secret) v1 <- value1.getData.map(_.secret) @@ -42,10 +42,9 @@ import besom.json.* assert(!v1, "value1 should not be a secret") assert(!v2, "value2 should not be a secret") assert(s, "structured should be a secret") + } - for { - _ <- Output(r) - } yield exports( + Stack(Output(sanityCheck)).exports( sshKeyUrn = sshKeyUrn, value1 = value1, value2 = value2, diff --git a/integration-tests/resources/tls-example/Main.scala b/integration-tests/resources/tls-example/Main.scala index e7ef26b2..95672caf 100644 --- a/integration-tests/resources/tls-example/Main.scala +++ b/integration-tests/resources/tls-example/Main.scala @@ -11,21 +11,20 @@ import besom.api.tls.GetPublicKeyResult rsaBits = 4096 ) ) - + val public1: Output[GetPublicKeyResult] = tls.getPublicKey( tls.GetPublicKeyArgs( privateKeyOpenssh = sshKey.privateKeyOpenssh ) ) - for - p <- sshKey.publicKeyOpenssh + val sanityCheck = for + p <- sshKey.publicKeyOpenssh p1 <- public1.publicKeyOpenssh - yield { - require(p.trim == p1.trim) - exports( - p = p.trim, - p1 = p1.trim, - ) - } -} \ No newline at end of file + yield require(p.trim == p1.trim) + + Stack(sanityCheck).exports( + p = sshKey.publicKeyOpenssh.map(_.trim), + p1 = public1.publicKeyOpenssh.map(_.trim) + ) +} diff --git a/integration-tests/resources/zio-tls-example/Main.scala b/integration-tests/resources/zio-tls-example/Main.scala index 8004ca21..b97332ca 100644 --- a/integration-tests/resources/zio-tls-example/Main.scala +++ b/integration-tests/resources/zio-tls-example/Main.scala @@ -7,8 +7,9 @@ def main(): Unit = Pulumi.run { val algorithm = Output.eval(ZIO.succeed("ECDSA")) + // verifying interruption semantics - we ignore interruption val interruptedIOOutput = Output.eval(ZIO.succeed("Don't interrupt me")).flatMap { _ => - for + for fib <- (ZIO.sleep(3.seconds) *> ZIO.succeed("xd")).fork _ <- (ZIO.sleep(1.second) *> fib.interrupt).fork res <- fib.join @@ -19,15 +20,17 @@ def main(): Unit = Pulumi.run { name = "my-private-key", args = PrivateKeyArgs( algorithm = algorithm, - ecdsaCurve = "P384", + ecdsaCurve = "P384" ) ) - for + val k = for alg <- algorithm - _ <- interruptedIOOutput - k <- key(alg) - yield Pulumi.exports( - privateKey = k.id + _ <- interruptedIOOutput + k <- key(alg) + yield k + + Stack.exports( + privateKey = k.map(_.id) ) } diff --git a/templates/aws/Main.scala b/templates/aws/Main.scala index da260254..7ecf7b2e 100644 --- a/templates/aws/Main.scala +++ b/templates/aws/Main.scala @@ -2,9 +2,9 @@ import besom.* import besom.api.aws @main def main = Pulumi.run { - for - bucket <- aws.s3.Bucket("my-bucket") - yield exports( + val bucket = aws.s3.Bucket("my-bucket") + + Stack.exports( bucketName = bucket.bucket ) -} \ No newline at end of file +} diff --git a/templates/aws/project.scala b/templates/aws/project.scala index 3bab8ffe..460081be 100644 --- a/templates/aws/project.scala +++ b/templates/aws/project.scala @@ -2,3 +2,4 @@ //> using plugin "org.virtuslab::besom-compiler-plugin:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-core:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-aws:6.18.1-core.0.1.1-SNAPSHOT" +//> using options -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement diff --git a/templates/default/Main.scala b/templates/default/Main.scala index c75bc478..6ec8d7d2 100644 --- a/templates/default/Main.scala +++ b/templates/default/Main.scala @@ -2,10 +2,10 @@ import besom.* import besom.api.random.* @main def main = Pulumi.run { - for - randomPet <- RandomPet("randomPetServer") - name <- randomPet.id - yield exports( + val randomPet = RandomPet("randomPetServer") + val name = randomPet.map(_.id) + + Stack.exports( name = name ) -} \ No newline at end of file +} diff --git a/templates/default/project.scala b/templates/default/project.scala index 609033ec..af4b1638 100644 --- a/templates/default/project.scala +++ b/templates/default/project.scala @@ -2,3 +2,4 @@ //> using plugin "org.virtuslab::besom-compiler-plugin:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-core:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-random:4.15.1-core.0.1.1-SNAPSHOT" +//> using options -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement diff --git a/templates/gcp/Main.scala b/templates/gcp/Main.scala index 214904fb..75e44547 100644 --- a/templates/gcp/Main.scala +++ b/templates/gcp/Main.scala @@ -3,9 +3,9 @@ import besom.api.gcp import besom.api.gcp.storage.BucketArgs @main def main = Pulumi.run { - for - bucket <- gcp.storage.Bucket("my-bucket", BucketArgs(location = "US")) - yield exports( + val bucket = gcp.storage.Bucket("my-bucket", BucketArgs(location = "US")) + + Stack.exports( bucketName = bucket.url // Export the DNS name of the bucket ) -} \ No newline at end of file +} diff --git a/templates/gcp/project.scala b/templates/gcp/project.scala index 95d3b34b..1520a6af 100644 --- a/templates/gcp/project.scala +++ b/templates/gcp/project.scala @@ -2,3 +2,4 @@ //> using plugin "org.virtuslab::besom-compiler-plugin:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-core:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-gcp:7.6.0-core.0.1.1-SNAPSHOT" +//> using options -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement diff --git a/templates/kubernetes/Main.scala b/templates/kubernetes/Main.scala index 593e91d8..c81973ed 100644 --- a/templates/kubernetes/Main.scala +++ b/templates/kubernetes/Main.scala @@ -1,29 +1,30 @@ import besom.* import besom.api.kubernetes.apps.v1.{Deployment, DeploymentArgs} -import besom.api.kubernetes.core.v1.inputs.{ContainerArgs, ContainerPortArgs, PodSpecArgs, PodTemplateSpecArgs} +import besom.api.kubernetes.core.v1.inputs.{ContainerArgs, PodSpecArgs, PodTemplateSpecArgs} import besom.api.kubernetes.meta.v1.inputs.{LabelSelectorArgs, ObjectMetaArgs} import besom.api.kubernetes.apps.v1.inputs.DeploymentSpecArgs @main def main = Pulumi.run { val appLabels = Map("app" -> "nginx") - for nginxDeployment <- Deployment( - "nginx", - DeploymentArgs( - spec = DeploymentSpecArgs( - selector = LabelSelectorArgs(matchLabels = appLabels), - replicas = 1, - template = PodTemplateSpecArgs( - metadata = ObjectMetaArgs( - labels = appLabels - ), - spec = PodSpecArgs( - containers = List(ContainerArgs(name = "nginx", image = "nginx")) - ) + val nginxDeployment = Deployment( + "nginx", + DeploymentArgs( + spec = DeploymentSpecArgs( + selector = LabelSelectorArgs(matchLabels = appLabels), + replicas = 1, + template = PodTemplateSpecArgs( + metadata = ObjectMetaArgs( + labels = appLabels + ), + spec = PodSpecArgs( + containers = List(ContainerArgs(name = "nginx", image = "nginx")) ) ) ) ) - yield Pulumi.exports( + ) + + Stack.exports( name = nginxDeployment.metadata.name ) } diff --git a/templates/kubernetes/project.scala b/templates/kubernetes/project.scala index 202103c3..49b67ebf 100644 --- a/templates/kubernetes/project.scala +++ b/templates/kubernetes/project.scala @@ -2,3 +2,4 @@ //> using plugin "org.virtuslab::besom-compiler-plugin:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-core:0.1.1-SNAPSHOT" //> using dep "org.virtuslab::besom-kubernetes:4.7.1-core.0.1.1-SNAPSHOT" +//> using options -Werror -Wunused:all -Wvalue-discard -Wnonunit-statement diff --git a/website/docs/architecture.md b/website/docs/architecture.md index dcc34b7f..eda022d1 100644 --- a/website/docs/architecture.md +++ b/website/docs/architecture.md @@ -32,7 +32,7 @@ Following sections explain and showcase said differences: - [Resource constructors](constructors.md) - resource constructors are pure functions that return Outputs - [Context](context.md) - context is passed around implicitly via Scala's Context Function -- [Exports](exports.md) - your program is a function that returns Stack Outputs +- [Exports](exports.md) - your program is a function that returns Stack along with its Stack Outputs - [Laziness](laziness.md) - dangling resources are possible and resource constructors are memoized - [Apply method](apply_methods.md) - use `map` and `flatMap` to compose Outputs, not `apply` - [Logging](logging.md) - all logging statements need to be composed into the main flow diff --git a/website/docs/basics.md b/website/docs/basics.md index 03bbb2af..548354f4 100644 --- a/website/docs/basics.md +++ b/website/docs/basics.md @@ -72,7 +72,7 @@ runtime: scala #### Programs A Pulumi program, written in a general-purpose programming language, is a collection of [resources](#resources) -that are deployed to a [stack](#stacks). +that are deployed to form a [stack](#stacks). A minimal Besom program consists of: @@ -88,9 +88,9 @@ A minimal Besom program consists of: 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() + Stack( + log.warn("Nothing's here yet, it's waiting for you to write some code!") + ) } ``` @@ -114,6 +114,8 @@ named [`Pulumi..yaml`](https://www.pulumi.com/docs/concepts/projects/ in the root of the [project](#projects) directory that contains the [configuration](#configuration-and-secrets) specific to this stack. +The stack is represented in a Besom program by a `Stack` datatype that user is expected to return from the main `Pulumi.run` function. `Stack` is used to mark resources or values that stack depends on or that user wants to export as stack outputs. You can return a `Stack` that consists of exports only (for instance when everything you depend on is composed into a thing that you export in the final step) using `Stack.export(x = a, y = b)` or a `Stack` that has only dependencies when you don't want to export anything using `Stack(x, y)`. You can also use some resources and export others using `Stack(a, b).export(x = i, y = j)` syntax. + :::tip The recommended practice is to **check stack files into source control** as a means of collaboration.
Since secret values are encrypted, it is safe to check in these stack settings. @@ -134,7 +136,7 @@ Stacks can export values as [Stack Outputs](https://www.pulumi.com/docs/concepts These outputs are shown by Pulumi CLI commands, and are displayed in the Pulumi Cloud, and can be accessed programmatically using [Stack References](#stack-references). -To export values from a stack in Besom, use the [`Pulumi.exports`](exports.md) function in your program. +To export values from a stack in Besom, use the [`Stack.exports`](exports.md) function in your program to assign exported values to the final `Stack` value. ##### Stack References diff --git a/website/docs/changelog.md b/website/docs/changelog.md new file mode 100644 index 00000000..01304af3 --- /dev/null +++ b/website/docs/changelog.md @@ -0,0 +1,22 @@ +--- +title: Changelog +--- + +0.2.0 +--- + +* Changed the type of main `Pulumi.run` function from `Context ?=> Output[Exports]` (a [context function](https://docs.scala-lang.org/scala3/reference/contextual/context-functions.html) providing `besom.Context` instance implicitly in it's scope and expecting `Output[Exports]` as returned value) to `Context ?=> Stack`. This change has one core reason: it helps us solve a problem related to dry run functionality that was hindered when a external Output was interwoven in the final for-comprehension. External Outputs (Outputs that depend on real return values from Pulumi engine) no-op their flatMap/map chains in dry run similarly to Option's None (because there is no value to feed to the passed function) and therefore led to exports code not being executed in dry run at all, causing a diff showing that all exports are going to be removed in preview and then recreated in apply phase. New type of `Pulumi.run` function disallows returning of async values - Stack has to be returned unwrapped, synchronously. Stack is just a case class that takes only two arguments: `exports: Exports` and `dependsOn: Vector[Output[?]]`. `exports` serve the same purpose as before and `dependsOn` is used to _use_ all the `Outputs` that have to be evaluated for this stack to be constructed but are not to be exported. You can return a `Stack` that only consists of exports (for instance when everything you depend on is composed into a thing that you export in the final step) using `Stack.export(x = a, y = b)` or a `Stack` that has only dependencies when you don't want to export anything using `Stack(x, y)`. You can also use some resources and export others using `Stack(a, b).export(x = i, y = j)` syntax. Here's an example use of Stack: + + +```scala +@main def main = Pulumi.run { + val awsPolicy = aws.iam.Policy("my-policy", ...) + val s3 = aws.s3.Bucket("my-bucket") + val logMessage = log.info("Creating your bucket!") // logs are values too! + + Stack(logMessage, awsPolicy).exports( + url = s3.publicEndpoint + ) +} +``` + diff --git a/website/docs/components.md b/website/docs/components.md index 9aab0798..24e05527 100644 --- a/website/docs/components.md +++ b/website/docs/components.md @@ -43,11 +43,11 @@ def ZooVisit(date: OffsetDateTime)(using Context): Output[ZooVisit] = } @main def main = Pulumi.run { - ZooVisit(OffsetDateTime.now()).map { visit => - Pulumi.exports( - cats = visit.catPicsUrl, - parrots = visit.parrotPicsUrl - ) - } + val visit = ZooVisit(OffsetDateTime.now()) + + Stack.exports( + cats = visit.map(_.catPicsUrl), + parrots = visit.map(_.parrotPicsUrl) + ) } ``` diff --git a/website/docs/context.md b/website/docs/context.md index e7426ad8..1c5c1f3e 100644 --- a/website/docs/context.md +++ b/website/docs/context.md @@ -21,19 +21,17 @@ import besom.* // functions used in besom that are outside of `Pulumi.run` // have to have `(using Context)` parameter clause -def createAComponent(name: String)(using Context) = +def deployPostgres(dbName: String)(using Context): Output[Postgres] = ... // `Pulumi.run` <- the main entry point to a besom program @main def run = Pulumi.run { ... - val component = createAComponent("Stanley") + val component = deployPostgres("my-db") ... - for - _ <- component - yield Pulumi.exports( - aComponentUrn = component.urn + Stack.exports( + aComponentUrn = component.map(_.urn) ) } ``` \ No newline at end of file diff --git a/website/docs/exports.md b/website/docs/exports.md index b0aaf895..f8627102 100644 --- a/website/docs/exports.md +++ b/website/docs/exports.md @@ -6,19 +6,18 @@ Pulumi stack can export values as [Stack Outputs](basics.md#stack-outputs) to expose values to the user and share values between stacks using [Stack References](basics.md#stack-references). In other SDKs you are free to call an `export` method on the Pulumi Context object whenever you want in a program. -Besom's functional design disallows this - since **your program is a function**, exported keys and values have to be -the last value your main function returns. +Besom's functional design disallows this - since **your program is a function**, exported keys and values have to be a part of the final Stack value that your main function returns. -To export outputs from your stack use `Pulumi.exports`, e.g.: +To export outputs from your stack use `Stack.exports`, e.g.: ```scala 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 ) } diff --git a/website/docs/intro.md b/website/docs/intro.md index 91f9df39..d99a5b7b 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -39,7 +39,7 @@ To be able to do that, the API has to support **pure, lazy, functional evaluatio lowest common denominator. This means that there are some **small differences** in comparison to other Pulumi SDKs that are idiomatic to programs written in functional style, to name two: -- Stack exports are the return value of the main function of the program, +- Stack along with its exports are the return value of the main function of the program, - smaller chunks of the program have to be composed into the main flow of the program to be executed. #### Next steps: diff --git a/website/docs/laziness.md b/website/docs/laziness.md index f6b233e9..552f04f1 100644 --- a/website/docs/laziness.md +++ b/website/docs/laziness.md @@ -14,16 +14,20 @@ import besom.api.aws @main def main = Pulumi.run { val s3Bucket: Output[aws.s3.Bucket] = aws.s3.Bucket("my-bucket") - Output(Pulumi.exports()) + Stack.exports() } ``` -In this case the `s3Bucket` is only declared, but it's not composed into the main flow of the program -(it's never _mapped_ or _flatMapped_) so if you were to run `pulumi up` you'd see no resources in the deployment plan. +In this case the `s3Bucket` is only declared, but it's not used by the anything in your program: +* it's never _mapped_ or _flatMapped_ to derive another value +* it's not used by `Stack()` or exported using `Stack.export` +* it's never passed as an argument to another Resource + +If you were to run `pulumi up` you'd see no resources in the deployment plan. There are two ways to avoid this: **1.** if your infrastructure just depends on the resource being present, and you never use any of the properties that -are the Outputs of that resource you should manually compose it so that the resource constructor is evaluated: +are the Outputs of that resource you should pass it as an argument to the `Stack` it so that the resource constructor is evaluated: ```scala import besom.* import besom.api.aws @@ -31,11 +35,7 @@ import besom.api.aws @main def main = Pulumi.run { val s3Bucket: Output[aws.s3.Bucket] = aws.s3.Bucket("my-bucket") - for - _ <- s3Bucket - yield Pulumi.exports() - - // or just s3Bucket.map(_ => Pulumi.exports()) + Stack(s3Bucket) } ``` **2.** if your infrastructure or exports mention that resource or any of its properties Besom will implicitly pull @@ -47,7 +47,7 @@ import besom.api.aws @main def main = Pulumi.run { val s3Bucket: Output[aws.s3.Bucket] = aws.s3.Bucket("my-bucket") - Output(Pulumi.exports(s3Url = s3Bucket.map(_.websiteEndpoint))) + Stack.exports(s3Url = s3Bucket.map(_.websiteEndpoint)) } ``` This will also work if you pass any of the Outputs of any resource as inputs to another resource's constructor. @@ -55,10 +55,10 @@ This will also work if you pass any of the Outputs of any resource as inputs to To help you avoid mistakes with dangling resources that never get evaluated we are working on a feature that will warn you during dry run phase that there were resource constructor calls encountered during the evaluation of your program that were never composed back into the main flow of the Besom program. -Meanwhile, you can use the `-Wunused:all` compiler flag. +Meanwhile, it is strongly recommended to enable `-Wunused:all` compiler flag. There's also one more property of Besom's resource constructors that needs a mention here. Pure, functional programs are -expected to **execute side effects** as many times as they are executed. +expected to **execute side effects** as many times as the effect describing them is referenced. Let's see this on an example: ```scala @@ -69,12 +69,11 @@ import besom.api.aws val s3Bucket: Output[aws.s3.Bucket] = aws.s3.Bucket("my-bucket") val launchMissiles: Output[_] = Output { launch() } - for - _ <- s3Bucket - _ <- s3Bucket - _ <- launchMissiles - _ <- launchMissiles - yield Pulumi.exports() + Stack(s3Bucket, launchMissiles) + .export( + url = s3Bucket.map(_.websiteEndpoint) + fallout = launchMissiles + ) } ``` If we were to apply that understanding here we would expect that this program would create the S3 bucket twice diff --git a/website/docs/lifting.md b/website/docs/lifting.md index b291b599..958f4bb7 100644 --- a/website/docs/lifting.md +++ b/website/docs/lifting.md @@ -12,7 +12,7 @@ import besom.api.aws @main def main = Pulumi.run { val s3Bucket: Output[aws.s3.Bucket] = aws.s3.Bucket("my-bucket") - Output(Pulumi.exports(s3Url = s3Bucket.map(_.websiteEndpoint))) + Stack.exports(s3Url = s3Bucket.map(_.websiteEndpoint)) } ``` As you can see here we're accessing the property `websiteEndpoint` on `aws.s3.Bucket` class by first `map`ping over the @@ -26,7 +26,7 @@ extension (o: Output[aws.s3.Bucket]) This allows for this syntax: ```scala -Output(Pulumi.exports(s3Url = s3Bucket.websiteEndpoint)) +Stack.exports(s3Url = s3Bucket.websiteEndpoint) ``` These lifted syntaxes cover more cases and work recursively, so you can access even the properties diff --git a/website/docs/logging.md b/website/docs/logging.md index 64d082ab..86a4dcfe 100644 --- a/website/docs/logging.md +++ b/website/docs/logging.md @@ -7,17 +7,15 @@ logger by writing `log` with a following severity level used as a logging method ```scala @main def run = Pulumi.run { - for - _ <- log.warn("Nothing to do.") - yield Pulumi.exports() + Stack(log.warn("Nothing to do.")) } ``` -Logging is an asynchronous operation and returns an `Output`. This means that all logging statements need to be composed -into the flow of your program. This is similar to how logging frameworks for `cats` or `ZIO` behave (eg.: [log4cats](https://github.com/typelevel/log4cats)). +Logging is an asynchronous, effectful operation and therefore returns an `Output`. This means that all logging statements need to be composed +into other values that will eventually be either passed as `Stack` arguments or exports. This is similar to how logging frameworks for `cats` or `ZIO` behave (eg.: [log4cats](https://github.com/typelevel/log4cats)). ### Why not simply `println`? Given that you're working with CLI you might be tempted to just `println` some value, but that will have no visible effect. That's because Besom's Scala code is being executed in a different process than Pulumi. It's Pulumi that drives the -process by calling Besom. Therefore, you have to use functions provided by Besom for your code to have any effect. +process by calling Besom. Therefore, you have to use functions provided by Besom for your code to log anything. diff --git a/website/docs/tutorial.md b/website/docs/tutorial.md index 4703c1dd..671cfde7 100644 --- a/website/docs/tutorial.md +++ b/website/docs/tutorial.md @@ -89,9 +89,9 @@ Go ahead and open the `Main.scala` file. Inside you will see this snippet: import besom.* @main def main: Unit = Pulumi.run { - for - _ <- log.warn("Nothing's here yet, it's waiting for you to write some code!") - yield Pulumi.exports() + val warning = log.warn("Nothing's here yet, it's waiting for you to write some code!") + + Stack(warning) } ``` @@ -119,20 +119,18 @@ val feedBucket = s3.Bucket( ) ``` -You still need to add this resource to programs main flow so change this: +You still need to add this resource to program's main flow so change this: ```scala -for - _ <- log.warn("Nothing's here yet, it's waiting for you to write some code!") -yield Pulumi.exports() +val warning = log.warn("Nothing's here yet, it's waiting for you to write some code!") + +Stack(warning) ``` into this: ```scala -for - _ <- feedBucket -yield Pulumi.exports() +Stack(feedBucket) ``` Simple enough. Notice that what is done here is that you provide a name for Pulumi resource, not a name for the bucket @@ -241,13 +239,9 @@ has to be created before a public policy is actually applied to the bucket. Shou everything in parallel AWS API would return a `403 Forbidden` error. More about this topic can be found in [Resource constructors, outputs and asynchronicity](./constructors.md) section of the docs. -Please don't forget to add both resources to the final `for/yield`: +Please don't forget to add both resources to your `Stack`: ```scala - for - _ <- feedBucket - _ <- feedBucketPublicAccessBlock - _ <- feedBucketPolicy - yield Pulumi.exports() +Stack(feedBucket, feedBucketPublicAccessBlock, feedBucketPolicy) ``` If you run your program now you will have an empty but publicly accessible S3 bucket! @@ -297,7 +291,7 @@ val catPostTable = dynamodb.Table( ) ``` -That's all! DynamoDB is that easy to set up. Add the resource to the final `for/yield` and run it. +That's all! DynamoDB is that easy to set up. Add the resource to the final `Stack` and run it. ### AWS Lambdas @@ -383,7 +377,7 @@ The path is relative and points to pre-built packages by default. If you chose to rebuild the lambdas on your own you have to adjust the path so that it points to the relevant packages. -Add this to your program, add both lambdas to the final `for/yield`, run `pulumi up` and that's it, AWS Lambdas deployed. +Add this to your program, add both lambdas to the `Stack` at the end of your program, run `pulumi up` and that's it, AWS Lambdas deployed. ### AWS API Gateway @@ -546,12 +540,11 @@ Two things that need attention here is that API deployment has to be sequenced w [`deleteBeforeReplace` resource option](https://www.pulumi.com/docs/concepts/options/deletebeforereplace/) makes an appearance. These are necessary for AWS to correctly handle the deployment of these resources. -Ok, that's it. Add *all* of these resources to the final `for/yield` block and then modify -the [`exports`](./exports.md)) -block so that it matches this: +Ok, that's it. Add *all* of these resources to Stack and then modify +the [`exports`](./exports.md) so that it matches this: ```scala -Pulumi.exports( - feedBucket = bucket.bucket, +Stack(...).exports( + feedBucket = feedBucket.bucket, endpointURL = apiStage.invokeUrl ) ``` @@ -600,34 +593,34 @@ val addLambdaLogs = addLambda.name.flatMap { addName => ) } ``` -Again, remember to add them to the final `for/yield` block. +Again, remember to add them to the Stack at the end of the program. -### Addendum B - final `for/yield` block: +### Addendum B - final `Stack` block: -Here's how the final `for/yield` block looks like in a complete deployment: +Here's how the final `Stack` block looks like in a complete deployment: ```scala -for - bucket <- feedBucket - _ <- feedBucketPolicy - _ <- feedBucketPublicAccessBlock - _ <- catPostTable - _ <- feedLambdaLogs - _ <- addLambdaLogs - _ <- feedLambda - _ <- addLambda - _ <- api - _ <- feedLambdaPermission - _ <- addLambdaPermission - _ <- feedMethod - _ <- addResource - _ <- addMethod - _ <- feedIntegration - _ <- addIntegration - _ <- apiDeployment - apiStage <- apiStage - _ <- apiStageSettings -yield Pulumi.exports( - feedBucket = bucket.bucket, +Stack( + feedBucket, + feedBucketPolicy, + feedBucketPublicAccessBlock, + catPostTable, + feedLambdaLogs, + addLambdaLogs, + feedLambda, + addLambda, + api, + feedLambdaPermission, + addLambdaPermission, + feedMethod, + addResource, + addMethod, + feedIntegration, + addIntegration, + apiDeployment, + apiStage, + apiStageSettings +).exports( + feedBucket = feedBucket.bucket, endpointURL = apiStage.invokeUrl ) ``` diff --git a/website/sidebars.js b/website/sidebars.js index a8b66614..a97d605d 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -74,6 +74,11 @@ const sidebars = { id: 'templates', label: 'Pulumi templates', }, + { + type: 'doc', + id: 'changelog', + label: 'Changelog', + }, // { // type: 'doc', // id: 'intro',