Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dac3d33
refactor: share HTTP fetch logic
yejseo01 Jul 27, 2026
b1fe262
feat: load project catalog from Artifactory
yejseo01 Jul 27, 2026
9f947a2
fix: always register projects source flag
yejseo01 Jul 27, 2026
ee0cf35
remove unnecessary comment
yejseo01 Jul 27, 2026
a25db80
style: format catalog constants
yejseo01 Jul 27, 2026
2040321
refactor: use catalog URL as source default
yejseo01 Jul 28, 2026
051f6ad
refactor: let callers control fetch timeout
yejseo01 Jul 28, 2026
5b3f922
refactor: generate catalog types from schema
yejseo01 Jul 29, 2026
d948c4f
feat: derive catalog major version from schema release
yejseo01 Jul 30, 2026
09ac673
simplify error condition for url fetching
yejseo01 Jul 31, 2026
4bbfd1d
test: require OK status for fetch responses
yejseo01 Jul 31, 2026
4088273
refactor: remove unused catalog parsing context
yejseo01 Jul 31, 2026
1b69da0
docs: clarify catalog generator network requirement
yejseo01 Jul 31, 2026
e6921e1
refactor: remove catalog generation timeout
yejseo01 Jul 31, 2026
ce1b9fa
refactor: append catalog version to generated types
yejseo01 Jul 31, 2026
b832259
feat: report incompatible catalog schema versions
yejseo01 Jul 31, 2026
0a007b7
docs: add catalog type generation workflow
yejseo01 Aug 3, 2026
bf53d6a
improve unmarshal error message
yejseo01 Aug 4, 2026
f30b5d4
fix catalog version validation tests
yejseo01 Aug 4, 2026
c512308
remove preemptive SSH host key cleanup
yejseo01 Aug 4, 2026
f0a3cc0
test catalog unmarshalling errors
yejseo01 Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 5 additions & 24 deletions cmd/topo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@ var projectsCmd = &cobra.Command{

var projects []catalog.Project
var err error
source := getSource(cmd)
switch source {
case builtinProjects:
projects, err = catalog.ListBuiltinProjects()
default:
projects, err = catalog.ListProjectsFromURL(ctx, source)
source, err := cmd.Flags().GetString(sourceFlag)
if err != nil {
panic(fmt.Sprintf("internal error: %s flag not registered: %v", sourceFlag, err))
}
projects, err = catalog.ListProjectsFromURL(ctx, source)
if err != nil {
return err
}
Expand All @@ -56,23 +54,6 @@ var projectsCmd = &cobra.Command{
func init() {
addTargetFlag(projectsCmd)
addTimeoutFlag(projectsCmd, defaultTimeout)
if experimentalFeaturesEnabled() {
projectsCmd.Flags().StringP(sourceFlag, "s", "", "where to source projects' data from")
}
projectsCmd.Flags().StringP(sourceFlag, "s", catalog.DefaultCatalogURL, "where to source projects' data from")
rootCmd.AddCommand(projectsCmd)
}

const builtinProjects = "builtin"

func getSource(cmd *cobra.Command) string {
if experimentalFeaturesEnabled() {
flagValue, err := cmd.Flags().GetString(sourceFlag)
if err != nil {
panic(fmt.Sprintf("internal error: %s flag not registered: %v", sourceFlag, err))
}
if flagValue != "" {
return flagValue
}
}
return builtinProjects
}
16 changes: 16 additions & 0 deletions docs/development/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,19 @@ docker compose up
```

The documentation preview is available at `http://localhost:3000` and automatically reloads when files change.

## Updating the catalog schema

The catalog schema version is declared by the `go:generate` directive in `internal/catalog/catalog.go` and copied into `internal/catalog/catalog_schema_generated.go`. Its major component also selects the catalog version used by Topo.

Generating the Go types requires Node.js 20 or newer, `npx`, and access to the internet. From the repository root, run:

```sh
go generate ./internal/catalog
```
Comment thread
awphi marked this conversation as resolved.

The generator downloads the configured version's `catalog.schema.json`, generates the catalog Go types with the pinned Quicktype version, and rewrites `internal/catalog/catalog_schema_generated.go`. To use another schema release, update the directive in `internal/catalog/catalog.go` and rerun the command.

Commit the newly generated catalog schema types file and raise a PR to update the main branch.

Available catalog versions and schemas are published in the [Topo Project Catalog Artifactory repository](https://artifacts.tools.arm.com/devx-topo-project-catalog/).
116 changes: 31 additions & 85 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
package catalog

//go:generate go run ../../scripts/generate_catalog_types v2.0.0

import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"

"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/arm/topo/internal/fetch"
)

//go:embed data/catalog.json
var catalogJSON []byte

//go:embed data/catalog.schema.json
var catalogSchemaJSON []byte

type catalogDocument struct {
Schema string `json:"$schema,omitempty"`
Projects []Project `json:"projects"`
}
type Project = ProjectElement

type Project struct {
Name string `json:"name"`
Description string `json:"description"`
Features []string `json:"features"`
URL string `json:"url"`
Ref string `json:"ref"`
}
var (
majorCatalogVersion = majorVersion(CatalogSchemaVersion)
defaultURL = "https://artifacts.tools.arm.com/devx-topo-project-catalog/" + majorCatalogVersion + "/catalog/"
DefaultCatalogURL = defaultURL + "catalog.json"
)

func ListBuiltinProjects() ([]Project, error) {
return parseProjects(catalogJSON)
func majorVersion(version string) string {
major, _, _ := strings.Cut(version, ".")
return major
}

func ListProjectsFromURL(ctx context.Context, url string) ([]Project, error) {
Expand All @@ -47,39 +34,31 @@ func ListProjectsFromURL(ctx context.Context, url string) ([]Project, error) {
}

func parseProjects(b []byte) ([]Project, error) {
if err := validateAgainstSchema(b); err != nil {
return nil, fmt.Errorf("failed schema validation: %w", err)
catalogVersion, versionErr := unmarshalCatalogVersion(b)
schemaVersionMajor := majorVersion(CatalogSchemaVersion)
if majorVersion(catalogVersion) != schemaVersionMajor {
return nil, fmt.Errorf(
"failed to parse catalog: requested catalog version %q is incompatible with supported schema version %q: %w",
catalogVersion,
CatalogSchemaVersion,
versionErr,
)
}

var catalog catalogDocument
if err := json.Unmarshal(b, &catalog); err != nil {
return nil, fmt.Errorf("failed to unmarshal projects: %w", err)
catalog, err := UnmarshalCatalogDocument(b)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal catalog: %w", err)
}

return catalog.Projects, nil
Comment thread
awphi marked this conversation as resolved.
}

func validateAgainstSchema(b []byte) error {
const projectsSchemaURL = "https://raw.githubusercontent.com/arm/topo/main/internal/catalog/data/catalog.schema.json"

compiler := jsonschema.NewCompiler()
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(catalogSchemaJSON))
if err != nil {
return fmt.Errorf("failed to unmarshal schema: %w", err)
}
if err := compiler.AddResource(projectsSchemaURL, schemaDoc); err != nil {
return fmt.Errorf("failed to add schema resource: %w", err)
func unmarshalCatalogVersion(b []byte) (string, error) {
var header struct {
Version string `json:"version"`
}
schema, err := compiler.Compile(projectsSchemaURL)
if err != nil {
return fmt.Errorf("failed to compile schema: %w", err)
}

jsonDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b))
if err != nil {
return fmt.Errorf("failed to unmarshal projects: %w", err)
if err := json.Unmarshal(b, &header); err != nil {
return "", err
}
return schema.Validate(jsonDoc)
return header.Version, nil
}

func fetchProjectsJSON(ctx context.Context, url string) ([]byte, error) {
Expand All @@ -92,42 +71,9 @@ func fetchProjectsJSON(ctx context.Context, url string) ([]byte, error) {
return data, nil
}

data, err := httpGet(ctx, url)
data, err := fetch.Get(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to fetch project: %w", err)
}
return data, nil
}

func httpGet(ctx context.Context, rawURL string) ([]byte, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme)
}

req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
parsedURL.String(),
nil,
)
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req) // #nosec G704 -- URL is explicitly provided by the CLI user and scheme-validated above.
if err != nil {
return nil, err
}
defer resp.Body.Close() // nolint:errcheck

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
}

return io.ReadAll(resp.Body)
}
70 changes: 70 additions & 0 deletions internal/catalog/catalog_schema_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 37 additions & 12 deletions internal/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,6 @@ func TestListProjectsFromURL(t *testing.T) {
assert.Equal(t, projects, got)
})

t.Run("errors when payload doesn't validate against schema", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.json")
projects := []catalog.Project{{Name: "aloha"}}
testutil.RequireWriteFile(t, path, string(asJSON(projects)))

url := fmt.Sprintf("file://%s", path)
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
})

t.Run("errors when request fails", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
Expand All @@ -76,15 +65,51 @@ func TestListProjectsFromURL(t *testing.T) {
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
assert.ErrorContains(t, err, "failed to unmarshal projects")
assert.ErrorContains(t, err, "failed to parse catalog")
assert.ErrorContains(t, err, `requested catalog version "" is incompatible`)
})

t.Run("errors when catalog fails to unmarshal", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.json")
testutil.RequireWriteFile(t, path, fmt.Sprintf(
`{"projects":"invalid","version":%q}`,
catalog.CatalogSchemaVersion,
))

url := fmt.Sprintf("file://%s", path)
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
assert.ErrorContains(t, err, "failed to unmarshal catalog")
})

t.Run("reports incompatible catalog", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.json")
catalogVersion := "v0.0.0"
if catalogVersion == catalog.CatalogSchemaVersion {
catalogVersion = "v999.0.0"
}
testutil.RequireWriteFile(t, path, fmt.Sprintf(`{"projects":"invalid","version":%q}`, catalogVersion))

url := fmt.Sprintf("file://%s", path)
_, err := catalog.ListProjectsFromURL(context.Background(), url)

require.Error(t, err)
assert.ErrorContains(t, err, fmt.Sprintf(
`requested catalog version %q is incompatible with supported schema version %q`,
catalogVersion,
catalog.CatalogSchemaVersion,
))
})
}

func asJSON(projects []catalog.Project) []byte {
data, err := json.Marshal(struct {
Projects []catalog.Project `json:"projects"`
Version string `json:"version"`
}{
Projects: projects,
Version: catalog.CatalogSchemaVersion,
})
if err != nil {
panic(err)
Expand Down
Loading