diff --git a/CHANGELOG.md b/CHANGELOG.md index fa26325..1f01f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.5.0] — 2023-11-02 + +### Changed + +- Improved and enhanced onboarding and configuration management experience + +### Fixed + +- Fixed the logic to get the users +- Fixed the issue of downloading correct Docker image +- Fixed the issue of container startup + +## [0.4.0] — 2023-03-24 + +### Changed + +- Refactoring and UX improvements +- Full support on Windows + ## [0.3.0] - 2023-01-10 ### Added diff --git a/Makefile b/Makefile index bbd8b17..2e6154e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ IMAGE?=localstack/localstack-docker-desktop -TAG?=0.4.2 +TAG?=0.5.0 BUILDER=buildx-multi-arch @@ -15,6 +15,16 @@ install-extension: build-extension ## Install the extension update-extension: build-extension ## Update the extension docker extension update $(IMAGE):$(TAG) +debug: ## Start the extension in debug mode + docker extension dev debug $(IMAGE) + +hot-reload: ## Enable hot reloading + docker extension dev ui-source $(IMAGE) http://localhost:3000 + cd ui/ && npm start + +stop-hot-realoading: ## Disable hot reloading + docker extension dev reset $(IMAGE) + prepare-buildx: ## Create buildx builder for multi-arch build, if not exists docker buildx inspect $(BUILDER) || docker buildx create --name=$(BUILDER) --driver=docker-container --driver-opt=network=host diff --git a/README.md b/README.md index 54ce9f6..9edc471 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ LocalStack empowers developers to use over 75+ AWS services locally while helpin You can install the LocalStack Extension for Docker Desktop via pulling our public Docker image from Docker Hub: ```bash -docker extension install localstack/localstack-docker-desktop:0.3.1 +docker extension install localstack/localstack-docker-desktop:0.5.0 ``` To setup the Docker Extension by building the image locally, you can run the following commands: @@ -44,15 +44,18 @@ To contribute, check out our [issue tracker](https://github.com/localstack/local 2. Open the Developer Tools or create new features: ```bash - $ docker extension dev debug localstack/localstack-docker-desktop + $ make debug ``` 3. Start the Extension on Docker Desktop and enable hot-reload using the following command: ```bash - $ npm start - $ docker extension dev ui-source localstack/localstack-docker-desktop http://localhost:3000 + $ make hot-reloading ``` +4. Disable hot reloading: + ```bash + $ make stop-hot-reloading + ``` ## Releases Please refer to [`CHANGELOG`](CHANGELOG.md) to see the complete list of changes for each release. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b045c78..ae22e55 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,7 +18,9 @@ export const App = (): ReactElement => { return ( <> - {showForm && } + {showForm && + + }
diff --git a/ui/src/components/Header/Controller.tsx b/ui/src/components/Header/Controller.tsx index c85db5c..a19e1d0 100644 --- a/ui/src/components/Header/Controller.tsx +++ b/ui/src/components/Header/Controller.tsx @@ -25,10 +25,10 @@ import { ProgressButton } from '../Feedback'; const EXCLUDED_ERROR_TOAST = ['INFO', 'WARN', 'DEBUG']; export const Controller = (): ReactElement => { - const { runConfigs, isLoading, createConfig } = useRunConfigs(); + const { configData, isLoading, setRunningConfig: setBackendRunningConfig, createConfig } = useRunConfigs(); const { data, mutate } = useLocalStack(); const { user, os, hasSkippedConfiguration } = useMountPoint(); - const [runningConfig, setRunningConfig] = useState('Default'); + const [runningConfig, setRunningConfig] = useState(configData.runningConfig ?? DEFAULT_CONFIGURATION_ID); const [downloadProps, setDownloadProps] = useState({ open: false, image: IMAGE }); const [isStarting, setIsStarting] = useState(false); const [isStopping, setIsStopping] = useState(false); @@ -38,14 +38,18 @@ export const Controller = (): ReactElement => { const tooltipLabel = isUnhealthy ? 'Unhealthy' : 'Healthy'; useEffect(() => { - if (!isLoading && (!runConfigs || !runConfigs.find(item => item.name === 'Default'))) { + if (!isLoading && + (!configData?.configs || !configData.configs?.find(item => item.id === DEFAULT_CONFIGURATION_ID))) { createConfig({ name: 'Default', id: DEFAULT_CONFIGURATION_ID, vars: [], - }, - ); + }); + } + if (!isLoading) { + setRunningConfig(configData.runningConfig ?? DEFAULT_CONFIGURATION_ID); } }, [isLoading]); + const buildMountArg = () => { let location = 'LOCALSTACK_VOLUME_DIR=/tmp/localstack/volume'; @@ -69,7 +73,7 @@ export const Controller = (): ReactElement => { const corsArg = ['-e', `EXTRA_CORS_ALLOWED_ORIGINS=${CORS_ALLOW_DEFAULT}`]; - const addedArgs = runConfigs.find(config => config.name === runningConfig) + const addedArgs = configData.configs.find(config => config.id === runningConfig) .vars.map(item => { if (item.variable === 'EXTRA_CORS_ALLOWED_ORIGINS') { // prevent overriding variable corsArg.slice(0, 0); @@ -88,8 +92,8 @@ export const Controller = (): ReactElement => { const start = async () => { const images = await ddClient.docker.listImages() as [DockerImage]; - const isPro = runConfigs.find(config => config.name === runningConfig) - .vars.some(item => item.variable === 'LOCALSTACK_API_KEY'); + const isPro = configData.configs.find(config => config.id === runningConfig) + .vars.some(item => item.variable === 'LOCALSTACK_API_KEY' && item.value); const haveCommunity = images.some(image => image.RepoTags?.at(0) === IMAGE); if (!haveCommunity) { @@ -180,11 +184,11 @@ export const Controller = (): ReactElement => { diff --git a/ui/src/components/Views/Configs/ConfigPage.tsx b/ui/src/components/Views/Configs/ConfigPage.tsx index c3a3068..01d9da0 100644 --- a/ui/src/components/Views/Configs/ConfigPage.tsx +++ b/ui/src/components/Views/Configs/ConfigPage.tsx @@ -18,7 +18,7 @@ const useStyles = makeStyles((theme: Theme) => createStyles({ export const ConfigPage = (): ReactElement => { - const { runConfigs, deleteConfig } = useRunConfigs(); + const { configData, deleteConfig } = useRunConfigs(); const mountPoint = useMountPoint(); const [openModal, setOpenModal] = useState(false); const [targetConfig, setTargetConfig] = useState(null); @@ -89,7 +89,7 @@ export const ConfigPage = (): ReactElement => { config.id !== DEFAULT_CONFIGURATION_ID)} + rows={configData?.configs?.filter(config => config.id !== DEFAULT_CONFIGURATION_ID) || []} columns={columns} getRowId={(row) => (row).id as string || uuid()} disableSelectionOnClick diff --git a/ui/src/components/Views/Configs/SettingsForm.tsx b/ui/src/components/Views/Configs/SettingsForm.tsx index c610909..e2e1186 100644 --- a/ui/src/components/Views/Configs/SettingsForm.tsx +++ b/ui/src/components/Views/Configs/SettingsForm.tsx @@ -41,7 +41,7 @@ export const SettingsForm = ({ initialState }: MountPointFormProps): ReactElemen const { setMountPointData, user, os } = useMountPoint(); const ddClient = useDDClient(); - const steps = ['Enable Docker Desktop option', 'Set mount point']; + const steps = ['Enable Docker Desktop option', 'Launching pro container', 'Set mount point']; const handleNext = () => { if (activeStep !== steps.length - 1) { @@ -158,7 +158,7 @@ export const SettingsForm = ({ initialState }: MountPointFormProps): ReactElemen )} - {activeStep === 0 ? + {activeStep === 0 && <> Make sure to have the option "Show Docker Extensions system containers" enabled. @@ -171,7 +171,15 @@ export const SettingsForm = ({ initialState }: MountPointFormProps): ReactElemen
  • In the bottom-right corner, select Apply & Restart
  • - : + } + { + activeStep === 1 && + + In order to start the Pro container, add a configuration with the variable LOCALSTACK_API_KEY + set to your API key and select that configuration in the top right corner + + } + {activeStep === 2 && <> Default mount point settings diff --git a/ui/src/components/Views/Configs/UpsertConfig.tsx b/ui/src/components/Views/Configs/UpsertConfig.tsx index 4c46524..a35b884 100644 --- a/ui/src/components/Views/Configs/UpsertConfig.tsx +++ b/ui/src/components/Views/Configs/UpsertConfig.tsx @@ -20,6 +20,12 @@ import { RunConfig } from '../../../types'; const DEFAULT_COLUMN_WIDTH = 2000; +const COMMON_CONFIGURATIONS = [ + ['DEBUG', '0', 'Flag to increase log level and print more verbose logs'], + ['PERSISTENCE', '0', 'Enable persistence'], + ['LOCALSTACK_API_KEY', '', 'API key to activate LocalStack Pro.'], +]; + type Props = { config?: RunConfig, open: boolean, @@ -42,7 +48,14 @@ export const UpsertConfig = ({ config, open, onClose }: Props): ReactElement => const [newVar, setNewVar] = useState(''); const [newValue, setNewValue] = useState(''); const [configName, setConfigName] = useState(config?.name || ''); - const [newConfig, setNewConfig] = useState(config || { name: '', id: uuid(), vars: [] } as RunConfig); + const [newConfig, setNewConfig] = useState(config || + { + name: '', + id: uuid(), + vars: COMMON_CONFIGURATIONS.map( + ([variable, value, description]) => ({ variable, value, id: uuid(), description }), + ), + } as RunConfig); const classes = useStyles(); const handleAddButtonPress = () => { @@ -105,34 +118,41 @@ export const UpsertConfig = ({ config, open, onClose }: Props): ReactElement => variant='outlined' label='Configuration Name' value={configName} + required onChange={(e) => setConfigName(e.target.value)} />
    Environment Variables + Environment Variables } > {newConfig?.vars.map(item => ( - - updateConfigKey(item.id, e.target.value.toLocaleUpperCase())} - value={item.variable} - /> - updateConfigValue(item.id, e.target.value)} - value={item.value} /> - handleRemoveButtonPress(item.id)} > - - + + + updateConfigKey(item.id, e.target.value.toLocaleUpperCase())} + value={item.variable} + /> + updateConfigValue(item.id, e.target.value)} + value={item.value} /> + handleRemoveButtonPress(item.id)} > + + + + {item.description && + {item.description} + } ))} @@ -173,6 +193,7 @@ export const UpsertConfig = ({ config, open, onClose }: Props): ReactElement => diff --git a/ui/src/services/hooks/api.ts b/ui/src/services/hooks/api.ts index 79bfd62..784ecd5 100644 --- a/ui/src/services/hooks/api.ts +++ b/ui/src/services/hooks/api.ts @@ -1,12 +1,13 @@ import useSWR from 'swr'; import { STORAGE_KEY_ENVVARS, STORAGE_KEY_LOCALSTACK, STORAGE_KEY_MOUNT } from '../../constants'; -import { DockerContainer, mountPointData, RunConfig } from '../../types'; +import { ConfigData, DockerContainer, mountPointData, RunConfig } from '../../types'; import { isALocalStackContainer, isJson } from '../util'; import { useDDClient } from './utils'; interface useRunConfigsReturn { - runConfigs: RunConfig[], + configData: ConfigData, isLoading: boolean, + setRunningConfig: (data: string) => unknown; createConfig: (data: RunConfig) => unknown; updateConfig: (data: RunConfig) => unknown; deleteConfig: (data: string) => unknown; @@ -16,6 +17,17 @@ interface HTTPMessageBody { Message: string, } +const adaptVersionData = (data: HTTPMessageBody, error: Error) => { + const newData = (!data || !data?.Message || error) ? + { configs: [], runningConfig: null } + : + JSON.parse(data?.Message); + if (Array.isArray(newData)) { + return { configs: newData, runningConfig: newData.at(0).id ?? null }; + } + return newData; +}; + export const useRunConfigs = (): useRunConfigsReturn => { const cacheKey = STORAGE_KEY_ENVVARS; const ddClient = useDDClient(); @@ -29,6 +41,11 @@ export const useRunConfigs = (): useRunConfigsReturn => { mutate(); }; + const setRunningConfig = async (configId: string) => { + await ddClient.extension.vm.service.put('/configs/running', { Data: JSON.stringify(configId) }); + mutate(); + }; + const createConfig = async (newData: RunConfig) => { await ddClient.extension.vm.service.post('/configs', { Data: JSON.stringify(newData) }); mutate(); @@ -39,9 +56,11 @@ export const useRunConfigs = (): useRunConfigsReturn => { mutate(); }; + return { - runConfigs: (!data || !data?.Message || error) ? [] : JSON.parse(data?.Message), + configData: adaptVersionData(data, error), isLoading: isValidating || (!error && !data), + setRunningConfig, createConfig, updateConfig, deleteConfig, @@ -78,7 +97,7 @@ export const useMountPoint = (): useMountPointReturn => { return { user: mountPointData?.user, os: mountPointData?.os, - showForm: mountPointData?.showForm == null? true : mountPointData?.showForm, + showForm: mountPointData?.showForm == null ? true : mountPointData?.showForm, showSetupWarning: mountPointData?.showSetupWarning == null ? true : mountPointData?.showSetupWarning, hasSkippedConfiguration: mountPointData?.hasSkippedConfiguration || false, isLoading: isValidating || (!error && !data), @@ -105,7 +124,7 @@ export const useLocalStack = (): useLocalStackReturn => { /* * compares whether the old (b) status aligns with that of new (a) status */ - (a, b) => a?.Id === b?.Id && a?.Status.includes('unhealthy') === b?.Status.includes('unhealthy'), + (a, b) => a?.Id === b?.Id && a?.Status.includes('unhealthy') === b?.Status.includes('unhealthy'), }, ); diff --git a/ui/src/services/util/containers.ts b/ui/src/services/util/containers.ts index 96c3ff4..922bd4e 100644 --- a/ui/src/services/util/containers.ts +++ b/ui/src/services/util/containers.ts @@ -1,3 +1,4 @@ +import { PRO_IMAGE } from '../../constants'; import { DockerContainer, DockerImage } from '../../types'; /** @@ -10,9 +11,23 @@ export function removeRepoFromImage(repoTag: string) { return repoTag.split('/').at(-1); } +const removeDockerTag = (imageString:string ) => { + // Split the image string by ":" to check if it has a tag + const parts = imageString.split(':'); + + // If there is no tag, return the original string as is + if (parts.length === 1) { + return imageString; + } + + // If there is a tag, return the part before the last ":" (image without tag) + return parts.slice(0, -1).join(':'); +}; export function removeTagFromImage(image: DockerImage){ - return image.RepoTags?.at(0)?.split(':').slice(0, -1).join(':'); + return removeDockerTag(image.RepoTags?.at(0)); } -export const isALocalStackContainer = (container: DockerContainer) => - (container.Image === 'localstack/localstack' || container.Image === 'localstack/localstack-pro'); +export const isALocalStackContainer = (container: DockerContainer) => { + const image = removeDockerTag(container.Image); + return image === 'localstack/localstack' || image === PRO_IMAGE; +}; diff --git a/ui/src/types/global.ts b/ui/src/types/global.ts index 079e156..2850be4 100644 --- a/ui/src/types/global.ts +++ b/ui/src/types/global.ts @@ -5,6 +5,7 @@ interface envVar { variable: string, value: string, id: string; + description?: string; } export interface RunConfig { @@ -13,6 +14,11 @@ export interface RunConfig { vars: Optional, } +export interface ConfigData { + runningConfig: string, + configs: RunConfig[], +} + export interface mountPointData { user: string, os: string, diff --git a/vm/main.go b/vm/main.go index 3ba8ee7..544b367 100644 --- a/vm/main.go +++ b/vm/main.go @@ -41,6 +41,7 @@ func main() { router.GET("/configs", getSettings) router.POST("/configs", createSetting) router.PUT("/configs", updateSetting) + router.PUT("/configs/running", updateRunningConfig) router.DELETE("/configs/:id", deleteSetting) router.GET("/mount", getMount) router.POST("/mount", setMount) @@ -67,10 +68,49 @@ type Configuration struct { } `json:"vars"` } +type ConfigurationData struct { + RunningConfig string `json:"runningConfig"` + Configs []Configuration `json:"configs"` +} + type Payload struct { Data string `json:"data"` } +func convertConfigurationVersion(loadedData []byte) ([]Configuration, error) { + var parsedContent []Configuration + err := json.Unmarshal(loadedData, &parsedContent) + return parsedContent, err +} + +func updateRunningConfig(ctx echo.Context) error { + var payload Payload + var reqContent string + var parsedContent ConfigurationData + + ctx.Bind(&payload) + json.Unmarshal([]byte(payload.Data), &reqContent) + savedData, file, _ := readDataKeepOpen() + defer file.Close() + + err := json.Unmarshal(savedData, &parsedContent) + + if err != nil { + configuration, newErr := convertConfigurationVersion(savedData) + if newErr != nil { + return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) + } + parsedContent.Configs = configuration + } + + parsedContent.RunningConfig = reqContent + err = writeData(parsedContent, file) + if err == nil { + return ctx.NoContent(http.StatusOK) + } + return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[1]}) +} + func getMount(ctx echo.Context) error { content, err := readData(MOUNT_POINT_FILE) if err != nil { @@ -108,7 +148,7 @@ func getSettings(ctx echo.Context) error { func updateSetting(ctx echo.Context) error { var payload Payload var reqContent Configuration - var parsedContent []Configuration + var parsedContent ConfigurationData var indexToChange int = -1 ctx.Bind(&payload) @@ -119,21 +159,26 @@ func updateSetting(ctx echo.Context) error { err := json.Unmarshal(savedData, &parsedContent) if err != nil { - return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) + configuration, newErr := convertConfigurationVersion(savedData) + if newErr != nil { + return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) + } + parsedContent.Configs = configuration } - for index, item := range parsedContent { + for index, item := range parsedContent.Configs { if item.ID == reqContent.ID { indexToChange = index } } if indexToChange == -1 { //no config with that id found - parsedContent = append(parsedContent, reqContent) + parsedContent.Configs = append(parsedContent.Configs, reqContent) } else { - parsedContent[indexToChange] = reqContent + parsedContent.Configs[indexToChange] = reqContent } + parsedContent.RunningConfig = reqContent.ID err = writeData(parsedContent, file) if err == nil { return ctx.NoContent(http.StatusOK) @@ -144,7 +189,7 @@ func updateSetting(ctx echo.Context) error { func deleteSetting(ctx echo.Context) error { var idToDelete string - var parsedContent []Configuration + var parsedContent ConfigurationData var indexToDelete int = -1 idToDelete = ctx.Param("id") @@ -156,17 +201,18 @@ func deleteSetting(ctx echo.Context) error { return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) } - for index, item := range parsedContent { + for index, item := range parsedContent.Configs { if item.ID == idToDelete { indexToDelete = index } } if indexToDelete != -1 { - if indexToDelete != len(parsedContent)-1 { - parsedContent[indexToDelete] = parsedContent[len(parsedContent)-1] + if indexToDelete != len(parsedContent.Configs)-1 { + parsedContent.Configs[indexToDelete] = parsedContent.Configs[len(parsedContent.Configs)-1] } - parsedContent = parsedContent[:len(parsedContent)-1] + parsedContent.Configs = parsedContent.Configs[:len(parsedContent.Configs)-1] + parsedContent.RunningConfig = "00000000-0000-0000-0000-000000000000" } err = writeData(parsedContent, file) @@ -182,7 +228,7 @@ func createSetting(ctx echo.Context) error { var payload Payload var reqContent Configuration - var parsedContent []Configuration + var parsedContent ConfigurationData ctx.Bind(&payload) json.Unmarshal([]byte(payload.Data), &reqContent) @@ -191,11 +237,17 @@ func createSetting(ctx echo.Context) error { err := json.Unmarshal(savedData, &parsedContent) if err != nil { - return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) + configuration, newErr := convertConfigurationVersion(savedData) + if newErr != nil { + return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[0]}) + } + parsedContent.Configs = configuration } - updatedArray := append(parsedContent, reqContent) - err = writeData(updatedArray, file) + updatedArray := append(parsedContent.Configs, reqContent) + parsedContent.Configs = updatedArray + parsedContent.RunningConfig = reqContent.ID + err = writeData(parsedContent, file) if err != nil { return ctx.JSON(http.StatusInternalServerError, HTTPMessageBody{Message: ERRORS[1]}) @@ -241,7 +293,7 @@ func readDataKeepOpen() ([]byte, *os.File, error) { return content, file, err } -func writeData(data []Configuration, file *os.File) error { +func writeData(data ConfigurationData, file *os.File) error { jsonData, err := json.Marshal(data) if err == nil { file.Truncate(0)