Skip to content

Commit 0185d0a

Browse files
authored
Support pull interval flag for catalog show. (#257)
1 parent d6236ee commit 0185d0a

File tree

7 files changed

+90
-18
lines changed

7 files changed

+90
-18
lines changed

cmd/docker-mcp/commands/catalog_next.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,22 +93,18 @@ func showCatalogNextCommand() *cobra.Command {
9393
if !supported {
9494
return fmt.Errorf("unsupported format: %s", format)
9595
}
96-
supportedPullOptions := slices.Contains(catalognext.SupportedPullOptions(), pullOption)
97-
if !supportedPullOptions {
98-
return fmt.Errorf("unsupported pull option: %s", pullOption)
99-
}
10096
dao, err := db.New()
10197
if err != nil {
10298
return err
10399
}
104100
ociService := oci.NewService()
105-
return catalognext.Show(cmd.Context(), dao, ociService, args[0], workingset.OutputFormat(format), catalognext.PullOption(pullOption))
101+
return catalognext.Show(cmd.Context(), dao, ociService, args[0], workingset.OutputFormat(format), pullOption)
106102
},
107103
}
108104

109105
flags := cmd.Flags()
110106
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
111-
flags.StringVar(&pullOption, "pull", string(catalognext.PullOptionNever), fmt.Sprintf("Supported: %s.", strings.Join(catalognext.SupportedPullOptions(), ", ")))
107+
flags.StringVar(&pullOption, "pull", string(catalognext.PullOptionNever), fmt.Sprintf("Supported: %s, or duration (e.g. '1h', '1d'). Duration represents time since last update.", strings.Join(catalognext.SupportedPullOptions(), ", ")))
112108
return cmd
113109
}
114110

pkg/catalog_next/catalog.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ const (
147147
PullOptionMissing = "missing"
148148
PullOptionNever = "never"
149149
PullOptionAlways = "always"
150+
151+
// Special value for duration-based pull options. Don't add as supported pull option below.
152+
PullOptionDuration = "duration"
150153
)
151154

152155
func SupportedPullOptions() []string {

pkg/catalog_next/show.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"errors"
88
"fmt"
99
"os"
10+
"slices"
1011
"strings"
12+
"time"
1113

1214
"github.com/goccy/go-yaml"
1315
"github.com/google/go-containerregistry/pkg/name"
@@ -17,7 +19,12 @@ import (
1719
"github.com/docker/mcp-gateway/pkg/workingset"
1820
)
1921

20-
func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string, format workingset.OutputFormat, pullOption PullOption) error {
22+
func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string, format workingset.OutputFormat, pullOptionParam string) error {
23+
pullOption, pullInterval, err := parsePullOption(pullOptionParam)
24+
if err != nil {
25+
return err
26+
}
27+
2128
ref, err := name.ParseReference(refStr)
2229
if err != nil {
2330
return fmt.Errorf("failed to parse oci-reference %s: %w", refStr, err)
@@ -37,19 +44,37 @@ func Show(ctx context.Context, dao db.DAO, ociService oci.Service, refStr string
3744
}
3845

3946
dbCatalog, err := dao.GetCatalog(ctx, refStr)
40-
if err != nil && errors.Is(err, sql.ErrNoRows) && pullOption == PullOptionMissing {
41-
fmt.Fprintf(os.Stderr, "Pulling catalog %s...\n", refStr)
42-
dbCatalog, err = pullCatalog(ctx, dao, ociService, refStr)
47+
if err != nil && errors.Is(err, sql.ErrNoRows) && (pullOption == PullOptionMissing || pullOption == PullOptionDuration) {
48+
fmt.Fprintf(os.Stderr, "Pulling catalog %s (missing)...\n", refStr)
49+
_, err = pullCatalog(ctx, dao, ociService, refStr)
4350
if err != nil {
4451
return fmt.Errorf("failed to pull missing catalog %s: %w", refStr, err)
4552
}
53+
// Reload the catalog after pulling
54+
dbCatalog, err = dao.GetCatalog(ctx, refStr)
55+
if err != nil {
56+
return fmt.Errorf("failed to get catalog %s: %w", refStr, err)
57+
}
4658
} else if err != nil {
4759
if errors.Is(err, sql.ErrNoRows) {
4860
return fmt.Errorf("catalog %s not found", refStr)
4961
}
5062
return fmt.Errorf("failed to get catalog: %w", err)
5163
}
5264

65+
if pullOption == PullOptionDuration && dbCatalog.LastUpdated != nil && time.Since(*dbCatalog.LastUpdated) > pullInterval {
66+
fmt.Fprintf(os.Stderr, "Pulling catalog %s... (last update was %s ago)\n", refStr, time.Since(*dbCatalog.LastUpdated).Round(time.Second))
67+
_, err := pullCatalog(ctx, dao, ociService, refStr)
68+
if err != nil {
69+
return fmt.Errorf("failed to pull catalog %s: %w", refStr, err)
70+
}
71+
// Reload the catalog
72+
dbCatalog, err = dao.GetCatalog(ctx, refStr)
73+
if err != nil {
74+
return fmt.Errorf("failed to get catalog %s: %w", refStr, err)
75+
}
76+
}
77+
5378
catalog := NewFromDb(dbCatalog)
5479

5580
var data []byte
@@ -86,3 +111,29 @@ func printHumanReadable(catalog CatalogWithDigest) string {
86111
servers = strings.TrimSuffix(servers, "\n")
87112
return fmt.Sprintf("Reference: %s\nTitle: %s\nSource: %s\nServers:\n%s", catalog.Ref, catalog.Title, catalog.Source, servers)
88113
}
114+
115+
func parsePullOption(pullOptionParam string) (PullOption, time.Duration, error) {
116+
if pullOptionParam == "" {
117+
return PullOptionNever, 0, nil
118+
}
119+
120+
var pullOption PullOption
121+
var pullInterval time.Duration
122+
isPullOption := slices.Contains(SupportedPullOptions(), pullOptionParam)
123+
if isPullOption {
124+
pullOption = PullOption(pullOptionParam)
125+
} else {
126+
// Maybe duration
127+
duration, err := time.ParseDuration(pullOptionParam)
128+
if err != nil {
129+
return PullOptionNever, 0, fmt.Errorf("failed to parse pull option %s: should be %s, or duration (e.g. '1h', '1d')", pullOptionParam, strings.Join(SupportedPullOptions(), ", "))
130+
}
131+
if duration < 0 {
132+
return PullOptionNever, 0, fmt.Errorf("duration %s must be positive", duration)
133+
}
134+
pullOption = PullOptionDuration
135+
pullInterval = duration
136+
}
137+
138+
return pullOption, pullInterval, nil
139+
}

pkg/catalog_next/show_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,13 @@ func TestShowInvalidReferenceWithDigest(t *testing.T) {
208208
require.Error(t, err)
209209
assert.Contains(t, err.Error(), "reference test/invalid-reference@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 must be a valid OCI reference without a digest")
210210
}
211+
212+
// TODO(cody): Add tests for pull once we have proper mocks in place
213+
func TestInvalidPullOption(t *testing.T) {
214+
dao := setupTestDB(t)
215+
ctx := t.Context()
216+
217+
err := Show(ctx, dao, getMockOciService(), "test/catalog:latest", workingset.OutputFormatJSON, "invalid")
218+
require.Error(t, err)
219+
assert.Contains(t, err.Error(), "failed to parse pull option invalid: should be missing, never, always, or duration (e.g. '1h', '1d')")
220+
}

pkg/db/catalog.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"time"
910
)
1011

1112
type CatalogDAO interface {
@@ -18,11 +19,12 @@ type CatalogDAO interface {
1819
type ToolList []string
1920

2021
type Catalog struct {
21-
Ref string `db:"ref"`
22-
Digest string `db:"digest"`
23-
Title string `db:"title"`
24-
Source string `db:"source"`
25-
Servers []CatalogServer `db:"-"`
22+
Ref string `db:"ref"`
23+
Digest string `db:"digest"`
24+
Title string `db:"title"`
25+
Source string `db:"source"`
26+
LastUpdated *time.Time `db:"last_updated"`
27+
Servers []CatalogServer `db:"-"`
2628
}
2729

2830
type CatalogServer struct {
@@ -53,7 +55,7 @@ func (tools *ToolList) Scan(value any) error {
5355
}
5456

5557
func (d *dao) GetCatalog(ctx context.Context, ref string) (*Catalog, error) {
56-
const query = `SELECT ref, digest, title, source FROM catalog WHERE ref = $1`
58+
const query = `SELECT ref, digest, title, source, last_updated FROM catalog WHERE ref = $1`
5759

5860
var catalog Catalog
5961
err := d.db.GetContext(ctx, &catalog, query, ref)
@@ -88,7 +90,7 @@ func (d *dao) UpsertCatalog(ctx context.Context, catalog Catalog) error {
8890
return err
8991
}
9092

91-
const insertQuery = `INSERT INTO catalog (ref, digest, title, source) VALUES ($1, $2, $3, $4)`
93+
const insertQuery = `INSERT INTO catalog (ref, digest, title, source, last_updated) VALUES ($1, $2, $3, $4, current_timestamp)`
9294

9395
_, err = tx.ExecContext(ctx, insertQuery, catalog.Ref, catalog.Digest, catalog.Title, catalog.Source)
9496
if err != nil {
@@ -133,7 +135,7 @@ func (d *dao) ListCatalogs(ctx context.Context) ([]Catalog, error) {
133135
ServerJSON string `db:"server_json"`
134136
}
135137

136-
const query = `SELECT c.ref, c.digest, c.title, c.source,
138+
const query = `SELECT c.ref, c.digest, c.title, c.source, c.last_updated,
137139
COALESCE(
138140
json_group_array(json_object('id', s.id, 'server_type', s.server_type, 'tools', json(s.tools), 'source', s.source, 'image', s.image, 'snapshot', json(s.snapshot))),
139141
'[]'

pkg/db/catalog_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package db
33
import (
44
"database/sql"
55
"testing"
6+
"time"
67

78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
@@ -40,6 +41,8 @@ func TestCreateCatalogAndGetCatalog(t *testing.T) {
4041
assert.Equal(t, catalog.Source, retrieved.Source)
4142
assert.Len(t, retrieved.Servers, 1)
4243
assert.Equal(t, "registry", retrieved.Servers[0].ServerType)
44+
assert.NotNil(t, retrieved.LastUpdated)
45+
assert.WithinDuration(t, time.Now().UTC(), *retrieved.LastUpdated, 60*time.Second)
4346
}
4447

4548
func TestCreateCatalogWithEmptyServers(t *testing.T) {
@@ -272,6 +275,11 @@ func TestListCatalogsWithServersAndSnapshots(t *testing.T) {
272275
require.NoError(t, err)
273276
assert.Len(t, retrieved, 2)
274277

278+
assert.NotNil(t, retrieved[0].LastUpdated)
279+
assert.NotNil(t, retrieved[1].LastUpdated)
280+
assert.WithinDuration(t, time.Now().UTC(), *retrieved[0].LastUpdated, 60*time.Second)
281+
assert.WithinDuration(t, time.Now().UTC(), *retrieved[1].LastUpdated, 60*time.Second)
282+
275283
// Find catalog 1 and verify
276284
var cat1 *Catalog
277285
for i := range retrieved {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
alter table catalog add column last_updated DATETIME;
2+
update catalog set last_updated = current_timestamp;

0 commit comments

Comments
 (0)