You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2022/07/22 18:25:40 UTC

[trafficcontrol] branch master updated: Add t3c metadata file (#6905)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 1c206fe5cb Add t3c metadata file (#6905)
1c206fe5cb is described below

commit 1c206fe5cb4a81ab1e9d7e71ec49a8782f60def3
Author: Robert O Butts <ro...@users.noreply.github.com>
AuthorDate: Fri Jul 22 12:25:34 2022 -0600

    Add t3c metadata file (#6905)
---
 cache-config/t3c-apply/t3c-apply.go                | 105 ++++++++++--
 cache-config/t3c-apply/torequest/torequest.go      |  15 +-
 cache-config/t3cutil/t3cutil.go                    | 182 +++++++++++++++++++++
 cache-config/t3cutil/t3cutil_test.go               |  72 ++++++++
 .../ort-tests/t3c-create-metadata-file_test.go     |  61 +++++++
 5 files changed, 416 insertions(+), 19 deletions(-)

diff --git a/cache-config/t3c-apply/t3c-apply.go b/cache-config/t3c-apply/t3c-apply.go
index ca06e0bf43..afdfc12146 100644
--- a/cache-config/t3c-apply/t3c-apply.go
+++ b/cache-config/t3c-apply/t3c-apply.go
@@ -20,8 +20,12 @@ package main
  */
 
 import (
+	"encoding/json"
+	"errors"
 	"fmt"
+	"io/ioutil"
 	"os"
+	"path/filepath"
 	"time"
 
 	"github.com/apache/trafficcontrol/cache-config/t3c-apply/config"
@@ -84,6 +88,7 @@ func main() {
 // DO NOT call os.Exit within this function; return the code instead.
 // Returns the application exit code.
 func Main() int {
+
 	var syncdsUpdate torequest.UpdateStatus
 	var lock util.FileLock
 	cfg, err := config.GetCfg(Version, GitRevision)
@@ -106,6 +111,20 @@ func Main() int {
 	}
 	log.Infoln("Acquired app lock")
 
+	// Note failing to load old metadata is not fatal!
+	// oldMetaData must always be checked for nil before usage!
+	oldMetaData, err := LoadMetaData(cfg)
+	if err != nil {
+		log.Errorln("Failed to load old metadata file, metadata-dependent functionality disabled: " + err.Error())
+	}
+
+	// Note we only write the metadata file after acquiring the app lock.
+	// We don't want to write a metadata file if we didn't run because another t3c-apply
+	// was already running.
+	metaData := t3cutil.NewApplyMetaData()
+
+	metaData.ServerHostName = cfg.CacheHostName
+
 	if cfg.UseGit == config.UseGitYes {
 		err := util.EnsureConfigDirIsGitRepo(cfg)
 		if err != nil {
@@ -158,18 +177,19 @@ func Main() int {
 
 		if err != nil {
 			log.Errorln("Checking revalidate state: " + err.Error())
-			return GitCommitAndExit(ExitCodeRevalidationError, FailureExitMsg, cfg)
+			return GitCommitAndExit(ExitCodeRevalidationError, FailureExitMsg, cfg, metaData, oldMetaData)
 		}
 		if syncdsUpdate == torequest.UpdateTropsNotNeeded {
 			log.Infoln("Checking revalidate state: returned UpdateTropsNotNeeded")
-			return GitCommitAndExit(ExitCodeRevalidationError, SuccessExitMsg, cfg)
+			metaData.Succeeded = true
+			return GitCommitAndExit(ExitCodeRevalidationError, SuccessExitMsg, cfg, metaData, oldMetaData)
 		}
 
 	} else {
-		syncdsUpdate, err = trops.CheckSyncDSState()
+		syncdsUpdate, err = trops.CheckSyncDSState(metaData)
 		if err != nil {
 			log.Errorln("Checking syncds state: " + err.Error())
-			return GitCommitAndExit(ExitCodeSyncDSError, FailureExitMsg, cfg)
+			return GitCommitAndExit(ExitCodeSyncDSError, FailureExitMsg, cfg, metaData, oldMetaData)
 		}
 		if !cfg.IgnoreUpdateFlag && cfg.Files == t3cutil.ApplyFilesFlagAll && syncdsUpdate == torequest.UpdateTropsNotNeeded {
 			// If touching remap.config fails, we want to still try to restart services
@@ -189,14 +209,16 @@ func Main() int {
 				}
 				if err := trops.StartServices(&syncdsUpdate); err != nil {
 					log.Errorln("failed to start services: " + err.Error())
-					return GitCommitAndExit(ExitCodeServicesError, PostConfigFailureExitMsg, cfg)
+					metaData.PartialSuccess = true
+					return GitCommitAndExit(ExitCodeServicesError, PostConfigFailureExitMsg, cfg, metaData, oldMetaData)
 				}
 			}
 			finalMsg := SuccessExitMsg
 			if postConfigFail {
 				finalMsg = PostConfigFailureExitMsg
 			}
-			return GitCommitAndExit(ExitCodeSuccess, finalMsg, cfg)
+			metaData.Succeeded = true
+			return GitCommitAndExit(ExitCodeSuccess, finalMsg, cfg, metaData, oldMetaData)
 		}
 	}
 
@@ -208,14 +230,14 @@ func Main() int {
 		err = trops.ProcessPackages()
 		if err != nil {
 			log.Errorf("Error processing packages: %s\n", err)
-			return GitCommitAndExit(ExitCodePackagingError, FailureExitMsg, cfg)
+			return GitCommitAndExit(ExitCodePackagingError, FailureExitMsg, cfg, metaData, oldMetaData)
 		}
 
 		// check and make sure packages are enabled for startup
 		err = trops.CheckSystemServices()
 		if err != nil {
 			log.Errorf("Error verifying system services: %s\n", err.Error())
-			return GitCommitAndExit(ExitCodeServicesError, FailureExitMsg, cfg)
+			return GitCommitAndExit(ExitCodeServicesError, FailureExitMsg, cfg, metaData, oldMetaData)
 		}
 	}
 
@@ -223,9 +245,9 @@ func Main() int {
 	err = trops.GetConfigFileList()
 	if err != nil {
 		log.Errorf("Getting config file list: %s\n", err)
-		return GitCommitAndExit(ExitCodeConfigFilesError, FailureExitMsg, cfg)
+		return GitCommitAndExit(ExitCodeConfigFilesError, FailureExitMsg, cfg, metaData, oldMetaData)
 	}
-	syncdsUpdate, err = trops.ProcessConfigFiles()
+	syncdsUpdate, err = trops.ProcessConfigFiles(metaData)
 	if err != nil {
 		log.Errorf("Error while processing config files: %s\n", err.Error())
 	}
@@ -248,7 +270,8 @@ func Main() int {
 
 	if err := trops.StartServices(&syncdsUpdate); err != nil {
 		log.Errorln("failed to start services: " + err.Error())
-		return GitCommitAndExit(ExitCodeServicesError, PostConfigFailureExitMsg, cfg)
+		metaData.PartialSuccess = true
+		return GitCommitAndExit(ExitCodeServicesError, PostConfigFailureExitMsg, cfg, metaData, oldMetaData)
 	}
 
 	// start 'teakd' if installed.
@@ -279,7 +302,8 @@ func Main() int {
 		log.Errorf("failed to update Traffic Ops: %s\n", err.Error())
 	}
 
-	return GitCommitAndExit(ExitCodeSuccess, SuccessExitMsg, cfg)
+	metaData.Succeeded = true
+	return GitCommitAndExit(ExitCodeSuccess, SuccessExitMsg, cfg, metaData, oldMetaData)
 }
 
 func LogPanic(f func() int) (exitCode int) {
@@ -297,7 +321,16 @@ func LogPanic(f func() int) (exitCode int) {
 // GitCommitAndExit attempts to git commit all changes, and logs any error.
 // It then logs exitMsg at the Info level, and returns exitCode.
 // This is a helper function, to reduce the duplicated commit-log-return into a single line.
-func GitCommitAndExit(exitCode int, exitMsg string, cfg config.Cfg) int {
+func GitCommitAndExit(exitCode int, exitMsg string, cfg config.Cfg, metaData *t3cutil.ApplyMetaData, oldMetaData *t3cutil.ApplyMetaData) int {
+
+	// metadata isn't actually part of git, but we always want to write it before committing to git, so this is the right place
+
+	// files previously dropped never become "unmanaged",
+	// and if we delete them they're removed from oldMetaData as well as the new,
+	// so add the old files to the new metadata.
+	// This is especially important for reval runs, which don't add all files.
+	metaData.OwnedFilePaths = t3cutil.CombineOwnedFilePaths(metaData, oldMetaData)
+	WriteMetaData(cfg, metaData)
 	success := exitCode == ExitCodeSuccess
 	if cfg.UseGit == config.UseGitYes || cfg.UseGit == config.UseGitAuto {
 		if err := util.MakeGitCommitAll(cfg, util.GitChangeIsSelf, success); err != nil {
@@ -330,3 +363,49 @@ func CheckMaxmindUpdate(cfg config.Cfg) bool {
 
 	return result
 }
+
+const MetaDataFileName = `t3c-apply-metadata.json`
+const MetaDataFileMode = 0600
+
+// WriteMetaData writes the metaData file.
+//
+// The metadata file is written in the ATS config directory, so it's versioned
+// with git.
+//
+// On error, an error is written to the log, but no error is returned.
+func WriteMetaData(cfg config.Cfg, metaData *t3cutil.ApplyMetaData) {
+	metaData.SetTime(time.Now())
+	bts, err := metaData.Format()
+	if err != nil {
+		log.Errorln("formatting metadata file: " + err.Error())
+		return
+	}
+
+	metaDataFilePath := GetMetaDataFilePath(cfg)
+
+	if err := ioutil.WriteFile(metaDataFilePath, bts, MetaDataFileMode); err != nil {
+		log.Errorln("writing metadata file '" + metaDataFilePath + "': " + err.Error())
+		return
+	}
+}
+
+func LoadMetaData(cfg config.Cfg) (*t3cutil.ApplyMetaData, error) {
+	metaDataFilePath := GetMetaDataFilePath(cfg)
+
+	bts, err := ioutil.ReadFile(metaDataFilePath)
+	if err != nil {
+		return nil, errors.New("reading metadata file '" + metaDataFilePath + "': " + err.Error())
+	}
+
+	metaData := &t3cutil.ApplyMetaData{}
+
+	if err := json.Unmarshal(bts, &metaData); err != nil {
+		return nil, errors.New("unmarshalling metadata file: " + err.Error())
+	}
+
+	return metaData, nil
+}
+
+func GetMetaDataFilePath(cfg config.Cfg) string {
+	return filepath.Join(cfg.TsConfigDir, MetaDataFileName)
+}
diff --git a/cache-config/t3c-apply/torequest/torequest.go b/cache-config/t3c-apply/torequest/torequest.go
index e61168209e..e0eef3f267 100644
--- a/cache-config/t3c-apply/torequest/torequest.go
+++ b/cache-config/t3c-apply/torequest/torequest.go
@@ -725,8 +725,9 @@ func (r *TrafficOpsReq) CheckRevalidateState(sleepOverride bool) (UpdateStatus,
 	return updateStatus, nil
 }
 
-// CheckSYncDSState retrieves and returns the DS Update status from Traffic Ops.
-func (r *TrafficOpsReq) CheckSyncDSState() (UpdateStatus, error) {
+// CheckSyncDSState retrieves and returns the DS Update status from Traffic Ops.
+// The metaData is this run's metadata. It must not be nil, and this function may add to it.
+func (r *TrafficOpsReq) CheckSyncDSState(metaData *t3cutil.ApplyMetaData) (UpdateStatus, error) {
 	updateStatus := UpdateTropsNotNeeded
 	randDispSec := time.Duration(0)
 	log.Debugln("Checking syncds state.")
@@ -763,7 +764,7 @@ func (r *TrafficOpsReq) CheckSyncDSState() (UpdateStatus, error) {
 			}
 		} else if !r.Cfg.IgnoreUpdateFlag {
 			log.Errorln("no queued update needs to be applied.  Running revalidation before exiting.")
-			r.RevalidateWhileSleeping()
+			r.RevalidateWhileSleeping(metaData)
 			return UpdateTropsNotNeeded, nil
 		} else {
 			log.Errorln("Traffic Ops is signaling that no update is waiting to be applied.")
@@ -793,7 +794,7 @@ func (r *TrafficOpsReq) CheckReloadRestart(data []FileRestartData) RestartData {
 }
 
 // ProcessConfigFiles processes all config files retrieved from Traffic Ops.
-func (r *TrafficOpsReq) ProcessConfigFiles() (UpdateStatus, error) {
+func (r *TrafficOpsReq) ProcessConfigFiles(metaData *t3cutil.ApplyMetaData) (UpdateStatus, error) {
 	var updateStatus UpdateStatus = UpdateTropsNotNeeded
 
 	log.Infoln(" ======== Start processing config files ========")
@@ -832,6 +833,8 @@ func (r *TrafficOpsReq) ProcessConfigFiles() (UpdateStatus, error) {
 	shouldRestartReload := ShouldReloadRestart{[]FileRestartData{}}
 
 	for _, cfg := range r.configFiles {
+		metaData.OwnedFilePaths = append(metaData.OwnedFilePaths, cfg.Path) // all config files are added to OwnedFiles, even if they aren't changed on disk.
+
 		if cfg.ChangeNeeded &&
 			!cfg.ChangeApplied &&
 			cfg.AuditComplete &&
@@ -1016,7 +1019,7 @@ func (r *TrafficOpsReq) ProcessPackages() error {
 	return nil
 }
 
-func (r *TrafficOpsReq) RevalidateWhileSleeping() (UpdateStatus, error) {
+func (r *TrafficOpsReq) RevalidateWhileSleeping(metaData *t3cutil.ApplyMetaData) (UpdateStatus, error) {
 	updateStatus, err := r.CheckRevalidateState(true)
 	if err != nil {
 		return updateStatus, err
@@ -1032,7 +1035,7 @@ func (r *TrafficOpsReq) RevalidateWhileSleeping() (UpdateStatus, error) {
 			return updateStatus, err
 		}
 
-		updateStatus, err := r.ProcessConfigFiles()
+		updateStatus, err := r.ProcessConfigFiles(metaData)
 		if err != nil {
 			return updateStatus, err
 		}
diff --git a/cache-config/t3cutil/t3cutil.go b/cache-config/t3cutil/t3cutil.go
index 782548301f..57532d6e05 100644
--- a/cache-config/t3cutil/t3cutil.go
+++ b/cache-config/t3cutil/t3cutil.go
@@ -21,6 +21,7 @@ package t3cutil
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"html"
@@ -29,8 +30,10 @@ import (
 	"os"
 	"os/exec"
 	"regexp"
+	"sort"
 	"strings"
 	"syscall"
+	"time"
 )
 
 type ATSConfigFile struct {
@@ -228,3 +231,182 @@ func UserAgentStr(appName string, versionNum string, gitRevision string) string
 	}
 	return appName + "/" + versionNum + ".." + gitRevision
 }
+
+// NewApplyMetaData creates a new, empty ApplyMetaData object.
+func NewApplyMetaData() *ApplyMetaData {
+	return &ApplyMetaData{
+		Version:           MetaDataVersion,
+		InstalledPackages: []string{}, // construct a slice, so JSON serializes '[]' not 'null'.
+		OwnedFilePaths:    []string{}, // construct a slice, so JSON serializes '[]' not 'null'.
+	}
+}
+
+// MetaDataVersion is the version of the metadata file.
+// This should update the major version with breaking changes,
+// and t3c versions should strive to maintain compatibility
+// at least one major version back, so features like tracking
+// t3c-owned files continue to work through upgrades.
+const MetaDataVersion = "1.0"
+
+// ApplyMetaData is metadata about a t3c-apply run.
+// Always use NewApplyMetaData, don't use a literal to construct a new object.
+type ApplyMetaData struct {
+	// Version is the metadata version of this metadata object or file. See MetaDataVersion.
+	Version string `json:"version"`
+
+	// ServerFQDN is the FQDN of this server.
+	// The primary purpose of this field is to allow distinguishing
+	// metadata files from different servers.
+	ServerHostName string `json:"server-hostname"`
+
+	// Time is an RFC3339Nano timestamp of the time t3c-apply ran for this metadata.
+	// This should be treated as approximate, as it could be the start time, end time, or
+	// any inexact time in-between.
+	// However, times of different metadata files should always be monotonically increasing.
+	Time string `json:"time"`
+
+	// ReloadedATS is whether this run restarted ATS.
+	// Note this is whether ATS was actually restarted, not whether it would have been,
+	// e.g. because of --report-only or --service-action.
+	ReloadedATS bool `json:"reloaded-ats"`
+
+	// RestartedATS is whether this run restarted ATS.
+	// Note this is whether ATS was actually restarted, not whether it would have been,
+	// e.g. because of --report-only or --service-action.
+	RestartedATS bool `json:"restarted-ats"`
+
+	// UnsetUpdateFlag is whether this t3c-apply run unset the update flag for this server.
+	// Note this is whether the flag was actually unset, not whether it would have been e.g.
+	// because of --no-unset-update-flag or --report-only.
+	UnsetUpdateFlag bool `json:"unset-update-flag"`
+
+	// UnsetRevalFlag is whether this t3c-apply run unset the revalidate flag for this server.
+	// Note this is whether the flag was actually unset, not whether it would have been e.g.
+	// because of --no-unset-reval-flag or --report-only.
+	UnsetRevalFlag bool `json:"unset-reval-flag"`
+
+	// InstalledPackages is which yum packages were installed.
+	// Note this packages actually installed, not what would have been e.g.
+	// because of --install-packages=false or --report-only.
+	InstalledPackages []string `json:"installed-packages"`
+
+	// OwnedFilePaths is the list of files t3c-apply produced in this run.
+	//
+	// This can be used to know which files in the ATS config directory were produced by t3c,
+	// and which were produced by some other means.
+	//
+	// Note this is all files produced, not necessarily all files written to disk. This
+	// will include files generated, but not changed on disk because they had no
+	// semantic diff from the existing file.
+	//
+	// This may be used in the future for t3c-apply to delete files produced by a previous
+	// run which no longer exist (for example, Header Rewrites from a Delivery Service
+	// no longer assigned to this server).
+	//
+	// Files are the full path and file name.
+	OwnedFilePaths []string `json:"owned-files"`
+
+	// Succeeded is whether this t3c-apply run generally succeeded.
+	//
+	// Note not all scenarios are black or white success-or-fail.
+	// For example, files may be successfully created, but reloading ATS may fail.
+	// In these scenarios, t3c-apply will attempt to set Succeeded to false,
+	// but also attempt to set other metadata about what was actually performed.
+	//
+	// But when partial failure occurrs, nothing is guaranteed in the metadata.
+	// Operators should consider the logs authoritative over the metadata.
+	Succeeded bool `json:"succeeded"`
+
+	// PartialSuccess indicates that some actions were successful, but
+	// later actions failed.
+	//
+	// This is a bad place to be, because it means some things were changed,
+	// but not everything that needed to be. This is often not fatal, because,
+	// for example, if config files were changed by ATS failed to reload,
+	// those config files typically needed placed anyway.
+	//
+	// But nevertheless, partial success is potentially catastrophic, and operators
+	// are strongly encouraged to set alarms and read logs in the event it occurs,
+	// to determine what was changed, what failed, and what actions need taken.
+	PartialSuccess bool `json:"partial-success"`
+}
+
+// Format prints the ApplyMetaData in a format designed to be written to a file,
+// and structured but pretty-printed to work well with line-based diffs (e.g. in git).
+func (md *ApplyMetaData) Format() ([]byte, error) {
+	bts, err := json.MarshalIndent(md, "", "  ")
+	if err != nil {
+		return nil, errors.New("marshalling metadata file: " + err.Error())
+	}
+	bts = append(bts, '\n') // newline at the end of the file, so it's a valid POSIX text file
+
+	return bts, nil
+}
+
+// SetTime sets the Time field in the prescribed format, based on the given time.
+// To set to the current time, call SetTime(time.Now()).
+// The format is UTC RFC3339Nano. See ApplyMetaData.
+func (md *ApplyMetaData) SetTime(tm time.Time) {
+	md.Time = tm.UTC().Format(time.RFC3339Nano)
+}
+
+// CombineOwnedFilePaths combines the owned file paths of two metadata objects.
+//
+// This is primarily useful when a config run, such as revalidate, adds owned files, but not
+// all owned files, but we don't want to write metadata incidating we don't own existing files,
+// so this can be used to combine the new files with the previous metadata.
+//
+// Both am and bm are may be nil, in which case the files from the non-nil object is returned,
+// or an empty array if both are nil.
+func CombineOwnedFilePaths(am *ApplyMetaData, bm *ApplyMetaData) []string {
+	if am == nil && bm == nil {
+		return []string{}
+	} else if am == nil {
+		sort.Strings(bm.OwnedFilePaths) // the func guarantees the returned array will always be sorted
+		return bm.OwnedFilePaths
+	} else if bm == nil {
+		sort.Strings(am.OwnedFilePaths) // the func guarantees the returned array will always be sorted
+		return am.OwnedFilePaths
+	}
+	return sortAndCombineStrs(am.OwnedFilePaths, bm.OwnedFilePaths)
+}
+
+// sortAndCombineStrs sorts as and bs, and then returns an array containing
+// the unique strings in each, without duplicates.
+func sortAndCombineStrs(as []string, bs []string) []string {
+	sort.Strings(as)
+	sort.Strings(bs)
+	combined := []string{}
+	ai := 0
+	bi := 0
+	for ai < len(as) && bi < len(bs) {
+		if as[ai] == bs[bi] {
+			combined = append(combined, as[ai])
+			ai++
+			bi++
+			continue
+		}
+		// at this point we know they don't match
+		// so add the lesser, increment it, and loop (but don't add or increment the greater)
+		if as[ai] < bs[bi] {
+			combined = append(combined, as[ai])
+			ai++
+			continue
+		}
+		combined = append(combined, bs[bi])
+		bi++
+	}
+
+	// at this point, we added everything up to the end of one of the arrays,
+	// but potentially not the other. So add the remaining strings in the other
+
+	for ai < len(as) {
+		combined = append(combined, as[ai])
+		ai++
+	}
+	for bi < len(bs) {
+		combined = append(combined, bs[bi])
+		bi++
+	}
+	return combined
+}
diff --git a/cache-config/t3cutil/t3cutil_test.go b/cache-config/t3cutil/t3cutil_test.go
new file mode 100644
index 0000000000..e4096b4aff
--- /dev/null
+++ b/cache-config/t3cutil/t3cutil_test.go
@@ -0,0 +1,72 @@
+package t3cutil
+
+/*
+ * 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 (
+	"reflect"
+	"testing"
+)
+
+func TestSortAndCombineStrs(t *testing.T) {
+	type Expected struct {
+		InputA   []string
+		InputB   []string
+		Expected []string
+	}
+	expecteds := []Expected{
+		{
+			InputA:   []string{"foo", "bar", "baz"},
+			InputB:   []string{"alpha", "bar", "beta"},
+			Expected: []string{"alpha", "bar", "baz", "beta", "foo"},
+		},
+		{
+			InputA:   []string{"remap.config", "regex_revalidate.config", "parent.config"},
+			InputB:   []string{"regex_revalidate.config"},
+			Expected: []string{"parent.config", "regex_revalidate.config", "remap.config"},
+		},
+		{
+			InputA:   []string{"remap.config", "parent.config"},
+			InputB:   []string{"regex_revalidate.config"},
+			Expected: []string{"parent.config", "regex_revalidate.config", "remap.config"},
+		},
+		{
+			InputA:   []string{},
+			InputB:   []string{"remap.config", "regex_revalidate.config", "parent.config"},
+			Expected: []string{"parent.config", "regex_revalidate.config", "remap.config"},
+		},
+		{
+			InputA:   []string{"remap.config", "regex_revalidate.config", "parent.config"},
+			InputB:   []string{},
+			Expected: []string{"parent.config", "regex_revalidate.config", "remap.config"},
+		},
+		{
+			InputA:   []string{},
+			InputB:   []string{},
+			Expected: []string{},
+		},
+	}
+
+	for _, ex := range expecteds {
+		actual := sortAndCombineStrs(ex.InputA, ex.InputB)
+		if !reflect.DeepEqual(actual, ex.Expected) {
+			t.Errorf("sortAndCombineStrs(%+v,%+v) expected %+v actual %+v", ex.InputA, ex.InputB, ex.Expected, actual)
+		}
+	}
+}
diff --git a/cache-config/testing/ort-tests/t3c-create-metadata-file_test.go b/cache-config/testing/ort-tests/t3c-create-metadata-file_test.go
new file mode 100644
index 0000000000..745fd89650
--- /dev/null
+++ b/cache-config/testing/ort-tests/t3c-create-metadata-file_test.go
@@ -0,0 +1,61 @@
+package orttest
+
+/*
+   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 (
+	"encoding/json"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/apache/trafficcontrol/cache-config/testing/ort-tests/tcdata"
+	"github.com/apache/trafficcontrol/cache-config/testing/ort-tests/util"
+)
+
+func TestT3cCreateMetaDataFile(t *testing.T) {
+	// t3c should create a metadata file
+	tcd.WithObjs(t, []tcdata.TCObj{
+		tcdata.CDNs, tcdata.Types, tcdata.Tenants, tcdata.Parameters,
+		tcdata.Profiles, tcdata.ProfileParameters,
+		tcdata.Divisions, tcdata.Regions, tcdata.PhysLocations,
+		tcdata.CacheGroups, tcdata.Servers, tcdata.Topologies,
+		tcdata.DeliveryServices}, func() {
+
+		err := t3cUpdateCreateEmptyFile(DefaultCacheHostName, "badass")
+		if err != nil {
+			t.Fatalf("t3c badass failed: %v", err)
+		}
+
+		const metaDataFileName = `t3c-apply-metadata.json`
+
+		filePath := filepath.Join(TestConfigDir, metaDataFileName)
+
+		if !util.FileExists(filePath) {
+			t.Fatalf("missing metadata file '%s'", filePath)
+		}
+
+		mdFileBts, err := ioutil.ReadFile(filePath)
+		if err != nil {
+			t.Fatalf("reading file '%s': %v", filePath, err)
+		}
+
+		// Test that the file is a valid JSON object.
+		// Other than that, we don't want to assert any particular data.
+		mdObj := map[string]interface{}{}
+		if err := json.Unmarshal(mdFileBts, &mdObj); err != nil {
+			t.Errorf("expected metadata file '%s' to be a json object, actual: %s", filePath, err)
+		}
+	})
+}