diff --git a/cmd/frostfs-cli/internal/client/client.go b/cmd/frostfs-cli/internal/client/client.go index 215490db..a0fa2241 100644 --- a/cmd/frostfs-cli/internal/client/client.go +++ b/cmd/frostfs-cli/internal/client/client.go @@ -2,10 +2,13 @@ package internal import ( "bytes" + "cmp" "context" "errors" "fmt" "io" + "os" + "slices" "sort" "strings" @@ -869,3 +872,65 @@ func SyncContainerSettings(ctx context.Context, prm SyncContainerPrm) (*SyncCont return new(SyncContainerRes), nil } + +// PatchObjectPrm groups parameters of PatchObject operation. +type PatchObjectPrm struct { + commonObjectPrm + objectAddressPrm + + NewAttributes []objectSDK.Attribute + + ReplaceAttribute bool + + PayloadPatches []PayloadPatch +} + +type PayloadPatch struct { + Range objectSDK.Range + + PayloadPath string +} + +type PatchRes struct { + OID oid.ID +} + +func Patch(ctx context.Context, prm PatchObjectPrm) (*PatchRes, error) { + patchPrm := client.PrmObjectPatch{ + XHeaders: prm.xHeaders, + BearerToken: prm.bearerToken, + Session: prm.sessionToken, + Address: prm.objAddr, + } + + slices.SortFunc(prm.PayloadPatches, func(a, b PayloadPatch) int { + return cmp.Compare(a.Range.GetOffset(), b.Range.GetOffset()) + }) + + patcher, err := prm.cli.ObjectPatchInit(ctx, patchPrm) + if err != nil { + return nil, fmt.Errorf("init payload reading: %w", err) + } + + if patcher.PatchAttributes(ctx, prm.NewAttributes, prm.ReplaceAttribute) { + for _, pp := range prm.PayloadPatches { + payloadFile, err := os.OpenFile(pp.PayloadPath, os.O_RDONLY, os.ModePerm) + if err != nil { + return nil, err + } + applied := patcher.PatchPayload(ctx, &pp.Range, payloadFile) + _ = payloadFile.Close() + if !applied { + break + } + } + } + + res, err := patcher.Close(ctx) + if err != nil { + return nil, err + } + return &PatchRes{ + OID: res.ObjectID(), + }, nil +} diff --git a/cmd/frostfs-cli/modules/object/patch.go b/cmd/frostfs-cli/modules/object/patch.go new file mode 100644 index 00000000..8f03885a --- /dev/null +++ b/cmd/frostfs-cli/modules/object/patch.go @@ -0,0 +1,151 @@ +package object + +import ( + "fmt" + "strconv" + "strings" + + internalclient "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + "github.com/spf13/cobra" +) + +const ( + newAttrsFlagName = "new-attrs" + replaceAttrsFlagName = "replace-attrs" + rangeFlagName = "range" + payloadFlagName = "payload" +) + +var objectPatchCmd = &cobra.Command{ + Use: "patch", + Run: patch, + Short: "Patch FrostFS object", + Long: "Patch FrostFS object. Each range passed to the command requires to pass a corresponding patch payload.", + Example: ` +frostfs-cli -c config.yml -r 127.0.0.1:8080 object patch --cid --oid --new-attrs 'key1=val1,key2=val2' --replace-attrs +frostfs-cli -c config.yml -r 127.0.0.1:8080 object patch --cid --oid --range offX:lnX --payload /path/to/payloadX --range offY:lnY --payload /path/to/payloadY +frostfs-cli -c config.yml -r 127.0.0.1:8080 object patch --cid --oid --new-attrs 'key1=val1,key2=val2' --replace-attrs --range offX:lnX --payload /path/to/payload +`, +} + +func initObjectPatchCmd() { + commonflags.Init(objectPatchCmd) + initFlagSession(objectPatchCmd, "PATCH") + + flags := objectPatchCmd.Flags() + + flags.String(commonflags.CIDFlag, "", commonflags.CIDFlagUsage) + _ = objectRangeCmd.MarkFlagRequired(commonflags.CIDFlag) + + flags.String(commonflags.OIDFlag, "", commonflags.OIDFlagUsage) + _ = objectRangeCmd.MarkFlagRequired(commonflags.OIDFlag) + + flags.String(newAttrsFlagName, "", "New object attributes in form of Key1=Value1,Key2=Value2") + flags.Bool(replaceAttrsFlagName, false, "Replace object attributes by new ones.") + flags.StringSlice(rangeFlagName, []string{}, "Range to which patch payload is applied. Format: offset:length") + flags.StringSlice(payloadFlagName, []string{}, "Path to file with patch payload.") +} + +func patch(cmd *cobra.Command, _ []string) { + var cnr cid.ID + var obj oid.ID + + objAddr := readObjectAddress(cmd, &cnr, &obj) + + ranges, err := getRangeSlice(cmd) + commonCmd.ExitOnErr(cmd, "", err) + + payloads := patchPayloadPaths(cmd) + + if len(ranges) != len(payloads) { + commonCmd.ExitOnErr(cmd, "", fmt.Errorf("the number of ranges and payloads are not equal: ranges = %d, payloads = %d", len(ranges), len(payloads))) + } + + newAttrs, err := parseNewObjectAttrs(cmd) + commonCmd.ExitOnErr(cmd, "can't parse new object attributes: %w", err) + replaceAttrs, _ := cmd.Flags().GetBool(replaceAttrsFlagName) + + pk := key.GetOrGenerate(cmd) + + cli := internalclient.GetSDKClientByFlag(cmd, pk, commonflags.RPC) + + var prm internalclient.PatchObjectPrm + prm.SetClient(cli) + Prepare(cmd, &prm) + ReadOrOpenSession(cmd, &prm, pk, cnr, nil) + + prm.SetAddress(objAddr) + prm.NewAttributes = newAttrs + prm.ReplaceAttribute = replaceAttrs + + for i := range ranges { + prm.PayloadPatches = append(prm.PayloadPatches, internalclient.PayloadPatch{ + Range: ranges[i], + PayloadPath: payloads[i], + }) + } + + res, err := internalclient.Patch(cmd.Context(), prm) + if err != nil { + commonCmd.ExitOnErr(cmd, "can't patch the object: %w", err) + } + cmd.Println("Patched object ID: ", res.OID.EncodeToString()) +} + +func parseNewObjectAttrs(cmd *cobra.Command) ([]objectSDK.Attribute, error) { + var rawAttrs []string + + raw := cmd.Flag(newAttrsFlagName).Value.String() + if len(raw) != 0 { + rawAttrs = strings.Split(raw, ",") + } + + attrs := make([]objectSDK.Attribute, len(rawAttrs), len(rawAttrs)+2) // name + timestamp attributes + for i := range rawAttrs { + k, v, found := strings.Cut(rawAttrs[i], "=") + if !found { + return nil, fmt.Errorf("invalid attribute format: %s", rawAttrs[i]) + } + attrs[i].SetKey(k) + attrs[i].SetValue(v) + } + return attrs, nil +} + +func getRangeSlice(cmd *cobra.Command) ([]objectSDK.Range, error) { + v, _ := cmd.Flags().GetStringSlice(rangeFlagName) + if len(v) == 0 { + return []objectSDK.Range{}, nil + } + rs := make([]objectSDK.Range, len(v)) + for i := range v { + before, after, found := strings.Cut(v[i], rangeSep) + if !found { + return nil, fmt.Errorf("invalid range specifier: %s", v[i]) + } + + offset, err := strconv.ParseUint(before, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid '%s' range offset specifier: %w", v[i], err) + } + length, err := strconv.ParseUint(after, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid '%s' range length specifier: %w", v[i], err) + } + + rs[i].SetOffset(offset) + rs[i].SetLength(length) + } + return rs, nil +} + +func patchPayloadPaths(cmd *cobra.Command) []string { + v, _ := cmd.Flags().GetStringSlice(payloadFlagName) + return v +} diff --git a/cmd/frostfs-cli/modules/object/root.go b/cmd/frostfs-cli/modules/object/root.go index 7d8008b1..b808a509 100644 --- a/cmd/frostfs-cli/modules/object/root.go +++ b/cmd/frostfs-cli/modules/object/root.go @@ -29,6 +29,7 @@ func init() { objectRangeCmd, objectLockCmd, objectNodesCmd, + objectPatchCmd, } Cmd.AddCommand(objectChildCommands...) @@ -39,6 +40,7 @@ func init() { } initObjectPutCmd() + initObjectPatchCmd() initObjectDeleteCmd() initObjectGetCmd() initObjectSearchCmd() diff --git a/cmd/frostfs-cli/modules/object/util.go b/cmd/frostfs-cli/modules/object/util.go index 381c790e..96b80fe1 100644 --- a/cmd/frostfs-cli/modules/object/util.go +++ b/cmd/frostfs-cli/modules/object/util.go @@ -306,6 +306,8 @@ func finalizeSession(cmd *cobra.Command, dst SessionPrm, tok *session.Object, ke case *internal.PutObjectPrm: common.PrintVerbose(cmd, "Binding session to object PUT...") tok.ForVerb(session.VerbObjectPut) + case *internal.PatchObjectPrm: + tok.ForVerb(session.VerbObjectPatch) case *internal.DeleteObjectPrm: common.PrintVerbose(cmd, "Binding session to object DELETE...") tok.ForVerb(session.VerbObjectDelete)