Automation Archiv - credativ®

Update: Proxday will now take place on October 15, 2026. We have moved the date to avoid a clash with the Dutch Proxmox Day of our friends at Tuxis. All other information remains current – the CfP deadline is extended accordingly to July 31, 2026.

Save the Date: On October 15, 2026, we are bringing the Proxmox world together in Mönchengladbach. With Proxday 2026, we are creating an event that goes far beyond our previous formats. While our Virtualization Gathering and Business Breakfast have already provided valuable impetus, it is now time for the ‘Next Level’. We are dedicating a full day to Proxmox VE and finally giving the community the space it deserves – for deep dives, exchange of experiences, and technical innovations.

Proxday is organized by credativ GmbH and is intended as a meeting point by the community for the community. The focus here is on practical exchange of experience, in-depth technical knowledge, and networking among experts. To ensure the program reflects the full spectrum of the Proxmox world, we invite you to actively contribute to shaping the day.

Call for Papers: Share Your Expertise!

The Call for Papers (CfP) is now officially open. We are looking for exciting presentations, technical deep dives, and practical experience reports. Whether you have built a complex infrastructure, developed automation solutions, or successfully migrated to Proxmox – the community benefits from your knowledge.

Possible topics for your submission:

  • Proxmox VE & Storage: Best practices for Ceph, ZFS, and high availability.
  • Automation & IaC: Proxmox management with tools like Ansible or Terraform.
  • Backup Strategies: Use of Proxmox Backup Server in enterprise environments.
  • Networking & Security: SDN implementations and security concepts.
  • Migration: Strategies and case studies for switching from other hypervisors.

The submission deadline for the Call for Papers is July 31, 2026.

Event Overview

We have chosen the Hotel Palace St. George in Mönchengladbach as the venue. With its combination of classic architecture and modern amenities, the hotel provides the perfect setting for a focused conference and intensive exchange.

  • When: October, 15th 2026
  • Where: Mönchengladbach (Hotel Palace St. George)
  • What: A day full of expert presentations, networking, and community exchange around Proxmox.

Join us now

Take the opportunity to present your projects to an expert audience or to get early information about the event. All details about the Call for Papers and the venue can be found on the official event website.

We look forward to welcoming the Proxmox community to Mönchengladbach in October 2026!

👉 Click here to go directly to the CfP on www.proxday.de

Foreman is clearly rooted in an IPv4 world. You can see it everywhere. Like it does not want to give you a host without IPv4, but without IPv6 is fine. But it does not have to be this way, so let’s go together on a journey to bring Foreman and the things around it into the present.
(more…)

Efficient Storage Automation in Proxmox with the proxmox_storage Module

Managing various storage systems in Proxmox environments often involves recurring tasks. Whether it’s creating new storage, connecting NFS, CIFS shares, iSCSI, or integrating more complex backends like CephFS or Proxmox Backup Server, in larger environments with multiple nodes or entire clusters, this can quickly become time-consuming, error-prone, and difficult to track.

With Ansible, these processes can be efficiently automated and standardized. Instead of manual configurations, Infrastructure as Code ensures a clear structure, reproducibility, and traceability of all changes. Similar to the relatively new module proxmox_cluster, which automates the creation and joining of Proxmox nodes to clusters, this now applies analogously to storage systems. This is precisely where the Ansible module proxmox_storage, developed by our highly esteemed colleague Florian Paul Azim Hoberg (also well-known in the open-source community as gyptazy), comes into play. It enables the simple and flexible integration of various storage types directly into Proxmox nodes and clusters, automated, consistent, and repeatable at any time. The module is already part of the Ansible Community.Proxmox Collections and has been included in the collections since version 1.3.0.

This makes storage management in Proxmox not only faster and more secure, but also seamlessly integrates into modern automation workflows.

Ansible Module: proxmox_storage

The proxmox_storage module is an Ansible module developed in-house at credativ for the automated management of storage in Proxmox VE. It supports various storage types such as NFS, CIFS, iSCSI, CephFS, and Proxmox Backup Server.

The module allows you to create new storage resources, adjust existing configurations, and completely automate the removal of no longer needed storage. Its integration into Ansible Playbooks enables idempotent and reproducible storage management in Proxmox nodes and clusters. The module simplifies complex configurations and reduces sources of error that can occur during manual setup.

Add iSCSI Storage

Integrating iSCSI storage into Proxmox enables centralized access to block-based storage that can be flexibly used by multiple nodes in the cluster. By using the proxmox_storage module, the connection can be configured automatically and consistently, which saves time and prevents errors during manual setup.

- name: Add iSCSI storage to Proxmox VE Cluster
  community.proxmox.proxmox_storage:
  api_host: proxmoxhost
  api_user: root@pam
  api_password: password123
  validate_certs: false
  nodes: ["de-cgn01-virt01", "de-cgn01-virt02", "de-cgn01-virt03"]
  state: present
  type: iscsi
  name: net-iscsi01
  iscsi_options:
  portal: 10.10.10.94
  target: "iqn.2005-10.org.freenas.ctl:s01-isci01"
  content: ["rootdir", "images"]

The integration takes place within a single task, where the consuming nodes and the iSCSI-relevant information are defined. It is also possible to define for which “content” this storage should be used.

Add Proxmox Backup Server

The Proxmox Backup Server (PBS) is also considered storage in Proxmox VE and can therefore be integrated into the environment just like other storage types. With the proxmox_storage module, a PBS can be easily integrated into individual nodes or entire clusters, making backups available centrally, consistently, and automatically.

- name: Add PBS storage to Proxmox VE Cluster
  community.proxmox.proxmox_storage:
  api_host: proxmoxhost
  api_user: root@pam
  api_password: password123
  validate_certs: false
  nodes: ["de-cgn01-virt01", "de-cgn01-virt02"]
  state: present
  name: backup-backupserver01
  type: pbs
  pbs_options:
  server: proxmox-backup-server.example.com
  username: backup@pbs
  password: password123
  datastore: backup
  fingerprint: "F3:04:D2:C1:33:B7:35:B9:88:D8:7A:24:85:21:DC:75:EE:7C:A5:2A:55:2D:99:38:6B:48:5E:CA:0D:E3:FE:66"
  export: "/mnt/storage01/b01pbs01"
  content: ["backup"]

Note: It is important to consider the fingerprint of the Proxmox Backup Server system that needs to be defined. This is always relevant if the instance’s associated certificate was not issued by a trusted root CA. If you are using and legitimizing your own root CA, this definition is not necessary. .

Remove Storage

No longer needed or outdated storage can be removed just as easily from Proxmox VE. With the proxmox_storage module, this process is automated and performed idempotently, ensuring that the cluster configuration remains consistent and unused resources are cleanly removed. A particular advantage is evident during storage migrations, as old storage can be removed in a controlled manner after successful data transfer. This way, environments can be gradually modernized without manual intervention or unnecessary configuration remnants remaining in the cluster.

- name: Remove storage from Proxmox VE Cluster
  community.proxmox.proxmox_storage:
  api_host: proxmoxhost
  api_user: root@pam
  api_password: password123
  validate_certs: false
  state: absent
  name: net-nfsshare01
  type: nfs

Conclusion

The example of automated storage integration with Ansible and Proxmox impressively demonstrates the advantages and extensibility of open-source solutions. Open-source products like Proxmox VE and Ansible can be flexibly combined, offering an enormous range of applications that also prove their worth in enterprise environments.

A decisive advantage is the independence from individual manufacturers, meaning companies do not have to fear vendor lock-in and retain more design freedom in the long term. At the same time, it becomes clear that the successful implementation of such scenarios requires sound knowledge and experience to optimally leverage the possibilities of open source.

While this only covers a partial area, our colleague Florian Paul Azim Hoberg (gyptazy) impressively demonstrates here in his video “Proxmox Cluster Fully Automated: Cluster Creation, NetApp Storage & SDN Networking with Ansible” what full automation with Proxmox can look like.

This is precisely where we stand by your side as your partner and are happy to support you in the areas of automation, development and all questions relating to Proxmox and modern infrastructures. Please do not hesitate to contact us – we will be happy to advise you!

Introduction

Puppet is a software configuration management solution to manage IT infrastructure. One of the first things to be learnt about Puppet is its domain-specific language – the Puppet-DSL – and the concepts that come with it.

Users can organize their code in classes and modules and use pre-defined resource types to manage resources like files, packages, services and others.

The most commonly used types are part of the Puppet core, implemented in Ruby. Composite resource types may be defined via Puppet-DSL from already known types by the Puppet user themself, or imported as part of an external Puppet module which is maintained by external module developers.

It so happens that Puppet users can stay within the Puppet-DSL for a long time even when they deal with Puppet on a regular basis.

The first time I had a glimpse into this topic was when Debian Stable was shipping Puppet 5.5, which was not too long ago. The Puppet 5.5 documentation includes a chapter on custom types and provider development respectively, but to me they felt incomplete and lacking self contained examples. Apparently I was not the only one feeling that way, even though Puppet’s gem documentation is a good overview of what is possible in principle.

Gary Larizza’s blog post was more than ten years ago. I had another look into the documentation for Puppet 7 on that topic recently, as this is the Puppet version in current’s Debian Stable.

The Puppet 5.5 way to type & provider development is now called the low level method, and its documentation has not changed significantly. However, Puppet 6 upwards recommends a new method to create custom types & providers via the so-called Resource-API, whose documentation is a major improvement compared to the low-level method’s. The Resource-API is not a replacement, though, and has several documented limitations.

Nevertheless, for the remaining blog post, we will re-prototype a small portion of files functionality using the low-level method, as well as the Resource-API, namely the ensure and content properties.

Preparations

The following preparations are not necessary in an agent-server setup. We use bundle to obtain a puppet executable for this demo.

demo@85c63b50bfa3:~$ cat > Gemfile <<EOF
source 'https://rubygems.org'

gem 'puppet', '>= 6'
EOF
demo@85c63b50bfa3:~$ bundle install
Fetching gem metadata from https://rubygems.org/........
Resolving dependencies...
...
Installing puppet 8.10.0
Bundle complete! 1 Gemfile dependency, 17 gems now installed.
Bundled gems are installed into `./.vendor`
demo@85c63b50bfa3:~$ cat > file_builtin.pp <<EOF
$file = '/home/demo/puppet-file-builtin'
file {$file: content => 'This is madness'}
EOF
demo@85c63b50bfa3:~$ bin/puppet apply file_builtin.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.01 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-builtin]/ensure: defined content as '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: Applied catalog in 0.04 seconds

demo@85c63b50bfa3:~$ sha256sum /home/demo/puppet-file-builtin
0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e /home/demo/puppet-file-builtin

Keep in mind the state change information printed by Puppet.

Low-level prototype

Custom types and providers that are not installed via a Gem need to be part of some Puppet module, so they can be copied to Puppet agents via the pluginsync mechanism.

A common location for Puppet modules is the modules directory inside a Puppet environment. For this demo, we declare a demo module.

Basic functionality

Our first attempt is the following type definition for a new type we will call file_llmethod. It has no documentation or validation of input values.

# modules/demo/lib/puppet/type/file_llmethod.rb

Puppet::Type.newtype(:file_llmethod) do
  newparam(:path, namevar: true) {}
  newproperty(:content) {}
  newproperty(:ensure) do
    newvalues(:present, :absent)
    defaultto(:present)
  end
end

We have declared a path parameter that serves as the namevar for this type – there cannot be other file_llmethod instances managing the same path. The ensure property is restricted to two values and defaults to present.

The following provider implementation consists of a getter and a setter for each of the two properties content and ensure.

# modules/demo/lib/puppet/provider/file_llmethod/ruby.rb

Puppet::Type.type(:file_llmethod).provide(:ruby) do
  def ensure
    File.exist?(@resource[:path]) ? :present : :absent
  end

  def ensure=(value)
    if value == :present
      # reuse setter
      self.content=(@resource[:content])
    else
      File.unlink(@resource[:path])
    end
  end

  def content
    File.read(@resource[:path])
  end

  def content=(value)
    File.write(@resource[:path], value)
  end
end

This gives us the following:

demo@85c63b50bfa3:~$ cat > file_llmethod_create.pp <<EOF
$file = '/home/demo/puppet-file-lowlevel-method-create'
file {$file: ensure => absent} ->
file_llmethod {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.01 seconds
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-create]/ensure: defined 'ensure' as 'present'

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.01 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-create]/ensure: removed
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-create]/ensure: defined 'ensure' as 'present'

demo@85c63b50bfa3:~$ cat > file_llmethod_change.pp <<EOF
$file = '/home/demo/puppet-file-lowlevel-method-change'
file {$file: content => 'This is madness'} ->
file_llmethod {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-change]/ensure: defined content as '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-change]/content: content changed 'This is madness' to 'This is Sparta!'

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-change]/content: content changed '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2' to '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-change]/content: content changed 'This is madness' to 'This is Sparta!'

Our custom type already kind of works, even though we have not implemented any explicit comparison of is- and should-state. Puppet does this for us based on the Puppet catalog and the property getter return values. Our defined setters are also invoked by Puppet on demand, only.

We can also see that the ensure state change notice is defined 'ensure' as 'present' and does not incorporate the desired content in any way, while the content state change notice shows plain text. Both tell us that the SHA256 checksum from the file_builtin.pp example is already something non-trivial.

Validating input

As a next step we add validation for path and content.

# modules/demo/lib/puppet/type/file_llmethod.rb

Puppet::Type.newtype(:file_llmethod) do
  newparam(:path, namevar: true) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
      fail "#{value} is not an absolute path" unless File.absolute_path?(value)
    end
  end

  newproperty(:content) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
    end
  end

  newproperty(:ensure) do
    newvalues(:present, :absent)
    defaultto(:present)
  end
end

Failed validations will look like these:

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules --exec 'file_llmethod {"./relative/path": }'
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Error: Parameter path failed on File_llmethod[./relative/path]: ./relative/path is not an absolute path (line: 1)

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules --exec 'file_llmethod {"/absolute/path": content => 42}'
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Error: Parameter content failed on File_llmethod[/absolute/path]: 42 is not a String (line: 1)

Content Checksums

We override change_to_s so that state changes include content checksums:

# modules/demo/lib/puppet/type/file_llmethod.rb

require 'digest'

Puppet::Type.newtype(:file_llmethod) do
  newparam(:path, namevar: true) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
      fail "#{value} is not an absolute path" unless File.absolute_path?(value)
    end
  end

  newproperty(:content) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
    end
    define_method(:change_to_s) do |currentvalue, newvalue|
      old = "{sha256}#{Digest::SHA256.hexdigest(currentvalue)}"
      new = "{sha256}#{Digest::SHA256.hexdigest(newvalue)}"
      "content changed '#{old}' to '#{new}'"
    end
  end

  newproperty(:ensure) do
    define_method(:change_to_s) do |currentvalue, newvalue| if currentvalue == :absent should = @resource.property(:content).should digest = "{sha256}#{Digest::SHA256.hexdigest(should)}" "defined content as '#{digest}'" else super(currentvalue, newvalue) end end newvalues(:present, :absent) defaultto(:present) end end

The above type definition yields:

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-create]/ensure: removed
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-create]/ensure: defined content as '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-change]/content: content changed '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2' to '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-change]/content: content changed '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e' to '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

Improving memory footprint

So far so good. While our current implementation apparently works, it has at least one major flaw. If the managed file already exists, the provider stores the file’s whole content in memory.

demo@85c63b50bfa3:~$ cat > file_llmethod_change_big.pp <<EOF
$file = '/home/demo/puppet-file-lowlevel-method-change_big'
file_llmethod {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ rm -f /home/demo/puppet-file-lowlevel-method-change_big
demo@85c63b50bfa3:~$ ulimit -Sv 200000

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change_big.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-change_big]/ensure: defined content as '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'
Notice: Applied catalog in 0.02 seconds

demo@85c63b50bfa3:~$ dd if=/dev/zero of=/home/demo/puppet-file-lowlevel-method-change_big seek=8G bs=1 count=1
1+0 records in
1+0 records out
1 byte copied, 8.3047e-05 s, 12.0 kB/s

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change_big.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Error: Could not run: failed to allocate memory

Instead, the implementation should only store the checksum so that Puppet can decide based on checksums if our content= setter needs to be invoked.

This also means that the Puppet catalog’s content needs to be checksummed by munge before it it processed by Puppet’s internal comparison routine. Luckily we also have access to the original value via shouldorig.

# modules/demo/lib/puppet/type/file_llmethod.rb

require 'digest'

Puppet::Type.newtype(:file_llmethod) do
  newparam(:path, namevar: true) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
      fail "#{value} is not an absolute path" unless File.absolute_path?(value)
    end
  end

  newproperty(:content) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
    end
    munge do |value|
      "{sha256}#{Digest::SHA256.hexdigest(value)}"
    end
    # No need to override change_to_s with munging
  end

  newproperty(:ensure) do
    define_method(:change_to_s) do |currentvalue, newvalue|
      if currentvalue == :absent
        should = @resource.property(:content).should
        "defined content as '#{should}'"
      else
        super(currentvalue, newvalue)
      end
    end
    newvalues(:present, :absent)
    defaultto(:present)
  end
end
# modules/demo/lib/puppet/provider/file_llmethod/ruby.rb

Puppet::Type.type(:file_llmethod).provide(:ruby) do
  ...

  def content
    File.open(@resource[:path], 'r') do |file|
      sha = Digest::SHA256.new
      while chunk = file.read(2**16)
        sha << chunk
      end
      "{sha256}#{sha.hexdigest}"
    end
  end

  def content=(value)
    # value is munged, but we need to write the original
    File.write(@resource[:path], @resource.parameter(:content).shouldorig[0])
  end
end

Now we can manage big files:

demo@85c63b50bfa3:~$ ulimit -Sv 200000
demo@85c63b50bfa3:~$ dd if=/dev/zero of=/home/demo/puppet-file-lowlevel-method-change_big seek=8G bs=1 count=1
1+0 records in
1+0 records out
1 byte copied, 9.596e-05 s, 10.4 kB/s
demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_change_big.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-change_big]/content: content changed '{sha256}ef17a425c57a0e21d14bec2001d8fa762767b97145b9fe47c5d4f2fda323697b' to '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

Ensure it the Puppet way

There is still something not right. Maybe you have noticed that our provider’s content getter attempts to open a file unconditionally, and yet the file_llmethod_create.pp run has not produced an error. It seems that an ensure transition from absent to present short-circuits the content getter, even though we have not expressed a wish to do so.

It turns out that an ensure property gets special treatment by Puppet. If we had attempted to use a makeitso property instead of ensure, there would be no short-circuiting and the content getter would raise an exception.

We will not fix the content getter though. If Puppet has special treatment for ensure, we should use Puppet’s intended mechanism for it, and declare the type ensurable:

# modules/demo/lib/puppet/type/file_llmethod.rb

require 'digest'

Puppet::Type.newtype(:file_llmethod) do
  ensurable

  newparam(:path, namevar: true) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
      fail "#{value} is not an absolute path" unless File.absolute_path?(value)
    end
  end

  newproperty(:content) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
    end
    munge do |value|
      "{sha256}#{Digest::SHA256.hexdigest(value)}"
    end
  end
end

With ensurable the provider needs to implement three new methods, but we can drop the ensure accessors:

# modules/demo/lib/puppet/provider/file_llmethod/ruby.rb

Puppet::Type.type(:file_llmethod).provide(:ruby) do
  def exists?
    File.exist?(@resource[:name])
  end

  def create
    self.content=(:dummy)
  end

  def destroy
    File.unlink(@resource[:name])
  end

  def content
    File.open(@resource[:path], 'r') do |file|
      sha = Digest::SHA256.new
      while chunk = file.read(2**16)
        sha << chunk
      end
      "{sha256}#{sha.hexdigest}"
    end
  end

  def content=(value)
    # value is munged, but we need to write the original
    File.write(@resource[:path], @resource.parameter(:content).shouldorig[0])
  end
end

However, now we have lost the SHA256 checksum on file creation:

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_create.pp Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-create]/ensure: removed Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-create]/ensure: created

To get it back, we replace ensurable by an adapted implementation of it, which includes our previous change_to_s override:

newproperty(:ensure, :parent => Puppet::Property::Ensure) do
  defaultvalues
  define_method(:change_to_s) do |currentvalue, newvalue|
    if currentvalue == :absent
      should = @resource.property(:content).should
      "defined content as '#{should}'"
    else
      super(currentvalue, newvalue)
    end
  end
end
demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_llmethod_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.02 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-lowlevel-method-create]/ensure: removed
Notice: /Stage[main]/Main/File_llmethod[/home/demo/puppet-file-lowlevel-method-create]/ensure: defined content as '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

Our final low-level prototype is thus as follows.

Final low-level prototype

# modules/demo/lib/puppet/type/file_llmethod.rb

# frozen_string_literal: true

require 'digest'

Puppet::Type.newtype(:file_llmethod) do
  newparam(:path, namevar: true) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
      fail "#{value} is not an absolute path" unless File.absolute_path?(value)
    end
  end

  newproperty(:content) do
    validate do |value|
      fail "#{value} is not a String" unless value.is_a?(String)
    end
    munge do |value|
      "{sha256}#{Digest::SHA256.hexdigest(value)}"
    end
  end

  newproperty(:ensure, :parent => Puppet::Property::Ensure) do
    defaultvalues
    define_method(:change_to_s) do |currentvalue, newvalue|
      if currentvalue == :absent
        should = @resource.property(:content).should
        "defined content as '#{should}'"
      else
        super(currentvalue, newvalue)
      end
    end
  end
end
# modules/demo/lib/puppet/provider/file_llmethod/ruby.rb

# frozen_string_literal: true

Puppet::Type.type(:file_llmethod).provide(:ruby) do
  def exists?
    File.exist?(@resource[:name])
  end

  def create
    self.content=(:dummy)
  end

  def destroy
    File.unlink(@resource[:name])
  end

  def content
    File.open(@resource[:path], 'r') do |file|
      sha = Digest::SHA256.new
      while (chunk = file.read(2**16))
        sha << chunk
      end
      "{sha256}#{sha.hexdigest}"
    end
  end

  def content=(_value)
    # value is munged, but we need to write the original
    File.write(@resource[:path], @resource.parameter(:content).shouldorig[0])
  end
end

Resource-API prototype

According to the Resource-API documentation we need to define our new file_rsapi type by calling Puppet::ResourceApi.register_type with several parameters, amongst which are the desired attributes, even ensure.

# modules/demo/lib/puppet/type/file_rsapi.rb

require 'puppet/resource_api'

Puppet::ResourceApi.register_type(
  name: 'file_rsapi', 
  attributes: {
    content: {
      desc: 'description of content parameter',
      type: 'String' 
    },
    ensure: {
      default: 'present',
      desc: 'description of ensure parameter',
      type: 'Enum[present, absent]'
    },
    path: {
      behaviour: :namevar,
      desc: 'description of path parameter',
      type: 'Pattern[/\A\/([^\n\/\0]+\/*)*\z/]'
    },
  },
  desc: 'description of file_rsapi'
)

The path type uses a built-in Puppet data type. Stdlib::Absolutepath would have been more convenient but external data types are not possible with the Resource-API yet.

In comparison with our low-level prototype, the above type definition has no SHA256-munging and SHA256-output counterparts. The canonicalize provider feature looks similar to munging, but we skip it for now.

The Resource-API documentation tells us to implement a get and a set method in our provider, stating

The get method reports the current state of the managed resources. It returns an enumerable of all existing resources. Each resource is a hash with attribute names as keys, and their respective values as values.

This demand is the first bummer, as we definitely do not want to read all files with their content and store it in memory. We can ignore this demand – how would the Resource-API know anyway.

However, the documented signature is def get(context) {...} where context has no information about the resource we want to manage.

This would have been a show-stopper, if the simple_get_filter provider feature didn’t exist, which changes the signature to def get(context, names = nil) {...}.

Our first version of file_rsapi is thus the following.

Basic functionality

# modules/demo/lib/puppet/type/file_rsapi.rb

require 'puppet/resource_api'

Puppet::ResourceApi.register_type(
  name: 'file_rsapi',
  features: %w[simple_get_filter],
  attributes: {
    content: {
      desc: 'description of content parameter',
      type: 'String' 
    },
    ensure: {
      default: 'present',
      desc: 'description of ensure parameter',
      type: 'Enum[present, absent]'
    },
    path: {
      behaviour: :namevar,
      desc: 'description of path parameter',
      type: 'Pattern[/\A\/([^\n\/\0]+\/*)*\z/]'
    },
  },
  desc: 'description of file_rsapi'
)
# modules/demo/lib/puppet/provider/file_rsapi/file_rsapi.rb

require 'digest'

class Puppet::Provider::FileRsapi::FileRsapi
  def get(context, names)
    (names or []).map do |name|
      File.exist?(name) ? {
        path: name,
        ensure: 'present',
        content: filedigest(name),
      } : nil
    end.compact # remove non-existing resources
  end

  def set(context, changes)
    changes.each do |path, change|
      if change[:should][:ensure] == 'present'
        File.write(path, change[:should][:content])
      elsif File.exist?(path)
        File.delete(path)
      end
    end
  end

  def filedigest(path)
    File.open(path, 'r') do |file|
      sha = Digest::SHA256.new
      while chunk = file.read(2**16)
         sha << chunk
      end
      "{sha256}#{sha.hexdigest}"
    end
  end
end

The desired content is written correctly into the file, but we have again no SHA256 checksum on creation as well as unnecessary writes, because the checksum from get does not match the cleartext from the catalog:

demo@85c63b50bfa3:~$ cat > file_rsapi_create.pp <<EOF
$file = '/home/demo/puppet-file-rsapi-create'
file       {$file: ensure  => absent} ->
file_rsapi {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_rsapi_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.03 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-rsapi-create]/ensure: removed
Notice: /Stage[main]/Main/File_rsapi[/home/demo/puppet-file-rsapi-create]/ensure: defined 'ensure' as 'present'
Notice: Applied catalog in 0.02 seconds

demo@85c63b50bfa3:~$ cat > file_rsapi_change.pp <<EOF
$file = '/home/demo/puppet-file-rsapi-change'
file       {$file: content => 'This is madness'} ->
file_rsapi {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_rsapi_change.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.03 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-rsapi-change]/content: content changed '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2' to '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: /Stage[main]/Main/File_rsapi[/home/demo/puppet-file-rsapi-change]/content: content changed '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e' to 'This is Sparta!'
Notice: Applied catalog in 0.03 seconds

Hence we enable and implement the canonicalize provider feature.

Canonicalize

According to the documentation, canonicalize is applied to the results of get as well as to the catalog properties. On the one hand, we do not want to read the file’s content into memory, on the other hand cannot checksum the file’s content twice.

An easy way would be to check whether the canonicalized call happens after the get call:

def canonicalize(context, resources)
  if @stage == :get
    # do nothing, is-state canonicalization already performed by get()
  else
    # catalog canonicalization
    ...
  end
end

def get(context, paths)
  @stage = :get

  ...
end

While this works for the current implementation of the Resource-API, there is no guarantee about the order of canonicalize calls. Instead we subclass from String and handle checksumming internally. We also add some state change calls to the appropriate context methods.

Our final Resource-API-based prototype implementation is:

# modules/demo/lib/puppet/type/file_rsapi.rb

# frozen_string_literal: true

require 'puppet/resource_api'

Puppet::ResourceApi.register_type(
  name: 'file_rsapi',
  features: %w[simple_get_filter canonicalize],
  attributes: {
    content: {
      desc: 'description of content parameter',
      type: 'String'
    },
    ensure: {
      default: 'present',
      desc: 'description of ensure parameter',
      type: 'Enum[present, absent]'
    },
    path: {
      behaviour: :namevar,
      desc: 'description of path parameter',
      type: 'Pattern[/\A\/([^\n\/\0]+\/*)*\z/]'
    }
  },
  desc: 'description of file_rsapi'
)
# modules/demo/lib/puppet/provider/file_rsapi/file_rsapi.rb

# frozen_string_literal: true

require 'digest'
require 'pathname'

class Puppet::Provider::FileRsapi::FileRsapi
  class CanonicalString < String
    attr_reader :original

    def class
      # Mask as String for YAML.dump to mitigate
      #   Error: Transaction store file /var/cache/puppet/state/transactionstore.yaml
      #   is corrupt ((/var/cache/puppet/state/transactionstore.yaml): Tried to
      #   load unspecified class: Puppet::Provider::FileRsapi::FileRsapi::CanonicalString)
      String
    end

    def self.from(obj)
      return obj if obj.is_a?(self)
      return new(filedigest(obj)) if obj.is_a?(Pathname)

      new("{sha256}#{Digest::SHA256.hexdigest(obj)}", obj)
    end

    def self.filedigest(path)
      File.open(path, 'r') do |file|
        sha = Digest::SHA256.new
        while (chunk = file.read(2**16))
          sha << chunk
        end
        "{sha256}#{sha.hexdigest}"
      end
    end

    def initialize(canonical, original = nil)
      @original = original
      super(canonical)
    end
  end

  def canonicalize(_context, resources)
    resources.each do |resource|
      next if resource[:ensure] == 'absent'

      resource[:content] = CanonicalString.from(resource[:content])
    end
    resources
  end

  def get(_context, names)
    (names or []).map do |name|
      next unless File.exist?(name)

      {
        content: CanonicalString.from(Pathname.new(name)),
        ensure: 'present',
        path: name
      }
    end.compact # remove non-existing resources
  end

  def set(context, changes)
    changes.each do |path, change|
      if change[:should][:ensure] == 'present'
        File.write(path, change[:should][:content].original)
        if change[:is][:ensure] == 'present'
          # The only other possible change is due to content,
          # but content change transition info is covered implicitly
        else
          context.created("#{path} with content '#{change[:should][:content]}'")
        end
      elsif File.exist?(path)
        File.delete(path)
        context.deleted(path)
      end
    end
  end
end

It gives us:

demo@85c63b50bfa3:~$ cat > file_rsapi_create.pp <<EOF
$file = '/home/demo/puppet-file-rsapi-create'
file       {$file: ensure  => absent} ->
file_rsapi {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_rsapi_create.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.03 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-rsapi-create]/ensure: removed
Notice: /Stage[main]/Main/File_rsapi[/home/demo/puppet-file-rsapi-create]/ensure: defined 'ensure' as 'present'
Notice: file_rsapi: Created: /home/demo/puppet-file-rsapi-create with content '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

demo@85c63b50bfa3:~$ cat > file_rsapi_change.pp <<EOF
$file = '/home/demo/puppet-file-rsapi-change'
file       {$file: content => 'This is madness'} ->
file_rsapi {$file: content => 'This is Sparta!'}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_rsapi_change.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.03 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-rsapi-change]/content: content changed '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2' to '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e'
Notice: /Stage[main]/Main/File_rsapi[/home/demo/puppet-file-rsapi-change]/content: content changed '{sha256}0549defd0a7d6d840e3a69b82566505924cacbe2a79392970ec28cddc763949e' to '{sha256}823cbb079548be98b892725b133df610d0bff46b33e38b72d269306d32b73df2'

demo@85c63b50bfa3:~$ cat > file_rsapi_remove.pp <<EOF
$file = '/home/demo/puppet-file-rsapi-remove'
file       {$file: ensure => present} ->
file_rsapi {$file: ensure => absent}
EOF

demo@85c63b50bfa3:~$ bin/puppet apply --modulepath modules file_rsapi_remove.pp
Notice: Compiled catalog for 85c63b50bfa3 in environment production in 0.03 seconds
Notice: /Stage[main]/Main/File[/home/demo/puppet-file-rsapi-remove]/ensure: created
Notice: /Stage[main]/Main/File_rsapi[/home/demo/puppet-file-rsapi-remove]/ensure: undefined 'ensure' from 'present'
Notice: file_rsapi: Deleted: /home/demo/puppet-file-rsapi-remove

Thoughts

The apparent need for a CanonicalString masking as String makes it look like we are missing something. If the Resource-API only checked data types after canonicalization, we could make get return something simpler than CanonicalString to signal an already canonicalized value.

The Resource-API’s default demand to return all existing resources simplifies development when one had this plan anyway. The low-level version of doing so is often a combination of prefetch and instances.

Hashicorp Terraform is a well-known infrastructure automation tool mostly targeting cloud deployments. Instaclustr (a part of NetApp’s CloudOps division and credativ’s parent company) provides a managed service for various data stores, including PostgreSQL. Provisioning managed clusters is possible via the Instaclustr console, a REST API or through the Instaclustr Terraform Provider.

In this first part of a blog series, it is shown how a Postgres cluster can be provisioned using the Instaclustr Terraform Provider, including whitelisting the IP address and finally connecting to it via the psql command-line client.

Initial Setup

The general requirement is having an account for the Instaclustr Managed service. The web console is located at https://console2.instaclustr.com/. Next, a provisioning API key needs to be created if not available already, as explained here.

Terraform providers are usually defined and configured in a file called provider.tf. For the Instaclustr Terraform provider, this means adding it to the list of required providers and setting the API key mentioned above:

terraform {
  required_providers {
    instaclustr = {
      source  = "instaclustr/instaclustr"
      version = ">= 2.0.0, < 3.0.0"
    }
  }
}

variable "ic_username" {
  type = string
}
variable "ic_api_key" {
  type = string
}

provider "instaclustr" {
    terraform_key = "Instaclustr-Terraform ${var.ic_username}:${var.ic_api_key}"
}

Here, ic_username and ic_api_key are defined as variables. They should be set in a terraform.tfvars file in the same directory

ic_username       = "username"
ic_api_key        = "0db87a8bd1[...]"

As the final preparatory step, Terraform needs to be initialized, installing the provider:

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding instaclustr/instaclustr versions matching ">= 2.0.0, < 3.0.0"...
- Installing instaclustr/instaclustr v2.0.136...
- Installed instaclustr/instaclustr v2.0.136 (self-signed, key ID 58D5F4E6CBB68583)

[...]

Terraform has been successfully initialized!

Defining Resources

Terraform resources define infrastructure objects, in our case a managed PostgreSQL cluster. Customarily, they are defined in a main.tf file, but any other file name can be chosen:

resource "instaclustr_postgresql_cluster_v2" "main" {
  name                    = "username-test1"
  postgresql_version      = "16.2.0"
  private_network_cluster = false
  sla_tier                = "NON_PRODUCTION"
  synchronous_mode_strict = false
  data_centre {
    name                         = "AWS_VPC_US_EAST_1"
    cloud_provider               = "AWS_VPC"
    region                       = "US_EAST_1"
    node_size                    = "PGS-DEV-t4g.small-5"
    number_of_nodes              = "2"
    network                      = "10.4.0.0/16"
    client_to_cluster_encryption = true
    intra_data_centre_replication {
      replication_mode           = "ASYNCHRONOUS"
    }
    inter_data_centre_replication {
      is_primary_data_centre = true
    }
  }
}

The above defines a 2-node cluster named username-test1 (and referred to internally as main by Terraform) in the AWS US_EAST_1 region with PGS-DEV-t4g.small-5 instance sizes (2 vCores, 2 GB RAM, 5 GB data disk) for the nodes. Test/developer instance sizes for the other cloud providers would be:

Cloud ProviderDefault RegionInstance SizeData DiskRAMCPU
AWS_VPCUS_EAST_1PGS-DEV-t4g.small-55 GB2 GB2 Cores
AWS_VPCUS_EAST_1PGS-DEV-t4g.medium-3030 GB4 GB2 Cores
AZURE_AZCENTRAL_USPGS-DEV-Standard_DS1_v2-5-an5 GB3.5 GB1 Core
AZURE_AZCENTRAL_USPGS-DEV-Standard_DS1_v2-30-an30 GB3.5 GB1 Core
GCPus-west1PGS-DEV-n2-standard-2-55 GB8 GB2 Cores
GCPus-west1PGS-DEV-n2-standard-2-3030 GB8 GB2 Core

Other instance sizes or regions can be looked up in the console or in the section node_size of the Instaclustr Terraform Provider documentation.

Running Terraform

Before letting Terraform provision the defined resources, it is best-practice to run terraform plan. This lets Terraform plan the provisioning as a dry-run, and makes it possible to review the expected actions before creating any actual infrastructure:

$ terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # instaclustr_postgresql_cluster_v2.main will be created
  + resource "instaclustr_postgresql_cluster_v2" "main" {
      + current_cluster_operation_status = (known after apply)
      + default_user_password            = (sensitive value)
      + description                      = (known after apply)
      + id                               = (known after apply)
      + name                             = "username-test1"
      + pci_compliance_mode              = (known after apply)
      + postgresql_version               = "16.2.0"
      + private_network_cluster          = false
      + sla_tier                         = "NON_PRODUCTION"
      + status                           = (known after apply)
      + synchronous_mode_strict          = false

      + data_centre {
          + client_to_cluster_encryption     = true
          + cloud_provider                   = "AWS_VPC"
          + custom_subject_alternative_names = (known after apply)
          + id                               = (known after apply)
          + name                             = "AWS_VPC_US_EAST_1"
          + network                          = "10.4.0.0/16"
          + node_size                        = "PGS-DEV-t4g.small-5"
          + number_of_nodes                  = 2
          + provider_account_name            = (known after apply)
          + region                           = "US_EAST_1"
          + status                           = (known after apply)

          + intra_data_centre_replication {
              + replication_mode = "ASYNCHRONOUS"
            }
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.
[...]

When the planned output looks reasonable, it can be applied via terraform apply:

$ terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create
[...]
Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

instaclustr_postgresql_cluster_v2.main: Creating...
instaclustr_postgresql_cluster_v2.main: Still creating... [10s elapsed]
[...]
instaclustr_postgresql_cluster_v2.main: Creation complete after 5m37s [id=704e1c20-bda6-410c-b95b-8d22ef3f5a04]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

That is it! The PostgreSQL cluster is now up and running after barely 5 minutes.

IP Whitelisting

In order to access the PostgreSQL cluster, firewall rules need to be defined for IP-whitelisting. In general, any network address block can be defined, but in order to allow access from the host running Terraform, a firewall rule for the local public IP address can be set via a service like icanhazip.com, appending to main.tf:

data "http" "myip" {
  url = "https://ipecho.net/plain"
}

resource "instaclustr_cluster_network_firewall_rules_v2" "main" {
  cluster_id = resource.instaclustr_postgresql_cluster_v2.main.id

  firewall_rule {
    network = "${chomp(data.http.myip.response_body)}/32"
    type    = "POSTGRESQL"
  }
}

The usage of the http Terraform module also needs an update to the providers.tf file, adding it to the list of required providers:

terraform {
  required_providers {
    instaclustr = {
      source  = "instaclustr/instaclustr"
      version = ">= 2.0.0, < 3.0.0"
    }
    http = {
      source = "hashicorp/http"
      version = "3.4.3"
    }
  }
}

And a subsequent re-run of terraform init, followed by terraform apply:

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of instaclustr/instaclustr from the dependency lock file
- Finding hashicorp/http versions matching "3.4.3"...
- Using previously-installed instaclustr/instaclustr v2.0.136
- Installing hashicorp/http v3.4.3...
- Installed hashicorp/http v3.4.3 (signed by HashiCorp)

[...]
Terraform has been successfully initialized!
[...]

$ terraform apply

data.http.myip: Reading...
instaclustr_postgresql_cluster_v2.main: Refreshing state... [id=704e1c20-bda6-410c-b95b-8d22ef3f5a04]
data.http.myip: Read complete after 1s [id=https://ipv4.icanhazip.com]

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # instaclustr_cluster_network_firewall_rules_v2.main will be created
  + resource "instaclustr_cluster_network_firewall_rules_v2" "main" {
      + cluster_id = "704e1c20-bda6-410c-b95b-8d22ef3f5a04"
      + id         = (known after apply)
      + status     = (known after apply)

      + firewall_rule {
          + deferred_reason = (known after apply)
          + id              = (known after apply)
          + network         = "123.134.145.5/32"
          + type            = "POSTGRESQL"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

instaclustr_cluster_network_firewall_rules_v2.main: Creating...
instaclustr_cluster_network_firewall_rules_v2.main: Creation complete after 2s [id=704e1c20-bda6-410c-b95b-8d22ef3f5a04]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Connecting to the Cluster

In order to connect to the newly-provisioned Postgres cluster, we need the public IP addresses of the nodes, and the password of the administrative database user, icpostgresql. Those are retrieved and stored in the Terraform state by the Instaclustr Terraform provider, by default in a local file terraform.tfstate. To secure the password, one can change the password after initial connection, secure the host Terraform is run from, or store the Terraform state remotely.

The combined connection string can be setup as an output variable in a outputs.tf file:

output "connstr" {
  value = format("host=%s user=icpostgresql password=%s dbname=postgres target_session_attrs=read-write",
          join(",", [for node in instaclustr_postgresql_cluster_v2.main.data_centre[0].nodes:
               format("%s", node.public_address)]),
                      instaclustr_postgresql_cluster_v2.main.default_user_password
                )
  sensitive = true
}

After another terraform apply to set the output variable, it is possible to connect to the PostgreSQL cluster without having to type or paste the default password via:

$ psql "$(terraform output -raw connstr)"
psql (16.3 (Debian 16.3-1.pgdg120+1), server 16.2 (Debian 16.2-1.pgdg110+2))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.

postgres=> 

Conclusion

In this first part, the provisioning of an Instaclustr Managed PostgreSQL cluster with Terraform was demonstrated. In the next part of this blog series, we plan to present a Terraform module that makes it even easier to provision PostgreSQL clusters. We will also check out which input variables can be set to further customize the managed PostgreSQL cluster.

Instaclustr offers a 30-day free trial for its managed service which allows to provision clusters with development instance sizes, so you can signup and try the above yourself today!