Skip to content

Commit 7ed695b

Browse files
committed
Import extensions with stable project scope
1 parent 49f7f24 commit 7ed695b

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/resource"
9+
"github.com/hashicorp/terraform-plugin-framework/types"
10+
"github.com/kernel/terraform-provider-kernel/internal/projectscope"
11+
)
12+
13+
type extensionImporter interface {
14+
DefaultProjectID() string
15+
}
16+
17+
func importExtensionResource(ctx context.Context, client extensionImporter, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
18+
if client == nil {
19+
resp.Diagnostics.AddError(
20+
"Missing Kernel Client",
21+
"The Kernel provider was not configured before importing an extension resource.",
22+
)
23+
return
24+
}
25+
26+
projectID, extensionID, ok := parseExtensionImportID(req.ID)
27+
if !ok {
28+
resp.Diagnostics.AddError(
29+
"Invalid Kernel Extension Import ID",
30+
"Cannot import "+strconv.Quote(req.ID)+": import an extension as \"<extension-id>\" or \"<project-id>/<extension-id>\". "+
31+
"The bare form uses the provider project_id when configured, otherwise the API key's project binding. Use the qualified form for a different project.",
32+
)
33+
return
34+
}
35+
if projectID == "" {
36+
projectID = client.DefaultProjectID()
37+
}
38+
39+
resp.Diagnostics.Append(resp.State.Set(ctx, extensionModel{
40+
ID: types.StringValue(extensionID),
41+
Name: types.StringUnknown(),
42+
ProjectID: projectscope.StateValue(projectID),
43+
SourcePath: types.StringNull(),
44+
SourceSHA256: types.StringUnknown(),
45+
})...)
46+
}
47+
48+
func parseExtensionImportID(id string) (projectID, extensionID string, ok bool) {
49+
before, after, found := strings.Cut(id, "/")
50+
if !found {
51+
return "", id, id != ""
52+
}
53+
if strings.Contains(after, "/") {
54+
return "", "", false
55+
}
56+
if before == "" || after == "" {
57+
return "", "", false
58+
}
59+
return before, after, true
60+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-framework/resource"
8+
"github.com/hashicorp/terraform-plugin-framework/types"
9+
"github.com/kernel/terraform-provider-kernel/internal/kernelclient"
10+
)
11+
12+
var _ extensionImporter = kernelclient.Clients{}
13+
14+
type fakeExtensionImporter struct {
15+
defaultProjectID string
16+
}
17+
18+
func (f fakeExtensionImporter) DefaultProjectID() string {
19+
return f.defaultProjectID
20+
}
21+
22+
func TestParseExtensionImportID(t *testing.T) {
23+
t.Parallel()
24+
25+
tests := map[string]struct {
26+
input string
27+
projectID string
28+
extensionID string
29+
ok bool
30+
}{
31+
"bare id": {input: "extension_123", extensionID: "extension_123", ok: true},
32+
"qualified id": {input: "project_123/extension_123", projectID: "project_123", extensionID: "extension_123", ok: true},
33+
"empty": {input: ""},
34+
"empty project": {input: "/extension_123"},
35+
"empty extension": {input: "project_123/"},
36+
"extra separator": {input: "project_123/extension_123/extra"},
37+
}
38+
39+
for name, test := range tests {
40+
t.Run(name, func(t *testing.T) {
41+
t.Parallel()
42+
projectID, extensionID, ok := parseExtensionImportID(test.input)
43+
if projectID != test.projectID || extensionID != test.extensionID || ok != test.ok {
44+
t.Fatalf("parseExtensionImportID(%q) = %q, %q, %t; want %q, %q, %t", test.input, projectID, extensionID, ok, test.projectID, test.extensionID, test.ok)
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestFrameworkImportExtensionSetsRecoverableState(t *testing.T) {
51+
t.Parallel()
52+
53+
tests := map[string]struct {
54+
importID string
55+
defaultProjectID string
56+
wantProjectID types.String
57+
}{
58+
"bare id inherits provider default": {
59+
importID: "extension_123",
60+
defaultProjectID: "project_default",
61+
wantProjectID: types.StringValue("project_default"),
62+
},
63+
"bare id remains api key scoped": {
64+
importID: "extension_123",
65+
wantProjectID: types.StringNull(),
66+
},
67+
"qualified id overrides provider default": {
68+
importID: "project_explicit/extension_123",
69+
defaultProjectID: "project_default",
70+
wantProjectID: types.StringValue("project_explicit"),
71+
},
72+
}
73+
74+
for name, test := range tests {
75+
t.Run(name, func(t *testing.T) {
76+
t.Parallel()
77+
resp := frameworkExtensionImportResponse()
78+
importExtensionResource(context.Background(), fakeExtensionImporter{defaultProjectID: test.defaultProjectID}, resource.ImportStateRequest{ID: test.importID}, resp)
79+
if resp.Diagnostics.HasError() {
80+
t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics)
81+
}
82+
state := frameworkExtensionImportState(t, resp)
83+
if got, want := state.ID.ValueString(), "extension_123"; got != want {
84+
t.Fatalf("id = %q, want %q", got, want)
85+
}
86+
if !state.ProjectID.Equal(test.wantProjectID) {
87+
t.Fatalf("project_id = %v, want %v", state.ProjectID, test.wantProjectID)
88+
}
89+
if !state.Name.IsUnknown() || !state.SourceSHA256.IsUnknown() {
90+
t.Fatalf("name/source_sha256 = %v/%v, want unknown until Read", state.Name, state.SourceSHA256)
91+
}
92+
if !state.SourcePath.IsNull() {
93+
t.Fatalf("source_path = %v, want null write-only state", state.SourcePath)
94+
}
95+
})
96+
}
97+
}
98+
99+
func TestFrameworkImportExtensionRejectsInvalidIDAndMissingClient(t *testing.T) {
100+
t.Parallel()
101+
102+
tests := map[string]struct {
103+
client extensionImporter
104+
importID string
105+
summary string
106+
}{
107+
"missing client": {importID: "extension_123", summary: "Missing Kernel Client"},
108+
"invalid id": {client: fakeExtensionImporter{}, importID: "project/extension/extra", summary: "Invalid Kernel Extension Import ID"},
109+
}
110+
111+
for name, test := range tests {
112+
t.Run(name, func(t *testing.T) {
113+
t.Parallel()
114+
resp := frameworkExtensionImportResponse()
115+
importExtensionResource(context.Background(), test.client, resource.ImportStateRequest{ID: test.importID}, resp)
116+
if !extensionDiagnosticContains(resp.Diagnostics, test.summary, "") {
117+
t.Fatalf("diagnostics = %v, want %q", resp.Diagnostics, test.summary)
118+
}
119+
if !resp.State.Raw.IsNull() {
120+
t.Fatalf("state = %v, want absent state", resp.State.Raw)
121+
}
122+
})
123+
}
124+
}
125+
126+
func frameworkExtensionImportResponse() *resource.ImportStateResponse {
127+
resp := &resource.ImportStateResponse{}
128+
resp.State.Schema = extensionSchema()
129+
resp.State.RemoveResource(context.Background())
130+
return resp
131+
}
132+
133+
func frameworkExtensionImportState(t *testing.T, resp *resource.ImportStateResponse) extensionModel {
134+
t.Helper()
135+
var state extensionModel
136+
if diags := resp.State.Get(context.Background(), &state); diags.HasError() {
137+
t.Fatalf("get extension import state: %v", diags)
138+
}
139+
return state
140+
}

0 commit comments

Comments
 (0)