diff --git a/cmd/provider/add.go b/cmd/provider/add.go
index 62da74122..dd4096233 100644
--- a/cmd/provider/add.go
+++ b/cmd/provider/add.go
@@ -3,6 +3,7 @@ package provider
import (
"context"
"fmt"
+ "strings"
"github.com/loft-sh/devpod/cmd/flags"
"github.com/loft-sh/devpod/pkg/config"
@@ -21,7 +22,8 @@ type AddCmd struct {
SingleMachine bool
Options []string
- Name string
+ Name string
+ FromExisting string
}
// NewAddCmd creates a new command
@@ -32,6 +34,13 @@ func NewAddCmd(flags *flags.GlobalFlags) *cobra.Command {
addCmd := &cobra.Command{
Use: "add",
Short: "Adds a new provider to DevPod",
+ PreRunE: func(cobraCommand *cobra.Command, args []string) error {
+ if cmd.FromExisting != "" {
+ return cobraCommand.MarkFlagRequired("name")
+ }
+
+ return nil
+ },
RunE: func(_ *cobra.Command, args []string) error {
ctx := context.Background()
devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
@@ -45,30 +54,48 @@ func NewAddCmd(flags *flags.GlobalFlags) *cobra.Command {
addCmd.Flags().BoolVar(&cmd.SingleMachine, "single-machine", false, "If enabled will use a single machine for all workspaces")
addCmd.Flags().StringVar(&cmd.Name, "name", "", "The name to use for this provider. If empty will use the name within the loaded config")
+ addCmd.Flags().StringVar(&cmd.FromExisting, "from-existing", "", "The name of an existing provider to use as a template. Needs to be used in conjunction with the --name flag")
addCmd.Flags().BoolVar(&cmd.Use, "use", true, "If enabled will automatically activate the provider")
addCmd.Flags().StringSliceVarP(&cmd.Options, "option", "o", []string{}, "Provider option in the form KEY=VALUE")
+
return addCmd
}
func (cmd *AddCmd) Run(ctx context.Context, devPodConfig *config.Config, args []string) error {
- if len(args) != 1 {
+ if len(args) != 1 && cmd.FromExisting == "" {
return fmt.Errorf("please specify either a local file, url or git repository. E.g. devpod provider add https://path/to/my/provider.yaml")
} else if cmd.Name != "" && provider.ProviderNameRegEx.MatchString(cmd.Name) {
return fmt.Errorf("provider name can only include smaller case letters, numbers or dashes")
} else if cmd.Name != "" && len(cmd.Name) > 32 {
return fmt.Errorf("provider name cannot be longer than 32 characters")
+ } else if cmd.FromExisting != "" && devPodConfig.Current() != nil && devPodConfig.Current().Providers[cmd.FromExisting] == nil {
+ return fmt.Errorf("provider %s does not exist", cmd.FromExisting)
}
- providerConfig, err := workspace.AddProvider(devPodConfig, cmd.Name, args[0], log.Default)
- if err != nil {
- return err
+ var providerConfig *provider.ProviderConfig
+ var options []string
+ if cmd.FromExisting != "" {
+ providerWithOptions, err := workspace.CloneProvider(devPodConfig, cmd.Name, cmd.FromExisting, log.Default)
+ if err != nil {
+ return err
+ }
+
+ providerConfig = providerWithOptions.Config
+ options = mergeOptions(providerWithOptions.Config.Options, providerWithOptions.State.Options, cmd.Options)
+ } else {
+ c, err := workspace.AddProvider(devPodConfig, cmd.Name, args[0], log.Default)
+ if err != nil {
+ return err
+ }
+ providerConfig = c
+ options = cmd.Options
}
log.Default.Donef("Successfully installed provider %s", providerConfig.Name)
if cmd.Use {
- configureErr := configureProvider(ctx, providerConfig, devPodConfig.DefaultContext, cmd.Options, true, &cmd.SingleMachine)
+ configureErr := configureProvider(ctx, providerConfig, devPodConfig.DefaultContext, options, true, &cmd.SingleMachine)
if configureErr != nil {
- devPodConfig, err = config.LoadConfig(cmd.Context, "")
+ devPodConfig, err := config.LoadConfig(cmd.Context, "")
if err != nil {
return err
}
@@ -88,3 +115,41 @@ func (cmd *AddCmd) Run(ctx context.Context, devPodConfig *config.Config, args []
log.Default.Infof("devpod provider use %s", providerConfig.Name)
return nil
}
+
+// mergeOptions combines user options with existing options, user provided options take precedence
+func mergeOptions(desiredOptions map[string]*provider.ProviderOption, stateOptions map[string]config.OptionValue, userOptions []string) []string {
+ retOptions := []string{}
+ for key := range desiredOptions {
+ userOption, ok := getUserOption(userOptions, key)
+ if ok {
+ retOptions = append(retOptions, userOption)
+ continue
+ }
+ stateOption, ok := stateOptions[key]
+ if !ok {
+ continue
+ }
+ retOptions = append(retOptions, fmt.Sprintf("%s=%s", key, stateOption.Value))
+ }
+
+ return retOptions
+}
+
+func getUserOption(allOptions []string, optionKey string) (string, bool) {
+ if len(allOptions) == 0 {
+ return "", false
+ }
+
+ for _, option := range allOptions {
+ splitted := strings.Split(option, "=")
+ if len(splitted) == 1 {
+ // ignore
+ continue
+ }
+ if splitted[0] == optionKey {
+ return option, true
+ }
+ }
+
+ return "", false
+}
diff --git a/desktop/src/components/Error/ErrorMessageBox.tsx b/desktop/src/components/Error/ErrorMessageBox.tsx
index d8e7095ba..77cbe9af9 100644
--- a/desktop/src/components/Error/ErrorMessageBox.tsx
+++ b/desktop/src/components/Error/ErrorMessageBox.tsx
@@ -1,8 +1,8 @@
-import { Box, Text, useColorModeValue } from "@chakra-ui/react"
+import { Box, BoxProps, Text, useColorModeValue } from "@chakra-ui/react"
import React from "react"
-type TErrorMessageBox = Readonly<{ error: Error }>
-export function ErrorMessageBox({ error }: TErrorMessageBox) {
+type TErrorMessageBox = Readonly<{ error: Error }> & BoxProps
+export function ErrorMessageBox({ error, ...boxProps }: TErrorMessageBox) {
const backgroundColor = useColorModeValue("red.50", "red.100")
const textColor = useColorModeValue("red.700", "red.800")
@@ -12,7 +12,8 @@ export function ErrorMessageBox({ error }: TErrorMessageBox) {
paddingY="1"
paddingX="2"
borderRadius="md"
- display="inline-block">
+ display="inline-block"
+ {...boxProps}>
{error.message.split("\n").map((line, index) => (
diff --git a/desktop/src/types.ts b/desktop/src/types.ts
index 29c790f20..ab998e1cf 100644
--- a/desktop/src/types.ts
+++ b/desktop/src/types.ts
@@ -64,6 +64,7 @@ type TProviderSource = Readonly<{
github: string | null
file: string | null
url: string | null
+ raw: string | null
}>
export type TProviderOptions = Record
export type TProviderOption = Readonly<{
diff --git a/desktop/src/views/Providers/AddProvider/ConfigureProviderOptionsForm.tsx b/desktop/src/views/Providers/AddProvider/ConfigureProviderOptionsForm.tsx
index 0f93ef658..8e032d8f0 100644
--- a/desktop/src/views/Providers/AddProvider/ConfigureProviderOptionsForm.tsx
+++ b/desktop/src/views/Providers/AddProvider/ConfigureProviderOptionsForm.tsx
@@ -13,7 +13,6 @@ import {
PopoverContent,
PopoverTrigger,
SimpleGrid,
- Text,
Tooltip,
VStack,
useBreakpointValue,
@@ -333,7 +332,9 @@ export function ConfigureProviderOptionsForm({
/>
- {isError(error) && }
+ {isError(error) && (
+
+ )}
diff --git a/desktop/src/views/Providers/AddProvider/LoadingProviderIndicator.tsx b/desktop/src/views/Providers/AddProvider/LoadingProviderIndicator.tsx
new file mode 100644
index 000000000..2f354dcc8
--- /dev/null
+++ b/desktop/src/views/Providers/AddProvider/LoadingProviderIndicator.tsx
@@ -0,0 +1,43 @@
+import { Box, HStack, Text } from "@chakra-ui/react"
+
+export function LoadingProviderIndicator({ label }: Readonly<{ label: string | undefined }>) {
+ return (
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/desktop/src/views/Providers/AddProvider/SetupClonedProvider.tsx b/desktop/src/views/Providers/AddProvider/SetupClonedProvider.tsx
new file mode 100644
index 000000000..660865c7c
--- /dev/null
+++ b/desktop/src/views/Providers/AddProvider/SetupClonedProvider.tsx
@@ -0,0 +1,133 @@
+import {
+ Code,
+ FormControl,
+ FormErrorMessage,
+ FormHelperText,
+ FormLabel,
+ VStack,
+} from "@chakra-ui/react"
+import styled from "@emotion/styled"
+import { Controller, useForm } from "react-hook-form"
+import { ErrorMessageBox } from "../../../components"
+import { useProviders } from "../../../contexts"
+import { exists, isError, useFormErrors } from "../../../lib"
+import { CustomNameInput } from "./SetupProviderSourceForm"
+import { ALLOWED_NAMES_REGEX, mergeProviderOptions } from "./helpers"
+import { FieldName, TCloneProviderInfo, TFormValues, TSetupProviderResult } from "./types"
+import { useAddProvider } from "./useAddProvider"
+import { useCallback } from "react"
+import { LoadingProviderIndicator } from "./LoadingProviderIndicator"
+
+const Form = styled.form`
+ width: 100%;
+ display: flex;
+ flex-flow: column nowrap;
+ justify-content: center;
+`
+
+type TCloneProviderProps = Readonly<{
+ isModal?: boolean
+ cloneProviderInfo: TCloneProviderInfo
+ onFinish: (result: TSetupProviderResult) => void
+ reset: () => void
+}>
+export function SetupClonedProvider({ cloneProviderInfo, onFinish, reset }: TCloneProviderProps) {
+ const [[providers]] = useProviders()
+ const { handleSubmit, formState, control, watch } = useForm({
+ defaultValues: {
+ [FieldName.PROVIDER_SOURCE]: cloneProviderInfo.sourceProviderSource,
+ },
+ })
+ const newProviderName = watch(FieldName.PROVIDER_NAME)
+ const { providerNameError } = useFormErrors([FieldName.PROVIDER_NAME], formState)
+ const {
+ mutate: addProvider,
+ status,
+ error,
+ } = useAddProvider({
+ onSuccess(result) {
+ const oldProvider = cloneProviderInfo.sourceProvider
+
+ onFinish({
+ providerID: result.providerID,
+ optionGroups: result.optionGroups,
+ options: mergeProviderOptions(oldProvider.state?.options, result.options),
+ })
+ },
+ onError() {
+ reset()
+ },
+ })
+ const onSubmit = useCallback(
+ async (values: TFormValues) => {
+ addProvider({
+ rawProviderSource: values[FieldName.PROVIDER_SOURCE],
+ config: { name: values[FieldName.PROVIDER_NAME] },
+ })
+ // gotta merge the options with the existing state now
+ },
+ [addProvider]
+ )
+ const isLoading = status === "loading"
+
+ return (
+ <>
+
+ {
+
+ }
+
+ >
+ )
+}
diff --git a/desktop/src/views/Providers/AddProvider/SetupProviderSourceForm.tsx b/desktop/src/views/Providers/AddProvider/SetupProviderSourceForm.tsx
index 6ee8fb2c4..c4da5d604 100644
--- a/desktop/src/views/Providers/AddProvider/SetupProviderSourceForm.tsx
+++ b/desktop/src/views/Providers/AddProvider/SetupProviderSourceForm.tsx
@@ -20,7 +20,7 @@ import {
useBreakpointValue,
} from "@chakra-ui/react"
import styled from "@emotion/styled"
-import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useEffect, useRef, useState } from "react"
import { Controller, ControllerRenderProps, SubmitHandler, useForm } from "react-hook-form"
@@ -33,14 +33,10 @@ import { useProviders } from "../../../contexts"
import { CustomSvg } from "../../../images"
import { exists, isError, randomString, useFormErrors } from "../../../lib"
import { QueryKeys } from "../../../queryKeys"
-import {
- TAddProviderConfig,
- TProviderID,
- TProviderOptionGroup,
- TProviderOptions,
- TWithProviderID,
-} from "../../../types"
-import { FieldName, TFormValues } from "./types"
+import { TProviderID } from "../../../types"
+import { LoadingProviderIndicator } from "./LoadingProviderIndicator"
+import { FieldName, TFormValues, TSetupProviderResult } from "./types"
+import { useAddProvider } from "./useAddProvider"
const Form = styled.form`
width: 100%;
@@ -53,10 +49,7 @@ const ALLOWED_NAMES_REGEX = /^[a-z0-9\\-]+$/
type TSetupProviderSourceFormProps = Readonly<{
suggestedProvider?: TProviderID
reset: () => void
- onFinish: (
- result: TWithProviderID &
- Readonly<{ options: TProviderOptions; optionGroups: TProviderOptionGroup[] }>
- ) => void
+ onFinish: (result: TSetupProviderResult) => void
removeDanglingProviders: VoidFunction
}>
export function SetupProviderSourceForm({
@@ -80,56 +73,7 @@ export function SetupProviderSourceForm({
status,
error,
reset: resetAddProvider,
- } = useMutation({
- mutationFn: async ({
- rawProviderSource,
- config,
- }: Readonly<{
- rawProviderSource: string
- config: TAddProviderConfig
- }>) => {
- // check if provider exists and is not initialized
- const providerID = config.name || (await client.providers.newID(rawProviderSource)).unwrap()
- if (!providerID) {
- throw new Error(`Couldn't find provider id`)
- }
-
- // list all providers
- let providers = (await client.providers.listAll()).unwrap()
- if (providers?.[providerID]) {
- if (!providers[providerID]?.state?.initialized) {
- ;(await client.providers.remove(providerID)).unwrap()
- } else {
- throw new Error(
- `Provider with name ${providerID} already exists, please choose a different name`
- )
- }
- }
-
- // add provider
- ;(await client.providers.add(rawProviderSource, config)).unwrap()
-
- // get options
- let options: TProviderOptions | undefined
- try {
- options = (await client.providers.getOptions(providerID!)).unwrap()
- } catch (e) {
- ;(await client.providers.remove(providerID)).unwrap()
- throw e
- }
-
- // check if provider could be added
- providers = (await client.providers.listAll()).unwrap()
- if (!providers?.[providerID!]) {
- throw new Error(`Provider ${providerID} couldn't be found`)
- }
-
- return {
- providerID: providerID!,
- options: options!,
- optionGroups: providers[providerID!]?.config?.optionGroups || [],
- }
- },
+ } = useAddProvider({
onSuccess(result) {
queryClient.invalidateQueries(QueryKeys.PROVIDERS)
setValue(FieldName.PROVIDER_NAME, undefined, { shouldDirty: true })
@@ -406,7 +350,9 @@ export function SetupProviderSourceForm({
{status === "error" && isError(error) && }
- {isLoading && }
+ {isLoading && (
+
+ )}
>
@@ -417,9 +363,10 @@ type TCustomNameInputProps = Readonly<{
field: ControllerRenderProps
isInvalid: boolean
onAccept: () => void
+ isDisabled?: boolean
}> &
InputProps
-function CustomNameInput({ field, isInvalid, onAccept }: TCustomNameInputProps) {
+export function CustomNameInput({ field, isInvalid, onAccept, isDisabled }: TCustomNameInputProps) {
return (
}
onClick={() => onAccept()}>
Save
@@ -502,45 +449,3 @@ function CustomProviderInput({ field, isInvalid, onAccept }: TCustomProviderInpu
)
}
-
-function LoadingProvider({ name }: Readonly<{ name: string | undefined }>) {
- return (
-
- Loading {name}
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/desktop/src/views/Providers/AddProvider/SetupProviderSteps.tsx b/desktop/src/views/Providers/AddProvider/SetupProviderSteps.tsx
index 3abcf72b2..cd83a5329 100644
--- a/desktop/src/views/Providers/AddProvider/SetupProviderSteps.tsx
+++ b/desktop/src/views/Providers/AddProvider/SetupProviderSteps.tsx
@@ -1,19 +1,24 @@
import { Box, Container, VStack } from "@chakra-ui/react"
import { useCallback, useEffect, useRef } from "react"
import { TProviderID } from "../../../types"
+import { SetupClonedProvider } from "./SetupClonedProvider"
import { ConfigureProviderOptionsForm } from "./ConfigureProviderOptionsForm"
import { SetupProviderSourceForm } from "./SetupProviderSourceForm"
+import { TCloneProviderInfo } from "./types"
import { useSetupProvider } from "./useSetupProvider"
type TSetupProviderStepsProps = Readonly<{
onFinish?: () => void
- suggestedProvider?: TProviderID
isModal?: boolean
+ suggestedProvider?: TProviderID
+ cloneProviderInfo?: TCloneProviderInfo
onProviderIDChanged?: (id: string | null) => void
}>
+
export function SetupProviderSteps({
onFinish,
suggestedProvider,
+ cloneProviderInfo,
onProviderIDChanged,
isModal = false,
}: TSetupProviderStepsProps) {
@@ -56,17 +61,28 @@ export function SetupProviderSteps({
return (
-
- {
completeSetupProvider(result)
scrollToElement(configureProviderRef.current)
}}
- removeDanglingProviders={removeDanglingProviders}
/>
-
+ ) : (
+
+ {
+ completeSetupProvider(result)
+ scrollToElement(configureProviderRef.current)
+ }}
+ removeDanglingProviders={removeDanglingProviders}
+ />
+
+ )}
diff --git a/desktop/src/views/Providers/AddProvider/helpers.ts b/desktop/src/views/Providers/AddProvider/helpers.ts
new file mode 100644
index 000000000..5b1c31259
--- /dev/null
+++ b/desktop/src/views/Providers/AddProvider/helpers.ts
@@ -0,0 +1,20 @@
+import { TProviderOptions } from "../../../types"
+
+export const ALLOWED_NAMES_REGEX = /^[a-z0-9\\-]+$/
+
+export function mergeProviderOptions(
+ old: TProviderOptions | undefined | null,
+ current: TProviderOptions
+): TProviderOptions {
+ let mergedOptions: TProviderOptions = {}
+ if (!old) {
+ mergedOptions = current
+ } else {
+ for (const [optionName, optionValue] of Object.entries(current)) {
+ const maybeOption = old[optionName]
+ mergedOptions[optionName] = { ...optionValue, value: maybeOption?.value ?? optionValue.value }
+ }
+ }
+
+ return mergedOptions
+}
diff --git a/desktop/src/views/Providers/AddProvider/index.ts b/desktop/src/views/Providers/AddProvider/index.ts
index e52a5aa97..d5cd7366a 100644
--- a/desktop/src/views/Providers/AddProvider/index.ts
+++ b/desktop/src/views/Providers/AddProvider/index.ts
@@ -1,2 +1,3 @@
export { SetupProviderSteps } from "./SetupProviderSteps"
export { ConfigureProviderOptionsForm } from "./ConfigureProviderOptionsForm"
+export type { TCloneProviderInfo } from "./types"
diff --git a/desktop/src/views/Providers/AddProvider/types.ts b/desktop/src/views/Providers/AddProvider/types.ts
index 49932ead7..ef0fe1617 100644
--- a/desktop/src/views/Providers/AddProvider/types.ts
+++ b/desktop/src/views/Providers/AddProvider/types.ts
@@ -1,3 +1,11 @@
+import {
+ TProvider,
+ TProviderID,
+ TProviderOptionGroup,
+ TProviderOptions,
+ TWithProviderID,
+} from "../../../types"
+
export const FieldName = {
PROVIDER_SOURCE: "providerSource",
PROVIDER_NAME: "providerName",
@@ -6,3 +14,12 @@ export type TFormValues = {
[FieldName.PROVIDER_SOURCE]: string
[FieldName.PROVIDER_NAME]: string | undefined
}
+
+export type TCloneProviderInfo = Readonly<{
+ sourceProviderID: TProviderID
+ sourceProvider: TProvider
+ sourceProviderSource: NonNullable["source"]>["raw"]>
+}>
+
+export type TSetupProviderResult = TWithProviderID &
+ Readonly<{ options: TProviderOptions; optionGroups: TProviderOptionGroup[] }>
diff --git a/desktop/src/views/Providers/AddProvider/useAddProvider.ts b/desktop/src/views/Providers/AddProvider/useAddProvider.ts
new file mode 100644
index 000000000..96cad87e8
--- /dev/null
+++ b/desktop/src/views/Providers/AddProvider/useAddProvider.ts
@@ -0,0 +1,78 @@
+import { UseMutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"
+import { client } from "../../../client"
+import {
+ TAddProviderConfig,
+ TProviderID,
+ TProviderOptionGroup,
+ TProviderOptions,
+} from "../../../types"
+import { QueryKeys } from "../../../queryKeys"
+
+type TAddUserMutationOptions = UseMutationOptions<
+ Readonly<{
+ providerID: TProviderID
+ options: TProviderOptions
+ optionGroups: TProviderOptionGroup[]
+ }>,
+ unknown,
+ Readonly<{
+ rawProviderSource: string
+ config: TAddProviderConfig
+ }>,
+ unknown
+>
+type TUseAddProvider = Pick
+export function useAddProvider({ onSuccess, onError }: TUseAddProvider) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ rawProviderSource, config }) => {
+ // check if provider exists and is not initialized
+ const providerID = config.name || (await client.providers.newID(rawProviderSource)).unwrap()
+ if (!providerID) {
+ throw new Error(`Couldn't find provider id`)
+ }
+
+ // list all providers
+ let providers = (await client.providers.listAll()).unwrap()
+ if (providers?.[providerID]) {
+ if (!providers[providerID]?.state?.initialized) {
+ ;(await client.providers.remove(providerID)).unwrap()
+ } else {
+ throw new Error(
+ `Provider with name ${providerID} already exists, please choose a different name`
+ )
+ }
+ }
+
+ // add provider
+ ;(await client.providers.add(rawProviderSource, config)).unwrap()
+
+ // get options
+ let options: TProviderOptions | undefined
+ try {
+ options = (await client.providers.getOptions(providerID!)).unwrap()
+ } catch (e) {
+ ;(await client.providers.remove(providerID)).unwrap()
+ throw e
+ }
+
+ // check if provider could be added
+ providers = (await client.providers.listAll()).unwrap()
+ if (!providers?.[providerID!]) {
+ throw new Error(`Provider ${providerID} couldn't be found`)
+ }
+
+ return {
+ providerID: providerID!,
+ options: options!,
+ optionGroups: providers[providerID!]?.config?.optionGroups || [],
+ }
+ },
+ onSuccess(result, ...rest) {
+ queryClient.invalidateQueries(QueryKeys.PROVIDERS)
+ onSuccess?.(result, ...rest)
+ },
+ onError,
+ })
+}
diff --git a/desktop/src/views/Providers/ProviderCard.tsx b/desktop/src/views/Providers/ProviderCard.tsx
index a5c6a1c81..501b9ed45 100644
--- a/desktop/src/views/Providers/ProviderCard.tsx
+++ b/desktop/src/views/Providers/ProviderCard.tsx
@@ -37,6 +37,8 @@ import { Routes } from "../../routes"
import { TProvider, TProviderID, TRunnable, TWithProviderID } from "../../types"
import { client } from "../../client"
import { QueryKeys } from "../../queryKeys"
+import { HiDuplicate } from "react-icons/hi"
+import { useSetupProviderModal } from "./useSetupProviderModal"
type TProviderCardProps = {
id: string
@@ -54,6 +56,7 @@ export function ProviderCard({ id, provider, remove }: TProviderCardProps) {
() => workspaces.filter((workspace) => workspace.provider?.name === id),
[id, workspaces]
)
+ const { modal: setupProviderModal, show: showSetupProviderModal } = useSetupProviderModal()
const { mutate: updateDefaultProvider } = useMutation<
void,
unknown,
@@ -70,6 +73,7 @@ export function ProviderCard({ id, provider, remove }: TProviderCardProps) {
const providerIcon = provider.config?.icon
const isDefaultProvider = provider.default ?? false
const providerVersion = provider.config?.version
+ const providerRawSource = provider.config?.source?.raw
return (
<>
@@ -139,13 +143,31 @@ export function ProviderCard({ id, provider, remove }: TProviderCardProps) {
Default
-
+
+ {providerRawSource && (
+
+
+ showSetupProviderModal({
+ isStrict: false,
+ cloneProviderInfo: {
+ sourceProviderID: id,
+ sourceProvider: provider,
+ sourceProviderSource: providerRawSource,
+ },
+ })
+ }
+ icon={}
+ />
+
+ )}
navigate(Routes.toProvider(id))}
- isLoading={false}
icon={}
/>
@@ -200,6 +222,8 @@ export function ProviderCard({ id, provider, remove }: TProviderCardProps) {
+
+ {setupProviderModal}
>
)
}
diff --git a/desktop/src/views/Providers/useSetupProviderModal.tsx b/desktop/src/views/Providers/useSetupProviderModal.tsx
index 58a244a84..e9d6a7310 100644
--- a/desktop/src/views/Providers/useSetupProviderModal.tsx
+++ b/desktop/src/views/Providers/useSetupProviderModal.tsx
@@ -13,12 +13,16 @@ import { useNavigate } from "react-router-dom"
import { Routes } from "../../routes"
import { TProviderID } from "../../types"
import { SetupProviderSteps } from "../Providers"
+import { TCloneProviderInfo } from "./AddProvider"
export function useSetupProviderModal() {
const navigate = useNavigate()
const { isOpen, onClose, onOpen } = useDisclosure()
const [isStrict, setIsStrict] = useState(true)
const [suggestedProvider, setSuggestedProvider] = useState(undefined)
+ const [cloneProviderInfo, setCloneProviderInfo] = useState(
+ undefined
+ )
const [wasDismissed, setWasDismissed] = useState(false)
const [currentProviderID, setCurrentProviderID] = useState(null)
@@ -26,7 +30,12 @@ export function useSetupProviderModal() {
({
isStrict,
suggestedProvider,
- }: Readonly<{ isStrict: boolean; suggestedProvider?: TProviderID }>) => {
+ cloneProviderInfo,
+ }: Readonly<{
+ isStrict: boolean
+ suggestedProvider?: TProviderID
+ cloneProviderInfo?: TCloneProviderInfo
+ }>) => {
if (isOpen) {
return
}
@@ -35,6 +44,10 @@ export function useSetupProviderModal() {
setSuggestedProvider(suggestedProvider)
}
+ if (cloneProviderInfo) {
+ setCloneProviderInfo(cloneProviderInfo)
+ }
+
setIsStrict(isStrict)
onOpen()
},
@@ -80,6 +93,7 @@ export function useSetupProviderModal() {
),
- [onClose, isOpen, title, handleCloseClicked, suggestedProvider]
+ [onClose, isOpen, title, handleCloseClicked, suggestedProvider, cloneProviderInfo]
)
return { modal, show, isOpen, wasDismissed }
diff --git a/pkg/provider/parse.go b/pkg/provider/parse.go
index 7c0c6b1bc..3fcddb035 100644
--- a/pkg/provider/parse.go
+++ b/pkg/provider/parse.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "os"
"regexp"
"strconv"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/blang/semver"
"github.com/ghodss/yaml"
+ "github.com/loft-sh/devpod/pkg/telemetry"
"github.com/pkg/errors"
)
@@ -38,7 +40,11 @@ func ParseProvider(reader io.Reader) (*ProviderConfig, error) {
}
decoder := json.NewDecoder(bytes.NewReader(jsonBytes))
- decoder.DisallowUnknownFields()
+
+ // Disallow unknown fields in standalone version but allow them in the UI unti we have a versioning strategy
+ if os.Getenv(telemetry.UIEnvVar) != "true" {
+ decoder.DisallowUnknownFields()
+ }
parsedConfig := &ProviderConfig{}
err = decoder.Decode(parsedConfig)
diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go
index b1a9de2ce..850d0e161 100644
--- a/pkg/provider/provider.go
+++ b/pkg/provider/provider.go
@@ -66,6 +66,9 @@ type ProviderSource struct {
// URL where the provider was downloaded from
URL string `json:"url,omitempty"`
+
+ // Raw is the exact string we used to load the provider
+ Raw string `json:"raw,omitempty"`
}
type ProviderAgentConfig struct {
diff --git a/pkg/workspace/provider.go b/pkg/workspace/provider.go
index eb89b57cb..69d95f0b5 100644
--- a/pkg/workspace/provider.go
+++ b/pkg/workspace/provider.go
@@ -46,13 +46,27 @@ func LoadProviders(devPodConfig *config.Config, log log.Logger) (*ProviderWithOp
return retProviders[defaultContext.DefaultProvider], retProviders, nil
}
+func CloneProvider(devPodConfig *config.Config, providerName, providerSourceRaw string, log log.Logger) (*ProviderWithOptions, error) {
+ providerWithOptions, err := FindProvider(devPodConfig, providerSourceRaw, log)
+ if err != nil {
+ return nil, err
+ }
+ providerConfig, err := installProvider(devPodConfig, providerWithOptions.Config, providerName, &providerWithOptions.Config.Source, log)
+ if err != nil {
+ return nil, err
+ }
+ providerWithOptions.Config = providerConfig
+
+ return providerWithOptions, nil
+}
+
func AddProvider(devPodConfig *config.Config, providerName, providerSourceRaw string, log log.Logger) (*provider2.ProviderConfig, error) {
providerRaw, providerSource, err := ResolveProvider(providerSourceRaw, log)
if err != nil {
return nil, err
}
- providerConfig, err := installProvider(devPodConfig, providerName, providerRaw, providerSource, log)
+ providerConfig, err := installRawProvider(devPodConfig, providerName, providerRaw, providerSource, log)
if err != nil {
return nil, err
}
@@ -85,7 +99,12 @@ func UpdateProvider(devPodConfig *config.Config, providerName, providerSourceRaw
}
if providerConfig.Config.Source.Internal {
- providerSourceRaw = providerConfig.Config.Name
+ // Name could also be overridden if initial name was already taken, so prefer the raw source if available
+ if providerConfig.Config.Source.Raw == "" {
+ providerSourceRaw = providerConfig.Config.Name
+ } else {
+ providerSourceRaw = providerConfig.Config.Source.Raw
+ }
} else if providerConfig.Config.Source.URL != "" {
providerSourceRaw = providerConfig.Config.Source.URL
} else if providerConfig.Config.Source.File != "" {
@@ -106,10 +125,13 @@ func UpdateProvider(devPodConfig *config.Config, providerName, providerSourceRaw
}
func ResolveProvider(providerSource string, log log.Logger) ([]byte, *provider2.ProviderSource, error) {
+ retSource := &provider2.ProviderSource{Raw: strings.TrimSpace(providerSource)}
+
// in-built?
internalProviders := providers.GetBuiltInProviders()
if internalProviders[providerSource] != "" {
- return []byte(internalProviders[providerSource]), &provider2.ProviderSource{Internal: true}, nil
+ retSource.Internal = true
+ return []byte(internalProviders[providerSource]), retSource, nil
}
// url?
@@ -119,8 +141,9 @@ func ResolveProvider(providerSource string, log log.Logger) ([]byte, *provider2.
if err != nil {
return nil, nil, err
}
+ retSource.URL = providerSource
- return out, &provider2.ProviderSource{URL: providerSource}, nil
+ return out, retSource, nil
}
// local file?
@@ -133,10 +156,9 @@ func ResolveProvider(providerSource string, log log.Logger) ([]byte, *provider2.
if err != nil {
return nil, nil, err
}
+ retSource.File = absPath
- return out, &provider2.ProviderSource{
- File: absPath,
- }, nil
+ return out, retSource, nil
}
}
}
@@ -193,6 +215,7 @@ func DownloadProviderGithub(originalPath string, log log.Logger) ([]byte, *provi
}
return out, &provider2.ProviderSource{
+ Raw: originalPath,
Github: path,
}, nil
}
@@ -258,11 +281,15 @@ func updateProvider(devPodConfig *config.Config, providerName string, raw []byte
return providerConfig, nil
}
-func installProvider(devPodConfig *config.Config, providerName string, raw []byte, source *provider2.ProviderSource, log log.Logger) (*provider2.ProviderConfig, error) {
+func installRawProvider(devPodConfig *config.Config, providerName string, raw []byte, source *provider2.ProviderSource, log log.Logger) (*provider2.ProviderConfig, error) {
providerConfig, err := provider2.ParseProvider(bytes.NewReader(raw))
if err != nil {
return nil, err
}
+ return installProvider(devPodConfig, providerConfig, providerName, source, log)
+}
+
+func installProvider(devPodConfig *config.Config, providerConfig *provider2.ProviderConfig, providerName string, source *provider2.ProviderSource, log log.Logger) (*provider2.ProviderConfig, error) {
providerConfig.Source = *source
if providerName != "" {
providerConfig.Name = providerName
@@ -365,10 +392,14 @@ func LoadAllProviders(devPodConfig *config.Config, log log.Logger) (map[string]*
continue
}
+ // keep source
+ originalSource := v.Config.Source
+
v.Config, err = provider2.ParseProvider(bytes.NewReader([]byte(providers.GetBuiltInProviders()[k])))
if err != nil {
return nil, err
}
+ v.Config.Source = originalSource
}
return retProviders, nil