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
96 changes: 96 additions & 0 deletions go/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,99 @@ func TestTeamspaceSecretsAndSetSecretUseGeneratedRoutes(t *testing.T) {

}
}

func TestUserSecretsDeleteAndTypedCollision(t *testing.T) {
var seen []string

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
switch r.Method + " " + r.URL.Path {
case "GET /v1/secrets":
_ = json.NewEncoder(w).Encode(map[string]any{
"secrets": []map[string]any{
{"id": "typed-secret", "name": "TOKEN", "type": "SECRET_TYPE_DOCKER_REGISTRY"},
{"id": "generic-secret", "name": "OLD_TOKEN", "type": "SECRET_TYPE_UNSPECIFIED"},
},
})
case "POST /v1/secrets":
var body struct {
Name string `json:"name"`
Value string `json:"value"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "TOKEN", body.Name)
assert.Equal(t, "created", body.Value)
_ = json.NewEncoder(w).Encode(map[string]any{})
case "DELETE /v1/secrets/generic-secret":
_ = json.NewEncoder(w).Encode(map[string]any{})
default:
assert.Fail(t, fmt.Sprintf("unexpected request: %s %s", r.Method, r.URL.RequestURI()))
}
}))
defer server.Close()
t.Setenv("LIGHTNING_CLOUD_URL", server.URL)

u := mustUser(t, "user-1", "alice")
require.NoError(t, u.SetSecret("TOKEN", "created"))
require.NoError(t, u.DeleteSecret("OLD_TOKEN"))
require.ErrorContains(t, u.DeleteSecret("MISSING"), `generic user secret "MISSING" was not found`)
require.Error(t, u.DeleteSecret("invalid-name"))

assert.Equal(t, []string{
"GET /v1/secrets",
"POST /v1/secrets",
"GET /v1/secrets",
"DELETE /v1/secrets/generic-secret",
"GET /v1/secrets",
}, seen)
}

func TestTeamspaceSecretsDeleteAndTypedCollision(t *testing.T) {
var seen []string

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
switch r.Method + " " + r.URL.Path {
case "GET /v1/projects/project-1/secrets":
_ = json.NewEncoder(w).Encode(map[string]any{
"secrets": []map[string]any{
{"id": "typed-secret", "name": "TOKEN", "type": "SECRET_TYPE_HF_TOKEN"},
{"id": "generic-secret", "name": "OLD_TOKEN", "type": "SECRET_TYPE_UNSPECIFIED"},
},
})
case "POST /v1/projects/project-1/secrets":
var body struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "TOKEN", body.Name)
assert.Equal(t, "SECRET_TYPE_UNSPECIFIED", body.Type)
assert.Equal(t, "created", body.Value)
_ = json.NewEncoder(w).Encode(map[string]any{})
case "DELETE /v1/projects/project-1/secrets/generic-secret":
_ = json.NewEncoder(w).Encode(map[string]any{})
default:
assert.Fail(t, fmt.Sprintf("unexpected request: %s %s", r.Method, r.URL.RequestURI()))
}
}))
defer server.Close()
t.Setenv("LIGHTNING_CLOUD_URL", server.URL)

ts := mustTeamspace(t, "project-1", "default", "alice")
require.NoError(t, ts.SetSecret("TOKEN", "created"))
require.NoError(t, ts.DeleteSecret("OLD_TOKEN"))
require.ErrorContains(t, ts.DeleteSecret("MISSING"), `generic teamspace secret "MISSING" was not found`)
require.Error(t, ts.DeleteSecret("invalid-name"))

assert.Equal(t, []string{
"GET /v1/projects/project-1/secrets",
"POST /v1/projects/project-1/secrets",
"GET /v1/projects/project-1/secrets",
"DELETE /v1/projects/project-1/secrets/generic-secret",
"GET /v1/projects/project-1/secrets",
}, seen)
}
7 changes: 6 additions & 1 deletion go/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,15 @@ func validSecretName(name string) bool {
return secretNamePattern.MatchString(name)
}

func isGenericSecret(secret *models.V1Secret) bool {
return secret != nil &&
(secret.Type == nil || *secret.Type == models.V1SecretTypeSECRETTYPEUNSPECIFIED)
}

func redactedSecrets(secrets []*models.V1Secret) map[string]string {
result := map[string]string{}
for _, secret := range secrets {
if secret == nil || secret.Type == nil || *secret.Type != models.V1SecretTypeSECRETTYPEUNSPECIFIED {
if !isGenericSecret(secret) {
continue
}
result[secret.Name] = "***REDACTED***"
Expand Down
25 changes: 25 additions & 0 deletions go/studio.go
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,11 @@ func (s *Studio) SetEnv(newEnv map[string]string, partial bool) error {
if s == nil || s.teamspaceID == "" || s.id == "" {
return errors.New("studio set env requires teamspace ID and studio ID")
}
for key := range newEnv {
if !validSecretName(key) {
return errors.New("environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores")
}
}
updatedEnv := map[string]string{}
if partial {
for key, value := range s.env {
Expand Down Expand Up @@ -919,6 +924,26 @@ func (s *Studio) SetEnv(newEnv map[string]string, partial bool) error {
return nil
}

// DeleteEnv deletes one directly configured Studio environment variable.
func (s *Studio) DeleteEnv(key string) error {
if s == nil || s.teamspaceID == "" || s.id == "" {
return errors.New("studio delete env requires teamspace ID and studio ID")
}
if !validSecretName(key) {
return errors.New("environment variable names must start with a letter or underscore and contain only letters, numbers, and underscores")
}
if _, ok := s.env[key]; !ok {
return fmt.Errorf("studio environment variable %q was not found", key)
}
updated := make(map[string]string, len(s.env)-1)
for name, value := range s.env {
if name != key {
updated[name] = value
}
}
return s.SetEnv(updated, false)
}

// AvailablePlugins lists plugins available for installation.
func (s *Studio) AvailablePlugins() (map[string]string, error) {
if s == nil || s.teamspaceID == "" || s.id == "" {
Expand Down
46 changes: 46 additions & 0 deletions go/studio_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,52 @@ func TestStudioSetEnvMergesByDefault(t *testing.T) {

}

func TestStudioDeleteEnvValidatesAndPreservesUnrelatedVariables(t *testing.T) {
var seen []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/v1/projects/project-1/cloudspaces/studio-1", r.URL.Path)

var body struct {
Env []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"env"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Len(t, body.Env, 1)
assert.Equal(t, "KEEP", body.Env[0].Name)
assert.Equal(t, "yes", body.Env[0].Value)

_ = json.NewEncoder(w).Encode(map[string]any{
"id": "studio-1",
"name": "dev",
"projectId": "project-1",
"env": []map[string]any{{"name": "KEEP", "value": "yes"}},
})
}))
defer server.Close()
t.Setenv("LIGHTNING_CLOUD_URL", server.URL)

s, err := lit.GetStudio("dev", lit.StudioOptions{
ID: "studio-1",
Teamspace: mustTeamspace(t, "project-1", ""),
Env: map[string]string{"KEEP": "yes", "OLD": "remove"},
})
require.NoError(t, err)

require.Error(t, s.SetEnv(map[string]string{"INVALID-NAME": "value"}, true))
require.Error(t, s.DeleteEnv("INVALID-NAME"))
require.ErrorContains(t, s.DeleteEnv("MISSING"), `studio environment variable "MISSING" was not found`)
require.Empty(t, seen)

require.NoError(t, s.DeleteEnv("OLD"))
assert.Equal(t, map[string]string{"KEEP": "yes"}, s.Env())
assert.Equal(t, []string{"PUT /v1/projects/project-1/cloudspaces/studio-1"}, seen)
}

func TestStudioListsPluginsThroughGeneratedRoutes(t *testing.T) {
var seen []string

Expand Down
33 changes: 32 additions & 1 deletion go/teamspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ func (t *Teamspace) SetSecret(key, value string) error {
return err
}
for _, secret := range secrets {
if secret != nil && secret.Name == key {
if isGenericSecret(secret) && secret.Name == key {
_, err := api.SecretService.SecretServiceUpdateSecret(
secret_service.NewSecretServiceUpdateSecretParamsWithContext(context.Background()).
WithProjectID(id).
Expand All @@ -561,6 +561,37 @@ func (t *Teamspace) SetSecret(key, value string) error {
return err
}

// DeleteSecret deletes a generic teamspace secret by name.
func (t *Teamspace) DeleteSecret(key string) error {
if !validSecretName(key) {
return errors.New("secret keys must only contain alphanumeric characters and underscores and not begin with a number")
}
id, err := t.requireID("delete secret")
if err != nil {
return err
}
secrets, err := t.listSecrets()
if err != nil {
return err
}
for _, secret := range secrets {
if !isGenericSecret(secret) || secret.Name != key {
continue
}
api, err := sdkclient.New()
if err != nil {
return err
}
_, err = api.SecretService.SecretServiceDeleteSecret(
secret_service.NewSecretServiceDeleteSecretParamsWithContext(context.Background()).
WithProjectID(id).
WithID(secret.ID),
)
return err
}
return fmt.Errorf("generic teamspace secret %q was not found", key)
}

// NewFolder creates a managed folder in the teamspace.
func (t *Teamspace) NewFolder(name string, opts ...FolderOptions) error {
id, err := t.requireID("new folder")
Expand Down
31 changes: 30 additions & 1 deletion go/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (u *User) SetSecret(key, value string) error {
return err
}
for _, secret := range secrets {
if secret != nil && secret.Name == key {
if isGenericSecret(secret) && secret.Name == key {
_, err := api.SecretService.SecretServiceUpdateUserSecret(
secret_service.NewSecretServiceUpdateUserSecretParamsWithContext(context.Background()).
WithID(secret.ID).
Expand All @@ -200,6 +200,35 @@ func (u *User) SetSecret(key, value string) error {
return err
}

// DeleteSecret deletes a generic user secret by name.
func (u *User) DeleteSecret(key string) error {
if u == nil {
return errors.New("user delete secret requires user")
}
if !validSecretName(key) {
return errors.New("secret keys must only contain alphanumeric characters and underscores and not begin with a number")
}
secrets, err := u.listSecrets()
if err != nil {
return err
}
for _, secret := range secrets {
if !isGenericSecret(secret) || secret.Name != key {
continue
}
api, err := sdkclient.New()
if err != nil {
return err
}
_, err = api.SecretService.SecretServiceDeleteUserSecret(
secret_service.NewSecretServiceDeleteUserSecretParamsWithContext(context.Background()).
WithID(secret.ID),
)
return err
}
return fmt.Errorf("generic user secret %q was not found", key)
}

func (u *User) listSecrets() ([]*models.V1Secret, error) {
api, err := sdkclient.New()
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion python/lightning_sdk/api/deployment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1404,7 +1404,7 @@ def restore_env(env: List[V1EnvVar]) -> List[Union[Secret, Env]]:


def to_env(env: Union[List[Union[Secret, Env]], Dict[str, str], None] = None) -> Optional[List[V1EnvVar]]:
if not env:
if env is None:
return None

env_list = []
Expand Down
36 changes: 34 additions & 2 deletions python/lightning_sdk/api/teamspace_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ def _resolve_secret_type(secret_type: Union[str, SecretType]) -> V1SecretType:
return _SECRET_TYPE_TO_API[resolved]


def _secret_matches_type(secret: V1Secret, secret_type: V1SecretType) -> bool:
if secret_type == V1SecretType.UNSPECIFIED:
return secret.type in (None, V1SecretType.UNSPECIFIED)
return secret.type == secret_type


class TeamspaceApi:
"""Internal API client for Teamspace requests (mainly http requests)."""

Expand Down Expand Up @@ -941,7 +947,11 @@ def get_secrets(self, teamspace_id: str) -> Dict[str, str]:
# this returns encrypted values for security. It doesn't make sense to show them,
# so we just return a placeholder
# not a security issue to replace in the client as we get the encrypted values from the server.
return {secret.name: "***REDACTED***" for secret in secrets if secret.type == V1SecretType.UNSPECIFIED}
return {
secret.name: "***REDACTED***"
for secret in secrets
if _secret_matches_type(secret, V1SecretType.UNSPECIFIED)
}

def set_secret(
self,
Expand All @@ -965,10 +975,32 @@ def set_secret(
resolved_type = _resolve_secret_type(secret_type)
secrets = self._get_secrets(teamspace_id)
for secret in secrets:
if secret.name == key:
if secret.name == key and _secret_matches_type(secret, resolved_type):
return self._update_secret(teamspace_id, secret.id, value)
return self._create_secret(teamspace_id, key, value, secret_type=resolved_type)

def delete_secret(self, teamspace_id: str, key: str) -> None:
"""Delete a generic secret from a teamspace.

Args:
teamspace_id: ID of the teamspace that owns the secret.
key: The generic secret name.

Raises:
ValueError: If no generic secret with the given name exists.
"""
secret = next(
(
secret
for secret in self._get_secrets(teamspace_id)
if secret.name == key and _secret_matches_type(secret, V1SecretType.UNSPECIFIED)
),
None,
)
if secret is None:
raise ValueError(f"Generic teamspace secret {key!r} was not found.")
self._client.secret_service_delete_secret(project_id=teamspace_id, id=secret.id)

def _get_secrets(self, teamspace_id: str) -> List[V1Secret]:
"""Fetch all raw secret objects for the teamspace.

Expand Down
Loading
Loading