Skip to content

Commit 3946709

Browse files
committed
feat: cw-credentials clarity and improved dev x
1 parent a936038 commit 3946709

5 files changed

Lines changed: 410 additions & 29 deletions

File tree

plugin/docs/cw-mounts.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,79 @@ kubectl create secret generic cw-credentials \
113113
--from-literal=AWS_SECRET_ACCESS_KEY=cwXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
114114
```
115115

116+
### Multi-Tenancy
117+
118+
Secrets are namespace-scoped in Kubernetes. For multi-tenant clusters:
119+
120+
1. Each team gets their own namespace
121+
2. Create `cw-credentials` in each namespace with team-specific S3 tokens
122+
3. The operator automatically uses the secret from the MarimoNotebook's namespace
123+
124+
Example setup for two teams:
125+
126+
```bash
127+
# Team Alpha - their own S3 token
128+
kubectl create secret generic cw-credentials -n team-alpha \
129+
--from-literal=AWS_ACCESS_KEY_ID=TEAM_ALPHA_KEY \
130+
--from-literal=AWS_SECRET_ACCESS_KEY=TEAM_ALPHA_SECRET
131+
132+
# Team Beta - different S3 token
133+
kubectl create secret generic cw-credentials -n team-beta \
134+
--from-literal=AWS_ACCESS_KEY_ID=TEAM_BETA_KEY \
135+
--from-literal=AWS_SECRET_ACCESS_KEY=TEAM_BETA_SECRET
136+
```
137+
138+
For RBAC lockdown, limit each team's Role to only access secrets in their namespace. The plugin prompts for confirmation in interactive terminals before creating secrets; in CI/CD (non-TTY) it creates automatically.
139+
140+
### Secret Persistence
141+
142+
Once created, the `cw-credentials` secret persists in the namespace until explicitly deleted. The plugin will reuse an existing secret without prompting.
143+
144+
To update credentials (e.g., after rotating tokens):
145+
146+
```bash
147+
# Delete the existing secret
148+
kubectl delete secret cw-credentials -n <namespace>
149+
150+
# The plugin will prompt to create a new one on next deploy
151+
kubectl marimo edit --source=cw://bucket notebook.py
152+
```
153+
154+
Or replace directly:
155+
156+
```bash
157+
kubectl delete secret cw-credentials -n <namespace>
158+
kubectl create secret generic cw-credentials -n <namespace> \
159+
--from-literal=AWS_ACCESS_KEY_ID=NEW_KEY \
160+
--from-literal=AWS_SECRET_ACCESS_KEY=NEW_SECRET
161+
```
162+
163+
### Credential Section Priority
164+
165+
The plugin reads credentials from `~/.s3cfg`, trying sections in order:
166+
167+
1. `[namespace]` - if deploying to a specific namespace (e.g., `[team-alpha]`)
168+
2. `[marimo]` - marimo-specific credentials
169+
3. `[default]` - standard s3cmd credentials
170+
171+
This allows namespace-specific or marimo-specific credentials:
172+
173+
```ini
174+
[default]
175+
access_key = GENERAL_KEY
176+
secret_key = GENERAL_SECRET
177+
178+
[marimo]
179+
access_key = MARIMO_KEY
180+
secret_key = MARIMO_SECRET
181+
182+
[team-alpha]
183+
access_key = TEAM_ALPHA_KEY
184+
secret_key = TEAM_ALPHA_SECRET
185+
```
186+
187+
When deploying with `kubectl marimo edit -n team-alpha ...`, the plugin will use `[team-alpha]` credentials if present.
188+
116189
## Usage
117190

118191
### URI Format

plugin/examples/getting-started.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def _(mo):
8383
@app.cell
8484
def _():
8585
import marimo as mo
86+
8687
return (mo,)
8788

8889

plugin/examples/with-rsync.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,5 @@
1111
app = marimo.App()
1212

1313

14-
15-
16-
1714
if __name__ == "__main__":
1815
app.run()

plugin/kubectl_marimo/deploy.py

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,52 +19,128 @@
1919
from .sync import sync_notebook
2020

2121

22+
def check_secret_exists(secret_name: str, namespace: str | None) -> bool:
23+
"""Check if a Kubernetes secret exists.
24+
25+
Args:
26+
secret_name: Name of the secret
27+
namespace: Kubernetes namespace (None = use kubectl context)
28+
29+
Returns:
30+
True if secret exists, False otherwise
31+
"""
32+
cmd = ["kubectl", "get", "secret", secret_name]
33+
if namespace is not None:
34+
cmd.extend(["-n", namespace])
35+
result = subprocess.run(cmd, capture_output=True)
36+
return result.returncode == 0
37+
38+
39+
def parse_s3_credentials(
40+
s3cfg_path: str, namespace: str | None = None
41+
) -> tuple[str | None, str | None, str | None]:
42+
"""Parse S3 credentials from ~/.s3cfg.
43+
44+
Tries sections in order: [namespace] -> [marimo] -> [default].
45+
This allows namespace-specific credentials for multi-tenant setups.
46+
47+
Args:
48+
s3cfg_path: Path to the s3cfg file
49+
namespace: Optional namespace to try as a section name first
50+
51+
Returns:
52+
(access_key, secret_key, section_name) or (None, None, None) if not found
53+
"""
54+
config = configparser.ConfigParser()
55+
config.read(s3cfg_path)
56+
57+
# Build section priority: namespace (if provided) -> marimo -> default
58+
sections = ["marimo", "default"]
59+
if namespace:
60+
sections.insert(0, namespace)
61+
62+
for section in sections:
63+
try:
64+
access_key = config.get(section, "access_key")
65+
secret_key = config.get(section, "secret_key")
66+
return access_key, secret_key, section
67+
except (configparser.NoSectionError, configparser.NoOptionError):
68+
continue
69+
70+
return None, None, None
71+
72+
2273
def ensure_cw_credentials(namespace: str | None) -> bool:
23-
"""Create cw-credentials secret from ~/.s3cfg if needed.
74+
"""Ensure cw-credentials secret exists for cw:// mounts.
75+
76+
If the secret already exists, returns True immediately.
77+
Otherwise, attempts to create it from ~/.s3cfg credentials.
78+
79+
In interactive terminals (TTY), prompts for confirmation before creating.
80+
In non-interactive environments (CI/CD), creates automatically.
2481
2582
Args:
2683
namespace: Kubernetes namespace (None = use kubectl context)
2784
2885
Returns:
2986
True if secret exists or was created, False if no credentials available
3087
"""
88+
# TODO: Support custom secret names via frontmatter `s3_secret` field
89+
# and pass through to operator via CRD spec.s3SecretName
90+
secret_name = "cw-credentials" # Hardcoded for now
91+
ns_display = namespace or "(current context)"
92+
93+
# Step 1: Check if secret already exists FIRST (most common case)
94+
if check_secret_exists(secret_name, namespace):
95+
click.echo(f"Using existing secret '{secret_name}' in {ns_display}")
96+
return True
97+
98+
# Step 2: Secret doesn't exist - check for local credentials
3199
s3cfg_path = os.path.expanduser("~/.s3cfg")
32100
if not os.path.exists(s3cfg_path):
33101
click.echo(
34-
"Warning: ~/.s3cfg not found. Run 's3cmd --configure' to set up credentials.",
102+
f"Secret '{secret_name}' not found and ~/.s3cfg does not exist.\n"
103+
"Options:\n"
104+
f" 1. Create secret manually:\n"
105+
f" kubectl create secret generic {secret_name} \\\n"
106+
f" --from-literal=AWS_ACCESS_KEY_ID=... \\\n"
107+
f" --from-literal=AWS_SECRET_ACCESS_KEY=...\n"
108+
" 2. Configure s3cmd: s3cmd --configure",
35109
err=True,
36110
)
37111
return False
38112

39-
# Check if secret already exists
40-
cmd = ["kubectl", "get", "secret", "cw-credentials"]
41-
if namespace is not None:
42-
cmd.extend(["-n", namespace])
43-
result = subprocess.run(
44-
cmd,
45-
capture_output=True,
46-
)
47-
if result.returncode == 0:
48-
return True # Already exists
113+
# Step 3: Parse credentials (try [namespace] -> [marimo] -> [default])
114+
access_key, secret_key, section = parse_s3_credentials(s3cfg_path, namespace)
115+
if not access_key or not secret_key:
116+
sections_tried = f"[{namespace}], " if namespace else ""
117+
click.echo(
118+
f"Could not read credentials from ~/.s3cfg.\n"
119+
f"Tried sections: {sections_tried}[marimo], [default]",
120+
err=True,
121+
)
122+
return False
49123

50-
# Parse ~/.s3cfg
51-
config = configparser.ConfigParser()
52-
config.read(s3cfg_path)
124+
# Step 4: Show what we're about to do
125+
click.echo(f"\nS3 Credentials:")
126+
click.echo(f" Namespace: {ns_display}")
127+
click.echo(f" Secret: {secret_name} (will create)")
128+
click.echo(f" Source: ~/.s3cfg [{section}]")
129+
click.echo(f" Access Key: ***")
53130

54-
try:
55-
access_key = config.get("default", "access_key")
56-
secret_key = config.get("default", "secret_key")
57-
except (configparser.NoSectionError, configparser.NoOptionError) as e:
58-
click.echo(f"Warning: Could not read credentials from ~/.s3cfg: {e}", err=True)
59-
return False
131+
# Step 5: Confirm with user if in interactive terminal
132+
if sys.stdin.isatty():
133+
if not click.confirm(f"\nCreate secret '{secret_name}'?"):
134+
click.echo("Secret creation skipped.")
135+
return False
60136

61-
# Create secret
137+
# Step 6: Create the secret
62138
cmd = [
63139
"kubectl",
64140
"create",
65141
"secret",
66142
"generic",
67-
"cw-credentials",
143+
secret_name,
68144
f"--from-literal=AWS_ACCESS_KEY_ID={access_key}",
69145
f"--from-literal=AWS_SECRET_ACCESS_KEY={secret_key}",
70146
]
@@ -78,12 +154,12 @@ def ensure_cw_credentials(namespace: str | None) -> bool:
78154
)
79155
if result.returncode != 0:
80156
click.echo(
81-
f"Warning: Failed to create cw-credentials secret: {result.stderr}",
157+
f"Failed to create secret: {result.stderr}",
82158
err=True,
83159
)
84160
return False
85161

86-
click.echo(f"Created cw-credentials secret in namespace {namespace}")
162+
click.echo(f"Created secret '{secret_name}'")
87163
return True
88164

89165

0 commit comments

Comments
 (0)