Skip to content

Commit d67484c

Browse files
committed
fix: certificate issues
1 parent 05faac1 commit d67484c

9 files changed

Lines changed: 174 additions & 18 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<claude-mem-context>
2+
# Recent Activity
3+
4+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5+
6+
*No recent activity*
7+
</claude-mem-context>

.planning/phases/50.1-fix-directory-service-role-post-missing-names-query-param/.gitkeep

Whitespace-only changes.

.serena/project.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ project_name: "terraform-provider-flashblade"
2828
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
2929
languages:
3030
- go
31+
- python
3132

3233
# the encoding used by text files in the project
3334
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings

docs/resources/certificate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ resource "flashblade_certificate" "example" {
3434

3535
### Optional
3636

37-
- `certificate_type` (String) The certificate type (e.g. appliance, external). Defaults to 'appliance' when not specified.
37+
- `certificate_type` (String) The certificate type. Valid values: 'array' (FlashBlade identity, requires private_key) or 'external' (trusted external server such as AD). When unset, the provider infers 'array' if private_key is provided; otherwise the API defaults to 'external'. Immutable after creation.
3838
- `intermediate_certificate` (String) The PEM-encoded intermediate certificate chain.
3939
- `passphrase` (String, Sensitive) The passphrase protecting the private key. Not returned by the API after creation.
4040
- `private_key` (String, Sensitive) The PEM-encoded private key. Not returned by the API after creation.

internal/client/certificates_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestUnit_Certificate_Get_Found(t *testing.T) {
3636
ID: "cert-seed-1",
3737
Name: "my-cert",
3838
Certificate: "-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----",
39-
CertificateType: "appliance",
39+
CertificateType: "array",
4040
CommonName: "test-cert",
4141
IssuedBy: "CN=Test CA",
4242
IssuedTo: "CN=test-cert",
@@ -100,8 +100,9 @@ func TestUnit_Certificate_Post(t *testing.T) {
100100

101101
pem := "-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----"
102102
got, err := c.PostCertificate(context.Background(), "new-cert", client.CertificatePost{
103-
Certificate: pem,
104-
PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIkey\n-----END PRIVATE KEY-----",
103+
Certificate: pem,
104+
CertificateType: "array",
105+
PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIkey\n-----END PRIVATE KEY-----",
105106
})
106107
if err != nil {
107108
t.Fatalf("PostCertificate: %v", err)
@@ -118,8 +119,8 @@ func TestUnit_Certificate_Post(t *testing.T) {
118119
if got.Status != "imported" {
119120
t.Errorf("expected Status %q, got %q", "imported", got.Status)
120121
}
121-
if got.CertificateType != "appliance" {
122-
t.Errorf("expected CertificateType %q, got %q", "appliance", got.CertificateType)
122+
if got.CertificateType != "array" {
123+
t.Errorf("expected CertificateType %q, got %q", "array", got.CertificateType)
123124
}
124125
if got.IssuedBy != "CN=Test CA" {
125126
t.Errorf("expected IssuedBy %q, got %q", "CN=Test CA", got.IssuedBy)

internal/provider/certificate_data_source_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func TestUnit_CertificateDataSource_Basic(t *testing.T) {
100100
ID: "cert-ds-001",
101101
Name: "ds-cert",
102102
Certificate: "-----BEGIN CERTIFICATE-----\nMIIBds\n-----END CERTIFICATE-----",
103-
CertificateType: "appliance",
103+
CertificateType: "array",
104104
CommonName: "flashblade.example.com",
105105
Country: "US",
106106
Email: "admin@example.com",
@@ -148,8 +148,8 @@ func TestUnit_CertificateDataSource_Basic(t *testing.T) {
148148
if model.Name.ValueString() != "ds-cert" {
149149
t.Errorf("expected name=ds-cert, got %s", model.Name.ValueString())
150150
}
151-
if model.CertificateType.ValueString() != "appliance" {
152-
t.Errorf("expected certificate_type=appliance, got %s", model.CertificateType.ValueString())
151+
if model.CertificateType.ValueString() != "array" {
152+
t.Errorf("expected certificate_type=array, got %s", model.CertificateType.ValueString())
153153
}
154154
if model.CommonName.ValueString() != "flashblade.example.com" {
155155
t.Errorf("expected common_name=flashblade.example.com, got %s", model.CommonName.ValueString())

internal/provider/certificate_resource.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func (r *certificateResource) Schema(ctx context.Context, _ resource.SchemaReque
9696
"certificate_type": schema.StringAttribute{
9797
Optional: true,
9898
Computed: true,
99-
Description: "The certificate type (e.g. appliance, external). Defaults to 'appliance' when not specified.",
99+
Description: "The certificate type. Valid values: 'array' (FlashBlade identity, requires private_key) or 'external' (trusted external server such as AD). When unset, the provider infers 'array' if private_key is provided; otherwise the API defaults to 'external'. Immutable after creation.",
100100
PlanModifiers: []planmodifier.String{
101101
stringplanmodifier.UseStateForUnknown(),
102102
},
@@ -228,16 +228,28 @@ func (r *certificateResource) Create(ctx context.Context, req resource.CreateReq
228228
post := client.CertificatePost{
229229
Certificate: data.Certificate.ValueString(),
230230
}
231-
if !data.CertificateType.IsNull() && !data.CertificateType.IsUnknown() {
231+
privateKeySet := !data.PrivateKey.IsNull() && data.PrivateKey.ValueString() != ""
232+
switch {
233+
case !data.CertificateType.IsNull() && !data.CertificateType.IsUnknown():
232234
post.CertificateType = data.CertificateType.ValueString()
233-
}
234-
if !data.IntermediateCertificate.IsNull() {
235+
case privateKeySet:
236+
// The FlashBlade API defaults to "external" when certificate_type is
237+
// omitted, and rejects private_key for external certificates. Infer
238+
// "array" when a private key is supplied so the common case works
239+
// without the user needing to set certificate_type explicitly.
240+
// Note: the swagger description names this value "appliance", but the
241+
// real API only accepts "array" (verified against the Pure-Storage
242+
// Ansible FlashBlade collection and live array behavior).
243+
post.CertificateType = "array"
244+
}
245+
wasIntermediateNull := data.IntermediateCertificate.IsNull()
246+
if !wasIntermediateNull {
235247
post.IntermediateCertificate = data.IntermediateCertificate.ValueString()
236248
}
237249
if !data.Passphrase.IsNull() {
238250
post.Passphrase = data.Passphrase.ValueString()
239251
}
240-
if !data.PrivateKey.IsNull() {
252+
if privateKeySet {
241253
post.PrivateKey = data.PrivateKey.ValueString()
242254
}
243255

@@ -253,6 +265,12 @@ func (r *certificateResource) Create(ctx context.Context, req resource.CreateReq
253265

254266
mapCertificateToModel(cert, &data)
255267

268+
// The API returns "" for fields that were never set; Terraform requires the
269+
// state to stay null when the config was null (Optional, non-Computed).
270+
if wasIntermediateNull && cert.IntermediateCertificate == "" {
271+
data.IntermediateCertificate = types.StringNull()
272+
}
273+
256274
data.PrivateKey = privateKey
257275
data.Passphrase = passphrase
258276

@@ -427,9 +445,14 @@ func (r *certificateResource) Read(ctx context.Context, req resource.ReadRequest
427445
// Preserve write-only fields from prior state.
428446
privateKey := data.PrivateKey
429447
passphrase := data.Passphrase
448+
wasIntermediateNull := data.IntermediateCertificate.IsNull()
430449

431450
mapCertificateToModel(cert, &data)
432451

452+
if wasIntermediateNull && cert.IntermediateCertificate == "" {
453+
data.IntermediateCertificate = types.StringNull()
454+
}
455+
433456
data.PrivateKey = privateKey
434457
data.Passphrase = passphrase
435458

@@ -490,9 +513,14 @@ func (r *certificateResource) Update(ctx context.Context, req resource.UpdateReq
490513
// Preserve write-only fields before mapping overwrites the model.
491514
privateKey := plan.PrivateKey
492515
passphrase := plan.Passphrase
516+
wasIntermediateNull := plan.IntermediateCertificate.IsNull()
493517

494518
mapCertificateToModel(cert, &plan)
495519

520+
if wasIntermediateNull && cert.IntermediateCertificate == "" {
521+
plan.IntermediateCertificate = types.StringNull()
522+
}
523+
496524
plan.PrivateKey = privateKey
497525
plan.Passphrase = passphrase
498526
} else {
@@ -558,6 +586,12 @@ func (r *certificateResource) ImportState(ctx context.Context, req resource.Impo
558586

559587
mapCertificateToModel(cert, &data)
560588

589+
// intermediate_certificate is Optional (non-Computed): keep it null when the
590+
// API reports no intermediate, so a follow-up plan doesn't show bogus drift.
591+
if cert.IntermediateCertificate == "" {
592+
data.IntermediateCertificate = types.StringNull()
593+
}
594+
561595
// Write-only sensitive fields are not returned by the API — set to empty string after import.
562596
data.PrivateKey = types.StringValue("")
563597
data.Passphrase = types.StringValue("")

internal/provider/certificate_resource_test.go

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,117 @@ func TestUnit_CertificateResource_Lifecycle(t *testing.T) {
228228
}
229229
}
230230

231+
// TestUnit_CertificateResource_Create_DefaultsToArrayWhenPrivateKey: verifies that
232+
// when certificate_type is unset but private_key is provided, the provider sends
233+
// "array" so the API accepts the request (API otherwise defaults to "external"
234+
// and rejects the private key).
235+
func TestUnit_CertificateResource_Create_DefaultsToArrayWhenPrivateKey(t *testing.T) {
236+
ms := testmock.NewMockServer()
237+
defer ms.Close()
238+
handlers.RegisterCertificateHandlers(ms.Mux)
239+
240+
r := newTestCertificateResource(t, ms)
241+
s := certificateResourceSchema(t).Schema
242+
243+
const certPEM = "-----BEGIN CERTIFICATE-----\nMIIBarr\n-----END CERTIFICATE-----"
244+
const privKey = "-----BEGIN PRIVATE KEY-----\nMIIEarr\n-----END PRIVATE KEY-----"
245+
246+
plan := certificatePlanWith(t, "arr-cert", certPEM, privKey, "")
247+
createResp := &resource.CreateResponse{
248+
State: tfsdk.State{Raw: tftypes.NewValue(buildCertificateType(), nil), Schema: s},
249+
}
250+
r.Create(context.Background(), resource.CreateRequest{Plan: plan}, createResp)
251+
if createResp.Diagnostics.HasError() {
252+
t.Fatalf("Create returned error: %s", createResp.Diagnostics)
253+
}
254+
255+
var got certificateModel
256+
if diags := createResp.State.Get(context.Background(), &got); diags.HasError() {
257+
t.Fatalf("Get state: %s", diags)
258+
}
259+
if got.CertificateType.ValueString() != "array" {
260+
t.Errorf("expected certificate_type=array (inferred from private_key), got %q", got.CertificateType.ValueString())
261+
}
262+
}
263+
264+
// TestUnit_CertificateResource_Create_ExternalWhenNoPrivateKey: verifies that when
265+
// neither certificate_type nor private_key is provided, the provider lets the API
266+
// apply its default ("external").
267+
func TestUnit_CertificateResource_Create_ExternalWhenNoPrivateKey(t *testing.T) {
268+
ms := testmock.NewMockServer()
269+
defer ms.Close()
270+
handlers.RegisterCertificateHandlers(ms.Mux)
271+
272+
r := newTestCertificateResource(t, ms)
273+
s := certificateResourceSchema(t).Schema
274+
275+
const certPEM = "-----BEGIN CERTIFICATE-----\nMIIBext\n-----END CERTIFICATE-----"
276+
277+
plan := certificatePlanWith(t, "ext-cert", certPEM, "", "")
278+
createResp := &resource.CreateResponse{
279+
State: tfsdk.State{Raw: tftypes.NewValue(buildCertificateType(), nil), Schema: s},
280+
}
281+
r.Create(context.Background(), resource.CreateRequest{Plan: plan}, createResp)
282+
if createResp.Diagnostics.HasError() {
283+
t.Fatalf("Create returned error: %s", createResp.Diagnostics)
284+
}
285+
286+
var got certificateModel
287+
if diags := createResp.State.Get(context.Background(), &got); diags.HasError() {
288+
t.Fatalf("Get state: %s", diags)
289+
}
290+
if got.CertificateType.ValueString() != "external" {
291+
t.Errorf("expected certificate_type=external (API default), got %q", got.CertificateType.ValueString())
292+
}
293+
}
294+
295+
// TestUnit_CertificateResource_Create_PreservesNullIntermediate: verifies that when
296+
// intermediate_certificate is unset in config and the API returns "", the state
297+
// remains null rather than "" (avoids "provider produced inconsistent result").
298+
func TestUnit_CertificateResource_Create_PreservesNullIntermediate(t *testing.T) {
299+
ms := testmock.NewMockServer()
300+
defer ms.Close()
301+
handlers.RegisterCertificateHandlers(ms.Mux)
302+
303+
r := newTestCertificateResource(t, ms)
304+
s := certificateResourceSchema(t).Schema
305+
306+
const certPEM = "-----BEGIN CERTIFICATE-----\nMIIBnoint\n-----END CERTIFICATE-----"
307+
308+
plan := certificatePlanWith(t, "noint-cert", certPEM, "", "external")
309+
createResp := &resource.CreateResponse{
310+
State: tfsdk.State{Raw: tftypes.NewValue(buildCertificateType(), nil), Schema: s},
311+
}
312+
r.Create(context.Background(), resource.CreateRequest{Plan: plan}, createResp)
313+
if createResp.Diagnostics.HasError() {
314+
t.Fatalf("Create returned error: %s", createResp.Diagnostics)
315+
}
316+
317+
var got certificateModel
318+
if diags := createResp.State.Get(context.Background(), &got); diags.HasError() {
319+
t.Fatalf("Get state: %s", diags)
320+
}
321+
if !got.IntermediateCertificate.IsNull() {
322+
t.Errorf("expected intermediate_certificate to stay null, got %q (IsNull=%v)",
323+
got.IntermediateCertificate.ValueString(), got.IntermediateCertificate.IsNull())
324+
}
325+
326+
// Read path must also preserve null on empty API response.
327+
readResp := &resource.ReadResponse{State: createResp.State}
328+
r.Read(context.Background(), resource.ReadRequest{State: createResp.State}, readResp)
329+
if readResp.Diagnostics.HasError() {
330+
t.Fatalf("Read returned error: %s", readResp.Diagnostics)
331+
}
332+
var afterRead certificateModel
333+
if diags := readResp.State.Get(context.Background(), &afterRead); diags.HasError() {
334+
t.Fatalf("Get read state: %s", diags)
335+
}
336+
if !afterRead.IntermediateCertificate.IsNull() {
337+
t.Errorf("expected intermediate_certificate null after Read, got %q",
338+
afterRead.IntermediateCertificate.ValueString())
339+
}
340+
}
341+
231342
// TestUnit_CertificateResource_Import: seed cert in mock → import by name → verify state.
232343
func TestUnit_CertificateResource_Import(t *testing.T) {
233344
ms := testmock.NewMockServer()
@@ -242,7 +353,7 @@ func TestUnit_CertificateResource_Import(t *testing.T) {
242353
ID: "cert-import-001",
243354
Name: "import-cert",
244355
Certificate: "-----BEGIN CERTIFICATE-----\nMIIBseed\n-----END CERTIFICATE-----",
245-
CertificateType: "appliance",
356+
CertificateType: "array",
246357
CommonName: "flashblade.example.com",
247358
IssuedBy: "CN=Test CA",
248359
IssuedTo: "CN=flashblade.example.com",
@@ -273,8 +384,8 @@ func TestUnit_CertificateResource_Import(t *testing.T) {
273384
if model.Name.ValueString() != "import-cert" {
274385
t.Errorf("expected name=import-cert, got %s", model.Name.ValueString())
275386
}
276-
if model.CertificateType.ValueString() != "appliance" {
277-
t.Errorf("expected certificate_type=appliance, got %s", model.CertificateType.ValueString())
387+
if model.CertificateType.ValueString() != "array" {
388+
t.Errorf("expected certificate_type=array, got %s", model.CertificateType.ValueString())
278389
}
279390
if model.CommonName.ValueString() != "flashblade.example.com" {
280391
t.Errorf("expected common_name=flashblade.example.com, got %s", model.CommonName.ValueString())

internal/testmock/handlers/certificates.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,11 @@ func (s *certificateStore) handlePost(w http.ResponseWriter, r *http.Request) {
117117
id := fmt.Sprintf("cert-%d", s.nextID)
118118
s.nextID++
119119

120+
// Match real FlashBlade API: certificate_type defaults to "external" when
121+
// omitted at creation time (see swagger _certificateBase.certificate_type).
120122
certType := body.CertificateType
121123
if certType == "" {
122-
certType = "appliance"
124+
certType = "external"
123125
}
124126

125127
cert := &client.Certificate{

0 commit comments

Comments
 (0)