Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion cmd/provider/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"github.com/spf13/cobra"
)

const currentMicrosandboxSource = "github.com/devsy-org/devsy-provider-microsandbox@v0.1.8"

// AddCmd holds the cmd flags.
type AddCmd struct {
*flags.GlobalFlags
Expand Down Expand Up @@ -177,7 +179,11 @@ func (cmd *AddCmd) resolveAddSource(
return resolvedAddSource{}, fmt.Errorf("specify either a URL or path, " +
"e.g. devsy provider add https://path/to/my/provider.yaml")
}
raw, source, err := provider.ResolveProvider(ctx, args[0])
sourceInput := args[0]
if sourceInput == provider.MicrosandboxDriver {
sourceInput = currentMicrosandboxSource
}
raw, source, err := provider.ResolveProvider(ctx, sourceInput)
if err != nil {
return resolvedAddSource{}, err
}
Expand Down
172 changes: 172 additions & 0 deletions cmd/provider/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package provider

import (
"bytes"
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

"github.com/devsy-org/devsy/cmd/flags"
"github.com/devsy-org/devsy/pkg/config"
devsyhttp "github.com/devsy-org/devsy/pkg/http"
providerpkg "github.com/devsy-org/devsy/pkg/provider"
"github.com/stretchr/testify/suite"
)

const externalMicrosandboxManifest = `name: microsandbox
version: v0.1.8
agent:
path: devsy
driver: external
external:
binary: DEVSY_RUNTIME_DRIVER
binaries:
DEVSY_RUNTIME_DRIVER:
- os: linux
arch: amd64
path: https://example.invalid/devsy-runtime
checksum: "0000000000000000000000000000000000000000000000000000000000000000"
exec:
command: echo hi
create: echo hi
delete: echo hi
`

type addDeprecationSuite struct {
suite.Suite
}

func TestAddDeprecationSuite(t *testing.T) {
suite.Run(t, new(addDeprecationSuite))
}

func (s *addDeprecationSuite) SetupTest() {
client := devsyhttp.GetHTTPClient()
previousTransport := client.Transport
client.Transport = providerAddRoundTripper(func(req *http.Request) (*http.Response, error) {
const manifestURL = "https://github.com/devsy-org/devsy-provider-microsandbox/" +
"releases/download/v0.1.8/provider.yaml"
if req.URL.String() != manifestURL {
s.T().Fatalf("unexpected source request: %s", req.URL)
}
return providerAddResponse(req, externalMicrosandboxManifest), nil
})
s.T().Cleanup(func() { client.Transport = previousTransport })
}

func (s *addDeprecationSuite) TestMicrosandboxAliasUsesPinnedExternalProvider() {
cmd := &AddCmd{GlobalFlags: &flags.GlobalFlags{}}
for _, tc := range []struct {
name string
want string
}{
{name: "custom-msb", want: "custom-msb"},
{want: providerpkg.MicrosandboxDriver},
} {
resolved, err := cmd.resolveAddSource(
context.Background(),
[]string{providerpkg.MicrosandboxDriver},
tc.name,
)
s.Require().NoError(err)
s.Require().Equal(tc.want, resolved.name)
s.Require().Equal(currentMicrosandboxSource, resolved.source.Raw)
s.Require().Equal("devsy-org/devsy-provider-microsandbox", resolved.source.Github)
parsed, err := providerpkg.ParseProvider(bytes.NewReader(resolved.raw))
s.Require().NoError(err)
s.Require().Equal(providerpkg.ExternalDriver, parsed.Agent.Driver)
}
}

func (s *addDeprecationSuite) TestCloneAndOtherSourcesRemainUnchanged() {
cmd := &AddCmd{GlobalFlags: &flags.GlobalFlags{}, FromExisting: "docker"}
resolved, err := cmd.resolveAddSource(
context.Background(),
[]string{providerpkg.MicrosandboxDriver},
"named-copy",
)
s.Require().NoError(err)
s.Require().Equal("named-copy", resolved.name)
s.Require().Nil(resolved.source)

cmd.FromExisting = ""
manifest := filepath.Join(s.T().TempDir(), "provider.yaml")
err = os.WriteFile(manifest, []byte("name: parsed\nagent:\n path: devsy\n"), 0o600)
s.Require().NoError(err)
resolved, err = cmd.resolveAddSource(context.Background(), []string{manifest}, "custom")
s.Require().NoError(err)
s.Require().Equal("custom", resolved.name)
s.Require().Equal(manifest, resolved.source.File)
}

func (s *addDeprecationSuite) TestExplicitDeprecatedSelectorUsesBuiltin() {
cmd := &AddCmd{GlobalFlags: &flags.GlobalFlags{}}
resolved, err := cmd.resolveAddSource(
context.Background(),
[]string{providerpkg.DeprecatedMicrosandboxSource},
"legacy-copy",
)
s.Require().NoError(err)
s.Require().Equal(providerpkg.MicrosandboxDriver, resolved.source.Raw)
s.Require().True(resolved.source.Internal)
s.Require().Equal("legacy-copy", resolved.name)
}

func (s *addDeprecationSuite) TestAliasDownloadFailureDoesNotInstallOrChangeDefault() {
s.T().Setenv("DEVSY_HOME", s.T().TempDir())
s.T().Setenv("DEVSY_CONFIG", "")
config.ResetPathManager()
s.T().Cleanup(config.ResetPathManager)
client := devsyhttp.GetHTTPClient()
client.Transport = providerAddRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("not found")),
Request: req,
}, nil
})
cmd := &AddCmd{GlobalFlags: &flags.GlobalFlags{}}
devsyConfig := &config.Config{
DefaultContext: config.DefaultContext,
Contexts: map[string]*config.ContextConfig{
config.DefaultContext: {
DefaultProvider: "docker",
Providers: map[string]*config.ProviderConfig{},
},
},
}
s.Require().NoError(config.SaveConfig(devsyConfig))
configPath, err := config.GetConfigPath()
s.Require().NoError(err)
before, err := os.ReadFile(filepath.Clean(configPath))
s.Require().NoError(err)
err = cmd.Run(context.Background(), devsyConfig, []string{providerpkg.MicrosandboxDriver})
s.Require().Error(err)
s.Require().NotContains(devsyConfig.Current().Providers, providerpkg.MicrosandboxDriver)
s.Require().Equal("docker", devsyConfig.Current().DefaultProvider)
after, err := os.ReadFile(filepath.Clean(configPath))
s.Require().NoError(err)
s.Require().Equal(before, after)
_, err = providerpkg.LoadProviderConfig(config.DefaultContext, providerpkg.MicrosandboxDriver)
s.Require().Error(err)
}

func providerAddResponse(req *http.Request, body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}
}

type providerAddRoundTripper func(*http.Request) (*http.Response, error)

func (roundTrip providerAddRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return roundTrip(req)
}
6 changes: 6 additions & 0 deletions cmd/provider/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,16 @@ func (cmd *ListCmd) renderInstalledPlain(
) error {
tableEntries := [][]string{}
for _, entry := range providers {
lifecycle := ""
if entry.Deprecated {
lifecycle = "deprecated"
}
tableEntries = append(tableEntries, []string{
entry.Config.Name,
entry.Config.Version,
strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name),
string(entry.Status),
lifecycle,
entry.Config.Description,
})
}
Expand All @@ -128,6 +133,7 @@ func (cmd *ListCmd) renderInstalledPlain(
"Version",
"Default",
"Status",
"Lifecycle",
"Description",
}, tableEntries)

Expand Down
113 changes: 113 additions & 0 deletions cmd/provider/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package provider

import (
"encoding/json"
"errors"
"io"
"os"
"testing"

"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/workspace"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

type listDeprecationSuite struct {
suite.Suite
}

const (
testMicrosandboxName = provider.MicrosandboxDriver
testLegacyName = "legacy"
)

func TestListDeprecationSuite(t *testing.T) {
suite.Run(t, new(listDeprecationSuite))
}

func (s *listDeprecationSuite) TestPlainListShowsLifecycleSeparately() {
cmd := &ListCmd{}
cfg := &config.Config{
DefaultContext: config.DefaultContext,
Contexts: map[string]*config.ContextConfig{
config.DefaultContext: {DefaultProvider: testMicrosandboxName},
},
}
entries := map[string]*workspace.ProviderWithOptions{
testMicrosandboxName: {
Config: &provider.ProviderConfig{
Name: testMicrosandboxName,
Version: "v1",
Description: testLegacyName,
},
Deprecated: true,
},
}
output := captureProviderListOutput(s.T(), func() error {
return cmd.renderInstalledPlain(cfg, entries)
})
s.Require().NoError(output.err)
s.Require().Contains(output.text, "Lifecycle")
s.Require().Contains(output.text, "deprecated")
s.Require().Contains(output.text, "true")
}

func (s *listDeprecationSuite) TestJSONListIncludesDeprecationMetadata() {
cmd := &ListCmd{}
cfg := &config.Config{
DefaultContext: config.DefaultContext,
Contexts: map[string]*config.ContextConfig{
config.DefaultContext: {DefaultProvider: testLegacyName},
},
}
entries := map[string]*workspace.ProviderWithOptions{
testLegacyName: {Config: &provider.ProviderConfig{Name: testLegacyName}, Deprecated: true},
"external": {Config: &provider.ProviderConfig{Name: testMicrosandboxName}},
}
output := captureProviderListOutput(s.T(), func() error {
return cmd.renderInstalledJSON(cfg, map[string]*config.ProviderConfig{}, entries)
})
s.Require().NoError(output.err)
var result map[string]map[string]json.RawMessage
s.Require().NoError(json.Unmarshal([]byte(output.text), &result))
var legacyDeprecated, legacyDefault bool
s.Require().NoError(json.Unmarshal(result[testLegacyName]["deprecated"], &legacyDeprecated))
s.Require().NoError(json.Unmarshal(result[testLegacyName]["default"], &legacyDefault))
s.Require().True(legacyDeprecated)
s.Require().True(legacyDefault)
s.Require().NotContains(result["external"], "deprecated")
var externalName string
var externalConfig map[string]json.RawMessage
s.Require().NoError(json.Unmarshal(result["external"]["config"], &externalConfig))
s.Require().NoError(json.Unmarshal(externalConfig["name"], &externalName))
s.Require().Equal("microsandbox", externalName)
}

type providerListOutput struct {
text string
err error
}

func captureProviderListOutput(t *testing.T, render func() error) providerListOutput {
t.Helper()
read, write, err := os.Pipe()
require.NoError(t, err)
previous := os.Stdout
os.Stdout = write
defer func() {
os.Stdout = previous
_ = write.Close()
_ = read.Close()
}()
renderErr := render()
os.Stdout = previous
closeErr := write.Close()
data, readErr := io.ReadAll(read)
readCloseErr := read.Close()
return providerListOutput{
text: string(data),
err: errors.Join(renderErr, closeErr, readErr, readCloseErr),
}
}
28 changes: 27 additions & 1 deletion desktop/src/main/__tests__/watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"
import { Watcher } from "../watcher.js"
import { parseProviderEntries, Watcher } from "../watcher.js"
import { WorkspaceJobs } from "../workspace-jobs.js"

function makeWatcher(
Expand Down Expand Up @@ -39,6 +39,32 @@ function makeWatcher(
return { watcher, cli, state, workspaceJobs }
}

describe("parseProviderEntries", () => {
it("maps the authoritative deprecation flag and defaults it for older CLI output", () => {
const [deprecated, legacy, external] = parseProviderEntries({
deprecated: {
config: { name: "custom-builtin-name" },
deprecated: true,
},
legacy: { config: { name: "legacy-provider" } },
external: {
config: {
name: "microsandbox",
source: { github: "example/provider" },
},
deprecated: false,
},
})

expect(deprecated).toMatchObject({
name: "custom-builtin-name",
deprecated: true,
})
expect(legacy).toMatchObject({ name: "legacy-provider", deprecated: false })
expect(external).toMatchObject({ name: "microsandbox", deprecated: false })
})
})

describe("Watcher.refreshProviders", () => {
it("does not run concurrently with another in-flight provider query", async () => {
let inFlight = 0
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/main/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ProviderEntry {
state?: { initialized?: boolean; singleMachine?: boolean }
status?: string
default?: boolean
deprecated?: boolean
}

export function parseProviderEntries(raw: Record<string, ProviderEntry>) {
Expand All @@ -50,6 +51,7 @@ export function parseProviderEntries(raw: Record<string, ProviderEntry>) {
options: entry.config.options ?? {},
optionGroups: entry.config.optionGroups ?? [],
isDefault: entry.default ?? false,
deprecated: entry.deprecated ?? false,
status: entry.status ?? "not_initialized",
state: {
initialized: entry.state?.initialized ?? false,
Expand Down
Loading
Loading