この記事は、Pulumi dotnet Advent Calendar 2019の2日目です。
Getting Startedを一日目で見たので、PulumiでC# を使ってそもそもどのような風に書けばいいのかコンセプトを把握します。
疑問はそれから順次書きながら解消していきましょう。
概要
- 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プレビューでサポートされていない
基本構造
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
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
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を1つにまとめたいなら、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)); });