Skip to content
Closed
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
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
signupcmd "github.com/launchdarkly/ldcli/cmd/signup"
sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps"
symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols"
synccmd "github.com/launchdarkly/ldcli/cmd/sync"
whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami"
"github.com/launchdarkly/ldcli/internal/analytics"
"github.com/launchdarkly/ldcli/internal/config"
Expand Down Expand Up @@ -299,6 +300,7 @@ func NewRootCommand(
cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient))
cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn))
cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn))
cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn))
cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient))
resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn)

Expand Down
1 change: 1 addition & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) {
"signup",
"sourcemaps",
"symbols",
"sync",
"whoami",
} {
assert.True(t, registered[name], "%s is not registered on the root command", name)
Expand Down
59 changes: 59 additions & 0 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package sync

import (
"fmt"
"os"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/launchdarkly/ldcli/cmd/cliflags"
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/cmd/validators"
"github.com/launchdarkly/ldcli/internal/output"
"github.com/launchdarkly/ldcli/internal/resources"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
)

const dryRunFlag = "dry-run"

func NewPromptCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "prompt",
Short: "Synchronize local prompt variations with LaunchDarkly",
Long: "Plan synchronization changes for local prompt variations. Use --dry-run to preview changes without creating a plan.",
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.NoArgs(cmd, args); err != nil {
return err
}
return validators.Validate()(cmd, args)
},
RunE: runPrompt(client),
}
cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without creating a plan")
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
return cmd
}

func runPrompt(client resources.Client) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, _ []string) error {
workingDirectory, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
outputKind := cliflags.GetOutputKind(cmd)
err = syncprompt.NewRunner(client).Run(syncprompt.Options{
WorkingDirectory: workingDirectory,
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
BaseURI: viper.GetString(cliflags.BaseURIFlag),
OutputKind: outputKind,
DryRun: dryRun,
Output: cmd.OutOrStdout(),
})
if err != nil {
return output.NewCmdOutputError(err, outputKind)
}
return nil
}
}
13 changes: 13 additions & 0 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package sync

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestPromptCommandDefinesDryRunFlag(t *testing.T) {
command := NewPromptCmd(nil)
assert.Equal(t, "prompt", command.Use)
assert.NotNil(t, command.Flags().Lookup(dryRunFlag))
}
40 changes: 40 additions & 0 deletions cmd/sync/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package sync

import (
"github.com/spf13/cobra"
"github.com/spf13/viper"

cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics"
"github.com/launchdarkly/ldcli/cmd/cliflags"
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/internal/analytics"
"github.com/launchdarkly/ldcli/internal/resources"
)

func NewSyncCmd(
client resources.Client,
analyticsTrackerFn analytics.TrackerFn,
) *cobra.Command {
cmd := &cobra.Command{
Use: "sync",
Short: "Synchronize local resources with LaunchDarkly",
Args: cobra.MinimumNArgs(1),
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
tracker := analyticsTrackerFn(
viper.GetString(cliflags.AccessTokenFlag),
viper.GetString(cliflags.BaseURIFlag),
viper.GetBool(cliflags.AnalyticsOptOut),
)
tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(
cmd,
"sync",
map[string]interface{}{"action": cmd.Name()},
))
},
}

cmd.AddCommand(NewPromptCmd(client))
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())

return cmd
}
1 change: 1 addition & 0 deletions cmd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Commands:
{{rpad "login" 29}} Log in to your LaunchDarkly account
{{rpad "signup" 29}} Create a new LaunchDarkly account
{{rpad "dev-server" 29}} Run a development server to serve flags locally
{{rpad "sync" 29}} Synchronize local resources with LaunchDarkly

Common resource commands:
{{rpad "flags" 29}} List, create, and modify feature flags and their targeting
Expand Down
1 change: 1 addition & 0 deletions cmd/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) {
"login",
"signup",
"dev-server",
"sync",
"flags",
"environments",
"projects",
Expand Down
189 changes: 189 additions & 0 deletions internal/sync/api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package api

import (
"encoding/json"
"fmt"
"net/http"
"net/url"

"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

type ResourceStatus string

const (
ResourceStatusInSync ResourceStatus = "in_sync"
ResourceStatusLocalChanged ResourceStatus = "local_changed"
ResourceStatusServerChanged ResourceStatus = "server_changed"
ResourceStatusConflict ResourceStatus = "conflict"
)

type SyncDirection string

const (
SyncDirectionCodeCanonical SyncDirection = "code_canonical"
SyncDirectionServerCanonical SyncDirection = "server_canonical"
SyncDirectionBoth SyncDirection = "both"
)

type ResourceError struct {
Code string `json:"code"`
Message string `json:"message"`
}

type PlannedResource struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
LookupKey string `json:"lookupKey"`
Status ResourceStatus `json:"status"`
SyncDirection SyncDirection `json:"syncDirection"`
Diff json.RawMessage `json:"diff,omitempty"`
Error *ResourceError `json:"error,omitempty"`
}

type ProjectPlan struct {
ProjectKey string `json:"-"`
PlanID string `json:"planId,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Resources []PlannedResource `json:"resources"`
}

type planRequest struct {
Source sourceRequest `json:"source"`
DryRun bool `json:"dryRun"`
Resources []resourceInput `json:"resources"`
}

type sourceRequest struct {
Type syncdomain.SourceType `json:"type"`
Identifier string `json:"identifier"`
}

type resourceInput struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
LookupKey string `json:"lookupKey"`
Upsert bool `json:"upsert"`
Payload json.RawMessage `json:"payload"`
}

type projectResources struct {
ProjectKey string
Resources []syncdomain.SyncedResource
}

type Client struct {
transport resources.Client
}

func NewClient(transport resources.Client) Client {
return Client{transport: transport}
}

func (client Client) Plan(
accessToken string,
baseURI string,
source syncdomain.Source,
dryRun bool,
synced []syncdomain.SyncedResource,
) ([]ProjectPlan, error) {
plans := make([]ProjectPlan, 0)

for _, project := range groupResourcesByProject(synced) {
plan, err := client.planProject(accessToken, baseURI, source, dryRun, project)
if err != nil {
return nil, err
}

plans = append(plans, plan)
}

return plans, nil
}

func (client Client) planProject(
accessToken string,
baseURI string,
source syncdomain.Source,
dryRun bool,
project projectResources,
) (ProjectPlan, error) {
request := planRequest{
Source: sourceRequest{
Type: source.Type(),
Identifier: source.Identifier(),
},
DryRun: dryRun,
Resources: make([]resourceInput, 0, len(project.Resources)),
}

for _, resource := range project.Resources {
if resource.Kind != syncdomain.KindVariation {
return ProjectPlan{}, fmt.Errorf("unsupported sync resource kind %q", resource.Kind)
}

request.Resources = append(request.Resources, resourceInput{
ResourceKind: resource.Kind,
LookupKey: resource.LookupKey,
Upsert: resource.Upsert,
Payload: resource.Payload,
})
}

body, err := json.MarshalIndent(request, "", " ")
if err != nil {
return ProjectPlan{}, fmt.Errorf("marshal plan request: %w", err)
}

endpoint, err := url.JoinPath(
baseURI,
"api/v2/projects",
project.ProjectKey,
"ai-configs/sync/plan",
)
if err != nil {
return ProjectPlan{}, fmt.Errorf("build plan endpoint: %w", err)
}

response, err := client.transport.MakeRequest(
accessToken,
http.MethodPost,
endpoint,
"application/json",
nil,
body,
false,
)
if err != nil {
return ProjectPlan{}, err
}

var plan ProjectPlan
if err := json.Unmarshal(response, &plan); err != nil {
return ProjectPlan{}, fmt.Errorf("decode plan response: %w", err)
}
if !dryRun && (plan.PlanID == "" || plan.ExpiresAt == "") {
return ProjectPlan{}, fmt.Errorf("decode plan response: durable plan requires planId and expiresAt")
}

plan.ProjectKey = project.ProjectKey

return plan, nil
}

func groupResourcesByProject(synced []syncdomain.SyncedResource) []projectResources {
var projects []projectResources
byProject := make(map[string]int)

for _, resource := range synced {
index, ok := byProject[resource.ProjectKey]
if !ok {
index = len(projects)
byProject[resource.ProjectKey] = index
projects = append(projects, projectResources{ProjectKey: resource.ProjectKey})
}

projects[index].Resources = append(projects[index].Resources, resource)
}

return projects
}
Loading
Loading