66a18064598b40628f4fda518eeb4204c34f437a

Author
Matthew Greenwald <1819760+mdgreenwald@users.noreply.github.com>
Committer
GitHub <noreply@github.com>
Date

Message

feat: default repo option (#915)

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index 4b30c24454f3c88e6012e701949aea12fd0637eb..3afa497ef45ed02d2f511fca772cbf003eb3cc42 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -127,6 +127,24 @@ full privileges.
  6 Using this environment variable, Soft Serve will create a new `admin` user that
  7 has full privileges. You can rename and change the user settings later.
  8 
  9+If you'd like a repository to exist the moment the server finishes booting,
 10+with no human logging in to run `repo create`, set `SOFT_SERVE_DEFAULT_REPO`
 11+to a repository name. This is useful for GitOps tools like ArgoCD that need a
 12+git remote to point at as part of their own bootstrap (e.g. from a Helm chart
 13+or Terraform apply):
 14+
 15+```sh
 16+SOFT_SERVE_INITIAL_ADMIN_KEYS="$(cat ~/.ssh/id_ed25519.pub)" \
 17+SOFT_SERVE_DEFAULT_REPO=gitops \
 18+  soft serve
 19+```
 20+
 21+The repository is created empty and public, exactly as if a human had run
 22+`repo create gitops` with no flags; only its *existence* is guaranteed on
 23+boot, not its reachability, which is still governed entirely by the
 24+`anon-access`/`allow-keyless` settings described below. Booting again against
 25+the same data directory is a no-op if the repository already exists.
 26+
 27 Check out [Systemd][systemd] on how to run Soft Serve as a service using
 28 Systemd. Soft Serve packages in our Apt/Yum repositories come with Systemd
 29 service units.
 30@@ -269,6 +287,10 @@ stats:
 31 # server for local dev tooling), not for production use.
 32 #anon_access: "admin-access"
 33 #allow_keyless: true
 34+
 35+# Repository name to create on boot if it does not already exist.
 36+# Leave empty to disable.
 37+#default_repo: ""
 38 ```
 39 
 40 You can also use environment variables, to override these settings. All server
 41@@ -283,6 +305,7 @@ name all in uppercase. Here are some examples:
 42 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
 43 - `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
 44 - `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
 45+- `SOFT_SERVE_DEFAULT_REPO`: Repository name to create on boot if missing
 46 
 47 #### Database Configuration
 48 
 49diff --git a/cmd/soft/serve/server.go b/cmd/soft/serve/server.go
 50index c3d45c18632ba140b9d771ac764d96fcc7b53287..4182553daab0c7e3b264c400c78f7526588e2287 100644
 51--- a/cmd/soft/serve/server.go
 52+++ b/cmd/soft/serve/server.go
 53@@ -16,8 +16,10 @@ import (
 54 	"github.com/charmbracelet/soft-serve/pkg/daemon"
 55 	"github.com/charmbracelet/soft-serve/pkg/db"
 56 	"github.com/charmbracelet/soft-serve/pkg/jobs"
 57+	"github.com/charmbracelet/soft-serve/pkg/proto"
 58 	sshsrv "github.com/charmbracelet/soft-serve/pkg/ssh"
 59 	"github.com/charmbracelet/soft-serve/pkg/stats"
 60+	"github.com/charmbracelet/soft-serve/pkg/utils"
 61 	"github.com/charmbracelet/soft-serve/pkg/web"
 62 	"github.com/charmbracelet/ssh"
 63 	"golang.org/x/sync/errgroup"
 64@@ -70,6 +72,8 @@ func NewServer(ctx context.Context) (*Server, error) {
 65 
 66 	srv.Cron = sched
 67 
 68+	ensureDefaultRepo(ctx, cfg, be, logger)
 69+
 70 	srv.SSHServer, err = sshsrv.NewSSHServer(ctx)
 71 	if err != nil {
 72 		return nil, fmt.Errorf("create ssh server: %w", err)
 73@@ -125,6 +129,43 @@ func warnIfAnonAdminAccess(ctx context.Context, be *backend.Backend, logger *log
 74 	logger.Warn("################################################################")
 75 }
 76 
 77+// ensureDefaultRepo creates the repo named by cfg.DefaultRepo if it does not
 78+// already exist. It never fails startup: it logs invalid names and creation
 79+// errors, then returns.
 80+func ensureDefaultRepo(ctx context.Context, cfg *config.Config, be *backend.Backend, logger *log.Logger) {
 81+	if cfg.DefaultRepo == "" {
 82+		return
 83+	}
 84+
 85+	name := utils.SanitizeRepo(cfg.DefaultRepo)
 86+	if err := utils.ValidateRepo(name); err != nil {
 87+		logger.Warn("invalid default_repo, skipping", "name", cfg.DefaultRepo, "err", err)
 88+		return
 89+	}
 90+
 91+	if _, err := be.Repository(ctx, name); err == nil {
 92+		return
 93+	} else if !errors.Is(err, proto.ErrRepoNotFound) {
 94+		logger.Warn("failed to look up default repo", "name", name, "err", err)
 95+		return
 96+	}
 97+
 98+	// The migration always inserts a user at ID 1. The repos table requires
 99+	// a non-null owner, so we attribute the repo to that account.
100+	owner, err := be.UserByID(ctx, 1)
101+	if err != nil {
102+		logger.Warn("failed to look up default repo owner, skipping", "name", name, "err", err)
103+		return
104+	}
105+
106+	if _, err := be.CreateRepository(ctx, name, owner, proto.RepositoryOptions{}); err != nil && !errors.Is(err, proto.ErrRepoExist) {
107+		logger.Warn("failed to create default repo", "name", name, "err", err)
108+		return
109+	}
110+
111+	logger.Info("created default repo", "name", name)
112+}
113+
114 // ReloadCertificates reloads the TLS certificates for the HTTP server.
115 func (s *Server) ReloadCertificates() error {
116 	if s.CertLoader == nil {
117diff --git a/cmd/soft/serve/server_test.go b/cmd/soft/serve/server_test.go
118index 37d5eb7b3f0ff66429358382a8e4d76636bbc285..d9dd513c25dad785cee3b49a2d455dc7c903afdb 100644
119--- a/cmd/soft/serve/server_test.go
120+++ b/cmd/soft/serve/server_test.go
121@@ -3,6 +3,9 @@ package serve
122 import (
123 	"bytes"
124 	"context"
125+	"os"
126+	"path/filepath"
127+	"runtime"
128 	"strings"
129 	"testing"
130 
131@@ -12,37 +15,42 @@ import (
132 	"github.com/charmbracelet/soft-serve/pkg/config"
133 	"github.com/charmbracelet/soft-serve/pkg/db"
134 	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
135+	"github.com/charmbracelet/soft-serve/pkg/proto"
136 	"github.com/charmbracelet/soft-serve/pkg/store"
137 	"github.com/charmbracelet/soft-serve/pkg/store/database"
138 	"github.com/matryer/is"
139-	_ "modernc.org/sqlite"
140+	_ "modernc.org/sqlite" // sqlite driver
141 )
142 
143-// newTestBackend returns a Backend backed by a real, freshly migrated
144-// SQLite database, along with the *config.Config it was constructed with,
145-// so tests can set AnonAccess/AllowKeyless overrides directly.
146-func newTestBackend(t *testing.T) (*backend.Backend, *config.Config) {
147-	t.Helper()
148-	is := is.New(t)
149-	ctx := context.Background()
150+func setupTestBackend(tb testing.TB) (context.Context, *config.Config, *backend.Backend) {
151+	tb.Helper()
152+	ctx, cfg, be, _ := setupTestBackendWithDB(tb)
153+	return ctx, cfg, be
154+}
155+
156+func setupTestBackendWithDB(tb testing.TB) (context.Context, *config.Config, *backend.Backend, *db.DB) {
157+	tb.Helper()
158+	is := is.New(tb)
159 
160-	dp := t.TempDir()
161 	cfg := config.DefaultConfig()
162-	cfg.DataPath = dp
163-	cfg.DB.Driver = "sqlite"
164-	cfg.DB.DataSource = dp + "/test.db"
165+	cfg.DataPath = tb.TempDir()
166+	is.NoErr(cfg.Validate())
167 
168+	ctx := context.Background()
169 	ctx = config.WithContext(ctx, cfg)
170+
171+	is.NoErr(os.MkdirAll(cfg.DataPath, os.ModePerm))
172 	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
173 	is.NoErr(err)
174-	t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
175+	tb.Cleanup(func() { dbx.Close() }) //nolint: errcheck
176 
177 	is.NoErr(migrate.Migrate(ctx, dbx))
178+
179 	dbstore := database.New(ctx, dbx)
180 	ctx = store.WithContext(ctx, dbstore)
181 	be := backend.New(ctx, cfg, dbx, dbstore)
182 
183-	return be, cfg
184+	return ctx, cfg, be, dbx
185 }
186 
187 func TestWarnIfAnonAdminAccess(t *testing.T) {
188@@ -61,8 +69,7 @@ func TestWarnIfAnonAdminAccess(t *testing.T) {
189 	for _, c := range cases {
190 		t.Run(c.name, func(t *testing.T) {
191 			is := is.New(t)
192-			be, cfg := newTestBackend(t)
193-			ctx := context.Background()
194+			ctx, cfg, be := setupTestBackend(t)
195 
196 			allow := c.allowKeyless
197 			cfg.AllowKeyless = &allow
198@@ -78,3 +85,140 @@ func TestWarnIfAnonAdminAccess(t *testing.T) {
199 		})
200 	}
201 }
202+
203+func TestEnsureDefaultRepoDisabled(t *testing.T) {
204+	is := is.New(t)
205+	ctx, cfg, be := setupTestBackend(t)
206+
207+	cfg.DefaultRepo = ""
208+	ensureDefaultRepo(ctx, cfg, be, log.New(os.Stderr))
209+
210+	repos, err := be.Repositories(ctx)
211+	is.NoErr(err)
212+	is.Equal(len(repos), 0)
213+}
214+
215+func TestEnsureDefaultRepoCreatesWhenMissing(t *testing.T) {
216+	is := is.New(t)
217+	ctx, cfg, be := setupTestBackend(t)
218+
219+	cfg.DefaultRepo = "gitops"
220+	ensureDefaultRepo(ctx, cfg, be, log.New(os.Stderr))
221diff --git a/pkg/config/config.go b/pkg/config/config.go
222index f17e6afbd229a49d034b5299f632e21fbd6b133e..fc025c10f8464d256c6b0195939cb1194fb402c1 100644
223--- a/pkg/config/config.go
224+++ b/pkg/config/config.go
225@@ -191,6 +191,10 @@ type Config struct {
226 	// A nil value means "no override": fall back to the database.
227 	AllowKeyless *bool `env:"ALLOW_KEYLESS" yaml:"allow_keyless"`
228 
229+	// DefaultRepo is a repository name to create on boot if it does not
230+	// already exist. Leave empty to disable.
231+	DefaultRepo string `env:"DEFAULT_REPO" yaml:"default_repo"`
232+
233 	// DataPath is the path to the directory where Soft Serve will store its data.
234 	DataPath string `env:"DATA_PATH" yaml:"-"`
235 }
236@@ -210,6 +214,7 @@ func (c *Config) Environ() []string {
237 		fmt.Sprintf("SOFT_SERVE_DATA_PATH=%s", c.DataPath),
238 		fmt.Sprintf("SOFT_SERVE_NAME=%s", c.Name),
239 		fmt.Sprintf("SOFT_SERVE_INITIAL_ADMIN_KEYS=%s", strings.Join(c.InitialAdminKeys, "\n")),
240+		fmt.Sprintf("SOFT_SERVE_DEFAULT_REPO=%s", c.DefaultRepo),
241 		fmt.Sprintf("SOFT_SERVE_SSH_ENABLED=%t", c.SSH.Enabled),
242 		fmt.Sprintf("SOFT_SERVE_SSH_LISTEN_ADDR=%s", c.SSH.ListenAddr),
243 		fmt.Sprintf("SOFT_SERVE_SSH_PUBLIC_URL=%s", c.SSH.PublicURL),
244@@ -378,7 +383,8 @@ func (c *Config) Exist() bool {
245 // Use Validate() to validate the config and ensure absolute paths.
246 func DefaultConfig() *Config {
247 	return &Config{
248-		Name:     "Soft Serve",
249+		Name: "Soft Serve",
250+		// DefaultRepo: "",
251 		DataPath: DefaultDataPath(),
252 		SSH: SSHConfig{
253 			Enabled:       true,
254diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
255index 990c9a83004b22778b6d082958ef100a7a06b612..00dcee3036e13a0d937c3c53d123ecf25e10f8e1 100644
256--- a/pkg/config/config_test.go
257+++ b/pkg/config/config_test.go
258@@ -81,6 +81,29 @@ func TestCustomConfigLocation(t *testing.T) {
259 	is.Equal(cfg.Name, "Soft Serve")
260 }
261 
262+func TestDefaultRepoDisabledByDefault(t *testing.T) {
263+	is := is.New(t)
264+	is.Equal(DefaultConfig().DefaultRepo, "")
265+}
266+
267+func TestParseDefaultRepo(t *testing.T) {
268+	is := is.New(t)
269+	is.NoErr(os.Setenv("SOFT_SERVE_DEFAULT_REPO", "gitops"))
270+	t.Cleanup(func() {
271+		is.NoErr(os.Unsetenv("SOFT_SERVE_DEFAULT_REPO"))
272+	})
273+	cfg := DefaultConfig()
274+	is.NoErr(cfg.ParseEnv())
275+	is.Equal(cfg.DefaultRepo, "gitops")
276+}
277+
278+func TestParseDefaultRepoFromFile(t *testing.T) {
279+	is := is.New(t)
280+	cfg := &Config{DataPath: t.TempDir()}
281+	is.NoErr(parseFile(cfg, "testdata/config_default_repo.yaml"))
282+	is.Equal(cfg.DefaultRepo, "gitops")
283+}
284+
285 func TestParseMultipleHeaders(t *testing.T) {
286 	is := is.New(t)
287 	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS", "Accept,Accept-Language,User-Agent"))
288diff --git a/pkg/config/file.go b/pkg/config/file.go
289index 8170493c3ee9911d05f64c139580d573df962e1b..8a928e7adf8e5a113dd3244944865c54621e52c6 100644
290--- a/pkg/config/file.go
291+++ b/pkg/config/file.go
292@@ -147,6 +147,10 @@ jobs:
293 # Additional admin keys.
294 #initial_admin_keys:
295 #  - "ssh-rsa AAAAB3NzaC1yc2..."
296+
297+# Name of a repository to create automatically on boot if it doesn't already
298+# exist. Empty disables this behavior.
299+#default_repo: ""
300 `))
301 
302 func newConfigFile(cfg *Config) string {
303diff --git a/pkg/config/testdata/config_default_repo.yaml b/pkg/config/testdata/config_default_repo.yaml
304new file mode 100644
305index 0000000000000000000000000000000000000000..d56b8f99cd0572eff3e6491bfcf93e8a4dd18abe
306--- /dev/null
307+++ b/pkg/config/testdata/config_default_repo.yaml
308@@ -0,0 +1,4 @@
309+# Soft Serve Server configurations
310+
311+name: "Test server name"
312+default_repo: "gitops"
313diff --git a/testscript/testdata/default-repo.txtar b/testscript/testdata/default-repo.txtar
314new file mode 100644
315index 0000000000000000000000000000000000000000..87a6ba9f2a723a5a4c257e51b7e7a2714917c0c8
316--- /dev/null
317+++ b/testscript/testdata/default-repo.txtar
318@@ -0,0 +1,35 @@
319+# vi: set ft=conf
320+
321+env SOFT_SERVE_DEFAULT_REPO=gitops
322+
323+# start soft serve, with no prior `repo create` call for the default repo,
324+# and no anon-access overrides -- just an ordinary seeded admin key
325+exec soft serve &
326+# wait for SSH server to start
327+ensureserverrunning SSH_PORT
328+
329+# the default repo must exist and be clonable with zero manual setup
330+soft repo list
331+stdout 'gitops'
332+
333+git clone ssh://localhost:$SSH_PORT/gitops gitops
334+mkfile ./gitops/README.md '# gitops'
335+git -C gitops add -A
336+git -C gitops commit -m 'first'
337+git -C gitops push origin HEAD
338+
339+# restart-safety: booting again against the same data dir must not error,
340+# duplicate, or reset the repo's content
341+stopserver
342+ensureservernotrunning SSH_PORT
343+exec soft serve &
344+ensureserverrunning SSH_PORT
345+
346+soft repo list
347+stdout 'gitops'
348+
349+git clone ssh://localhost:$SSH_PORT/gitops gitops2
350+grep '# gitops' gitops2/README.md
351+
352+# stop the server
353+[windows] stopserver