Skip to content

feat: pluggable storage backends #6694

Description

@pseudomorph

Community Note

  • Please vote on this issue by adding a 👍 reaction to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
  • Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
  • If you are interested in working on this issue or have submitted a pull request, please leave a comment.

Pluggable storage backends

Context

Atlantis stores several kinds of data, and each kind currently hardcodes its own backend selection:

  • Coordination data (project locks, the global apply/command lock, pull status) goes through the db.Database interface. --locking-db-type picks BoltDB or Redis for all of it at once, and the Redis connection is configured through dedicated --redis-* flags.
  • Plan artifacts go through the planstore.PlanStore interface. --enable-external-stores plus the external_stores.plan_store block in the server-side repo config picks local filesystem or S3.
  • Job output lives in process memory with no backend at all.

Two problems fall out of this. Connection configuration is tied to a single use case: the Redis flags exist only to serve locking, so pointing a second function (say, plans) at the same Redis requires new flags. And each new function invents its own selection mechanism: locking uses a flag, plans use a yaml block plus an enable flag, and a future jobs store would add a third pattern.

Decision

Split the two concepts and give each a home:

  • A backend declares how to reach a storage service: connection parameters, credentials, endpoint. Backends are configured once per type (boltdb, filesystem, redis, s3; memory is a future candidate) and know nothing about what data they hold. boltdb and filesystem live under the server's --data-dir and work without configuration. One instance per type keeps the config flat; if separate clusters of one type are ever needed, a named-instance form can be added without breaking this schema.
  • A store names a function that persists data: an interface scoped to one use case, plus one driver per backend type it supports. Each store binds to a backend type in config through its own schema, since stores don't share the same options; both take only backend today. On s3, each store appends its own key segment (plans/) under the backend's prefix, so stores sharing the backend cannot collide. Stores are coordination and plans today; jobs, logs, and remote work executors are future candidates.

Naming

coordination covers everything behind today's db.Database: project locks, the global apply/command lock, and pull status. These share transactional key-value semantics and always move between backends together. Pull status is state rather than a lock, so it may split into its own store later; nothing in this design blocks that.

plans covers plan artifacts — the .tfplan files a store must survive a server restart with. It replaces the external_stores.plan_store block from #6312, which is deleted rather than deprecated because it never reached a release (latest tag v0.46.0 predates it).

Backend types always use the concrete technology name — boltdb, filesystem, redis, s3, later mysql or memory — so the yaml states what actually holds the data; an earlier draft had a local umbrella type whose meaning varied per store, dropped for exactly that ambiguity.

Configuration

Backends and bindings live in the server-side repo config:

backends:
  redis:
    host: redis.internal
    port: 6379
    tls_enabled: true
  s3:
    bucket: atlantis-data
    region: us-east-1
    prefix: some/team

stores:
  coordination:
    backend: redis
  plans:
    backend: s3
store local redis s3
coordination yes (BoltDB file) yes no
plans yes (workdir files, no restore) planned yes
jobs yes (in memory) planned no

Package layout

server/core/backends/          # connection layer, function-agnostic
    backends.go                # Kind, Backend handles, lazy Registry
    boltdb.go                  # bolt file open
    redis.go                   # Redis dial (TLS, cluster selection, ping)
    s3.go                      # S3 client construction
    resolve.go                 # legacy-flag synthesis (removed with the flags)
server/core/coordination/      # store: locks, apply lock, pull status
    coordination.go            # LockStore/PullStatusStore/CommandLockStore + Store + NewStore(backend)
    locking.go                 # Locker client (was core/locking)
    apply_locking.go           # ApplyLocker client (was core/locking)
    pull_status_updater.go     # pull-status write policy (was events.DBUpdater)
    boltdb/                    # driver (was core/boltdb)
    redis/                     # driver (was core/redis)
server/core/planstore/         # store: plan artifacts
    plan_store.go              # PlanStore interface
    factory.go                 # New(backend) factory
    s3_plan_store.go           # s3 driver

This change absorbs the old server/core/db (interface, now coordination.Store), server/core/boltdb, server/core/redis, and server/core/locking packages into server/core/coordination. The locking package merged in rather than staying a consumer because its entire content (Locker client, NoOpLocker, ApplyLocker) wraps the coordination store; the lock-key parsing the drivers needed from it moved to models.ParseLockKey, which also removes the driver→service import.

The convention for a new store is a package under server/core/<store>/ owning its interface, its service API, a New*(backend) factory that switches on backend kind, and one driver file or subpackage per supported kind. The convention for a new backend type is a Kind constant, a config struct in raw/valid, and a client constructor in backends — no store code changes.

Constructed backends are typed handles behind the small backends.Backend interface — BoltDBBackend{DB}, FilesystemBackend{}, RedisBackend{Client}, S3Backend{Client, Bucket, Prefix} — mirroring the per-type config structs, so a handle cannot carry another type's fields. Store factories type-switch on the concrete handle and take the client, not the config: opening the bolt file, dialing redis (TLS, cluster selection, ping), and building the S3 client happen once in the registry, and store drivers only add their data layout on top (bucket creation and key migration for boltdb, key schema for redis). The registry constructs backends lazily on first Get, so a configured-but-unbound backend is never dialed.

Related consolidation

Landing with this change: coordination.Store splits into LockStore, PullStatusStore, and CommandLockStore so consumers depend on the slice they touch; events.DBUpdater becomes coordination.PullStatusUpdater (pull-identity freshness helpers move to models, result-classification types DirNotExistErr/ErrStaleCommandHead to command); and NoOpLocker is deleted — per-project repo_locking: false and --disable-repo-locking early-return inside DefaultProjectLocker.TryLock instead of swapping in a fake locker. That last change means the locks UI and unlock paths under --disable-repo-locking now query the real store: pre-existing locks become visible and deletable rather than silently ignored, and an unreachable redis surfaces as an error where the no-op never could fail.

Legacy flags

--locking-db-type and --redis-* predate this design in released versions, so backends.Resolve keeps them working: --locking-db-type=redis synthesizes a backends.redis block from the flags (a real backends.redis block wins over flag values) and binds coordination to it, while an explicit stores.coordination binding overrides the flag entirely. An unknown --locking-db-type value now errors at startup; previously it silently left the database nil. The flags are deprecated from this change on.

Follow-ups

Remove --locking-db-type, the --redis-* flags, and backends.Resolve after a deprecation window; drop the runtime plan-store aliases; migrate jobs/logs onto stores as needed.

Follow-up considerations before this grows past the first two stores, roughly in the order they'd start to hurt:

  1. Concurrent startup migrations. Both coordination drivers run key migrations inside their constructors and assume a single writer; two replicas booting at once (the HA motivation for this work) can race the migration. A per-store migration lock or version marker should exist before more stores copy the pattern.
  2. Secrets in checked-in config. backends.redis.password sits in the server-side repo config yaml. Env-var interpolation in the backends block should land before a mysql backend brings real credentials.
  3. Driver conformance tests. Each store interface should have one behavioral suite run against every driver (boltdb and redis passing the same LockStore/PullStatusStore tests). A memory backend gives the suite a cheap third target and doubles as the future jobs-store default.
  4. Registry-owned lifecycle. The registry constructs backends but the server closes the coordination store directly; moving Close ordering and per-backend health checks into the registry generalizes Ping into a readiness endpoint.
  5. Driver-count growth. Drivers are hand-written per store×backend pair — fine at today's size; if both sets grow, shared capability interfaces (KV, object) implemented once per backend cap the matrix.
  6. Store-op observability. One instrumentation wrapper applied in the store factories (op latency and error count tagged by store and backend) beats per-driver metrics plumbing later.
  7. Keyspace ownership within a shared backend. Plan objects live directly under the s3 prefix and DeleteForPull deletes the pull subtree without filename filtering, so a second s3 store must take its own key segment or plans must filter to *.tfplan.

Consequences

Adding a Redis plan store or a MySQL coordination store becomes one driver plus one factory case; the connection config already exists. Users pointing several stores at one service declare the connection once. The config surface moves from flags to the server-side repo config, which fits its deployment model (checked-in, reviewed) but means secrets like redis.password land in yaml — the existing --redis-password flag remains usable via synthesis until env-var interpolation for backend config is added. Coordination data still moves between backends as one unit; splitting pull status out remains open.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew functionality/enhancement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions