Repository: NearNodeFlash/nnf-dm
Pull request #298: Add copy-offload status query
Author: login: roehrich-hpe
Created: 2025-03-26T16:59:37Z | Merged: 2025-03-26T19:28:15Z



Diff:
diff --git a/daemons/copy-offload-testing/e2e-mocked.sh b/daemons/copy-offload-testing/e2e-mocked.sh
index 25ce2182..2ad213a3 100755
--- a/daemons/copy-offload-testing/e2e-mocked.sh
+++ b/daemons/copy-offload-testing/e2e-mocked.sh
@@ -139,7 +139,7 @@ if ! job1=$($CO $CO_TLS_ARGS -o -S /mnt/nnf/ooo -D /lus/foo); then
     echo "line $LINENO output: $job1"
     cleanup
 fi
-if [[ $job1 != "nnf-copy-offload-node-0" ]]; then
+if [[ $job1 != "mock-rabbit-01--nnf-copy-offload-node-0" ]]; then
     echo "FAIL: Unexpected output from copy. Got ($job1)."
     kill "$srvr_pid"
     exit 1
diff --git a/daemons/copy-offload/cmd/main.go b/daemons/copy-offload/cmd/main.go
index 7c2b27ae..938c022d 100644
--- a/daemons/copy-offload/cmd/main.go
+++ b/daemons/copy-offload/cmd/main.go
@@ -118,7 +118,7 @@ func main() {
 
 	crLog := setupLog()
 	// Make one of these for this server, and use it in all requests.
-	drvr := &driver.Driver{Log: crLog, Mock: mock}
+	drvr := driver.NewDriver(crLog, mock)
 
 	if !skipTls {
 		serverTLSCert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
@@ -156,6 +156,7 @@ func main() {
 
 	slog.Info("Ready", "node", rabbitName, "addr", *addr, "mock", mock, "TLS", !skipTls, "token", !skipToken)
 
+	// Make one of these for this server, and use it in all requests.
 	httpHandler := &userHttp.UserHttp{
 		Log:  crLog,
 		Drvr: drvr,
@@ -170,6 +171,7 @@ func main() {
 	mux.HandleFunc("/trial", httpHandler.TrialRequest)
 	mux.HandleFunc("/cancel/", httpHandler.CancelRequest)
 	mux.HandleFunc("/list", httpHandler.ListRequests)
+	mux.HandleFunc("/status/", httpHandler.GetRequest)
 
 	srv := &http.Server{
 		Addr:      *addr,
diff --git a/daemons/copy-offload/pkg/driver/dmrequest.go b/daemons/copy-offload/pkg/driver/dmrequest.go
index 8939c5c3..fb2e5a0e 100644
--- a/daemons/copy-offload/pkg/driver/dmrequest.go
+++ b/daemons/copy-offload/pkg/driver/dmrequest.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2024 Hewlett Packard Enterprise Development LP
+ * Copyright 2024-2025 Hewlett Packard Enterprise Development LP
  * Other additional copyright holders may be indicated within.
  *
  * The entirety of this work is licensed under the Apache License,
@@ -27,6 +27,7 @@ import (
 
 // DMRequest represents the content of one http request. This has a
 // one-to-one relationship with a DriverRequest object.
+// This is the v1.0 apiVersion. See COPY_OFFLOAD_API_VERSION.
 type DMRequest struct {
 	ComputeName string `json:"computeName"`
 
@@ -60,6 +61,20 @@ type DMRequest struct {
 	MpirunOptions string `json:"mpirunOptions"`
 }
 
+// StatusRequest represents the content of one http request. This has a
+// one-to-one relationship with a DriverRequest object.
+// This is the v1.0 apiVersion. See COPY_OFFLOAD_API_VERSION.
+type StatusRequest struct {
+	// The name and namespace of the initiating workflow
+	WorkflowName      string `json:"workflowName"`
+	WorkflowNamespace string `json:"workflowNamespace"`
+	// Name of the copy request.
+	RequestName string `json:"requestName"`
+	// Max number of seconds to wait for the completion of the copy request.
+	// A negative number results in an indefinite wait.
+	MaxWaitSecs int `json:"maxWaitSecs"`
+}
+
 func (m *DMRequest) Validator() error {
 
 	if m.ComputeName == "" {
@@ -86,3 +101,18 @@ func (m *DMRequest) Validator() error {
 
 	return nil
 }
+
+func (m *StatusRequest) Validator() error {
+
+	if m.RequestName == "" {
+		return fmt.Errorf("request name must be supplied")
+	}
+	if m.WorkflowName == "" {
+		return fmt.Errorf("workflow name must be supplied")
+	}
+	if m.WorkflowNamespace == "" {
+		m.WorkflowNamespace = corev1.NamespaceDefault
+	}
+
+	return nil
+}
diff --git a/daemons/copy-offload/pkg/driver/dmresponse.go b/daemons/copy-offload/pkg/driver/dmresponse.go
new file mode 100644
index 00000000..94037701
--- /dev/null
+++ b/daemons/copy-offload/pkg/driver/dmresponse.go
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2025 Hewlett Packard Enterprise Development LP
+ * Other additional copyright holders may be indicated within.
+ *
+ * The entirety of this work is licensed under the Apache License,
+ * Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License.
+ *
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package driver
+
+import "fmt"
+
+type DataMovementStatusResponse_State string
+
+const (
+	DataMovementStatusResponse_PENDING       DataMovementStatusResponse_State = "pending"       // The request is created but has a pending status
+	DataMovementStatusResponse_STARTING      DataMovementStatusResponse_State = "starting"      // The request was created and is in the act of starting
+	DataMovementStatusResponse_RUNNING       DataMovementStatusResponse_State = "running"       // The data movement request is actively running
+	DataMovementStatusResponse_COMPLETED     DataMovementStatusResponse_State = "completed"     // The data movement request has completed
+	DataMovementStatusResponse_CANCELLING    DataMovementStatusResponse_State = "cancelling"    // The data movement request has started the cancellation process
+	DataMovementStatusResponse_UNKNOWN_STATE DataMovementStatusResponse_State = "unknown state" // Unknown state
+)
+
+type DataMovementStatusResponse_Status string
+
+const (
+	DataMovementStatusResponse_INVALID        DataMovementStatusResponse_Status = "invalid"        // The request was found to be invalid. See `message` for details
+	DataMovementStatusResponse_NOT_FOUND      DataMovementStatusResponse_Status = "not found"      // The request with the supplied UID was not found
+	DataMovementStatusResponse_SUCCESS        DataMovementStatusResponse_Status = "success"        // The request completed with success
+	DataMovementStatusResponse_FAILED         DataMovementStatusResponse_Status = "failed"         // The request failed. See `message` for details
+	DataMovementStatusResponse_CANCELLED      DataMovementStatusResponse_Status = "cancelled"      // The request was cancelled
+	DataMovementStatusResponse_UNKNOWN_STATUS DataMovementStatusResponse_Status = "unknown status" // Unknown status
+)
+
+// Data movement operations contain a CommandStatus in the Status object. This information relays the current state of the data movement progress.
+type DataMovementCommandStatus struct {
+	// Command used to perform Data Movement
+	Command string `json:"command,omitempty"`
+	// Progress percentage (0-100%) of the data movement based on the output of `dcp`. `dcp` must be present in the command.
+	Progress int32 `json:"progress,omitempty"`
+	// Duration of how long the operation has been running (e.g. 1m30s)
+	ElapsedTime string `json:"elapsedTime,omitempty"`
+	// The most recent line of output from the data movement command
+	LastMessage string `json:"lastMessage,omitempty"`
+	// The time (local) of lastMessage
+	LastMessageTime string `json:"lastMessageTime,omitempty"`
+}
+
+// The Data Movement Status Response message defines the current status of a data movement request.
+// This is the v1.0 apiVersion. See COPY_OFFLOAD_API_VERSION.
+type DataMovementStatusResponse_v1_0 struct {
+	// Current state of the Data Movement Request
+	State DataMovementStatusResponse_State `json:"state,omitempty"`
+	// Current status of the Data Movement Request
+	Status DataMovementStatusResponse_Status `json:"status,omitempty"`
+	// Any extra information supplied with the state/status
+	Message string `json:"message,omitempty"`
+	// Current state/progress of the Data Movement command
+	CommandStatus *DataMovementCommandStatus `json:"commandStatus,omitempty"`
+	// The start time (local) of the data movement operation
+	StartTime string `json:"startTime,omitempty"`
+	// The end time (local) of the data movement operation
+	EndTime string `json:"endTime,omitempty"`
+}
+
+func (dmr *DataMovementStatusResponse_v1_0) String() error {
+	if dmr.State == "" {
+		return fmt.Errorf("State must be set")
+	}
+	if dmr.Status == "" {
+		return fmt.Errorf("Status must be set")
+	}
+	if dmr.CommandStatus == nil {
+		return fmt.Errorf("CommandStatus must be set")
+	}
+	if dmr.StartTime == "" {
+		return fmt.Errorf("StartTime must be set")
+	}
+	if dmr.EndTime == "" {
+		return fmt.Errorf("EndTime must be set")
+	}
+	return nil
+}
diff --git a/daemons/copy-offload/pkg/driver/driver.go b/daemons/copy-offload/pkg/driver/driver.go
index b879bf84..c543523c 100644
--- a/daemons/copy-offload/pkg/driver/driver.go
+++ b/daemons/copy-offload/pkg/driver/driver.go
@@ -25,6 +25,8 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"math"
+	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -37,6 +39,7 @@ import (
 	"github.com/go-logr/logr"
 	"go.openly.dev/pointy"
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/util/retry"
@@ -67,13 +70,17 @@ type Driver struct {
 	// This is a thread safe map since multiple data movement reconcilers and go routines will be executing at the same time.
 	// The SrvrDataMovementRecord objects are stored here.
 	contexts sync.Map
+
+	// A map of operation completions. When one is completed, its name is recorded
+	// here until it can be reaped.
+	completions map[string]struct{}
+	cond        *sync.Cond
 }
 
 // Keep track of the context and its cancel function so that we can track
 // and cancel data movement operations in progress.
 // These objects are stored in the Driver.contexts map.
 type SrvrDataMovementRecord struct {
-	dmreq         *nnfv1alpha6.NnfDataMovement
 	cancelContext helpers.DataMovementCancelContext
 }
 
@@ -98,7 +105,16 @@ type DriverRequest struct {
 	cmdArgs []string
 }
 
-func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (*nnfv1alpha6.NnfDataMovement, error) {
+func NewDriver(log logr.Logger, mock bool) *Driver {
+	return &Driver{
+		Log:         log,
+		Mock:        mock,
+		completions: make(map[string]struct{}),
+		cond:        sync.NewCond(&sync.Mutex{}),
+	}
+}
+
+func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (string, *nnfv1alpha6.NnfDataMovement, error) {
 
 	drvr := r.Drvr
 	crLog := drvr.Log.WithValues("workflow", dmreq.WorkflowName)
@@ -107,25 +123,25 @@ func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (*nnfv1alph
 	wf := types.NamespacedName{Name: dmreq.WorkflowName, Namespace: dmreq.WorkflowNamespace}
 	if err := drvr.Client.Get(ctx, wf, workflow); err != nil {
 		crLog.Info("Unable to get workflow: %v", err)
-		return nil, err
+		return "", nil, err
 	}
 	if workflow.Status.State != dwsv1alpha3.StatePreRun || workflow.Status.Status != "Completed" {
 		err := fmt.Errorf("workflow must be in '%s' state and 'Completed' status", dwsv1alpha3.StatePreRun)
 		crLog.Error(err, "Workflow is in an invalid state")
-		return nil, err
+		return "", nil, err
 	}
 
 	computeClientMount, computeMountInfo, err := r.findComputeMountInfo(ctx, dmreq)
 	if err != nil {
 		crLog.Error(err, "Failed to retrieve compute mountinfo")
-		return nil, err
+		return "", nil, err
 	}
 
 	// Determine which rabbit is local to the compute that made the request
 	rabbit, err := r.findRabbitNameFromCompute(dmreq.ComputeName)
 	if err != nil {
 		crLog.Error(err, "Failed to trace compute node to its rabbit node")
-		return nil, err
+		return "", nil, err
 	}
 	r.RabbitName = rabbit
 
@@ -145,14 +161,14 @@ func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (*nnfv1alph
 
 	if err != nil {
 		crLog.Error(err, "Failed to create DM request")
-		return nil, err
+		return "", nil, err
 	}
 
 	// Dm Profile - no pinned profiles here since copy_offload could use any profile.
 	r.dmProfile, err = r.selectProfile(ctx, dmreq)
 	if err != nil {
 		crLog.Error(err, "Failed to get data movement profile", "wanted", dmreq.DMProfile)
-		return nil, err
+		return "", nil, err
 	}
 	dm.Spec.ProfileReference = corev1.ObjectReference{
 		Kind:      reflect.TypeOf(nnfv1alpha6.NnfDataMovementProfile{}).Name(),
@@ -178,14 +194,14 @@ func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (*nnfv1alph
 	r.nodes, err = helpers.GetStorageNodeNames(drvr.Client, ctx, dm)
 	if err != nil {
 		crLog.Error(err, "could not get storage nodes for data movement")
-		return nil, err
+		return "", nil, err
 	}
 
 	// Get the FQDNs of the worker nodes so we can use them to create the mpirun hostfile
 	r.hosts, err = helpers.GetCopyOffloadWorkerHostnames(drvr.Client, ctx, r.nodes, dmreq.WorkflowName, dmreq.WorkflowNamespace, dm)
 	if err != nil {
 		crLog.Error(err, "could not get worker nodes for data movement")
-		return nil, err
+		return "", nil, err
 	}
 
 	// Create the hostfile used by `mpirun`. This is needed for preparing the destination
@@ -193,38 +209,39 @@ func (r *DriverRequest) Create(ctx context.Context, dmreq DMRequest) (*nnfv1alph
 	r.mpiHostfile, err = helpers.CreateMpiHostfile(r.dmProfile, r.hosts, dm)
 	if err != nil {
 		crLog.Error(err, "could not create MPI hostfile")
-		return nil, err
+		return "", nil, err
 	}
 	crLog.Info("MPI Hostfile preview", "first line", helpers.PeekMpiHostfile(r.mpiHostfile))
 
 	// Prepare Destination Directory
 	if err := helpers.PrepareDestination(drvr.Client, ctx, r.dmProfile, dm, r.mpiHostfile, crLog); err != nil {
 		crLog.Error(err, "could not prepare destination")
-		return nil, err
+		return "", nil, err
 	}
 
 	// Build command
 	r.cmdArgs, err = helpers.BuildDMCommand(r.dmProfile, r.mpiHostfile, false, dm, crLog)
 	if err != nil {
 		crLog.Error(err, "could not create data movement command")
-		return nil, err
+		return "", nil, err
 	}
 
 	if err := drvr.Client.Create(ctx, dm); err != nil {
 		crLog.Error(err, "Failed to create NnfDataMovement")
-		return nil, err
+		return "", nil, err
 	}
 	crLog.Info("Created NnfDataMovement", "name", dm.Name)
 	r.recordRequest(ctx, dm)
 
-	return dm, nil
+	return r.dmKey(dm), dm, nil
 }
 
-func (r *DriverRequest) CreateMock(ctx context.Context, dmreq DMRequest) (*nnfv1alpha6.NnfDataMovement, error) {
+func (r *DriverRequest) CreateMock(ctx context.Context, dmreq DMRequest) (string, *nnfv1alpha6.NnfDataMovement, error) {
 	drvr := r.Drvr
 	crLog := drvr.Log.WithValues("workflow", dmreq.WorkflowName)
 	var err error
 
+	r.RabbitName = "mock-rabbit-01"
 	dm := &nnfv1alpha6.NnfDataMovement{
 		ObjectMeta: metav1.ObjectMeta{
 			GenerateName: nodeNameBase,
@@ -267,18 +284,18 @@ func (r *DriverRequest) CreateMock(ctx context.Context, dmreq DMRequest) (*nnfv1
 	r.cmdArgs, err = helpers.BuildDMCommand(r.dmProfile, r.mpiHostfile, false, dm, crLog)
 	if err != nil {
 		crLog.Error(err, "could not create data movement command")
-		return nil, err
+		return "", nil, err
 	}
 
 	r.recordRequest(ctx, dm)
-	return dm, nil
+	return r.dmKey(dm), dm, nil
 }
 
 func (r *DriverRequest) Drive(ctx context.Context, dmreq DMRequest, dm *nnfv1alpha6.NnfDataMovement) error {
 	drvr := r.Drvr
 	crLog := drvr.Log.WithValues("workflow", dmreq.WorkflowName)
 
-	contextRecord, err := r.loadRequest(dm.Name)
+	contextRecord, err := r.loadRequest(dm)
 	if err != nil {
 		crLog.Error(err, "request not found")
 		return err
@@ -287,7 +304,8 @@ func (r *DriverRequest) Drive(ctx context.Context, dmreq DMRequest, dm *nnfv1alp
 	if err := r.driveWithContext(ctx, cancelContext.Ctx, dm, crLog); err != nil {
 		crLog.Error(err, "failed copy")
 		os.RemoveAll(filepath.Dir(r.mpiHostfile))
-		drvr.contexts.Delete(dm.Name)
+		r.deleteRequest(dm)
+		r.notifyCompletion(dm)
 		return err
 	}
 
@@ -298,7 +316,7 @@ func (r *DriverRequest) DriveMock(ctx context.Context, dmreq DMRequest, dm *nnfv
 	drvr := r.Drvr
 	crLog := drvr.Log.WithValues("workflow", dmreq.WorkflowName)
 
-	contextRecord, err := r.loadRequest(dm.Name)
+	contextRecord, err := r.loadRequest(dm)
 	if err != nil {
 		crLog.Error(err, "request not found")
 		return err
@@ -306,21 +324,30 @@ func (r *DriverRequest) DriveMock(ctx context.Context, dmreq DMRequest, dm *nnfv
 	cancelContext := contextRecord.cancelContext
 	if err := r.driveWithContext(ctx, cancelContext.Ctx, dm, crLog); err != nil {
 		crLog.Error(err, "failed copy")
-		drvr.contexts.Delete(dm.Name)
+		r.deleteRequest(dm)
+		r.notifyCompletion(dm)
 		return err
 	}
 
 	return nil
 }
 
+func (r *DriverRequest) dmKey(dm *nnfv1alpha6.NnfDataMovement) string {
+	return fmt.Sprintf("%s--%s", dm.Namespace, dm.Name)
+}
+
+func (r *DriverRequest) dmKeySplit(key string) (string, string) {
+	split := strings.Split(key, "--")
+	return split[0], split[1]
+}
+
 func (r *DriverRequest) recordRequest(ctx context.Context, dm *nnfv1alpha6.NnfDataMovement) {
 	drvr := r.Drvr
 
 	// Expand the context with cancel and store it in the map so the cancel function can be
 	// found by another server thread if necessary.
 	ctxCancel, cancel := context.WithCancel(ctx)
-	drvr.contexts.Store(dm.Name, SrvrDataMovementRecord{
-		dmreq: dm,
+	drvr.contexts.Store(r.dmKey(dm), SrvrDataMovementRecord{
 		cancelContext: helpers.DataMovementCancelContext{
 			Ctx:    ctxCancel,
 			Cancel: cancel,
@@ -328,7 +355,11 @@ func (r *DriverRequest) recordRequest(ctx context.Context, dm *nnfv1alpha6.NnfDa
 	})
 }
 
-func (r *DriverRequest) loadRequest(name string) (SrvrDataMovementRecord, error) {
+func (r *DriverRequest) loadRequest(dm *nnfv1alpha6.NnfDataMovement) (SrvrDataMovementRecord, error) {
+	return r.loadRequestByName(r.dmKey(dm))
+}
+
+func (r *DriverRequest) loadRequestByName(name string) (SrvrDataMovementRecord, error) {
 	storedCancelContext, loaded := r.Drvr.contexts.Load(name)
 	if !loaded {
 		return SrvrDataMovementRecord{}, fmt.Errorf("request not found")
@@ -336,10 +367,14 @@ func (r *DriverRequest) loadRequest(name string) (SrvrDataMovementRecord, error)
 	return storedCancelContext.(SrvrDataMovementRecord), nil
 }
 
+func (r *DriverRequest) deleteRequest(dm *nnfv1alpha6.NnfDataMovement) {
+	r.Drvr.contexts.Delete(r.dmKey(dm))
+}
+
 func (r *DriverRequest) CancelRequest(ctx context.Context, name string) error {
 	drvr := r.Drvr
 
-	contextRecord, err := r.loadRequest(name)
+	contextRecord, err := r.loadRequestByName(name)
 	if err != nil {
 		// Maybe the Go routine already finished and removed the record.
 		return err
@@ -355,6 +390,123 @@ func (r *DriverRequest) CancelRequest(ctx context.Context, name string) error {
 	return nil
 }
 
+func (r *DriverRequest) GetRequestMock(ctx context.Context, statreq StatusRequest) (*DataMovementStatusResponse_v1_0, int, error) {
+	return &DataMovementStatusResponse_v1_0{
+		State:  DataMovementStatusResponse_PENDING,
+		Status: DataMovementStatusResponse_UNKNOWN_STATUS,
+	}, -1, nil
+}
+
+func (r *DriverRequest) GetRequest(ctx context.Context, statreq StatusRequest) (*DataMovementStatusResponse_v1_0, int, error) {
+	drvr := r.Drvr
+	keyNS, keyName := r.dmKeySplit(statreq.RequestName)
+	dmReq := types.NamespacedName{Name: keyName, Namespace: keyNS}
+	dm := &nnfv1alpha6.NnfDataMovement{}
+	crLog := drvr.Log.WithValues("workflow", statreq.WorkflowName, "request", statreq.RequestName, "namespace", statreq.WorkflowNamespace)
+
+	drvr.Log.Info("Getting request", "request", dmReq)
+	if err := drvr.Client.Get(ctx, dmReq, dm); err != nil {
+		if apierrors.IsNotFound(err) {
+			r.deleteCompletionByName(statreq.RequestName)
+			return nil, http.StatusNotFound, errors.New("request not found")
+		}
+		crLog.Error(err, "failed to get NnfDataMovement resource")
+		return nil, http.StatusInternalServerError, err
+	}
+	if dm.Labels[dwsv1alpha3.WorkflowNameLabel] != statreq.WorkflowName || dm.Labels[dwsv1alpha3.WorkflowNamespaceLabel] != statreq.WorkflowNamespace {
+		return nil, http.StatusBadRequest, errors.New("request does not belong to workflow")
+	}
+
+	if dm.Status.EndTime.IsZero() && statreq.MaxWaitSecs != 0 {
+
+		r.waitForCompletionOrTimeout(statreq)
+
+		if err := drvr.Client.Get(ctx, dmReq, dm); err != nil {
+			if apierrors.IsNotFound(err) {
+				r.deleteCompletionByName(statreq.RequestName)
+				return nil, http.StatusNotFound, errors.New("request not found after waiting for completion")
+			}
+			crLog.Error(err, "failed to get NnfDataMovement resource afer waiting for completion")
+			return nil, http.StatusInternalServerError, err
+		}
+	}
+
+	if dm.Status.StartTime.IsZero() && dm.Status.Status != nnfv1alpha6.DataMovementConditionReasonInvalid {
+		return &DataMovementStatusResponse_v1_0{
+			State:  DataMovementStatusResponse_PENDING,
+			Status: DataMovementStatusResponse_UNKNOWN_STATUS,
+		}, -1, nil
+	}
+
+	stateMap := map[string]DataMovementStatusResponse_State{
+		"": DataMovementStatusResponse_UNKNOWN_STATE,
+		nnfv1alpha6.DataMovementConditionTypeStarting: DataMovementStatusResponse_STARTING,
+		nnfv1alpha6.DataMovementConditionTypeRunning:  DataMovementStatusResponse_RUNNING,
+		nnfv1alpha6.DataMovementConditionTypeFinished: DataMovementStatusResponse_COMPLETED,
+	}
+
+	state, ok := stateMap[dm.Status.State]
+	if !ok {
+		return nil, http.StatusInternalServerError, errors.New("failed to decode returned state")
+	}
+
+	if state != DataMovementStatusResponse_COMPLETED && dm.Spec.Cancel {
+		state = DataMovementStatusResponse_CANCELLING
+	}
+
+	statusMap := map[string]DataMovementStatusResponse_Status{
+		"": DataMovementStatusResponse_UNKNOWN_STATUS,
+		nnfv1alpha6.DataMovementConditionReasonFailed:    DataMovementStatusResponse_FAILED,
+		nnfv1alpha6.DataMovementConditionReasonSuccess:   DataMovementStatusResponse_SUCCESS,
+		nnfv1alpha6.DataMovementConditionReasonInvalid:   DataMovementStatusResponse_INVALID,
+		nnfv1alpha6.DataMovementConditionReasonCancelled: DataMovementStatusResponse_CANCELLED,
+	}
+
+	status, ok := statusMap[dm.Status.Status]
+	if !ok {
+		return nil, http.StatusInternalServerError, errors.New("failed to decode returned status")
+	}
+
+	cmdStatus := DataMovementCommandStatus{}
+	if dm.Status.CommandStatus != nil {
+		cmdStatus.Command = dm.Status.CommandStatus.Command
+		cmdStatus.LastMessage = dm.Status.CommandStatus.LastMessage
+
+		if dm.Status.CommandStatus.ElapsedTime.Duration > 0 {
+			d := dm.Status.CommandStatus.ElapsedTime.Truncate(time.Millisecond)
+			cmdStatus.ElapsedTime = d.String()
+		}
+		if !dm.Status.CommandStatus.LastMessageTime.IsZero() {
+			cmdStatus.LastMessageTime = dm.Status.CommandStatus.LastMessageTime.Local().String()
+		}
+		if dm.Status.CommandStatus.ProgressPercentage != nil {
+			cmdStatus.Progress = *dm.Status.CommandStatus.ProgressPercentage
+		}
+	}
+
+	startTimeStr := ""
+	if !dm.Status.StartTime.IsZero() {
+		startTimeStr = dm.Status.StartTime.Local().String()
+	}
+	endTimeStr := ""
+	if !dm.Status.EndTime.IsZero() {
+		endTimeStr = dm.Status.EndTime.Local().String()
+	}
+
+	if state == DataMovementStatusResponse_COMPLETED && !dm.Status.EndTime.IsZero() {
+		r.deleteCompletionByName(statreq.RequestName)
+	}
+
+	return &DataMovementStatusResponse_v1_0{
+		State:         state,
+		Status:        status,
+		Message:       dm.Status.Message,
+		CommandStatus: &cmdStatus,
+		StartTime:     startTimeStr,
+		EndTime:       endTimeStr,
+	}, -1, nil
+}
+
 func (r *DriverRequest) ListRequests(ctx context.Context) ([]string, error) {
 	drvr := r.Drvr
 
@@ -404,7 +556,10 @@ func (r *DriverRequest) driveWithContext(ctx context.Context, ctxCancel context.
 	}
 
 	crLog.Info("Running Command", "cmd", cmdStatus.Command)
-	contextDelete := func() { drvr.contexts.Delete(dm.Name) }
+	contextDelete := func() {
+		r.deleteRequest(dm)
+		r.notifyCompletion(dm)
+	}
 	r.runit(ctx, ctxCancel, contextDelete, dmReq, cmd, &cmdStatus, r.dmProfile, r.mpiHostfile, crLog)
 	return nil
 }
@@ -589,6 +744,63 @@ func (r *DriverRequest) runit(ctx context.Context, ctxCancel context.Context, co
 	}()
 }
 
+func (r *DriverRequest) notifyCompletion(dm *nnfv1alpha6.NnfDataMovement) {
+	drvr := r.Drvr
+	key := r.dmKey(dm)
+
+	drvr.cond.L.Lock()
+	drvr.completions[key] = struct{}{}
+	drvr.cond.L.Unlock()
+	drvr.cond.Broadcast()
+}
+
+func (r *DriverRequest) deleteCompletionByName(name string) {
+	drvr := r.Drvr
+
+	drvr.cond.L.Lock()
+	delete(drvr.completions, name)
+	drvr.cond.L.Unlock()
+}
+
+// waitForCompletionOrTimeout waits for the completion of the status request or for timeout.
+func (r *DriverRequest) waitForCompletionOrTimeout(statreq StatusRequest) {
+	drvr := r.Drvr
+	timeout := false
+	complete := make(chan struct{}, 1)
+
+	// Start a go routine that waits for the completion of this status request or for timeout
+	go func() {
+
+		drvr.cond.L.Lock()
+		for {
+			if _, found := drvr.completions[statreq.RequestName]; found || timeout {
+				break
+			}
+
+			drvr.cond.Wait()
+		}
+
+		drvr.cond.L.Unlock()
+
+		complete <- struct{}{}
+	}()
+
+	var maxWaitTimeDuration time.Duration
+	if statreq.MaxWaitSecs < 0 || time.Duration(statreq.MaxWaitSecs) > math.MaxInt64/time.Second {
+		maxWaitTimeDuration = math.MaxInt64
+	} else {
+		maxWaitTimeDuration = time.Duration(statreq.MaxWaitSecs) * time.Second
+	}
+
+	// Wait for completion signal or timeout
+	select {
+	case <-complete:
+	case <-time.After(maxWaitTimeDuration):
+		timeout = true
+		drvr.cond.Broadcast() // Wake up everyone (including myself) to close out the running go routine
+	}
+}
+
 // Set the DM's UserConfig options based on the incoming requests's options
 func setUserConfig(dmreq DMRequest, dm *nnfv1alpha6.NnfDataMovement) {
 	dm.Spec.UserConfig = &nnfv1alpha6.NnfDataMovementConfig{}
diff --git a/daemons/copy-offload/pkg/server/server.go b/daemons/copy-offload/pkg/server/server.go
index fb1e96b7..44201f5c 100644
--- a/daemons/copy-offload/pkg/server/server.go
+++ b/daemons/copy-offload/pkg/server/server.go
@@ -27,6 +27,7 @@ import (
 	"net/http"
 	"net/url"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"github.com/NearNodeFlash/nnf-dm/daemons/copy-offload/pkg/driver"
@@ -35,6 +36,8 @@ import (
 	"github.com/golang-jwt/jwt/v5"
 )
 
+// UserHttp will have only one instance per process, shared by all threads
+// in the process.
 type UserHttp struct {
 	Log      logr.Logger
 	Drvr     *driver.Driver
@@ -103,6 +106,66 @@ func (user *UserHttp) Hello(w http.ResponseWriter, req *http.Request) {
 	fmt.Fprintf(w, "hello back at ya\n")
 }
 
+func (user *UserHttp) GetRequest(w http.ResponseWriter, req *http.Request) {
+	var err error
+	var apiVersion string
+	if req.Method != "GET" {
+		http.Error(w, "method not supported", http.StatusNotImplemented)
+		return
+	}
+	if apiVersion = user.validateMessage(w, req); apiVersion == "" {
+		return
+	}
+
+	user.Log.Info("In GetRequest", "version", apiVersion, "url", req.URL)
+	urlParts, err := url.Parse(req.URL.String())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("unable to parse URL query parameters: %s", err.Error()), http.StatusBadRequest)
+		return
+	}
+	requestName := filepath.Base(urlParts.Path)
+	params := urlParts.Query()
+
+	// This is the v1.0 apiVersion input. See COPY_OFFLOAD_API_VERSION.
+	var statreq driver.StatusRequest
+	statreq.RequestName = requestName
+	statreq.WorkflowName = params.Get("workflowName")
+	statreq.WorkflowNamespace = params.Get("workflowNamespace")
+	statreq.MaxWaitSecs, err = strconv.Atoi(params.Get("maxWaitSecs"))
+	if err != nil {
+		http.Error(w, fmt.Sprintf("unable to parse maxWaitSecs: %s", err.Error()), http.StatusBadRequest)
+		return
+	}
+	if err := statreq.Validator(); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	user.Log.Info("  GetRequest", "dmreq", statreq)
+
+	drvrReq := driver.DriverRequest{Drvr: user.Drvr}
+	var http_code int
+	// This is the v1.0 apiVersion output. See COPY_OFFLOAD_API_VERSION.
+	var response *driver.DataMovementStatusResponse_v1_0
+	if user.Mock {
+		response, http_code, err = drvrReq.GetRequestMock(context.TODO(), statreq)
+	} else {
+		response, http_code, err = drvrReq.GetRequest(context.TODO(), statreq)
+	}
+	if err != nil {
+		if http_code > 0 {
+			http.Error(w, fmt.Sprintf("%s\n", err.Error()), http_code)
+		} else {
+			http.Error(w, fmt.Sprintf("unable to get request: %s\n", err.Error()), http.StatusInternalServerError)
+		}
+		return
+	}
+	if err := json.NewEncoder(w).Encode(response); err != nil {
+		http.Error(w, fmt.Sprintf("unable to encode data movement status response: %s", err.Error()), http.StatusInternalServerError)
+		return
+	}
+	// StatusOK is implied.
+}
+
 func (user *UserHttp) ListRequests(w http.ResponseWriter, req *http.Request) {
 	var apiVersion string
 	if req.Method != "GET" {
@@ -119,6 +182,7 @@ func (user *UserHttp) ListRequests(w http.ResponseWriter, req *http.Request) {
 		http.Error(w, fmt.Sprintf("unable to list requests: %s\n", err.Error()), http.StatusInternalServerError)
 		return
 	}
+	// This is the v1.0 apiVersion output. See COPY_OFFLOAD_API_VERSION.
 	if len(items) > 0 {
 		fmt.Fprintln(w, strings.Join(items, ","))
 	}
@@ -136,7 +200,7 @@ func (user *UserHttp) CancelRequest(w http.ResponseWriter, req *http.Request) {
 	user.Log.Info("In DELETE", "version", apiVersion, "url", req.URL)
 	urlParts, err := url.Parse(req.URL.String())
 	if err != nil {
-		http.Error(w, "unable to parse URL", http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf("unable to parse URL: %s", err.Error()), http.StatusBadRequest)
 		return
 	}
 	name := filepath.Base(urlParts.Path)
@@ -160,22 +224,24 @@ func (user *UserHttp) TrialRequest(w http.ResponseWriter, req *http.Request) {
 	}
 	user.Log.Info("In TrialRequest", "version", apiVersion, "url", req.URL)
 
+	// This is the v1.0 apiVersion input. See COPY_OFFLOAD_API_VERSION.
 	var dmreq driver.DMRequest
 	if err := json.NewDecoder(req.Body).Decode(&dmreq); err != nil {
 		http.Error(w, fmt.Sprintf("unable to decode data movement request body: %s", err.Error()), http.StatusBadRequest)
 		return
 	}
-	user.Log.Info("  TrialRequest", "dmreq", dmreq)
 	if err := dmreq.Validator(); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
+	user.Log.Info("  TrialRequest", "dmreq", dmreq)
 
 	var dm *nnfv1alpha6.NnfDataMovement
+	var dmKey string
 	var err error
 	drvrReq := driver.DriverRequest{Drvr: user.Drvr}
 	if user.Mock {
-		dm, err = drvrReq.CreateMock(context.TODO(), dmreq)
+		dmKey, dm, err = drvrReq.CreateMock(context.TODO(), dmreq)
 		if err != nil {
 			http.Error(w, fmt.Sprintf("%s\n", err.Error()), http.StatusInternalServerError)
 			return
@@ -187,7 +253,7 @@ func (user *UserHttp) TrialRequest(w http.ResponseWriter, req *http.Request) {
 			return
 		}
 	} else {
-		dm, err = drvrReq.Create(context.TODO(), dmreq)
+		dmKey, dm, err = drvrReq.Create(context.TODO(), dmreq)
 		if err != nil {
 			http.Error(w, fmt.Sprintf("%s\n", err.Error()), http.StatusInternalServerError)
 			return
@@ -199,5 +265,7 @@ func (user *UserHttp) TrialRequest(w http.ResponseWriter, req *http.Request) {
 			return
 		}
 	}
-	fmt.Fprintf(w, "name=%s\n", dm.GetName())
+
+	// This is the v1.0 apiVersion output. See COPY_OFFLOAD_API_VERSION.
+	fmt.Fprintf(w, "name=%s\n", dmKey)
 }
diff --git a/daemons/copy-offload/pkg/server/server_test.go b/daemons/copy-offload/pkg/server/server_test.go
index fb0c7b31..ef9644c9 100644
--- a/daemons/copy-offload/pkg/server/server_test.go
+++ b/daemons/copy-offload/pkg/server/server_test.go
@@ -145,13 +145,15 @@ func setupLog() logr.Logger {
 }
 
 func TestA_Hello(t *testing.T) {
+	crLog := setupLog()
+	drvr := driver.NewDriver(crLog, true)
+	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
+
 	t.Run("returns hello response", func(t *testing.T) {
 		request, _ := http.NewRequest(http.MethodGet, "/hello", nil)
 		request.Header.Set("Accepts-version", "1.0")
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog()}
-
 		httpHandler.Hello(response, request)
 
 		res := response.Result()
@@ -196,7 +198,7 @@ func TestB_ListRequests(t *testing.T) {
 	}
 
 	crLog := setupLog()
-	drvr := &driver.Driver{Log: crLog, Mock: true}
+	drvr := driver.NewDriver(crLog, true)
 	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	for _, test := range testCases {
@@ -220,7 +222,87 @@ func TestB_ListRequests(t *testing.T) {
 	}
 }
 
-func TestC_CancelRequest(t *testing.T) {
+func TestC_GetRequest(t *testing.T) {
+	testCases := []struct {
+		name        string
+		method      string
+		requestName string
+		params      string
+		wantText    string
+		wantStatus  int
+	}{
+		{
+			name:        "returns status-ok",
+			method:      http.MethodGet,
+			requestName: "nnf-copy-offload-node-9ae2a136-4",
+			params:      "workflowNamespace=default&workflowName=yellow&maxWaitSecs=10",
+			wantText:    "{\"state\":\"pending\",\"status\":\"unknown status\"}\n",
+			wantStatus:  http.StatusOK,
+		},
+		{
+			name:        "returns status-badr-request for maxWaitSecs",
+			method:      http.MethodGet,
+			requestName: "nnf-copy-offload-node-9ae2a136-4",
+			params:      "workflowNamespace=default&workflowName=yellow",
+			wantText:    "unable to parse maxWaitSecs: strconv.Atoi: parsing \"\": invalid syntax\n",
+			wantStatus:  http.StatusBadRequest,
+		},
+		{
+			name:        "returns status-badr-request for requestName",
+			method:      http.MethodGet,
+			requestName: "nnf-copy-offload-node-9ae2a136-4",
+			params:      "workflowNamespace=default&maxWaitSecs=10",
+			wantText:    "workflow name must be supplied\n",
+			wantStatus:  http.StatusBadRequest,
+		},
+		{
+			name:        "returns status-ok for empty workNamespace",
+			method:      http.MethodGet,
+			requestName: "nnf-copy-offload-node-9ae2a136-4",
+			params:      "workflowName=yellow&maxWaitSecs=10",
+			wantText:    "{\"state\":\"pending\",\"status\":\"unknown status\"}\n",
+			wantStatus:  http.StatusOK,
+		},
+		{
+			name:       "returns status-not-implemented for POST",
+			method:     http.MethodPost,
+			wantText:   "method not supported\n",
+			wantStatus: http.StatusNotImplemented,
+		},
+		{
+			name:       "returns status-not-implemented for PUT",
+			method:     http.MethodPut,
+			wantText:   "method not supported\n",
+			wantStatus: http.StatusNotImplemented,
+		},
+	}
+
+	crLog := setupLog()
+	drvr := driver.NewDriver(crLog, true)
+	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			request, _ := http.NewRequest(test.method, fmt.Sprintf("/status/%s?%s", test.requestName, test.params), nil)
+			request.Header.Set("Accepts-version", "1.0")
+			response := httptest.NewRecorder()
+
+			httpHandler.GetRequest(response, request)
+
+			res := response.Result()
+			got := response.Body.String()
+
+			if res.StatusCode != test.wantStatus {
+				t.Errorf("got status %d, want status %d", res.StatusCode, test.wantStatus)
+			}
+			if got != test.wantText {
+				t.Errorf("got %q, want %q", got, test.wantText)
+			}
+		})
+	}
+}
+
+func TestD_CancelRequest(t *testing.T) {
 	testCases := []struct {
 		name       string
 		method     string
@@ -248,7 +330,7 @@ func TestC_CancelRequest(t *testing.T) {
 	}
 
 	crLog := setupLog()
-	drvr := &driver.Driver{Log: crLog, Mock: true}
+	drvr := driver.NewDriver(crLog, true)
 	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	for _, test := range testCases {
@@ -272,7 +354,7 @@ func TestC_CancelRequest(t *testing.T) {
 	}
 }
 
-func TestD_TrialRequest(t *testing.T) {
+func TestE_TrialRequest(t *testing.T) {
 	testCases := []struct {
 		name       string
 		method     string
@@ -283,8 +365,8 @@ func TestD_TrialRequest(t *testing.T) {
 		{
 			name:       "returns status-ok",
 			method:     http.MethodPost,
-			body:       []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
-			wantText:   "name=nnf-copy-offload-node-0\n",
+			body:       []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			wantText:   "name=mock-rabbit-01--nnf-copy-offload-node-0\n",
 			wantStatus: http.StatusOK,
 		},
 		{
@@ -309,7 +391,7 @@ func TestD_TrialRequest(t *testing.T) {
 	}
 
 	crLog := setupLog()
-	drvr := &driver.Driver{Log: crLog, Mock: true}
+	drvr := driver.NewDriver(crLog, true)
 	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	for _, test := range testCases {
@@ -337,7 +419,7 @@ func TestD_TrialRequest(t *testing.T) {
 	}
 }
 
-func TestE_Lifecycle(t *testing.T) {
+func TestF_Lifecycle(t *testing.T) {
 
 	scheduleJobs := []struct {
 		name       string
@@ -349,28 +431,28 @@ func TestE_Lifecycle(t *testing.T) {
 		{
 			name:       "schedule job 1",
 			method:     http.MethodPost,
-			body:       []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
-			wantText:   "name=nnf-copy-offload-node-0\n",
+			body:       []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			wantText:   "name=mock-rabbit-01--nnf-copy-offload-node-0\n",
 			wantStatus: http.StatusOK,
 		},
 		{
 			name:       "schedule job 2",
 			method:     http.MethodPost,
-			body:       []byte("{\"computeName\": \"rabbit-compute-4\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
-			wantText:   "name=nnf-copy-offload-node-1\n",
+			body:       []byte("{\"computeName\": \"rabbit-compute-4\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			wantText:   "name=mock-rabbit-01--nnf-copy-offload-node-1\n",
 			wantStatus: http.StatusOK,
 		},
 		{
 			name:       "schedule job 3",
 			method:     http.MethodPost,
-			body:       []byte("{\"computeName\": \"rabbit-compute-5\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
-			wantText:   "name=nnf-copy-offload-node-2\n",
+			body:       []byte("{\"computeName\": \"rabbit-compute-5\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			wantText:   "name=mock-rabbit-01--nnf-copy-offload-node-2\n",
 			wantStatus: http.StatusOK,
 		},
 	}
 
 	crLog := setupLog()
-	drvr := &driver.Driver{Log: crLog, Mock: true}
+	drvr := driver.NewDriver(crLog, true)
 	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	var listWanted []string
@@ -427,7 +509,7 @@ func TestE_Lifecycle(t *testing.T) {
 		// Go bug? If I try to dynamically build the url for this request I will
 		// get a null pointer reference in CancelRequest(), where 'req' will
 		// be null.
-		request, _ := http.NewRequest(http.MethodDelete, "/cancel/nnf-copy-offload-node-0", nil)
+		request, _ := http.NewRequest(http.MethodDelete, "/cancel/mock-rabbit-01--nnf-copy-offload-node-0", nil)
 		request.Header.Set("Accepts-version", "1.0")
 		response := httptest.NewRecorder()
 
@@ -480,10 +562,10 @@ func TestE_Lifecycle(t *testing.T) {
 	})
 }
 
-func TestF_BadAPIVersion(t *testing.T) {
+func TestG_BadAPIVersion(t *testing.T) {
 
 	crLog := setupLog()
-	drvr := &driver.Driver{Log: crLog, Mock: true}
+	drvr := driver.NewDriver(crLog, true)
 	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	testCases := []struct {
@@ -537,14 +619,14 @@ func TestF_BadAPIVersion(t *testing.T) {
 			name:    "bad api version for copy",
 			method:  http.MethodPost,
 			url:     "/trial",
-			body:    []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			body:    []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
 			handler: httpHandler.TrialRequest,
 		},
 		{
 			name:           "skip api version for copy",
 			method:         http.MethodPost,
 			url:            "/trial",
-			body:           []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"yellow\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
+			body:           []byte("{\"computeName\": \"rabbit-compute-3\", \"workflowName\": \"nnf-copy-offload-node-9ae2a136-4\", \"sourcePath\": \"/mnt/nnf/dc51a384-99bd-4ef1-8444-4ee3b0cdc8a8-0\", \"destinationPath\": \"/lus/global/dean/foo\", \"dryrun\": true}"),
 			handler:        httpHandler.TrialRequest,
 			skipApiVersion: true,
 		},
@@ -579,7 +661,10 @@ func TestF_BadAPIVersion(t *testing.T) {
 	}
 }
 
-func TestG_BearerToken(t *testing.T) {
+func TestH_BearerToken(t *testing.T) {
+	crLog := setupLog()
+	drvr := driver.NewDriver(crLog, true)
+	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	t.Run("accepts valid bearer token when using matching key", func(t *testing.T) {
 		request, _ := http.NewRequest(http.MethodGet, "/hello", nil)
@@ -587,7 +672,7 @@ func TestG_BearerToken(t *testing.T) {
 		request.Header.Set("Authorization", "Bearer "+bearerToken1)
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: derKey1}
+		httpHandler.KeyBytes = derKey1
 
 		httpHandler.Hello(response, request)
 
@@ -605,7 +690,10 @@ func TestG_BearerToken(t *testing.T) {
 	})
 }
 
-func TestH_BearerTokenNegatives(t *testing.T) {
+func TestI_BearerTokenNegatives(t *testing.T) {
+	crLog := setupLog()
+	drvr := driver.NewDriver(crLog, true)
+	httpHandler := &UserHttp{Log: crLog, Drvr: drvr, Mock: true}
 
 	t.Run("fails when bearer token is expected but is not correct", func(t *testing.T) {
 		request, _ := http.NewRequest(http.MethodGet, "/hello", nil)
@@ -613,7 +701,7 @@ func TestH_BearerTokenNegatives(t *testing.T) {
 		request.Header.Set("Authorization", "Bearer "+bearerToken2)
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: derKey1}
+		httpHandler.KeyBytes = derKey1
 
 		httpHandler.Hello(response, request)
 
@@ -637,7 +725,7 @@ func TestH_BearerTokenNegatives(t *testing.T) {
 
 		invalidKey := derKey1
 		invalidKey = append(invalidKey, byte('='))
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: invalidKey}
+		httpHandler.KeyBytes = invalidKey
 
 		httpHandler.Hello(response, request)
 
@@ -659,7 +747,7 @@ func TestH_BearerTokenNegatives(t *testing.T) {
 		request.Header.Set("Authorization", "Bearer "+bearerToken1)
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: derKey2}
+		httpHandler.KeyBytes = derKey2
 
 		httpHandler.Hello(response, request)
 
@@ -680,7 +768,7 @@ func TestH_BearerTokenNegatives(t *testing.T) {
 		request.Header.Set("Accepts-version", "1.0")
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: derKey1}
+		httpHandler.KeyBytes = derKey1
 
 		httpHandler.Hello(response, request)
 
@@ -702,7 +790,7 @@ func TestH_BearerTokenNegatives(t *testing.T) {
 		request.Header.Set("Authorization", "Bearer "+string(bearerTokenAlg2))
 		response := httptest.NewRecorder()
 
-		httpHandler := &UserHttp{Log: setupLog(), KeyBytes: derKeyAlg2}
+		httpHandler.KeyBytes = derKeyAlg2
 
 		httpHandler.Hello(response, request)
 
diff --git a/daemons/lib-copy-offload/copy-offload.c b/daemons/lib-copy-offload/copy-offload.c
index ade36e65..7e933fa3 100644
--- a/daemons/lib-copy-offload/copy-offload.c
+++ b/daemons/lib-copy-offload/copy-offload.c
@@ -320,9 +320,17 @@ int copy_offload_hello(COPY_OFFLOAD *offload, char **output) {
     long http_code;
     struct memory chunk = {NULL, 0};
     int ret = 1;
+    int n;
     char urlbuf[COPY_OFFLOAD_URL_SIZE];
 
-    snprintf(urlbuf, COPY_OFFLOAD_URL_SIZE, "%s://%s:%s/hello", offload->proto, offload->server_host, offload->server_port);
+    n = snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/hello", offload->proto, offload->server_host, offload->server_port);
+    if (n >= (int)sizeof(urlbuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        return ret;
+    }
     curl_easy_setopt(offload->curl, CURLOPT_URL, urlbuf);
 
     http_code = copy_offload_perform(offload, &chunk);
@@ -337,6 +345,49 @@ int copy_offload_hello(COPY_OFFLOAD *offload, char **output) {
     return ret;
 }
 
+/* Submit a status request.
+ * The caller is responsible for calling free() on @output if *output is non-NULL.
+ */
+int copy_offload_status(COPY_OFFLOAD *offload, char *job_name, int max_wait_secs, char **output) {
+    long http_code;
+    struct memory chunk = {NULL, 0};
+    int ret = 1;
+    int n;
+    char urlbuf[COPY_OFFLOAD_URL_SIZE];
+    char parambuf[COPY_OFFLOAD_URL_SIZE];
+
+    // This is the v1.0 apiVersion. See COPY_OFFLOAD_API_VERSION.
+    const char *offload_req_v1_0 = "workflowNamespace=%s&workflowName=%s&maxWaitSecs=%d";
+    n = snprintf(parambuf, sizeof(parambuf), offload_req_v1_0, offload->workflow_namespace, offload->workflow_name, max_wait_secs);
+    if (n >= (int)sizeof(parambuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting parameters: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting parameters");
+        return ret;
+    }
+
+    n = snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/status/%s?%s", offload->proto, offload->server_host, offload->server_port, job_name, parambuf);
+    if (n >= (int)sizeof(urlbuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        return ret;
+    }
+    curl_easy_setopt(offload->curl, CURLOPT_URL, urlbuf);
+
+    http_code = copy_offload_perform(offload, &chunk);
+    if (http_code == 200)
+        ret = 0;
+    if (chunk.response != NULL) {
+        *output = strdup(chunk.response);
+        chop(output);
+        free(chunk.response);
+    }
+    return ret;
+}
+
 /* List the active copy-offload requests.
  * The caller is responsible for calling free() on @output if *output is non-NULL.
  */
@@ -344,9 +395,17 @@ int copy_offload_list(COPY_OFFLOAD *offload, char **output) {
     long http_code;
     struct memory chunk = {NULL, 0};
     int ret = 1;
+    int n;
     char urlbuf[COPY_OFFLOAD_URL_SIZE];
 
-    snprintf(urlbuf, COPY_OFFLOAD_URL_SIZE, "%s://%s:%s/list", offload->proto, offload->server_host, offload->server_port);
+    n = snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/list", offload->proto, offload->server_host, offload->server_port);
+    if (n >= (int)sizeof(urlbuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        return ret;
+    }
     curl_easy_setopt(offload->curl, CURLOPT_URL, urlbuf);
 
     http_code = copy_offload_perform(offload, &chunk);
@@ -365,9 +424,17 @@ int copy_offload_cancel(COPY_OFFLOAD *offload, char *job_name, char **output) {
     long http_code;
     struct memory chunk = {NULL, 0};
     int ret = 1;
+    int n;
     char urlbuf[COPY_OFFLOAD_URL_SIZE];
 
-    snprintf(urlbuf, COPY_OFFLOAD_URL_SIZE, "%s://%s:%s/cancel/%s", offload->proto, offload->server_host, offload->server_port, job_name);
+    n = snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/cancel/%s", offload->proto, offload->server_host, offload->server_port, job_name);
+    if (n >= (int)sizeof(urlbuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        return ret;
+    }
     curl_easy_setopt(offload->curl, CURLOPT_URL, urlbuf);
     curl_easy_setopt(offload->curl, CURLOPT_CUSTOMREQUEST, "DELETE");
 
@@ -394,7 +461,14 @@ int copy_offload_copy(COPY_OFFLOAD *offload, const char *profile_name, int slots
     char postbuf[COPY_OFFLOAD_POST_SIZE];
     char dry_run_str[8];
 
-    snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/trial", offload->proto, offload->server_host, offload->server_port);
+    n = snprintf(urlbuf, sizeof(urlbuf), "%s://%s:%s/trial", offload->proto, offload->server_host, offload->server_port);
+    if (n >= (int)sizeof(urlbuf)) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: buffer too small");
+        return ret;
+    } else if (n < 0) {
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        return ret;
+    }
     curl_easy_setopt(offload->curl, CURLOPT_URL, urlbuf);
 
     if (profile_name == NULL) {
@@ -407,7 +481,8 @@ int copy_offload_copy(COPY_OFFLOAD *offload, const char *profile_name, int slots
         strcpy(dry_run_str, "false");
     }
 
-    const char *offload_req =
+    // This is the v1.0 apiVersion. See COPY_OFFLOAD_API_VERSION.
+    const char *offload_req_v1_0 =
         "{\"computeName\": \"%s\", "
         "\"workflowName\": \"%s\", "
         "\"workflowNamespace\": \"%s\", "
@@ -420,12 +495,12 @@ int copy_offload_copy(COPY_OFFLOAD *offload, const char *profile_name, int slots
         "\"storeStdout\": false, "
         "\"slots\": %d, "
         "\"maxSlots\": %d}";
-    n = snprintf(postbuf, sizeof(postbuf), offload_req, offload->my_host_name, offload->workflow_name, offload->workflow_namespace, source_path, dest_path, profile_name, dry_run_str, slots, max_slots);
+    n = snprintf(postbuf, sizeof(postbuf), offload_req_v1_0, offload->my_host_name, offload->workflow_name, offload->workflow_namespace, source_path, dest_path, profile_name, dry_run_str, slots, max_slots);
     if (n >= (int)sizeof(postbuf)) {
-        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request: request truncated, buffer too small");
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting body: buffer too small");
         return ret;
     } else if (n < 0) {
-        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting request");
+        snprintf(offload->err_message, COPY_OFFLOAD_MSG_SIZE, "Error formatting body");
         return ret;
     }
     curl_easy_setopt(offload->curl, CURLOPT_POSTFIELDS, postbuf);
diff --git a/daemons/lib-copy-offload/copy-offload.h b/daemons/lib-copy-offload/copy-offload.h
index 6e02fcc6..ad3825ae 100644
--- a/daemons/lib-copy-offload/copy-offload.h
+++ b/daemons/lib-copy-offload/copy-offload.h
@@ -89,23 +89,36 @@ COPY_OFFLOAD *copy_offload_init();
  */
 int copy_offload_configure(COPY_OFFLOAD *offload);
 
+/* Submit a new copy-offload request.
+ * The new job's name will be placed in @output. This name is used to cancel the
+ * request or to check its status.
+ * The caller is responsible for calling free() on @output if *output is non-NULL.
+ * Returns 0 on success.
+ * On failure it returns 1 and places an error message in @offload->err_message.
+ */
+int copy_offload_copy(COPY_OFFLOAD *offload, const char *profile_name, int slots, int max_slots, int dry_run, char *source_path, char *dest_path, char **output);
+
 /* List the active copy-offload requests.
- * The comma-separated list of job names will be placed in @output. The caller
- * is responsible for calling free() on @output if *output is non-NULL.
+ * The comma-separated list of job names will be placed in @output. The job names
+ * are the same ones returned by copy_offload_copy().
+ * The caller is responsible for calling free() on @output if *output is non-NULL.
  * Returns 0 on success.
  * On failure it returns 1 and places an error message in @offload->err_message.
  */
 int copy_offload_list(COPY_OFFLOAD *offload, char **output);
 
-/* Send a hello message to the server.
- * The server's response will be placed in @output. The caller is responsible
- * for calling free() on @output if *output is non-NULL.
+/* Submit a status request. The @job_name is the value returned by copy_offload_copy().
+ * The maximum number of seconds to wait for the job to complete is @max_wait_secs.
+ * If @max_wait_secs is 0, then the server will return immediately. If it is -1, then
+ * the server will wait indefinitely.
+ * The caller is responsible for calling free() on @output if *output is non-NULL.
  * Returns 0 on success.
- * On failure it returns 1 and places an error message in @offload->err_message.
+ * On failure it returns 1 and places an error message in @offload->err_message. 
  */
-int copy_offload_hello(COPY_OFFLOAD *offload, char **output);
+int copy_offload_status(COPY_OFFLOAD *offload, char *job_name, int max_wait_secs, char **output);
 
-/* Cancel a specific copy-offload request.
+/* Cancel a specific copy-offload request. The @job_name is the value returned
+ * by copy_offload_copy().
  * Any output from the server, if present, will be placed in @output. The caller
  * is responsible for calling free() on @output if *output is non-NULL.
  * Returns 0 on success.
@@ -113,14 +126,6 @@ int copy_offload_hello(COPY_OFFLOAD *offload, char **output);
  */
 int copy_offload_cancel(COPY_OFFLOAD *offload, char *job_name, char **output);
 
-/* Submit a new copy-offload request.
- * The new job's name will be placed in @output. The caller is responsible
- * for calling free() on @output if *output is non-NULL.
- * Returns 0 on success.
- * On failure it returns 1 and places an error message in @offload->err_message.
- */
-int copy_offload_copy(COPY_OFFLOAD *offload, const char *profile_name, int slots, int max_slots, int dry_run, char *source_path, char *dest_path, char **output);
-
 /* Clean up the handle's resources. */
 void copy_offload_cleanup(COPY_OFFLOAD *offload);
 
@@ -131,6 +136,14 @@ void copy_offload_cleanup(COPY_OFFLOAD *offload);
 /* Request verbose output from libcurl. */
 void copy_offload_verbose(COPY_OFFLOAD *offload);
 
+/* Send a hello message to the server.
+ * The server's response will be placed in @output. The caller is responsible
+ * for calling free() on @output if *output is non-NULL.
+ * Returns 0 on success.
+ * On failure it returns 1 and places an error message in @offload->err_message.
+ */
+int copy_offload_hello(COPY_OFFLOAD *offload, char **output);
+
 /* Setup the handle with information needed to connect to the server but with
  * TLS disabled.
  * Returns 0 on success.
diff --git a/daemons/lib-copy-offload/test-tool/main.c b/daemons/lib-copy-offload/test-tool/main.c
index 31b08d52..7df61bcb 100644
--- a/daemons/lib-copy-offload/test-tool/main.c
+++ b/daemons/lib-copy-offload/test-tool/main.c
@@ -51,12 +51,17 @@ void usage(const char **argv) {
     fprintf(stderr, "       -m SLOTS           Number of slots (processes).\n");
     fprintf(stderr, "       -M MAX_SLOTS       Maximum number of slots (processes).\n");
     fprintf(stderr, "       -d                 Perform a dry run.\n");
-    fprintf(stderr, "       -C MY_HOSTNAME     Name of the host submitting the request. (for development/debugging)\n");
+    fprintf(stderr, "\n");
+    fprintf(stderr, "Usage: %s [COMMON_ARGS] -S <ARGS>\n", argv[0]);
+    fprintf(stderr, "    -q            Submit a status request for the specified job.\n");
+    fprintf(stderr, "       -j JOB_NAME    The job name returned by the copy-offload request.\n");
+    fprintf(stderr, "       -w MAX_WAIT    Maximum number of seconds to wait for the job to complete. (default 0)\n");
     fprintf(stderr, "\n");
     fprintf(stderr, "COMMON_ARGS\n");
     fprintf(stderr, "    -v                  Request verbose output from this tool.\n");
     fprintf(stderr, "    -V                  Request verbose output from libcurl.\n");
     fprintf(stderr, "    -s                  Skip TLS configuration.\n");
+    fprintf(stderr, "    -C MY_HOSTNAME      Name of the host submitting the request. (for development/debugging)\n");
     fprintf(stderr, "    -t TOKEN_FILE       Bearer token file.\n");
     fprintf(stderr, "    -x CERT_FILE        CA/Server certificate file. A self-signed certificate.\n");
 }
@@ -86,9 +91,11 @@ int main(int argc, const char **argv) {
     int slots = -1; /* -1 defers to dm profile, 0 disables slots */
     int max_slots = -1;
     int H_opt = 0;
+    int q_opt = 0;
+    int max_wait_secs = 0;
     int ret;
 
-    while ((c = getopt(argc, cargv, "hvVlst:x:c:oC:P:S:D:m:M:dH")) != -1) {
+    while ((c = getopt(argc, cargv, "hvVlst:x:c:oC:P:S:D:m:M:dHqj:w:")) != -1) {
         switch (c) {
             case 'c':
                 c_opt = 1;
@@ -139,6 +146,15 @@ int main(int argc, const char **argv) {
             case 'H':
                 H_opt = 1;
                 break;
+            case 'q':
+                q_opt = 1;
+                break;
+            case 'j':
+                job_name = optarg;
+                break;
+            case 'w':
+                max_wait_secs = atoi(optarg);
+                break;
             default:
                 usage(argv);
                 exit(1);
@@ -155,6 +171,12 @@ int main(int argc, const char **argv) {
             exit(1);
         }
     }
+    if (q_opt || c_opt) {
+        if (job_name == NULL) {
+            usage(argv);
+            exit(1);
+        }
+    }
 
     offload = copy_offload_init();
     if (skip_tls) {
@@ -201,6 +223,8 @@ int main(int argc, const char **argv) {
         ret = copy_offload_copy(offload, profile_name, slots, max_slots, dry_run, source_path, dest_path, &output);
     } else if (H_opt) {
         ret = copy_offload_hello(offload, &output);
+    } else if (q_opt) {
+        ret = copy_offload_status(offload, job_name, max_wait_secs, &output);
     } else {
         fprintf(stderr, "What action?\n");
         copy_offload_cleanup(offload);


Reviews:
user: login: ajfloeder
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:19:59Z
user: login: bdevcich
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:55:41Z
user: login: bdevcich
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:56:57Z
user: login: bdevcich
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:01:39Z
user: login: bdevcich
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:05:35Z
user: login: bdevcich
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:28:43Z
user: login: roehrich-hpe
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:04:05Z
user: login: bdevcich
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:14:50Z
user: login: bdevcich
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:15:00Z
user: login: bdevcich
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:22:21Z

Review comments:
diff_hunk: @@ -298,48 +316,66 @@ func (r *DriverRequest) DriveMock(ctx context.Context, dmreq DMRequest, dm *nnfv
 	drvr := r.Drvr
 	crLog := drvr.Log.WithValues("workflow", dmreq.WorkflowName)
 
-	contextRecord, err := r.loadRequest(dm.Name)
+	contextRecord, err := r.loadRequest(dm)
 	if err != nil {
 		crLog.Error(err, "request not found")
 		return err
 	}
 	cancelContext := contextRecord.cancelContext
 	if err := r.driveWithContext(ctx, cancelContext.Ctx, dm, crLog); err != nil {
 		crLog.Error(err, "failed copy")
-		drvr.contexts.Delete(dm.Name)
+		r.deleteRequest(dm)
+		r.notifyCompletion(dm)
 		return err
 	}
 
 	return nil
 }
 
+func (r *DriverRequest) dmkey(dm *nnfv1alpha6.NnfDataMovement) string {
+	return fmt.Sprintf("%s--%s", dm.Namespace, dm.Name)
+}
+
+func (r *DriverRequest) dmkeySplit(key string) (string, string) {
+	split := strings.Split(key, "--")
+	return split[0], split[1]
+}
+
 func (r *DriverRequest) recordRequest(ctx context.Context, dm *nnfv1alpha6.NnfDataMovement) {
 	drvr := r.Drvr
 
 	// Expand the context with cancel and store it in the map so the cancel function can be
 	// found by another server thread if necessary.
 	ctxCancel, cancel := context.WithCancel(ctx)
-	drvr.contexts.Store(dm.Name, SrvrDataMovementRecord{
-		dmreq: dm,
+	key := r.dmkey(dm)
+	drvr.contexts.Store(key, SrvrDataMovementRecord{
path: daemons/copy-offload/pkg/driver/driver.go
user: login: bdevcich
body: ```suggestion
	drvr.contexts.Store(r.dmkey(dm), SrvrDataMovementRecord{
```
created_at: 2025-03-26T17:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -355,6 +391,123 @@ func (r *DriverRequest) CancelRequest(ctx context.Context, name string) error {
 	return nil
 }
 
+func (r *DriverRequest) GetRequestMock(ctx context.Context, statreq StatusRequest) (*DataMovementStatusResponse_v1_0, int, error) {
+	return &DataMovementStatusResponse_v1_0{
+		State:  DataMovementStatusResponse_PENDING,
+		Status: DataMovementStatusResponse_UNKNOWN_STATUS,
+	}, -1, nil
+}
+
+func (r *DriverRequest) GetRequest(ctx context.Context, statreq StatusRequest) (*DataMovementStatusResponse_v1_0, int, error) {
+	drvr := r.Drvr
+	keyns, keyname := r.dmkeySplit(statreq.RequestName)
path: daemons/copy-offload/pkg/driver/driver.go
user: login: bdevcich
body: ```suggestion
	keyNS, keyName := r.dmkeySplit(statreq.RequestName)
```
created_at: 2025-03-26T17:56:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -160,22 +224,24 @@ func (user *UserHttp) TrialRequest(w http.ResponseWriter, req *http.Request) {
 	}
 	user.Log.Info("In TrialRequest", "version", apiVersion, "url", req.URL)
 
+	// This is the v1.0 apiVersion input. See COPY_OFFLOAD_API_VERSION.
 	var dmreq driver.DMRequest
 	if err := json.NewDecoder(req.Body).Decode(&dmreq); err != nil {
 		http.Error(w, fmt.Sprintf("unable to decode data movement request body: %s", err.Error()), http.StatusBadRequest)
 		return
 	}
-	user.Log.Info("  TrialRequest", "dmreq", dmreq)
 	if err := dmreq.Validator(); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
+	user.Log.Info("  TrialRequest", "dmreq", dmreq)
 
 	var dm *nnfv1alpha6.NnfDataMovement
+	var dmkey string
path: daemons/copy-offload/pkg/server/server.go
user: login: bdevcich
body: ```suggestion
	var dmKey string
```
created_at: 2025-03-26T18:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,12 +51,17 @@ void usage(const char **argv) {
     fprintf(stderr, "       -m SLOTS           Number of slots (processes).\n");
     fprintf(stderr, "       -M MAX_SLOTS       Maximum number of slots (processes).\n");
     fprintf(stderr, "       -d                 Perform a dry run.\n");
-    fprintf(stderr, "       -C MY_HOSTNAME     Name of the host submitting the request. (for development/debugging)\n");
+    fprintf(stderr, "\n");
+    fprintf(stderr, "Usage: %s [COMMON_ARGS] -S <ARGS>\n", argv[0]);
+    fprintf(stderr, "    -Q            Submit a status request for the specified job.\n");
path: daemons/lib-copy-offload/test-tool/main.c
user: login: bdevcich
body: The other CRUD options are all lower case (-o, -l, -c). I think this should be -q
created_at: 2025-03-26T18:05:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,12 +51,17 @@ void usage(const char **argv) {
     fprintf(stderr, "       -m SLOTS           Number of slots (processes).\n");
     fprintf(stderr, "       -M MAX_SLOTS       Maximum number of slots (processes).\n");
     fprintf(stderr, "       -d                 Perform a dry run.\n");
-    fprintf(stderr, "       -C MY_HOSTNAME     Name of the host submitting the request. (for development/debugging)\n");
+    fprintf(stderr, "\n");
+    fprintf(stderr, "Usage: %s [COMMON_ARGS] -S <ARGS>\n", argv[0]);
+    fprintf(stderr, "    -Q            Submit a status request for the specified job.\n");
path: daemons/lib-copy-offload/test-tool/main.c
user: login: roehrich-hpe
body: done

created_at: 2025-03-26T19:04:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -347,8 +347,7 @@ func (r *DriverRequest) recordRequest(ctx context.Context, dm *nnfv1alpha6.NnfDa
 	// Expand the context with cancel and store it in the map so the cancel function can be
 	// found by another server thread if necessary.
 	ctxCancel, cancel := context.WithCancel(ctx)
-	key := r.dmkey(dm)
-	drvr.contexts.Store(key, SrvrDataMovementRecord{
+	drvr.contexts.Store(r.dmkey(dm), SrvrDataMovementRecord{
path: daemons/copy-offload/pkg/driver/driver.go
user: login: bdevcich
body: same thing here then: dmkey->dmKey
created_at: 2025-03-26T19:14:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: roehrich-hpe
created_at: 2025-03-26T17:00:39Z
body: The libcopyoffload library does not yet attempt to parse the json response for the status. For now it just returns the json string. I'll work on parsing that later.


author_association: CONTRIBUTOR
user: login: bdevcich
created_at: 2025-03-26T18:28:35Z
body: LGTM. A few nits. Tested on htx-1:

```
$ /tester -o -S /mnt/nnf/7ada1c50-5114-4120-b82d-06ec6aa0baec-0 -D /lus/global/copyoff -P no-xattr -m 32
rabbit-node-2--nnf-copy-offload-node-ncqr2

$ ./tester -l
rabbit-node-2--nnf-copy-offload-node-ncqr2

$ ./tester -Q -j rabbit-node-2--nnf-copy-offload-node-ncqr2 -w -1 | jq
{
  "state": "completed",
  "status": "success",
  "message": "Warning: Permanently added '[copy-offload-blake-worker-0.copy-offload-blake.default.svc.cluster.local]:2222' (ED25519) to the list of known hosts.\r\n[2025-03-26T18:26:58] Walking /mnt/nnf/7ada1c50-5114-4120-b82d-06ec6aa0baec-0/rabbit-node-2-1\n[2025-03-26T18:26:58] Walked 2 items in 0.001 secs (2055.374 items/sec) ...\n[2025-03-26T18:26:58] Walked 2 items in 0.001 seconds (1775.434 items/sec)\n[2025-03-26T18:26:58] Copying to /lus/global/copyoff\n[2025-03-26T18:26:58] Items: 2\n[2025-03-26T18:26:58]   Directories: 1\n[2025-03-26T18:26:58]   Files: 1\n[2025-03-26T18:26:58]   Links: 0\n[2025-03-26T18:26:58] Data: 1.863 GiB (1.863 GiB per file)\n[2025-03-26T18:26:58] Creating 1 directories\n[2025-03-26T18:26:58] Original directory exists, skip the creation: `/lus/global/copyoff/rabbit-node-2-1' (errno=2 No such file or directory)\n[2025-03-26T18:26:58] Creating 1 files.\n[2025-03-26T18:26:58] Original file exists, skip the creation: `/lus/global/copyoff/rabbit-node-2-1/durian-rabbit-compute-5' (errno=2 No such file or directory)\n[2025-03-26T18:26:58] Copying data.\n[2025-03-26T18:27:15] Copied 1.430 GiB (77%) in 17.086 secs (85.684 MiB/s) 5 secs left ...\n[2025-03-26T18:27:15] Copied 1.863 GiB (100%) in 17.086 secs (111.631 MiB/s) done\n[2025-03-26T18:27:15] Copy data: 1.863 GiB (2000000000 bytes)\n[2025-03-26T18:27:15] Copy rate: 111.631 MiB/s (2000000000 bytes in 17.086 seconds)\n[2025-03-26T18:27:15] Syncing data to disk.\n[2025-03-26T18:27:15] Sync completed in 0.024 seconds.\n[2025-03-26T18:27:15] Fixing permissions.\n[2025-03-26T18:27:15] Updated 2 items in 0.001 seconds (1579.935 items/sec)\n[2025-03-26T18:27:15] Syncing directory updates to disk.\n[2025-03-26T18:27:15] Sync completed in 0.003 seconds.\n[2025-03-26T18:27:15] Started: Mar-26-2025,18:26:58\n[2025-03-26T18:27:15] Completed: Mar-26-2025,18:27:15\n[2025-03-26T18:27:15] Seconds: 17.118\n[2025-03-26T18:27:15] Items: 2\n[2025-03-26T18:27:15]   Directories: 1\n[2025-03-26T18:27:15]   Files: 1\n[2025-03-26T18:27:15]   Links: 0\n[2025-03-26T18:27:15] Data: 1.863 GiB (2000000000 bytes)\n[2025-03-26T18:27:15] Rate: 111.425 MiB/s (2000000000 bytes in 17.118 seconds)\n",
  "commandStatus": {
    "command": "/bin/bash -c ulimit -n 2048 && mpirun --allow-run-as-root --hostfile /tmp/hostfile dcp --progress 1   --xattrs none /mnt/nnf/7ada1c50-5114-4120-b82d-06ec6aa0baec-0/rabbit-node-2-1 /lus/global/copyoff",
    "progress": 100,
    "elapsedTime": "17.713s",
    "lastMessage": "[2025-03-26T18:27:15] Rate: 111.425 MiB/s (2000000000 bytes in 17.118 seconds)\n",
    "lastMessageTime": "2025-03-26 18:27:15.492868 +0000 UTC"
  },
  "startTime": "2025-03-26 18:26:57.774238 +0000 UTC",
  "endTime": "2025-03-26 18:27:15.504455 +0000 UTC"
}
```
author_association: CONTRIBUTOR