Skip to content

feat(CC-0004): add common types and pure function packages#7

Merged
berendt merged 1 commit intomainfrom
feature/CC-0004
Mar 1, 2026
Merged

feat(CC-0004): add common types and pure function packages#7
berendt merged 1 commit intomainfrom
feature/CC-0004

Conversation

@berendt
Copy link
Contributor

@berendt berendt commented Mar 1, 2026

  • Add shared CRD-embeddable types in internal/common/types for
    ImageSpec, DatabaseSpec, MessagingSpec, CacheSpec, PolicySpec,
    PluginSpec, PipelineSpec, and related structs without kubebuilder
    markers (deferred to CRD embedding sites)
  • Add conditions package with SetCondition, GetCondition, IsReady,
    and AllTrue helpers for managing K8s status conditions with
    LastTransitionTime preservation on unchanged status
  • Add config package with deterministic INI rendering, user-wins
    MergeDefaults, InjectSecrets with MySQL connection string assembly
    and RFC 3986 password encoding, and InjectOsloPolicyConfig
  • Add plugins package with RenderPastePipeline for generating
    api-paste.ini with positional middleware insertion and
    RenderPluginConfig for converting plugin specs to config maps
  • Add policy package with MergePolicies (inline-wins precedence),
    ValidatePolicyRules returning field.ErrorList for webhooks, and
    RenderPolicyYAML with deterministic sorted key output
  • Add comprehensive table-driven tests with gomega for all packages
    covering edge cases, nil safety, and deterministic output
  • Add reference documentation for shared types and utility packages

berendt added a commit that referenced this pull request Mar 1, 2026
@sourcery-ai
Copy link

sourcery-ai bot commented Mar 1, 2026

Reviewer's Guide

Introduces a new internal/common module with shared CRD-embeddable types and pure-function helper packages for conditions, INI config generation, PasteDeploy plugin/pipeline rendering, and policy rule handling, all covered by table-driven tests and referenced in new docs.

Class diagram for shared internal common types

classDiagram
    class ImageSpec {
      string Repository
      string Tag
    }

    class SecretRefSpec {
      string Name
      string Key
    }

    class corev1_LocalObjectReference {
      string Name
    }

    class DatabaseSpec {
      corev1_LocalObjectReference ClusterRef
      string Host
      int32 Port
      string Database
      SecretRefSpec SecretRef
    }

    class MessagingSpec {
      string Host
      int32 Port
      SecretRefSpec SecretRef
      string Vhost
    }

    class CacheSpec {
      corev1_LocalObjectReference ClusterRef
      string[] Servers
    }

    class PolicySpec {
      corev1_LocalObjectReference ConfigMapRef
      map~string,string~ Inline
    }

    class PluginSpec {
      string Name
      string Section
      map~string,string~ Config
    }

    class PipelinePosition {
      string Before
      string After
    }

    class MiddlewareSpec {
      string Name
      string FilterFactory
      map~string,string~ Config
      PipelinePosition Position
    }

    class FilterSpec {
      string Name
      string Factory
      map~string,string~ Config
    }

    class PipelineSpec {
      string Name
      string BasePipeline
      FilterSpec[] Filters
      MiddlewareSpec[] Middleware
    }

    DatabaseSpec --> SecretRefSpec : uses
    DatabaseSpec --> corev1_LocalObjectReference : ClusterRef
    MessagingSpec --> SecretRefSpec : uses
    CacheSpec --> corev1_LocalObjectReference : ClusterRef
    PolicySpec --> corev1_LocalObjectReference : ConfigMapRef
    MiddlewareSpec --> PipelinePosition : embeds
    PipelineSpec --> FilterSpec : contains
    PipelineSpec --> MiddlewareSpec : contains
Loading

Class diagram for internal common utility packages and functions

classDiagram
    class conditions_Package {
      <<utility>>
      +SetCondition(conditions, conditionType, status, reason, message) void
      +GetCondition(conditions, conditionType) metav1_Condition_ptr
      +IsReady(conditions) bool
      +AllTrue(conditions, types) bool
    }

    class config_Package {
      <<utility>>
      +RenderINI(config) string
      +MergeDefaults(userConfig, defaults) map_string_map_string_string
      +InjectSecrets(config, secrets) map_string_map_string_string
      +InjectOsloPolicyConfig(config, policyFilePath) map_string_map_string_string
    }

    class plugins_Package {
      <<utility>>
      +RenderPastePipeline(spec) string
      +RenderPluginConfig(plugins) map_string_map_string_string
    }

    class policy_Package {
      <<utility>>
      +MergePolicies(external, inline) map_string_string
      +ValidatePolicyRules(rules, fldPath) field_ErrorList
      +RenderPolicyYAML(rules) string_or_error
    }

    class metav1_Condition {
      string Type
      string Status
      string Reason
      string Message
      time_Time LastTransitionTime
    }

    class types_PipelineSpec {
    }

    class types_PluginSpec {
    }

    class field_Path {
    }

    class field_ErrorList {
    }

    conditions_Package --> metav1_Condition : operates_on
    plugins_Package --> types_PipelineSpec : parameter
    plugins_Package --> types_PluginSpec : parameter
    policy_Package --> field_Path : uses
    policy_Package --> field_ErrorList : returns
Loading

Flow diagram for reconciler config and policy pipeline

flowchart TD
    A[CRD_Spec_Fields] --> B[MergeDefaults]
    D[Operator_Defaults] --> B
    B --> C[InjectSecrets]
    E[Resolved_Secrets] --> C
    C --> F[InjectOsloPolicyConfig]
    G[Policy_File_Path] --> F
    F --> H[RenderINI]
    H --> I[Main_INI_ConfigMap]

    A --> J[RenderPluginConfig]
    J --> K[Plugin_Config_Map]
    K --> L[MergeDefaults_Combined]
    H --> L
    L --> M[Final_INI_ConfigMap]

    A --> N[RenderPastePipeline]
    N --> O[api_paste_ini_ConfigMap]

    A --> P[MergePolicies]
    Q[External_Policy_ConfigMap_Rules] --> P
    P --> R[ValidatePolicyRules]
    R --> S[RenderPolicyYAML]
    S --> T[policy_yaml_ConfigMap]
Loading

File-Level Changes

Change Details Files
Add shared CRD-embeddable types for images, databases, messaging, cache, policy, plugins, and pipelines
  • Define ImageSpec, SecretRefSpec, DatabaseSpec, MessagingSpec, CacheSpec, PolicySpec, PluginSpec, PipelinePosition, MiddlewareSpec, FilterSpec, and PipelineSpec structs with JSON tags suitable for CRD embedding
  • Depend only on corev1.LocalObjectReference from k8s.io/api/core/v1 to reference other Kubernetes resources
  • Document the types package and its purpose in a package comment
internal/common/types/types.go
internal/common/types/doc.go
Implement pure helper functions for deterministic INI config rendering and secret-driven injection
  • RenderINI produces stable INI text by alphabetically sorting sections and keys and trimming trailing newlines
  • MergeDefaults deep-merges defaults into user config with user-wins semantics and without mutating inputs
  • InjectSecrets copies the config, then injects values from secrets keyed as section/key, including assembling MySQL connection strings from database/* parts with RFC 3986 password escaping and sensible defaults
  • InjectOsloPolicyConfig injects or adds an oslo_policy section with policy_file when a non-empty path is given
  • Provide an internal deep-copy helper for config maps and constants/logic for database part detection
internal/common/config/config.go
internal/common/config/doc.go
Provide condition helpers for K8s status management that preserve LastTransitionTime
  • SetCondition upserts a metav1.Condition in-place on a slice pointer, preserving LastTransitionTime if the status is unchanged and updating it otherwise
  • GetCondition returns a pointer to the first condition with the requested type or nil
  • IsReady checks for a Ready=True condition using GetCondition
  • AllTrue verifies that all requested condition types are present and True, with vacuous truth when no types are given
  • Document the conditions package intent
internal/common/conditions/conditions.go
internal/common/conditions/doc.go
Add PasteDeploy pipeline and plugin rendering helpers
  • RenderPastePipeline builds the pipeline token list by inserting middleware names into the BasePipeline according to PipelinePosition Before/After semantics, then renders a [pipeline:name] section followed by [filter:*] sections for both middleware and filters
  • Filters and middleware are rendered via a shared filterEntry representation, with sections sorted alphabetically by name and per-section config keys sorted to ensure determinism
  • RenderPluginConfig flattens []PluginSpec into a map[section]map[key]value, merging multiple plugins targeting the same section with last-wins semantics and without mutating inputs
  • Add small internal helpers insertMiddleware, insertToken, and renderFilterSection
internal/common/plugins/plugins.go
internal/common/plugins/doc.go
Implement policy rule merging, validation, and deterministic YAML rendering
  • MergePolicies returns a new map where inline rules override external ones, handling nil inputs gracefully
  • ValidatePolicyRules walks rules sorted by key and returns a field.ErrorList for empty keys and values using field.Invalid with deterministic ordering for webhook consumption
  • RenderPolicyYAML constructs a yaml.Node mapping with keys sorted alphabetically to produce stable output and returns "{}\n" for empty input
  • Document the policy package purpose
internal/common/policy/policy.go
internal/common/policy/doc.go
Add reference documentation for new shared types and utility packages
  • Describe each shared type, its fields, JSON tags, and intended CRD embedding patterns in internal-common-types.md
  • Document conditions, config, plugins, and policy packages, including function signatures, semantics, and typical reconciliation pipelines in internal-common-packages.md
  • Spell out expected config/policy/paste pipelines and future feature dependencies (e.g., CC-0005, CC-0011)
docs/reference/internal-common-types.md
docs/reference/internal-common-packages.md
Add comprehensive, table-driven tests for all new packages
  • Test JSON round-tripping and JSON tags of all shared types, including dual-mode database and cache specs and pipeline aggregation behaviour
  • Test RenderINI, MergeDefaults, InjectSecrets, and InjectOsloPolicyConfig for determinism, nil/empty handling, non-mutation of inputs, and correct URL-encoding/assembly of database connections
  • Test SetCondition, GetCondition, IsReady, and AllTrue for edge cases including nil pointers, unchanged status preserving LastTransitionTime, and multi-condition interactions
  • Test RenderPastePipeline and RenderPluginConfig for correct insertion ordering, section sorting, config key sorting, behaviour with empty/nil inputs, and section-merging semantics
  • Test MergePolicies, RenderPolicyYAML, and ValidatePolicyRules for precedence, deterministic YAML, special characters, nil/empty handling, and validation error generation
internal/common/types/types_test.go
internal/common/config/config_test.go
internal/common/conditions/conditions_test.go
internal/common/plugins/plugins_test.go
internal/common/policy/policy_test.go
Update planwerk tracking files for CC-0004 feature progress and review metadata
  • Move feature tracking JSON for CC-0004 from .planwerk/features to .planwerk/progress
  • Add a review record JSON for the first review of CC-0004 batch b001
.planwerk/features/CC-0004-b001-add-common-types-and-pure-function-packages.json
.planwerk/progress/CC-0004-b001-add-common-types-and-pure-function-packages.json
.planwerk/reviews/CC-0004-b001-add-common-types-and-pure-function-packages-review-1.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In RenderPolicyYAML, the comment says "Empty map returns empty YAML ("{}")" but the function actually returns "{}\n"; consider aligning the comment (and any reference docs) with the actual behavior to avoid confusion.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `RenderPolicyYAML`, the comment says "Empty map returns empty YAML ("{}")" but the function actually returns `"{}\n"`; consider aligning the comment (and any reference docs) with the actual behavior to avoid confusion.

## Individual Comments

### Comment 1
<location path="internal/common/plugins/plugins.go" line_range="89" />
<code_context>
+}
+
+// insertToken inserts a token into the pipeline at the position specified.
+func insertToken(tokens []string, name string, pos types.PipelinePosition) []string {
+	if pos.After != "" {
+		for i, t := range tokens {
+			if t == pos.After {
+				result := make([]string, 0, len(tokens)+1)
+				result = append(result, tokens[:i+1]...)
+				result = append(result, name)
+				result = append(result, tokens[i+1:]...)
+				return result
+			}
+		}
+	}
+
+	if pos.Before != "" {
+		for i, t := range tokens {
+			if t == pos.Before {
+				result := make([]string, 0, len(tokens)+1)
+				result = append(result, tokens[:i]...)
+				result = append(result, name)
+				result = append(result, tokens[i:]...)
+				return result
+			}
+		}
+	}
+
+	// Default: append to end.
+	return append(tokens, name)
+}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** insertToken prioritizes After over Before and silently appends when no anchor is found, which might hide configuration mistakes

Consider either failing when a requested `Before`/`After` anchor is not found (so configuration errors surface early) or clearly documenting that invalid anchors fall back to an append so callers understand the behavior and its risks.

```suggestion
// insertToken inserts a token into the pipeline at the position specified.
// If both After and Before are set, After takes precedence.
// If the requested anchor token (After/Before) is not found in the existing
// pipeline, the token is appended to the end. This fallback behavior is
// intentional; callers that require strict placement should validate their
// configuration before invoking insertToken.
```
</issue_to_address>

### Comment 2
<location path="internal/common/conditions/conditions_test.go" line_range="159-148" />
<code_context>
+func TestGetCondition(t *testing.T) {
</code_context>
<issue_to_address>
**issue (testing):** Add a test that mutating the condition returned by GetCondition also updates the underlying slice element

`TestGetCondition` currently only checks the fields of the returned pointer, but not that it references the original slice element. Please also verify that mutating the returned condition (e.g. `c.Status = ...`) updates the original `[]metav1.Condition`, ensuring `GetCondition` returns a pointer into the slice rather than a copy, e.g.:

```go
g := NewGomegaWithT(t)
conditions := []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse}}

c := GetCondition(conditions, "Ready")
g.Expect(c).NotTo(BeNil())
c.Status = metav1.ConditionTrue

g.Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue))
```
</issue_to_address>

### Comment 3
<location path="internal/common/config/config_test.go" line_range="183" />
<code_context>
+	})
+}
+
+func TestInjectSecrets(t *testing.T) {
+	tests := []struct {
+		name     string
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for incomplete or conflicting database secret inputs in InjectSecrets

The current `TestInjectSecrets` table is strong, but two edge cases are missing:

1. When only some of `database/user`, `database/password`, and `database/host` are set, `hasDatabaseParts` is false and `database.connection` is not produced. Please add a case that asserts this behavior so we don’t accidentally start emitting partial connection strings.
2. When both `database/connection` and the individual parts are provided, the code appears to prefer `database/connection`. A test that captures this precedence would help prevent regressions.

New table entries for these will better document and lock in the intended behavior for borderline configurations.
</issue_to_address>

### Comment 4
<location path="docs/reference/internal-common-packages.md" line_range="143" />
<code_context>
+| `config`  | `map[string]map[string]string`  | Current configuration to inject into           |
+| `secrets` | `map[string]string`             | Resolved secret values keyed by `section/key`  |
+
+**Returns:** `map[string]map[string]string` — new config with secrets injected. Empty secrets is a no-op.
+
+**Database connection assembly:** When individual secret parts (`database/user`, `database/password`, `database/host`, and optionally `database/port`, `database/name`) are present, they are assembled into a connection string:
</code_context>
<issue_to_address>
**issue (typo):** Consider changing "Empty secrets is a no-op" to "Empty secrets are a no-op".

Because "secrets" is plural, use "are" instead of "is" in that sentence.

```suggestion
**Returns:** `map[string]map[string]string` — new config with secrets injected. Empty secrets are a no-op.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

}
})
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Add a test that mutating the condition returned by GetCondition also updates the underlying slice element

TestGetCondition currently only checks the fields of the returned pointer, but not that it references the original slice element. Please also verify that mutating the returned condition (e.g. c.Status = ...) updates the original []metav1.Condition, ensuring GetCondition returns a pointer into the slice rather than a copy, e.g.:

g := NewGomegaWithT(t)
conditions := []metav1.Condition{{Type: "Ready", Status: metav1.ConditionFalse}}

c := GetCondition(conditions, "Ready")
g.Expect(c).NotTo(BeNil())
c.Status = metav1.ConditionTrue

g.Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue))

})
}

func TestInjectSecrets(t *testing.T) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for incomplete or conflicting database secret inputs in InjectSecrets

The current TestInjectSecrets table is strong, but two edge cases are missing:

  1. When only some of database/user, database/password, and database/host are set, hasDatabaseParts is false and database.connection is not produced. Please add a case that asserts this behavior so we don’t accidentally start emitting partial connection strings.
  2. When both database/connection and the individual parts are provided, the code appears to prefer database/connection. A test that captures this precedence would help prevent regressions.

New table entries for these will better document and lock in the intended behavior for borderline configurations.

- Add shared type definitions (ImageSpec, DatabaseSpec, MessagingSpec,
CacheSpec, PolicySpec, PluginSpec, PipelineSpec) for reuse across
OpenStack operator CRDs
- Add conditions package for managing K8s status conditions with
LastTransitionTime preservation on no-op updates
- Add config package with deterministic INI rendering, user-wins merge
semantics, secret injection with RFC 3986 password encoding, and
oslo_policy config injection
- Add plugins package for generating PasteDeploy api-paste.ini pipelines
with positional middleware insertion and plugin config rendering
- Add policy package for merging oslo.policy rules with inline-wins
precedence, validation via field.ErrorList, and deterministic YAML
rendering
- Include comprehensive table-driven tests with Gomega for all packages,
covering edge cases (nil inputs, empty maps, mutation safety,
idempotency)
- Add reference documentation for internal/common packages and types

AI-assisted: Claude Code
On-behalf-of: @SAP christian.berendt@sap.com
Signed-off-by: Christian Berendt <berendt@23technologies.cloud>
@berendt berendt merged commit 7fd3453 into main Mar 1, 2026
3 checks passed
@berendt berendt deleted the feature/CC-0004 branch March 1, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant