Skip to content

Commit 7901a94

Browse files
authored
fix(edge_configurations): correct ID resolution and detect file content changes (#115, #116)
1 parent 7163079 commit 7901a94

3 files changed

Lines changed: 351 additions & 35 deletions

File tree

docs/resources/edge_configurations.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ resource "portainer_edge_configurations" "example_edge_configuration" {
2424

2525
## ⚙️ Lifecycle & Behavior
2626
- **Create** uploads a configuration file and sets the name, type, and association to edge groups.
27-
- **Update** updates the configuration via `PUT /edge_configurations/{id}` (supports changing `type`, `edge_group_ids`, and `file_path`).
27+
- **Update** updates the configuration via `PUT /edge_configurations/{id}` (supports changing `type`, `edge_group_ids`, and `file_path`, and is also triggered when the contents of the file at `file_path` change — see `file_sha256` below).
2828
- **Delete** removes the configuration via `DELETE /edge_configurations/{id}`.
29-
- **Read** retrieves metadata and synchronizes state with Portainer using `GET /edge_configurations/{id}`.
29+
- **Read** retrieves metadata and synchronizes state with Portainer using `GET /edge_configurations/{id}`. The Portainer API does not return file content or any digest, so `file_sha256` is preserved from state and recomputed locally during plan.
30+
31+
> 💡 **File content change detection:** During plan the provider reads `file_path` and computes its SHA256 into the `file_sha256` computed attribute. If the new hash differs from state, Terraform plans an in-place Update — even when `file_path` itself didn't change. This means rewriting the file in place (or `terraform apply` after the file's bytes change) will correctly re-upload it. `file_path` must be readable at plan time.
32+
33+
> ⚠️ **Same-name limitation:** Portainer's `POST /edge_configurations` endpoint does not return the new configuration's ID. To resolve the ID after create, the provider snapshots existing edge configurations matching the requested `name` *before* the POST and picks the new entry that appears afterwards. If multiple entries appear (concurrent writers) the most recently created one is chosen. Because Portainer permits multiple edge configurations with the same `name`, **avoid creating Terraform-managed and out-of-band configurations with identical names** — if a same-name entry is created concurrently with `terraform apply`, the provider may bind to the wrong record. This will be fully resolved once Portainer returns the ID on POST.
3034
3135
---
3236

@@ -45,6 +49,7 @@ resource "portainer_edge_configurations" "example_edge_configuration" {
4549

4650
## 📤 Attributes Reference
4751

48-
| Name | Description |
49-
|------|-----------------------------------------|
50-
| `id` | The Edge Configuration ID assigned by Portainer |
52+
| Name | Description |
53+
|----------------|-----------------------------------------------------------------------------|
54+
| `id` | The Edge Configuration ID assigned by Portainer |
55+
| `file_sha256` | SHA256 hex digest of the uploaded file's contents, used to detect in-place file changes between plans |

internal/resource_edge_configurations.go

Lines changed: 146 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ package internal
22

33
import (
44
"bytes"
5+
"context"
6+
"crypto/sha256"
7+
"encoding/hex"
58
"encoding/json"
69
"fmt"
710
"io"
811
"mime/multipart"
912
"net/http"
1013
"os"
1114
"path/filepath"
15+
"sort"
1216
"strconv"
1317

1418
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -49,10 +53,11 @@ type EdgeConfiguration struct {
4953

5054
func resourcePortainerEdgeConfigurations() *schema.Resource {
5155
return &schema.Resource{
52-
Create: resourcePortainerEdgeConfigurationsCreate,
53-
Read: resourcePortainerEdgeConfigurationsRead,
54-
Update: resourcePortainerEdgeConfigurationsUpdate,
55-
Delete: resourcePortainerEdgeConfigurationsDelete,
56+
Create: resourcePortainerEdgeConfigurationsCreate,
57+
Read: resourcePortainerEdgeConfigurationsRead,
58+
Update: resourcePortainerEdgeConfigurationsUpdate,
59+
Delete: resourcePortainerEdgeConfigurationsDelete,
60+
CustomizeDiff: customizeDiffEdgeConfigurationFileHash,
5661
Importer: &schema.ResourceImporter{
5762
State: schema.ImportStatePassthrough,
5863
},
@@ -63,6 +68,7 @@ func resourcePortainerEdgeConfigurations() *schema.Resource {
6368
"base_dir": {Type: schema.TypeString, Optional: true, Default: ""},
6469
"edge_group_ids": {Type: schema.TypeList, Required: true, Elem: &schema.Schema{Type: schema.TypeInt}},
6570
"file_path": {Type: schema.TypeString, Required: true},
71+
"file_sha256": {Type: schema.TypeString, Computed: true},
6672
},
6773
}
6874
}
@@ -75,8 +81,126 @@ func convertToIntSlice(input []interface{}) []int {
7581
return result
7682
}
7783

84+
// sha256File returns the lowercase hex-encoded SHA256 of the file contents
85+
// at the given path. Used to detect changes to the underlying edge config
86+
// file when the file_path itself hasn't changed (issue #116) — the Portainer
87+
// API doesn't expose the uploaded file content or any digest, so we track
88+
// our locally computed hash in state.
89+
func sha256File(path string) (string, error) {
90+
f, err := os.Open(path)
91+
if err != nil {
92+
return "", err
93+
}
94+
defer f.Close()
95+
h := sha256.New()
96+
if _, err := io.Copy(h, f); err != nil {
97+
return "", err
98+
}
99+
return hex.EncodeToString(h.Sum(nil)), nil
100+
}
101+
102+
// customizeDiffEdgeConfigurationFileHash hashes the file at file_path during
103+
// plan and writes it to file_sha256. If the new hash differs from the value
104+
// in state, Terraform sees a diff and triggers an in-place Update — even when
105+
// file_path didn't change (i.e. the file's contents were rewritten in place).
106+
func customizeDiffEdgeConfigurationFileHash(_ context.Context, d *schema.ResourceDiff, _ interface{}) error {
107+
fp, _ := d.Get("file_path").(string)
108+
if fp == "" {
109+
return nil
110+
}
111+
hash, err := sha256File(fp)
112+
if err != nil {
113+
return fmt.Errorf("failed to hash file_path %q: %w", fp, err)
114+
}
115+
if d.Get("file_sha256").(string) == hash {
116+
return nil
117+
}
118+
return d.SetNew("file_sha256", hash)
119+
}
120+
121+
// listEdgeConfigurations fetches all edge configurations from Portainer.
122+
func listEdgeConfigurations(client *APIClient) ([]EdgeConfiguration, error) {
123+
req, err := http.NewRequest("GET", fmt.Sprintf("%s/edge_configurations", client.Endpoint), nil)
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to build list request: %w", err)
126+
}
127+
if client.APIKey != "" {
128+
req.Header.Set("X-API-Key", client.APIKey)
129+
} else if client.JWTToken != "" {
130+
req.Header.Set("Authorization", "Bearer "+client.JWTToken)
131+
}
132+
resp, err := client.HTTPClient.Do(req)
133+
if err != nil {
134+
return nil, fmt.Errorf("failed to list edge configurations: %w", err)
135+
}
136+
defer resp.Body.Close()
137+
var configs []EdgeConfiguration
138+
if err := json.NewDecoder(resp.Body).Decode(&configs); err != nil {
139+
return nil, fmt.Errorf("failed to decode edge configurations list: %w", err)
140+
}
141+
return configs, nil
142+
}
143+
144+
// resolveCreatedEdgeConfigID disambiguates the just-created edge configuration
145+
// when the Portainer API returns an empty POST response. It diffs the post-create
146+
// listing against a pre-create snapshot of IDs sharing the same name; if no new
147+
// entry is found, it falls back to the most recently created matching config.
148+
//
149+
// Background: Portainer's POST /edge_configurations does not return the new ID
150+
// (https://github.com/portainer/terraform-provider-portainer/issues/115). Using
151+
// name alone causes the provider to bind to a pre-existing same-name config,
152+
// later mutating or deleting it. The snapshot-based diff fixes that for any
153+
// case where a same-name config already exists.
154+
func resolveCreatedEdgeConfigID(configs []EdgeConfiguration, name string, preExistingIDs map[int]struct{}) (EdgeConfiguration, error) {
155+
var newMatches []EdgeConfiguration
156+
var allMatches []EdgeConfiguration
157+
for _, c := range configs {
158+
if c.Name != name {
159+
continue
160+
}
161+
allMatches = append(allMatches, c)
162+
if _, existed := preExistingIDs[c.ID]; !existed {
163+
newMatches = append(newMatches, c)
164+
}
165+
}
166+
167+
pickNewest := func(in []EdgeConfiguration) EdgeConfiguration {
168+
sort.Slice(in, func(i, j int) bool { return in[i].Created > in[j].Created })
169+
return in[0]
170+
}
171+
172+
switch {
173+
case len(newMatches) >= 1:
174+
// Exactly the entry POST just produced; if a concurrent caller raced
175+
// us, prefer the most recently created one.
176+
return pickNewest(newMatches), nil
177+
case len(allMatches) >= 1:
178+
// Server returned no new entry (replication lag, server quirk). Best
179+
// effort: pick the most recently created matching name.
180+
return pickNewest(allMatches), nil
181+
default:
182+
return EdgeConfiguration{}, fmt.Errorf("edge configuration created but could not determine its ID")
183+
}
184+
}
185+
78186
func resourcePortainerEdgeConfigurationsCreate(d *schema.ResourceData, meta interface{}) error {
79187
client := meta.(*APIClient)
188+
name := d.Get("name").(string)
189+
190+
// Snapshot existing edge configuration IDs sharing the requested name.
191+
// Portainer's POST does not return the new ID, so after the create we
192+
// diff the listing against this snapshot to identify the new entry. This
193+
// is what prevents adopting a pre-existing same-name config (issue #115).
194+
preCreate, err := listEdgeConfigurations(client)
195+
if err != nil {
196+
return err
197+
}
198+
preExistingIDs := make(map[int]struct{})
199+
for _, c := range preCreate {
200+
if c.Name == name {
201+
preExistingIDs[c.ID] = struct{}{}
202+
}
203+
}
80204

81205
filePath := d.Get("file_path").(string)
82206
file, err := os.Open(filePath)
@@ -89,7 +213,7 @@ func resourcePortainerEdgeConfigurationsCreate(d *schema.ResourceData, meta inte
89213
writer := multipart.NewWriter(body)
90214

91215
payload := map[string]interface{}{
92-
"name": d.Get("name").(string),
216+
"name": name,
93217
"type": d.Get("type").(string),
94218
"category": d.Get("category").(string),
95219
"baseDir": d.Get("base_dir").(string),
@@ -147,38 +271,26 @@ func resourcePortainerEdgeConfigurationsCreate(d *schema.ResourceData, meta inte
147271
}
148272

149273
if created.ID == 0 {
150-
// API returned empty body — find the created config by name
151-
name := d.Get("name").(string)
152-
listReq, err := http.NewRequest("GET", fmt.Sprintf("%s/edge_configurations", client.Endpoint), nil)
274+
postCreate, err := listEdgeConfigurations(client)
153275
if err != nil {
154-
return fmt.Errorf("failed to build list request: %w", err)
276+
return err
155277
}
156-
if client.APIKey != "" {
157-
listReq.Header.Set("X-API-Key", client.APIKey)
158-
} else if client.JWTToken != "" {
159-
listReq.Header.Set("Authorization", "Bearer "+client.JWTToken)
160-
}
161-
listResp, err := client.HTTPClient.Do(listReq)
278+
created, err = resolveCreatedEdgeConfigID(postCreate, name, preExistingIDs)
162279
if err != nil {
163-
return fmt.Errorf("failed to list edge configurations: %w", err)
164-
}
165-
defer listResp.Body.Close()
166-
var configs []EdgeConfiguration
167-
if err := json.NewDecoder(listResp.Body).Decode(&configs); err != nil {
168-
return fmt.Errorf("failed to decode edge configurations list: %w", err)
169-
}
170-
for _, c := range configs {
171-
if c.Name == name {
172-
created = c
173-
break
174-
}
175-
}
176-
if created.ID == 0 {
177-
return fmt.Errorf("edge configuration created but could not determine its ID")
280+
return err
178281
}
179282
}
180283

181284
d.SetId(strconv.Itoa(created.ID))
285+
286+
// Persist the file's SHA256 in state so future plans can detect content
287+
// changes even when file_path is unchanged (issue #116). CustomizeDiff
288+
// already computed this for the diff, but we re-compute here so the value
289+
// stored matches the bytes we actually uploaded.
290+
if hash, err := sha256File(filePath); err == nil {
291+
d.Set("file_sha256", hash)
292+
}
293+
182294
return nil
183295
}
184296

@@ -238,6 +350,10 @@ func resourcePortainerEdgeConfigurationsUpdate(d *schema.ResourceData, meta inte
238350
return fmt.Errorf("failed to update edge configuration: %s", string(respBody))
239351
}
240352

353+
if hash, err := sha256File(filePath); err == nil {
354+
d.Set("file_sha256", hash)
355+
}
356+
241357
return resourcePortainerEdgeConfigurationsRead(d, meta)
242358
}
243359

0 commit comments

Comments
 (0)