Documentation
Design
Runtime Access to Values Generated by Infrastructure Code at Compile Time

Runtime Access to Values Generated by Infrastructure Code at Compile Time

Pluto programs are split into multiple functions at compile time, which are then deployed to cloud platforms by adapters. The most common deployment method is: a set of infrastructure definition code (hereafter referred to as IaC code) is generated by the generator, and then the adapter executes the IaC code using the corresponding provisioning engine. Alternatively, for runtime environments that do not support IaC engines (such as local simulation environments), the adapter may directly call the APIs of the runtime environment to build the infrastructure environment and deploy all functions of the Pluto program based on the architecture reference.

After a Pluto program is deployed, the deployed function instances access certain methods or properties of resource objects at runtime to interact with resource instances. However, the values of resource object properties may be returned by the platform after executing the IaC code or calling the runtime environment API, such as the URL of ApiGateway. These property values cannot be obtained based solely on the information provided by the user in the program. Therefore, a mechanism needs to be built to allow runtime functions to access values generated by infrastructure code at compile time.

This document is designed to establish such a mechanism to solve this problem. Firstly, the basic idea of solving this problem will be introduced, then an idealized example will be used to see what the best interface provided to users looks like, and finally, the implementation idea will be given based on the functional requirements.

Solution

The basic idea to solve this problem is to pass the generated values at compile time through environment variables. The premise is: the ability to configure the environment variables of function instances at compile time.

The specific process is, for the compile-time generated values that a function instance depends on, to construct a pair of environment variable keys and values, fill the generated values into the environment variables of the function instance at compile time, so that the function instance can read this environment variable at runtime. The key of the environment variable is consistent at compile time and runtime, thus achieving the transfer of generated values from compile time to runtime.

Deployment Based on Provisioning Engine

For the first deployment method mentioned above, i.e., deployment based on the provisioning engine, we can use the orchestration capabilities provided by the provisioning engine itself for specific implementation.

When writing infrastructure definition code, infrastructure configuration languages (such as Pulumi, HCL, etc.) usually support configuring the environment variables of function instances and support Lazy types as the values of environment variable key-value pairs. For example, Pulumi supports pulumi.Input<string> and pulumi.Output<string> types as the values of environment variables when configuring AWS Lambda instances. The final values of these two types depend on other resource instances, i.e., values that can only be known after other resource instances are created. Therefore, the use of Lazy types will constitute dependencies between different resource instances, but we do not need to care about these dependencies, because the provisioning engine will handle them. When executing the infrastructure definition code, the provisioning engine will create different instances in a reasonable order to ensure that all dependent Lazy values have been obtained before creating a certain function instance.

Therefore, Pluto's implementation idea is: in the generated infrastructure definition code, the compile-time generated values that the function instance depends on, are constructed into Lazy type variables and added to the environment variables of the function instance; at runtime, the environment variables are obtained. This completes the transfer of generated values from compile time to runtime.

Deployment by Direct API Call

For the second deployment method mentioned above, i.e., deployment by direct API call, the idea is consistent with the first deployment method, but it needs to handle the dependencies between resource instances independently, i.e., the adapter under this deployment method needs to independently analyze and determine the topological relationship and creation order between resource instances, to ensure that all the compile-time generated values that a certain function instance depends on have been obtained before it is created.

Note: No matter which deployment method, there should be no circular dependencies between resources, otherwise it will not be possible to successfully build the infrastructure environment.

Ideal Scenario

Ideal Example
const router = new Router();
 
router.get("/echo", async (req: HttpRequest): Promise<HttpResponse> => {
  const message = req.query["message"];
  return { statusCode: 200, body: message ?? "No message" };
});
 
// Can be used to display the resource's information to the user.
console.log(`This website's url is`, router.url);
 
const tester = new Tester("e2e");
 
tester.test("test echo", async () => {
  // Can be used in the unit testing to verify the correctness of business logic.
  const res = await fetch(router.url + "/echo?message=Hello%20Pluto!");
  const body = await res.text();
  expect(res.status).toBe(200);
  expect(body).toBe("Hello Pluto!");
});

Functions usually depend on a certain property of resource objects to achieve their functional goals. For example, in this example, router.url is accessed twice, once in the global scope to feedback to the user what the access address is after deployment by printing router.url; the other time is in the tester.test method, using router.url to complete the correctness check of business logic. The url property of router can only be known after router is created, which is the situation we have been focusing on.

Here, we use accessing the property values of resource objects as the only way for users to access the values generated at compile time, that is, we constrain the values generated at compile time to the properties of resource types. Therefore, the problem in this article is transformed into constructing a mechanism that allows functions to access the property values of resource objects generated at compile time at runtime. Of course, it is still implemented based on environment variables.

Implementation Idea

Rules for Generating Environment Variable Names

Each resource has a unique ID resourceId in the project, and each property of a resource type has a unique name. Therefore, at runtime and compile time, for a specific property of a specific resource object, the name of its corresponding environment variable is agreed to be upperCase(resourceType + "_" + resourceId + "_" + propertyName). A utility function propEnvName is provided in the specific implementation to generate this name.

utils.ts
export function propEnvName(resourceType: string, resourceId: string, propertyName: string) {
  const envName = `${resourceType}_${resourceId}_${propertyName}`
    .toUpperCase()
    .replace(/[^a-zA-Z0-9_]/g, "_");
  return envName;
}

For the router in the above example, assuming its resource type is @plutolang/pluto.Router, ID is myRouter, then the environment variable name corresponding to router.url is _PLUTOLANG_PLUTO_ROUTER_MYROUTER_URL.

CapturedProperty Interface Identifies Special Properties

Infra API and Client API are two types of interface methods that need to be detected and handled at compile time. Similarly, the property values of this type of resource type that we are concerned about in this article also need special attention at compile time. Because only when the properties that each function instance depends on are reflected in the reference architecture of the program, can we configure them in the generated infrastructure definition code, or inform the Adapter. Therefore, we define an interface called CapturedProperty, and the getter methods contained in the sub-interfaces that extend this interface are the properties that can only be known after creation.

For deployment based on the provisioning engine, we will generate infrastructure definition code. At compile time, this type of property is configured into the environment variables of function instances, and at runtime, the specific values are read from the environment variables. The operation at compile time is related to the Infra SDK, and the operation at runtime is related to the Client SDK. Therefore, this type of property is related to both types of SDKs, which causes complexity in implementation.

In order to avoid as much as possible the problems caused by complexity, we agree that both the Client SDK and the Infra SDK implement the CapturedProperty interface, but their functions and behaviors are not the same. The implementation in the Client SDK is only responsible for reading environment variables and returning them to the business code after parsing. The implementation in the Infra SDK is only responsible for constructing the Lazy type object of this property. The configuration process of environment variables is in the creation phase of the calculation closure. In this phase, the properties of the resource object will be called, and the obtained Lazy object will be added to the dependency list of the calculation closure, and finally configured to its corresponding function instance.

For deployment by direct API call, its deployment is unrelated to the Infra SDK, and the adapter needs to obtain the properties of the resource that each function instance depends on by itself, and configure it into the environment variables of the function instance.

Generation Strategy of Infrastructure Definition Code

  1. The deducer infers the values of the properties of the resource objects that each calculation closure depends on according to CapturedProperty, and the inference results are reflected in the reference architecture of the program.
    1. Inference method: Detect whether the property values of resource objects have been accessed, and the properties are in the interface that extends the CapturedProperty interface.
  2. The generator generates infrastructure definition code according to the reference architecture. When constructing the calculation closure object, it adds the dependencies of the calculation closure, including property access and Client API calls, to the dependencies property of the calculation closure. The Infra API constructs a function resource instance and extracts all dependencies of the calculation closure object. If it is a property dependency, it will construct a key-value pair and configure it into the environment variables of the function instance.

The following code is the infrastructure definition code corresponding to the example code in the ideal scenario.

const router = new Router();
 
import { default as closure_fn_1 } from "./clourse_1";
const closure_1 = closure_fn_1 as ComputeClosure & typeof closure;
closure_1.filepath = "./clourse_1/index.ts";
closure_1.dependencies = [];
router.get("/echo", closure_1);
 
const tester = new Tester("e2e");
 
import { default as closure_fn_2 } from "./clourse_2";
const closure_2 = closure_fn_2 as ComputeClosure & typeof closure;
closure_2.filepath = "./clourse_2/index.ts";
closure_2.dependencies = [
  {
    resourceObject: router,
    resourceType: "@plutolang/pluto.Router",
    type: "property",
    method: "url",
  },
];
tester.test("test echo", closure_2);
 
import { default as closure_fn_main } from "./main_closure";
const main_closure = closure_fn_main as ComputeClosure & typeof closure;
main_closure.filepath = "./main_closure/index.ts";
main_closure.dependencies = [
  {
    resourceObject: router,
    resourceType: "@plutolang/pluto.Router",
    type: "property",
    method: "url",
  },
];
new Function(main_closure);

Usage Process

Next, we will take the router.url property in the ideal scenario as an example, and based on Pulumi and AWS ApiGateway implementation, to illustrate the process related to the topic of this article when SDK developers develop Infra SDK and Client SDK.

Overall Steps

  1. In the class declaration file of the Router resource type, define the RouterCapturedProperty interface, which extends the CapturedProperty interface of the base library and contains the get url() method.
  2. The Router implementation class in Infra SDK implements the RouterCapturedProperty interface, and the implementation content is: get the value of the corresponding property at compile time, which may be a Lazy value.
  3. The Router implementation class in Client SDK implements the RouterCapturedProperty interface, and the implementation content is: get the value of the corresponding environment variable based on the resource type, resource object ID, and property name, and return it to the user. If it is a complex type of value, then parse it in this method and then return it to the user.

Interface Definition

In the Router's class declaration file @plutolang/pluto/src/router.ts, define the RouterCapturedProperty interface, which contains the getter method of url, and is extended by the Router interface. In this way, developers can pass the type check of TypeScript and have completion prompts during business development.

router.ts
export interface RouterCapturedProperty extends CapturedProperty {
  get url(): string;
}
 
export interface RouterClientApi extends ResourceClientApi {}
 
export interface RouterInfraApi extends ResourceInfraApi {
  get(path: string, fn: RequestHandler): void;
  // more methods...
}
 
export interface Router extends RouterClientApi, RouterInfraApi, RouterCapturedProperty {}

Client SDK Implementation

The Router implementation class in the Client SDK implements the RouterCapturedProperty and RouterClientApi interfaces. In the url method, it calls the utility function provided by the base library to get the value of the environment variable and returns it to the user. If it is a complex type of value, then parse it in this method and then return it to the user.

@plutolang/pluto:router.apigateway.ts
export class ApiGatewayRouter implements RouterClientApi {
  // ...
  public get url(): string {
    return getGeneratedPropertyValue(
      /* Resource type */ Router.name,
      /* Resource id */ this.id,
      /* Method name */ "url"
    );
  }
}
 
// utils.ts
export function getGeneratedPropertyValue(
  resourceType: string,
  resourceId: string,
  propertyName: string
): string {
  const envName = propEnvName(resourceType, resourceId, propertyName);
  const value = process.env[envName];
  if (!value) {
    throw new Error(
      `The '${propertyName}' of the '<${resourceType}>${resourceId}' cannot be found.`
    );
  }
  return value;
}

Infra SDK Implementation

The implementation class of Infra SDK constructs the Lazy value of the property and returns it. Since the URL's exact value cannot be obtained at compile time, but is of the pulumi.Output<string> type, it needs to be forcibly modified to any to pass the type check of TypeScript.

@plutolang/pluto-infra:router.apigateway.ts
export class ApiGatewayRouter
  extends pulumi.ComponentResource
  implements RouterInfraApi, ResourceInfra
{
  // ...
  public get url(): string {
    return pulumi.interpolate`https://${this.apiGateway.id}.execute-api.${region}.amazonaws.com/${DEFAULT_STAGE_NAME}` as any;
  }
}