Merge pull request #36608 from appsmithorg/release

30/09 Daily Promotion
This commit is contained in:
Aparna Ramachandran 2024-09-30 13:50:41 +05:30 committed by GitHub
commit 8145ed2a1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 510 additions and 116 deletions

View File

@ -247,10 +247,14 @@ jobs:
run: |
scripts/generate_info_json.sh
# As pg docker image is continuously updated for each scheduled cron on release, we are using the nightly tag while building the latest tag
- name: Place server artifacts-es
run: |
if [[ -f scripts/prepare_server_artifacts.sh ]]; then
scripts/prepare_server_artifacts.sh
PG_TAG=nightly scripts/prepare_server_artifacts.sh
else
echo "No script found to prepare server artifacts"
exit 1
fi
- name: Login to DockerHub

View File

@ -356,8 +356,12 @@ jobs:
- name: Place server artifacts-es
run: |
run: |
if [[ -f scripts/prepare_server_artifacts.sh ]]; then
scripts/prepare_server_artifacts.sh
else
echo "No script found to prepare server artifacts"
exit 1
fi
- name: Set up Depot CLI
@ -439,6 +443,9 @@ jobs:
run: |
if [[ -f scripts/prepare_server_artifacts.sh ]]; then
scripts/prepare_server_artifacts.sh
else
echo "No script found to prepare server artifacts"
exit 1
fi
- name: Set up Depot CLI

View File

@ -1,5 +1,4 @@
import ReconnectLocators from "../../../../locators/ReconnectLocators";
import { featureFlagIntercept } from "../../../../support/Objects/FeatureFlags";
import {
agHelper,
gitSync,
@ -7,7 +6,7 @@ import {
} from "../../../../support/Objects/ObjectsCore";
let wsName: string;
let repoName: string = "TED-testrepo1";
let repoName: string = "TED-autocommit-test-1";
describe(
"Git Autocommit",
@ -15,8 +14,8 @@ describe(
tags: [
"@tag.Git",
"@tag.GitAutocommit",
"@tag.excludeForAirgap",
"@tag.Sanity",
"@tag.TedMigration",
],
},
function () {

View File

@ -0,0 +1,81 @@
import React from "react";
import { useSelector } from "react-redux";
import ActionNameEditor from "components/editorComponents/ActionNameEditor";
import { usePluginActionContext } from "PluginActionEditor/PluginActionContext";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { getHasManageActionPermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { PluginType } from "entities/Action";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import styled from "styled-components";
import { getSavingStatusForActionName } from "selectors/actionSelectors";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { ActionUrlIcon } from "pages/Editor/Explorer/ExplorerIcons";
export interface SaveActionNameParams {
id: string;
name: string;
}
export interface PluginActionNameEditorProps {
saveActionName: (
params: SaveActionNameParams,
) => ReduxAction<SaveActionNameParams>;
}
const ActionNameEditorWrapper = styled.div`
& .ads-v2-box {
gap: var(--ads-v2-spaces-2);
}
&& .t--action-name-edit-field {
font-size: 12px;
.bp3-editable-text-content {
height: unset !important;
line-height: unset !important;
}
}
& .t--plugin-icon-box {
height: 12px;
width: 12px;
img {
width: 12px;
height: auto;
}
}
`;
const PluginActionNameEditor = (props: PluginActionNameEditorProps) => {
const { action, plugin } = usePluginActionContext();
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const isChangePermitted = getHasManageActionPermission(
isFeatureEnabled,
action?.userPermissions,
);
const saveStatus = useSelector((state) =>
getSavingStatusForActionName(state, action?.id || ""),
);
const iconUrl = getAssetUrl(plugin?.iconLocation) || "";
const icon = ActionUrlIcon(iconUrl);
return (
<ActionNameEditorWrapper>
<ActionNameEditor
actionConfig={action}
disabled={!isChangePermitted}
enableFontStyling={plugin?.type === PluginType.API}
icon={icon}
saveActionName={props.saveActionName}
saveStatus={saveStatus}
/>
</ActionNameEditorWrapper>
);
};
export default PluginActionNameEditor;

View File

@ -6,3 +6,8 @@ export {
export { default as PluginActionToolbar } from "./components/PluginActionToolbar";
export { default as PluginActionForm } from "./components/PluginActionForm";
export { default as PluginActionResponse } from "./components/PluginActionResponse";
export type {
SaveActionNameParams,
PluginActionNameEditorProps,
} from "./components/PluginActionNameEditor";
export { default as PluginActionNameEditor } from "./components/PluginActionNameEditor";

View File

@ -1,19 +1,13 @@
import React, { memo } from "react";
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import EditableText, {
EditInteractionKind,
} from "components/editorComponents/EditableText";
import { removeSpecialChars } from "utils/helpers";
import type { AppState } from "ee/reducers";
import { saveActionName } from "actions/pluginActionActions";
import { Flex } from "@appsmith/ads";
import { getActionByBaseId, getPlugin } from "ee/selectors/entitiesSelector";
import NameEditorComponent, {
IconBox,
IconWrapper,
NameWrapper,
} from "components/utils/NameEditorComponent";
import {
@ -21,14 +15,13 @@ import {
ACTION_NAME_PLACEHOLDER,
createMessage,
} from "ee/constants/messages";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { getSavingStatusForActionName } from "selectors/actionSelectors";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import type { SaveActionNameParams } from "PluginActionEditor";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import type { Action } from "entities/Action";
import type { ModuleInstance } from "ee/constants/ModuleInstanceConstants";
interface SaveActionNameParams {
id: string;
name: string;
}
interface ActionNameEditorProps {
/*
This prop checks if page is API Pane or Query Pane or Curl Pane
@ -38,38 +31,34 @@ interface ActionNameEditorProps {
*/
enableFontStyling?: boolean;
disabled?: boolean;
saveActionName?: (
saveActionName: (
params: SaveActionNameParams,
) => ReduxAction<SaveActionNameParams>;
actionConfig?: Action | ModuleInstance;
icon?: JSX.Element;
saveStatus: { isSaving: boolean; error: boolean };
}
function ActionNameEditor(props: ActionNameEditorProps) {
const params = useParams<{ baseApiId?: string; baseQueryId?: string }>();
const {
actionConfig,
disabled = false,
enableFontStyling = false,
icon = "",
saveActionName,
saveStatus,
} = props;
const currentActionConfig = useSelector((state: AppState) =>
getActionByBaseId(state, params.baseApiId || params.baseQueryId || ""),
);
const currentPlugin = useSelector((state: AppState) =>
getPlugin(state, currentActionConfig?.pluginId || ""),
);
const saveStatus = useSelector((state) =>
getSavingStatusForActionName(state, currentActionConfig?.id || ""),
const isActionRedesignEnabled = useFeatureFlag(
FEATURE_FLAG.release_actions_redesign_enabled,
);
return (
<NameEditorComponent
/**
* This component is used by module editor in EE which uses a different
* action to save the name of an action. The current callers of this component
* pass the existing saveAction action but as fallback the saveActionName is used here
* as a guard.
*/
dispatchAction={props.saveActionName || saveActionName}
id={currentActionConfig?.id}
id={actionConfig?.id}
idUndefinedErrorMessage={ACTION_ID_NOT_FOUND_IN_URL}
name={currentActionConfig?.name}
name={actionConfig?.name}
onSaveName={saveActionName}
saveStatus={saveStatus}
>
{({
@ -85,28 +74,22 @@ function ActionNameEditor(props: ActionNameEditorProps) {
isNew: boolean;
saveStatus: { isSaving: boolean; error: boolean };
}) => (
<NameWrapper enableFontStyling={props.enableFontStyling}>
<NameWrapper enableFontStyling={enableFontStyling}>
<Flex
alignItems="center"
gap="spaces-3"
overflow="hidden"
width="100%"
>
{currentPlugin && (
<IconBox>
<IconWrapper
alt={currentPlugin.name}
src={getAssetUrl(currentPlugin?.iconLocation)}
/>
</IconBox>
)}
{icon && <IconBox className="t--plugin-icon-box">{icon}</IconBox>}
<EditableText
className="t--action-name-edit-field"
defaultValue={currentActionConfig ? currentActionConfig.name : ""}
disabled={props.disabled}
defaultValue={actionConfig ? actionConfig.name : ""}
disabled={disabled}
editInteractionKind={EditInteractionKind.SINGLE}
errorTooltipClass="t--action-name-edit-error"
forceDefault={forceUpdate}
iconSize={isActionRedesignEnabled ? "sm" : "md"}
isEditingDefault={isNew}
isInvalid={isInvalidNameForEntity}
onTextChanged={handleNameChange}

View File

@ -6,7 +6,13 @@ import {
} from "@blueprintjs/core";
import styled from "styled-components";
import _ from "lodash";
import { Button, Spinner, toast, Tooltip } from "@appsmith/ads";
import {
Button,
Spinner,
toast,
Tooltip,
type ButtonSizes,
} from "@appsmith/ads";
import { INVALID_NAME_ERROR, createMessage } from "ee/constants/messages";
export enum EditInteractionKind {
@ -39,6 +45,7 @@ interface EditableTextProps {
minLines?: number;
customErrorTooltip?: string;
useFullWidth?: boolean;
iconSize?: ButtonSizes;
}
// using the !important keyword here is mandatory because a style is being applied to that element using the style attribute
@ -129,6 +136,7 @@ export function EditableText(props: EditableTextProps) {
errorTooltipClass,
forceDefault,
hideEditIcon,
iconSize = "md",
isEditingDefault,
isInvalid,
maxLength,
@ -275,7 +283,7 @@ export function EditableText(props: EditableTextProps) {
className="t--action-name-edit-icon"
isIconButton
kind="tertiary"
size="md"
size={iconSize}
startIcon="pencil-line"
/>
))}

View File

@ -11,6 +11,8 @@ import {
} from "ee/constants/messages";
import styled from "styled-components";
import { Classes } from "@blueprintjs/core";
import type { SaveActionNameParams } from "PluginActionEditor";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
export const NameWrapper = styled.div<{ enableFontStyling?: boolean }>`
min-width: 50%;
@ -71,9 +73,9 @@ interface NameEditorProps {
children: (params: any) => JSX.Element;
id?: string;
name?: string;
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dispatchAction: (a: any) => any;
onSaveName: (
params: SaveActionNameParams,
) => ReduxAction<SaveActionNameParams>;
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
suffixErrorMessage?: (params?: any) => string;
@ -90,10 +92,10 @@ interface NameEditorProps {
function NameEditor(props: NameEditorProps) {
const {
dispatchAction,
id: entityId,
idUndefinedErrorMessage,
name: entityName,
onSaveName,
saveStatus,
suffixErrorMessage = ACTION_NAME_CONFLICT_ERROR,
} = props;
@ -131,8 +133,8 @@ function NameEditor(props: NameEditorProps) {
const handleNameChange = useCallback(
(name: string) => {
if (name !== entityName && !isInvalidNameForEntity(name)) {
dispatch(dispatchAction({ id: entityId, name }));
if (name !== entityName && !isInvalidNameForEntity(name) && entityId) {
dispatch(onSaveName({ id: entityId, name }));
}
},
[dispatch, isInvalidNameForEntity, entityId, entityName],

View File

@ -1,11 +1,7 @@
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import type { PaginationField } from "api/ActionAPI";
import React, { createContext, useMemo } from "react";
interface SaveActionNameParams {
id: string;
name: string;
}
import type { SaveActionNameParams } from "PluginActionEditor";
interface ApiEditorContextContextProps {
moreActionsMenu?: React.ReactNode;
@ -15,7 +11,7 @@ interface ApiEditorContextContextProps {
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settingsConfig: any;
saveActionName?: (
saveActionName: (
params: SaveActionNameParams,
) => ReduxAction<SaveActionNameParams>;
closeEditorLink?: React.ReactNode;

View File

@ -35,6 +35,9 @@ import {
InfoFields,
RequestTabs,
} from "PluginActionEditor/components/PluginActionForm/components/CommonEditorForm";
import { getSavingStatusForActionName } from "selectors/actionSelectors";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { ActionUrlIcon } from "../Explorer/ExplorerIcons";
const Form = styled.form`
position: relative;
@ -245,6 +248,18 @@ function CommonEditorForm(props: CommonFormPropsWithExtraParams) {
currentActionConfig?.userPermissions,
);
const currentPlugin = useSelector((state: AppState) =>
getPlugin(state, currentActionConfig?.pluginId || ""),
);
const saveStatus = useSelector((state) =>
getSavingStatusForActionName(state, currentActionConfig?.id || ""),
);
const iconUrl = getAssetUrl(currentPlugin?.iconLocation) || "";
const icon = ActionUrlIcon(iconUrl);
const plugin = useSelector((state: AppState) =>
getPlugin(state, pluginId ?? ""),
);
@ -281,9 +296,12 @@ function CommonEditorForm(props: CommonFormPropsWithExtraParams) {
<FormRow className="form-row-header">
<NameWrapper className="t--nameOfApi">
<ActionNameEditor
actionConfig={currentActionConfig}
disabled={!isChangePermitted}
enableFontStyling
icon={icon}
saveActionName={saveActionName}
saveStatus={saveStatus}
/>
</NameWrapper>
<ActionButtons className="t--formActionButtons">

View File

@ -8,7 +8,11 @@ import {
getPluginSettingConfigs,
getPlugins,
} from "ee/selectors/entitiesSelector";
import { deleteAction, runAction } from "actions/pluginActionActions";
import {
deleteAction,
runAction,
saveActionName,
} from "actions/pluginActionActions";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import Editor from "./Editor";
import BackToCanvas from "components/common/BackToCanvas";
@ -151,15 +155,7 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) {
});
dispatch(runAction(action?.id ?? "", paginationField));
},
[
action?.id,
apiName,
pageName,
getPageName,
plugins,
pluginId,
datasourceId,
],
[action?.id, apiName, pageName, plugins, pluginId, datasourceId, dispatch],
);
const actionRightPaneBackLink = useMemo(() => {
@ -173,13 +169,13 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) {
pageName,
});
dispatch(deleteAction({ id: action?.id ?? "", name: apiName }));
}, [getPageName, pages, basePageId, apiName]);
}, [pages, basePageId, apiName, action?.id, dispatch, pageName]);
const notification = useMemo(() => {
if (!isConverting) return null;
return <ConvertEntityNotification icon={icon} name={action?.name || ""} />;
}, [action?.name, isConverting]);
}, [action?.name, isConverting, icon]);
const isActionRedesignEnabled = useFeatureFlag(
FEATURE_FLAG.release_actions_redesign_enabled,
@ -196,6 +192,7 @@ function ApiEditorWrapper(props: ApiEditorWrapperProps) {
handleRunClick={handleRunClick}
moreActionsMenu={moreActionsMenu}
notification={notification}
saveActionName={saveActionName}
settingsConfig={settingsConfig}
>
<Disabler isDisabled={isConverting}>

View File

@ -16,6 +16,8 @@ import {
import { Tooltip } from "@appsmith/ads";
import { useSelector } from "react-redux";
import { getSavingStatusForActionName } from "selectors/actionSelectors";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import type { SaveActionNameParams } from "PluginActionEditor";
export const searchHighlightSpanClassName = "token";
export const searchTokenizationDelimiter = "!!";
@ -84,7 +86,7 @@ export interface EntityNameProps {
name: string;
isEditing?: boolean;
onChange?: (name: string) => void;
updateEntityName: (name: string) => void;
updateEntityName: (name: string) => ReduxAction<SaveActionNameParams>;
entityId: string;
searchKeyword?: string;
className?: string;
@ -164,10 +166,10 @@ export const EntityName = React.memo(
return (
<NameEditorComponent
dispatchAction={handleUpdateName}
id={props.entityId}
idUndefinedErrorMessage={ACTION_ID_NOT_FOUND_IN_URL}
name={updatedName}
onSaveName={handleUpdateName}
saveStatus={saveStatus}
suffixErrorMessage={ENTITY_EXPLORER_ACTION_NAME_CONFLICT_ERROR}
>

View File

@ -25,11 +25,8 @@ import NameEditorComponent, {
} from "components/utils/NameEditorComponent";
import { getSavingStatusForJSObjectName } from "selectors/actionSelectors";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import type { SaveActionNameParams } from "PluginActionEditor";
export interface SaveActionNameParams {
id: string;
name: string;
}
export interface JSObjectNameEditorProps {
/*
This prop checks if page is API Pane or Query Pane or Curl Pane
@ -64,10 +61,10 @@ export function JSObjectNameEditor(props: JSObjectNameEditorProps) {
return (
<NameEditorComponent
dispatchAction={props.saveJSObjectName}
id={currentJSObjectConfig?.id}
idUndefinedErrorMessage={JSOBJECT_ID_NOT_FOUND_IN_URL}
name={currentJSObjectConfig?.name}
onSaveName={props.saveJSObjectName}
saveStatus={saveStatus}
>
{({

View File

@ -1,18 +1,14 @@
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import type { SaveActionNameParams } from "PluginActionEditor";
import React, { createContext, useMemo } from "react";
interface SaveActionNameParams {
id: string;
name: string;
}
interface QueryEditorContextContextProps {
moreActionsMenu?: React.ReactNode;
onCreateDatasourceClick?: () => void;
onEntityNotFoundBackClick?: () => void;
changeQueryPage?: (baseQueryId: string) => void;
actionRightPaneBackLink?: React.ReactNode;
saveActionName?: (
saveActionName: (
params: SaveActionNameParams,
) => ReduxAction<SaveActionNameParams>;
closeEditorLink?: React.ReactNode;

View File

@ -13,6 +13,7 @@ import { useActiveActionBaseId } from "ee/pages/Editor/Explorer/hooks";
import { useSelector } from "react-redux";
import {
getActionByBaseId,
getPlugin,
getPluginNameFromId,
} from "ee/selectors/entitiesSelector";
import { QueryEditorContext } from "./QueryEditorContext";
@ -21,6 +22,9 @@ import type { Datasource } from "entities/Datasource";
import type { AppState } from "ee/reducers";
import { SQL_DATASOURCES } from "constants/QueryEditorConstants";
import DatasourceSelector from "./DatasourceSelector";
import { getSavingStatusForActionName } from "selectors/actionSelectors";
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { ActionUrlIcon } from "../Explorer/ExplorerIcons";
const NameWrapper = styled.div`
display: flex;
@ -79,6 +83,18 @@ const QueryEditorHeader = (props: Props) => {
currentActionConfig?.userPermissions,
);
const currentPlugin = useSelector((state: AppState) =>
getPlugin(state, currentActionConfig?.pluginId || ""),
);
const saveStatus = useSelector((state) =>
getSavingStatusForActionName(state, currentActionConfig?.id || ""),
);
const iconUrl = getAssetUrl(currentPlugin?.iconLocation) || "";
const icon = ActionUrlIcon(iconUrl);
// get the current action's plugin name
const currentActionPluginName = useSelector((state: AppState) =>
getPluginNameFromId(state, currentActionConfig?.pluginId || ""),
@ -106,8 +122,11 @@ const QueryEditorHeader = (props: Props) => {
<StyledFormRow>
<NameWrapper>
<ActionNameEditor
actionConfig={currentActionConfig}
disabled={!isChangePermitted}
icon={icon}
saveActionName={saveActionName}
saveStatus={saveStatus}
/>
</NameWrapper>
<ActionsWrapper>

View File

@ -42,6 +42,7 @@ import { ENTITY_ICON_SIZE, EntityIcon } from "../Explorer/ExplorerIcons";
import { getIDEViewMode } from "selectors/ideSelectors";
import { EditorViewMode } from "ee/entities/IDE/constants";
import { AppPluginActionEditor } from "../AppPluginActionEditor";
import { saveActionName } from "actions/pluginActionActions";
type QueryEditorProps = RouteComponentProps<QueryEditorRouteParams>;
@ -126,6 +127,7 @@ function QueryEditor(props: QueryEditorProps) {
}, [
action?.id,
action?.name,
action?.pluginType,
isChangePermitted,
isDeletePermitted,
basePageId,
@ -143,7 +145,7 @@ function QueryEditor(props: QueryEditorProps) {
changeQuery({ baseQueryId: baseQueryId, basePageId, applicationId }),
);
},
[basePageId, applicationId],
[basePageId, applicationId, dispatch],
);
const onCreateDatasourceClick = useCallback(() => {
@ -159,13 +161,7 @@ function QueryEditor(props: QueryEditorProps) {
AnalyticsUtil.logEvent("NAVIGATE_TO_CREATE_NEW_DATASOURCE_PAGE", {
entryPoint,
});
}, [
basePageId,
history,
integrationEditorURL,
DatasourceCreateEntryPoints,
AnalyticsUtil,
]);
}, [basePageId]);
// custom function to return user to integrations page if action is not found
const onEntityNotFoundBackClick = useCallback(
@ -176,7 +172,7 @@ function QueryEditor(props: QueryEditorProps) {
selectedTab: INTEGRATION_TABS.ACTIVE,
}),
),
[basePageId, history, integrationEditorURL],
[basePageId],
);
const notification = useMemo(() => {
@ -189,7 +185,7 @@ function QueryEditor(props: QueryEditorProps) {
withPadding
/>
);
}, [action?.name, isConverting]);
}, [action?.name, isConverting, icon]);
const isActionRedesignEnabled = useFeatureFlag(
FEATURE_FLAG.release_actions_redesign_enabled,
@ -207,6 +203,7 @@ function QueryEditor(props: QueryEditorProps) {
notification={notification}
onCreateDatasourceClick={onCreateDatasourceClick}
onEntityNotFoundBackClick={onEntityNotFoundBackClick}
saveActionName={saveActionName}
>
<Disabler isDisabled={isConverting}>
<Editor

View File

@ -32,7 +32,6 @@ const StyledModalBody = styled(ModalBody)`
overflow-y: initial;
display: flex;
flex-direction: column;
min-height: min-content;
max-height: calc(
100vh - 200px - 32px - 56px - 44px
); // 200px offset, 32px outer padding, 56px footer, 44px header

View File

@ -24,6 +24,7 @@ public interface PluginConstants {
String APPSMITH_AI_PLUGIN = "appsmithai-plugin";
String DATABRICKS_PLUGIN = "databricks-plugin";
String AWS_LAMBDA_PLUGIN = "aws-lambda-plugin";
String MONGO_PLUGIN = "mongo-plugin";
}
public static final String DEFAULT_REST_DATASOURCE = "DEFAULT_REST_DATASOURCE";

View File

@ -0,0 +1,20 @@
package com.appsmith.server.domains;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.Instant;
@Getter
@Setter
@ToString
public class DatasourcePluginContext<T> {
private T connection;
private String pluginId;
private Instant creationTime;
public DatasourcePluginContext() {
creationTime = Instant.now();
}
}

View File

@ -13,6 +13,7 @@ import com.appsmith.server.datasources.base.DatasourceService;
import com.appsmith.server.datasourcestorages.base.DatasourceStorageService;
import com.appsmith.server.domains.DatasourceContext;
import com.appsmith.server.domains.DatasourceContextIdentifier;
import com.appsmith.server.domains.DatasourcePluginContext;
import com.appsmith.server.domains.Plugin;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
@ -20,6 +21,10 @@ import com.appsmith.server.helpers.PluginExecutorHelper;
import com.appsmith.server.plugins.base.PluginService;
import com.appsmith.server.services.ConfigService;
import com.appsmith.server.solutions.DatasourcePermission;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
@ -29,8 +34,12 @@ import reactor.core.scheduler.Schedulers;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
@Slf4j
public class DatasourceContextServiceCEImpl implements DatasourceContextServiceCE {
@ -38,6 +47,21 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
protected final Map<DatasourceContextIdentifier, Mono<DatasourceContext<Object>>> datasourceContextMonoMap;
protected final Map<DatasourceContextIdentifier, Object> datasourceContextSynchronizationMonitorMap;
protected final Map<DatasourceContextIdentifier, DatasourceContext<?>> datasourceContextMap;
/**
* This cache is used to store the datasource context for a limited time and a limited max number of connections and
* then destroy the least recently used connection. The cleanup process is triggered when the cache is accessed and
* either the time limit or the max connections are reached.
* The purpose of this is to prevent the large number of open dangling connections to the movies mockDB.
* The removalListener method is called when the connection is removed from the cache.
*/
protected final Cache<DatasourceContextIdentifier, DatasourcePluginContext> datasourcePluginContextMapLRUCache =
CacheBuilder.newBuilder()
.removalListener(createRemovalListener())
.expireAfterAccess(2, TimeUnit.HOURS)
.maximumSize(300) // caches most recently used 300 mock connections per pod
.build();
private final DatasourceService datasourceService;
private final DatasourceStorageService datasourceStorageService;
private final PluginService pluginService;
@ -67,6 +91,50 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
this.datasourcePermission = datasourcePermission;
}
private RemovalListener<DatasourceContextIdentifier, DatasourcePluginContext> createRemovalListener() {
return (RemovalNotification<DatasourceContextIdentifier, DatasourcePluginContext> removalNotification) -> {
handleRemoval(removalNotification);
};
}
private Object getConnectionFromDatasourceContextMap(DatasourceContextIdentifier datasourceContextIdentifier) {
return this.datasourceContextMap.containsKey(datasourceContextIdentifier)
&& this.datasourceContextMap.get(datasourceContextIdentifier) != null
? this.datasourceContextMap.get(datasourceContextIdentifier).getConnection()
: null;
}
private void handleRemoval(
RemovalNotification<DatasourceContextIdentifier, DatasourcePluginContext> removalNotification) {
final DatasourceContextIdentifier datasourceContextIdentifier = removalNotification.getKey();
final DatasourcePluginContext datasourcePluginContext = removalNotification.getValue();
log.debug(
"Removing Datasource Context from cache and closing the open connection for DatasourceId: {} and environmentId: {}",
datasourceContextIdentifier.getDatasourceId(),
datasourceContextIdentifier.getEnvironmentId());
log.info("LRU Cache Size after eviction: {}", datasourcePluginContextMapLRUCache.size());
// Close connection and remove entry from both cache maps
final Object connection = getConnectionFromDatasourceContextMap(datasourceContextIdentifier);
Mono<Plugin> pluginMono =
pluginService.findById(datasourcePluginContext.getPluginId()).cache();
if (connection != null) {
pluginExecutorHelper
.getPluginExecutor(pluginMono)
.flatMap(pluginExecutor -> Mono.fromRunnable(() -> pluginExecutor.datasourceDestroy(connection)))
.onErrorResume(e -> {
log.error("Error destroying stale datasource connection", e);
return Mono.empty();
})
.subscribe(); // Trigger the execution
}
// Remove the entries from both maps
datasourceContextMonoMap.remove(datasourceContextIdentifier);
datasourceContextMap.remove(datasourceContextIdentifier);
}
/**
* This method defines a critical section that can be executed only by one thread at a time per datasource id - i
* .e. if two threads want to create datasource for different datasource ids then they would not be synchronized.
@ -115,6 +183,11 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
}
datasourceContextMonoMap.remove(datasourceContextIdentifier);
datasourceContextMap.remove(datasourceContextIdentifier);
log.info(
"Invalidating the LRU cache entry for datasource id {}, environment id {} as the connection is stale or in error state",
datasourceContextIdentifier.getDatasourceId(),
datasourceContextIdentifier.getEnvironmentId());
datasourcePluginContextMapLRUCache.invalidate(datasourceContextIdentifier);
}
/*
@ -129,17 +202,13 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
+ ": Cached resource context mono exists for datasource id {}, environment id {}. Returning the same.",
datasourceContextIdentifier.getDatasourceId(),
datasourceContextIdentifier.getEnvironmentId());
// Accessing the LRU cache to update the last accessed time
datasourcePluginContextMapLRUCache.getIfPresent(datasourceContextIdentifier);
return datasourceContextMonoMap.get(datasourceContextIdentifier);
}
/* Create a fresh datasource context */
DatasourceContext<Object> datasourceContext = new DatasourceContext<>();
if (datasourceContextIdentifier.isKeyValid() && shouldCacheContextForThisPlugin(plugin)) {
/* For this datasource, either the context doesn't exist, or the context is stale. Replace (or add) with
the new connection in the context map. */
datasourceContextMap.put(datasourceContextIdentifier, datasourceContext);
}
Mono<Object> connectionMonoCache = pluginExecutor
.datasourceCreate(datasourceStorage.getDatasourceConfiguration())
.cache();
@ -159,15 +228,34 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
datasourceContext)
.cache(); /* Cache the value so that further evaluations don't result in new connections */
if (datasourceContextIdentifier.isKeyValid() && shouldCacheContextForThisPlugin(plugin)) {
datasourceContextMonoMap.put(datasourceContextIdentifier, datasourceContextMonoCache);
}
log.debug(
Thread.currentThread().getName()
+ ": Cached new datasource context for datasource id {}, environment id {}",
datasourceContextIdentifier.getDatasourceId(),
datasourceContextIdentifier.getEnvironmentId());
return datasourceContextMonoCache;
return connectionMonoCache
.flatMap(connection -> {
datasourceContext.setConnection(connection);
if (datasourceContextIdentifier.isKeyValid()
&& shouldCacheContextForThisPlugin(plugin)) {
datasourceContextMap.put(datasourceContextIdentifier, datasourceContext);
datasourceContextMonoMap.put(
datasourceContextIdentifier, datasourceContextMonoCache);
if (TRUE.equals(datasourceStorage.getIsMock())
&& PluginConstants.PackageName.MONGO_PLUGIN.equals(
plugin.getPackageName())) {
log.info(
"Datasource is a mock mongo DB. Adding the connection to LRU cache!");
DatasourcePluginContext<Object> datasourcePluginContext =
new DatasourcePluginContext<>();
datasourcePluginContext.setConnection(datasourceContext.getConnection());
datasourcePluginContext.setPluginId(plugin.getId());
datasourcePluginContextMapLRUCache.put(
datasourceContextIdentifier, datasourcePluginContext);
log.info(
"LRU Cache Size after adding: {}",
datasourcePluginContextMapLRUCache.size());
}
}
return datasourceContextMonoCache;
})
.switchIfEmpty(datasourceContextMonoCache);
}
})
.flatMap(obj -> obj)
@ -195,7 +283,7 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
.setAuthentication(updatableConnection.getAuthenticationDTO(
datasourceStorage.getDatasourceConfiguration().getAuthentication()));
datasourceStorageMono = datasourceStorageService.updateDatasourceStorage(
datasourceStorage, datasourceStorage.getEnvironmentId(), Boolean.FALSE, false);
datasourceStorage, datasourceStorage.getEnvironmentId(), FALSE, false);
}
return datasourceStorageMono.thenReturn(connection);
}
@ -308,6 +396,8 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
} else {
if (isValidDatasourceContextAvailable(datasourceStorage, datasourceContextIdentifier)) {
log.debug("Resource context exists. Returning the same.");
// Accessing the LRU cache to update the last accessed time
datasourcePluginContextMapLRUCache.getIfPresent(datasourceContextIdentifier);
return Mono.just(datasourceContextMap.get(datasourceContextIdentifier));
}
}
@ -399,7 +489,11 @@ public class DatasourceContextServiceCEImpl implements DatasourceContextServiceC
log.info("Clearing datasource context for datasource storage ID {}.", datasourceStorage.getId());
pluginExecutor.datasourceDestroy(datasourceContext.getConnection());
datasourceContextMonoMap.remove(datasourceContextIdentifier);
log.info(
"Invalidating the LRU cache entry for datasource id {}, environment id {} as delete datasource context is invoked",
datasourceContextIdentifier.getDatasourceId(),
datasourceContextIdentifier.getEnvironmentId());
datasourcePluginContextMapLRUCache.invalidate(datasourceContextIdentifier);
if (!datasourceContextMap.containsKey(datasourceContextIdentifier)) {
log.info(
"datasourceContextMap does not contain any entry for datasource storage with id: {} ",

View File

@ -146,7 +146,7 @@ parts.push(`
${isRateLimitingEnabled ? `rate_limit {
zone dynamic_zone {
key {http.request.remote_ip}
key {http.request.client_ip}
events ${RATE_LIMIT}
window 1s
}

View File

@ -0,0 +1,92 @@
#!/bin/bash
waitForPostgresAvailability() {
if [ -z "$PG_DB_HOST" ]; then
tlog "PostgreSQL host name is empty. Check env variables. Error. Exiting java setup"
exit 2
else
MAX_RETRIES=50
RETRYSECONDS=10
retry_count=0
while true; do
su postgres -c "pg_isready -h '${PG_DB_HOST}' -p '${PG_DB_PORT}'"
status=$?
case $status in
0)
tlog "PostgreSQL host '$PG_DB_HOST' is ready."
break
;;
1)
tlog "PostgreSQL host '$PG_DB_HOST' is rejecting connections e.g. due to being in recovery mode or not accepting connections eg. connections maxed out."
;;
2)
tlog "PostgreSQL host '$PG_DB_HOST' is not responding or running."
;;
3)
tlog "The connection check failed e.g. due to network issues or incorrect parameters."
;;
*)
tlog "pg_isready exited with unexpected status code: $status"
break
;;
esac
retry_count=$((retry_count + 1))
if [ $retry_count -le $MAX_RETRIES ]; then
tlog "PostgreSQL connection failed. Retrying attempt $retry_count/$MAX_RETRIES in $RETRYSECONDS seconds..."
sleep $RETRYSECONDS
else
tlog "Exceeded maximum retry attempts ($MAX_RETRIES). Exiting."
# use exit code 2 to indicate that the script failed to connect to postgres and supervisor conf is set not to restart the program for 2.
exit 2
fi
done
fi
}
# for PostgreSQL, we use APPSMITH_DB_URL=postgresql://username:password@postgresserver:5432/dbname
# Args:
# conn_string (string): PostgreSQL connection string
# Returns:
# None
# Example:
# postgres syntax
# "postgresql://user:password@localhost:5432/appsmith"
# "postgresql://user:password@localhost/appsmith"
# "postgresql://user@localhost:5432/appsmith"
# "postgresql://user@localhost/appsmith"
extract_postgres_db_params() {
local conn_string=$1
# Use node to parse the URI and extract components
IFS=' ' read -r USER PASSWORD HOST PORT DB <<<"$(node -e "
const connectionString = process.argv[1];
const pgUri = connectionString.startsWith(\"postgresql://\")
? connectionString
: 'http://' + connectionString; //Prepend a fake scheme for URL parsing
const url = require('url');
const parsedUrl = new url.URL(pgUri);
// Extract the pathname and remove the leading '/'
const db = parsedUrl.pathname.substring(1);
// Default the port to 5432 if it's empty
const port = parsedUrl.port || '5432';
console.log(\`\${parsedUrl.username || '-'} \${parsedUrl.password || '-'} \${parsedUrl.hostname} \${port} \${db}\`);
" "$conn_string")"
# Now, set the environment variables
export PG_DB_USER="$USER"
export PG_DB_PASSWORD="$PASSWORD"
export PG_DB_HOST="$HOST"
export PG_DB_PORT="$PORT"
export PG_DB_NAME="$DB"
}
# Example usage of the functions
# waitForPostgresAvailability
# extract_postgres_db_params "postgresql://user:password@localhost:5432/dbname"

View File

@ -1,5 +1,8 @@
#!/bin/bash
# Source the helper script
source pg-utils.sh
set -o errexit
set -o pipefail
set -o nounset
@ -29,6 +32,12 @@ match-proxy-url() {
[[ -n $proxy_host ]]
}
# Extract the database parameters from the APPSMITH_DB_URL and wait for the database to be available
if [[ "$mode" == "pg" ]]; then
extract_postgres_db_params "$APPSMITH_DB_URL"
waitForPostgresAvailability
fi
if match-proxy-url "${HTTP_PROXY-}"; then
extra_args+=(-Dhttp.proxyHost="$proxy_host" -Dhttp.proxyPort="$proxy_port")
if [[ -n $proxy_user ]]; then

View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -e
# Include the script to be tested
source /Users/appsmith/Work/appsmith-ce/deploy/docker/fs/opt/appsmith/pg-utils.sh
assert_equals() {
if [ "$1" != "$2" ]; then
echo "Assertion failed: expected '$2', but got '$1'"
return 1
fi
}
# Test extract_postgres_db_params function
test_extract_postgres_db_params_valid_db_string() {
local conn_string="postgresql://user:password@localhost:5432/dbname"
extract_postgres_db_params "$conn_string"
if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "password" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "dbname" ]; then
echo "Test failed: test_extract_postgres_db_params_valid_db_string did not extract parameters correctly"
echo_params
exit 1
fi
echo "Test passed: test_extract_postgres_db_params_valid_db_string"
}
test_extract_postgres_db_params_empty_dbname() {
local conn_string="postgresql://user:password@localhost:5432"
extract_postgres_db_params "$conn_string"
if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "password" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "" ]; then
echo "Test failed: test_extract_postgres_db_params_empty_dbname did not extract parameters correctly"
echo_params
exit 1
fi
echo "Test passed: test_extract_postgres_db_params_empty_dbname"
}
test_extract_postgres_db_params_with_spaces() {
local conn_string="postgresql://user:p a s s w o r d@localhost:5432/db_name"
extract_postgres_db_params "$conn_string"
if [ "$PG_DB_USER" != "user" ] || [ "$PG_DB_PASSWORD" != "p%20a%20s%20s%20w%20o%20r%20d" ] || [ "$PG_DB_HOST" != "localhost" ] || [ "$PG_DB_PORT" != "5432" ] || [ "$PG_DB_NAME" != "db_name" ]; then
echo "Test failed: test_extract_postgres_db_params_with_spaces did not extract parameters correctly"
echo_params
exit 1
fi
echo "Test passed: test_extract_postgres_db_params_with_spaces"
}
echo_params() {
echo "PG_DB_USER: $PG_DB_USER"
echo "PG_DB_PASSWORD: $PG_DB_PASSWORD"
echo "PG_DB_HOST: $PG_DB_HOST"
echo "PG_DB_PORT: $PG_DB_PORT"
echo "PG_DB_NAME: $PG_DB_NAME"
}
# Run tests
test_extract_postgres_db_params_valid_db_string
test_extract_postgres_db_params_empty_dbname
test_extract_postgres_db_params_with_spaces
echo "All Tests Pass!"