Skip to content

Commit fa88107

Browse files
authored
Fix installflags change detection (#784)
* Fix installFlags change detection Signed-off-by: Kimmo Lehto <[email protected]> * Add tests Signed-off-by: Kimmo Lehto <[email protected]> --------- Signed-off-by: Kimmo Lehto <[email protected]>
1 parent 81260d3 commit fa88107

File tree

9 files changed

+417
-164
lines changed

9 files changed

+417
-164
lines changed

internal/shell/split.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package shell
2+
3+
// this is borrowed as-is from rig v2 until k0sctl is updated to use it
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// Split splits the input string respecting shell-like quoted segments.
11+
func Split(input string) ([]string, error) { //nolint:cyclop
12+
var segments []string
13+
14+
currentSegment, ok := builderPool.Get().(*strings.Builder)
15+
if !ok {
16+
currentSegment = &strings.Builder{}
17+
}
18+
defer builderPool.Put(currentSegment)
19+
defer currentSegment.Reset()
20+
21+
var inDoubleQuotes, inSingleQuotes, isEscaped bool
22+
23+
for i := range len(input) {
24+
currentChar := input[i]
25+
26+
if isEscaped {
27+
currentSegment.WriteByte(currentChar)
28+
isEscaped = false
29+
continue
30+
}
31+
32+
switch {
33+
case currentChar == '\\' && !inSingleQuotes:
34+
isEscaped = true
35+
case currentChar == '"' && !inSingleQuotes:
36+
inDoubleQuotes = !inDoubleQuotes
37+
case currentChar == '\'' && !inDoubleQuotes:
38+
inSingleQuotes = !inSingleQuotes
39+
case currentChar == ' ' && !inDoubleQuotes && !inSingleQuotes:
40+
// Space outside quotes; delimiter for a new segment
41+
segments = append(segments, currentSegment.String())
42+
currentSegment.Reset()
43+
default:
44+
currentSegment.WriteByte(currentChar)
45+
}
46+
}
47+
48+
if inDoubleQuotes || inSingleQuotes {
49+
return nil, fmt.Errorf("split `%q`: %w", input, ErrMismatchedQuotes)
50+
}
51+
52+
if isEscaped {
53+
return nil, fmt.Errorf("split `%q`: %w", input, ErrTrailingBackslash)
54+
}
55+
56+
// Add the last segment if present
57+
if currentSegment.Len() > 0 {
58+
segments = append(segments, currentSegment.String())
59+
}
60+
61+
return segments, nil
62+
}

internal/shell/unquote.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package shell
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"sync"
8+
)
9+
10+
// This is borrowed from rig v2 until k0sctl is updated to use it
11+
12+
var (
13+
builderPool = sync.Pool{
14+
New: func() interface{} {
15+
return &strings.Builder{}
16+
},
17+
}
18+
19+
// ErrMismatchedQuotes is returned when the input string has mismatched quotes when unquoting.
20+
ErrMismatchedQuotes = errors.New("mismatched quotes")
21+
22+
// ErrTrailingBackslash is returned when the input string ends with a trailing backslash.
23+
ErrTrailingBackslash = errors.New("trailing backslash")
24+
)
25+
26+
// Unquote is a mostly POSIX compliant implementation of unquoting a string the same way a shell would.
27+
// Variables and command substitutions are not handled.
28+
func Unquote(input string) (string, error) { //nolint:cyclop
29+
sb, ok := builderPool.Get().(*strings.Builder)
30+
if !ok {
31+
sb = &strings.Builder{}
32+
}
33+
defer builderPool.Put(sb)
34+
defer sb.Reset()
35+
36+
var inDoubleQuotes, inSingleQuotes, isEscaped bool
37+
38+
for i := range len(input) {
39+
currentChar := input[i]
40+
41+
if isEscaped {
42+
sb.WriteByte(currentChar)
43+
isEscaped = false
44+
continue
45+
}
46+
47+
switch currentChar {
48+
case '\\':
49+
if !inSingleQuotes { // Escape works in double quotes or outside any quotes
50+
isEscaped = true
51+
} else {
52+
sb.WriteByte(currentChar) // Treat as a regular character within single quotes
53+
}
54+
case '"':
55+
if !inSingleQuotes { // Toggle double quotes only if not in single quotes
56+
inDoubleQuotes = !inDoubleQuotes
57+
} else {
58+
sb.WriteByte(currentChar) // Treat as a regular character within single quotes
59+
}
60+
case '\'':
61+
if !inDoubleQuotes { // Toggle single quotes only if not in double quotes
62+
inSingleQuotes = !inSingleQuotes
63+
} else {
64+
sb.WriteByte(currentChar) // Treat as a regular character within double quotes
65+
}
66+
default:
67+
sb.WriteByte(currentChar)
68+
}
69+
}
70+
71+
if inDoubleQuotes || inSingleQuotes {
72+
return "", fmt.Errorf("unquote `%q`: %w", input, ErrMismatchedQuotes)
73+
}
74+
75+
if isEscaped {
76+
return "", fmt.Errorf("unquote `%q`: %w", input, ErrTrailingBackslash)
77+
}
78+
79+
return sb.String(), nil
80+
}

internal/shell/unquote_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package shell_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/k0sproject/k0sctl/internal/shell"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestUnquote(t *testing.T) {
11+
t.Run("no quotes", func(t *testing.T) {
12+
out, err := shell.Unquote("foo bar")
13+
require.NoError(t, err)
14+
require.Equal(t, "foo bar", out)
15+
})
16+
17+
t.Run("simple quotes", func(t *testing.T) {
18+
out, err := shell.Unquote("\"foo\" 'bar'")
19+
require.NoError(t, err)
20+
require.Equal(t, "foo bar", out)
21+
})
22+
23+
t.Run("mid-word quotes", func(t *testing.T) {
24+
out, err := shell.Unquote("f\"o\"o b'a'r")
25+
require.NoError(t, err)
26+
require.Equal(t, "foo bar", out)
27+
})
28+
29+
t.Run("complex quotes", func(t *testing.T) {
30+
out, err := shell.Unquote(`'"'"'foo'"'"'`)
31+
require.NoError(t, err)
32+
require.Equal(t, `"'foo'"`, out)
33+
})
34+
35+
t.Run("escaped quotes", func(t *testing.T) {
36+
out, err := shell.Unquote("\\'foo\\' 'bar'")
37+
require.NoError(t, err)
38+
require.Equal(t, "'foo' bar", out)
39+
})
40+
}

phase/gather_k0s_facts.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,14 @@ func (p *GatherK0sFacts) investigateK0s(h *cluster.Host) error {
272272

273273
h.Metadata.NeedsUpgrade = p.needsUpgrade(h)
274274

275+
var args cluster.Flags
275276
if len(status.Args) > 2 {
276277
// status.Args contains the binary path and the role as the first two elements, which we can ignore here.
277-
h.Metadata.K0sStatusArgs = status.Args[2:]
278+
for _, a := range status.Args[2:] {
279+
args.Add(a)
280+
}
278281
}
282+
h.Metadata.K0sStatusArgs = args
279283

280284
log.Infof("%s: is running k0s %s version %s", h, h.Role, h.Metadata.K0sRunningVersion)
281285
if h.IsController() {

phase/reinstall.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (p *Reinstall) reinstall(h *cluster.Host) error {
7777
h.InstallFlags.AddOrReplace("--enable-dynamic-config")
7878
}
7979

80-
h.InstallFlags.AddOrReplace("--force")
80+
h.InstallFlags.AddOrReplace("--force=true")
8181

8282
cmd, err := h.K0sInstallCommand()
8383
if err != nil {

pkg/apis/k0sctl.k0sproject.io/v1beta1/cluster/flags.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,55 @@
11
package cluster
22

33
import (
4+
"fmt"
45
"strconv"
56
"strings"
7+
8+
"github.com/alessio/shellescape"
9+
"github.com/k0sproject/k0sctl/internal/shell"
610
)
711

812
// Flags is a slice of strings with added functions to ease manipulating lists of command-line flags
913
type Flags []string
1014

1115
// Add adds a flag regardless if it exists already or not
1216
func (f *Flags) Add(s string) {
17+
if ns, err := shell.Unquote(s); err == nil {
18+
s = ns
19+
}
1320
*f = append(*f, s)
1421
}
1522

1623
// Add a flag with a value
1724
func (f *Flags) AddWithValue(key, value string) {
18-
*f = append(*f, key+" "+value)
25+
if nv, err := shell.Unquote(value); err == nil {
26+
value = nv
27+
}
28+
*f = append(*f, key+"="+value)
1929
}
2030

2131
// AddUnlessExist adds a flag unless one with the same prefix exists
2232
func (f *Flags) AddUnlessExist(s string) {
33+
if ns, err := shell.Unquote(s); err == nil {
34+
s = ns
35+
}
2336
if f.Include(s) {
2437
return
2538
}
26-
*f = append(*f, s)
39+
f.Add(s)
2740
}
2841

2942
// AddOrReplace replaces a flag with the same prefix or adds a new one if one does not exist
3043
func (f *Flags) AddOrReplace(s string) {
44+
if ns, err := shell.Unquote(s); err == nil {
45+
s = ns
46+
}
3147
idx := f.Index(s)
3248
if idx > -1 {
3349
(*f)[idx] = s
3450
return
3551
}
36-
*f = append(*f, s)
52+
f.Add(s)
3753
}
3854

3955
// Include returns true if a flag with a matching prefix can be found
@@ -43,6 +59,9 @@ func (f Flags) Include(s string) bool {
4359

4460
// Index returns an index to a flag with a matching prefix
4561
func (f Flags) Index(s string) int {
62+
if ns, err := shell.Unquote(s); err == nil {
63+
s = ns
64+
}
4665
var flag string
4766
sepidx := strings.IndexAny(s, "= ")
4867
if sepidx < 0 {
@@ -73,17 +92,16 @@ func (f Flags) GetValue(s string) string {
7392
if fl == "" {
7493
return ""
7594
}
95+
if nfl, err := shell.Unquote(fl); err == nil {
96+
fl = nfl
97+
}
7698

7799
idx := strings.IndexAny(fl, "= ")
78100
if idx < 0 {
79101
return ""
80102
}
81103

82104
val := fl[idx+1:]
83-
s, err := strconv.Unquote(val)
84-
if err == nil {
85-
return s
86-
}
87105

88106
return val
89107
}
@@ -137,5 +155,59 @@ func (f *Flags) MergeAdd(b Flags) {
137155

138156
// Join creates a string separated by spaces
139157
func (f *Flags) Join() string {
140-
return strings.Join(*f, " ")
158+
var parts []string
159+
f.Each(func(k, v string) {
160+
if v == "" && k != "" {
161+
parts = append(parts, shellescape.Quote(k))
162+
} else {
163+
parts = append(parts, fmt.Sprintf("%s=%s", k, shellescape.Quote(v)))
164+
}
165+
})
166+
return strings.Join(parts, " ")
167+
}
168+
169+
// Each iterates over each flag and calls the function with the flag key and value as arguments
170+
func (f Flags) Each(fn func(string, string)) {
171+
for _, flag := range f {
172+
sepidx := strings.IndexAny(flag, "= ")
173+
if sepidx < 0 {
174+
if flag == "" {
175+
continue
176+
}
177+
fn(flag, "")
178+
} else {
179+
key, value := flag[:sepidx], flag[sepidx+1:]
180+
if unq, err := shell.Unquote(value); err == nil {
181+
value = unq
182+
}
183+
fn(key, value)
184+
}
185+
}
186+
}
187+
188+
// Map returns a map[string]string of the flags where the key is the flag and the value is the value
189+
func (f Flags) Map() map[string]string {
190+
res := make(map[string]string)
191+
f.Each(func(k, v string) {
192+
res[k] = v
193+
})
194+
return res
195+
}
196+
197+
// Equals compares the flags with another Flags and returns true if they have the same flags and values, ignoring order
198+
func (f Flags) Equals(b Flags) bool {
199+
if len(f) != len(b) {
200+
return false
201+
}
202+
for _, flag := range f {
203+
if !b.Include(flag) {
204+
return false
205+
}
206+
ourValue := f.GetValue(flag)
207+
theirValue := b.GetValue(flag)
208+
if ourValue != theirValue {
209+
return false
210+
}
211+
}
212+
return true
141213
}

0 commit comments

Comments
 (0)