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 ( + <> + + { +
+ + Name + { + if (value === undefined) return true + if (value === "") return "Name cannot be empty" + + return providers?.[value] === undefined ? true : "Name must be unique" + }, + }, + maxLength: { value: 48, message: "Name cannot be longer than 48 characters" }, + }} + render={({ field }) => ( + + )} + /> + {exists(providerNameError) ? ( + {providerNameError.message ?? "Error"} + ) : ( + + Please give your provider a different name from the one specified in its{" "} + provider.yaml + + )} + + + {status === "error" && isError(error) && } + {isLoading && ( + ${newProviderName}`} + /> + )} + + } +
+ + ) +} 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