Support Check-And-Set deletion of config entries (#11419)

Implements #11372
This commit is contained in:
Daniel Upton 2021-11-01 16:42:01 +00:00 committed by GitHub
parent 8ca5be47c8
commit d47b7311b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 543 additions and 27 deletions

6
.changelog/11419.txt Normal file
View File

@ -0,0 +1,6 @@
```release-note:improvement
config: Support Check-And-Set (CAS) deletion of config entries
```
```release-note:improvement
cli: Add `-cas` and `-modify-index` flags to the `consul config delete` command to support Check-And-Set (CAS) deletion of config entries
```

View File

@ -101,12 +101,27 @@ func (s *HTTPHandlers) configDelete(resp http.ResponseWriter, req *http.Request)
return nil, err return nil, err
} }
var reply struct{} // Check for cas value
if casStr := req.URL.Query().Get("cas"); casStr != "" {
casVal, err := strconv.ParseUint(casStr, 10, 64)
if err != nil {
return nil, err
}
args.Op = structs.ConfigEntryDeleteCAS
args.Entry.GetRaftIndex().ModifyIndex = casVal
}
var reply structs.ConfigEntryDeleteResponse
if err := s.agent.RPC("ConfigEntry.Delete", &args, &reply); err != nil { if err := s.agent.RPC("ConfigEntry.Delete", &args, &reply); err != nil {
return nil, err return nil, err
} }
return reply, nil // Return the `deleted` boolean for CAS operations, but not normal deletions
// to maintain backwards-compatibility with existing callers.
if args.Op == structs.ConfigEntryDeleteCAS {
return reply.Deleted, nil
}
return struct{}{}, nil
} }
// ConfigApply applies the given config entry update. // ConfigApply applies the given config entry update.

View File

@ -196,6 +196,88 @@ func TestConfig_Delete(t *testing.T) {
} }
} }
func TestConfig_Delete_CAS(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
require := require.New(t)
a := NewTestAgent(t, "")
defer a.Shutdown()
testrpc.WaitForTestAgent(t, a.RPC, "dc1")
// Create a config entry.
entry := &structs.ServiceConfigEntry{
Kind: structs.ServiceDefaults,
Name: "foo",
}
var created bool
require.NoError(a.RPC("ConfigEntry.Apply", &structs.ConfigEntryRequest{
Datacenter: "dc1",
Entry: entry,
}, &created))
require.True(created)
// Read it back to get its ModifyIndex.
var out structs.ConfigEntryResponse
require.NoError(a.RPC("ConfigEntry.Get", &structs.ConfigEntryQuery{
Datacenter: "dc1",
Kind: entry.Kind,
Name: entry.Name,
}, &out))
require.NotNil(out.Entry)
modifyIndex := out.Entry.GetRaftIndex().ModifyIndex
t.Run("attempt to delete with an invalid index", func(t *testing.T) {
req := httptest.NewRequest(
"DELETE",
fmt.Sprintf("/v1/config/%s/%s?cas=%d", entry.Kind, entry.Name, modifyIndex-1),
nil,
)
rawRsp, err := a.srv.Config(httptest.NewRecorder(), req)
require.NoError(err)
deleted, isBool := rawRsp.(bool)
require.True(isBool, "response should be a boolean")
require.False(deleted, "entry should not have been deleted")
// Verify it was not deleted.
var out structs.ConfigEntryResponse
require.NoError(a.RPC("ConfigEntry.Get", &structs.ConfigEntryQuery{
Datacenter: "dc1",
Kind: entry.Kind,
Name: entry.Name,
}, &out))
require.NotNil(out.Entry)
})
t.Run("attempt to delete with a valid index", func(t *testing.T) {
req := httptest.NewRequest(
"DELETE",
fmt.Sprintf("/v1/config/%s/%s?cas=%d", entry.Kind, entry.Name, modifyIndex),
nil,
)
rawRsp, err := a.srv.Config(httptest.NewRecorder(), req)
require.NoError(err)
deleted, isBool := rawRsp.(bool)
require.True(isBool, "response should be a boolean")
require.True(deleted, "entry should have been deleted")
// Verify it was deleted.
var out structs.ConfigEntryResponse
require.NoError(a.RPC("ConfigEntry.Get", &structs.ConfigEntryQuery{
Datacenter: "dc1",
Kind: entry.Kind,
Name: entry.Name,
}, &out))
require.Nil(out.Entry)
})
}
func TestConfig_Apply(t *testing.T) { func TestConfig_Apply(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("too slow for testing.Short") t.Skip("too slow for testing.Short")

View File

@ -262,7 +262,7 @@ func (c *ConfigEntry) ListAll(args *structs.ConfigEntryListAllRequest, reply *st
} }
// Delete deletes a config entry. // Delete deletes a config entry.
func (c *ConfigEntry) Delete(args *structs.ConfigEntryRequest, reply *struct{}) error { func (c *ConfigEntry) Delete(args *structs.ConfigEntryRequest, reply *structs.ConfigEntryDeleteResponse) error {
if err := c.srv.validateEnterpriseRequest(args.Entry.GetEnterpriseMeta(), true); err != nil { if err := c.srv.validateEnterpriseRequest(args.Entry.GetEnterpriseMeta(), true); err != nil {
return err return err
} }
@ -294,9 +294,30 @@ func (c *ConfigEntry) Delete(args *structs.ConfigEntryRequest, reply *struct{})
return acl.ErrPermissionDenied return acl.ErrPermissionDenied
} }
args.Op = structs.ConfigEntryDelete // Only delete and delete-cas ops are supported. If the caller erroneously
_, err = c.srv.raftApply(structs.ConfigEntryRequestType, args) // sent something else, we assume they meant delete.
return err switch args.Op {
case structs.ConfigEntryDelete, structs.ConfigEntryDeleteCAS:
default:
args.Op = structs.ConfigEntryDelete
}
rsp, err := c.srv.raftApply(structs.ConfigEntryRequestType, args)
if err != nil {
return err
}
if args.Op == structs.ConfigEntryDeleteCAS {
// In CAS deletions the FSM will return a boolean value indicating whether the
// operation was successful.
deleted, _ := rsp.(bool)
reply.Deleted = deleted
} else {
// For non-CAS deletions any non-error result indicates a successful deletion.
reply.Deleted = true
}
return nil
} }
// ResolveServiceConfig // ResolveServiceConfig

View File

@ -676,6 +676,64 @@ func TestConfigEntry_Delete(t *testing.T) {
}) })
} }
func TestConfigEntry_DeleteCAS(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
require := require.New(t)
dir, s := testServer(t)
defer os.RemoveAll(dir)
defer s.Shutdown()
codec := rpcClient(t, s)
defer codec.Close()
testrpc.WaitForLeader(t, s.RPC, "dc1")
// Create a simple config entry.
entry := &structs.ServiceConfigEntry{
Kind: structs.ServiceDefaults,
Name: "foo",
}
state := s.fsm.State()
require.NoError(state.EnsureConfigEntry(1, entry))
// Verify it's there.
_, existing, err := state.ConfigEntry(nil, entry.Kind, entry.Name, nil)
require.NoError(err)
// Send a delete CAS request with an invalid index.
args := structs.ConfigEntryRequest{
Datacenter: "dc1",
Op: structs.ConfigEntryDeleteCAS,
}
args.Entry = entry.Clone()
args.Entry.GetRaftIndex().ModifyIndex = existing.GetRaftIndex().ModifyIndex - 1
var rsp structs.ConfigEntryDeleteResponse
require.NoError(msgpackrpc.CallWithCodec(codec, "ConfigEntry.Delete", &args, &rsp))
require.False(rsp.Deleted)
// Verify the entry was not deleted.
_, existing, err = s.fsm.State().ConfigEntry(nil, structs.ServiceDefaults, "foo", nil)
require.NoError(err)
require.NotNil(existing)
// Restore the valid index and try again.
args.Entry.GetRaftIndex().ModifyIndex = existing.GetRaftIndex().ModifyIndex
require.NoError(msgpackrpc.CallWithCodec(codec, "ConfigEntry.Delete", &args, &rsp))
require.True(rsp.Deleted)
// Verify the entry was deleted.
_, existing, err = s.fsm.State().ConfigEntry(nil, structs.ServiceDefaults, "foo", nil)
require.NoError(err)
require.Nil(existing)
}
func TestConfigEntry_Delete_ACLDeny(t *testing.T) { func TestConfigEntry_Delete_ACLDeny(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("too slow for testing.Short") t.Skip("too slow for testing.Short")

View File

@ -237,7 +237,7 @@ func TestReplication_ConfigEntries(t *testing.T) {
Entry: entry, Entry: entry,
} }
var out struct{} var out structs.ConfigEntryDeleteResponse
require.NoError(t, s1.RPC("ConfigEntry.Delete", &arg, &out)) require.NoError(t, s1.RPC("ConfigEntry.Delete", &arg, &out))
} }

View File

@ -543,6 +543,14 @@ func (c *FSM) applyConfigEntryOperation(buf []byte, index uint64) interface{} {
return err return err
} }
return true return true
case structs.ConfigEntryDeleteCAS:
defer metrics.MeasureSinceWithLabels([]string{"fsm", "config_entry", req.Entry.GetKind()}, time.Now(),
[]metrics.Label{{Name: "op", Value: "delete"}})
deleted, err := c.state.DeleteConfigEntryCAS(index, req.Entry.GetRaftIndex().ModifyIndex, req.Entry)
if err != nil {
return err
}
return deleted
case structs.ConfigEntryDelete: case structs.ConfigEntryDelete:
defer metrics.MeasureSinceWithLabels([]string{"fsm", "config_entry", req.Entry.GetKind()}, time.Now(), defer metrics.MeasureSinceWithLabels([]string{"fsm", "config_entry", req.Entry.GetKind()}, time.Now(),
[]metrics.Label{{Name: "op", Value: "delete"}}) []metrics.Label{{Name: "op", Value: "delete"}})

View File

@ -1352,6 +1352,57 @@ func TestFSM_ConfigEntry(t *testing.T) {
} }
} }
func TestFSM_ConfigEntry_DeleteCAS(t *testing.T) {
t.Parallel()
require := require.New(t)
logger := testutil.Logger(t)
fsm, err := New(nil, logger)
require.NoError(err)
// Create a simple config entry and write it to the state store.
entry := &structs.ServiceConfigEntry{
Kind: structs.ServiceDefaults,
Name: "global",
}
require.NoError(fsm.state.EnsureConfigEntry(1, entry))
// Raft index is populated by EnsureConfigEntry, hold on to it so that we can
// restore it later.
raftIndex := entry.RaftIndex
require.NotZero(raftIndex.ModifyIndex)
// Attempt a CAS delete with an invalid index.
entry = entry.Clone()
entry.RaftIndex = structs.RaftIndex{
ModifyIndex: 99,
}
req := &structs.ConfigEntryRequest{
Op: structs.ConfigEntryDeleteCAS,
Entry: entry,
}
buf, err := structs.Encode(structs.ConfigEntryRequestType, req)
require.NoError(err)
// Expect to get boolean false back.
rsp := fsm.Apply(makeLog(buf))
didDelete, isBool := rsp.(bool)
require.True(isBool)
require.False(didDelete)
// Attempt a CAS delete with a valid index.
entry.RaftIndex = raftIndex
buf, err = structs.Encode(structs.ConfigEntryRequestType, req)
require.NoError(err)
// Expect to get boolean true back.
rsp = fsm.Apply(makeLog(buf))
didDelete, isBool = rsp.(bool)
require.True(isBool)
require.True(didDelete)
}
// This adapts another test by chunking the encoded data and then performing // This adapts another test by chunking the encoded data and then performing
// out-of-order applies of half the logs. It then snapshots, restores to a new // out-of-order applies of half the logs. It then snapshots, restores to a new
// FSM, and applies the rest. The goal is to verify that chunking snapshotting // FSM, and applies the rest. The goal is to verify that chunking snapshotting

View File

@ -242,6 +242,41 @@ func (s *Store) EnsureConfigEntryCAS(idx, cidx uint64, conf structs.ConfigEntry)
return err == nil, err return err == nil, err
} }
// DeleteConfigEntryCAS performs a check-and-set deletion of a config entry
// with the given raft index. If the index is not specified, or is not equal
// to the entry's current ModifyIndex then the call is a noop, otherwise the
// normal deletion is performed.
func (s *Store) DeleteConfigEntryCAS(idx, cidx uint64, conf structs.ConfigEntry) (bool, error) {
tx := s.db.WriteTxn(idx)
defer tx.Abort()
existing, err := tx.First(tableConfigEntries, indexID, newConfigEntryQuery(conf))
if err != nil {
return false, fmt.Errorf("failed config entry lookup: %s", err)
}
if existing == nil {
return false, nil
}
if existing.(structs.ConfigEntry).GetRaftIndex().ModifyIndex != cidx {
return false, nil
}
if err := deleteConfigEntryTxn(
tx,
idx,
conf.GetKind(),
conf.GetName(),
conf.GetEnterpriseMeta(),
); err != nil {
return false, err
}
err = tx.Commit()
return err == nil, err
}
func (s *Store) DeleteConfigEntry(idx uint64, kind, name string, entMeta *structs.EnterpriseMeta) error { func (s *Store) DeleteConfigEntry(idx uint64, kind, name string, entMeta *structs.EnterpriseMeta) error {
tx := s.db.WriteTxn(idx) tx := s.db.WriteTxn(idx)
defer tx.Abort() defer tx.Abort()

View File

@ -125,6 +125,47 @@ func TestStore_ConfigEntryCAS(t *testing.T) {
require.Equal(updated, config) require.Equal(updated, config)
} }
func TestStore_ConfigEntry_DeleteCAS(t *testing.T) {
require := require.New(t)
s := testConfigStateStore(t)
entry := &structs.ProxyConfigEntry{
Kind: structs.ProxyDefaults,
Name: "global",
Config: map[string]interface{}{
"DestinationServiceName": "foo",
},
}
// Attempt to delete the entry before it exists.
ok, err := s.DeleteConfigEntryCAS(1, 0, entry)
require.NoError(err)
require.False(ok)
// Create the entry.
require.NoError(s.EnsureConfigEntry(1, entry))
// Attempt to delete with an invalid index.
ok, err = s.DeleteConfigEntryCAS(2, 99, entry)
require.NoError(err)
require.False(ok)
// Entry should not be deleted.
_, config, err := s.ConfigEntry(nil, entry.Kind, entry.Name, nil)
require.NoError(err)
require.NotNil(config)
// Attempt to delete with a valid index.
ok, err = s.DeleteConfigEntryCAS(2, 1, entry)
require.NoError(err)
require.True(ok)
// Entry should be deleted.
_, config, err = s.ConfigEntry(nil, entry.Kind, entry.Name, nil)
require.NoError(err)
require.Nil(config)
}
func TestStore_ConfigEntry_UpdateOver(t *testing.T) { func TestStore_ConfigEntry_UpdateOver(t *testing.T) {
// This test uses ServiceIntentions because they are the only // This test uses ServiceIntentions because they are the only
// kind that implements UpdateOver() at this time. // kind that implements UpdateOver() at this time.

View File

@ -445,6 +445,7 @@ const (
ConfigEntryUpsert ConfigEntryOp = "upsert" ConfigEntryUpsert ConfigEntryOp = "upsert"
ConfigEntryUpsertCAS ConfigEntryOp = "upsert-cas" ConfigEntryUpsertCAS ConfigEntryOp = "upsert-cas"
ConfigEntryDelete ConfigEntryOp = "delete" ConfigEntryDelete ConfigEntryOp = "delete"
ConfigEntryDeleteCAS ConfigEntryOp = "delete-cas"
) )
// ConfigEntryRequest is used when creating/updating/deleting a ConfigEntry. // ConfigEntryRequest is used when creating/updating/deleting a ConfigEntry.
@ -1121,3 +1122,7 @@ func validateConfigEntryMeta(meta map[string]string) error {
} }
return err return err
} }
type ConfigEntryDeleteResponse struct {
Deleted bool
}

View File

@ -468,20 +468,45 @@ func (conf *ConfigEntries) set(entry ConfigEntry, params map[string]string, w *W
} }
func (conf *ConfigEntries) Delete(kind string, name string, w *WriteOptions) (*WriteMeta, error) { func (conf *ConfigEntries) Delete(kind string, name string, w *WriteOptions) (*WriteMeta, error) {
_, wm, err := conf.delete(kind, name, nil, w)
return wm, err
}
// DeleteCAS performs a Check-And-Set deletion of the given config entry, and
// returns true if it was successful. If the provided index no longer matches
// the entry's ModifyIndex (i.e. it was modified by another process) then the
// operation will fail and return false.
func (conf *ConfigEntries) DeleteCAS(kind, name string, index uint64, w *WriteOptions) (bool, *WriteMeta, error) {
return conf.delete(kind, name, map[string]string{"cas": strconv.FormatUint(index, 10)}, w)
}
func (conf *ConfigEntries) delete(kind, name string, params map[string]string, w *WriteOptions) (bool, *WriteMeta, error) {
if kind == "" || name == "" { if kind == "" || name == "" {
return nil, fmt.Errorf("Both kind and name parameters must not be empty") return false, nil, fmt.Errorf("Both kind and name parameters must not be empty")
} }
r := conf.c.newRequest("DELETE", fmt.Sprintf("/v1/config/%s/%s", kind, name)) r := conf.c.newRequest("DELETE", fmt.Sprintf("/v1/config/%s/%s", kind, name))
r.setWriteOptions(w) r.setWriteOptions(w)
for param, value := range params {
r.params.Set(param, value)
}
rtt, resp, err := conf.c.doRequest(r) rtt, resp, err := conf.c.doRequest(r)
if err != nil { if err != nil {
return nil, err return false, nil, err
} }
defer closeResponseBody(resp) defer closeResponseBody(resp)
if err := requireOK(resp); err != nil { if err := requireOK(resp); err != nil {
return nil, err return false, nil, err
} }
var buf bytes.Buffer
if _, err := io.Copy(&buf, resp.Body); err != nil {
return false, nil, fmt.Errorf("Failed to read response: %v", err)
}
res := strings.Contains(buf.String(), "true")
wm := &WriteMeta{RequestTime: rtt} wm := &WriteMeta{RequestTime: rtt}
return wm, nil return res, wm, nil
} }

View File

@ -249,6 +249,37 @@ func TestAPI_ConfigEntries(t *testing.T) {
}) })
}) })
t.Run("CAS deletion", func(t *testing.T) {
require := require.New(t)
entry := &ProxyConfigEntry{
Kind: ProxyDefaults,
Name: ProxyConfigGlobal,
Config: map[string]interface{}{
"foo": "bar",
},
}
// Create a config entry.
created, _, err := config_entries.Set(entry, nil)
require.NoError(err)
require.True(created, "entry should have been created")
// Read it back to get the ModifyIndex.
result, _, err := config_entries.Get(entry.Kind, entry.Name, nil)
require.NoError(err)
require.NotNil(entry)
// Attempt a deletion with an invalid index.
deleted, _, err := config_entries.DeleteCAS(entry.Kind, entry.Name, result.GetModifyIndex()-1, nil)
require.NoError(err)
require.False(deleted, "entry should not have been deleted")
// Attempt a deletion with a valid index.
deleted, _, err = config_entries.DeleteCAS(entry.Kind, entry.Name, result.GetModifyIndex(), nil)
require.NoError(err)
require.True(deleted, "entry should have been deleted")
})
} }
func runStep(t *testing.T, name string, fn func(t *testing.T)) { func runStep(t *testing.T, name string, fn func(t *testing.T)) {

View File

@ -1,6 +1,7 @@
package delete package delete
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
@ -20,14 +21,23 @@ type cmd struct {
http *flags.HTTPFlags http *flags.HTTPFlags
help string help string
kind string kind string
name string name string
cas bool
modifyIndex uint64
} }
func (c *cmd) init() { func (c *cmd) init() {
c.flags = flag.NewFlagSet("", flag.ContinueOnError) c.flags = flag.NewFlagSet("", flag.ContinueOnError)
c.flags.StringVar(&c.kind, "kind", "", "The kind of configuration to delete.") c.flags.StringVar(&c.kind, "kind", "", "The kind of configuration to delete.")
c.flags.StringVar(&c.name, "name", "", "The name of configuration to delete.") c.flags.StringVar(&c.name, "name", "", "The name of configuration to delete.")
c.flags.BoolVar(&c.cas, "cas", false,
"Perform a Check-And-Set operation. Specifying this value also "+
"requires the -modify-index flag to be set. The default value "+
"is false.")
c.flags.Uint64Var(&c.modifyIndex, "modify-index", 0,
"Unsigned integer representing the ModifyIndex of the config entry. "+
"This is used in combination with the -cas flag.")
c.http = &flags.HTTPFlags{} c.http = &flags.HTTPFlags{}
flags.Merge(c.flags, c.http.ClientFlags()) flags.Merge(c.flags, c.http.ClientFlags())
flags.Merge(c.flags, c.http.ServerFlags()) flags.Merge(c.flags, c.http.ServerFlags())
@ -40,13 +50,8 @@ func (c *cmd) Run(args []string) int {
return 1 return 1
} }
if c.kind == "" { if err := c.validateArgs(); err != nil {
c.UI.Error("Must specify the -kind parameter") c.UI.Error(err.Error())
return 1
}
if c.name == "" {
c.UI.Error("Must specify the -name parameter")
return 1 return 1
} }
@ -55,17 +60,50 @@ func (c *cmd) Run(args []string) int {
c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err))
return 1 return 1
} }
entries := client.ConfigEntries()
var deleted bool
if c.cas {
deleted, _, err = entries.DeleteCAS(c.kind, c.name, c.modifyIndex, nil)
} else {
_, err = entries.Delete(c.kind, c.name, nil)
deleted = err == nil
}
_, err = client.ConfigEntries().Delete(c.kind, c.name, nil)
if err != nil { if err != nil {
c.UI.Error(fmt.Sprintf("Error deleting config entry %s/%s: %v", c.kind, c.name, err)) c.UI.Error(fmt.Sprintf("Error deleting config entry %s/%s: %v", c.kind, c.name, err))
return 1 return 1
} }
if !deleted {
c.UI.Error(fmt.Sprintf("Config entry not deleted: %s/%s", c.kind, c.name))
return 1
}
c.UI.Info(fmt.Sprintf("Config entry deleted: %s/%s", c.kind, c.name)) c.UI.Info(fmt.Sprintf("Config entry deleted: %s/%s", c.kind, c.name))
return 0 return 0
} }
func (c *cmd) validateArgs() error {
if c.kind == "" {
return errors.New("Must specify the -kind parameter")
}
if c.name == "" {
return errors.New("Must specify the -name parameter")
}
if c.cas && c.modifyIndex == 0 {
return errors.New("Must specify a -modify-index greater than 0 with -cas")
}
if c.modifyIndex != 0 && !c.cas {
return errors.New("Cannot specify -modify-index without -cas")
}
return nil
}
func (c *cmd) Synopsis() string { func (c *cmd) Synopsis() string {
return synopsis return synopsis
} }

View File

@ -1,6 +1,7 @@
package delete package delete
import ( import (
"strconv"
"testing" "testing"
"github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/agent"
@ -53,12 +54,97 @@ func TestConfigDelete(t *testing.T) {
require.Nil(t, entry) require.Nil(t, entry)
} }
func TestConfigDelete_CAS(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}
t.Parallel()
a := agent.NewTestAgent(t, ``)
defer a.Shutdown()
client := a.Client()
_, _, err := client.ConfigEntries().Set(&api.ServiceConfigEntry{
Kind: api.ServiceDefaults,
Name: "web",
Protocol: "tcp",
}, nil)
require.NoError(t, err)
entry, _, err := client.ConfigEntries().Get(api.ServiceDefaults, "web", nil)
require.NoError(t, err)
t.Run("with an invalid modify index", func(t *testing.T) {
ui := cli.NewMockUi()
c := New(ui)
args := []string{
"-http-addr=" + a.HTTPAddr(),
"-kind=" + api.ServiceDefaults,
"-name=web",
"-cas",
"-modify-index=" + strconv.FormatUint(entry.GetModifyIndex()-1, 10),
}
code := c.Run(args)
require.Equal(t, 1, code)
require.Contains(t, ui.ErrorWriter.String(),
"Config entry not deleted: service-defaults/web")
require.Empty(t, ui.OutputWriter.String())
})
t.Run("with a valid modify index", func(t *testing.T) {
ui := cli.NewMockUi()
c := New(ui)
args := []string{
"-http-addr=" + a.HTTPAddr(),
"-kind=" + api.ServiceDefaults,
"-name=web",
"-cas",
"-modify-index=" + strconv.FormatUint(entry.GetModifyIndex(), 10),
}
code := c.Run(args)
require.Equal(t, 0, code)
require.Contains(t, ui.OutputWriter.String(),
"Config entry deleted: service-defaults/web")
require.Empty(t, ui.ErrorWriter.String())
entry, _, err := client.ConfigEntries().Get(api.ServiceDefaults, "web", nil)
require.Error(t, err)
require.Nil(t, entry)
})
}
func TestConfigDelete_InvalidArgs(t *testing.T) { func TestConfigDelete_InvalidArgs(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[string][]string{ cases := map[string]struct {
"no kind": {}, args []string
"no name": {"-kind", "service-defaults"}, err string
}{
"no kind": {
args: []string{},
err: "Must specify the -kind parameter",
},
"no name": {
args: []string{"-kind", api.ServiceDefaults},
err: "Must specify the -name parameter",
},
"cas but no modify-index": {
args: []string{"-kind", api.ServiceDefaults, "-name", "web", "-cas"},
err: "Must specify a -modify-index greater than 0 with -cas",
},
"cas but no zero modify-index": {
args: []string{"-kind", api.ServiceDefaults, "-name", "web", "-cas", "-modify-index", "0"},
err: "Must specify a -modify-index greater than 0 with -cas",
},
"modify-index but no cas": {
args: []string{"-kind", api.ServiceDefaults, "-name", "web", "-modify-index", "1"},
err: "Cannot specify -modify-index without -cas",
},
} }
for name, tcase := range cases { for name, tcase := range cases {
@ -66,8 +152,8 @@ func TestConfigDelete_InvalidArgs(t *testing.T) {
ui := cli.NewMockUi() ui := cli.NewMockUi()
c := New(ui) c := New(ui)
require.NotEqual(t, 0, c.Run(tcase)) require.NotEqual(t, 0, c.Run(tcase.args))
require.NotEmpty(t, ui.ErrorWriter.String()) require.Contains(t, ui.ErrorWriter.String(), tcase.err)
}) })
} }
} }

View File

@ -182,7 +182,7 @@ func WaitForServiceIntentions(t *testing.T, rpc rpcFn, dc string) {
Name: fakeConfigName, Name: fakeConfigName,
}, },
} }
var ignored struct{} var ignored structs.ConfigEntryDeleteResponse
if err := rpc("ConfigEntry.Delete", args, &ignored); err != nil { if err := rpc("ConfigEntry.Delete", args, &ignored); err != nil {
r.Fatalf("err: %v", err) r.Fatalf("err: %v", err)
} }

View File

@ -276,6 +276,12 @@ The table below shows this endpoint's support for
`X-Consul-Namespace` header. If not provided, the namespace will be inherited `X-Consul-Namespace` header. If not provided, the namespace will be inherited
from the request's ACL token or will default to the `default` namespace. Added in Consul 1.7.0. from the request's ACL token or will default to the `default` namespace. Added in Consul 1.7.0.
- `cas` `(int: 0)` - Specifies to use a Check-And-Set operation. Unlike `PUT`,
the index must be greater than 0 for Consul to take any action: a 0 index will
not delete the config entry. If the index is non-zero, the config entry is only
deleted if the index matches the `ModifyIndex` of that config entry. This is
specified as part of the URL as a query parameter.
### Sample Request ### Sample Request
```shell-session ```shell-session

View File

@ -31,6 +31,14 @@ Usage: `consul config delete [options]`
`proxy-defaults` config entry must be `global`, and the name of the `mesh` `proxy-defaults` config entry must be `global`, and the name of the `mesh`
config entry must be `mesh`. config entry must be `mesh`.
- `-cas` - Perform a Check-And-Set operation. Specifying this value also
requires the -modify-index flag to be set. The default value is false.
- `-modify-index=<int>` - Unsigned integer representing the ModifyIndex of the
config entry. This is used in combination with the -cas flag.
## Examples ## Examples
$ consul config delete -kind service-defaults -name web $ consul config delete -kind service-defaults -name web
$ consul config delete -kind service-defaults -name web -cas -modify-index 26