diff --git a/README.md b/README.md index f83e81f8..fcc5c7e4 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ The LaunchDarkly CLI allows you to save preferred settings, either as environmen Supported settings: -* `access-token` A LaunchDarkly access token with write-level access -* `analytics-opt-out` Opt out of analytics tracking (default false) -* `base-uri` LaunchDarkly base URI (default "https://app.launchdarkly.com") +- `access-token`: A LaunchDarkly access token with write-level access +- `analytics-opt-out`: Opt out of analytics tracking (default: false) +- `base-uri`: LaunchDarkly base URI (default: "https://app.launchdarkly.com") - `environment`: Default environment key - `flag`: Default feature flag key - `output`: Command response output format in either JSON or plain text @@ -122,7 +122,7 @@ Additional documentation is available at https://docs.launchdarkly.com/home/gett We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this project. ### Running a local build of the CLI -If you wish to test your changes locally, simply +If you wish to test your changes locally, simply: 1. Clone this repo to your local machine; 2. Run `make build` from the repo root; 3. Run commands as usual with `./ldcli`. diff --git a/internal/dev_server/db/sqlite.go b/internal/dev_server/db/sqlite.go index 2e847ff2..bfb8467e 100644 --- a/internal/dev_server/db/sqlite.go +++ b/internal/dev_server/db/sqlite.go @@ -3,14 +3,12 @@ package db import ( "context" "database/sql" - "encoding/json" "io" "os" _ "github.com/mattn/go-sqlite3" "github.com/pkg/errors" - "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/ldcli/internal/dev_server/db/backup" "github.com/launchdarkly/ldcli/internal/dev_server/model" ) @@ -24,344 +22,6 @@ type Sqlite struct { var _ model.Store = &Sqlite{} -func (s *Sqlite) GetDevProjectKeys(ctx context.Context) ([]string, error) { - rows, err := s.database.Query("select key from projects") - if err != nil { - return nil, err - } - var keys []string - for rows.Next() { - var key string - err = rows.Scan(&key) - if err != nil { - return nil, err - } - keys = append(keys, key) - } - return keys, nil -} - -func (s *Sqlite) GetDevProject(ctx context.Context, key string) (*model.Project, error) { - var project model.Project - var contextData string - var flagStateData string - - row := s.database.QueryRowContext(ctx, ` - SELECT key, source_environment_key, context, last_sync_time, flag_state - FROM projects - WHERE key = ? - `, key) - - if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &contextData, &project.LastSyncTime, &flagStateData); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, model.NewErrNotFound("project", key) - } - return nil, err - } - - // Parse the context JSON string - if err := json.Unmarshal([]byte(contextData), &project.Context); err != nil { - return nil, errors.Wrap(err, "unable to unmarshal context data") - } - - // Parse the flag state JSON string - if err := json.Unmarshal([]byte(flagStateData), &project.AllFlagsState); err != nil { - return nil, errors.Wrap(err, "unable to unmarshal flag state data") - } - - return &project, nil -} - -func (s *Sqlite) UpdateProject(ctx context.Context, project model.Project) (bool, error) { - flagsStateJson, err := json.Marshal(project.AllFlagsState) - if err != nil { - return false, errors.Wrap(err, "unable to marshal flags state when updating project") - } - - tx, err := s.database.BeginTx(ctx, nil) - if err != nil { - return false, err - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - result, err := tx.ExecContext(ctx, ` - UPDATE projects - SET flag_state = ?, last_sync_time = ?, context=?, source_environment_key=? - WHERE key = ?; - `, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.SourceEnvironmentKey, project.Key) - if err != nil { - return false, errors.Wrap(err, "unable to execute update project") - } - - // Delete all and add all new variations. Definitely room for optimization... - _, err = tx.ExecContext(ctx, ` - DELETE FROM available_variations - WHERE project_key = ? - `, project.Key) - if err != nil { - return false, err - } - - err = InsertAvailableVariations(ctx, tx, project) - if err != nil { - return false, err - } - - // Delete all overrides that are linked to a flag that is no longer in the project - // https://github.com/launchdarkly/ldcli/issues/541#issuecomment-2920512092 - _, err = tx.ExecContext(ctx, ` - DELETE FROM overrides - WHERE project_key = ? AND flag_key NOT IN (SELECT flag_key FROM available_variations WHERE project_key = ?) - `, project.Key, project.Key) - if err != nil { - return false, err - } - - err = tx.Commit() - if err != nil { - return false, err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return false, err - } - if rowsAffected == 0 { - return false, nil - } - - return true, nil -} - -func (s *Sqlite) DeleteDevProject(ctx context.Context, key string) (bool, error) { - result, err := s.database.Exec("DELETE FROM projects where key=?", key) - if err != nil { - return false, err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return false, err - } - if rowsAffected == 0 { - return false, nil - } - return true, nil -} - -func InsertAvailableVariations(ctx context.Context, tx *sql.Tx, project model.Project) (err error) { - for _, variation := range project.AvailableVariations { - jsonValue, err := variation.Value.MarshalJSON() - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, ` - INSERT INTO available_variations - (project_key, flag_key, id, value, description, name) - VALUES (?, ?, ?, ?, ?, ?) - `, project.Key, variation.FlagKey, variation.Id, string(jsonValue), variation.Description, variation.Name) - if err != nil { - return err - } - } - return nil -} - -func (s *Sqlite) InsertProject(ctx context.Context, project model.Project) (err error) { - flagsStateJson, err := json.Marshal(project.AllFlagsState) - if err != nil { - return errors.Wrap(err, "unable to marshal flags state when writing project") - } - tx, err := s.database.BeginTx(ctx, nil) - if err != nil { - return - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - projects, err := tx.QueryContext(ctx, ` -SELECT 1 FROM projects WHERE key = ? -`, project.Key) - if err != nil { - return - } - if projects.Next() { - err = model.NewErrAlreadyExists("project", project.Key) - return - } - err = projects.Close() - if err != nil { - return - } - _, err = tx.Exec(` -INSERT INTO projects (key, source_environment_key, context, last_sync_time, flag_state) -VALUES (?, ?, ?, ?, ?) -`, - project.Key, - project.SourceEnvironmentKey, - project.Context.JSONString(), - project.LastSyncTime, - string(flagsStateJson), - ) - if err != nil { - return - } - - err = InsertAvailableVariations(ctx, tx, project) - if err != nil { - return err - } - return tx.Commit() -} - -func (s *Sqlite) GetAvailableVariationsForProject(ctx context.Context, projectKey string) (map[string][]model.Variation, error) { - rows, err := s.database.QueryContext(ctx, ` - SELECT flag_key, id, name, description, value - FROM available_variations - WHERE project_key = ? - `, projectKey) - - if err != nil { - return nil, err - } - - availableVariations := make(map[string][]model.Variation) - for rows.Next() { - var flagKey string - var id string - var nameNullable sql.NullString - var descriptionNullable sql.NullString - var valueJson string - - err = rows.Scan(&flagKey, &id, &nameNullable, &descriptionNullable, &valueJson) - if err != nil { - return nil, err - } - - var value ldvalue.Value - err = json.Unmarshal([]byte(valueJson), &value) - if err != nil { - return nil, err - } - - var name, description *string - if nameNullable.Valid { - name = &nameNullable.String - } - if descriptionNullable.Valid { - description = &descriptionNullable.String - } - availableVariations[flagKey] = append(availableVariations[flagKey], model.Variation{ - Id: id, - Name: name, - Description: description, - Value: value, - }) - } - return availableVariations, nil -} - -func (s *Sqlite) GetOverridesForProject(ctx context.Context, projectKey string) (model.Overrides, error) { - rows, err := s.database.QueryContext(ctx, ` - SELECT flag_key, active, value, version - FROM overrides - WHERE project_key = ? - `, projectKey) - - if err != nil { - return nil, err - } - defer rows.Close() - - overrides := make(model.Overrides, 0) - for rows.Next() { - var flagKey string - var active bool - var value string - var version int - - err = rows.Scan(&flagKey, &active, &value, &version) - if err != nil { - return nil, err - } - - var ldValue ldvalue.Value - err = json.Unmarshal([]byte(value), &ldValue) - if err != nil { - return nil, err - } - overrides = append(overrides, model.Override{ - ProjectKey: projectKey, - FlagKey: flagKey, - Value: ldValue, - Active: active, - Version: version, - }) - } - - if err = rows.Err(); err != nil { - return nil, err - } - - return overrides, nil -} - -func (s *Sqlite) UpsertOverride(ctx context.Context, override model.Override) (model.Override, error) { - valueJson, err := override.Value.MarshalJSON() - if err != nil { - return model.Override{}, errors.Wrap(err, "unable to marshal override value when writing override") - } - row := s.database.QueryRowContext(ctx, ` - INSERT INTO overrides (project_key, flag_key, value, active) - VALUES (?, ?, ?, ?) - ON CONFLICT(flag_key, project_key) DO UPDATE SET - value=excluded.value, - active=excluded.active, - version=version+1 - RETURNING project_key, flag_key, active, value, version; - `, - override.ProjectKey, - override.FlagKey, - valueJson, - override.Active, - ) - var tempValue []byte - if err := row.Scan(&override.ProjectKey, &override.FlagKey, &override.Active, &tempValue, &override.Version); err != nil { - return model.Override{}, errors.Wrap(err, "unable to upsert override") - } - if err := json.Unmarshal(tempValue, &override.Value); err != nil { - return model.Override{}, errors.Wrap(err, "unable to unmarshal override value") - } - return override, nil -} - -func (s *Sqlite) DeactivateOverride(ctx context.Context, projectKey, flagKey string) (int, error) { - row := s.database.QueryRowContext(ctx, ` - UPDATE overrides - set active = false, version = version+1 - where project_key = ? and flag_key = ? and active = true - returning version - `, - projectKey, - flagKey, - ) - var version int - if err := row.Scan(&version); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return 0, errors.Wrapf(model.NewErrNotFound("flag", flagKey), "no override in project %s", projectKey) - } - return 0, err - } - - return version, nil -} - func (s *Sqlite) RestoreBackup(ctx context.Context, stream io.Reader) (string, error) { filepath, err := s.backupManager.RestoreToFile(ctx, stream) if err != nil { diff --git a/internal/dev_server/db/sqlite_overrides.go b/internal/dev_server/db/sqlite_overrides.go new file mode 100644 index 00000000..dc5f8a17 --- /dev/null +++ b/internal/dev_server/db/sqlite_overrides.go @@ -0,0 +1,107 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/pkg/errors" + + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/ldcli/internal/dev_server/model" +) + +func (s *Sqlite) GetOverridesForProject(ctx context.Context, projectKey string) (model.Overrides, error) { + rows, err := s.database.QueryContext(ctx, ` + SELECT flag_key, active, value, version + FROM overrides + WHERE project_key = ? + `, projectKey) + + if err != nil { + return nil, err + } + defer rows.Close() + + overrides := make(model.Overrides, 0) + for rows.Next() { + var flagKey string + var active bool + var value string + var version int + + err = rows.Scan(&flagKey, &active, &value, &version) + if err != nil { + return nil, err + } + + var ldValue ldvalue.Value + err = json.Unmarshal([]byte(value), &ldValue) + if err != nil { + return nil, err + } + overrides = append(overrides, model.Override{ + ProjectKey: projectKey, + FlagKey: flagKey, + Value: ldValue, + Active: active, + Version: version, + }) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return overrides, nil +} + +func (s *Sqlite) UpsertOverride(ctx context.Context, override model.Override) (model.Override, error) { + valueJson, err := override.Value.MarshalJSON() + if err != nil { + return model.Override{}, errors.Wrap(err, "unable to marshal override value when writing override") + } + row := s.database.QueryRowContext(ctx, ` + INSERT INTO overrides (project_key, flag_key, value, active) + VALUES (?, ?, ?, ?) + ON CONFLICT(flag_key, project_key) DO UPDATE SET + value=excluded.value, + active=excluded.active, + version=version+1 + RETURNING project_key, flag_key, active, value, version; + `, + override.ProjectKey, + override.FlagKey, + valueJson, + override.Active, + ) + var tempValue []byte + if err := row.Scan(&override.ProjectKey, &override.FlagKey, &override.Active, &tempValue, &override.Version); err != nil { + return model.Override{}, errors.Wrap(err, "unable to upsert override") + } + if err := json.Unmarshal(tempValue, &override.Value); err != nil { + return model.Override{}, errors.Wrap(err, "unable to unmarshal override value") + } + return override, nil +} + +func (s *Sqlite) DeactivateOverride(ctx context.Context, projectKey, flagKey string) (int, error) { + row := s.database.QueryRowContext(ctx, ` + UPDATE overrides + set active = false, version = version+1 + where project_key = ? and flag_key = ? and active = true + returning version + `, + projectKey, + flagKey, + ) + var version int + if err := row.Scan(&version); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, errors.Wrapf(model.NewErrNotFound("flag", flagKey), "no override in project %s", projectKey) + } + return 0, err + } + + return version, nil +} diff --git a/internal/dev_server/db/sqlite_projects.go b/internal/dev_server/db/sqlite_projects.go new file mode 100644 index 00000000..a27bfed5 --- /dev/null +++ b/internal/dev_server/db/sqlite_projects.go @@ -0,0 +1,255 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/pkg/errors" + + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/ldcli/internal/dev_server/model" +) + +func (s *Sqlite) GetDevProjectKeys(ctx context.Context) ([]string, error) { + rows, err := s.database.Query("select key from projects") + if err != nil { + return nil, err + } + var keys []string + for rows.Next() { + var key string + err = rows.Scan(&key) + if err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, nil +} + +func (s *Sqlite) GetDevProject(ctx context.Context, key string) (*model.Project, error) { + var project model.Project + var contextData string + var flagStateData string + + row := s.database.QueryRowContext(ctx, ` + SELECT key, source_environment_key, context, last_sync_time, flag_state + FROM projects + WHERE key = ? + `, key) + + if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &contextData, &project.LastSyncTime, &flagStateData); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, model.NewErrNotFound("project", key) + } + return nil, err + } + + // Parse the context JSON string + if err := json.Unmarshal([]byte(contextData), &project.Context); err != nil { + return nil, errors.Wrap(err, "unable to unmarshal context data") + } + + // Parse the flag state JSON string + if err := json.Unmarshal([]byte(flagStateData), &project.AllFlagsState); err != nil { + return nil, errors.Wrap(err, "unable to unmarshal flag state data") + } + + return &project, nil +} + +func (s *Sqlite) UpdateProject(ctx context.Context, project model.Project) (bool, error) { + flagsStateJson, err := json.Marshal(project.AllFlagsState) + if err != nil { + return false, errors.Wrap(err, "unable to marshal flags state when updating project") + } + + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + result, err := tx.ExecContext(ctx, ` + UPDATE projects + SET flag_state = ?, last_sync_time = ?, context=?, source_environment_key=? + WHERE key = ?; + `, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.SourceEnvironmentKey, project.Key) + if err != nil { + return false, errors.Wrap(err, "unable to execute update project") + } + + // Delete all and add all new variations. Definitely room for optimization... + _, err = tx.ExecContext(ctx, ` + DELETE FROM available_variations + WHERE project_key = ? + `, project.Key) + if err != nil { + return false, err + } + + err = InsertAvailableVariations(ctx, tx, project) + if err != nil { + return false, err + } + + // Delete all overrides that are linked to a flag that is no longer in the project + // https://github.com/launchdarkly/ldcli/issues/541#issuecomment-2920512092 + _, err = tx.ExecContext(ctx, ` + DELETE FROM overrides + WHERE project_key = ? AND flag_key NOT IN (SELECT flag_key FROM available_variations WHERE project_key = ?) + `, project.Key, project.Key) + if err != nil { + return false, err + } + + err = tx.Commit() + if err != nil { + return false, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return false, err + } + if rowsAffected == 0 { + return false, nil + } + + return true, nil +} + +func (s *Sqlite) DeleteDevProject(ctx context.Context, key string) (bool, error) { + result, err := s.database.Exec("DELETE FROM projects where key=?", key) + if err != nil { + return false, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return false, err + } + if rowsAffected == 0 { + return false, nil + } + return true, nil +} + +func InsertAvailableVariations(ctx context.Context, tx *sql.Tx, project model.Project) (err error) { + for _, variation := range project.AvailableVariations { + jsonValue, err := variation.Value.MarshalJSON() + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO available_variations + (project_key, flag_key, id, value, description, name) + VALUES (?, ?, ?, ?, ?, ?) + `, project.Key, variation.FlagKey, variation.Id, string(jsonValue), variation.Description, variation.Name) + if err != nil { + return err + } + } + return nil +} + +func (s *Sqlite) InsertProject(ctx context.Context, project model.Project) (err error) { + flagsStateJson, err := json.Marshal(project.AllFlagsState) + if err != nil { + return errors.Wrap(err, "unable to marshal flags state when writing project") + } + tx, err := s.database.BeginTx(ctx, nil) + if err != nil { + return + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + projects, err := tx.QueryContext(ctx, ` +SELECT 1 FROM projects WHERE key = ? +`, project.Key) + if err != nil { + return + } + if projects.Next() { + err = model.NewErrAlreadyExists("project", project.Key) + return + } + err = projects.Close() + if err != nil { + return + } + _, err = tx.Exec(` +INSERT INTO projects (key, source_environment_key, context, last_sync_time, flag_state) +VALUES (?, ?, ?, ?, ?) +`, + project.Key, + project.SourceEnvironmentKey, + project.Context.JSONString(), + project.LastSyncTime, + string(flagsStateJson), + ) + if err != nil { + return + } + + err = InsertAvailableVariations(ctx, tx, project) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Sqlite) GetAvailableVariationsForProject(ctx context.Context, projectKey string) (map[string][]model.Variation, error) { + rows, err := s.database.QueryContext(ctx, ` + SELECT flag_key, id, name, description, value + FROM available_variations + WHERE project_key = ? + `, projectKey) + + if err != nil { + return nil, err + } + + availableVariations := make(map[string][]model.Variation) + for rows.Next() { + var flagKey string + var id string + var nameNullable sql.NullString + var descriptionNullable sql.NullString + var valueJson string + + err = rows.Scan(&flagKey, &id, &nameNullable, &descriptionNullable, &valueJson) + if err != nil { + return nil, err + } + + var value ldvalue.Value + err = json.Unmarshal([]byte(valueJson), &value) + if err != nil { + return nil, err + } + + var name, description *string + if nameNullable.Valid { + name = &nameNullable.String + } + if descriptionNullable.Valid { + description = &descriptionNullable.String + } + availableVariations[flagKey] = append(availableVariations[flagKey], model.Variation{ + Id: id, + Name: name, + Description: description, + Value: value, + }) + } + return availableVariations, nil +} diff --git a/internal/dev_server/dev_server.go b/internal/dev_server/dev_server.go index a042e7f3..30bfa093 100644 --- a/internal/dev_server/dev_server.go +++ b/internal/dev_server/dev_server.go @@ -109,7 +109,7 @@ func (c LDClient) RunServer(ctx context.Context, serverParams ServerParams) { } handler := handlers.CombinedLoggingHandler(os.Stdout, r) - addr := fmt.Sprintf("0.0.0.0:%s", serverParams.Port) + addr := fmt.Sprintf("127.0.0.1:%s", serverParams.Port) log.Printf("Server running on %s", addr) log.Printf("Access the UI for toggling overrides at http://localhost:%s/ui or by running `ldcli dev-server ui`", serverParams.Port)