66a18064598b40628f4fda518eeb4204c34f437a
- Author
- Matthew Greenwald <1819760+mdgreenwald@users.noreply.github.com>
- Committer
- GitHub <noreply@github.com>
- Date
Message
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