Skip to content
Draft
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
19 changes: 18 additions & 1 deletion cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ import (

const (
addFlag = "add"
applyFlag = "apply"
dryRunFlag = "dry-run"
yesFlag = "yes"
)

func NewPromptCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "prompt",
Short: "Synchronize local prompt variations with LaunchDarkly",
Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or preview synchronization changes.",
Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, preview synchronization changes, or apply a durable sync plan.",
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.NoArgs(cmd, args); err != nil {
return err
Expand All @@ -33,9 +35,14 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
},
RunE: runPrompt(client),
}

cmd.Flags().Bool(addFlag, false, "Select additional prompt variations from LaunchDarkly")
cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without creating a plan")
cmd.Flags().String(applyFlag, "", "Apply an existing durable plan ID without planning again")
cmd.Flags().Bool(yesFlag, false, "Apply planned changes without interactive confirmation")
cmd.Flags().String(cliflags.ProjectFlag, "", "Project key for --apply when it cannot be inferred from the workspace")
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())

return cmd
}

Expand All @@ -45,18 +52,28 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error {
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}

add, _ := cmd.Flags().GetBool(addFlag)
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
planID, _ := cmd.Flags().GetString(applyFlag)
yes, _ := cmd.Flags().GetBool(yesFlag)
projectKey, _ := cmd.Flags().GetString(cliflags.ProjectFlag)
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,
ProjectKey: projectKey,
ProjectSpecified: cmd.Flags().Changed(cliflags.ProjectFlag),
PlanID: planID,
Add: add,
DryRun: dryRun,
Yes: yes,
Input: cmd.InOrStdin(),
Output: cmd.OutOrStdout(),
ErrorOutput: cmd.ErrOrStderr(),
})
if err != nil {
return output.NewCmdOutputError(err, outputKind)
Expand Down
9 changes: 7 additions & 2 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/launchdarkly/ldcli/cmd/cliflags"
)

func TestPromptCommandDefinesDryRunFlag(t *testing.T) {
func TestPromptCommandDefinesSyncFlags(t *testing.T) {
command := NewPromptCmd(nil)

assert.Equal(t, "prompt", command.Use)
assert.NotNil(t, command.Flags().Lookup(dryRunFlag))
for _, name := range []string{addFlag, applyFlag, dryRunFlag, yesFlag, cliflags.ProjectFlag} {
assert.NotNil(t, command.Flags().Lookup(name), "missing --%s", name)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/samber/lo v1.51.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.10
Expand Down Expand Up @@ -84,7 +85,6 @@ require (
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/onsi/gomega v1.27.6 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
Expand Down
147 changes: 132 additions & 15 deletions internal/sync/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ type ResourceError struct {
}

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"`
ResourceKind syncdomain.Kind `json:"resourceKind"`
LookupKey string `json:"lookupKey"`
Status ResourceStatus `json:"status"`
SyncDirection SyncDirection `json:"syncDirection"`
ManifestUpdateRequired bool `json:"manifestUpdateRequired"`
LocalDeleted bool `json:"localDeleted"`
ServerDeleted bool `json:"serverDeleted"`
Diff json.RawMessage `json:"diff,omitempty"`
Error *ResourceError `json:"error,omitempty"`
}

type ProjectPlan struct {
Expand All @@ -48,10 +51,45 @@ type ProjectPlan struct {
Resources []PlannedResource `json:"resources"`
}

type PlanStatus string

const (
PlanStatusApplied PlanStatus = "applied"
PlanStatusFailed PlanStatus = "failed"
)

type ResourceApplyOutcome string

const (
ResourceApplyOutcomeApplied ResourceApplyOutcome = "applied"
ResourceApplyOutcomeFailed ResourceApplyOutcome = "failed"
ResourceApplyOutcomeNotAttempted ResourceApplyOutcome = "not_attempted"
)

type AppliedResource struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
LookupKey string `json:"lookupKey"`
Outcome ResourceApplyOutcome `json:"outcome"`
Error *ResourceError `json:"error,omitempty"`
}

type ProjectApply struct {
ProjectKey string `json:"-"`
PlanID string `json:"planId"`
Status PlanStatus `json:"status"`
Error *ResourceError `json:"error,omitempty"`
Resources []AppliedResource `json:"resources"`
}

type applyRequest struct {
PlanID string `json:"planId"`
}

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

type sourceRequest struct {
Expand Down Expand Up @@ -84,11 +122,14 @@ func (client Client) Plan(
baseURI string,
source syncdomain.Source,
dryRun bool,
inventoryProjectKeys []string,
synced []syncdomain.SyncedResource,
) ([]ProjectPlan, error) {
plans := make([]ProjectPlan, 0)

for _, project := range groupResourcesByProject(synced) {
// Inventory projects must be planned even when they contain no local
// resources, because an empty inventory can represent local deletions.
for _, project := range groupResourcesByProject(synced, inventoryProjectKeys) {
plan, err := client.planProject(accessToken, baseURI, source, dryRun, project)
if err != nil {
return nil, err
Expand All @@ -100,6 +141,55 @@ func (client Client) Plan(
return plans, nil
}

func (client Client) Apply(
accessToken string,
baseURI string,
projectKey string,
planID string,
) (ProjectApply, error) {
body, err := json.MarshalIndent(applyRequest{
PlanID: planID,
}, "", " ")
if err != nil {
return ProjectApply{}, fmt.Errorf("marshal apply request: %w", err)
}

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

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

var result ProjectApply
if err := json.Unmarshal(response, &result); err != nil {
return ProjectApply{}, fmt.Errorf("decode apply response: %w", err)
}
if result.PlanID == "" || result.Status == "" {
return ProjectApply{}, fmt.Errorf(
"decode apply response: planId and status are required",
)
}
result.ProjectKey = projectKey
return result, nil
}

func (client Client) planProject(
accessToken string,
baseURI string,
Expand All @@ -112,8 +202,9 @@ func (client Client) planProject(
Type: source.Type(),
Identifier: source.Identifier(),
},
DryRun: dryRun,
Resources: make([]resourceInput, 0, len(project.Resources)),
DryRun: dryRun,
FullInventory: true,
Resources: make([]resourceInput, 0, len(project.Resources)),
}

for _, resource := range project.Resources {
Expand Down Expand Up @@ -161,19 +252,45 @@ func (client Client) planProject(
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")
if !dryRun {
hasPlanID := plan.PlanID != ""
hasExpiration := plan.ExpiresAt != ""
if hasPlanID != hasExpiration || (!hasPlanID && !hasConflict(plan)) {
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 {
func hasConflict(plan ProjectPlan) bool {
for _, resource := range plan.Resources {
if resource.Status == ResourceStatusConflict {
return true
}
}
return false
}

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

for _, projectKey := range inventoryProjectKeys {
if _, exists := byProject[projectKey]; exists {
continue
}
byProject[projectKey] = len(projects)
projects = append(projects, projectResources{ProjectKey: projectKey})
}

for _, resource := range synced {
index, ok := byProject[resource.ProjectKey]
if !ok {
Expand Down
Loading
Loading