{"record":{"id":"73f7a36c88ac0f36","repo":"hashicorp/nomad","slug":"missing-externalid","errorCode":null,"errorMessage":"missing ExternalID","messagePattern":"missing ExternalID","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"plugins/csi/plugin.go","lineNumber":446,"sourceCode":"\tNodeID     string\n\tSecrets    structs.CSISecrets\n}\n\nfunc (r *ControllerUnpublishVolumeRequest) ToCSIRepresentation() *csipbv1.ControllerUnpublishVolumeRequest {\n\tif r == nil {\n\t\treturn nil\n\t}\n\n\treturn &csipbv1.ControllerUnpublishVolumeRequest{\n\t\tVolumeId: r.ExternalID,\n\t\tNodeId:   r.NodeID,\n\t\tSecrets:  r.Secrets,\n\t}\n}\n\nfunc (r *ControllerUnpublishVolumeRequest) Validate() error {\n\tif r.ExternalID == \"\" {\n\t\treturn errors.New(\"missing ExternalID\")\n\t}\n\tif r.NodeID == \"\" {\n\t\t// the spec allows this but it would unpublish the\n\t\t// volume from all nodes\n\t\treturn errors.New(\"missing NodeID\")\n\t}\n\treturn nil\n}\n\ntype ControllerUnpublishVolumeResponse struct{}\n\ntype ControllerCreateVolumeRequest struct {\n\t// note that Name is intentionally differentiated from both CSIVolume.ID\n\t// and ExternalVolumeID. This name is only a recommendation for the\n\t// storage provider, and many will discard this suggestion\n\tName                      string\n\tCapacityRange             *CapacityRange\n\tVolumeCapabilities        []*VolumeCapability","sourceCodeStart":428,"sourceCodeEnd":464,"githubUrl":"https://github.com/hashicorp/nomad/blob/482b49bf1aec006f089bcfc7e632d8f6ac303e5e/plugins/csi/plugin.go#L428-L464","documentation":"ControllerUnpublishVolumeRequest.Validate() rejects requests with an empty ExternalID, the provider-side volume identifier to detach. Without it the plugin cannot identify which volume to un-publish, so it fails before issuing the controller RPC.","triggerScenarios":"Calling ControllerUnpublishVolume with ExternalID empty, e.g. a zero-value request, a lost ID across retries, or reconcilers that only carry NodeID for teardown.","commonSituations":"Teardown code paths where the volume was already deleted and its external ID is gone; state store corruption/deserialization losing the external ID; hand-built unpublish requests in tests.","solutions":["Set ExternalID from the persisted volume claim state before calling ControllerUnpublishVolume","If the volume was deleted, skip unpublish rather than calling it with an empty ID","Persist the external ID at publish time so teardown can recover it"],"exampleFix":"// before\nreq := &csi.ControllerUnpublishVolumeRequest{\n    NodeID: nodeID,\n}\n// after\nreq := &csi.ControllerUnpublishVolumeRequest{\n    ExternalID: claim.ExternalVolumeID,\n    NodeID:     nodeID,\n}","handlingStrategy":"validation","validationCode":"func validateUnpublish(req *csi.ControllerUnpublishVolumeRequest) error {\n    if req.ExternalID == \"\" {\n        return errors.New(\"ExternalID required for unpublish; recover it from claim state\")\n    }\n    return nil\n}","typeGuard":"func canUnpublish(req *csi.ControllerUnpublishVolumeRequest) bool {\n    return req != nil && req.ExternalID != \"\" && req.NodeID != \"\"\n}","tryCatchPattern":"if err := req.Validate(); err != nil {\n    if strings.Contains(err.Error(), \"missing ExternalID\") {\n        return fmt.Errorf(\"unpublish refused: volume external ID unavailable (was the volume already deleted?): %w\", err)\n    }\n    return err\n}","preventionTips":["Persist ExternalVolumeID on the claim at publish time and read it during teardown","Skip controller unpublish for claims that never reached the publish stage","Log claim state before teardown RPCs to spot lost IDs"],"tags":["csi","validation","go","storage"],"backgroundTag":"csi-request-validation-failed","analyzedSha":"482b49bf1aec006f089bcfc7e632d8f6ac303e5e","analyzedAt":"2026-09-04T07:54:14.808Z","contentChangedAt":"2026-09-04T07:54:14.808Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}