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
29 changes: 29 additions & 0 deletions api/v1alpha1/paperclipinstance_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ type InstanceSpec struct {
// +optional
Adapters AdaptersSpec `json:"adapters,omitempty"`

// Connections configures third-party OAuth provider credentials for
// the Paperclip connections system (GitHub, GitLab, Slack, etc.).
// +optional
Connections *ConnectionsSpec `json:"connections,omitempty"`

// Plugins lists plugins to install.
// +optional
Plugins []PluginRef `json:"plugins,omitempty"`
Expand Down Expand Up @@ -331,6 +336,30 @@ type AdaptersSpec struct {
APIKeysSecretRef *corev1.LocalObjectReference `json:"apiKeysSecretRef,omitempty"`
}

// ConnectionsSpec configures third-party OAuth provider credentials.
// The operator injects credentials as PAPERCLIP_OAUTH_CREDENTIALS from
// the referenced Secret, enabling the Paperclip connections system to
// manage OAuth flows and token lifecycle for external services.
type ConnectionsSpec struct {
// CredentialsSecretRef references a Secret containing OAuth client credentials.
// The Secret must contain a key (default "PAPERCLIP_OAUTH_CREDENTIALS") whose
// value is a JSON object mapping provider IDs to {clientId, clientSecret} pairs.
// Example: {"github":{"clientId":"...","clientSecret":"..."},"slack":{"clientId":"...","clientSecret":"..."}}
CredentialsSecretRef corev1.LocalObjectReference `json:"credentialsSecretRef"`

// CredentialsKey is the key within the Secret that holds the JSON credentials.
// Defaults to "PAPERCLIP_OAUTH_CREDENTIALS".
// +kubebuilder:default="PAPERCLIP_OAUTH_CREDENTIALS"
// +optional
CredentialsKey string `json:"credentialsKey,omitempty"`

// ProvidersConfigRef optionally references a ConfigMap containing a
// PAPERCLIP_OAUTH_PROVIDERS key with a JSON provider catalog to extend
// or override the built-in provider definitions at runtime.
// +optional
ProvidersConfigRef *corev1.LocalObjectReference `json:"providersConfigRef,omitempty"`
}

// PluginRef references a Paperclip plugin.
type PluginRef struct {
// Name is the plugin package name.
Expand Down
26 changes: 26 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 @@ -1397,6 +1397,55 @@ spec:
required:
- schedule
type: object
connections:
description: |-
Connections configures third-party OAuth provider credentials for
the Paperclip connections system (GitHub, GitLab, Slack, etc.).
properties:
credentialsKey:
default: PAPERCLIP_OAUTH_CREDENTIALS
description: |-
CredentialsKey is the key within the Secret that holds the JSON credentials.
Defaults to "PAPERCLIP_OAUTH_CREDENTIALS".
type: string
credentialsSecretRef:
description: |-
CredentialsSecretRef references a Secret containing OAuth client credentials.
The Secret must contain a key (default "PAPERCLIP_OAUTH_CREDENTIALS") whose
value is a JSON object mapping provider IDs to {clientId, clientSecret} pairs.
Example: {"github":{"clientId":"...","clientSecret":"..."},"slack":{"clientId":"...","clientSecret":"..."}}
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
providersConfigRef:
description: |-
ProvidersConfigRef optionally references a ConfigMap containing a
PAPERCLIP_OAUTH_PROVIDERS key with a JSON provider catalog to extend
or override the built-in provider definitions at runtime.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
required:
- credentialsSecretRef
type: object
database:
description: Database configures the PostgreSQL connection.
properties:
Expand Down
49 changes: 49 additions & 0 deletions config/crd/bases/paperclip.inc_instances.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,55 @@ spec:
required:
- schedule
type: object
connections:
description: |-
Connections configures third-party OAuth provider credentials for
the Paperclip connections system (GitHub, GitLab, Slack, etc.).
properties:
credentialsKey:
default: PAPERCLIP_OAUTH_CREDENTIALS
description: |-
CredentialsKey is the key within the Secret that holds the JSON credentials.
Defaults to "PAPERCLIP_OAUTH_CREDENTIALS".
type: string
credentialsSecretRef:
description: |-
CredentialsSecretRef references a Secret containing OAuth client credentials.
The Secret must contain a key (default "PAPERCLIP_OAUTH_CREDENTIALS") whose
value is a JSON object mapping provider IDs to {clientId, clientSecret} pairs.
Example: {"github":{"clientId":"...","clientSecret":"..."},"slack":{"clientId":"...","clientSecret":"..."}}
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
providersConfigRef:
description: |-
ProvidersConfigRef optionally references a ConfigMap containing a
PAPERCLIP_OAUTH_PROVIDERS key with a JSON provider catalog to extend
or override the built-in provider definitions at runtime.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
required:
- credentialsSecretRef
type: object
database:
description: Database configures the PostgreSQL connection.
properties:
Expand Down
4 changes: 4 additions & 0 deletions config/samples/paperclip_v1alpha1_instance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ spec:
apiKeysSecretRef:
name: paperclip-api-keys

# connections:
# credentialsSecretRef:
# name: paperclip-oauth-credentials

security:
networkPolicy:
enabled: true
Expand Down
26 changes: 26 additions & 0 deletions config/samples/paperclip_v1alpha1_instance_public.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ spec:
apiKeysSecretRef:
name: paperclip-api-keys

connections:
credentialsSecretRef:
name: paperclip-oauth-credentials

security:
networkPolicy:
enabled: true
Expand Down Expand Up @@ -108,3 +112,25 @@ spec:
startup:
failureThreshold: 60
periodSeconds: 5
---
# OAuth credentials for third-party connections (GitHub, Slack, etc.)
# Register OAuth apps with each provider and set the callback URL to:
# https://paperclip.example.com/api/connections/callback
apiVersion: v1
kind: Secret
metadata:
name: paperclip-oauth-credentials
namespace: paperclip
type: Opaque
stringData:
PAPERCLIP_OAUTH_CREDENTIALS: |
{
"github": {
"clientId": "Iv1.xxxxxxxxxxxxxxxx",
"clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"slack": {
"clientId": "1234567890.1234567890",
"clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
5 changes: 5 additions & 0 deletions internal/resources/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ const (
// DefaultPaperclipEntrypoint is the default Paperclip container entrypoint.
// Used when the operator needs to inject a shell wrapper (e.g., heartbeat leader election).
DefaultPaperclipEntrypoint = `node --import ./server/node_modules/tsx/dist/loader.mjs server/dist/index.js`

// EnvOAuthCredentials is the environment variable for OAuth provider credentials JSON.
EnvOAuthCredentials = "PAPERCLIP_OAUTH_CREDENTIALS" // #nosec G101 -- env var name, not a credential //nolint:gosec
// EnvOAuthProviders is the environment variable for custom OAuth provider definitions.
EnvOAuthProviders = "PAPERCLIP_OAUTH_PROVIDERS"
)

// Ptr returns a pointer to the given value.
Expand Down
93 changes: 93 additions & 0 deletions internal/resources/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,99 @@ func TestBuildPDB(t *testing.T) {
}
}

func TestBuildStatefulSetConnectionsEnvVars(t *testing.T) {
instance := newTestInstance("my-paperclip")
instance.Spec.Connections = &paperclipv1alpha1.ConnectionsSpec{
CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"},
}
sts := BuildStatefulSet(instance)
container := sts.Spec.Template.Spec.Containers[0]

var found bool
for _, env := range container.Env {
if env.Name == EnvOAuthCredentials {
found = true
if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil {
t.Fatal("expected SecretKeyRef for PAPERCLIP_OAUTH_CREDENTIALS")
}
if env.ValueFrom.SecretKeyRef.Name != "oauth-creds" {
t.Errorf("expected secret name 'oauth-creds', got %q", env.ValueFrom.SecretKeyRef.Name)
}
if env.ValueFrom.SecretKeyRef.Key != EnvOAuthCredentials {
t.Errorf("expected default key 'PAPERCLIP_OAUTH_CREDENTIALS', got %q", env.ValueFrom.SecretKeyRef.Key)
}
}
}
if !found {
t.Error("expected PAPERCLIP_OAUTH_CREDENTIALS env var")
}
}

func TestBuildStatefulSetConnectionsCustomKey(t *testing.T) {
instance := newTestInstance("my-paperclip")
instance.Spec.Connections = &paperclipv1alpha1.ConnectionsSpec{
CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"},
CredentialsKey: "custom-key",
}
sts := BuildStatefulSet(instance)
container := sts.Spec.Template.Spec.Containers[0]

for _, env := range container.Env {
if env.Name == EnvOAuthCredentials {
if env.ValueFrom.SecretKeyRef.Key != "custom-key" {
t.Errorf("expected key 'custom-key', got %q", env.ValueFrom.SecretKeyRef.Key)
}
return
}
}
t.Error("expected PAPERCLIP_OAUTH_CREDENTIALS env var")
}

func TestBuildStatefulSetConnectionsWithProvidersCatalog(t *testing.T) {
instance := newTestInstance("my-paperclip")
instance.Spec.Connections = &paperclipv1alpha1.ConnectionsSpec{
CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"},
ProvidersConfigRef: &corev1.LocalObjectReference{Name: "custom-providers"},
}
sts := BuildStatefulSet(instance)
container := sts.Spec.Template.Spec.Containers[0]

var foundCreds, foundProviders bool
for _, env := range container.Env {
if env.Name == EnvOAuthCredentials {
foundCreds = true
}
if env.Name == EnvOAuthProviders {
foundProviders = true
if env.ValueFrom == nil || env.ValueFrom.ConfigMapKeyRef == nil {
t.Fatal("expected ConfigMapKeyRef for PAPERCLIP_OAUTH_PROVIDERS")
}
if env.ValueFrom.ConfigMapKeyRef.Name != "custom-providers" {
t.Errorf("expected configmap name 'custom-providers', got %q", env.ValueFrom.ConfigMapKeyRef.Name)
}
}
}
if !foundCreds {
t.Error("expected PAPERCLIP_OAUTH_CREDENTIALS env var")
}
if !foundProviders {
t.Error("expected PAPERCLIP_OAUTH_PROVIDERS env var")
}
}

func TestBuildStatefulSetNoConnections(t *testing.T) {
instance := newTestInstance("my-paperclip")
// Connections is nil by default
sts := BuildStatefulSet(instance)
container := sts.Spec.Template.Spec.Containers[0]

for _, env := range container.Env {
if env.Name == EnvOAuthCredentials || env.Name == EnvOAuthProviders {
t.Errorf("unexpected OAuth env var %q when connections is nil", env.Name)
}
}
}

func TestLabels(t *testing.T) {
instance := newTestInstance("my-paperclip")
labels := Labels(instance)
Expand Down
Loading
Loading