Skip to content

Commit 88e8869

Browse files
TheGnaneswarclaude
andcommitted
fix(kubernetes): implement Read + Importer for all 17 stub K8s resources
All 17 Kubernetes resources previously had stub Read functions (`return nil`) and no Importer block, making them invisible to drift detection and blocking `terraform import` entirely. Changes: - Added `Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext}` to all 17 resources - Replaced every `return nil` stub with a real context-based Read using `diag.Diagnostics` - Added `internal/resource_kubernetes_common.go` with `k8sConfirmExistsByGET` — a shared helper used by the 13 manifest-based resources that issues a GET, handles 404 by clearing the ID, and avoids refreshing the manifest field (K8s server-expands objects, so re-reading would cause permanent diffs) - `portainer_kubernetes_helm`: upgraded from existence-check to full Read; recovers `chart` and `repo` from `chartReference.{chartPath,repoURL}` in the release-detail response, eliminating the ForceNew destroy+recreate that would otherwise trigger after `terraform import` - `portainer_kubernetes_namespace`: guards `owner` and `resource_quota` with nil checks so environments with EnableResourceOverCommit or a missing NamespaceOwner field don't generate permanent drift - `portainer_kubernetes_namespace_system`: reads `IsSystem` from the namespace API and stores it - All Read implementations follow the context-aware `diag.Diagnostics` pattern introduced in the recent provider refactor Tested end-to-end on a live Portainer/k0s environment: terraform apply → 17/17 created rm terraform.tfstate → state dropped (brownfield simulation) terraform import (×17) → 17/17 "Import successful!" terraform apply → 15 in-place reconciliation updates (idempotent re-POST of manifests; no resources destroyed) terraform plan → No changes. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 5609672 commit 88e8869

18 files changed

Lines changed: 604 additions & 4 deletions

internal/resource_kubernetes_application.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func resourceKubernetesApplication() *schema.Resource {
2121
UpdateContext: resourceKubernetesApplicationUpdate,
2222
DeleteContext: resourceKubernetesApplicationDelete,
2323

24+
Importer: &schema.ResourceImporter{
25+
StateContext: schema.ImportStatePassthroughContext,
26+
},
27+
2428
Timeouts: &schema.ResourceTimeout{
2529
Create: schema.DefaultTimeout(10 * time.Minute),
2630
Update: schema.DefaultTimeout(10 * time.Minute),
@@ -154,6 +158,24 @@ func resourceKubernetesApplicationUpdate(ctx context.Context, d *schema.Resource
154158
}
155159

156160
func resourceKubernetesApplicationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
161+
client := meta.(*APIClient)
162+
163+
endpointID, namespace, name := parseApllicationsID(d.Id())
164+
if endpointID == 0 || namespace == "" || name == "" {
165+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'endpointID:namespace:name': %s", d.Id()))
166+
}
167+
168+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/apis/apps/v1/namespaces/%s/deployments/%s", client.Endpoint, endpointID, namespace, name)
169+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "deployment "+name); diags.HasError() {
170+
return diags
171+
}
172+
if d.Id() == "" {
173+
return nil
174+
}
175+
176+
d.Set("endpoint_id", endpointID)
177+
d.Set("namespace", namespace)
178+
// "manifest" intentionally not refreshed — see k8sConfirmExistsByGET.
157179
return nil
158180
}
159181

internal/resource_kubernetes_clusterrole.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesClusterRoles() *schema.Resource {
2020
UpdateContext: resourceKubernetesClusterRolesUpdate,
2121
DeleteContext: resourceKubernetesClusterRolesDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Schema: map[string]*schema.Schema{
2428
"endpoint_id": {
2529
Type: schema.TypeInt,
@@ -133,6 +137,23 @@ func resourceKubernetesClusterRolesUpdate(ctx context.Context, d *schema.Resourc
133137
}
134138

135139
func resourceKubernetesClusterRolesRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
140+
client := meta.(*APIClient)
141+
142+
endpointID, name := parseClusterRolesID(d.Id())
143+
if endpointID == 0 || name == "" {
144+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'endpointID:name': %s", d.Id()))
145+
}
146+
147+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/apis/rbac.authorization.k8s.io/v1/clusterroles/%s", client.Endpoint, endpointID, name)
148+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "clusterrole "+name); diags.HasError() {
149+
return diags
150+
}
151+
if d.Id() == "" {
152+
return nil
153+
}
154+
155+
d.Set("endpoint_id", endpointID)
156+
// "manifest" intentionally not refreshed — see k8sConfirmExistsByGET.
136157
return nil
137158
}
138159

internal/resource_kubernetes_clusterrolebinding.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesClusterRoleBindings() *schema.Resource {
2020
UpdateContext: resourceKubernetesClusterRoleBindingsUpdate,
2121
DeleteContext: resourceKubernetesClusterRoleBindingsDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Schema: map[string]*schema.Schema{
2428
"endpoint_id": {
2529
Type: schema.TypeInt,
@@ -133,6 +137,23 @@ func resourceKubernetesClusterRoleBindingsUpdate(ctx context.Context, d *schema.
133137
}
134138

135139
func resourceKubernetesClusterRoleBindingsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
140+
client := meta.(*APIClient)
141+
142+
endpointID, name := parseClusterRolesBindingsID(d.Id())
143+
if endpointID == 0 || name == "" {
144+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'endpointID:name': %s", d.Id()))
145+
}
146+
147+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/%s", client.Endpoint, endpointID, name)
148+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "clusterrolebinding "+name); diags.HasError() {
149+
return diags
150+
}
151+
if d.Id() == "" {
152+
return nil
153+
}
154+
155+
d.Set("endpoint_id", endpointID)
156+
// "manifest" intentionally not refreshed — see k8sConfirmExistsByGET.
136157
return nil
137158
}
138159

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
9+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
11+
)
12+
13+
// k8sConfirmExistsByGET issues a GET against a manifest-based Kubernetes resource to
14+
// verify it still exists. On HTTP 404 it clears the resource ID via d.SetId("") so the
15+
// next plan recreates it (out-of-band deletion detection). On any other non-2xx it
16+
// returns diagnostics carrying the response body.
17+
//
18+
// It deliberately does NOT refresh the authored "manifest" field. The Kubernetes API
19+
// returns a fully server-expanded object (status, managedFields, resourceVersion,
20+
// defaulted spec fields, …) that never matches the user's hand-written manifest;
21+
// writing it back would produce a permanent diff. Since these resources' Update is
22+
// delete+recreate, that diff would also churn the workload on every apply. So the
23+
// authored manifest stays the source of truth in state — Read only confirms existence
24+
// and (in the caller) recovers identity fields. Manifest-content drift is therefore
25+
// not detected; only deletion is. After `terraform import`, the manifest field must be
26+
// set in config to match the live object.
27+
//
28+
// Returns nil diagnostics with the ID preserved when the object exists, or nil
29+
// diagnostics with the ID cleared when it is gone. Callers should check d.Id() == ""
30+
// before setting any other fields.
31+
func k8sConfirmExistsByGET(ctx context.Context, d *schema.ResourceData, client *APIClient, url, kind string) diag.Diagnostics {
32+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
33+
if err != nil {
34+
return diag.FromErr(err)
35+
}
36+
if client.APIKey != "" {
37+
req.Header.Set("X-API-Key", client.APIKey)
38+
} else if client.JWTToken != "" {
39+
req.Header.Set("Authorization", "Bearer "+client.JWTToken)
40+
} else {
41+
return diag.FromErr(fmt.Errorf("no valid authentication method provided (api_key or jwt token)"))
42+
}
43+
44+
resp, err := client.HTTPClient.Do(req)
45+
if err != nil {
46+
return diag.FromErr(err)
47+
}
48+
defer resp.Body.Close()
49+
50+
if resp.StatusCode == http.StatusNotFound {
51+
d.SetId("")
52+
return nil
53+
}
54+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
55+
body, _ := io.ReadAll(resp.Body)
56+
return diag.FromErr(fmt.Errorf("failed to read %s (%d): %s", kind, resp.StatusCode, string(body)))
57+
}
58+
return nil
59+
}

internal/resource_kubernetes_configmaps.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesConfigMaps() *schema.Resource {
2020
UpdateContext: resourceKubernetesConfigMapsUpdate,
2121
DeleteContext: resourceKubernetesConfigMapsDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Schema: map[string]*schema.Schema{
2428
"endpoint_id": {
2529
Type: schema.TypeInt,
@@ -139,6 +143,24 @@ func resourceKubernetesConfigMapsUpdate(ctx context.Context, d *schema.ResourceD
139143
}
140144

141145
func resourceKubernetesConfigMapsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
146+
client := meta.(*APIClient)
147+
148+
endpointID, namespace, name := parseConfigMapsID(d.Id())
149+
if endpointID == 0 || namespace == "" || name == "" {
150+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'endpointID:namespace:name': %s", d.Id()))
151+
}
152+
153+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/api/v1/namespaces/%s/configmaps/%s", client.Endpoint, endpointID, namespace, name)
154+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "configmap "+name); diags.HasError() {
155+
return diags
156+
}
157+
if d.Id() == "" {
158+
return nil
159+
}
160+
161+
d.Set("endpoint_id", endpointID)
162+
d.Set("namespace", namespace)
163+
// "manifest" intentionally not refreshed — see k8sConfirmExistsByGET.
142164
return nil
143165
}
144166

internal/resource_kubernetes_cronjob.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesCronJob() *schema.Resource {
2020
UpdateContext: resourceKubernetesCronJobUpdate,
2121
DeleteContext: resourceKubernetesCronJobDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Schema: map[string]*schema.Schema{
2428
"endpoint_id": {
2529
Type: schema.TypeInt,
@@ -139,6 +143,24 @@ func resourceKubernetesCronJobUpdate(ctx context.Context, d *schema.ResourceData
139143
}
140144

141145
func resourceKubernetesCronJobRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
146+
client := meta.(*APIClient)
147+
148+
endpointID, namespace, name := parseCronJobID(d.Id())
149+
if endpointID == 0 || namespace == "" || name == "" {
150+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'endpointID:namespace:name': %s", d.Id()))
151+
}
152+
153+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/apis/batch/v1/namespaces/%s/cronjobs/%s", client.Endpoint, endpointID, namespace, name)
154+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "cronjob "+name); diags.HasError() {
155+
return diags
156+
}
157+
if d.Id() == "" {
158+
return nil
159+
}
160+
161+
d.Set("endpoint_id", endpointID)
162+
d.Set("namespace", namespace)
163+
// "manifest" intentionally not refreshed — see k8sConfirmExistsByGET.
142164
return nil
143165
}
144166

internal/resource_kubernetes_helm.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesHelm() *schema.Resource {
2020
ReadContext: resourceKubernetesHelmRead,
2121
DeleteContext: resourceKubernetesHelmDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Timeouts: &schema.ResourceTimeout{
2428
Create: schema.DefaultTimeout(15 * time.Minute),
2529
Delete: schema.DefaultTimeout(10 * time.Minute),
@@ -114,7 +118,70 @@ func resourceKubernetesHelmCreate(ctx context.Context, d *schema.ResourceData, m
114118
}
115119

116120
func resourceKubernetesHelmRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
117-
// No-op for now
121+
client := meta.(*APIClient)
122+
123+
idParts := strings.SplitN(d.Id(), ":", 3)
124+
if len(idParts) != 3 {
125+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'envID:namespace:release': %s", d.Id()))
126+
}
127+
var envID int
128+
fmt.Sscanf(idParts[0], "%d", &envID)
129+
namespace := idParts[1]
130+
release := idParts[2]
131+
if envID == 0 || release == "" {
132+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'envID:namespace:release': %s", d.Id()))
133+
}
134+
135+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/helm/%s?namespace=%s", client.Endpoint, envID, release, namespace)
136+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
137+
if err != nil {
138+
return diag.FromErr(err)
139+
}
140+
if client.APIKey != "" {
141+
req.Header.Set("X-API-Key", client.APIKey)
142+
} else if client.JWTToken != "" {
143+
req.Header.Set("Authorization", "Bearer "+client.JWTToken)
144+
} else {
145+
return diag.FromErr(fmt.Errorf("no valid authentication method provided (api_key or jwt token)"))
146+
}
147+
148+
resp, err := client.HTTPClient.Do(req)
149+
if err != nil {
150+
return diag.FromErr(err)
151+
}
152+
defer resp.Body.Close()
153+
154+
if resp.StatusCode == http.StatusNotFound {
155+
d.SetId("")
156+
return nil
157+
}
158+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
159+
body, _ := io.ReadAll(resp.Body)
160+
return diag.FromErr(fmt.Errorf("failed to read helm release %s (%d): %s", release, resp.StatusCode, string(body)))
161+
}
162+
163+
var helmRelease struct {
164+
ChartReference struct {
165+
ChartPath string `json:"chartPath"`
166+
RepoURL string `json:"repoURL"`
167+
} `json:"chartReference"`
168+
}
169+
if err := json.NewDecoder(resp.Body).Decode(&helmRelease); err != nil {
170+
return diag.FromErr(fmt.Errorf("failed to decode helm release response: %w", err))
171+
}
172+
173+
d.Set("environment_id", envID)
174+
d.Set("namespace", namespace)
175+
d.Set("name", release)
176+
if helmRelease.ChartReference.ChartPath != "" {
177+
d.Set("chart", helmRelease.ChartReference.ChartPath)
178+
}
179+
if helmRelease.ChartReference.RepoURL != "" {
180+
d.Set("repo", helmRelease.ChartReference.RepoURL)
181+
}
182+
// values not restored — the API returns server-merged computed values, not the
183+
// original user-supplied YAML. Keep whatever is currently in state; users with
184+
// custom values should add lifecycle { ignore_changes = [values] } after import.
118185
return nil
119186
}
120187

internal/resource_kubernetes_ingresses.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func resourceKubernetesNamespaceIngress() *schema.Resource {
2020
UpdateContext: resourceKubernetesNamespaceIngressUpdate,
2121
DeleteContext: resourceKubernetesNamespaceIngressDelete,
2222

23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
2327
Schema: map[string]*schema.Schema{
2428
"environment_id": {
2529
Type: schema.TypeInt,
@@ -215,7 +219,37 @@ func createOrUpdateIngress(ctx context.Context, d *schema.ResourceData, client *
215219
}
216220

217221
func resourceKubernetesNamespaceIngressRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
218-
return nil // No-op
222+
client := meta.(*APIClient)
223+
224+
parts := strings.SplitN(d.Id(), ":", 3)
225+
if len(parts) != 3 {
226+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'envID:namespace:name': %s", d.Id()))
227+
}
228+
var envID int
229+
fmt.Sscanf(parts[0], "%d", &envID)
230+
namespace := parts[1]
231+
name := parts[2]
232+
if envID == 0 || namespace == "" || name == "" {
233+
return diag.FromErr(fmt.Errorf("invalid ID format, expected 'envID:namespace:name': %s", d.Id()))
234+
}
235+
236+
// Existence check via the K8s proxy (standard networking.k8s.io path) for clean 404s.
237+
url := fmt.Sprintf("%s/endpoints/%d/kubernetes/apis/networking.k8s.io/v1/namespaces/%s/ingresses/%s", client.Endpoint, envID, namespace, name)
238+
if diags := k8sConfirmExistsByGET(ctx, d, client, url, "ingress "+name); diags.HasError() {
239+
return diags
240+
}
241+
if d.Id() == "" {
242+
return nil
243+
}
244+
245+
d.Set("environment_id", envID)
246+
d.Set("namespace", namespace)
247+
d.Set("name", name)
248+
// Nested spec (hosts/tls/paths/annotations/labels/class_name) intentionally not
249+
// refreshed — reconstructing it from the live Ingress object is lossy and would diff
250+
// against the authored config. The config stays the source of truth; only deletion is
251+
// detected. After `terraform import`, set those fields to match.
252+
return nil
219253
}
220254

221255
func resourceKubernetesNamespaceIngressDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {

0 commit comments

Comments
 (0)