diff --git a/cli/cli.go b/cli/cli.go index 0925a24..bb58886 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -34,6 +34,7 @@ func getWorkerNamespace() components.Namespace { commands.GetRemoveCommand(), commands.GetListCommand(), commands.GetAddSecretCommand(), + commands.GetAddPropertyCommand(), commands.GetListEventsCommand(), commands.GetEditScheduleCommand(), commands.GetShowExecutionHistoryCommand(), diff --git a/commands/add_property_cmd.go b/commands/add_property_cmd.go new file mode 100644 index 0000000..d9b1c9d --- /dev/null +++ b/commands/add_property_cmd.go @@ -0,0 +1,106 @@ +// Package commands provides JFrog platform services worker management commands. +package commands + +import ( + "fmt" + "os" + + "github.com/jfrog/jfrog-cli-platform-services/commands/common" + + plugins_common "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/jfrog-cli-platform-services/model" +) + +type addPropertyCommand struct { + ctx *components.Context +} + +func GetAddPropertyCommand() components.Command { + return components.Command{ + Name: "add-property", + Description: "Add a clear-text property to a worker", + AIDescription: `Add or update a clear-text property in the local manifest.json. Pass the value as a second argument, or omit it to prompt or use JFROG_WORKER_CLI_DEV_ADD_PROPERTY_VALUE. This command only writes locally; run 'jf worker deploy' to send the property to the server. + +Properties are not secrets: values remain unencrypted and readable on disk. Omitting manifest.properties preserves remote properties, while an explicit empty object clears them on the next deploy.`, + Aliases: []string{"ap"}, + Flags: []components.Flag{ + components.NewBoolFlag(model.FlagEdit, "Whether to update an existing property.", components.WithBoolDefaultValue(false)), + }, + Arguments: []components.Argument{ + { + Name: "property-name", + Description: "The property name.", + }, + { + Name: "property-value", + Description: "The property value. If omitted, prompted or taken from JFROG_WORKER_CLI_DEV_ADD_PROPERTY_VALUE.", + Optional: true, + }, + }, + Action: func(c *components.Context) error { + return (&addPropertyCommand{ctx: c}).run() + }, + } +} + +func (c *addPropertyCommand) run() error { + manifest, err := common.ReadManifest() + if err != nil { + return err + } + if err = common.ValidateManifest(manifest, nil); err != nil { + return err + } + + propertyName, err := c.getPropertyName() + if err != nil { + return err + } + if err = c.checkUpdate(manifest, propertyName); err != nil { + return err + } + + propertyValue := c.readPropertyValue() + if manifest.Properties == nil { + manifest.Properties = map[string]string{} + } + manifest.Properties[propertyName] = propertyValue + + if err = common.SaveManifest(manifest); err != nil { + return err + } + + log.Info(fmt.Sprintf("Property '%s' saved", propertyName)) + return nil +} + +func (c *addPropertyCommand) getPropertyName() (string, error) { + if len(c.ctx.Arguments) < 1 || len(c.ctx.Arguments) > 2 { + return "", plugins_common.WrongNumberOfArgumentsHandler(c.ctx) + } + return c.ctx.Arguments[0], nil +} + +func (c *addPropertyCommand) checkUpdate(manifest *model.Manifest, propertyName string) error { + if _, exists := manifest.Properties[propertyName]; exists && !c.ctx.GetBoolFlagValue(model.FlagEdit) { + return fmt.Errorf("%s already exists, use --%s to overwrite", propertyName, model.FlagEdit) + } + return nil +} + +func (c *addPropertyCommand) readPropertyValue() string { + if len(c.ctx.Arguments) > 1 { + return c.ctx.Arguments[1] + } + if propertyValue, exists := os.LookupEnv(model.EnvKeyAddPropertyValue); exists { + return propertyValue + } + + var propertyValue string + ioutils.ScanFromConsole("Value", &propertyValue, "") + return propertyValue +} diff --git a/commands/add_property_cmd_test.go b/commands/add_property_cmd_test.go new file mode 100644 index 0000000..f054932 --- /dev/null +++ b/commands/add_property_cmd_test.go @@ -0,0 +1,122 @@ +//go:build test +// +build test + +package commands + +import ( + "fmt" + "os" + "testing" + + "github.com/jfrog/jfrog-cli-platform-services/commands/common" + "github.com/jfrog/jfrog-cli-platform-services/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddPropertyCmd(t *testing.T) { + tests := []struct { + name string + commandArgs []string + propertyName string + propertyValue string + wantErr string + want map[string]string + patchManifest func(mf *model.Manifest) + }{ + { + name: "add", + propertyName: "prop-1", + propertyValue: "value-1", + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{"prop-2": "value-2"} + }, + want: map[string]string{"prop-1": "value-1", "prop-2": "value-2"}, + }, + { + name: "add from argument", + commandArgs: []string{"prop-1", "value-1"}, + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{"prop-2": "value-2"} + }, + want: map[string]string{"prop-1": "value-1", "prop-2": "value-2"}, + }, + { + name: "argument overrides env", + commandArgs: []string{"prop-1", "from-arg"}, + propertyValue: "from-env", + want: map[string]string{"prop-1": "from-arg"}, + }, + { + name: "reject extra arguments", + commandArgs: []string{"prop-1", "value-1", "extra"}, + wantErr: "Wrong number of arguments (3).", + }, + { + name: "add with nil properties", + propertyName: "prop-1", + propertyValue: "value-1", + patchManifest: func(mf *model.Manifest) { + mf.Properties = nil + }, + want: map[string]string{"prop-1": "value-1"}, + }, + { + name: "edit property", + propertyName: "prop-1", + propertyValue: "new-value", + commandArgs: []string{fmt.Sprintf("--%s", model.FlagEdit)}, + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{"prop-1": "old-value"} + }, + want: map[string]string{"prop-1": "new-value"}, + }, + { + name: "reject duplicate without edit", + propertyName: "prop-1", + propertyValue: "new-value", + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{"prop-1": "old-value"} + }, + wantErr: "prop-1 already exists, use --edit to overwrite", + }, + { + name: "reject missing name", + wantErr: "Wrong number of arguments (0).", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + common.NewMockWorkerServer(t, common.NewServerStub(t).WithDefaultActionsMetadataEndpoint()) + + workerDir, workerName := common.PrepareWorkerDirForTest(t) + runCmd := common.CreateCliRunner(t, GetInitCommand(), GetAddPropertyCommand()) + require.NoError(t, runCmd("worker", "init", "GENERIC_EVENT", workerName)) + + if tt.patchManifest != nil { + common.PatchManifest(t, tt.patchManifest) + } + if tt.propertyValue != "" { + require.NoError(t, os.Setenv(model.EnvKeyAddPropertyValue, tt.propertyValue)) + t.Cleanup(func() { _ = os.Unsetenv(model.EnvKeyAddPropertyValue) }) + } + + cmd := append([]string{"worker", "add-property"}, tt.commandArgs...) + if tt.propertyName != "" { + cmd = append(cmd, tt.propertyName) + } + + err := runCmd(cmd...) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + manifest, err := common.ReadManifest(workerDir) + require.NoError(t, err) + assert.Equal(t, tt.want, manifest.Properties) + }) + } +} diff --git a/commands/common/cmd_commons.go b/commands/common/cmd_commons.go index 979b259..a782d81 100644 --- a/commands/common/cmd_commons.go +++ b/commands/common/cmd_commons.go @@ -91,3 +91,27 @@ func PrepareSecretsUpdate(mf *model.Manifest, existingWorker *model.WorkerDetail return secrets } + +func PreparePropertiesUpdate(mf *model.Manifest, existingWorker *model.WorkerDetails) []*model.Property { + removedProperties := map[string]any{} + if existingWorker != nil { + for _, existingProperty := range existingWorker.Properties { + removedProperties[existingProperty.Key] = struct{}{} + } + } + + var properties []*model.Property + for propertyName, propertyValue := range mf.Properties { + if _, propertyExists := removedProperties[propertyName]; propertyExists { + properties = append(properties, &model.Property{Key: propertyName, MarkedForRemoval: true}) + } + delete(removedProperties, propertyName) + properties = append(properties, &model.Property{Key: propertyName, Value: propertyValue}) + } + + for removedProperty := range removedProperties { + properties = append(properties, &model.Property{Key: removedProperty, MarkedForRemoval: true}) + } + + return properties +} diff --git a/commands/common/cmd_commons_test.go b/commands/common/cmd_commons_test.go index fca7ba5..fe6097d 100644 --- a/commands/common/cmd_commons_test.go +++ b/commands/common/cmd_commons_test.go @@ -45,6 +45,50 @@ func Test_cleanImports(t *testing.T) { } } +func TestPreparePropertiesUpdate(t *testing.T) { + tests := []struct { + name string + properties map[string]string + existing []*model.Property + want []*model.Property + }{ + { + name: "adds local properties", + properties: map[string]string{"new": "value"}, + want: []*model.Property{{Key: "new", Value: "value"}}, + }, + { + name: "replaces existing and removes remote-only properties", + properties: map[string]string{"changed": "new-value"}, + existing: []*model.Property{ + {Key: "changed", Value: "old-value"}, + {Key: "remote-only", Value: "remote-value"}, + }, + want: []*model.Property{ + {Key: "changed", MarkedForRemoval: true}, + {Key: "changed", Value: "new-value"}, + {Key: "remote-only", MarkedForRemoval: true}, + }, + }, + { + name: "empty map removes every remote property", + properties: map[string]string{}, + existing: []*model.Property{{Key: "remote", Value: "value"}}, + want: []*model.Property{{Key: "remote", MarkedForRemoval: true}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PreparePropertiesUpdate( + &model.Manifest{Properties: tt.properties}, + &model.WorkerDetails{Properties: tt.existing}, + ) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + func Test_extractProjectAndKeyFromCommandContext(t *testing.T) { tests := []struct { name string diff --git a/commands/common/cmd_worker_api.go b/commands/common/cmd_worker_api.go index 00e27df..fa8f4f4 100644 --- a/commands/common/cmd_worker_api.go +++ b/commands/common/cmd_worker_api.go @@ -23,7 +23,7 @@ func FetchWorkerDetails(c model.IntFlagProvider, serverURL string, accessToken s if len(content) == 0 { return nil } - log.Info(fmt.Sprintf("Worker %s details returned from the server", details.Key)) + log.Info(fmt.Sprintf("Worker %s details returned from the server", workerKey)) return json.Unmarshal(content, details) }, }) diff --git a/commands/common/manifest_test.go b/commands/common/manifest_test.go index 50b8a85..c0d997d 100644 --- a/commands/common/manifest_test.go +++ b/commands/common/manifest_test.go @@ -104,6 +104,41 @@ func TestReadManifest(t *testing.T) { } } +func TestReadManifestPreservesOmittedAndEmptyProperties(t *testing.T) { + tests := []struct { + name string + properties string + assert func(*testing.T, map[string]string) + }{ + { + name: "omitted", + assert: func(t *testing.T, properties map[string]string) { + assert.Nil(t, properties) + }, + }, + { + name: "empty", + properties: `,"properties":{}`, + assert: func(t *testing.T, properties map[string]string) { + assert.NotNil(t, properties) + assert.Empty(t, properties) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"worker","sourceCodePath":"worker.ts","action":"GENERIC_EVENT"` + tt.properties + `}` + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600)) + + got, err := ReadManifest(dir) + require.NoError(t, err) + tt.assert(t, got.Properties) + }) + } +} + func TestManifest_ReadSourceCode(t *testing.T) { tests := []struct { name string diff --git a/commands/common/test_worker_server.go b/commands/common/test_worker_server.go index babbae4..7cbd6cb 100644 --- a/commands/common/test_worker_server.go +++ b/commands/common/test_worker_server.go @@ -35,6 +35,7 @@ type workerDeployPayload struct { Action model.Action `json:"action"` FilterCriteria *model.FilterCriteria `json:"filterCriteria,omitempty"` Secrets []*model.Secret `json:"secrets"` + Properties []*model.Property `json:"properties,omitempty"` ProjectKey string `json:"projectKey"` Version *model.Version `json:"version,omitempty"` } @@ -641,6 +642,7 @@ func mapWorkerSentToWorkerDetails(workerSent workerDeployPayload) *model.WorkerD Action: workerSent.Action.Name, // Map Action.Name FilterCriteria: workerSent.FilterCriteria, Secrets: workerSent.Secrets, + Properties: workerSent.Properties, ProjectKey: workerSent.ProjectKey, } } diff --git a/commands/deploy_cmd.go b/commands/deploy_cmd.go index 5fe2eda..47d00c7 100644 --- a/commands/deploy_cmd.go +++ b/commands/deploy_cmd.go @@ -26,6 +26,7 @@ type deployRequest struct { Action model.Action `json:"action"` FilterCriteria *model.FilterCriteria `json:"filterCriteria,omitempty"` Secrets []*model.Secret `json:"secrets"` + Properties []*model.Property `json:"properties,omitempty"` ProjectKey string `json:"projectKey"` Version *model.Version `json:"version,omitempty"` } @@ -60,15 +61,16 @@ Prerequisites: Common patterns: $ jf worker deploy $ jf worker deploy --no-secrets - $ jf worker deploy --version 1.2.3 --description "Add filter" --commit-sha abc1234 + $ jf worker deploy --changes-version 1.2.3 --changes-description "Add filter" --changes-commitsha abc1234 $ jf worker deploy --base64 $ jf worker deploy --format json Gotchas: - Secrets in manifest.json are decrypted locally and sent in plaintext over TLS unless --no-secrets is set. +- Properties are clear text, stored unencrypted in manifest.json, and sent over TLS; --no-secrets does not omit them. - Filter criteria are only sent when the action requires them (e.g. BEFORE_UPLOAD with a repo filter, SCHEDULED_EVENT with a cron). - The --base64 flag is ignored by servers that do not support base64-encoded source code. -- Versioning fields are only validated against the server's version policy when at least one of --version / --description / --commit-sha is set. +- Versioning fields are only validated against the server's version policy when at least one of --changes-version / --changes-description / --changes-commitsha is set. Related: jf worker test-run, jf worker undeploy, jf worker list, jf worker edit-schedule`, Aliases: []string{"d"}, @@ -255,6 +257,10 @@ func (h *deployCommandHandler) prepareRequest(existingWorker *model.WorkerDetail Version: h.version, } + if h.manifest.Properties != nil { + payload.Properties = common.PreparePropertiesUpdate(h.manifest, existingWorker) + } + if h.actionMeta.MandatoryFilter { payload.FilterCriteria = h.manifest.FilterCriteria } diff --git a/commands/deploy_cmd_test.go b/commands/deploy_cmd_test.go index e1b96b1..676ba3c 100644 --- a/commands/deploy_cmd_test.go +++ b/commands/deploy_cmd_test.go @@ -43,11 +43,15 @@ func TestDeployCommand(t *testing.T) { WithGetOneEndpoint(). WithOptionsEndpoint(). WithCreateEndpoint( - expectDeployRequest( + expectDeployRequestWithProperties( actionsMeta, "wk-0", "BEFORE_UPLOAD", "", + []*model.Property{ + {Key: "prop-1", Value: "value-1"}, + {Key: "prop-2", Value: "value-2"}, + }, &model.Secret{Key: "sec-1", Value: "val-1"}, &model.Secret{Key: "sec-2", Value: "val-2"}, ), @@ -57,6 +61,10 @@ func TestDeployCommand(t *testing.T) { "sec-1": common.MustEncryptSecret(t, "val-1"), "sec-2": common.MustEncryptSecret(t, "val-2"), } + mf.Properties = map[string]string{ + "prop-1": "value-1", + "prop-2": "value-2", + } }, }, { @@ -103,6 +111,93 @@ func TestDeployCommand(t *testing.T) { } }, }, + { + name: "update with changed and removed properties", + workerAction: "AFTER_MOVE", + workerName: "wk-properties", + serverBehavior: common.NewServerStub(t). + WithGetOneEndpoint(). + WithOptionsEndpoint(). + WithUpdateEndpoint( + expectDeployRequestWithProperties( + actionsMeta, + "wk-properties", + "AFTER_MOVE", + "", + []*model.Property{ + {Key: "prop-1", MarkedForRemoval: true}, + {Key: "prop-1", Value: "new-value"}, + {Key: "prop-2", MarkedForRemoval: true}, + }, + ), + ). + WithWorkers(&model.WorkerDetails{ + Key: "wk-properties", + Properties: []*model.Property{ + {Key: "prop-1", Value: "old-value"}, + {Key: "prop-2", Value: "remote-only"}, + }, + }), + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{"prop-1": "new-value"} + }, + }, + { + name: "secrets and properties are independent", + workerAction: "GENERIC_EVENT", + workerName: "wk-independent", + serverBehavior: common.NewServerStub(t). + WithGetOneEndpoint(). + WithOptionsEndpoint(). + WithCreateEndpoint( + expectDeployRequestWithProperties( + actionsMeta, + "wk-independent", + "GENERIC_EVENT", + "", + []*model.Property{{Key: "shared-key", Value: "property-value"}}, + &model.Secret{Key: "shared-key", Value: "secret-value"}, + ), + ), + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{"shared-key": common.MustEncryptSecret(t, "secret-value")} + mf.Properties = map[string]string{"shared-key": "property-value"} + }, + }, + { + name: "no secrets still sends properties", + workerAction: "GENERIC_EVENT", + workerName: "wk-no-secrets", + commandArgs: []string{"--" + model.FlagNoSecrets}, + serverBehavior: common.NewServerStub(t). + WithGetOneEndpoint(). + WithOptionsEndpoint(). + WithCreateEndpoint( + expectDeployRequestWithProperties( + actionsMeta, + "wk-no-secrets", + "GENERIC_EVENT", + "", + []*model.Property{{Key: "prop-1", Value: "value-1"}}, + ), + ), + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{"sec-1": "not-encrypted"} + mf.Properties = map[string]string{"prop-1": "value-1"} + }, + }, + { + name: "omitted manifest properties omits request field", + workerAction: "GENERIC_EVENT", + workerName: "wk-omitted-properties", + serverBehavior: common.NewServerStub(t). + WithGetOneEndpoint(). + WithOptionsEndpoint(). + WithCreateEndpoint(expectDeployRequestWithoutProperties()), + patchManifest: func(mf *model.Manifest) { + mf.Properties = nil + }, + }, { name: "create with project key", workerAction: "GENERIC_EVENT", @@ -288,6 +383,16 @@ func assertDeployRequestEquals(t require.TestingT, want, got *deployRequest) { gotSecrets = append(gotSecrets, fmt.Sprintf("%s:%s:%v", s.Key, s.Value, s.MarkedForRemoval)) } assert.ElementsMatchf(t, wantSecrets, gotSecrets, "Secrets mismatch") + + assert.Equalf(t, len(want.Properties), len(got.Properties), "Properties length mismatch") + var wantProperties, gotProperties []string + for _, p := range want.Properties { + wantProperties = append(wantProperties, fmt.Sprintf("%s:%s:%v", p.Key, p.Value, p.MarkedForRemoval)) + } + for _, p := range got.Properties { + gotProperties = append(gotProperties, fmt.Sprintf("%s:%s:%v", p.Key, p.Value, p.MarkedForRemoval)) + } + assert.ElementsMatchf(t, wantProperties, gotProperties, "Properties mismatch") } func expectDeployRequest(actionsMeta common.ActionsMetadata, workerName, actionName, projectKey string, secrets ...*model.Secret) common.BodyValidator { @@ -300,6 +405,29 @@ func expectDeployRequest(actionsMeta common.ActionsMetadata, workerName, actionN } } +func expectDeployRequestWithProperties( + actionsMeta common.ActionsMetadata, + workerName, actionName, projectKey string, + properties []*model.Property, + secrets ...*model.Secret, +) common.BodyValidator { + return func(t require.TestingT, body []byte) { + want := getExpectedDeployRequestForAction(t, actionsMeta, workerName, actionName, projectKey, secrets...) + want.Properties = properties + got := &deployRequest{} + require.NoError(t, json.Unmarshal(body, got)) + assertDeployRequestEquals(t, want, got) + } +} + +func expectDeployRequestWithoutProperties() common.BodyValidator { + return func(t require.TestingT, body []byte) { + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + assert.NotContains(t, payload, "properties") + } +} + func expectDeployRequestBase64(actionsMeta common.ActionsMetadata, workerName, actionName, projectKey string, secrets ...*model.Secret) common.BodyValidator { return func(t require.TestingT, body []byte) { want := getExpectedDeployRequestForAction(t, actionsMeta, workerName, actionName, projectKey, secrets...) diff --git a/commands/dry_run_cmd.go b/commands/dry_run_cmd.go index 92eecaa..dfc3c81 100644 --- a/commands/dry_run_cmd.go +++ b/commands/dry_run_cmd.go @@ -21,10 +21,11 @@ type dryRunHandler struct { } type dryRunRequest struct { - Code string `json:"code"` - Action string `json:"action"` - StagedSecrets []*model.Secret `json:"stagedSecrets,omitempty"` - Data map[string]any `json:"data"` + Code string `json:"code"` + Action string `json:"action"` + StagedSecrets []*model.Secret `json:"stagedSecrets,omitempty"` + StagedProperties []*model.Property `json:"stagedProperties,omitempty"` + Data map[string]any `json:"data"` } func GetDryRunCommand() components.Command { @@ -54,6 +55,7 @@ Gotchas: - The payload argument is required and must match what the action delivers at runtime; check types.ts for the expected shape. - Use '@filename' to load the payload from a file and '@-' to read it from stdin. - By default, secrets in manifest.json are decrypted and sent as staged secrets; pass --no-secrets to omit them. +- Properties are clear text, stored unencrypted in manifest.json, and sent over TLS; --no-secrets does not omit them. - The 'debug' flag in manifest.json controls whether debug logs are returned by the sandbox. Related: jf worker deploy, jf worker execute, jf worker init`, @@ -177,6 +179,9 @@ func (c *dryRunHandler) preparePayload(manifest *model.Manifest, serverURL strin if !c.ctx.GetBoolFlagValue(model.FlagNoSecrets) { payload.StagedSecrets = common.PrepareSecretsUpdate(manifest, existingWorker) } + if manifest.Properties != nil { + payload.StagedProperties = common.PreparePropertiesUpdate(manifest, existingWorker) + } return json.Marshal(&payload) } diff --git a/commands/dry_run_cmd_test.go b/commands/dry_run_cmd_test.go index 7ec77ef..e1f260c 100644 --- a/commands/dry_run_cmd_test.go +++ b/commands/dry_run_cmd_test.go @@ -20,11 +20,12 @@ import ( func TestDryRun(t *testing.T) { tests := []struct { - name string - commandArgs []string - initExtraArgs []string - assert common.AssertOutputFunc - patchManifest func(mf *model.Manifest) + name string + commandArgs []string + initExtraArgs []string + assert common.AssertOutputFunc + patchManifest func(mf *model.Manifest) + existingProperties []*model.Property // Use this workerKey instead of a random generated one workerKey string // The server behavior @@ -128,6 +129,44 @@ func TestDryRun(t *testing.T) { }, assert: common.AssertOutputJson(map[string]any{"valid": "response"}), }, + { + name: "stages property updates with no secrets", + commandArgs: []string{"--" + model.FlagNoSecrets, `{}`}, + existingProperties: []*model.Property{ + {Key: "prop-1", Value: "old-value"}, + {Key: "prop-2", Value: "remote-only"}, + }, + serverStub: common.NewServerStub(t). + WithTestEndpoint( + validateStagedProperties([]*model.Property{ + {Key: "prop-1", MarkedForRemoval: true}, + {Key: "prop-1", Value: "new-value"}, + {Key: "prop-2", MarkedForRemoval: true}, + }, true), + map[string]any{"valid": "response"}, + ), + patchManifest: func(mf *model.Manifest) { + mf.Secrets = model.Secrets{"sec-1": "not-encrypted"} + mf.Properties = map[string]string{"prop-1": "new-value"} + }, + assert: common.AssertOutputJson(map[string]any{"valid": "response"}), + }, + { + name: "omitted properties omits staged field", + commandArgs: []string{`{}`}, + existingProperties: []*model.Property{ + {Key: "prop-1", Value: "remote-value"}, + }, + serverStub: common.NewServerStub(t). + WithTestEndpoint( + validateStagedProperties(nil, false), + map[string]any{"valid": "response"}, + ), + patchManifest: func(mf *model.Manifest) { + mf.Properties = nil + }, + assert: common.AssertOutputJson(map[string]any{"valid": "response"}), + }, } for _, tt := range tests { @@ -150,7 +189,8 @@ func TestDryRun(t *testing.T) { WithDefaultActionsMetadataEndpoint(). WithGetOneEndpoint(). WithWorkers(&model.WorkerDetails{ - Key: workerName, + Key: workerName, + Properties: tt.existingProperties, }), ) @@ -202,6 +242,22 @@ func validateTestPayloadData(data any) common.BodyValidator { }) } +func validateStagedProperties(want []*model.Property, wantField bool) common.BodyValidator { + return func(t require.TestingT, body []byte) { + var raw map[string]any + require.NoError(t, json.Unmarshal(body, &raw)) + if wantField { + assert.Contains(t, raw, "stagedProperties") + } else { + assert.NotContains(t, raw, "stagedProperties") + } + + var payload dryRunRequest + require.NoError(t, json.Unmarshal(body, &payload)) + assert.ElementsMatch(t, want, payload.StagedProperties) + } +} + const workerKeyForDryRunTest = "test-worker" func setupDryRunFormatTest(t *testing.T) (func(args ...string) error, *bytes.Buffer) { diff --git a/commands/templates/manifest.json_template b/commands/templates/manifest.json_template index 4bec9cf..fd76810 100644 --- a/commands/templates/manifest.json_template +++ b/commands/templates/manifest.json_template @@ -17,6 +17,7 @@ }, {{- end }} "secrets": {}, + "properties": {}, "sourceCodePath": "./worker.ts", "action": "{{.Action}}", "enabled": false, diff --git a/model/flags.go b/model/flags.go index b85ab14..d1879ce 100644 --- a/model/flags.go +++ b/model/flags.go @@ -30,10 +30,11 @@ const ( ) var ( - EnvKeyServerURL = "JFROG_WORKER_CLI_DEV_SERVER_URL" - EnvKeyAccessToken = "JFROG_WORKER_CLI_DEV_ACCESS_TOKEN" - EnvKeySecretsPassword = "JFROG_WORKER_CLI_DEV_SECRETS_PASSWORD" - EnvKeyAddSecretValue = "JFROG_WORKER_CLI_DEV_ADD_SECRET_VALUE" + EnvKeyServerURL = "JFROG_WORKER_CLI_DEV_SERVER_URL" + EnvKeyAccessToken = "JFROG_WORKER_CLI_DEV_ACCESS_TOKEN" + EnvKeySecretsPassword = "JFROG_WORKER_CLI_DEV_SECRETS_PASSWORD" + EnvKeyAddSecretValue = "JFROG_WORKER_CLI_DEV_ADD_SECRET_VALUE" + EnvKeyAddPropertyValue = "JFROG_WORKER_CLI_DEV_ADD_PROPERTY_VALUE" ) type IntFlagProvider interface { diff --git a/model/manifest.go b/model/manifest.go index 329e125..6d56ccc 100644 --- a/model/manifest.go +++ b/model/manifest.go @@ -20,14 +20,15 @@ type FilterCriteria struct { type Secrets map[string]string type Manifest struct { - Name string `json:"name"` - Description string `json:"description"` - SourceCodePath string `json:"sourceCodePath"` - Action string `json:"action"` - Enabled bool `json:"enabled"` - Debug bool `json:"debug"` - ProjectKey string `json:"projectKey"` - Secrets Secrets `json:"secrets"` - FilterCriteria *FilterCriteria `json:"filterCriteria,omitempty"` - Application string `json:"application,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + SourceCodePath string `json:"sourceCodePath"` + Action string `json:"action"` + Enabled bool `json:"enabled"` + Debug bool `json:"debug"` + ProjectKey string `json:"projectKey"` + Secrets Secrets `json:"secrets"` + Properties map[string]string `json:"properties"` + FilterCriteria *FilterCriteria `json:"filterCriteria,omitempty"` + Application string `json:"application,omitempty"` } diff --git a/model/worker.go b/model/worker.go index f8ab05d..650dbbe 100644 --- a/model/worker.go +++ b/model/worker.go @@ -5,6 +5,13 @@ type Secret struct { Value string `json:"value"` MarkedForRemoval bool `json:"markedForRemoval"` } + +type Property struct { + Key string `json:"key"` + Value string `json:"value"` + MarkedForRemoval bool `json:"markedForRemoval"` +} + type WorkerDetails struct { Key string `json:"key"` Description string `json:"description"` @@ -14,5 +21,6 @@ type WorkerDetails struct { Action string `json:"action"` FilterCriteria *FilterCriteria `json:"filterCriteria,omitempty"` Secrets []*Secret `json:"secrets"` + Properties []*Property `json:"properties"` ProjectKey string `json:"projectKey"` } diff --git a/qa-plugin/main.go b/qa-plugin/main.go index 50ca753..7db346b 100644 --- a/qa-plugin/main.go +++ b/qa-plugin/main.go @@ -28,6 +28,7 @@ func getCommands() []components.Command { commands.GetRemoveCommand(), commands.GetListCommand(), commands.GetAddSecretCommand(), + commands.GetAddPropertyCommand(), commands.GetListEventsCommand(), commands.GetEditScheduleCommand(), commands.GetShowExecutionHistoryCommand(), diff --git a/test/commands/deploy_cmd_test.go b/test/commands/deploy_cmd_test.go index 95136ab..74fd740 100644 --- a/test/commands/deploy_cmd_test.go +++ b/test/commands/deploy_cmd_test.go @@ -37,6 +37,12 @@ func TestDeployCommand(t *testing.T) { name: "create", workerKey: "wk-0", timeout: 10 * time.Second, + patchManifest: func(mf *model.Manifest) { + mf.Properties = map[string]string{ + "prop-1": "value-1", + "prop-2": "value-2", + } + }, }), deployTestSpec(deployTestCase{ name: "create with a version", @@ -188,4 +194,10 @@ func assertWorkerDeployed(it *infra.Test, mf *model.Manifest) { assert.Truef(it, secretShouldHaveBeenDeployed, "Invalid deployed secret %s", deployedSecret) infra.AssertSecretValueFromServer(it, mf.Name, deployedSecret.Key, mf.Secrets[deployedSecret.Key]) } + + deployedProperties := make(map[string]string, len(deployed.Properties)) + for _, property := range deployed.Properties { + deployedProperties[property.Key] = property.Value + } + assert.Equalf(it, mf.Properties, deployedProperties, "Properties mismatch") }