Hetzner Pulumi Intro
The full configuration for this article can be visited here: https://github.com/shibumi/infra/tree/pulumi-migration
This weekend I had finally some time to have a longer glimpse on Hetzner and Pulumi. Pulumi sparked my interest for a pretty long time now after reading Engin’s blog post about pulumi and Microsoft Azure. I tried Pulumi earlier, but I gave up pretty fast, because it had no Netlify support. The missing Netlify support did not change, but I did not want to invest time in my Terraform configuration, hence I decided to have a look on Pulumi instead.
So, what is Pulumi? Pulumi is just another infrastructure as code tool, but this Pulumi is more serious about the code aspect of it. You may know tools like Hashicorp Terraform already. Hashicorp states that Terraform is infrastructure as code and although the Hashicorp configuration language (HCL) may be turing complete (is it?!) I would not really consider it as infrastructure as code. What I always disliked about Terraform was that HCL felt more like a configuration language than a programming language. For me it always felt like JSON on steroids with lots of syntax sugar and additional templating features. It did not really feel like Code. Pulumi does this all different by providing a client, an optional web service and real programming libraries. The latter in that list is the game changer. With Pulumi it is possible to use your favorite programming language and finally do what infrastructure as code should be like: You define in your infrastructure in a high level programming language. The supported languages are Node.js, Python, Go and .NET Core. Most libraries in Pulumi have been imported from Terraform modules (I wonder how Hashicorp feels about this) and the bigger libraries are rewritten from scratch as Pulumi native library. Today, I would like to showcase Pulumi a little bit with setting up a server at the Hetzner Cloud. I choose Hetzner, because I think there were enough hyperscaler tutorials.
Let us start with initializing the Pulumi client. Pulumi keeps, similar to Terraform, a state. This state can be stored on your local machine or in the cloud.
If you are very paranoid about your secrets you can enable local storage via executing pulumi login --local
. This command will initialize the Pulumi state in your home directory
at $HOME/.pulumi
. You can skip this command if you prefer the Pulumi web service as state storage. In a production environment, I would suggest storing the state within your cloud provider
or within Pulumi.
My favorite programming language is Go, right now. The following lines initialize a new Go pulumi project:
❯ pulumi new go
This command will walk you through creating a new Pulumi project.
Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.
project name: (infra) infra
project description: (A minimal Go Pulumi program) my private infrastructure
Created project 'infra'
stack name: (dev)
Created stack 'dev'
Enter your passphrase to protect config/secrets:
Re-enter your passphrase to confirm:
Enter your passphrase to unlock config/secrets
(set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):
Installing dependencies...
Finished installing dependencies
Your new project is ready to go!
To perform an initial deployment, run 'pulumi up'
One of the first aspects I like about Pulumi is that the state is encrypted on default. Next, we are going to have a look on our custom layout. Due to the infrastructure as code philosophy, we can fully customize the layout of our project. My current infrastructure project is as follows:
.
├── assets
│ └── cloud-config
│ └── ritsuko.yaml
├── go.mod
├── go.sum
├── internal
│ ├── cloudconfig.go
│ ├── config.go
│ └── helper.go
├── main.go
├── Pulumi.dev.yaml
└── Pulumi.yaml
The assets directory has a sub-directory with cloud configuration files. Pulumi.yaml is the main configuration file of the project and
Pulumi.dev.yaml is the configuration file for the stack dev
. Stacks are different environments (Dev, Stage, Production).
Our goal for this little article is to get a configuration from our Pulumi.dev.yaml file, read all files in the cloud-config
directory and use this cloud-config files to create servers.
After initialization of the project you just have a main.go file and the two Pulumi configuration files. The main.go should look like this:
package main
import (
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
return nil
})
}
For further development you just have to extend the pulumi.Run
method. But, first we are going to add some variables to the dev stack configuration file:
encryptionsalt: <REDACTED>
config:
infra🔑
id: "chris@motoko"
publicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDHlRfwIYqaqWfh5ObpCV5pA+n+KolK64LZ5VyIi5ZjwtEswkDI6KcGDTGcWYY+/XJ42kj7SbYHSCm4t/HAXHgmKDuQPzq72nVY7G1DjYrArGig9ni0/XCJY64s5oBgW8wVPTnbf/wYo+gHqsXO7ZaJKknW7jybmIiMC9hx+BkGugyT2WdVnI/8fXiR7VBArSfPIT/ieuWi+GR/7nIz6X09d77pY+tZzeOfbm2obU3EsIh8KJzoZeeopqOFnxooTGtk3ifL8Sv154KDzPwRnaGKdwd36aljQharAUkRQS3bVZiRx1Jw19+1XT0a8/D70ilAKMX6ilUa+LO9jObd49pUSitVGN6gHV5LBybbXjdaLe62dN9gRttJ24KjoJer1o2PMRxNxjwGgksPXhcyfBgbxmNOnsGYZ90PFdp3CH3eh9V8wmwj/ATPnX0s7pAVIpJt6lvdMfoZoezWhk/N0e0GpLWc7hmhxmEP0GYp5+oLL4n5wGr39uOsjPeqpx5c+QJPgIk0cJpKW8gVOw5T8e72v6r44APy9+XLTx2rAwfKeTBwyQo/yiGRo+gEdrPROOl9bei+eGFApJLtHPGqMP5PzMpY1A67z3D4tZ8zPoIqDoos5O6k04aXkbHjNCOkbwY29PfqZzqZmEo+FGDAhqwzpfc/7e7vDJTLWusIxxaaOQ== chris@motoko"
infra:cloudConfigPath: "assets/cloud-config"
Keys in the pulumi world always have a namespace and an identifier namespace:identifier
. infra
is our default namespace, because our project has the name infra
.
For Hetzner cloud access we can add the Hetzner cloud token to the configuration and import the pulumi Hetzner package:
$ pulumi config set hcloud:token XXXXXXXXXXXXXX --secret
$ go get github.com/pulumi/pulumi-hcloud/sdk/go/hcloud@latest
With this configuration in place we can now continue with our main method and add our first SSH public key to Hetzner:
package main
import (
"github.com/pulumi/pulumi-hcloud/sdk/go/hcloud"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// read configuration file
var pubKey internal.SSHPublicKey
pulumiConf := config.New(ctx, "") // namespace "" refers to the default project namespace "infra"
pulumiConf.RequireObject("key", &pubKey) // read infra:key object
cloudConfigPath := pulumiConf.Require("cloudConfigPath") // read infra:cloudConfigPath string
// create Hetzner SSH Public Key
sshKey, err := hcloud.NewSshKey(ctx, pubKey.ID, &hcloud.SshKeyArgs{
Name: pulumi.String(pubKey.ID),
PublicKey: pulumi.String(pubKey.PublicKey),
})
return nil
})
}
internal.SSHPublicKey refers to a struct in our internal
package:
package internal
// SSHPublicKey extends the SSH public key with its ID (comment field)
// This makes handling easier. We just get the key from the pulumi configuration.
// An alternative is parsing the key and reading the comment field.
type SSHPublicKey struct {
ID string
PublicKey string
}
Next, we are creating our first cloud config file. I usually name these files in the following pattern <serverName>.yaml
.
This has the little advantage that we can use these files to bootstrap servers later. Here is a very simplified cloud configuration:
#cloud-config
ntp:
enabled: true
timezone: UTC
fqdn: ritsuko.shibumi.dev
ssh_pwauth: false
ssh_authorized_keys:
- "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDHlRfwIYqaqWfh5ObpCV5pA+n+KolK64LZ5VyIi5ZjwtEswkDI6KcGDTGcWYY+/XJ42kj7SbYHSCm4t/HAXHgmKDuQPzq72nVY7G1DjYrArGig9ni0/XCJY64s5oBgW8wVPTnbf/wYo+gHqsXO7ZaJKknW7jybmIiMC9hx+BkGugyT2WdVnI/8fXiR7VBArSfPIT/ieuWi+GR/7nIz6X09d77pY+tZzeOfbm2obU3EsIh8KJzoZeeopqOFnxooTGtk3ifL8Sv154KDzPwRnaGKdwd36aljQharAUkRQS3bVZiRx1Jw19+1XT0a8/D70ilAKMX6ilUa+LO9jObd49pUSitVGN6gHV5LBybbXjdaLe62dN9gRttJ24KjoJer1o2PMRxNxjwGgksPXhcyfBgbxmNOnsGYZ90PFdp3CH3eh9V8wmwj/ATPnX0s7pAVIpJt6lvdMfoZoezWhk/N0e0GpLWc7hmhxmEP0GYp5+oLL4n5wGr39uOsjPeqpx5c+QJPgIk0cJpKW8gVOw5T8e72v6r44APy9+XLTx2rAwfKeTBwyQo/yiGRo+gEdrPROOl9bei+eGFApJLtHPGqMP5PzMpY1A67z3D4tZ8zPoIqDoos5O6k04aXkbHjNCOkbwY29PfqZzqZmEo+FGDAhqwzpfc/7e7vDJTLWusIxxaaOQ== chris@motoko"
runcmd:
- "dnf install dnf-automatic -y"
- "systemctl enable dnf-automatic.timer --now"
You might be confused now, because I am adding the SSH key twice and you absolutly can be. Actually, I would like to add the key via the cloud-config file only, but Hetzner cloud reacts with enabling password authentication for the host and sending you the password via mail if you do not set the SSH key via their API. I would like to circumvent this and decided to just set it twice. It might make sense to either remove it in the cloud-config file and set it only via Hetzner API or set it only via cloud-config, while ignoring Hetzner root password mails. The cloud-config file disables ssh_pwauth anyway (shrug).
For reading all cloud-config files I have setup a little helper function:
package internal
import (
"github.com/pulumi/pulumi-cloudinit/sdk/go/cloudinit"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"io/ioutil"
"path/filepath"
"strings"
)
// cloudConfigContentType cannot be a constant, because we cannot use pointers to constants in Go
var cloudConfigContentType = "text/cloud-config"
// CloudConfig extends the pulumi cloud-config with an ID
type CloudConfig struct {
ID string
CloudConfig *cloudinit.LookupConfigResult
}
// NewCloudConfigs reads all cloud-config files in a given path and returns
// a slice of CloudConfig
func NewCloudConfigs(ctx *pulumi.Context, path string) ([]CloudConfig, error) {
var cloudConfigs []CloudConfig
files, err := ioutil.ReadDir(path)
if err != nil {
return nil, err
}
for _, f := range files {
config, err := ioutil.ReadFile(filepath.Join(path, f.Name()))
if err != nil {
return nil, err
}
cloudConfig, err := cloudinit.LookupConfig(ctx, &cloudinit.LookupConfigArgs{
Base64Encode: BoolPtr(false),
Gzip: BoolPtr(false),
Parts: []cloudinit.GetConfigPart{
{
Content: string(config),
ContentType: &cloudConfigContentType,
Filename: StringPtr(f.Name()),
},
},
})
if err != nil {
return nil, err
}
cloudConfigs = append(cloudConfigs, CloudConfig{
ID: strings.TrimSuffix(f.Name(), filepath.Ext(f.Name())),
CloudConfig: cloudConfig,
})
}
return cloudConfigs, nil
}
// BoolPtr needs a bool and returns a pointer to the bool.
// This function is needed for pulumi's cloud-config.
// Pulumi's cloud-config does not seem to support pulumi.Bool or pulumi.BoolPtr :(
func BoolPtr(b bool) *bool {
return &b
}
// StringPtr needs a string and returns a pointer to the string.
// This function is needed for pulumi's cloud-config.
// Pulumi's cloud-config does not seem to support pulumi.String or pulumi.StringPtr :(
func StringPtr(s string) *string {
return &s
}
The NewCloudConfigs
function reads all files in the cloud-config directory, creates cloud-config objects from these files and connects
them with their filename as ID. Surprisingly, we have to create two small helper functions here, because I seem to be unable to use
the two Pulumi types for the CloudConfig fields (pulumi.Bool
and pulumi.BoolPtr
). The final main.go file can then use this NewCloudConfigs
method:
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// read configuration file
var pubKey internal.SSHPublicKey
pulumiConf := config.New(ctx, "") // namespace "" refers to the project namespace "infra"
pulumiConf.RequireObject("key", &pubKey) // read infra:key object
cloudConfigPath := pulumiConf.Require("cloudConfigPath") // read infra:cloudConfigPath string
// create Hetzner SSH Public Key
sshKey, err := hcloud.NewSshKey(ctx, pubKey.ID, &hcloud.SshKeyArgs{
Name: pulumi.String(pubKey.ID),
PublicKey: pulumi.String(pubKey.PublicKey),
})
// create cloud-configs
cloudConfigs, err := internal.NewCloudConfigs(ctx, cloudConfigPath)
if err != nil {
return err
}
// use cloud-configs to initialize virtual machines
for _, cloudConfig := range cloudConfigs {
_, err := hcloud.NewServer(ctx, cloudConfig.ID, &hcloud.ServerArgs{
Image: pulumi.String("fedora-34"),
Name: pulumi.String(cloudConfig.ID),
ServerType: pulumi.String("cx11"),
SshKeys: pulumi.StringArray{
sshKey.Name,
},
UserData: pulumi.String(cloudConfig.CloudConfig.Rendered),
})
if err != nil {
return err
}
}
return nil
})
}
With running pulumi up
we are able to create all resources and with pulumi destroy
we can destroy all resources, again:
❯ pulumi up
Previewing update (dev):
Type Name Plan
+ pulumi:pulumi:Stack infra-dev create
+ ├─ hcloud:index:SshKey chris@motoko create
+ └─ hcloud:index:Server ritsuko create
Resources:
+ 3 to create
Do you want to perform this update? [Use arrows to move, enter to select, type to filter]
yes
> no
details
Conclusion
I think Pulumi has a huge potential, because it feels much more natural than using the Hashicorp configuration language (HCL). I do not know how many hours I have wasted into HCL for writing very simple loops and just for finding out later that these loops do not work that way, because Terraform is a little bit different. With Pulumi these frustrations are gone.
Pulumi provides a very convenient way for teams without any HCL knowledge to manage infrastructure in their favorite programming language.
But, I still see a few problems with Pulumi. Writing the Pulumi code feels a little bit frustrating sometimes, too.
Especially, Pulumi’s custom datatypes like pulumi.String
or pulumi.Bool
gave me lots of headache, because I had no idea how to fill
these fields in the Pulumi structs at first and then I found out about the Pulumi datatypes I got even more frustrated when I found out
that the Pulumi cloud configuration method had trouble with using these custom data types. This might be just my personal experience.
If you know a way how to fill these fields in the cloudinit.LookupConfigArgs
struct without using custom helper methods let me know.