Skip to content

Commit 44c23b0

Browse files
authored
Drop troubleshoot.sh/v1beta3 docs from release files (#6009)
Kots has no support for troubleshoot.sh/v1beta3 specs, so an EC v3 release packaging both a v1beta2 and v1beta3 doc (dual preflight) would silently have the v1beta3 doc misinterpreted as v1beta2 and executed. Detect v1beta3 docs by matching the apiVersion header line directly rather than requiring the whole doc to parse as valid yaml, since a doc can embed unrendered template syntax (e.g. Helm's "{{ .Values.x }}") that breaks yaml parsing further down while the header stays literal text. Packaged Helm chart archives (.tgz/.tar.gz) are left untouched, since they're binary content, not yaml. Signed-off-by: Evans Mungai <evans@replicated.com>
1 parent 089c73a commit 44c23b0

6 files changed

Lines changed: 243 additions & 2 deletions

File tree

pkg/kotsutil/kots.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path"
1313
"path/filepath"
14+
"regexp"
1415
"strconv"
1516
"strings"
1617
"time"
@@ -912,6 +913,39 @@ func IsApiVersionKind(content []byte, apiVersion, kind string) bool {
912913
return false
913914
}
914915

916+
var v1Beta3APIVersionRe = regexp.MustCompile(`(?m)^apiVersion:[ \t]*['"]?troubleshoot\.sh/v1beta3['"]?[ \t]*(#.*)?$`)
917+
918+
// IsV1Beta3Doc reports whether doc is an unsupported troubleshoot.sh/v1beta3
919+
// doc. Matches the apiVersion header line directly, so it still works on docs
920+
// with unrendered template syntax (e.g. Helm's "{{ .Values.x }}") further down.
921+
func IsV1Beta3Doc(doc []byte) bool {
922+
return v1Beta3APIVersionRe.Match(doc)
923+
}
924+
925+
// FilterOutV1Beta3Docs removes v1beta3 documents from content (a possibly
926+
// multi-doc yaml file). Returns nil if nothing remains.
927+
func FilterOutV1Beta3Docs(content []byte) []byte {
928+
docs := util.ConvertToSingleDocs(content)
929+
930+
kept := make([][]byte, 0, len(docs))
931+
changed := false
932+
for _, doc := range docs {
933+
if IsV1Beta3Doc(doc) {
934+
changed = true
935+
continue
936+
}
937+
kept = append(kept, doc)
938+
}
939+
940+
if !changed {
941+
return content
942+
}
943+
if len(kept) == 0 {
944+
return nil
945+
}
946+
return bytes.Join(kept, []byte("\n---\n"))
947+
}
948+
915949
func LoadInstallationFromPath(installationFilePath string) (*kotsv1beta1.Installation, error) {
916950
installationData, err := os.ReadFile(installationFilePath)
917951
if err != nil {

pkg/kotsutil/kots_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,71 @@ var _ = Describe("Kots", func() {
730730
})
731731
})
732732

733+
func TestIsV1Beta3Doc(t *testing.T) {
734+
tests := []struct {
735+
name string
736+
doc []byte
737+
want bool
738+
}{
739+
{
740+
name: "v1beta2 preflight",
741+
doc: []byte("apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\n"),
742+
want: false,
743+
},
744+
{
745+
name: "v1beta3 preflight",
746+
doc: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n"),
747+
want: true,
748+
},
749+
{
750+
name: "v1beta3 host preflight",
751+
doc: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: HostPreflight\n"),
752+
want: true,
753+
},
754+
{
755+
name: "v1beta3 doc, any other kind",
756+
doc: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: SupportBundle\n"),
757+
want: true,
758+
},
759+
{
760+
name: "v1beta3 with quoted apiVersion",
761+
doc: []byte("apiVersion: \"troubleshoot.sh/v1beta3\"\nkind: 'Preflight'\n"),
762+
want: true,
763+
},
764+
{
765+
name: "v1beta3 doc whose spec breaks yaml parsing via unrendered helm template syntax",
766+
doc: []byte(
767+
"apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\nmetadata:\n name: vendorflow\nspec:\n" +
768+
" collectors:\n {{- if eq .Chart.Name \"vendorflow\" }}\n - clusterInfo: {}\n {{- end }}\n",
769+
),
770+
want: true,
771+
},
772+
}
773+
for _, tt := range tests {
774+
t.Run(tt.name, func(t *testing.T) {
775+
assert.Equal(t, tt.want, kotsutil.IsV1Beta3Doc(tt.doc))
776+
})
777+
}
778+
}
779+
780+
func TestFilterOutV1Beta3Docs(t *testing.T) {
781+
content := []byte(
782+
"apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\nmetadata:\n name: v2\n" +
783+
"\n---\n" +
784+
"apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\nmetadata:\n name: v3\n" +
785+
"\n---\n" +
786+
"apiVersion: troubleshoot.sh/v1beta3\nkind: HostPreflight\nmetadata:\n name: hp3\n",
787+
)
788+
789+
filtered := kotsutil.FilterOutV1Beta3Docs(content)
790+
assert.Equal(t, "apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\nmetadata:\n name: v2\n", string(filtered))
791+
792+
assert.Nil(t, kotsutil.FilterOutV1Beta3Docs([]byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n")))
793+
794+
unchanged := []byte("apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\n")
795+
assert.Equal(t, unchanged, kotsutil.FilterOutV1Beta3Docs(unchanged))
796+
}
797+
733798
func TestIsKotsKind(t *testing.T) {
734799
type args struct {
735800
apiVersion string

pkg/upstream/replicated.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@ func readReplicatedAppFromLocalPath(localPath string, localCursor replicatedapp.
350350
appPath := strings.TrimPrefix(path, localPath)
351351
appPath = strings.TrimLeft(appPath, string(os.PathSeparator))
352352

353-
release.Manifests[appPath] = contents
353+
filtered, ok := filterV1Beta3(appPath, contents)
354+
if !ok {
355+
return nil
356+
}
357+
release.Manifests[appPath] = filtered
354358

355359
return nil
356360
})
@@ -465,7 +469,11 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream,
465469
return nil, errors.Wrap(err, "failed to read file from tar")
466470
}
467471

468-
release.Manifests[name] = content
472+
filtered, ok := filterV1Beta3(name, content)
473+
if !ok {
474+
continue
475+
}
476+
release.Manifests[name] = filtered
469477
}
470478

471479
i++
@@ -856,6 +864,25 @@ func findAppInRelease(release *Release) *kotsv1beta1.Application {
856864
return app
857865
}
858866

867+
// filterV1Beta3 drops unsupported troubleshoot.sh/v1beta3 docs from content.
868+
// ok is false when nothing is left to keep. Helm chart archives (.tgz/.tar.gz)
869+
// are skipped, since they're binary, not yaml.
870+
func filterV1Beta3(filename string, content []byte) (filtered []byte, ok bool) {
871+
if strings.HasSuffix(filename, ".tgz") || strings.HasSuffix(filename, ".tar.gz") {
872+
return content, true
873+
}
874+
875+
filtered = kotsutil.FilterOutV1Beta3Docs(content)
876+
if filtered == nil {
877+
logger.Infof("skipping v1beta3 file from release: %s", filename)
878+
return nil, false
879+
}
880+
if !bytes.Equal(filtered, content) {
881+
logger.Infof("removed v1beta3 doc(s) from release file: %s", filename)
882+
}
883+
return filtered, true
884+
}
885+
859886
func releaseToFiles(release *Release) ([]types.UpstreamFile, error) {
860887
upstreamFiles := []types.UpstreamFile{}
861888

pkg/upstream/replicated_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,66 @@ func Test_releaseToFiles(t *testing.T) {
105105
}
106106
}
107107

108+
func Test_filterV1Beta3(t *testing.T) {
109+
tests := []struct {
110+
name string
111+
filename string
112+
content []byte
113+
wantOk bool
114+
wantContent []byte
115+
}{
116+
{
117+
name: "not a v1beta3 doc",
118+
filename: "manifests/deployment.yaml",
119+
content: []byte("a: b"),
120+
wantOk: true,
121+
wantContent: []byte("a: b"),
122+
},
123+
{
124+
name: "v1beta3 doc, any kind, is dropped entirely",
125+
filename: "manifests/preflight.yaml",
126+
content: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n"),
127+
wantOk: false,
128+
wantContent: nil,
129+
},
130+
{
131+
name: "only the v1beta3 doc is stripped from a multi-doc file mixing v1beta2 and v1beta3",
132+
filename: "manifests/preflight.yaml",
133+
content: []byte(
134+
"apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\nmetadata:\n name: v2\n" +
135+
"\n---\n" +
136+
"apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\nmetadata:\n name: v3\n",
137+
),
138+
wantOk: true,
139+
wantContent: []byte("apiVersion: troubleshoot.sh/v1beta2\nkind: Preflight\nmetadata:\n name: v2\n"),
140+
},
141+
{
142+
name: "v1beta3 doc is dropped even when a template breaks yaml parsing of the rest of the doc",
143+
filename: "manifests/preflight.yaml",
144+
content: []byte(
145+
"apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\nmetadata:\n name: vendorflow\nspec:\n" +
146+
" collectors:\n {{- if eq .Chart.Name \"vendorflow\" }}\n - clusterInfo: {}\n {{- end }}\n",
147+
),
148+
wantOk: false,
149+
wantContent: nil,
150+
},
151+
{
152+
name: "packaged helm chart archives are left untouched",
153+
filename: "manifests/vendorflow-0.1.0.tgz",
154+
content: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n"), // not real tgz content, but proves content is never inspected
155+
wantOk: true,
156+
wantContent: []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n"),
157+
},
158+
}
159+
for _, tt := range tests {
160+
t.Run(tt.name, func(t *testing.T) {
161+
content, ok := filterV1Beta3(tt.filename, tt.content)
162+
require.Equal(t, tt.wantOk, ok)
163+
require.Equal(t, tt.wantContent, content)
164+
})
165+
}
166+
}
167+
108168
func Test_createConfigValues(t *testing.T) {
109169
applicationName := "Test App"
110170
appInfo := &template.ApplicationInfo{Slug: "app-slug"}
@@ -735,6 +795,42 @@ spec:
735795
}
736796
})
737797

798+
t.Run("drops v1beta3 docs from the release's manifests", func(t *testing.T) {
799+
v1beta3Files := map[string][]byte{
800+
"manifests/app.yaml": testFiles["manifests/app.yaml"],
801+
"manifests/preflight.yaml": []byte("apiVersion: troubleshoot.sh/v1beta3\nkind: Preflight\n"),
802+
}
803+
804+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
805+
var buf bytes.Buffer
806+
gzipWriter := gzip.NewWriter(&buf)
807+
tarWriter := tar.NewWriter(gzipWriter)
808+
809+
for name, content := range v1beta3Files {
810+
header := &tar.Header{Name: name, Mode: 0600, Size: int64(len(content))}
811+
require.NoError(t, tarWriter.WriteHeader(header))
812+
_, err := tarWriter.Write(content)
813+
require.NoError(t, err)
814+
}
815+
816+
require.NoError(t, tarWriter.Close())
817+
require.NoError(t, gzipWriter.Close())
818+
819+
w.WriteHeader(http.StatusOK)
820+
w.Write(buf.Bytes())
821+
}))
822+
defer server.Close()
823+
824+
license.V1.Spec.Endpoint = server.URL
825+
826+
release, err := downloadReplicatedApp(upstr, license, cursor, nil, "")
827+
require.NoError(t, err)
828+
829+
_, ok := release.Manifests["manifests/preflight.yaml"]
830+
require.False(t, ok, "expected the v1beta3 preflight to be dropped from the release's manifests")
831+
require.Equal(t, testFiles["manifests/app.yaml"], release.Manifests["manifests/app.yaml"])
832+
})
833+
738834
// Test error cases
739835
t.Run("HTTP error", func(t *testing.T) {
740836
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

pkg/util/util.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ import (
1010
"net/url"
1111
"os"
1212
"path/filepath"
13+
"regexp"
1314

1415
"github.com/pkg/errors"
1516
"github.com/replicatedhq/kots/pkg/crypto"
1617
"github.com/replicatedhq/kotskinds/pkg/licensewrapper"
1718
)
1819

20+
// yamlDocSeparatorLine matches a "---" yaml document separator that has
21+
// trailing whitespace or a trailing comment on the same line (both valid per
22+
// the yaml spec), so it can be normalized to a bare "---" before splitting.
23+
var yamlDocSeparatorLine = regexp.MustCompile(`(?m)^---[ \t]*(#.*)?$`)
24+
1925
func IsURL(str string) bool {
2026
_, err := url.ParseRequestURI(str)
2127
if err != nil {
@@ -127,6 +133,9 @@ func ConvertToSingleDocs(doc []byte) [][]byte {
127133
singleDocs := [][]byte{}
128134
// replace all windows line endings with unix line endings
129135
doc = bytes.ReplaceAll(doc, []byte("\r\n"), []byte("\n"))
136+
// normalize separators with trailing whitespace or a comment (e.g. "--- " or
137+
// "--- # comment") to a bare "---" so they're recognized as document boundaries
138+
doc = yamlDocSeparatorLine.ReplaceAll(doc, []byte("---"))
130139
docs := bytes.Split(doc, []byte("\n---\n"))
131140
for _, doc := range docs {
132141
if len(bytes.TrimSpace(doc)) == 0 {

pkg/util/util_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,16 @@ func TestConvertToSingleDocs(t *testing.T) {
318318
doc: []byte("abc\r\n---\r\n\r\n---\r\ndef"),
319319
want: [][]byte{[]byte("abc"), []byte("def")},
320320
},
321+
{
322+
name: "multiple docs with trailing whitespace after separator",
323+
doc: []byte("abc\n--- \ndef"),
324+
want: [][]byte{[]byte("abc"), []byte("def")},
325+
},
326+
{
327+
name: "multiple docs with comment after separator",
328+
doc: []byte("abc\n--- # comment\ndef"),
329+
want: [][]byte{[]byte("abc"), []byte("def")},
330+
},
321331
}
322332
for _, tt := range tests {
323333
t.Run(tt.name, func(t *testing.T) {

0 commit comments

Comments
 (0)