diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1b4ce33..f3b085e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -38,6 +38,20 @@ jobs: # the floor check, so it exists even if the floor trips. run: make coverage + - name: Coverage summary + if: always() + run: go tool cover -func=cover.out | tee coverage-summary.txt + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage + path: | + cover.out + coverage-summary.txt + if-no-files-found: error + - name: Upload Coverage to Codecov if: always() uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 @@ -46,3 +60,42 @@ jobs: files: cover.out flags: unittests fail_ci_if_error: false + + # SonarCloud: needs repo secret SONAR_TOKEN. Disable Automatic Analysis on the + # SonarCloud project first (mutually exclusive with CI-based analysis + Go coverage). + # Not a protect-main required check — advisory until the quality gate is tuned. + sonarcloud: + name: sonarcloud + runs-on: ubuntu-24.04 + needs: [coverage] + continue-on-error: true + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download coverage artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: coverage + + - name: Gate on SONAR_TOKEN + id: sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -z "${SONAR_TOKEN}" ]; then + echo "has_token=false" >> "${GITHUB_OUTPUT}" + echo "::warning::SONAR_TOKEN secret is unset; SonarCloud scan skipped. Add the org analysis token under Settings → Secrets → Actions, and disable Automatic Analysis on the SonarCloud project (see sonar-project.properties header)." + else + echo "has_token=true" >> "${GITHUB_OUTPUT}" + fi + + - uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 + if: steps.sonar.outputs.has_token == 'true' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + diff --git a/.github/workflows/sonarcloud.yaml b/.github/workflows/sonarcloud.yaml new file mode 100644 index 0000000..54dae9a --- /dev/null +++ b/.github/workflows/sonarcloud.yaml @@ -0,0 +1,49 @@ +# SonarCloud — manual re-scan shim. Canonical analysis is the `sonarcloud` job in +# coverage.yml (after `make coverage` produces cover.out). Keep this workflow for +# on-demand re-analysis of a ref. +name: SonarCloud + +on: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + sonarcloud: + name: sonarcloud + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + submodules: true + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25' + check-latest: true + + - name: Run Coverage + run: make coverage + + - name: Gate on SONAR_TOKEN + id: sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [ -z "${SONAR_TOKEN}" ]; then + echo "has_token=false" >> "${GITHUB_OUTPUT}" + echo "::warning::SONAR_TOKEN secret is unset; SonarCloud scan skipped. Add the org analysis token under Settings → Secrets → Actions, and disable Automatic Analysis on the SonarCloud project." + else + echo "has_token=true" >> "${GITHUB_OUTPUT}" + fi + + - uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 + if: steps.sonar.outputs.has_token == 'true' + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index 9e836a7..edf5cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ cover.out !agent-context/ !agent-context/** # .claude/ (local coordination + worktrees) remains ignored by the global rule. + +# Sonar scanner local working directory +.scannerwork/ diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 4e7cf56..ba5508c 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -2,10 +2,14 @@ # # SPDX-License-Identifier: CC0-1.0 -# SonarCloud Automatic Analysis configuration. +# SonarCloud Automatic Analysis configuration (legacy while Autoscan is still on). # -# NOTE: Automatic Analysis reads THIS file (.sonarcloud.properties) and ignores -# any sonar-project.properties. See: +# Go coverage requires CI-based analysis: see sonar-project.properties and the +# `sonarcloud` job in .github/workflows/coverage.yml. Disable Automatic Analysis +# (Administration → Analysis Method) so CI scans + cover.out are accepted. +# +# NOTE: While Automatic Analysis is enabled it reads THIS file and ignores +# sonar-project.properties. See: # https://docs.sonarsource.com/sonarqube-cloud/analyzing-source-code/automatic-analysis/ # # The new-code duplication gate (new_duplicated_lines_density <= 3%) was tripping diff --git a/apis/cluster/v1alpha1/register.go b/apis/cluster/v1alpha1/register.go index a78a6f7..3ec771b 100644 --- a/apis/cluster/v1alpha1/register.go +++ b/apis/cluster/v1alpha1/register.go @@ -19,4 +19,7 @@ var ( SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} ) -func init() {} +func init() { + // Intentionally empty: CRD types register via SchemeBuilder and generated zz_*.go, + // not from this Upjet scaffold stub. Do not call AddToScheme here (double-register risk). +} diff --git a/apis/namespaced/v1alpha1/register.go b/apis/namespaced/v1alpha1/register.go index 6c63946..4bfbbd4 100644 --- a/apis/namespaced/v1alpha1/register.go +++ b/apis/namespaced/v1alpha1/register.go @@ -19,4 +19,7 @@ var ( SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} ) -func init() {} +func init() { + // Intentionally empty: CRD types register via SchemeBuilder and generated zz_*.go, + // not from this Upjet scaffold stub. Do not call AddToScheme here (double-register risk). +} diff --git a/cluster/images/provider-gridscale/Dockerfile b/cluster/images/provider-gridscale/Dockerfile index dea30de..bb4ea33 100644 --- a/cluster/images/provider-gridscale/Dockerfile +++ b/cluster/images/provider-gridscale/Dockerfile @@ -24,19 +24,19 @@ ENV PLUGIN_DIR=/terraform/provider-mirror/registry.terraform.io/${TERRAFORM_PROV ENV TF_CLI_CONFIG_FILE=/terraform/.terraformrc ENV TF_FORK=0 -RUN mkdir -p ${PLUGIN_DIR} +RUN mkdir -p "${PLUGIN_DIR}" -ADD https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip /tmp -ADD ${TERRAFORM_PROVIDER_DOWNLOAD_URL_PREFIX}/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip /tmp -COPY terraformrc.hcl ${TF_CLI_CONFIG_FILE} +ADD "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip" /tmp +ADD "${TERRAFORM_PROVIDER_DOWNLOAD_URL_PREFIX}/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip" /tmp +COPY terraformrc.hcl "${TF_CLI_CONFIG_FILE}" -RUN unzip /tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip -d /usr/local/bin \ +RUN unzip "/tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip" -d /usr/local/bin \ && chmod +x /usr/local/bin/terraform \ - && rm /tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip \ - && unzip /tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip -d ${PLUGIN_DIR} \ - && chmod +x ${PLUGIN_DIR}/* \ - && rm /tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip \ - && chown -R ${USER_ID}:${USER_ID} /terraform + && rm "/tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip" \ + && unzip "/tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip" -d "${PLUGIN_DIR}" \ + && chmod +x "${PLUGIN_DIR}"/* \ + && rm "/tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip" \ + && chown -R "${USER_ID}:${USER_ID}" /terraform # End of - Setup Terraform environment # Provider controller needs these environment variable at runtime diff --git a/config/crd_contract_test.go b/config/crd_contract_test.go index e38f40b..ab4d126 100644 --- a/config/crd_contract_test.go +++ b/config/crd_contract_test.go @@ -32,34 +32,55 @@ import ( const crdDir = "../package/crds" +// Named view types below replace anonymous nested structs so Sonar godre:S8205 +// stays clear while JSON tags and unmarshalling behaviour stay identical. + +type crdNames struct { + Kind string `json:"kind"` + Plural string `json:"plural"` +} + +type crdForProvider struct { + Properties map[string]json.RawMessage `json:"properties"` +} + +type crdSpecProperties struct { + ForProvider crdForProvider `json:"forProvider"` +} + +type crdResourceSpec struct { + Properties crdSpecProperties `json:"properties"` +} + +type crdSchemaProperties struct { + Spec crdResourceSpec `json:"spec"` +} + +type crdOpenAPIV3Schema struct { + Properties crdSchemaProperties `json:"properties"` +} + +type crdVersionSchema struct { + OpenAPIV3Schema crdOpenAPIV3Schema `json:"openAPIV3Schema"` +} + +type crdVersion struct { + Name string `json:"name"` + Schema crdVersionSchema `json:"schema"` +} + +type crdSpec struct { + Group string `json:"group"` + Names crdNames `json:"names"` + Scope string `json:"scope"` + Versions []crdVersion `json:"versions"` +} + // crd is a minimal typed view of a generated CRD. Unmarshalling into a struct // (rather than map[string]interface{}) means missing fields become zero values // that fail assertions naturally, instead of panicking on a bad type assertion. type crd struct { - Spec struct { - Group string `json:"group"` - Names struct { - Kind string `json:"kind"` - Plural string `json:"plural"` - } `json:"names"` - Scope string `json:"scope"` - Versions []struct { - Name string `json:"name"` - Schema struct { - OpenAPIV3Schema struct { - Properties struct { - Spec struct { - Properties struct { - ForProvider struct { - Properties map[string]json.RawMessage `json:"properties"` - } `json:"forProvider"` - } `json:"properties"` - } `json:"spec"` - } `json:"properties"` - } `json:"openAPIV3Schema"` - } `json:"schema"` - } `json:"versions"` - } `json:"spec"` + Spec crdSpec `json:"spec"` } // contract is the scoped, stable fragment we serialize to a golden file. @@ -159,94 +180,120 @@ func namespacedGroupFor(clusterGroup string) string { return strings.Replace(clusterGroup, ".gridscale.", ".gridscale.m.", 1) } +func marshalCRDFragment(t *testing.T, frag contract) []byte { + t.Helper() + got, err := json.MarshalIndent(frag, "", " ") + if err != nil { + t.Fatalf("marshal fragment: %v", err) + } + return append(got, '\n') +} + +func writeCRDGolden(t *testing.T, goldenPath string, got []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil { + t.Fatalf("mkdir golden dir: %v", err) + } + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } +} + +func assertCRDGoldenMatch(t *testing.T, name string, got []byte, goldenPath string) { + t.Helper() + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden %s (run UPDATE_GOLDEN=1 to create): %v", goldenPath, err) + } + if !bytes.Equal(got, want) { + t.Errorf("CRD contract drift for %s.\n--- got ---\n%s\n--- want ---\n%s", + name, got, want) + } +} + +func runCRDGoldenCase(t *testing.T, tc crdCase, update bool) { + t.Helper() + got := marshalCRDFragment(t, extract(t, loadCRD(t, tc.clusterFile))) + goldenPath := filepath.Join("testdata", "crd-contract", tc.name+".golden.json") + if update { + writeCRDGolden(t, goldenPath, got) + return + } + assertCRDGoldenMatch(t, tc.name, got, goldenPath) +} + // TestCRDGoldenContract compares the scoped fragment of each cluster-scoped CRD // against its committed golden. UPDATE_GOLDEN=1 regenerates the goldens. func TestCRDGoldenContract(t *testing.T) { update := os.Getenv("UPDATE_GOLDEN") == "1" for _, tc := range crdCases { t.Run(tc.name, func(t *testing.T) { - frag := extract(t, loadCRD(t, tc.clusterFile)) - got, err := json.MarshalIndent(frag, "", " ") - if err != nil { - t.Fatalf("marshal fragment: %v", err) - } - got = append(got, '\n') - - goldenPath := filepath.Join("testdata", "crd-contract", tc.name+".golden.json") - if update { - if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil { - t.Fatalf("mkdir golden dir: %v", err) - } - if err := os.WriteFile(goldenPath, got, 0o644); err != nil { - t.Fatalf("write golden: %v", err) - } - return - } - - want, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("read golden %s (run UPDATE_GOLDEN=1 to create): %v", goldenPath, err) - } - if !bytes.Equal(got, want) { - t.Errorf("CRD contract drift for %s.\n--- got ---\n%s\n--- want ---\n%s", - tc.name, got, want) - } + runCRDGoldenCase(t, tc, update) }) } } +func assertClusterKindGroupScope(t *testing.T, cluster crd, tc crdCase) { + t.Helper() + if cluster.Spec.Names.Kind != tc.wantKind { + t.Errorf("cluster kind = %q, want %q", cluster.Spec.Names.Kind, tc.wantKind) + } + if cluster.Spec.Group != tc.wantClusterGroup { + t.Errorf("cluster group = %q, want %q", cluster.Spec.Group, tc.wantClusterGroup) + } + if !strings.HasSuffix(cluster.Spec.Group, clusterGroupSuffix) { + t.Errorf("cluster group %q lacks suffix %q", cluster.Spec.Group, clusterGroupSuffix) + } + if cluster.Spec.Scope != "Cluster" { + t.Errorf("cluster scope = %q, want Cluster", cluster.Spec.Scope) + } +} + +func assertNamespacedGroupScope(t *testing.T, cluster, namespaced crd) { + t.Helper() + wantNS := namespacedGroupFor(cluster.Spec.Group) + if namespaced.Spec.Group != wantNS { + t.Errorf("namespaced group = %q, want %q (derived from cluster group)", + namespaced.Spec.Group, wantNS) + } + if namespaced.Spec.Scope != "Namespaced" { + t.Errorf("namespaced scope = %q, want Namespaced", namespaced.Spec.Scope) + } +} + +func assertClusterNamespacedParity(t *testing.T, cluster, namespaced crd) { + t.Helper() + cf := extract(t, cluster) + nf := extract(t, namespaced) + if len(cf.ForProviderProps) == 0 { + t.Errorf("cluster forProvider property set is empty") + } + if namespaced.Spec.Names.Kind != cluster.Spec.Names.Kind { + t.Errorf("kind mismatch cluster=%q namespaced=%q", + cluster.Spec.Names.Kind, namespaced.Spec.Names.Kind) + } + if strings.Join(cf.ForProviderProps, ",") != strings.Join(nf.ForProviderProps, ",") { + t.Errorf("forProvider property set differs between scopes.\ncluster: %v\nnamespaced: %v", + cf.ForProviderProps, nf.ForProviderProps) + } +} + +func assertCRDStructuralInvariants(t *testing.T, tc crdCase) { + t.Helper() + cluster := loadCRD(t, tc.clusterFile) + namespaced := loadCRD(t, tc.namespacedFile) + assertClusterKindGroupScope(t, cluster, tc) + assertNamespacedGroupScope(t, cluster, namespaced) + assertClusterNamespacedParity(t, cluster, namespaced) +} + // TestCRDStructuralInvariants asserts invariants that must hold regardless of // the golden files, so the suite is not purely self-referential and can // fail-first on a malformed or drifted CRD. func TestCRDStructuralInvariants(t *testing.T) { for _, tc := range crdCases { t.Run(tc.name, func(t *testing.T) { - cluster := loadCRD(t, tc.clusterFile) - namespaced := loadCRD(t, tc.namespacedFile) - - // Kind override / expected Kind is honored in the rendered CRD. - if cluster.Spec.Names.Kind != tc.wantKind { - t.Errorf("cluster kind = %q, want %q", cluster.Spec.Names.Kind, tc.wantKind) - } - - // Cluster group matches expectation and follows the suffix rule. - if cluster.Spec.Group != tc.wantClusterGroup { - t.Errorf("cluster group = %q, want %q", cluster.Spec.Group, tc.wantClusterGroup) - } - if !strings.HasSuffix(cluster.Spec.Group, clusterGroupSuffix) { - t.Errorf("cluster group %q lacks suffix %q", cluster.Spec.Group, clusterGroupSuffix) - } - if cluster.Spec.Scope != "Cluster" { - t.Errorf("cluster scope = %q, want Cluster", cluster.Spec.Scope) - } - - // Namespaced group is the cluster group with the `.m.` infix, and - // the CRD is Namespaced-scoped. - wantNS := namespacedGroupFor(cluster.Spec.Group) - if namespaced.Spec.Group != wantNS { - t.Errorf("namespaced group = %q, want %q (derived from cluster group)", - namespaced.Spec.Group, wantNS) - } - if namespaced.Spec.Scope != "Namespaced" { - t.Errorf("namespaced scope = %q, want Namespaced", namespaced.Spec.Scope) - } - - // Cluster and namespaced variants must agree on kind and expose an - // identical, non-empty forProvider property set. Catches drift that - // touches only one scope. - cf := extract(t, cluster) - nf := extract(t, namespaced) - if len(cf.ForProviderProps) == 0 { - t.Errorf("cluster forProvider property set is empty") - } - if namespaced.Spec.Names.Kind != cluster.Spec.Names.Kind { - t.Errorf("kind mismatch cluster=%q namespaced=%q", - cluster.Spec.Names.Kind, namespaced.Spec.Names.Kind) - } - if strings.Join(cf.ForProviderProps, ",") != strings.Join(nf.ForProviderProps, ",") { - t.Errorf("forProvider property set differs between scopes.\ncluster: %v\nnamespaced: %v", - cf.ForProviderProps, nf.ForProviderProps) - } + assertCRDStructuralInvariants(t, tc) }) } } diff --git a/config/external_name_fuzz_test.go b/config/external_name_fuzz_test.go index 7474b7e..1821e14 100644 --- a/config/external_name_fuzz_test.go +++ b/config/external_name_fuzz_test.go @@ -6,16 +6,8 @@ import ( ujconfig "github.com/crossplane/upjet/v2/pkg/config" ) -// FuzzGetExternalName fuzzes the custom GetExternalNameFn stub installed by -// idWithStub() — the highest-value hand-authored parsing surface in this -// generated provider. The stub extracts the provider-assigned identifier from -// Terraform state (a map[string]any) via IDAsExternalName and deliberately -// swallows the "id not found" error, yielding ("", nil) instead. -// -// The fuzzer drives the string content that lands under the "id" key -// (adversarial: empty, huge, embedded NULs/separators, non-UTF8, unicode) and -// asserts the stub's contract holds and never panics. -func FuzzGetExternalName(f *testing.F) { +func seedExternalNameCorpus(f *testing.F) { + f.Helper() // Seed corpus: representative + edge inputs. f.Add("") // empty id f.Add("6a5d0f0e-8a2b-4c7d-9e1f-0123456789ab") // normal UUID @@ -25,8 +17,10 @@ func FuzzGetExternalName(f *testing.F) { f.Add("id\x00with\x00nul") // embedded NUL bytes f.Add(string([]byte{0xff, 0xfe, 0xfd})) // invalid UTF-8 f.Add(" surrounding whitespace ") // whitespace +} - // Resolve the stub once; it is a pure function of its tfstate argument. +func resolveGetExternalName(f *testing.F) ujconfig.GetExternalNameFn { + f.Helper() opt := ExternalNameConfigurations() r := &ujconfig.Resource{} opt(r) @@ -34,50 +28,78 @@ func FuzzGetExternalName(f *testing.F) { if getExternalName == nil { f.Fatal("GetExternalNameFn is nil; nothing to fuzz") } + return getExternalName +} - f.Fuzz(func(t *testing.T, id string) { - // Case A: "id" present as a string. This exercises the successful - // type-assertion path and the empty-string branch inside - // IDAsExternalName. - gotA, errA := getExternalName(map[string]any{"id": id}) - if errA != nil { - // Contract: the stub NEVER surfaces an error (it swallows the - // underlying IDAsExternalName error). - t.Fatalf("GetExternalNameFn(id=%q): want nil error, got %v", id, errA) - } - if id == "" { - // Empty id triggers the error branch upstream, swallowed to "". - if gotA != "" { - t.Fatalf("GetExternalNameFn(id=\"\"): want empty external name, got %q", gotA) - } - } else { - // Non-empty id must be returned verbatim, byte-for-byte. This - // catches any accidental mutation/normalization of the identifier - // (e.g. trimming, truncation, re-encoding of non-UTF8 bytes). - if gotA != id { - t.Fatalf("GetExternalNameFn(id=%q): want verbatim %q, got %q", id, id, gotA) - } +func assertExternalNameStringID(t *testing.T, get ujconfig.GetExternalNameFn, id string) { + t.Helper() + // Case A: "id" present as a string. This exercises the successful + // type-assertion path and the empty-string branch inside + // IDAsExternalName. + got, err := get(map[string]any{"id": id}) + if err != nil { + // Contract: the stub NEVER surfaces an error (it swallows the + // underlying IDAsExternalName error). + t.Fatalf("GetExternalNameFn(id=%q): want nil error, got %v", id, err) + } + if id == "" { + // Empty id triggers the error branch upstream, swallowed to "". + if got != "" { + t.Fatalf("GetExternalNameFn(id=\"\"): want empty external name, got %q", got) } + return + } + // Non-empty id must be returned verbatim, byte-for-byte. This + // catches any accidental mutation/normalization of the identifier + // (e.g. trimming, truncation, re-encoding of non-UTF8 bytes). + if got != id { + t.Fatalf("GetExternalNameFn(id=%q): want verbatim %q, got %q", id, id, got) + } +} - // Case B: "id" present but NOT a string (wrong dynamic type). The - // type assertion in IDAsExternalName must fail gracefully — no panic — - // and the stub swallows the error, yielding ("", nil). - gotB, errB := getExternalName(map[string]any{"id": []byte(id)}) - if errB != nil { - t.Fatalf("GetExternalNameFn(non-string id): want nil error, got %v", errB) - } - if gotB != "" { - t.Fatalf("GetExternalNameFn(non-string id): want empty external name, got %q", gotB) - } +func assertExternalNameNonStringID(t *testing.T, get ujconfig.GetExternalNameFn, id string) { + t.Helper() + // Case B: "id" present but NOT a string (wrong dynamic type). The + // type assertion in IDAsExternalName must fail gracefully — no panic — + // and the stub swallows the error, yielding ("", nil). + got, err := get(map[string]any{"id": []byte(id)}) + if err != nil { + t.Fatalf("GetExternalNameFn(non-string id): want nil error, got %v", err) + } + if got != "" { + t.Fatalf("GetExternalNameFn(non-string id): want empty external name, got %q", got) + } +} - // Case C: "id" key absent entirely, but the fuzzed string occupies an - // unrelated key. The stub must ignore it and yield ("", nil). - gotC, errC := getExternalName(map[string]any{"other": id}) - if errC != nil { - t.Fatalf("GetExternalNameFn(missing id): want nil error, got %v", errC) - } - if gotC != "" { - t.Fatalf("GetExternalNameFn(missing id): want empty external name, got %q", gotC) - } +func assertExternalNameMissingID(t *testing.T, get ujconfig.GetExternalNameFn, id string) { + t.Helper() + // Case C: "id" key absent entirely, but the fuzzed string occupies an + // unrelated key. The stub must ignore it and yield ("", nil). + got, err := get(map[string]any{"other": id}) + if err != nil { + t.Fatalf("GetExternalNameFn(missing id): want nil error, got %v", err) + } + if got != "" { + t.Fatalf("GetExternalNameFn(missing id): want empty external name, got %q", got) + } +} + +// FuzzGetExternalName fuzzes the custom GetExternalNameFn stub installed by +// idWithStub() — the highest-value hand-authored parsing surface in this +// generated provider. The stub extracts the provider-assigned identifier from +// Terraform state (a map[string]any) via IDAsExternalName and deliberately +// swallows the "id not found" error, yielding ("", nil) instead. +// +// The fuzzer drives the string content that lands under the "id" key +// (adversarial: empty, huge, embedded NULs/separators, non-UTF8, unicode) and +// asserts the stub's contract holds and never panics. +func FuzzGetExternalName(f *testing.F) { + seedExternalNameCorpus(f) + getExternalName := resolveGetExternalName(f) + + f.Fuzz(func(t *testing.T, id string) { + assertExternalNameStringID(t, getExternalName, id) + assertExternalNameNonStringID(t, getExternalName, id) + assertExternalNameMissingID(t, getExternalName, id) }) } diff --git a/hack/check-api-docs.sh b/hack/check-api-docs.sh index 124c577..e822923 100755 --- a/hack/check-api-docs.sh +++ b/hack/check-api-docs.sh @@ -19,7 +19,7 @@ go run "github.com/elastic/crd-ref-docs@${CRD_REF_DOCS_VERSION}" \ --output-path=docs/api/ if ! git diff --quiet -- docs/api/; then - echo "::error::docs/api/ is stale relative to apis/** — run \`make docs\` and commit." + echo "::error::docs/api/ is stale relative to apis/** — run \`make docs\` and commit." >&2 git --no-pager diff --stat -- docs/api/ git --no-pager diff -- docs/api/ | head -n 200 exit 1 diff --git a/hack/check-docs.sh b/hack/check-docs.sh index 6241394..6962734 100755 --- a/hack/check-docs.sh +++ b/hack/check-docs.sh @@ -17,8 +17,8 @@ for path in "${crd_dir}"/*.platformrelay.io_*.yaml; do case "${name}" in *.m.platformrelay.io_*) continue ;; *providerconfig*|*storeconfig*) continue ;; + *) managed+=("${name}") ;; esac - managed+=("${name}") done resource_count="${#managed[@]}" @@ -30,12 +30,12 @@ echo "actual: ${resource_count} managed resources across ${group_count} API grou fail=0 if ! grep -qE "\b${resource_count}\b" "${readme}"; then - echo "::error::README does not mention the current resource count (${resource_count}); resource matrix is stale." + echo "::error::README does not mention the current resource count (${resource_count}); resource matrix is stale." >&2 fail=1 fi if ! grep -qE "\b${group_count}\b" "${readme}"; then - echo "::error::README does not mention the current group count (${group_count}); resource matrix is stale." + echo "::error::README does not mention the current group count (${group_count}); resource matrix is stale." >&2 fail=1 fi -[ "${fail}" -eq 0 ] && echo "README resource matrix is in sync (${resource_count}/${group_count})." +[[ "${fail}" -eq 0 ]] && echo "README resource matrix is in sync (${resource_count}/${group_count})." exit "${fail}" diff --git a/hack/test/e8_s01_observe_docs_test.sh b/hack/test/e8_s01_observe_docs_test.sh index d526feb..e355160 100755 --- a/hack/test/e8_s01_observe_docs_test.sh +++ b/hack/test/e8_s01_observe_docs_test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -[ -f docs/observe-only.md ] || { echo "FAIL: docs/observe-only.md not found"; exit 1; } +[[ -f docs/observe-only.md ]] || { echo "FAIL: docs/observe-only.md not found"; exit 1; } grep -q "managementPolicies" docs/observe-only.md || { echo "FAIL: missing managementPolicies"; exit 1; } grep -q "crossplane.io/external-name" docs/observe-only.md || { echo "FAIL: missing crossplane.io/external-name"; exit 1; } echo "PASS: docs/observe-only.md is correct" diff --git a/hack/test/e8_s01_observe_yaml_count_test.sh b/hack/test/e8_s01_observe_yaml_count_test.sh index 0afd3b2..57743c5 100755 --- a/hack/test/e8_s01_observe_yaml_count_test.sh +++ b/hack/test/e8_s01_observe_yaml_count_test.sh @@ -2,6 +2,6 @@ set -euo pipefail CLUSTER=$(find examples/cluster/observe -name '*.yaml' 2>/dev/null | wc -l | tr -d ' ') NAMESPACED=$(find examples/namespaced/observe -name '*.yaml' 2>/dev/null | wc -l | tr -d ' ') -[ "$CLUSTER" -eq 19 ] || { echo "FAIL: cluster observe YAMLs: $CLUSTER (want 19)"; exit 1; } -[ "$NAMESPACED" -eq 19 ] || { echo "FAIL: namespaced observe YAMLs: $NAMESPACED (want 19)"; exit 1; } +[[ "$CLUSTER" -eq 19 ]] || { echo "FAIL: cluster observe YAMLs: $CLUSTER (want 19)"; exit 1; } +[[ "$NAMESPACED" -eq 19 ]] || { echo "FAIL: namespaced observe YAMLs: $NAMESPACED (want 19)"; exit 1; } echo "PASS: cluster=$CLUSTER namespaced=$NAMESPACED" diff --git a/hack/test/e8_s02_backuplist_crd_exists_test.sh b/hack/test/e8_s02_backuplist_crd_exists_test.sh index df1ef8d..421a4d2 100755 --- a/hack/test/e8_s02_backuplist_crd_exists_test.sh +++ b/hack/test/e8_s02_backuplist_crd_exists_test.sh @@ -2,8 +2,8 @@ set -euo pipefail CLUSTER=package/crds/storage.gridscale.platformrelay.io_backuplists.yaml NAMESPACED=package/crds/storage.gridscale.m.platformrelay.io_backuplists.yaml -[ -f "$CLUSTER" ] || { echo "FAIL: missing $CLUSTER"; exit 1; } -[ -f "$NAMESPACED" ] || { echo "FAIL: missing $NAMESPACED"; exit 1; } +[[ -f "$CLUSTER" ]] || { echo "FAIL: missing $CLUSTER"; exit 1; } +[[ -f "$NAMESPACED" ]] || { echo "FAIL: missing $NAMESPACED"; exit 1; } grep -q "kind: CustomResourceDefinition" "$CLUSTER" || { echo "FAIL: not a CRD: $CLUSTER"; exit 1; } grep -q "kind: CustomResourceDefinition" "$NAMESPACED" || { echo "FAIL: not a CRD: $NAMESPACED"; exit 1; } echo "PASS: BackupList CRD YAMLs exist" diff --git a/hack/test/e8_s03_publicnetwork_crd_exists_test.sh b/hack/test/e8_s03_publicnetwork_crd_exists_test.sh index db9cecf..7fb10c2 100755 --- a/hack/test/e8_s03_publicnetwork_crd_exists_test.sh +++ b/hack/test/e8_s03_publicnetwork_crd_exists_test.sh @@ -2,8 +2,8 @@ set -euo pipefail CLUSTER=package/crds/gridscale.gridscale.platformrelay.io_publicnetworks.yaml NAMESPACED=package/crds/gridscale.gridscale.m.platformrelay.io_publicnetworks.yaml -[ -f "$CLUSTER" ] || { echo "FAIL: missing $CLUSTER"; exit 1; } -[ -f "$NAMESPACED" ] || { echo "FAIL: missing $NAMESPACED"; exit 1; } +[[ -f "$CLUSTER" ]] || { echo "FAIL: missing $CLUSTER"; exit 1; } +[[ -f "$NAMESPACED" ]] || { echo "FAIL: missing $NAMESPACED"; exit 1; } grep -q "kind: CustomResourceDefinition" "$CLUSTER" || { echo "FAIL: not a CRD: $CLUSTER"; exit 1; } grep -q "kind: CustomResourceDefinition" "$NAMESPACED" || { echo "FAIL: not a CRD: $NAMESPACED"; exit 1; } echo "PASS: PublicNetwork CRD YAMLs exist" diff --git a/hack/test/sonar_pg_02_no_secret_in_run_test.sh b/hack/test/sonar_pg_02_no_secret_in_run_test.sh index 0310fab..0bdfbba 100755 --- a/hack/test/sonar_pg_02_no_secret_in_run_test.sh +++ b/hack/test/sonar_pg_02_no_secret_in_run_test.sh @@ -14,12 +14,11 @@ fail() { [[ -f "${WF}" ]] || fail "missing ${WF}" # Secret must not be expanded inside any run: script body. -if grep -Eq '\$\{\{[[:space:]]*secrets\.UPTEST_DATASOURCE[[:space:]]*\}\}' "${WF}"; then - # Allowed only under an env: binding (key: ${{ secrets.UPTEST_DATASOURCE }}). - # Disallow the echo/printf form that embeds the expression in run:. - if grep -Eq '(echo|printf).*\$\{\{[[:space:]]*secrets\.UPTEST_DATASOURCE[[:space:]]*\}\}' "${WF}"; then - fail "${WF}: secrets.UPTEST_DATASOURCE must not appear inside run: (use env: + \"\$UPTEST_DATASOURCE\")" - fi +# Allowed only under an env: binding (key: ${{ secrets.UPTEST_DATASOURCE }}). +# Disallow the echo/printf form that embeds the expression in run:. +if grep -Eq '\$\{\{[[:space:]]*secrets\.UPTEST_DATASOURCE[[:space:]]*\}\}' "${WF}" \ + && grep -Eq '(echo|printf).*\$\{\{[[:space:]]*secrets\.UPTEST_DATASOURCE[[:space:]]*\}\}' "${WF}"; then + fail "${WF}: secrets.UPTEST_DATASOURCE must not appear inside run: (use env: + \"\$UPTEST_DATASOURCE\")" fi # Must bind the secret through the step env: block. @@ -32,12 +31,11 @@ if ! grep -Eq '\$UPTEST_DATASOURCE' "${WF}"; then fail "${WF}: run: must reference \"\$UPTEST_DATASOURCE\" (not secrets expression)" fi +# Accept either printf '%s\n' "$UPTEST_DATASOURCE" form from the spec. if ! grep -Eq "printf[[:space:]]+'%s\\\\n'[[:space:]]+\"\\\$UPTEST_DATASOURCE\"" "${WF}" \ - && ! grep -Eq 'printf[[:space:]]+"%s\\n"[[:space:]]+"\$UPTEST_DATASOURCE"' "${WF}"; then - # Accept either printf '%s\n' "$UPTEST_DATASOURCE" form from the spec. - if ! grep -F 'printf' "${WF}" | grep -q 'UPTEST_DATASOURCE'; then - fail "${WF}: run: should write datasource via printf '%s\\n' \"\$UPTEST_DATASOURCE\"" - fi + && ! grep -Eq 'printf[[:space:]]+"%s\\n"[[:space:]]+"\$UPTEST_DATASOURCE"' "${WF}" \ + && ! grep -F 'printf' "${WF}" | grep -q 'UPTEST_DATASOURCE'; then + fail "${WF}: run: should write datasource via printf '%s\\n' \"\$UPTEST_DATASOURCE\"" fi echo "PASS: UPTEST_DATASOURCE is bound via env: and referenced as \"\$UPTEST_DATASOURCE\" in run:" diff --git a/hack/test/sonar_pg_04_ci_job_scoped_permissions_test.sh b/hack/test/sonar_pg_04_ci_job_scoped_permissions_test.sh index ab71b3c..b0d662d 100755 --- a/hack/test/sonar_pg_04_ci_job_scoped_permissions_test.sh +++ b/hack/test/sonar_pg_04_ci_job_scoped_permissions_test.sh @@ -20,15 +20,14 @@ pre_jobs="$(awk ' ' "${WF}")" # Workflow-level permissions block must not grant contents: read. -if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*$'; then - if echo "${pre_jobs}" | awk ' +if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*$' \ + && echo "${pre_jobs}" | awk ' /^[[:space:]]*permissions:[[:space:]]*$/ { in_perm=1; next } in_perm && /^[^[:space:]#]/ { exit } in_perm && /^[[:space:]]*contents:[[:space:]]*read[[:space:]]*$/ { found=1; exit } END { exit found ? 0 : 1 } '; then - fail "${WF}: workflow-level 'contents: read' must be removed; declare permissions on jobs" - fi + fail "${WF}: workflow-level 'contents: read' must be removed; declare permissions on jobs" fi # Also catch the one-liner form: permissions: contents: read (unusual but possible). diff --git a/hack/test/sonar_pg_04_e2e_job_scoped_permissions_test.sh b/hack/test/sonar_pg_04_e2e_job_scoped_permissions_test.sh index ad78983..de00323 100755 --- a/hack/test/sonar_pg_04_e2e_job_scoped_permissions_test.sh +++ b/hack/test/sonar_pg_04_e2e_job_scoped_permissions_test.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# REQ-SONAR-PG-04 (e2e half): no workflow-level contents: read; jobs that need -# it declare permissions: locally. +# REQ-SONAR-PG-04 (e2e half): workflow may set a read-only floor +# (`permissions: contents: read` — a311484), but must not use write-all or any +# non-read permission at workflow level. Jobs that checkout / post statuses +# still declare local permissions including contents: read. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -13,39 +15,51 @@ fail() { [[ -f "${WF}" ]] || fail "missing ${WF}" -# Extract everything before the top-level `jobs:` key. pre_jobs="$(awk ' /^[[:space:]]*jobs:[[:space:]]*$/ { exit } { print } ' "${WF}")" -# Workflow-level permissions block must not grant contents: read. +# Canonical broad grant. +if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*write-all[[:space:]]*$'; then + fail "${WF}: workflow-level permissions must not grant write/admin scopes (write-all)" +fi + +# Nested permissions block: every granted key must be `: read` (allowlist). +# Reject write/admin and unknown write-class values; allow `contents: read` only. if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*$'; then - # Collect indented keys under that permissions: until a non-indented / less-indented key. - if echo "${pre_jobs}" | awk ' + bad="$(echo "${pre_jobs}" | awk ' /^[[:space:]]*permissions:[[:space:]]*$/ { in_perm=1; next } in_perm && /^[^[:space:]#]/ { exit } - in_perm && /^[[:space:]]*contents:[[:space:]]*read[[:space:]]*$/ { found=1; exit } - END { exit found ? 0 : 1 } - '; then - fail "${WF}: workflow-level 'contents: read' must be removed; declare permissions on jobs" + in_perm && /^[[:space:]]*[a-z0-9_-]+:[[:space:]]*/ { + line=$0 + sub(/^[[:space:]]*/, "", line) + split(line, a, /:[[:space:]]*/) + key=a[1]; val=a[2] + gsub(/[[:space:]]/, "", val) + if (key == "") next + if (!(key == "contents" && val == "read")) { + print key ": " val + } + } + ')" + if [[ -n "${bad}" ]]; then + fail "${WF}: workflow-level permissions must not grant write/admin scopes (only contents: read allowed); got: ${bad}" fi fi -# Also catch the one-liner form: permissions: contents: read (unusual but possible). -if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*contents:[[:space:]]*read'; then - fail "${WF}: workflow-level 'permissions: contents: read' must be removed" +# One-liner non-read forms. +if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]+' \ + && ! echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*contents:[[:space:]]*read[[:space:]]*$'; then + # permissions: that is not contents: read + if echo "${pre_jobs}" | grep -Eq '^[[:space:]]*permissions:[[:space:]]*(write-all|.+:[[:space:]]*(write|admin))'; then + fail "${WF}: workflow-level permissions must not grant write/admin scopes" + fi fi -# Jobs that check out the repo / use gh pr checkout need a local permissions block -# with contents: read. get-example-list and uptest already elevate for statuses. for job in get-example-list uptest; do if ! awk -v job="${job}" ' $0 ~ "^[[:space:]]*" job ":[[:space:]]*$" { in_job=1; next } - in_job && /^[[:space:]]+[a-zA-Z0-9_-]+:[[:space:]]*$/ && $0 !~ /^[[:space:]]{2}steps:/ { - # next sibling job (2-space indent under jobs:) - if (match($0, /^ [a-zA-Z0-9_-]+:[[:space:]]*$/)) { exit } - } in_job && /^ [a-zA-Z0-9_-]+:[[:space:]]*$/ && $0 !~ "^ " job ":" { exit } in_job && /^[[:space:]]+permissions:[[:space:]]*$/ { has_perm=1 } in_job && /contents:[[:space:]]*read/ { has_contents=1 } @@ -55,4 +69,4 @@ for job in get-example-list uptest; do fi done -echo "PASS: e2e.yaml has no workflow-level contents: read; jobs declare permissions locally" +echo "PASS: e2e.yaml workflow floor is read-only; jobs declare permissions locally" diff --git a/hack/test/sonar_pg_09_coverage_report_paths_test.sh b/hack/test/sonar_pg_09_coverage_report_paths_test.sh new file mode 100755 index 0000000..d7f9121 --- /dev/null +++ b/hack/test/sonar_pg_09_coverage_report_paths_test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-09: CI-based SonarCloud analysis must import the Go coverprofile +# produced by `make coverage` (cover.out). Automatic Analysis cannot ingest Go +# coverage; sonar-project.properties + a sonarcloud CI job are required (kollect / +# mkurator pattern). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROPS="${ROOT}/sonar-project.properties" +COV_WF="${ROOT}/.github/workflows/coverage.yml" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -f "${PROPS}" ]] || fail "missing ${PROPS} (CI scanner config; Autoscan cannot import Go coverage)" + +# Project identity must match the SonarCloud project used by SECURITY remediation. +grep -Eq '^sonar\.projectKey=PlatformRelay_provider-gridscale$' "${PROPS}" \ + || fail "${PROPS}: sonar.projectKey must be PlatformRelay_provider-gridscale" +grep -Eq '^sonar\.organization=platformrelay$' "${PROPS}" \ + || fail "${PROPS}: sonar.organization must be platformrelay" + +# Coverage report path must match Makefile `coverage` target output. +grep -Eq '^sonar\.go\.coverage\.reportPaths=cover\.out$' "${PROPS}" \ + || fail "${PROPS}: sonar.go.coverage.reportPaths must be cover.out (make coverage artifact)" + +# Preserve CPD exclusions from the Autoscan-era .sonarcloud.properties so the +# new_duplicated_lines_density gate does not regress when switching to CI analysis. +grep -Eq '^sonar\.cpd\.exclusions=.*internal/controller/\*\*/\*\.go' "${PROPS}" \ + || fail "${PROPS}: sonar.cpd.exclusions must keep internal/controller/**/*.go" +grep -Eq '^sonar\.exclusions=.*\*\*/zz_\*\.go' "${PROPS}" \ + || fail "${PROPS}: sonar.exclusions must keep **/zz_*.go" + +[[ -f "${COV_WF}" ]] || fail "missing ${COV_WF}" + +# coverage.yml must upload cover.out and run a sonarcloud job that consumes it. +grep -Eq 'files:[[:space:]]*cover\.out|name:[[:space:]]*coverage' "${COV_WF}" \ + || fail "${COV_WF}: must reference cover.out / coverage artifact" +grep -Eq 'sonarcloud:' "${COV_WF}" \ + || fail "${COV_WF}: must define a sonarcloud job" +grep -Eq 'sonarqube-scan-action|sonar-scanner' "${COV_WF}" \ + || fail "${COV_WF}: sonarcloud job must invoke the Sonar scanner action" + +echo "PASS: sonar-project.properties points at cover.out; coverage.yml has sonarcloud job" diff --git a/hack/test/sonar_pg_m01_register_init_not_empty_test.sh b/hack/test/sonar_pg_m01_register_init_not_empty_test.sh new file mode 100755 index 0000000..0c63ff0 --- /dev/null +++ b/hack/test/sonar_pg_m01_register_init_not_empty_test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M01: apis/{cluster,namespaced}/v1alpha1/register.go must not keep a +# bare-empty `func init() {}` (go:S1186). Require a nested comment or other +# non-empty body. Do not call AddToScheme from these stubs. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +check_register_init() { + local file="$1" + [[ -f "${file}" ]] || fail "missing ${file}" + + # Fail when `func init()` is declared with a bare-empty body: `func init() {}` + # (optional whitespace inside braces). Nested comments or statements pass. + if grep -Eq 'func[[:space:]]+init[[:space:]]*\([[:space:]]*\)[[:space:]]*\{[[:space:]]*\}' "${file}"; then + fail "${file}: bare-empty func init() {} (REQ-SONAR-PG-M01 / go:S1186)" + fi + + if ! grep -Eq 'func[[:space:]]+init[[:space:]]*\(' "${file}"; then + fail "${file}: missing func init() (do not delete; nest a comment instead)" + fi + + echo "PASS: ${file#$ROOT/} init body is not bare-empty" +} + +check_register_init "${ROOT}/apis/cluster/v1alpha1/register.go" +check_register_init "${ROOT}/apis/namespaced/v1alpha1/register.go" + +echo "PASS: REQ-SONAR-PG-M01 register init stubs are non-empty" diff --git a/hack/test/sonar_pg_m04_case_default_test.sh b/hack/test/sonar_pg_m04_case_default_test.sh new file mode 100755 index 0000000..ddfb296 --- /dev/null +++ b/hack/test/sonar_pg_m04_case_default_test.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M04: hack/check-docs.sh case must include a *) default arm (shelldre:S131). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="${ROOT}/hack/check-docs.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[[ -f "${SCRIPT}" ]] || fail "missing ${SCRIPT}" + +# Extract the case…esac that filters CRD basenames (must have an explicit default). +case_block="$(awk ' + /case[[:space:]]+"/ { in_case=1 } + in_case { print } + in_case && /^[[:space:]]*esac[[:space:]]*$/ { exit } +' "${SCRIPT}")" + +[[ -n "${case_block}" ]] || fail "${SCRIPT}: no case/esac block found" + +if ! printf '%s\n' "${case_block}" | grep -Eq '^[[:space:]]*\*\)'; then + fail "${SCRIPT}: case missing *) default arm (REQ-SONAR-PG-M04 / shelldre:S131)" +fi + +echo "PASS: REQ-SONAR-PG-M04 ${SCRIPT#$ROOT/} case has *) default" diff --git a/hack/test/sonar_pg_m05_stderr_errors_test.sh b/hack/test/sonar_pg_m05_stderr_errors_test.sh new file mode 100755 index 0000000..b7e381b --- /dev/null +++ b/hack/test/sonar_pg_m05_stderr_errors_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M05: ::error:: / failure echoes in check-docs.sh and check-api-docs.sh +# must go to stderr (shelldre:S7677). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +check_error_stderr() { + local file="$1" + [[ -f "${file}" ]] || fail "missing ${file}" + + # Every echo that emits ::error:: (or similarly labeled error text) must redirect >&2. + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + if ! grep -Eq '>&2[[:space:]]*$|&>\&2' <<<"${line}"; then + # Also accept >&2 before the string: echo >&2 "::error::..." + if ! grep -Eq 'echo[[:space:]]+>&2' <<<"${line}"; then + fail "${file}: error echo missing >&2: ${line}" + fi + fi + done < <(grep -E 'echo[[:space:]].*::error::' "${file}" || true) + + if ! grep -Eq 'echo[[:space:]].*::error::' "${file}"; then + fail "${file}: expected at least one ::error:: echo to guard (REQ-SONAR-PG-M05)" + fi + + echo "PASS: ${file#$ROOT/} ::error:: echoes redirect to stderr" +} + +check_error_stderr "${ROOT}/hack/check-docs.sh" +check_error_stderr "${ROOT}/hack/check-api-docs.sh" + +echo "PASS: REQ-SONAR-PG-M05 stderr error redirects" diff --git a/hack/test/sonar_pg_m06_prefer_double_bracket_test.sh b/hack/test/sonar_pg_m06_prefer_double_bracket_test.sh new file mode 100755 index 0000000..2e8d78f --- /dev/null +++ b/hack/test/sonar_pg_m06_prefer_double_bracket_test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M06: listed hack scripts must prefer [[ over [ (shelldre:S7688). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Files Sonar flagged for S7688 in E5-S20 (single-bracket tests). +FILES=( + "${ROOT}/hack/check-docs.sh" + "${ROOT}/hack/test/e8_s01_observe_docs_test.sh" + "${ROOT}/hack/test/e8_s01_observe_yaml_count_test.sh" + "${ROOT}/hack/test/e8_s02_backuplist_crd_exists_test.sh" + "${ROOT}/hack/test/e8_s03_publicnetwork_crd_exists_test.sh" +) + +# Match a test/command form of `[` that is not part of `[[` or array assignment. +# Heuristic: line starts (after indent) with `[` then space/flag — classic `[ -f` / `[ "$x"`. +single_bracket_re='^[[:space:]]*\[[[:space:]]' + +for file in "${FILES[@]}"; do + [[ -f "${file}" ]] || fail "missing ${file}" + if grep -Eq "${single_bracket_re}" "${file}"; then + fail "${file}: still uses single-bracket [ test (REQ-SONAR-PG-M06 / shelldre:S7688); prefer [[" + fi + echo "PASS: ${file#$ROOT/} has no single-bracket [ tests" +done + +echo "PASS: REQ-SONAR-PG-M06 prefer [[" diff --git a/hack/test/sonar_pg_m07_merge_nested_ifs_test.sh b/hack/test/sonar_pg_m07_merge_nested_ifs_test.sh new file mode 100755 index 0000000..3b4924e --- /dev/null +++ b/hack/test/sonar_pg_m07_merge_nested_ifs_test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M07: sonar_pg_02 / sonar_pg_04_* must not keep Sonar-flagged nested if +# pairs (shelldre:S1066). Merge with &&; preserve fail messages/conditions. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Detect a classic nested-if smell: an `if …; then` whose next non-blank/non-comment +# line is another `if …; then` at deeper indent (the S1066 pattern Sonar flagged). +has_nested_if_pair() { + local file="$1" + awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { + line = $0 + match(line, /^[[:space:]]*/) + indent = RLENGTH + is_if = (line ~ /^[[:space:]]*if[[:space:]]/) + if (prev_is_if && is_if && indent > prev_indent) { + found = 1 + exit + } + prev_is_if = is_if + prev_indent = indent + } + END { exit found ? 0 : 1 } + ' "${file}" +} + +FILES=( + "${ROOT}/hack/test/sonar_pg_02_no_secret_in_run_test.sh" + "${ROOT}/hack/test/sonar_pg_04_ci_job_scoped_permissions_test.sh" + "${ROOT}/hack/test/sonar_pg_04_e2e_job_scoped_permissions_test.sh" +) + +for file in "${FILES[@]}"; do + [[ -f "${file}" ]] || fail "missing ${file}" + if has_nested_if_pair "${file}"; then + fail "${file}: nested if pair still present (REQ-SONAR-PG-M07 / shelldre:S1066); merge with &&" + fi + + # Security / permission fail strings must remain (do not weaken greps). + case "${file}" in + *sonar_pg_02*) + grep -Fq 'secrets.UPTEST_DATASOURCE must not appear inside run:' "${file}" \ + || fail "${file}: missing secret-in-run fail message" + grep -Eq '\$\{\{[[:space:]]*secrets\.UPTEST_DATASOURCE' "${file}" \ + || fail "${file}: missing secrets.UPTEST_DATASOURCE grep" + ;; + *sonar_pg_04_ci*) + grep -Fq "workflow-level 'contents: read' must be removed" "${file}" \ + || fail "${file}: missing workflow-level contents: read fail message" + ;; + *sonar_pg_04_e2e*) + # e2e allows read-only workflow floor (a311484); still forbid write/admin. + grep -Eq "workflow-level permissions must not grant write/admin scopes" "${file}" \ + || fail "${file}: missing workflow write/admin forbid message" + ;; + esac + + echo "PASS: ${file#$ROOT/} nested ifs merged; fail semantics retained" +done + +echo "PASS: REQ-SONAR-PG-M07 merge nested ifs" diff --git a/hack/test/sonar_pg_m08_dockerfile_quoting_test.sh b/hack/test/sonar_pg_m08_dockerfile_quoting_test.sh new file mode 100755 index 0000000..97b089b --- /dev/null +++ b/hack/test/sonar_pg_m08_dockerfile_quoting_test.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# REQ-SONAR-PG-M08: Dockerfile Setup Terraform expansions must be quoted (docker:S6570). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DOCKERFILE="${ROOT}/cluster/images/provider-gridscale/Dockerfile" + +if [[ ! -f "${DOCKERFILE}" ]]; then + echo "FAIL: Dockerfile not found at ${DOCKERFILE}" >&2 + exit 1 +fi + +failures=0 + +assert_contains() { + local label="$1" + local pattern="$2" + + if grep -F -- "${pattern}" "${DOCKERFILE}" >/dev/null; then + echo "PASS: ${label}" + else + echo "FAIL: missing quoted form — ${label}" >&2 + echo " expected substring: ${pattern}" >&2 + failures=$((failures + 1)) + fi +} + +assert_absent() { + local label="$1" + local pattern="$2" + + if grep -F -- "${pattern}" "${DOCKERFILE}" >/dev/null; then + echo "FAIL: unquoted expansion still present — ${label}" >&2 + echo " forbidden substring: ${pattern}" >&2 + failures=$((failures + 1)) + else + echo "PASS: no unquoted ${label}" + fi +} + +# --- Required quoted forms (Setup Terraform block ≈ lines 27–39) --- +assert_contains 'mkdir PLUGIN_DIR' 'mkdir -p "${PLUGIN_DIR}"' + +assert_contains 'ADD terraform zip URL' \ + 'ADD "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip" /tmp' + +assert_contains 'ADD provider zip URL' \ + 'ADD "${TERRAFORM_PROVIDER_DOWNLOAD_URL_PREFIX}/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip" /tmp' + +assert_contains 'COPY terraformrc dest' 'COPY terraformrc.hcl "${TF_CLI_CONFIG_FILE}"' + +assert_contains 'unzip terraform zip' \ + 'unzip "/tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip"' + +assert_contains 'rm terraform zip' \ + 'rm "/tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip"' + +assert_contains 'unzip provider zip' \ + 'unzip "/tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip"' + +assert_contains 'unzip -d PLUGIN_DIR' '-d "${PLUGIN_DIR}"' + +assert_contains 'chmod PLUGIN_DIR glob' 'chmod +x "${PLUGIN_DIR}"/*' + +assert_contains 'rm provider zip' \ + 'rm "/tmp/${TERRAFORM_PROVIDER_DOWNLOAD_NAME}_${TERRAFORM_PROVIDER_VERSION}_${TARGETOS}_${TARGETARCH}.zip"' + +assert_contains 'chown USER_ID' 'chown -R "${USER_ID}:${USER_ID}" /terraform' + +# --- Forbidden unquoted forms that Sonar flags --- +assert_absent 'mkdir PLUGIN_DIR' 'mkdir -p ${PLUGIN_DIR}' +assert_absent 'ADD terraform zip (unquoted URL)' \ + 'ADD https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip /tmp' +assert_absent 'COPY terraformrc unquoted dest' 'COPY terraformrc.hcl ${TF_CLI_CONFIG_FILE}' +assert_absent 'unzip terraform unquoted' \ + 'unzip /tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip' +assert_absent 'rm terraform unquoted' \ + 'rm /tmp/terraform_${TERRAFORM_VERSION}_${TARGETOS}_${TARGETARCH}.zip' +assert_absent 'unzip -d PLUGIN_DIR unquoted' '-d ${PLUGIN_DIR}' +assert_absent 'chmod PLUGIN_DIR unquoted' 'chmod +x ${PLUGIN_DIR}/*' +assert_absent 'chown USER_ID unquoted' 'chown -R ${USER_ID}:${USER_ID} /terraform' + +if [[ "${failures}" -ne 0 ]]; then + echo "REQ-SONAR-PG-M08: ${failures} failure(s)" >&2 + exit 1 +fi + +echo "REQ-SONAR-PG-M08: all checks passed" diff --git a/openspec/changes/sonar-maintainability-2026-08/design.md b/openspec/changes/sonar-maintainability-2026-08/design.md new file mode 100644 index 0000000..c229117 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/design.md @@ -0,0 +1,11 @@ +# Design: sonar-maintainability-2026-08 + +## Approach +File-disjoint lanes (L-REG / L-CFGTEST / L-SHELL / L-DOCKER) land independently under +agent-loop-local. Each REQ has a meta **Test:** under `hack/test/sonar_pg_m*` and a **Verify:** +bash command. Prefer Sonar-accepted minimal fixes (nested comments, `[[`, `>&2`, quoted expansions) +over behaviour-changing refactors. + +## Decisions +- **D-023 → A:** nested comments in empty `init()` (not AddToScheme). +- Preserve E5-S17 Dockerfile `COPY` for local provider/terraformrc artefacts. diff --git a/openspec/changes/sonar-maintainability-2026-08/proposal.md b/openspec/changes/sonar-maintainability-2026-08/proposal.md new file mode 100644 index 0000000..060a84b --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/proposal.md @@ -0,0 +1,19 @@ +# Change: sonar-maintainability-2026-08 — SonarCloud MAINTAINABILITY + RELIABILITY + +## Why +SonarCloud reports **46 open CODE_SMELL** (Maintainability 38 · Reliability 8) on +`PlatformRelay_provider-gridscale` with zero SECURITY/BUG. Batch 11 (E5-S18…S21) clears the +hand-authored clusters: empty `init()`, config test complexity/named types, shell hygiene, and +Dockerfile expansion quoting. Generated `zz_*.go` smells stay deferred. + +## What Changes +- Nested comments in empty package `init()` stubs (`apis/{cluster,namespaced}/v1alpha1/register.go`). +- Refactor `config/*_test.go` for cognitive complexity ≤15 and named CRD view types. +- Shell hygiene in `hack/check-*.sh` and selected `hack/test/*` meta scripts. +- Quote Dockerfile expansions (preserve E5-S17 `COPY` for local artefacts). +- Meta tests `hack/test/sonar_pg_m0{1..8}_*` + OpenSpec REQs **PG-M01…PG-M08**. + +## Impact +- Affected: hand-authored register stubs, config tests, hack scripts, Dockerfile, OpenSpec. +- No product API/CRD behaviour change intended. +- Out of scope: coverage CI wiring (`sonar-coverage-ci`), generated code smells. diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m01.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m01.md new file mode 100644 index 0000000..1f55b81 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m01.md @@ -0,0 +1,17 @@ +# REQ-SONAR-PG-M01 — Empty register `init()` stubs are non-bare-empty + +## Requirement +Package `init()` stubs in `apis/cluster/v1alpha1/register.go` and +`apis/namespaced/v1alpha1/register.go` MUST NOT be bare-empty `func init() {}` +(Sonar go:S1186). Each body MUST contain a nested comment explaining that types +register via `SchemeBuilder` / generated `zz_*.go`. Callers MUST NOT wire +`AddToScheme` from these stubs (double-register risk). **D-023 → A.** + +## Scenario +Given the Upjet scaffold empty `init()` functions, when the fix lands, then each +`init` body includes an intentional nested comment and remains a no-op. + +## Test / Verify +- **Level:** M +- **Test:** `hack/test/sonar_pg_m01_register_init_not_empty_test.sh` +- **Verify:** `bash hack/test/sonar_pg_m01_register_init_not_empty_test.sh` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m02.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m02.md new file mode 100644 index 0000000..f05d5f2 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m02.md @@ -0,0 +1,19 @@ +# REQ-SONAR-PG-M02: Config test cognitive complexity ≤ 15 + +**Priority:** must · **Story:** E5-S19 · **Findings:** +`AZ9nqLQ5RbNgprH5pBN9`, `AZ9nqLQ5RbNgprH5pBN-`, `AZ9n-G-75WRcdOwb-njc` (`go:S3776`) + +**Given** `TestCRDGoldenContract`, `TestCRDStructuralInvariants` +(`config/crd_contract_test.go`), and `FuzzGetExternalName` +(`config/external_name_fuzz_test.go`) +**When** cognitive complexity is reduced via extracted helpers / early continue +(outcome-focused; goldens and stub contract unchanged) +**Then** each of the three functions reports cognitive complexity **≤ 15** under +Sonar (or an equivalent static check), and: +- `go test ./config/ -count=1` stays green without `UPDATE_GOLDEN=1` +- fuzz still builds: `go test ./config/ -run=FuzzGetExternalName -count=1` +- empty / non-string / missing `id` fuzz cases still enforce never-error + empty → `""` + +**Test:** `go test ./config/ -count=1` (characterization; optional +`hack/test/sonar_pg_m02_*` structural meta not required) +**Verify:** `go test ./config/ -count=1 && go test ./config/ -run=FuzzGetExternalName -count=1` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m03.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m03.md new file mode 100644 index 0000000..cd93b5f --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m03.md @@ -0,0 +1,15 @@ +# REQ-SONAR-PG-M03: Named CRD view types (no anonymous nesting) + +**Priority:** must · **Story:** E5-S19 · **Findings:** +`AZ9nqLQ5RbNgprH5pBN_` … `AZ9nqLQ5RbNgprH5pBOE` (`godre:S8205`, 6×) + +**Given** the nested anonymous structs inside `type crd` in +`config/crd_contract_test.go` (formerly ~lines 41–53) +**When** each flagged nesting level is replaced with a **named type** +(unexported OK) +**Then** JSON tags and unmarshalling behaviour are identical, and +`go test ./config/ -count=1` without `UPDATE_GOLDEN=1` still passes all golden + +structural invariants (byte-stable goldens for an unchanged CRD tree) + +**Test:** `go test ./config/ -count=1` +**Verify:** `go test ./config/ -count=1` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m04.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m04.md new file mode 100644 index 0000000..a1d8982 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m04.md @@ -0,0 +1,12 @@ +# REQ-SONAR-PG-M04 — check-docs case has default arm (S131) + +`hack/check-docs.sh` case over CRD basenames MUST include a `*)` default arm. + +## Related: e2e half of REQ-SONAR-PG-04 (D-025) +`hack/test/sonar_pg_04_e2e_*` allows a workflow-level **read-only floor** +(`permissions: contents: read` per a311484) and MUST reject `permissions: write-all` +and any workflow-level non-read grant. Jobs `get-example-list` / `uptest` still +declare local `contents: read`. + +**Test:** `hack/test/sonar_pg_m04_case_default_test.sh` +**Verify:** `bash hack/test/sonar_pg_m04_case_default_test.sh` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m05.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m05.md new file mode 100644 index 0000000..8dbd4cc --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m05.md @@ -0,0 +1,6 @@ +# REQ-SONAR-PG-M05 — doc-check errors on stderr (S7677) + +Failure / `::error::` messages in `hack/check-docs.sh` and `hack/check-api-docs.sh` +MUST go to stderr (`>&2`). +**Test:** `hack/test/sonar_pg_m05_stderr_errors_test.sh` +**Verify:** `bash hack/test/sonar_pg_m05_stderr_errors_test.sh` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m06.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m06.md new file mode 100644 index 0000000..235e9d5 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m06.md @@ -0,0 +1,5 @@ +# REQ-SONAR-PG-M06 — prefer `[[` over `[` (S7688) + +Listed `hack/check-docs.sh` and `hack/test/e8_s0{1,2,3}_*` predicates use bash `[[`. +**Test:** `hack/test/sonar_pg_m06_prefer_double_bracket_test.sh` +**Verify:** `bash hack/test/sonar_pg_m06_prefer_double_bracket_test.sh` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m07.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m07.md new file mode 100644 index 0000000..c49dd05 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m07.md @@ -0,0 +1,5 @@ +# REQ-SONAR-PG-M07 — merge nested ifs in sonar meta tests (S1066) + +`sonar_pg_02` / `sonar_pg_04_{ci,e2e}` nested `if` pairs are merged with equivalent fail conditions. +**Test:** `hack/test/sonar_pg_m07_merge_nested_ifs_test.sh` +**Verify:** `bash hack/test/sonar_pg_m07_merge_nested_ifs_test.sh` diff --git a/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m08.md b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m08.md new file mode 100644 index 0000000..45bfb17 --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/specs/maintainability/pg-m08.md @@ -0,0 +1,43 @@ +# REQ-SONAR-PG-M08 — Dockerfile quote expansions (docker:S6570) + +Clear Sonar MAJOR Maintainability findings on unquoted shell expansions in the +provider image Setup Terraform block without regressing the E5-S17 local-`COPY` +contract. + +**Sonar keys / rule** +- `cluster/images/provider-gridscale/Dockerfile:27–39` — docker:S6570 ×19 + keys `AZ-GeyaXbiMwwowBPDZ4` … `AZ-GeyaXbiMwwowBPDaK` + +**Lane:** L-DOCKER / E5-S21 + +## Given / When / Then + +**Given** the Setup Terraform block (~lines 27–39) expanding `${PLUGIN_DIR}`, +`${TERRAFORM_*}`, `${TARGETOS}`, `${TARGETARCH}`, `${USER_ID}`, zip paths, and +`${TF_CLI_CONFIG_FILE}`, +**when** the E5-S21 fix lands, +**then** each flagged expansion is double-quoted per docker:S6570 +(`mkdir -p "${PLUGIN_DIR}"`, quoted `ADD` URLs, quoted zip paths in `RUN`, +`chmod +x "${PLUGIN_DIR}"/*`, `chown -R "${USER_ID}:${USER_ID}"`, etc.). + +**Given** the E5-S17 / REQ-SONAR-PG-08 contract, +**when** this lane finishes, +**then** local artefacts still use **`COPY`** (not `ADD`) for +`bin/${TARGETOS}_${TARGETARCH}/provider` and `terraformrc.hcl`; remote URL +`ADD` may remain (quoted). + +**Edge — Given** a quoted Dockerfile build, +**when** `docker build` / CI image build runs, +**then** terraform + provider plugin still land under `${PLUGIN_DIR}` and the +non-root `USER` still applies (no path-split regressions from quoting). + +## Test / Verify + +| | | +| --- | --- | +| **Level** | M (meta) | +| **Test** | `hack/test/sonar_pg_m08_dockerfile_quoting_test.sh` | +| **Verify** | `bash hack/test/sonar_pg_m08_dockerfile_quoting_test.sh` | +| **Regression** | `bash hack/test/sonar_pg_08_dockerfile_copy_test.sh` | + +**Out of scope:** base-image bumps; `TERRAFORM_VERSION` pin changes; coverage CI. diff --git a/openspec/changes/sonar-maintainability-2026-08/tasks.md b/openspec/changes/sonar-maintainability-2026-08/tasks.md new file mode 100644 index 0000000..cd93e8b --- /dev/null +++ b/openspec/changes/sonar-maintainability-2026-08/tasks.md @@ -0,0 +1,10 @@ +# Tasks: sonar-maintainability-2026-08 + +- [x] E5-S18 / REQ-SONAR-PG-M01 — register init nested comments + `sonar_pg_m01_*` +- [ ] E5-S19 / REQ-SONAR-PG-M02 — config test cognitive complexity +- [ ] E5-S19 / REQ-SONAR-PG-M03 — named CRD view types +- [ ] E5-S20 / REQ-SONAR-PG-M04 — case default (S131) +- [ ] E5-S20 / REQ-SONAR-PG-M05 — stderr for errors (S7677) +- [ ] E5-S20 / REQ-SONAR-PG-M06 — `[[` over `[` (S7688) +- [ ] E5-S20 / REQ-SONAR-PG-M07 — merge nested ifs (S1066) +- [ ] E5-S21 / REQ-SONAR-PG-M08 — Dockerfile quoting (S6570) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..9a154e4 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 The Crossplane Authors +# +# SPDX-License-Identifier: CC0-1.0 + +# SonarCloud CI-based analysis (sonar-scanner / GitHub Action). +# Automatic Analysis cannot import Go coverage — disable it on the project +# (Administration → Analysis Method) before the first CI upload with cover.out. +# Project key matches the existing SonarCloud project used by E5 SECURITY work. + +sonar.projectKey=PlatformRelay_provider-gridscale +sonar.organization=platformrelay +sonar.projectName=provider-gridscale +sonar.sources=. +sonar.tests=. +sonar.test.inclusions=**/*_test.go + +# Generated code — exclude from ALL analysis (mirrors golangci `generated: lax` +# and the Autoscan-era .sonarcloud.properties exclusions). Test files are sources +# for the test sensor via sonar.test.inclusions, not main sources. +sonar.exclusions=**/zz_*.go,**/*_test.go,**/vendor/** + +# Hand-written cluster/namespaced mirror — suppress copy-paste only (CPD); +# these files still participate in bug/security/reliability analysis. +sonar.cpd.exclusions=internal/controller/**/*.go,apis/**/*_types.go + +# `make coverage` → cover.out (config + internal/clients hand-authored surface). +sonar.go.coverage.reportPaths=cover.out +sonar.coverage.exclusions=**/apis/**,**/cmd/**