You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by rr...@apache.org on 2022/07/08 16:18:37 UTC

[trafficserver] branch 9.2.x updated: File change monitoring on s3_auth (#8905)

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

rrm pushed a commit to branch 9.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/9.2.x by this push:
     new e93063e26 File change monitoring on s3_auth (#8905)
e93063e26 is described below

commit e93063e26172eb9f7ed92ff9627b0a807479ba59
Author: Mo Chen <un...@gmail.com>
AuthorDate: Fri Jul 8 11:18:32 2022 -0500

    File change monitoring on s3_auth (#8905)
---
 configure.ac                                       |   8 +-
 doc/admin-guide/plugins/s3_auth.en.rst             |   8 +-
 .../testing/blackbox-testing.en.rst                |   1 +
 include/ts/apidefs.h.in                            |  12 ++
 include/ts/ts.h                                    |  17 ++
 include/tscore/ink_config.h.in                     |   1 +
 iocore/Makefile.am                                 |   2 +-
 iocore/fs/FileChange.cc                            | 227 +++++++++++++++++++++
 iocore/fs/FileChange.h                             |  82 ++++++++
 iocore/{ => fs}/Makefile.am                        |  19 +-
 plugins/s3_auth/s3_auth.cc                         | 193 +++++++++++++++++-
 src/traffic_layout/info.cc                         |   1 +
 src/traffic_server/InkAPI.cc                       |  21 ++
 src/traffic_server/Makefile.inc                    |   1 +
 src/traffic_server/traffic_server.cc               |   4 +
 tests/README.md                                    |   1 +
 .../pluginTest/s3_auth/gold/s3_auth_basic.gold     |  34 +++
 .../pluginTest/s3_auth/gold/traffic_server.gold    |   2 +
 .../pluginTest/s3_auth/rules/region_map.conf       |  17 ++
 .../pluginTest/s3_auth/rules/v4-modified.conf      |  20 ++
 tests/gold_tests/pluginTest/s3_auth/rules/v4.conf  |  20 ++
 .../s3_auth/s3_auth_watch_config.test.py           |  87 ++++++++
 22 files changed, 768 insertions(+), 10 deletions(-)

diff --git a/configure.ac b/configure.ac
index 64192700d..67d57c45a 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1808,6 +1808,10 @@ AC_LANG_POP([C++])
 # Right now, the healthcheck plugins requires inotify_init (and friends)
 AM_CONDITIONAL([BUILD_HEALTHCHECK_PLUGIN], [ test "$ac_cv_func_inotify_init" = "yes" ])
 
+use_inotify=0
+AS_IF([test "x$ac_cv_func_inotify_init" = "xyes"], [use_inotify=1])
+AC_SUBST(use_inotify)
+
 #
 # Check for tcmalloc, jemalloc and mimalloc
 TS_CHECK_TCMALLOC
@@ -2252,7 +2256,8 @@ iocore_include_dirs="\
 -I\$(abs_top_srcdir)/iocore/hostdb \
 -I\$(abs_top_srcdir)/iocore/cache \
 -I\$(abs_top_srcdir)/iocore/utils \
--I\$(abs_top_srcdir)/iocore/dns"
+-I\$(abs_top_srcdir)/iocore/dns \
+-I\$(abs_top_srcdir)/iocore/fs"
 
 AC_SUBST([AM_CPPFLAGS])
 AC_SUBST([AM_CFLAGS])
@@ -2300,6 +2305,7 @@ AC_CONFIG_FILES([
   iocore/cache/Makefile
   iocore/dns/Makefile
   iocore/eventsystem/Makefile
+  iocore/fs/Makefile
   iocore/hostdb/Makefile
   iocore/net/Makefile
   iocore/net/quic/Makefile
diff --git a/doc/admin-guide/plugins/s3_auth.en.rst b/doc/admin-guide/plugins/s3_auth.en.rst
index 87f1a9209..23da86ed6 100644
--- a/doc/admin-guide/plugins/s3_auth.en.rst
+++ b/doc/admin-guide/plugins/s3_auth.en.rst
@@ -44,7 +44,7 @@ Alternatively, you can store the access key and secret in an external configurat
 
    # remap.config
 
-   ...  @plugin=s3_auth.so @pparam=--config @pparam=s3_auth_v2.config
+   ...  @plugin=s3_auth.so @pparam=--config @pparam=s3_auth_v2.config @pparam=--watch-config @pparam=--ttl=5
 
 
 Where ``s3.config`` could look like::
@@ -94,6 +94,8 @@ The ``s3_auth_v4.config`` config file could look like this::
     v4-include-headers=<comma-separated-list-of-headers-to-be-signed>
     v4-exclude-headers=<comma-separated-list-of-headers-not-to-be-signed>
     v4-region-map=region_map.config
+    watch-config
+    ttl=20
 
 Where the ``region_map.config`` defines the entry-point hostname to region mapping i.e.::
 
@@ -123,6 +125,10 @@ If ``--v4-include-headers`` is not specified all headers except those specified
 
 If ``--v4-include-headers`` is specified only the headers specified will be signed except those specified in ``--v4-exclude-headers``
 
+If ``--watch-config`` is specified, the plugin will reload the config file set in ``--config`` when it changes
+
+If ``--ttl`` is specified, the plugin will cache configs for the specified number of seconds.  During the ttl period, manual config reloads and ``--watch-config`` will not cause the config to be updated.  The default is 60 seconds.  Setting ttl to zero causes all reloads to read from the config file.  This option is useful if the config file is fetched from a service, and you wish to limit the fetch rate.
+
 
 AWS Authentication version 2
 ============================
diff --git a/doc/developer-guide/testing/blackbox-testing.en.rst b/doc/developer-guide/testing/blackbox-testing.en.rst
index 25af57aec..5ca6f3f57 100644
--- a/doc/developer-guide/testing/blackbox-testing.en.rst
+++ b/doc/developer-guide/testing/blackbox-testing.en.rst
@@ -262,6 +262,7 @@ Condition Testing
         - TS_HAS_128BIT_CAS
         - TS_HAS_TESTS
         - TS_HAS_WCCP
+        - TS_USE_INOTIFY
 
 
 Examples:
diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in
index ac550a95f..efacb7435 100644
--- a/include/ts/apidefs.h.in
+++ b/include/ts/apidefs.h.in
@@ -554,6 +554,11 @@ typedef enum {
   TS_EVENT_SSL_CLIENT_HELLO  = 60207,
   TS_EVENT_SSL_SECRET        = 60208,
 
+  TS_EVENT_FILE_CREATED = 60300,
+  TS_EVENT_FILE_UPDATED = 60301,
+  TS_EVENT_FILE_DELETED = 60302,
+  TS_EVENT_FILE_IGNORED = 60303,
+
   TS_EVENT_MGMT_UPDATE = 60300
 } TSEvent;
 #define TS_EVENT_HTTP_READ_REQUEST_PRE_REMAP TS_EVENT_HTTP_PRE_REMAP /* backwards compat */
@@ -1454,6 +1459,13 @@ namespace ts
 }
 #endif
 
+typedef int TSWatchDescriptor;
+typedef enum { TS_WATCH_CREATE, TS_WATCH_DELETE, TS_WATCH_MODIFY } TSFileWatchKind;
+typedef struct {
+  TSWatchDescriptor wd;
+  const char *name;
+} TSFileWatchData;
+
 #ifdef __cplusplus
 }
 #endif /* __cplusplus */
diff --git a/include/ts/ts.h b/include/ts/ts.h
index 4d35972e7..12cb1a8a3 100644
--- a/include/ts/ts.h
+++ b/include/ts/ts.h
@@ -2720,6 +2720,23 @@ tsapi void TSHostStatusSet(const char *hostname, const size_t hostname_len, TSHo
 tsapi bool TSHttpTxnCntlGet(TSHttpTxn txnp, TSHttpCntlType ctrl);
 tsapi TSReturnCode TSHttpTxnCntlSet(TSHttpTxn txnp, TSHttpCntlType ctrl, bool data);
 
+/*
+ * Get notified for file system events
+ *
+ * Currently, this only works in Linux using inotify.
+ *
+ * TODO: Fix multiple plugins watching the same path.
+ *
+ * The edata (a.k.a. cookie) field of the continuation handler will contain information
+ * depending on the type of file event.  edata is always a pointer to a TSFileWatchData.
+ * If the event is TS_EVENT_FILE_CREATED, name is a pointer to a null-terminated string
+ * containing the file name.  Otherwise, name is a nullptr.  wd is the watch descriptor
+ * for the event.
+ *
+ */
+tsapi TSWatchDescriptor TSFileEventRegister(const char *filename, TSFileWatchKind kind, TSCont contp);
+tsapi void TSFileEventUnRegister(TSWatchDescriptor wd);
+
 #ifdef __cplusplus
 }
 #endif /* __cplusplus */
diff --git a/include/tscore/ink_config.h.in b/include/tscore/ink_config.h.in
index 5b297efe7..b6e635f4f 100644
--- a/include/tscore/ink_config.h.in
+++ b/include/tscore/ink_config.h.in
@@ -83,6 +83,7 @@
 #define TS_HAS_TLS_EARLY_DATA @has_tls_early_data@
 #define TS_HAS_TLS_SESSION_TICKET @has_tls_session_ticket@
 #define TS_HAS_VERIFY_CERT_STORE @has_verify_cert_store@
+#define TS_USE_INOTIFY @use_inotify@
 
 #define TS_USE_HRW_GEOIP @use_hrw_geoip@
 #define TS_USE_HRW_MAXMINDDB @use_hrw_maxminddb@
diff --git a/iocore/Makefile.am b/iocore/Makefile.am
index 5aae15ea5..f870d193b 100644
--- a/iocore/Makefile.am
+++ b/iocore/Makefile.am
@@ -16,4 +16,4 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-SUBDIRS = eventsystem net aio dns hostdb utils cache
+SUBDIRS = eventsystem net aio dns hostdb utils cache fs
diff --git a/iocore/fs/FileChange.cc b/iocore/fs/FileChange.cc
new file mode 100644
index 000000000..864df401f
--- /dev/null
+++ b/iocore/fs/FileChange.cc
@@ -0,0 +1,227 @@
+/** @file FileChange.cc
+
+  Watch for file system changes.
+
+  @section license License
+
+  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.
+ */
+
+#include "FileChange.h"
+#include "tscore/Diags.h"
+#include "P_EventSystem.h"
+
+#include <cassert>
+#include <functional>
+#include <mutex>
+#include <optional>
+
+// Globals
+FileChangeManager fileChangeManager;
+static constexpr auto TAG ATS_UNUSED = "FileChange";
+
+// Wrap a continuation
+class FileChangeCallback : public Continuation
+{
+public:
+  explicit FileChangeCallback(Continuation *contp, TSEvent event) : Continuation(contp->mutex.get()), m_cont(contp), m_event(event)
+  {
+    SET_HANDLER(&FileChangeCallback::event_handler);
+  }
+
+  int
+  event_handler(int, void *eventp)
+  {
+    Event *e = reinterpret_cast<Event *>(eventp);
+    if (m_cont->mutex) {
+      MUTEX_TRY_LOCK(trylock, m_cont->mutex, this_ethread());
+      if (!trylock.is_locked()) {
+        eventProcessor.schedule_in(this, HRTIME_MSECONDS(10), ET_TASK);
+      } else {
+        m_cont->handleEvent(m_event, e->cookie);
+        delete this;
+      }
+    } else {
+      m_cont->handleEvent(m_event, e->cookie);
+      delete this;
+    }
+
+    return 0;
+  }
+
+  std::string filename; // File name if the event is a file creation event.  This is used in the cookie for a create event.
+  TSFileWatchData data;
+
+private:
+  Continuation *m_cont;
+  TSEvent m_event;
+};
+
+#if TS_USE_INOTIFY
+static constexpr size_t INOTIFY_BUF_SIZE = 4096;
+
+static void
+invoke(FileChangeCallback *cb)
+{
+  void *cookie = static_cast<void *>(&cb->data);
+  eventProcessor.schedule_imm(cb, ET_TASK, 1, cookie);
+}
+
+void
+FileChangeManager::process_file_event(struct inotify_event *event)
+{
+  std::shared_lock file_watches_read_lock(file_watches_mutex);
+  auto finfo_it = file_watches.find(event->wd);
+  if (finfo_it != file_watches.end()) {
+    TSEvent event_type            = TS_EVENT_NONE;
+    const struct file_info &finfo = finfo_it->second;
+    Continuation *contp           = finfo.contp;
+
+    if (event->mask & (IN_DELETE_SELF | IN_MOVED_FROM)) {
+      Debug(TAG, "Delete file event (%d) on %s", event->mask, finfo.path.c_str());
+      int rc2 = inotify_rm_watch(inotify_fd, event->wd);
+      if (rc2 == -1) {
+        Error("Failed to remove inotify watch on %s: %s (%d)", finfo.path.c_str(), strerror(errno), errno);
+      }
+      event_type             = TS_EVENT_FILE_DELETED;
+      FileChangeCallback *cb = new FileChangeCallback(contp, event_type);
+      cb->data.wd            = event->wd;
+      cb->data.name          = nullptr;
+      invoke(cb);
+    }
+
+    if (event->mask & (IN_CREATE | IN_MOVED_TO)) {
+      // Name may be padded with nul characters.  Trim them.
+      auto len = strnlen(event->name, event->len);
+      std::string name{event->name, len};
+      Debug(TAG, "Create file event (%d) on %s (wd = %d): %s", event->mask, finfo.path.c_str(), event->wd, name.c_str());
+      event_type = TS_EVENT_FILE_CREATED;
+
+      FileChangeCallback *cb = new FileChangeCallback(contp, event_type);
+      cb->filename           = name;
+      cb->data.wd            = event->wd;
+      cb->data.name          = cb->filename.c_str();
+      invoke(cb);
+    }
+
+    if (event->mask & (IN_CLOSE_WRITE | IN_ATTRIB)) {
+      Debug(TAG, "Modify file event (%d) on %s (wd = %d)", event->mask, finfo.path.c_str(), event->wd);
+      event_type             = TS_EVENT_FILE_UPDATED;
+      FileChangeCallback *cb = new FileChangeCallback(contp, event_type);
+      cb->data.wd            = event->wd;
+      cb->data.name          = nullptr;
+      invoke(cb);
+    }
+
+    if (event->mask & (IN_IGNORED)) {
+      Debug(TAG, "Ignored file event (%d) on %s (wd = %d)", event->mask, finfo.path.c_str(), event->wd);
+      event_type             = TS_EVENT_FILE_IGNORED;
+      FileChangeCallback *cb = new FileChangeCallback(contp, event_type);
+      cb->data.wd            = event->wd;
+      cb->data.name          = nullptr;
+      invoke(cb);
+    }
+  }
+}
+#endif
+
+void
+FileChangeManager::init()
+{
+#if TS_USE_INOTIFY
+  // TODO: auto configure based on whether inotify is available
+  inotify_fd = inotify_init1(IN_CLOEXEC);
+  if (inotify_fd == -1) {
+    Error("Failed to init inotify: %s (%d)", strerror(errno), errno);
+    return;
+  }
+  auto inotify_thread = [manager = this]() mutable {
+    for (;;) {
+      char inotify_buf[INOTIFY_BUF_SIZE];
+
+      // blocking read
+      ssize_t rc = read(manager->inotify_fd, inotify_buf, sizeof inotify_buf);
+
+      if (rc == -1) {
+        Error("Failed to read inotify: %s (%d)", strerror(errno), errno);
+        if (errno == EINTR) {
+          continue;
+        } else {
+          break;
+        }
+      }
+
+      ssize_t offset = 0;
+      while (offset < rc) {
+        struct inotify_event *event = reinterpret_cast<struct inotify_event *>(inotify_buf + offset);
+
+        // Process file events
+        manager->process_file_event(event);
+        offset += sizeof(struct inotify_event) + event->len;
+      }
+    }
+  };
+  poll_thread = std::thread(inotify_thread);
+  poll_thread.detach();
+#else
+  // Implement this
+#endif
+}
+
+watch_handle_t
+FileChangeManager::add(const ts::file::path &path, TSFileWatchKind kind, Continuation *contp)
+{
+#if TS_USE_INOTIFY
+  Debug(TAG, "Adding a watch on %s", path.c_str());
+  watch_handle_t wd = 0;
+
+  // Let the OS handle multiple watches on one file.
+  uint32_t mask = 0;
+  if (kind == TS_WATCH_CREATE) {
+    mask = IN_CREATE | IN_MOVED_TO | IN_ONLYDIR;
+  } else if (kind == TS_WATCH_DELETE) {
+    mask = IN_DELETE_SELF | IN_MOVED_FROM;
+  } else if (kind == TS_WATCH_MODIFY) {
+    mask = IN_CLOSE_WRITE | IN_ATTRIB;
+  }
+  wd = inotify_add_watch(inotify_fd, path.c_str(), mask);
+  if (wd == -1) {
+    Error("Failed to add file watch on %s: %s (%d)", path.c_str(), strerror(errno), errno);
+    return -1;
+  } else {
+    std::unique_lock file_watches_write_lock(file_watches_mutex);
+    file_watches[wd] = {path, contp};
+  }
+
+  Debug(TAG, "Watch handle = %d", wd);
+  return wd;
+#else
+  Warning("File change notification is not supported on this OS.");
+  return 0;
+#endif
+}
+
+void
+FileChangeManager::remove(watch_handle_t watch_handle)
+{
+#if TS_USE_INOTIFY
+  Debug(TAG, "Deleting watch %d", watch_handle);
+  inotify_rm_watch(inotify_fd, watch_handle);
+  std::unique_lock file_watches_write_lock(file_watches_mutex);
+  file_watches.erase(watch_handle);
+#endif
+}
diff --git a/iocore/fs/FileChange.h b/iocore/fs/FileChange.h
new file mode 100644
index 000000000..57aa17225
--- /dev/null
+++ b/iocore/fs/FileChange.h
@@ -0,0 +1,82 @@
+/** @file FileChange.h
+
+  Watch for file system changes.
+
+  @section license License
+
+  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.
+ */
+
+#pragma once
+#include "tscore/ink_config.h"
+
+#include <thread>
+#include <chrono>
+#include <set>
+#include <unordered_map>
+#include <vector>
+#include <shared_mutex>
+#include "tscore/ts_file.h"
+#include "P_EventSystem.h"
+
+#if TS_USE_INOTIFY
+#include <sys/inotify.h>
+#else
+// implement this
+#endif
+
+using watch_handle_t = int;
+
+// File watch info
+struct file_info {
+  ts::file::path path;
+  Continuation *contp;
+};
+
+class FileChangeManager
+{
+public:
+  FileChangeManager() {}
+
+  void init();
+
+  /**
+    Add a file watch
+
+    @return a watch handle, or -1 on error
+  */
+  watch_handle_t add(const ts::file::path &path, TSFileWatchKind kind, Continuation *contp);
+
+  /**
+    Remove a file watch
+  */
+  void remove(watch_handle_t watch_handle);
+
+private:
+  std::thread poll_thread;
+
+#if TS_USE_INOTIFY
+  void process_file_event(struct inotify_event *event);
+  std::shared_mutex file_watches_mutex;
+  std::unordered_map<watch_handle_t, struct file_info> file_watches;
+  int inotify_fd;
+#else
+  // implement this
+#endif
+};
+
+extern FileChangeManager fileChangeManager;
diff --git a/iocore/Makefile.am b/iocore/fs/Makefile.am
similarity index 69%
copy from iocore/Makefile.am
copy to iocore/fs/Makefile.am
index 5aae15ea5..a851273ea 100644
--- a/iocore/Makefile.am
+++ b/iocore/fs/Makefile.am
@@ -1,4 +1,4 @@
-# Makefile.am for traffic/iocore
+# Makefile.am for the traffic/iocore/fs hierarchy
 #
 #  Licensed to the Apache Software Foundation (ASF) under one
 #  or more contributor license agreements.  See the NOTICE file
@@ -16,4 +16,19 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
-SUBDIRS = eventsystem net aio dns hostdb utils cache
+AM_CPPFLAGS += \
+	$(iocore_include_dirs) \
+	-I$(abs_top_srcdir)/include \
+	-I$(abs_top_srcdir)/lib \
+	$(TS_INCLUDES)
+
+noinst_LIBRARIES = libinkfs.a
+
+libinkfs_a_SOURCES = \
+	FileChange.cc \
+	FileChange.h
+
+include $(top_srcdir)/build/tidy.mk
+
+clang-tidy-local: $(DIST_SOURCES)
+	$(CXX_Clang_Tidy)
diff --git a/plugins/s3_auth/s3_auth.cc b/plugins/s3_auth/s3_auth.cc
index df5c09077..50498822a 100644
--- a/plugins/s3_auth/s3_auth.cc
+++ b/plugins/s3_auth/s3_auth.cc
@@ -42,11 +42,13 @@
 #include <thread>
 #include <mutex>
 #include <shared_mutex>
+#include <optional>
 
 #include <ts/ts.h>
 #include <ts/remap.h>
 #include <tscpp/util/TsSharedMutex.h>
 #include "tscore/ink_config.h"
+#include "tscore/ts_file.h"
 
 #include "aws_auth_v4.h"
 
@@ -161,21 +163,22 @@ private:
     // never indicate config is fresh when it isn't.
     std::atomic<S3Config *> config;
     std::atomic<time_t> load_time;
+    std::atomic<int> ttl = 60;
 
     _ConfigData() {}
 
-    _ConfigData(S3Config *config_, time_t load_time_) : config(config_), load_time(load_time_) {}
+    _ConfigData(S3Config *config_, time_t load_time_, int ttl_) : config(config_), load_time(load_time_), ttl(ttl_) {}
 
     _ConfigData(_ConfigData &&lhs)
     {
       update_status = lhs.update_status.load();
       config        = lhs.config.load();
       load_time     = lhs.load_time.load();
+      ttl           = lhs.ttl.load();
     }
   };
 
   std::unordered_map<std::string, _ConfigData> _cache;
-  static const int _ttl = 60;
 };
 
 ConfigCache gConfCache;
@@ -185,6 +188,8 @@ ConfigCache gConfCache;
 //
 int event_handler(TSCont, TSEvent, void *); // Forward declaration
 int config_reloader(TSCont, TSEvent, void *);
+int config_dir_watch(TSCont, TSEvent, void *);
+int config_watch(TSCont, TSEvent, void *);
 
 class S3Config
 {
@@ -197,6 +202,12 @@ public:
 
       _conf_rld = TSContCreate(config_reloader, TSMutexCreate());
       TSContDataSet(_conf_rld, static_cast<void *>(this));
+
+      _dir_watch = TSContCreate(config_dir_watch, TSMutexCreate());
+      TSContDataSet(_dir_watch, static_cast<void *>(this));
+
+      _conf_watch = TSContCreate(config_watch, TSMutexCreate());
+      TSContDataSet(_conf_watch, static_cast<void *>(this));
     }
   }
 
@@ -302,6 +313,10 @@ public:
       TSfree(_conf_fname);
       _conf_fname = TSstrdup(src->_conf_fname);
     }
+
+    if (src->_watch_config) {
+      _watch_config = src->_watch_config;
+    }
   }
 
   // Getters
@@ -383,6 +398,18 @@ public:
     return _conf_fname;
   }
 
+  bool
+  watch_config() const
+  {
+    return _watch_config;
+  }
+
+  int
+  ttl() const
+  {
+    return _ttl;
+  }
+
   int
   incr_conf_reload_count()
   {
@@ -463,6 +490,18 @@ public:
     _conf_fname = TSstrdup(s);
   }
 
+  void
+  set_watch_config()
+  {
+    _watch_config = true;
+  }
+
+  void
+  set_ttl(const char *s)
+  {
+    _ttl = strtol(s, nullptr, 10);
+  }
+
   void
   reset_conf_reload_count()
   {
@@ -489,7 +528,70 @@ public:
     _conf_rld_act = TSContScheduleOnPool(_conf_rld, delay * 1000, TS_THREAD_POOL_NET);
   }
 
+  void
+  start_watch_config()
+  {
+    std::unique_lock lock(wd_mutex);
+    ts::file::path fname{makeConfigPath(_conf_fname)};
+    if (!_config_file_wd) {
+      _config_file_wd = TSFileEventRegister(fname.c_str(), TS_WATCH_MODIFY, _conf_watch);
+      if (_config_file_wd == -1) {
+        _config_file_wd.reset();
+        TSDebug(PLUGIN_NAME, "Waiting for config file to be created: %s", fname.c_str());
+      } else {
+        TSDebug(PLUGIN_NAME, "Watching config file: %s (%d)", fname.c_str(), _config_file_wd.value());
+      }
+    }
+
+    if (!_config_dir_wd) {
+      auto parent_dir = fname.parent_path();
+      _config_dir_wd  = TSFileEventRegister(parent_dir.c_str(), TS_WATCH_CREATE, _dir_watch);
+      if (_config_dir_wd == -1) {
+        _config_dir_wd.reset();
+        TSError("s3_auth: failed to watch config file directory: %s", parent_dir.c_str());
+      } else {
+        TSDebug(PLUGIN_NAME, "Watching config file directory: %s (%d)", parent_dir.c_str(), _config_file_wd.value());
+      }
+    }
+  }
+
+  void
+  stop_watch_config()
+  {
+    std::unique_lock lock(wd_mutex);
+    if (_config_file_wd) {
+      TSFileEventUnRegister(_config_file_wd.value());
+      _config_file_wd.reset();
+    }
+
+    if (_config_dir_wd) {
+      TSFileEventUnRegister(_config_dir_wd.value());
+      _config_dir_wd.reset();
+    }
+  }
+
+  void
+  config_file_watch_ignored(TSWatchDescriptor wd)
+  {
+    std::unique_lock lock(wd_mutex);
+    if (_config_file_wd == wd) {
+      TSFileEventUnRegister(_config_file_wd.value());
+      _config_file_wd.reset();
+    }
+  }
+
+  void
+  config_dir_watch_ignored(TSWatchDescriptor wd)
+  {
+    std::unique_lock lock(wd_mutex);
+    if (_config_dir_wd == wd) {
+      TSFileEventUnRegister(_config_dir_wd.value());
+      _config_dir_wd.reset();
+    }
+  }
+
   ts::shared_mutex reload_mutex;
+  ts::shared_mutex wd_mutex;
 
 private:
   char *_secret            = nullptr;
@@ -504,6 +606,8 @@ private:
   bool _virt_host_modified = false;
   TSCont _cont             = nullptr;
   TSCont _conf_rld         = nullptr;
+  TSCont _conf_watch       = nullptr;
+  TSCont _dir_watch        = nullptr;
   TSAction _conf_rld_act   = nullptr;
   StringSet _v4includeHeaders;
   bool _v4includeHeaders_modified = false;
@@ -514,6 +618,10 @@ private:
   long _expiration          = 0;
   char *_conf_fname         = nullptr;
   int _conf_reload_count    = 0;
+  bool _watch_config        = false;
+  std::optional<TSWatchDescriptor> _config_file_wd;
+  std::optional<TSWatchDescriptor> _config_dir_wd;
+  int _ttl = 60;
 };
 
 bool
@@ -577,6 +685,10 @@ S3Config::parse_config(const std::string &config_fname)
         set_region_map(val_str.c_str());
       } else if (key_str == "expiration") {
         set_expiration(val_str.c_str());
+      } else if (key_str == "ttl") {
+        set_ttl(val_str.c_str());
+      } else if (key_str == "watch-config") {
+        set_watch_config();
       } else {
         // ToDo: warnings?
       }
@@ -611,7 +723,7 @@ ConfigCache::get(const char *fname)
 
   if (it != _cache.end()) {
     unsigned update_status = it->second.update_status;
-    if (tv.tv_sec > (it->second.load_time + _ttl)) {
+    if (tv.tv_sec > (it->second.load_time + it->second.ttl)) {
       if (!(update_status & 1) && it->second.update_status.compare_exchange_strong(update_status, update_status + 1)) {
         TSDebug(PLUGIN_NAME, "Configuration from %s is stale, reloading", config_fname.c_str());
         s3 = new S3Config(false); // false == this config does not get the continuation
@@ -624,10 +736,14 @@ ConfigCache::get(const char *fname)
           s3 = nullptr;
           TSAssert(!"Configuration parsing / caching failed");
         }
+        TSDebug(PLUGIN_NAME, "Updated rule: access_key=%s, virtual_host=%s, version=%d", s3->keyid(),
+                s3->virt_host() ? "yes" : "no", s3->version());
 
         delete it->second.config;
         it->second.config    = s3;
         it->second.load_time = tv.tv_sec;
+        it->second.ttl       = s3->ttl();
+        TSDebug(PLUGIN_NAME, "Config ttl updated to %d seconds", it->second.ttl.load());
 
         // Update is complete.
         ++it->second.update_status;
@@ -649,10 +765,12 @@ ConfigCache::get(const char *fname)
     // Create a new cached file.
     s3 = new S3Config(false); // false == this config does not get the continuation
 
-    TSDebug(PLUGIN_NAME, "Parsing and caching configuration from %s, version:%d", config_fname.c_str(), s3->version());
+    TSDebug(PLUGIN_NAME, "Parsing and caching configuration from %s", config_fname.c_str());
     if (s3->parse_config(config_fname)) {
       s3->set_conf_fname(fname);
-      _cache.emplace(config_fname, _ConfigData(s3, tv.tv_sec));
+      _cache.emplace(config_fname, _ConfigData(s3, tv.tv_sec, s3->ttl()));
+      _cache[config_fname].ttl = s3->ttl();
+      TSDebug(PLUGIN_NAME, "Config ttl set to %d seconds", _cache[config_fname].ttl.load());
     } else {
       delete s3;
       s3 = nullptr;
@@ -1047,6 +1165,51 @@ cal_reload_delay(long time_diff)
   }
 }
 
+int
+config_dir_watch(TSCont cont, TSEvent event, void *edata)
+{
+  TSDebug(PLUGIN_NAME, "config directory watch handler");
+  S3Config *s3 = static_cast<S3Config *>(TSContDataGet(cont));
+  TSAssert(edata != nullptr);
+  TSFileWatchData *fwd = reinterpret_cast<TSFileWatchData *>(edata);
+
+  if (event == TS_EVENT_FILE_IGNORED) {
+    TSDebug(PLUGIN_NAME, "Config directory lost.  No longer watching for config changes.");
+    // Lost the config file's directory.  We currently can't deal with this.  Just stop watching for file changes.
+    s3->config_dir_watch_ignored(fwd->wd);
+    return TS_SUCCESS;
+  }
+
+  TSAssert(event == TS_EVENT_FILE_CREATED);
+  TSDebug(PLUGIN_NAME, "File created: %s", fwd->name);
+  if (strncmp(s3->conf_fname(), fwd->name, strlen(s3->conf_fname())) == 0) {
+    TSDebug(PLUGIN_NAME, "config file created");
+    config_reloader(cont, event, edata);
+  }
+
+  return TS_SUCCESS;
+}
+
+int
+config_watch(TSCont cont, TSEvent event, void *edata)
+{
+  TSDebug(PLUGIN_NAME, "config watch handler");
+  TSAssert(edata != nullptr);
+  TSFileWatchData *fwd = reinterpret_cast<TSFileWatchData *>(edata);
+
+  S3Config *s3 = static_cast<S3Config *>(TSContDataGet(cont));
+  if (event == TS_EVENT_FILE_IGNORED) {
+    TSDebug(PLUGIN_NAME, "Config file watch lost.");
+    // Probably deleted.  Directory watch will see if it's re-created.
+    s3->config_file_watch_ignored(fwd->wd);
+    return TS_SUCCESS;
+  }
+
+  config_reloader(cont, event, edata);
+
+  return TS_SUCCESS;
+}
+
 int
 config_reloader(TSCont cont, TSEvent event, void *edata)
 {
@@ -1084,6 +1247,12 @@ config_reloader(TSCont cont, TSEvent event, void *edata)
     }
   }
 
+  if (s3->watch_config()) {
+    s3->start_watch_config();
+  } else {
+    s3->stop_watch_config();
+  }
+
   return TS_SUCCESS;
 }
 
@@ -1124,6 +1293,8 @@ TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSE
     {const_cast<char *>("v4-exclude-headers"), required_argument, nullptr, 'e'},
     {const_cast<char *>("v4-region-map"), required_argument, nullptr, 'm'},
     {const_cast<char *>("session_token"), required_argument, nullptr, 't'},
+    {const_cast<char *>("watch-config"), no_argument, nullptr, 'w'},
+    {const_cast<char *>("ttl"), no_argument, nullptr, 'T'},
     {nullptr, no_argument, nullptr, '\0'},
   };
 
@@ -1171,6 +1342,12 @@ TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSE
     case 'm':
       s3->set_region_map(optarg);
       break;
+    case 'w':
+      s3->set_watch_config();
+      break;
+    case 'T':
+      s3->set_ttl(optarg);
+      break;
     }
 
     if (opt == -1) {
@@ -1207,6 +1384,12 @@ TSRemapNewInstance(int argc, char *argv[], void **ih, char * /* errbuf ATS_UNUSE
     }
   }
 
+  if (s3->watch_config()) {
+    s3->start_watch_config();
+  } else {
+    s3->stop_watch_config();
+  }
+
   *ih = static_cast<void *>(s3);
   TSDebug(PLUGIN_NAME, "New rule: access_key=%s, virtual_host=%s, version=%d", s3->keyid(), s3->virt_host() ? "yes" : "no",
           s3->version());
diff --git a/src/traffic_layout/info.cc b/src/traffic_layout/info.cc
index a54a1b10f..7b911793d 100644
--- a/src/traffic_layout/info.cc
+++ b/src/traffic_layout/info.cc
@@ -108,6 +108,7 @@ produce_features(bool json)
   print_feature("TS_USE_EPOLL", TS_USE_EPOLL, json);
   print_feature("TS_USE_KQUEUE", TS_USE_KQUEUE, json);
   print_feature("TS_USE_PORT", TS_USE_PORT, json);
+  print_feature("TS_USE_INOTIFY", TS_USE_INOTIFY, json);
   print_feature("TS_USE_POSIX_CAP", TS_USE_POSIX_CAP, json);
   print_feature("TS_USE_TPROXY", TS_USE_TPROXY, json);
   print_feature("TS_HAS_SO_MARK", TS_HAS_SO_MARK, json);
diff --git a/src/traffic_server/InkAPI.cc b/src/traffic_server/InkAPI.cc
index 1f46cdfdb..89fb09173 100644
--- a/src/traffic_server/InkAPI.cc
+++ b/src/traffic_server/InkAPI.cc
@@ -28,6 +28,10 @@
 #include <unordered_map>
 #include <string_view>
 
+#if defined(__linux__)
+#include <sys/inotify.h>
+#endif
+
 #include "tscore/ink_platform.h"
 #include "tscore/ink_base64.h"
 #include "tscore/PluginUserArgs.h"
@@ -75,6 +79,7 @@
 #include "I_Machine.h"
 #include "HttpProxyServerMain.h"
 #include "shared/overridable_txn_vars.h"
+#include "FileChange.h"
 
 #include "ts/ts.h"
 
@@ -10429,3 +10434,19 @@ TSHttpTxnPostBufferReaderGet(TSHttpTxn txnp)
   HttpSM *sm = (HttpSM *)txnp;
   return (TSIOBufferReader)sm->get_postbuf_clone_reader();
 }
+
+tsapi TSWatchDescriptor
+TSFileEventRegister(const char *filename, TSFileWatchKind kind, TSCont contp)
+{
+  sdk_assert(sdk_sanity_check_iocore_structure(contp) == TS_SUCCESS);
+  sdk_assert(sdk_sanity_check_null_ptr((void *)this_ethread()) == TS_SUCCESS);
+
+  Continuation *pCont = reinterpret_cast<Continuation *>(contp);
+  return fileChangeManager.add(ts::file::path{filename}, kind, pCont);
+}
+
+tsapi void
+TSFileEventUnRegister(TSWatchDescriptor wd)
+{
+  fileChangeManager.remove(wd);
+}
diff --git a/src/traffic_server/Makefile.inc b/src/traffic_server/Makefile.inc
index b908fea37..c9f002ef3 100644
--- a/src/traffic_server/Makefile.inc
+++ b/src/traffic_server/Makefile.inc
@@ -82,6 +82,7 @@ traffic_server_traffic_server_LDADD = \
 	$(top_builddir)/iocore/net/libinknet.a \
 	$(top_builddir)/lib/records/librecords_p.a \
 	$(top_builddir)/iocore/eventsystem/libinkevent.a \
+	$(top_builddir)/iocore/fs/libinkfs.a \
 	@HWLOC_LIBS@ \
 	@LIBPCRE@ \
 	@LIBRESOLV@ \
diff --git a/src/traffic_server/traffic_server.cc b/src/traffic_server/traffic_server.cc
index 84c95aa7d..d09b76981 100644
--- a/src/traffic_server/traffic_server.cc
+++ b/src/traffic_server/traffic_server.cc
@@ -104,6 +104,7 @@ extern "C" int plock(int);
 #include "tscore/ink_config.h"
 #include "P_SSLSNI.h"
 #include "P_SSLClientUtils.h"
+#include "FileChange.h"
 
 #if TS_USE_QUIC == 1
 #include "Http3.h"
@@ -2035,6 +2036,9 @@ main(int /* argc ATS_UNUSED */, const char **argv)
     proxyServerCheck.notify_one();
   }
 
+  // Spawn a thread to do file system change notification
+  fileChangeManager.init();
+
   // !! ET_NET threads start here !!
   // This means any spawn scheduling must be done before this point.
   eventProcessor.start(num_of_net_threads, stacksize);
diff --git a/tests/README.md b/tests/README.md
index e28a6b907..a97ba0221 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -321,6 +321,7 @@ ts.Disk.remap_config.AddLine(
  * TS_HAS_128BIT_CAS
  * TS_HAS_TESTS
  * TS_HAS_WCCP
+ * TS_USE_INOTIFY
 
 ### Example
 ```python
diff --git a/tests/gold_tests/pluginTest/s3_auth/gold/s3_auth_basic.gold b/tests/gold_tests/pluginTest/s3_auth/gold/s3_auth_basic.gold
new file mode 100644
index 000000000..a789fd013
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/gold/s3_auth_basic.gold
@@ -0,0 +1,34 @@
+*   Trying 127.0.0.1:``...
+* Connected to 127.0.0.1 (127.0.0.1) port `` (#0)
+> GET / HTTP/1.1
+> Host: www.example.com
+> User-Agent: curl/``
+> Accept: */*
+> 
+* Mark bundle as not supporting multiuse
+< HTTP/1.1 200 OK
+< Date: ``
+< Age: 0
+< Transfer-Encoding: chunked
+< Connection: keep-alive
+< Server: ATS/9.2.0
+< 
+{ [5 bytes data]
+* Connection #0 to host 127.0.0.1 left intact
+*   Trying 127.0.0.1:``...
+* Connected to 127.0.0.1 (127.0.0.1) port `` (#0)
+> GET / HTTP/1.1
+> Host: www.example.com
+> User-Agent: curl/``
+> Accept: */*
+> 
+* Mark bundle as not supporting multiuse
+< HTTP/1.1 200 OK
+< Date: ``
+< Age: 0
+< Transfer-Encoding: chunked
+< Connection: keep-alive
+< Server: ATS/9.2.0
+< 
+{ [5 bytes data]
+* Connection #0 to host 127.0.0.1 left intact
diff --git a/tests/gold_tests/pluginTest/s3_auth/gold/traffic_server.gold b/tests/gold_tests/pluginTest/s3_auth/gold/traffic_server.gold
new file mode 100644
index 000000000..4c41e8d8c
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/gold/traffic_server.gold
@@ -0,0 +1,2 @@
+`` DIAG: (s3_auth) Updated rule: access_key=1111111, virtual_host=no, version=4
+``
diff --git a/tests/gold_tests/pluginTest/s3_auth/rules/region_map.conf b/tests/gold_tests/pluginTest/s3_auth/rules/region_map.conf
new file mode 100644
index 000000000..1f663336a
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/rules/region_map.conf
@@ -0,0 +1,17 @@
+#
+# 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.
+127.0.0.1 : us-east-1
diff --git a/tests/gold_tests/pluginTest/s3_auth/rules/v4-modified.conf b/tests/gold_tests/pluginTest/s3_auth/rules/v4-modified.conf
new file mode 100644
index 000000000..165d0b963
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/rules/v4-modified.conf
@@ -0,0 +1,20 @@
+#
+# 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.
+access_key=1111111
+secret_key=5555555
+version=4
+ttl=0
diff --git a/tests/gold_tests/pluginTest/s3_auth/rules/v4.conf b/tests/gold_tests/pluginTest/s3_auth/rules/v4.conf
new file mode 100644
index 000000000..822b53461
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/rules/v4.conf
@@ -0,0 +1,20 @@
+#
+# 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.
+access_key=1234567
+secret_key=9999999
+version=4
+ttl=0
diff --git a/tests/gold_tests/pluginTest/s3_auth/s3_auth_watch_config.test.py b/tests/gold_tests/pluginTest/s3_auth/s3_auth_watch_config.test.py
new file mode 100644
index 000000000..1a5654340
--- /dev/null
+++ b/tests/gold_tests/pluginTest/s3_auth/s3_auth_watch_config.test.py
@@ -0,0 +1,87 @@
+'''
+Test s3_auth config change watch function
+'''
+#  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.
+
+Test.ContinueOnFail = True
+
+Test.SkipUnless(
+    Condition.HasATSFeature('TS_USE_INOTIFY'),
+)
+
+ts = Test.MakeATSProcess("ts")
+server = Test.MakeOriginServer("server")
+
+Test.testName = "s3_auth: watch config"
+
+# define the request header and the desired response header
+request_header = {
+    "headers": "GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n",
+    "timestamp": "1469733493.993",
+    "body": ""
+}
+
+# desired response form the origin server
+response_header = {
+    "headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n",
+    "timestamp": "1469733493.993",
+    "body": ""
+}
+
+# add request/response
+server.addResponse("sessionlog.log", request_header, response_header)
+
+ts.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'FileChange|s3_auth',
+})
+
+ts.Setup.CopyAs('rules/v4.conf', Test.RunDirectory)
+ts.Setup.CopyAs('rules/v4-modified.conf', Test.RunDirectory)
+ts.Setup.CopyAs('rules/region_map.conf', Test.RunDirectory)
+
+ts.Disk.remap_config.AddLine(
+    'map http://www.example.com http://127.0.0.1:{0} \
+        @plugin=s3_auth.so \
+            @pparam=--config @pparam={1}/v4.conf \
+            @pparam=--v4-region-map @pparam={1}/region_map.conf \
+            @pparam=--watch-config \
+            '
+    .format(server.Variables.Port, Test.RunDirectory)
+)
+
+# Commands to get the following response headers
+# 1. make a request
+# 2. modify the config
+# 3. make another request
+curlRequest = (
+    'curl -s -v -H "Host: www.example.com" http://127.0.0.1:{0};'
+    'sleep 1; cp {1}/v4-modified.conf {1}/v4.conf;'
+    'sleep 1; curl -s -v -H "Host: www.example.com" http://127.0.0.1:{0};'
+)
+
+# Test Case
+tr = Test.AddTestRun()
+tr.Processes.Default.Command = curlRequest.format(ts.Variables.port, Test.RunDirectory)
+tr.Processes.Default.ReturnCode = 0
+tr.Processes.Default.StartBefore(server)
+tr.Processes.Default.StartBefore(ts)
+tr.Processes.Default.Streams.stderr = "gold/s3_auth_basic.gold"
+tr.StillRunningAfter = server
+
+ts.Streams.stderr = "gold/traffic_server.gold"
+ts.ReturnCode = 0