diff --git a/docs/mutation.md b/docs/mutation.md index 4a9a4b31..fcfa9706 100644 --- a/docs/mutation.md +++ b/docs/mutation.md @@ -75,6 +75,7 @@ The difference between **omitted** and **explicitly written** matters for merge- ## File input, GET YAML, and current limits - If an endpoint accepts `-f`, supplying a file takes precedence over GET-then-update. The supplied body is sent as a write document (with the endpoint's file-body wrapping behavior); it is **not** run through the field handlers or the flag-built sparse PATCH path. `body_params` from the automatic mutation path are not merged into it. A full write-ready PATCH file is allowed, but inspect its shape before sending it. +- When the API stores the resource as a YAML string inside a JSON response (for example `data.yaml` on triggers), pick it with `update_body_pick: yamlParse(it.data.yaml)` and set `content_type: application/yaml`. The mutated object is then re-encoded as the YAML request body, so comments are dropped and keys are written sorted. - `get --format yaml` still uses the **get command's separate `yaml_pick_expr` / `yaml_exclude`**, not `update_body_pick` or custom field handlers. FME's current YAML output is not automatically write-ready for tags/owners; do not promise a GET-YAML-to-`-f` round trip until its specs and conversion path are addressed. - There is no `patch_always_fields` facility yet. PATCH does not copy unchanged picked IDs or other required fields automatically. If an API requires one, establish that requirement before changing the spec/core; an explicit `body_params` value may suffice when it is available independently of GET. - Core does not look up owner emails or reconstruct missing API identifiers. FME currently returns a group name without the identifier needed to write that group back; that requires a server-side round-trip fix before mixed-owner edits can be reliable. diff --git a/modules/pipeline/pipeline.go b/modules/pipeline/pipeline.go index 58916862..b969d79f 100644 --- a/modules/pipeline/pipeline.go +++ b/modules/pipeline/pipeline.go @@ -17,8 +17,6 @@ import ( func ModuleInit(reg registry.ModuleRegistrar) { reg.RegisterFetchFn(listExecutionLogsFetchFnID, listExecutionLogsFetchFn) reg.RegisterWorkflow(getPipelineLogHandlerID, getPipelineLogHandler) - reg.RegisterWorkflow(updateTriggerEnableHandlerID, updateTriggerEnableHandler) - reg.RegisterWorkflow(updateTriggerDisableHandlerID, updateTriggerDisableHandler) reg.RegisterBodyFn(executePipelineBodyFnID, executePipelineBody) reg.RegisterBodyFn(executeInputSetBodyFnID, executeInputSetBody) reg.RegisterBodyFn(executeDynamicBodyFnID, executeDynamicBody) diff --git a/modules/pipeline/trigger_toggle.go b/modules/pipeline/trigger_toggle.go deleted file mode 100644 index 6a755544..00000000 --- a/modules/pipeline/trigger_toggle.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright © 2026 Harness Inc. -// SPDX-License-Identifier: Apache-2.0 - -package pipeline - -import ( - "bytes" - "errors" - "fmt" - "net/url" - "strconv" - - "go.yaml.in/yaml/v3" - - "github.com/harness/cli/v3/pkg/client" - "github.com/harness/cli/v3/pkg/cmdctx" - "github.com/harness/cli/v3/pkg/format" -) - -const ( - updateTriggerEnableHandlerID = "update_trigger_enable" - updateTriggerDisableHandlerID = "update_trigger_disable" -) - -func updateTriggerEnableHandler(ctx *cmdctx.Ctx) error { return toggleTrigger(ctx, true) } -func updateTriggerDisableHandler(ctx *cmdctx.Ctx) error { return toggleTrigger(ctx, false) } - -// toggleTrigger flips trigger.enabled by fetching the trigger YAML, editing it and -// PUTting it back: the NG API has no dedicated enable/disable endpoint. -func toggleTrigger(ctx *cmdctx.Ctx, enabled bool) error { - if len(ctx.IdParts) != 2 { - return fmt.Errorf("expected /, got %q", ctx.Id) - } - pipelineID, triggerID := ctx.IdParts[0], ctx.IdParts[1] - state := "disabled" - if enabled { - state = "enabled" - } - - path := "/pipeline/api/triggers/" + url.PathEscape(triggerID) - qp := map[string]string{ - "orgIdentifier": ctx.Auth.OrgID, - "projectIdentifier": ctx.Auth.ProjectID, - "targetIdentifier": pipelineID, - } - c := client.New(ctx) - - resp, _, err := c.DoRequest(client.Request{Method: "GET", Path: path, QueryParams: qp}) - if err != nil { - return err - } - doc, ok := triggerYAML(resp) - if !ok { - return fmt.Errorf("trigger %s/%s: response has no YAML to update", pipelineID, triggerID) - } - updated, changed, err := setTriggerEnabled(doc, enabled) - if err != nil { - return fmt.Errorf("trigger %s/%s: %w", pipelineID, triggerID, err) - } - - w, err := format.OpenWriter(ctx.FormatFlags.OutFile) - if err != nil { - return err - } - - if !changed { - fmt.Fprintf(w, "Trigger %s is already %s\n", triggerID, state) - return nil - } - _, _, err = c.DoRequest(client.Request{ - Method: "PUT", - Path: path, - QueryParams: qp, - Body: updated, - BodyContentType: "application/yaml", - }) - if errors.Is(err, client.ErrRequestPreviewed) { - return nil - } - if err != nil { - return err - } - fmt.Fprintf(w, "Trigger %s %s\n", triggerID, state) - return nil -} - -// triggerYAML extracts data.yaml from a get-trigger response. -func triggerYAML(resp any) (string, bool) { - root, _ := resp.(map[string]any) - data, _ := root["data"].(map[string]any) - doc, ok := data["yaml"].(string) - return doc, ok && doc != "" -} - -// setTriggerEnabled sets trigger.enabled in a trigger YAML document. It edits the -// parsed node tree so key order and comments survive the round trip. changed is -// false, and doc is returned untouched, when enabled already has the target value. -func setTriggerEnabled(doc string, enabled bool) (out string, changed bool, err error) { - var root yaml.Node - if err := yaml.Unmarshal([]byte(doc), &root); err != nil { - return "", false, fmt.Errorf("parsing trigger YAML: %w", err) - } - if root.Kind != yaml.DocumentNode || len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode { - return "", false, fmt.Errorf("trigger YAML is not a mapping") - } - trigger := mappingValue(root.Content[0], "trigger") - if trigger == nil || trigger.Kind != yaml.MappingNode { - return "", false, fmt.Errorf("trigger YAML has no \"trigger\" mapping") - } - node := mappingValue(trigger, "enabled") - if node == nil || node.Kind != yaml.ScalarNode { - return "", false, fmt.Errorf("trigger YAML has no scalar \"trigger.enabled\"") - } - - var current bool - if node.Tag == "!!bool" && node.Decode(¤t) == nil && current == enabled { - return doc, false, nil - } - node.Value = strconv.FormatBool(enabled) - node.Tag = "!!bool" - node.Style = 0 - - var buf bytes.Buffer - enc := yaml.NewEncoder(&buf) - enc.SetIndent(2) - if err := enc.Encode(&root); err != nil { - return "", false, fmt.Errorf("encoding trigger YAML: %w", err) - } - if err := enc.Close(); err != nil { - return "", false, fmt.Errorf("encoding trigger YAML: %w", err) - } - return buf.String(), true, nil -} - -func mappingValue(m *yaml.Node, key string) *yaml.Node { - for i := 0; i+1 < len(m.Content); i += 2 { - if m.Content[i].Value == key { - return m.Content[i+1] - } - } - return nil -} diff --git a/modules/pipeline/trigger_toggle_test.go b/modules/pipeline/trigger_toggle_test.go deleted file mode 100644 index aa5919f8..00000000 --- a/modules/pipeline/trigger_toggle_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright © 2026 Harness Inc. -// SPDX-License-Identifier: Apache-2.0 - -package pipeline - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/harness/cli/v3/pkg/auth" - "github.com/harness/cli/v3/pkg/cmdctx" - "github.com/harness/cli/v3/pkg/hbase" -) - -const triggerDoc = `# keep me -trigger: - name: My Trigger - identifier: my_trigger - enabled: true # toggled by the CLI - pipelineIdentifier: my_pipeline - source: - type: Webhook - spec: - type: Custom - spec: - payloadConditions: [] -` - -func TestSetTriggerEnabled(t *testing.T) { - t.Run("flips_value_and_preserves_order_and_comments", func(t *testing.T) { - got, changed, err := setTriggerEnabled(triggerDoc, false) - if err != nil || !changed { - t.Fatalf("changed=%v err=%v, want changed and no error", changed, err) - } - want := strings.Replace(triggerDoc, "enabled: true", "enabled: false", 1) - if got != want { - t.Fatalf("output differs beyond the enabled value:\n%s", got) - } - }) - - t.Run("already_in_state_is_unchanged", func(t *testing.T) { - got, changed, err := setTriggerEnabled(triggerDoc, true) - if err != nil || changed || got != triggerDoc { - t.Fatalf("changed=%v err=%v, want untouched document", changed, err) - } - }) - - t.Run("quoted_string_is_rewritten_as_bool", func(t *testing.T) { - doc := strings.Replace(triggerDoc, "enabled: true", `enabled: "true"`, 1) - got, changed, err := setTriggerEnabled(doc, true) - if err != nil || !changed || !strings.Contains(got, "enabled: true # toggled") { - t.Fatalf("changed=%v err=%v got:\n%s", changed, err, got) - } - }) - - for _, tc := range []struct{ name, doc, wantErr string }{ - {"not_a_mapping", "- a\n- b\n", "not a mapping"}, - {"no_trigger_key", "name: x\n", `no "trigger" mapping`}, - {"no_enabled_key", "trigger:\n name: x\n", `"trigger.enabled"`}, - {"invalid_yaml", "trigger: [", "parsing trigger YAML"}, - } { - t.Run(tc.name, func(t *testing.T) { - _, _, err := setTriggerEnabled(tc.doc, false) - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("error = %v, want containing %q", err, tc.wantErr) - } - }) - } -} - -type toggleCall struct { - method string - path string - query url.Values - contentType string - body string -} - -// toggleServer answers the GET with getStatus/getBody and records every request. -func toggleServer(t *testing.T, getStatus int, getBody string) (*httptest.Server, *[]toggleCall) { - t.Helper() - calls := &[]toggleCall{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - *calls = append(*calls, toggleCall{r.Method, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), string(b)}) - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodGet { - w.WriteHeader(getStatus) - io.WriteString(w, getBody) - return - } - io.WriteString(w, "{}") - })) - t.Cleanup(srv.Close) - return srv, calls -} - -func toggleCtx(t *testing.T, apiURL string) (*cmdctx.Ctx, string) { - t.Helper() - out := filepath.Join(t.TempDir(), "out.txt") - t.Cleanup(func() { - if err := hbase.Cleanup(); err != nil { - t.Error(err) - } - }) - ctx := &cmdctx.Ctx{ - Context: context.Background(), - Auth: &auth.ResolvedAuth{ - APIUrl: apiURL, AccountID: "acct", OrgID: "org", ProjectID: "proj", - PATToken: "pat.test", AuthType: auth.AuthTypePAT, - }, - Id: "pipe/trig", - IdParts: []string{"pipe", "trig"}, - } - ctx.FormatFlags.OutFile = out - return ctx, out -} - -func triggerResponse(t *testing.T, doc string) string { - t.Helper() - b, err := json.Marshal(map[string]any{"data": map[string]any{"yaml": doc}}) - if err != nil { - t.Fatal(err) - } - return string(b) -} - -func TestToggleTrigger(t *testing.T) { - t.Run("puts_edited_yaml", func(t *testing.T) { - srv, calls := toggleServer(t, 200, triggerResponse(t, triggerDoc)) - ctx, out := toggleCtx(t, srv.URL) - if err := toggleTrigger(ctx, false); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(*calls) != 2 || (*calls)[0].method != "GET" || (*calls)[1].method != "PUT" { - t.Fatalf("calls = %+v, want GET then PUT", *calls) - } - put := (*calls)[1] - if put.path != "/pipeline/api/triggers/trig" || put.contentType != "application/yaml" { - t.Fatalf("PUT path=%q content-type=%q", put.path, put.contentType) - } - if want := strings.Replace(triggerDoc, "enabled: true", "enabled: false", 1); put.body != want { - t.Fatalf("PUT body = %q, want %q", put.body, want) - } - for k, want := range map[string]string{"targetIdentifier": "pipe", "orgIdentifier": "org", "projectIdentifier": "proj"} { - if got := put.query.Get(k); got != want { - t.Errorf("PUT query %s = %q, want %q", k, got, want) - } - } - if b, _ := os.ReadFile(out); !strings.Contains(string(b), "Trigger trig disabled") { - t.Errorf("output = %q", b) - } - }) - - t.Run("already_in_state_skips_put", func(t *testing.T) { - srv, calls := toggleServer(t, 200, triggerResponse(t, triggerDoc)) - ctx, out := toggleCtx(t, srv.URL) - if err := toggleTrigger(ctx, true); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(*calls) != 1 { - t.Fatalf("calls = %+v, want GET only", *calls) - } - if b, _ := os.ReadFile(out); !strings.Contains(string(b), "already enabled") { - t.Errorf("output = %q", b) - } - }) - - t.Run("missing_yaml_errors_without_put", func(t *testing.T) { - srv, calls := toggleServer(t, 200, `{"data":{}}`) - ctx, _ := toggleCtx(t, srv.URL) - if err := toggleTrigger(ctx, true); err == nil || !strings.Contains(err.Error(), "no YAML") { - t.Fatalf("error = %v, want no-YAML error", err) - } - if len(*calls) != 1 { - t.Fatalf("calls = %+v, want GET only", *calls) - } - }) - - t.Run("get_failure_errors_without_put", func(t *testing.T) { - srv, calls := toggleServer(t, 404, `{"message":"Trigger trig does not exist"}`) - ctx, _ := toggleCtx(t, srv.URL) - if err := toggleTrigger(ctx, true); err == nil || !strings.Contains(err.Error(), "does not exist") { - t.Fatalf("error = %v, want not-found error", err) - } - if len(*calls) != 1 { - t.Fatalf("calls = %+v, want GET only", *calls) - } - }) - - t.Run("bare_id_is_rejected", func(t *testing.T) { - ctx, _ := toggleCtx(t, "http://unused") - ctx.Id, ctx.IdParts = "trig", []string{"trig"} - if err := toggleTrigger(ctx, true); err == nil || !strings.Contains(err.Error(), "/") { - t.Fatalf("error = %v, want id format error", err) - } - }) -} diff --git a/pkg/exprenv/exprenv.go b/pkg/exprenv/exprenv.go index 7a7ce0fa..b83ea6c7 100644 --- a/pkg/exprenv/exprenv.go +++ b/pkg/exprenv/exprenv.go @@ -62,6 +62,7 @@ func BaseFuncs(ptyEnabled bool) map[string]any { "substr": exprfuncs.Substr, "formatOrder": exprfuncs.FormatOrder, "env": exprfuncs.Env, + "yamlParse": exprfuncs.YamlParse, "b64decode": exprfuncs.Base64Decode, } } diff --git a/pkg/exprenv/exprfuncs/exprfuncs.go b/pkg/exprenv/exprfuncs/exprfuncs.go index c68a2699..e93daade 100644 --- a/pkg/exprenv/exprfuncs/exprfuncs.go +++ b/pkg/exprenv/exprfuncs/exprfuncs.go @@ -14,6 +14,8 @@ import ( "strings" "time" + "go.yaml.in/yaml/v3" + "github.com/harness/cli/v3/pkg/console" "github.com/harness/cli/v3/pkg/format" ) @@ -429,6 +431,16 @@ func Env(name string, def ...string) string { return "" } +// YamlParse parses a YAML document string into a map. Returns nil when s is empty, +// invalid, or not a mapping, so callers see an unresolved expression. +func YamlParse(s string) any { + var out map[string]any + if err := yaml.Unmarshal([]byte(s), &out); err != nil || out == nil { + return nil + } + return out +} + // Base64Decode decodes s as standard or URL-safe base64, padded or not. // Returns s unchanged when it is not valid base64, so unencoded values pass through. func Base64Decode(s string) string { diff --git a/pkg/exprenv/exprfuncs/exprfuncs_test.go b/pkg/exprenv/exprfuncs/exprfuncs_test.go index 3b79113a..41e1512a 100644 --- a/pkg/exprenv/exprfuncs/exprfuncs_test.go +++ b/pkg/exprenv/exprfuncs/exprfuncs_test.go @@ -4,6 +4,7 @@ package exprfuncs import ( + "reflect" "strconv" "testing" "time" @@ -92,6 +93,23 @@ func TestParseDateMsRejectsUnparseable(t *testing.T) { } } +func TestYamlParse(t *testing.T) { + got := YamlParse("trigger:\n name: n\n tags:\n env: dev\n items: [1, 2]\n") + want := map[string]any{"trigger": map[string]any{ + "name": "n", + "tags": map[string]any{"env": "dev"}, + "items": []any{1, 2}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("YamlParse = %#v, want %#v", got, want) + } + for _, in := range []string{"", "a: [", "- a\n- b"} { + if got := YamlParse(in); got != nil { + t.Errorf("YamlParse(%q) = %#v, want nil", in, got) + } + } +} + func TestBase64Decode(t *testing.T) { const want = "pat.acct.id.s3cr?t>" cases := map[string]string{ diff --git a/pkg/registry/checks.go b/pkg/registry/checks.go index e01b869c..eb9296df 100644 --- a/pkg/registry/checks.go +++ b/pkg/registry/checks.go @@ -405,8 +405,8 @@ func validateEndpointConstraints(cs *spec.CommandSpec) error { default: return fmt.Errorf("command %q: invalid content_type %q (must be \"application/json\", \"application/merge-patch+json\", or \"application/yaml\")", cs.Command, ep.ContentType) } - if ep.ContentType != "" && ep.FileBody == spec.FileBodyNone { - return fmt.Errorf("command %q: content_type requires file_body to be set", cs.Command) + if ep.ContentType != "" && ep.FileBody == spec.FileBodyNone && ep.UpdateStrategy == "" { + return fmt.Errorf("command %q: content_type requires file_body or update_strategy to be set", cs.Command) } switch ep.FileBodyContentType { case "", "application/json", "application/merge-patch+json", "application/yaml": diff --git a/pkg/registry/checks_workflow_test.go b/pkg/registry/checks_workflow_test.go index e38d0765..6b9c7fab 100644 --- a/pkg/registry/checks_workflow_test.go +++ b/pkg/registry/checks_workflow_test.go @@ -447,6 +447,17 @@ func TestValidateEndpointConstraints_ContentTypeRequiresFileBody(t *testing.T) { } } +func TestValidateEndpointConstraints_ContentTypeAllowedWithUpdateStrategy(t *testing.T) { + cs := &spec.CommandSpec{ + Command: "update trigger", Verb: VerbUpdate, VerbHandler: VerbUpdate, Noun: "trigger", Module: "test", + HandlerType: spec.HandlerEndpoint, + Endpoint: &spec.EndpointSpec{Method: "PUT", ContentType: "application/yaml", UpdateStrategy: spec.UpdateStrategyGetThenPut}, + } + if err := validateEndpointConstraints(cs); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + func TestValidateEndpointConstraints_FileBodyContentTypeRequiresFileBody(t *testing.T) { cs := &spec.CommandSpec{ Command: "create pipeline", Verb: VerbCreate, VerbHandler: VerbCreate, Noun: "pipeline", Module: "test", diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index 1a8e7108..12c5466c 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "maps" + "math" "net/http" "strings" @@ -799,19 +800,51 @@ func runGetThenUpdate(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, c *client.Client, return nil, err } + contentType := resolveContentType(ep, method) var updateBody any = body + if contentType == "application/yaml" { + out, err := yaml.Marshal(integralFloatsToInts(body)) + if err != nil { + return nil, fmt.Errorf("get-then-%s: encoding YAML body: %w", strings.ToLower(method), err) + } + updateBody = string(out) + } updateQP := evalQueryParams(ctx, ep.QueryParams, true) result, _, err := c.DoRequest(client.Request{ Method: method, Path: path, QueryParams: updateQP, Body: updateBody, - BodyContentType: resolveContentType(ep, method), + BodyContentType: contentType, Headers: extraHeaders, }) return result, err } +// integralFloatsToInts undoes the float64 widening from the JSON round trip so whole +// numbers are written as "1000000", not "1e+06". +func integralFloatsToInts(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = integralFloatsToInts(e) + } + return out + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = integralFloatsToInts(e) + } + return out + case float64: + if t == math.Trunc(t) && math.Abs(t) < 1<<53 { + return int64(t) + } + } + return v +} + // runGetThenPutKV implements the "get-then-put-kv" update strategy for APIs // that store metadata as [{key, value}] arrays (e.g. HAR v2 metadata endpoint): // 1. GET current kv pairs using get_query_params (falls back to query_params) diff --git a/pkg/registry/endpoint_test.go b/pkg/registry/endpoint_test.go index 7c7349b8..747330a5 100644 --- a/pkg/registry/endpoint_test.go +++ b/pkg/registry/endpoint_test.go @@ -659,6 +659,74 @@ func TestCallEndpointFull_Priority2_UpdateStrategies(t *testing.T) { } } +func TestCallEndpointFull_GetThenPutYAMLString(t *testing.T) { + getResp := `{"data":{"yaml":"trigger:\n name: old\n identifier: t1\n pollInterval: 1000000\n tags:\n env: dev\n"}}` + reg := New() + if err := reg.RegisterNoun(spec.NounDef{ + Noun: "widget", + Fields: []spec.FieldDef{{ID: "name", Expr: "it.name", MutablePath: "trigger.name"}}, + }); err != nil { + t.Fatal(err) + } + + srv, caps := sequenceServer(t, []string{getResp, `{}`}) + ctx := testCtx(srv.URL, nil) + ctx.Noun = "widget" + ctx.Id = "w1" + ctx.SetArgs = map[string]string{"name": "new"} + ctx.Resolver = reg + ep := &spec.EndpointSpec{ + Path: "/widgets/w1", Method: "PUT", UpdateStrategy: spec.UpdateStrategyGetThenPut, + UpdateBodyPick: "yamlParse(it.data.yaml)", ContentType: "application/yaml", FileBody: spec.FileBodyOptional, + } + if _, _, err := callEndpointFull(ctx, ep, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + put := (*caps)[1] + if put.contentType != "application/yaml" { + t.Fatalf("PUT Content-Type = %q, want application/yaml", put.contentType) + } + want := "trigger:\n identifier: t1\n name: new\n pollInterval: 1000000\n tags:\n env: dev\n" + if string(put.body) != want { + t.Fatalf("PUT body = %q, want %q", put.body, want) + } +} + +func TestCallEndpointFull_GetThenPutYAMLString_UnparseableYAMLSendsNoPut(t *testing.T) { + reg := New() + if err := reg.RegisterNoun(spec.NounDef{ + Noun: "widget", + Fields: []spec.FieldDef{{ID: "name", Expr: "it.name", MutablePath: "trigger.name"}}, + }); err != nil { + t.Fatal(err) + } + + for name, getResp := range map[string]string{ + "invalid": `{"data":{"yaml":"a: ["}}`, + "empty": `{"data":{"yaml":""}}`, + } { + t.Run(name, func(t *testing.T) { + srv, caps := sequenceServer(t, []string{getResp, `{}`}) + ctx := testCtx(srv.URL, nil) + ctx.Noun = "widget" + ctx.Id = "w1" + ctx.SetArgs = map[string]string{"name": "new"} + ctx.Resolver = reg + ep := &spec.EndpointSpec{ + Path: "/widgets/w1", Method: "PUT", UpdateStrategy: spec.UpdateStrategyGetThenPut, + UpdateBodyPick: "yamlParse(it.data.yaml)", ContentType: "application/yaml", FileBody: spec.FileBodyOptional, + } + if _, _, err := callEndpointFull(ctx, ep, nil); err == nil || !strings.Contains(err.Error(), "did not resolve") { + t.Fatalf("expected did-not-resolve error, got: %v", err) + } + if len(*caps) != 1 { + t.Fatalf("expected only the GET, got %d calls", len(*caps)) + } + }) + } +} + func TestCallEndpointFull_Priority2_GetThenPutKV(t *testing.T) { getResp := `[{"key":"env","value":"prod"},{"key":"team","value":"ops"}]` diff --git a/pkg/spec/pipeline.spec.yaml b/pkg/spec/pipeline.spec.yaml index 882b0282..5daa1c68 100644 --- a/pkg/spec/pipeline.spec.yaml +++ b/pkg/spec/pipeline.spec.yaml @@ -135,16 +135,21 @@ nouns: expr: it.identifier - id: name expr: it.name + mutable_path: trigger.name - id: type expr: it.type - id: enabled expr: it.enabled + mutable_path: trigger.enabled - id: description expr: it.description + mutable_path: trigger.description - id: pipeline expr: coalesce(it.targetIdentifier, ctx.idParts[0]) - id: tags expr: formatTagDisplay(it.tags) + field_type: tags + mutable_path: trigger.tags - id: webhook_url expr: it.webhookUrl - id: last_execution_status @@ -991,7 +996,7 @@ commands: - command: update trigger verb: update noun: trigger - short: "Update a trigger: harness update trigger / -f trigger.yaml" + short: "Update a trigger: harness update trigger / --set name= | -f trigger.yaml" id_parts: 2 id_label: "/" completion_seq: @@ -999,10 +1004,15 @@ commands: - completion_noun: trigger keep_order: true handler_type: endpoint + flags_builtin: + set: true + del: true endpoint: method: PUT path: /pipeline/api/triggers/{{lastPart(ctx.id)}} - file_body: required + update_strategy: get-then-put + update_body_pick: yamlParse(it.data.yaml) + file_body: optional content_type: application/yaml no_fields: true text_header: "\nUpdated trigger {{lastPart(ctx.id)}}\n" @@ -1020,13 +1030,19 @@ commands: - completion_noun: pipeline - completion_noun: trigger keep_order: true - handler_type: workflow - workflow_id: update_trigger_enable - flags: - - name: preview-request - description: Print the write request without sending it - is_bool: true - hidden: true + handler_type: endpoint + endpoint: + method: PUT + path: /pipeline/api/triggers/{{lastPart(ctx.id)}} + update_strategy: get-then-put + update_body_pick: yamlParse(it.data.yaml) + content_type: application/yaml + body_params: + trigger.enabled: "true" + no_fields: true + text_header: "\nEnabled trigger {{lastPart(ctx.id)}}\n" + query_params: + targetIdentifier: ctx.idParts[0] - command: update trigger:disable verb: update @@ -1039,13 +1055,19 @@ commands: - completion_noun: pipeline - completion_noun: trigger keep_order: true - handler_type: workflow - workflow_id: update_trigger_disable - flags: - - name: preview-request - description: Print the write request without sending it - is_bool: true - hidden: true + handler_type: endpoint + endpoint: + method: PUT + path: /pipeline/api/triggers/{{lastPart(ctx.id)}} + update_strategy: get-then-put + update_body_pick: yamlParse(it.data.yaml) + content_type: application/yaml + body_params: + trigger.enabled: "false" + no_fields: true + text_header: "\nDisabled trigger {{lastPart(ctx.id)}}\n" + query_params: + targetIdentifier: ctx.idParts[0] - command: delete trigger verb: delete