Skip to content

Commit e4a57d9

Browse files
authored
tag: accept multiple tags per resource (#74)
This makes `oci_tag` accept multiple tags, so we don't need a TF resource per tag. Plopping that many resources into TF state seems to make it slow. Instead we'll deprecate `tag` in favor of `tags`, which does the same thing by applying multiple tags per resource. After callers are migrated to `tags` we can remove the deprecated field. --------- Signed-off-by: Jason Hall <[email protected]>
1 parent 421ca53 commit e4a57d9

File tree

4 files changed

+204
-37
lines changed

4 files changed

+204
-37
lines changed

docs/resources/tag.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ Tag an existing image by digest.
1818
### Required
1919

2020
- `digest_ref` (String) Image ref by digest to apply the tag to.
21-
- `tag` (String) Tag to apply to the image.
21+
22+
### Optional
23+
24+
- `tag` (String, Deprecated) Tag to apply to the image.
25+
- `tags` (List of String) Tags to apply to the image.
2226

2327
### Read-Only
2428

internal/provider/tag_resource.go

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package provider
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67

78
"github.com/chainguard-dev/terraform-provider-oci/pkg/validators"
@@ -10,10 +11,13 @@ import (
1011
"github.com/hashicorp/terraform-plugin-framework/path"
1112
"github.com/hashicorp/terraform-plugin-framework/resource"
1213
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
14+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
1315
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
1416
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
1517
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
1618
"github.com/hashicorp/terraform-plugin-framework/types"
19+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
20+
"golang.org/x/sync/errgroup"
1721
)
1822

1923
var _ resource.Resource = &TagResource{}
@@ -35,6 +39,7 @@ type TagResourceModel struct {
3539

3640
DigestRef types.String `tfsdk:"digest_ref"`
3741
Tag types.String `tfsdk:"tag"`
42+
Tags []string `tfsdk:"tags"`
3843
}
3944

4045
func (r *TagResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
@@ -53,11 +58,19 @@ func (r *TagResource) Schema(ctx context.Context, req resource.SchemaRequest, re
5358
},
5459
"tag": schema.StringAttribute{
5560
MarkdownDescription: "Tag to apply to the image.",
56-
Required: true,
61+
Optional: true,
5762
Validators: []validator.String{validators.TagValidator{}},
5863
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
64+
DeprecationMessage: "The `tag` attribute is deprecated. Use `tags` instead.",
65+
},
66+
"tags": schema.ListAttribute{
67+
MarkdownDescription: "Tags to apply to the image.",
68+
// TODO: make this required after tag deprecation period.
69+
Optional: true,
70+
ElementType: basetypes.StringType{},
71+
Validators: []validator.List{uniqueTagsValidator{}},
72+
PlanModifiers: []planmodifier.List{listplanmodifier.RequiresReplace()},
5973
},
60-
6174
"tagged_ref": schema.StringAttribute{
6275
Computed: true,
6376
MarkdownDescription: "The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).",
@@ -113,8 +126,8 @@ func (r *TagResource) Read(ctx context.Context, req resource.ReadRequest, resp *
113126
return
114127
}
115128

116-
// Don't actually tag, but check whether the digest is already tagged so we get a useful diff.
117-
// If the digest is already tagged, we'll set the ID and tagged_ref to the correct output value.
129+
// Don't actually tag, but check whether the digest is already tagged with all requested tags, so we get a useful diff.
130+
// If the digest is already tagged with all requested tags, we'll set the ID and tagged_ref to the correct output value.
118131
// Otherwise, we'll set them to empty strings so that the create will run when applied.
119132

120133
d, err := name.NewDigest(data.DigestRef.ValueString())
@@ -123,22 +136,38 @@ func (r *TagResource) Read(ctx context.Context, req resource.ReadRequest, resp *
123136
return
124137
}
125138

126-
t := d.Context().Tag(data.Tag.ValueString())
127-
desc, err := remote.Get(t, r.popts.withContext(ctx)...)
128-
if err != nil {
129-
resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error getting image: %s", err.Error()))
130-
return
139+
tags := []string{}
140+
if data.Tag.ValueString() != "" {
141+
tags = append(tags, data.Tag.ValueString())
142+
} else if len(data.Tags) > 0 {
143+
tags = data.Tags
144+
} else {
145+
resp.Diagnostics.AddError("Tag Error", "either tag or tags must be set")
146+
}
147+
if data.Tag.ValueString() != "" && len(data.Tags) > 0 {
148+
resp.Diagnostics.AddError("Tag Error", "only one of tag or tags may be set")
131149
}
150+
for _, tag := range tags {
151+
t := d.Context().Tag(tag)
152+
desc, err := remote.Get(t, r.popts.withContext(ctx)...)
153+
if err != nil {
154+
// Failed to get the image by tag, so we need to create.
155+
return
156+
}
132157

133-
if desc.Digest.String() != d.DigestStr() {
134-
data.Id = types.StringValue("")
135-
data.TaggedRef = types.StringValue("")
136-
} else {
137-
id := fmt.Sprintf("%s@%s", t.Name(), desc.Digest.String())
138-
data.Id = types.StringValue(id)
139-
data.TaggedRef = types.StringValue(id)
158+
// Some tag is wrong, so we need to create.
159+
if desc.Digest.String() != d.DigestStr() {
160+
data.Id = types.StringValue("")
161+
data.TaggedRef = types.StringValue("")
162+
break
163+
}
140164
}
141165

166+
// All tags are correct so we can set the ID and tagged_ref to the correct output value.
167+
id := fmt.Sprintf("%s@%s", d.Context().Tag(tags[0]), d.DigestStr())
168+
data.Id = types.StringValue(id)
169+
data.TaggedRef = types.StringValue(id)
170+
142171
// Save updated data into Terraform state
143172
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
144173
}
@@ -171,21 +200,83 @@ func (r *TagResource) ImportState(ctx context.Context, req resource.ImportStateR
171200
}
172201

173202
func (r *TagResource) doTag(ctx context.Context, data *TagResourceModel) (string, error) {
203+
var tags []string
204+
if data.Tag.ValueString() != "" {
205+
tags = append(tags, data.Tag.ValueString())
206+
} else if len(data.Tags) > 0 {
207+
tags = data.Tags
208+
} else {
209+
return "", errors.New("either tag or tags must be set")
210+
}
211+
if data.Tag.ValueString() != "" && len(data.Tags) > 0 {
212+
return "", errors.New("only one of tag or tags may be set")
213+
}
214+
174215
d, err := name.NewDigest(data.DigestRef.ValueString())
175216
if err != nil {
176217
return "", fmt.Errorf("digest_ref must be a digest reference: %v", err)
177218
}
178-
t := d.Context().Tag(data.Tag.ValueString())
179-
if err != nil {
180-
return "", fmt.Errorf("error parsing tag: %v", err)
181-
}
219+
182220
desc, err := remote.Get(d, r.popts.withContext(ctx)...)
183221
if err != nil {
184222
return "", fmt.Errorf("error fetching digest: %v", err)
185223
}
186-
if err := remote.Tag(t, desc, r.popts.withContext(ctx)...); err != nil {
187-
return "", fmt.Errorf("error tagging digest: %v", err)
224+
225+
errg, ctx := errgroup.WithContext(ctx)
226+
for _, tag := range tags {
227+
tag := tag
228+
errg.Go(func() error {
229+
t := d.Context().Tag(tag)
230+
if err != nil {
231+
return fmt.Errorf("error parsing tag %q: %v", tag, err)
232+
}
233+
if err := remote.Tag(t, desc, r.popts.withContext(ctx)...); err != nil {
234+
return fmt.Errorf("error tagging digest with %q: %v", tag, err)
235+
}
236+
return nil
237+
})
238+
}
239+
if err := errg.Wait(); err != nil {
240+
return "", err
241+
}
242+
243+
t := d.Context().Tag(tags[0])
244+
if err != nil {
245+
return "", fmt.Errorf("error parsing tag: %v", err)
188246
}
189-
digest := fmt.Sprintf("%s@%s", t.Name(), desc.Digest.String())
247+
digest := fmt.Sprintf("%s@%s", t.Name(), d.DigestStr())
190248
return digest, nil
191249
}
250+
251+
type uniqueTagsValidator struct{}
252+
253+
var _ validator.List = uniqueTagsValidator{}
254+
255+
func (v uniqueTagsValidator) Description(context.Context) string {
256+
return `value must be valid OCI tag elements (e.g., "latest", "v1.2.3")`
257+
}
258+
func (v uniqueTagsValidator) MarkdownDescription(ctx context.Context) string {
259+
return v.Description(ctx)
260+
}
261+
262+
func (v uniqueTagsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
263+
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
264+
return
265+
}
266+
var tags []string
267+
if diag := req.ConfigValue.ElementsAs(ctx, &tags, false); diag.HasError() {
268+
resp.Diagnostics.Append(diag...)
269+
return
270+
}
271+
272+
seen := map[string]bool{}
273+
for _, t := range tags {
274+
if seen[t] {
275+
resp.Diagnostics.AddWarning("Duplicate tag", fmt.Sprintf("duplicate tag %q", t))
276+
}
277+
seen[t] = true
278+
if _, err := name.NewTag("example.com:" + t); err != nil {
279+
resp.Diagnostics.AddError("Invalid OCI tag name", fmt.Sprintf("parsing tag %q: %v", t, err))
280+
}
281+
}
282+
}

internal/provider/tag_resource_test.go

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
)
1212

1313
func TestAccTagResource(t *testing.T) {
14-
repo, cleanup := ocitesting.SetupRepository(t, "test")
14+
repo, cleanup := ocitesting.SetupRepository(t, "repo")
1515
defer cleanup()
1616

1717
// Push an image to the local registry.
@@ -30,7 +30,7 @@ func TestAccTagResource(t *testing.T) {
3030
}
3131
dig1 := ref1.Context().Digest(d1.String())
3232

33-
// Push an image to the local registry.
33+
// Push another image to the local registry.
3434
ref2 := repo.Tag("2")
3535
t.Logf("Using ref2: %s", ref2)
3636
img2, err := random.Image(1024, 1)
@@ -46,36 +46,108 @@ func TestAccTagResource(t *testing.T) {
4646
}
4747
dig2 := ref2.Context().Digest(d2.String())
4848

49-
want1 := fmt.Sprintf("%s:test@%s", repo, d2)
50-
want2 := fmt.Sprintf("%s:test2@%s", repo, d2)
51-
49+
// Create the resource tagging dig1 three tags.
50+
want1 := fmt.Sprintf("%s:foo@%s", repo, d1)
5251
resource.Test(t, resource.TestCase{
5352
PreCheck: func() { testAccPreCheck(t) },
5453
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
5554
Steps: []resource.TestStep{
56-
// Create and Read testing
5755
{
5856
Config: fmt.Sprintf(`resource "oci_tag" "test" {
5957
digest_ref = %q
60-
tag = "test"
58+
tags = ["foo", "bar", "baz"]
6159
}`, dig1),
6260
Check: resource.ComposeAggregateTestCheckFunc(
6361
resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want1),
6462
resource.TestCheckResourceAttr("oci_tag.test", "id", want1),
6563
),
6664
},
67-
// Update and Read testing
65+
},
66+
})
67+
68+
// The digest should be tagged with all three tags.
69+
for _, want := range []string{"foo", "bar", "baz"} {
70+
desc, err := remote.Get(repo.Tag(want))
71+
if err != nil {
72+
t.Errorf("failed to get image with tag %q: %v", want, err)
73+
}
74+
if desc.Digest != d1 {
75+
t.Errorf("image with tag %q has wrong digest: got %s, want %s", want, desc.Digest, d1)
76+
}
77+
}
78+
79+
// Point the tags to another digest.
80+
want2 := fmt.Sprintf("%s:foo@%s", repo, d2)
81+
resource.Test(t, resource.TestCase{
82+
PreCheck: func() { testAccPreCheck(t) },
83+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
84+
Steps: []resource.TestStep{
6885
{
6986
Config: fmt.Sprintf(`resource "oci_tag" "test" {
70-
digest_ref = %q
71-
tag = "test2"
72-
}`, dig2),
87+
digest_ref = %q
88+
tags = ["foo", "bar", "baz"]
89+
}`, dig2),
7390
Check: resource.ComposeAggregateTestCheckFunc(
7491
resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want2),
7592
resource.TestCheckResourceAttr("oci_tag.test", "id", want2),
7693
),
7794
},
78-
// Delete testing automatically occurs in TestCase
7995
},
8096
})
97+
98+
// The second digest should be tagged with all three tags.
99+
for _, want := range []string{"foo", "bar", "baz"} {
100+
desc, err := remote.Get(repo.Tag(want))
101+
if err != nil {
102+
t.Errorf("failed to get image with tag %q: %v", want, err)
103+
}
104+
if desc.Digest != d2 {
105+
t.Errorf("image with tag %q has wrong digest: got %s, want %s", want, desc.Digest, d2)
106+
}
107+
}
108+
109+
// Add a fourth tag.
110+
resource.Test(t, resource.TestCase{
111+
PreCheck: func() { testAccPreCheck(t) },
112+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
113+
Steps: []resource.TestStep{
114+
{
115+
Config: fmt.Sprintf(`resource "oci_tag" "test" {
116+
digest_ref = %q
117+
tags = ["foo", "bar", "baz", "qux"]
118+
}`, dig2),
119+
Check: resource.ComposeAggregateTestCheckFunc(
120+
resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want2),
121+
resource.TestCheckResourceAttr("oci_tag.test", "id", want2),
122+
),
123+
},
124+
},
125+
})
126+
127+
// The second digest should be tagged with all three tags.
128+
for _, want := range []string{"foo", "bar", "baz", "qux"} {
129+
desc, err := remote.Get(repo.Tag(want))
130+
if err != nil {
131+
t.Errorf("failed to get image with tag %q: %v", want, err)
132+
}
133+
if desc.Digest != d2 {
134+
t.Errorf("image with tag %q has wrong digest: got %s, want %s", want, desc.Digest, d2)
135+
}
136+
}
137+
138+
// Tag the digest with the same tag multiple times, which should be allowed but warn.
139+
resource.Test(t, resource.TestCase{
140+
PreCheck: func() { testAccPreCheck(t) },
141+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
142+
Steps: []resource.TestStep{{
143+
Config: fmt.Sprintf(`resource "oci_tag" "test" {
144+
digest_ref = %q
145+
tags = ["foo", "foo", "foo", "bar", "bar", "bar"]
146+
}`, dig2),
147+
Check: resource.ComposeAggregateTestCheckFunc(
148+
resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want2),
149+
resource.TestCheckResourceAttr("oci_tag.test", "id", want2),
150+
),
151+
}},
152+
})
81153
}

pkg/validators/tag_validator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
// TagValidator is a string validator that checks that the string is valid OCI reference by digest.
1111
type TagValidator struct{}
1212

13-
var _ validator.String = DigestValidator{}
13+
var _ validator.String = TagValidator{}
1414

1515
func (v TagValidator) Description(context.Context) string {
1616
return `value must be a valid OCI tag element (e.g., "latest", "v1.2.3")`

0 commit comments

Comments
 (0)