Skip to content

Commit 238f284

Browse files
authored
implemented reuse option (#51)
* implemented reuse option the new optional `reuse` attribute allows to re-assign freed keys instead of enforcing incremental values. fixes #50 * refactoring of README.md * added usage example for `reuse` * fixed required Go version (according to go.mod) * updated Go link * cleanup
1 parent abd93fd commit 238f284

File tree

4 files changed

+267
-54
lines changed

4 files changed

+267
-54
lines changed

README.md

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,98 @@
11
# Persistent Counter provider for Terraform
22

3-
```
3+
This provider allows to assign unique, increasing integers to string keys
4+
maintaining the same value per key while the key stays assigned.
5+
6+
## Example
7+
8+
```terraform
49
terraform {
510
required_providers {
611
persistent = {
7-
source = "rosmo/persistent"
12+
source = "rosmo/persistent"
13+
version = ">=0.1.11"
814
}
915
}
1016
}
1117
12-
resource "persistent_counter" "example" {
13-
keys = ["a", "b", "c"]
18+
variable input {
19+
type = set(string)
1420
}
1521
16-
# Result would be:
17-
# persistent_counter.example.values = { a = 0, b = 1, c = 2 }
22+
resource "persistent_counter" "with-reuse" {
23+
initial_value = 1
24+
keys = var.input
25+
reuse = true
26+
}
1827
19-
# Changing the keys:
20-
resource "persistent_counter" "example" {
21-
keys = ["a", "b", "d", "c"]
28+
resource "persistent_counter" "without-reuse" {
29+
initial_value = 1
30+
keys = var.input
31+
reuse = false
2232
}
2333
24-
# New result would be:
25-
# persistent_counter.example.values = { a = 0, b = 1, d = 3, c = 2 }
34+
output counters {
35+
value = {
36+
"with-reuse" = persistent_counter.with-reuse.values,
37+
"without-reuse" = persistent_counter.without-reuse.values,
38+
}
39+
}
40+
```
41+
42+
Run the example providing an initial value as input results in the following output:
2643

44+
```shell
45+
$ terraform apply -auto-approve -var 'input=["c","b","a"]'
46+
47+
counters = {
48+
"with-reuse" = tomap({
49+
"a" = 1
50+
"b" = 2
51+
"c" = 3
52+
})
53+
"without-reuse" = tomap({
54+
"a" = 1
55+
"b" = 2
56+
"c" = 3
57+
})
58+
}
2759
```
2860

29-
The provider is available from Terraform registry: [registry.terraform.io/providers/rosmo/persistent/latest](https://registry.terraform.io/providers/rosmo/persistent/latest)
61+
If values were just added, both, the version with and without `reuse` enabled
62+
behave the same. Also note, that keys are always sorted ascending, before counter
63+
values are assigned to them.
64+
65+
The difference the two versions in this example can be made clear when exchanging
66+
an element (i.e. removing a value and adding a new one at the same time):
67+
68+
```shell
69+
$ terraform apply -auto-approve -var 'input=["c","a","d"]'
70+
71+
counters = {
72+
"with-reuse" = tomap({
73+
"a" = 1
74+
"c" = 3
75+
"d" = 2
76+
})
77+
"without-reuse" = tomap({
78+
"a" = 1
79+
"c" = 3
80+
"d" = 4
81+
})
82+
}
83+
```
84+
85+
When `reuse` is set to `true`, the counter will re-assign values that are no
86+
longer in use, while a value of `false` will always emit unique, ascending values.
87+
88+
## Usage
89+
90+
The provider is available from Terraform registry: [registry.terraform.io/providers/rosmo/persistent/latest](https://registry.terraform.io/providers/rosmo/persistent/latest).
3091

3192
## Requirements
3293

3394
- [Terraform](https://www.terraform.io/downloads.html) >= 1.0
34-
- [Go](https://golang.org/doc/install) >= 1.18
95+
- [Go](https://go.dev/doc/install) >= 1.21 (building from source only)
3596

3697
## Building The Provider
3798

@@ -42,4 +103,3 @@ The provider is available from Terraform registry: [registry.terraform.io/provid
42103
```shell
43104
go install
44105
```
45-

internal/provider/assignment.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package provider
2+
3+
import (
4+
"slices"
5+
)
6+
7+
// assignKeys assigns counter values to the keys provided as input
8+
func assignKeys(keys []string, state map[string]int64, reuse bool, initial int64, last int64) (int64, map[string]int64) {
9+
// Create a map to hold the assigned values
10+
assignedValues := make(map[string]int64, len(keys))
11+
// Create a list of values for easier tracking for the next possible one
12+
values := make([]int64, 0, len(keys))
13+
14+
// If the previous state is defined, maintain all entries that are still present in keys.
15+
// Also handle a changing initial value.
16+
for key, value := range state {
17+
if slices.Contains(keys, key) && value >= initial {
18+
assignedValues[key] = value
19+
values = append(values, value)
20+
}
21+
}
22+
23+
// Sort keys to provide a predictable behaviour
24+
slices.Sort(keys)
25+
26+
// Iterate over the keys and provide values to those not covered yet
27+
for _, key := range keys {
28+
// If the key has not yet a value assigned
29+
if _, exists := assignedValues[key]; !exists {
30+
// If reuse is true, find a value that does not exist in the assignedValues map
31+
if reuse {
32+
for i := initial; ; i++ {
33+
if !slices.Contains(values, i) {
34+
assignedValues[key] = i
35+
values = append(values, i)
36+
last = i
37+
break
38+
}
39+
}
40+
} else {
41+
// If reuse is false, increment the last value and assign it to the key
42+
last = max(last+1, initial)
43+
assignedValues[key] = last
44+
}
45+
}
46+
}
47+
48+
// Return the last value and the assignedValues map
49+
return last, assignedValues
50+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package provider
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestEmpty(t *testing.T) {
9+
input := []string{}
10+
initial := 5
11+
last, res := assignKeys(input, nil, false, int64(initial), int64(initial))
12+
t.Logf("input keys: %v, output: %v", input, res)
13+
expectedLast := initial + len(input)
14+
if last != int64(expectedLast) {
15+
t.Errorf("Expecting last to be %d, got %d", expectedLast, last)
16+
}
17+
if len(res) != len(input) {
18+
t.Errorf("Result map length %d != key length %d", len(res), len(input))
19+
}
20+
}
21+
22+
func TestInitial(t *testing.T) {
23+
input := []string{"a", "c", "b"}
24+
initial := 5
25+
last, res := assignKeys(input, nil, false, int64(initial), int64(initial-1))
26+
t.Logf("input keys: %v, output: %v", input, res)
27+
expectedLast := initial + len(input) - 1
28+
if last != int64(expectedLast) {
29+
t.Errorf("Expecting last to be %d, got %d", expectedLast, last)
30+
}
31+
if len(res) != len(input) {
32+
t.Errorf("Result map length %d != key length %d", len(res), len(input))
33+
}
34+
expected := map[string]int64{"a": 5, "b": 6, "c": 7}
35+
if !reflect.DeepEqual(res, expected) {
36+
t.Errorf("Expected %v got %v", expected, res)
37+
}
38+
}
39+
40+
func TestNop(t *testing.T) {
41+
input := []string{"a", "c", "b"}
42+
state := map[string]int64{"a": 5, "b": 9, "c": 11}
43+
initial := 5
44+
last := 11
45+
last2, res := assignKeys(input, state, false, int64(initial), int64(last))
46+
if !reflect.DeepEqual(res, state) {
47+
t.Errorf("Expected %v got %v", state, res)
48+
}
49+
if int64(last) != last2 {
50+
t.Errorf("Expected last value to stay at %d but got %d", last, last2)
51+
}
52+
last2, res = assignKeys(input, state, true, int64(initial), int64(last))
53+
if !reflect.DeepEqual(res, state) {
54+
t.Errorf("Expected %v got %v", state, res)
55+
}
56+
if int64(last) != last2 {
57+
t.Errorf("Expected last value to stay at %d but got %d", last, last2)
58+
}
59+
}
60+
61+
func TestChange(t *testing.T) {
62+
input := []string{"d", "a", "c"}
63+
state := map[string]int64{"a": 5, "b": 6, "c": 7}
64+
initial := 5
65+
last := state["c"]
66+
last2, res := assignKeys(input, state, false, int64(initial), int64(last))
67+
expected := map[string]int64{"a": 5, "c": 7, "d": 8}
68+
if !reflect.DeepEqual(res, expected) {
69+
t.Errorf("Expected %v got %v", state, res)
70+
}
71+
if int64(last2) != last+1 {
72+
t.Errorf("Expected last value to stay at %d but got %d", last, last2)
73+
}
74+
expected = map[string]int64{"a": 5, "c": 7, "d": 6}
75+
last2, res = assignKeys(input, state, true, int64(initial), int64(last))
76+
if !reflect.DeepEqual(res, expected) {
77+
t.Errorf("Expected %v got %v", state, res)
78+
}
79+
if int64(last2) != expected["d"] {
80+
t.Errorf("Expected last value to stay at %d but got %d", state["b"], last2)
81+
}
82+
}

internal/provider/counter_resource.go

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77

8+
"github.com/hashicorp/terraform-plugin-framework/attr"
89
"github.com/hashicorp/terraform-plugin-framework/path"
910
"github.com/hashicorp/terraform-plugin-framework/resource"
1011
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -28,6 +29,7 @@ type PersistentCounterResource struct {
2829
type PersistentCounterResourceModel struct {
2930
Id types.String `tfsdk:"id"`
3031
Keys types.List `tfsdk:"keys"`
32+
Reuse types.Bool `tfsdk:"reuse"`
3133
InitialValue types.Int64 `tfsdk:"initial_value"`
3234
LastValue types.Int64 `tfsdk:"last_value"`
3335
Values types.Map `tfsdk:"values"`
@@ -59,6 +61,10 @@ func (r *PersistentCounterResource) Schema(ctx context.Context, req resource.Sch
5961
ElementType: types.StringType,
6062
Required: true,
6163
},
64+
"reuse": schema.BoolAttribute{
65+
Description: "Allows reusing freed keys for new ones.",
66+
Optional: true,
67+
},
6268
"initial_value": schema.Int64Attribute{
6369
Optional: true,
6470
Computed: true,
@@ -115,21 +121,16 @@ func (r *PersistentCounterResource) Create(ctx context.Context, req resource.Cre
115121

116122
// Generate new set of keys
117123
if data.Values.IsUnknown() {
118-
keys := data.Keys.Elements()
119-
keysLength := len(keys)
120-
values := make(map[string]int64, keysLength)
121-
currentValue := data.InitialValue.ValueInt64()
122-
for i := 0; i < keysLength; i++ {
123-
_keyValue, ok := keys[i].(types.String)
124-
if !ok {
125-
continue
126-
}
127-
keyValue := _keyValue.ValueString()
128-
values[keyValue] = currentValue
129-
currentValue += 1
130-
}
131-
data.LastValue = types.Int64Value(currentValue - 1)
124+
keys := convertKeys(data.Keys.Elements())
125+
last, values := assignKeys(
126+
keys, nil, data.Reuse.ValueBool(),
127+
// initial value
128+
data.InitialValue.ValueInt64(),
129+
// use initial value - 1 for last value
130+
data.InitialValue.ValueInt64()-1,
131+
)
132132

133+
data.LastValue = types.Int64Value(last)
133134
_values, diags := types.MapValueFrom(ctx, types.Int64Type, values)
134135
resp.Diagnostics.Append(diags...)
135136
if resp.Diagnostics.HasError() {
@@ -165,33 +166,20 @@ func (r *PersistentCounterResource) Update(ctx context.Context, req resource.Upd
165166
return
166167
}
167168

168-
if !data.Keys.Equal(state.Keys) {
169-
lastValue := state.LastValue.ValueInt64()
170-
171-
keys := data.Keys.Elements()
172-
keysLength := len(keys)
173-
values := make(map[string]int64, keysLength)
174-
175-
stateValues := state.Values.Elements()
176-
for _, key := range keys {
177-
_keyValue, ok := key.(types.String)
178-
if !ok {
179-
continue
180-
}
181-
keyValue := _keyValue.ValueString()
182-
if val, found := stateValues[keyValue]; found {
183-
_intValue, ok := val.(types.Int64)
184-
if !ok {
185-
continue
186-
}
187-
values[keyValue] = _intValue.ValueInt64()
188-
} else {
189-
lastValue += 1
190-
values[keyValue] = lastValue
191-
}
192-
}
193-
data.LastValue = types.Int64Value(lastValue)
169+
if !data.Keys.Equal(state.Keys) || !data.InitialValue.Equal(state.Keys) {
170+
171+
keys := convertKeys(data.Keys.Elements())
172+
stateVals := convertState(state.Values.Elements())
173+
174+
last, values := assignKeys(
175+
keys, stateVals, data.Reuse.ValueBool(),
176+
data.InitialValue.ValueInt64(),
177+
state.LastValue.ValueInt64(),
178+
)
179+
180+
data.LastValue = types.Int64Value(last)
194181
state.LastValue = data.LastValue
182+
195183
_values, diags := types.MapValueFrom(ctx, types.Int64Type, values)
196184
resp.Diagnostics.Append(diags...)
197185
if resp.Diagnostics.HasError() {
@@ -226,3 +214,36 @@ func (r *PersistentCounterResource) Delete(ctx context.Context, req resource.Del
226214
func (r *PersistentCounterResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
227215
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
228216
}
217+
218+
// convertKeys generates a string slice from the terraform string list representation
219+
func convertKeys(tfKeys []attr.Value) []string {
220+
keys := make([]string, 0, len(tfKeys))
221+
for _, k := range tfKeys {
222+
val, ok := k.(types.String)
223+
if !ok {
224+
// conversion failed
225+
continue
226+
}
227+
str := val.ValueString()
228+
if str == "" {
229+
// invalid string value or string empty
230+
continue
231+
}
232+
keys = append(keys, str)
233+
234+
}
235+
return keys
236+
}
237+
238+
// convertState converts the counter state from terraform format to map[string]int64
239+
func convertState(tfState map[string]attr.Value) map[string]int64 {
240+
state := make(map[string]int64, len(tfState))
241+
for k, v := range tfState {
242+
intVal, ok := v.(types.Int64)
243+
if !ok {
244+
continue
245+
}
246+
state[k] = intVal.ValueInt64()
247+
}
248+
return state
249+
}

0 commit comments

Comments
 (0)