Skip to main content
HashiTalks 2025 Learn about unique use cases, homelab setups, and best practices at scale at our 24-hour virtual knowledge sharing event. Register
Demo

Extending Packer

Watch a demo on how to create your own third party plugins for use with the Packer core.

Packer has a wealth of builders, provisioners, and post-processors to help you create the perfect image. But sometimes what we provide in the core binary is not quite right for a particular use case. This talk will dig into how to create your own third party plugins for use with the Packer core.

Speakers

  • Megan Marsh
    Megan MarshEngineering Lead— Packer

Transcript

Hi, everyone. I hope you're all doing well today. I'm here to talk about Packer, which—I know—maybe three of you didn't raise your hands. This is a cool open-source tool for creating identical machine images for multiple platforms from a single source configuration.

This talk is going to be about extending Packer. Packer, as we think of it, is a JSON template. It has a variable section, normally, and then, the real meat and bones of Packer is the builder section, the provisioner section, and the post-processor section. Those three sections represent things that we call plugins inside of Packer, and it turns out that you can actually write your own.

You can write your own builders, post-processors, and provisioners—that’s what we're going to talk about today. My name is Megan Marsh. Like Jake said, I am the engineering lead for Packer. My GitHub handle is SwampDragons. You'll notice I don't have a Twitter account, so if you have any criticism, go ahead and send that to /dev/null. If you like this talk, please feel free to tweet at Mitchell and Armon about how great I am.

First, I want to get through is what our goals for this talk are. I want to talk about what Packer plugins are. I want to talk about why you might want to use Packer plugins, and how to create them for yourself.

What is a Packer plugin?

I want to start with what they are. Like I mentioned, if you're using Packer for the first, second, or even 10,000th time—if you've never had to check your running processes while Packer is running—you may not realize that Packer actually is a bunch of sub-processes. A sub-process means it's called from a separate binary, and it talks to Packer via a Remote Procedure Call (RPC).

If you were to launch a packer build with debug flags, what you would see is something like this. It's not one process running. You've got the packer build that you called from the command line. You'll also see a whole bunch of sub-process calls, and they'll take two forms.

You've got your packer plugin call, which is the Packer binary that ships when you download it from HashiCorp. And then, it calls an internal call—that’s a plugin. It says packer plugin, and then packer builder null. The null builder ships with the binary, and it's not something that you're doing specifically.

You'll also see in this particular example, another binary being called and it's being stored in this .packer.b/plugins folder. This doesn't ship with the core Packer binary. It's something that I wrote independently in Go, I packaged into its own Go binary, I put it in the right place, and Packer knew what to do with it.

The cool thing about sub-processes is that once they're launched, Packer couldn't care less whether you use the packer plugin call to call something that ships with the core, or whether you launched a binary itself. Once they're launched, they talk to Packer the same way—which is via a TCP over localhost.

It's worth noticing here that you've got duplicates of the same sub-process being called. In this case, I have four different calls to packer-provisioner-foo. That's because every single time you make reference to a particular builder inside of your Packer template, every one of those references to a builder is going to generate a different sub-process. So, if I call shell-local half a dozen times, I'm going to see half a dozen instances of the shell-local provisioner being launched inside of this process list.

At this point, you might be thinking to yourself, "Oh, geez. If I want to develop for Packer, do I need to have to keep track of how all of these things communicate with each other? That sounds insane, and I don't want to do it. And I think I might be able to catch a different talk in another room if I sneak out right now." But the reality is that in the entire time I've been maintaining Packer—which is almost three years now—I’ve had to get out my strings and thumbtacks maybe twice.

The sub-process situation is really well abstracted. The vast majority of the time, you're completely capable of thinking about Packer as a single monolithic piece of code even though it's really not.

Why use third-party Packer plugins?

The first obvious answer is maybe you want to do something that I haven't programmed for you yet. There are a lot of situations where you can get what you need inside the Packer toolbox. But then, there are other situations where maybe you want to do something that isn't our bread and butter. For example, there are people out there who want to use Packer to provision a Raspberry Pi.

There is a Packer ARM builder available as a third-party plugin so that you can do that if that's something you're interested in doing. Now, some people also want to use a cloud that I haven't had a chance to build a builder for yet. It feels like there's a cloud being born every minute right now, and so there's definitely a point where Packer hasn't gotten around to creating the builder you want to build.

You can create a third-party plugin, or you can contribute to Packer core. Either one of those works well. It would also be cool to have a third-party plugin in the case where you have a new builder because you're decoupled from the Packer release process. Anytime that you want to make an update to your particular cloud's builder, you could just update it. Then that binary still works with the latest release version of Packer.

Another possibility is that our functionality isn't exactly right for your particular use case. You could do what you want to do with Packer—with what ships with the core—but you might not have the best experience at it. I know there are some people at this conference who have that exact situation.

The one that comes to mind for me is the VMware builder. We have a built-in VMware builder that does allow you to build on VMware remotely—on ESX. But our builder SSHes into the ESX instance, and then uses the ESX CLI to manage the VM lifecycle, which is cool if you don't have vSphere, which a lot of people didn't when that builder was written.

However, nowadays, most of you probably who are using VMware have vSphere—you have vCenter. You want to be able to hit that vSphere API instead of dealing with this wordy little builder that expects you to even be able to have SSH access to your ESX instance. The cool thing about third-party plugins is that long before it was on the Packer radar to build something to fix this, JetBrains stepped in and said, "Hey, we need a vSphere builder, so we're going to build one." It is a super popular third-party builder that a ton of people can use. They can reach out to the vSphere API instead of depending on what we ship with the core.

Now, there is a conversation right now about merging that builder with the Packer core. But it takes a while because there are a lot of stakeholders. A lot of people have to sign off on making that merge happen. We have to check and make sure variable naming matches. We have to make sure they get around to opening the PR. In the meantime, it doesn't matter from an end-user perspective, whether it's a third-party builder or whether it ships with the core. You get to have your cake and eat it, too.

Another possibility—another reason you might want to use a Packer plugin—is that you want to tweak one of our provisioners in a way that's right for you, but may not be a great fit for merging with the mainline project. That's a complicated way of saying, "I told you no." Like maybe you opened a PR, and I said, "That seems hard to maintain, or that's backwards incompatible, or I don't think this fits with the philosophy of the project." Which are all things I've had to say. I hate saying no. It is absolutely my least favorite part of what is otherwise a total dream job. We're hiring for Packer, by the way!

But when I say no, that doesn't need to be the end of the story for a person who has a contribution that works for them. Because if something works well for you, you don't even need to fork the project and maintain your own version of Packer. You could make a copy of the builder that has the thing that you wanted to change. Change that builder into a standalone binary, make the tweaks that you want to make. Then, that binary will run side-by-side with the version of Packer that you downloaded from Packer.io.

You can stay updated on all core features, all other builders, and all the other plugins—all of the processors. And yet you're still able to have your cake and eat it, too, in that way also. That's really, powerful—and I think more people should take advantage of that.

Why give a talk about Packer plugins?

Finally, another last question is, “Why should I spend my one HashiConf talk talking to you about something that isn't technically HashiCorp's thing, and isn't technically even supported by me?” And the answer is that the Packer team is actually really small. There are two of us right now, and we're trying to increase. But for a tool that every one of you in this room uses—and thousands of other people use—that’s not a huge number of people to iterate quickly. We need to be able to make Packer as modular and extensible as possible. We don't have the human bandwidth to maintain Packer in a reasonable way by ourselves.

Also, no matter how big the team is, the maintainable surface area of the tool will always be bigger than the team. Although plugins have been part of Packer's DNA for a super long time, no one really takes advantage of the ability that they give us to extend Packer in ways that are customizable for ourselves.

One thing that we're running into right now—particularly with community plugins, as people are coming up with newer and more interesting use cases every day—is that we cannot maintain any more plugins with the Packer core. We need the community to start helping us out a little bit more. We're getting to a point where I simply cannot merge any more of them. So, that's got to change, and so I'm going to spend my talk today talking to all of you about how to use and how to build your own plugins so that I can recruit your help to start maybe building a bigger community around other open-source plugins.

How to install a third-party plugin

First, I want to get into how to install a third-party plugin. Because when we talk about plugins and we talk about having separate binaries, it can feel a little bit intimidating. It certainly did to me before I sat down and looked into it. In this case, when I say install, what I mean is grab a binary and put it in the right place—so please don't be too concerned.

First, you download the plugin. Many maintainers of third-party plugins already upload the binaries for you—either onto GitHub via the GitHub releases site or somewhere else. So, you really only have to download the plugin, and you're good to go. You don't have to worry about installing Go. You don't have to worry about building, you don't have to worry about any of that.

You move the plugin into the right place on your computer. You make sure you have permission to execute the plugin, which is something that I've definitely made a mistake with before. And then, you use the plugin in your Packer template. You reference it. You say, Use the foo provisioner." Packer will know where to look to find it.

Where to install a third-party plugin

Let's get in a little bit more deeply into step two here. There are a few places that you can put that plugin where Packer will find it. The first one is in your home directory. It's a little bit different on Windows. Before I get into any of the actual pathing and stuff here, all of this is documented on our website. You don't need to take notes, there will not be a test. I just want to give you the overview so that you can feel a little bit inspired to go and check it out yourselves.

You go in your home directory .packer.d/plugins is the subdirectory structure. If you drop your plugin in there, Packer would be able to find it. But if you aren't feeling that organized, you don't want to put it into your home directory, that's okay. We'll look in your current working directory, wherever you're calling Packer from, we'll check and see if there's a plugin that has the right name there. And if you don't want to do that, you can install it next to the Packer binary. Wherever you put your path to your Packer binary, that's where we'll look for a plugin. And finally, you can tell us where to look. We have an environment variable called $Packer_Config_Dir.

If you set that environment variable instead of checking your home directory, it will check inside that config directory. As long as you still have that sub-directory structure so that we know where your plugins are—.packer.d/plugins—it can live anywhere on your system. We're not trying to make this a super complicated situation.

The name of your plugin matters

The last thing to know—and I alluded to this earlier—is that the name of your plugin matters. If you have something in your current working directory, Packer is not going to check every single file in your current working directory. It would be like, "I don't know, are you a plugin?” It has to be named.

So, it starts with Packer, it's a three-part name. The prefix is Packer. The second part is the type of plugin it is. I mentioned earlier, there are three possible kinds of plugins. There are builders, provisioners, and post-processors. If you were to name this—if it were a post-processor, you were running—it would be post-processor. It would not be a single word—since that probably feels like a question that would come up.

Then, finally, you put in the name of your plugin. In this example case, it's comment. Now, the name of the plugin—that is the suffix of the name—is going to be the name that you put in your JSON template. That's the only way that Packer knows. There's nothing inherent to the plugin binary itself that determines its name. It's the name at the end of the suffix. The same comment here in my Packer JSON, I will just save, I need to use, in my provisioner section type equals comment, and then I'll be able to use it.

This is cool because it means even if you have two of the same plugins, you could have a copy of the binary. You could name them two different things. You could reference them two different ways in your template. Packer will know what to do, and it's not a big deal.

With that, we've covered everything that we need to know about using plugins that someone else has built, but we're all programmers here, I'm pretty sure. So, let's talk about how to get our hands dirty and build some.

How to build Packer plugins

This is going to be the fun part for me. I created a GitHub repo that is on my personal GitHub account that I would like all of you to take a look at at some point. It contains a bare-bones, minimal, functional example of how to create a provisioner. It's a few lines of code as I could get away with to be able to do this. I was inspired by this. We have a very long-running issue for converting Packer to use HCL2 instead of JSON. Someone commented, "I'd like this because JSON doesn't allow comments." Well, I would like it, too.

We're working on it. But in the meantime, I thought it could be fun to make a provisioner that allows you to have a NoOps provisioner that comments your JSON files and annotates them. Now, we could have used a shell-local provisioner and echoed, "Hey, this next thing I'm going to do is going to be awesome," into dev/null, or somewhere. Then, you don't have to worry about it. But that's not fun. I was also inspired by the fact that Ansible will go ahead and put the title into cowsay when you have a title for a task in an Ansible role—if you have cowsay installed on your computer.

Now, I happen to be obsessed with cowsay, I don't know why. It brings me joy every time I see those little buggy eyes. I thought I want to do something charming like that. I went online, I looked and saw if there were any Golang modules that already implemented cowsay. It turns out there are. But none of them were quite as modular as I wanted to keep the example repo super minimal.

I found something that was almost as good, which was cutting-edge bubble text. I created a provisioner that will print the word of your choice in cutting edge bubble text to your terminal. But to make this bubble text vision a reality, I need to first understand what makes a provisioner a provisioner. Now is where we're going to dive into the actual giant slides full of code themselves.

An in-depth look at plugin code

Don't panic. The first step is to realize that we have a concept called interfaces in Golang. How many of you in the audience have Golang experience? That's actually a pretty good chunk. This part is going to be a little boring for you. When you first get started programming in Golang— which is not a particularly difficult language to get started with as long as you're feeling confident in yourself—there’s this concept called interfaces.

It's probably the biggest hangup that I had as a new Golang programmer, but it shouldn't be. It's defining an API, and saying, "This is my promise to you about what this API is going to contain.” Rather than being an API that you're hitting across the internet, it's an API for an object. Golang is an object-oriented language.

This is where you're declaring it's an interface. This is saying that a provisioner is any object that has both a prepare method and a provision method. Those methods have to have these function signatures. They have to take as input–in prepare's case, a series of interfaces, and in provision's case—a couple of specific Packer-specific objects. They have to return errors or lacks thereof.

As long as those interfaces are fulfilled, you can create an object in Golang that does any number of things—any number of extra fields on it, any amount of data stored on it. As long as it also has a method named, “prepare,” with this function signature, and a method named, “provision,” with this function signature, you're going to be good to go. We will be able to use it.

If you don't have these function signatures and you try to create a provisioner, when you go to compile that binary, the compiler will tell you that you're bad and you should feel bad. The good news about that is that you'll know right away whether or not your provisioner will cause Packer to crash long before you ever launch Packer—in an ideal case.

What does third-party plugin code actually look like?

That interface I showed you doesn't live inside of my special standalone repo. That code lives inside of the Packer core and is a library that you can use to help you. It's a helper library for building new plugins.

We're going to look at the code itself. Before we do, I want to let you know—do not panic—there are two Go files in my entire repo. There's main.go, and there's provisioner.go. All together, they are 60 lines of code, and probably at least a quarter of that is copy-pasted. We're going to dig into it. Do not panic.

Provisioner.go starts out with naming the package. You're going to want to name it main. There's going to be some Golang compilation tips and tricks later. It gets weird compiling if you don't name it main, so just name it main.

You import the context library from Go. This is the bubble text library that I'm importing here. And then, there are two Packer helper functions that ship inside of Packer. That first one there is going to help you read your JSON template into Golang objects that we can use for the provisioner. And then, this one is where the interfaces live. This is where the definition of a provisioner exists and where some of the objects that we get handed from Packer are defined.

After that, we have to define our config. This is going to be a direct one-to-one mapping to our JSON template. The JSON template that we have—I decided for this particular provisioner— should have three options. It should have a comment option, which is, “What do I want to be saying inside of my provisioner.” It should have a send to UI boolean option that says whether or not to print information or to be no-op annotation. Then, finally, whether I want to put it in bubble text. I've facetiously named that one fancy, but it could be named anything.

This over here is where the magic of translating from the JSON template into the Golang struct comes. The map structure library is something that we have that allows you to say, "The thing in the JSON template called comment—read that string into the comment object field on the struct. And the thing named fancy—read that into the fancy field on the struct."

Now, if I change this to say mapstructure: "fancy pants", in my JSON template, my option that I type would be fancy pants. True. Yes, I am a fancy pants. Then moving onto the CommentProvisioner struct. If you're more of a Python person, then this is the equivalent of a class. This is the basic object that we're sticking everything else on. We're storing that config we just defined as a field on that struct so that we can access it later. And now, we finally get to talk about what we do in the prepare and provision methods that we need to create to fulfill the provisioner interface.

Anything that you're doing that is a preflight thing—reading the template, doing input validation, making sure that you shouldn't both be able to say bubble text and cowsay. You should only do one of those two things: “If they've set both of those to true, then I'm going to fail." That should happen in the prepare method. If you're doing any API calls, anything that requires credentials, that probably belongs in the provision section. The reason is that the prepare section isn't just called by the packer build UI command—it’s also called by the packer validate command. It should be the static analysis stuff. The stuff that says, this is a valid template, nothing is immediately smelly to me. But anything that is logic—anything that should happen during the time the VM is alive, that you're provisioning—that needs to happen in the provision section.

In this case, I wanted to call out quickly that if you're not a Golang person, this can look a little bit intimidating. These are called pointer receivers, and they're saying that the methods belong to the struct we defined that's named CommentProvisioner above. That's how we are making sure that we're fulfilling the interface and saying that these methods don't float around in the ether of the main package.

Inside of the prepare method, I'm decoding the config. This is coming out of that helper library that we imported earlier. This is copy-pasted. You don't need to worry about it. It's just something that we give to you. As long as you're decoding it—you’re decoding it from the JSON template into the config. And as a little bit what you're getting for free there is like basic input validation. So, if I had fancy pants, but my map structure says fancy, this config method will say, "Wait a second, that's not right. I don't have a fancy pants option to decode into. I can't do this. This build won't run, the end."

The provision method

This is where I want to dig slightly quickly into the objects that Packer is handing you to work with when you're creating a provisioner.

The context object

This comes from Golang. It means if I'm hitting Control-C, I want to be able to return immediately. This isn't important for this particular provisioner. It is important if you had a long-running API call where you were waiting for something to become ready. You want to be able to say, "If someone hit cancel, I need to cancel that API call and return right away." Instead of saying, "Oh, I'm going to wait for 20 minutes, and then eventually, I'm going to return even though they hit cancel." The context object allows us to do that.

The Packer UI object

Packer does some weird stuff wrapping our standard out and standard error streams. We give you a UI object—you don't have to worry about that. Instead, you can use the UI object and say, "UI, say, UI, message." Then, we'll put it into the Packer terminal for you.

The Packer communicator

That's not going to be useful in this one because I'm not really communicating with the VM instance, and this is a very simple provisioner. But normally, this would contain all of the connection information that I need. If I wanted to upload a file, instead of trying to create a new connection and then SCP-ing it onto my instance, I would say, communicator.upload.

We have all of that abstracted away for you. It's its own separate interface, and you can check that out inside of the Packer library. That allows us to abstract away. It doesn't matter if you're using SSH or WinRM from a provisioner perspective. All it cares about is that it has a communicator that has an upload function, a download function, a call function; stuff like that—so that you can send commands to it.

And then, finally, inside, this is just the logic of what’s doing. It's simple. I'm checking whether I want to send this to the UI. I'm checking whether I want to format this as bubble text. I'm printing that text into my UI object, and then I'm returning nil. The return is important too because—as we saw in the interface—you have to return an error. If you returned nil, you're saying, "Everything went hunky-dory, and there's no big deal."

If you return an error, you're saying, "Oh, something happened that I, the provisioner, could not handle—could not recover from—and you should fail the Packer build.” If something happens, that should definitely mean like, "Oh, this image that's produced is not going to be the right image." You return an error from here. If it's something silly like, "Oh, I couldn't log something, or I couldn't register some piece of unnecessary information.” Then, you don't return an error for that, and that helps our Packer core understand whether the provisioning run was a success or a failure.

That was the entire provision.go thing, and main.go is all copy-pasted from our docs on how to build a provisioner. Main.go is creating a server that serves the binary we just created so that it is available via the RPC conversation that I talked about earlier. This is the reason it's so abstracted. It lives in this one file. It's 15 lines of code. The only thing I changed was I made sure that the name inside these parentheses matched the name of the struct I defined.

If you do that—and you make sure you have this file—we can serve it properly. Then, you don't have to think about any of the communication that happens between the core and your binary. It's all handled for you inside of this file. That was the entire thing. That was the entire provisioner. If you build it, it runs by itself, it stands entirely alone, and you can make it work. Again, this lives in my repo—not in the Packer repo—because it is entirely separate from the Packer repo, apart from a couple of imports.

I would recommend if you want to take a look at this, check out the version 0.2 tag instead of what I have as the latest commit on master. Simply because within the last week, as I was preparing for this talk, I got bored. Then I decided I wanted to tweak it, and I added extra features. It's a little bit more complicated than the master branch that I just showed you.

I was bumming about not having cowsay so I created a special implementation packersay, which is Packy, our Packer friend. She's cool, but I didn't ask marketing's permission before implementing her, so don't tell them. This can maybe be our little secret. But the one cool thing about the more up-to-date stuff is now you can see with the implementation of Packy how to import other ASCII art, a little bit of Golang-specialized text template handling. It's pretty jank, but it's cool, and you can also see what it looks like to return a real error. If I can't render Packy because something went wrong with the way the Golang template was rendering, then it'll return an error because, "Oh, no, everything's failed forever.” That's an example of why you maybe want to check out one of the older version tags. Although, it's not so much more complicated that I don't think everyone in this room is smart enough to handle it. It's a couple of extra lines of code.

How to build a third-party plugin

I've written my binary, or I've written my Golang code. I need to turn it into a binary, and I've never written Go before, and I don't know how to compile stuff. What do I do? Please help me. Installing Golang is probably outside the scope of this talk. But I think you're all competent and intelligent human beings. I'm sure you can figure it out based on their documentation.

If you haven't written it yourself, you clone the code from wherever on GitHub you found it, then you run, go build main. Now, this is why I mentioned earlier that it's important to make sure that your package is named main. If your package is named something else—see, I named my package comment—because I felt like I was being useful. When I run go build comment, it'll say, "Yeah, that built." Then, it will throw away the binary.

Golang doesn't think that you want to save anything unless it's named main. If it's named main, it'll save main into your current working directory. Then, from there, you can rename it packer.provisioner.comment, and move it where you want to put it.

Once you've moved it where you want to put it and changed permissions—not that I did that—make sure you run your build. You run your build by referencing it in the template, and you're good to go. This is what a template would look like, that makes the comment provisioner—that we wrote together—run. It's very straightforward because I've said print Packer to the UI and make it fancy, it will use the bubble text, and that's what you're going to get out of it.

What about builders and post-processors?

I only have 30 minutes, and I'll probably get booed off the stage if I try to do any more than that. I'm not going to dig into this too much. But if you respect the interfaces that we give you, you'll be totally fine. Builders are a little bit more complicated than provisioners, but not so much more that you should be intimidated. A good example of a builder that's inside the Packer repo, rather than a standalone—but you can get the same idea—is the null builder. It's simple—really straightforward. It'll walk you through the basic idea that a builder is a structure with a config just like a provisioner, but its job is to manage the VM lifecycle.

It does that via a series of steps. It says: Step—configure a thing. Step—launch the VM. Step—connect to the VM and figure out the password. Then, there will be a step inside that list of steps, that's step provision, and that's where all the provisioners get run. Steps are another tool that's provided to you via Packer. It's called multi-step, and it's not something we need to dig into right now. But it is available, and as soon as you look at a builder, it will become clear to you. A builder manages a VM lifecycle and then produces an artifact. An artifact is an interface that defines basic information about your thing. Where does it live? Is there a path associated with it? What's the AMI ID, if it's an Amazon thing, etc.

What about post-processors? Oh, like I said, artifact, it's a bonus interface, but it's not that hard. It's pretty straightforward. If you want to take a look at how post-processors are built—you want to do something after your build is all done and you want to check something out, I would recommend the manifest post-processor because you'd get two birds with one stone there.

You manage to see a basic post-processor in action, but you also get to see an artifact in action. Because the manifest post-processor goes through the artifact and prints information from the artifact object into a file. So you get to see both of those things—both of those interfaces being implemented nicely and cleanly. A post-processor is a struct with a config— like the provisioner that we talked about—with code that acts upon an artifact and changes it in some way.

Hopefully, you've learned what plugins are, why to use them, how to make them. If you want to look more at this, you can check out the community tools page.

I'm starting to create a list of third-party plugins for people to access. If you use a third-party plugin that isn't on that list—if you write a new plugin—reach out to me via email or via the GitHub issue tracker. Or for bonus points, go ahead and make a PR adding your thing to the website. In the future, I'm hoping to create maybe a Packer Init, maybe a Packer registry where we can start storing and formalizing the process of having these plugins.

I haven't completely figured that out yet, what that's going to look like. But I do want to do that within the next year or so. It will be easier to sell to my boss that I should spend my time doing that if all of you start using and creating more plugins for me. In the meantime, I want you to go forth and build some cool things. I hope this talk has been informative for you. Again, if not, dev/null–if yes, Mitchell H. Have a great rest of your conference.

More resources like this one

2/3/2023Case Study

Automating Multi-Cloud, Multi-Region Vault for Teams and Landing Zones

1/20/2023Case Study

Adopting GitOps and the Cloud in a Regulated Industry

12/31/2022Presentation

Golden Images and How To Create Them

12/19/2022Presentation

The Packer Roadmap — HashiConf Global 2022