tech.guitarrapc.cóm

Technical updates

Pulumi のコンセプト - プログラミングモデル

この記事は、Pulumi dotnet Advent Calendar 2019 の2日目です。

qiita.com

Getting Started を一日目で見たので、Pulumi で C# を使ってそもそもどういう風に書けばいいのかコンセプトを把握します。

疑問はそれから順次書きながら解消していきましょう。

目次

TL;DR

  • Resource インスタンスを new するとリソースができる
  • Resourceインスタンスの実態は、name によって論理的に一意に区別されている (URNという)
  • ResourceArgs でリソース特有の値を差し込める (型によってドキュメントが不要)
  • ResourceOptions でリソースの共通オプションを指定できる
  • リソースを作成すると実際のリソースには Auto-naming でランダムな suffixが付く
  • リソースの生成で得られる予定の値は Output<T> で表現されている
  • リソースは他のリソースの依存性を自動的に解決するためOutput<T> に対応した Input<T> で値を受けることができる
  • Output<T>Input<T>T を渡すことができる (implicit による暗黙的型変換が用意されている)
  • リソースをまとめるときは ComponentsResource を使う
  • Providerを使うと他のアカウントなどプロバイダーを明示的に参照することもできる
  • DynamicProvidersなどの機能は .NET プレビューではサポートされていない

基本構造

Programming Model

Resource インスタンスの初期化時に、name, args, options を指定する。

var res = new Resource(name, args, options);

All resources have a name, which must be unique in the Pulumi program.

The args provided to a resource determine what inputs will be used to initialize the resource. These can be either raw values or outputs from other resources.

リソースは、name で区別されているのが特徴。

Resource options

Options は、pulumi がどのようにリソースを制御するかの指定に使う。

  • additionalSecretOutputs

出力がsecret扱いになってないけどsecretにしたいときに使う。

// Ensure the password generated for the database is marked as a secret
var db = new Database("new-name-for-db", new DatabaseArgs(), new CustomResourceOptions { AdditionalSecretOutputs = { "password" } });
  • alias

旧リソース名が新しいリソースに変更になる場合、ただリソースを作ると、前のリソースを消して新しく作る。が、Alias を使うことで前のリソースはこのリソースにmigrateしたことを示すことができる。(作り直さない。)

// Provide an alias to ensure migration of the existing resource.
var db = new Database("new-name-for-db", new DatabaseArgs(), new ResourceOptions { Aliases = { new Alias { Name = "old-name-for-db"} } });
  • customTimeouts

めっちゃ時間がかかるリソースの待機をしたいときや、さくっと失敗が時間で判別できるときに使う。 適用できるオペレーションは、create, update, delete

文字列で “5m”, “40s”, “1d” という指定 customTimeouts: { create: "1m" } がほか言語で使われるが、C# なら TimeSpan でok。

// Wait up to 30m for the database to be created
var db = new Database("db", new DatabaseArgs(), new ResourceOptions { CustomTimeouts = new CustomTimeouts { Create = TimeSpan.FromMinutes(30) } });
  • deleteBeforeReplace

リソースの入れ替え時に先に現在のリソースを消してから新しく作る。当然ダウンタイムが強制されるが、一部リソースはこの対応が必要。defaultは、false。

// The resource will be deleted before it's replacement is created
var db = new Database("db", new DatabaseArgs(), new CustomResourceOptions { DeleteBeforeReplace = true });
  • dependsOn

基本的に pulumi では、そのリソースで他リソースを参照している場合依存が暗黙的にに解決される。 この dependsOnを使うのは、リソースで直接参照されてないけど、実は依存があるときの明示的な指定。

var res1 = new MyResource("res1", new MyResourceArgs());
var res2 = new MyResource("res2", new MyResourceArgs(), new ResourceOptions { DependsOn = { res1 } });
  • ignoreChanges

このオプションを使うことで、create作成時にはプロパティを使うけど、update更新時には無視したいプロパティを指定できる。 つまり、作成はするけど、k8sなどで自動的に値が変わるから pulumi上では変更したくない、k8sに任せたいときが該当する。

// Changes to the value of `prop` will not lead to updates/replacements
var res = new MyResource("res", new MyResourceArgs { prop = "new-value" }, new ResourceOptions { IgnoreChanges = { "prop" } });
  • import

既存リソースの id を指定することでpulumi で管理ができるようになる。これが設定されるとプロバイダーにある現在のリソースを見に行って食える。取り込むときに、コンストラクタは対象の状態とずれてないようにしないと import が失敗します。 一度取り込まれたらこのプロパティは必ず外します。

// The input properties must match the values for the exsiting resource `my-database-id`
var db = new Database("db", new DatabaseArgs { /*...*/ }, new CustomResourceOptions { ImportId = "my-database-id" });
  • parent

リソースの親を設定できます。親に関しては、詳しくは Component 参照。

var parent = new MyResource("parent", new MyResourceArgs());
var child = new MyResource("child", new MyResourceArgs(), new ResourceOptions { Parent = parent });
  • protect

リソースを保護します。 保護すると、直接削除ができなくなり pulumi destroy などでも消せなくなります。 親コンポーネントから設定は受け継ぐのと、 parentが設定されてない場合はデフォルトでfalse です。

var db = new Database("db", new DatabaseArgs(), new ResourceOptions { Protect = true });
  • provider

リソースの providerを指定します。

var provider = new Aws.Provider("provider", new Aws.ProviderArgs { Region = "us-west-2" });
var vpc = new Aws.Ec2.Vpc("vpc", new Aws.Ec2.VpcArgs(), new ResourceOptions { Provider = provider });
  • transformations

デフォルトで tag 足したりできるっぽいけど、ちょっといまいちわからないので TODO

[TBD]

Auto-naming

Auto-naming: Programming Model

pulumi でリソースを作成すると、デフォルトで auto-named 、つまり my-role と作っても my-role-d7c2fa0 みたいになる。

random suffix を追加する理由は2つ。

  • 同じプロジェクト内で2つスタックがあったときに名前の競合でデプロイが失敗することがないように。
    • プロジェクト内で複数のインスタンスを作るのが簡単になるのでよさみ。スケーリングとか、テストとかでよく作るじゃろ?
    • もし auto-naming がなかったら自分で suffixを考えて対応しないといけないじゃろ。
  • pulumi で 0 downtime アップデートをするため。
    • リソースを更新するときに、一部の更新は入れ替えを必須とします。pulumi が auto-naming をすることで、リソースを新規に作ってから、古いリソースを消すことが可能になり処理がシンプルになります。auto-naming がないと、今のリソースを消してから作成することになって、ダウンタイム必須になるじゃろ。

もし Auto-naming が嫌なら実リソースの手動で名前を指定可能です。リソースの Args にある、Name プロパティを使いましょう。

var role = new Aws.Iam.Role("my-role", new Aws.Iam.RoleArgs
{
    Name = "my-role-001",
});

もし Name プロパティがそのリソースのArgsにない場合は、個別にリソースを見てください。例えばS3 Bucket の場合、Nameじゃなくて Bucket 担っています。

実リソースと論理リソース名は一致する必要がないのでこういうのでもok

var role = new Aws.Iam.Role("my-role", new Aws.Iam.RoleArgs
{
    Name = "my-role-" + Deployment.Instance.ProjectName + "-" + Deployment.Instance.StackName,
});

URNs

URNs - Programming Model

Unique Resource Name (URN) は、リソースの作成時に自動的に生成されます。

urn:pulumi:thumbnailer-twitch::video-thumbnailer::cloud:bucket:Bucket$cloud:function:Function::onNewThumbnail
urn:pulumi:    <stackname>   ::  <projectname>  ::    <parenttype>   $     <resourcetype>    ::<resourcename>

将来的に、よりシンプルになったり複雑になる可能性があるので注意

URNに変更がある = 新旧リソースに関連がないとみなされて、古いリソースは削除、新規リソースは作成扱いになります。 例えば、リソースのコンストラクタの name を変更するとかがソレです。

Outputs と Inputs

もし出力を変更したい場合は、Apply メソッドを使います。

例えば、VMの DnsName 出力に https: をつけないなら

var url = virtualmachine.DnsName.Apply(dnsName => "https://" + dnsName);

現時点で .NETは、Outputs のプロパティアクセスがサポートされないので注意。

こういうのは C# では書けない。

let certCertificate = new aws.acm.Certificate("cert", {
  domainName: "example.com",
  validationMethod: "DNS",
});
let certValidation = new aws.route53.Record("cert_validation", {
  // Need to pass along a deep subproperty of this Output
  records: [certCertificate.domainValidationOptions.apply(domainValidationOptions => domainValidationOptions[0].resourceRecordValue)],
  • All

もし Outputs を一つにまとめたいなら、All を使います。が、C# で Output.All が使えるのがすべての出力の型が同じ場合のみ(Output<string> とか) なので、Output.Tuple で得られる Tuple から Apply で生成するほうがいい説があります。

// In .NET 'Output.Tuple' is used so that each unwrapped value will preserve their distinct type.
// 'Output.All' can be used when all input values have the same type (i.e. all are Output<string>)
var connectionString = Output.Tuple(sqlServer.name, database.name)
                             .Apply(t => `Server=tcp:${t.Item1}.database.windows.net;initial catalog=${t.Item2}...`);
  • Convert Input to Output

Input をそのまま Output で使いたい場合は、.ToOutput() します。

Output<string[]> Split(Input<string[]> input)
{
    var output = input.ToOutput()
    return output.Apply(v => v.Split(","));
}
  • Outputs の文字列で毎回 Apply 書きたくない
Output<string> hostName = // get some Output
Output<int> port = // get some Output

// Would like to produce a string equivalent to: http://{hostname}:{port}/
var url = // ?

というケースを考えます。

先ほどの例では、var url = pulumi.Tuple(hostname, port).Apply(t =>http://{t.Item1}:{t.Item2}/); 感じになるのが予想できますが、めんどくさいので、Output.Format()が公開されているのでこっちで。

// In .NET 'Interpolate' is called 'Format'.
var url2 = Output.Format($"http://{hostname}:{port}/");

Secrets の読み込み

  • Config からの読み込みでは、Config.GetSecret(key)Config.REquireSecret(key) を使います。
  • 出力から生成するには、Output.CreateSecret(value) を使います。

InSecrue に Parameter Store に突っ込むときとこうなります。

var cfg = new Pulumi.Config()
var param = new Aws.Ssm.Parameter("a-secret-param", new Aws.Ssm.ParameterArgs
{
    type = "SecureString",
    value = cfg.Require("my-secret-value"),
});

Secure にするならこう。

var cfg = new Pulumi.Config()
var param = new Aws.Ssm.Parameter("a-secret-param", new Aws.Ssm.ParameterArgs
{
    type = "SecureString",
    value = cfg.RequireSecret("my-secret-value"),
});

もし明示的に出力をSecret にするなら、Argsにあった additionalSecretOutputs を使います。

Stack の出力

エントリーポイントで次の出力を行えばok

return new Dictionary<string, object> { { "url", resource.Url } };

CLI 上では、pulumi stack output url でurl を出力できます。

出力は JSON なので、Dictionary で出力すると値によってはクォートなしで出力されます。

// The dictionary returned by the function passed to Deployment.Run will be used to provide all the exported values.
static Task Main() =>
    Deployment.Run(async () =>
    {
        return new Dictionary<string, object>
        {
            { "x", "hello" },
            { "o", new Dictionary<string, int> { { "num", 42 } } },
        };
    });

なら

$ pulumi stack output x
hello
$ pulumi stack output o
{"num": 42}

まとめて JSON 出力したいなら

pulumi stack output --json
{
  "x": "hello",
  "o": {
      "num": 42
  }
}

また、Dictionary の value が object となっていますが、JSON に直変換できるもの以外は String として出さないとエラーが出ます。 例えば、char を出力しようとすると

    error: Running program 'C:\git\xxxxx\xxxxx\pulumi\bin\Debug\netcoreapp3.0\Infra.dll' failed with an unhandled exception:
    System.InvalidOperationException: System.Char is not a supported argument type.

Stack 参照

現時点で C# はサポートされてません。 ほかの Stack で定義した内容を別のStack で使うことはできない。

Complete Support for .NET · Issue #3470 · pulumi/pulumi · GitHub

Config 参照

pulumi config set で設定したコンフィグを参照するには、config.Require(name) とします。

var config = new Pulumi.Config();
var name = config.Require("name");
Console.WriteLine($"Hello, {name}!");

return new Dictionary<string, object> {
    { "config-name", name}
};

設定されてなければエラーが出ます。

    error: Missing Required configuration variable 'pulumi:name'
        please set a value using the command `pulumi config set pulumi:name <value>`
$ pulumi config set name foo
$ pulumi up
  + config-name   : "foo"
$ pulumi config rm name

JSON オブジェクトを config に設定して読みだすには、RequireObject<System.Text.Json.JsonElement>() を使います。

ただし、普通に登録しようとするとJSONの中で使っている"" が外れる模様。

TIPS : CMD を使って'{}' でJSON 登録しようとしてもトラップなので注意

# これがWindows cmd ではダメ (powershell ではok)
> pulumi config set data '{"active": true, "nums": [1,2,3]}'
# これだと JSON として不正に登録
> pulumi config set data "{"active": true, "nums": [1,2,3]}"
> pulumi config
data        {active: true, nums: [1,2,3]}
> pulumi up
error: Configuration 'pulumi:data' value '{active: true, nums: [1,2,3]}' is not a valid System.Text.Json.JsonElement

これでok

# 前は"" で2つ、後ろは"""" で4つ
> pulumi config set data "{""active"""": true, ""nums"""": [1,2,3]}"
> pulumi config get data
{"active": true, "nums": [1,2,3]}

TIPS: PowerShell も CMD と同様のエスケープが必要。ただし、'{}' でくくること。

PS> pulumi config set data '{"active": true, "nums": [1,2,3]}'
PS> pulumi config
KEY         VALUE
data        {active: true, nums: [1,2,3]}
PS> pulumi config get data
{active: true, nums: [1,2,3]}
PS> pulumi up
error: Configuration 'pulumi:data' value '{active: true, nums: [1,2,3]}' is not a valid System.Text.Json.JsonElement

これでok

# 前は"" で2つ、後ろは"""" で4つ
PS> pulumi config set data '{""active"""": true, ""nums"""": [1,2,3]}'
PS> pulumi config get data
{"active": true, "nums": [1,2,3]}

あるいはダブルクォート使うならこう

PS> pulumi config set data "{`"`"active`"`"`"`": true, `"`"nums`"`"`"`": [1,2,3]}"
PS> pulumi config get data
{"active": true, "nums": [1,2,3]}

RequireObject<System.Text.Json.JsonElement>() でデシリアライズかけているので、出力時にJSONに対応する型にすること。

            var config = new Config();
            var data = config.RequireObject<JsonElement>("data");
            var active = data.GetProperty("active").GetBoolean();
            var num0 = data.GetProperty("nums")[0].GetInt32();

            // Export the name of the bucket
            return new Dictionary<string, object> {
                { "bucket_name", bucket.Id },
                { "config-active", active },
                { "config-num0", num0 },
            };

Components

リソースをグルーピングして他から参照して使うのに利用するのが Components。 terraform でいうところの modules に該当する。

C# では Pulumi.ComponentResource クラスを継承して利用する。

この時、.ctor(string type, string name, ResourceOptions? options = null) : base(type, name, options) の実装が求められるのでここに処理を書く。 Components からの出力は、this.RegisterOutputs(Dictionary<string, object>) で返す。

    class EksClusterResource : Pulumi.ComponentResource
    {
        public EksClusterResource(string type, string name, ResourceOptions? options = null) : base(type, name, options)
        {
            // initialization logic
            var bucket = new Pulumi.Aws.S3.Bucket($"{name}-bucket", new Pulumi.Aws.S3.BucketArgs(), new CustomResourceOptions { Parent = this });

            this.RegisterOutputs(new Dictionary<string, object>
            {
                { "bucketDnsName", bucket.BucketDomainName }
            });
        }
    }

利用するときは、type、name、ResourceOptions を指定する。

            var component = new EksClusterResource("ekscluster", "sandbox", new ComponentResourceOptions { });

これで、指定した type、name に基づいてコンポーネントが生成されていることがわかる。

> pulumi up

Previewing update (dev):
     Type                 Name        Plan     Info
     pulumi:pulumi:Stack  pulumi-dev           'dotnet build .' completed successfully����܂
     Type                 Name            Plan
     pulumi:pulumi:Stack  pulumi-dev
 +   ├─ ekscluster        sandbox         create
 +   │  └─ aws:s3:Bucket  sandbox-bucket  create
 +   ├─ aws:kms:Key       my-key          create
 +   └─ aws:s3:Bucket     my-bucket       create

Outputs:
  + bucket_name  : output<string>
  + config-active: true

Resources:
    + 4 to create
    1 unchanged

Provider

別のプロバイダーを指定して参照するのに使います。

using Pulumi;
using Pulumi.Aws;

class Program
{
   async Task Main() =>
       Deployment.Run(() =>
       {
           // Create an AWS provider for the us-east-1 region.
           var useast1 = new Aws.Provider("useast1", new Aws.ProviderArgs { Region = "us-east-1" });

           // Create an ACM certificate in us-east-1.
           var cert = new Aws.Acm.Certificate("cert", new Aws.Acm.CertifiateArgs
           {
               DomainName = "foo.com",
               ValidationMethod = "EMAIL",
           }, new ResourceArgs { Provider = useast1 });

           // Create an ALB listener in the default region that references the ACM certificate created above.
           var listener = new Aws.Lb.Listener("listener", new Aws.Lb.ListenerArgs
           {
               LoadBalancerArn = loadBalancerArn,
               Port = 443,
               Protocol = "HTTPS",
               SslPolicy = "ELBSecurityPolicy-2016-08",
               CertificateArn = cert.arn,
               DefaultAction: new Aws.Lb.ListenerDefaultAction
               {
                   TargetGroupArn = targetGroupArn,
                   Type = "forward",
               },
           });
       });
}

DynamicProviders

https://www.pulumi.com/docs/intro/concepts/programming-model/#dynamicproviders

C# では現状サポートなし

Runtime code

https://www.pulumi.com/docs/intro/concepts/programming-model/#runtime

実行時の処理実行はC# ではサポートされていない。

let bucket = new aws.s3.Bucket("mybucket");
bucket.onObjectCreated("onObject", async (ev: aws.s3.BucketEvent) => {
    // This is the code that will be run when the Lambda is invoked (any time an object is added to the bucket).
    console.log(JSON.stringify(ev));
});

REF

Programming Model