Skip to content

marketplace container#530

Open
rahb3rt wants to merge 2 commits intomasterfrom
robert.davis/marketplace-container
Open

marketplace container#530
rahb3rt wants to merge 2 commits intomasterfrom
robert.davis/marketplace-container

Conversation

@rahb3rt
Copy link
Copy Markdown

@rahb3rt rahb3rt commented Apr 7, 2026

Add Dockerfile and supporting files to enable self-hosted marketplace deployments. This allows users to build a container image of the marketplace server and mount a custom plugins.json to serve their own curated set of plugins.

Also adds a --database-url flag that fetches plugins.json from a remote HTTP URL (e.g. a raw GitLab/GitHub file) with periodic background refresh, enabling live plugin list updates without restarting the container.

Files added:

  • Dockerfile — multi-stage build (Go 1.22 builder → Alpine 3.20 runtime) that compiles and serves the marketplace on port 8085
  • .dockerignore — keeps the build context lean
  • custom-plugins.json — empty starter file for custom plugin lists
  • internal/store/remote.go — RemoteStore that fetches plugins from a URL and hot-swaps on a configurable interval

Files modified:

  • cmd/marketplace/server.go — added --database-url and --database-refresh-interval flags

Usage:

Build the image

docker build -t mattermost-marketplace .

Run with built-in plugins.json

docker run -d -p 8085:8085 mattermost-marketplace

Run with a custom local plugins.json

docker run -d -p 8085:8085 -v $(pwd)/custom-plugins.json:/app/plugins.json:ro mattermost-marketplace

Run with a remote URL (auto-refreshes every 5 minutes)

docker run -d -p 8085:8085 mattermost-marketplace server \
--database-url "https://gitlab.com/mygroup/myrepo/-/raw/main/plugins.json" \
--database-refresh-interval 5m \
--listen :8085

Ticket Link

N/A

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Adds Docker containerization files and a new remote-backed plugin store with periodic refresh: a .dockerignore, Dockerfile, custom-plugins.json, server CLI flags for remote DB, and internal/store/remote.go implementing a background-refreshing RemoteStore.

Changes

Cohort / File(s) Summary
Docker files
.dockerignore, Dockerfile, custom-plugins.json
Add .dockerignore excluding build artifacts, add multi-stage Dockerfile that compiles a static Go binary with git metadata and packages it on Alpine, and add an empty custom-plugins.json.
Server CLI & startup
cmd/marketplace/server.go
Add --database-url and --database-refresh-interval flags; initialize a remote store when database-url is an HTTP(S) URL, fall back to local static store otherwise; minor shutdown/control-flow refactor.
Remote store implementation
internal/store/remote.go
New RemoteStore type with NewRemote(url, refreshInterval, logger) that performs initial synchronous fetch, starts a background refreshLoop() using a ticker, atomically swaps an underlying StaticStore, exposes GetPlugins(filter) delegating to the current static store, and Stop() to terminate the loop. Logs errors and continues on refresh failures.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Server
participant RemoteStore
participant HTTP as Remote HTTP API
participant StaticStore
Server->>RemoteStore: NewRemote(url, refreshInterval, logger)
RemoteStore->>HTTP: GET url (initial fetch)
HTTP-->>RemoteStore: 200 + JSON
RemoteStore->>StaticStore: NewStatic(from parsed JSON)
RemoteStore-->>Server: return RemoteStore (ready)
Note over RemoteStore: starts refreshLoop (ticker)
Server->>RemoteStore: GetPlugins(filter)
RemoteStore->>StaticStore: GetPlugins(filter) (reads current store under RLock)
StaticStore-->>RemoteStore: plugins
RemoteStore-->>Server: plugins
Note over RemoteStore,HTTP: On ticker tick -> RemoteStore fetches and swaps StaticStore; errors are logged, loop continues

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'marketplace container' is partially related to the changeset, referring to Docker container support, but is vague and does not clearly convey the main additions (Dockerfile, remote database support). Consider a more descriptive title such as 'Add Dockerfile and remote database support for self-hosted marketplace' to better summarize the primary changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description is well-related to the changeset, providing clear context about Docker support, remote URL fetching, and the new files added.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch robert.davis/marketplace-container

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.11.4)

Error: can't load config: unsupported version of the configuration: "" See https://golangci-lint.run/docs/product/migration-guide for migration instructions
The command is terminated due to an error: can't load config: unsupported version of the configuration: "" See https://golangci-lint.run/docs/product/migration-guide for migration instructions


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
Dockerfile (1)

33-34: Consider adding --chown for explicit file ownership.

Files copied after USER marketplace are still root-owned (COPY always runs as root). While this works since the files only need read/execute permissions, explicit ownership improves clarity and avoids potential confusion.

♻️ Optional: Add explicit ownership
-COPY --from=builder /marketplace /app/marketplace
-COPY plugins.json /app/plugins.json
+COPY --from=builder --chown=marketplace:marketplace /marketplace /app/marketplace
+COPY --chown=marketplace:marketplace plugins.json /app/plugins.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 33 - 34, The COPY instructions that run after USER
marketplace still create root-owned files; update the two COPY commands ("COPY
--from=builder /marketplace /app/marketplace" and "COPY plugins.json
/app/plugins.json") to include explicit ownership (e.g.,
--chown=marketplace:marketplace) so the copied files are owned by the
marketplace user and avoid root-owned artifacts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.dockerignore:
- Line 2: The .dockerignore excludes .git/ which prevents the Docker build stage
(the Dockerfile commands using git describe and git rev-parse) from retrieving
real commit/version data, causing the /health endpoint to report fallback
values; fix by removing the `.git/` entry from .dockerignore so the .git
directory is included in the build context, or alternatively modify the
Dockerfile to accept explicit build args (e.g., VERSION, COMMIT) and update the
build invocation to pass those values so git describe/rev-parse are not
required.

---

Nitpick comments:
In `@Dockerfile`:
- Around line 33-34: The COPY instructions that run after USER marketplace still
create root-owned files; update the two COPY commands ("COPY --from=builder
/marketplace /app/marketplace" and "COPY plugins.json /app/plugins.json") to
include explicit ownership (e.g., --chown=marketplace:marketplace) so the copied
files are owned by the marketplace user and avoid root-owned artifacts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f887a6c3-91b2-4397-8c2d-196ef10a9e02

📥 Commits

Reviewing files that changed from the base of the PR and between 67d3240 and cb3c222.

📒 Files selected for processing (3)
  • .dockerignore
  • Dockerfile
  • custom-plugins.json

Comment thread .dockerignore
@@ -0,0 +1,6 @@
dist/
.git/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Excluding .git/ will cause build metadata to always use fallback values.

Since the Dockerfile's build stage uses git describe and git rev-parse (lines 13-15) to populate version info, excluding .git/ from the build context means these commands will always fail and fall back to "dev" and "unknown". The /health endpoint will report these fallback values instead of actual version information.

If build metadata is desired, remove .git/ from this file. If reproducible builds with static metadata are preferred, consider documenting this behaviour or passing version info via build args instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.dockerignore at line 2, The .dockerignore excludes .git/ which prevents the
Docker build stage (the Dockerfile commands using git describe and git
rev-parse) from retrieving real commit/version data, causing the /health
endpoint to report fallback values; fix by removing the `.git/` entry from
.dockerignore so the .git directory is included in the build context, or
alternatively modify the Dockerfile to accept explicit build args (e.g.,
VERSION, COMMIT) and update the build invocation to pass those values so git
describe/rev-parse are not required.

Add --database-url flag to fetch plugins.json from a remote HTTP URL
(e.g. raw GitLab/GitHub file) with periodic background refresh via
--database-refresh-interval (default 5m). This enables updating the
marketplace plugin list without restarting the container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/marketplace/server.go`:
- Around line 53-67: Parse the database URL using net/url (e.g., call url.Parse
on the value retrieved via command.Flags().GetString("database-url")), validate
that parsing succeeded and that the scheme is exactly "http" or "https"
(case-insensitive), and return an error if not valid; only when valid call
store.NewRemote with the parsed URL, and ensure any logged URL
(logger.WithFields) uses a redacted form (strip User info and sensitive query
params) rather than the raw databaseURL before logging; keep remoteStore.Stop()
defer and apiStore assignment unchanged.
- Around line 134-135: The signal handler currently only listens for
os.Interrupt (SIGINT), so modify the signal registration to also listen for
syscall.SIGTERM (e.g., signal.Notify(stopCh, os.Interrupt, syscall.SIGTERM)) in
the same scope where the shutdown is triggered; keep the existing shutdown flow
that calls srv.Shutdown(ctx) and logs with logger.WithField("err",
err).Error("Failed to shutdown") so that docker stop (SIGTERM) triggers the same
graceful shutdown path. Ensure you import "syscall" if not already imported.

In `@internal/store/remote.go`:
- Around line 47-65: The refresh function uses http.Get and io.ReadAll which
have no timeout or size cap; change RemoteStore.refresh to create a request with
a cancellable context with a timeout (use either r.ctx if Remote has a
background/context used for Stop or create a child context with
time.AfterTimeout) and use http.Client/Do or http.NewRequestWithContext instead
of http.Get so the call can be cancelled; when reading the response, wrap
resp.Body with an io.LimitReader (e.g., maxResponseSize) and decode via
model.PluginsFromReader from that reader rather than io.ReadAll to prevent
unbounded memory usage; ensure resp.Body is closed and context errors are
propagated/translated to the returned error so Stop can interrupt in-flight
fetches.
- Around line 30-44: NewRemote should validate the refreshInterval before
starting refreshLoop: check that the passed refreshInterval is > 0 and return a
descriptive error if not, so NewRemote rejects non-positive durations instead of
allowing refreshLoop (which uses time.NewTicker) to panic; update NewRemote to
perform this check (before assigning to RemoteStore or calling r.refresh) and
reference RemoteStore.interval, NewRemote, refreshLoop and refresh when making
the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1b4d9fb6-d25c-4b44-bff4-cb1f25632590

📥 Commits

Reviewing files that changed from the base of the PR and between cb3c222 and 3e2e203.

📒 Files selected for processing (2)
  • cmd/marketplace/server.go
  • internal/store/remote.go

Comment thread cmd/marketplace/server.go
Comment on lines +53 to +67
databaseURL, _ := command.Flags().GetString("database-url")
if databaseURL != "" && strings.HasPrefix(databaseURL, "http") {
refreshInterval, _ := command.Flags().GetDuration("database-refresh-interval")
logger.WithFields(logrus.Fields{
"url": databaseURL,
"interval": refreshInterval,
}).Info("Using remote database URL")

remoteStore, remoteErr := store.NewRemote(databaseURL, refreshInterval, logger)
if remoteErr != nil {
return errors.Wrap(remoteErr, "failed to initialize remote store")
}
defer remoteStore.Stop()
apiStore = remoteStore
} else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the code at the specified location in cmd/marketplace/server.go
cat -n cmd/marketplace/server.go | sed -n '50,70p'

Repository: mattermost/mattermost-marketplace

Length of output: 976


🏁 Script executed:

# Check the internal/store/remote.go around line 48 for logging concerns
cat -n internal/store/remote.go | sed -n '45,55p'

Repository: mattermost/mattermost-marketplace

Length of output: 470


🏁 Script executed:

# Search for URL validation and logging patterns in the store package
rg -A 3 -B 3 "HasPrefix.*http" cmd/ internal/

Repository: mattermost/mattermost-marketplace

Length of output: 562


🏁 Script executed:

# Check how NewRemote function handles URL parsing
rg -A 10 "func NewRemote" internal/store/

Repository: mattermost/mattermost-marketplace

Length of output: 694


Parse and validate --database-url with proper scheme checking instead of prefix matching.

The current strings.HasPrefix(databaseURL, "http") check is case-sensitive and inadequate:

  • HTTPS://... or Http://... incorrectly fall through to local-file mode
  • Invalid schemes like httpx://... are incorrectly accepted as remote
  • No validation of actual URL structure

Additionally, the raw URL is logged at both info level (line 57) and debug level in internal/store/remote.go line 48, creating a security risk for embedded credentials or signed query parameters. Parse the URL once using Go's net/url package, require only http or https schemes (case-insensitive), fail fast on invalid input, and log a redacted form instead of the raw URL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/marketplace/server.go` around lines 53 - 67, Parse the database URL using
net/url (e.g., call url.Parse on the value retrieved via
command.Flags().GetString("database-url")), validate that parsing succeeded and
that the scheme is exactly "http" or "https" (case-insensitive), and return an
error if not valid; only when valid call store.NewRemote with the parsed URL,
and ensure any logged URL (logger.WithFields) uses a redacted form (strip User
info and sensitive query params) rather than the raw databaseURL before logging;
keep remoteStore.Stop() defer and apiStore assignment unchanged.

Comment thread cmd/marketplace/server.go
Comment on lines +134 to 135
if err := srv.Shutdown(ctx); err != nil {
logger.WithField("err", err).Error("Failed to shutdown")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | head -20

Repository: mattermost/mattermost-marketplace

Length of output: 520


🏁 Script executed:

fd -t f "server.go" cmd/

Repository: mattermost/mattermost-marketplace

Length of output: 102


🏁 Script executed:

wc -l cmd/marketplace/server.go

Repository: mattermost/mattermost-marketplace

Length of output: 106


🏁 Script executed:

sed -n '1,160p' cmd/marketplace/server.go | cat -n

Repository: mattermost/mattermost-marketplace

Length of output: 5329


Handle SIGTERM for Docker container graceful shutdown.

The signal handler on line 126 currently only registers os.Interrupt (SIGINT), which means graceful shutdown will not occur when Docker sends SIGTERM via docker stop. This needs to be added for proper container lifecycle support.

Suggested fix
 import (
 	"context"
 	"log"
 	"net/http"
 	"os"
 	"os/signal"
 	"strings"
+	"syscall"
 	"time"
@@
-	signal.Notify(c, os.Interrupt)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/marketplace/server.go` around lines 134 - 135, The signal handler
currently only listens for os.Interrupt (SIGINT), so modify the signal
registration to also listen for syscall.SIGTERM (e.g., signal.Notify(stopCh,
os.Interrupt, syscall.SIGTERM)) in the same scope where the shutdown is
triggered; keep the existing shutdown flow that calls srv.Shutdown(ctx) and logs
with logger.WithField("err", err).Error("Failed to shutdown") so that docker
stop (SIGTERM) triggers the same graceful shutdown path. Ensure you import
"syscall" if not already imported.

Comment thread internal/store/remote.go
Comment on lines +30 to +44
func NewRemote(url string, refreshInterval time.Duration, logger logrus.FieldLogger) (*RemoteStore, error) {
r := &RemoteStore{
url: url,
interval: refreshInterval,
logger: logger,
stopCh: make(chan struct{}),
}

if err := r.refresh(); err != nil {
return nil, errors.Wrap(err, "failed initial fetch from remote URL")
}

go r.refreshLoop()

return r, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the file structure
find . -name "remote.go" -type f | head -20

Repository: mattermost/mattermost-marketplace

Length of output: 103


🏁 Script executed:

# Look at the remote.go file to see the full implementation
cat -n internal/store/remote.go

Repository: mattermost/mattermost-marketplace

Length of output: 3366


🏁 Script executed:

# Search for refreshLoop implementation
rg "func.*refreshLoop" -A 10

Repository: mattermost/mattermost-marketplace

Length of output: 628


🏁 Script executed:

# Check for interval validation in the codebase
rg "refreshInterval|refresh-interval|database-refresh-interval" -i

Repository: mattermost/mattermost-marketplace

Length of output: 702


🏁 Script executed:

# Check how the interval parameter is used
rg "NewTicker.*interval" -B 2 -A 2

Repository: mattermost/mattermost-marketplace

Length of output: 300


🌐 Web query:

Go time.NewTicker non-positive duration panic documentation

💡 Result:

time.NewTicker(d) requires d > 0. The Go time package docs state: “The duration d must be greater than zero; if not, NewTicker will panic.” [1]

In the standard library implementation, the panic happens when d <= 0, with the panic message: panic("non-positive interval for NewTicker"). [2]

Sources:
[1] Go time package docs (NewTicker) [1]
[2] Go standard library source (src/time/tick.go) [2]


Add validation to reject non-positive refresh intervals in NewRemote.

time.NewTicker panics if the duration is zero or negative, which means a misconfigured --database-refresh-interval flag will crash the process inside the refreshLoop() goroutine after the initial fetch succeeds. This should be caught and returned as a validation error during initialization instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/store/remote.go` around lines 30 - 44, NewRemote should validate the
refreshInterval before starting refreshLoop: check that the passed
refreshInterval is > 0 and return a descriptive error if not, so NewRemote
rejects non-positive durations instead of allowing refreshLoop (which uses
time.NewTicker) to panic; update NewRemote to perform this check (before
assigning to RemoteStore or calling r.refresh) and reference
RemoteStore.interval, NewRemote, refreshLoop and refresh when making the change.

Comment thread internal/store/remote.go
Comment on lines +47 to +65
func (r *RemoteStore) refresh() error {
r.logger.WithField("url", r.url).Debug("Fetching plugins from remote URL")

resp, err := http.Get(r.url) // #nosec G107 -- URL is operator-configured, not user input
if err != nil {
return errors.Wrap(err, "failed to fetch remote plugins")
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("remote URL returned status %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "failed to read response body")
}

plugins, err := model.PluginsFromReader(bytes.NewReader(body))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's understand the file structure and find the complete refresh() method
wc -l internal/store/remote.go

Repository: mattermost/mattermost-marketplace

Length of output: 105


🏁 Script executed:

#!/bin/bash
# Read the complete file to understand context and any timeout/context usage
cat -n internal/store/remote.go

Repository: mattermost/mattermost-marketplace

Length of output: 3366


🏁 Script executed:

#!/bin/bash
# Search for any context or timeout related code in the store package
rg -n "context|timeout|Context|Timeout" internal/store/ -i

Repository: mattermost/mattermost-marketplace

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Look for the Stop() method and how it interacts with refresh()
rg -n "func.*Stop|Stop()" internal/store/ -A 5

Repository: mattermost/mattermost-marketplace

Length of output: 789


Add timeout and size limits to remote fetch operations.

http.Get uses the default client with no timeout, and io.ReadAll buffers the entire response body without a size cap. A stalled or oversized response can hang start-up indefinitely (blocking NewRemote), and during periodic refresh, Stop() cannot interrupt an in-flight fetch since it only signals the refresh loop's select statement—a blocked http.Get or io.ReadAll call won't be cancelled. Use a context with timeout and decode through a bounded reader instead of buffering the full payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/store/remote.go` around lines 47 - 65, The refresh function uses
http.Get and io.ReadAll which have no timeout or size cap; change
RemoteStore.refresh to create a request with a cancellable context with a
timeout (use either r.ctx if Remote has a background/context used for Stop or
create a child context with time.AfterTimeout) and use http.Client/Do or
http.NewRequestWithContext instead of http.Get so the call can be cancelled;
when reading the response, wrap resp.Body with an io.LimitReader (e.g.,
maxResponseSize) and decode via model.PluginsFromReader from that reader rather
than io.ReadAll to prevent unbounded memory usage; ensure resp.Body is closed
and context errors are propagated/translated to the returned error so Stop can
interrupt in-flight fetches.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant