You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2021/03/01 20:39:52 UTC

[trafficcontrol] branch master updated: added status endpoint for async tasks (#5544)

This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 04e0b03  added status endpoint for async tasks (#5544)
04e0b03 is described below

commit 04e0b03ae3320069a7a5fa0ab2ac829250db7ffe
Author: mattjackson220 <33...@users.noreply.github.com>
AuthorDate: Mon Mar 1 13:39:41 2021 -0700

    added status endpoint for async tasks (#5544)
    
    * added status endpoint for async tasks
    
    * updated changelog
    
    * cleaned up err.Error calls
    
    * updated per comments and added tests
    
    * updated per comment
---
 CHANGELOG.md                                       |   1 +
 docs/source/api/v4/acme_autorenew.rst              |   4 +-
 docs/source/api/v4/async_status.rst                |  63 ++++++++++
 .../2021022300000000_add_async_status_table.sql    |  43 +++++++
 traffic_ops/traffic_ops_golang/api/async_status.go | 136 +++++++++++++++++++++
 .../traffic_ops_golang/api/async_status_test.go    | 114 +++++++++++++++++
 .../deliveryservice/autorenewcerts.go              |  58 ++++++++-
 .../deliveryservice/letsencryptcert.go             |   4 +-
 traffic_ops/traffic_ops_golang/routing/routes.go   |   1 +
 9 files changed, 415 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 849c009..b2e5f5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Added license files to the RPMs
 - Added ACME certificate renewals and ACME account registration using external account binding
 - Added functionality to automatically renew ACME certificates.
+- Added an endpoint for statuses on asynchronous jobs and applied it to the ACME renewal endpoint.
 
 ### Fixed
 - [#5558](https://github.com/apache/trafficcontrol/issues/5558) - Fixed `TM UI` and `/api/cache-statuses` to report aggregate `bandwidth_kbps` correctly.
diff --git a/docs/source/api/v4/acme_autorenew.rst b/docs/source/api/v4/acme_autorenew.rst
index bf65a0a..249b803 100644
--- a/docs/source/api/v4/acme_autorenew.rst
+++ b/docs/source/api/v4/acme_autorenew.rst
@@ -13,7 +13,7 @@
 .. limitations under the License.
 ..
 
-.. _to-api-acnme-autorenew:
+.. _to-api-acme-autorenew:
 
 ******************
 ``acme_autorenew``
@@ -43,7 +43,7 @@ Response Structure
 
 	{ "alerts": [
 		{
-			"text": "Beginning async call to renew certificates. This may take a few minutes.",
+			"text": "Beginning async call to renew certificates. This may take a few minutes. Status updates can be found here: /api/4.0/async_status/1",
 			"level": "success"
 		}
 	]}
diff --git a/docs/source/api/v4/async_status.rst b/docs/source/api/v4/async_status.rst
new file mode 100644
index 0000000..6744cff
--- /dev/null
+++ b/docs/source/api/v4/async_status.rst
@@ -0,0 +1,63 @@
+..
+..
+.. 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.
+..
+
+.. _to-api-async_status:
+
+***********************
+``async_status/{{id}}``
+***********************
+
+``GET``
+=======
+Returns a status update for an asynchronous task.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+.. table:: Request Path Parameters
+
+	+------+----------+--------------------------------------------------------------------------------------------------------------------------------------+
+	| Name | Required | Description                                                                                                                          |
+	+======+==========+======================================================================================================================================+
+	| id   | yes      | The integral, unique identifier for the desired asynchronous job status. This will be provided when the asynchronous job is started. |
+	+------+----------+--------------------------------------------------------------------------------------------------------------------------------------+
+
+
+Response Structure
+------------------
+:id:         The integral, unique identifier for the asynchronous job status.
+:status:     The status of the asynchronous job. This will be `PENDING`, `SUCCEEDED`, or `FAILED`.
+:start_time: The time the asynchronous job was started.
+:end_time:   The time the asynchronous job completed. This will be `null` if it has not completed yet.
+:message:    A message about the job status.
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/1.1 200 OK
+	Content-Type: application/json
+
+	{ "response":
+		{
+			"id":1,
+			"status":"PENDING",
+			"start_time":"2021-02-18T17:13:56.352261Z",
+			"end_time":null,
+			"message":"Async job has started."
+		}
+	}
diff --git a/traffic_ops/app/db/migrations/2021022300000000_add_async_status_table.sql b/traffic_ops/app/db/migrations/2021022300000000_add_async_status_table.sql
new file mode 100644
index 0000000..005dda0
--- /dev/null
+++ b/traffic_ops/app/db/migrations/2021022300000000_add_async_status_table.sql
@@ -0,0 +1,43 @@
+/*
+
+    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.
+*/
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+
+CREATE SEQUENCE IF NOT EXISTS async_status_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+CREATE TABLE IF NOT EXISTS async_status (
+    id bigint NOT NULL DEFAULT nextval('async_status_id_seq'::regclass),
+    status TEXT NOT NULL,
+    message TEXT,
+    start_time timestamp with time zone DEFAULT now() NOT NULL,
+    end_time timestamp with time zone,
+
+    PRIMARY KEY (id)
+);
+
+ALTER SEQUENCE async_status_id_seq OWNED BY async_status.id;
+
+
+-- +goose Down
+-- SQL section 'Down' is executed when this migration is rolled back
+
+DROP TABLE IF EXISTS async_status;
+DROP SEQUENCE IF EXISTS async_status_id_seq;
diff --git a/traffic_ops/traffic_ops_golang/api/async_status.go b/traffic_ops/traffic_ops_golang/api/async_status.go
new file mode 100644
index 0000000..eb55879
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/api/async_status.go
@@ -0,0 +1,136 @@
+package api
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import (
+	"database/sql"
+	"errors"
+	"net/http"
+	"time"
+
+	"github.com/jmoiron/sqlx"
+)
+
+const (
+	AsyncSucceeded = "SUCCEEDED"
+	AsyncFailed    = "FAILED"
+	AsyncPending   = "PENDING"
+)
+
+const CurrentAsyncEndpoint = "/api/4.0/async_status/"
+
+type AsyncStatus struct {
+	Id        int        `json:"id, omitempty" db:"id"`
+	Status    string     `json:"status, omitempty" db:"status"`
+	StartTime time.Time  `json:"start_time, omitempty" db:"start_time"`
+	EndTime   *time.Time `json:"end_time, omitempty" db:"end_time"`
+	Message   *string    `json:"message, omitempty" db:"message"`
+}
+
+const selectAsyncStatusQuery = `SELECT id, status, message, start_time, end_time from async_status WHERE id = $1`
+const insertAsyncStatusQuery = `INSERT INTO async_status (status, message) VALUES ($1, $2) RETURNING id`
+const updateAsyncStatusEndTimeQuery = `UPDATE async_status SET status = $1, message = $2, end_time = now() WHERE id = $3`
+const updateAsyncStatusQuery = `UPDATE async_status SET status = $1, message = $2 WHERE id = $3`
+
+// GetAsyncStatus returns the status of an asynchronous job.
+func GetAsyncStatus(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := NewInfo(r, []string{"id"}, []string{"id"})
+	if userErr != nil || sysErr != nil {
+		HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	asyncStatusId := inf.Params["id"]
+
+	rows, err := inf.Tx.Tx.Query(selectAsyncStatusQuery, asyncStatusId)
+	if err != nil {
+		HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	defer rows.Close()
+
+	asyncStatus := AsyncStatus{}
+	rowCount := 0
+	for rows.Next() {
+		rowCount++
+		err := rows.Scan(&asyncStatus.Id, &asyncStatus.Status, &asyncStatus.Message, &asyncStatus.StartTime, &asyncStatus.EndTime)
+		if err != nil {
+			HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+			return
+		}
+	}
+
+	if rowCount == 0 {
+		HandleErr(w, r, inf.Tx.Tx, http.StatusNotFound, nil, errors.New("async status not found"))
+		return
+	}
+
+	WriteResp(w, r, asyncStatus)
+}
+
+// InsertAsyncStatus inserts a new status for an asynchronous job.
+func InsertAsyncStatus(tx *sql.Tx, message string) (int, int, error, error) {
+	defer tx.Commit()
+
+	resultRows, err := tx.Query(insertAsyncStatusQuery, AsyncPending, message)
+	if err != nil {
+		userErr, sysErr, errCode := ParseDBError(err)
+		return 0, errCode, userErr, sysErr
+	}
+	defer resultRows.Close()
+
+	var asyncStatusId int
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&asyncStatusId); err != nil {
+			return 0, http.StatusInternalServerError, nil, err
+		}
+	}
+	if rowsAffected == 0 {
+		return 0, http.StatusInternalServerError, nil, errors.New("async status create: no status was inserted, no id was returned")
+	} else if rowsAffected > 1 {
+		return 0, http.StatusInternalServerError, nil, errors.New("too many ids returned from async status insert")
+	}
+
+	return asyncStatusId, http.StatusOK, nil, nil
+}
+
+// UpdateAsyncStatus updates the status table for an asynchronous job.
+func UpdateAsyncStatus(db *sqlx.DB, newStatus string, newMessage string, asyncStatusId int, finished bool) error {
+	tx, err := db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Commit()
+
+	q := updateAsyncStatusQuery
+	if finished {
+		q = updateAsyncStatusEndTimeQuery
+	}
+	_, err = tx.Exec(q, newStatus, newMessage, asyncStatusId)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/traffic_ops/traffic_ops_golang/api/async_status_test.go b/traffic_ops/traffic_ops_golang/api/async_status_test.go
new file mode 100644
index 0000000..e2c269d
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/api/async_status_test.go
@@ -0,0 +1,114 @@
+package api
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/jmoiron/sqlx"
+
+	"gopkg.in/DATA-DOG/go-sqlmock.v1"
+)
+
+func TestInsertAsyncStatus(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
+		return
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	expectedMessage := "test async message"
+	mock.ExpectBegin()
+	rows := sqlmock.NewRows([]string{"id"})
+	rows.AddRow(1)
+	mock.ExpectQuery("INSERT").WithArgs(AsyncPending, expectedMessage).WillReturnRows(rows)
+
+	asyncId, errCode, userErr, sysErr := InsertAsyncStatus(db.MustBegin().Tx, expectedMessage)
+
+	if userErr != nil {
+		t.Fatalf("userError was expected to be nil but got %v", userErr)
+		return
+	}
+	if sysErr != nil {
+		t.Fatalf("sysErr was expected to be nil but got %v", sysErr)
+		return
+	}
+	if errCode != http.StatusOK {
+		t.Fatalf("errCode was expected to be %v but got %v", http.StatusOK, errCode)
+		return
+	}
+	if asyncId != 1 {
+		t.Fatalf("asyncId was expected to be 1 but got %v", asyncId)
+		return
+	}
+}
+
+func TestUpdateAsyncStatus(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
+		return
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	expectedMessage := "test updated async message"
+	expectedStatus := AsyncPending
+	mock.ExpectBegin()
+	mock.ExpectExec("UPDATE").WithArgs(expectedStatus, expectedMessage, 1).WillReturnResult(sqlmock.NewResult(1, 1))
+
+	updateErr := UpdateAsyncStatus(db, expectedStatus, expectedMessage, 1, false)
+
+	if updateErr != nil {
+		t.Fatalf("updateErr was expected to be nil but got %v", updateErr)
+		return
+	}
+}
+
+func TestUpdateAsyncStatusFinished(t *testing.T) {
+	mockDB, mock, err := sqlmock.New()
+	if err != nil {
+		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
+		return
+	}
+	defer mockDB.Close()
+
+	db := sqlx.NewDb(mockDB, "sqlmock")
+	defer db.Close()
+
+	expectedMessage := "test job complete"
+	expectedStatus := AsyncSucceeded
+	mock.ExpectBegin()
+	mock.ExpectExec("UPDATE").WithArgs(expectedStatus, expectedMessage, 1).WillReturnResult(sqlmock.NewResult(1, 1))
+
+	updateErr := UpdateAsyncStatus(db, expectedStatus, expectedMessage, 1, true)
+
+	if updateErr != nil {
+		t.Fatalf("updateErr was expected to be nil but got %v", updateErr)
+		return
+	}
+}
diff --git a/traffic_ops/traffic_ops_golang/deliveryservice/autorenewcerts.go b/traffic_ops/traffic_ops_golang/deliveryservice/autorenewcerts.go
index cea7e55..d04683a 100644
--- a/traffic_ops/traffic_ops_golang/deliveryservice/autorenewcerts.go
+++ b/traffic_ops/traffic_ops_golang/deliveryservice/autorenewcerts.go
@@ -104,7 +104,12 @@ func renewCertificates(w http.ResponseWriter, r *http.Request, deprecated bool)
 
 	ctx, _ := context.WithTimeout(r.Context(), LetsEncryptTimeout*time.Duration(len(existingCerts)))
 
-	go RunAutorenewal(existingCerts, inf.Config, ctx, inf.User)
+	asyncStatusId, errCode, userErr, sysErr := api.InsertAsyncStatus(inf.Tx.Tx, "ACME async job has started.")
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+	}
+
+	go RunAutorenewal(existingCerts, inf.Config, ctx, inf.User, asyncStatusId)
 
 	var alerts tc.Alerts
 	if deprecated {
@@ -112,33 +117,47 @@ func renewCertificates(w http.ResponseWriter, r *http.Request, deprecated bool)
 	}
 
 	alerts.AddAlert(tc.Alert{
-		Text:  "Beginning async call to renew certificates. This may take a few minutes.",
+		Text:  "Beginning async call to renew certificates. This may take a few minutes. Status updates can be found here: " + api.CurrentAsyncEndpoint + strconv.Itoa(asyncStatusId),
 		Level: tc.SuccessLevel.String(),
 	})
+
+	w.Header().Add("Location", api.CurrentAsyncEndpoint+strconv.Itoa(asyncStatusId))
 	api.WriteAlerts(w, r, http.StatusAccepted, alerts)
 
 }
-func RunAutorenewal(existingCerts []ExistingCerts, cfg *config.Config, ctx context.Context, currentUser *auth.CurrentUser) {
+func RunAutorenewal(existingCerts []ExistingCerts, cfg *config.Config, ctx context.Context, currentUser *auth.CurrentUser, asyncStatusId int) {
 	db, err := api.GetDB(ctx)
 	if err != nil {
 		log.Errorf("Error getting db: %s", err.Error())
+		if err = api.UpdateAsyncStatus(db, api.AsyncFailed, "ACME renewal failed.", asyncStatusId, true); err != nil {
+			log.Errorf("updating async status for id %v: %v", asyncStatusId, err)
+		}
 		return
 	}
 	tx, err := db.Begin()
 	if err != nil {
 		log.Errorf("Error getting tx: %s", err.Error())
+		if err = api.UpdateAsyncStatus(db, api.AsyncFailed, "ACME renewal failed.", asyncStatusId, true); err != nil {
+			log.Errorf("updating async status for id %v: %v", asyncStatusId, err)
+		}
 		return
 	}
 
 	logTx, err := db.Begin()
 	if err != nil {
 		log.Errorf("Error getting logTx: %s", err.Error())
+		if err = api.UpdateAsyncStatus(db, api.AsyncFailed, "ACME renewal failed.", asyncStatusId, true); err != nil {
+			log.Errorf("updating async status for id %v: %v", asyncStatusId, err)
+		}
 		return
 	}
 	defer logTx.Commit()
 
 	keysFound := ExpirationSummary{}
 
+	renewedCount := 0
+	errorCount := 0
+
 	for _, ds := range existingCerts {
 		if !ds.Version.Valid || ds.Version.Int64 == 0 {
 			continue
@@ -166,13 +185,21 @@ func RunAutorenewal(existingCerts []ExistingCerts, cfg *config.Config, ctx conte
 		err = base64DecodeCertificate(&keyObj.Certificate)
 		if err != nil {
 			log.Errorf("cert autorenewal: error getting SSL keys for XMLID '%s': %s", ds.XmlId, err.Error())
-			return
+			dsExpInfo.XmlId = ds.XmlId
+			dsExpInfo.Version = util.JSONIntStr(int(ds.Version.Int64))
+			dsExpInfo.Error = errors.New("decoding the certificate for xmlId: " + ds.XmlId + " and version: " + strconv.Itoa(int(ds.Version.Int64)))
+			keysFound.OtherExpirations = append(keysFound.OtherExpirations, dsExpInfo)
+			continue
 		}
 
 		expiration, err := parseExpirationFromCert([]byte(keyObj.Certificate.Crt))
 		if err != nil {
 			log.Errorf("cert autorenewal: %s: %s", ds.XmlId, err.Error())
-			return
+			dsExpInfo.XmlId = ds.XmlId
+			dsExpInfo.Version = util.JSONIntStr(int(ds.Version.Int64))
+			dsExpInfo.Error = errors.New("parsing the expiration for xmlId: " + ds.XmlId + " and version: " + strconv.Itoa(int(ds.Version.Int64)))
+			keysFound.OtherExpirations = append(keysFound.OtherExpirations, dsExpInfo)
+			continue
 		}
 
 		// Renew only certificates within configured limit. Default is 30 days.
@@ -204,6 +231,9 @@ func RunAutorenewal(existingCerts []ExistingCerts, cfg *config.Config, ctx conte
 
 			if error := GetLetsEncryptCertificates(cfg, req, ctx, currentUser); error != nil {
 				dsExpInfo.Error = error
+				errorCount++
+			} else {
+				renewedCount++
 			}
 			keysFound.LetsEncryptExpirations = append(keysFound.LetsEncryptExpirations, dsExpInfo)
 
@@ -216,17 +246,35 @@ func RunAutorenewal(existingCerts []ExistingCerts, cfg *config.Config, ctx conte
 			} else {
 				userErr, sysErr, statusCode := renewAcmeCerts(cfg, keyObj.DeliveryService, ctx, currentUser)
 				if userErr != nil {
+					errorCount++
 					dsExpInfo.Error = userErr
 				} else if sysErr != nil {
+					errorCount++
 					dsExpInfo.Error = sysErr
 				} else if statusCode != http.StatusOK {
+					errorCount++
 					dsExpInfo.Error = errors.New("Status code not 200: " + strconv.Itoa(statusCode))
+				} else {
+					renewedCount++
 				}
 				keysFound.AcmeExpirations = append(keysFound.AcmeExpirations, dsExpInfo)
 			}
 
 		}
 
+		if err = api.UpdateAsyncStatus(db, api.AsyncPending, "ACME renewal in progress. "+strconv.Itoa(renewedCount)+" certs renewed, "+strconv.Itoa(errorCount)+" errors.", asyncStatusId, false); err != nil {
+			log.Errorf("updating async status for id %v: %v", asyncStatusId, err)
+		}
+
+	}
+
+	// put status as succeeded if any certs were successfully renewed
+	asyncStatus := api.AsyncSucceeded
+	if errorCount > 0 && renewedCount == 0 {
+		asyncStatus = api.AsyncFailed
+	}
+	if err = api.UpdateAsyncStatus(db, asyncStatus, "ACME renewal complete. "+strconv.Itoa(renewedCount)+" certs renewed, "+strconv.Itoa(errorCount)+" errors.", asyncStatusId, true); err != nil {
+		log.Errorf("updating async status for id %v: %v", asyncStatusId, err)
 	}
 
 	if cfg.SMTP.Enabled && cfg.ConfigAcmeRenewal.SummaryEmail != "" {
diff --git a/traffic_ops/traffic_ops_golang/deliveryservice/letsencryptcert.go b/traffic_ops/traffic_ops_golang/deliveryservice/letsencryptcert.go
index a062ef8..37bb781 100644
--- a/traffic_ops/traffic_ops_golang/deliveryservice/letsencryptcert.go
+++ b/traffic_ops/traffic_ops_golang/deliveryservice/letsencryptcert.go
@@ -121,8 +121,8 @@ func (d *DNSProviderTrafficRouter) CleanUp(domain, token, keyAuth string) error
 			return errors.New("Determining rows affected when deleting dns txt record for fqdn '" + fqdn + "' record '" + value + "': " + err.Error())
 		}
 		if rows == 0 {
-			log.Errorf("Zero rows affected when deleting dns txt record for fqdn '" + fqdn + "' record '" + value + "': " + err.Error())
-			return errors.New("Zero rows affected when deleting dns txt record for fqdn '" + fqdn + "' record '" + value + "': " + err.Error())
+			log.Errorf("Zero rows affected when deleting dns txt record for fqdn '" + fqdn + "' record '" + value)
+			return errors.New("Zero rows affected when deleting dns txt record for fqdn '" + fqdn + "' record '" + value)
 		}
 	}
 
diff --git a/traffic_ops/traffic_ops_golang/routing/routes.go b/traffic_ops/traffic_ops_golang/routing/routes.go
index 1adaab7..f7794b7 100644
--- a/traffic_ops/traffic_ops_golang/routing/routes.go
+++ b/traffic_ops/traffic_ops_golang/routing/routes.go
@@ -141,6 +141,7 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		//Delivery service ACME
 		{api.Version{4, 0}, http.MethodPost, `deliveryservices/xmlId/{xmlid}/sslkeys/renew$`, deliveryservice.RenewAcmeCertificate, auth.PrivLevelOperations, Authenticated, nil, 2534390573},
 		{api.Version{4, 0}, http.MethodPost, `acme_autorenew/?$`, deliveryservice.RenewCertificates, auth.PrivLevelOperations, Authenticated, nil, 2534390574},
+		{api.Version{4, 0}, http.MethodGet, `async_status/{id}$`, api.GetAsyncStatus, auth.PrivLevelOperations, Authenticated, nil, 2534390575},
 
 		// API Capability
 		{api.Version{4, 0}, http.MethodGet, `api_capabilities/?$`, apicapability.GetAPICapabilitiesHandler, auth.PrivLevelReadOnly, Authenticated, nil, 48132065893},