Skip to content
Draft
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
20 changes: 18 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ Kubernetes operator for managing Posit Team deployments.

```bash
just build # Build operator binary to ./bin/team-operator
just test # Run go tests
just test # Run go tests (unit tests only, no envtest/kubebuilder)
just mtest # Run all tests including integration tests (requires envtest)
just run # Run operator locally from source
just deps # Install dependencies
just mgenerate # Regenerate manifests after API changes
just mgenerate # Regenerate manifests and client-go after API changes
just helm-generate # Sync Helm chart with kustomize CRDs/RBAC (run after mgenerate)
just helm-lint # Lint Helm chart
just helm-template # Render Helm templates locally
just helm-install # Install operator via Helm
Expand All @@ -40,6 +42,20 @@ helm install team-operator ./dist/chart \
--create-namespace
```

## Testing

- **`just test`** runs `go test` directly — fast, but skips integration tests that need a control plane (etcd, kube-apiserver).
- **`just mtest`** runs `make test`, which uses `setup-envtest` to download kubebuilder binaries and sets `KUBEBUILDER_ASSETS` before running tests. Use this for controller/reconciler tests in `internal/controller/`.

Use `just mtest` when changing reconciler logic or controller tests. Use `just test` for quick feedback on unit-only packages (`api/`, `internal/` non-controller code).

## Code Generation

After changing CRD types in `api/`, run these in order:

1. **`just mgenerate`** — regenerates deepcopy, client-go, CRD manifests in `config/crd/`, and OpenAPI specs.
2. **`make helm-generate`** — copies CRDs and RBAC from `config/` into `dist/chart/templates/`. The Helm chart is not updated automatically by `mgenerate`, so this step is required or the chart will drift.

## Git Worktrees

**Always use git worktrees instead of plain branches.** This enables concurrent Claude sessions in the same repo.
Expand Down
56 changes: 52 additions & 4 deletions api/core/v1beta1/workbench_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ type WorkbenchSecretConfig struct {
}

type WorkbenchSecretIniConfig struct {
Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"`
OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"`
Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"`
Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"`
OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"`
Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"`
OAuthClients map[string]*WorkbenchOAuthClientConfig `json:"oauth-clients.conf,omitempty"`
}

func (w *WorkbenchSecretIniConfig) GenerateConfigMap() map[string]string {
Expand Down Expand Up @@ -69,7 +70,12 @@ func (w *WorkbenchSecretIniConfig) GenerateConfigMap() map[string]string {
valueKey := value.Type().Field(j).Name
valueVal := value.Field(j)

if fmt.Sprintf("%v", valueVal) != "" {
if valueVal.Kind() == reflect.Slice {
arrayString := sliceToString(valueVal, ",")
if arrayString != "" {
builder.WriteString(fmt.Sprintf("%v", toKebabCase(valueKey)) + "=" + arrayString + "\n")
}
} else if fmt.Sprintf("%v", valueVal) != "" {
builder.WriteString(fmt.Sprintf("%v", toKebabCase(valueKey)) + "=" + fmt.Sprintf("%v", valueVal) + "\n")
}
}
Expand Down Expand Up @@ -909,6 +915,48 @@ type WorkbenchDatabaseConfig struct {
Password string `json:"password,omitempty"`
}

// WorkbenchOAuthClientConfig defines configuration for a custom OAuth integration.
// See https://docs.posit.co/ide/server-pro/integration/custom-oauth.html for details.
type WorkbenchOAuthClientConfig struct {
// Name is the display name for this OAuth integration shown in the Workbench UI.
// If not provided, the section header (map key) is used as the display name.
// +optional
Name string `json:"name,omitempty"`

// ClientId is the OAuth client ID obtained from the external service provider.
// This field is required for the integration to function.
// +kubebuilder:validation:MinLength=1
ClientId string `json:"client-id,omitempty"`

// ClientSecret is populated automatically from the secret store.
// Do not set this field directly - instead, create a secret with the name
// "dev-oauth-client-secret-{integration-name}" in your secret store.
// +optional
ClientSecret string `json:"client-secret,omitempty"`

// AuthorizationUrl is the OAuth authorization endpoint URL.
// This field is required for the integration to function.
// +kubebuilder:validation:MinLength=1
AuthorizationUrl string `json:"authorization-url,omitempty"`

// TokenUrl is the OAuth token endpoint URL.
// This field is required for the integration to function.
// +kubebuilder:validation:MinLength=1
TokenUrl string `json:"token-url,omitempty"`

// TokenEndpointAuthMethod specifies how the client authenticates with the token endpoint.
// Common values: "client_secret_post", "client_secret_basic".
// Defaults to "client_secret_post" if not specified.
// +optional
// +kubebuilder:validation:Enum=client_secret_post;client_secret_basic
TokenEndpointAuthMethod string `json:"token-endpoint-auth-method,omitempty"`

// Scopes is a list of OAuth scopes to request from the authorization server.
// These are rendered as a comma-separated list in the configuration file.
// +optional
Scopes []string `json:"scopes,omitempty"`
}

type WorkbenchVsCodeConfig struct {
Enabled int `json:"enabled,omitempty"`
Exe string `json:"exe,omitempty"`
Expand Down
122 changes: 122 additions & 0 deletions api/core/v1beta1/workbench_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,128 @@ func TestWorkbenchSecretConfig_GenerateSecretData(t *testing.T) {
require.Len(t, res, 3)
}

func TestWorkbenchSecretConfig_GenerateSecretData_WithOAuthClients(t *testing.T) {
wb := WorkbenchSecretConfig{
WorkbenchSecretIniConfig{
OAuthClients: map[string]*WorkbenchOAuthClientConfig{
"box-integration": {
Name: "Box API Access",
ClientId: "box-client-id-123",
ClientSecret: "box-secret-xyz",
AuthorizationUrl: "https://account.box.com/api/oauth2/authorize",
TokenUrl: "https://api.box.com/oauth2/token",
TokenEndpointAuthMethod: "client_secret_post",
Scopes: []string{"read", "write"},
},
"google-drive": {
Name: "Google Drive",
ClientId: "google-client-id-456",
ClientSecret: "google-secret-abc",
AuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
TokenUrl: "https://oauth2.googleapis.com/token",
Scopes: []string{"https://www.googleapis.com/auth/drive.readonly"},
},
},
},
}

res, err := wb.GenerateSecretData()
require.Nil(t, err)
require.Contains(t, res, "oauth-clients.conf")

oauthConf := res["oauth-clients.conf"]

// Verify box-integration section
require.Contains(t, oauthConf, "[box-integration]")
require.Contains(t, oauthConf, "name=Box API Access")
require.Contains(t, oauthConf, "client-id=box-client-id-123")
require.Contains(t, oauthConf, "client-secret=box-secret-xyz")
require.Contains(t, oauthConf, "authorization-url=https://account.box.com/api/oauth2/authorize")
require.Contains(t, oauthConf, "token-url=https://api.box.com/oauth2/token")
require.Contains(t, oauthConf, "token-endpoint-auth-method=client_secret_post")
require.Contains(t, oauthConf, "scopes=read,write")

// Verify google-drive section
require.Contains(t, oauthConf, "[google-drive]")
require.Contains(t, oauthConf, "name=Google Drive")
require.Contains(t, oauthConf, "client-id=google-client-id-456")
require.Contains(t, oauthConf, "client-secret=google-secret-abc")
require.Contains(t, oauthConf, "authorization-url=https://accounts.google.com/o/oauth2/v2/auth")
require.Contains(t, oauthConf, "token-url=https://oauth2.googleapis.com/token")
require.Contains(t, oauthConf, "scopes=https://www.googleapis.com/auth/drive.readonly")

// Verify field names are converted to kebab-case
require.NotContains(t, oauthConf, "ClientId")
require.NotContains(t, oauthConf, "AuthorizationUrl")

// Verify trailing newline
require.True(t, strings.HasSuffix(oauthConf, "\n"))
}

func TestWorkbenchSecretConfig_GenerateSecretData_WithOAuthClients_EmptyScopes(t *testing.T) {
wb := WorkbenchSecretConfig{
WorkbenchSecretIniConfig{
OAuthClients: map[string]*WorkbenchOAuthClientConfig{
"dropbox": {
Name: "Dropbox",
ClientId: "dropbox-client-id",
ClientSecret: "dropbox-secret",
AuthorizationUrl: "https://www.dropbox.com/oauth2/authorize",
TokenUrl: "https://api.dropbox.com/oauth2/token",
// No scopes specified
},
},
},
}

res, err := wb.GenerateSecretData()
require.Nil(t, err)
require.Contains(t, res, "oauth-clients.conf")

oauthConf := res["oauth-clients.conf"]
require.Contains(t, oauthConf, "[dropbox]")
require.Contains(t, oauthConf, "name=Dropbox")
require.Contains(t, oauthConf, "client-id=dropbox-client-id")

// Scopes should not appear in output if empty
require.NotContains(t, oauthConf, "scopes=")
}

func TestWorkbenchSecretConfig_GenerateSecretData_WithMultipleConfigs(t *testing.T) {
wb := WorkbenchSecretConfig{
WorkbenchSecretIniConfig{
Database: &WorkbenchDatabaseConfig{
Provider: WorkbenchDatabaseProviderPostgres,
Database: "testdb",
Port: "5432",
Host: "localhost",
Username: "user",
},
OAuthClients: map[string]*WorkbenchOAuthClientConfig{
"box": {
Name: "Box",
ClientId: "box-id",
ClientSecret: "box-secret",
AuthorizationUrl: "https://account.box.com/api/oauth2/authorize",
TokenUrl: "https://api.box.com/oauth2/token",
},
},
},
}

res, err := wb.GenerateSecretData()
require.Nil(t, err)

// Should have both database.conf and oauth-clients.conf
require.Contains(t, res, "database.conf")
require.Contains(t, res, "oauth-clients.conf")
require.Len(t, res, 2)

// Verify both configs are properly formatted
require.Contains(t, res["database.conf"], "provider=postgresql")
require.Contains(t, res["oauth-clients.conf"], "[box]")
}

func TestWorkbenchConfig_GenerateConfigmap(t *testing.T) {
wb := WorkbenchConfig{
WorkbenchIniConfig: WorkbenchIniConfig{
Expand Down
36 changes: 36 additions & 0 deletions api/core/v1beta1/zz_generated.deepcopy.go

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

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

15 changes: 15 additions & 0 deletions client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go

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

Loading