Skip to content

Commit

Permalink
Merge pull request #368 from pascalbreuninger/feat/ENG-1499-clone-pro…
Browse files Browse the repository at this point in the history
…vider

feat(cli): add option to clone existing provider
  • Loading branch information
pascalbreuninger authored Jun 5, 2023
2 parents 87ce838 + 27a51a9 commit 0a09ba3
Show file tree
Hide file tree
Showing 17 changed files with 499 additions and 140 deletions.
79 changes: 72 additions & 7 deletions cmd/provider/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package provider
import (
"context"
"fmt"
"strings"

"github.com/loft-sh/devpod/cmd/flags"
"github.com/loft-sh/devpod/pkg/config"
Expand All @@ -21,7 +22,8 @@ type AddCmd struct {
SingleMachine bool
Options []string

Name string
Name string
FromExisting string
}

// NewAddCmd creates a new command
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
9 changes: 5 additions & 4 deletions desktop/src/components/Error/ErrorMessageBox.tsx
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -12,7 +12,8 @@ export function ErrorMessageBox({ error }: TErrorMessageBox) {
paddingY="1"
paddingX="2"
borderRadius="md"
display="inline-block">
display="inline-block"
{...boxProps}>
<Text userSelect="auto" color={textColor} fontFamily="monospace">
{error.message.split("\n").map((line, index) => (
<React.Fragment key={index}>
Expand Down
1 change: 1 addition & 0 deletions desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type TProviderSource = Readonly<{
github: string | null
file: string | null
url: string | null
raw: string | null
}>
export type TProviderOptions = Record<string, TProviderOption>
export type TProviderOption = Readonly<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
PopoverContent,
PopoverTrigger,
SimpleGrid,
Text,
Tooltip,
VStack,
useBreakpointValue,
Expand Down Expand Up @@ -333,7 +332,9 @@ export function ConfigureProviderOptionsForm({
/>
</PopoverTrigger>
<PopoverContent minWidth="96">
{isError(error) && <ErrorMessageBox error={error} />}
{isError(error) && (
<ErrorMessageBox maxHeight="xl" overflowY="auto" error={error} />
)}
</PopoverContent>
</Popover>
</HStack>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Box, HStack, Text } from "@chakra-ui/react"

export function LoadingProviderIndicator({ label }: Readonly<{ label: string | undefined }>) {
return (
<HStack marginTop="2" justifyContent="center" alignItems="center" color="gray.600">
<Text fontWeight="medium">{label}</Text>
<Box as="svg" height="3" marginInlineStart="0 !important" width="8" viewBox="0 0 48 30">
<circle fill="currentColor" stroke="none" cx="6" cy="24" r="6">
<animateTransform
attributeName="transform"
dur="1s"
type="translate"
values="0 0; 0 -12; 0 0; 0 0; 0 0; 0 0"
repeatCount="indefinite"
begin="0"
/>
</circle>
<circle fill="currentColor" stroke="none" cx="24" cy="24" r="6">
<animateTransform
id="op"
attributeName="transform"
dur="1s"
type="translate"
values="0 0; 0 -12; 0 0; 0 0; 0 0; 0 0"
repeatCount="indefinite"
begin="0.3s"
/>
</circle>
<circle fill="currentColor" stroke="none" cx="42" cy="24" r="6">
<animateTransform
id="op"
attributeName="transform"
dur="1s"
type="translate"
values="0 0; 0 -12; 0 0; 0 0; 0 0; 0 0"
repeatCount="indefinite"
begin="0.6s"
/>
</circle>
</Box>
</HStack>
)
}
133 changes: 133 additions & 0 deletions desktop/src/views/Providers/AddProvider/SetupClonedProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<TFormValues>({
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 (
<>
<VStack align="start" spacing={8} width="full" marginBottom={6}>
{
<Form onSubmit={handleSubmit(onSubmit)}>
<FormControl
alignSelf="center"
maxWidth={{ base: "3xl", xl: "4xl" }}
marginBottom={4}
isDisabled={isLoading || status === "success"}
isInvalid={exists(providerNameError)}>
<FormLabel>Name</FormLabel>
<Controller
name={FieldName.PROVIDER_NAME}
control={control}
rules={{
pattern: {
value: ALLOWED_NAMES_REGEX,
message: "Name can only contain letters, numbers and -",
},
validate: {
unique: (value) => {
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 }) => (
<CustomNameInput
field={field}
onAccept={handleSubmit(onSubmit)}
isInvalid={exists(providerNameError)}
isDisabled={isLoading || status === "success"}
/>
)}
/>
{exists(providerNameError) ? (
<FormErrorMessage>{providerNameError.message ?? "Error"}</FormErrorMessage>
) : (
<FormHelperText>
Please give your provider a different name from the one specified in its{" "}
<Code>provider.yaml</Code>
</FormHelperText>
)}
</FormControl>

{status === "error" && isError(error) && <ErrorMessageBox error={error} />}
{isLoading && (
<LoadingProviderIndicator
label={`Cloning ${cloneProviderInfo.sourceProviderID} -> ${newProviderName}`}
/>
)}
</Form>
}
</VStack>
</>
)
}
Loading

0 comments on commit 0a09ba3

Please sign in to comment.