diff --git a/.github/workflows/check-openapi-updates.yml b/.github/workflows/check-openapi-updates.yml index 4559a90a..8aa6ffd9 100644 --- a/.github/workflows/check-openapi-updates.yml +++ b/.github/workflows/check-openapi-updates.yml @@ -12,19 +12,4 @@ jobs: - uses: actions/checkout@v4 - name: Check OpenAPI updates run: make openapi-spec-check-updates - on-failure: - runs-on: ubuntu-latest - if: ${{ always() && (needs.check-open-api-spec-updates.result == 'failure' || needs.check-open-api-spec-updates.result == 'timed_out') }} - needs: - - check-open-api-spec-updates - steps: - - uses: actions/checkout@v4 - - name: Send Slack notification - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_CHANNEL: proj-cli - SLACK_COLOR: ${{ job.status }} - SLACK_ICON_EMOJI: ':launchdarkly:' - SLACK_TITLE: ':warning: The OpenAPI spec has changed and resources need to be updated.' - SLACK_USERNAME: github - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + continue-on-error: true diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cc405dba..b09aef9a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,15 +11,17 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.23' + cache: true + cache-dependency-path: go.sum - name: build run: go build . diff --git a/.github/workflows/lint-pr-title.yml b/.github/workflows/lint-pr-title.yml index 4ba79c13..1e5c7078 100644 --- a/.github/workflows/lint-pr-title.yml +++ b/.github/workflows/lint-pr-title.yml @@ -7,6 +7,12 @@ on: - edited - synchronize +permissions: + contents: read + jobs: lint-pr-title: + # This workflow is safe to use with pull_request_target because it only + # calls an external reusable workflow and does not execute any code + # from the PR itself. The GITHUB_TOKEN is read-only (contents: read). uses: launchdarkly/gh-actions/.github/workflows/lint-pr-title.yml@main diff --git a/README.md b/README.md deleted file mode 100644 index f83e81f8..00000000 --- a/README.md +++ /dev/null @@ -1,155 +0,0 @@ -[![NPM][npm-badge]][npm-link] -[![Docker][docker-badge]][docker-link] -[![GitHub release][ghrelease-badge]][ghrelease-link] - -# LaunchDarkly CLI - -The LaunchDarkly CLI helps you manage your feature flags from your terminal or your IDE. - -With the CLI, you can: - -- Create and evaluate your first feature flag with a guided `setup` command. -- Onboard your whole team by inviting new members. -- Interact with the [LaunchDarkly API](https://apidocs.launchdarkly.com/) using resource- and CRUD-based commands. - -## Installation - -The LaunchDarkly CLI is available for macOS, Windows, and Linux. - -### macOS -The CLI is available on macOS via [Homebrew](https://brew.sh/): -```shell -brew tap launchdarkly/homebrew-tap -brew install ldcli -``` - -### Windows -A Windows executable of `ldcli` is available on the [releases page](https://github.com/launchdarkly/ldcli/releases). - -### Linux -A Linux executable of `ldcli` is available on the [releases page](https://github.com/launchdarkly/ldcli/releases). - -### Additional installations - -You can also install the LaunchDarkly CLI using npm or Docker. - -#### npm -Install with npm: -```shell -npm install -g @launchdarkly/ldcli -``` - -#### Docker -Pull from Docker: -```shell -docker pull launchdarkly/ldcli -``` - -## Usage - -Installing the CLI provides access to the `ldcli` command. - -```sh-session -ldcli [command] - -# Run `--help` for detailed information about CLI commands -ldcli --help -``` - -## Configuration - -The LaunchDarkly CLI allows you to save preferred settings, either as environment variables or within a config file. Use the `config` commands to save your settings. - -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") -- `environment`: Default environment key -- `flag`: Default feature flag key -- `output`: Command response output format in either JSON or plain text -- `project`: Default project key - -Available `config` commands: - -- `config --set {key} {value}` -- `config --unset {key}` -- `config --list` - -To save a setting as an environment variable, prepend the variable name with `LD`. For example: - -```shell -export LD_ACCESS_TOKEN=api-00000000-0000-0000-0000-000000000000 -``` - -To save a setting in the configuration file: - -```shell -ldcli config --set access-token api-00000000-0000-0000-0000-000000000000 -``` - -Running this command creates a configuration file located at `$XDG_CONFIG_HOME/ldcli/config.yml` with the access token. Subsequent commands read from this file, so you do not need to specify the access token each time. - -## Commands - -LaunchDarkly CLI commands: - -- `setup` guides you through creating your first flag, connecting an SDK, and evaluating your flag in your Test environment -- `dev-server` lets you start a local server and retrieve flag values from a LaunchDarkly source environment so you can test your code locally. For assistance starting with or running dev-server, refer to the [reference docs](https://launchdarkly.com/docs/guides/flags/ldcli-dev-server). - -### Resource Commands - -Resource commands mirror the LaunchDarkly API and make requests for a given resource. To see a full list of resources supported by the CLI, enter `ldcli --help` into your terminal. - -To see the commands available for a given resource: - -```sh-session -ldcli --help -``` - -Here is an example command to create a flag: - -```sh-session -ldcli flags create --access-token --project default --data '{"name": "My Test Flag", "key": "my-test-flag"}' -``` - -## Documentation - -Additional documentation is available at https://docs.launchdarkly.com/home/getting-started/ldcli. - -## Contributing - -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 -1. Clone this repo to your local machine; -2. Run `make build` from the repo root; -3. Run commands as usual with `./ldcli`. - -## Verifying build provenance with the SLSA framework - -LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published packages. To learn more, see the [provenance guide](./PROVENANCE.md). - -## About LaunchDarkly - -* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: - * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. - * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). - * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. - * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. -* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. -* Explore LaunchDarkly - * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information - * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides - * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation - * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates - -[npm-badge]: https://img.shields.io/npm/v/@launchdarkly/ldcli.svg?style=flat-square -[npm-link]: https://www.npmjs.com/package/@launchdarkly/ldcli - -[docker-badge]: https://img.shields.io/docker/v/launchdarkly/ldcli.svg?style=flat-square&label=Docker -[docker-link]: https://hub.docker.com/r/launchdarkly/ldcli - -[ghrelease-badge]: https://img.shields.io/github/release/launchdarkly/ldcli.svg -[ghrelease-link]: https://github.com/launchdarkly/ldcli/releases/latest diff --git a/cmd/cliflags/flags.go b/cmd/cliflags/flags.go index b1615c2e..51bfbb47 100644 --- a/cmd/cliflags/flags.go +++ b/cmd/cliflags/flags.go @@ -1,9 +1,22 @@ package cliflags +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func GetOutputKind(cmd *cobra.Command) string { + if jsonFlag, err := cmd.Root().PersistentFlags().GetBool(JSONFlag); err == nil && jsonFlag { + return "json" + } + return viper.GetString(OutputFlag) +} + const ( BaseURIDefault = "https://app.launchdarkly.com" DevStreamURIDefault = "https://stream.launchdarkly.com" PortDefault = "8765" + HostDefault = "127.0.0.1" AccessTokenFlag = "access-token" AnalyticsOptOut = "analytics-opt-out" @@ -15,6 +28,8 @@ const ( EmailsFlag = "emails" EnvironmentFlag = "environment" FlagFlag = "flag" + HostFlag = "host" + JSONFlag = "json" OutputFlag = "output" PortFlag = "port" ProjectFlag = "project" @@ -29,6 +44,8 @@ const ( DevStreamURIDescription = "Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint" EnvironmentFlagDescription = "Default environment key" FlagFlagDescription = "Default feature flag key" + HostFlagDescription = "Host for the dev server to bind to (default: 127.0.0.1). Use 0.0.0.0 to allow external connections" + JSONFlagDescription = "Output JSON format (shorthand for --output json)" OutputFlagDescription = "Command response output format in either JSON or plain text" PortFlagDescription = "Port for the dev server to run on" ProjectFlagDescription = "Default project key" @@ -45,6 +62,7 @@ func AllFlagsHelp() map[string]string { DevStreamURIFlag: DevStreamURIDescription, EnvironmentFlag: EnvironmentFlagDescription, FlagFlag: FlagFlagDescription, + HostFlag: HostFlagDescription, OutputFlag: OutputFlagDescription, PortFlag: PortFlagDescription, ProjectFlag: ProjectFlagDescription, diff --git a/cmd/config/testdata/help.golden b/cmd/config/testdata/help.golden index 1ca8abbf..0161eaf5 100644 --- a/cmd/config/testdata/help.golden +++ b/cmd/config/testdata/help.golden @@ -9,6 +9,7 @@ Supported settings: - `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint - `environment`: Default environment key - `flag`: Default feature flag key +- `host`: Host for the dev server to bind to (default: 127.0.0.1). Use 0.0.0.0 to allow external connections - `output`: Command response output format in either JSON or plain text - `port`: Port for the dev server to run on - `project`: Default project key diff --git a/cmd/dev_server/dev_server.go b/cmd/dev_server/dev_server.go index 94886523..4e87b337 100644 --- a/cmd/dev_server/dev_server.go +++ b/cmd/dev_server/dev_server.go @@ -50,6 +50,14 @@ func NewDevServerCmd(client resources.Client, analyticsTrackerFn analytics.Track _ = viper.BindPFlag(cliflags.PortFlag, cmd.PersistentFlags().Lookup(cliflags.PortFlag)) + cmd.PersistentFlags().String( + cliflags.HostFlag, + cliflags.HostDefault, + cliflags.HostFlagDescription, + ) + + _ = viper.BindPFlag(cliflags.HostFlag, cmd.PersistentFlags().Lookup(cliflags.HostFlag)) + cmd.PersistentFlags().Bool( cliflags.CorsEnabledFlag, false, @@ -89,5 +97,9 @@ func NewDevServerCmd(client resources.Client, analyticsTrackerFn analytics.Track } func getDevServerUrl() string { - return fmt.Sprintf("http://localhost:%s", viper.GetString(cliflags.PortFlag)) + host := viper.GetString(cliflags.HostFlag) + if host == "0.0.0.0" { + host = "localhost" + } + return fmt.Sprintf("http://%s:%s", host, viper.GetString(cliflags.PortFlag)) } diff --git a/cmd/dev_server/start_server.go b/cmd/dev_server/start_server.go index a155d929..9530055b 100644 --- a/cmd/dev_server/start_server.go +++ b/cmd/dev_server/start_server.go @@ -89,6 +89,7 @@ func startServer(client dev_server.Client) func(*cobra.Command, []string) error BaseURI: viper.GetString(cliflags.BaseURIFlag), DevStreamURI: viper.GetString(cliflags.DevStreamURIFlag), Port: viper.GetString(cliflags.PortFlag), + Host: viper.GetString(cliflags.HostFlag), CorsEnabled: viper.GetBool(cliflags.CorsEnabledFlag), CorsOrigin: viper.GetString(cliflags.CorsOriginFlag), InitialProjectSettings: initialSetting, diff --git a/internal/dev_server/adapters/mocks/sdk.go b/internal/dev_server/adapters/mocks/sdk.go index 80c84dd8..2e713cf8 100644 --- a/internal/dev_server/adapters/mocks/sdk.go +++ b/internal/dev_server/adapters/mocks/sdk.go @@ -56,3 +56,17 @@ func (mr *MockSdkMockRecorder) GetAllFlagsState(ctx, ldContext, sdkKey any) *gom mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllFlagsState", reflect.TypeOf((*MockSdk)(nil).GetAllFlagsState), ctx, ldContext, sdkKey) } + +// Close mocks base method. +func (m *MockSdk) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockSdkMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSdk)(nil).Close)) +} diff --git a/internal/dev_server/adapters/sdk.go b/internal/dev_server/adapters/sdk.go index 1d64bfb6..531b4915 100644 --- a/internal/dev_server/adapters/sdk.go +++ b/internal/dev_server/adapters/sdk.go @@ -15,19 +15,6 @@ import ( const ctxKeySdk = ctxKey("adapters.sdk") -func WithSdk(ctx context.Context, s Sdk) context.Context { - return context.WithValue(ctx, ctxKeySdk, s) -} - -func GetSdk(ctx context.Context) Sdk { - return ctx.Value(ctxKeySdk).(Sdk) -} - -//go:generate go run go.uber.org/mock/mockgen -destination mocks/sdk.go -package mocks . Sdk -type Sdk interface { - GetAllFlagsState(ctx context.Context, ldContext ldcontext.Context, sdkKey string) (flagstate.AllFlags, error) -} - type streamingSdk struct { streamingUrl string } @@ -52,11 +39,30 @@ func (s streamingSdk) GetAllFlagsState(ctx context.Context, ldContext ldcontext. return flagstate.AllFlags{}, errors.Wrap(err, "unable to get source flags from LD SDK") } defer func() { - err := ldClient.Close() - if err != nil { + if err := ldClient.Close(); err != nil { log.Printf("error while closing SDK client: %+v", err) } }() - flags := ldClient.AllFlagsState(ldContext) - return flags, nil + return ldClient.AllFlagsState(ldContext), nil +} + +func (s streamingSdk) Close() error { + return nil +} + +func WithSdk(ctx context.Context, sdk Sdk) context.Context { + return context.WithValue(ctx, ctxKeySdk, sdk) +} + +func GetSdk(ctx context.Context) Sdk { + if sdk := ctx.Value(ctxKeySdk); sdk != nil { + return sdk.(Sdk) + } + return nil +} + +//go:generate go run go.uber.org/mock/mockgen -destination mocks/sdk.go -package mocks . Sdk +type Sdk interface { + GetAllFlagsState(ctx context.Context, ldContext ldcontext.Context, sdkKey string) (flagstate.AllFlags, error) + Close() error } 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..6796f9de 100644 --- a/internal/dev_server/dev_server.go +++ b/internal/dev_server/dev_server.go @@ -31,6 +31,7 @@ type ServerParams struct { BaseURI string DevStreamURI string Port string + Host string CorsEnabled bool CorsOrigin string InitialProjectSettings model.InitialProjectSettings @@ -109,7 +110,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("%s:%s", serverParams.Host, 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) diff --git a/internal/dev_server/model/store.go b/internal/dev_server/model/store.go index 8c3d9284..c3a6784c 100644 --- a/internal/dev_server/model/store.go +++ b/internal/dev_server/model/store.go @@ -38,7 +38,10 @@ func ContextWithStore(ctx context.Context, store Store) context.Context { } func StoreFromContext(ctx context.Context) Store { - return ctx.Value(ctxKeyStore).(Store) + if store := ctx.Value(ctxKeyStore); store != nil { + return store.(Store) + } + return nil } func StoreMiddleware(store Store) mux.MiddlewareFunc { diff --git a/internal/resources/client.go b/internal/resources/client.go index 01a06786..383e929c 100644 --- a/internal/resources/client.go +++ b/internal/resources/client.go @@ -49,7 +49,10 @@ func (c ResourcesClient) MakeRequest( isBeta bool, ) ([]byte, error) { client := http.Client{} - req, _ := http.NewRequest(method, path, bytes.NewReader(data)) + req, err := http.NewRequest(method, path, bytes.NewReader(data)) + if err != nil { + return nil, err + } req.Header.Add("Authorization", accessToken) req.Header.Add("Content-Type", contentType) req.Header.Set("User-Agent", fmt.Sprintf("launchdarkly-cli/v%s", c.cliVersion))