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
8 changes: 8 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ resources:
kind: ClusterWatchRule
path: github.com/ConfigButler/gitops-reverser/api/v1alpha3
version: v1alpha3
- api:
crdVersion: v1
namespaced: true
controller: true
domain: configbutler.ai
kind: GitReceiver
path: github.com/ConfigButler/gitops-reverser/api/v1alpha3
version: v1alpha3
- api:
crdVersion: v1
namespaced: true
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ agent over MCP.

Custom resources configure it: `GitProvider` for repository and credentials, `GitBranch` for one
branch of that repository, `GitTarget` for a folder on it, `WatchRule` or `ClusterWatchRule` for what
to capture, and `KubeProvider` for the source cluster, created as `local` in the operator's
namespace on install. A rule selects whole resource collections, including their deletions; the
`GitTarget`'s prune mode decides whether a deletion removes the file from Git.
to capture, `KubeProvider` for the source cluster, created as `local` in the operator's namespace on
install, and optionally `GitReceiver` for the Git host's push webhook. A rule selects whole resource
collections, including their deletions; the `GitTarget`'s prune mode decides whether a deletion
removes the file from Git.
See [Configuration](docs/configuration.md).

By default it watches its own cluster. Add more of each as you need them: one operator can serve
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha3/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ func newExampleObject(kind string) any {
return &GitBranch{}
case "GitTarget":
return &GitTarget{}
case "GitReceiver":
return &GitReceiver{}

case "KubeProvider":
return &KubeProvider{}
Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha3/gitbranch_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ type GitBranchStatus struct {
// +optional
Repository string `json:"repository,omitempty"`

// LastPushNoticeAt is when a GitReceiver last delivered a push to this branch, which made the
// branch worker look at once instead of at its next refresh. It records receipt only: whether
// that look reached the remote is status.remote's to say. Absent means no delivery has reached
// this branch: no GitReceiver is set up, the Git host's webhook does not reach one, the delivery
// names the repository by a URL other than the GitProvider's spec.url, or no branch worker writes
// the branch because no GitTarget references this GitBranch. It advances at most once a minute,
// and is cleared when the GitProvider is recreated for another repository.
// +optional
LastPushNoticeAt *metav1.Time `json:"lastPushNoticeAt,omitempty"`

// Remote is where this branch is on the Git remote, and when that was last proved. It is
// renewed by every confirmed look: a successful push as well as a fetch. Absent means nothing
// has proved anything yet: nothing has looked, or the GitProvider was recreated against a
Expand Down
83 changes: 14 additions & 69 deletions api/v1alpha3/gitprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
package v1alpha3

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"

meta "github.com/fluxcd/pkg/apis/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand All @@ -23,6 +19,13 @@ import (
// commit identity/signing.
//
// +kubebuilder:validation:XValidation:rule="self.url == oldSelf.url",message="spec.url is immutable; delete and recreate the GitProvider to point at a different repository"
//
// spec.receiver was replaced by the GitReceiver kind, which wakes every GitProvider whose spec.url
// its deliveries name. A pruned field would apply cleanly and leave a provider that used to be
// notified silently unnotified, so the old spelling stays in the schema only to be refused by name.
// The rule sits on spec, not on the field, so CRD validation ratcheting cannot wave a stored value
// through on the next edit.
// +kubebuilder:validation:XValidation:rule="!has(self.receiver)",message="spec.receiver was replaced by a GitReceiver: create one with this type and secretRef, and remove spec.receiver; deliveries wake every GitProvider whose spec.url they name"
type GitProviderSpec struct {
// URL of the repository (HTTP/SSH).
// Immutable: delete and recreate the GitProvider to point at a different repository.
Expand Down Expand Up @@ -66,68 +69,16 @@ type GitProviderSpec struct {
// +optional
Commit *CommitSpec `json:"commit,omitempty"`

// The names are Flux's Receiver's, type values included, so a Flux user already knows them.

// Receiver lets the Git host tell GitOps Reverser that somebody pushed. Point the host's push
// webhook at the receiver listener plus status.webhookPath, with the Secret's token as the
// webhook secret. A delivery is only a hint: it makes GitOps Reverser read where the branch is
// on the remote, and what the remote says decides whether a GitTarget's applier is asked to
// apply. Without a receiver, a foreign push is still found, by the next rejected push or by
// the periodic refresh.
// RemovedReceiver is the old place of a GitReceiver's type and secretRef. It is never read;
// setting it is refused.
// +optional
Receiver *GitProviderReceiver `json:"receiver,omitempty"`
RemovedReceiver *RemovedGitProviderReceiver `json:"receiver,omitempty"`
}

// GitProviderReceiver is the push-notification endpoint of a GitProvider.
type GitProviderReceiver struct {
// Type is the payload and signature the Git host sends. "github" checks X-Hub-Signature-256,
// "gitlab" checks X-Gitlab-Token, "gitea" checks X-Gitea-Signature, and "generic-hmac" checks
// X-Signature: sha256=<hex HMAC of the body> over a {"repository", "branch", "after"} body,
// for a relay of your own.
// +required
// +kubebuilder:validation:Enum=github;gitlab;gitea;generic-hmac
Type ReceiverType `json:"type"`

// SecretRef names a Secret in this namespace whose "token" key is the webhook secret. The
// token also derives status.webhookPath, so rotating it moves the path: register the new path
// with the new secret.
// +required
SecretRef meta.LocalObjectReference `json:"secretRef"`
}

// ReceiverType is the payload format a receiver accepts. The values are Flux's Receiver types.
type ReceiverType string

const (
// ReceiverTypeGitHub is a GitHub push event.
ReceiverTypeGitHub ReceiverType = "github"
// ReceiverTypeGitLab is a GitLab push event.
ReceiverTypeGitLab ReceiverType = "gitlab"
// ReceiverTypeGitea is a Gitea push event.
ReceiverTypeGitea ReceiverType = "gitea"
// ReceiverTypeGenericHMAC is a {"repository", "branch", "after"} body signed as Flux's
// generic-hmac Receiver expects.
ReceiverTypeGenericHMAC ReceiverType = "generic-hmac"
)

// ReceiverTokenKey is the Secret key holding a receiver's token, the key a Flux Receiver Secret uses.
const ReceiverTokenKey = "token"

// ReceiverPathPrefix is the prefix every receiver path starts with.
const ReceiverPathPrefix = "/git-push/"

// A path that names the GitProvider would let anybody who can reach the listener list which
// namespaces and providers exist. Like Flux's Receiver, the path is derived from the token, so
// only whoever holds the token can find it; unlike Flux's sha256(token+name+namespace), it is an
// HMAC over "namespace/name", which no two providers sharing a token can collide on.

// ReceiverPath is the path a GitProvider's receiver is served on: the prefix and the hex
// HMAC-SHA256 of "<namespace>/<name>", keyed by the receiver token.
func ReceiverPath(token []byte, namespace, name string) string {
mac := hmac.New(sha256.New, token)
mac.Write([]byte(namespace + "/" + name))
return ReceiverPathPrefix + hex.EncodeToString(mac.Sum(nil))
}
// RemovedGitProviderReceiver keeps spec.receiver in the schema so that setting it is refused rather
// than pruned. Any value is kept as written until the refusal names its replacement.
// +kubebuilder:pruning:PreserveUnknownFields
type RemovedGitProviderReceiver struct{}

// KnownHostsReference points at a namespace-local ConfigMap or Secret that holds SSH known_hosts
// host-trust material. The data is read from the "known_hosts" key, falling back to
Expand Down Expand Up @@ -223,12 +174,6 @@ type GitProviderStatus struct {
// that cadence. Providers are few and the check was already running; this adds no request.
// +optional
LastVerifiedAt *metav1.Time `json:"lastVerifiedAt,omitempty"`

// WebhookPath is the path spec.receiver is served on, under the receiver listener:
// /git-push/ and a hash of this provider's name and the receiver token. Empty without
// spec.receiver, and while the token Secret or its "token" key is missing.
// +optional
WebhookPath string `json:"webhookPath,omitempty"`
}

// CommitSpec configures the commit identity and signing a GitProvider uses. Message formatting
Expand Down
151 changes: 151 additions & 0 deletions api/v1alpha3/gitreceiver_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: Apache-2.0

package v1alpha3

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"

meta "github.com/fluxcd/pkg/apis/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

// The names are Flux's Receiver's, type values included, so a Flux user already knows them. The
// kind is not called Receiver because Flux's Receiver sits in the same clusters.

// GitReceiverSpec defines the desired state of GitReceiver.
type GitReceiverSpec struct {
// Type is the payload and signature the Git host sends. "github" checks X-Hub-Signature-256,
// "gitlab" checks X-Gitlab-Token, "gitea" checks X-Gitea-Signature, and "generic-hmac" checks
// X-Signature: sha256=<hex HMAC of the body> over a {"repository", "branch", "after"} body,
// for a relay of your own.
// +required
// +kubebuilder:validation:Enum=github;gitlab;gitea;generic-hmac
Type ReceiverType `json:"type"`

// SecretRef names a Secret in this GitReceiver's namespace whose "token" key is the webhook
// secret. The token also derives status.webhookPath, so rotating it moves the path: register the
// new path with the new secret.
// +required
// +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.secretRef.name must not be empty"
SecretRef meta.LocalObjectReference `json:"secretRef"`
}

// ReceiverType is the payload format a receiver accepts. The values are Flux's Receiver types.
type ReceiverType string

const (
// ReceiverTypeGitHub is a GitHub push event.
ReceiverTypeGitHub ReceiverType = "github"
// ReceiverTypeGitLab is a GitLab push event.
ReceiverTypeGitLab ReceiverType = "gitlab"
// ReceiverTypeGitea is a Gitea push event.
ReceiverTypeGitea ReceiverType = "gitea"
// ReceiverTypeGenericHMAC is a {"repository", "branch", "after"} body signed as Flux's
// generic-hmac Receiver expects.
ReceiverTypeGenericHMAC ReceiverType = "generic-hmac"
)

// ReceiverTokenKey is the Secret key holding a receiver's token, the key a Flux Receiver Secret uses.
const ReceiverTokenKey = "token"

// ReceiverPathPrefix is the prefix every receiver path starts with.
const ReceiverPathPrefix = "/git-push/"

// A path that names the GitReceiver would let anybody who can reach the listener list which
// namespaces and receivers exist. Like Flux's Receiver, the path is derived from the token, so
// only whoever holds the token can find it; unlike Flux's sha256(token+name+namespace), it is an
// HMAC over "namespace/name", which no two receivers sharing a token can collide on.

// ReceiverPath is the path a GitReceiver is served on: the prefix and the hex HMAC-SHA256 of
// "<namespace>/<name>", keyed by the receiver token.
func ReceiverPath(token []byte, namespace, name string) string {
mac := hmac.New(sha256.New, token)
mac.Write([]byte(namespace + "/" + name))
return ReceiverPathPrefix + hex.EncodeToString(mac.Sum(nil))
}

// GitReceiverStatus defines the observed state of GitReceiver.
type GitReceiverStatus struct {
// ObservedGeneration is the latest generation observed by the controller.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// Conditions report whether this GitReceiver serves deliveries: the Ready summary plus the
// kstatus Reconciling/Stalled pair. Ready is True once the token Secret is readable and the
// receiver listener is on.
// +optional
// +listType=map
// +listMapKey=type
// +patchMergeKey=type
// +patchStrategy=merge
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`

// WebhookPath is the path this GitReceiver is served on, under the receiver listener:
// /git-push/ and an HMAC of this GitReceiver's namespace and name, keyed by the token. Point the
// Git host's push webhook at the listener plus this path. Empty while the token Secret or its
// "token" key is missing.
// +optional
WebhookPath string `json:"webhookPath,omitempty"`
}

// A delivery is routed by the repository it names, not by a reference from each GitProvider: a push
// event carries little more than a repository and a branch, and a reference bought nothing but a
// line of wiring per provider and, for a GitHub App, an admission in every namespace. Waking across
// namespaces grants nothing, because a woken worker reads the remote with its own provider's
// credential, and it holds at most one pending look however many deliveries arrive. That bounds the
// queue, not the rate: a stream of deliveries can keep a worker reading continuously, an
// availability cost accepted without a per-worker cooldown.

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// +kubebuilder:printcolumn:name="Path",type=string,JSONPath=`.status.webhookPath`,priority=1
// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

// GitReceiver is the endpoint a Git host notifies on a push. A delivery to it authenticates against
// its token, names a repository and a branch, and wakes the branch worker of every GitProvider, in
// any namespace, whose spec.url the delivery names, for that branch. A provider reaching its host
// through an alias (another host name or port) is not matched, and finds pushes by its periodic
// refresh. A delivery is only a hint: the branch worker reads the remote with its own provider's
// credential to find out what happened, so nothing a delivery says can choose a URL, a credential
// or a commit. GitBranch.status.lastPushNoticeAt shows that deliveries reach a branch.
type GitReceiver struct {
metav1.TypeMeta `json:",inline"`

// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`

// spec defines the desired state of GitReceiver
// +required
Spec GitReceiverSpec `json:"spec"`

// status defines the observed state of GitReceiver
// +optional
Status GitReceiverStatus `json:"status,omitempty,omitzero"`
}

// Key is this GitReceiver's namespace and name.
func (r *GitReceiver) Key() types.NamespacedName {
return types.NamespacedName{Namespace: r.Namespace, Name: r.Name}
}

// +kubebuilder:object:root=true

// GitReceiverList contains a list of GitReceiver.
type GitReceiverList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`

Items []GitReceiver `json:"items"`
}

func init() {
SchemeBuilder.Register(&GitReceiver{}, &GitReceiverList{})
}
29 changes: 29 additions & 0 deletions api/v1alpha3/gitreceiver_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: Apache-2.0

package v1alpha3

import (
"testing"

"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

func TestGitReceiver_Key(t *testing.T) {
receiver := &GitReceiver{ObjectMeta: metav1.ObjectMeta{Name: "hook", Namespace: "platform"}}
assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "hook"}, receiver.Key())
}

// TestReceiverPath_KeyedByTokenAndIdentity is the path's whole contract: it names neither the
// namespace nor the receiver, and changes with each of the three inputs.
func TestReceiverPath_KeyedByTokenAndIdentity(t *testing.T) {
path := ReceiverPath([]byte("token"), "platform", "hook")
assert.Regexp(t, `^/git-push/[0-9a-f]{64}$`, path)
assert.NotContains(t, path, "platform")
assert.NotContains(t, path, "hook")
assert.Equal(t, path, ReceiverPath([]byte("token"), "platform", "hook"), "the path is stable")
assert.NotEqual(t, path, ReceiverPath([]byte("other"), "platform", "hook"))
assert.NotEqual(t, path, ReceiverPath([]byte("token"), "tenant-a", "hook"))
assert.NotEqual(t, path, ReceiverPath([]byte("token"), "platform", "other"))
}
8 changes: 8 additions & 0 deletions api/v1alpha3/status_accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func (p *GitProvider) SetObservedGeneration(generation int64) {
// StatusConditions returns the condition set to write through.
func (p *GitProvider) StatusConditions() *[]metav1.Condition { return &p.Status.Conditions }

// SetObservedGeneration records the generation this object's status describes.
func (r *GitReceiver) SetObservedGeneration(generation int64) {
r.Status.ObservedGeneration = generation
}

// StatusConditions returns the condition set to write through.
func (r *GitReceiver) StatusConditions() *[]metav1.Condition { return &r.Status.Conditions }

// SetObservedGeneration records the generation this object's status describes.
func (p *KubeProvider) SetObservedGeneration(generation int64) {
p.Status.ObservedGeneration = generation
Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha3/status_accessors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestStatusAccessors_ReachTheLiveStatus(t *testing.T) {

gitTarget := &GitTarget{}
gitProvider := &GitProvider{}
gitReceiver := &GitReceiver{}
kubeProvider := &KubeProvider{}
watchRule := &WatchRule{}
clusterWatchRule := &ClusterWatchRule{}
Expand All @@ -47,6 +48,11 @@ func TestStatusAccessors_ReachTheLiveStatus(t *testing.T) {
observed: func() int64 { return gitProvider.Status.ObservedGeneration },
stored: func() []metav1.Condition { return gitProvider.Status.Conditions },
},
{
name: "GitReceiver", object: gitReceiver,
observed: func() int64 { return gitReceiver.Status.ObservedGeneration },
stored: func() []metav1.Condition { return gitReceiver.Status.Conditions },
},
{
name: "KubeProvider", object: kubeProvider,
observed: func() int64 { return kubeProvider.Status.ObservedGeneration },
Expand Down
Loading
Loading