You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@trafficcontrol.apache.org by GitBox <gi...@apache.org> on 2021/05/27 16:27:14 UTC

[GitHub] [trafficcontrol] srijeet0406 opened a new pull request #5895: Add the ability to CRD CDN Locks

srijeet0406 opened a new pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895


   <!--
   ************ STOP!! ************
   If this Pull Request is intended to fix a security vulnerability, DO NOT submit it! Instead, contact
   the Apache Software Foundation Security Team at security@trafficcontrol.apache.org and follow the
   guidelines at https://www.apache.org/security/ regarding vulnerability disclosure.
   -->
   ## What does this PR (Pull Request) do?
   <!-- Explain the changes you made here. If this fixes an Issue, identify it by
   replacing the text in the checkbox item with the Issue number e.g.
   
   - [x] This PR fixes #9001 OR is not related to any Issue
   
   ^ This will automatically close Issue number 9001 when the Pull Request is
   merged (The '#' is important).
   
   Be sure you check the box properly, see the "The following criteria are ALL
   met by this PR" section for details.
   -->
   
   - [x] This PR is not related to any Issue <!-- You can check for an issue here: https://github.com/apache/trafficcontrol/issues -->
   
   
   ## Which Traffic Control components are affected by this PR?
   <!-- Please delete all components from this list that are NOT affected by this
   Pull Request. Also, feel free to add the name of a tool or script that is
   affected but not on the list.
   
   Additionally, if this Pull Request does NOT affect documentation, please
   explain why documentation is not required. -->
   
   - Documentation
   - Traffic Control Go Client 
   - Traffic Ops
   - CI tests
   
   ## What is the best way to verify this PR?
   <!-- Please include here ALL the steps necessary to test your Pull Request. If
   it includes tests (and most should), outline here the steps needed to run the
   tests. If not, lay out the manual testing procedure and please explain why
   tests are unnecessary for this Pull Request. -->
   Make sure the migrations run successfully.
   Make sure all the API and unit tests pass.
   Send requests to hit the POST, GET, DELETE and DELETE ADMIN endpoints for `cdn_locks` and make sure you see the expected results.
   ## If this is a bug fix, what versions of Traffic Control are affected?
   <!-- If this PR fixes a bug, please list here all of the affected versions - to
   the best of your knowledge. It's also pretty helpful to include a commit hash
   of where 'master' is at the time this PR is opened (if it affects master),
   because what 'master' means will change over time. For example, if this PR
   fixes a bug that's present in master (at commit hash '1df853c8'), in v4.0.0,
   and in the current 4.0.1 Release candidate (e.g. RC1), then this list would
   look like:
   
   - master (1df853c8)
   - 4.0.0
   - 4.0.1 (RC1)
   
   If you don't know what other versions might have this bug, AND don't know how
   to find the commit hash of 'master', then feel free to leave this section
   blank (or, preferably, delete it entirely).
    -->
   
   - master
   
   ## The following criteria are ALL met by this PR
   <!-- Check the boxes to signify that the associated statement is true. To
   "check a box", replace the space inside of the square brackets with an 'x'.
   e.g.
   
   - [ x] <- Wrong
   - [x ] <- Wrong
   - [] <- Wrong
   - [*] <- Wrong
   - [x] <- Correct!
   
   -->
   
   - [x] This PR includes tests 
   - [x] This PR includes documentation 
   - [x] This PR includes an update to CHANGELOG.md 
   - [x] This PR includes any and all required license headers
   - [x] This PR **DOES NOT FIX A SERIOUS SECURITY VULNERABILITY** (see [the Apache Software Foundation's security guidelines](https://www.apache.org/security/) for details)
   
   
   ## Additional Information
   <!-- If you would like to include any additional information on the PR for
   potential reviewers please put it here.
   
   Some examples of this would be:
   
   - Before and after screenshots/gifs of the Traffic Portal if it is affected
   - Links to other dependent Pull Requests
   - References to relevant context (e.g. new/updates to dependent libraries,
   mailing list records, blueprints)
   
   Feel free to leave this section blank (or, preferably, delete it entirely).
   -->
   
   <!--
   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.
   -->
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640857003



##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       The primary key for a CDN lock is the CDN which it locks, so it's impossible to for more than one user to have a lock on a CDN at a time. the `DELETE` method requires the name of the CDN to be passed in the `cdn` query string parameter according to the documentation. So the request already fully and uniquely defines the lock to be deleted, so passing `self=true` or `self=false` doesn't make much sense to me. The lock either belongs to you or it doesn't, you can't delete only "your" lock on a CDN because a CDN only has one lock. The only thing that needs to be decided is whether or not the requesting user has permission to perform the operation.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645122427



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       Ideally, it could, but I dont think the benefit would be too much. The number of locks can only be less than or equal to the number of CDNs (worst case). Depending on how often we make the call to `GET` all the locks (which I dont think will be a lot), the bandwidth advantage isn't very high. Additionally, we'd need to add another database migration for it.
   I can add it if you want.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640878693



##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       Agreed, but that does leave us to the chance of an admin accidentally or unintentionally deleting someone else's lock. I know we could pass it off as user error, but I was just thinking of adding some more safety into it. What do you think? 




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r641605488



##########
File path: docs/source/api/v4/cdn_locks.rst
##########
@@ -0,0 +1,197 @@
+..
+..
+.. 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-cdn-locks:
+
+*****************
+``cdn_locks``
+*****************
+
+.. versionadded:: 4.0
+
+``GET``
+=======
+Gets information for all CDN locks.
+
+:Auth. Required: Yes
+:Roles Required: None
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| Parameter     | Required | Description                                                                       |
+	+===============+==========+===================================================================================+
+	| username      | no       | Return only the CDN lock that the user with ``username`` possesses                |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| cdn           | no       | Return only the CDN lock for the CDN that has the name ``cdn``                    |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+
+Response Structure
+------------------
+:userName:       The username for which the lock exists.
+:cdn:            The name of the CDN for which the lock exists.
+:message:        The message or reason that the user specified while acquiring the lock.
+:soft:           Whether or not this is a soft(shared) lock.
+:lastUpdated:    Time that this lock was last updated(created).
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/2 200
+	Content-Type: application/json
+
+	{ "response": [
+		{
+			"userName": "foo",
+			"cdn": "bar",
+			"message": "acquiring lock to snap CDN",
+			"soft": true,
+			"lastUpdated": "2021-05-26T09:31:57-06"
+		}
+	]}
+
+``POST``
+========
+Allows user to acquire a lock on a CDN.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type:  Object
+
+Request Structure
+-----------------
+The request body must be a single ``CDN Lock`` object with the following keys:
+:cdn:            The name of the CDN for which the user wants to acquire a lock.
+:message:        The message or reason for the user to acquire the lock. This is an optional field.
+:soft:           Whether or not this is a soft(shared) lock. This is an optional field; ``soft`` will be set to ``true`` by default.
+
+.. code-block:: http
+	:caption: Request Example
+
+	POST /api/4.0/cdn_locks HTTP/2
+	Host: localhost:8443
+	User-Agent: curl/7.64.2
+	Accept: */*
+	Cookie: mojolicious=...
+	Content-Type: application/json
+	Content-Length: 81
+
+	{
+		"cdn": "bar",
+		"message": "acquiring lock to snap CDN",
+		"soft": true
+	}
+
+Response Structure
+------------------
+:userName:       The username for which the lock was created.
+:cdn:            The name of the CDN for which the lock was created.
+:message:        The message or reason that the user specified while acquiring the lock.
+:soft:           Whether or not this is a soft(shared) lock.
+:lastUpdated:    Time that this lock was last updated(created).
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/2 201
+	Access-Control-Allow-Credentials: true
+	Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Set-Cookie, Cookie
+	Access-Control-Allow-Methods: POST,GET,OPTIONS,PUT,DELETE
+	Access-Control-Allow-Origin: *
+	Content-Type: application/json
+	Set-Cookie: mojolicious=...; Path=/; Expires=Wed, 26 May 2021 17:59:10 GMT; Max-Age=3600; HttpOnly
+	Whole-Content-Sha512: IWjt4zhg4OlPDTfOebjMTS1uHsZ8LycEaHgSS3KHnmc6Vvmw5/S6q70CCnbAePV2x1bxKkVEifTIxfft8vq3sg==
+	X-Server-Name: traffic_ops_golang/
+	Date: Wed, 26 May 2021 16:59:10 GMT
+	Content-Length: 204
+
+	{ "alerts": [
+		{
+			"text": "CDN lock acquired!",
+			"level":"success"
+		}
+	],
+	"response": {
+		"userName": "foo",
+		"cdn": "bar",
+		"message": "acquiring lock to snap CDN",
+		"soft": true,
+		"lastUpdated": "2021-05-26T10:59:10-06"
+	}}
+
+``DELETE``
+----------
+Deletes an existing ``CDN Lock``.
+
+:Auth. Required: Yes
+:Roles Required: "admin" or "operations"
+:Response Type: Object
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| Parameter     | Required | Description                                                                       |
+	+===============+==========+===================================================================================+
+	| cdn           | yes      | Delete the CDN lock for the CDN that has the name ``cdn``                    |

Review comment:
       Table is malformed




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645113753



##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Well, for any non admin user, it is assumed that the lock that the user is trying to delete will be their own lock. And if a non admin user(A) is trying to delete someone else's(B) lock, the DB query that gets constructed will have the username set to `A`. Since this lock doesn't exist, the user gets a 404.  




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 merged pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 merged pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645144863



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       [The database storage section lists it as `not null`](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#data-model--database-impact), but also [the REST API section labels it as "required"](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#rest-api-impact). Personally, I think it's better to be explicit about typing rather than implicit; it's a lot harder to make a mistake if you **have** to specify the value yourself.
   
   Honestly, if it were up to me entirely, I would eliminate optional properties entirely. But that would be a major overhaul since Go is incapable of distinguishing between `null` and `undefined` unless you jump through some `json.RawMessage` hoops.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645160269



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       Fixed now.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640794512



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}

Review comment:
       You shouldn't need to do this; the `api.NewInfo` function returns a structure that holds a reference to the authenticated user in `inf.User`.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645110137



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       So that required/ not null field was intended for the database storage. While creating a lock, a user is free to leave out the `soft` property from the request structure, and by default we would always create `soft` locks. Its better of the user has to specify lesser details. Does that sound ok to you?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645145868



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       idk how useful it could be, I just thought we wanted everything to do that from now on. Idc about adding it, it's fine.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r648593881



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -1,3 +1,4 @@
+// cdn_lock package contains the CRD methods related to CDNLocks

Review comment:
       Package GoDocs should be complete sentences that start with `Package {{package name}}`.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645088439



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       Should this endpoint support IMS?

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,48 @@
+package tc
+
+/*
+ * 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 (
+	"time"
+)
+
+// CDNLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CDNLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	CDN         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated time.Time `json:"lastUpdated" db:"last_updated"`
+}
+
+// CdnLockCreateResponse is a struct to store the response of a CREATE operation on a lock.
+type CdnLockCreateResponse struct {
+	Response CDNLock `json:"response"`
+	Alerts
+}
+
+// CdnLocksGetResponse is a struct to store the response of a GET operation on locks.
+type CdnLocksGetResponse struct {
+	Response []CDNLock `json:"response"`
+	Alerts
+}
+
+// CdnLockDeleteResponse is a struct to store the response of a DELETE operation on a lock.
+type CdnLockDeleteResponse CdnLockCreateResponse

Review comment:
       These three types should use `CDN...` for their names, to be consistent with `CDNLock`.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       The blueprint listed the `soft` property as required and the database migration restricts it to `NOT NULL` - is there a reason to allow it to be null or undefined?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+	cdnLock.UserName = inf.User.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.CDN, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", inf.User.UserName, cdnLock.CDN)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var result tc.CDNLock
+	var err error
+	if inf.User.PrivLevel == auth.PrivLevelAdmin {
+		err = inf.Tx.Tx.QueryRow(deleteAdminQuery, cdn).Scan(&result.UserName, &result.CDN, &result.Message, &result.Soft, &result.LastUpdated)
+	} else {
+		err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, inf.User.UserName).Scan(&result.UserName, &result.CDN, &result.Message, &result.Soft, &result.LastUpdated)
+	}
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			api.HandleErr(w, r, tx, http.StatusNotFound, fmt.Errorf("deleting cdn lock with cdn name %s: lock not found", cdn), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("deleting cdn lock with cdn name %s : %v", cdn, err.Error()))

Review comment:
       formatting parameters for errors should look like `fmt.Errorf("%w", err)` rather than `fmt.Errorf("%v", err.Error())` - `%w` is a special formatting directive for errors that only `Errorf` provides, which "wraps" the error it formats. Wrapping errors is depended upon by things like `errors.Is`.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Shouldn't this be a `403 Forbidden` if the user doesn't have permission to delete the lock?

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,48 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CDNLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &response)
+	return response, reqInf, err
+}
+
+// GetCdnLocks retrieves the CDN locks based on the passed in parameters.
+func (to *Session) GetCdnLocks(opts RequestOptions) (tc.CdnLocksGetResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLocksGetResponse
+	reqInf, err := to.get(apiCDNLocks, opts, &data)
+	return data, reqInf, err
+}
+
+// DeleteCdnLocks deletes the CDN lock of a particular(requesting) user.
+func (to *Session) DeleteCdnLocks(opts RequestOptions) (tc.CdnLockDeleteResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLockDeleteResponse
+	reqInf, err := to.del(apiCDNLocks, opts, &data)
+	return data, reqInf, err
+}

Review comment:
       nit but CDN should be capitalized in these exported symbols, since it's an initialism.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       That sounds like it could be confusing. I can `GET` something with 200 OK but then try to `DELETE` it and the API tells me it doesn't exist. But I can still `GET` it. A client-side error should help to inform the client how to rectify the situation.
   
   A 404 indicates, it seems to me, that you can't request the path you're requesting, so the solution is to change the path. But that won't help in this case, because the actual problem is that since the lock doesn't belong to me and I'm not an administrator, I don't have permission to do the thing I'm trying to do, which is what a 403 indicates IMO.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       [The database storage section lists it as `not null`](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#data-model--database-impact), but also [the REST API section labels it as "required"](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#rest-api-impact). Personally, I think it's better to be explicit about typing rather than implicit; it's a lot harder to make a mistake if you **have** to specify the value yourself.
   
   Honestly, if it were up to me entirely, I would eliminate optional properties entirely. But that would be a major overhaul since Go is incapable of distinguishing between `null` and `undefined` unless you jump through some `json.RawMessage` hoops.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       [The database storage section lists it as `not null`](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#data-model--database-impact), but also [the REST API section labels it as "required"](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#rest-api-impact). Personally, I think it's better to be explicit about typing rather than implicit; it's a lot harder to make a mistake if you **have** to specify the value yourself.
   
   Honestly, if it were up to me entirely, I would eliminate optional properties altogether. But that would be a major overhaul since Go is incapable of distinguishing between `null` and `undefined` unless you jump through some `json.RawMessage` hoops.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       idk how useful it could be, I just thought we wanted everything to do that from now on. Idc about adding it, it's fine.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645138470



##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       That sounds like it could be confusing. I can `GET` something with 200 OK but then try to `DELETE` it and the API tells me it doesn't exist. But I can still `GET` it. A client-side error should help to inform the client how to rectify the situation.
   
   A 404 indicates, it seems to me, that you can't request the path you're requesting, so the solution is to change the path. But that won't help in this case, because the actual problem is that since the lock doesn't belong to me and I'm not an administrator, I don't have permission to do the thing I'm trying to do, which is what a 403 indicates IMO.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645088439



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       Should this endpoint support IMS?

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,48 @@
+package tc
+
+/*
+ * 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 (
+	"time"
+)
+
+// CDNLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CDNLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	CDN         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated time.Time `json:"lastUpdated" db:"last_updated"`
+}
+
+// CdnLockCreateResponse is a struct to store the response of a CREATE operation on a lock.
+type CdnLockCreateResponse struct {
+	Response CDNLock `json:"response"`
+	Alerts
+}
+
+// CdnLocksGetResponse is a struct to store the response of a GET operation on locks.
+type CdnLocksGetResponse struct {
+	Response []CDNLock `json:"response"`
+	Alerts
+}
+
+// CdnLockDeleteResponse is a struct to store the response of a DELETE operation on a lock.
+type CdnLockDeleteResponse CdnLockCreateResponse

Review comment:
       These three types should use `CDN...` for their names, to be consistent with `CDNLock`.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       The blueprint listed the `soft` property as required and the database migration restricts it to `NOT NULL` - is there a reason to allow it to be null or undefined?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+	cdnLock.UserName = inf.User.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.CDN, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", inf.User.UserName, cdnLock.CDN)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var result tc.CDNLock
+	var err error
+	if inf.User.PrivLevel == auth.PrivLevelAdmin {
+		err = inf.Tx.Tx.QueryRow(deleteAdminQuery, cdn).Scan(&result.UserName, &result.CDN, &result.Message, &result.Soft, &result.LastUpdated)
+	} else {
+		err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, inf.User.UserName).Scan(&result.UserName, &result.CDN, &result.Message, &result.Soft, &result.LastUpdated)
+	}
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			api.HandleErr(w, r, tx, http.StatusNotFound, fmt.Errorf("deleting cdn lock with cdn name %s: lock not found", cdn), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("deleting cdn lock with cdn name %s : %v", cdn, err.Error()))

Review comment:
       formatting parameters for errors should look like `fmt.Errorf("%w", err)` rather than `fmt.Errorf("%v", err.Error())` - `%w` is a special formatting directive for errors that only `Errorf` provides, which "wraps" the error it formats. Wrapping errors is depended upon by things like `errors.Is`.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Shouldn't this be a `403 Forbidden` if the user doesn't have permission to delete the lock?

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,48 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CDNLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &response)
+	return response, reqInf, err
+}
+
+// GetCdnLocks retrieves the CDN locks based on the passed in parameters.
+func (to *Session) GetCdnLocks(opts RequestOptions) (tc.CdnLocksGetResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLocksGetResponse
+	reqInf, err := to.get(apiCDNLocks, opts, &data)
+	return data, reqInf, err
+}
+
+// DeleteCdnLocks deletes the CDN lock of a particular(requesting) user.
+func (to *Session) DeleteCdnLocks(opts RequestOptions) (tc.CdnLockDeleteResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLockDeleteResponse
+	reqInf, err := to.del(apiCDNLocks, opts, &data)
+	return data, reqInf, err
+}

Review comment:
       nit but CDN should be capitalized in these exported symbols, since it's an initialism.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640843483



##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,60 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CdnLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	var alerts tc.Alerts
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &alerts)
+	response.Response = cdnLock

Review comment:
       Sorry that was a bad paste. Fixed now.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645139712



##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Ok I was just thinking of 404 in another way, that the lock doesn't exist. But what you said makes sense to me. I'll change it to return a 403.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640904727



##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       Sounds good, just wanted to put it out there. 👍 




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r648539691



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,172 @@
+package cdn_lock

Review comment:
       Package should have a GoDoc

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,172 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {

Review comment:
       GoDoc?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,172 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.Soft == nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'soft' must be present"), nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	cdnLock.UserName = inf.User.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.CDN, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", inf.User.UserName, cdnLock.CDN)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {

Review comment:
       GoDoc?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,172 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       GoDoc?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640813430



##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {
+	Response []CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLockDeleteResponse CdnLockCreateResponse
+
+func (c CdnLock) Validate(tx *sql.Tx) error {
+	return nil
+}

Review comment:
       The file changed during my review, so I sort of said the same thing twice. Marking this as resolved so the discussion stays in one place.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645110137



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       So that required/ not null field was intended for the database storage. While creating a lock, a user is free to leave out the `soft` property from the request structure, and by default we would always create `soft` locks. Its better of the user has to specify lesser details. Does that sound ok to you?

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Well, for any non admin user, it is assumed that the lock that the user is trying to delete will be their own lock. And if a non admin user(A) is trying to delete someone else's(B) lock, the DB query that gets constructed will have the username set to `A`. Since this lock doesn't exist, the user gets a 404.  

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {

Review comment:
       Ideally, it could, but I dont think the benefit would be too much. The number of locks can only be less than or equal to the number of CDNs (worst case). Depending on how often we make the call to `GET` all the locks (which I dont think will be a lot), the bandwidth advantage isn't very high. Additionally, we'd need to add another database migration for it.
   I can add it if you want.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,201 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err)
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CDNLock
+	cdnLock.CDN = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLockResp.Response.CDN)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err)
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].CDN != cdnLock.CDN {
+		t.Errorf("expected cdn %v, got %v", cdnLock.CDN, cdnLocksReadResp.Response[0].CDN)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft == nil {
+		t.Errorf("expected a valid soft/hard setting, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.CDN}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err)
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err)
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("lockuseremail@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Create another new user with operations level privileges
+	user2 := tc.User{
+		Username:             util.StrPtr("lock_user2"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word2"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word2"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user2.Email = util.StrPtr("newlockuseremail@domain.com")
+	user2.TenantID = util.IntPtr(resp.Response[0].ID)
+	user2.FullName = util.StrPtr("firstName2 LastName2")
+	_, _, err = TOSession.CreateUser(user2, client.RequestOptions{})
+	if err != nil {
+		fmt.Println(err)
+		t.Fatalf("could not create test user with username: %s", *user2.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user2"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	// Establish another session with the newly created non admin level user
+	userSession2, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user2.Username, *user2.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err)
+	}
+
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CDNLock{
+		CDN:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err)
+	}
+
+	// Non admin user trying to delete another user's lock -> this should fail
+	_, reqInf, err := userSession2.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to delete another user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {

Review comment:
       Ok I was just thinking of 404 in another way, that the lock doesn't exist. But what you said makes sense to me. I'll change it to return a 403.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       Fixed now.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r645144863



##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,168 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	cdnLock := []tc.CDNLock{}
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CDNLock
+		if err = rows.Scan(&cLock.UserName, &cLock.CDN, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+	tx := inf.Tx.Tx
+	if inf.User == nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("couldn't get user for the current request"))
+	}
+	var cdnLock tc.CDNLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	if cdnLock.CDN == "" {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, errors.New("field 'cdn' must be present"), nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {

Review comment:
       [The database storage section lists it as `not null`](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#data-model--database-impact), but also [the REST API section labels it as "required"](https://github.com/apache/trafficcontrol/blob/master/blueprints/to-locks.md#rest-api-impact). Personally, I think it's better to be explicit about typing rather than implicit; it's a lot harder to make a mistake if you **have** to specify the value yourself.
   
   Honestly, if it were up to me entirely, I would eliminate optional properties altogether. But that would be a major overhaul since Go is incapable of distinguishing between `null` and `undefined` unless you jump through some `json.RawMessage` hoops.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] srijeet0406 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
srijeet0406 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640836432



##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       So here's what I was thinking: If an `admin` level user hits the `DELETE` endpoint, it might be because the user wants to delete their own lock, or some other user's lock. That why I wanted to split them out into two different endpoints, one for deleting the user's own locks, and one for other deleting other people's locks (for an admin). However, I could add another query parameter to the DELETE endpoint, saying something like `self=true` to imply that the user wants to delete their own locks. Thoughts?




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640900653



##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       Well that's what I mean, the admin user will say "I'm going to unlock this CDN" and then... that's exactly what happens. I don't think it's super likely to accidentally delete someone else's lock, because if someone else has it locked then you couldn't have locked it in the first place. So if you have no memory of locking the CDN, you should be able to immediately suppose that the lock was placed by someone else.
   
   If we're worried about users doing legal things accidentally, then I'd say the proper way to handle that is to place a warning and/or confirmation dialog in the UI. But IMO we don't need a whole separate endpoint to describe a second way of checking for permissions to do a single operation. 1 operation <-> 1 method of one endpoint makes the most sense to me.




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [trafficcontrol] ocket8888 commented on a change in pull request #5895: Add the ability to CRD CDN Locks

Posted by GitBox <gi...@apache.org>.
ocket8888 commented on a change in pull request #5895:
URL: https://github.com/apache/trafficcontrol/pull/5895#discussion_r640784667



##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {

Review comment:
       GoDoc?

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {
+	Response []CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLockDeleteResponse CdnLockCreateResponse
+
+func (c CdnLock) Validate(tx *sql.Tx) error {
+	return nil
+}

Review comment:
       We're trying to leave validation out of `lib/go-tc/` from now on, since it's only useful to Traffic Ops it should just go in the TO codebase.

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s : %v", cdn, err.Error())))

Review comment:
       same as above RE: `fmt.Errorf`

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)

Review comment:
       ideally this would set a `Location` HTTP header to the URI that can be used to interact with the created resource, but you don't need to.

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,57 @@
+package tc
+
+/*
+ * 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"
+
+	validation "github.com/go-ozzo/ozzo-validation"
+
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
+)
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {

Review comment:
       nit but `CdnLock` should be `CDNLock` since "CDN" is an initialism.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.Cdn}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}

Review comment:
       Shouldn't this also error if `cdnLockResp.Response.Soft == nil`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}

Review comment:
       same as above RE: soft being `nil`

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {
+	Response []CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLockDeleteResponse CdnLockCreateResponse

Review comment:
       GoDoc?

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`

Review comment:
       This should just be a `time.Time` so it gets formatted properly.

##########
File path: docs/source/api/v4/cdn_locks.rst
##########
@@ -0,0 +1,260 @@
+..
+..
+.. 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-cdn-locks:
+
+*****************
+``cdn_locks``
+*****************
+
+.. versionadded:: 4.0
+
+``GET``
+=======
+Gets information for all CDN locks.
+
+:Auth. Required: Yes
+:Roles Required: None
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| Parameter     | Required | Description                                                                       |
+	+===============+==========+===================================================================================+
+	| username      | no       | Return only the CDN lock that the user with ``username`` possesses                |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| cdn           | no       | Return only the CDN lock for the CDN that has the name ``cdn``                    |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+
+Response Structure
+------------------
+:userName:       The username for which the lock exists.
+:cdn:            The name of the CDN for which the lock exists.
+:message:        The message or reason that the user specified while acquiring the lock.
+:soft:           Whether or not this is a soft(shared) lock.
+:lastUpdated:    Time that this lock was last updated(created).
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/2 200
+	Content-Type: application/json
+
+	{ "response": [
+		{
+			"userName": "foo",
+			"cdn": "bar",
+			"message": "acquiring lock to snap CDN",
+			"soft": true,
+			"lastUpdated": "2021-05-26 09:31:57-06"
+		}
+	]}
+
+``POST``
+========
+Allows user to acquire a lock on a CDN

Review comment:
       Missing punctuation

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {

Review comment:
       specific errors should be checked for using `errors.Is` instead of `==`

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}

Review comment:
       Same as above RE: `inf.User`

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)

Review comment:
       Instead of `errors.New(fmt.Sprintf(...))`, you could just do `fmt.Errorf(...)`

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock

Review comment:
       This should be initialized to an empty slice; if there are no locks this will return `{"response": null}` instead of `{"response": []}`.

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {

Review comment:
       GoDoc?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s : %v", cdn, err.Error())))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "cdn lock deleted")
+	api.WriteAlertsObj(w, r, http.StatusOK, alerts, result)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Released", u.UserName, cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func AdminDelete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+	err := inf.Tx.Tx.QueryRow(deleteAdminQuery, cdn).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)

Review comment:
       same as above RE: `fmt.Errorf`

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}

Review comment:
       You shouldn't need to do this; the `api.NewInfo` function returns a function that holds a reference to the authenticated user in `inf.User`.

##########
File path: traffic_ops/app/db/migrations/2021052400000000_add_cdn_lock.sql
##########
@@ -0,0 +1,30 @@
+/*
+	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
+CREATE TABLE IF NOT EXISTS cdn_lock (

Review comment:
       we've recently discovered that some people have issues with migrations that don't explicitly state schema; these things being CREATEd and DROPped should be in the `public` schema e.g. `public.cdn_lock`.

##########
File path: traffic_ops/traffic_ops_golang/routing/routes.go
##########
@@ -133,6 +134,12 @@ func Routes(d ServerData) ([]Route, []RawRoute, http.Handler, error) {
 		 * 4.x API
 		 */
 
+		// CDN lock
+		{api.Version{Major: 4, Minor: 0}, http.MethodGet, `cdn_locks/?$`, cdn_lock.Read, auth.PrivLevelReadOnly, Authenticated, nil, 4134390561},
+		{api.Version{Major: 4, Minor: 0}, http.MethodPost, `cdn_locks/?$`, cdn_lock.Create, auth.PrivLevelOperations, Authenticated, nil, 4134390562},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/?$`, cdn_lock.Delete, auth.PrivLevelOperations, Authenticated, nil, 4134390564},
+		{api.Version{Major: 4, Minor: 0}, http.MethodDelete, `cdn_locks/admin/?$`, cdn_lock.AdminDelete, auth.PrivLevelAdmin, Authenticated, nil, 4134390565},

Review comment:
       The blueprint only specified one route - `/cdn_locks` - and just seemed to say that the user's ability to `DELETE` a lock would be checked based on both user (users can delete their own locks)  and privilege level (admins can delete anyone's lock). Why not just do that instead of adding a new route (that would need documentation also)?

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s : %v", cdn, err.Error())))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "cdn lock deleted")
+	api.WriteAlertsObj(w, r, http.StatusOK, alerts, result)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Released", u.UserName, cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func AdminDelete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+	err := inf.Tx.Tx.QueryRow(deleteAdminQuery, cdn).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {

Review comment:
       same as above RE: `errors.Is`

##########
File path: docs/source/api/v4/cdn_locks.rst
##########
@@ -0,0 +1,260 @@
+..
+..
+.. 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-cdn-locks:
+
+*****************
+``cdn_locks``
+*****************
+
+.. versionadded:: 4.0
+
+``GET``
+=======
+Gets information for all CDN locks.
+
+:Auth. Required: Yes
+:Roles Required: None
+:Response Type:  Array
+
+Request Structure
+-----------------
+.. table:: Request Query Parameters
+
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| Parameter     | Required | Description                                                                       |
+	+===============+==========+===================================================================================+
+	| username      | no       | Return only the CDN lock that the user with ``username`` possesses                |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+	| cdn           | no       | Return only the CDN lock for the CDN that has the name ``cdn``                    |
+	+---------------+----------+-----------------------------------------------------------------------------------+
+
+Response Structure
+------------------
+:userName:       The username for which the lock exists.
+:cdn:            The name of the CDN for which the lock exists.
+:message:        The message or reason that the user specified while acquiring the lock.
+:soft:           Whether or not this is a soft(shared) lock.
+:lastUpdated:    Time that this lock was last updated(created).
+
+.. code-block:: http
+	:caption: Response Example
+
+	HTTP/2 200
+	Content-Type: application/json
+
+	{ "response": [
+		{
+			"userName": "foo",
+			"cdn": "bar",
+			"message": "acquiring lock to snap CDN",
+			"soft": true,
+			"lastUpdated": "2021-05-26 09:31:57-06"

Review comment:
       Time format should be RFC3339

##########
File path: traffic_ops/traffic_ops_golang/cdn_lock/cdn_lock.go
##########
@@ -0,0 +1,210 @@
+package cdn_lock
+
+/*
+ * 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api"
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/dbhelpers"
+)
+
+const readQuery = `SELECT username, cdn, message, soft, last_updated FROM cdn_lock`
+const insertQuery = `INSERT INTO cdn_lock (username, cdn, message, soft) VALUES (:username, :cdn, :message, :soft) RETURNING username, cdn, message, soft, last_updated`
+const deleteQuery = `DELETE FROM cdn_lock WHERE cdn=$1 AND username=$2 RETURNING username, cdn, message, soft, last_updated`
+const deleteAdminQuery = `DELETE FROM cdn_lock WHERE cdn=$1 RETURNING username, cdn, message, soft, last_updated`
+
+func Read(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	tx := inf.Tx.Tx
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cols := map[string]dbhelpers.WhereColumnInfo{
+		"cdn":      {Column: "cdn_lock.cdn", Checker: nil},
+		"username": {Column: "cdn_lock.username", Checker: nil},
+	}
+
+	where, orderBy, pagination, queryValues, errs := dbhelpers.BuildWhereAndOrderByAndPagination(inf.Params, cols)
+	if len(errs) > 0 {
+		errCode = http.StatusBadRequest
+		userErr = util.JoinErrs(errs)
+		api.HandleErr(w, r, tx, errCode, userErr, nil)
+		return
+	}
+
+	var cdnLock []tc.CdnLock
+	query := readQuery + where + orderBy + pagination
+	rows, err := inf.Tx.NamedQuery(query, queryValues)
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("querying cdn locks: "+err.Error()))
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var cLock tc.CdnLock
+		if err = rows.Scan(&cLock.UserName, &cLock.Cdn, &cLock.Message, &cLock.Soft, &cLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn locks: "+err.Error()))
+			return
+		}
+		cdnLock = append(cdnLock, cLock)
+	}
+
+	api.WriteResp(w, r, cdnLock)
+}
+
+func Create(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, nil, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	tx := inf.Tx.Tx
+
+	var cdnLock tc.CdnLock
+	if err := json.NewDecoder(r.Body).Decode(&cdnLock); err != nil {
+		api.HandleErr(w, r, tx, http.StatusBadRequest, err, nil)
+		return
+	}
+	// by default, always create soft (or shared) locks
+	if cdnLock.Soft == nil {
+		cdnLock.Soft = util.BoolPtr(true)
+	}
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+
+	cdnLock.UserName = u.UserName
+	resultRows, err := inf.Tx.NamedQuery(insertQuery, cdnLock)
+	if err != nil {
+		userErr, sysErr, errCode := api.ParseDBError(err)
+		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
+		return
+	}
+	defer resultRows.Close()
+
+	rowsAffected := 0
+	for resultRows.Next() {
+		rowsAffected++
+		if err := resultRows.Scan(&cdnLock.UserName, &cdnLock.Cdn, &cdnLock.Message, &cdnLock.Soft, &cdnLock.LastUpdated); err != nil {
+			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: scanning locks: "+err.Error()))
+			return
+		}
+	}
+	if rowsAffected == 0 {
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("cdn lock create: lock couldn't be acquired"))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "CDN lock acquired!")
+	api.WriteAlertsObj(w, r, http.StatusCreated, alerts, cdnLock)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Acquired", u.UserName, cdnLock.Cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func Delete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+
+	c, err := api.GetConfig(r.Context())
+	if err != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, err)
+		return
+	}
+	u, userErr, sysErr, errCode := api.GetUserFromReq(w, r, c.Secrets[0])
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+
+	err = inf.Tx.Tx.QueryRow(deleteQuery, cdn, u.UserName).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s : %v", cdn, err.Error())))
+		return
+	}
+
+	alerts := tc.CreateAlerts(tc.SuccessLevel, "cdn lock deleted")
+	api.WriteAlertsObj(w, r, http.StatusOK, alerts, result)
+
+	changeLogMsg := fmt.Sprintf("USER: %s, CDN: %s, ACTION: Lock Released", u.UserName, cdn)
+	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
+}
+
+func AdminDelete(w http.ResponseWriter, r *http.Request) {
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	if userErr != nil || sysErr != nil {
+		api.HandleErr(w, r, inf.Tx.Tx, errCode, userErr, sysErr)
+		return
+	}
+	defer inf.Close()
+
+	cdn := inf.Params["cdn"]
+	tx := inf.Tx.Tx
+	var result tc.CdnLock
+	err := inf.Tx.Tx.QueryRow(deleteAdminQuery, cdn).Scan(&result.UserName, &result.Cdn, &result.Message, &result.Soft, &result.LastUpdated)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			api.HandleErr(w, r, tx, http.StatusNotFound, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s: lock not found", cdn)), nil)
+			return
+		}
+		api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New(fmt.Sprintf("deleting cdn lock with cdn name %s : %v", cdn, err.Error())))

Review comment:
       same as above RE: `fmt.Errorf`

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,57 @@
+package tc
+
+/*
+ * 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"
+
+	validation "github.com/go-ozzo/ozzo-validation"
+
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
+)
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {
+	Response []CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLockDeleteResponse CdnLockCreateResponse
+
+func (c CdnLock) Validate(tx *sql.Tx) error {
+	errs := validation.Errors{
+		"cdn": validation.Validate(c.Cdn, validation.Required),
+	}
+	return util.JoinErrs(tovalidate.ToErrors(errs))
+}

Review comment:
       This doesn't look used anywhere that I can see. If you do want to use it, the `POST` handler should use `api.Parse` - and in that case, this code should go inside TO's codebase. This function has no use outside the context of TO, so it doesn't need to go in `lib/go-tc`, and we're trying not to do that anymore for that reason.

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,60 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CdnLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	var alerts tc.Alerts
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &alerts)
+	response.Response = cdnLock
+	response.Alerts = alerts
+	return response, reqInf, err
+}
+
+// GetCdnLocks retrieves the CDN locks based on the passed in parameters.
+func (to *Session) GetCdnLocks(opts RequestOptions) (tc.CdnLocksGetResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLocksGetResponse
+	reqInf, err := to.get(fmt.Sprintf(apiCDNLocks), opts, &data)

Review comment:
       unnecessary `fmt.Sprintf`

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,60 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CdnLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	var alerts tc.Alerts
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &alerts)
+	response.Response = cdnLock

Review comment:
       This will swallow the actual response from TO and substitute it for what the user entered. Instead of cobbling together the response, why not just pass a reference to a `tc.CdnLockCreateResponse` as the fourth argument to `to.post`?

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,60 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CdnLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	var alerts tc.Alerts
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &alerts)
+	response.Response = cdnLock
+	response.Alerts = alerts
+	return response, reqInf, err
+}
+
+// GetCdnLocks retrieves the CDN locks based on the passed in parameters.
+func (to *Session) GetCdnLocks(opts RequestOptions) (tc.CdnLocksGetResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLocksGetResponse
+	reqInf, err := to.get(fmt.Sprintf(apiCDNLocks), opts, &data)
+	return data, reqInf, err
+}
+
+// DeleteCdnLocks deletes the CDN lock of a particular(requesting) user.
+func (to *Session) DeleteCdnLocks(opts RequestOptions) (tc.CdnLockDeleteResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLockDeleteResponse
+	reqInf, err := to.del(fmt.Sprintf(apiCDNLocks), opts, &data)
+	return data, reqInf, err
+}
+
+// AdminDeleteCdnLocks hits the endpoint an admin user would use to delete somebody else's lock.
+func (to *Session) AdminDeleteCdnLocks(opts RequestOptions) (tc.CdnLockDeleteResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLockDeleteResponse
+	reqInf, err := to.del(fmt.Sprintf(apiAdminCDNLocks), opts, &data)

Review comment:
       unnecessary `fmt.Sprintf`

##########
File path: traffic_ops/v4-client/cdn_lock.go
##########
@@ -0,0 +1,60 @@
+package client
+
+/*
+
+   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.
+*/
+
+import (
+	"fmt"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
+)
+
+// apiCDNLocks is the API version-relative path for the /cdn_locks API endpoint.
+const apiCDNLocks = "/cdn_locks"
+
+// apiAdminCDNLocks is the API version-relative path for the /cdn_locks/admin API endpoint.
+const apiAdminCDNLocks = "/cdn_locks/admin"
+
+// CreateCdnLock creates a CDN Lock.
+func (to *Session) CreateCdnLock(cdnLock tc.CdnLock, opts RequestOptions) (tc.CdnLockCreateResponse, toclientlib.ReqInf, error) {
+	var response tc.CdnLockCreateResponse
+	var alerts tc.Alerts
+	reqInf, err := to.post(apiCDNLocks, opts, cdnLock, &alerts)
+	response.Response = cdnLock
+	response.Alerts = alerts
+	return response, reqInf, err
+}
+
+// GetCdnLocks retrieves the CDN locks based on the passed in parameters.
+func (to *Session) GetCdnLocks(opts RequestOptions) (tc.CdnLocksGetResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLocksGetResponse
+	reqInf, err := to.get(fmt.Sprintf(apiCDNLocks), opts, &data)
+	return data, reqInf, err
+}
+
+// DeleteCdnLocks deletes the CDN lock of a particular(requesting) user.
+func (to *Session) DeleteCdnLocks(opts RequestOptions) (tc.CdnLockDeleteResponse, toclientlib.ReqInf, error) {
+	var data tc.CdnLockDeleteResponse
+	reqInf, err := to.del(fmt.Sprintf(apiCDNLocks), opts, &data)

Review comment:
       unnecessary `fmt.Sprintf`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.Cdn}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err.Error())
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())

Review comment:
       nit: don't need `.Error()` when formatting with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.Cdn}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err.Error())
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err.Error())
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("email@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err.Error())
+	}
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CdnLock{
+		Cdn:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.Cdn}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err.Error())
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err.Error())
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("email@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err.Error())
+	}
+	cdn := getCDNName(t)
+	// Create a lock for this user
+	_, _, err = userSession.CreateCdnLock(tc.CdnLock{
+		Cdn:     cdn,
+		Message: util.StrPtr("test lock"),
+		Soft:    util.BoolPtr(true),
+	}, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+
+	// Non admin user trying to hit an admin privileged endpoint -> this should fail
+	_, reqInf, err := userSession.AdminDeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected error when a non admin user tries to hit an admin privileged endpoint, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusForbidden {
+		t.Fatalf("expected a 403 status code, but got %d instead", reqInf.StatusCode)
+	}
+
+	// Try to delete another user's lock by hitting the normal DELETE endpoint for cdn_locks -> this should fail
+	_, reqInf, err = TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err == nil {
+		t.Fatalf("expected an error while deleting other user's lock, but got nothing")
+	}
+	if reqInf.StatusCode != http.StatusNotFound {
+		t.Fatalf("expected a 404 status code, but got %d instead", reqInf.StatusCode)
+	}
+
+	// Now try to delete another user's lock by hitting the admin DELETE endpoint for cdn_locks -> this should pass
+	_, reqInf, err = TOSession.AdminDeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdn}}})
+	if err != nil {
+		t.Fatalf("expected no error while deleting other user's lock using admin endpoint, but got %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,57 @@
+package tc
+
+/*
+ * 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"
+
+	validation "github.com/go-ozzo/ozzo-validation"
+
+	"github.com/apache/trafficcontrol/lib/go-tc/tovalidate"
+	"github.com/apache/trafficcontrol/lib/go-util"
+)
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`

Review comment:
       nit but `Cdn` should be `CDN` since "CDN" is an initialism

##########
File path: lib/go-tc/cdn_lock.go
##########
@@ -0,0 +1,47 @@
+package tc
+
+/*
+ * 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"
+
+// CdnLock is a struct to store the details of a lock that a user wishes to acquire on a CDN.
+type CdnLock struct {
+	UserName    string    `json:"userName" db:"username"`
+	Cdn         string    `json:"cdn" db:"cdn"`
+	Message     *string   `json:"message" db:"message"`
+	Soft        *bool     `json:"soft" db:"soft"`
+	LastUpdated TimeNoMod `json:"lastUpdated" db:"last_updated"`
+}
+
+type CdnLockCreateResponse struct {
+	Response CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLocksGetResponse struct {
+	Response []CdnLock `json:"response"`
+	Alerts
+}
+
+type CdnLockDeleteResponse CdnLockCreateResponse
+
+func (c CdnLock) Validate(tx *sql.Tx) error {
+	return nil
+}

Review comment:
       On further inspection, it looks like this isn't used, so you could probably just get rid of it.

##########
File path: traffic_ops/testing/api/v4/cdn_locks_test.go
##########
@@ -0,0 +1,178 @@
+package v4
+
+/*
+
+   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.
+*/
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/lib/go-util"
+	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
+)
+
+func TestCDNLocks(t *testing.T) {
+	WithObjs(t, []TCObj{Tenants, Roles, Users, CDNs}, func() {
+		CRDCdnLocks(t)
+		AdminCdnLocks(t)
+	})
+}
+
+func getCDNName(t *testing.T) string {
+	cdnResp, _, err := TOSession.GetCDNs(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't get CDNs: %v", err.Error())
+	}
+	if len(cdnResp.Response) < 1 {
+		t.Fatalf("no valid CDNs in response")
+	}
+	return cdnResp.Response[0].Name
+}
+
+func CRDCdnLocks(t *testing.T) {
+	cdn := getCDNName(t)
+	// CREATE
+	var cdnLock tc.CdnLock
+	cdnLock.Cdn = cdn
+	cdnLock.UserName = TOSession.UserName
+	cdnLock.Message = util.StrPtr("snapping cdn")
+	cdnLock.Soft = util.BoolPtr(true)
+	cdnLockResp, _, err := TOSession.CreateCdnLock(cdnLock, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("couldn't create cdn lock: %v", err.Error())
+	}
+	if cdnLockResp.Response.UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLockResp.Response.UserName)
+	}
+	if cdnLockResp.Response.Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLockResp.Response.Cdn)
+	}
+	if cdnLockResp.Response.Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLockResp.Response.Message != nil && *cdnLockResp.Response.Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLockResp.Response.Message)
+	}
+	if cdnLockResp.Response.Soft != nil && *cdnLockResp.Response.Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLockResp.Response.Soft)
+	}
+
+	// READ
+	cdnLocksReadResp, _, err := TOSession.GetCdnLocks(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not get CDN Locks: %v", err.Error())
+	}
+	if len(cdnLocksReadResp.Response) != 1 {
+		t.Fatalf("expected to get back one CDN lock, but got %d instead", len(cdnLocksReadResp.Response))
+	}
+	if cdnLocksReadResp.Response[0].UserName != cdnLock.UserName {
+		t.Errorf("expected username %v, got %v", cdnLock.UserName, cdnLocksReadResp.Response[0].UserName)
+	}
+	if cdnLocksReadResp.Response[0].Cdn != cdnLock.Cdn {
+		t.Errorf("expected cdn %v, got %v", cdnLock.Cdn, cdnLocksReadResp.Response[0].Cdn)
+	}
+	if cdnLocksReadResp.Response[0].Message == nil {
+		t.Errorf("expected a valid message, but got nothing")
+	}
+	if cdnLocksReadResp.Response[0].Message != nil && *cdnLocksReadResp.Response[0].Message != *cdnLock.Message {
+		t.Errorf("expected Message %v, got %v", *cdnLock.Message, *cdnLocksReadResp.Response[0].Message)
+	}
+	if cdnLocksReadResp.Response[0].Soft != nil && *cdnLocksReadResp.Response[0].Soft != *cdnLock.Soft {
+		t.Errorf("expected 'Soft' to be %v, got %v", *cdnLock.Soft, *cdnLocksReadResp.Response[0].Soft)
+	}
+
+	// DELETE
+	_, reqInf, err := TOSession.DeleteCdnLocks(client.RequestOptions{QueryParameters: url.Values{"cdn": []string{cdnLock.Cdn}}})
+	if err != nil {
+		t.Fatalf("couldn't delete cdn lock, err: %v", err.Error())
+	}
+	if reqInf.StatusCode != http.StatusOK {
+		t.Errorf("expected status code of 200, but got %d instead", reqInf.StatusCode)
+	}
+
+}
+
+func AdminCdnLocks(t *testing.T) {
+	resp, _, err := TOSession.GetTenants(client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not GET tenants: %v", err.Error())
+	}
+	if len(resp.Response) == 0 {
+		t.Fatalf("didn't get any tenant in response")
+	}
+
+	// Create a new user with operations level privileges
+	user1 := tc.User{
+		Username:             util.StrPtr("lock_user1"),
+		RegistrationSent:     tc.TimeNoModFromTime(time.Now()),
+		LocalPassword:        util.StrPtr("test_pa$$word"),
+		ConfirmLocalPassword: util.StrPtr("test_pa$$word"),
+		RoleName:             util.StrPtr("operations"),
+	}
+	user1.Email = util.StrPtr("email@domain.com")
+	user1.TenantID = util.IntPtr(resp.Response[0].ID)
+	user1.FullName = util.StrPtr("firstName LastName")
+	_, _, err = TOSession.CreateUser(user1, client.RequestOptions{})
+	if err != nil {
+		t.Fatalf("could not create test user with username: %s", *user1.Username)
+	}
+	defer ForceDeleteTestUsersByUsernames(t, []string{"lock_user1"})
+
+	// Establish a session with the newly created non admin level user
+	userSession, _, err := client.LoginWithAgent(Config.TrafficOps.URL, *user1.Username, *user1.LocalPassword, true, "to-api-v4-client-tests", false, toReqTimeout)
+	if err != nil {
+		t.Fatalf("could not login with user lock_user1: %v", err.Error())

Review comment:
       same as above RE: `.Error()` with `%v`




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org