mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-28 07:37:49 +09:00
branch-review on #3184 measured a real regression: switching CRD/ APIService discovery to content-based scanning made every file in packages/+internal a candidate, and each was split into documents and fully unmarshaled twice — once per parser — inflating a full snapshot load from ~0.5s to ~3.4s. Split each file's documents once and probe each document's kind once before deciding which shape to try, so the common case (a document that is neither a CRD nor an APIService — the overwhelming majority of what the wider walk now sees) pays for at most one parse attempt instead of two. Add a raw substring fast-reject, at both the per-document and whole-file level, ahead of any YAML parsing: neither kind this package cares about can appear without its own name appearing as a literal substring first. Also fixes two smaller gaps flagged in the same review: an untrusted PR head checkout could point discovery at an arbitrarily large file with no size guard (capped at 8 MiB), and a group's merged APIService resource only recorded the first contributing file's path, dropping the others from the report. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
356 lines
14 KiB
Go
356 lines
14 KiB
Go
/*
|
|
Copyright 2026 The Cozystack Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package apigate
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// appsGroup is the fixed API group served by the cozyrd ApplicationDefinitions.
|
|
const appsGroup = "apps.cozystack.io"
|
|
|
|
// ParseCozyRD parses an ApplicationDefinition (cozyrd) manifest into a single
|
|
// apps.cozystack.io Resource. The apps group is served at v1alpha1 only, and
|
|
// its per-resource schema lives in spec.application.openAPISchema as an
|
|
// embedded JSON string.
|
|
func ParseCozyRD(origin string, data []byte) (Resource, bool, error) {
|
|
var doc struct {
|
|
Kind string `json:"kind"`
|
|
Spec struct {
|
|
Application struct {
|
|
Kind string `json:"kind"`
|
|
Plural string `json:"plural"`
|
|
OpenAPISchema string `json:"openAPISchema"`
|
|
} `json:"application"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
|
return Resource{}, false, fmt.Errorf("%s: %w", origin, err)
|
|
}
|
|
if doc.Kind != "ApplicationDefinition" {
|
|
return Resource{}, false, nil
|
|
}
|
|
app := doc.Spec.Application
|
|
if app.Plural == "" {
|
|
return Resource{}, false, fmt.Errorf("%s: ApplicationDefinition has empty spec.application.plural", origin)
|
|
}
|
|
res := Resource{
|
|
Group: appsGroup,
|
|
Kind: app.Kind,
|
|
Plural: app.Plural,
|
|
Source: SourceCozyRD,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
}
|
|
if strings.TrimSpace(app.OpenAPISchema) != "" {
|
|
var sch Schema
|
|
if err := json.Unmarshal([]byte(app.OpenAPISchema), &sch); err != nil {
|
|
return Resource{}, false, fmt.Errorf("%s: spec.application.openAPISchema is not valid JSON: %w", origin, err)
|
|
}
|
|
res.Versions["v1alpha1"] = sch
|
|
}
|
|
return res, true, nil
|
|
}
|
|
|
|
// ParseCRDs parses a (possibly multi-document) CustomResourceDefinition YAML
|
|
// file into one Resource per CRD, keyed by spec.group + spec.names.plural with
|
|
// each served version's openAPIV3Schema. CRD discovery is content-based (any
|
|
// file under the walked roots, regardless of directory name — see
|
|
// discoverYAMLFiles), so a single file routinely mixes genuine CRD documents
|
|
// with unrendered Helm markup (a `{{- if }}` guard, an `{{ include }}` call)
|
|
// in neighboring documents of the same `---`-delimited stream. A document
|
|
// that fails to parse as YAML is therefore skipped on its own rather than
|
|
// aborting the whole file, so a real CRD sharing a file with templated
|
|
// documents is still found.
|
|
func ParseCRDs(origin string, data []byte) ([]Resource, error) {
|
|
var out []Resource
|
|
for i, docBytes := range splitYAMLDocs(data) {
|
|
if len(strings.TrimSpace(string(docBytes))) == 0 {
|
|
continue
|
|
}
|
|
res, ok, err := crdFromDoc(origin, i, docBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
out = append(out, res)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// crdFromDoc attempts to parse a single YAML document as a
|
|
// CustomResourceDefinition. ok is false whenever the document is not a CRD,
|
|
// including when it fails to parse at all; err is non-nil only when the
|
|
// document unambiguously claims to be a CRD (right kind) but is structurally
|
|
// incomplete. docKind is checked first so the fuller CRD-shaped unmarshal —
|
|
// and its cost — is paid only for documents that are actually CRDs, since
|
|
// LoadSnapshot's content-based walk runs this against every YAML file in the
|
|
// tree, the overwhelming majority of which are not CRDs at all.
|
|
func crdFromDoc(origin string, i int, docBytes []byte) (Resource, bool, error) {
|
|
if docKind(docBytes) != "CustomResourceDefinition" {
|
|
return Resource{}, false, nil
|
|
}
|
|
var doc struct {
|
|
Spec struct {
|
|
Group string `json:"group"`
|
|
Names struct {
|
|
Kind string `json:"kind"`
|
|
Plural string `json:"plural"`
|
|
} `json:"names"`
|
|
Versions []struct {
|
|
Name string `json:"name"`
|
|
Served bool `json:"served"`
|
|
Schema struct {
|
|
OpenAPIV3Schema Schema `json:"openAPIV3Schema"`
|
|
} `json:"schema"`
|
|
} `json:"versions"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := unmarshalYAMLDoc(docBytes, &doc); err != nil {
|
|
// docKind above already parsed this document successfully, so this
|
|
// would be surprising; tolerate it like any other unparseable
|
|
// document rather than erroring.
|
|
return Resource{}, false, nil
|
|
}
|
|
if doc.Spec.Group == "" || doc.Spec.Names.Plural == "" {
|
|
return Resource{}, false, fmt.Errorf("%s (document %d): CRD missing spec.group or spec.names.plural", origin, i)
|
|
}
|
|
res := Resource{
|
|
Group: doc.Spec.Group,
|
|
Kind: doc.Spec.Names.Kind,
|
|
Plural: doc.Spec.Names.Plural,
|
|
Source: SourceCRD,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
}
|
|
for _, v := range doc.Spec.Versions {
|
|
// Only served versions are live API surface. Skipping unserved
|
|
// versions keeps the classifier from diffing schemas no client can
|
|
// call, and makes the standard CRD deprecation step (flip
|
|
// served:true -> false) surface as a removed served version.
|
|
if v.Name == "" || !v.Served {
|
|
continue
|
|
}
|
|
res.Versions[v.Name] = v.Schema.OpenAPIV3Schema
|
|
}
|
|
return res, true, nil
|
|
}
|
|
|
|
// storageVarGroups maps the storage-map variable prefixes used in
|
|
// pkg/apiserver/apiserver.go to their API group. The apps map is intentionally
|
|
// omitted: apps.cozystack.io resources are discovered from the cozyrds, which
|
|
// additionally carry their schema. A new entry here (or a new variable prefix
|
|
// in the apiserver) is the signal to extend this table.
|
|
var storageVarGroups = map[string]string{
|
|
"coreV1alpha1Storage": "core.cozystack.io",
|
|
"sdnV1alpha1Storage": "sdn.cozystack.io",
|
|
}
|
|
|
|
// apiserverStorageRe matches `<var>["<plural>"]` storage registrations, e.g.
|
|
//
|
|
// coreV1alpha1Storage["tenantsecrets"] = ...
|
|
//
|
|
// The plural class allows hyphens so a future hyphenated resource name is not
|
|
// silently dropped. The apps storage map is indexed by a variable
|
|
// (appsV1alpha1Storage[resConfig.Application.Plural]), not a string literal, so
|
|
// it never matches here — the apps API is covered by the cozyrds instead.
|
|
var apiserverStorageRe = regexp.MustCompile(`(\w+Storage)\["([a-z0-9-]+)"\]`)
|
|
|
|
// unmappedStoragePrefix labels resources whose storage variable prefix is not
|
|
// in storageVarGroups. Rather than silently dropping them — which would let a
|
|
// contributor add a brand-new aggregated group under a new variable and bypass
|
|
// the gate — they are surfaced under this synthetic group so the classifier
|
|
// reports them as a new group/resource, prompting the reviewer to map the
|
|
// prefix in storageVarGroups.
|
|
const unmappedStoragePrefix = "unmapped-apiserver-storage:"
|
|
|
|
// ParseAPIServerStorages extracts the static Go-backed aggregated resources
|
|
// registered in pkg/apiserver/apiserver.go. Known variable prefixes map to
|
|
// their real group (storageVarGroups); an unknown prefix is surfaced under a
|
|
// synthetic group (unmappedStoragePrefix) so drift cannot silently defeat the
|
|
// gate. These resources carry no checked-in schema, so they populate only the
|
|
// new-group / new-resource / removal detection. Kind is left empty (not
|
|
// recoverable from the registration line alone); reporting falls back to the
|
|
// plural.
|
|
func ParseAPIServerStorages(origin string, data []byte) []Resource {
|
|
var out []Resource
|
|
seen := map[resourceKey]struct{}{}
|
|
for _, m := range apiserverStorageRe.FindAllStringSubmatch(string(data), -1) {
|
|
prefix, plural := m[1], m[2]
|
|
group, ok := storageVarGroups[prefix]
|
|
if !ok {
|
|
group = unmappedStoragePrefix + prefix
|
|
}
|
|
k := resourceKey{Group: group, Plural: plural}
|
|
if _, dup := seen[k]; dup {
|
|
continue
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, Resource{
|
|
Group: group,
|
|
Plural: plural,
|
|
Source: SourceAPIServer,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// apiServicePlural is the sentinel Plural used for SourceAPIService
|
|
// resources. An APIService does not describe an individual resource the way
|
|
// a CRD or cozyrd does — it registers a whole (group, version) pair, served
|
|
// by whatever aggregated apiserver binary the group's registration points
|
|
// at, which may be built entirely outside this repository (no vendored Go
|
|
// types, no checked-in CRD). The literal is namespaced with "$" so it can
|
|
// never collide with a real plural name.
|
|
const apiServicePlural = "$apiservice"
|
|
|
|
// ParseAPIServices parses a (possibly multi-document) YAML file for
|
|
// kind: APIService manifests, surfacing one schema-less Resource per
|
|
// first-party (group, version) registration. Like ParseAPIServerStorages,
|
|
// these resources carry no checked-in schema, so they participate only in
|
|
// new-group / new-resource / removal detection via the shared
|
|
// empty-Versions mechanism — diffResource has nothing to iterate, so no
|
|
// Breaking finding is ever produced from one of these alone. Documents that
|
|
// fail to parse (Helm markup sharing a file with a real APIService, as with
|
|
// ParseCRDs) are skipped rather than failing the file.
|
|
func ParseAPIServices(origin string, data []byte) []Resource {
|
|
var out []Resource
|
|
for _, docBytes := range splitYAMLDocs(data) {
|
|
if len(strings.TrimSpace(string(docBytes))) == 0 {
|
|
continue
|
|
}
|
|
if res, ok := apiServiceFromDoc(origin, docBytes); ok {
|
|
out = append(out, res)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// apiServiceFromDoc attempts to parse a single YAML document as an
|
|
// APIService. See crdFromDoc for why docKind is checked before the fuller
|
|
// unmarshal.
|
|
func apiServiceFromDoc(origin string, docBytes []byte) (Resource, bool) {
|
|
if docKind(docBytes) != "APIService" {
|
|
return Resource{}, false
|
|
}
|
|
var doc struct {
|
|
Spec struct {
|
|
Group string `json:"group"`
|
|
Version string `json:"version"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := unmarshalYAMLDoc(docBytes, &doc); err != nil {
|
|
return Resource{}, false
|
|
}
|
|
if doc.Spec.Group == "" || doc.Spec.Version == "" {
|
|
return Resource{}, false
|
|
}
|
|
return Resource{
|
|
Group: doc.Spec.Group,
|
|
Kind: "(aggregated apiserver)",
|
|
Plural: apiServicePlural,
|
|
Source: SourceAPIService,
|
|
Origin: origin,
|
|
Versions: map[string]Schema{doc.Spec.Version: {}},
|
|
}, true
|
|
}
|
|
|
|
// helmDirectiveLine matches a line that is entirely Go-template/Helm markup
|
|
// (`{{- if ... }}`, `{{- end }}`, `{{- /* comment */ -}}`), including its
|
|
// trailing newline.
|
|
var helmDirectiveLine = regexp.MustCompile(`(?m)^[ \t]*\{\{-?[^\n]*-?\}\}[ \t]*\r?\n?`)
|
|
|
|
// unmarshalYAMLDoc decodes one `---`-delimited document, tolerating Helm
|
|
// directive lines that share a document with otherwise-valid YAML. The
|
|
// convention this repo's charts use for a raw CRD/APIService body is to wrap
|
|
// the whole manifest in a single `{{- if }}...{{- end }}` block; the `{{- if
|
|
// }}` half is naturally its own document (nothing precedes it since it opens
|
|
// the block before a `---`), but the closing `{{- end }}` has no `---` before
|
|
// it and so lands in the same document as the manifest itself — plain
|
|
// yaml.Unmarshal rejects that whole document as invalid. Stripping
|
|
// whole-line directives before decoding recovers the manifest; a document
|
|
// that still fails after stripping is genuinely not YAML and is left to the
|
|
// caller to skip.
|
|
func unmarshalYAMLDoc(doc []byte, out any) error {
|
|
if err := yaml.Unmarshal(doc, out); err == nil {
|
|
return nil
|
|
}
|
|
return yaml.Unmarshal(helmDirectiveLine.ReplaceAll(doc, nil), out)
|
|
}
|
|
|
|
// docKind cheaply extracts a YAML document's top-level kind field, without
|
|
// paying for a full CRD- or APIService-shaped unmarshal on documents that
|
|
// are neither. LoadSnapshot's discovery walk scans every .yaml/.yml file
|
|
// under packages/ and internal/ rather than a curated directory allowlist
|
|
// (see discoverYAMLFiles), so the overwhelming majority of documents it
|
|
// sees are not API manifests at all; probing kind first, once, keeps that
|
|
// walk cheap.
|
|
// mightBeAPIManifest is a cheap fast-reject shared by docKind (per document)
|
|
// and LoadSnapshot (per whole file, before even splitting it into
|
|
// documents). Both kinds this package looks for (crdFromDoc,
|
|
// apiServiceFromDoc) appear as their own kind name verbatim in a valid
|
|
// manifest; content containing neither substring cannot be either kind. This
|
|
// is the overwhelming majority of what a content-based walk across the whole
|
|
// tree sees — Deployments, Services, plain values trees, and everything else
|
|
// that is neither a CRD nor an APIService — so rejecting on a raw substring
|
|
// scan avoids the cost of the `---` split, the YAML parse attempt, and (for
|
|
// a document that isn't valid strict YAML at all — an ordinary Helm
|
|
// template) the further regex-stripped retry in unmarshalYAMLDoc.
|
|
func mightBeAPIManifest(data []byte) bool {
|
|
return bytes.Contains(data, []byte("CustomResourceDefinition")) || bytes.Contains(data, []byte("APIService"))
|
|
}
|
|
|
|
func docKind(docBytes []byte) string {
|
|
if !mightBeAPIManifest(docBytes) {
|
|
return ""
|
|
}
|
|
var probe struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
if err := unmarshalYAMLDoc(docBytes, &probe); err != nil {
|
|
return ""
|
|
}
|
|
return probe.Kind
|
|
}
|
|
|
|
// yamlDocSeparator matches a `---` document-separator line. Compiled once at
|
|
// package init rather than per splitYAMLDocs call, since LoadSnapshot's
|
|
// content-based walk now runs this against every YAML file in the tree.
|
|
var yamlDocSeparator = regexp.MustCompile(`(?m)^---\s*$`)
|
|
|
|
// splitYAMLDocs splits a YAML stream into individual document byte slices on
|
|
// `---` separators. It intentionally mirrors the simple splitting Helm and
|
|
// kubectl use; CRD manifests in this repo do not embed `---` inside block
|
|
// scalars, so line-based splitting is safe.
|
|
func splitYAMLDocs(data []byte) [][]byte {
|
|
parts := yamlDocSeparator.Split(string(data), -1)
|
|
out := make([][]byte, 0, len(parts))
|
|
for _, p := range parts {
|
|
out = append(out, []byte(p))
|
|
}
|
|
return out
|
|
}
|