Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/_conforma-validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
packages: read
env:
# renovate: datasource=github-releases depName=conforma/cli
EC_VERSION: "0.10.1"
EC_VERSION: "0.10.2"
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down
6 changes: 5 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
["@semantic-release/commit-analyzer", {
"releaseRules": [
{ "breaking": true, "release": "minor" }
]
}],
"@semantic-release/release-notes-generator",
["@semantic-release/github", {
"successComment": false
Expand Down
23 changes: 18 additions & 5 deletions api/v1alpha1/certificate_immutability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,35 @@ func TestCertificateCertnameIsImmutable(t *testing.T) {
}
})

t.Run("the defaulted certname survives an unrelated update", func(t *testing.T) {
// The shared "puppet" default used to make two Certificates collide by
// default rather than by mistake: a certname identifies exactly one entry
// on the CA. There is no default any more, and an omitted certname is a
// validation error rather than a silent collision.
t.Run("an omitted certname is rejected", func(t *testing.T) {
cert := &Certificate{
ObjectMeta: metav1.ObjectMeta{GenerateName: "test-cert-", Namespace: "default"},
Spec: CertificateSpec{AuthorityRef: "production-ca"},
}
err := k8sClient.Create(ctx, cert)
if err == nil {
t.Cleanup(func() { _ = k8sClient.Delete(ctx, cert) })
t.Fatalf("expected a Certificate without a certname to be rejected, got certname %q", cert.Spec.Certname)
}
if !strings.Contains(err.Error(), "certname") {
t.Errorf("expected the error to name the certname field, got: %v", err)
}
})

t.Run("an unrelated update leaves the certname alone", func(t *testing.T) {
cert := newCert()
if err := k8sClient.Create(ctx, cert); err != nil {
t.Fatalf("creating Certificate: %v", err)
}
t.Cleanup(func() { _ = k8sClient.Delete(ctx, cert) })
if cert.Spec.Certname != "puppet" {
t.Fatalf("expected the default certname, got %q", cert.Spec.Certname)
}

cert.Spec.RenewBefore = "30d"
if err := k8sClient.Update(ctx, cert); err != nil {
t.Errorf("an update that leaves the defaulted certname alone must pass, got: %v", err)
t.Errorf("an update that does not touch the certname must pass, got: %v", err)
}
})
}
34 changes: 28 additions & 6 deletions api/v1alpha1/certificate_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,15 @@ type CertificateSpec struct {
// The name is baked into the issued certificate and into the entry the CA
// keeps for it. Changing it would leave that entry behind under the old
// name, so the finalizer could no longer clean it up on deletion.
// +kubebuilder:default="puppet"
//
// There is deliberately no default. A certname identifies exactly one entry
// on the CA, so a shared default made two Certificates collide by default
// rather than by mistake. "puppet" is also only the right identity for the
// main server: PuppetDB and any further certificate need their own. The
// names agents connect through belong in DNSAltNames, not here.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="certname is immutable"
// +optional
Certname string `json:"certname,omitempty"`
Certname string `json:"certname"`

// DNSAltNames is a list of DNS subject alternative names for the certificate.
// Order is irrelevant and duplicates are rejected.
Expand Down Expand Up @@ -119,14 +124,27 @@ type CertificateStatus struct {
// +optional
SecretName string `json:"secretName,omitempty"`

// SignedSpecHash digests the spec fields the current certificate was issued
// for: certname, dnsAltNames and csrExtensions. When it no longer matches
// the spec, the certificate is re-signed. An empty value means the hash was
// SignedSpecHash digests what the current certificate was issued for:
// certname, the effective alt names and csrExtensions. When it no longer
// matches, the certificate is re-signed. An empty value means the hash was
// never recorded (certificates issued before this field existed) and is
// adopted on the next reconcile rather than triggering a re-sign.
// +optional
SignedSpecHash string `json:"signedSpecHash,omitempty"`

// EffectiveDNSAltNames lists the alt names the certificate is actually
// issued for: spec.dnsAltNames plus the route hostname of every Pool that
// asks for injection and is joined by a Server using this Certificate.
//
// The Pool used to append its hostname to spec.dnsAltNames directly. Under
// GitOps that turned into a loop: the Pool added the name, the source of
// truth reverted it, the Pool added it again, and each round changed a
// signing-relevant field. Deriving it here writes nothing foreign and is
// idempotent.
// +listType=set
// +optional
EffectiveDNSAltNames []string `json:"effectiveDNSAltNames,omitempty"`

// NotAfter is the expiration time of the signed certificate.
// +optional
NotAfter *metav1.Time `json:"notAfter,omitempty"`
Expand All @@ -141,6 +159,10 @@ type CertificateStatus struct {
// Condition types for Certificate.
const (
ConditionCertSigned = "CertSigned"

// ConditionCertnameConflict reports that another Certificate already claims
// this certname against the same CertificateAuthority.
ConditionCertnameConflict = "CertnameConflict"
)

func init() {
Expand Down
7 changes: 6 additions & 1 deletion api/v1alpha1/config_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,16 @@ type ImageSpec struct {
Tag string `json:"tag,omitempty"`

// PullPolicy defines the image pull policy.
// +kubebuilder:default="IfNotPresent"
//
// A nested default would be materialised into every Server, which is what
// made the Server-level override unreachable: the field was never empty, so
// it could not express "inherit". Unset falls back to the Config and then
// to IfNotPresent.
// +optional
PullPolicy corev1.PullPolicy `json:"pullPolicy,omitempty"`

// PullSecrets is a list of image pull secrets.
// On Server an entry overrides the Config's list rather than adding to it.
// +optional
PullSecrets []corev1.LocalObjectReference `json:"pullSecrets,omitempty"`
}
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha1/list_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestListSemantics(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{GenerateName: "test-cert-", Namespace: "default"},
Spec: CertificateSpec{
AuthorityRef: "production-ca",
Certname: "web.example.com",
DNSAltNames: []string{"puppet.example.com", "puppet.example.com"},
},
}
Expand All @@ -39,6 +40,7 @@ func TestListSemantics(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{GenerateName: "test-cert-", Namespace: "default"},
Spec: CertificateSpec{
AuthorityRef: "production-ca",
Certname: "web.example.com",
DNSAltNames: []string{"a.example.com", "b.example.com"},
},
}
Expand Down
13 changes: 13 additions & 0 deletions api/v1alpha1/server_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@ type ServerSpec struct {
// (runAsUser/runAsGroup/fsGroup) applied to the Server pods.
// +optional
SecurityContext *PodSecurityContextSpec `json:"securityContext,omitempty"`

// ReadOnlyRootFilesystem overrides the Config's setting for this Server.
//
// One Config backs several Servers with different roles, typically the CA
// and the compilers, and hardening is otherwise a per-Server concern here
// alongside securityContext and extraVolumes. Without this override a
// single Server that needs a writable root forces the setting off for every
// Server under the Config, the CA included.
//
// Unset inherits from the Config. Deliberately a pointer: with a true
// default on the Config, a plain bool could not express false.
// +optional
ReadOnlyRootFilesystem *bool `json:"readOnlyRootFilesystem,omitempty"`
}

// PDBSpec defines PodDisruptionBudget settings.
Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,19 @@ spec:
signs this certificate.
type: string
certname:
default: puppet
description: |-
Certname is the certificate common name. Immutable after creation.

The name is baked into the issued certificate and into the entry the CA
keeps for it. Changing it would leave that entry behind under the old
name, so the finalizer could no longer clean it up on deletion.

There is deliberately no default. A certname identifies exactly one entry
on the CA, so a shared default made two Certificates collide by default
rather than by mistake. "puppet" is also only the right identity for the
main server: PuppetDB and any further certificate need their own. The
names agents connect through belong in DNSAltNames, not here.
minLength: 1
type: string
x-kubernetes-validations:
- message: certname is immutable
Expand Down Expand Up @@ -114,6 +120,7 @@ spec:
type: string
required:
- authorityRef
- certname
type: object
status:
description: CertificateStatus defines the observed state of Certificate.
Expand Down Expand Up @@ -178,6 +185,21 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
effectiveDNSAltNames:
description: |-
EffectiveDNSAltNames lists the alt names the certificate is actually
issued for: spec.dnsAltNames plus the route hostname of every Pool that
asks for injection and is joined by a Server using this Certificate.

The Pool used to append its hostname to spec.dnsAltNames directly. Under
GitOps that turned into a loop: the Pool added the name, the source of
truth reverted it, the Pool added it again, and each round changed a
signing-relevant field. Deriving it here writes nothing foreign and is
idempotent.
items:
type: string
type: array
x-kubernetes-list-type: set
notAfter:
description: NotAfter is the expiration time of the signed certificate.
format: date-time
Expand Down Expand Up @@ -210,9 +232,9 @@ spec:
type: string
signedSpecHash:
description: |-
SignedSpecHash digests the spec fields the current certificate was issued
for: certname, dnsAltNames and csrExtensions. When it no longer matches
the spec, the certificate is re-signed. An empty value means the hash was
SignedSpecHash digests what the current certificate was issued for:
certname, the effective alt names and csrExtensions. When it no longer
matches, the certificate is re-signed. An empty value means the hash was
never recorded (certificates issued before this field existed) and is
adopted on the next reconcile rather than triggering a re-sign.
type: string
Expand Down
13 changes: 10 additions & 3 deletions charts/openvox-operator/crds/openvox.voxpupuli.org_configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,18 @@ spec:
in this Config.
properties:
pullPolicy:
default: IfNotPresent
description: PullPolicy defines the image pull policy.
description: |-
PullPolicy defines the image pull policy.

A nested default would be materialised into every Server, which is what
made the Server-level override unreachable: the field was never empty, so
it could not express "inherit". Unset falls back to the Config and then
to IfNotPresent.
type: string
pullSecrets:
description: PullSecrets is a list of image pull secrets.
description: |-
PullSecrets is a list of image pull secrets.
On Server an entry overrides the Config's list rather than adding to it.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
Expand Down
13 changes: 10 additions & 3 deletions charts/openvox-operator/crds/openvox.voxpupuli.org_databases.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ spec:
description: Image defines the container image for the Database.
properties:
pullPolicy:
default: IfNotPresent
description: PullPolicy defines the image pull policy.
description: |-
PullPolicy defines the image pull policy.

A nested default would be materialised into every Server, which is what
made the Server-level override unreachable: the field was never empty, so
it could not express "inherit". Unset falls back to the Config and then
to IfNotPresent.
type: string
pullSecrets:
description: PullSecrets is a list of image pull secrets.
description: |-
PullSecrets is a list of image pull secrets.
On Server an entry overrides the Config's list rather than adding to it.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
Expand Down
26 changes: 23 additions & 3 deletions charts/openvox-operator/crds/openvox.voxpupuli.org_servers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3385,11 +3385,18 @@ spec:
description: Image overrides the Config's default image.
properties:
pullPolicy:
default: IfNotPresent
description: PullPolicy defines the image pull policy.
description: |-
PullPolicy defines the image pull policy.

A nested default would be materialised into every Server, which is what
made the Server-level override unreachable: the field was never empty, so
it could not express "inherit". Unset falls back to the Config and then
to IfNotPresent.
type: string
pullSecrets:
description: PullSecrets is a list of image pull secrets.
description: |-
PullSecrets is a list of image pull secrets.
On Server an entry overrides the Config's list rather than adding to it.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
Expand Down Expand Up @@ -3669,6 +3676,19 @@ spec:
description: PriorityClassName is the name of the PriorityClass for
the Server pods.
type: string
readOnlyRootFilesystem:
description: |-
ReadOnlyRootFilesystem overrides the Config's setting for this Server.

One Config backs several Servers with different roles, typically the CA
and the compilers, and hardening is otherwise a per-Server concern here
alongside securityContext and extraVolumes. Without this override a
single Server that needs a writable root forces the setting off for every
Server under the Config, the CA included.

Unset inherits from the Config. Deliberately a pointer: with a true
default on the Config, a plain bool could not express false.
type: boolean
replicas:
default: 1
description: Replicas is the number of Server instances.
Expand Down
3 changes: 3 additions & 0 deletions charts/openvox-stack/templates/certificates.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{{- range $entry := .Values.servers }}
{{- if not $entry.certificate.certname }}
{{- fail (printf "servers[%s].certificate.certname is required: a certname identifies exactly one entry on the CA, so two servers sharing one cannot both be signed" $entry.name) }}
{{- end }}
---
apiVersion: openvox.voxpupuli.org/v1alpha1
kind: Certificate
Expand Down
Loading
Loading