Skip to content

Commit c8a364b

Browse files
authored
Merge pull request #2 from stategraph/feat/dry-run-mode
feat: add dry-run mode to preview API calls and flyctl commands
2 parents 1558bb1 + e1f9c00 commit c8a364b

31 files changed

+356
-48
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ Every resource implements `Resource`, `ResourceWithConfigure`, and `ResourceWith
6363
| `api_url` | `FLY_API_URL` | No |
6464
| `org_slug` | `FLY_ORG` | No |
6565
| `flyctl_path` | `FLYCTL_PATH` | No |
66+
| `dry_run` | `FLY_DRY_RUN` | No |

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,41 @@ terraform import fly_ip_address.v6 my-app/ip-id
155155
| `api_url` | `FLY_API_URL` | API base URL (default: `https://api.machines.dev/v1`) |
156156
| `org_slug` | `FLY_ORG` | Default organization |
157157
| `flyctl_path` | `FLYCTL_PATH` | Path to flyctl binary (default: search PATH) |
158+
| `dry_run` | `FLY_DRY_RUN` | Preview commands without executing them (see below) |
158159

159160
Generate a token: `fly tokens create org`
160161

162+
## Dry-run mode
163+
164+
Dry-run mode shows you exactly what API calls and flyctl commands would be executed during an apply, without actually making any changes to your infrastructure.
165+
166+
Enable it via the provider config or environment variable:
167+
168+
```hcl
169+
provider "fly" {
170+
dry_run = true
171+
}
172+
```
173+
174+
```bash
175+
FLY_DRY_RUN=1 terraform apply
176+
```
177+
178+
Terraform warnings will show each intercepted mutation:
179+
180+
```
181+
Warning: Dry Run [1]
182+
POST https://api.machines.dev/v1/apps/my-app/machines/abc123/lease body={"ttl":60}
183+
184+
Warning: Dry Run [2]
185+
POST https://api.machines.dev/v1/apps/my-app/machines/abc123 body={...}
186+
187+
Warning: Dry Run [3]
188+
/usr/local/bin/flyctl ips allocate-v6 -a my-app
189+
```
190+
191+
Read operations (state refresh, imports, data sources) work normally so Terraform can build an accurate plan. Only mutating operations (create, update, delete) are intercepted.
192+
161193
## Development
162194

163195
### Requirements

internal/models/provider_data.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package models
22

33
import (
4+
"fmt"
5+
6+
"github.com/hashicorp/terraform-plugin-framework/diag"
47
"github.com/stategraph/terraform-provider-fly/pkg/apiclient"
58
"github.com/stategraph/terraform-provider-fly/pkg/flyctl"
69
)
@@ -9,4 +12,20 @@ import (
912
type ProviderData struct {
1013
APIClient *apiclient.Client
1114
Flyctl *flyctl.Executor
15+
DryRun bool
16+
}
17+
18+
// FlushDryRunWarnings drains accumulated dry-run messages from the API client
19+
// and flyctl executor, adding each as a Terraform warning diagnostic.
20+
func FlushDryRunWarnings(diags *diag.Diagnostics, client *apiclient.Client, flyctl *flyctl.Executor) {
21+
if client != nil {
22+
for i, msg := range client.FlushDryRunMessages() {
23+
diags.AddWarning(fmt.Sprintf("Dry Run [%d]", i+1), msg)
24+
}
25+
}
26+
if flyctl != nil {
27+
for i, msg := range flyctl.FlushDryRunMessages() {
28+
diags.AddWarning(fmt.Sprintf("Dry Run [%d]", i+1), msg)
29+
}
30+
}
1231
}

internal/provider/provider.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type FlyProviderModel struct {
2727
APIURL types.String `tfsdk:"api_url"`
2828
OrgSlug types.String `tfsdk:"org_slug"`
2929
FlyctlPath types.String `tfsdk:"flyctl_path"`
30+
DryRun types.Bool `tfsdk:"dry_run"`
3031
}
3132

3233
func New(version string) func() provider.Provider {
@@ -61,6 +62,10 @@ func (p *FlyProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *
6162
Description: "Path to the flyctl binary. Can also be set via FLYCTL_PATH. If unset, searches PATH for flyctl or fly.",
6263
Optional: true,
6364
},
65+
"dry_run": schema.BoolAttribute{
66+
Description: "When true, flyctl commands are logged but not executed. Useful for previewing what commands would run. Can also be set via FLY_DRY_RUN.",
67+
Optional: true,
68+
},
6469
},
6570
}
6671
}
@@ -95,6 +100,7 @@ func (p *FlyProvider) Configure(ctx context.Context, req provider.ConfigureReque
95100
}
96101

97102
client := apiclient.NewClient(token, p.version, opts...)
103+
// DryRun is set below after config is resolved.
98104

99105
// Resolve flyctl binary path.
100106
flyctlPath := config.FlyctlPath.ValueString()
@@ -113,14 +119,23 @@ func (p *FlyProvider) Configure(ctx context.Context, req provider.ConfigureReque
113119
binaryPath = ""
114120
}
115121

122+
dryRun := config.DryRun.ValueBool()
123+
if !dryRun && os.Getenv("FLY_DRY_RUN") != "" {
124+
dryRun = true
125+
}
126+
127+
client.DryRun = dryRun
128+
116129
var executor *flyctl.Executor
117130
if binaryPath != "" {
118131
executor = flyctl.NewExecutor(binaryPath, token)
132+
executor.DryRun = dryRun
119133
}
120134

121135
pd := &models.ProviderData{
122136
APIClient: client,
123137
Flyctl: executor,
138+
DryRun: dryRun,
124139
}
125140

126141
resp.DataSourceData = pd

internal/resources/app_resource.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ func (r *appResource) Configure(_ context.Context, req resource.ConfigureRequest
9898
}
9999

100100
func (r *appResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
101+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
101102
var plan models.AppResourceModel
102103
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
103104
if resp.Diagnostics.HasError() {
@@ -168,13 +169,15 @@ func (r *appResource) Read(ctx context.Context, req resource.ReadRequest, resp *
168169
}
169170

170171
func (r *appResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
172+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
171173
resp.Diagnostics.AddError(
172174
"Update not supported",
173175
"All attributes of fly_app require replacement. Update should never be called.",
174176
)
175177
}
176178

177179
func (r *appResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
180+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
178181
var state models.AppResourceModel
179182
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
180183
if resp.Diagnostics.HasError() {

internal/resources/certificate_resource.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func (r *certificateResource) Configure(_ context.Context, req resource.Configur
103103
}
104104

105105
func (r *certificateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
106+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
106107
var plan models.CertificateResourceModel
107108
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
108109
if resp.Diagnostics.HasError() {
@@ -161,13 +162,15 @@ func (r *certificateResource) Read(ctx context.Context, req resource.ReadRequest
161162
}
162163

163164
func (r *certificateResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
165+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
164166
resp.Diagnostics.AddError(
165167
"Update not supported",
166168
"All attributes of fly_certificate require replacement. Update should never be called.",
167169
)
168170
}
169171

170172
func (r *certificateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
173+
defer models.FlushDryRunWarnings(&resp.Diagnostics, r.client, nil)
171174
var state models.CertificateResourceModel
172175
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
173176
if resp.Diagnostics.HasError() {

internal/resources/egress_ip_resource.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func (r *egressIPResource) Configure(_ context.Context, req resource.ConfigureRe
8585
}
8686

8787
func (r *egressIPResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
88+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
8889
var plan models.EgressIPResourceModel
8990
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
9091
if resp.Diagnostics.HasError() {
@@ -94,7 +95,7 @@ func (r *egressIPResource) Create(ctx context.Context, req resource.CreateReques
9495
appName := plan.App.ValueString()
9596

9697
// Allocate command doesn't support --json, use Run then list.
97-
_, err := r.flyctl.Run(ctx, "ips", "allocate-egress", "-a", appName, "--yes")
98+
_, err := r.flyctl.RunMut(ctx, "ips", "allocate-egress", "-a", appName, "--yes")
9899
if err != nil {
99100
resp.Diagnostics.AddError("Error allocating egress IP", err.Error())
100101
return
@@ -164,17 +165,19 @@ func (r *egressIPResource) Read(ctx context.Context, req resource.ReadRequest, r
164165
}
165166

166167
func (r *egressIPResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
168+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
167169
resp.Diagnostics.AddError("Update not supported", "All attributes of fly_egress_ip require replacement.")
168170
}
169171

170172
func (r *egressIPResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
173+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
171174
var state models.EgressIPResourceModel
172175
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
173176
if resp.Diagnostics.HasError() {
174177
return
175178
}
176179

177-
_, err := r.flyctl.Run(ctx, "ips", "release-egress", state.Address.ValueString(), "-a", state.App.ValueString())
180+
_, err := r.flyctl.RunMut(ctx, "ips", "release-egress", state.Address.ValueString(), "-a", state.App.ValueString())
178181
if err != nil {
179182
if flyctl.IsNotFound(err) {
180183
return

internal/resources/extension_resource.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func getStringAttrFromState(ctx context.Context, state tfsdk.State, attrName str
130130
}
131131

132132
func (r *extensionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
133+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
133134
name, diags := getStringAttrFromPlan(ctx, req.Plan, "name")
134135
resp.Diagnostics.Append(diags...)
135136
if resp.Diagnostics.HasError() {
@@ -157,7 +158,7 @@ func (r *extensionResource) Create(ctx context.Context, req resource.CreateReque
157158
}
158159

159160
var result flyctlExtension
160-
err := r.flyctl.RunJSON(ctx, &result, args...)
161+
err := r.flyctl.RunJSONMut(ctx, &result, args...)
161162
if err != nil {
162163
resp.Diagnostics.AddError(fmt.Sprintf("Error creating %s extension", r.config.TypeName), err.Error())
163164
return
@@ -188,17 +189,19 @@ func (r *extensionResource) Read(ctx context.Context, req resource.ReadRequest,
188189
}
189190

190191
func (r *extensionResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
192+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
191193
resp.Diagnostics.AddError("Update not supported", fmt.Sprintf("All attributes of fly_ext_%s require replacement.", r.config.TypeName))
192194
}
193195

194196
func (r *extensionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
197+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
195198
name, diags := getStringAttrFromState(ctx, req.State, "name")
196199
resp.Diagnostics.Append(diags...)
197200
if resp.Diagnostics.HasError() {
198201
return
199202
}
200203

201-
_, err := r.flyctl.Run(ctx, "ext", r.config.TypeName, "destroy", name, "--yes")
204+
_, err := r.flyctl.RunMut(ctx, "ext", r.config.TypeName, "destroy", name, "--yes")
202205
if err != nil {
203206
if flyctl.IsNotFound(err) {
204207
return

internal/resources/ip_address_resource.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func (r *ipAddressResource) Configure(_ context.Context, req resource.ConfigureR
105105
}
106106

107107
func (r *ipAddressResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
108+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
108109
var plan models.IPAddressResourceModel
109110
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
110111
if resp.Diagnostics.HasError() {
@@ -141,12 +142,22 @@ func (r *ipAddressResource) Create(ctx context.Context, req resource.CreateReque
141142
}
142143

143144
// Allocate commands don't support --json, so use Run then list to get details.
144-
_, err := r.flyctl.Run(ctx, args...)
145+
_, err := r.flyctl.RunMut(ctx, args...)
145146
if err != nil {
146147
resp.Diagnostics.AddError("Error allocating IP address", err.Error())
147148
return
148149
}
149150

151+
// In dry-run mode, no IP was actually allocated. Set placeholder state.
152+
if r.flyctl.DryRun {
153+
plan.ID = types.StringValue("dry-run")
154+
plan.Address = types.StringValue("")
155+
plan.Region = types.StringValue("")
156+
plan.CreatedAt = types.StringValue("")
157+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
158+
return
159+
}
160+
150161
// List IPs to find the newly allocated one.
151162
ips, err := r.listIPs(ctx, appName)
152163
if err != nil {
@@ -216,20 +227,22 @@ func (r *ipAddressResource) Read(ctx context.Context, req resource.ReadRequest,
216227
}
217228

218229
func (r *ipAddressResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
230+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
219231
resp.Diagnostics.AddError(
220232
"Update not supported",
221233
"All attributes of fly_ip_address require replacement. Update should never be called.",
222234
)
223235
}
224236

225237
func (r *ipAddressResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
238+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
226239
var state models.IPAddressResourceModel
227240
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
228241
if resp.Diagnostics.HasError() {
229242
return
230243
}
231244

232-
_, err := r.flyctl.Run(ctx, "ips", "release", state.Address.ValueString(), "-a", state.App.ValueString())
245+
_, err := r.flyctl.RunMut(ctx, "ips", "release", state.Address.ValueString(), "-a", state.App.ValueString())
233246
if err != nil {
234247
if flyctl.IsNotFound(err) {
235248
return

internal/resources/litefs_cluster_resource.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func (r *liteFSClusterResource) Configure(_ context.Context, req resource.Config
8181
}
8282

8383
func (r *liteFSClusterResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
84+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
8485
var plan models.LiteFSClusterResourceModel
8586
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
8687
if resp.Diagnostics.HasError() {
@@ -94,7 +95,7 @@ func (r *liteFSClusterResource) Create(ctx context.Context, req resource.CreateR
9495
}
9596

9697
var result flyctlLiteFSCluster
97-
err := r.flyctl.RunJSON(ctx, &result, args...)
98+
err := r.flyctl.RunJSONMut(ctx, &result, args...)
9899
if err != nil {
99100
resp.Diagnostics.AddError("Error creating LiteFS cluster", err.Error())
100101
return
@@ -135,17 +136,19 @@ func (r *liteFSClusterResource) Read(ctx context.Context, req resource.ReadReque
135136
}
136137

137138
func (r *liteFSClusterResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
139+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
138140
resp.Diagnostics.AddError("Update not supported", "All attributes of fly_litefs_cluster require replacement.")
139141
}
140142

141143
func (r *liteFSClusterResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
144+
defer models.FlushDryRunWarnings(&resp.Diagnostics, nil, r.flyctl)
142145
var state models.LiteFSClusterResourceModel
143146
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
144147
if resp.Diagnostics.HasError() {
145148
return
146149
}
147150

148-
_, err := r.flyctl.Run(ctx, "litefs-cloud", "clusters", "destroy", state.Name.ValueString(), "--yes")
151+
_, err := r.flyctl.RunMut(ctx, "litefs-cloud", "clusters", "destroy", state.Name.ValueString(), "--yes")
149152
if err != nil {
150153
if flyctl.IsNotFound(err) {
151154
return

0 commit comments

Comments
 (0)