- commit
- f23dc2b
- parent
- 0c5fc9c
- author
- Eric Bower
- date
- 2024-04-13 14:24:07 +0000 UTC
feat(imgs): ssh cli with ls and rm commands
7 files changed,
+441,
-140
M
Makefile
+8,
-0
1@@ -140,3 +140,11 @@ restore:
2 $(DOCKER_CMD) exec -it $(DB_CONTAINER) /bin/bash
3 # psql postgres -U postgres -d pico < /backup.sql
4 .PHONY: restore
5+
6+registry-clean:
7+ # https://github.com/distribution/distribution/issues/3200#issuecomment-671062638
8+ # NOTICE: if using s3 you need an empty file inside:
9+ # - `imgs/docker/registry/v2/repositories` and
10+ # - `imgs/docker/registry/v2/blobs`
11+ docker compose exec registry bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
12+.PHONY: registry-clean
1@@ -102,7 +102,7 @@ func main() {
2 }
3 }
4
5- session := &pgs.CmdSessionLogger{
6+ session := &shared.CmdSessionLogger{
7 Log: logger,
8 }
9
+2,
-0
1@@ -23,6 +23,8 @@ services:
2 environment:
3 REGISTRY_STORAGE_S3_ACCESSKEY: ${MINIO_ROOT_USER}
4 REGISTRY_STORAGE_S3_SECRETKEY: ${MINIO_ROOT_PASSWORD}
5+ links:
6+ - minio
7 imgproxy:
8 image: darthsim/imgproxy:latest
9 restart: always
+129,
-107
1@@ -24,6 +24,7 @@ import (
2 "github.com/picosh/pico/db"
3 "github.com/picosh/pico/db/postgres"
4 "github.com/picosh/pico/shared"
5+ "github.com/picosh/pico/shared/storage"
6 "github.com/picosh/ptun"
7 )
8
9@@ -72,152 +73,154 @@ func (e *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
10 http.Error(w, e.Err.Error(), http.StatusInternalServerError)
11 }
12
13-func serveMux(ctx ssh.Context) http.Handler {
14- router := http.NewServeMux()
15+func createServeMux(handler *CliHandler) func(ctx ssh.Context) http.Handler {
16+ return func(ctx ssh.Context) http.Handler {
17+ router := http.NewServeMux()
18
19- slug := ""
20- user, err := getUserCtx(ctx)
21- if err == nil && user != nil {
22- slug = user.Name
23- }
24-
25- proxy := httputil.NewSingleHostReverseProxy(&url.URL{
26- Scheme: "http",
27- Host: "registry:5000",
28- })
29-
30- oldDirector := proxy.Director
31-
32- proxy.Director = func(r *http.Request) {
33- log.Printf("%+v", r)
34- oldDirector(r)
35-
36- if strings.HasSuffix(r.URL.Path, "_catalog") || r.URL.Path == "/v2" || r.URL.Path == "/v2/" {
37- return
38+ slug := ""
39+ user, err := getUserCtx(ctx)
40+ if err == nil && user != nil {
41+ slug = user.Name
42 }
43
44- fullPath := strings.TrimPrefix(r.URL.Path, "/v2")
45+ proxy := httputil.NewSingleHostReverseProxy(&url.URL{
46+ Scheme: "http",
47+ Host: handler.RegistryUrl,
48+ })
49
50- newPath, err := url.JoinPath("/v2", slug, fullPath)
51- if err != nil {
52- return
53- }
54+ oldDirector := proxy.Director
55
56- r.URL.Path = newPath
57+ proxy.Director = func(r *http.Request) {
58+ log.Printf("%+v", r)
59+ oldDirector(r)
60
61- query := r.URL.Query()
62+ if strings.HasSuffix(r.URL.Path, "_catalog") || r.URL.Path == "/v2" || r.URL.Path == "/v2/" {
63+ return
64+ }
65
66- if query.Has("from") {
67- joinedFrom, err := url.JoinPath(slug, query.Get("from"))
68+ fullPath := strings.TrimPrefix(r.URL.Path, "/v2")
69+
70+ newPath, err := url.JoinPath("/v2", slug, fullPath)
71 if err != nil {
72 return
73 }
74- query.Set("from", joinedFrom)
75
76- r.URL.RawQuery = query.Encode()
77- }
78+ r.URL.Path = newPath
79
80- log.Printf("%+v", r)
81- }
82+ query := r.URL.Query()
83
84- proxy.ModifyResponse = func(r *http.Response) error {
85- log.Printf("%+v", r)
86- shared.CorsHeaders(r.Header)
87+ if query.Has("from") {
88+ joinedFrom, err := url.JoinPath(slug, query.Get("from"))
89+ if err != nil {
90+ return
91+ }
92+ query.Set("from", joinedFrom)
93
94- if slug != "" && r.Request.Method == http.MethodGet && strings.HasSuffix(r.Request.URL.Path, "_catalog") {
95- b, err := io.ReadAll(r.Body)
96- if err != nil {
97- return err
98+ r.URL.RawQuery = query.Encode()
99 }
100
101- err = r.Body.Close()
102- if err != nil {
103- return err
104- }
105+ log.Printf("%+v", r)
106+ }
107
108- var data map[string]any
109- err = json.Unmarshal(b, &data)
110- if err != nil {
111- return err
112- }
113+ proxy.ModifyResponse = func(r *http.Response) error {
114+ log.Printf("%+v", r)
115+ shared.CorsHeaders(r.Header)
116
117- newRepos := []string{}
118+ if r.Request.Method == http.MethodGet && strings.HasSuffix(r.Request.URL.Path, "_catalog") {
119+ b, err := io.ReadAll(r.Body)
120+ if err != nil {
121+ return err
122+ }
123
124- if repos, ok := data["repositories"].([]any); ok {
125- for _, repo := range repos {
126- if repoStr, ok := repo.(string); ok && strings.HasPrefix(repoStr, slug) {
127- newRepos = append(newRepos, strings.Replace(repoStr, fmt.Sprintf("%s/", slug), "", 1))
128+ err = r.Body.Close()
129+ if err != nil {
130+ return err
131+ }
132+
133+ var data map[string]any
134+ err = json.Unmarshal(b, &data)
135+ if err != nil {
136+ return err
137+ }
138+
139+ newRepos := []string{}
140+
141+ if repos, ok := data["repositories"].([]any); ok {
142+ for _, repo := range repos {
143+ if repoStr, ok := repo.(string); ok && strings.HasPrefix(repoStr, slug) {
144+ newRepos = append(newRepos, strings.Replace(repoStr, fmt.Sprintf("%s/", slug), "", 1))
145+ }
146 }
147 }
148- }
149
150- data["repositories"] = newRepos
151+ data["repositories"] = newRepos
152
153- newB, err := json.Marshal(data)
154- if err != nil {
155- return err
156- }
157+ newB, err := json.Marshal(data)
158+ if err != nil {
159+ return err
160+ }
161
162- jsonBuf := bytes.NewBuffer(newB)
163+ jsonBuf := bytes.NewBuffer(newB)
164
165- r.ContentLength = int64(jsonBuf.Len())
166- r.Header.Set("Content-Length", strconv.FormatInt(r.ContentLength, 10))
167- r.Body = io.NopCloser(jsonBuf)
168- }
169+ r.ContentLength = int64(jsonBuf.Len())
170+ r.Header.Set("Content-Length", strconv.FormatInt(r.ContentLength, 10))
171+ r.Body = io.NopCloser(jsonBuf)
172+ }
173
174- if slug != "" && r.Request.Method == http.MethodGet && (strings.Contains(r.Request.URL.Path, "/tags/") || strings.Contains(r.Request.URL.Path, "/manifests/")) {
175- splitPath := strings.Split(r.Request.URL.Path, "/")
176+ if r.Request.Method == http.MethodGet && (strings.Contains(r.Request.URL.Path, "/tags/") || strings.Contains(r.Request.URL.Path, "/manifests/")) {
177+ splitPath := strings.Split(r.Request.URL.Path, "/")
178
179- if len(splitPath) > 1 {
180- ele := splitPath[len(splitPath)-2]
181- if ele == "tags" || ele == "manifests" {
182- b, err := io.ReadAll(r.Body)
183- if err != nil {
184- return err
185- }
186+ if len(splitPath) > 1 {
187+ ele := splitPath[len(splitPath)-2]
188+ if ele == "tags" || ele == "manifests" {
189+ b, err := io.ReadAll(r.Body)
190+ if err != nil {
191+ return err
192+ }
193
194- err = r.Body.Close()
195- if err != nil {
196- return err
197- }
198+ err = r.Body.Close()
199+ if err != nil {
200+ return err
201+ }
202
203- var data map[string]any
204- err = json.Unmarshal(b, &data)
205- if err != nil {
206- return err
207- }
208+ var data map[string]any
209+ err = json.Unmarshal(b, &data)
210+ if err != nil {
211+ return err
212+ }
213
214- if name, ok := data["name"].(string); ok {
215- if strings.HasPrefix(name, slug) {
216- data["name"] = strings.Replace(name, fmt.Sprintf("%s/", slug), "", 1)
217+ if name, ok := data["name"].(string); ok {
218+ if strings.HasPrefix(name, slug) {
219+ data["name"] = strings.Replace(name, fmt.Sprintf("%s/", slug), "", 1)
220+ }
221 }
222- }
223
224- newB, err := json.Marshal(data)
225- if err != nil {
226- return err
227- }
228+ newB, err := json.Marshal(data)
229+ if err != nil {
230+ return err
231+ }
232
233- jsonBuf := bytes.NewBuffer(newB)
234+ jsonBuf := bytes.NewBuffer(newB)
235
236- r.ContentLength = int64(jsonBuf.Len())
237- r.Header.Set("Content-Length", strconv.FormatInt(r.ContentLength, 10))
238- r.Body = io.NopCloser(jsonBuf)
239+ r.ContentLength = int64(jsonBuf.Len())
240+ r.Header.Set("Content-Length", strconv.FormatInt(r.ContentLength, 10))
241+ r.Body = io.NopCloser(jsonBuf)
242+ }
243 }
244 }
245- }
246
247- locationHeader := r.Header.Get("location")
248- if slug != "" && strings.Contains(locationHeader, fmt.Sprintf("/v2/%s", slug)) {
249- r.Header.Set("location", strings.ReplaceAll(locationHeader, fmt.Sprintf("/v2/%s", slug), "/v2"))
250- }
251+ locationHeader := r.Header.Get("location")
252+ if strings.Contains(locationHeader, fmt.Sprintf("/v2/%s", slug)) {
253+ r.Header.Set("location", strings.ReplaceAll(locationHeader, fmt.Sprintf("/v2/%s", slug), "/v2"))
254+ }
255
256- return nil
257- }
258+ return nil
259+ }
260
261- router.HandleFunc("/", proxy.ServeHTTP)
262+ router.HandleFunc("/", proxy.ServeHTTP)
263
264- return router
265+ return router
266+ }
267 }
268
269 func StartSshServer() {
270@@ -230,14 +233,33 @@ func StartSshServer() {
271 port = "2222"
272 }
273 dbUrl := os.Getenv("DATABASE_URL")
274+ registryUrl := shared.GetEnv("REGISTRY_URL", "registry:5000")
275+ minioUrl := shared.GetEnv("MINIO_URL", "")
276+ minioUser := shared.GetEnv("MINIO_ROOT_USER", "")
277+ minioPass := shared.GetEnv("MINIO_ROOT_PASSWORD", "")
278+
279 logger := slog.Default()
280 dbh := postgres.NewDB(dbUrl, logger)
281+ st, err := storage.NewStorageMinio(minioUrl, minioUser, minioPass)
282+ if err != nil {
283+ panic(err)
284+ }
285+
286+ handler := &CliHandler{
287+ Logger: logger,
288+ DBPool: dbh,
289+ Storage: st,
290+ RegistryUrl: registryUrl,
291+ }
292
293 s, err := wish.NewServer(
294 wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
295 wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
296 wish.WithPublicKeyAuth(AuthHandler(dbh, logger)),
297- ptun.WithWebTunnel(ptun.NewWebTunnelHandler(serveMux, logger)),
298+ wish.WithMiddleware(WishMiddleware(handler)),
299+ ptun.WithWebTunnel(
300+ ptun.NewWebTunnelHandler(createServeMux(handler), logger),
301+ ),
302 )
303
304 if err != nil {
+263,
-0
1@@ -0,0 +1,263 @@
2+package imgs
3+
4+import (
5+ "encoding/json"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "net/http"
11+ "path/filepath"
12+ "strings"
13+
14+ "github.com/charmbracelet/ssh"
15+ "github.com/charmbracelet/wish"
16+ "github.com/picosh/pico/db"
17+ "github.com/picosh/pico/shared"
18+ "github.com/picosh/pico/shared/storage"
19+ "github.com/picosh/pico/tui/common"
20+ sst "github.com/picosh/pobj/storage"
21+ "github.com/picosh/send/send/utils"
22+)
23+
24+func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
25+ var err error
26+ key, err := shared.KeyText(s)
27+ if err != nil {
28+ return nil, fmt.Errorf("key not found")
29+ }
30+
31+ user, err := dbpool.FindUserForKey(s.User(), key)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ if user.Name == "" {
37+ return nil, fmt.Errorf("must have username set")
38+ }
39+
40+ return user, nil
41+}
42+
43+func flagSet(cmdName string, sesh ssh.Session) (*flag.FlagSet, *bool) {
44+ cmd := flag.NewFlagSet(cmdName, flag.ContinueOnError)
45+ cmd.SetOutput(sesh)
46+ write := cmd.Bool("write", false, "apply changes")
47+ return cmd, write
48+}
49+
50+func flagCheck(cmd *flag.FlagSet, posArg string, cmdArgs []string) bool {
51+ _ = cmd.Parse(cmdArgs)
52+
53+ if posArg == "-h" || posArg == "--help" || posArg == "-help" {
54+ cmd.Usage()
55+ return false
56+ }
57+ return true
58+}
59+
60+type Cmd struct {
61+ User *db.User
62+ Session shared.CmdSession
63+ Log *slog.Logger
64+ Dbpool db.DB
65+ Write bool
66+ Styles common.Styles
67+ Storage sst.ObjectStorage
68+ RegistryUrl string
69+}
70+
71+func (c *Cmd) output(out string) {
72+ _, _ = c.Session.Write([]byte(out + "\r\n"))
73+}
74+
75+func (c *Cmd) error(err error) {
76+ _, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
77+ _ = c.Session.Exit(1)
78+ _ = c.Session.Close()
79+}
80+
81+func (c *Cmd) bail(err error) {
82+ if err == nil {
83+ return
84+ }
85+ c.Log.Error(err.Error())
86+ c.error(err)
87+}
88+
89+func (c *Cmd) notice() {
90+ if !c.Write {
91+ c.output("\nNOTICE: changes not commited, use `--write` to save operation")
92+ }
93+}
94+
95+func (c *Cmd) help() {
96+ helpStr := "Commands: [help, ls, rm]\n"
97+ helpStr += "NOTICE: *must* append with `--write` for the changes to persist.\n"
98+ c.output(helpStr)
99+}
100+
101+func (c *Cmd) rm(repo string) error {
102+ bucket, err := c.Storage.GetBucket("imgs")
103+ if err != nil {
104+ return err
105+ }
106+
107+ fp := filepath.Join("docker/registry/v2/repositories", c.User.Name, repo)
108+
109+ fileList, err := c.Storage.ListObjects(bucket, fp, true)
110+ if err != nil {
111+ return err
112+ }
113+
114+ if len(fileList) == 0 {
115+ c.output(fmt.Sprintf("repo not found (%s)", repo))
116+ return nil
117+ }
118+ c.output(fmt.Sprintf("found (%d) objects for repo (%s), removing", len(fileList), repo))
119+
120+ for _, obj := range fileList {
121+ fname := filepath.Join(fp, obj.Name())
122+ intent := fmt.Sprintf("deleted (%s)", obj.Name())
123+ c.Log.Info(
124+ "attempting to delete file",
125+ "user", c.User.Name,
126+ "bucket", bucket.Name,
127+ "repo", repo,
128+ "filename", fname,
129+ )
130+ if c.Write {
131+ err := c.Storage.DeleteObject(bucket, fname)
132+ if err != nil {
133+ return err
134+ }
135+ }
136+ c.output(intent)
137+ }
138+
139+ return nil
140+}
141+
142+type RegistryCatalog struct {
143+ Repos []string `json:"repositories"`
144+}
145+
146+func (c *Cmd) ls() error {
147+ res, err := http.Get(
148+ fmt.Sprintf("http://%s/v2/_catalog", c.RegistryUrl),
149+ )
150+ if err != nil {
151+ return err
152+ }
153+
154+ body, err := io.ReadAll(res.Body)
155+ if err != nil {
156+ return err
157+ }
158+
159+ var data RegistryCatalog
160+ err = json.Unmarshal(body, &data)
161+
162+ if err != nil {
163+ return err
164+ }
165+
166+ if len(data.Repos) == 0 {
167+ c.output("You don't have any repos on imgs.sh")
168+ return nil
169+ }
170+
171+ user := c.User.Name
172+ out := "repos\n"
173+ out += "-----\n"
174+ for _, repo := range data.Repos {
175+ if !strings.HasPrefix(repo, user+"/") {
176+ continue
177+ }
178+ rr := strings.TrimPrefix(repo, user+"/")
179+ out += fmt.Sprintf("%s\n", rr)
180+ }
181+ c.output(out)
182+ return nil
183+}
184+
185+type CliHandler struct {
186+ DBPool db.DB
187+ Logger *slog.Logger
188+ Storage storage.StorageServe
189+ RegistryUrl string
190+}
191+
192+func WishMiddleware(handler *CliHandler) wish.Middleware {
193+ dbpool := handler.DBPool
194+ log := handler.Logger
195+ st := handler.Storage
196+
197+ return func(next ssh.Handler) ssh.Handler {
198+ return func(sesh ssh.Session) {
199+ user, err := getUser(sesh, dbpool)
200+ if err != nil {
201+ utils.ErrorHandler(sesh, err)
202+ return
203+ }
204+
205+ args := sesh.Command()
206+
207+ opts := Cmd{
208+ Session: sesh,
209+ User: user,
210+ Log: log,
211+ Dbpool: dbpool,
212+ Write: false,
213+ Storage: st,
214+ RegistryUrl: handler.RegistryUrl,
215+ }
216+
217+ if len(args) == 0 {
218+ next(sesh)
219+ return
220+ }
221+
222+ cmd := strings.TrimSpace(args[0])
223+ if len(args) == 1 {
224+ if cmd == "help" {
225+ opts.help()
226+ return
227+ } else if cmd == "ls" {
228+ err := opts.ls()
229+ opts.bail(err)
230+ return
231+ } else {
232+ next(sesh)
233+ return
234+ }
235+ }
236+
237+ repoName := strings.TrimSpace(args[1])
238+ cmdArgs := args[2:]
239+ log.Info(
240+ "imgs middleware detected command",
241+ "args", args,
242+ "cmd", cmd,
243+ "repoName", repoName,
244+ "cmdArgs", cmdArgs,
245+ )
246+
247+ if cmd == "rm" {
248+ rmCmd, write := flagSet("rm", sesh)
249+ if !flagCheck(rmCmd, repoName, cmdArgs) {
250+ return
251+ }
252+ opts.Write = *write
253+
254+ err := opts.rm(repoName)
255+ opts.notice()
256+ opts.bail(err)
257+ return
258+ } else {
259+ next(sesh)
260+ return
261+ }
262+ }
263+ }
264+}
+1,
-32
1@@ -3,9 +3,7 @@ package pgs
2 import (
3 "errors"
4 "fmt"
5- "io"
6 "log/slog"
7- "os"
8 "path/filepath"
9 "strings"
10
11@@ -107,38 +105,9 @@ func getHelpText(styles common.Styles, userName string) string {
12 return helpStr
13 }
14
15-type CmdSessionLogger struct {
16- Log *slog.Logger
17-}
18-
19-func (c *CmdSessionLogger) Write(out []byte) (int, error) {
20- c.Log.Info(string(out))
21- return 0, nil
22-}
23-
24-func (c *CmdSessionLogger) Exit(code int) error {
25- os.Exit(code)
26- return fmt.Errorf("panic %d", code)
27-}
28-
29-func (c *CmdSessionLogger) Close() error {
30- return fmt.Errorf("closing")
31-}
32-
33-func (c *CmdSessionLogger) Stderr() io.ReadWriter {
34- return nil
35-}
36-
37-type CmdSession interface {
38- Write([]byte) (int, error)
39- Exit(code int) error
40- Close() error
41- Stderr() io.ReadWriter
42-}
43-
44 type Cmd struct {
45 User *db.User
46- Session CmdSession
47+ Session shared.CmdSession
48 Log *slog.Logger
49 Store storage.StorageServe
50 Dbpool db.DB
1@@ -0,0 +1,37 @@
2+package shared
3+
4+import (
5+ "fmt"
6+ "io"
7+ "log/slog"
8+ "os"
9+)
10+
11+type CmdSessionLogger struct {
12+ Log *slog.Logger
13+}
14+
15+func (c *CmdSessionLogger) Write(out []byte) (int, error) {
16+ c.Log.Info(string(out))
17+ return 0, nil
18+}
19+
20+func (c *CmdSessionLogger) Exit(code int) error {
21+ os.Exit(code)
22+ return fmt.Errorf("panic %d", code)
23+}
24+
25+func (c *CmdSessionLogger) Close() error {
26+ return fmt.Errorf("closing")
27+}
28+
29+func (c *CmdSessionLogger) Stderr() io.ReadWriter {
30+ return nil
31+}
32+
33+type CmdSession interface {
34+ Write([]byte) (int, error)
35+ Exit(code int) error
36+ Close() error
37+ Stderr() io.ReadWriter
38+}