Skip to content

Commit dba8f3c

Browse files
authored
oci_tags resource (#75)
This adds a new `oci_tags` resource that's designed to be used to apply multiple tags to multiple digests. We do this today with `oci_tag` and a huge mess of `for_each`es, which result in a huge explosion of resources, which at scale can cause TF to get very slow since it marshals and unmarshals TF state each time a resource is applied, and it serializes those state writes to be sure it wrote them completely before proceeding. With this resource, we can apply multiple tags in one resource, which can massively reduce the number of resources in play at a time. --------- Signed-off-by: Jason Hall <[email protected]>
1 parent e4a57d9 commit dba8f3c

File tree

5 files changed

+354
-1
lines changed

5 files changed

+354
-1
lines changed

docs/resources/tags.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "oci_tags Resource - terraform-provider-oci"
4+
subcategory: ""
5+
description: |-
6+
Tag many digests with many tags.
7+
---
8+
9+
# oci_tags (Resource)
10+
11+
Tag many digests with many tags.
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Required
19+
20+
- `repo` (String) Repository for the tags.
21+
- `tags` (Map of String) Map of tag -> digest to apply.
22+
23+
### Read-Only
24+
25+
- `id` (String) The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (p *OCIProvider) Resources(ctx context.Context) []func() resource.Resource
9797
return []func() resource.Resource{
9898
NewAppendResource,
9999
NewTagResource,
100+
NewTagsResource,
100101
}
101102
}
102103

internal/provider/tag_resource_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func TestAccTagResource(t *testing.T) {
4040
if err := remote.Write(ref2, img2); err != nil {
4141
t.Fatalf("failed to write image: %v", err)
4242
}
43-
d2, err := img1.Digest()
43+
d2, err := img2.Digest()
4444
if err != nil {
4545
t.Fatalf("failed to get digest: %v", err)
4646
}

internal/provider/tags_resource.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/json"
7+
"fmt"
8+
9+
"github.com/chainguard-dev/terraform-provider-oci/pkg/validators"
10+
"github.com/google/go-containerregistry/pkg/name"
11+
"github.com/google/go-containerregistry/pkg/v1/remote"
12+
"github.com/hashicorp/terraform-plugin-framework/path"
13+
"github.com/hashicorp/terraform-plugin-framework/resource"
14+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
15+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
17+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
18+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
19+
"github.com/hashicorp/terraform-plugin-framework/types"
20+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
21+
)
22+
23+
var _ resource.Resource = &TagsResource{}
24+
var _ resource.ResourceWithImportState = &TagsResource{}
25+
26+
func NewTagsResource() resource.Resource {
27+
return &TagsResource{}
28+
}
29+
30+
// TagsResource defines the resource implementation.
31+
type TagsResource struct {
32+
popts ProviderOpts
33+
}
34+
35+
// TagsResourceModel describes the resource data model.
36+
type TagsResourceModel struct {
37+
Id types.String `tfsdk:"id"`
38+
39+
Repo string `tfsdk:"repo"`
40+
Tags map[string]string `tfsdk:"tags"` // tag -> digest
41+
}
42+
43+
func (r *TagsResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
44+
resp.TypeName = req.ProviderTypeName + "_tags"
45+
}
46+
47+
func (r *TagsResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
48+
resp.Schema = schema.Schema{
49+
MarkdownDescription: "Tag many digests with many tags.",
50+
Attributes: map[string]schema.Attribute{
51+
"repo": schema.StringAttribute{
52+
MarkdownDescription: "Repository for the tags.",
53+
Required: true,
54+
Validators: []validator.String{validators.RepoValidator{}},
55+
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
56+
},
57+
"tags": schema.MapAttribute{
58+
MarkdownDescription: "Map of tag -> digest to apply.",
59+
Required: true,
60+
ElementType: basetypes.StringType{},
61+
// TODO: validator -- check that digests and tags are well formed.
62+
PlanModifiers: []planmodifier.Map{mapplanmodifier.RequiresReplace()},
63+
},
64+
65+
// TODO: any outputs?
66+
67+
"id": schema.StringAttribute{
68+
Computed: true,
69+
MarkdownDescription: "The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).",
70+
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
71+
},
72+
},
73+
}
74+
}
75+
76+
func (r *TagsResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
77+
// Prevent panic if the provider has not been configured.
78+
if req.ProviderData == nil {
79+
return
80+
}
81+
82+
popts, ok := req.ProviderData.(*ProviderOpts)
83+
if !ok || popts == nil {
84+
resp.Diagnostics.AddError("Client Error", "invalid provider data")
85+
return
86+
}
87+
r.popts = *popts
88+
}
89+
90+
func (r *TagsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
91+
var data *TagsResourceModel
92+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
93+
if resp.Diagnostics.HasError() {
94+
return
95+
}
96+
97+
digest, err := r.doTags(ctx, data)
98+
if err != nil {
99+
resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging image: %s", err.Error()))
100+
return
101+
}
102+
103+
data.Id = types.StringValue(digest)
104+
105+
// Save data into Terraform state
106+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
107+
}
108+
109+
func (r *TagsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
110+
var data *TagsResourceModel
111+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
112+
if resp.Diagnostics.HasError() {
113+
return
114+
}
115+
116+
// Don't actually tag, but check whether the digests are already tagged with all requested tags, so we get a useful diff.
117+
// If the digests are already tagged with all requested tags, we'll set the ID to the correct output value.
118+
// Otherwise, we'll set them to empty strings so that the create will run when applied.
119+
// TODO: Can we get a better diff about what new updates will be applied?
120+
if id, err := r.checkTags(ctx, data); err != nil {
121+
data.Id = types.StringValue("")
122+
} else {
123+
data.Id = types.StringValue(id)
124+
}
125+
126+
// Save updated data into Terraform state
127+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
128+
}
129+
130+
func (r *TagsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
131+
var data *TagsResourceModel
132+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
133+
if resp.Diagnostics.HasError() {
134+
return
135+
}
136+
137+
id, err := r.doTags(ctx, data)
138+
if err != nil {
139+
resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging images: %s", err.Error()))
140+
return
141+
}
142+
143+
data.Id = types.StringValue(id)
144+
145+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
146+
}
147+
148+
func (r *TagsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
149+
resp.Diagnostics.Append(req.State.Get(ctx, &TagsResourceModel{})...)
150+
}
151+
152+
func (r *TagsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
153+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
154+
}
155+
156+
func (r *TagsResource) checkTags(ctx context.Context, data *TagsResourceModel) (string, error) {
157+
repo, err := name.NewRepository(data.Repo)
158+
if err != nil {
159+
return "", fmt.Errorf("error parsing repo ref: %w", err)
160+
}
161+
162+
for tag, digest := range data.Tags {
163+
t := repo.Tag(tag)
164+
desc, err := remote.Head(t, r.popts.withContext(ctx)...)
165+
if err != nil {
166+
return "", fmt.Errorf("error getting tag %q: %w", t, err)
167+
}
168+
if desc.Digest.String() != digest {
169+
return "", fmt.Errorf("tag %q does not point to digest %q (got %q)", tag, digest, desc.Digest.String())
170+
}
171+
}
172+
// ID is the SHA256 of the JSONified map.
173+
b, err := json.Marshal(data.Tags)
174+
if err != nil {
175+
return "", fmt.Errorf("error marshaling tags: %w", err)
176+
}
177+
return fmt.Sprintf("%x", sha256.Sum256(b)), nil
178+
}
179+
180+
func (r *TagsResource) doTags(ctx context.Context, data *TagsResourceModel) (string, error) {
181+
repo, err := name.NewRepository(data.Repo)
182+
if err != nil {
183+
return "", fmt.Errorf("error parsing repo ref: %w", err)
184+
}
185+
186+
for tag, digest := range data.Tags {
187+
t := repo.Tag(tag)
188+
d := repo.Digest(digest)
189+
desc, err := remote.Get(d, r.popts.withContext(ctx)...)
190+
if err != nil {
191+
return "", fmt.Errorf("error getting digest %q: %w", digest, err)
192+
}
193+
if err := remote.Tag(t, desc, r.popts.withContext(ctx)...); err != nil {
194+
return "", fmt.Errorf("error tagging %q with %q: %w", digest, tag, err)
195+
}
196+
}
197+
198+
// ID is the SHA256 of the JSONified map.
199+
b, err := json.Marshal(data.Tags)
200+
if err != nil {
201+
return "", fmt.Errorf("error marshaling tags: %w", err)
202+
}
203+
return fmt.Sprintf("%x", sha256.Sum256(b)), nil
204+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package provider
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing"
9+
"github.com/google/go-containerregistry/pkg/name"
10+
v1 "github.com/google/go-containerregistry/pkg/v1"
11+
"github.com/google/go-containerregistry/pkg/v1/random"
12+
"github.com/google/go-containerregistry/pkg/v1/remote"
13+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
14+
)
15+
16+
func TestAccTagsResource(t *testing.T) {
17+
repo, cleanup := ocitesting.SetupRepository(t, "repo")
18+
defer cleanup()
19+
20+
// Push an image to the local registry.
21+
ref1 := repo.Tag("1")
22+
img1, err := random.Image(1024, 1)
23+
if err != nil {
24+
t.Fatalf("failed to create image: %v", err)
25+
}
26+
if err := remote.Write(ref1, img1); err != nil {
27+
t.Fatalf("failed to write image: %v", err)
28+
}
29+
d1, err := img1.Digest()
30+
if err != nil {
31+
t.Fatalf("failed to get digest: %v", err)
32+
}
33+
dig1 := ref1.Context().Digest(d1.String())
34+
t.Logf("Using ref1: %s -> %s", ref1, dig1)
35+
36+
// Push another image to the local registry.
37+
ref2 := repo.Tag("2")
38+
img2, err := random.Image(1024, 1)
39+
if err != nil {
40+
t.Fatalf("failed to create image: %v", err)
41+
}
42+
if err := remote.Write(ref2, img2); err != nil {
43+
t.Fatalf("failed to write image: %v", err)
44+
}
45+
d2, err := img2.Digest()
46+
if err != nil {
47+
t.Fatalf("failed to get digest: %v", err)
48+
}
49+
dig2 := ref2.Context().Digest(d2.String())
50+
t.Logf("Using ref2: %s -> %s", ref2, dig2)
51+
52+
// Tag the digests with some tags.
53+
marshal := func(a any) string {
54+
b, err := json.MarshalIndent(a, "", " ")
55+
if err != nil {
56+
t.Fatalf("failed to marshal: %v", err)
57+
}
58+
return string(b)
59+
}
60+
resource.Test(t, resource.TestCase{
61+
PreCheck: func() { testAccPreCheck(t) },
62+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
63+
Steps: []resource.TestStep{{
64+
Config: fmt.Sprintf(`resource "oci_tags" "test" {
65+
repo = %q
66+
tags = %s
67+
}`, repo, marshal(map[string]v1.Hash{
68+
"foo": d1,
69+
"bar": d1,
70+
"baz": d1,
71+
"hello": d2,
72+
"world": d2,
73+
})),
74+
}},
75+
})
76+
77+
// Check those tags were applied, and the original tags didn't change.
78+
checkTags := func(want map[string][]string) {
79+
for dig, tags := range want {
80+
d, err := name.NewDigest(dig)
81+
if err != nil {
82+
t.Fatalf("error parsing digest ref: %v", err)
83+
}
84+
for _, tag := range tags {
85+
got, err := remote.Head(repo.Tag(tag))
86+
if err != nil {
87+
t.Errorf("failed to get image with tag %q: %v", tag, err)
88+
}
89+
if got.Digest.String() != d.DigestStr() {
90+
t.Errorf("image with tag %q has wrong digest: got %s, want %s", tag, got.Digest, d.DigestStr())
91+
}
92+
}
93+
}
94+
}
95+
checkTags(map[string][]string{
96+
dig1.String(): {"1", "foo", "bar", "baz"},
97+
dig2.String(): {"2", "hello", "world"},
98+
})
99+
100+
// Update some tags.
101+
resource.Test(t, resource.TestCase{
102+
PreCheck: func() { testAccPreCheck(t) },
103+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
104+
Steps: []resource.TestStep{{
105+
Config: fmt.Sprintf(`resource "oci_tags" "test" {
106+
repo = %q
107+
tags = %s
108+
}`, repo, marshal(map[string]v1.Hash{
109+
// "foo" isn't specified, but this doesn't untag it.
110+
"bar": d1,
111+
"baz": d1,
112+
"hello": d1, // "hello" moved from 2 to 1.
113+
"world": d2,
114+
"goodbye": d1, // new tag on 1.
115+
"kevin": d2, // new tag on 2.
116+
})),
117+
}},
118+
})
119+
checkTags(map[string][]string{
120+
dig1.String(): {"1", "foo", "bar", "baz", "hello", "goodbye"},
121+
dig2.String(): {"2", "world", "kevin"},
122+
})
123+
}

0 commit comments

Comments
 (0)