diff --git a/cmd/agent.go b/cmd/agent.go index 77bcdae8..f39984b1 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -18,4 +18,6 @@ func init() { AgentCmd.AddCommand(agents.AgentCICmd) AgentCmd.AddCommand(agents.DepsCmd) AgentCmd.AddCommand(agents.InstallCmd) + AgentCmd.AddCommand(agents.VersionsCmd) + AgentCmd.AddCommand(agents.ListCmd) } diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go new file mode 100644 index 00000000..35ea7683 --- /dev/null +++ b/cmd/agents/versions.go @@ -0,0 +1,659 @@ +package agents + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "text/tabwriter" + + "github.com/blang/semver" + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/resources" + "github.com/google/go-github/v37/github" + "github.com/spf13/cobra" +) + +// ciPlatform is the os/arch a released agent must ship an asset for to be +// downloadable inside CI. A git tag can exist with no artifact for this +// platform — that gap (module-saas-starter#3) is exactly what these commands +// surface. +const ciPlatform = "linux_amd64" + +// Seams so the resolvability logic can be tested without reaching GitHub, an +// OCI registry, or the local filesystem. +var ( + fetchReleases = fetchReleasesFromGitHub + fetchTags = fetchTagsFromGitHub + fetchOCITags = fetchOCITagsFromRegistry +) + +// releaseInfo is one published GitHub release: the version it tags and the +// os_arch suffixes it ships a downloadable asset for. +type releaseInfo struct { + version string + platforms []string +} + +// sourceFlags records, per version, which sources can supply it. GithubRelease +// specifically means the CI-platform asset is present — the resolvability +// signal — regardless of which other platforms the release also ships. +type sourceFlags struct { + Tag bool `json:"tag"` + GithubRelease bool `json:"github_release"` + OCI bool `json:"oci"` + PinnedHere bool `json:"pinned_here"` + LocalCache bool `json:"local_cache"` +} + +func (f sourceFlags) resolvable() bool { + return f.GithubRelease || f.OCI +} + +type versionEntry struct { + Version string `json:"version"` + Sources sourceFlags `json:"sources"` + // ReleasePlatforms lists every os_arch the GitHub release ships an asset + // for, so a version downloadable only for the host (not the CI platform) + // isn't misread as having no artifact at all. + ReleasePlatforms []string `json:"release_platforms,omitempty"` + sem semver.Version +} + +type inventory struct { + Agent string `json:"agent"` + CIPlatform string `json:"ci_platform"` + OCIConfigured bool `json:"oci_configured"` + Versions []versionEntry `json:"versions"` + Pinned []string `json:"pinned,omitempty"` + LatestTag string `json:"latest_tag,omitempty"` + LatestResolvable string `json:"latest_resolvable,omitempty"` +} + +func (inv inventory) versionResolvable(version string) bool { + if version == "latest" { + return inv.LatestResolvable != "" + } + for _, entry := range inv.Versions { + if entry.Version == version { + return entry.Sources.resolvable() + } + } + return false +} + +var versionsJSON bool + +// VersionsCmd reports every known version of a single agent and whether each is +// resolvable per source (git tag, GitHub release asset, OCI manifest), plus the +// version pinned in the current workspace and what sits in the local cache. +var VersionsCmd = &cobra.Command{ + Use: "versions ", + Short: "List an agent's versions and whether each is resolvable", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + + // This command lists every version, so the specific version in the + // argument is irrelevant. Pin it to a placeholder so ParseAgent doesn't + // warn "no version specified, using latest" on the common bare form. + spec := args[0] + if !strings.Contains(spec, ":") { + spec += ":latest" + } + agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, spec) + if err != nil { + return fmt.Errorf("invalid agent: %w", err) + } + + inv := collectInventory(ctx, agent, pinnedVersions(ctx, agent)) + + if versionsJSON { + return writeJSON(inv) + } + renderInventory(inv) + return nil + }, +} + +var listJSON bool + +// ListCmd enumerates every agent pinned across the current workspace and, for +// each, whether the pinned version is resolvable, the latest resolvable +// version, and the latest tag — a one-shot "are all my pins publishable?" view. +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List every agent pinned in the workspace and its resolvability", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, done := common.NewContext() + defer done() + + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return err + } + pins, err := workspacePins(ctx, workspace) + if err != nil { + return err + } + + summaries := summarizeWorkspaceAgents(ctx, pins) + if listJSON { + return writeJSON(summaries) + } + renderSummaries(summaries) + return nil + }, +} + +func init() { + VersionsCmd.Flags().BoolVar(&versionsJSON, "json", false, "Emit the version inventory as JSON") + ListCmd.Flags().BoolVar(&listJSON, "json", false, "Emit the workspace agent inventory as JSON") +} + +// collectInventory gathers versions from every source and assembles the +// resolvability inventory. GitHub lookups that fail (missing repo, rate limit) +// degrade to a warning so the local-cache and pinned columns still render. +func collectInventory(ctx context.Context, agent *resources.Agent, pinned []string) inventory { + releases, err := fetchReleases(ctx, agent) + if err != nil { + cli.Warning("cannot list GitHub releases for %s/%s: %v", agent.Publisher, agent.Name, err) + } + tags, err := fetchTags(ctx, agent) + if err != nil { + cli.Warning("cannot list GitHub tags for %s/%s: %v", agent.Publisher, agent.Name, err) + } + local := localCacheVersions(ctx, agent) + ociConfigured, ociVersions, err := fetchOCITags(ctx, agent) + if err != nil { + cli.Warning("cannot list OCI tags for %s/%s: %v", agent.Publisher, agent.Name, err) + } + return buildInventory(agent, releases, tags, local, pinned, ociVersions, ociConfigured) +} + +// buildInventory is the pure assembly step: given the versions each source +// knows about, it unions them, flags every source per version, and derives the +// latest tag and latest resolvable version. ociVersions are the tags an OCI +// registry lists as available; a version present only there still surfaces. +func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, pinned, ociVersions []string, ociConfigured bool) inventory { + entries := map[string]*versionEntry{} + ensure := func(version string) (*versionEntry, bool) { + parsed, err := semver.Parse(strings.TrimPrefix(version, "v")) + if err != nil { + return nil, false + } + key := parsed.String() + entry, ok := entries[key] + if !ok { + entry = &versionEntry{Version: key, sem: parsed} + entries[key] = entry + } + return entry, true + } + + for _, release := range releases { + if entry, ok := ensure(release.version); ok { + entry.Sources.Tag = true + for _, platform := range release.platforms { + entry.ReleasePlatforms = appendUnique(entry.ReleasePlatforms, platform) + if platform == ciPlatform { + entry.Sources.GithubRelease = true + } + } + } + } + for _, tag := range tags { + if entry, ok := ensure(tag); ok { + entry.Sources.Tag = true + } + } + for _, version := range local { + if entry, ok := ensure(version); ok { + entry.Sources.LocalCache = true + } + } + for _, version := range pinned { + if entry, ok := ensure(version); ok { + entry.Sources.PinnedHere = true + } + } + for _, version := range ociVersions { + if entry, ok := ensure(version); ok { + entry.Sources.OCI = true + } + } + + inv := inventory{ + Agent: fmt.Sprintf("%s/%s", agent.Publisher, agent.Name), + CIPlatform: ciPlatform, + OCIConfigured: ociConfigured, + Pinned: pinned, + Versions: make([]versionEntry, 0, len(entries)), + } + var latestTag, latestResolvable *semver.Version + for _, entry := range entries { + slices.Sort(entry.ReleasePlatforms) + inv.Versions = append(inv.Versions, *entry) + if entry.Sources.Tag && (latestTag == nil || entry.sem.GT(*latestTag)) { + v := entry.sem + latestTag = &v + } + if entry.Sources.resolvable() && (latestResolvable == nil || entry.sem.GT(*latestResolvable)) { + v := entry.sem + latestResolvable = &v + } + } + sort.Slice(inv.Versions, func(i, j int) bool { + return inv.Versions[i].sem.GT(inv.Versions[j].sem) + }) + if latestTag != nil { + inv.LatestTag = latestTag.String() + } + if latestResolvable != nil { + inv.LatestResolvable = latestResolvable.String() + } + return inv +} + +// agentPin is a single service's pinned agent, tagged with the module the +// service lives in. +type agentPin struct { + module string + agent *resources.Agent +} + +type agentSummary struct { + Agent string `json:"agent"` + Pinned string `json:"pinned"` + PinnedResolvable bool `json:"pinned_resolvable"` + LatestResolvable string `json:"latest_resolvable"` + LatestTag string `json:"latest_tag"` + Modules []string `json:"modules,omitempty"` +} + +// workspacePins enumerates every service in the workspace and returns those +// that pin an agent, tagged with their module. +func workspacePins(ctx context.Context, workspace *resources.Workspace) ([]agentPin, error) { + refs, err := workspace.LoadServiceWithModules(ctx) + if err != nil { + return nil, fmt.Errorf("load workspace services: %w", err) + } + var pins []agentPin + for _, ref := range refs { + service, err := workspace.LoadService(ctx, ref) + if err != nil { + // A single broken service shouldn't blind the whole "are all my + // pins publishable?" overview — skip it with a warning. + cli.Warning("cannot load service %q: %v", ref.Name, err) + continue + } + if service.Agent == nil { + continue + } + pins = append(pins, agentPin{module: ref.Module, agent: service.Agent}) + } + return pins, nil +} + +// summarizeWorkspaceAgents builds one summary row per distinct pinned agent. +// Inventories are cached per publisher/name so agents pinned by several +// services only hit GitHub once. +func summarizeWorkspaceAgents(ctx context.Context, pins []agentPin) []agentSummary { + inventories := map[string]inventory{} + rows := map[string]*agentSummary{} + var order []string + + for _, pin := range pins { + agent := pin.agent + repoKey := fmt.Sprintf("%s/%s", agent.Publisher, agent.Name) + inv, ok := inventories[repoKey] + if !ok { + inv = collectInventory(ctx, agent, nil) + inventories[repoKey] = inv + } + pinKey := agent.Identifier() + row, ok := rows[pinKey] + if !ok { + row = &agentSummary{ + Agent: repoKey, + Pinned: agent.Version, + PinnedResolvable: inv.versionResolvable(agent.Version), + LatestResolvable: inv.LatestResolvable, + LatestTag: inv.LatestTag, + } + rows[pinKey] = row + order = append(order, pinKey) + } + if pin.module != "" { + row.Modules = appendUnique(row.Modules, pin.module) + } + } + + summaries := make([]agentSummary, 0, len(order)) + for _, key := range order { + summaries = append(summaries, *rows[key]) + } + sort.Slice(summaries, func(i, j int) bool { + if summaries[i].Agent != summaries[j].Agent { + return summaries[i].Agent < summaries[j].Agent + } + return summaries[i].Pinned < summaries[j].Pinned + }) + return summaries +} + +// pinnedVersions returns the versions of the given agent pinned by services in +// the current workspace. Best-effort: outside a workspace it returns nothing so +// `agent versions` still works from anywhere. +func pinnedVersions(ctx context.Context, agent *resources.Agent) []string { + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return nil + } + services, err := workspace.LoadServices(ctx) + if err != nil { + return nil + } + var versions []string + for _, service := range services { + if service.Agent == nil { + continue + } + if service.Agent.Publisher == agent.Publisher && service.Agent.Name == agent.Name { + versions = appendUnique(versions, service.Agent.Version) + } + } + return versions +} + +func fetchReleasesFromGitHub(ctx context.Context, agent *resources.Agent) ([]releaseInfo, error) { + client := newGitHubClient() + owner, repo := githubSource(agent) + var out []releaseInfo + opt := &github.ListOptions{PerPage: 100} + for { + releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opt) + if err != nil { + return nil, err + } + for _, release := range releases { + version := strings.TrimPrefix(release.GetTagName(), "v") + assetPrefix := fmt.Sprintf("service-%s_%s_", agent.Name, version) + var platforms []string + for _, asset := range release.Assets { + platform, ok := strings.CutPrefix(asset.GetName(), assetPrefix) + if !ok { + continue + } + if platform, ok := strings.CutSuffix(platform, ".tar.gz"); ok { + platforms = append(platforms, platform) + } + } + out = append(out, releaseInfo{version: version, platforms: platforms}) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return out, nil +} + +func fetchTagsFromGitHub(ctx context.Context, agent *resources.Agent) ([]string, error) { + client := newGitHubClient() + owner, repo := githubSource(agent) + var out []string + opt := &github.ListOptions{PerPage: 100} + for { + tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opt) + if err != nil { + return nil, err + } + for _, tag := range tags { + out = append(out, strings.TrimPrefix(tag.GetName(), "v")) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return out, nil +} + +// githubSource mirrors manager.toGithubSource (unexported): the publisher's +// dots become dashes and the repo is service-. +func githubSource(agent *resources.Agent) (owner, repo string) { + return strings.ReplaceAll(agent.Publisher, ".", "-"), "service-" + agent.Name +} + +// newGitHubClient returns a client authenticated with GITHUB_TOKEN/GH_TOKEN +// when either is set. Listing every version of every pinned agent multiplies +// requests fast, and the unauthenticated 60/hour limit turns this diagnostic +// flaky exactly when a workspace has many pins to check. +func newGitHubClient() *github.Client { + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("GH_TOKEN")) + } + if token == "" { + return github.NewClient(nil) + } + return github.NewClient(&http.Client{Transport: &tokenTransport{token: token}}) +} + +type tokenTransport struct { + token string +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(clone) +} + +func localCacheVersions(ctx context.Context, agent *resources.Agent) []string { + dir := filepath.Join(resources.AgentBase(ctx), "agents", agentSubdir(agent), agent.Publisher) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + prefix := agent.Name + "__" + var out []string + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + version := strings.TrimPrefix(name, prefix) + if _, err := semver.Parse(version); err != nil { + continue + } + out = append(out, version) + } + return out +} + +func agentSubdir(agent *resources.Agent) string { + switch { + case agent.IsApplication(): + return "applications" + case agent.IsToolbox(): + return "toolboxes" + case agent.IsService(): + return "services" + default: + return "modules" + } +} + +// fetchOCITagsFromRegistry lists the versions an OCI registry publishes for the +// agent via the distribution-spec tags/list endpoint. Listing (rather than +// probing known versions one at a time) is what lets a version published only +// to OCI still appear in the inventory. The first return reports whether a +// registry is configured at all, so the OCI column can distinguish "absent" +// from "not checked". +func fetchOCITagsFromRegistry(ctx context.Context, agent *resources.Agent) (bool, []string, error) { + registry := strings.TrimSpace(os.Getenv("AGENT_REGISTRY")) + if registry == "" { + return false, nil, nil + } + scheme := strings.TrimSpace(os.Getenv("AGENT_REGISTRY_SCHEME")) + if scheme == "" { + if strings.HasPrefix(registry, "localhost") || strings.HasPrefix(registry, "127.0.0.1") { + scheme = "http" + } else { + scheme = "https" + } + } + url := fmt.Sprintf("%s://%s/v2/agents/%s/%s/tags/list", scheme, registry, agent.Publisher, agent.Name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return true, nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return true, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return true, nil, fmt.Errorf("registry returned %d", resp.StatusCode) + } + var payload struct { + Tags []string `json:"tags"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return true, nil, err + } + for i := range payload.Tags { + payload.Tags[i] = strings.TrimPrefix(payload.Tags[i], "v") + } + return true, payload.Tags, nil +} + +func renderInventory(inv inventory) { + cli.Header(1, "%s", inv.Agent) + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "VERSION\tTAG\tGITHUB-RELEASE\tOCI\tPINNED-HERE\tLOCAL-CACHE") + for _, entry := range inv.Versions { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + entry.Version, + presence(entry.Sources.Tag), + releaseCell(entry), + ociMark(entry.Sources.OCI, inv.OCIConfigured), + presence(entry.Sources.PinnedHere), + presence(entry.Sources.LocalCache), + ) + } + _ = tw.Flush() + + fmt.Printf("\nGitHub-release: ✓/✗ = %s asset present (CI-downloadable); any other platforms shipped are listed in parentheses\n", inv.CIPlatform) + if !inv.OCIConfigured { + fmt.Println("OCI column: not checked (set AGENT_REGISTRY)") + } + fmt.Printf("latest tag -> %s\n", dashIfEmpty(inv.LatestTag)) + fmt.Printf("latest resolvable -> %s\n", dashIfEmpty(inv.LatestResolvable)) + if inv.LatestTag != "" && !inv.versionResolvable(inv.LatestTag) { + fmt.Printf(" warning: latest tag %s has no downloadable artifact\n", inv.LatestTag) + } + for _, pin := range inv.Pinned { + fmt.Printf("pinned -> %s (resolvable: %s)\n", pin, yesNo(inv.versionResolvable(pin))) + } +} + +func renderSummaries(summaries []agentSummary) { + if len(summaries) == 0 { + cli.Info("No agents pinned in the workspace") + return + } + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "AGENT\tPINNED\tRESOLVABLE\tLATEST-RESOLVABLE\tLATEST-TAG\tMODULES") + for _, summary := range summaries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + summary.Agent, + summary.Pinned, + yesNo(summary.PinnedResolvable), + dashIfEmpty(summary.LatestResolvable), + dashIfEmpty(summary.LatestTag), + dashIfEmpty(strings.Join(summary.Modules, ", ")), + ) + } + _ = tw.Flush() +} + +func writeJSON(payload any) error { + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} + +// releaseCell renders the GitHub-release column. ✓/✗ reflects whether the +// CI-platform asset (the resolvability signal) is present; any platforms the +// release actually ships are listed so a host-only asset isn't misread as +// "no artifact at all". +func releaseCell(entry versionEntry) string { + if entry.Sources.GithubRelease { + return "✓ " + strings.Join(entry.ReleasePlatforms, ",") + } + if len(entry.ReleasePlatforms) > 0 { + return "✗ (" + strings.Join(entry.ReleasePlatforms, ",") + ")" + } + return "✗" +} + +// mark is for columns where absence is a meaningful "no" (a release asset that +// isn't there): present ✓, absent ✗. +func mark(ok bool) string { + if ok { + return "✓" + } + return "✗" +} + +// presence is for columns where absence is just "not applicable" (this version +// isn't tagged / pinned / cached) rather than a failure: present ✓, absent "-". +func presence(ok bool) string { + if ok { + return "✓" + } + return "-" +} + +func ociMark(ok, configured bool) string { + if !configured { + return "-" + } + return mark(ok) +} + +func yesNo(ok bool) string { + if ok { + return "yes" + } + return "no" +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} + +func appendUnique(list []string, value string) []string { + if slices.Contains(list, value) { + return list + } + return append(list, value) +} diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go new file mode 100644 index 00000000..029c3b5d --- /dev/null +++ b/cmd/agents/versions_test.go @@ -0,0 +1,332 @@ +package agents + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/agents/manager" + "github.com/codefly-dev/core/resources" +) + +func redisAgent() *resources.Agent { + return &resources.Agent{ + Kind: resources.ServiceAgent, + Publisher: "codefly.dev", + Name: "redis", + Version: "0.0.74", + } +} + +func findVersion(inv inventory, version string) (versionEntry, bool) { + for _, entry := range inv.Versions { + if entry.Version == version { + return entry, true + } + } + return versionEntry{}, false +} + +func TestBuildInventoryUnionsSourcesAndFlags(t *testing.T) { + releases := []releaseInfo{ + {version: "0.0.74", platforms: []string{ciPlatform, "darwin_arm64"}}, + {version: "0.0.73", platforms: []string{"darwin_arm64"}}, // release exists, but no CI asset + } + tags := []string{"0.0.74", "0.0.73", "0.0.56"} // 0.0.56 is tag-only + local := []string{"0.0.74", "0.0.10"} // 0.0.10 only in cache + pinned := []string{"0.0.74"} + + inv := buildInventory(redisAgent(), releases, tags, local, pinned, nil, false) + + cases := map[string]sourceFlags{ + "0.0.74": {Tag: true, GithubRelease: true, PinnedHere: true, LocalCache: true}, + "0.0.73": {Tag: true}, + "0.0.56": {Tag: true}, + "0.0.10": {LocalCache: true}, + } + if len(inv.Versions) != len(cases) { + t.Fatalf("versions = %d, want %d", len(inv.Versions), len(cases)) + } + for version, want := range cases { + entry, ok := findVersion(inv, version) + if !ok { + t.Fatalf("missing version %s", version) + } + if entry.Sources != want { + t.Fatalf("version %s sources = %+v, want %+v", version, entry.Sources, want) + } + } + + // The host-only release (0.0.73) keeps its platform for display even though + // it isn't CI-resolvable. + if entry, _ := findVersion(inv, "0.0.73"); len(entry.ReleasePlatforms) != 1 || entry.ReleasePlatforms[0] != "darwin_arm64" { + t.Fatalf("0.0.73 release platforms = %v, want [darwin_arm64]", entry.ReleasePlatforms) + } +} + +func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { + // The module-saas-starter#3 failure mode: the newest tag has no + // downloadable artifact, so latest-resolvable lags latest-tag. + releases := []releaseInfo{{version: "0.0.74", platforms: []string{ciPlatform}}} + tags := []string{"0.0.90", "0.0.74"} + + inv := buildInventory(redisAgent(), releases, tags, nil, nil, nil, false) + + if inv.LatestTag != "0.0.90" { + t.Fatalf("latest tag = %q, want 0.0.90", inv.LatestTag) + } + if inv.LatestResolvable != "0.0.74" { + t.Fatalf("latest resolvable = %q, want 0.0.74", inv.LatestResolvable) + } +} + +func TestBuildInventorySortsDescending(t *testing.T) { + tags := []string{"0.0.56", "0.0.74", "0.0.73"} + inv := buildInventory(redisAgent(), nil, tags, nil, nil, nil, false) + want := []string{"0.0.74", "0.0.73", "0.0.56"} + for i, entry := range inv.Versions { + if entry.Version != want[i] { + t.Fatalf("versions[%d] = %s, want %s", i, entry.Version, want[i]) + } + } +} + +func TestBuildInventoryOCIMakesVersionResolvable(t *testing.T) { + tags := []string{"0.0.74"} // tag-only, no release asset + ociVersions := []string{"0.0.74"} // but published to the OCI registry + pinned := []string{"0.0.74"} + + inv := buildInventory(redisAgent(), nil, tags, nil, pinned, ociVersions, true) + + entry, ok := findVersion(inv, "0.0.74") + if !ok || !entry.Sources.OCI { + t.Fatalf("OCI flag not set: %+v", entry.Sources) + } + if inv.LatestResolvable != "0.0.74" { + t.Fatalf("latest resolvable = %q, want 0.0.74 via OCI", inv.LatestResolvable) + } + if !inv.versionResolvable("0.0.74") { + t.Fatal("pinned version reported unresolvable despite OCI availability") + } +} + +func TestBuildInventorySurfacesOCIOnlyVersion(t *testing.T) { + // A version present only in the OCI registry — no git tag, no release, + // not cached, not pinned — must still appear as a resolvable row. + inv := buildInventory(redisAgent(), nil, nil, nil, nil, []string{"0.0.99"}, true) + + entry, ok := findVersion(inv, "0.0.99") + if !ok { + t.Fatal("OCI-only version 0.0.99 missing from inventory") + } + if entry.Sources.Tag || entry.Sources.GithubRelease { + t.Fatalf("OCI-only version wrongly flagged as tagged/released: %+v", entry.Sources) + } + if !entry.Sources.OCI || inv.LatestResolvable != "0.0.99" { + t.Fatalf("OCI-only version not resolvable: sources=%+v latest=%q", entry.Sources, inv.LatestResolvable) + } + if inv.LatestTag != "" { + t.Fatalf("latest tag = %q, want empty (no tags)", inv.LatestTag) + } +} + +func TestReleaseCellReflectsPlatforms(t *testing.T) { + cases := []struct { + name string + entry versionEntry + want string + }{ + {"ci asset present", versionEntry{Sources: sourceFlags{GithubRelease: true}, ReleasePlatforms: []string{"darwin_arm64", ciPlatform}}, "✓ darwin_arm64," + ciPlatform}, + {"host only, no ci asset", versionEntry{ReleasePlatforms: []string{"darwin_arm64"}}, "✗ (darwin_arm64)"}, + {"no assets at all", versionEntry{}, "✗"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := releaseCell(tc.entry); got != tc.want { + t.Fatalf("releaseCell = %q, want %q", got, tc.want) + } + }) + } +} + +func TestVersionResolvableHandlesLatest(t *testing.T) { + inv := inventory{LatestResolvable: "0.0.74"} + if !inv.versionResolvable("latest") { + t.Fatal("latest should be resolvable when a resolvable version exists") + } + empty := inventory{} + if empty.versionResolvable("latest") { + t.Fatal("latest should be unresolvable when nothing is resolvable") + } +} + +func TestBuildInventorySkipsNonSemverTags(t *testing.T) { + inv := buildInventory(redisAgent(), nil, []string{"main", "0.0.74", "v-broken"}, nil, nil, nil, false) + if len(inv.Versions) != 1 || inv.Versions[0].Version != "0.0.74" { + t.Fatalf("versions = %+v, want only 0.0.74", inv.Versions) + } +} + +func TestLocalCacheVersionsScansAgentDir(t *testing.T) { + home := t.TempDir() + t.Setenv("CODEFLY_HOME", home) + + agent := redisAgent() + dir := filepath.Join(home, "agents", "services", agent.Publisher) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"redis__0.0.74", "redis__0.0.10", "redis__notsemver", "vault__0.0.1"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + versions := localCacheVersions(context.Background(), agent) + if len(versions) != 2 { + t.Fatalf("versions = %v, want redis 0.0.74 and 0.0.10", versions) + } + joined := strings.Join(versions, ",") + if !strings.Contains(joined, "0.0.74") || !strings.Contains(joined, "0.0.10") { + t.Fatalf("versions = %v, want both redis versions", versions) + } +} + +func TestTokenTransportAddsAuthorization(t *testing.T) { + var got string + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + })) + defer server.Close() + + client := &http.Client{Transport: &tokenTransport{token: "secret"}} + resp, err := client.Get(server.URL) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if got != "Bearer secret" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer secret") + } +} + +func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { + restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags + defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }() + + var releaseCalls int + fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) { + releaseCalls++ + if agent.Name == "redis" { + return []releaseInfo{{version: "0.0.74", platforms: []string{ciPlatform}}}, nil + } + return nil, nil // vault: tag-only, unpublished + } + fetchTags = func(_ context.Context, agent *resources.Agent) ([]string, error) { + if agent.Name == "redis" { + return []string{"0.0.74"}, nil + } + return []string{"0.0.15"}, nil + } + fetchOCITags = func(_ context.Context, _ *resources.Agent) (bool, []string, error) { + return false, nil, nil + } + + pins := []agentPin{ + {module: "cache", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}}, + {module: "session", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}}, + {module: "secrets", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "vault", Version: "0.0.15"}}, + } + + summaries := summarizeWorkspaceAgents(context.Background(), pins) + + if releaseCalls != 2 { + t.Fatalf("release lookups = %d, want 2 (one per distinct agent repo)", releaseCalls) + } + if len(summaries) != 2 { + t.Fatalf("summaries = %d, want 2 distinct pins", len(summaries)) + } + + byAgent := map[string]agentSummary{} + for _, summary := range summaries { + byAgent[summary.Agent] = summary + } + redis := byAgent["codefly.dev/redis"] + if !redis.PinnedResolvable { + t.Fatal("redis pin should be resolvable") + } + if len(redis.Modules) != 2 { + t.Fatalf("redis modules = %v, want cache and session", redis.Modules) + } + vault := byAgent["codefly.dev/vault"] + if vault.PinnedResolvable { + t.Fatal("vault pin should be unresolvable (tag-only)") + } + if vault.LatestResolvable != "" { + t.Fatalf("vault latest resolvable = %q, want empty", vault.LatestResolvable) + } + if vault.LatestTag != "0.0.15" { + t.Fatalf("vault latest tag = %q, want 0.0.15", vault.LatestTag) + } +} + +// TestGithubSourceMatchesManagerDownloadURL guards against drift: githubSource +// reimplements core's unexported owner/repo mapping, so pin it to the one +// exported source of truth (manager.DownloadURL) the actual download uses. +func TestGithubSourceMatchesManagerDownloadURL(t *testing.T) { + for _, agent := range []*resources.Agent{ + {Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}, + {Kind: resources.ServiceAgent, Publisher: "acme.co.uk", Name: "multi.part", Version: "1.2.3"}, + } { + owner, repo := githubSource(agent) + u, err := url.Parse(manager.DownloadURL(agent)) + if err != nil { + t.Fatalf("parse download URL: %v", err) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + t.Fatalf("unexpected download URL path %q", u.Path) + } + if parts[0] != owner || parts[1] != repo { + t.Fatalf("githubSource = %s/%s, download URL uses %s/%s", owner, repo, parts[0], parts[1]) + } + } +} + +func TestFetchOCITagsListsRegistryVersions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/agents/codefly.dev/redis/tags/list" { + w.Write([]byte(`{"name":"agents/codefly.dev/redis","tags":["0.0.74","v0.0.73"]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + t.Setenv("AGENT_REGISTRY", strings.TrimPrefix(server.URL, "http://")) + t.Setenv("AGENT_REGISTRY_SCHEME", "http") + + configured, versions, err := fetchOCITagsFromRegistry(context.Background(), redisAgent()) + if err != nil { + t.Fatal(err) + } + if !configured { + t.Fatal("registry configured but reported otherwise") + } + if strings.Join(versions, ",") != "0.0.74,0.0.73" { + t.Fatalf("versions = %v, want [0.0.74 0.0.73] with the v-prefix stripped", versions) + } +} + +func TestFetchOCITagsUnconfigured(t *testing.T) { + t.Setenv("AGENT_REGISTRY", "") + configured, versions, err := fetchOCITagsFromRegistry(context.Background(), redisAgent()) + if err != nil || configured || versions != nil { + t.Fatalf("unconfigured registry: configured=%v versions=%v err=%v", configured, versions, err) + } +} diff --git a/go.mod b/go.mod index 98d38344..46ddc924 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/fatih/color v1.19.0 github.com/go-git/go-git/v5 v5.19.1 + github.com/google/go-github/v37 v37.0.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/rs/cors v1.11.1 @@ -101,7 +102,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.28.0 // indirect - github.com/google/go-github/v37 v37.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect